checkout: buttons verbessert, paddle zahlungsschritt schicker gemacht, schritt 4 optimiert+schick gemacht. Dashboard: translations ergänzt. Startseite vom Event Admin optimiert.

This commit is contained in:
Codex Agent
2025-11-17 11:06:46 +01:00
parent 5290072ffe
commit 167734f87a
25 changed files with 1981 additions and 1002 deletions

View File

@@ -97,6 +97,42 @@
"description": "Aktives Paket und Historie einsehen."
}
},
"onboarding": {
"hero": {
"cta": "Setup fortsetzen"
},
"card": {
"title": "Dein Start in fünf Schritten",
"description": "Bearbeite die Schritte in der Admin-App das Dashboard zeigt dir den Status.",
"completed": "Alle Schritte abgeschlossen großartig! Du kannst jederzeit zur Admin-App wechseln.",
"cta_fallback": "Jetzt starten"
},
"admin_app": {
"title": "Admin-App öffnen",
"description": "Verwalte Events, Uploads und Gäste direkt in der Admin-App. Die mobile Oberfläche ist für Live-Einsätze optimiert.",
"cta": "Admin-App starten"
},
"event_setup": {
"title": "Erstes Event vorbereiten",
"description": "Lege in der Admin-App Name, Datum und Aufgaben fest. So wissen Gäste, welche Fotos ihr euch wünscht.",
"cta": "Event anlegen"
},
"invite_guests": {
"title": "Gäste einladen",
"description": "Teile QR-Codes oder Links, damit Gäste direkt mit dem Hochladen beginnen können.",
"cta": "QR-Links öffnen"
},
"collect_photos": {
"title": "Erste Fotos einsammeln",
"description": "Sobald die ersten Uploads eintrudeln, erscheint alles in eurer Galerie. Moderation und Freigaben laufen in der Admin-App.",
"cta": "Uploads prüfen"
},
"branding": {
"title": "Branding & Aufgaben verfeinern",
"description": "Passt Farbwelt und Aufgabenpakete an euren Anlass an so fühlt sich alles wie aus einem Guss an.",
"cta": "Branding öffnen"
}
},
"limitsCard": {
"title": "Kontingente & Laufzeiten",
"description": "Fokus-Event: {{name}}",
@@ -251,6 +287,42 @@
"planning": "In Planung",
"noDate": "Kein Datum"
}
},
"onboarding": {
"hero": {
"cta": "Setup fortsetzen"
},
"card": {
"title": "Dein Start in fünf Schritten",
"description": "Bearbeite die Schritte in der Admin-App das Dashboard zeigt dir den Status.",
"completed": "Alle Schritte abgeschlossen großartig! Du kannst jederzeit zur Admin-App wechseln.",
"cta_fallback": "Jetzt starten"
},
"admin_app": {
"title": "Admin-App öffnen",
"description": "Verwalte Events, Uploads und Gäste direkt in der Admin-App. Die mobile Oberfläche ist für Live-Einsätze optimiert.",
"cta": "Admin-App starten"
},
"event_setup": {
"title": "Erstes Event vorbereiten",
"description": "Lege in der Admin-App Name, Datum und Aufgaben fest. So wissen Gäste, welche Fotos ihr euch wünscht.",
"cta": "Event anlegen"
},
"invite_guests": {
"title": "Gäste einladen",
"description": "Teile QR-Codes oder Links, damit Gäste direkt mit dem Hochladen beginnen können.",
"cta": "QR-Links öffnen"
},
"collect_photos": {
"title": "Erste Fotos einsammeln",
"description": "Sobald die ersten Uploads eintrudeln, erscheint alles in eurer Galerie. Moderation und Freigaben laufen in der Admin-App.",
"cta": "Uploads prüfen"
},
"branding": {
"title": "Branding & Aufgaben verfeinern",
"description": "Passt Farbwelt und Aufgabenpakete an euren Anlass an so fühlt sich alles wie aus einem Guss an.",
"cta": "Branding öffnen"
}
}
}
}

View File

@@ -97,6 +97,42 @@
"description": "View your active package and history."
}
},
"onboarding": {
"hero": {
"cta": "Continue setup"
},
"card": {
"title": "Your start in five steps",
"description": "Complete the steps inside the Admin App — the dashboard keeps track of your status.",
"completed": "All steps finished — fantastic! You can switch to the Admin App at any time.",
"cta_fallback": "Start now"
},
"admin_app": {
"title": "Open the Admin App",
"description": "Manage events, uploads, and guests inside the Admin App. The mobile interface is optimised for live operations.",
"cta": "Launch Admin App"
},
"event_setup": {
"title": "Prepare first event",
"description": "Define name, date, and tasks inside the Admin App so guests know which photos you expect.",
"cta": "Create event"
},
"invite_guests": {
"title": "Invite guests",
"description": "Share QR codes or links so guests can start uploading instantly.",
"cta": "Open QR links"
},
"collect_photos": {
"title": "Collect first photos",
"description": "As soon as uploads arrive, they show up in your gallery. Moderation happens inside the Admin App.",
"cta": "Review uploads"
},
"branding": {
"title": "Fine-tune branding & tasks",
"description": "Adjust colours and task bundles to match your occasion — everything feels tailor-made.",
"cta": "Open branding"
}
},
"limitsCard": {
"title": "Limits & gallery status",
"description": "Focus event: {{name}}",
@@ -251,6 +287,42 @@
"planning": "In planning",
"noDate": "No date"
}
},
"onboarding": {
"hero": {
"cta": "Continue setup"
},
"card": {
"title": "Your start in five steps",
"description": "Complete the steps inside the Admin App — the dashboard keeps track of your status.",
"completed": "All steps finished — fantastic! You can switch to the Admin App at any time.",
"cta_fallback": "Start now"
},
"admin_app": {
"title": "Open the Admin App",
"description": "Manage events, uploads, and guests inside the Admin App. The mobile interface is optimised for live operations.",
"cta": "Launch Admin App"
},
"event_setup": {
"title": "Prepare first event",
"description": "Define name, date, and tasks inside the Admin App so guests know which photos you expect.",
"cta": "Create event"
},
"invite_guests": {
"title": "Invite guests",
"description": "Share QR codes or links so guests can start uploading instantly.",
"cta": "Open QR links"
},
"collect_photos": {
"title": "Collect first photos",
"description": "As soon as uploads arrive, they show up in your gallery. Moderation happens inside the Admin App.",
"cta": "Review uploads"
},
"branding": {
"title": "Fine-tune branding & tasks",
"description": "Adjust colours and task bundles to match your occasion — everything feels tailor-made.",
"cta": "Open branding"
}
}
}
}

View File

@@ -0,0 +1,3 @@
export function navigateToHref(target: string): void {
window.location.assign(target);
}

View File

@@ -1,31 +1,148 @@
import React from 'react';
import { Sparkles, Users, Camera, Heart, ArrowRight } from 'lucide-react';
import {
ArrowRight,
Camera,
Clock3,
Heart,
MessageCircle,
Moon,
QrCode,
ShieldCheck,
Sparkles,
SunMedium,
Users,
Wand2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { LanguageSwitcher } from '../components/LanguageSwitcher';
import { FrostedSurface } from '../components/tenant';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_LOGIN_PATH } from '../constants';
import { encodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
import { navigateToHref } from '../lib/navigation';
const highlights = [
const heroStats = [
{ label: 'Events begleitet', value: '2.100+' },
{ label: 'Fotos kuratiert', value: '680k' },
{ label: 'Mission Cards live', value: '120+' },
];
const featureCards = [
{
icon: Sparkles,
title: 'Momente lenken, nicht das Handy',
badge: 'Welcome Flow',
title: 'Geführte Einrichtung',
description:
'Fotospiel liefert euch spielerische Aufgaben, damit eure Gäste das Fest genießen und gleichzeitig emotionale Motive festhalten.',
'Ein roter Faden von eurer ersten Mission bis zum Event-Branding. Alles läuft dort, wo ihr später auch Events steuert.',
},
{
icon: Users,
title: 'Alle Gäste auf einer Reise',
icon: QrCode,
badge: 'Gäste einladen',
title: 'Links & QR-Cards teilen',
description:
'Einladungslinks und QR-Codes führen direkt in eure Event-Galerie. Kein Technik-Know-how nötig nur teilen und loslegen.',
'Mit einem Fingertipp entstehen Einladungslinks, QR-Codes und TWA-Links für Android & iOS ganz ohne App Store Hürden.',
},
{
icon: Camera,
title: 'Live-Galerie und Moderation',
badge: 'Live Galerie',
title: 'Moderieren wie im Dashboard',
description:
'Sammelt Bilder in Echtzeit, markiert Highlights und entscheidet gemeinsam, welche Erinnerungen groß rauskommen.',
'Markiert Highlights, ordnet Emotions oder sperrt Fotos exakt die Tools aus dem Event Admin, nur mit Willkommens-Glow.',
},
];
const timelineSteps = [
{
title: 'Mission Cards wählen',
description: 'Kuratiert Aufgaben, die eure Gäste ins Erzählen bringen. Jeder Schritt speichert automatisch.',
},
{
title: 'Event Branding festlegen',
description: 'Farben, Story, Cover alles in einer Ansicht. Vorschau zeigt sofort, wie der Gastzugang wirkt.',
},
{
title: 'Link teilen & feiern',
description: 'QR-Code oder Link verschicken, fertig. Gäste landen direkt in eurer Galerie und können ohne Login starten.',
},
];
const supportHighlights = [
{
icon: ShieldCheck,
title: 'Datenschutz-ready',
description: 'Keine versteckten Tracker, DSGVO-konformes Hosting und Kontrolle, wer Fotos sieht.',
},
{
icon: Clock3,
title: 'Offline nutzbar',
description: 'Uploads puffern automatisch, falls das WLAN in der Location aussetzt.',
},
{
icon: MessageCircle,
title: 'Crew an eurer Seite',
description: 'Direkter Chat zum Fotospiel-Team aus der App heraus oder via hallo@fotospiel.de.',
},
];
export default function WelcomeTeaserPage() {
const [isRedirecting, setIsRedirecting] = React.useState(false);
const [mode, setMode] = React.useState<'dark' | 'light'>('dark');
const isLightMode = mode === 'light';
const flowSectionRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
document.body.classList.add('tenant-admin-theme', 'tenant-admin-welcome-theme');
return () => {
document.body.classList.remove('tenant-admin-theme', 'tenant-admin-welcome-theme');
};
}, []);
React.useEffect(() => {
document.body.classList.toggle('tenant-admin-welcome-light', isLightMode);
document.body.classList.toggle('tenant-admin-welcome-dark', !isLightMode);
}, [isLightMode]);
const theme = React.useMemo(
() => ({
rootBackground: isLightMode
? 'bg-gradient-to-br from-rose-50 via-white to-sky-50 text-slate-900'
: 'bg-slate-950 text-white',
aurora: isLightMode
? 'bg-[radial-gradient(ellipse_at_top,_rgba(255,179,205,0.55),_transparent_55%),radial-gradient(ellipse_at_bottom,_rgba(148,187,233,0.45),_transparent_60%)]'
: 'bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.25),_transparent_60%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.25),_transparent_62%)]',
overlay: isLightMode
? 'bg-gradient-to-br from-white/85 via-rose-50/70 to-sky-50/70'
: 'bg-gradient-to-br from-slate-950/95 via-slate-950/80 to-[#150b1f]/90',
headerBadge: isLightMode ? 'border-rose-100 bg-white text-rose-500' : 'border-white/30 bg-white/5 text-white',
headerSubtitle: isLightMode ? 'text-slate-600' : 'text-white/70',
heroEyebrow: isLightMode ? 'text-rose-500' : 'text-rose-200',
heroTitle: isLightMode ? 'text-slate-900' : 'text-white',
heroBody: isLightMode ? 'text-slate-600' : 'text-white/75',
ghostButton: isLightMode ? 'text-slate-700 hover:bg-slate-100' : 'text-white/80 hover:bg-white/10',
statCard: isLightMode
? 'border-rose-100/70 bg-white/90 text-slate-900 shadow-rose-100/50'
: 'border-white/15 bg-white/10 text-white shadow-rose-500/30',
featureCard: isLightMode
? 'border-rose-100 bg-white text-slate-900 shadow-rose-100/40'
: 'border-white/15 bg-white/10 text-white shadow-slate-900/40',
timelineCard: isLightMode ? 'border-slate-200 bg-white text-slate-900' : 'border-white/15 bg-white/5 text-white',
timelineDescription: isLightMode ? 'text-slate-600' : 'text-white/70',
supportCard: isLightMode ? 'border-slate-200 bg-white text-slate-900' : 'border-white/15 bg-white/10 text-white',
supportDescription: isLightMode ? 'text-slate-600' : 'text-white/70',
ctaSurface: isLightMode ? 'border-slate-200 bg-white text-slate-900' : 'border-white/15 bg-white/10 text-white',
ctaDetail: isLightMode ? 'text-slate-600' : 'text-white/70',
footer: isLightMode
? 'border-t border-slate-200/80 bg-white/70 text-slate-500'
: 'border-t border-white/10 bg-black/20 text-white/60',
}),
[isLightMode]
);
const toggleMode = React.useCallback(() => {
setMode((prev) => (prev === 'dark' ? 'light' : 'dark'));
}, []);
const handleLoginRedirect = React.useCallback(() => {
if (isRedirecting) {
@@ -39,90 +156,339 @@ export default function WelcomeTeaserPage() {
const { finalTarget, encodedFinal } = resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH);
const target = new URL(ADMIN_LOGIN_PATH, window.location.origin);
target.searchParams.set('return_to', encodedFinal ?? encodeReturnTo(finalTarget));
window.location.href = `${target.pathname}${target.search}`;
const nextHref = `${target.pathname}${target.search}`;
navigateToHref(nextHref);
}, [isRedirecting]);
const handleScrollToFlow = React.useCallback(() => {
flowSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, []);
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 via-white to-sky-50 text-slate-800">
<header className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-6 pb-12 pt-16 text-center md:pt-20">
<div className="mx-auto w-fit rounded-full border border-rose-100 bg-white/80 px-4 py-1 text-sm font-medium text-rose-500 shadow-sm">
Willkommen bei Fotospiel
</div>
<h1 className="font-display text-4xl font-semibold tracking-tight text-slate-900 md:text-5xl">
Eure Gäste als Geschichtenerzähler ohne Technikstress
</h1>
<p className="mx-auto max-w-2xl text-base leading-relaxed text-slate-600 md:text-lg">
Dieses Kontrollzentrum zeigt euch, wie ihr Fotospiel für Hochzeit, Jubiläum oder Team-Event einsetzt.
Wir führen euch Schritt für Schritt durch Aufgaben, Event-Setup und Einladungen.
</p>
<div className="flex flex-col justify-center gap-3 md:flex-row">
<button
type="button"
className="group inline-flex items-center justify-center gap-2 rounded-full border border-transparent bg-white px-6 py-3 text-base font-semibold text-rose-500 shadow-sm transition hover:border-rose-200 hover:text-rose-600"
onClick={handleLoginRedirect}
disabled={isRedirecting}
>
{isRedirecting ? 'Weiterleitung ...' : 'Ich habe bereits Zugang'}
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-0.5" />
</button>
</div>
</header>
<div className={cn('relative min-h-svh overflow-hidden transition-colors duration-500', theme.rootBackground)}>
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 motion-safe:animate-[aurora_24s_ease-in-out_infinite]',
theme.aurora
)}
/>
<div aria-hidden className={cn('absolute inset-0 transition-colors duration-500', theme.overlay)} />
<main className="mx-auto w-full max-w-5xl space-y-12 px-6 pb-16">
<section className="grid gap-6 rounded-3xl border border-white/60 bg-white/80 p-6 shadow-xl shadow-rose-100/40 backdrop-blur-md md:grid-cols-3 md:p-8">
{highlights.map((item) => (
<article key={item.title} className="flex flex-col gap-4 rounded-2xl bg-white/70 p-5 shadow-sm">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-rose-100 text-rose-500">
<item.icon className="h-6 w-6" />
</div>
<h2 className="text-lg font-semibold text-slate-900">{item.title}</h2>
<p className="text-sm leading-relaxed text-slate-600">{item.description}</p>
</article>
))}
</section>
<section className="grid gap-8 rounded-3xl border border-sky-100 bg-gradient-to-br from-white via-sky-50 to-white p-6 shadow-lg md:grid-cols-2 md:p-10">
<div className="space-y-5">
<div className="inline-flex items-center gap-2 rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold text-sky-700">
<Heart className="h-4 w-4" />
So startet ihr
</div>
<h2 className="text-2xl font-semibold text-slate-900 md:text-3xl">In drei Schritten zu eurer Story</h2>
<ol className="space-y-4 text-sm leading-relaxed text-slate-600">
<li>
<span className="font-semibold text-slate-900">1. Aufgaben entdecken&nbsp;</span>
Stellt euer erstes Aufgabenpaket zusammen, das zu eurer Feier passt.
</li>
<li>
<span className="font-semibold text-slate-900">2. Event anlegen&nbsp;</span>
Benennt euer Event, legt Farben fest und erstellt den QR-Einladungslink.
</li>
<li>
<span className="font-semibold text-slate-900">3. Link teilen&nbsp;</span>
Gäste scannen, laden Fotos hoch und ihr entscheidet, was in euer Album kommt.
</li>
</ol>
<div className="relative z-10 flex min-h-svh flex-col">
<header className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-4 px-6 pb-4 pt-8">
<div className="space-y-1">
<Badge
variant="outline"
className={cn(
'border text-[11px] font-semibold uppercase tracking-[0.4em] transition-colors duration-300',
theme.headerBadge
)}
>
Event Admin · Fotospiel
</Badge>
<p className={cn('text-sm transition-colors duration-300', theme.headerSubtitle)}>
Euer mobiles Kontrollzentrum für Events, Workshops und Feiern
</p>
</div>
<aside className="space-y-4 rounded-2xl border border-rose-100 bg-white/90 p-6 text-sm leading-relaxed text-slate-600 shadow-md">
<p>
Ihr könnt jederzeit unterbrechen und später weiter machen. Falls ihr Fragen habt, meldet euch unter{' '}
<a className="font-medium text-rose-500 underline" href="mailto:hallo@fotospiel.de">
hallo@fotospiel.de
</a>
.
</p>
<p>
Nach dem Login geleiten wir euch automatisch zur geführten Einrichtung. Dort entscheidet ihr auch,
wann eure Gästegalerie sichtbar wird.
</p>
</aside>
</section>
</main>
<div className="flex flex-wrap items-center gap-3">
<LanguageSwitcher />
<Button
type="button"
variant="outline"
className={cn(
'items-center gap-2 border text-xs font-semibold uppercase tracking-wide',
isLightMode
? 'border-slate-200 text-slate-700 hover:bg-slate-100'
: 'border-white/30 text-white hover:bg-white/10'
)}
onClick={toggleMode}
>
{isLightMode ? (
<>
<Moon className="h-4 w-4" />
Dark Mode
</>
) : (
<>
<SunMedium className="h-4 w-4" />
Light Mode
</>
)}
</Button>
<Button
type="button"
variant="outline"
className={cn(
'border text-sm font-semibold transition-colors duration-300',
isLightMode ? 'border-slate-200 text-slate-700 hover:bg-slate-100' : 'border-white/30 text-white hover:bg-white/10'
)}
onClick={handleLoginRedirect}
disabled={isRedirecting}
>
{isRedirecting ? 'Weiterleitung …' : 'Login öffnen'}
</Button>
</div>
</header>
<footer className="border-t border-white/60 bg-white/70 py-6 text-center text-xs text-slate-500">
Fotospiel Eure Gäste gestalten eure Lieblingsmomente
</footer>
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-12 px-6 pb-16 pt-4">
<section className="grid gap-10 lg:grid-cols-[1.1fr_0.9fr]">
<div className="space-y-8">
<div className="space-y-4">
<p
className={cn(
'text-sm font-semibold uppercase tracking-[0.4em] transition-colors duration-300',
theme.heroEyebrow
)}
>
Willkommen im Event-Kontrollzentrum
</p>
<h1
className={cn(
'font-display text-4xl font-semibold leading-tight transition-colors duration-300 md:text-5xl',
theme.heroTitle
)}
>
Alles für eure Gästegeschichte in einer App
</h1>
<p
className={cn(
'text-base leading-relaxed transition-colors duration-300 md:text-lg',
theme.heroBody
)}
>
Der neue Startscreen begrüßt euch wie eine mobile App, führt euch in den Welcome Flow und lässt euch
jederzeit zurück ins Dashboard springen, sobald ihr bereit seid.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Button type="button" size="lg" onClick={handleLoginRedirect} disabled={isRedirecting}>
{isRedirecting ? 'Weiterleitung …' : 'Event Admin öffnen'}
<ArrowRight className="h-4 w-4" />
</Button>
<Button
type="button"
size="lg"
variant="ghost"
className={cn('transition-colors duration-300', theme.ghostButton)}
onClick={handleScrollToFlow}
>
Geführten Ablauf ansehen
<Heart className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-3">
{heroStats.map((stat) => (
<FrostedSurface
key={stat.label}
className={cn(
'rounded-3xl border px-5 py-4 shadow-2xl backdrop-blur transition-colors duration-300',
theme.statCard
)}
>
<p className={cn('text-2xl font-semibold', isLightMode ? 'text-slate-900' : 'text-white')}>{stat.value}</p>
<p
className={cn(
'text-xs font-semibold uppercase tracking-wide',
isLightMode ? 'text-slate-500' : 'text-white/70'
)}
>
{stat.label}
</p>
</FrostedSurface>
))}
</div>
</div>
<div className="relative">
<div className="pointer-events-none absolute inset-0 -z-10 translate-y-6 scale-105 rounded-[40px] bg-gradient-to-br from-rose-500/40 via-white/5 to-sky-500/30 blur-3xl" />
<div className="relative rounded-[40px] border border-white/15 bg-white/95 p-6 text-slate-900 shadow-[0_40px_90px_rgba(15,23,42,0.55)]">
<div className="flex items-center justify-between text-xs font-semibold text-slate-500">
<span>Fotospiel</span>
<span>Event Admin</span>
</div>
<div className="mt-4 rounded-3xl bg-slate-900/5 p-4">
<div className="flex items-center justify-between text-xs font-semibold text-slate-500">
<span>Heute</span>
<span>Live</span>
</div>
<div className="mt-4 space-y-3">
{['Mission Pack auswählen', 'Event Branding finalisieren', 'Einladungslink teilen'].map((item) => (
<div key={item} className="flex items-center justify-between rounded-2xl bg-white p-3 text-sm shadow">
<div>
<p className="font-semibold text-slate-900">{item}</p>
<p className="text-xs text-slate-500">Welcome Flow</p>
</div>
<Wand2 className="h-5 w-5 text-rose-500" />
</div>
))}
</div>
</div>
<div className="mt-6 rounded-3xl bg-slate-900 text-white">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-3 text-xs uppercase tracking-[0.3em] text-white/70">
<span>Live Moments</span>
<span>+28</span>
</div>
<div className="space-y-4 px-5 py-6">
{[
{ title: 'Anna lädt 6 Fotos hoch', subtitle: 'Event · Gartenpalast' },
{ title: 'Max reagiert auf Mission „Emotionen“', subtitle: 'Tasks · Emotion Board' },
{ title: 'QR-Link 124x gescannt', subtitle: 'Einladungen · Photobooth' },
].map((entry) => (
<div key={entry.title} className="space-y-1">
<p className="text-sm font-semibold">{entry.title}</p>
<p className="text-xs text-white/70">{entry.subtitle}</p>
</div>
))}
</div>
</div>
</div>
</div>
</section>
<section className="grid gap-6 lg:grid-cols-3">
{featureCards.map((feature) => (
<FrostedSurface
key={feature.title}
className={cn(
'flex flex-col gap-4 rounded-3xl border p-6 shadow-lg backdrop-blur transition-colors duration-300',
theme.featureCard
)}
>
<div
className={cn('flex items-center gap-3 text-sm', isLightMode ? 'text-rose-600' : 'text-white/80')}
>
<feature.icon className={cn('h-5 w-5', isLightMode ? 'text-rose-400' : 'text-rose-200')} />
<span>{feature.badge}</span>
</div>
<h2 className={cn('text-2xl font-semibold', isLightMode ? 'text-slate-900' : 'text-white')}>
{feature.title}
</h2>
<p
className={cn('text-sm leading-relaxed', isLightMode ? 'text-slate-600' : 'text-white/75')}
>
{feature.description}
</p>
</FrostedSurface>
))}
</section>
<section ref={flowSectionRef} className="grid gap-8 lg:grid-cols-[0.95fr_1.05fr]">
<FrostedSurface
className={cn('rounded-3xl border p-6 transition-colors duration-300', theme.timelineCard)}
>
<p
className={cn(
'text-xs font-semibold uppercase tracking-[0.4em]',
isLightMode ? 'text-rose-500' : 'text-rose-200'
)}
>
Guided Journey
</p>
<h3 className={cn('mt-3 text-3xl font-semibold', isLightMode ? 'text-slate-900' : 'text-white')}>
So leitet euch der Welcome Flow
</h3>
<div className="mt-6 space-y-4">
{timelineSteps.map((step, index) => (
<div
key={step.title}
className={cn(
'flex gap-4 rounded-2xl border p-4 transition-colors duration-300',
isLightMode ? 'border-slate-200 bg-white' : 'border-white/15 bg-white/5'
)}
>
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-2xl text-lg font-semibold',
isLightMode ? 'bg-rose-100 text-rose-600' : 'bg-rose-500/15 text-white'
)}
>
{index + 1}
</div>
<div>
<p className={cn('font-semibold', isLightMode ? 'text-slate-900' : 'text-white')}>
{step.title}
</p>
<p className={cn('text-sm', theme.timelineDescription)}>{step.description}</p>
</div>
</div>
))}
</div>
</FrostedSurface>
<div className="space-y-6">
<FrostedSurface className="flex flex-col gap-4 rounded-3xl border-white/15 bg-white/95 p-6 text-slate-900 shadow-2xl">
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-[0.3em] text-slate-500">
<span>Mobile Greeting</span>
<span>Neu</span>
</div>
<p className="text-2xl font-semibold text-slate-900">Fühlt sich an wie eine native App</p>
<p className="text-sm text-slate-600">
Mit großen Touch-Flächen, Animationen und frosted Cards wirkt der Startscreen wie die mobile Version
des Event Admins perfekt für TWA und Capacitor Builds.
</p>
<div className="mt-2 grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl bg-slate-900/5 p-4">
<p className="text-xs text-slate-500">CTA</p>
<p className="text-lg font-semibold text-slate-900">Event Admin öffnen</p>
<p className="text-sm text-slate-600">Leitet direkt zum OAuth Login</p>
</div>
<div className="rounded-2xl bg-slate-900/5 p-4">
<p className="text-xs text-slate-500">Scroll Action</p>
<p className="text-lg font-semibold text-slate-900">Journey ansehen</p>
<p className="text-sm text-slate-600">Smooth Scroll bis zur Timeline</p>
</div>
</div>
</FrostedSurface>
<div className="grid gap-4 sm:grid-cols-3">
{supportHighlights.map((support) => (
<FrostedSurface
key={support.title}
className={cn(
'flex flex-col gap-2 rounded-2xl border p-4 transition-colors duration-300',
theme.supportCard
)}
>
<support.icon className={cn('h-5 w-5', isLightMode ? 'text-rose-500' : 'text-rose-200')} />
<p className={cn('font-semibold', isLightMode ? 'text-slate-900' : 'text-white')}>
{support.title}
</p>
<p className={cn('text-sm', theme.supportDescription)}>{support.description}</p>
</FrostedSurface>
))}
</div>
</div>
</section>
<FrostedSurface
className={cn(
'flex flex-col gap-4 rounded-3xl border px-6 py-5 transition-colors duration-300 md:flex-row md:items-center md:justify-between',
theme.ctaSurface
)}
>
<div>
<p className={cn('text-sm uppercase tracking-[0.4em]', theme.ctaDetail)}>Bereit?</p>
<h3 className={cn('text-2xl font-semibold', isLightMode ? 'text-slate-900' : 'text-white')}>
Wechselt ins Dashboard, sobald euer Flow steht.
</h3>
<p className={cn('text-sm', theme.ctaDetail)}>
Alle Schritte lassen sich später direkt aus dem Event Admin wieder öffnen.
</p>
</div>
<Button type="button" size="lg" className="self-start md:self-auto" onClick={handleLoginRedirect} disabled={isRedirecting}>
{isRedirecting ? 'Weiterleitung …' : 'Zum Login'}
<ArrowRight className="h-4 w-4" />
</Button>
</FrostedSurface>
</main>
<footer className={cn('py-6 text-center text-xs transition-colors duration-300', theme.footer)}>
Fotospiel · Eure Gäste gestalten eure Lieblingsmomente
</footer>
</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { describe, expect, it, afterEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import WelcomeTeaserPage from '../WelcomeTeaserPage';
const navigateMock = vi.fn();
vi.mock('../../components/LanguageSwitcher', () => ({
LanguageSwitcher: () => <div data-testid="language-switcher" />,
}));
vi.mock('../../lib/navigation', () => ({
navigateToHref: (href: string) => navigateMock(href),
}));
describe('WelcomeTeaserPage', () => {
afterEach(() => {
document.body.classList.remove('tenant-admin-theme', 'tenant-admin-welcome-theme');
vi.clearAllMocks();
navigateMock.mockReset();
});
it('applies the tenant admin theme classes while mounted', () => {
const { unmount } = render(<WelcomeTeaserPage />);
expect(document.body.classList.contains('tenant-admin-theme')).toBe(true);
expect(document.body.classList.contains('tenant-admin-welcome-theme')).toBe(true);
unmount();
expect(document.body.classList.contains('tenant-admin-theme')).toBe(false);
expect(document.body.classList.contains('tenant-admin-welcome-theme')).toBe(false);
});
it('shows the hero stats and triggers the login redirect CTA', async () => {
render(<WelcomeTeaserPage />);
expect(screen.getByText('Events begleitet')).toBeInTheDocument();
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /event admin öffnen/i }));
expect(navigateMock).toHaveBeenCalledWith(expect.stringContaining('/event-admin/login'));
});
it('allows switching between light and dark presentation modes', async () => {
render(<WelcomeTeaserPage />);
const user = userEvent.setup();
const toggle = screen.getByRole('button', { name: /light mode/i });
await user.click(toggle);
expect(toggle).toHaveTextContent(/dark mode/i);
});
});

View File

@@ -50,6 +50,7 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale }
const [errors, setErrors] = useState<FieldErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
const [shouldFocusError, setShouldFocusError] = useState(false);
useEffect(() => {
if (!hasTriedSubmit) {
@@ -63,7 +64,7 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale }
}, [errors, hasTriedSubmit]);
useEffect(() => {
if (!hasTriedSubmit) {
if (!hasTriedSubmit || !shouldFocusError) {
return;
}
@@ -75,11 +76,13 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale }
const field = document.querySelector<HTMLInputElement>(`[name="${firstKey}"]`);
field?.scrollIntoView({ behavior: "smooth", block: "center" });
field?.focus();
}, [errors, hasTriedSubmit]);
setShouldFocusError(false);
}, [errors, hasTriedSubmit, shouldFocusError]);
const updateValue = (key: keyof typeof values, value: string | boolean) => {
setValues((current) => ({ ...current, [key]: value }));
setErrors((current) => ({ ...current, [key as string]: "" }));
setShouldFocusError(false);
};
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
@@ -129,11 +132,13 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale }
});
setErrors(fieldErrors);
setShouldFocusError(true);
toast.error(t("login.failed_generic", "Ungueltige Anmeldedaten"));
return;
}
toast.error(t("login.unexpected_error", "Beim Login ist ein Fehler aufgetreten."));
setShouldFocusError(false);
} catch (error) {
console.error("Login request failed", error);
toast.error(t("login.unexpected_error", "Beim Login ist ein Fehler aufgetreten."));
@@ -205,4 +210,3 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale }
</form>
);
}

View File

@@ -390,7 +390,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
<div className="md:col-span-1">
<label htmlFor="password_confirmation" className="block text-sm font-medium text-gray-700 mb-1">
{t('register.confirm_password')} {t('common:required')}
{t('register.password_confirmation')} {t('common:required')}
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
@@ -410,7 +410,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
}
}}
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.password_confirmation ? 'border-red-500' : 'border-gray-300'}`}
placeholder={t('register.confirm_password_placeholder')}
placeholder={t('register.password_confirmation_placeholder')}
/>
</div>
{errors.password_confirmation && <p className="text-sm text-red-600 mt-1">{errors.password_confirmation}</p>}
@@ -442,7 +442,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
onClick={() => setPrivacyOpen(true)}
className="text-[#FFB6C1] hover:underline inline bg-transparent border-none cursor-pointer p-0 font-medium"
>
{t('register.privacy_policy')}
{t('register.privacy_policy_link')}
</button>.
</label>
{errors.privacy_consent && <p className="mt-2 text-sm text-red-600">{errors.privacy_consent}</p>}
@@ -485,5 +485,3 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
</div>
);
}

View File

@@ -265,7 +265,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
<div className="md:col-span-1">
<label htmlFor="password_confirmation" className="block text-sm font-medium text-gray-700 mb-1">
{t('register.confirm_password')} {t('common:required')}
{t('register.password_confirmation')} {t('common:required')}
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
@@ -285,7 +285,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
}
}}
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.password_confirmation ? 'border-red-500' : 'border-gray-300'}`}
placeholder={t('register.confirm_password_placeholder')}
placeholder={t('register.password_confirmation_placeholder')}
/>
</div>
{errors.password_confirmation && <p key={`error-password_confirmation`} className="text-sm text-red-600 mt-1">{errors.password_confirmation}</p>}
@@ -313,7 +313,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
onClick={() => setPrivacyOpen(true)}
className="text-[#FFB6C1] hover:underline inline bg-transparent border-none cursor-pointer p-0 font-medium"
>
{t('register.privacy_policy')}
{t('register.privacy_policy_link')}
</button>.
</label>
{errors.privacy_consent && <p className="mt-2 text-sm text-red-600">{errors.privacy_consent}</p>}

View File

@@ -3,8 +3,6 @@ import { Head, usePage } from "@inertiajs/react";
import MarketingLayout from "@/layouts/mainWebsite";
import type { CheckoutPackage, GoogleProfilePrefill } from "./checkout/types";
import { CheckoutWizard } from "./checkout/CheckoutWizard";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
interface CheckoutWizardPageProps {
package: CheckoutPackage;
@@ -50,19 +48,6 @@ const CheckoutWizardPage: React.FC<CheckoutWizardPageProps> = ({
<Head title="Checkout Wizard" />
<div className="min-h-screen bg-muted/20 py-12">
<div className="mx-auto w-full max-w-4xl px-4">
{/* Abbruch-Button oben rechts */}
<div className="flex justify-end mb-4">
<Button
variant="ghost"
size="sm"
onClick={() => window.location.href = '/packages'}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4 mr-2" />
Abbrechen
</Button>
</div>
<CheckoutWizard
initialPackage={initialPackage}
packageOptions={dedupedOptions}

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import { PackageStep } from "./steps/PackageStep";
import { AuthStep } from "./steps/AuthStep";
import { ConfirmationStep } from "./steps/ConfirmationStep";
import { useAnalytics } from '@/hooks/useAnalytics';
import { cn } from "@/lib/utils";
const PaymentStep = lazy(() => import('./steps/PaymentStep').then((module) => ({ default: module.PaymentStep })));
@@ -69,6 +70,7 @@ const WizardBody: React.FC<{
googleProfile?: GoogleProfilePrefill | null;
onClearGoogleProfile?: () => void;
}> = ({ privacyHtml, googleProfile, onClearGoogleProfile }) => {
const primaryCtaClassName = "min-w-[160px] disabled:bg-muted disabled:text-muted-foreground disabled:hover:bg-muted disabled:hover:text-muted-foreground";
const { t } = useTranslation('marketing');
const {
currentStep,
@@ -160,13 +162,8 @@ const WizardBody: React.FC<{
return true;
}, [atLastStep, authUser, currentStep, isAuthenticated, isFreeSelected, paymentCompleted, selectedPackage]);
const shouldShowNextButton = useMemo(() => {
if (currentStep !== 'payment') {
return true;
}
return isFreeSelected || paymentCompleted;
}, [currentStep, isFreeSelected, paymentCompleted]);
const shouldShowNextButton = useMemo(() => currentStep !== 'confirmation', [currentStep]);
const highlightNextCta = currentStep === 'payment' && paymentCompleted;
const handleNext = useCallback(() => {
if (!canProceedToNextStep) {
@@ -200,8 +197,42 @@ const WizardBody: React.FC<{
window.location.href = '/event-admin';
}, []);
const primaryCta = useMemo(() => {
if (currentStep === 'confirmation') {
return {
label: t('checkout.confirmation_step.to_admin'),
onClick: handleGoToAdmin,
disabled: false,
};
}
if (!shouldShowNextButton) {
return null;
}
return {
label: t('checkout.next'),
onClick: handleNext,
disabled: !canProceedToNextStep,
};
}, [currentStep, handleGoToAdmin, handleNext, shouldShowNextButton, t, canProceedToNextStep]);
const ctaClassName = cn(primaryCtaClassName, highlightNextCta && 'animate-pulse ring-2 ring-primary/50 ring-offset-2 ring-offset-background');
return (
<div className="space-y-8">
{primaryCta && (
<div className="flex justify-end">
<Button
size="lg"
className={ctaClassName}
onClick={primaryCta.onClick}
disabled={primaryCta.disabled}
>
{primaryCta.label}
</Button>
</div>
)}
<div ref={progressRef} className="space-y-4">
<Progress value={progress} />
<Steps steps={stepConfig} currentStep={currentIndex >= 0 ? currentIndex : 0} />
@@ -226,13 +257,18 @@ const WizardBody: React.FC<{
)}
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Button variant="ghost" onClick={handlePrevious} disabled={currentIndex <= 0}>
{t('checkout.back')}
</Button>
{shouldShowNextButton ? (
<Button onClick={handleNext} disabled={!canProceedToNextStep}>
{t('checkout.next')}
{primaryCta ? (
<Button
size="lg"
className={ctaClassName}
onClick={primaryCta.onClick}
disabled={primaryCta.disabled}
>
{primaryCta.label}
</Button>
) : (
<div className="h-10 min-w-[128px]" aria-hidden="true" />

View File

@@ -64,8 +64,8 @@ describe('CheckoutWizard auth step navigation guard', () => {
/>,
);
const nextButton = screen.getByRole('button', { name: 'checkout.next' });
expect(nextButton).toBeDisabled();
const nextButtons = screen.getAllByRole('button', { name: 'checkout.next' });
nextButtons.forEach((button) => expect(button).toBeDisabled());
});
it('enables the next button once the user is authenticated on the auth step', () => {
@@ -79,8 +79,8 @@ describe('CheckoutWizard auth step navigation guard', () => {
/>,
);
const nextButton = screen.getByRole('button', { name: 'checkout.next' });
expect(nextButton).not.toBeDisabled();
const nextButtons = screen.getAllByRole('button', { name: 'checkout.next' });
nextButtons.forEach((button) => expect(button).not.toBeDisabled());
});
it('only renders the next button on the payment step after the payment is completed', async () => {
@@ -98,10 +98,12 @@ describe('CheckoutWizard auth step navigation guard', () => {
await screen.findByTestId('payment-step');
expect(screen.queryByRole('button', { name: 'checkout.next' })).toBeNull();
const nextButtons = screen.getAllByRole('button', { name: 'checkout.next' });
nextButtons.forEach((button) => expect(button).toBeDisabled());
fireEvent.click(screen.getByRole('button', { name: 'mark-complete' }));
expect(await screen.findByRole('button', { name: 'checkout.next' })).toBeEnabled();
const activatedButtons = await screen.findAllByRole('button', { name: 'checkout.next' });
activatedButtons.forEach((button) => expect(button).toBeEnabled());
});
});

View File

@@ -8,7 +8,9 @@ import LoginForm, { AuthUserPayload } from "../../../auth/LoginForm";
import RegisterForm, { RegisterSuccessPayload } from "../../../auth/RegisterForm";
import { Trans, useTranslation } from 'react-i18next';
import toast from 'react-hot-toast';
import { LoaderCircle } from "lucide-react";
import { ChevronDown, LoaderCircle } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
interface AuthStepProps {
privacyHtml: string;
@@ -43,6 +45,7 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml, googleProfile,
const { isAuthenticated, authUser, setAuthUser, nextStep, selectedPackage } = useCheckoutWizard();
const [mode, setMode] = useState<'login' | 'register'>('register');
const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false);
const [showGoogleHelper, setShowGoogleHelper] = useState(false);
useEffect(() => {
if (googleAuth?.status === 'signin') {
@@ -131,33 +134,54 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml, googleProfile,
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-2">
<Button
variant={mode === 'register' ? 'default' : 'outline'}
onClick={() => setMode('register')}
>
{t('checkout.auth_step.switch_to_register')}
</Button>
<Button
variant={mode === 'login' ? 'default' : 'outline'}
onClick={() => setMode('login')}
>
{t('checkout.auth_step.switch_to_login')}
</Button>
<Button
variant="outline"
onClick={handleGoogleLogin}
disabled={isRedirectingToGoogle}
className="flex items-center gap-2"
>
{isRedirectingToGoogle ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<GoogleIcon className="h-4 w-4" />
)}
{t('checkout.auth_step.continue_with_google')}
</Button>
</div>
<Collapsible open={showGoogleHelper} onOpenChange={setShowGoogleHelper} className="w-full space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Button
variant={mode === 'register' ? 'default' : 'outline'}
onClick={() => setMode('register')}
>
{t('checkout.auth_step.switch_to_register')}
</Button>
<Button
variant={mode === 'login' ? 'default' : 'outline'}
onClick={() => setMode('login')}
>
{t('checkout.auth_step.switch_to_login')}
</Button>
<div className="flex flex-1 justify-start gap-2 sm:flex-none">
<Button
variant="outline"
onClick={handleGoogleLogin}
disabled={isRedirectingToGoogle}
className="flex items-center gap-2"
>
{isRedirectingToGoogle ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<GoogleIcon className="h-4 w-4" />
)}
{t('checkout.auth_step.continue_with_google')}
</Button>
<CollapsibleTrigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center gap-1 rounded-full border border-muted-foreground/30 px-3 py-1 text-xs font-medium text-muted-foreground transition hover:border-muted-foreground/60 hover:text-foreground",
showGoogleHelper && "bg-muted/60 text-foreground"
)}
>
{t('checkout.auth_step.google_helper_badge')}
<ChevronDown className={cn("h-3 w-3 transition-transform", showGoogleHelper && "rotate-180")} />
</button>
</CollapsibleTrigger>
</div>
</div>
<CollapsibleContent>
<Alert className="border-dashed border-muted/60 bg-muted/20 text-xs sm:text-sm">
<AlertDescription>{t('checkout.auth_step.google_helper')}</AlertDescription>
</Alert>
</CollapsibleContent>
</Collapsible>
{googleAuth?.error && (
<Alert variant="destructive">

View File

@@ -1,8 +1,10 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useCheckoutWizard } from "../WizardContext";
import { Trans, useTranslation } from 'react-i18next';
import { Badge } from "@/components/ui/badge";
import { CalendarDays, QrCode, ClipboardList, Smartphone, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
interface ConfirmationStepProps {
onViewProfile?: () => void;
@@ -30,29 +32,99 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
const packageName = selectedPackage?.name ?? '';
const onboardingItems = [
{
key: 'event',
icon: CalendarDays,
},
{
key: 'invites',
icon: QrCode,
},
{
key: 'tasks',
icon: ClipboardList,
},
] as const;
return (
<div className="space-y-6">
<Alert className="border-primary/40 bg-primary/5">
<AlertTitle className="text-xl font-semibold">
{t('checkout.confirmation_step.welcome')}
</AlertTitle>
<AlertDescription className="space-y-2 text-base leading-relaxed">
<Trans
t={t}
i18nKey="checkout.confirmation_step.package_summary"
components={{ strong: <span className="font-semibold" /> }}
values={{ name: packageName }}
/>
<p className="text-sm text-muted-foreground">
{t('checkout.confirmation_step.email_followup')}
</p>
</AlertDescription>
</Alert>
<div className="overflow-hidden rounded-2xl border bg-gradient-to-br from-primary via-primary/70 to-primary/60 p-6 text-primary-foreground shadow-lg">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-3">
<Badge variant="secondary" className="bg-white/15 text-white shadow-sm ring-1 ring-white/30 backdrop-blur">
<Sparkles className="mr-1 h-3.5 w-3.5" />
{t('checkout.confirmation_step.hero_badge')}
</Badge>
<div className="space-y-2">
<h3 className="text-2xl font-semibold">{t('checkout.confirmation_step.hero_title')}</h3>
<p className="text-sm text-white/80">
<Trans
t={t}
i18nKey="checkout.confirmation_step.package_summary"
components={{ strong: <span className="font-semibold" /> }}
values={{ name: packageName }}
/>
</p>
<p className="text-sm text-white/80">{t('checkout.confirmation_step.hero_body')}</p>
</div>
</div>
<div className="rounded-xl border border-white/30 bg-white/10 px-5 py-4 text-sm text-white/90 shadow-inner backdrop-blur lg:max-w-sm">
<p>{t('checkout.confirmation_step.hero_next')}</p>
</div>
</div>
</div>
<div className="rounded-xl border bg-card/60 p-6 shadow-sm">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('checkout.confirmation_step.onboarding_title')}
</p>
<p className="text-sm text-muted-foreground">{t('checkout.confirmation_step.onboarding_subtitle')}</p>
</div>
<Badge variant="outline" className="text-xs font-medium">
{t('checkout.confirmation_step.onboarding_badge')}
</Badge>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-3">
{onboardingItems.map(({ key, icon: Icon }) => (
<div key={key} className="rounded-lg border bg-background/60 p-4 shadow-inner">
<div className={cn("mb-3 inline-flex rounded-full bg-primary/10 p-2 text-primary")}>
<Icon className="h-4 w-4" />
</div>
<p className="text-sm font-semibold">
{t(`checkout.confirmation_step.onboarding_items.${key}.title`)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t(`checkout.confirmation_step.onboarding_items.${key}.body`)}
</p>
</div>
))}
</div>
</div>
<div className="rounded-xl border bg-muted/30 p-6 shadow-inner">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold text-foreground">
{t('checkout.confirmation_step.control_center_title')}
</p>
<p className="text-xs text-muted-foreground">
{t('checkout.confirmation_step.control_center_body')}
</p>
</div>
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground">
<Smartphone className="h-4 w-4" />
{t('checkout.confirmation_step.control_center_hint')}
</div>
</div>
</div>
<div className="flex flex-wrap gap-3 justify-end">
<Button variant="outline" onClick={handleProfile}>
{t('checkout.confirmation_step.open_profile')}
</Button>
<Button onClick={handleAdmin}>{t('checkout.confirmation_step.to_admin')}</Button>
</div>
</div>
);

View File

@@ -1,8 +1,7 @@
import React, { useMemo, useState } from "react";
import React, { useMemo } from "react";
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { Check, Package as PackageIcon, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Check, Package as PackageIcon } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useCheckoutWizard } from "../WizardContext";
@@ -107,8 +106,7 @@ function PackageOption({ pkg, isActive, onSelect, t }: { pkg: CheckoutPackage; i
export const PackageStep: React.FC = () => {
const { t } = useTranslation('marketing');
const { selectedPackage, packageOptions, setSelectedPackage, resetPaymentState, nextStep } = useCheckoutWizard();
const [isLoading, setIsLoading] = useState(false);
const { selectedPackage, packageOptions, setSelectedPackage, resetPaymentState } = useCheckoutWizard();
// Early return if no package is selected
@@ -141,31 +139,10 @@ export const PackageStep: React.FC = () => {
resetPaymentState();
};
const handleNextStep = async () => {
setIsLoading(true);
// Kleine Verzögerung für bessere UX
setTimeout(() => {
nextStep();
setIsLoading(false);
}, 300);
};
return (
<div className="grid gap-8 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6">
<PackageSummary pkg={selectedPackage} t={t} />
<div className="flex justify-end">
<Button size="lg" onClick={handleNextStep} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('checkout.package_step.loading')}
</>
) : (
t('checkout.package_step.next_to_account')
)}
</Button>
</div>
</div>
<aside className="space-y-4">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">

View File

@@ -2,12 +2,14 @@ import React, { FormEvent, useCallback, useEffect, useMemo, useRef, useState } f
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { LoaderCircle, CheckCircle2, XCircle } from 'lucide-react';
import { LoaderCircle, CheckCircle2, XCircle, ShieldCheck, Receipt, Headphones } from 'lucide-react';
import { useCheckoutWizard } from '../WizardContext';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { previewCoupon as requestCouponPreview } from '@/lib/coupons';
import type { CouponPreviewResponse } from '@/types/coupon';
import { cn } from '@/lib/utils';
import toast from 'react-hot-toast';
type PaymentStatus = 'idle' | 'processing' | 'ready' | 'error';
@@ -27,6 +29,7 @@ declare global {
const PADDLE_SCRIPT_URL = 'https://cdn.paddle.com/paddle/v2/paddle.js';
const PADDLE_SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es', 'it', 'nl', 'pt', 'sv', 'da', 'fi', 'no'];
const PRIMARY_CTA_STYLES = 'min-w-[200px] disabled:bg-muted disabled:text-muted-foreground disabled:hover:bg-muted disabled:hover:text-muted-foreground';
export function resolvePaddleLocale(rawLocale?: string | null): string {
if (!rawLocale) {
@@ -94,11 +97,16 @@ async function loadPaddle(environment: PaddleEnvironment): Promise<typeof window
return configurePaddle(paddle, environment);
}
const PaddleCta: React.FC<{ onCheckout: () => Promise<void>; disabled: boolean; isProcessing: boolean }> = ({ onCheckout, disabled, isProcessing }) => {
const PaddleCta: React.FC<{ onCheckout: () => Promise<void>; disabled: boolean; isProcessing: boolean; className?: string }> = ({ onCheckout, disabled, isProcessing, className }) => {
const { t } = useTranslation('marketing');
return (
<Button size="lg" className="w-full sm:w-auto" disabled={disabled} onClick={onCheckout}>
<Button
size="lg"
className={cn('w-full sm:w-auto', className)}
disabled={disabled}
onClick={onCheckout}
>
{isProcessing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
{t('checkout.payment_step.pay_with_paddle')}
</Button>
@@ -130,6 +138,7 @@ export const PaymentStep: React.FC = () => {
const [couponNotice, setCouponNotice] = useState<string | null>(null);
const [couponLoading, setCouponLoading] = useState(false);
const paddleRef = useRef<typeof window.Paddle | null>(null);
const checkoutContainerRef = useRef<HTMLDivElement | null>(null);
const eventCallbackRef = useRef<(event: any) => void>();
const hasAutoAppliedCoupon = useRef(false);
const checkoutContainerClass = 'paddle-checkout-container';
@@ -297,6 +306,17 @@ export const PaymentStep: React.FC = () => {
setInlineActive(true);
setStatus('ready');
setMessage(t('checkout.payment_step.paddle_overlay_ready'));
if (typeof window !== 'undefined' && checkoutContainerRef.current) {
window.requestAnimationFrame(() => {
const rect = checkoutContainerRef.current?.getBoundingClientRect();
if (!rect) {
return;
}
const offset = 120;
const target = Math.max(window.scrollY + rect.top - offset, 0);
window.scrollTo({ top: target, behavior: 'smooth' });
});
}
return;
}
@@ -380,6 +400,7 @@ export const PaymentStep: React.FC = () => {
setMessage(t('checkout.payment_step.paddle_overlay_ready'));
setInlineActive(false);
setPaymentCompleted(true);
toast.success(t('checkout.payment_step.toast_success'));
}
if (event.name === 'checkout.closed') {
@@ -494,10 +515,60 @@ export const PaymentStep: React.FC = () => {
);
}
const TrustPill = ({ icon: Icon, label }: { icon: React.ElementType; label: string }) => (
<div className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-white backdrop-blur-sm">
<Icon className="h-4 w-4 text-white/80" />
<span>{label}</span>
</div>
);
const PaddleLogo = () => (
<div className="flex items-center gap-3 rounded-full bg-white/15 px-4 py-2 text-white shadow-inner backdrop-blur-sm">
<img
src="/paddle.logo.svg"
alt="Paddle"
className="h-6 w-auto brightness-0 invert"
loading="lazy"
decoding="async"
/>
<span className="text-xs font-semibold">{t('checkout.payment_step.paddle_partner')}</span>
</div>
);
return (
<div className="space-y-4">
<div className="rounded-lg border bg-card p-6 shadow-sm">
<div className="space-y-4">
<div className="rounded-2xl border bg-card p-6 shadow-sm">
<div className="space-y-6">
{!inlineActive && (
<div className="overflow-hidden rounded-2xl border bg-gradient-to-br from-[#001835] via-[#002b55] to-[#00407c] p-6 text-white shadow-md">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-4">
<PaddleLogo />
<div className="space-y-2">
<h3 className="text-2xl font-semibold">{t('checkout.payment_step.guided_title')}</h3>
<p className="text-sm text-white/80">{t('checkout.payment_step.guided_body')}</p>
</div>
<div className="flex flex-wrap gap-2">
<TrustPill icon={ShieldCheck} label={t('checkout.payment_step.trust_secure')} />
<TrustPill icon={Receipt} label={t('checkout.payment_step.trust_tax')} />
<TrustPill icon={Headphones} label={t('checkout.payment_step.trust_support')} />
</div>
</div>
<div className="flex flex-col items-stretch gap-3 w-full max-w-sm">
<PaddleCta
onCheckout={startPaddleCheckout}
disabled={status === 'processing'}
isProcessing={status === 'processing'}
className={cn('bg-white text-[#001835] hover:bg-white/90', PRIMARY_CTA_STYLES)}
/>
<p className="text-xs text-white/70 text-center">
{t('checkout.payment_step.guided_cta_hint')}
</p>
</div>
</div>
</div>
)}
<div className="space-y-3">
<form className="flex flex-col gap-2 sm:flex-row" onSubmit={handleCouponSubmit}>
<Input
@@ -564,6 +635,7 @@ export const PaymentStep: React.FC = () => {
onCheckout={startPaddleCheckout}
disabled={status === 'processing'}
isProcessing={status === 'processing'}
className={PRIMARY_CTA_STYLES}
/>
</div>
)}
@@ -586,7 +658,7 @@ export const PaymentStep: React.FC = () => {
</Alert>
)}
<div className={`${checkoutContainerClass} min-h-[360px]`} />
<div ref={checkoutContainerRef} className={`${checkoutContainerClass} min-h-[360px]`} />
<p className={`text-xs text-muted-foreground ${inlineActive ? 'text-center' : 'sm:text-right'}`}>
{t('checkout.payment_step.paddle_disclaimer')}