410 lines
15 KiB
TypeScript
410 lines
15 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useCheckoutWizard } from "../WizardContext";
|
|
import { Trans, useTranslation } from 'react-i18next';
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { CalendarDays, CheckCircle2, ClipboardList, LoaderCircle, MailCheck, QrCode, ShieldCheck, Smartphone, Sparkles, XCircle } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface ConfirmationStepProps {
|
|
onViewProfile?: () => void;
|
|
onGoToAdmin?: () => void;
|
|
}
|
|
|
|
const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
|
style: "currency",
|
|
currency: "EUR",
|
|
minimumFractionDigits: 2,
|
|
});
|
|
|
|
export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfile, onGoToAdmin }) => {
|
|
const { t } = useTranslation('marketing');
|
|
const {
|
|
selectedPackage,
|
|
checkoutSessionId,
|
|
setPaymentCompleted,
|
|
clearCheckoutSessionId,
|
|
} = useCheckoutWizard();
|
|
const [status, setStatus] = useState<'processing' | 'completed' | 'failed'>(
|
|
checkoutSessionId ? 'processing' : 'completed',
|
|
);
|
|
const [elapsedMs, setElapsedMs] = useState(0);
|
|
const [checking, setChecking] = useState(false);
|
|
const handleProfile = React.useCallback(() => {
|
|
if (typeof onViewProfile === 'function') {
|
|
onViewProfile();
|
|
return;
|
|
}
|
|
window.location.href = '/settings/profile';
|
|
}, [onViewProfile]);
|
|
const handleAdmin = React.useCallback(() => {
|
|
if (typeof onGoToAdmin === 'function') {
|
|
onGoToAdmin();
|
|
return;
|
|
}
|
|
window.location.href = '/event-admin';
|
|
}, [onGoToAdmin]);
|
|
|
|
const packageName = selectedPackage?.name ?? '';
|
|
const packagePrice = useMemo(() => {
|
|
if (!selectedPackage) {
|
|
return '';
|
|
}
|
|
return selectedPackage.price === 0 ? t('packages.free') : currencyFormatter.format(selectedPackage.price);
|
|
}, [selectedPackage, t]);
|
|
const packageType = useMemo(() => {
|
|
if (!selectedPackage) {
|
|
return '';
|
|
}
|
|
return selectedPackage.type === 'reseller' ? t('packages.subscription') : t('packages.one_time');
|
|
}, [selectedPackage, t]);
|
|
|
|
const statusCopy = useMemo(() => {
|
|
if (status === 'completed') {
|
|
return {
|
|
label: t('checkout.confirmation_step.status_state.completed'),
|
|
body: t('checkout.confirmation_step.status_body_completed'),
|
|
tone: 'text-emerald-600',
|
|
icon: CheckCircle2,
|
|
badge: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
|
};
|
|
}
|
|
if (status === 'failed') {
|
|
return {
|
|
label: t('checkout.confirmation_step.status_state.failed'),
|
|
body: t('checkout.confirmation_step.status_body_failed'),
|
|
tone: 'text-rose-600',
|
|
icon: XCircle,
|
|
badge: 'bg-rose-50 text-rose-700 border-rose-200',
|
|
};
|
|
}
|
|
return {
|
|
label: t('checkout.confirmation_step.status_state.processing'),
|
|
body: t('checkout.confirmation_step.status_body_processing'),
|
|
tone: 'text-amber-600',
|
|
icon: LoaderCircle,
|
|
badge: 'bg-amber-50 text-amber-700 border-amber-200',
|
|
};
|
|
}, [status, t]);
|
|
|
|
const checkSessionStatus = useCallback(async (): Promise<'processing' | 'completed' | 'failed'> => {
|
|
if (!checkoutSessionId) {
|
|
return 'completed';
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/checkout/session/${checkoutSessionId}/status`, {
|
|
headers: {
|
|
Accept: 'application/json',
|
|
},
|
|
credentials: 'same-origin',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return 'processing';
|
|
}
|
|
|
|
const payload = await response.json();
|
|
const remoteStatus = typeof payload?.status === 'string' ? payload.status : null;
|
|
|
|
if (remoteStatus === 'completed') {
|
|
setPaymentCompleted(true);
|
|
clearCheckoutSessionId();
|
|
return 'completed';
|
|
}
|
|
|
|
if (remoteStatus === 'failed' || remoteStatus === 'cancelled') {
|
|
clearCheckoutSessionId();
|
|
return 'failed';
|
|
}
|
|
} catch (error) {
|
|
return 'processing';
|
|
}
|
|
|
|
return 'processing';
|
|
}, [checkoutSessionId, clearCheckoutSessionId, setPaymentCompleted]);
|
|
|
|
useEffect(() => {
|
|
if (!checkoutSessionId) {
|
|
setStatus('completed');
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
let timeoutId: number | null = null;
|
|
|
|
const poll = async () => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
const nextStatus = await checkSessionStatus();
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
setStatus(nextStatus);
|
|
if (nextStatus === 'processing' && typeof window !== 'undefined') {
|
|
timeoutId = window.setTimeout(poll, 5000);
|
|
}
|
|
};
|
|
|
|
void poll();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (timeoutId && typeof window !== 'undefined') {
|
|
window.clearTimeout(timeoutId);
|
|
}
|
|
};
|
|
}, [checkSessionStatus, checkoutSessionId]);
|
|
|
|
useEffect(() => {
|
|
if (status !== 'processing' || typeof window === 'undefined') {
|
|
setElapsedMs(0);
|
|
return;
|
|
}
|
|
|
|
const startedAt = Date.now();
|
|
const timer = window.setInterval(() => {
|
|
setElapsedMs(Date.now() - startedAt);
|
|
}, 1000);
|
|
|
|
return () => {
|
|
window.clearInterval(timer);
|
|
};
|
|
}, [status]);
|
|
|
|
const onboardingItems = [
|
|
{
|
|
key: 'event',
|
|
icon: CalendarDays,
|
|
},
|
|
{
|
|
key: 'invites',
|
|
icon: QrCode,
|
|
},
|
|
{
|
|
key: 'tasks',
|
|
icon: ClipboardList,
|
|
},
|
|
] as const;
|
|
|
|
const statusItems = [
|
|
{ key: 'payment', icon: CheckCircle2 },
|
|
{ key: 'email', icon: MailCheck },
|
|
{ key: 'access', icon: ShieldCheck },
|
|
] as const;
|
|
|
|
const statusProgress = useMemo(() => {
|
|
if (status === 'completed') {
|
|
return { payment: true, email: true, access: true };
|
|
}
|
|
if (status === 'failed') {
|
|
return { payment: false, email: false, access: false };
|
|
}
|
|
return { payment: true, email: false, access: false };
|
|
}, [status]);
|
|
|
|
const showManualActions = status === 'processing' && elapsedMs >= 30000;
|
|
const StatusIcon = statusCopy.icon;
|
|
|
|
const handleStatusRetry = useCallback(async () => {
|
|
if (checking) {
|
|
return;
|
|
}
|
|
|
|
setChecking(true);
|
|
const nextStatus = await checkSessionStatus();
|
|
setStatus(nextStatus);
|
|
setChecking(false);
|
|
}, [checkSessionStatus, checking]);
|
|
|
|
const handlePageRefresh = useCallback(() => {
|
|
if (typeof window !== 'undefined') {
|
|
window.location.reload();
|
|
}
|
|
}, []);
|
|
|
|
return (
|
|
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
|
<div className="space-y-6">
|
|
<div className="overflow-hidden rounded-2xl border bg-gradient-to-br from-primary via-primary/70 to-primary/60 p-6 text-primary-foreground shadow-lg">
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
<div className="space-y-3">
|
|
<Badge variant="secondary" className="bg-white/15 text-white shadow-sm ring-1 ring-white/30 backdrop-blur">
|
|
<Sparkles className="mr-1 h-3.5 w-3.5" />
|
|
{t('checkout.confirmation_step.hero_badge')}
|
|
</Badge>
|
|
<div className="space-y-2">
|
|
<h3 className="text-2xl font-semibold">{t('checkout.confirmation_step.hero_title')}</h3>
|
|
<p className="text-sm text-white/80">
|
|
<Trans
|
|
t={t}
|
|
i18nKey="checkout.confirmation_step.package_summary"
|
|
components={{ strong: <span className="font-semibold" /> }}
|
|
values={{ name: packageName }}
|
|
/>
|
|
</p>
|
|
<p className="text-sm text-white/80">{t('checkout.confirmation_step.hero_body')}</p>
|
|
</div>
|
|
</div>
|
|
<div className="rounded-xl border border-white/30 bg-white/10 px-5 py-4 text-sm text-white/90 shadow-inner backdrop-blur lg:max-w-sm">
|
|
<p>{t('checkout.confirmation_step.hero_next')}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Card className="border-muted/40 bg-card/70 shadow-sm">
|
|
<CardHeader className="space-y-3">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div>
|
|
<CardTitle className="text-base">{t('checkout.confirmation_step.status_title')}</CardTitle>
|
|
<CardDescription>{statusCopy.body}</CardDescription>
|
|
</div>
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
'inline-flex items-center gap-2 border px-2 py-1 text-xs font-medium',
|
|
statusCopy.badge,
|
|
)}
|
|
>
|
|
<StatusIcon className={cn('h-3.5 w-3.5', status === 'processing' && 'animate-spin')} />
|
|
{statusCopy.label}
|
|
</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
{statusItems.map(({ key, icon: Icon }) => {
|
|
const active = statusProgress[key];
|
|
return (
|
|
<div
|
|
key={key}
|
|
className={cn(
|
|
'rounded-lg border p-4 shadow-inner transition',
|
|
active ? 'border-primary/20 bg-primary/5' : 'border-muted bg-background/60 text-muted-foreground',
|
|
)}
|
|
>
|
|
<div className={cn("mb-3 inline-flex rounded-full p-2", active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground")}>
|
|
<Icon className="h-4 w-4" />
|
|
</div>
|
|
<p className="text-sm font-semibold">
|
|
{t(`checkout.confirmation_step.status_items.${key}.title`)}
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{t(`checkout.confirmation_step.status_items.${key}.body`)}
|
|
</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{showManualActions && (
|
|
<div className="rounded-lg border border-dashed bg-muted/30 p-4 text-sm text-muted-foreground">
|
|
<p>{t('checkout.confirmation_step.status_manual_hint')}</p>
|
|
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
<Button type="button" variant="outline" onClick={handleStatusRetry} disabled={checking}>
|
|
{checking && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
|
{t('checkout.confirmation_step.status_retry')}
|
|
</Button>
|
|
<Button type="button" variant="ghost" onClick={handlePageRefresh}>
|
|
{t('checkout.confirmation_step.status_refresh')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-muted/40 bg-card/60 shadow-sm">
|
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
|
<div>
|
|
<CardTitle className="text-sm uppercase tracking-wide text-muted-foreground">
|
|
{t('checkout.confirmation_step.onboarding_title')}
|
|
</CardTitle>
|
|
<CardDescription className="text-sm text-muted-foreground">
|
|
{t('checkout.confirmation_step.onboarding_subtitle')}
|
|
</CardDescription>
|
|
</div>
|
|
<Badge variant="outline" className="text-xs font-medium">
|
|
{t('checkout.confirmation_step.onboarding_badge')}
|
|
</Badge>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-4 md:grid-cols-3">
|
|
{onboardingItems.map(({ key, icon: Icon }) => (
|
|
<div key={key} className="rounded-lg border bg-background/60 p-4 shadow-inner">
|
|
<div className={cn("mb-3 inline-flex rounded-full bg-primary/10 p-2 text-primary")}>
|
|
<Icon className="h-4 w-4" />
|
|
</div>
|
|
<p className="text-sm font-semibold">
|
|
{t(`checkout.confirmation_step.onboarding_items.${key}.title`)}
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{t(`checkout.confirmation_step.onboarding_items.${key}.body`)}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<aside className="space-y-6">
|
|
<Card className="border-muted/40 bg-card shadow-sm">
|
|
<CardHeader className="space-y-1">
|
|
<CardTitle className="text-base">{t('checkout.confirmation_step.package_title')}</CardTitle>
|
|
<CardDescription>{t('checkout.confirmation_step.package_body')}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="rounded-lg border bg-muted/30 p-4 shadow-inner">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
{t('checkout.confirmation_step.package_label')}
|
|
</p>
|
|
<p className="mt-1 text-base font-semibold text-foreground">{packageName}</p>
|
|
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Badge variant="secondary" className="uppercase tracking-wide text-[10px]">
|
|
{packageType}
|
|
</Badge>
|
|
<span>{packagePrice}</span>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">{t('checkout.confirmation_step.email_followup')}</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-muted/40 bg-muted/20 shadow-inner">
|
|
<CardHeader className="space-y-1">
|
|
<CardTitle className="text-sm font-semibold text-foreground">
|
|
{t('checkout.confirmation_step.control_center_title')}
|
|
</CardTitle>
|
|
<CardDescription className="text-xs text-muted-foreground">
|
|
{t('checkout.confirmation_step.control_center_body')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Smartphone className="h-4 w-4" />
|
|
{t('checkout.confirmation_step.control_center_hint')}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-muted/40 bg-card shadow-sm">
|
|
<CardHeader className="space-y-1">
|
|
<CardTitle className="text-sm font-semibold">{t('checkout.confirmation_step.actions_title')}</CardTitle>
|
|
<CardDescription className="text-xs text-muted-foreground">
|
|
{t('checkout.confirmation_step.actions_body')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-2">
|
|
<Button onClick={handleAdmin}>{t('checkout.confirmation_step.to_admin')}</Button>
|
|
<Button variant="outline" onClick={handleProfile}>
|
|
{t('checkout.confirmation_step.open_profile')}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</aside>
|
|
</div>
|
|
);
|
|
};
|