Files
guilan-ace-frontend/src/components/OutcomePage.tsx

296 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}