296 lines
13 KiB
TypeScript
296 lines
13 KiB
TypeScript
"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>
|
||
);
|
||
}
|