fix(odeme): action redirect fırlatmaz, hedef döndürür + çift paket çekimine sunucu koruması
All checks were successful
Deploy / deploy (push) Successful in 8m46s
All checks were successful
Deploy / deploy (push) Successful in 8m46s
- baslatOdeme { error } | { yonlendir } döndürür; SatinAlForm client'ta
router.push yapar (cacheComponents altında geri dönüşte buton tıklamaya
sağır kalıyordu); tıklamada ok → dönen halka
- paketi olan yeniden 'paket', paketi olmayan 'topup' başlatamaz
- /odeme, paket alınmışken eski bekleyen paket siparişinin formunu göstermez
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
# iyzico ödeme entegrasyonu
|
||||
|
||||
Ödeme akışı iyzico **Checkout Form (CF)** üzerinden yürür: kart bilgisi hiçbir
|
||||
zaman bize ulaşmaz, kullanıcı iyzico'nun barındırdığı ödeme sayfasına gider.
|
||||
zaman bize ulaşmaz; kart formu iyzico iframe'i olarak kendi `/odeme` sayfamıza
|
||||
gömülür.
|
||||
|
||||
## Ortam değişkenleri
|
||||
|
||||
@@ -29,8 +30,13 @@ Ayrıntı: `src/lib/app-url.ts`.
|
||||
|
||||
1. `baslatOdeme` (`src/features/odeme/odeme-actions.ts`) — `orders` satırını
|
||||
`pending` olarak yazar, `checkoutFormInitialize` çağırır, dönen `token`'ı
|
||||
siparişe iliştirir ve kullanıcıyı `paymentPageUrl`'e yönlendirir.
|
||||
siparişe iliştirir ve `/odeme?siparis=<id>` adresini **döndürür** (`redirect()`
|
||||
fırlatmaz); yönlendirmeyi `SatinAlForm` client'ta `router.push` ile yapar.
|
||||
`conversationId` = `basketId` = sipariş id'miz.
|
||||
Oturum yoksa `/giris?callback=/paket` döner. Sunucu koruması: paketi olan
|
||||
yeniden `paket`, paketi olmayan `topup` başlatamaz; `/odeme` sayfası da
|
||||
paket alınmışken eski bekleyen paket siparişinin formunu göstermez
|
||||
(bayat sekme / ikinci cihazdan çift çekim önlemi).
|
||||
2. Kullanıcı ödemeyi bitirince iyzico `/api/odeme/callback` adresine
|
||||
**cross-site POST** atar; gövdede yalnızca `token` vardır. SameSite=Lax
|
||||
nedeniyle session çerezi gelmez, bu yüzden kullanıcı token'dan çözülür.
|
||||
|
||||
@@ -2,7 +2,7 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { Lock } from "lucide-react";
|
||||
import { verifySession } from "@/lib/session";
|
||||
import { getCurrentUser, verifySession } from "@/lib/session";
|
||||
import { SectionEyebrow } from "@/components/pixel-decor";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { siparisGetir } from "../odeme-queries";
|
||||
@@ -28,6 +28,12 @@ export async function OdemeSayfasi({ siparisId }: { siparisId?: string }) {
|
||||
if (order.status === "failed" || !order.iyzicoFormContent) {
|
||||
redirect("/paket");
|
||||
}
|
||||
// Paket bu arada alındıysa (başka sekme/cihaz, geri tuşu) eski bekleyen
|
||||
// paket siparişinin formu yeniden çizilmesin — ikinci kez ücret çekilmesin.
|
||||
if (order.product === "paket") {
|
||||
const kullanici = await getCurrentUser();
|
||||
if (kullanici?.hasPaket) redirect("/paket");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { useLayoutEffect, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import { baslatOdeme, type OdemeBaslatDurum } from "../odeme-actions";
|
||||
import { baslatOdeme } from "../odeme-actions";
|
||||
|
||||
const BAGLANTI_HATASI =
|
||||
"Ödeme başlatılamadı; kartından çekim yapılmadı. Bağlantını kontrol edip tekrar dener misin?";
|
||||
|
||||
export function SatinAlForm({
|
||||
urun,
|
||||
@@ -16,24 +20,50 @@ export function SatinAlForm({
|
||||
label: string;
|
||||
vurgulu?: boolean;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState<
|
||||
OdemeBaslatDurum,
|
||||
FormData
|
||||
>(baslatOdeme, undefined);
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
// Action bitti, /odeme'ye geçiş sürüyor: buton o arada yeniden açılıp ikinci
|
||||
// bir sipariş başlatmasın.
|
||||
const [gidiyor, setGidiyor] = useState(false);
|
||||
const mesgul = pending || gidiyor;
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.error) toast.error(state.error);
|
||||
}, [state]);
|
||||
// cacheComponents açıkken /paket, /odeme'ye geçince unmount olmaz; gizli
|
||||
// <Activity> içinde state'iyle saklanır. Geri dönen kullanıcı kilitli değil
|
||||
// taze bir buton bulsun diye sayfa gizlenirken sıfırlanır (layout effect
|
||||
// cleanup'ı Activity gizlenince çalışır).
|
||||
useLayoutEffect(() => {
|
||||
return () => setGidiyor(false);
|
||||
}, []);
|
||||
|
||||
// Yönlendirme client'ta: action `redirect()` fırlatmaz, hedefi döndürür
|
||||
// (nedeni odeme-actions.ts'te). Hata toast'ı da burada, effect'te değil —
|
||||
// effect'ler Activity geri açılınca yeniden koştuğu için eski hatayı
|
||||
// tekrar gösteriyordu.
|
||||
function gonder() {
|
||||
olay("odeme_baslatildi", { urun });
|
||||
startTransition(async () => {
|
||||
let sonuc;
|
||||
try {
|
||||
sonuc = await baslatOdeme(urun);
|
||||
} catch {
|
||||
toast.error(BAGLANTI_HATASI);
|
||||
return;
|
||||
}
|
||||
if ("error" in sonuc) {
|
||||
toast.error(sonuc.error);
|
||||
return;
|
||||
}
|
||||
setGidiyor(true);
|
||||
router.push(sonuc.yonlendir);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
action={formAction}
|
||||
onSubmit={() => olay("odeme_baslatildi", { urun })}
|
||||
>
|
||||
<input type="hidden" name="urun" value={urun} />
|
||||
<form action={gonder}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
disabled={mesgul}
|
||||
aria-busy={mesgul}
|
||||
className={
|
||||
vurgulu
|
||||
? "h-12 w-full cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
@@ -41,11 +71,15 @@ export function SatinAlForm({
|
||||
}
|
||||
variant={vurgulu ? "default" : "outline"}
|
||||
>
|
||||
{/* Tıklamada buton içeriği değişmez (metin/spinner takası yok); dar
|
||||
top-up kartında taşmaya yol açıyordu. Çift gönderim `disabled`
|
||||
ile engellenir. */}
|
||||
{/* Tıklamada metin değişmez (dar top-up kartında taşıyordu); geri
|
||||
bildirim aynı boyuttaki ikon takasıyla verilir: ok → dönen halka.
|
||||
Çift gönderim `disabled` ile engellenir. */}
|
||||
{label}
|
||||
{mesgul ? (
|
||||
<Loader2 className="size-4 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { verifySession } from "@/lib/session";
|
||||
import { getCurrentUser, getSession } from "@/lib/session";
|
||||
import { appUrl as siteKok } from "@/lib/app-url";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import { URUNLER } from "@/lib/credits";
|
||||
import { initializeCheckoutForm, initImzaDurumu } from "@/lib/iyzico";
|
||||
|
||||
export type OdemeBaslatDurum = { error?: string } | undefined;
|
||||
// Action bilerek `redirect()` FIRLATMAZ, hedefi döndürür; yönlendirmeyi client
|
||||
// yapar (bkz. satin-al-form.tsx). Fırlatılan redirect, client'ta action
|
||||
// promise'ini reddedip formu RedirectBoundary üzerinden yeniden mount ediyor;
|
||||
// cacheComponents altında /paket gizli <Activity> içinde canlı tutulduğu için
|
||||
// bu yarım kalmış durum geri dönüşte butonu tıklamalara sağır bırakıyordu.
|
||||
export type OdemeBaslatDurum = { error: string } | { yonlendir: string };
|
||||
|
||||
const GENEL_HATA =
|
||||
"Ödeme şu anda başlatılamıyor; kartından çekim yapılmadı. Birazdan tekrar dener misin?";
|
||||
@@ -23,15 +27,26 @@ function adSoyadAyir(tamAd: string): [string, string] {
|
||||
}
|
||||
|
||||
export async function baslatOdeme(
|
||||
_prev: OdemeBaslatDurum,
|
||||
formData: FormData,
|
||||
product: "paket" | "topup",
|
||||
): Promise<OdemeBaslatDurum> {
|
||||
// Ucuz senkron doğrulama pahalı oturum turundan önce
|
||||
const product = formData.get("urun");
|
||||
// Ucuz senkron doğrulama pahalı oturum turundan önce (argüman client'tan
|
||||
// gelir; tipine güvenilmez)
|
||||
if (product !== "paket" && product !== "topup") {
|
||||
return { error: "Geçersiz ürün." };
|
||||
}
|
||||
const session = await verifySession("/paket");
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return { yonlendir: `/giris?callback=${encodeURIComponent("/paket")}` };
|
||||
}
|
||||
// Arayüz bu kuralları zaten uygular ama bayat sekmeden (ör. paket başka
|
||||
// sekmede alındı) gelen istek ikinci kez ücret çekmesin.
|
||||
const kullanici = await getCurrentUser();
|
||||
if (product === "paket" && kullanici?.hasPaket) {
|
||||
return { error: "Paketin zaten aktif; yeniden ödeme alınmadı." };
|
||||
}
|
||||
if (product === "topup" && !kullanici?.hasPaket) {
|
||||
return { error: "Ek kredi için önce paketi alman gerekiyor." };
|
||||
}
|
||||
const urun = URUNLER[product];
|
||||
|
||||
const orderId = crypto.randomUUID();
|
||||
@@ -100,5 +115,5 @@ export async function baslatOdeme(
|
||||
.set({ iyzicoToken: init.token, iyzicoFormContent: init.checkoutFormContent })
|
||||
.where(eq(schema.orders.id, orderId));
|
||||
|
||||
redirect(`/odeme?siparis=${orderId}`);
|
||||
return { yonlendir: `/odeme?siparis=${orderId}` };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user