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

- 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:
bilalgursen
2026-09-21 16:13:30 +03:00
parent d7c29ac4a6
commit 8f4669e31d
4 changed files with 93 additions and 32 deletions

View File

@@ -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}
<ArrowRight className="size-4" aria-hidden />
{mesgul ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<ArrowRight className="size-4" aria-hidden />
)}
</Button>
</form>
);