Files
fotospiel-app/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx

509 lines
21 KiB
TypeScript

import React from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Receipt,
ShieldCheck,
ArrowRight,
ArrowLeft,
CreditCard,
AlertTriangle,
Info,
Loader2,
} from "lucide-react";
import {
TenantWelcomeLayout,
WelcomeStepCard,
OnboardingCTAList,
useOnboardingProgress,
} from "..";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { FrostedSurface } from "../../components/tenant";
import { ADMIN_BILLING_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PATH } from "../../constants";
import { useTenantPackages } from "../hooks/useTenantPackages";
import {
assignFreeTenantPackage,
createTenantPaddleCheckout,
} from "../../api";
import { cn } from "@/lib/utils";
type PaddleCheckoutProps = {
packageId: number;
onSuccess: () => void;
t: ReturnType<typeof useTranslation>["t"];
className?: string;
};
function useLocaleFormats(locale: string) {
const currencyFormatter = React.useMemo(
() =>
new Intl.NumberFormat(locale, {
style: "currency",
currency: "EUR",
minimumFractionDigits: 0,
}),
[locale]
);
const dateFormatter = React.useMemo(
() =>
new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "2-digit",
day: "2-digit",
}),
[locale]
);
return { currencyFormatter, dateFormatter };
}
function humanizeFeature(t: ReturnType<typeof useTranslation>["t"], key: string): string {
const translationKey = `summary.details.features.${key}`;
const translated = t(translationKey);
if (translated !== translationKey) {
return translated;
}
return key
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function PaddleCheckout({ packageId, onSuccess, t, className }: PaddleCheckoutProps) {
const [status, setStatus] = React.useState<'idle' | 'processing' | 'success' | 'error'>('idle');
const [error, setError] = React.useState<string | null>(null);
const handleCheckout = React.useCallback(async () => {
try {
setStatus('processing');
setError(null);
const { checkout_url } = await createTenantPaddleCheckout(packageId);
window.open(checkout_url, '_blank', 'noopener');
setStatus('success');
onSuccess();
} catch (err) {
console.error('[Onboarding] Paddle checkout failed', err);
setStatus('error');
setError(err instanceof Error ? err.message : t('summary.paddle.genericError'));
}
}, [packageId, onSuccess, t]);
return (
<FrostedSurface
className={cn(
"space-y-4 rounded-2xl border border-white/20 p-6 text-slate-900 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100",
className
)}
>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-rose-400 dark:text-rose-200">
{t('summary.paddle.sectionTitle')}
</p>
<p className="text-base font-semibold text-slate-900 dark:text-slate-100">
{t('summary.paddle.heading')}
</p>
</div>
{error ? (
<div
role="alert"
className="flex items-start gap-3 rounded-2xl border border-rose-300/40 bg-rose-100/15 p-4 text-sm text-rose-700 shadow-inner shadow-rose-200/20 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200"
>
<AlertTriangle className="size-4 shrink-0 text-current" />
<div className="space-y-1">
<p className="text-sm font-semibold uppercase tracking-wide">{t('summary.paddle.errorTitle')}</p>
<p className="text-sm text-rose-600/80 dark:text-rose-200/80">{error}</p>
</div>
</div>
) : null}
<Button
size="lg"
className="w-full rounded-full bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)] disabled:opacity-60"
disabled={status === 'processing'}
onClick={handleCheckout}
>
{status === 'processing' ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
{t('summary.paddle.processing')}
</>
) : (
<>
<CreditCard className="mr-2 size-4" />
{t('summary.paddle.cta')}
</>
)}
</Button>
<p className="text-xs text-slate-600 dark:text-slate-400">{t('summary.paddle.hint')}</p>
</FrostedSurface>
);
}
export default function WelcomeOrderSummaryPage() {
const navigate = useNavigate();
const location = useLocation();
const { progress, markStep } = useOnboardingProgress();
const packagesState = useTenantPackages();
const { t, i18n } = useTranslation("onboarding");
const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE";
const { currencyFormatter, dateFormatter } = useLocaleFormats(locale);
const packageIdFromState = typeof location.state === "object" ? (location.state as any)?.packageId : undefined;
const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null;
React.useEffect(() => {
if (!selectedPackageId && packagesState.status !== "loading") {
navigate(ADMIN_WELCOME_PACKAGES_PATH, { replace: true });
}
}, [selectedPackageId, packagesState.status, navigate]);
React.useEffect(() => {
markStep({ lastStep: "summary" });
}, [markStep]);
const packageDetails =
packagesState.status === "success" && selectedPackageId
? packagesState.catalog.find((pkg) => pkg.id === selectedPackageId)
: null;
const activePackage =
packagesState.status === "success"
? packagesState.purchasedPackages.find((pkg) => pkg.package_id === selectedPackageId)
: null;
const isSubscription = Boolean(packageDetails?.features?.subscription);
const requiresPayment = Boolean(packageDetails && packageDetails.price > 0);
const [freeAssignStatus, setFreeAssignStatus] = React.useState<"idle" | "loading" | "success" | "error">("idle");
const [freeAssignError, setFreeAssignError] = React.useState<string | null>(null);
const priceText =
progress.selectedPackage?.priceText ??
(packageDetails && typeof packageDetails.price === "number"
? currencyFormatter.format(packageDetails.price)
: null);
const detailBadges = React.useMemo(() => {
if (!packageDetails) {
return [] as string[];
}
const badges: string[] = [];
if (packageDetails.max_photos) {
badges.push(t("summary.details.photos", { count: packageDetails.max_photos }));
}
if (packageDetails.gallery_days) {
badges.push(t("summary.details.galleryDays", { count: packageDetails.gallery_days }));
}
if (packageDetails.max_guests) {
badges.push(t("summary.details.guests", { count: packageDetails.max_guests }));
}
return badges;
}, [packageDetails, t]);
const featuresList = React.useMemo(() => {
if (!packageDetails) {
return [] as string[];
}
return Object.entries(packageDetails.features ?? {})
.filter(([, enabled]) => Boolean(enabled))
.map(([feature]) => humanizeFeature(t, feature));
}, [packageDetails, t]);
const nextSteps = t("summary.nextSteps", { returnObjects: true }) as string[];
return (
<TenantWelcomeLayout
eyebrow={t("summary.layout.eyebrow")}
title={t("summary.layout.title")}
subtitle={t("summary.layout.subtitle")}
footer={
<button
type="button"
className="inline-flex items-center gap-2 text-sm font-medium text-brand-rose hover:text-rose-600"
onClick={() => navigate(ADMIN_WELCOME_PACKAGES_PATH)}
>
<ArrowLeft className="size-4" />
{t("summary.footer.back")}
</button>
}
>
<WelcomeStepCard
step={3}
totalSteps={4}
title={t("summary.step.title")}
description={t("summary.step.description")}
icon={Receipt}
>
{packagesState.status === "loading" && (
<FrostedSurface className="flex items-center gap-3 rounded-2xl border border-white/20 p-6 text-sm text-slate-600 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-300">
<Loader2 className="size-5 animate-spin text-rose-400" />
{t("summary.state.loading")}
</FrostedSurface>
)}
{packagesState.status === "error" && (
<FrostedSurface
role="alert"
className="flex items-start gap-3 rounded-2xl border border-rose-300/40 bg-rose-100/15 p-6 text-sm text-rose-700 shadow-md shadow-rose-200/30 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200"
>
<AlertTriangle className="size-4 shrink-0 text-current" />
<div className="space-y-1">
<p className="text-sm font-semibold uppercase tracking-wide">
{t("summary.state.errorTitle")}
</p>
<p className="text-sm text-rose-600/80 dark:text-rose-200/80">
{packagesState.message ?? t("summary.state.errorDescription")}
</p>
</div>
</FrostedSurface>
)}
{packagesState.status === "success" && !packageDetails && (
<FrostedSurface
role="alert"
className="flex items-start gap-3 rounded-2xl border border-white/20 bg-white/15 p-6 text-sm text-slate-800 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-200"
>
<Info className="size-4 shrink-0 text-current" />
<div className="space-y-1">
<p className="text-sm font-semibold uppercase tracking-wide">
{t("summary.state.missingTitle")}
</p>
<p className="text-sm text-slate-700/90 dark:text-slate-300/80">
{t("summary.state.missingDescription")}
</p>
</div>
</FrostedSurface>
)}
{packagesState.status === "success" && packageDetails && (
<div className="grid gap-4">
<FrostedSurface className="relative overflow-hidden rounded-3xl border border-white/20 p-6 text-white shadow-xl shadow-rose-300/25 dark:border-slate-800/70 dark:bg-slate-950/85">
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.35),_transparent_65%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.3),_transparent_65%)]"
/>
<div aria-hidden className="absolute inset-0 bg-gradient-to-br from-slate-950/65 via-slate-900/10 to-transparent mix-blend-overlay" />
<div className="relative z-10 flex flex-col gap-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-3">
<Badge className="w-fit rounded-full border border-white/40 bg-white/20 px-4 py-1 text-[10px] font-semibold uppercase tracking-[0.4em] text-white shadow-inner shadow-white/20">
{t(isSubscription ? "summary.details.subscription" : "summary.details.creditPack")}
</Badge>
<h3 className="font-display text-2xl font-semibold tracking-tight text-white md:text-3xl">
{packageDetails.name}
</h3>
</div>
<div className="flex flex-col items-start gap-2 text-left sm:items-end sm:text-right">
{priceText ? (
<Badge className="rounded-full border border-white/40 bg-white/15 px-4 py-2 text-base font-semibold text-white shadow-inner shadow-white/20">
{priceText}
</Badge>
) : null}
<span className="text-xs font-semibold uppercase tracking-[0.35em] text-white/70">
{activePackage
? t("summary.details.section.statusActive")
: t("summary.details.section.statusInactive")}
</span>
</div>
</div>
<dl className="grid gap-4 text-sm text-white/85 md:grid-cols-2">
<div className="space-y-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-white/70">
{t("summary.details.section.photosTitle")}
</dt>
<dd>
{packageDetails.max_photos
? t("summary.details.section.photosValue", {
count: packageDetails.max_photos,
days: packageDetails.gallery_days ?? t("summary.details.infinity"),
})
: t("summary.details.section.photosUnlimited")}
</dd>
</div>
<div className="space-y-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-white/70">
{t("summary.details.section.guestsTitle")}
</dt>
<dd>
{packageDetails.max_guests
? t("summary.details.section.guestsValue", { count: packageDetails.max_guests })
: t("summary.details.section.guestsUnlimited")}
</dd>
</div>
<div className="space-y-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-white/70">
{t("summary.details.section.featuresTitle")}
</dt>
<dd>
{featuresList.length ? (
<div className="flex flex-wrap gap-2">
{featuresList.map((feature) => (
<Badge
key={feature}
className="rounded-full border border-white/30 bg-white/20 px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-white"
>
{feature}
</Badge>
))}
</div>
) : (
<span className="text-white/70">{t("summary.details.section.featuresNone")}</span>
)}
</dd>
</div>
<div className="space-y-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-white/70">
{t("summary.details.section.statusTitle")}
</dt>
<dd>
<Badge className="rounded-full border border-white/30 bg-white/20 px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-white">
{activePackage
? t("summary.details.section.statusActive")
: t("summary.details.section.statusInactive")}
</Badge>
</dd>
</div>
</dl>
{detailBadges.length > 0 ? (
<div className="flex flex-wrap gap-2">
{detailBadges.map((badge) => (
<Badge
key={badge}
className="rounded-full border border-white/30 bg-white/20 px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-white"
>
{badge}
</Badge>
))}
</div>
) : null}
</div>
</FrostedSurface>
{!activePackage && (
<FrostedSurface
role="status"
className="flex items-start gap-3 rounded-2xl border border-amber-200/40 bg-amber-100/15 p-5 text-sm text-amber-800 shadow-md shadow-amber-200/20 dark:border-amber-300/30 dark:bg-amber-500/10 dark:text-amber-100"
>
<ShieldCheck className="size-4 shrink-0 text-current" />
<div className="space-y-1">
<p className="text-sm font-semibold uppercase tracking-wide">
{t("summary.status.pendingTitle")}
</p>
<p className="text-sm text-amber-700/90 dark:text-amber-100/80">
{t("summary.status.pendingDescription")}
</p>
</div>
</FrostedSurface>
)}
{packageDetails.price === 0 && (
<FrostedSurface className="space-y-3 rounded-2xl border border-emerald-200/40 bg-emerald-100/15 p-6 text-sm text-emerald-900 shadow-md shadow-emerald-200/20 dark:border-emerald-400/30 dark:bg-emerald-500/10 dark:text-emerald-100">
<p>{t("summary.free.description")}</p>
{freeAssignStatus === "success" ? (
<div className="space-y-1 rounded-xl border border-emerald-200/60 bg-emerald-50/30 p-4 text-sm text-emerald-800 dark:border-emerald-400/30 dark:bg-emerald-500/20 dark:text-emerald-50">
<p className="font-semibold">{t("summary.free.successTitle")}</p>
<p>{t("summary.free.successDescription")}</p>
</div>
) : (
<Button
onClick={async () => {
if (!packageDetails) {
return;
}
setFreeAssignStatus("loading");
setFreeAssignError(null);
try {
await assignFreeTenantPackage(packageDetails.id);
setFreeAssignStatus("success");
markStep({ packageSelected: true });
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
} catch (error) {
console.error("[Onboarding] Free package assignment failed", error);
setFreeAssignStatus("error");
setFreeAssignError(
error instanceof Error ? error.message : t("summary.free.errorMessage")
);
}
}}
disabled={freeAssignStatus === "loading"}
className="mt-2 w-full rounded-full bg-gradient-to-r from-[#34d399] via-[#10b981] to-[#059669] text-white shadow-lg shadow-emerald-300/30 hover:from-[#22c783] hover:via-[#0fa776] hover:to-[#047857]"
>
{freeAssignStatus === "loading" ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
{t("summary.free.progress")}
</>
) : (
t("summary.free.activate")
)}
</Button>
)}
{freeAssignStatus === "error" && freeAssignError ? (
<div
role="alert"
className="rounded-xl border border-rose-300/40 bg-rose-100/15 p-4 text-sm text-rose-700 shadow-inner shadow-rose-200/20 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200"
>
<p className="font-semibold">{t("summary.free.failureTitle")}</p>
<p>{freeAssignError}</p>
</div>
) : null}
</FrostedSurface>
)}
{requiresPayment && (
<PaddleCheckout
packageId={packageDetails.id}
onSuccess={() => {
markStep({ packageSelected: true });
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
}}
t={t}
/>
)}
<FrostedSurface className="flex flex-col gap-3 rounded-2xl border border-white/20 p-6 text-slate-900 shadow-inner shadow-rose-200/10 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100">
<h4 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{t("summary.nextStepsTitle")}</h4>
<ol className="list-decimal space-y-2 pl-5 text-sm text-slate-600 dark:text-slate-300">
{nextSteps.map((step, index) => (
<li key={`${step}-${index}`}>{step}</li>
))}
</ol>
</FrostedSurface>
</div>
)}
</WelcomeStepCard>
<OnboardingCTAList
actions={[
{
id: "checkout",
label: t("summary.cta.billing.label"),
description: t("summary.cta.billing.description"),
buttonLabel: t("summary.cta.billing.button"),
href: ADMIN_BILLING_PATH,
icon: CreditCard,
variant: "secondary",
},
{
id: "continue-to-setup",
label: t("summary.cta.setup.label"),
description: t("summary.cta.setup.description"),
buttonLabel: t("summary.cta.setup.button"),
onClick: () => {
markStep({ lastStep: "event-setup" });
navigate(ADMIN_WELCOME_EVENT_PATH);
},
icon: ArrowRight,
},
]}
/>
</TenantWelcomeLayout>
);
}
export { PaddleCheckout };