login-seiten neu designt, homepage neu designt. "so funktioniert's" ergänzt und Demo-Seite hinzugefügt. Paketansicht in mobile verbessert.

This commit is contained in:
Codex Agent
2025-11-03 11:47:19 +01:00
parent 073b51e2d5
commit 20eda6b4f8
23 changed files with 2481 additions and 587 deletions

View File

@@ -1,21 +1,23 @@
{
"login": {
"title": "Tenant-Admin",
"badge": "Fotospiel Tenant Admin",
"hero_title": "Event-Steuerung, die sich wie Zuhause anfühlt.",
"hero_subtitle": "Wechsle mühelos zwischen Mandanten, behalte Live-Uploads im Blick und teile elegante Einladungen alles in einer ruhigen Oberfläche.",
"title": "Event-Admin",
"badge": "Fotospiel Event Admin",
"hero_tagline": "Kontrolle behalten, entspannt bleiben",
"hero_title": "Das Cockpit für dein Fotospiel Event",
"hero_subtitle": "Moderation, Uploads und Kommunikation laufen hier zusammen mobil wie auf dem Desktop.",
"features": [
"Gestalte QR-Einladungen und druckfertige Layouts in wenigen Klicks passend zu eurer Marke.",
"Organisiere Aufgaben, Emotionen und Sammlungen für jeden Eventtyp ohne Excel-Chaos.",
"Bleib am Eventtag souverän mit Dashboards, Live-Statistiken und sofortiger Moderation."
"Überwache Uploads in Echtzeit und archiviere Highlights ohne Aufwand.",
"Erstelle Einladungen mit personalisierten QR-Codes und teile sie sofort.",
"Steuere Aufgaben, Emotionen und Slideshows direkt vom Event aus."
],
"lead": "Die Anmeldung erfolgt über unseren sicheren OAuth-Login und bringt dich direkt wieder zurück.",
"panel_copy": "Melde dich mit deinen Fotospiel-Admin-Zugangsdaten an. Wir schützen dein Konto mit OAuth 2.1 und mandantenbewussten Berechtigungen.",
"lead": "Du meldest dich über unseren sicheren OAuth-Login an und landest direkt im Event-Dashboard.",
"panel_title": "Melde dich an",
"panel_copy": "Logge dich mit deinem Fotospiel-Adminzugang ein. Wir schützen dein Konto mit OAuth 2.1 und klaren Rollenrechten.",
"cta": "Mit Fotospiel-Login fortfahren",
"loading": "Bitte warten …",
"oauth_error_title": "Login aktuell nicht möglich",
"oauth_error": "Anmeldung fehlgeschlagen: {{message}}",
"support": "Du brauchst Zugriff? Wende dich an den Tenant-Inhaber oder schreibe an support@fotospiel.de wir helfen gern weiter.",
"support": "Du brauchst Zugriff? Kontaktiere dein Event-Team oder schreib uns an support@fotospiel.de.",
"appearance_label": "Darstellung"
}
}

View File

@@ -1,21 +1,23 @@
{
"login": {
"title": "Tenant Admin",
"badge": "Fotospiel Tenant Admin",
"hero_title": "Event control that feels at home.",
"hero_subtitle": "Switch between tenants, monitor live uploads, and share beautiful invites — all in one calm workspace.",
"title": "Event Admin",
"badge": "Fotospiel Event Admin",
"hero_tagline": "Stay in control, stay relaxed",
"hero_title": "Your cockpit for every Fotospiel event",
"hero_subtitle": "Moderation, uploads, and communication come together in one calm workspace — on desktop and mobile.",
"features": [
"Design QR invites and print-ready layouts that match your brand in minutes.",
"Coordinate tasks, emotions, and achievements for every event flow.",
"Stay confident on event day with dashboards, live stats, and instant moderation."
"Monitor uploads in real time and archive highlights effortlessly.",
"Create invites with personalized QR codes and share them instantly.",
"Run tasks, emotions, and slideshows right from the event dashboard."
],
"lead": "You will be redirected to our secure OAuth login and come right back afterwards.",
"panel_copy": "Sign in with your Fotospiel admin credentials to continue. We secure your account with OAuth 2.1 and tenant-aware permissions.",
"lead": "Use our secure OAuth login and land directly in the event dashboard.",
"panel_title": "Sign in",
"panel_copy": "Sign in with your Fotospiel admin access. OAuth 2.1 and clear role permissions keep your account protected.",
"cta": "Continue with Fotospiel login",
"loading": "Signing you in …",
"oauth_error_title": "Login not possible right now",
"oauth_error": "Sign-in failed: {{message}}",
"support": "Need access? Contact your tenant owner or email support@fotospiel.de — we're happy to help.",
"support": "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.",
"appearance_label": "Appearance"
}
}

View File

@@ -30,6 +30,7 @@ import {
} from '../constants';
import { buildLimitWarnings } from '../lib/limitWarnings';
import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel';
import { buildDownloadFilename, normalizeEventDateSegment } from './components/invite-layout/fileNames';
import { DesignerCanvas } from './components/invite-layout/DesignerCanvas';
import {
CANVAS_HEIGHT,
@@ -252,6 +253,7 @@ export default function EventInvitesPage(): React.ReactElement {
const event = state.event;
const eventName = event ? renderEventName(event.name) : t('toolkit.titleFallback', 'Event');
const eventDate = event?.event_date ?? null;
const selectedInvite = React.useMemo(
() => state.invites.find((invite) => invite.id === selectedInviteId) ?? null,
@@ -587,7 +589,13 @@ export default function EventInvitesPage(): React.ReactElement {
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = `${selectedInvite.token || 'invite'}-qr.png`;
const eventDateSegment = normalizeEventDateSegment(eventDate);
const downloadName = buildDownloadFilename(
['QR Code fuer', eventName, eventDateSegment],
'png',
'qr-code',
);
link.download = downloadName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
@@ -596,7 +604,7 @@ export default function EventInvitesPage(): React.ReactElement {
console.error('[Invites] QR download failed', error);
setExportError(t('invites.export.qr.error', 'QR-Code konnte nicht gespeichert werden.'));
}
}, [selectedInvite, t]);
}, [selectedInvite, eventName, eventDate, t]);
const handleExportDownload = React.useCallback(
async (format: string) => {
@@ -609,6 +617,13 @@ export default function EventInvitesPage(): React.ReactElement {
setExportDownloadBusy(busyKey);
setExportError(null);
const eventDateSegment = normalizeEventDateSegment(eventDate);
const filename = buildDownloadFilename(
['Einladungslayout', eventName, exportLayout.name ?? null, eventDateSegment],
normalizedFormat,
'einladungslayout',
);
const exportOptions = {
elements: exportElements,
accentColor: exportPreview.accentColor,
@@ -623,19 +638,17 @@ export default function EventInvitesPage(): React.ReactElement {
selectedId: null,
} as const;
const filenameStem = `${selectedInvite.token || 'invite'}-${exportLayout.id}`;
try {
if (normalizedFormat === 'png') {
const dataUrl = await generatePngDataUrl(exportOptions);
await triggerDownloadFromDataUrl(dataUrl, `${filenameStem}.png`);
await triggerDownloadFromDataUrl(dataUrl, filename);
} else if (normalizedFormat === 'pdf') {
const pdfBytes = await generatePdfBytes(
exportOptions,
'a4',
'portrait',
);
triggerDownloadFromBlob(new Blob([pdfBytes as any], { type: 'application/pdf' }), `${filenameStem}.pdf`);
triggerDownloadFromBlob(new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' }), filename);
} else {
setExportError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'));
}
@@ -646,7 +659,7 @@ export default function EventInvitesPage(): React.ReactElement {
setExportDownloadBusy(null);
}
},
[selectedInvite, exportLayout, exportPreview, exportElements, exportQr, exportLogo, t]
[selectedInvite, exportLayout, exportPreview, exportElements, exportQr, exportLogo, eventName, eventDate, t]
);
const handleExportPrint = React.useCallback(
@@ -809,6 +822,7 @@ export default function EventInvitesPage(): React.ReactElement {
<InviteLayoutCustomizerPanel
invite={selectedInvite ?? null}
eventName={eventName}
eventDate={eventDate}
saving={customizerSaving}
resetting={customizerResetting}
onSave={handleSaveCustomization}

View File

@@ -53,108 +53,149 @@ export default function LoginPage(): JSX.Element {
}));
}, [t]);
const heroTagline = t('login.hero_tagline', 'Stay in control, stay relaxed');
const heroTitle = t('login.hero_title', 'Your cockpit for every Fotospiel event');
const heroSubtitle = t('login.hero_subtitle', 'Moderation, uploads, and communication come together in one calm workspace — on desktop and mobile.');
const panelTitle = t('login.panel_title', t('login.title', 'Event Admin'));
const leadCopy = t('login.lead', 'Use our secure OAuth login and land directly in the event dashboard.');
const panelCopy = t('login.panel_copy', 'Sign in with your Fotospiel admin access. OAuth 2.1 and clear role permissions keep your account protected.');
const supportCopy = t('login.support', "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.");
const isLoading = status === 'loading';
return (
<div className="relative min-h-screen overflow-hidden bg-[var(--brand-navy)] text-white">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,var(--brand-rose-soft)_0%,rgba(3,7,18,0.65)_55%,rgba(15,76,117,0.9)_100%)] opacity-95" />
<div className="pointer-events-none absolute inset-y-0 right-[-25%] w-[55%] bg-[radial-gradient(circle_at_center,var(--brand-sky)_0%,rgba(255,255,255,0)_70%)] opacity-40" />
<div className="pointer-events-none absolute inset-y-0 left-[-20%] w-[45%] bg-[radial-gradient(circle_at_center,var(--brand-rose)_0%,rgba(255,255,255,0)_65%)] opacity-35" />
<div className="relative min-h-svh overflow-hidden bg-slate-950 text-white">
<div
aria-hidden
className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.35),_transparent_60%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.35),_transparent_55%)] blur-3xl"
/>
<div className="absolute inset-0 bg-gradient-to-br from-gray-950 via-gray-950/70 to-[#1a0f1f]" aria-hidden />
<div className="relative z-10 flex min-h-screen flex-col">
<header className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 pt-10">
<div className="relative z-10 flex min-h-svh flex-col">
<header className="mx-auto flex w-full max-w-5xl items-center justify-between px-4 pt-10 sm:px-6 lg:px-8">
<div className="flex items-center gap-3">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/15 shadow-lg shadow-black/10 backdrop-blur">
<AppLogoIcon className="h-7 w-7 text-white" />
</span>
<div>
<p className="text-xs uppercase tracking-[0.35em] text-white/70">{t('login.badge')}</p>
<div className="space-y-1">
<p className="text-xs uppercase tracking-[0.35em] text-white/70">{t('login.badge', 'Fotospiel Event Admin')}</p>
<p className="text-lg font-semibold">Fotospiel</p>
</div>
</div>
<AppearanceToggleDropdown />
</header>
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-6 pb-16 pt-12">
<div className="grid flex-1 gap-12 lg:grid-cols-[0.95fr_1.05fr]" data-testid="tenant-login-layout">
<section className="order-2 space-y-10 lg:order-1">
<div className="space-y-5">
<span className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-4 py-1 text-sm font-medium text-white/80 backdrop-blur">
<Sparkles className="h-4 w-4 text-[var(--brand-gold)]" />
{t('login.badge')}
</span>
<h1 className="text-4xl font-semibold leading-tight sm:text-5xl">
{t('login.hero_title')}
</h1>
<p className="max-w-xl text-base text-white/80 sm:text-lg">
{t('login.hero_subtitle')}
</p>
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col px-4 pb-16 pt-12 sm:px-6 lg:px-8">
<div className="mb-10 space-y-5 text-center md:hidden">
<span className="inline-flex items-center gap-2 rounded-full border border-white/25 bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.35em] text-white/70">
<Sparkles className="h-3.5 w-3.5" aria-hidden />
{heroTagline}
</span>
<h1 className="text-3xl font-semibold leading-tight sm:text-4xl">{heroTitle}</h1>
<p className="mx-auto max-w-xl text-sm text-white/80 sm:text-base">{heroSubtitle}</p>
</div>
<div className="grid flex-1 gap-10 md:grid-cols-[1.08fr_1fr]" data-testid="tenant-login-layout">
<section className="relative hidden h-full flex-col justify-between gap-10 overflow-hidden rounded-3xl border border-white/15 bg-gradient-to-br from-[#1d1937] via-[#2a1134] to-[#ff5f87] p-10 md:flex">
<div aria-hidden className="pointer-events-none absolute inset-0 opacity-40">
<div className="absolute -inset-16 bg-[radial-gradient(circle_at_top,_rgba(255,255,255,0.5),_transparent_55%),radial-gradient(circle_at_bottom_left,_rgba(236,72,153,0.35),_transparent_60%)]" />
</div>
{featureList.length ? (
<div className="grid gap-4 sm:grid-cols-2">
{featureList.map(({ text, Icon }, index) => (
<div
key={`login-feature-${index}`}
className="group relative overflow-hidden rounded-2xl border border-white/15 bg-white/10 p-5 shadow-lg shadow-black/5 backdrop-blur transition hover:border-white/35"
>
<Icon className="mb-3 h-5 w-5 text-[var(--brand-gold)] transition group-hover:text-white" />
<p className="text-sm text-white/90">{text}</p>
</div>
))}
<div className="relative z-10 flex flex-col gap-6">
<div className="flex items-center gap-3 text-xs font-semibold uppercase tracking-[0.45em] text-white/70">
<Sparkles className="h-4 w-4" aria-hidden />
<span className="font-sans-marketing">{heroTagline}</span>
</div>
) : null}
<p className="flex items-center gap-2 text-sm text-white/70">
<ArrowRight className="h-4 w-4" />
{t('login.lead')}
<div className="space-y-4">
<h2 className="font-display text-3xl leading-tight sm:text-4xl">{heroTitle}</h2>
<p className="text-sm text-white/80 sm:text-base">{heroSubtitle}</p>
</div>
{featureList.length ? (
<ul className="space-y-4">
{featureList.map(({ text, Icon }, index) => (
<li key={`login-feature-desktop-${index}`} className="flex items-start gap-3">
<span className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/15 backdrop-blur">
<Icon className="h-4 w-4" aria-hidden />
</span>
<span className="space-y-1">
<p className="text-sm font-semibold tracking-tight sm:text-base">{text}</p>
</span>
</li>
))}
</ul>
) : null}
</div>
<p className="relative z-10 flex items-center gap-2 text-xs font-medium text-white/75">
<ArrowRight className="h-4 w-4" aria-hidden />
{leadCopy}
</p>
</section>
<section className="order-1 lg:order-2">
<div className="relative">
<div className="absolute inset-0 -translate-y-4 translate-x-6 scale-95 rounded-3xl bg-white/20 opacity-50 blur-2xl" />
<div className="relative overflow-hidden rounded-3xl border border-white/20 bg-white/90 p-10 text-slate-900 shadow-2xl shadow-black/20 backdrop-blur-xl dark:border-white/10 dark:bg-slate-900/90 dark:text-slate-50">
<div className="space-y-6">
<div className="space-y-2">
<h2 className="text-2xl font-semibold">{t('login.title')}</h2>
<p className="text-sm text-slate-600 dark:text-slate-300">{t('login.panel_copy')}</p>
</div>
{oauthError ? (
<Alert className="border-red-400/40 bg-red-50/80 text-red-800 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100">
<AlertTitle>{t('login.oauth_error_title')}</AlertTitle>
<AlertDescription>{t('login.oauth_error', { message: oauthError })}</AlertDescription>
</Alert>
) : null}
<Button
size="lg"
className="group flex w-full items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[var(--brand-rose)] via-[var(--brand-gold)] to-[var(--brand-sky)] px-8 py-3 text-base font-semibold text-slate-900 shadow-lg shadow-rose-400/30 transition hover:opacity-90 focus-visible:ring-4 focus-visible:ring-brand-rose/40 dark:text-slate-900"
disabled={isLoading}
onClick={() => login(redirectTarget)}
>
{isLoading ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
{t('login.loading')}
</>
) : (
<>
{t('login.cta')}
<ArrowRight className="h-5 w-5 transition group-hover:translate-x-1" />
</>
)}
</Button>
<p className="text-xs leading-relaxed text-slate-500 dark:text-slate-300">
{t('login.support')}
</p>
<section className="relative">
<div className="absolute inset-0 -translate-y-4 translate-x-4 scale-95 rounded-3xl bg-white/20 opacity-45 blur-2xl" aria-hidden />
<div className="relative flex h-full flex-col justify-between gap-6 overflow-hidden rounded-3xl border border-white/15 bg-white/95 p-8 text-slate-900 shadow-2xl shadow-rose-400/20 backdrop-blur-xl dark:border-white/10 dark:bg-slate-900/95 dark:text-slate-50">
<div className="space-y-3">
<span className="inline-flex items-center gap-2 rounded-full border border-rose-200/50 bg-rose-50/70 px-3 py-1 text-xs font-semibold uppercase tracking-[0.35em] text-rose-500/80 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200/80">
{t('login.badge', 'Fotospiel Event Admin')}
</span>
<div className="space-y-1">
<h2 className="text-2xl font-semibold">{panelTitle}</h2>
<p className="text-sm text-slate-600 dark:text-slate-300">{panelCopy}</p>
</div>
</div>
{oauthError ? (
<Alert className="border-red-400/40 bg-red-50/80 text-red-800 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100">
<AlertTitle>{t('login.oauth_error_title')}</AlertTitle>
<AlertDescription>{t('login.oauth_error', { message: oauthError })}</AlertDescription>
</Alert>
) : null}
<Button
size="lg"
className="group flex w-full items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-8 py-3 text-base font-semibold text-white shadow-lg shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5] focus-visible:ring-4 focus-visible:ring-rose-400/40"
disabled={isLoading}
onClick={() => login(redirectTarget)}
>
{isLoading ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
{t('login.loading', 'Signing you in …')}
</>
) : (
<>
{t('login.cta', 'Continue with Fotospiel login')}
<ArrowRight className="h-5 w-5 transition group-hover:translate-x-1" />
</>
)}
</Button>
<div className="space-y-2 text-xs leading-relaxed text-slate-500 dark:text-slate-300">
<p>{leadCopy}</p>
<p>{supportCopy}</p>
</div>
</div>
</section>
</div>
{featureList.length ? (
<div className="mt-10 grid gap-4 md:hidden">
{featureList.map(({ text, Icon }, index) => (
<div
key={`login-feature-mobile-${index}`}
className="flex items-start gap-3 rounded-2xl border border-white/15 bg-white/10 p-4 text-sm text-white/85 shadow-lg shadow-black/15 backdrop-blur"
>
<span className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/15">
<Icon className="h-4 w-4" aria-hidden />
</span>
<p>{text}</p>
</div>
))}
</div>
) : null}
</main>
</div>
</div>

View File

@@ -54,6 +54,7 @@ import {
triggerDownloadFromBlob,
triggerDownloadFromDataUrl,
} from './invite-layout/export-utils';
import { buildDownloadFilename, normalizeEventDateSegment } from './invite-layout/fileNames';
export type { QrLayoutCustomization } from './invite-layout/schema';
@@ -182,6 +183,7 @@ function serializeElements(elements: LayoutElement[], context: LayoutSerializati
type InviteLayoutCustomizerPanelProps = {
invite: EventQrInvite | null;
eventName: string;
eventDate: string | null;
saving: boolean;
resetting: boolean;
onSave: (customization: QrLayoutCustomization) => Promise<void>;
@@ -199,6 +201,7 @@ const ZOOM_STEP = 0.05;
export function InviteLayoutCustomizerPanel({
invite,
eventName,
eventDate,
saving,
resetting,
onSave,
@@ -1391,7 +1394,12 @@ export function InviteLayoutCustomizerPanel({
}
const normalizedFormat = format.toLowerCase();
const filenameStem = `${invite.token || 'invite'}-${normalizedFormat}`;
const eventDateSegment = normalizeEventDateSegment(eventDate);
const filename = buildDownloadFilename(
['Einladungslayout', eventName, activeLayout?.name ?? null, eventDateSegment],
normalizedFormat,
'einladungslayout',
);
setDownloadBusy(normalizedFormat);
setError(null);
@@ -1412,14 +1420,14 @@ export function InviteLayoutCustomizerPanel({
if (normalizedFormat === 'png') {
const dataUrl = await generatePngDataUrl(exportOptions);
await triggerDownloadFromDataUrl(dataUrl, `${filenameStem}.png`);
await triggerDownloadFromDataUrl(dataUrl, filename);
} else if (normalizedFormat === 'pdf') {
const pdfBytes = await generatePdfBytes(
exportOptions,
'a4',
'portrait',
);
triggerDownloadFromBlob(new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' }), `${filenameStem}.pdf`);
triggerDownloadFromBlob(new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' }), filename);
} else {
throw new Error(`Unsupported format: ${normalizedFormat}`);
}
@@ -1509,34 +1517,8 @@ export function InviteLayoutCustomizerPanel({
return (
<div className="space-y-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-xl font-semibold text-foreground">{t('invites.customizer.heading', 'Layout anpassen')}</h2>
<p className="text-sm text-muted-foreground">{t('invites.customizer.copy', 'Bearbeite Texte, Farben und Positionen direkt neben der Live-Vorschau. Änderungen werden sofort sichtbar.')}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={handleResetClick} disabled={resetting || saving}>
{resetting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RotateCcw className="mr-2 h-4 w-4" />}
{t('invites.customizer.actions.reset', 'Zurücksetzen')}
</Button>
<Button
type="button"
onClick={() => {
if (formRef.current) {
if (typeof formRef.current.requestSubmit === 'function') {
formRef.current.requestSubmit();
} else {
formRef.current.submit();
}
}
}}
className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white shadow-lg shadow-rose-500/20"
disabled={saving || resetting}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
{t('invites.customizer.actions.save', 'Layout speichern')}
</Button>
</div>
<div className="hidden flex-wrap items-center justify-end gap-2 lg:flex">
{renderActionButtons('inline')}
</div>
{error ? (
@@ -1845,7 +1827,7 @@ export function InviteLayoutCustomizerPanel({
</Tabs>
</section>
<div className={cn('mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end', showFloatingActions ? 'hidden' : 'flex')}>
<div className={cn('mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end lg:hidden', showFloatingActions ? 'hidden' : 'flex')}>
{renderActionButtons('inline')}
</div>
<div ref={actionsSentinelRef} className="h-1 w-full" />

View File

@@ -239,7 +239,7 @@ export function DesignerCanvas({
const elementId = target.elementId;
const bounds = target.getBoundingRect();
let nextPatch: Partial<LayoutElement> = {
const nextPatch: Partial<LayoutElement> = {
x: clamp(Math.round(bounds.left ?? 0), 20, CANVAS_WIDTH - 20),
y: clamp(Math.round(bounds.top ?? 0), 20, CANVAS_HEIGHT - 20),
};

View File

@@ -0,0 +1,52 @@
export function sanitizeFilenameSegment(value: string | null | undefined, fallback = ''): string {
if (typeof value !== 'string') {
return fallback;
}
const normalized = value
.trim()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '');
const slug = normalized.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase();
return slug.length ? slug : fallback;
}
export function normalizeEventDateSegment(dateValue: string | null | undefined): string | null {
if (!dateValue) {
return null;
}
const trimmed = dateValue.trim();
if (!trimmed) {
return null;
}
const isoCandidate = trimmed.slice(0, 10);
if (/^\d{4}-\d{2}-\d{2}$/.test(isoCandidate)) {
return isoCandidate;
}
const parsed = new Date(trimmed);
if (Number.isNaN(parsed.getTime())) {
return null;
}
return parsed.toISOString().slice(0, 10);
}
export function buildDownloadFilename(
parts: Array<string | null | undefined>,
extension: string,
fallback = 'download',
): string {
const sanitizedParts = parts
.map((part) => sanitizeFilenameSegment(part, ''))
.filter((segment) => segment.length > 0);
const base = sanitizedParts.length ? sanitizedParts.join('-') : fallback;
const cleanExtension = sanitizeFilenameSegment(extension, '').replace(/[^a-z0-9]/gi, '') || 'bin';
return `${base}.${cleanExtension.toLowerCase()}`;
}

View File

@@ -8,11 +8,11 @@ import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Sun, Moon, Menu, X, ChevronRight } from 'lucide-react';
import { Sun, Moon, Menu, X, Languages, UserRound } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
NavigationMenu,
@@ -31,10 +31,15 @@ const Header: React.FC = () => {
const { localizedPath } = useLocalizedRoutes();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const toggleTheme = () => {
const newAppearance = appearance === 'dark' ? 'light' : 'dark';
updateAppearance(newAppearance);
const setTheme = useCallback((mode: 'light' | 'dark') => {
if (appearance !== mode) {
updateAppearance(mode);
}
setMobileMenuOpen(false);
}, [appearance, updateAppearance]);
const toggleTheme = () => {
setTheme(appearance === 'dark' ? 'light' : 'dark');
};
const handleLanguageChange = useCallback(async (value: string) => {
@@ -68,49 +73,61 @@ const Header: React.FC = () => {
});
};
const navItems = useMemo(() => ([
{
key: 'home',
label: t('header.home', 'Home'),
href: localizedPath('/'),
},
{
key: 'packages',
label: t('header.packages', 'Pakete'),
href: localizedPath('/packages'),
},
{
key: 'blog',
label: t('header.blog', 'Blog'),
href: localizedPath('/blog'),
},
{
key: 'occasions',
label: t('header.occasions.label', 'Anlässe'),
children: [
{
key: 'wedding',
label: t('header.occasions.wedding', 'Hochzeit'),
href: localizedPath('/anlaesse/hochzeit'),
},
{
key: 'birthday',
label: t('header.occasions.birthday', 'Geburtstag'),
href: localizedPath('/anlaesse/geburtstag'),
},
{
key: 'corporate',
label: t('header.occasions.corporate', 'Firmenevent'),
href: localizedPath('/anlaesse/firmenevent'),
},
],
},
{
key: 'contact',
label: t('header.contact', 'Kontakt'),
href: localizedPath('/kontakt'),
},
]), [localizedPath, t]);
const ctaHref = localizedPath('/demo');
const navItems = useMemo(() => {
const homeHref = localizedPath('/');
const howItWorksHref = localizedPath('/so-funktionierts');
return [
{
key: 'howItWorks',
label: t('header.how_it_works', "So geht's"),
href: howItWorksHref,
},
{
key: 'packages',
label: t('header.packages', 'Pakete'),
href: localizedPath('/packages'),
},
{
key: 'occasions',
label: t('header.occasions.label', 'Anlässe'),
children: [
{
key: 'wedding',
label: t('header.occasions.wedding', 'Hochzeiten'),
href: localizedPath('/anlaesse/hochzeit'),
},
{
key: 'birthday',
label: t('header.occasions.birthday', 'Geburtstage'),
href: localizedPath('/anlaesse/geburtstag'),
},
{
key: 'corporate',
label: t('header.occasions.corporate', 'Firmenfeiern'),
href: localizedPath('/anlaesse/firmenevent'),
},
{
key: 'confirmation',
label: t('header.occasions.confirmation', 'Konfirmation/Jugendweihe'),
href: localizedPath('/anlaesse/konfirmation'),
},
],
},
{
key: 'blog',
label: t('header.blog', 'Blog'),
href: localizedPath('/blog'),
},
{
key: 'contact',
label: t('header.contact', 'Kontakt'),
href: localizedPath('/kontakt'),
},
];
}, [localizedPath, t]);
const handleNavSelect = useCallback(() => setMobileMenuOpen(false), []);
@@ -125,25 +142,27 @@ const Header: React.FC = () => {
</span>
</Link>
<NavigationMenu className="hidden lg:flex flex-1 justify-center" viewport={false}>
<NavigationMenuList className="gap-2">
<NavigationMenuList className="gap-1.5">
{navItems.map((item) => (
<NavigationMenuItem key={item.key}>
{item.children ? (
<>
<NavigationMenuTrigger className="bg-transparent text-gray-700 hover:text-pink-600 hover:bg-pink-50 dark:text-gray-300 dark:hover:text-pink-400 dark:hover:bg-pink-950/20 font-sans-marketing !text-lg font-medium">
<NavigationMenuTrigger className="bg-transparent px-3 py-1.5 !text-lg font-medium text-gray-700 hover:text-pink-600 hover:bg-pink-50 dark:text-gray-300 dark:hover:text-pink-400 dark:hover:bg-pink-950/20 font-sans-marketing">
{item.label}
</NavigationMenuTrigger>
<NavigationMenuContent className="min-w-[220px] rounded-md border bg-popover p-3 shadow-lg">
<NavigationMenuContent className="min-w-[260px] rounded-md border bg-popover p-3 shadow-lg">
<ul className="flex flex-col gap-1">
{item.children.map((child) => (
<li key={child.key}>
<NavigationMenuLink asChild>
<Link
href={child.href}
className="flex items-center justify-between rounded-md px-3 py-2 !text-lg font-medium text-gray-700 transition hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60 font-sans-marketing"
className="flex w-full items-center rounded-md px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60 font-sans-marketing"
>
{child.label}
<ChevronRight className="h-4 w-4" />
<span aria-hidden className="mr-2 flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
</span>
<span className="whitespace-nowrap">{child.label}</span>
</Link>
</NavigationMenuLink>
</li>
@@ -157,7 +176,7 @@ const Header: React.FC = () => {
href={item.href}
className={cn(
navigationMenuTriggerStyle(),
"bg-transparent !text-lg font-medium text-gray-700 hover:bg-pink-50 hover:text-pink-600 dark:text-gray-300 dark:hover:bg-pink-950/20 dark:hover:text-pink-400 font-sans-marketing"
"bg-transparent px-3 py-1.5 !text-lg font-medium text-gray-700 hover:bg-pink-50 hover:text-pink-600 dark:text-gray-300 dark:hover:bg-pink-950/20 dark:hover:text-pink-400 font-sans-marketing"
)}
>
{item.label}
@@ -168,26 +187,49 @@ const Header: React.FC = () => {
))}
</NavigationMenuList>
</NavigationMenu>
<div className="hidden lg:flex items-center space-x-4">
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
className="h-8 w-8"
>
<Sun className={cn("h-4 w-4", appearance === "dark" && "hidden")} />
<Moon className={cn("h-4 w-4", appearance !== "dark" && "hidden")} />
<span className="sr-only">Theme Toggle</span>
<div className="hidden lg:flex items-center space-x-2">
<Button asChild size="sm" className="bg-[#FF5F87] hover:bg-[#ff4674] text-white shadow-md shadow-rose-500/20">
<Link href={ctaHref} className="font-sans-marketing font-semibold px-3">
{t('header.cta', 'Jetzt ausprobieren')}
</Link>
</Button>
<Select value={i18n.language || 'de'} onValueChange={handleLanguageChange}>
<SelectTrigger className="w-[70px] h-8">
<SelectValue placeholder={t('common.ui.language_select')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="de">DE</SelectItem>
<SelectItem value="en">EN</SelectItem>
</SelectContent>
</Select>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label={t('header.utility', 'Darstellung und Sprache öffnen')}
>
<Languages className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="end" forceMount>
<DropdownMenuLabel className="font-sans-marketing text-xs uppercase tracking-wide text-muted-foreground">
{t('header.appearance', 'Darstellung')}
</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setTheme('light')} className="font-sans-marketing">
<Sun className="mr-2 h-4 w-4" />
{t('header.appearance_light', 'Hell')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')} className="font-sans-marketing">
<Moon className="mr-2 h-4 w-4" />
{t('header.appearance_dark', 'Dunkel')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="font-sans-marketing text-xs uppercase tracking-wide text-muted-foreground">
{t('header.language', 'Sprache')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup value={i18n.language || 'de'} onValueChange={handleLanguageChange}>
<DropdownMenuRadioItem value="de" className="font-sans-marketing">
Deutsch
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="en" className="font-sans-marketing">
English
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{auth.user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -223,14 +265,16 @@ const Header: React.FC = () => {
</DropdownMenuContent>
</DropdownMenu>
) : (
<>
<Button asChild variant="ghost" size="icon" className="h-8 w-8">
<Link
href={localizedPath('/login')}
className="text-gray-700 hover:text-pink-600 dark:text-gray-300 dark:hover:text-pink-400 font-medium transition-colors duration-200 font-sans-marketing"
className="flex items-center justify-center text-gray-700 hover:text-pink-600 dark:text-gray-300 dark:hover:text-pink-400"
aria-label={t('header.login')}
>
{t('header.login')}
<UserRound className="h-4 w-4" />
<span className="sr-only">{t('header.login')}</span>
</Link>
</>
</Button>
)}
</div>
<div className="flex items-center lg:hidden">
@@ -279,11 +323,11 @@ const Header: React.FC = () => {
<SheetClose asChild key={child.key}>
<Link
href={child.href}
className="flex items-center justify-between rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60 font-sans-marketing"
className="flex w-full items-center rounded-md border border-transparent px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60 font-sans-marketing"
onClick={handleNavSelect}
>
<span aria-hidden className="mr-2 text-muted-foreground"></span>
<span>{child.label}</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</Link>
</SheetClose>
))}
@@ -305,6 +349,15 @@ const Header: React.FC = () => {
</nav>
<Separator />
<div className="flex flex-col gap-4">
<SheetClose asChild>
<Link
href={ctaHref}
className="rounded-full bg-[#FF5F87] px-4 py-3 text-center text-base font-semibold text-white shadow-md shadow-rose-500/20 transition hover:bg-[#ff4674] font-sans-marketing"
onClick={handleNavSelect}
>
{t('header.cta', 'Jetzt ausprobieren')}
</Link>
</SheetClose>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Darstellung</span>
<Button

View File

@@ -1,7 +1,10 @@
import AppLogoIcon from '@/components/app-logo-icon';
import { home } from '@/routes';
import { Button } from '@/components/ui/button';
import { home, packages } from '@/routes';
import { Link } from '@inertiajs/react';
import { Sparkles, Camera, ShieldCheck } from 'lucide-react';
import { type PropsWithChildren } from 'react';
import { useTranslation } from 'react-i18next';
interface AuthLayoutProps {
name?: string;
@@ -10,24 +13,105 @@ interface AuthLayoutProps {
}
export default function AuthSimpleLayout({ children, title, description }: PropsWithChildren<AuthLayoutProps>) {
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10">
<div className="w-full max-w-sm">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-4">
<Link href={home()} className="flex flex-col items-center gap-2 font-medium">
<div className="mb-1 flex h-9 w-9 items-center justify-center rounded-md">
<AppLogoIcon className="size-9 fill-current text-[var(--foreground)] dark:text-white" />
</div>
<span className="sr-only">{title}</span>
</Link>
const { t } = useTranslation('auth');
<div className="space-y-2 text-center">
<h1 className="text-xl font-medium">{title}</h1>
<p className="text-center text-sm text-muted-foreground">{description}</p>
const highlights = [
{
icon: Sparkles,
title: t('login.highlights.moments', 'Momente in Echtzeit teilen'),
description: t('login.highlights.moments_description', 'Uploads landen sofort in der Event-Galerie ohne App-Download.'),
},
{
icon: Camera,
title: t('login.highlights.branding', 'Branding & Slideshows, die begeistern'),
description: t('login.highlights.branding_description', 'Konfiguriere Slideshow, Wasserzeichen und Aufgaben für dein Event.'),
},
{
icon: ShieldCheck,
title: t('login.highlights.privacy', 'Sicherer Zugang über Tokens'),
description: t('login.highlights.privacy_description', 'Eventzugänge bleiben geschützt DSGVO-konform mit Join Tokens.'),
},
];
return (
<div className="relative min-h-svh overflow-hidden bg-slate-950">
<div
aria-hidden
className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.35),_transparent_60%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.35),_transparent_55%)] blur-3xl"
/>
<div className="absolute inset-0 bg-gradient-to-br from-gray-950 via-gray-950/70 to-[#1a0f1f]" aria-hidden />
<div className="relative z-10 flex min-h-svh items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
<div className="w-full max-w-5xl">
<div className="grid overflow-hidden rounded-3xl border border-white/15 bg-white/95 shadow-2xl shadow-fuchsia-500/10 backdrop-blur-2xl dark:border-gray-800/70 dark:bg-gray-950/85 md:grid-cols-[1.08fr_1fr]">
<div className="relative hidden flex-col justify-between gap-10 overflow-hidden bg-gradient-to-br from-[#1d1937] via-[#2a1134] to-[#ff5f87] p-10 text-white md:flex">
<div aria-hidden className="pointer-events-none absolute inset-0 opacity-45">
<div className="absolute -inset-20 bg-[radial-gradient(circle_at_top,_rgba(255,255,255,0.4),_transparent_55%),radial-gradient(circle_at_bottom_left,_rgba(236,72,153,0.35),_transparent_60%)]" />
</div>
<div className="relative z-10 flex flex-col gap-6">
<div className="flex items-center gap-3 text-xs font-semibold uppercase tracking-[0.45em] text-white/70">
<Sparkles className="h-4 w-4" aria-hidden />
<span className="font-sans-marketing">{t('login.hero_tagline', 'Event-Tech mit Herz')}</span>
</div>
<div className="space-y-4">
<h2 className="font-display text-3xl leading-tight sm:text-4xl">
{t('login.hero_heading', 'Willkommen zurück bei Fotospiel')}
</h2>
<p className="text-sm text-white/80 sm:text-base">
{t('login.hero_subheading', 'Verwalte Events, Galerien und Gästelisten in einem liebevoll gestalteten Dashboard.')}
</p>
</div>
<ul className="space-y-4">
{highlights.map(({ icon: Icon, title: highlightTitle, description: highlightDescription }) => (
<li key={highlightTitle} className="flex items-start gap-3">
<span className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/15 backdrop-blur">
<Icon className="h-4 w-4" aria-hidden />
</span>
<span className="space-y-1">
<p className="text-sm font-semibold tracking-tight sm:text-base">{highlightTitle}</p>
<p className="text-xs text-white/70 sm:text-sm">{highlightDescription}</p>
</span>
</li>
))}
</ul>
</div>
<div className="relative z-10 flex items-center justify-between gap-4 rounded-2xl border border-white/15 bg-white/10 p-4 text-xs text-white/80 sm:text-sm">
<div className="space-y-1">
<p className="font-semibold">{t('login.hero_footer.headline', 'Noch kein Account?')}</p>
<p>{t('login.hero_footer.subline', 'Entdecke unsere Packages und erlebe Fotospiel live.')}</p>
</div>
<Button asChild variant="secondary" className="h-10 rounded-full bg-white px-5 text-sm font-semibold text-gray-900 shadow-md shadow-white/30 transition hover:bg-white/90">
<Link href={packages()}>{t('login.hero_footer.cta', 'Packages entdecken')}</Link>
</Button>
</div>
</div>
<div className="relative bg-white/95 px-6 py-10 sm:px-10 dark:bg-gray-950/90">
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-pink-400 via-fuchsia-400 to-sky-400" aria-hidden />
<div className="relative z-10 flex flex-col gap-8">
<div className="flex flex-col items-center gap-4 text-center">
<Link href={home()} className="group flex flex-col items-center gap-3 font-medium">
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-[#ff8ab4] to-[#a855f7] shadow-lg shadow-pink-400/40 transition duration-300 group-hover:scale-105">
<AppLogoIcon className="size-8 fill-white" aria-hidden />
</span>
<span className="text-2xl font-semibold font-display text-gray-900 dark:text-white">Fotospiel</span>
<span className="sr-only">{title}</span>
</Link>
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight text-gray-900 dark:text-white sm:text-3xl">{title}</h1>
<p className="text-sm text-muted-foreground sm:text-base">{description}</p>
</div>
</div>
{children}
</div>
</div>
</div>
{children}
</div>
</div>
</div>

View File

@@ -35,17 +35,20 @@ export default function Login({ status, canResetPassword }: LoginProps) {
});
};
const errorKeys = Object.keys(errors);
const hasErrors = errorKeys.length > 0;
useEffect(() => {
if (!hasTriedSubmit) {
return;
}
const errorKeys = Object.keys(errors);
if (errorKeys.length === 0) {
const keys = Object.keys(errors);
if (keys.length === 0) {
return;
}
const field = document.querySelector<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(`[name="${errorKeys[0]}"]`);
const field = document.querySelector<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(`[name="${keys[0]}"]`);
if (field) {
field.scrollIntoView({ behavior: 'smooth', block: 'center' });
@@ -57,10 +60,17 @@ export default function Login({ status, canResetPassword }: LoginProps) {
<AuthLayout title={t('login.title')} description={t('login.description')}>
<Head title={t('login.title')} />
<form onSubmit={submit} className="flex flex-col gap-6">
<div className="grid gap-6">
<form
onSubmit={submit}
className="relative flex flex-col gap-6 overflow-hidden rounded-3xl border border-gray-200/70 bg-white/80 p-6 shadow-xl shadow-rose-200/40 backdrop-blur-sm transition dark:border-gray-800/80 dark:bg-gray-900/70"
>
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-pink-400/80 via-rose-400/70 to-sky-400/70" aria-hidden />
<div className="grid gap-6 pt-2 sm:pt-4">
<div className="grid gap-2">
<Label htmlFor="email">{t('login.email')}</Label>
<Label htmlFor="email" className="text-sm font-semibold text-gray-700 dark:text-gray-200">
{t('login.email')}
</Label>
<Input
id="email"
type="email"
@@ -77,15 +87,26 @@ export default function Login({ status, canResetPassword }: LoginProps) {
clearErrors('email');
}
}}
className="h-12 rounded-xl border-gray-200/80 bg-white/90 px-4 text-base shadow-inner shadow-gray-200/40 focus-visible:ring-[#ff8bb1]/40 dark:border-gray-800/70 dark:bg-gray-900/60 dark:text-gray-100"
/>
<InputError
key={`error-email`}
message={errors.email}
className="text-sm font-medium text-rose-600 dark:text-rose-400"
/>
<InputError key={`error-email`} message={errors.email} />
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">{t('login.password')}</Label>
<Label htmlFor="password" className="text-sm font-semibold text-gray-700 dark:text-gray-200">
{t('login.password')}
</Label>
{canResetPassword && (
<TextLink href={request()} className="ml-auto text-sm" tabIndex={5}>
<TextLink
href={request()}
className="ml-auto text-sm font-semibold text-[#ff5f87] transition hover:text-[#ff3b6d]"
tabIndex={5}
>
{t('login.forgot')}
</TextLink>
)}
@@ -105,44 +126,69 @@ export default function Login({ status, canResetPassword }: LoginProps) {
clearErrors('password');
}
}}
className="h-12 rounded-xl border-gray-200/80 bg-white/90 px-4 text-base shadow-inner shadow-gray-200/40 focus-visible:ring-[#ff8bb1]/40 dark:border-gray-800/70 dark:bg-gray-900/60 dark:text-gray-100"
/>
<InputError
key={`error-password`}
message={errors.password}
className="text-sm font-medium text-rose-600 dark:text-rose-400"
/>
<InputError key={`error-password`} message={errors.password} />
</div>
<div className="flex items-center space-x-3">
<div className="flex items-center gap-3 rounded-2xl border border-gray-200/60 bg-gray-50/70 px-4 py-3 text-sm font-medium text-gray-600 shadow-inner dark:border-gray-800/70 dark:bg-gray-900/60 dark:text-gray-200">
<Checkbox
id="remember"
name="remember"
tabIndex={3}
checked={data.remember}
className="size-5 rounded-lg border-gray-300 bg-white/90 data-[state=checked]:border-transparent data-[state=checked]:bg-[#ff5f87] data-[state=checked]:text-white dark:border-gray-700 dark:bg-gray-900/70"
onCheckedChange={(checked) => setData('remember', Boolean(checked))}
/>
<Label htmlFor="remember">{t('login.remember')}</Label>
<Label htmlFor="remember" className="cursor-pointer select-none font-semibold text-gray-700 dark:text-gray-200">
{t('login.remember')}
</Label>
</div>
<Button type="submit" className="mt-4 w-full" tabIndex={4} disabled={processing}>
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
{t('login.submit')}
</Button>
</div>
<div className="text-center text-sm text-muted-foreground">
<Button
type="submit"
className="mt-2 h-12 w-full justify-center rounded-xl bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-base font-semibold shadow-[0_18px_35px_-18px_rgba(236,72,153,0.7)] transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
tabIndex={4}
disabled={processing}
>
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
{t('login.submit')}
</Button>
<div className="space-y-4 text-sm">
{status && (
<div className="rounded-2xl border border-emerald-200/70 bg-emerald-50/90 p-3 text-center font-medium text-emerald-700 shadow-sm">
{status}
</div>
)}
{hasErrors && (
<div
key={`general-errors-${errorKeys.join('-')}`}
role="alert"
className="rounded-2xl border border-rose-200/80 bg-rose-50/90 p-3 text-center font-medium text-rose-700 shadow-sm dark:border-rose-900/50 dark:bg-rose-900/40 dark:text-rose-100"
>
{Object.values(errors).join(' ')}
</div>
)}
</div>
<div className="rounded-2xl border border-gray-200/60 bg-gray-50/80 p-4 text-center text-sm text-muted-foreground shadow-inner dark:border-gray-800/70 dark:bg-gray-900/60">
{t('login.no_account')}{' '}
<TextLink href={register()} tabIndex={5}>
<TextLink
href={register()}
tabIndex={5}
className="font-semibold text-[#ff5f87] transition hover:text-[#ff3b6d]"
>
{t('login.sign_up')}
</TextLink>
</div>
</form>
{status && <div className="mb-4 text-center text-sm font-medium text-green-600">{status}</div>}
{Object.keys(errors).length > 0 && (
<div key={`general-errors-${Object.keys(errors).join('-')}`} className="p-4 bg-red-50 border border-red-200 rounded-md mb-4">
<p className="text-sm text-red-800">
{Object.values(errors).join(' ')}
</p>
</div>
)}
</AuthLayout>
);
}

View File

@@ -0,0 +1,125 @@
import React from 'react';
import { Head, Link } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import MarketingLayout from '@/layouts/mainWebsite';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Sparkles, CheckCircle2 } from 'lucide-react';
type DemoFeature = { title: string; description: string };
const DEMO_TOKEN = 'mdhyA5XSVEVEabF8JhZ85B6fMocyyRMTfmThSeUKPzk7LLTu';
const DemoPage: React.FC = () => {
const { t } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes();
const demo = t('demo_page', { returnObjects: true }) as {
title: string;
subtitle: string;
primaryCta: string;
secondaryCta: string;
iframeNote: string;
openFull: string;
features: DemoFeature[];
};
return (
<MarketingLayout title={demo.title}>
<Head title={demo.title} />
<section className="relative overflow-hidden bg-gradient-to-br from-pink-100 via-white to-white px-4 py-16 dark:from-pink-950/40 dark:via-gray-950 dark:to-gray-950">
<div className="absolute -top-32 right-20 hidden h-72 w-72 rounded-full bg-pink-200/50 blur-3xl dark:bg-pink-900/30 lg:block" />
<div className="container mx-auto relative z-10 flex max-w-5xl flex-col gap-10 lg:flex-row lg:items-center">
<div className="flex-1 space-y-6">
<Badge variant="outline" className="border-pink-400 text-pink-600 dark:border-pink-500 dark:text-pink-300">
Demo Live
</Badge>
<h1 className="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-50 md:text-5xl">
{demo.title}
</h1>
<p className="max-w-xl text-lg text-gray-600 dark:text-gray-300">
{demo.subtitle}
</p>
<div className="flex flex-wrap items-center gap-3">
<Button asChild size="lg" className="bg-pink-500 hover:bg-pink-600">
<Link href={localizedPath('/packages')}>
{demo.primaryCta}
</Link>
</Button>
<Button asChild size="lg" variant="ghost" className="text-pink-600 hover:text-pink-700 dark:text-pink-300">
<Link href={localizedPath('/so-funktionierts')}>
{demo.secondaryCta}
</Link>
</Button>
</div>
</div>
<div className="flex-1">
<div className="relative mx-auto w-full max-w-[320px] rounded-[2.5rem] border border-gray-200 bg-gray-900 p-4 shadow-2xl dark:border-gray-700 md:max-w-[360px]">
<div className="absolute left-1/2 top-2 h-1.5 w-16 -translate-x-1/2 rounded-full bg-gray-300 dark:bg-gray-600" aria-hidden />
<iframe
title="Fotospiel Demo"
src={`/e/${DEMO_TOKEN}`}
className="aspect-[9/16] w-full rounded-[1.75rem] border-0 bg-white shadow-inner dark:bg-gray-950"
loading="lazy"
sandbox="allow-scripts allow-same-origin allow-forms"
/>
</div>
<div className="mt-4 flex flex-col items-center gap-1 text-center">
<p className="text-sm text-gray-600 dark:text-gray-300">{demo.iframeNote}</p>
<Button asChild variant="link" className="text-pink-600 hover:text-pink-700 dark:text-pink-300">
<Link href={`/e/${DEMO_TOKEN}`} target="_blank" rel="noopener">
{demo.openFull}
</Link>
</Button>
</div>
</div>
</div>
</section>
<section className="container mx-auto px-4 pb-16">
<div className="mx-auto max-w-5xl">
<div className="grid gap-6 md:grid-cols-3">
{demo.features.map((feature) => (
<Card key={feature.title} className="border-gray-100 shadow-sm dark:border-gray-800">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Sparkles className="h-5 w-5 text-pink-500" aria-hidden />
{feature.title}
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-sm text-gray-600 dark:text-gray-300">
{feature.description}
</CardDescription>
</CardContent>
</Card>
))}
</div>
<Alert className="mt-10 border-pink-200 bg-white shadow-lg dark:border-pink-900/50 dark:bg-gray-950">
<AlertTitle className="flex items-center gap-2 text-gray-900 dark:text-gray-100">
<CheckCircle2 className="h-5 w-5 text-pink-500" aria-hidden />
{t('marketing.labels.readyToLaunch', 'Bereit für dein Event?')}
</AlertTitle>
<AlertDescription className="mt-3 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<span className="text-sm text-gray-600 dark:text-gray-300">
{t('marketing.labels.readyToLaunchCopy', 'Registriere dich kostenlos und lege noch heute dein erstes Event an.')}
</span>
<Button asChild className="bg-pink-500 hover:bg-pink-600">
<Link href={localizedPath('/register')}>
{demo.primaryCta}
</Link>
</Button>
</AlertDescription>
</Alert>
</div>
</section>
</MarketingLayout>
);
};
export default DemoPage;

View File

@@ -1,10 +1,16 @@
import React from 'react';
import React from 'react';
import { Head, Link, useForm } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import MarketingLayout from '@/layouts/mainWebsite';
import { useAnalytics } from '@/hooks/useAnalytics';
import { useCtaExperiment } from '@/hooks/useCtaExperiment';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { ArrowRight, Camera, QrCode, ShieldCheck, Sparkles, Smartphone } from 'lucide-react';
interface Package {
id: number;
@@ -17,6 +23,9 @@ interface Props {
packages: Package[];
}
const heroBulletIcons = [Sparkles, ShieldCheck, Camera];
const howStepIcons = [QrCode, Smartphone, ShieldCheck];
const Home: React.FC<Props> = ({ packages }) => {
const { t } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes();
@@ -31,8 +40,49 @@ const Home: React.FC<Props> = ({ packages }) => {
message: '',
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const heroBulletsRaw = t('home.hero_bullets', { returnObjects: true });
const heroBullets = Array.isArray(heroBulletsRaw) ? (heroBulletsRaw as string[]) : [];
const featuresRaw = t('home.features_highlight', { returnObjects: true });
const features = Array.isArray(featuresRaw)
? (featuresRaw as Array<{ title: string; description: string }>)
: [];
const howSteps = [
{
icon: howStepIcons[0] ?? QrCode,
title: t('home.step1_title'),
description: t('home.step1_desc'),
},
{
icon: howStepIcons[1] ?? Smartphone,
title: t('home.step2_title'),
description: t('home.step2_desc'),
},
{
icon: howStepIcons[2] ?? ShieldCheck,
title: t('home.step3_title'),
description: t('home.step3_desc'),
},
];
const occasionLinks = [
{ key: 'wedding', href: localizedPath('/anlaesse/hochzeit') },
{ key: 'birthday', href: localizedPath('/anlaesse/geburtstag') },
{ key: 'corporate', href: localizedPath('/anlaesse/firmenevent') },
{ key: 'confirmation', href: localizedPath('/anlaesse/konfirmation') },
];
const heroPrimaryHref = localizedPath('/demo');
const heroPrimaryLabel =
heroCtaVariant === 'gradient' ? t('home.cta_demo_highlight') : t('home.cta_demo');
const heroSecondaryHref = localizedPath('/so-funktionierts');
const heroSecondaryLabel = t('home.cta_how');
const heroTertiaryHref = localizedPath('/packages');
const heroTertiaryLabel = t('home.cta_packages');
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
post(localizedPath('/kontakt'), {
onSuccess: () => {
trackEvent({
@@ -54,226 +104,471 @@ const Home: React.FC<Props> = ({ packages }) => {
<MarketingLayout title={t('home.title')}>
<Head title={t('home.hero_title')} />
{/* Hero Section */}
<section id="hero" className="bg-aurora-enhanced text-gray-900 dark:text-gray-100 py-20 px-4">
<div className="container mx-auto flex flex-col md:flex-row items-center gap-8 max-w-6xl">
<div className="md:w-1/2 text-center md:text-left">
<h1 className="text-4xl md:text-6xl font-bold mb-4 font-display">{t('home.hero_title')}</h1>
<p className="text-xl md:text-2xl mb-8 font-sans-marketing">{t('home.hero_description')}</p>
<Link
href={localizedPath('/packages')}
onClick={() => {
trackHeroCtaClick();
trackEvent({
category: 'marketing_home',
action: 'hero_cta',
name: `packages:${heroCtaVariant}`,
});
}}
className={[
'inline-block rounded-full px-8 py-4 font-bold transition duration-300',
heroCtaVariant === 'gradient'
? 'bg-gradient-to-r from-rose-500 via-pink-500 to-amber-400 text-white shadow-lg shadow-rose-500/40 hover:from-rose-500/95 hover:via-pink-500/95 hover:to-amber-400/95'
: 'bg-white text-[#FFB6C1] hover:bg-gray-100 dark:bg-gray-800 dark:text-rose-200 dark:hover:bg-gray-700',
].join(' ')}
>
{heroCtaVariant === 'gradient' ? t('home.cta_explore_highlight') : t('home.cta_explore')}
</Link>
<section id="hero" className="bg-aurora-enhanced py-20 px-4 text-gray-900 dark:text-gray-100">
<div className="container mx-auto flex max-w-6xl flex-col items-center gap-12 md:flex-row">
<div className="flex flex-col gap-8 text-center md:w-1/2 md:text-left">
<div className="flex flex-col gap-4">
<Badge className="mx-auto w-fit bg-white/80 px-3 py-1 text-xs font-semibold uppercase text-rose-500 shadow-sm md:mx-0 md:text-[0.72rem]">
{t('home.hero_tagline')}
</Badge>
<h1 className="font-display text-4xl font-bold leading-tight md:text-5xl lg:text-6xl">
{t('home.hero_title')}
</h1>
<p className="text-lg text-gray-700 dark:text-gray-200 md:text-xl">
{t('home.hero_description')}
</p>
{heroBullets.length > 0 && (
<ul className="mx-auto flex flex-col gap-3 text-left text-sm font-semibold text-gray-700 dark:text-gray-200 md:mx-0">
{heroBullets.map((item, index) => {
const Icon = heroBulletIcons[index % heroBulletIcons.length] ?? Sparkles;
return (
<li key={`hero-bullet-${index}`} className="flex items-start gap-3">
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white/80 text-rose-500 shadow-sm dark:bg-gray-900/70">
<Icon className="h-4 w-4" aria-hidden />
</span>
<span className="flex-1 text-base">{item}</span>
</li>
);
})}
</ul>
)}
</div>
<div className="flex flex-wrap items-center justify-center gap-3 md:justify-start">
<Button
asChild
size="lg"
className={
heroCtaVariant === 'gradient'
? 'group h-12 rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-7 text-base font-semibold text-white shadow-[0_18px_35px_-18px_rgba(236,72,153,0.7)] transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]'
: 'group h-12 rounded-full bg-white/90 px-7 text-base font-semibold text-rose-500 shadow-md shadow-rose-200/40 transition hover:bg-white'
}
>
<Link
href={heroPrimaryHref}
onClick={() => {
trackHeroCtaClick();
trackEvent({
category: 'marketing_home',
action: 'hero_cta',
name: `demo:${heroCtaVariant}`,
});
}}
className="flex items-center gap-2"
>
<span>{heroPrimaryLabel}</span>
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-1" aria-hidden />
</Link>
</Button>
<Button
asChild
variant="outline"
size="lg"
className="h-12 rounded-full border-rose-200 bg-white/80 px-6 text-base font-semibold text-rose-500 shadow-sm transition hover:bg-white dark:border-rose-500/30 dark:bg-transparent dark:text-rose-200 dark:hover:bg-white/10"
>
<Link
href={heroSecondaryHref}
onClick={() =>
trackEvent({
category: 'marketing_home',
action: 'hero_secondary_cta',
})
}
className="flex items-center gap-2"
>
<span>{heroSecondaryLabel}</span>
</Link>
</Button>
<Link
href={heroTertiaryHref}
onClick={() =>
trackEvent({
category: 'marketing_home',
action: 'hero_packages_cta',
})
}
className="font-semibold text-rose-500 underline-offset-4 hover:underline dark:text-rose-200"
>
{heroTertiaryLabel}
</Link>
</div>
</div>
<div className="md:w-1/2">
<div className="relative w-full max-w-xl md:w-1/2">
<div className="absolute inset-0 rounded-3xl bg-white/40 blur-xl" aria-hidden />
<img
src="/joyous_wedding_guests_posing.jpg"
alt={t('home.hero_image_alt')}
className="w-full h-auto rounded-lg shadow-lg"
className="relative w-full rounded-[32px] border border-white/60 shadow-2xl"
/>
</div>
</div>
</section>
{/* How it Works Section */}
<section className="py-20 px-4 bg-gray-50 dark:bg-gray-900">
<section id="how-it-works" className="bg-gray-50 py-20 px-4 dark:bg-gray-950">
<div className="container mx-auto max-w-6xl">
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 font-display">{t('home.how_title')}</h2>
<div className="grid md:grid-cols-3 gap-8">
<div className="text-center">
<div className="w-16 h-16 bg-[#FFB6C1] rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-2xl">1</span>
</div>
<h3 className="text-xl font-semibold mb-2">{t('home.step1_title')}</h3>
<p>{t('home.step1_desc')}</p>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-[#FFB6C1] rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-2xl">2</span>
</div>
<h3 className="text-xl font-semibold mb-2">{t('home.step2_title')}</h3>
<p>{t('home.step2_desc')}</p>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-[#FFB6C1] rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-2xl">3</span>
</div>
<h3 className="text-xl font-semibold mb-2">{t('home.step3_title')}</h3>
<p>{t('home.step3_desc')}</p>
</div>
<div className="mx-auto max-w-3xl text-center">
<h2 className="font-display text-3xl font-bold md:text-4xl">{t('home.how_title')}</h2>
<p className="mt-4 text-base text-muted-foreground md:text-lg">
{t('home.how_subtitle')}
</p>
</div>
</div>
</section>
{/* Features Section */}
<section className="py-20 px-4 dark:bg-gray-700">
<div className="container mx-auto max-w-6xl">
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 font-display">{t('home.features_title')}</h2>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
<h3 className="text-xl font-semibold mb-2">{t('home.feature1_title')}</h3>
<p>{t('home.feature1_desc')}</p>
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
<h3 className="text-xl font-semibold mb-2">{t('home.feature2_title')}</h3>
<p>{t('home.feature2_desc')}</p>
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
<h3 className="text-xl font-semibold mb-2">{t('home.feature3_title')}</h3>
<p>{t('home.feature3_desc')}</p>
</div>
</div>
</div>
</section>
{/* Packages Teaser */}
<section className="py-20 px-4 bg-gray-50 dark:bg-gray-900">
<div className="container mx-auto max-w-6xl">
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 font-display">{t('home.packages_title')}</h2>
<div className="grid md:grid-cols-2 gap-8 mb-8">
{packages.slice(0, 2).map((pkg) => (
<div key={pkg.id} className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md text-center">
<h3 className="text-2xl font-bold mb-2">{pkg.name}</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4">{pkg.description}</p>
<p className="text-3xl font-bold text-[#FFB6C1]">{pkg.price} {t('currency.euro')}</p>
<Link
href={`${localizedPath('/packages')}?package_id=${pkg.id}`}
onClick={() =>
trackEvent({
category: 'marketing_home',
action: 'package_teaser_cta',
name: pkg.name,
value: pkg.price,
})
}
className="mt-4 inline-block bg-[#FFB6C1] text-white px-6 py-2 rounded-full hover:bg-pink-600"
>
{t('home.view_details')}
</Link>
</div>
<div className="mt-12 grid gap-6 md:grid-cols-3">
{howSteps.map(({ icon: Icon, title, description }, index) => (
<Card
key={`how-step-${index}`}
className="border-gray-200/70 bg-white/90 shadow-md shadow-gray-200/40 transition hover:-translate-y-1 hover:shadow-lg hover:shadow-rose-200/30 dark:border-gray-800/60 dark:bg-gray-900/60"
>
<CardHeader className="flex flex-col gap-4">
<span className="flex h-12 w-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-100">
<Icon className="h-6 w-6" aria-hidden />
</span>
<CardTitle className="text-xl">{title}</CardTitle>
<CardDescription className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
{description}
</CardDescription>
</CardHeader>
</Card>
))}
</div>
<div className="text-center">
<Link
href={localizedPath('/packages')}
onClick={() =>
trackEvent({
category: 'marketing_home',
action: 'all_packages_cta',
})
}
className="bg-[#FFB6C1] text-white px-8 py-4 rounded-full font-bold hover:bg-pink-600 transition"
>
{t('home.all_packages')}
</Link>
</div>
</div>
</section>
{/* Contact Section */}
<section id="contact" className="py-20 px-4 dark:bg-gray-700">
<div className="container mx-auto max-w-4xl">
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 font-display">{t('home.contact_title')}</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-2">
{t('home.name_label')} {t('common.required')}
</label>
<input
type="text"
id="name"
value={data.name}
onChange={(e) => setData('name', e.target.value)}
className="w-full p-3 border rounded-lg"
/>
{errors.name && <p className="text-red-500 text-sm">{errors.name}</p>}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-2">
{t('home.email_label')} {t('common.required')}
</label>
<input
type="email"
id="email"
value={data.email}
onChange={(e) => setData('email', e.target.value)}
className="w-full p-3 border rounded-lg"
/>
{errors.email && <p className="text-red-500 text-sm">{errors.email}</p>}
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium mb-2">
{t('home.message_label')} {t('common.required')}
</label>
<textarea
id="message"
rows={4}
value={data.message}
onChange={(e) => setData('message', e.target.value)}
className="w-full p-3 border rounded-lg"
/>
{errors.message && <p className="text-red-500 text-sm">{errors.message}</p>}
</div>
<button
type="submit"
disabled={processing}
className="bg-[#FFB6C1] text-white px-8 py-3 rounded-full font-bold hover:bg-pink-600 transition disabled:opacity-50"
>
{processing ? t('home.sending') : t('home.send')}
</button>
</form>
</div>
</section>
{/* Testimonials Section */}
<section className="py-20 px-4 bg-gray-50 dark:bg-gray-900">
<section className="bg-slate-950 py-20 px-4 text-white">
<div className="container mx-auto max-w-6xl">
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 font-display">{t('home.testimonials_title')}</h2>
<div className="grid md:grid-cols-3 gap-8">
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
<p className="italic mb-4">"{t('home.testimonial1')}"</p>
<p className="font-semibold">{t('common.testimonials.anna.name')}</p>
<div className="grid gap-10 md:grid-cols-[1.1fr_0.9fr] md:items-center">
<div className="flex flex-col gap-6">
<Badge className="w-fit bg-white/15 px-3 py-1 text-xs uppercase tracking-[0.35em] text-white/80">
{t('home.demo_title')}
</Badge>
<h2 className="font-display text-3xl font-semibold leading-tight md:text-4xl">
{t('home.demo_description')}
</h2>
<p className="text-sm text-white/75">{t('home.demo_hint')}</p>
<Button
asChild
size="lg"
className="h-12 w-fit rounded-full bg-white px-7 text-base font-semibold text-slate-900 shadow-lg shadow-white/30 transition hover:bg-white/90"
>
<Link
href={localizedPath('/demo')}
onClick={() =>
trackEvent({
category: 'marketing_home',
action: 'demo_section_cta',
})
}
className="flex items-center gap-2"
>
<span>{t('home.demo_cta')}</span>
<ArrowRight className="h-4 w-4" aria-hidden />
</Link>
</Button>
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
<p className="italic mb-4">"{t('home.testimonial2')}"</p>
<p className="font-semibold">{t('common.testimonials.max.name')}</p>
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
<p className="italic mb-4">"{t('home.testimonial3')}"</p>
<p className="font-semibold">{t('common.testimonials.lisa.name')}</p>
<div className="relative mx-auto w-full max-w-sm">
<div className="absolute inset-0 rounded-[42px] bg-gradient-to-br from-rose-400 via-purple-500 to-indigo-500 opacity-80 blur-2xl" aria-hidden />
<div className="relative aspect-[9/16] w-full overflow-hidden rounded-[42px] border border-white/20 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 shadow-[0_40px_90px_-30px_rgba(15,23,42,0.75)]">
<div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
<Smartphone className="h-12 w-12 text-white/60" aria-hidden />
<p className="text-sm text-white/75">{t('home.demo_media_alt')}</p>
<Link
href={localizedPath('/demo')}
onClick={() =>
trackEvent({
category: 'marketing_home',
action: 'demo_frame_cta',
})
}
className="text-sm font-semibold text-white underline-offset-4 hover:underline"
>
{t('home.demo_cta')}
</Link>
</div>
</div>
</div>
</div>
</div>
</section>
{/* FAQ Section */}
<section className="py-20 px-4 dark:bg-gray-700">
<section className="bg-white py-20 px-4 dark:bg-gray-950">
<div className="container mx-auto max-w-6xl">
<div className="mx-auto max-w-3xl text-center">
<h2 className="font-display text-3xl font-bold md:text-4xl">{t('home.features_title')}</h2>
</div>
<div className="mt-12 grid gap-6 md:grid-cols-3">
{features.map((feature, index) => (
<Card
key={`feature-${index}`}
className="border-gray-200/80 bg-gray-50/80 shadow-sm shadow-rose-100/40 transition hover:-translate-y-1 hover:shadow-lg hover:shadow-rose-200/40 dark:border-gray-800/60 dark:bg-gray-900/70"
>
<CardHeader>
<CardTitle className="text-xl">{feature.title}</CardTitle>
<CardDescription className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
{feature.description}
</CardDescription>
</CardHeader>
</Card>
))}
</div>
</div>
</section>
<section className="bg-gray-50 py-20 px-4 dark:bg-gray-950/80">
<div className="container mx-auto max-w-6xl">
<div className="grid gap-10 lg:grid-cols-[1.15fr_0.85fr]">
<Card className="border-rose-200/50 bg-white/95 shadow-md shadow-rose-200/40 dark:border-rose-500/30 dark:bg-gray-900/80">
<CardHeader className="flex flex-col gap-4">
<Badge className="w-fit bg-rose-100 px-3 py-1 text-xs font-semibold uppercase text-rose-600 dark:bg-rose-500/20 dark:text-rose-200">
{t('home.occasions_title')}
</Badge>
<CardTitle className="text-2xl">{t('home.occasions_description')}</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2">
{occasionLinks.map(({ key, href }) => (
<Link
key={key}
href={href}
onClick={() =>
trackEvent({
category: 'marketing_home',
action: 'occasion_tile_click',
name: key,
})
}
className="group flex items-center gap-3 rounded-xl border border-rose-100/60 bg-white/80 px-4 py-3 text-sm font-semibold text-rose-600 shadow-sm shadow-rose-100/50 transition hover:bg-rose-50 hover:text-rose-700 dark:border-rose-500/30 dark:bg-gray-900/70 dark:text-rose-200 dark:hover:bg-rose-500/10"
>
<ArrowRight className="h-4 w-4 -translate-x-1 transition group-hover:translate-x-0" aria-hidden />
<span>{t(`home.occasions.${key}`)}</span>
</Link>
))}
</CardContent>
</Card>
<Card className="border-gray-200/70 bg-white/95 shadow-md shadow-gray-200/40 transition hover:-translate-y-1 hover:shadow-lg hover:shadow-rose-200/40 dark:border-gray-800/60 dark:bg-gray-900/80">
<CardHeader>
<CardTitle className="text-2xl">{t('home.blog_teaser_title')}</CardTitle>
<CardDescription className="text-sm leading-relaxed text-muted-foreground">
{t('home.blog_teaser_description')}
</CardDescription>
</CardHeader>
<CardFooter className="px-6 pb-6">
<Button
asChild
variant="ghost"
className="group inline-flex items-center gap-2 rounded-full px-0 text-base font-semibold text-rose-500 transition hover:text-rose-600 dark:text-rose-200 dark:hover:text-rose-100"
>
<Link
href={localizedPath('/blog')}
onClick={() =>
trackEvent({
category: 'marketing_home',
action: 'blog_teaser_cta',
})
}
className="flex items-center gap-2"
>
<span>{t('home.blog_teaser_cta')}</span>
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-1" aria-hidden />
</Link>
</Button>
</CardFooter>
</Card>
</div>
</div>
</section>
<section className="bg-white py-20 px-4 dark:bg-gray-950">
<div className="container mx-auto max-w-6xl">
<div className="flex flex-col items-center justify-between gap-4 text-center md:flex-row md:text-left">
<div>
<h2 className="font-display text-3xl font-bold md:text-4xl">
{t('home.packages_title')}
</h2>
<p className="mt-3 max-w-2xl text-base text-muted-foreground md:text-lg">
{t('home.packages_subtitle')}
</p>
</div>
<Button
asChild
className="h-11 rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-sm font-semibold text-white shadow-lg shadow-rose-300/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
>
<Link
href={localizedPath('/packages')}
onClick={() =>
trackEvent({
category: 'marketing_home',
action: 'all_packages_cta',
})
}
>
{t('home.all_packages')}
</Link>
</Button>
</div>
<div className="mt-10 grid gap-8 md:grid-cols-2">
{packages.slice(0, 2).map((pkg) => (
<Card
key={pkg.id}
className="border-gray-200 bg-white/95 text-center shadow-md shadow-rose-200/30 transition hover:-translate-y-1 hover:shadow-xl hover:shadow-rose-200/40 dark:border-gray-800 dark:bg-gray-900/80"
>
<CardHeader className="gap-4">
<CardTitle className="text-2xl">{pkg.name}</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
{pkg.description}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
<p className="text-3xl font-bold text-rose-500">
{pkg.price} {t('currency.euro')}
</p>
<Button
asChild
className="rounded-full bg-rose-500 px-5 py-2 text-sm font-semibold text-white shadow-lg shadow-rose-200/40 transition hover:bg-rose-600"
>
<Link
href={`${localizedPath('/packages')}?package_id=${pkg.id}`}
onClick={() =>
trackEvent({
category: 'marketing_home',
action: 'package_teaser_cta',
name: pkg.name,
value: pkg.price,
})
}
>
{t('home.view_details')}
</Link>
</Button>
</CardContent>
</Card>
))}
</div>
</div>
</section>
<section id="contact" className="bg-gray-50 py-20 px-4 dark:bg-gray-950/80">
<div className="container mx-auto max-w-4xl">
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 font-display">{t('home.faq_title')}</h2>
<div className="space-y-4">
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
<h3 className="font-semibold">{t('home.faq1_q')}</h3>
<p>{t('home.faq1_a')}</p>
</div>
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
<h3 className="font-semibold">{t('home.faq2_q')}</h3>
<p>{t('home.faq2_a')}</p>
</div>
<Card className="border-gray-200/70 bg-white/95 shadow-lg shadow-rose-200/40 dark:border-gray-800/60 dark:bg-gray-900/80">
<CardHeader className="text-center">
<Badge className="mx-auto mb-3 w-fit bg-rose-100 px-3 py-1 text-xs font-semibold uppercase text-rose-600 dark:bg-rose-500/20 dark:text-rose-200">
{t('home.contact_title')}
</Badge>
<CardTitle className="text-2xl md:text-3xl">{t('home.contact_lead')}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-2">
<label htmlFor="name" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
{t('home.name_label')} *
</label>
<Input
id="name"
value={data.name}
onChange={(event) => setData('name', event.target.value)}
className="h-12 rounded-xl border-gray-200/70 bg-white/90 shadow-inner shadow-gray-200/40 focus-visible:ring-rose-300/60 dark:border-gray-700 dark:bg-gray-900/70"
autoComplete="name"
required
/>
{errors.name && (
<p className="text-sm font-medium text-rose-600 dark:text-rose-300">{errors.name}</p>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="email" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
{t('home.email_label')} *
</label>
<Input
id="email"
type="email"
value={data.email}
onChange={(event) => setData('email', event.target.value)}
className="h-12 rounded-xl border-gray-200/70 bg-white/90 shadow-inner shadow-gray-200/40 focus-visible:ring-rose-300/60 dark:border-gray-700 dark:bg-gray-900/70"
autoComplete="email"
required
/>
{errors.email && (
<p className="text-sm font-medium text-rose-600 dark:text-rose-300">{errors.email}</p>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="message" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
{t('home.message_label')} *
</label>
<Textarea
id="message"
rows={5}
value={data.message}
onChange={(event) => setData('message', event.target.value)}
className="rounded-xl border-gray-200/70 bg-white/90 shadow-inner shadow-gray-200/40 focus-visible:ring-rose-300/60 dark:border-gray-700 dark:bg-gray-900/70"
required
/>
{errors.message && (
<p className="text-sm font-medium text-rose-600 dark:text-rose-300">{errors.message}</p>
)}
</div>
<div className="space-y-3 text-sm text-muted-foreground">
<p>{t('home.contact_privacy')}</p>
<Button
type="submit"
disabled={processing}
className="h-12 w-full rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-base font-semibold text-white shadow-[0_18px_35px_-18px_rgba(236,72,153,0.7)] transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5] disabled:cursor-not-allowed disabled:opacity-60"
>
{processing ? t('home.sending') : t('home.send')}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</section>
<section className="bg-white py-20 px-4 dark:bg-gray-950">
<div className="container mx-auto max-w-6xl">
<div className="mx-auto max-w-3xl text-center">
<h2 className="font-display text-3xl font-bold md:text-4xl">
{t('home.testimonials_title')}
</h2>
<p className="mt-4 text-base text-muted-foreground">{t('home.testimonials_subtitle')}</p>
</div>
<div className="mt-12 grid gap-6 md:grid-cols-3">
{[t('home.testimonial1'), t('home.testimonial2'), t('home.testimonial3')].map(
(quote, index) => (
<Card
key={`testimonial-${index}`}
className="border-gray-200/70 bg-white/95 shadow-md shadow-rose-200/40 dark:border-gray-800/60 dark:bg-gray-900/80"
>
<CardContent className="flex h-full flex-col justify-between gap-4 p-6">
<p className="text-base italic text-slate-700 dark:text-slate-200">{quote}</p>
</CardContent>
</Card>
),
)}
</div>
</div>
</section>
<section className="bg-gray-50 py-20 px-4 dark:bg-gray-950/80">
<div className="container mx-auto max-w-4xl">
<div className="mx-auto text-center">
<h2 className="font-display text-3xl font-bold md:text-4xl">{t('home.faq_title')}</h2>
<p className="mt-3 text-base text-muted-foreground">{t('home.faq_subtitle')}</p>
</div>
<div className="mt-10 space-y-4">
{[{ q: t('home.faq1_q'), a: t('home.faq1_a') }, { q: t('home.faq2_q'), a: t('home.faq2_a') }].map(
({ q, a }, index) => (
<Card
key={`faq-${index}`}
className="border-gray-200/70 bg-white/95 shadow-sm shadow-gray-200/40 dark:border-gray-800/60 dark:bg-gray-900/80"
>
<CardHeader>
<CardTitle className="text-lg">{q}</CardTitle>
<CardDescription className="text-sm leading-relaxed text-muted-foreground">
{a}
</CardDescription>
</CardHeader>
</Card>
),
)}
</div>
</div>
</section>

View File

@@ -0,0 +1,423 @@
import React from 'react';
import { Head, Link } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import MarketingLayout from '@/layouts/mainWebsite';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { CheckCircle2, Images, Sparkles, Users } from 'lucide-react';
type HeroStat = { value: string; label: string };
type ExperienceStep = { title: string; description: string };
type ExperienceGroup = {
label: string;
intro: string;
steps: ExperienceStep[];
callouts: string[];
};
type TimelineItem = { title: string; body: string; tips: string[] };
type UseCase = {
value: string;
label: string;
goal: string;
recommendations: string[];
ideas: string[];
};
type FaqItem = { question: string; answer: string };
const iconByUseCase: Record<string, React.ReactNode> = {
wedding: <Sparkles className="h-6 w-6 text-pink-500" aria-hidden />,
birthday: <Sparkles className="h-6 w-6 text-pink-500" aria-hidden />,
corporate: <Sparkles className="h-6 w-6 text-pink-500" aria-hidden />,
confirmation: <Sparkles className="h-6 w-6 text-pink-500" aria-hidden />,
public: <Users className="h-6 w-6 text-pink-500" aria-hidden />,
};
const HowItWorks: React.FC = () => {
const { t } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes();
const hero = t('how_it_works_page.hero', { returnObjects: true }) as {
title: string;
subtitle: string;
primaryCta: string;
secondaryCta: string;
stats: HeroStat[];
};
const experience = t('how_it_works_page.experience', { returnObjects: true }) as {
host: ExperienceGroup;
guest: ExperienceGroup;
};
const pillars = t('how_it_works_page.pillars', { returnObjects: true }) as Array<{
title: string;
description: string;
}>;
const timeline = t('how_it_works_page.timeline', { returnObjects: true }) as TimelineItem[];
const useCases = t('how_it_works_page.use_cases', { returnObjects: true }) as {
title: string;
description: string;
tabs: UseCase[];
};
const checklist = t('how_it_works_page.checklist', { returnObjects: true }) as {
title: string;
items: string[];
cta: string;
};
const faq = t('how_it_works_page.faq', { returnObjects: true }) as {
title: string;
items: FaqItem[];
};
const support = t('how_it_works_page.support', { returnObjects: true }) as {
title: string;
description: string;
cta: string;
};
return (
<MarketingLayout title={hero.title}>
<Head title={hero.title} />
<section className="relative overflow-hidden bg-gradient-to-br from-pink-100 via-white to-white px-4 py-16 dark:from-pink-950/40 dark:via-gray-950 dark:to-gray-950">
<div className="absolute -top-24 right-24 hidden h-64 w-64 rounded-full bg-pink-200/60 blur-3xl dark:bg-pink-900/50 lg:block" />
<div className="container mx-auto relative z-10 flex max-w-6xl flex-col gap-12 lg:flex-row lg:items-center">
<div className="flex-1 space-y-6">
<Badge variant="outline" className="border-pink-400 text-pink-600 dark:border-pink-500 dark:text-pink-300">
Fotospiel Flow
</Badge>
<h1 className="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-50 md:text-5xl">
{hero.title}
</h1>
<p className="max-w-2xl text-lg text-gray-600 dark:text-gray-300">
{hero.subtitle}
</p>
<div className="flex flex-wrap items-center gap-3">
<Button asChild size="lg" className="bg-pink-500 hover:bg-pink-600">
<Link href={localizedPath('/register')}>
{hero.primaryCta}
</Link>
</Button>
<Button asChild size="lg" variant="outline" className="border-pink-200 text-pink-600 hover:bg-pink-100 dark:border-pink-900 dark:text-pink-300 dark:hover:bg-pink-900/40">
<Link href={localizedPath('/kontakt')}>
{hero.secondaryCta}
</Link>
</Button>
</div>
</div>
<div className="flex-1">
<div className="grid gap-4 sm:grid-cols-3">
{hero.stats.map((stat) => (
<Card key={stat.label} className="border-pink-100/70 shadow-none dark:border-pink-900/40">
<CardHeader className="pb-2">
<CardTitle className="text-2xl font-semibold text-pink-600 dark:text-pink-300">
{stat.value}
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-sm text-gray-600 dark:text-gray-300">
{stat.label}
</CardDescription>
</CardContent>
</Card>
))}
</div>
</div>
</div>
</section>
<section className="container mx-auto px-4 py-16">
<div className="mx-auto max-w-5xl">
<Tabs defaultValue="host" className="w-full">
<TabsList className="flex w-full justify-start gap-2 bg-pink-50/60 p-2 dark:bg-gray-900/60">
<TabsTrigger value="host" className="flex-1 text-base">
{experience.host.label}
</TabsTrigger>
<TabsTrigger value="guest" className="flex-1 text-base">
{experience.guest.label}
</TabsTrigger>
</TabsList>
<TabsContent value="host" className="mt-6">
<ExperiencePanel data={experience.host} />
</TabsContent>
<TabsContent value="guest" className="mt-6">
<ExperiencePanel data={experience.guest} />
</TabsContent>
</Tabs>
</div>
</section>
<section className="container mx-auto px-4 py-16">
<div className="mx-auto max-w-6xl text-center">
<Badge variant="secondary" className="mb-4">Core Features</Badge>
<h2 className="text-3xl font-bold text-gray-900 dark:text-gray-50 md:text-4xl">
{t('home.features_title', 'Warum Fotospiel?')}
</h2>
<p className="mt-3 text-lg text-gray-600 dark:text-gray-300">
{t('home.hero_description')}
</p>
</div>
<div className="mx-auto mt-10 grid max-w-6xl gap-6 md:grid-cols-2">
{pillars.map((pillar) => (
<Card key={pillar.title} className="border-gray-100 shadow-sm dark:border-gray-800">
<CardHeader>
<CardTitle className="flex items-center gap-3 text-left text-xl">
<Sparkles className="h-5 w-5 text-pink-500" aria-hidden />
{pillar.title}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-left text-gray-600 dark:text-gray-300">
{pillar.description}
</p>
</CardContent>
</Card>
))}
</div>
</section>
<section className="bg-gradient-to-br from-white via-pink-50/40 to-white px-4 py-16 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950">
<div className="container mx-auto max-w-5xl">
<div className="mb-8 text-center">
<Badge variant="outline" className="border-pink-400 text-pink-600 dark:border-pink-500 dark:text-pink-300">
{t('how_it_works_page.timeline_title', 'Der Ablauf im Detail')}
</Badge>
<h2 className="mt-3 text-3xl font-bold text-gray-900 dark:text-gray-50">
Ein klarer Fahrplan für dein Event
</h2>
</div>
<Accordion type="single" collapsible className="w-full">
{timeline.map((item, index) => (
<AccordionItem key={item.title} value={`step-${index}`}>
<AccordionTrigger className="text-left text-lg">
<div className="flex items-center gap-3">
<Badge variant="secondary" className="rounded-full">{index + 1}</Badge>
<span>{item.title}</span>
</div>
</AccordionTrigger>
<AccordionContent>
<p className="text-gray-600 dark:text-gray-300">
{item.body}
</p>
{item.tips?.length ? (
<div className="mt-4 rounded-lg border border-pink-100 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-pink-900/40 dark:bg-gray-900 dark:text-gray-300">
<p className="mb-2 font-semibold text-pink-600 dark:text-pink-300">
{t('marketing.actions.tips', 'Tipps')}
</p>
<ul className="space-y-1">
{item.tips.map((tip) => (
<li key={tip} className="flex items-start gap-2">
<span className="mt-1 inline-flex h-1.5 w-1.5 rounded-full bg-pink-400" aria-hidden />
<span>{tip}</span>
</li>
))}
</ul>
</div>
) : null}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</section>
<section className="container mx-auto px-4 py-16">
<div className="mx-auto max-w-5xl text-center">
<Badge variant="secondary" className="mb-4">
{useCases.title}
</Badge>
<h2 className="text-3xl font-bold text-gray-900 dark:text-gray-50 md:text-4xl">
{useCases.description}
</h2>
</div>
<div className="mx-auto mt-10 max-w-5xl">
<Tabs defaultValue={useCases.tabs[0]?.value ?? ''}>
<TabsList className="flex flex-wrap gap-2 bg-transparent p-0">
{useCases.tabs.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="rounded-full border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm data-[state=active]:border-pink-500 data-[state=active]:bg-pink-50 data-[state=active]:text-pink-600 dark:border-gray-800 dark:text-gray-200 dark:data-[state=active]:border-pink-500 dark:data-[state=active]:bg-pink-900/40"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{useCases.tabs.map((tab) => (
<TabsContent key={tab.value} value={tab.value} className="mt-6">
<Card className="border-gray-100 shadow-md dark:border-gray-800">
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-2xl">
{tab.label}
</CardTitle>
<CardDescription className="text-base text-gray-600 dark:text-gray-300">
{tab.goal}
</CardDescription>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-pink-100 dark:bg-pink-900/40">
{iconByUseCase[tab.value] ?? <Images className="h-6 w-6 text-pink-500" aria-hidden />}
</div>
</CardHeader>
<CardContent className="grid gap-6 md:grid-cols-2">
<div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-pink-600 dark:text-pink-300">
{t('marketing.labels.recommendations', 'Empfehlungen')}
</p>
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
{tab.recommendations.map((item) => (
<li key={item} className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-pink-500" aria-hidden />
<span>{item}</span>
</li>
))}
</ul>
</div>
<div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-pink-600 dark:text-pink-300">
{t('marketing.labels.challengeIdeas', 'Ideen für Challenges')}
</p>
<div className="flex flex-wrap gap-2">
{tab.ideas.map((idea) => (
<Badge key={idea} variant="outline" className="border-pink-200 text-pink-600 dark:border-pink-900/40 dark:text-pink-300">
{idea}
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
))}
</Tabs>
</div>
</section>
<section className="container mx-auto px-4 py-16">
<div className="mx-auto max-w-4xl">
<Card className="border-gray-100 shadow-sm dark:border-gray-800">
<CardHeader>
<CardTitle>{checklist.title}</CardTitle>
<CardDescription>
{t('marketing.labels.prepHint', 'Alles, was du vor dem Event abhaken solltest.')}
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-3 text-gray-600 dark:text-gray-300">
{checklist.items.map((item) => (
<li key={item} className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-5 w-5 text-pink-500" aria-hidden />
<span>{item}</span>
</li>
))}
</ul>
<Button asChild className="mt-6 bg-pink-500 hover:bg-pink-600">
<Link href={localizedPath('/register')}>
{checklist.cta}
</Link>
</Button>
</CardContent>
</Card>
</div>
</section>
<section className="container mx-auto px-4 pb-16">
<div className="mx-auto max-w-4xl">
<h2 className="text-3xl font-bold text-gray-900 dark:text-gray-50">
{faq.title}
</h2>
<Accordion type="single" collapsible className="mt-6">
{faq.items.map((item, index) => (
<AccordionItem key={item.question} value={`faq-${index}`}>
<AccordionTrigger className="text-left text-lg">
{item.question}
</AccordionTrigger>
<AccordionContent className="text-gray-600 dark:text-gray-300">
{item.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</section>
<section className="bg-pink-50/80 px-4 py-14 dark:bg-pink-950/30">
<div className="container mx-auto max-w-4xl">
<Alert className="border-pink-200 bg-white shadow-lg dark:border-pink-800 dark:bg-gray-950">
<AlertTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
{support.title}
</AlertTitle>
<AlertDescription className="mt-2 flex flex-col gap-4 text-gray-600 dark:text-gray-300 md:flex-row md:items-center md:justify-between">
<span className="max-w-xl text-base">{support.description}</span>
<Button asChild className="bg-pink-500 hover:bg-pink-600">
<Link href={localizedPath('/kontakt')}>
{support.cta}
</Link>
</Button>
</AlertDescription>
</Alert>
</div>
</section>
</MarketingLayout>
);
};
const ExperiencePanel: React.FC<{ data: ExperienceGroup }> = ({ data }) => {
const { t } = useTranslation('marketing');
return (
<Card className="border-gray-100 shadow-md dark:border-gray-800">
<CardHeader>
<CardTitle className="text-2xl text-gray-900 dark:text-gray-50">
{data.label}
</CardTitle>
<CardDescription className="text-base text-gray-600 dark:text-gray-300">
{data.intro}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-3">
{data.steps.map((step, index) => (
<div key={step.title} className="rounded-lg border border-gray-100 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div className="mb-3 inline-flex h-9 w-9 items-center justify-center rounded-full bg-pink-100 text-base font-semibold text-pink-600 dark:bg-pink-900/40 dark:text-pink-300">
{index + 1}
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{step.title}
</h3>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
{step.description}
</p>
</div>
))}
</div>
{data.callouts?.length ? (
<div className="rounded-xl border border-pink-100 bg-pink-50/60 p-6 dark:border-pink-900/40 dark:bg-pink-950/30">
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-pink-600 dark:text-pink-300">
{t('how_it_works_page.labels.good_to_know', 'Gut zu wissen')}
</p>
<ul className="grid gap-3 md:grid-cols-3">
{data.callouts.map((item) => (
<li key={item} className="flex items-start gap-2 text-sm text-pink-700 dark:text-pink-200">
<Sparkles className="mt-0.5 h-4 w-4" aria-hidden />
<span>{item}</span>
</li>
))}
</ul>
</div>
) : null}
</CardContent>
</Card>
);
};
export default HowItWorks;

View File

@@ -46,6 +46,17 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
],
cta: t('occasions.cta'),
},
konfirmation: {
title: t('occasions.confirmation.title'),
description: t('occasions.confirmation.description'),
features: [
t('occasions.confirmation.benefit1'),
t('occasions.confirmation.benefit2'),
t('occasions.confirmation.benefit3'),
t('occasions.confirmation.benefit4'),
],
cta: t('occasions.cta'),
},
};
const content = occasionsContent[type as keyof typeof occasionsContent] || occasionsContent.hochzeit;
@@ -78,4 +89,4 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
);
};
export default Occasions;
export default Occasions;

View File

@@ -2,7 +2,6 @@ import React, { useState, useEffect, useMemo } from 'react';
import { Head, Link, usePage } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -484,27 +483,7 @@ function PackageCard({
<div className="container mx-auto">
<h2 className="text-3xl font-bold text-center mb-12 font-display">{t('packages.section_endcustomer')}</h2>
<div className="block md:hidden">
<Carousel className="mx-auto w-full max-w-md" opts={{ loop: true }}>
<CarouselContent className="-ml-2">
{endcustomerPackages.map((pkg) => (
<CarouselItem key={pkg.id} className="basis-full pl-2">
<PackageCard
pkg={pkg}
variant="endcustomer"
highlight={pkg.id === highlightEndcustomerId}
onSelect={(pkg) => handleCardClick(pkg, 'endcustomer')}
className="h-full"
/>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
<div className="hidden gap-6 md:grid md:grid-cols-2 lg:gap-8 lg:grid-cols-3">
<div className="grid gap-6 sm:grid-cols-2 lg:gap-8 lg:grid-cols-3">
{endcustomerPackages.map((pkg) => (
<PackageCard
key={pkg.id}
@@ -652,27 +631,7 @@ function PackageCard({
<div className="container mx-auto">
<h2 className="text-3xl font-bold text-center mb-12 font-display">{t('packages.section_reseller')}</h2>
<div className="block md:hidden">
<Carousel className="mx-auto w-full max-w-md" opts={{ loop: true }}>
<CarouselContent className="-ml-2">
{resellerPackages.map((pkg) => (
<CarouselItem key={pkg.id} className="basis-full pl-2">
<PackageCard
pkg={pkg}
variant="reseller"
highlight={pkg.id === highlightResellerId}
onSelect={(pkg) => handleCardClick(pkg, 'reseller')}
className="h-full"
/>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
<div className="hidden gap-6 md:grid md:grid-cols-2 lg:gap-8 xl:grid-cols-3">
<div className="grid gap-6 sm:grid-cols-2 lg:gap-8 xl:grid-cols-3">
{resellerPackages.map((pkg) => (
<PackageCard
key={pkg.id}

View File

@@ -144,7 +144,7 @@ export const PaymentStep: React.FC = () => {
const inlineSupported = initialised && !!paddleConfig?.client_token;
if (typeof window !== 'undefined') {
// eslint-disable-next-line no-console
console.info('[Checkout] Paddle inline status', {
inlineSupported,
initialised,
@@ -188,7 +188,7 @@ export const PaymentStep: React.FC = () => {
}
if (typeof window !== 'undefined') {
// eslint-disable-next-line no-console
console.info('[Checkout] Opening inline Paddle checkout', inlinePayload);
}
@@ -214,7 +214,7 @@ export const PaymentStep: React.FC = () => {
const rawBody = await response.text();
if (typeof window !== 'undefined') {
// eslint-disable-next-line no-console
console.info('[Checkout] Hosted checkout response', { status: response.status, rawBody });
}
@@ -270,7 +270,7 @@ export const PaymentStep: React.FC = () => {
}
if (typeof window !== 'undefined') {
// eslint-disable-next-line no-console
console.debug('[Checkout] Paddle event', event);
}
@@ -307,7 +307,7 @@ export const PaymentStep: React.FC = () => {
let inlineReady = false;
if (typeof paddle.Initialize === 'function' && clientToken) {
if (typeof window !== 'undefined') {
// eslint-disable-next-line no-console
console.info('[Checkout] Initializing Paddle.js', { environment, hasToken: Boolean(clientToken) });
}