fix(payments): reuse checkout attempts and show pending status
This commit is contained in:
@@ -1076,6 +1076,7 @@ class ApiClient {
|
||||
discount_code?: string | null;
|
||||
mobile?: string | null;
|
||||
email?: string | null;
|
||||
idempotency_key: string;
|
||||
}) {
|
||||
return this.request<Types.CreatePaymentOut>(
|
||||
'/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<Types.PaymentDetailSchema>(`/api/payments/by-ref/${encodeURIComponent(refId)}`, { method: 'GET' });
|
||||
}
|
||||
|
||||
async getPaymentStatus(paymentId: number) {
|
||||
return this.request<Types.PaymentDetailSchema>(`/api/payments/status/${paymentId}`, { method: 'GET' });
|
||||
}
|
||||
async checkDiscountCode(event_id: number, code: string) {
|
||||
return this.request<{
|
||||
discount_amount: number;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string | null>(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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<SavedPayment | null>(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<SavedPayment | null>(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<ReturnType<typeof api.getPaymentStatus>>) => {
|
||||
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<PaymentOutcome, { title: string; subtitle: string; pageState: string }> = {
|
||||
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<HTMLDivElement | null>(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 = (
|
||||
<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) */}
|
||||
<div
|
||||
className={[
|
||||
"rounded-md p-3 text-sm mb-4 border",
|
||||
ok
|
||||
? "bg-emerald-100 text-emerald-800 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-200 dark:border-emerald-800/50"
|
||||
: "bg-red-100 text-red-800 border-red-200 dark:bg-red-900/30 dark:text-red-200 dark:border-red-800/50",
|
||||
"mb-4 rounded-md border p-3 text-sm",
|
||||
outcomeStyle,
|
||||
].join(" ")}
|
||||
>
|
||||
{ok ? "پرداخت با موفقیت انجام شد." : "پرداخت ناموفق بود."}
|
||||
<p className="font-semibold">{currentOutcome.title}</p>
|
||||
<p className="mt-1 leading-6">{currentOutcome.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Event + QR */}
|
||||
@@ -230,9 +314,11 @@ export default function PaymentResult() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-6 flex max-w-xl items-center justify-end gap-2">
|
||||
<Markdown content={successMarkdown} justify size="base" />
|
||||
</div>
|
||||
{ok && successMarkdown ? (
|
||||
<div className="mx-auto mt-6 flex max-w-xl items-center justify-end gap-2">
|
||||
<Markdown content={successMarkdown} justify size="base" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Invoice */}
|
||||
<div className="mt-6 rounded-md border border-border p-3">
|
||||
@@ -246,6 +332,10 @@ export default function PaymentResult() {
|
||||
<span className="text-muted-foreground">تخفیف</span>
|
||||
<span>{money(data?.discount_amount)}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between py-2">
|
||||
<span className="text-muted-foreground">وضعیت</span>
|
||||
<span>{currentOutcome.title}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between py-2 font-semibold">
|
||||
<span>مبلغ نهایی</span>
|
||||
<span>{money(data?.amount)}</span>
|
||||
@@ -256,9 +346,11 @@ export default function PaymentResult() {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-4 flex flex-wrap gap-2 justify-end print:hidden">
|
||||
{data?.slug ? (
|
||||
<Link to={`/events/${data.slug}`}>
|
||||
<Button variant="outline">بازگشت به رویداد</Button>
|
||||
{data?.event_id || eventId ? (
|
||||
<Link to={getEventPath({ id: data?.event_id || eventId, slug: data?.slug })}>
|
||||
<Button variant="outline">
|
||||
{ok ? "مشاهده رویداد" : outcome === "pending" ? "بازگشت به رویداد" : "بازگشت برای اقدام دوباره"}
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/events">
|
||||
|
||||
Reference in New Issue
Block a user