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:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={[
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
Reference in New Issue
Block a user