feat(events): unify registration checkout and receipts
This commit is contained in:
295
src/components/OutcomePage.tsx
Normal file
295
src/components/OutcomePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user