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
|
# iyzico ödeme entegrasyonu
|
||||||
|
|
||||||
Ödeme akışı iyzico **Checkout Form (CF)** üzerinden yürür: kart bilgisi hiçbir
|
Ö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
|
## 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ı
|
1. `baslatOdeme` (`src/features/odeme/odeme-actions.ts`) — `orders` satırını
|
||||||
`pending` olarak yazar, `checkoutFormInitialize` çağırır, dönen `token`'ı
|
`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.
|
`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
|
2. Kullanıcı ödemeyi bitirince iyzico `/api/odeme/callback` adresine
|
||||||
**cross-site POST** atar; gövdede yalnızca `token` vardır. SameSite=Lax
|
**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.
|
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 Link from "next/link";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { Lock } from "lucide-react";
|
import { Lock } from "lucide-react";
|
||||||
import { verifySession } from "@/lib/session";
|
import { getCurrentUser, verifySession } from "@/lib/session";
|
||||||
import { SectionEyebrow } from "@/components/pixel-decor";
|
import { SectionEyebrow } from "@/components/pixel-decor";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { siparisGetir } from "../odeme-queries";
|
import { siparisGetir } from "../odeme-queries";
|
||||||
@@ -28,6 +28,12 @@ export async function OdemeSayfasi({ siparisId }: { siparisId?: string }) {
|
|||||||
if (order.status === "failed" || !order.iyzicoFormContent) {
|
if (order.status === "failed" || !order.iyzicoFormContent) {
|
||||||
redirect("/paket");
|
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 (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useEffect } from "react";
|
import { useLayoutEffect, useState, useTransition } from "react";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ArrowRight, Loader2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { olay } from "@/lib/analitik";
|
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({
|
export function SatinAlForm({
|
||||||
urun,
|
urun,
|
||||||
@@ -16,24 +20,50 @@ export function SatinAlForm({
|
|||||||
label: string;
|
label: string;
|
||||||
vurgulu?: boolean;
|
vurgulu?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [state, formAction, pending] = useActionState<
|
const router = useRouter();
|
||||||
OdemeBaslatDurum,
|
const [pending, startTransition] = useTransition();
|
||||||
FormData
|
// Action bitti, /odeme'ye geçiş sürüyor: buton o arada yeniden açılıp ikinci
|
||||||
>(baslatOdeme, undefined);
|
// bir sipariş başlatmasın.
|
||||||
|
const [gidiyor, setGidiyor] = useState(false);
|
||||||
|
const mesgul = pending || gidiyor;
|
||||||
|
|
||||||
useEffect(() => {
|
// cacheComponents açıkken /paket, /odeme'ye geçince unmount olmaz; gizli
|
||||||
if (state?.error) toast.error(state.error);
|
// <Activity> içinde state'iyle saklanır. Geri dönen kullanıcı kilitli değil
|
||||||
}, [state]);
|
// 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 (
|
return (
|
||||||
<form
|
<form action={gonder}>
|
||||||
action={formAction}
|
|
||||||
onSubmit={() => olay("odeme_baslatildi", { urun })}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="urun" value={urun} />
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={pending}
|
disabled={mesgul}
|
||||||
|
aria-busy={mesgul}
|
||||||
className={
|
className={
|
||||||
vurgulu
|
vurgulu
|
||||||
? "h-12 w-full cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
? "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"}
|
variant={vurgulu ? "default" : "outline"}
|
||||||
>
|
>
|
||||||
{/* Tıklamada buton içeriği değişmez (metin/spinner takası yok); dar
|
{/* Tıklamada metin değişmez (dar top-up kartında taşıyordu); geri
|
||||||
top-up kartında taşmaya yol açıyordu. Çift gönderim `disabled`
|
bildirim aynı boyuttaki ikon takasıyla verilir: ok → dönen halka.
|
||||||
ile engellenir. */}
|
Çift gönderim `disabled` ile engellenir. */}
|
||||||
{label}
|
{label}
|
||||||
|
{mesgul ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
<ArrowRight className="size-4" aria-hidden />
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,14 +2,18 @@
|
|||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { getCurrentUser, getSession } from "@/lib/session";
|
||||||
import { verifySession } from "@/lib/session";
|
|
||||||
import { appUrl as siteKok } from "@/lib/app-url";
|
import { appUrl as siteKok } from "@/lib/app-url";
|
||||||
import { appDb, schema } from "@/lib/appdb";
|
import { appDb, schema } from "@/lib/appdb";
|
||||||
import { URUNLER } from "@/lib/credits";
|
import { URUNLER } from "@/lib/credits";
|
||||||
import { initializeCheckoutForm, initImzaDurumu } from "@/lib/iyzico";
|
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 =
|
const GENEL_HATA =
|
||||||
"Ödeme şu anda başlatılamıyor; kartından çekim yapılmadı. Birazdan tekrar dener misin?";
|
"Ö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(
|
export async function baslatOdeme(
|
||||||
_prev: OdemeBaslatDurum,
|
product: "paket" | "topup",
|
||||||
formData: FormData,
|
|
||||||
): Promise<OdemeBaslatDurum> {
|
): Promise<OdemeBaslatDurum> {
|
||||||
// Ucuz senkron doğrulama pahalı oturum turundan önce
|
// Ucuz senkron doğrulama pahalı oturum turundan önce (argüman client'tan
|
||||||
const product = formData.get("urun");
|
// gelir; tipine güvenilmez)
|
||||||
if (product !== "paket" && product !== "topup") {
|
if (product !== "paket" && product !== "topup") {
|
||||||
return { error: "Geçersiz ürün." };
|
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 urun = URUNLER[product];
|
||||||
|
|
||||||
const orderId = crypto.randomUUID();
|
const orderId = crypto.randomUUID();
|
||||||
@@ -100,5 +115,5 @@ export async function baslatOdeme(
|
|||||||
.set({ iyzicoToken: init.token, iyzicoFormContent: init.checkoutFormContent })
|
.set({ iyzicoToken: init.token, iyzicoFormContent: init.checkoutFormContent })
|
||||||
.where(eq(schema.orders.id, orderId));
|
.where(eq(schema.orders.id, orderId));
|
||||||
|
|
||||||
redirect(`/odeme?siparis=${orderId}`);
|
return { yonlendir: `/odeme?siparis=${orderId}` };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user