Added opaque join-token support across backend and frontend: new migration/model/service/endpoints, guest controllers now resolve tokens, and the demo seeder seeds a token. Tenant event details list/manage tokens with copy/revoke actions, and the guest PWA uses tokens end-to-end (routing, storage, uploads, achievements, etc.). Docs TODO updated to reflect completed steps.
This commit is contained in:
@@ -1,60 +1,95 @@
|
||||
import React from 'react';
|
||||
import { CreditCard, ShoppingBag, ArrowRight, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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';
|
||||
} 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') {
|
||||
if (packagesState.status === "success") {
|
||||
const active = packagesState.activePackage;
|
||||
markStep({
|
||||
packageSelected: Boolean(packagesState.activePackage),
|
||||
lastStep: 'packages',
|
||||
selectedPackage: packagesState.activePackage
|
||||
packageSelected: Boolean(active),
|
||||
lastStep: "packages",
|
||||
selectedPackage: active
|
||||
? {
|
||||
id: packagesState.activePackage.package_id,
|
||||
name: packagesState.activePackage.package_name,
|
||||
priceText: packagesState.activePackage.price
|
||||
? new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: packagesState.activePackage.currency ?? 'EUR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(packagesState.activePackage.price)
|
||||
: null,
|
||||
isSubscription: false,
|
||||
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]);
|
||||
}, [packagesState, markStep, currencyFormatter, t]);
|
||||
|
||||
const handleSelectPackage = React.useCallback(
|
||||
(pkg: Package) => {
|
||||
const priceText =
|
||||
typeof pkg.price === 'number'
|
||||
? new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(pkg.price)
|
||||
: 'Auf Anfrage';
|
||||
typeof pkg.price === "number" ? currencyFormatter.format(pkg.price) : t("packages.card.onRequest");
|
||||
|
||||
markStep({
|
||||
packageSelected: true,
|
||||
lastStep: package-,
|
||||
lastStep: "packages",
|
||||
selectedPackage: {
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
@@ -65,131 +100,158 @@ export default function WelcomePackagesPage() {
|
||||
|
||||
navigate(ADMIN_WELCOME_SUMMARY_PATH, { state: { packageId: pkg.id } });
|
||||
},
|
||||
[markStep, navigate]
|
||||
[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="Schritt 2"
|
||||
title="W<>hle dein Eventpaket"
|
||||
subtitle="Fotospiel unterst<73>tzt flexible Preismodelle: einmalige Credits oder Abos, die mehrere Events abdecken."
|
||||
eyebrow={t("packages.layout.eyebrow")}
|
||||
title={t("packages.layout.title")}
|
||||
subtitle={t("packages.layout.subtitle")}
|
||||
>
|
||||
<WelcomeStepCard
|
||||
step={2}
|
||||
totalSteps={4}
|
||||
title="Aktiviere die passenden Credits"
|
||||
description="Sichere dir Kapazit<69>t f<>r dein n<>chstes Event. Du kannst jederzeit upgraden <20> bezahle nur, was du wirklich brauchst."
|
||||
title={t("packages.step.title")}
|
||||
description={t("packages.step.description")}
|
||||
icon={CreditCard}
|
||||
>
|
||||
{packagesState.status === 'loading' && (
|
||||
<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" />
|
||||
Pakete werden geladen <EFBFBD>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{packagesState.status === 'error' && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="size-4" />
|
||||
<AlertTitle>Fehler beim Laden</AlertTitle>
|
||||
<AlertDescription>{packagesState.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{packagesState.status === 'success' && (
|
||||
<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]) => key.replace(/_/g, ' '));
|
||||
|
||||
const isSubscription = Boolean(pkg.features?.subscription);
|
||||
const priceText =
|
||||
typeof pkg.price === 'number'
|
||||
? new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(pkg.price)
|
||||
: 'Auf Anfrage';
|
||||
|
||||
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">
|
||||
{isSubscription ? 'Abo' : 'Credit-Paket'}
|
||||
</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
|
||||
? Bis zu Fotos inklusive <EFBFBD> perfekt f<EFBFBD>r lebendige Reportagen.
|
||||
: 'Sofort einsatzbereit f<>r dein n<>chstes Event.'}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-wide text-brand-rose">
|
||||
{pkg.max_guests && (
|
||||
<span className="rounded-full bg-brand-rose-soft px-3 py-1">{${pkg.max_guests} G<EFBFBD>ste}</span>
|
||||
)}
|
||||
{pkg.gallery_days && (
|
||||
<span className="rounded-full bg-brand-rose-soft px-3 py-1">{${pkg.gallery_days} Tage Galerie}</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">Aktives Paket</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)}
|
||||
>
|
||||
Paket w<EFBFBD>hlen
|
||||
<ArrowRight className="ml-2 size-4" />
|
||||
</Button>
|
||||
{purchased && (
|
||||
<p className="text-xs text-brand-rose">
|
||||
Bereits gekauft am{' '}
|
||||
{purchased.purchased_at
|
||||
? new Date(purchased.purchased_at).toLocaleDateString('de-DE')
|
||||
: 'unbekanntem Datum'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{renderPackageList()}
|
||||
</WelcomeStepCard>
|
||||
|
||||
<OnboardingCTAList
|
||||
actions={[
|
||||
{
|
||||
id: 'skip-to-checkout',
|
||||
label: 'Direkt zum Billing',
|
||||
description:
|
||||
'Falls du schon wei<65>t, welches Paket du brauchst, gelangst du hier zum bekannten Abrechnungsbereich.',
|
||||
buttonLabel: 'Billing <20>ffnen',
|
||||
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',
|
||||
variant: "secondary",
|
||||
},
|
||||
{
|
||||
id: 'continue',
|
||||
label: 'Bestell<6C>bersicht anzeigen',
|
||||
description: 'Pr<50>fe Paketdetails und entscheide, ob du direkt zahlen oder sp<73>ter fortfahren m<>chtest.',
|
||||
buttonLabel: 'Weiter zur <20>bersicht',
|
||||
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,
|
||||
},
|
||||
@@ -197,4 +259,4 @@ export default function WelcomePackagesPage() {
|
||||
/>
|
||||
</TenantWelcomeLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user