der tenant admin hat eine neue, mobil unterstützende UI, login redirect funktioniert, typescript fehler wurden bereinigt. Neue Blog Posts von ChatGPT eingebaut, übersetzt von Gemini 2.5

This commit is contained in:
Codex Agent
2025-11-05 19:27:10 +01:00
parent adb93b5f9d
commit c6ac04eb15
44 changed files with 1995 additions and 1949 deletions

View File

@@ -1,6 +1,8 @@
import React from 'react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { act, render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import type { TFunction } from 'i18next';
import { PaddleCheckout } from '../pages/WelcomeOrderSummaryPage';
const { createPaddleCheckoutMock } = vi.hoisted(() => ({
@@ -21,12 +23,13 @@ describe('PaddleCheckout', () => {
createPaddleCheckoutMock.mockResolvedValue({ checkout_url: 'https://paddle.example/checkout' });
const onSuccess = vi.fn();
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
const tMock = ((key: string) => key) as unknown as TFunction;
render(
<PaddleCheckout
packageId={99}
onSuccess={onSuccess}
t={(key: string) => key}
t={tMock}
/>
);
@@ -45,12 +48,13 @@ describe('PaddleCheckout', () => {
it('shows an error message on failure', async () => {
createPaddleCheckoutMock.mockRejectedValue(new Error('boom'));
const tMock = ((key: string) => key) as unknown as TFunction;
render(
<PaddleCheckout
packageId={99}
onSuccess={vi.fn()}
t={(key: string) => key}
t={tMock}
/>
);
@@ -59,7 +63,7 @@ describe('PaddleCheckout', () => {
});
await waitFor(() => {
expect(screen.getByText('summary.paddle.genericError')).toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveTextContent('boom');
});
});
});

View File

@@ -3,6 +3,8 @@ import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { LucideIcon } from 'lucide-react';
import { FrostedSurface } from '../../components/tenant';
export interface OnboardingAction {
id: string;
label: string;
@@ -28,30 +30,30 @@ export function OnboardingCTAList({ actions, className }: OnboardingCTAListProps
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
<FrostedSurface
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"
className="flex flex-col gap-3 border border-white/20 p-5 text-slate-900 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80"
>
<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 ? (
<span className="flex size-10 items-center justify-center rounded-full bg-rose-100/80 text-rose-500 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-200">
<Icon className="size-5" />
</span>
)}
<span className="text-base font-semibold text-brand-slate">{label}</span>
) : null}
<span className="text-base font-semibold text-slate-900 dark:text-slate-100">{label}</span>
</div>
{description && (
<p className="text-sm text-brand-navy/80">{description}</p>
)}
{description ? (
<p className="text-sm text-slate-600 dark:text-slate-400">{description}</p>
) : null}
<div>
<Button
variant="default"
size="lg"
className={cn(
'w-full rounded-full transition-all',
'w-full rounded-full transition-colors',
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)]'
? 'bg-slate-900/80 text-white shadow-md shadow-slate-900/30 hover:bg-slate-900 dark:bg-slate-800'
: 'bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-lg shadow-rose-300/30 hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]'
)}
disabled={disabled}
onClick={onClick}
@@ -60,7 +62,7 @@ export function OnboardingCTAList({ actions, className }: OnboardingCTAListProps
{href ? <a href={href}>{buttonLabel ?? label}</a> : buttonLabel ?? label}
</Button>
</div>
</div>
</FrostedSurface>
))}
</div>
);

View File

@@ -1,8 +1,10 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { FrostedSurface } from '../../components/tenant';
export interface HighlightItem {
id: string;
icon: LucideIcon;
@@ -24,27 +26,27 @@ export function OnboardingHighlightsGrid({ items, className }: OnboardingHighlig
return (
<div className={cn('grid gap-4 md:grid-cols-3', className)}>
{items.map(({ id, icon: Icon, title, description, badge }) => (
<Card
<FrostedSurface
key={id}
className="relative overflow-hidden rounded-3xl border border-white/70 bg-white/90 shadow-xl shadow-rose-100/40"
className="relative overflow-hidden rounded-3xl border border-white/20 p-6 text-slate-900 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80"
>
<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">
<span className="flex size-12 items-center justify-center rounded-full bg-rose-100/80 text-rose-500 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-200">
<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">
<span className="rounded-full bg-rose-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-rose-500 dark:bg-rose-500/20 dark:text-rose-200">
{badge}
</span>
)}
</div>
<CardTitle className="text-lg font-semibold text-slate-900">{title}</CardTitle>
<CardTitle className="text-lg font-semibold text-slate-900 dark:text-slate-100">{title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-slate-600">{description}</p>
<p className="text-sm text-slate-600 dark:text-slate-400">{description}</p>
</CardContent>
</Card>
</FrostedSurface>
))}
</div>
);

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { LanguageSwitcher } from '../../components/LanguageSwitcher';
import { FrostedSurface } from '../../components/tenant';
export interface TenantWelcomeLayoutProps {
eyebrow?: string;
@@ -27,24 +28,28 @@ export function TenantWelcomeLayout({
}, []);
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">
<div className="relative min-h-svh w-full overflow-hidden bg-slate-950 text-white">
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.28),_transparent_60%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.25),_transparent_65%)] motion-safe:animate-[aurora_20s_ease-in-out_infinite]"
/>
<div aria-hidden className="absolute inset-0 bg-gradient-to-br from-slate-950 via-slate-900/70 to-[#1d1130]" />
<div className="relative z-10 mx-auto flex min-h-svh w-full max-w-5xl flex-col gap-10 px-6 py-12 sm:px-8 md:py-16 lg:px-12">
<FrostedSurface className="flex w-full flex-1 flex-col gap-10 rounded-[36px] border border-white/15 p-8 text-slate-900 shadow-2xl shadow-rose-300/20 backdrop-blur-2xl transition-colors duration-200 dark:text-slate-100 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">
<div className="max-w-xl space-y-4">
{eyebrow ? (
<p className="text-xs uppercase tracking-[0.35em] text-rose-300 dark:text-rose-200">{eyebrow}</p>
) : null}
{title ? (
<h1 className="font-display text-3xl font-semibold tracking-tight text-slate-900 dark:text-slate-100 md:text-5xl">
{title}
</h1>
)}
{subtitle && (
<p className="mt-4 text-base font-sans-marketing text-brand-navy/80 md:text-lg">
{subtitle}
</p>
)}
) : null}
{subtitle ? (
<p className="text-base text-slate-600 dark:text-slate-300 md:text-lg">{subtitle}</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<LanguageSwitcher />
@@ -56,12 +61,12 @@ export function TenantWelcomeLayout({
{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 className="flex flex-col items-center gap-4 text-sm text-slate-600 dark:text-slate-400 md:flex-row md:justify-between">
{footer}
</footer>
)}
</div>
) : null}
</FrostedSurface>
</div>
</div>
);

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { Button } from '@/components/ui/button';
import { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { FrostedSurface } from '../../components/tenant';
interface ActionProps {
label: string;
@@ -29,28 +30,32 @@ export function WelcomeHero({
className,
}: WelcomeHeroProps) {
return (
<section
<FrostedSurface
className={cn(
'rounded-3xl border border-brand-rose-soft bg-brand-card p-8 shadow-brand-primary backdrop-blur-xl',
'relative overflow-hidden rounded-3xl border border-white/15 p-8 text-slate-900 shadow-2xl shadow-rose-300/20 backdrop-blur-2xl transition-colors duration-200 dark:text-slate-100 md:p-12',
className
)}
>
<div className="space-y-4 text-center md:space-y-6">
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-[-30%] h-[220px] bg-[radial-gradient(circle,_rgba(255,137,170,0.35),_transparent_60%)]"
/>
<div className="relative space-y-4 text-center md:space-y-6">
{eyebrow && (
<p className="text-xs uppercase tracking-[0.35em] text-brand-rose md:text-sm">
<p className="text-xs uppercase tracking-[0.35em] text-rose-300 dark:text-rose-200 md:text-sm">
{eyebrow}
</p>
)}
<h2 className="font-display text-3xl font-semibold tracking-tight text-brand-slate md:text-4xl">
<h2 className="font-display text-3xl font-semibold tracking-tight text-slate-900 dark:text-slate-100 md:text-4xl">
{title}
</h2>
{scriptTitle && (
<p className="font-script text-2xl text-brand-rose md:text-3xl">
<p className="font-script text-2xl text-rose-300 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">
<p className="mx-auto max-w-2xl text-base text-slate-600 dark:text-slate-300 md:text-lg">
{description}
</p>
)}
@@ -62,10 +67,10 @@ export function WelcomeHero({
size="lg"
variant={variant === 'outline' ? 'outline' : 'default'}
className={cn(
'min-w-[220px] rounded-full px-6',
'min-w-[220px] rounded-full px-6 transition-colors',
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)]'
? 'border-white/60 bg-white/20 text-slate-900 hover:bg-white/40 dark:border-slate-700 dark:bg-slate-900/40 dark:text-slate-100'
: 'bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-lg shadow-rose-300/30 hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]'
)}
onClick={onClick}
{...(href ? { asChild: true } : {})}
@@ -86,7 +91,7 @@ export function WelcomeHero({
</div>
)}
</div>
</section>
</FrostedSurface>
);
}

View File

@@ -1,9 +1,11 @@
import React from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { FrostedCard } from '../../components/tenant';
export interface WelcomeStepCardProps {
step: number;
totalSteps: number;
@@ -27,16 +29,16 @@ export function WelcomeStepCard({
const percent = totalSteps <= 1 ? 100 : Math.round((progress / totalSteps) * 100);
return (
<Card
<FrostedCard
className={cn(
'relative overflow-hidden rounded-3xl border border-brand-rose-soft bg-brand-card shadow-brand-primary',
'relative overflow-hidden rounded-3xl border border-white/20 text-slate-900 shadow-lg shadow-rose-200/20 dark:text-slate-100',
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" />
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1]" />
<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">
<span className="text-xs font-semibold uppercase tracking-[0.4em] text-rose-300 dark:text-rose-200">
Step {progress} / {totalSteps}
</span>
<div className="w-28">
@@ -45,20 +47,20 @@ export function WelcomeStepCard({
</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">
<span className="flex size-12 items-center justify-center rounded-full bg-rose-100/90 text-rose-500 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-200">
<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>
<CardTitle className="font-display text-2xl font-semibold text-slate-900 dark:text-slate-100 md:text-3xl">{title}</CardTitle>
{description && (
<CardDescription className="text-base font-sans-marketing text-brand-navy/80">{description}</CardDescription>
<CardDescription className="text-base text-slate-600 dark:text-slate-400">{description}</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-6 pb-10">{children}</CardContent>
</Card>
<CardContent className="space-y-6 pb-10 text-slate-700 dark:text-slate-300">{children}</CardContent>
</FrostedCard>
);
}

View File

@@ -11,6 +11,7 @@ import {
} from "..";
import { Button } from "@/components/ui/button";
import { ADMIN_EVENT_CREATE_PATH, ADMIN_EVENTS_PATH, ADMIN_HOME_PATH } from "../../constants";
import { FrostedSurface } from "../../components/tenant";
export default function WelcomeEventSetupPage() {
const navigate = useNavigate();
@@ -55,25 +56,25 @@ export default function WelcomeEventSetupPage() {
icon: ArrowRight,
},
].map((item) => (
<div
<FrostedSurface
key={item.id}
className="flex flex-col gap-3 rounded-2xl border border-brand-rose-soft bg-brand-card p-5 shadow-brand-primary"
className="flex flex-col gap-3 border border-white/20 p-5 text-slate-900 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100"
>
<span className="flex size-10 items-center justify-center rounded-full bg-brand-rose-soft text-brand-rose shadow-inner">
<span className="flex size-10 items-center justify-center rounded-full bg-rose-100/80 text-rose-500 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-200">
<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>
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{item.title}</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">{item.copy}</p>
</FrostedSurface>
))}
</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">{t("eventSetup.cta.heading")}</h4>
<p className="text-sm text-brand-navy/80">{t("eventSetup.cta.description")}</p>
<FrostedSurface className="mt-6 flex flex-col items-start gap-3 border border-white/20 p-6 text-slate-900 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100">
<h4 className="text-lg font-semibold text-rose-400 dark:text-rose-200">{t("eventSetup.cta.heading")}</h4>
<p className="text-sm text-slate-600 dark:text-slate-400">{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)]"
className="mt-2 rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-lg shadow-rose-300/30 hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
onClick={() => {
markStep({ lastStep: "event-create-intent" });
navigate(ADMIN_EVENT_CREATE_PATH);
@@ -82,7 +83,7 @@ export default function WelcomeEventSetupPage() {
{t("eventSetup.cta.button")}
<ArrowRight className="ml-2 size-4" />
</Button>
</div>
</FrostedSurface>
</WelcomeStepCard>
<OnboardingCTAList

View File

@@ -1,7 +1,7 @@
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 { Sparkles, Users, Camera, CalendarDays, ChevronRight, CreditCard, UserPlus, Palette } from "lucide-react";
import {
TenantWelcomeLayout,
@@ -11,16 +11,52 @@ import {
useOnboardingProgress,
} from "..";
import { ADMIN_WELCOME_PACKAGES_PATH, ADMIN_EVENTS_PATH } from "../../constants";
import { ChecklistRow, FrostedSurface } from "../../components/tenant";
export default function WelcomeLandingPage() {
const navigate = useNavigate();
const { markStep } = useOnboardingProgress();
const { markStep, progress } = useOnboardingProgress();
const { t } = useTranslation("onboarding");
React.useEffect(() => {
markStep({ welcomeSeen: true, lastStep: "landing" });
}, [markStep]);
const progressStatus = React.useMemo(
() => ({
complete: t("landingProgress.status.complete"),
pending: t("landingProgress.status.pending"),
}),
[t]
);
const progressSteps = React.useMemo(
() => [
{
key: "package",
icon: <CreditCard className="h-5 w-5" />,
label: t("landingProgress.steps.package.title"),
hint: t("landingProgress.steps.package.hint"),
completed: progress.packageSelected,
},
{
key: "invite",
icon: <UserPlus className="h-5 w-5" />,
label: t("landingProgress.steps.invite.title"),
hint: t("landingProgress.steps.invite.hint"),
completed: progress.inviteCreated,
},
{
key: "branding",
icon: <Palette className="h-5 w-5" />,
label: t("landingProgress.steps.branding.title"),
hint: t("landingProgress.steps.branding.hint"),
completed: progress.brandingConfigured,
},
],
[progress.packageSelected, progress.inviteCreated, progress.brandingConfigured, t]
);
return (
<TenantWelcomeLayout
eyebrow={t("layout.eyebrow")}
@@ -60,6 +96,32 @@ export default function WelcomeLandingPage() {
]}
/>
<FrostedSurface className="space-y-4 rounded-3xl border border-white/20 p-6 text-slate-900 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-rose-300 dark:text-rose-200">
{t("landingProgress.eyebrow")}
</p>
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
{t("landingProgress.title")}
</h2>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t("landingProgress.description")}
</p>
</div>
<div className="grid gap-3">
{progressSteps.map((step) => (
<ChecklistRow
key={step.key}
icon={step.icon}
label={step.label}
hint={step.hint}
completed={step.completed}
status={progressStatus}
/>
))}
</div>
</FrostedSurface>
<OnboardingHighlightsGrid
items={[
{

View File

@@ -1,4 +1,4 @@
import React from "react";
import React from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
@@ -8,6 +8,7 @@ import {
ArrowLeft,
CreditCard,
AlertTriangle,
Info,
Loader2,
} from "lucide-react";
@@ -17,20 +18,22 @@ import {
OnboardingCTAList,
useOnboardingProgress,
} from "..";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { FrostedSurface } from "../../components/tenant";
import { ADMIN_BILLING_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PATH } from "../../constants";
import { useTenantPackages } from "../hooks/useTenantPackages";
import {
assignFreeTenantPackage,
createTenantPaddleCheckout,
} from "../../api";
import { cn } from "@/lib/utils";
type PaddleCheckoutProps = {
packageId: number;
onSuccess: () => void;
t: ReturnType<typeof useTranslation>["t"];
className?: string;
};
function useLocaleFormats(locale: string) {
@@ -69,7 +72,7 @@ function humanizeFeature(t: ReturnType<typeof useTranslation>["t"], key: string)
.join(" ");
}
function PaddleCheckout({ packageId, onSuccess, t }: PaddleCheckoutProps) {
function PaddleCheckout({ packageId, onSuccess, t, className }: PaddleCheckoutProps) {
const [status, setStatus] = React.useState<'idle' | 'processing' | 'success' | 'error'>('idle');
const [error, setError] = React.useState<string | null>(null);
@@ -89,14 +92,32 @@ function PaddleCheckout({ packageId, onSuccess, t }: PaddleCheckoutProps) {
}, [packageId, 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">{t('summary.paddle.heading')}</p>
{error && (
<Alert variant="destructive">
<AlertTitle>{t('summary.paddle.errorTitle')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
<FrostedSurface
className={cn(
"space-y-4 rounded-2xl border border-white/20 p-6 text-slate-900 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100",
className
)}
>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-rose-400 dark:text-rose-200">
{t('summary.paddle.sectionTitle')}
</p>
<p className="text-base font-semibold text-slate-900 dark:text-slate-100">
{t('summary.paddle.heading')}
</p>
</div>
{error ? (
<div
role="alert"
className="flex items-start gap-3 rounded-2xl border border-rose-300/40 bg-rose-100/15 p-4 text-sm text-rose-700 shadow-inner shadow-rose-200/20 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200"
>
<AlertTriangle className="size-4 shrink-0 text-current" />
<div className="space-y-1">
<p className="text-sm font-semibold uppercase tracking-wide">{t('summary.paddle.errorTitle')}</p>
<p className="text-sm text-rose-600/80 dark:text-rose-200/80">{error}</p>
</div>
</div>
) : null}
<Button
size="lg"
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"
@@ -115,8 +136,8 @@ function PaddleCheckout({ packageId, onSuccess, t }: PaddleCheckoutProps) {
</>
)}
</Button>
<p className="text-xs text-brand-navy/70">{t('summary.paddle.hint')}</p>
</div>
<p className="text-xs text-slate-600 dark:text-slate-400">{t('summary.paddle.hint')}</p>
</FrostedSurface>
);
}
@@ -216,101 +237,177 @@ export default function WelcomeOrderSummaryPage() {
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" />
<FrostedSurface className="flex items-center gap-3 rounded-2xl border border-white/20 p-6 text-sm text-slate-600 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-300">
<Loader2 className="size-5 animate-spin text-rose-400" />
{t("summary.state.loading")}
</div>
</FrostedSurface>
)}
{packagesState.status === "error" && (
<Alert variant="destructive">
<AlertTriangle className="size-4" />
<AlertTitle>{t("summary.state.errorTitle")}</AlertTitle>
<AlertDescription>
{packagesState.message ?? t("summary.state.errorDescription")}
</AlertDescription>
</Alert>
<FrostedSurface
role="alert"
className="flex items-start gap-3 rounded-2xl border border-rose-300/40 bg-rose-100/15 p-6 text-sm text-rose-700 shadow-md shadow-rose-200/30 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200"
>
<AlertTriangle className="size-4 shrink-0 text-current" />
<div className="space-y-1">
<p className="text-sm font-semibold uppercase tracking-wide">
{t("summary.state.errorTitle")}
</p>
<p className="text-sm text-rose-600/80 dark:text-rose-200/80">
{packagesState.message ?? t("summary.state.errorDescription")}
</p>
</div>
</FrostedSurface>
)}
{packagesState.status === "success" && !packageDetails && (
<Alert>
<AlertTitle>{t("summary.state.missingTitle")}</AlertTitle>
<AlertDescription>{t("summary.state.missingDescription")}</AlertDescription>
</Alert>
<FrostedSurface
role="alert"
className="flex items-start gap-3 rounded-2xl border border-white/20 bg-white/15 p-6 text-sm text-slate-800 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-200"
>
<Info className="size-4 shrink-0 text-current" />
<div className="space-y-1">
<p className="text-sm font-semibold uppercase tracking-wide">
{t("summary.state.missingTitle")}
</p>
<p className="text-sm text-slate-700/90 dark:text-slate-300/80">
{t("summary.state.missingDescription")}
</p>
</div>
</FrostedSurface>
)}
{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">
{t(isSubscription ? "summary.details.subscription" : "summary.details.creditPack")}
</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">{t("summary.details.section.photosTitle")}</dt>
<dd>
{packageDetails.max_photos
? 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">{t("summary.details.section.guestsTitle")}</dt>
<dd>
{packageDetails.max_guests
? t("summary.details.section.guestsValue", { count: packageDetails.max_guests })
: t("summary.details.section.guestsUnlimited")}
</dd>
</div>
<div>
<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">{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}
<FrostedSurface className="relative overflow-hidden rounded-3xl border border-white/20 p-6 text-white shadow-xl shadow-rose-300/25 dark:border-slate-800/70 dark:bg-slate-950/85">
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.35),_transparent_65%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.3),_transparent_65%)]"
/>
<div aria-hidden className="absolute inset-0 bg-gradient-to-br from-slate-950/65 via-slate-900/10 to-transparent mix-blend-overlay" />
<div className="relative z-10 flex flex-col gap-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-3">
<Badge className="w-fit rounded-full border border-white/40 bg-white/20 px-4 py-1 text-[10px] font-semibold uppercase tracking-[0.4em] text-white shadow-inner shadow-white/20">
{t(isSubscription ? "summary.details.subscription" : "summary.details.creditPack")}
</Badge>
<h3 className="font-display text-2xl font-semibold tracking-tight text-white md:text-3xl">
{packageDetails.name}
</h3>
</div>
<div className="flex flex-col items-start gap-2 text-left sm:items-end sm:text-right">
{priceText ? (
<Badge className="rounded-full border border-white/40 bg-white/15 px-4 py-2 text-base font-semibold text-white shadow-inner shadow-white/20">
{priceText}
</Badge>
) : null}
<span className="text-xs font-semibold uppercase tracking-[0.35em] text-white/70">
{activePackage
? t("summary.details.section.statusActive")
: t("summary.details.section.statusInactive")}
</span>
))}
</div>
</div>
)}
</div>
<dl className="grid gap-4 text-sm text-white/85 md:grid-cols-2">
<div className="space-y-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-white/70">
{t("summary.details.section.photosTitle")}
</dt>
<dd>
{packageDetails.max_photos
? 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 className="space-y-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-white/70">
{t("summary.details.section.guestsTitle")}
</dt>
<dd>
{packageDetails.max_guests
? t("summary.details.section.guestsValue", { count: packageDetails.max_guests })
: t("summary.details.section.guestsUnlimited")}
</dd>
</div>
<div className="space-y-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-white/70">
{t("summary.details.section.featuresTitle")}
</dt>
<dd>
{featuresList.length ? (
<div className="flex flex-wrap gap-2">
{featuresList.map((feature) => (
<Badge
key={feature}
className="rounded-full border border-white/30 bg-white/20 px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-white"
>
{feature}
</Badge>
))}
</div>
) : (
<span className="text-white/70">{t("summary.details.section.featuresNone")}</span>
)}
</dd>
</div>
<div className="space-y-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-white/70">
{t("summary.details.section.statusTitle")}
</dt>
<dd>
<Badge className="rounded-full border border-white/30 bg-white/20 px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-white">
{activePackage
? t("summary.details.section.statusActive")
: t("summary.details.section.statusInactive")}
</Badge>
</dd>
</div>
</dl>
{detailBadges.length > 0 ? (
<div className="flex flex-wrap gap-2">
{detailBadges.map((badge) => (
<Badge
key={badge}
className="rounded-full border border-white/30 bg-white/20 px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-white"
>
{badge}
</Badge>
))}
</div>
) : null}
</div>
</FrostedSurface>
{!activePackage && (
<Alert className="rounded-2xl border-brand-rose-soft bg-brand-rose-soft/60 text-rose-700">
<ShieldCheck className="size-4" />
<AlertTitle>{t("summary.status.pendingTitle")}</AlertTitle>
<AlertDescription>{t("summary.status.pendingDescription")}</AlertDescription>
</Alert>
<FrostedSurface
role="status"
className="flex items-start gap-3 rounded-2xl border border-amber-200/40 bg-amber-100/15 p-5 text-sm text-amber-800 shadow-md shadow-amber-200/20 dark:border-amber-300/30 dark:bg-amber-500/10 dark:text-amber-100"
>
<ShieldCheck className="size-4 shrink-0 text-current" />
<div className="space-y-1">
<p className="text-sm font-semibold uppercase tracking-wide">
{t("summary.status.pendingTitle")}
</p>
<p className="text-sm text-amber-700/90 dark:text-amber-100/80">
{t("summary.status.pendingDescription")}
</p>
</div>
</FrostedSurface>
)}
{packageDetails.price === 0 && (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/40 p-6">
<p className="text-sm text-emerald-700">{t("summary.free.description")}</p>
<FrostedSurface className="space-y-3 rounded-2xl border border-emerald-200/40 bg-emerald-100/15 p-6 text-sm text-emerald-900 shadow-md shadow-emerald-200/20 dark:border-emerald-400/30 dark:bg-emerald-500/10 dark:text-emerald-100">
<p>{t("summary.free.description")}</p>
{freeAssignStatus === "success" ? (
<Alert className="mt-4 border-emerald-200 bg-white text-emerald-600">
<AlertTitle>{t("summary.free.successTitle")}</AlertTitle>
<AlertDescription>{t("summary.free.successDescription")}</AlertDescription>
</Alert>
<div className="space-y-1 rounded-xl border border-emerald-200/60 bg-emerald-50/30 p-4 text-sm text-emerald-800 dark:border-emerald-400/30 dark:bg-emerald-500/20 dark:text-emerald-50">
<p className="font-semibold">{t("summary.free.successTitle")}</p>
<p>{t("summary.free.successDescription")}</p>
</div>
) : (
<Button
onClick={async () => {
@@ -333,7 +430,7 @@ export default function WelcomeOrderSummaryPage() {
}
}}
disabled={freeAssignStatus === "loading"}
className="mt-4 rounded-full bg-brand-teal text-white shadow-md shadow-emerald-300/40 hover:opacity-90"
className="mt-2 w-full rounded-full bg-gradient-to-r from-[#34d399] via-[#10b981] to-[#059669] text-white shadow-lg shadow-emerald-300/30 hover:from-[#22c783] hover:via-[#0fa776] hover:to-[#047857]"
>
{freeAssignStatus === "loading" ? (
<>
@@ -345,37 +442,37 @@ export default function WelcomeOrderSummaryPage() {
)}
</Button>
)}
{freeAssignStatus === "error" && freeAssignError && (
<Alert variant="destructive" className="mt-3">
<AlertTitle>{t("summary.free.failureTitle")}</AlertTitle>
<AlertDescription>{freeAssignError}</AlertDescription>
</Alert>
)}
</div>
{freeAssignStatus === "error" && freeAssignError ? (
<div
role="alert"
className="rounded-xl border border-rose-300/40 bg-rose-100/15 p-4 text-sm text-rose-700 shadow-inner shadow-rose-200/20 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200"
>
<p className="font-semibold">{t("summary.free.failureTitle")}</p>
<p>{freeAssignError}</p>
</div>
) : null}
</FrostedSurface>
)}
{requiresPayment && (
<div className="space-y-4">
<h4 className="text-lg font-semibold text-brand-slate">{t('summary.paddle.sectionTitle')}</h4>
<PaddleCheckout
packageId={packageDetails.id}
onSuccess={() => {
markStep({ packageSelected: true });
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
}}
t={t}
/>
</div>
<PaddleCheckout
packageId={packageDetails.id}
onSuccess={() => {
markStep({ packageSelected: true });
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
}}
t={t}
/>
)}
<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">{t("summary.nextStepsTitle")}</h4>
<ol className="list-decimal space-y-2 pl-5 text-sm text-brand-navy/80">
<FrostedSurface className="flex flex-col gap-3 rounded-2xl border border-white/20 p-6 text-slate-900 shadow-inner shadow-rose-200/10 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100">
<h4 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{t("summary.nextStepsTitle")}</h4>
<ol className="list-decimal space-y-2 pl-5 text-sm text-slate-600 dark:text-slate-300">
{nextSteps.map((step, index) => (
<li key={`${step}-${index}`}>{step}</li>
))}
</ol>
</div>
</FrostedSurface>
</div>
)}
</WelcomeStepCard>

View File

@@ -11,6 +11,7 @@ import {
} from "..";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { FrostedSurface } from "../../components/tenant/frosted-surface";
import { ADMIN_BILLING_PATH, ADMIN_WELCOME_SUMMARY_PATH } from "../../constants";
import { useTenantPackages } from "../hooks/useTenantPackages";
import { Package } from "../../api";
@@ -115,16 +116,16 @@ export default function WelcomePackagesPage() {
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" />
<FrostedSurface className="flex items-center gap-3 border border-white/20 p-6 text-sm text-slate-600 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-300">
<Loader2 className="size-5 animate-spin text-rose-400" />
{t("packages.state.loading")}
</div>
</FrostedSurface>
);
}
if (packagesState.status === "error") {
return (
<Alert variant="destructive">
<Alert variant="destructive" className="border-rose-300/60 bg-rose-50/80 text-rose-700 dark:border-rose-500/40 dark:bg-rose-500/15 dark:text-rose-200">
<AlertCircle className="size-4" />
<AlertTitle>{t("packages.state.errorTitle")}</AlertTitle>
<AlertDescription>{packagesState.message ?? t("packages.state.errorDescription")}</AlertDescription>
@@ -134,7 +135,7 @@ export default function WelcomePackagesPage() {
if (packagesState.status === "success" && packagesState.catalog.length === 0) {
return (
<Alert variant="default" className="border-brand-rose-soft bg-brand-card/60 text-brand-navy">
<Alert variant="default" className="border-white/20 bg-white/70 text-slate-700 shadow-sm dark:border-slate-800/60 dark:bg-slate-950/80 dark:text-slate-300">
<AlertTitle>{t("packages.state.emptyTitle")}</AlertTitle>
<AlertDescription>{t("packages.state.emptyDescription")}</AlertDescription>
</Alert>
@@ -170,59 +171,70 @@ export default function WelcomePackagesPage() {
}
return (
<div
<FrostedSurface
key={pkg.id}
className="flex flex-col gap-4 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 shadow-brand-primary"
className="flex flex-col gap-4 border border-white/20 p-6 text-slate-900 shadow-md shadow-rose-200/20 transition-colors duration-200 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100"
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-brand-rose">
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-rose-400 dark:text-rose-200">
{t(isSubscription ? "packages.card.subscription" : "packages.card.creditPack")}
</p>
<h3 className="text-xl font-semibold text-brand-slate">{pkg.name}</h3>
<h3 className="text-xl font-semibold text-slate-900 dark:text-slate-100">{pkg.name}</h3>
</div>
<span className="text-lg font-medium text-brand-rose">{priceText}</span>
<span className="text-lg font-medium text-rose-400 dark:text-rose-200">{priceText}</span>
</div>
<p className="text-sm text-brand-navy/80">
<p className="text-sm text-slate-600 dark:text-slate-300">
{pkg.max_photos
? t("packages.card.descriptionWithPhotos", { count: pkg.max_photos })
: t("packages.card.description")}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-wide text-brand-rose">
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-wide text-rose-400 dark:text-rose-200">
{badges.map((badge) => (
<span key={badge} className="rounded-full bg-brand-rose-soft px-3 py-1">
<span key={badge} className="rounded-full bg-rose-100/80 px-3 py-1 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-100">
{badge}
</span>
))}
{featureLabels.map((feature) => (
<span key={feature} className="rounded-full bg-brand-rose-soft px-3 py-1">
<span key={feature} className="rounded-full bg-rose-100/80 px-3 py-1 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-100">
{feature}
</span>
))}
{isActive && (
<span className="rounded-full bg-brand-sky-soft px-3 py-1 text-brand-navy">
{isActive ? (
<span className="rounded-full bg-sky-100/80 px-3 py-1 text-sky-600 shadow-inner shadow-sky-200/40 dark:bg-sky-500/20 dark:text-sky-200">
{t("packages.card.active")}
</span>
)}
) : null}
</div>
<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 className="flex flex-col gap-3 pt-2">
<Button
size="lg"
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-lg shadow-rose-300/30 hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
onClick={() => handleSelectPackage(pkg)}
disabled={isActive}
>
{isActive ? t("packages.card.active") : t("packages.card.select")}
<ArrowRight className="ml-2 size-4" />
</Button>
{purchased ? (
<p className="text-xs text-slate-500 dark:text-slate-400">
{t("packages.card.purchased", {
date: purchased.purchased_at
? dateFormatter.format(new Date(purchased.purchased_at))
: t("packages.card.purchasedUnknown"),
})}
</p>
) : null}
<Button
variant="ghost"
className="text-sm text-slate-600 hover:text-rose-400 dark:text-slate-400 dark:hover:text-rose-200"
onClick={() => navigate(ADMIN_BILLING_PATH)}
>
<CreditCard className="mr-2 h-4 w-4" />
{t("packages.card.viewBilling")}
</Button>
</div>
</FrostedSurface>
);
})}
</div>

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { fetchOnboardingStatus, trackOnboarding } from '../api';
import { useAuth } from '../auth/context';
export type OnboardingProgress = {
welcomeSeen: boolean;
@@ -13,6 +14,8 @@ export type OnboardingProgress = {
priceText?: string | null;
isSubscription?: boolean;
} | null;
inviteCreated: boolean;
brandingConfigured: boolean;
};
type OnboardingUpdate = Partial<OnboardingProgress> & {
@@ -34,6 +37,8 @@ const DEFAULT_PROGRESS: OnboardingProgress = {
lastStep: null,
adminAppOpenedAt: null,
selectedPackage: null,
inviteCreated: false,
brandingConfigured: false,
};
const STORAGE_KEY = 'tenant-admin:onboarding-progress';
@@ -74,13 +79,27 @@ function writeStoredProgress(progress: OnboardingProgress) {
export function OnboardingProgressProvider({ children }: { children: React.ReactNode }) {
const [progress, setProgressState] = React.useState<OnboardingProgress>(() => readStoredProgress());
const [synced, setSynced] = React.useState(false);
const { status } = useAuth();
React.useEffect(() => {
if (status !== 'authenticated') {
if (synced) {
setSynced(false);
}
return;
}
if (synced) {
return;
}
let cancelled = false;
fetchOnboardingStatus().then((status) => {
if (cancelled) {
return;
}
if (!status) {
setSynced(true);
return;
@@ -92,6 +111,8 @@ export function OnboardingProgressProvider({ children }: { children: React.React
adminAppOpenedAt: status.steps.admin_app_opened_at ?? prev.adminAppOpenedAt ?? null,
eventCreated: Boolean(status.steps.event_created ?? prev.eventCreated),
packageSelected: Boolean(status.steps.selected_packages ?? prev.packageSelected),
inviteCreated: Boolean(status.steps.invite_created ?? prev.inviteCreated),
brandingConfigured: Boolean(status.steps.branding_completed ?? prev.brandingConfigured),
};
writeStoredProgress(next);
@@ -110,8 +131,16 @@ export function OnboardingProgressProvider({ children }: { children: React.React
}
setSynced(true);
}).catch(() => {
if (!cancelled) {
setSynced(true);
}
});
}, [synced]);
return () => {
cancelled = true;
};
}, [status, synced]);
const setProgress = React.useCallback((updater: (prev: OnboardingProgress) => OnboardingProgress) => {
setProgressState((prev) => {
@@ -124,11 +153,39 @@ export function OnboardingProgressProvider({ children }: { children: React.React
const markStep = React.useCallback((step: OnboardingUpdate) => {
const { serverStep, meta, ...rest } = step;
setProgress((prev) => ({
...prev,
...rest,
lastStep: typeof rest.lastStep === 'undefined' ? prev.lastStep : rest.lastStep,
}));
setProgress((prev) => {
const derived: Partial<OnboardingProgress> = {};
switch (serverStep) {
case 'package_selected':
derived.packageSelected = true;
break;
case 'event_created':
derived.eventCreated = true;
break;
case 'invite_created':
derived.inviteCreated = true;
break;
case 'branding_configured':
derived.brandingConfigured = true;
break;
default:
break;
}
const next: OnboardingProgress = {
...prev,
...rest,
...derived,
lastStep: typeof rest.lastStep === 'undefined' ? prev.lastStep : rest.lastStep,
};
if (serverStep === 'admin_app_opened' && !next.adminAppOpenedAt) {
next.adminAppOpenedAt = new Date().toISOString();
}
return next;
});
if (serverStep) {
trackOnboarding(serverStep, meta).catch(() => {});