feat(events): unify registration checkout and receipts

This commit is contained in:
2026-10-01 12:21:27 +03:30
parent 9f3f3631ac
commit 0f5ec429d0
21 changed files with 1593 additions and 840 deletions

View File

@@ -0,0 +1,295 @@
"use client";
import { Children, useState, type ReactNode } from "react";
import Image from "next/image";
import {
ArrowLeft,
BadgeCheck,
Ban,
Check,
Clock3,
Copy,
CircleHelp,
LoaderCircle,
Mail,
Send,
TicketCheck,
X,
} from "lucide-react";
import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button";
export type OutcomeTone = "success" | "pending" | "failed" | "canceled" | "unknown" | "checking";
export type OutcomeAction = {
label: string;
href?: string;
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
};
type OutcomeDetail = { label: string; value: ReactNode };
type OutcomePageProps = {
tone: OutcomeTone;
statusLabel: string;
title: string;
subtitle: string;
event?: { title: string; imageUrl?: string | null; href?: string; caption?: string };
details?: OutcomeDetail[];
reference?: string | null;
referenceLabel?: string;
support?: boolean;
supportDescription?: string;
refreshing?: boolean;
primaryAction?: OutcomeAction;
secondaryActions?: OutcomeAction[];
receiptRef?: React.Ref<HTMLDivElement>;
children?: ReactNode;
};
const toneClasses: Record<OutcomeTone, { badge: string; halo: string; card: string }> = {
success: {
badge: "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/50 dark:text-emerald-200",
halo: "text-emerald-600 dark:text-emerald-300",
card: "border-emerald-200/80 dark:border-emerald-900/80",
},
pending: {
badge: "border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200",
halo: "text-amber-600 dark:text-amber-300",
card: "border-amber-200/80 dark:border-amber-900/80",
},
failed: {
badge: "border-destructive/25 bg-destructive/10 text-destructive",
halo: "text-destructive",
card: "border-destructive/20",
},
canceled: {
badge: "border-destructive/25 bg-destructive/10 text-destructive",
halo: "text-destructive",
card: "border-destructive/20",
},
unknown: {
badge: "border-primary/20 bg-primary/5 text-primary",
halo: "text-primary",
card: "border-border",
},
checking: {
badge: "border-primary/20 bg-primary/5 text-primary",
halo: "text-primary",
card: "border-border",
},
};
function ToneIcon({ tone, className = "size-5" }: { tone: OutcomeTone; className?: string }) {
if (tone === "success") return <Check className={className} strokeWidth={2.6} />;
if (tone === "pending") return <Clock3 className={className} />;
if (tone === "failed") return <X className={className} strokeWidth={2.6} />;
if (tone === "canceled") return <Ban className={className} />;
if (tone === "checking") return <LoaderCircle className={`${className} animate-spin`} />;
return <CircleHelp className={className} />;
}
function PaymentIllustration({ tone }: { tone: OutcomeTone }) {
const colors = toneClasses[tone];
return (
<div className={`order-first relative mx-auto flex aspect-square w-48 items-center justify-center sm:w-56 md:order-last ${colors.halo}`} aria-hidden="true">
<div className="absolute inset-5 rounded-full bg-primary/[0.06] dark:bg-primary/[0.12]" />
<div className="absolute inset-10 rounded-full border border-primary/15" />
<svg viewBox="0 0 220 190" className="relative z-10 h-full w-full overflow-visible">
<circle cx="38" cy="38" r="4" className="fill-accent" />
<circle cx="183" cy="145" r="5" className="fill-primary/40" />
<path d="M39 145c18-23 37-34 59-34" className="fill-none stroke-primary/20" strokeWidth="2" strokeDasharray="4 7" />
<rect x="48" y="34" width="124" height="94" rx="16" className="fill-card stroke-primary/35" strokeWidth="2" />
<rect x="48" y="52" width="124" height="19" className="fill-primary/10" />
<path d="M63 87h34M63 101h21" className="stroke-primary/50" strokeWidth="4" strokeLinecap="round" />
<rect x="117" y="87" width="34" height="23" rx="5" className="fill-primary/15 stroke-primary/35" strokeWidth="2" />
<path d="M125 97h18" className="stroke-primary/55" strokeWidth="2" strokeLinecap="round" />
<path d="M73 143h55l-3 8a8 8 0 0 1-8 6H84a8 8 0 0 1-8-6z" className="fill-primary/15 stroke-primary/35" strokeWidth="2" />
<path d="M82 143v14M119 143v14" className="stroke-primary/30" strokeWidth="2" strokeDasharray="3 4" />
</svg>
<div className={`absolute bottom-7 left-5 z-20 flex size-12 items-center justify-center rounded-2xl border bg-card shadow-sm ${colors.card}`}>
<ToneIcon tone={tone} />
</div>
<div className="absolute right-7 top-7 z-20 flex size-10 items-center justify-center rounded-xl border border-border bg-card text-primary shadow-sm">
<TicketCheck className="size-5" />
</div>
</div>
);
}
function ActionButton({ action, primary = false }: { action: OutcomeAction; primary?: boolean }) {
const content = (
<>
<span>{action.label}</span>
{action.loading
? <LoaderCircle className="size-4 animate-spin" aria-hidden="true" />
: primary ? <ArrowLeft className="size-4" aria-hidden="true" /> : null}
</>
);
if (action.href) {
return (
<Button
asChild
variant={primary ? "default" : "outline"}
className="h-11 min-w-36 gap-2"
>
<Link to={action.href}>{content}</Link>
</Button>
);
}
return (
<Button
type="button"
variant={primary ? "default" : "outline"}
disabled={action.disabled || action.loading}
aria-busy={action.loading || undefined}
onClick={action.onClick}
className="h-11 min-w-36 gap-2"
>
{content}
</Button>
);
}
export default function OutcomePage({
tone,
statusLabel,
title,
subtitle,
event,
details = [],
reference,
referenceLabel = "کد پیگیری",
support = false,
supportDescription = "کد پیگیری را نگه دارید و برای بررسی وضعیت با انجمن در تماس باشید. تا روشن‌شدن نتیجه، پرداخت تازه‌ای آغاز نکنید.",
refreshing = false,
primaryAction,
secondaryActions = [],
receiptRef,
children,
}: OutcomePageProps) {
const [copied, setCopied] = useState(false);
const colors = toneClasses[tone];
const hasChildren = Children.toArray(children).length > 0;
const copyReference = async () => {
if (!reference || !navigator.clipboard) return;
try {
await navigator.clipboard.writeText(reference);
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
} catch {
setCopied(false);
}
};
return (
<main className="min-h-[65vh] bg-background px-4 pb-32 pt-8 sm:py-12 md:pb-12" dir="rtl">
<article className={`mx-auto w-full max-w-3xl overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-sm ${colors.card}`}>
<div ref={receiptRef} className="p-5 sm:p-8">
<header className="grid items-center gap-5 border-b border-border/70 pb-6 md:grid-cols-[minmax(0,1fr)_240px] md:gap-8 md:pb-8">
<div className="min-w-0">
<div data-receipt-status-badge className={`mb-4 inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium ${colors.badge}`}>
<ToneIcon tone={tone} className="size-4" />
<span>{statusLabel}</span>
</div>
<h1 className="text-2xl font-bold leading-relaxed sm:text-3xl">{title}</h1>
{subtitle ? <p className="mt-2 max-w-2xl text-sm leading-7 text-muted-foreground sm:text-base">{subtitle}</p> : null}
</div>
<PaymentIllustration tone={tone} />
</header>
{event ? (
<section className="mt-6 grid gap-4 rounded-xl border border-border bg-muted/30 p-3 sm:p-4 md:grid-cols-[minmax(0,1fr)_180px] md:items-center">
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">رویداد</p>
<h2 className="mt-1 break-words text-lg font-semibold leading-7">{event.title}</h2>
{event.caption ? <p className="mt-1 text-sm leading-6 text-muted-foreground">{event.caption}</p> : null}
{event.href ? (
<Link to={event.href} className="registration-event-details-link mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
مشاهده جزئیات رویداد <ArrowLeft className="size-4" aria-hidden="true" />
</Link>
) : null}
</div>
<div className="relative aspect-[16/9] overflow-hidden rounded-lg border border-border bg-background">
{event.imageUrl ? (
<Image
src={event.imageUrl}
alt=""
fill
sizes="(min-width: 768px) 180px, 100vw"
unoptimized
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-primary/60">
<TicketCheck className="size-10" strokeWidth={1.4} aria-hidden="true" />
</div>
)}
</div>
</section>
) : null}
{reference ? (
<section data-receipt-reference className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border px-4 py-3">
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{referenceLabel}</p>
<code dir="ltr" className="mt-1 block break-all text-left font-mono text-sm font-semibold text-foreground">{reference}</code>
</div>
<Button type="button" variant="outline" size="sm" onClick={copyReference} className="registration-copy-reference gap-2" aria-label={`${referenceLabel} را کپی کنید`}>
{copied ? <BadgeCheck className="size-4" /> : <Copy className="size-4" />}
{copied ? "کپی شد" : "کپی کد"}
</Button>
</section>
) : null}
{details.length > 0 ? (
<section className="mt-4 rounded-xl border border-border p-4 sm:p-5">
<h2 className="mb-2 text-sm font-semibold">جزئیات تراکنش</h2>
<dl className="divide-y divide-border/70">
{details.map((detail) => (
<div key={detail.label} className="flex min-w-0 items-start justify-between gap-4 py-3 text-sm">
<dt className="shrink-0 text-muted-foreground">{detail.label}</dt>
<dd className="min-w-0 break-words text-left font-medium text-foreground">{detail.value}</dd>
</div>
))}
</dl>
</section>
) : null}
{hasChildren ? <div className="mt-5 rounded-xl border border-border bg-muted/25 p-4 text-sm leading-7">{children}</div> : null}
{support ? (
<aside className="mt-5 rounded-xl border border-primary/20 bg-primary/[0.04] p-4 sm:p-5">
<h2 className="font-semibold">نیاز به پیگیری دارید؟</h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground">{supportDescription}</p>
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-2 text-sm">
<a className="inline-flex items-center gap-2 text-primary underline-offset-4 hover:underline" href="mailto:eastguilanceassociation@gmail.com">
<Mail className="size-4" aria-hidden="true" />
ایمیل انجمن
</a>
<a className="inline-flex items-center gap-2 text-primary underline-offset-4 hover:underline" href="https://t.me/guilancea" target="_blank" rel="noreferrer">
<Send className="size-4" aria-hidden="true" />
تلگرام انجمن
</a>
</div>
</aside>
) : null}
</div>
<footer className="flex flex-col gap-2 border-t border-border bg-muted/20 p-4 sm:flex-row sm:flex-wrap sm:items-center sm:p-5 print:hidden">
{primaryAction ? <ActionButton action={primaryAction} primary /> : null}
{secondaryActions.map((action) => (
<ActionButton key={action.label} action={action} />
))}
{refreshing ? <span className="inline-flex items-center gap-2 px-1 text-xs text-muted-foreground" role="status"><LoaderCircle className="size-4 animate-spin" /> در حال بررسی وضعیت...</span> : null}
</footer>
</article>
</main>
);
}