- Tenant-Admin-PWA: Neues /event-admin/welcome Onboarding mit WelcomeHero, Packages-, Order-Summary- und Event-Setup-Pages, Zustandsspeicher, Routing-Guard und Dashboard-CTA für Erstnutzer; Filament-/admin-Login via Custom-View behoben.

- Brand/Theming: Marketing-Farb- und Typographievariablen in `resources/css/app.css` eingeführt, AdminLayout, Dashboardkarten und Onboarding-Komponenten entsprechend angepasst; Dokumentation (`docs/todo/tenant-admin-onboarding-fusion.md`, `docs/changes/...`) aktualisiert.
- Checkout & Payments: Checkout-, PayPal-Controller und Tests für integrierte Stripe/PayPal-Flows sowie Paket-Billing-Abläufe überarbeitet; neue PayPal SDK-Factory und Admin-API-Helper (`resources/js/admin/api.ts`) schaffen Grundlage für Billing/Members/Tasks-Seiten.
- DX & Tests: Neue Playwright/E2E-Struktur (docs/testing/e2e.md, `tests/e2e/tenant-onboarding-flow.test.ts`, Utilities), E2E-Tenant-Seeder und zusätzliche Übersetzungen/Factories zur Unterstützung der neuen Flows.
- Marketing-Kommunikation: Automatische Kontakt-Bestätigungsmail (`ContactConfirmation` + Blade-Template) implementiert; Guest-PWA unter `/event` erreichbar.
- Nebensitzung: Blogsystem gefixt und umfassenden BlogPostSeeder für Beispielinhalte angelegt.
This commit is contained in:
Codex Agent
2025-10-10 21:31:55 +02:00
parent 52197f216d
commit d04e234ca0
84 changed files with 8397 additions and 1005 deletions

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { LucideIcon } from 'lucide-react';
export interface OnboardingAction {
id: string;
label: string;
description?: string;
href?: string;
onClick?: () => void;
icon?: LucideIcon;
variant?: 'primary' | 'secondary';
disabled?: boolean;
buttonLabel?: string;
}
interface OnboardingCTAListProps {
actions: OnboardingAction[];
className?: string;
}
export function OnboardingCTAList({ actions, className }: OnboardingCTAListProps) {
if (!actions.length) {
return null;
}
return (
<div className={cn('grid gap-4 md:grid-cols-2', className)}>
{actions.map(({ id, label, description, href, onClick, icon: Icon, variant = 'primary', disabled, buttonLabel }) => (
<div
key={id}
className="flex flex-col gap-3 rounded-2xl border border-brand-rose-soft bg-brand-card p-5 shadow-brand-primary backdrop-blur"
>
<div className="flex items-center gap-3">
{Icon && (
<span className="flex size-10 items-center justify-center rounded-full bg-brand-rose-soft text-brand-rose shadow-inner">
<Icon className="size-5" />
</span>
)}
<span className="text-base font-semibold text-brand-slate">{label}</span>
</div>
{description && (
<p className="text-sm text-brand-navy/80">{description}</p>
)}
<div>
<Button
variant="default"
size="lg"
className={cn(
'w-full rounded-full transition-all',
variant === 'secondary'
? 'bg-brand-gold text-brand-slate shadow-md shadow-amber-200/40 hover:bg-[var(--brand-gold-soft)]'
: 'bg-brand-rose text-white shadow-md shadow-rose-400/30 hover:bg-[var(--brand-rose-strong)]'
)}
disabled={disabled}
onClick={onClick}
{...(href ? { asChild: true } : {})}
>
{href ? <a href={href}>{buttonLabel ?? label}</a> : buttonLabel ?? label}
</Button>
</div>
</div>
))}
</div>
);
}
OnboardingCTAList.displayName = 'OnboardingCTAList';

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface HighlightItem {
id: string;
icon: LucideIcon;
title: string;
description: string;
badge?: string;
}
interface OnboardingHighlightsGridProps {
items: HighlightItem[];
className?: string;
}
export function OnboardingHighlightsGrid({ items, className }: OnboardingHighlightsGridProps) {
if (!items.length) {
return null;
}
return (
<div className={cn('grid gap-4 md:grid-cols-3', className)}>
{items.map(({ id, icon: Icon, title, description, badge }) => (
<Card
key={id}
className="relative overflow-hidden rounded-3xl border border-white/70 bg-white/90 shadow-xl shadow-rose-100/40"
>
<CardHeader className="space-y-3">
<div className="flex items-center justify-between">
<span className="flex size-12 items-center justify-center rounded-full bg-rose-100 text-rose-500 shadow-inner">
<Icon className="size-6" />
</span>
{badge && (
<span className="rounded-full bg-rose-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-rose-500">
{badge}
</span>
)}
</div>
<CardTitle className="text-lg font-semibold text-slate-900">{title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-slate-600">{description}</p>
</CardContent>
</Card>
))}
</div>
);
}
OnboardingHighlightsGrid.displayName = 'OnboardingHighlightsGrid';

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { cn } from '@/lib/utils';
export interface TenantWelcomeLayoutProps {
eyebrow?: string;
title?: string;
subtitle?: string;
headerAction?: React.ReactNode;
footer?: React.ReactNode;
children: React.ReactNode;
}
export function TenantWelcomeLayout({
eyebrow,
title,
subtitle,
headerAction,
footer,
children,
}: TenantWelcomeLayoutProps) {
React.useEffect(() => {
document.body.classList.add('tenant-admin-theme', 'tenant-admin-welcome-theme');
return () => {
document.body.classList.remove('tenant-admin-theme', 'tenant-admin-welcome-theme');
};
}, []);
return (
<div className="min-h-screen w-full bg-brand-gradient text-brand-slate transition-colors duration-500 ease-out">
<div className="mx-auto flex min-h-screen w-full max-w-5xl px-6 py-12 md:py-16 lg:px-10">
<div className="flex w-full flex-col gap-10 rounded-[40px] border border-brand-rose-soft bg-brand-card p-8 shadow-brand-primary backdrop-blur-xl md:gap-14 md:p-14">
<header className="flex flex-col gap-6 md:flex-row md:items-start md:justify-between">
<div className="max-w-xl">
{eyebrow && (
<p className="text-xs uppercase tracking-[0.35em] text-brand-rose">{eyebrow}</p>
)}
{title && (
<h1 className="mt-2 font-display text-4xl font-semibold tracking-tight text-brand-slate md:text-5xl">
{title}
</h1>
)}
{subtitle && (
<p className="mt-4 text-base font-sans-marketing text-brand-navy/80 md:text-lg">
{subtitle}
</p>
)}
</div>
{headerAction && <div className="flex shrink-0 items-center">{headerAction}</div>}
</header>
<main className="flex flex-1 flex-col gap-8">
{children}
</main>
{footer && (
<footer className="flex flex-col items-center gap-4 text-sm text-brand-navy/70 md:flex-row md:justify-between">
{footer}
</footer>
)}
</div>
</div>
</div>
);
}
TenantWelcomeLayout.displayName = 'TenantWelcomeLayout';

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ActionProps {
label: string;
onClick?: () => void;
href?: string;
icon?: LucideIcon;
variant?: 'default' | 'outline';
}
export interface WelcomeHeroProps {
eyebrow?: string;
title: string;
scriptTitle?: string;
description?: string;
actions?: ActionProps[];
className?: string;
}
export function WelcomeHero({
eyebrow,
title,
scriptTitle,
description,
actions = [],
className,
}: WelcomeHeroProps) {
return (
<section
className={cn(
'rounded-3xl border border-brand-rose-soft bg-brand-card p-8 shadow-brand-primary backdrop-blur-xl',
className
)}
>
<div className="space-y-4 text-center md:space-y-6">
{eyebrow && (
<p className="text-xs uppercase tracking-[0.35em] text-brand-rose md:text-sm">
{eyebrow}
</p>
)}
<h2 className="font-display text-3xl font-semibold tracking-tight text-brand-slate md:text-4xl">
{title}
</h2>
{scriptTitle && (
<p className="font-script text-2xl text-brand-rose md:text-3xl">
{scriptTitle}
</p>
)}
{description && (
<p className="mx-auto max-w-2xl text-base font-sans-marketing text-brand-navy/80 md:text-lg">
{description}
</p>
)}
{actions.length > 0 && (
<div className="flex flex-col items-center gap-3 pt-4 md:flex-row md:justify-center">
{actions.map(({ label, onClick, href, icon: Icon, variant = 'default' }) => (
<Button
key={label}
size="lg"
variant={variant === 'outline' ? 'outline' : 'default'}
className={cn(
'min-w-[220px] rounded-full px-6',
variant === 'outline'
? 'border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40'
: 'bg-brand-rose text-white shadow-md shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]'
)}
onClick={onClick}
{...(href ? { asChild: true } : {})}
>
{href ? (
<a href={href} className="flex items-center justify-center gap-2">
{Icon && <Icon className="h-4 w-4" />}
<span>{label}</span>
</a>
) : (
<span className="flex items-center justify-center gap-2">
{Icon && <Icon className="h-4 w-4" />}
<span>{label}</span>
</span>
)}
</Button>
))}
</div>
)}
</div>
</section>
);
}
WelcomeHero.displayName = 'WelcomeHero';

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface WelcomeStepCardProps {
step: number;
totalSteps: number;
title: string;
description?: string;
icon?: LucideIcon;
children?: React.ReactNode;
className?: string;
}
export function WelcomeStepCard({
step,
totalSteps,
title,
description,
icon: Icon,
children,
className,
}: WelcomeStepCardProps) {
const progress = Math.min(Math.max(step, 1), totalSteps);
const percent = totalSteps <= 1 ? 100 : Math.round((progress / totalSteps) * 100);
return (
<Card
className={cn(
'relative overflow-hidden rounded-3xl border border-brand-rose-soft bg-brand-card shadow-brand-primary',
className
)}
>
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-brand-rose-soft via-[var(--brand-gold-soft)] to-brand-sky-soft" />
<CardHeader className="space-y-4 pt-8">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-semibold uppercase tracking-[0.4em] text-brand-rose">
Step {progress} / {totalSteps}
</span>
<div className="w-28">
<Progress value={percent} />
</div>
</div>
<div className="flex items-start gap-4">
{Icon && (
<span className="flex size-12 items-center justify-center rounded-full bg-brand-rose-soft text-brand-rose shadow-inner">
<Icon className="size-5" />
</span>
)}
<div className="space-y-2">
<CardTitle className="font-display text-2xl font-semibold text-brand-slate md:text-3xl">{title}</CardTitle>
{description && (
<CardDescription className="text-base font-sans-marketing text-brand-navy/80">{description}</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-6 pb-10">{children}</CardContent>
</Card>
);
}
WelcomeStepCard.displayName = 'WelcomeStepCard';

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { getTenantPackagesOverview, getPackages, Package, TenantPackageSummary } from '../../api';
export type TenantPackagesState =
| { status: 'loading' }
| { status: 'error'; message: string }
| {
status: 'success';
catalog: Package[];
activePackage: TenantPackageSummary | null;
purchasedPackages: TenantPackageSummary[];
};
export function useTenantPackages(): TenantPackagesState {
const [state, setState] = React.useState<TenantPackagesState>({ status: 'loading' });
React.useEffect(() => {
let cancelled = false;
(async () => {
try {
const [tenantPackages, catalog] = await Promise.all([
getTenantPackagesOverview(),
getPackages('endcustomer'),
]);
if (cancelled) return;
setState({
status: 'success',
catalog,
activePackage: tenantPackages.activePackage,
purchasedPackages: tenantPackages.packages,
});
} catch (error) {
console.error('[useTenantPackages] Failed to fetch', error);
if (cancelled) return;
setState({
status: 'error',
message: 'Pakete konnten nicht geladen werden. Bitte später erneut versuchen.',
});
}
})();
return () => {
cancelled = true;
};
}, []);
return state;
}

View File

@@ -0,0 +1,7 @@
export * from './store';
export * from './components/TenantWelcomeLayout';
export * from './components/WelcomeHero';
export * from './components/WelcomeStepCard';
export * from './components/OnboardingCTAList';
export * from './components/OnboardingHighlightsGrid';
export * from './hooks/useTenantPackages';

View File

@@ -0,0 +1,116 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
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';
export default function WelcomeEventSetupPage() {
const navigate = useNavigate();
const { markStep } = useOnboardingProgress();
React.useEffect(() => {
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."
>
<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."
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.',
icon: Sparkles,
},
{
id: 'team',
title: 'Team organisieren',
copy: 'Lade Moderator*innen oder Fotograf*innen ein und teile Rollen zu.',
icon: Globe,
},
{
id: 'launch',
title: 'Go-Live vorbereiten',
copy: 'Erstelle QR-Codes, teste die G<>stegalerie und kommuniziere den Ablauf.',
icon: ArrowRight,
},
].map((item) => (
<div
key={item.id}
className="flex flex-col gap-3 rounded-2xl border border-brand-rose-soft bg-brand-card p-5 shadow-brand-primary"
>
<span className="flex size-10 items-center justify-center rounded-full bg-brand-rose-soft text-brand-rose shadow-inner">
<item.icon className="size-5" />
</span>
<h3 className="text-lg font-semibold text-brand-slate">{item.title}</h3>
<p className="text-sm text-brand-navy/80">{item.copy}</p>
</div>
))}
</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>
<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' });
navigate(ADMIN_EVENT_CREATE_PATH);
}}
>
Event erstellen
<ArrowRight className="ml-2 size-4" />
</Button>
</div>
</WelcomeStepCard>
<OnboardingCTAList
actions={[
{
id: 'back',
label: 'Noch einmal Pakete pr<70>fen',
description: 'Vergleiche Preise oder aktualisiere dein derzeitiges Paket.',
buttonLabel: 'Zu Paketen',
onClick: () => navigate(-1),
variant: 'secondary',
},
{
id: 'dashboard',
label: 'Zum Dashboard',
description: 'Springe ins Management, um bestehende Events zu bearbeiten.',
buttonLabel: 'Dashboard <20>ffnen',
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',
onClick: () => navigate(ADMIN_EVENTS_PATH),
},
]}
/>
</TenantWelcomeLayout>
);
}

View File

@@ -0,0 +1,114 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { Sparkles, Users, Camera, CalendarDays, ChevronRight } from "lucide-react";
import {
TenantWelcomeLayout,
WelcomeHero,
OnboardingHighlightsGrid,
OnboardingCTAList,
useOnboardingProgress,
} from "..";
import { ADMIN_WELCOME_PACKAGES_PATH, ADMIN_EVENTS_PATH } from "../../constants";
export default function WelcomeLandingPage() {
const navigate = useNavigate();
const { markStep } = useOnboardingProgress();
React.useEffect(() => {
markStep({ welcomeSeen: true, lastStep: "landing" });
}, [markStep]);
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."
footer={
<>
<span className="text-brand-navy/80">Schon vertraut mit Fotospiel?</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
<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."
actions={[
{
label: "Pakete entdecken",
buttonLabel: "Pakete entdecken",
onClick: () => navigate(ADMIN_WELCOME_PACKAGES_PATH),
icon: Sparkles,
},
{
label: "Events anzeigen",
buttonLabel: "Bestehende Events anzeigen",
onClick: () => navigate(ADMIN_EVENTS_PATH),
icon: CalendarDays,
variant: "outline",
},
]}
/>
<OnboardingHighlightsGrid
items={[
{
id: "gallery",
icon: Camera,
title: "Premium Gästegalerie",
description:
"Kuratiere Fotos in Echtzeit, markiere Highlights und teile QR-Codes mit einem Tap.",
badge: "Neu",
},
{
id: "team",
icon: Users,
title: "Flexibles Team-Onboarding",
description:
"Lade Co-Hosts ein, weise Rollen zu und behalte den Überblick über Moderation und Aufgaben.",
},
{
id: "sparkles",
icon: Sparkles,
title: "Storytelling in Etappen",
description:
"Geführte Aufgaben und Emotionskarten machen jedes Event zu einer erinnerungswürdigen Reise.",
},
]}
/>
<OnboardingCTAList
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.",
onClick: () => navigate(ADMIN_WELCOME_PACKAGES_PATH),
icon: Sparkles,
buttonLabel: "Weiter zu Paketen",
},
{
id: "create-event",
label: "Event vorbereiten",
description:
"Sammle Eventdetails, plane Aufgaben und sorge für einen reibungslosen Ablauf noch vor dem Tag des Events.",
onClick: () => navigate(ADMIN_EVENTS_PATH),
icon: CalendarDays,
buttonLabel: "Zum Event-Manager",
variant: "secondary",
},
]}
/>
</TenantWelcomeLayout>
);
}

View File

@@ -0,0 +1,579 @@
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Receipt, ShieldCheck, ArrowRight, ArrowLeft, CreditCard, AlertTriangle, Loader2 } from 'lucide-react';
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';
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';
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;
};
function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeCheckoutProps) {
const stripe = useStripe();
const elements = useElements();
const [submitting, setSubmitting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!stripe || !elements) {
setError('Zahlungsmodul noch nicht bereit. Bitte lade die Seite neu.');
return;
}
setSubmitting(true);
setError(null);
const result = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: window.location.href,
},
redirect: 'if_required',
});
if (result.error) {
setError(result.error.message ?? 'Zahlung fehlgeschlagen. Bitte erneut versuchen.');
setSubmitting(false);
return;
}
const paymentIntent = result.paymentIntent;
const paymentMethodId =
typeof paymentIntent?.payment_method === 'string'
? paymentIntent.payment_method
: typeof paymentIntent?.id === 'string'
? paymentIntent.id
: null;
if (!paymentMethodId) {
setError('Zahlung konnte nicht bestätigt werden (fehlende Zahlungs-ID).');
setSubmitting(false);
return;
}
try {
await completeTenantPackagePurchase({
packageId,
paymentMethodId,
});
onSuccess();
} catch (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.'
);
setSubmitting(false);
}
};
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>
<PaymentElement id="payment-element" />
</div>
{error && (
<Alert variant="destructive">
<AlertTitle>Zahlung fehlgeschlagen</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
type="submit"
disabled={submitting || !stripe || !elements}
className="w-full rounded-full bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)] disabled:opacity-60"
>
{submitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Zahlung wird bestätigt ...
</>
) : (
<>
<CreditCard className="mr-2 size-4" />
Jetzt bezahlen
</>
)}
</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} />
</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');
const [error, setError] = React.useState<string | null>(null);
const handleCreateOrder = React.useCallback(async () => {
try {
setStatus('creating');
const orderId = await createTenantPayPalOrder(packageId);
setStatus('idle');
setError(null);
return orderId;
} catch (err) {
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.'
);
throw err;
}
}, [packageId]);
const handleApprove = React.useCallback(
async (orderId: string) => {
try {
setStatus('capturing');
await captureTenantPayPalOrder(orderId);
setStatus('success');
setError(null);
onSuccess();
} catch (err) {
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.'
);
throw err;
}
},
[onSuccess]
);
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>
{error && (
<Alert variant="destructive">
<AlertTitle>PayPal-Fehler</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<PayPalButtons
style={{ layout: 'vertical' }}
forceReRender={[packageId, currency]}
createOrder={async () => handleCreateOrder()}
onApprove={async (data) => {
if (!data.orderID) {
setError('PayPal hat keine Order-ID geliefert.');
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.');
}}
onCancel={() => {
setStatus('idle');
setError('PayPal-Zahlung wurde abgebrochen.');
}}
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>
</div>
);
}
export default function WelcomeOrderSummaryPage() {
const navigate = useNavigate();
const location = useLocation();
const { progress, markStep } = useOnboardingProgress();
const packagesState = useTenantPackages();
const packageIdFromState = typeof location.state === 'object' ? (location.state as any)?.packageId : undefined;
const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null;
React.useEffect(() => {
if (!selectedPackageId && packagesState.status !== 'loading') {
navigate(ADMIN_WELCOME_PACKAGES_PATH, { replace: true });
}
}, [selectedPackageId, packagesState.status, navigate]);
React.useEffect(() => {
markStep({ lastStep: 'summary' });
}, [markStep]);
const packageDetails =
packagesState.status === 'success' && selectedPackageId
? packagesState.catalog.find((pkg) => pkg.id === selectedPackageId)
: null;
const activePackage =
packagesState.status === 'success'
? packagesState.purchasedPackages.find((pkg) => pkg.package_id === selectedPackageId)
: null;
const isSubscription = Boolean(packageDetails?.features?.subscription);
const requiresPayment = Boolean(packageDetails && packageDetails.price > 0);
const [clientSecret, setClientSecret] = React.useState<string | null>(null);
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 [freeAssignError, setFreeAssignError] = React.useState<string | null>(null);
React.useEffect(() => {
if (!requiresPayment || !packageDetails) {
setClientSecret(null);
setIntentStatus('idle');
setIntentError(null);
return;
}
if (!stripePromise) {
setIntentError('Stripe Publishable Key fehlt. Bitte konfiguriere VITE_STRIPE_PUBLISHABLE_KEY.');
setIntentStatus('error');
return;
}
let cancelled = false;
setIntentStatus('loading');
setIntentError(null);
createTenantPackagePaymentIntent(packageDetails.id)
.then((secret) => {
if (cancelled) return;
setClientSecret(secret);
setIntentStatus('ready');
})
.catch((error) => {
console.error('[Onboarding] Failed to create payment intent', error);
if (cancelled) return;
setIntentError(
error instanceof Error
? error.message
: 'PaymentIntent konnte nicht erstellt werden. Bitte später erneut versuchen.'
);
setIntentStatus('error');
});
return () => {
cancelled = true;
};
}, [requiresPayment, packageDetails?.id]);
const priceText =
progress.selectedPackage?.priceText ??
(packageDetails && typeof packageDetails.price === 'number'
? new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
}).format(packageDetails.price)
: null);
return (
<TenantWelcomeLayout
eyebrow="Schritt 3"
title="Bestellübersicht"
subtitle="Prüfe Paket, Preis und Abrechnung bevor du zum Event-Setup wechselst."
footer={
<button
type="button"
className="inline-flex items-center gap-2 text-sm font-medium text-brand-rose hover:text-rose-600"
onClick={() => navigate(ADMIN_WELCOME_PACKAGES_PATH)}
>
<ArrowLeft className="size-4" />
Zurück zur Paketauswahl
</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."
icon={Receipt}
>
{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
</div>
)}
{packagesState.status === 'error' && (
<Alert variant="destructive">
<AlertTriangle className="size-4" />
<AlertTitle>Paketdaten derzeit nicht verfügbar</AlertTitle>
<AlertDescription>
{packagesState.message} Du kannst das Event trotzdem vorbereiten und später zurückkehren.
</AlertDescription>
</Alert>
)}
{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>
</Alert>
)}
{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'}
</p>
<h3 className="text-2xl font-semibold text-brand-slate">{packageDetails.name}</h3>
</div>
{priceText && (
<Badge className="rounded-full bg-rose-100 px-4 py-2 text-base font-semibold text-rose-600">
{priceText}
</Badge>
)}
</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>
<dd>
{packageDetails.max_photos
? `Bis zu ${packageDetails.max_photos} Fotos, Galerie ${packageDetails.gallery_days ?? '∞'} Tage`
: 'Unbegrenzte Fotos, flexible Galerie'}
</dd>
</div>
<div>
<dt className="font-semibold text-brand-slate">Gäste & Team</dt>
<dd>
{packageDetails.max_guests
? `${packageDetails.max_guests} Gäste inklusive, Co-Hosts frei planbar`
: 'Unbegrenzte Gästeliste'}
</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>
</div>
<div>
<dt className="font-semibold text-brand-slate">Status</dt>
<dd>{activePackage ? 'Bereits gebucht' : 'Noch nicht gebucht'}</dd>
</div>
</dl>
</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>
</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' ? (
<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&apos;s mit dem Event-Setup.
</AlertDescription>
</Alert>
) : (
<Button
onClick={async () => {
if (!packageDetails) {
return;
}
setFreeAssignStatus('loading');
setFreeAssignError(null);
try {
await assignFreeTenantPackage(packageDetails.id);
setFreeAssignStatus('success');
markStep({ packageSelected: true });
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
} catch (error) {
console.error('[Onboarding] Free package assignment failed', error);
setFreeAssignStatus('error');
setFreeAssignError(
error instanceof Error ? error.message : 'Kostenloses Paket konnte nicht aktiviert werden.'
);
}
}}
disabled={freeAssignStatus === 'loading'}
className="mt-4 rounded-full bg-brand-teal text-white shadow-md shadow-emerald-300/40 hover:opacity-90"
>
{freeAssignStatus === 'loading' ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Aktivierung läuft ...
</>
) : (
'Gratis-Paket aktivieren'
)}
</Button>
)}
{freeAssignStatus === 'error' && freeAssignError && (
<Alert variant="destructive" className="mt-3">
<AlertTitle>Aktivierung fehlgeschlagen</AlertTitle>
<AlertDescription>{freeAssignError}</AlertDescription>
</Alert>
)}
</div>
)}
{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' && (
<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
</div>
)}
{intentStatus === 'error' && (
<Alert variant="destructive">
<AlertTitle>Stripe nicht verfügbar</AlertTitle>
<AlertDescription>{intentError}</AlertDescription>
</Alert>
)}
{intentStatus === 'ready' && clientSecret && stripePromise && (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<StripeCheckoutForm
clientSecret={clientSecret}
packageId={packageDetails.id}
onSuccess={() => {
markStep({ packageSelected: true });
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
}}
/>
</Elements>
)}
</div>
{paypalClientId ? (
<div className="space-y-4">
<h4 className="text-lg font-semibold text-brand-slate">PayPal</h4>
<PayPalScriptProvider
options={{ 'client-id': paypalClientId, currency: 'EUR', intent: 'CAPTURE' }}
>
<PayPalCheckout
packageId={packageDetails.id}
onSuccess={() => {
markStep({ packageSelected: true });
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
}}
/>
</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>
</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>
<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>
</ol>
</div>
</div>
)}
</WelcomeStepCard>
<OnboardingCTAList
actions={[
{
id: 'checkout',
label: 'Abrechnung starten',
description: 'Öffnet den Billing-Bereich mit allen Kaufoptionen (Stripe, PayPal, Credits).',
buttonLabel: 'Zu Billing & Zahlung',
href: ADMIN_BILLING_PATH,
icon: CreditCard,
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',
onClick: () => {
markStep({ lastStep: 'event-setup' });
navigate(ADMIN_WELCOME_EVENT_PATH);
},
icon: ArrowRight,
},
]}
/>
</TenantWelcomeLayout>
);
}

View File

@@ -0,0 +1,200 @@
import React from 'react';
import { CreditCard, ShoppingBag, ArrowRight, Loader2, AlertCircle } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
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';
export default function WelcomePackagesPage() {
const navigate = useNavigate();
const { markStep } = useOnboardingProgress();
const packagesState = useTenantPackages();
React.useEffect(() => {
if (packagesState.status === 'success') {
markStep({
packageSelected: Boolean(packagesState.activePackage),
lastStep: 'packages',
selectedPackage: packagesState.activePackage
? {
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,
}
: null,
});
}
}, [packagesState, markStep]);
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';
markStep({
packageSelected: true,
lastStep: package-,
selectedPackage: {
id: pkg.id,
name: pkg.name,
priceText,
isSubscription: Boolean(pkg.features?.subscription),
},
});
navigate(ADMIN_WELCOME_SUMMARY_PATH, { state: { packageId: pkg.id } });
},
[markStep, navigate]
);
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."
>
<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."
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>
)}
</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',
href: ADMIN_BILLING_PATH,
icon: ShoppingBag,
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',
onClick: () => navigate(ADMIN_WELCOME_SUMMARY_PATH),
icon: ArrowRight,
},
]}
/>
</TenantWelcomeLayout>
);
}

View File

@@ -0,0 +1,109 @@
import React from 'react';
export type OnboardingProgress = {
welcomeSeen: boolean;
packageSelected: boolean;
eventCreated: boolean;
lastStep?: string | null;
selectedPackage?: {
id: number;
name: string;
priceText?: string | null;
isSubscription?: boolean;
} | null;
};
type OnboardingContextValue = {
progress: OnboardingProgress;
setProgress: (updater: (prev: OnboardingProgress) => OnboardingProgress) => void;
markStep: (step: Partial<OnboardingProgress>) => void;
reset: () => void;
};
const DEFAULT_PROGRESS: OnboardingProgress = {
welcomeSeen: false,
packageSelected: false,
eventCreated: false,
lastStep: null,
selectedPackage: null,
};
const STORAGE_KEY = 'tenant-admin:onboarding-progress';
const OnboardingProgressContext = React.createContext<OnboardingContextValue | undefined>(undefined);
function readStoredProgress(): OnboardingProgress {
if (typeof window === 'undefined') {
return DEFAULT_PROGRESS;
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return DEFAULT_PROGRESS;
}
const parsed = JSON.parse(raw) as Partial<OnboardingProgress>;
return {
...DEFAULT_PROGRESS,
...parsed,
};
} catch (error) {
console.warn('[OnboardingProgress] Failed to parse stored value', error);
return DEFAULT_PROGRESS;
}
}
function writeStoredProgress(progress: OnboardingProgress) {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
} catch (error) {
console.warn('[OnboardingProgress] Failed to persist value', error);
}
}
export function OnboardingProgressProvider({ children }: { children: React.ReactNode }) {
const [progress, setProgressState] = React.useState<OnboardingProgress>(() => readStoredProgress());
const setProgress = React.useCallback((updater: (prev: OnboardingProgress) => OnboardingProgress) => {
setProgressState((prev) => {
const next = updater(prev);
writeStoredProgress(next);
return next;
});
}, []);
const markStep = React.useCallback((step: Partial<OnboardingProgress>) => {
setProgress((prev) => ({
...prev,
...step,
lastStep: typeof step.lastStep === 'undefined' ? prev.lastStep : step.lastStep,
}));
}, [setProgress]);
const reset = React.useCallback(() => {
setProgress(() => DEFAULT_PROGRESS);
}, [setProgress]);
const value = React.useMemo<OnboardingContextValue>(() => ({
progress,
setProgress,
markStep,
reset,
}), [progress, setProgress, markStep, reset]);
return (
<OnboardingProgressContext.Provider value={value}>
{children}
</OnboardingProgressContext.Provider>
);
}
export function useOnboardingProgress() {
const context = React.useContext(OnboardingProgressContext);
if (!context) {
throw new Error('useOnboardingProgress must be used within OnboardingProgressProvider');
}
return context;
}