263 lines
9.1 KiB
TypeScript
263 lines
9.1 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 { 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,
|
|
});
|
|
}
|
|
}, [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),
|
|
},
|
|
});
|
|
|
|
navigate(ADMIN_WELCOME_SUMMARY_PATH, { state: { packageId: pkg.id } });
|
|
},
|
|
[currencyFormatter, markStep, navigate, t]
|
|
);
|
|
|
|
const renderPackageList = () => {
|
|
if (packagesState.status === "loading") {
|
|
return (
|
|
<div className="flex items-center gap-3 rounded-2xl bg-brand-sky-soft/60 p-6 text-sm text-brand-navy/80">
|
|
<Loader2 className="size-5 animate-spin text-brand-rose" />
|
|
{t("packages.state.loading")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (packagesState.status === "error") {
|
|
return (
|
|
<Alert variant="destructive">
|
|
<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-brand-rose-soft bg-brand-card/60 text-brand-navy">
|
|
<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 (
|
|
<div
|
|
key={pkg.id}
|
|
className="flex flex-col gap-4 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 shadow-brand-primary"
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-brand-rose">
|
|
{t(isSubscription ? "packages.card.subscription" : "packages.card.creditPack")}
|
|
</p>
|
|
<h3 className="text-xl font-semibold text-brand-slate">{pkg.name}</h3>
|
|
</div>
|
|
<span className="text-lg font-medium text-brand-rose">{priceText}</span>
|
|
</div>
|
|
<p className="text-sm text-brand-navy/80">
|
|
{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-brand-rose">
|
|
{badges.map((badge) => (
|
|
<span key={badge} className="rounded-full bg-brand-rose-soft px-3 py-1">
|
|
{badge}
|
|
</span>
|
|
))}
|
|
{featureLabels.map((feature) => (
|
|
<span key={feature} className="rounded-full bg-brand-rose-soft px-3 py-1">
|
|
{feature}
|
|
</span>
|
|
))}
|
|
{isActive && (
|
|
<span className="rounded-full bg-brand-sky-soft px-3 py-1 text-brand-navy">
|
|
{t("packages.card.active")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Button
|
|
size="lg"
|
|
className="mt-auto rounded-full bg-brand-rose text-white shadow-lg shadow-rose-300/40 hover:bg-[var(--brand-rose-strong)]"
|
|
onClick={() => handleSelectPackage(pkg)}
|
|
>
|
|
{t("packages.card.select")}
|
|
<ArrowRight className="ml-2 size-4" />
|
|
</Button>
|
|
{purchased && (
|
|
<p className="text-xs text-brand-rose">
|
|
{t("packages.card.purchased", {
|
|
date: purchased.purchased_at
|
|
? dateFormatter.format(new Date(purchased.purchased_at))
|
|
: t("packages.card.purchasedUnknown"),
|
|
})}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</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>
|
|
);
|
|
}
|