Files
fotospiel-app/resources/js/guest/pages/LandingPage.tsx

350 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Sparkles, Camera, ShieldCheck, QrCode, PartyPopper, Smartphone } from 'lucide-react';
import { Html5Qrcode } from 'html5-qrcode';
import { readGuestName } from '../context/GuestIdentityContext';
import { useTranslation } from '../i18n/useTranslation';
type LandingErrorKey = 'eventClosed' | 'network' | 'camera';
export default function LandingPage() {
const nav = useNavigate();
const { t } = useTranslation();
const [eventCode, setEventCode] = useState('');
const [loading, setLoading] = useState(false);
const [errorKey, setErrorKey] = useState<LandingErrorKey | null>(null);
const [isScanning, setIsScanning] = useState(false);
const [scanner, setScanner] = useState<Html5Qrcode | null>(null);
const errorMessage = errorKey ? t(`landing.errors.${errorKey}`) : null;
function extractEventKey(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return '';
}
try {
const url = new URL(trimmed);
const inviteParam = url.searchParams.get('invite') ?? url.searchParams.get('token');
if (inviteParam) {
return inviteParam;
}
const segments = url.pathname.split('/').filter(Boolean);
const eventIndex = segments.findIndex((segment) => segment === 'e');
if (eventIndex >= 0 && segments.length > eventIndex + 1) {
return decodeURIComponent(segments[eventIndex + 1]);
}
if (segments.length > 0) {
return decodeURIComponent(segments[segments.length - 1]);
}
} catch {
// Not a URL, treat as raw code
}
return trimmed;
}
async function join(input?: string) {
const provided = input ?? eventCode;
const normalized = extractEventKey(provided);
if (!normalized) return;
setLoading(true);
setErrorKey(null);
try {
const res = await fetch(`/api/v1/events/${encodeURIComponent(normalized)}`);
if (!res.ok) {
setErrorKey('eventClosed');
return;
}
const data = await res.json();
const targetKey = data.join_token ?? '';
if (!targetKey) {
setErrorKey('eventClosed');
return;
}
const storedName = readGuestName(targetKey);
if (!storedName) {
nav(`/setup/${encodeURIComponent(targetKey)}`);
} else {
nav(`/e/${encodeURIComponent(targetKey)}`);
}
} catch (e) {
console.error('Join request failed', e);
setErrorKey('network');
} finally {
setLoading(false);
}
}
const qrConfig = { fps: 10, qrbox: { width: 250, height: 250 } } as const;
async function startScanner() {
if (scanner) {
try {
await scanner.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined);
setIsScanning(true);
} catch (err) {
console.error('Scanner start failed', err);
setErrorKey('camera');
}
return;
}
try {
const newScanner = new Html5Qrcode('qr-reader');
setScanner(newScanner);
await newScanner.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined);
setIsScanning(true);
} catch (err) {
console.error('Scanner initialisation failed', err);
setErrorKey('camera');
}
}
function stopScanner() {
if (!scanner) {
setIsScanning(false);
return;
}
scanner
.stop()
.then(() => {
setIsScanning(false);
})
.catch((err) => console.error('Scanner stop failed', err));
}
async function onScanSuccess(decodedText: string) {
const value = decodedText.trim();
if (!value) return;
await join(value);
stopScanner();
}
useEffect(() => () => {
if (scanner) {
scanner.stop().catch(() => undefined);
}
}, [scanner]);
const heroFeatures = [
{
icon: Sparkles,
title: t('landing.features.momentsTitle', 'Momente mit Wow-Effekt'),
description: t('landing.features.momentsCopy', 'Moderierte Fotoaufgaben motivieren dein Team und halten die Stimmung hoch.'),
},
{
icon: Camera,
title: t('landing.features.uploadTitle', 'Uploads ohne App-Stress'),
description: t('landing.features.uploadCopy', 'Scan & Shoot: Gäste landen direkt im Event und teilen ihre Highlights live.'),
},
{
icon: ShieldCheck,
title: t('landing.features.trustTitle', 'Sicher & DSGVO-konform'),
description: t('landing.features.trustCopy', 'Nur eingeladene Gäste erhalten Zugriff mit Tokens, Rollenrechten und deutschem Hosting.'),
},
];
const highlightCards = [
{
icon: Sparkles,
title: t('landing.highlight.story', 'Storytelling statt Sammelalbum'),
description: t('landing.highlight.storyCopy', 'Fotospiel verbindet Aufgaben, Emotionen und Uploads zu einer spannenden Timeline.'),
},
{
icon: Camera,
title: t('landing.highlight.mobile', 'Optimiert für jedes Smartphone'),
description: t('landing.highlight.mobileCopy', 'Keine App-Installation nötig einfach Link öffnen oder QR-Code scannen.'),
},
{
icon: ShieldCheck,
title: t('landing.highlight.privacy', 'Transparente Freigaben'),
description: t('landing.highlight.privacyCopy', 'Admin- und Gästerollen sorgen dafür, dass nur autorisierte Personen Inhalte sehen.'),
},
{
icon: PartyPopper,
title: t('landing.highlight.live', 'Live auf Screens & Slideshows'),
description: t('landing.highlight.liveCopy', 'Uploads können sofort auf Displays, Projektoren oder dem großen Screen erscheinen.'),
},
];
const steps = [
{ icon: QrCode, label: t('landing.steps.scan', 'QR-Code vom Event scannen oder Link öffnen.') },
{ icon: Smartphone, label: t('landing.steps.profile', 'Kurz vorstellen: Name eintragen und loslegen.') },
{ icon: PartyPopper, label: t('landing.steps.upload', 'Fotos aufnehmen, Aufgaben lösen, Erinnerungen teilen.') },
];
return (
<div className="relative min-h-screen bg-slate-950 text-white">
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(255,174,204,0.55),transparent_55%),radial-gradient(circle_at_30%_30%,rgba(56,189,248,0.35),transparent_50%),radial-gradient(circle_at_bottom,_rgba(113,88,226,0.45),transparent_60%)] opacity-80"
/>
<div className="absolute inset-0 bg-gradient-to-b from-slate-950/80 via-slate-950/85 to-slate-950" aria-hidden />
<div className="relative z-10 px-4 py-10 sm:px-6 lg:px-10">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-10">
{errorMessage && (
<Alert variant="destructive" className="border-rose-400/70 bg-rose-500/15 text-rose-50 backdrop-blur">
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
<section className="grid gap-12 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
<div className="space-y-6">
<p className="text-sm uppercase tracking-[0.5em] text-rose-200">{t('landing.pageTitle')}</p>
<div className="space-y-4">
<h1 className="text-4xl font-semibold leading-tight text-white sm:text-5xl">
{t('landing.headline', 'Die Event-Landing, die zum Marketing passt.')}
</h1>
<p className="text-lg text-slate-200 sm:text-xl">
{t('landing.subheadline', 'Fotospiel begrüßt deine Gäste mit einem warmen Erlebnis, noch bevor die erste Aufnahme entsteht.')}
</p>
</div>
<ul className="space-y-3 text-base text-slate-200">
{heroFeatures.map((feature) => (
<li key={feature.title} className="flex items-start gap-3">
<span className="mt-1 flex h-9 w-9 items-center justify-center rounded-xl bg-white/10 text-rose-200">
<feature.icon className="h-4 w-4" />
</span>
<div>
<p className="text-base font-semibold text-white">{feature.title}</p>
<p className="text-sm text-slate-300">{feature.description}</p>
</div>
</li>
))}
</ul>
<div className="flex flex-wrap gap-3 text-sm text-slate-300">
<span className="rounded-full border border-white/20 px-4 py-1.5">
{t('landing.tags.private', 'Nur für eingeladene Gäste')}
</span>
<span className="rounded-full border border-white/20 px-4 py-1.5">
{t('landing.tags.instant', 'Live-Uploads & Aufgaben')}
</span>
<span className="rounded-full border border-white/20 px-4 py-1.5">
{t('landing.tags.deHosted', 'Gehostet in Deutschland')}
</span>
</div>
</div>
<Card className="border-white/15 bg-white/95 text-slate-900 shadow-[0_25px_60px_-30px_rgba(15,23,42,0.85)] backdrop-blur-xl">
<CardHeader className="space-y-2 text-center">
<CardTitle className="text-2xl font-semibold text-slate-900">
{t('landing.join.title')}
</CardTitle>
<CardDescription className="text-base text-slate-600">
{t('landing.join.description')}
</CardDescription>
<div className="text-xs uppercase tracking-[0.4em] text-rose-500">
{t('landing.join.subline', 'QR · Code · Link')}
</div>
</CardHeader>
<CardContent className="space-y-5">
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 text-center">
<p className="text-sm font-semibold text-slate-700">{t('landing.scan.headline', 'QR-Code scannen')}</p>
<p className="text-xs text-slate-500">{t('landing.scan.subline', 'Nutze die Kamera deines Smartphones oder Tablets')}</p>
<div className="mt-4 flex flex-col items-center gap-3">
<div className="flex h-24 w-24 items-center justify-center rounded-2xl bg-white shadow-inner shadow-slate-200/80">
<QrCode className="h-10 w-10 text-slate-800" />
</div>
<div id="qr-reader" className="w-full" hidden={!isScanning} />
<Button
variant={isScanning ? 'secondary' : 'default'}
className="w-full justify-center rounded-xl text-base"
onClick={isScanning ? stopScanner : startScanner}
disabled={loading}
>
{isScanning ? t('landing.scan.stop') : t('landing.scan.start')}
</Button>
</div>
</div>
<div className="text-center text-xs uppercase tracking-[0.3em] text-slate-400">
{t('landing.scan.manualDivider')}
</div>
<div className="space-y-3">
<Input
value={eventCode}
onChange={(event) => setEventCode(event.target.value)}
placeholder={t('landing.input.placeholder')}
disabled={loading}
className="h-12 rounded-2xl border-slate-200 bg-white px-4 text-base"
/>
<Button
className="h-12 w-full rounded-2xl bg-gradient-to-r from-rose-500 via-fuchsia-500 to-indigo-500 text-base font-semibold text-white shadow-lg shadow-rose-500/30 transition hover:brightness-110"
disabled={loading || !eventCode.trim()}
onClick={() => join()}
>
{loading ? t('landing.join.buttonLoading') : t('landing.join.button')}
</Button>
</div>
<p className="text-center text-xs text-slate-500">
{t('landing.hint.support', 'Du hast einen Link erhalten? Füge ihn direkt oben ein wir erkennen den Event-Code automatisch.')}
</p>
</CardContent>
</Card>
</section>
<section className="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-inner shadow-white/10 backdrop-blur">
<p className="text-sm font-semibold uppercase tracking-[0.45em] text-rose-200">
{t('landing.steps.title', 'So funktioniert Fotospiel')}
</p>
<div className="mt-4 grid gap-6 md:grid-cols-3">
{steps.map(({ icon: Icon, label }) => (
<div key={label} className="space-y-2 rounded-2xl border border-white/10 bg-white/5 p-5 text-sm text-slate-200">
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/10 text-rose-200">
<Icon className="h-5 w-5" />
</span>
<p className="text-base font-medium text-white">{label}</p>
</div>
))}
</div>
</section>
<section className="grid gap-6 lg:grid-cols-2">
{highlightCards.map((feature) => (
<div
key={feature.title}
className="rounded-3xl border border-white/10 bg-white/5 p-5 text-slate-200 shadow-inner shadow-white/5 backdrop-blur"
>
<div className="flex items-center gap-3 text-white">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-white/10 text-rose-200">
<feature.icon className="h-5 w-5" />
</span>
<h3 className="text-lg font-semibold">{feature.title}</h3>
</div>
<p className="mt-2 text-sm text-slate-200">{feature.description}</p>
</div>
))}
</section>
<section className="rounded-3xl border border-white/10 bg-white/10 p-6 text-sm text-white backdrop-blur">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-base font-semibold uppercase tracking-[0.35em] text-rose-200">
{t('landing.support.title', 'Support & Fragen')}
</p>
<p className="mt-1 text-sm text-white/80">
{t('landing.support.copy', 'Frag dein Event-Team oder melde dich bei uns wir helfen sofort weiter.')}
</p>
</div>
<div className="flex flex-wrap gap-3 text-sm">
<span className="rounded-full border border-white/20 px-4 py-1.5">
{t('landing.support.email', 'support@fotospiel.de')}
</span>
<span className="rounded-full border border-white/20 px-4 py-1.5">
{t('landing.support.reply', 'Direkt auf die Einladung antworten')}
</span>
</div>
</div>
</section>
</div>
</div>
</div>
);
}