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

284 lines
11 KiB
TypeScript

import React from "react";
import { CreditCard, ShoppingBag, ArrowRight, Loader2, AlertCircle } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
TenantWelcomeLayout,
WelcomeStepCard,
OnboardingCTAList,
useOnboardingProgress,
} from "..";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { FrostedSurface } from "../../components/tenant/frosted-surface";
import { ADMIN_BILLING_PATH, ADMIN_WELCOME_SUMMARY_PATH } from "../../constants";
import { useTenantPackages } from "../hooks/useTenantPackages";
import { Package } from "../../api";
const DEFAULT_CURRENCY = "EUR";
function useLocaleFormats(locale: string) {
const currencyFormatter = React.useMemo(
() =>
new Intl.NumberFormat(locale, {
style: "currency",
currency: DEFAULT_CURRENCY,
minimumFractionDigits: 0,
}),
[locale]
);
const dateFormatter = React.useMemo(
() =>
new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "2-digit",
day: "2-digit",
}),
[locale]
);
return { currencyFormatter, dateFormatter };
}
function formatFeatureLabel(t: ReturnType<typeof useTranslation>["t"], key: string): string {
const translationKey = `packages.features.${key}`;
const translated = t(translationKey);
if (translated !== translationKey) {
return translated;
}
return key
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export default function WelcomePackagesPage() {
const navigate = useNavigate();
const { 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);
React.useEffect(() => {
if (packagesState.status === "success") {
const active = packagesState.activePackage;
markStep({
packageSelected: Boolean(active),
lastStep: "packages",
selectedPackage: active
? {
id: active.package_id,
name: active.package_name,
priceText:
typeof active.price === "number"
? currencyFormatter.format(active.price)
: t("packages.card.onRequest"),
isSubscription: Boolean(active.package_limits?.subscription),
}
: null,
serverStep: active ? "package_selected" : undefined,
meta: active
? {
packages: [active.package_id],
is_active: active.active,
}
: undefined,
});
}
}, [packagesState, markStep, currencyFormatter, t]);
const handleSelectPackage = React.useCallback(
(pkg: Package) => {
const priceText =
typeof pkg.price === "number" ? currencyFormatter.format(pkg.price) : t("packages.card.onRequest");
markStep({
packageSelected: true,
lastStep: "packages",
selectedPackage: {
id: pkg.id,
name: pkg.name,
priceText,
isSubscription: Boolean(pkg.features?.subscription),
},
serverStep: "package_selected",
meta: { packages: [pkg.id] },
});
navigate(ADMIN_WELCOME_SUMMARY_PATH, { state: { packageId: pkg.id } });
},
[currencyFormatter, markStep, navigate, t]
);
const renderPackageList = () => {
if (packagesState.status === "loading") {
return (
<FrostedSurface className="flex items-center gap-3 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("packages.state.loading")}
</FrostedSurface>
);
}
if (packagesState.status === "error") {
return (
<Alert variant="destructive" className="border-rose-300/60 bg-rose-50/80 text-rose-700 dark:border-rose-500/40 dark:bg-rose-500/15 dark:text-rose-200">
<AlertCircle className="size-4" />
<AlertTitle>{t("packages.state.errorTitle")}</AlertTitle>
<AlertDescription>{packagesState.message ?? t("packages.state.errorDescription")}</AlertDescription>
</Alert>
);
}
if (packagesState.status === "success" && packagesState.catalog.length === 0) {
return (
<Alert variant="default" className="border-white/20 bg-white/70 text-slate-700 shadow-sm dark:border-slate-800/60 dark:bg-slate-950/80 dark:text-slate-300">
<AlertTitle>{t("packages.state.emptyTitle")}</AlertTitle>
<AlertDescription>{t("packages.state.emptyDescription")}</AlertDescription>
</Alert>
);
}
if (packagesState.status !== "success") {
return null;
}
return (
<div className="grid gap-4 md:grid-cols-2">
{packagesState.catalog.map((pkg) => {
const isActive = packagesState.activePackage?.package_id === pkg.id;
const purchased = packagesState.purchasedPackages.find((tenantPkg) => tenantPkg.package_id === pkg.id);
const featureLabels = Object.entries(pkg.features ?? {})
.filter(([, enabled]) => Boolean(enabled))
.map(([key]) => formatFeatureLabel(t, key));
const isSubscription = Boolean(pkg.features?.subscription);
const priceText =
typeof pkg.price === "number" ? currencyFormatter.format(pkg.price ?? 0) : t("packages.card.onRequest");
const badges: string[] = [];
if (pkg.max_guests) {
badges.push(t("packages.card.badges.guests", { count: pkg.max_guests }));
}
if (pkg.gallery_days) {
badges.push(t("packages.card.badges.days", { count: pkg.gallery_days }));
}
if (pkg.max_photos) {
badges.push(t("packages.card.badges.photos", { count: pkg.max_photos }));
}
return (
<FrostedSurface
key={pkg.id}
className="flex flex-col gap-4 border border-white/20 p-6 text-slate-900 shadow-md shadow-rose-200/20 transition-colors duration-200 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100"
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-rose-400 dark:text-rose-200">
{t(isSubscription ? "packages.card.subscription" : "packages.card.creditPack")}
</p>
<h3 className="text-xl font-semibold text-slate-900 dark:text-slate-100">{pkg.name}</h3>
</div>
<span className="text-lg font-medium text-rose-400 dark:text-rose-200">{priceText}</span>
</div>
<p className="text-sm text-slate-600 dark:text-slate-300">
{pkg.max_photos
? t("packages.card.descriptionWithPhotos", { count: pkg.max_photos })
: t("packages.card.description")}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-wide text-rose-400 dark:text-rose-200">
{badges.map((badge) => (
<span key={badge} className="rounded-full bg-rose-100/80 px-3 py-1 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-100">
{badge}
</span>
))}
{featureLabels.map((feature) => (
<span key={feature} className="rounded-full bg-rose-100/80 px-3 py-1 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-100">
{feature}
</span>
))}
{isActive ? (
<span className="rounded-full bg-sky-100/80 px-3 py-1 text-sky-600 shadow-inner shadow-sky-200/40 dark:bg-sky-500/20 dark:text-sky-200">
{t("packages.card.active")}
</span>
) : null}
</div>
<div className="flex flex-col gap-3 pt-2">
<Button
size="lg"
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-lg shadow-rose-300/30 hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
onClick={() => handleSelectPackage(pkg)}
disabled={isActive}
>
{isActive ? t("packages.card.active") : t("packages.card.select")}
<ArrowRight className="ml-2 size-4" />
</Button>
{purchased ? (
<p className="text-xs text-slate-500 dark:text-slate-400">
{t("packages.card.purchased", {
date: purchased.purchased_at
? dateFormatter.format(new Date(purchased.purchased_at))
: t("packages.card.purchasedUnknown"),
})}
</p>
) : null}
<Button
variant="ghost"
className="text-sm text-slate-600 hover:text-rose-400 dark:text-slate-400 dark:hover:text-rose-200"
onClick={() => navigate(ADMIN_BILLING_PATH)}
>
<CreditCard className="mr-2 h-4 w-4" />
{t("packages.card.viewBilling")}
</Button>
</div>
</FrostedSurface>
);
})}
</div>
);
};
return (
<TenantWelcomeLayout
eyebrow={t("packages.layout.eyebrow")}
title={t("packages.layout.title")}
subtitle={t("packages.layout.subtitle")}
>
<WelcomeStepCard
step={2}
totalSteps={4}
title={t("packages.step.title")}
description={t("packages.step.description")}
icon={CreditCard}
>
{renderPackageList()}
</WelcomeStepCard>
<OnboardingCTAList
actions={[
{
id: "skip-to-billing",
label: t("packages.cta.billing.label"),
description: t("packages.cta.billing.description"),
buttonLabel: t("packages.cta.billing.button"),
href: ADMIN_BILLING_PATH,
icon: ShoppingBag,
variant: "secondary",
},
{
id: "continue",
label: t("packages.cta.summary.label"),
description: t("packages.cta.summary.description"),
buttonLabel: t("packages.cta.summary.button"),
onClick: () => navigate(ADMIN_WELCOME_SUMMARY_PATH),
icon: ArrowRight,
},
]}
/>
</TenantWelcomeLayout>
);
}