diff --git a/docs/odeme/iyzico.md b/docs/odeme/iyzico.md index 6b4d903..70e3ac3 100644 --- a/docs/odeme/iyzico.md +++ b/docs/odeme/iyzico.md @@ -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=` 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. diff --git a/src/features/odeme/components/odeme-sayfasi.tsx b/src/features/odeme/components/odeme-sayfasi.tsx index 7b23d3b..c473990 100644 --- a/src/features/odeme/components/odeme-sayfasi.tsx +++ b/src/features/odeme/components/odeme-sayfasi.tsx @@ -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 (
diff --git a/src/features/odeme/components/satin-al-form.tsx b/src/features/odeme/components/satin-al-form.tsx index c0b4822..94dc212 100644 --- a/src/features/odeme/components/satin-al-form.tsx +++ b/src/features/odeme/components/satin-al-form.tsx @@ -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 + // 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 ( -
olay("odeme_baslatildi", { urun })} - > - +
); diff --git a/src/features/odeme/odeme-actions.ts b/src/features/odeme/odeme-actions.ts index ccc09e1..362a171 100644 --- a/src/features/odeme/odeme-actions.ts +++ b/src/features/odeme/odeme-actions.ts @@ -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 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 { - // 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}` }; }