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,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LanguageSwitcher } from '../../components/LanguageSwitcher';
|
||||
|
||||
export interface TenantWelcomeLayoutProps {
|
||||
eyebrow?: string;
|
||||
@@ -46,7 +46,10 @@ export function TenantWelcomeLayout({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{headerAction && <div className="flex shrink-0 items-center">{headerAction}</div>}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
{headerAction}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex flex-1 flex-col gap-8">
|
||||
@@ -64,4 +67,4 @@ export function TenantWelcomeLayout({
|
||||
);
|
||||
}
|
||||
|
||||
TenantWelcomeLayout.displayName = 'TenantWelcomeLayout';
|
||||
TenantWelcomeLayout.displayName = 'TenantWelcomeLayout';
|
||||
|
||||
@@ -1,54 +1,57 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ClipboardCheck, Sparkles, Globe, ArrowRight } from 'lucide-react';
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ClipboardCheck, Sparkles, Globe, ArrowRight } from "lucide-react";
|
||||
|
||||
import {
|
||||
TenantWelcomeLayout,
|
||||
WelcomeStepCard,
|
||||
OnboardingCTAList,
|
||||
useOnboardingProgress,
|
||||
} from '..';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ADMIN_EVENT_CREATE_PATH, ADMIN_EVENTS_PATH, ADMIN_HOME_PATH } from '../../constants';
|
||||
} from "..";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ADMIN_EVENT_CREATE_PATH, ADMIN_EVENTS_PATH, ADMIN_HOME_PATH } from "../../constants";
|
||||
|
||||
export default function WelcomeEventSetupPage() {
|
||||
const navigate = useNavigate();
|
||||
const { markStep } = useOnboardingProgress();
|
||||
const { t } = useTranslation("onboarding");
|
||||
|
||||
React.useEffect(() => {
|
||||
markStep({ lastStep: 'event-setup' });
|
||||
markStep({ lastStep: "event-setup" });
|
||||
}, [markStep]);
|
||||
|
||||
return (
|
||||
<TenantWelcomeLayout
|
||||
eyebrow="Schritt 4"
|
||||
title="Bereite dein erstes Event vor"
|
||||
subtitle="F<>lle wenige Details aus, lade Co-Hosts ein und <20>ffne deine G<>stegalerie f<>r das gro<72>e Ereignis."
|
||||
eyebrow={t("eventSetup.layout.eyebrow")}
|
||||
title={t("eventSetup.layout.title")}
|
||||
subtitle={t("eventSetup.layout.subtitle")}
|
||||
>
|
||||
<WelcomeStepCard
|
||||
step={4}
|
||||
totalSteps={4}
|
||||
title="Event-Setup in Minuten"
|
||||
description="Wir f<>hren dich durch Name, Datum, Mood und Aufgaben. Danach kannst du Fotos moderieren und G<>ste live begleiten."
|
||||
title={t("eventSetup.step.title")}
|
||||
description={t("eventSetup.step.description")}
|
||||
icon={ClipboardCheck}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
id: 'story',
|
||||
title: 'Story & Stimmung',
|
||||
copy: 'W<>hle Bildsprache, Farben und Emotionskarten f<>r dein Event.',
|
||||
id: "story",
|
||||
title: t("eventSetup.tiles.story.title"),
|
||||
copy: t("eventSetup.tiles.story.copy"),
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
id: 'team',
|
||||
title: 'Team organisieren',
|
||||
copy: 'Lade Moderator*innen oder Fotograf*innen ein und teile Rollen zu.',
|
||||
id: "team",
|
||||
title: t("eventSetup.tiles.team.title"),
|
||||
copy: t("eventSetup.tiles.team.copy"),
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
id: 'launch',
|
||||
title: 'Go-Live vorbereiten',
|
||||
copy: 'Erstelle QR-Codes, teste die G<>stegalerie und kommuniziere den Ablauf.',
|
||||
id: "launch",
|
||||
title: t("eventSetup.tiles.launch.title"),
|
||||
copy: t("eventSetup.tiles.launch.copy"),
|
||||
icon: ArrowRight,
|
||||
},
|
||||
].map((item) => (
|
||||
@@ -66,20 +69,17 @@ export default function WelcomeEventSetupPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col items-start gap-3 rounded-3xl border border-brand-rose-soft bg-brand-sky-soft/40 p-6 text-brand-navy">
|
||||
<h4 className="text-lg font-semibold text-brand-rose">Bereit f<EFBFBD>r dein erstes Event?</h4>
|
||||
<p className="text-sm text-brand-navy/80">
|
||||
Du wechselst jetzt in den Event-Manager. Dort kannst du Tasks zuweisen, Mitglieder einladen und die
|
||||
G<EFBFBD>stegalerie testen. Keine Sorge: Du kannst jederzeit zur Welcome Journey zur<EFBFBD>ckkehren.
|
||||
</p>
|
||||
<h4 className="text-lg font-semibold text-brand-rose">{t("eventSetup.cta.heading")}</h4>
|
||||
<p className="text-sm text-brand-navy/80">{t("eventSetup.cta.description")}</p>
|
||||
<Button
|
||||
size="lg"
|
||||
className="mt-2 rounded-full bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]"
|
||||
onClick={() => {
|
||||
markStep({ lastStep: 'event-create-intent' });
|
||||
markStep({ lastStep: "event-create-intent" });
|
||||
navigate(ADMIN_EVENT_CREATE_PATH);
|
||||
}}
|
||||
>
|
||||
Event erstellen
|
||||
{t("eventSetup.cta.button")}
|
||||
<ArrowRight className="ml-2 size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -88,29 +88,29 @@ export default function WelcomeEventSetupPage() {
|
||||
<OnboardingCTAList
|
||||
actions={[
|
||||
{
|
||||
id: 'back',
|
||||
label: 'Noch einmal Pakete pr<70>fen',
|
||||
description: 'Vergleiche Preise oder aktualisiere dein derzeitiges Paket.',
|
||||
buttonLabel: 'Zu Paketen',
|
||||
id: "back",
|
||||
label: t("eventSetup.actions.back.label"),
|
||||
description: t("eventSetup.actions.back.description"),
|
||||
buttonLabel: t("eventSetup.actions.back.button"),
|
||||
onClick: () => navigate(-1),
|
||||
variant: 'secondary',
|
||||
variant: "secondary",
|
||||
},
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Zum Dashboard',
|
||||
description: 'Springe ins Management, um bestehende Events zu bearbeiten.',
|
||||
buttonLabel: 'Dashboard <20>ffnen',
|
||||
id: "dashboard",
|
||||
label: t("eventSetup.actions.dashboard.label"),
|
||||
description: t("eventSetup.actions.dashboard.description"),
|
||||
buttonLabel: t("eventSetup.actions.dashboard.button"),
|
||||
onClick: () => navigate(ADMIN_HOME_PATH),
|
||||
},
|
||||
{
|
||||
id: 'events',
|
||||
label: 'Event<6E>bersicht',
|
||||
description: 'Behalte den <20>berblick <20>ber alle aktiven und archivierten Events.',
|
||||
buttonLabel: 'Eventliste',
|
||||
id: "events",
|
||||
label: t("eventSetup.actions.events.label"),
|
||||
description: t("eventSetup.actions.events.description"),
|
||||
buttonLabel: t("eventSetup.actions.events.button"),
|
||||
onClick: () => navigate(ADMIN_EVENTS_PATH),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TenantWelcomeLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from "react";
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sparkles, Users, Camera, CalendarDays, ChevronRight } from "lucide-react";
|
||||
|
||||
import {
|
||||
TenantWelcomeLayout,
|
||||
WelcomeHero,
|
||||
@@ -13,6 +15,7 @@ import { ADMIN_WELCOME_PACKAGES_PATH, ADMIN_EVENTS_PATH } from "../../constants"
|
||||
export default function WelcomeLandingPage() {
|
||||
const navigate = useNavigate();
|
||||
const { markStep } = useOnboardingProgress();
|
||||
const { t } = useTranslation("onboarding");
|
||||
|
||||
React.useEffect(() => {
|
||||
markStep({ welcomeSeen: true, lastStep: "landing" });
|
||||
@@ -20,38 +23,36 @@ export default function WelcomeLandingPage() {
|
||||
|
||||
return (
|
||||
<TenantWelcomeLayout
|
||||
eyebrow="Fotospiel Tenant Admin"
|
||||
title="Willkommen im Event-Erlebnisstudio"
|
||||
subtitle="Starte mit einer inspirierten Einführung, sichere dir dein Event-Paket und kreiere die perfekte Gästegalerie – alles optimiert für mobile Hosts."
|
||||
eyebrow={t("layout.eyebrow")}
|
||||
title={t("layout.title")}
|
||||
subtitle={t("layout.subtitle")}
|
||||
footer={
|
||||
<>
|
||||
<span className="text-brand-navy/80">Schon vertraut mit Fotospiel?</span>
|
||||
<span className="text-brand-navy/80">{t("layout.alreadyFamiliar")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-sm font-semibold text-brand-rose hover:text-[var(--brand-rose-strong)]"
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
>
|
||||
Direkt zum Dashboard
|
||||
{t("layout.jumpToDashboard")}
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<WelcomeHero
|
||||
eyebrow="Dein Event, deine Bühne"
|
||||
title="Gestalte das nächste Fotospiel Erlebnis"
|
||||
scriptTitle="Einmalig für Gäste, mühelos für dich."
|
||||
description="Mit nur wenigen Schritten führst du deine Gäste durch ein magisches Fotoabenteuer – inklusive Storytelling, Aufgaben und moderierter Galerie."
|
||||
eyebrow={t("hero.eyebrow")}
|
||||
title={t("hero.title")}
|
||||
scriptTitle={t("hero.scriptTitle")}
|
||||
description={t("hero.description")}
|
||||
actions={[
|
||||
{
|
||||
label: "Pakete entdecken",
|
||||
buttonLabel: "Pakete entdecken",
|
||||
label: t("hero.primary.label"),
|
||||
onClick: () => navigate(ADMIN_WELCOME_PACKAGES_PATH),
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
label: "Events anzeigen",
|
||||
buttonLabel: "Bestehende Events anzeigen",
|
||||
label: t("hero.secondary.label"),
|
||||
onClick: () => navigate(ADMIN_EVENTS_PATH),
|
||||
icon: CalendarDays,
|
||||
variant: "outline",
|
||||
@@ -64,24 +65,21 @@ export default function WelcomeLandingPage() {
|
||||
{
|
||||
id: "gallery",
|
||||
icon: Camera,
|
||||
title: "Premium Gästegalerie",
|
||||
description:
|
||||
"Kuratiere Fotos in Echtzeit, markiere Highlights und teile QR-Codes mit einem Tap.",
|
||||
badge: "Neu",
|
||||
title: t("highlights.gallery.title"),
|
||||
description: t("highlights.gallery.description"),
|
||||
badge: t("highlights.gallery.badge"),
|
||||
},
|
||||
{
|
||||
id: "team",
|
||||
icon: Users,
|
||||
title: "Flexibles Team-Onboarding",
|
||||
description:
|
||||
"Lade Co-Hosts ein, weise Rollen zu und behalte den Überblick über Moderation und Aufgaben.",
|
||||
title: t("highlights.team.title"),
|
||||
description: t("highlights.team.description"),
|
||||
},
|
||||
{
|
||||
id: "sparkles",
|
||||
id: "story",
|
||||
icon: Sparkles,
|
||||
title: "Storytelling in Etappen",
|
||||
description:
|
||||
"Geführte Aufgaben und Emotionskarten machen jedes Event zu einer erinnerungswürdigen Reise.",
|
||||
title: t("highlights.story.title"),
|
||||
description: t("highlights.story.description"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -90,21 +88,19 @@ export default function WelcomeLandingPage() {
|
||||
actions={[
|
||||
{
|
||||
id: "choose-package",
|
||||
label: "Dein Eventpaket auswählen",
|
||||
description:
|
||||
"Reserviere Credits oder Abos, um sofort Events zu aktivieren. Flexible Optionen für jede Eventgröße.",
|
||||
label: t("ctaList.choosePackage.label"),
|
||||
description: t("ctaList.choosePackage.description"),
|
||||
onClick: () => navigate(ADMIN_WELCOME_PACKAGES_PATH),
|
||||
icon: Sparkles,
|
||||
buttonLabel: "Weiter zu Paketen",
|
||||
buttonLabel: t("ctaList.choosePackage.button"),
|
||||
},
|
||||
{
|
||||
id: "create-event",
|
||||
label: "Event vorbereiten",
|
||||
description:
|
||||
"Sammle Eventdetails, plane Aufgaben und sorge für einen reibungslosen Ablauf noch vor dem Tag des Events.",
|
||||
label: t("ctaList.createEvent.label"),
|
||||
description: t("ctaList.createEvent.description"),
|
||||
onClick: () => navigate(ADMIN_EVENTS_PATH),
|
||||
icon: CalendarDays,
|
||||
buttonLabel: "Zum Event-Manager",
|
||||
buttonLabel: t("ctaList.createEvent.button"),
|
||||
variant: "secondary",
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -1,39 +1,93 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Receipt, ShieldCheck, ArrowRight, ArrowLeft, CreditCard, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import React from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Receipt,
|
||||
ShieldCheck,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
CreditCard,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";
|
||||
|
||||
import {
|
||||
TenantWelcomeLayout,
|
||||
WelcomeStepCard,
|
||||
OnboardingCTAList,
|
||||
useOnboardingProgress,
|
||||
} from '..';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ADMIN_BILLING_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PATH } from '../../constants';
|
||||
import { useTenantPackages } from '../hooks/useTenantPackages';
|
||||
} from "..";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ADMIN_BILLING_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PATH } from "../../constants";
|
||||
import { useTenantPackages } from "../hooks/useTenantPackages";
|
||||
import {
|
||||
assignFreeTenantPackage,
|
||||
completeTenantPackagePurchase,
|
||||
createTenantPackagePaymentIntent,
|
||||
createTenantPayPalOrder,
|
||||
captureTenantPayPalOrder,
|
||||
} from '../../api';
|
||||
import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { PayPalScriptProvider, PayPalButtons } from '@paypal/react-paypal-js';
|
||||
} from "../../api";
|
||||
|
||||
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? '';
|
||||
const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID ?? '';
|
||||
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID ?? "";
|
||||
const stripePromise = stripePublishableKey ? loadStripe(stripePublishableKey) : null;
|
||||
|
||||
type StripeCheckoutProps = {
|
||||
clientSecret: string;
|
||||
packageId: number;
|
||||
onSuccess: () => void;
|
||||
t: ReturnType<typeof useTranslation>["t"];
|
||||
};
|
||||
|
||||
function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeCheckoutProps) {
|
||||
type PayPalCheckoutProps = {
|
||||
packageId: number;
|
||||
onSuccess: () => void;
|
||||
t: ReturnType<typeof useTranslation>["t"];
|
||||
currency?: 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 StripeCheckoutForm({ clientSecret, packageId, onSuccess, t }: StripeCheckoutProps) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
@@ -42,7 +96,7 @@ function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeChecko
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!stripe || !elements) {
|
||||
setError('Zahlungsmodul noch nicht bereit. Bitte lade die Seite neu.');
|
||||
setError(t("summary.stripe.notReady"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,25 +108,25 @@ function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeChecko
|
||||
confirmParams: {
|
||||
return_url: window.location.href,
|
||||
},
|
||||
redirect: 'if_required',
|
||||
redirect: "if_required",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'Zahlung fehlgeschlagen. Bitte erneut versuchen.');
|
||||
setError(result.error.message ?? t("summary.stripe.genericError"));
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentIntent = result.paymentIntent;
|
||||
const paymentMethodId =
|
||||
typeof paymentIntent?.payment_method === 'string'
|
||||
typeof paymentIntent?.payment_method === "string"
|
||||
? paymentIntent.payment_method
|
||||
: typeof paymentIntent?.id === 'string'
|
||||
? paymentIntent.id
|
||||
: null;
|
||||
: typeof paymentIntent?.id === "string"
|
||||
? paymentIntent.id
|
||||
: null;
|
||||
|
||||
if (!paymentMethodId) {
|
||||
setError('Zahlung konnte nicht bestätigt werden (fehlende Zahlungs-ID).');
|
||||
setError(t("summary.stripe.missingPaymentId"));
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
@@ -84,11 +138,11 @@ function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeChecko
|
||||
});
|
||||
onSuccess();
|
||||
} catch (purchaseError) {
|
||||
console.error('[Onboarding] Purchase completion failed', purchaseError);
|
||||
console.error("[Onboarding] Purchase completion failed", purchaseError);
|
||||
setError(
|
||||
purchaseError instanceof Error
|
||||
? purchaseError.message
|
||||
: 'Der Kauf wurde bei uns noch nicht verbucht. Bitte kontaktiere den Support mit deiner Zahlungsbestätigung.'
|
||||
: t("summary.stripe.completionFailed")
|
||||
);
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -97,12 +151,12 @@ function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeChecko
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 shadow-brand-primary">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-brand-slate">Kartenzahlung</p>
|
||||
<p className="text-sm font-medium text-brand-slate">{t("summary.stripe.heading")}</p>
|
||||
<PaymentElement id="payment-element" />
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Zahlung fehlgeschlagen</AlertTitle>
|
||||
<AlertTitle>{t("summary.stripe.errorTitle")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -114,110 +168,94 @@ function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeChecko
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Zahlung wird bestätigt ...
|
||||
{t("summary.stripe.submitting")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CreditCard className="mr-2 size-4" />
|
||||
Jetzt bezahlen
|
||||
{t("summary.stripe.submit")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-xs text-brand-navy/70">
|
||||
Sicherer Checkout über Stripe. Du erhältst eine Bestätigung, sobald der Kauf verbucht wurde.
|
||||
</p>
|
||||
<input type="hidden" value={clientSecret} />
|
||||
<p className="text-xs text-brand-navy/70">{t("summary.stripe.hint")}</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
type PayPalCheckoutProps = {
|
||||
packageId: number;
|
||||
onSuccess: () => void;
|
||||
currency?: string;
|
||||
};
|
||||
|
||||
function PayPalCheckout({ packageId, onSuccess, currency = 'EUR' }: PayPalCheckoutProps) {
|
||||
const [status, setStatus] = React.useState<'idle' | 'creating' | 'capturing' | 'error' | 'success'>('idle');
|
||||
function PayPalCheckout({ packageId, onSuccess, t, currency = "EUR" }: PayPalCheckoutProps) {
|
||||
const [status, setStatus] = React.useState<"idle" | "creating" | "capturing" | "error" | "success">("idle");
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const handleCreateOrder = React.useCallback(async () => {
|
||||
try {
|
||||
setStatus('creating');
|
||||
setStatus("creating");
|
||||
const orderId = await createTenantPayPalOrder(packageId);
|
||||
setStatus('idle');
|
||||
setStatus("idle");
|
||||
setError(null);
|
||||
return orderId;
|
||||
} catch (err) {
|
||||
console.error('[Onboarding] PayPal create order failed', err);
|
||||
setStatus('error');
|
||||
console.error("[Onboarding] PayPal create order failed", err);
|
||||
setStatus("error");
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'PayPal-Bestellung konnte nicht erstellt werden. Bitte versuche es erneut.'
|
||||
err instanceof Error ? err.message : t("summary.paypal.createFailed")
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}, [packageId]);
|
||||
}, [packageId, t]);
|
||||
|
||||
const handleApprove = React.useCallback(
|
||||
async (orderId: string) => {
|
||||
try {
|
||||
setStatus('capturing');
|
||||
setStatus("capturing");
|
||||
await captureTenantPayPalOrder(orderId);
|
||||
setStatus('success');
|
||||
setStatus("success");
|
||||
setError(null);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
console.error('[Onboarding] PayPal capture failed', err);
|
||||
setStatus('error');
|
||||
console.error("[Onboarding] PayPal capture failed", err);
|
||||
setStatus("error");
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'PayPal-Zahlung konnte nicht abgeschlossen werden. Bitte kontaktiere den Support, falls der Betrag bereits abgebucht wurde.'
|
||||
err instanceof Error ? err.message : t("summary.paypal.captureFailed")
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[onSuccess]
|
||||
[onSuccess, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-2xl border border-brand-rose-soft bg-white/85 p-6 shadow-inner">
|
||||
<p className="text-sm font-medium text-brand-slate">PayPal</p>
|
||||
<p className="text-sm font-medium text-brand-slate">{t("summary.paypal.heading")}</p>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>PayPal-Fehler</AlertTitle>
|
||||
<AlertTitle>{t("summary.paypal.errorTitle")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<PayPalButtons
|
||||
style={{ layout: 'vertical' }}
|
||||
style={{ layout: "vertical" }}
|
||||
forceReRender={[packageId, currency]}
|
||||
createOrder={async () => handleCreateOrder()}
|
||||
onApprove={async (data) => {
|
||||
if (!data.orderID) {
|
||||
setError('PayPal hat keine Order-ID geliefert.');
|
||||
setStatus('error');
|
||||
setError(t("summary.paypal.missingOrderId"));
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
await handleApprove(data.orderID);
|
||||
}}
|
||||
onError={(err) => {
|
||||
console.error('[Onboarding] PayPal onError', err);
|
||||
setStatus('error');
|
||||
setError('PayPal hat ein Problem gemeldet. Bitte versuche es in wenigen Minuten erneut.');
|
||||
console.error("[Onboarding] PayPal onError", err);
|
||||
setStatus("error");
|
||||
setError(t("summary.paypal.genericError"));
|
||||
}}
|
||||
onCancel={() => {
|
||||
setStatus('idle');
|
||||
setError('PayPal-Zahlung wurde abgebrochen.');
|
||||
setStatus("idle");
|
||||
setError(t("summary.paypal.cancelled"));
|
||||
}}
|
||||
disabled={status === 'creating' || status === 'capturing'}
|
||||
disabled={status === "creating" || status === "capturing"}
|
||||
/>
|
||||
<p className="text-xs text-brand-navy/70">
|
||||
PayPal leitet dich ggf. kurz weiter, um die Zahlung zu bestätigen. Anschließend wirst du automatisch zum Setup
|
||||
zurückgebracht.
|
||||
</p>
|
||||
<p className="text-xs text-brand-navy/70">{t("summary.paypal.hint")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -227,27 +265,30 @@ export default function WelcomeOrderSummaryPage() {
|
||||
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 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') {
|
||||
if (!selectedPackageId && packagesState.status !== "loading") {
|
||||
navigate(ADMIN_WELCOME_PACKAGES_PATH, { replace: true });
|
||||
}
|
||||
}, [selectedPackageId, packagesState.status, navigate]);
|
||||
|
||||
React.useEffect(() => {
|
||||
markStep({ lastStep: 'summary' });
|
||||
markStep({ lastStep: "summary" });
|
||||
}, [markStep]);
|
||||
|
||||
const packageDetails =
|
||||
packagesState.status === 'success' && selectedPackageId
|
||||
packagesState.status === "success" && selectedPackageId
|
||||
? packagesState.catalog.find((pkg) => pkg.id === selectedPackageId)
|
||||
: null;
|
||||
|
||||
const activePackage =
|
||||
packagesState.status === 'success'
|
||||
packagesState.status === "success"
|
||||
? packagesState.purchasedPackages.find((pkg) => pkg.package_id === selectedPackageId)
|
||||
: null;
|
||||
|
||||
@@ -255,65 +296,86 @@ export default function WelcomeOrderSummaryPage() {
|
||||
const requiresPayment = Boolean(packageDetails && packageDetails.price > 0);
|
||||
|
||||
const [clientSecret, setClientSecret] = React.useState<string | null>(null);
|
||||
const [intentStatus, setIntentStatus] = React.useState<'idle' | 'loading' | 'error' | 'ready'>('idle');
|
||||
const [intentStatus, setIntentStatus] = React.useState<"idle" | "loading" | "error" | "ready">("idle");
|
||||
const [intentError, setIntentError] = React.useState<string | null>(null);
|
||||
const [freeAssignStatus, setFreeAssignStatus] = React.useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [freeAssignStatus, setFreeAssignStatus] = React.useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
const [freeAssignError, setFreeAssignError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!requiresPayment || !packageDetails) {
|
||||
setClientSecret(null);
|
||||
setIntentStatus('idle');
|
||||
setIntentStatus("idle");
|
||||
setIntentError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stripePromise) {
|
||||
setIntentError('Stripe Publishable Key fehlt. Bitte konfiguriere VITE_STRIPE_PUBLISHABLE_KEY.');
|
||||
setIntentStatus('error');
|
||||
setIntentError(t("summary.stripe.missingKey"));
|
||||
setIntentStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIntentStatus('loading');
|
||||
setIntentStatus("loading");
|
||||
setIntentError(null);
|
||||
|
||||
createTenantPackagePaymentIntent(packageDetails.id)
|
||||
.then((secret) => {
|
||||
if (cancelled) return;
|
||||
setClientSecret(secret);
|
||||
setIntentStatus('ready');
|
||||
setIntentStatus("ready");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[Onboarding] Failed to create payment intent', error);
|
||||
console.error("[Onboarding] Payment intent failed", error);
|
||||
if (cancelled) return;
|
||||
setIntentError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'PaymentIntent konnte nicht erstellt werden. Bitte später erneut versuchen.'
|
||||
);
|
||||
setIntentStatus('error');
|
||||
setIntentStatus("error");
|
||||
setIntentError(error instanceof Error ? error.message : t("summary.stripe.intentFailed"));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [requiresPayment, packageDetails?.id]);
|
||||
}, [requiresPayment, packageDetails, t]);
|
||||
|
||||
const priceText =
|
||||
progress.selectedPackage?.priceText ??
|
||||
(packageDetails && typeof packageDetails.price === 'number'
|
||||
? new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(packageDetails.price)
|
||||
(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="Schritt 3"
|
||||
title="Bestellübersicht"
|
||||
subtitle="Prüfe Paket, Preis und Abrechnung bevor du zum Event-Setup wechselst."
|
||||
eyebrow={t("summary.layout.eyebrow")}
|
||||
title={t("summary.layout.title")}
|
||||
subtitle={t("summary.layout.subtitle")}
|
||||
footer={
|
||||
<button
|
||||
type="button"
|
||||
@@ -321,50 +383,48 @@ export default function WelcomeOrderSummaryPage() {
|
||||
onClick={() => navigate(ADMIN_WELCOME_PACKAGES_PATH)}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Zurück zur Paketauswahl
|
||||
{t("summary.footer.back")}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<WelcomeStepCard
|
||||
step={3}
|
||||
totalSteps={4}
|
||||
title="Deine Auswahl im Überblick"
|
||||
description="Du kannst sofort an die Abrechnung übergeben oder das Setup fortsetzen und später bezahlen."
|
||||
title={t("summary.step.title")}
|
||||
description={t("summary.step.description")}
|
||||
icon={Receipt}
|
||||
>
|
||||
{packagesState.status === 'loading' && (
|
||||
{packagesState.status === "loading" && (
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-slate-50 p-6 text-sm text-brand-navy/80">
|
||||
<CreditCard className="size-5 text-brand-rose" />
|
||||
Wir prüfen verfügbare Pakete …
|
||||
{t("summary.state.loading")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{packagesState.status === 'error' && (
|
||||
{packagesState.status === "error" && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertTitle>Paketdaten derzeit nicht verfügbar</AlertTitle>
|
||||
<AlertTitle>{t("summary.state.errorTitle")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{packagesState.message} Du kannst das Event trotzdem vorbereiten und später zurückkehren.
|
||||
{packagesState.message ?? t("summary.state.errorDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{packagesState.status === 'success' && !packageDetails && (
|
||||
{packagesState.status === "success" && !packageDetails && (
|
||||
<Alert>
|
||||
<AlertTitle>Keine Paketauswahl gefunden</AlertTitle>
|
||||
<AlertDescription>
|
||||
Bitte wähle zuerst ein Paket aus oder aktualisiere die Seite, falls sich die Daten geändert haben.
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("summary.state.missingTitle")}</AlertTitle>
|
||||
<AlertDescription>{t("summary.state.missingDescription")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{packagesState.status === 'success' && packageDetails && (
|
||||
{packagesState.status === "success" && packageDetails && (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-3xl border border-brand-rose-soft bg-brand-card p-6 shadow-md shadow-rose-100/40">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-brand-rose">
|
||||
{isSubscription ? 'Abo' : 'Credit-Paket'}
|
||||
{t(isSubscription ? "summary.details.subscription" : "summary.details.creditPack")}
|
||||
</p>
|
||||
<h3 className="text-2xl font-semibold text-brand-slate">{packageDetails.name}</h3>
|
||||
</div>
|
||||
@@ -376,59 +436,59 @@ export default function WelcomeOrderSummaryPage() {
|
||||
</div>
|
||||
<dl className="mt-6 grid gap-3 text-sm text-brand-navy/80 md:grid-cols-2">
|
||||
<div>
|
||||
<dt className="font-semibold text-brand-slate">Fotos & Galerie</dt>
|
||||
<dt className="font-semibold text-brand-slate">{t("summary.details.section.photosTitle")}</dt>
|
||||
<dd>
|
||||
{packageDetails.max_photos
|
||||
? `Bis zu ${packageDetails.max_photos} Fotos, Galerie ${packageDetails.gallery_days ?? '∞'} Tage`
|
||||
: 'Unbegrenzte Fotos, flexible Galerie'}
|
||||
? 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>
|
||||
<dt className="font-semibold text-brand-slate">Gäste & Team</dt>
|
||||
<dt className="font-semibold text-brand-slate">{t("summary.details.section.guestsTitle")}</dt>
|
||||
<dd>
|
||||
{packageDetails.max_guests
|
||||
? `${packageDetails.max_guests} Gäste inklusive, Co-Hosts frei planbar`
|
||||
: 'Unbegrenzte Gästeliste'}
|
||||
? t("summary.details.section.guestsValue", { count: packageDetails.max_guests })
|
||||
: t("summary.details.section.guestsUnlimited")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold text-brand-slate">Highlights</dt>
|
||||
<dd>
|
||||
{Object.entries(packageDetails.features ?? {})
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([feature]) => feature.replace(/_/g, ' '))
|
||||
.join(', ') || 'Standard'}
|
||||
</dd>
|
||||
<dt className="font-semibold text-brand-slate">{t("summary.details.section.featuresTitle")}</dt>
|
||||
<dd>{featuresList.length ? featuresList.join(", ") : t("summary.details.section.featuresNone")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold text-brand-slate">Status</dt>
|
||||
<dd>{activePackage ? 'Bereits gebucht' : 'Noch nicht gebucht'}</dd>
|
||||
<dt className="font-semibold text-brand-slate">{t("summary.details.section.statusTitle")}</dt>
|
||||
<dd>{activePackage ? t("summary.details.section.statusActive") : t("summary.details.section.statusInactive")}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{detailBadges.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs uppercase tracking-wide text-brand-rose">
|
||||
{detailBadges.map((badge) => (
|
||||
<span key={badge} className="rounded-full bg-brand-rose-soft px-3 py-1">
|
||||
{badge}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!activePackage && (
|
||||
<Alert className="rounded-2xl border-brand-rose-soft bg-brand-rose-soft/60 text-rose-700">
|
||||
<ShieldCheck className="size-4" />
|
||||
<AlertTitle>Abrechnung steht noch aus</AlertTitle>
|
||||
<AlertDescription>
|
||||
Du kannst das Event bereits vorbereiten. Spätestens zur Veröffentlichung benötigst du ein aktives Paket.
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("summary.status.pendingTitle")}</AlertTitle>
|
||||
<AlertDescription>{t("summary.status.pendingDescription")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{packageDetails.price === 0 && (
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/40 p-6">
|
||||
<p className="text-sm text-emerald-700">
|
||||
Dieses Paket ist kostenlos. Du kannst es sofort deinem Tenant zuweisen und direkt mit dem Setup
|
||||
weitermachen.
|
||||
</p>
|
||||
{freeAssignStatus === 'success' ? (
|
||||
<p className="text-sm text-emerald-700">{t("summary.free.description")}</p>
|
||||
{freeAssignStatus === "success" ? (
|
||||
<Alert className="mt-4 border-emerald-200 bg-white text-emerald-600">
|
||||
<AlertTitle>Gratis-Paket aktiviert</AlertTitle>
|
||||
<AlertDescription>
|
||||
Deine Credits wurden hinzugefügt. Weiter geht's mit dem Event-Setup.
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("summary.free.successTitle")}</AlertTitle>
|
||||
<AlertDescription>{t("summary.free.successDescription")}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Button
|
||||
@@ -436,37 +496,37 @@ export default function WelcomeOrderSummaryPage() {
|
||||
if (!packageDetails) {
|
||||
return;
|
||||
}
|
||||
setFreeAssignStatus('loading');
|
||||
setFreeAssignStatus("loading");
|
||||
setFreeAssignError(null);
|
||||
try {
|
||||
await assignFreeTenantPackage(packageDetails.id);
|
||||
setFreeAssignStatus('success');
|
||||
setFreeAssignStatus("success");
|
||||
markStep({ packageSelected: true });
|
||||
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
|
||||
} catch (error) {
|
||||
console.error('[Onboarding] Free package assignment failed', error);
|
||||
setFreeAssignStatus('error');
|
||||
console.error("[Onboarding] Free package assignment failed", error);
|
||||
setFreeAssignStatus("error");
|
||||
setFreeAssignError(
|
||||
error instanceof Error ? error.message : 'Kostenloses Paket konnte nicht aktiviert werden.'
|
||||
error instanceof Error ? error.message : t("summary.free.errorMessage")
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={freeAssignStatus === 'loading'}
|
||||
disabled={freeAssignStatus === "loading"}
|
||||
className="mt-4 rounded-full bg-brand-teal text-white shadow-md shadow-emerald-300/40 hover:opacity-90"
|
||||
>
|
||||
{freeAssignStatus === 'loading' ? (
|
||||
{freeAssignStatus === "loading" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Aktivierung läuft ...
|
||||
{t("summary.free.progress")}
|
||||
</>
|
||||
) : (
|
||||
'Gratis-Paket aktivieren'
|
||||
t("summary.free.activate")
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{freeAssignStatus === 'error' && freeAssignError && (
|
||||
{freeAssignStatus === "error" && freeAssignError && (
|
||||
<Alert variant="destructive" className="mt-3">
|
||||
<AlertTitle>Aktivierung fehlgeschlagen</AlertTitle>
|
||||
<AlertTitle>{t("summary.free.failureTitle")}</AlertTitle>
|
||||
<AlertDescription>{freeAssignError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -476,20 +536,20 @@ export default function WelcomeOrderSummaryPage() {
|
||||
{requiresPayment && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-semibold text-brand-slate">Kartenzahlung (Stripe)</h4>
|
||||
{intentStatus === 'loading' && (
|
||||
<h4 className="text-lg font-semibold text-brand-slate">{t("summary.stripe.sectionTitle")}</h4>
|
||||
{intentStatus === "loading" && (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 text-sm text-brand-navy/80">
|
||||
<Loader2 className="size-4 animate-spin text-brand-rose" />
|
||||
Zahlungsdetails werden geladen …
|
||||
{t("summary.stripe.loading")}
|
||||
</div>
|
||||
)}
|
||||
{intentStatus === 'error' && (
|
||||
{intentStatus === "error" && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Stripe nicht verfügbar</AlertTitle>
|
||||
<AlertDescription>{intentError}</AlertDescription>
|
||||
<AlertTitle>{t("summary.stripe.unavailableTitle")}</AlertTitle>
|
||||
<AlertDescription>{intentError ?? t("summary.stripe.unavailableDescription")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{intentStatus === 'ready' && clientSecret && stripePromise && (
|
||||
{intentStatus === "ready" && clientSecret && stripePromise && (
|
||||
<Elements stripe={stripePromise} options={{ clientSecret }}>
|
||||
<StripeCheckoutForm
|
||||
clientSecret={clientSecret}
|
||||
@@ -498,6 +558,7 @@ export default function WelcomeOrderSummaryPage() {
|
||||
markStep({ packageSelected: true });
|
||||
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
</Elements>
|
||||
)}
|
||||
@@ -505,9 +566,14 @@ export default function WelcomeOrderSummaryPage() {
|
||||
|
||||
{paypalClientId ? (
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-semibold text-brand-slate">PayPal</h4>
|
||||
<h4 className="text-lg font-semibold text-brand-slate">{t("summary.paypal.sectionTitle")}</h4>
|
||||
<PayPalScriptProvider
|
||||
options={{ 'client-id': paypalClientId, currency: 'EUR', intent: 'CAPTURE' }}
|
||||
options={{
|
||||
clientId: paypalClientId,
|
||||
"client-id": paypalClientId,
|
||||
currency: "EUR",
|
||||
intent: "CAPTURE",
|
||||
}}
|
||||
>
|
||||
<PayPalCheckout
|
||||
packageId={packageDetails.id}
|
||||
@@ -515,26 +581,25 @@ export default function WelcomeOrderSummaryPage() {
|
||||
markStep({ packageSelected: true });
|
||||
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
</PayPalScriptProvider>
|
||||
</div>
|
||||
) : (
|
||||
<Alert variant="default" className="border-amber-200 bg-amber-50 text-amber-700">
|
||||
<AlertTitle>PayPal nicht konfiguriert</AlertTitle>
|
||||
<AlertDescription>
|
||||
Hinterlege `VITE_PAYPAL_CLIENT_ID`, damit Gastgeber optional mit PayPal bezahlen können.
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("summary.paypal.notConfiguredTitle")}</AlertTitle>
|
||||
<AlertDescription>{t("summary.paypal.notConfiguredDescription")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-2xl bg-brand-card p-6 shadow-inner shadow-rose-100/40">
|
||||
<h4 className="text-lg font-semibold text-brand-slate">Nächste Schritte</h4>
|
||||
<h4 className="text-lg font-semibold text-brand-slate">{t("summary.nextStepsTitle")}</h4>
|
||||
<ol className="list-decimal space-y-2 pl-5 text-sm text-brand-navy/80">
|
||||
<li>Optional: Abrechnung abschließen (Stripe/PayPal) im Billing-Bereich.</li>
|
||||
<li>Event-Setup durchlaufen und Aufgaben, Team & Galerie konfigurieren.</li>
|
||||
<li>Vor dem Go-Live Credits prüfen und Gäste-Link teilen.</li>
|
||||
{nextSteps.map((step, index) => (
|
||||
<li key={`${step}-${index}`}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
@@ -544,21 +609,21 @@ export default function WelcomeOrderSummaryPage() {
|
||||
<OnboardingCTAList
|
||||
actions={[
|
||||
{
|
||||
id: 'checkout',
|
||||
label: 'Abrechnung starten',
|
||||
description: 'Öffnet den Billing-Bereich mit allen Kaufoptionen (Stripe, PayPal, Credits).',
|
||||
buttonLabel: 'Zu Billing & Zahlung',
|
||||
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',
|
||||
variant: "secondary",
|
||||
},
|
||||
{
|
||||
id: 'continue-to-setup',
|
||||
label: 'Mit Event-Setup fortfahren',
|
||||
description: 'Du kannst später jederzeit zur Abrechnung zurückkehren.',
|
||||
buttonLabel: 'Weiter zum Setup',
|
||||
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' });
|
||||
markStep({ lastStep: "event-setup" });
|
||||
navigate(ADMIN_WELCOME_EVENT_PATH);
|
||||
},
|
||||
icon: ArrowRight,
|
||||
@@ -568,12 +633,3 @@ export default function WelcomeOrderSummaryPage() {
|
||||
</TenantWelcomeLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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