diff --git a/src/lib/api.ts b/src/lib/api.ts index 78b4264..f97e151 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1076,6 +1076,7 @@ class ApiClient { discount_code?: string | null; mobile?: string | null; email?: string | null; + idempotency_key: string; }) { return this.request( '/api/payments/create', @@ -1088,24 +1089,12 @@ class ApiClient { } async getPaymentByRef(refId: string) { - return this.request<{ - ref_id: string; - authority: string; - base_amount: number; - discount_amount: number; - amount: number; - status: 'INIT' | 'PENDING' | 'PAID' | 'FAILED' | 'CANCELED'; - verified_at?: string | null; - event: { - id: number; - title: string; - slug: string; - image_url?: string | null; - success_markdown?: string | null; - }; - }>(`/api/payments/by-ref/${encodeURIComponent(refId)}`, { method: 'GET' }); + return this.request(`/api/payments/by-ref/${encodeURIComponent(refId)}`, { method: 'GET' }); } + async getPaymentStatus(paymentId: number) { + return this.request(`/api/payments/status/${paymentId}`, { method: 'GET' }); + } async checkDiscountCode(event_id: number, code: string) { return this.request<{ discount_amount: number; diff --git a/src/lib/types.ts b/src/lib/types.ts index 0f65e9b..f1b569e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1063,9 +1063,31 @@ export interface BlogAnalyticsSchema { // payment export interface CreatePaymentOut { - start_pay_url: string; - authority: string; + start_pay_url: string | null; + authority: string | null; + payment_id: number | null; + idempotency_key: string | null; + status: "initiated" | "pending" | "paid" | "failed" | "canceled"; base_amount: number; discount_amount: number; amount: number; } + +export interface PaymentDetailSchema { + payment_id: number; + event_id: number; + ref_id: string | null; + authority: string | null; + base_amount: number; + discount_amount: number; + amount: number; + status: "initiated" | "pending" | "paid" | "failed" | "canceled"; + verified_at: string | null; + event: { + id: number; + title: string; + slug: string; + image_url?: string | null; + success_markdown?: string | null; + }; +} diff --git a/src/views/EventDetail.tsx b/src/views/EventDetail.tsx index 6b77e1c..ae32b7e 100644 --- a/src/views/EventDetail.tsx +++ b/src/views/EventDetail.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "@/lib/router"; import { api } from "@/lib/api"; import type * as Types from "@/lib/types"; @@ -76,6 +76,8 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) { const [submitting, setSubmitting] = useState(false); const [alreadyRegistered, setAlreadyRegistered] = useState(false); const [nowTs, setNowTs] = useState(() => Date.now()); + const checkoutInProgressRef = useRef(false); + const checkoutIdempotencyKeyRef = useRef(null); const basePrice = Number(event?.price ?? 0); const isFree = useMemo(() => basePrice <= 0, [basePrice]); @@ -163,6 +165,9 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) { return; } + if (checkoutInProgressRef.current) return; + checkoutInProgressRef.current = true; + try { setSubmitting(true); const reg = await api.registerForEvent(event.id, coupon); @@ -185,9 +190,10 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) { event_id: event.id, description: `پرداخت رویداد: ${event.title}`, discount_code: (coupon ?? "").trim() || null, + idempotency_key: checkoutIdempotencyKeyRef.current ??= crypto.randomUUID(), }); - if (!result?.start_pay_url || Number(result.amount) === 0) { + if (Number(result?.amount ?? 0) === 0) { window.sessionStorage.setItem( "payment:last", buildPaymentSnapshot(event, eventThumb, { @@ -200,6 +206,20 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) { return; } + if (result.status === "failed" || result.status === "canceled") { + checkoutIdempotencyKeyRef.current = null; + setOpen(false); + navigate(`/payments/result?status=${result.status}&event_id=${event.id}&payment_id=${result.payment_id ?? ""}`); + return; + } + + if (!result.start_pay_url) { + setOpen(false); + const resultStatus = result.status === "paid" ? "success" : "pending"; + navigate(`/payments/result?status=${resultStatus}&event_id=${event.id}&payment_id=${result.payment_id ?? ""}`); + return; + } + window.sessionStorage.setItem( "payment:last", buildPaymentSnapshot(event, eventThumb, { @@ -223,6 +243,7 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) { }); } finally { setSubmitting(false); + checkoutInProgressRef.current = false; setOpen(false); } }; diff --git a/src/views/PaymentResult.tsx b/src/views/PaymentResult.tsx index 9b41145..6b4480a 100644 --- a/src/views/PaymentResult.tsx +++ b/src/views/PaymentResult.tsx @@ -12,8 +12,10 @@ import { api } from '@/lib/api'; import { formatNumberPersian, formatToman, toPersianDigits } from '@/lib/utils'; import Markdown from '@/components/Markdown'; import { siteUrl } from '@/lib/site'; +import { getEventPath, getPaymentOutcome, type PaymentOutcome } from '@/lib/event-ui'; type SavedPayment = { + ref_id?: string | null; event_id: number; slug?: string; title?: string; @@ -23,45 +25,126 @@ type SavedPayment = { amount?: number; started_at?: string; success_markdown?: string | null; + status?: string; }; export default function PaymentResult() { const [params] = useSearchParams(); - const status = params.get('status'); // success | failed - const refId = params.get('ref_id') || ''; - const eventId = Number(params.get('event_id') || '0'); + const status = params.get('status'); + const requestedRefId = params.get('ref_id') || ''; + const paymentId = Number(params.get('payment_id') || '0'); + const [fallback, setFallback] = useState(null); + const [manualReview, setManualReview] = useState(false); + const refId = requestedRefId || fallback?.ref_id || ''; + const eventId = Number(params.get('event_id') || fallback?.event_id || '0'); const humanEventId = eventId ? formatNumberPersian(eventId) : '—'; const refIdDisplay = refId ? toPersianDigits(refId) : ''; - const [fallback, setFallback] = useState(null); - - // 2) اگر saved نبود و refId هست، از بک‌اند اطلاعات را می‌گیریم (اندپوینت اختیاری by-ref) useEffect(() => { - (async () => { - if (!refId) return; - try { - const p = await api.getPaymentByRef(refId); - setFallback({ - event_id: p.event.id, - slug: p.event.slug, - title: p.event.title, - thumb: p.event.image_url || null, - base_amount: p.base_amount, - discount_amount: p.discount_amount, - amount: p.amount, - started_at: p.verified_at || undefined, - success_markdown: p.event?.success_markdown - }); - } catch { - // بی‌صدا؛ حداقل status/ref_id نمایش داده می‌شود + let cancelled = false; + let timer: number | undefined; + let delay = 5_000; + const pollingDeadline = Date.now() + 35 * 60 * 1_000; + + const updatePayment = (payment: Awaited>) => { + setFallback({ + ref_id: payment.ref_id, + event_id: payment.event.id, + slug: payment.event.slug, + title: payment.event.title, + thumb: payment.event.image_url || null, + base_amount: payment.base_amount, + discount_amount: payment.discount_amount, + amount: payment.amount, + started_at: payment.verified_at || undefined, + success_markdown: payment.event.success_markdown, + status: payment.status, + }); + + const pending = getPaymentOutcome(payment.status) === 'pending'; + const canVerifyWithGateway = Boolean(payment.authority); + if (pending && !canVerifyWithGateway && payment.status !== 'initiated') { + setManualReview(true); + } else if (pending && Date.now() >= pollingDeadline) { + setManualReview(true); + } else { + setManualReview(false); } - })(); - }, [refId]); + }; + + const refreshPayment = async () => { + try { + const payment = paymentId + ? await api.getPaymentStatus(paymentId) + : requestedRefId + ? await api.getPaymentByRef(requestedRefId) + : null; + if (!payment || cancelled) return; + updatePayment(payment); + + if ( + paymentId && + getPaymentOutcome(payment.status) === 'pending' && + (Boolean(payment.authority) || payment.status === 'initiated') && + Date.now() < pollingDeadline + ) { + timer = window.setTimeout(refreshPayment, delay); + delay = Math.min(delay * 2, 30_000); + } + } catch { + if (paymentId && !cancelled && Date.now() < pollingDeadline) { + timer = window.setTimeout(refreshPayment, delay); + delay = Math.min(delay * 2, 30_000); + } else if (paymentId && !cancelled) { + setManualReview(true); + } + } + }; + + void refreshPayment(); + return () => { + cancelled = true; + if (timer !== undefined) window.clearTimeout(timer); + }; + }, [paymentId, requestedRefId]); const data = fallback; - const ok = status === 'success'; + const outcome: PaymentOutcome = fallback?.status + ? getPaymentOutcome(fallback.status) + : getPaymentOutcome(status); + const ok = outcome === 'paid'; + const outcomeContent: Record = { + paid: { + title: 'پرداخت تأیید شد', + subtitle: 'درگاه پرداخت، تراکنش را تأیید کرده است و ثبت‌نام رویداد تکمیل شده است.', + pageState: 'پرداخت تأیید شد', + }, + pending: { + title: 'وضعیت پرداخت هنوز نهایی نیست', + subtitle: manualReview + ? 'وضعیت پرداخت هنوز قطعی نیست و برای بررسی پشتیبانی در انتظار مانده است. ثبت‌نام را نهایی‌شده تلقی نکنید.' + : 'وضعیت پرداخت هنوز نهایی نیست. تا دریافت تأیید قطعی، ثبت‌نام را نهایی‌شده تلقی نکنید.', + pageState: 'پرداخت در انتظار تأیید', + }, + failed: { + title: 'پرداخت تأیید نشد', + subtitle: 'درگاه پرداخت این تراکنش را تأیید نکرده است؛ ثبت‌نام نهایی نشده است. می‌توانید از صفحهٔ رویداد دوباره اقدام کنید.', + pageState: 'پرداخت ناموفق', + }, + canceled: { + title: 'پرداخت لغو شد', + subtitle: 'پرداخت کامل نشد و ثبت‌نام نهایی نشده است. برای شروع دوباره به صفحهٔ رویداد برگردید.', + pageState: 'پرداخت لغوشده', + }, + unknown: { + title: 'وضعیت پرداخت مشخص نیست', + subtitle: 'وضعیت قطعی تراکنش در دسترس نیست. تا دریافت تأیید سامانه، ثبت‌نام را نهایی‌شده تلقی نکنید.', + pageState: 'وضعیت پرداخت نامشخص', + }, + }; + const currentOutcome = outcomeContent[outcome]; const money = (n?: number) => (typeof n === 'number' && Number.isFinite(n) ? formatToman(n) : '—'); const receiptRef = useRef(null); const successMarkdown = data?.success_markdown ?? ''; @@ -77,14 +160,8 @@ export default function PaymentResult() { }; const eventTitle = data?.title || (eventId ? `Event #${humanEventId}` : 'Event payment'); const referenceFragment = refId ? ` Reference: ${refIdDisplay}.` : ''; - const pageState = - status === 'success' - ? 'Payment successful' - : status === 'failed' - ? 'Payment failed' - : 'Payment status'; - const pageTitle = `${pageState} | ${siteName}`; - const pageDescription = `${ok ? 'Payment confirmed' : 'Review your payment status'} for ${eventTitle}.${referenceFragment}`; + const pageTitle = `${currentOutcome.pageState} | ${siteName}`; + const pageDescription = `${currentOutcome.subtitle} ${eventTitle}.${referenceFragment}`; const ogImage = toAbsoluteUrl(data?.thumb) ?? `${siteUrl}/favicon.ico`; const helmet = ( @@ -118,9 +195,17 @@ export default function PaymentResult() { const url = new URL(`${base}/payments/result`); if (refId) url.searchParams.set('ref_id', refId); if (eventId) url.searchParams.set('event_id', String(eventId)); - url.searchParams.set('status', ok ? 'success' : 'failed'); + url.searchParams.set('status', outcome); return url.toString(); - }, [eventId, ok, refId]); + }, [eventId, outcome, refId]); + + const outcomeStyle = { + paid: 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-100', + pending: 'border-amber-200 bg-amber-50 text-amber-950 dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-100', + failed: 'border-red-200 bg-red-50 text-red-900 dark:border-red-800 dark:bg-red-900/30 dark:text-red-100', + canceled: 'border-border bg-muted/50 text-foreground', + unknown: 'border-border bg-muted/50 text-foreground', + }[outcome]; const handleDownloadPdf = async () => { const el = receiptRef.current; @@ -178,13 +263,12 @@ export default function PaymentResult() { {/* Header (status + ref) */}
- {ok ? "پرداخت با موفقیت انجام شد." : "پرداخت ناموفق بود."} +

{currentOutcome.title}

+

{currentOutcome.subtitle}

{/* Event + QR */} @@ -230,9 +314,11 @@ export default function PaymentResult() { -
- -
+ {ok && successMarkdown ? ( +
+ +
+ ) : null} {/* Invoice */}
@@ -246,6 +332,10 @@ export default function PaymentResult() { تخفیف {money(data?.discount_amount)} +
  • + وضعیت + {currentOutcome.title} +
  • مبلغ نهایی {money(data?.amount)} @@ -256,9 +346,11 @@ export default function PaymentResult() { {/* Actions */}
    - {data?.slug ? ( - - + {data?.event_id || eventId ? ( + + ) : (