login seitentexte verbessert und event selector gefixt. allgemeine event-landingpage schickt gemacht.
This commit is contained in:
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { getEvents, type TenantEvent } from '../api';
|
||||
import { useAuth } from '../auth/context';
|
||||
|
||||
const STORAGE_KEY = 'tenant-admin.active-event';
|
||||
|
||||
@@ -15,6 +16,7 @@ export interface EventContextValue {
|
||||
const EventContext = React.createContext<EventContextValue | undefined>(undefined);
|
||||
|
||||
export function EventProvider({ children }: { children: React.ReactNode }) {
|
||||
const { status } = useAuth();
|
||||
const [storedSlug, setStoredSlug] = React.useState<string | null>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
@@ -22,20 +24,29 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
|
||||
return window.localStorage.getItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
const { data: events = [], isLoading } = useQuery<TenantEvent[]>({
|
||||
const authReady = status === 'authenticated';
|
||||
|
||||
const {
|
||||
data: fetchedEvents = [],
|
||||
isLoading: queryLoading,
|
||||
} = useQuery<TenantEvent[]>({
|
||||
queryKey: ['tenant-events'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await getEvents();
|
||||
} catch (error) {
|
||||
console.warn('[EventContext] Failed to fetch events', error);
|
||||
return [];
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
cacheTime: 5 * 60 * 1000,
|
||||
enabled: authReady,
|
||||
});
|
||||
|
||||
const events = authReady ? fetchedEvents : [];
|
||||
const isLoading = authReady ? queryLoading : status === 'loading';
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!storedSlug && events.length === 1 && events[0]?.slug && typeof window !== 'undefined') {
|
||||
setStoredSlug(events[0].slug);
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
"Steuere Aufgaben, Emotionen und Slideshows direkt vom Event aus."
|
||||
],
|
||||
"lead": "Du meldest dich über unser gesichertes Fotospiel-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 persönlichen Zugriffstokens und klaren Rollenrechten.",
|
||||
"panel_title": "Team Login für Fotospiel",
|
||||
"panel_copy": "Melde dich an, um Events zu planen, Fotos zu moderieren und Aufgaben im Fotospiel-Team zu koordinieren.",
|
||||
"actions_title": "Wähle deine Anmeldemethode",
|
||||
"actions_copy": "Greife sicher mit deinem Fotospiel-Login oder deinem Google-Konto auf das Tenant-Dashboard zu.",
|
||||
"cta": "Mit Fotospiel-Login fortfahren",
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -119,14 +118,22 @@ export default function LoginPage(): JSX.Element {
|
||||
|
||||
<div className="relative z-10 mx-auto flex w-full max-w-md flex-col gap-10 px-6 py-16">
|
||||
<header className="flex flex-col items-center gap-3 text-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/15 shadow-lg shadow-black/20">
|
||||
<AppLogoIcon className="h-7 w-7 text-white" />
|
||||
<span className="flex h-14 w-14 items-center justify-center overflow-hidden rounded-full border border-white/20 bg-white/10 shadow-lg shadow-black/20">
|
||||
<img
|
||||
src="/logo-transparent-md.png"
|
||||
alt={t('login.brand_alt', 'Fotospiel Logo')}
|
||||
className="h-12 w-12 object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</span>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{t('login.panel_title', t('login.title', 'Event Admin Login'))}
|
||||
{t('login.panel_title', t('login.title', 'Team Login für Fotospiel'))}
|
||||
</h1>
|
||||
<p className="max-w-sm text-sm text-white/70">
|
||||
{t('login.panel_copy', 'Sign in with your Fotospiel admin credentials to manage your events and galleries.')}
|
||||
{t(
|
||||
'login.panel_copy',
|
||||
'Melde dich an, um Events zu planen, Fotos zu moderieren und Aufgaben im Fotospiel-Team zu koordinieren.'
|
||||
)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -195,7 +202,7 @@ export default function LoginPage(): JSX.Element {
|
||||
</form>
|
||||
|
||||
<footer className="text-center text-xs text-white/50">
|
||||
{t('login.support', "Brauchen Sie Hilfe? Schreiben Sie uns an support@fotospiel.de")}
|
||||
{t('login.support', 'Fragen? Schreib uns an support@fotospiel.de oder antworte direkt auf deine Einladung.')}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -106,9 +106,9 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
},
|
||||
},
|
||||
landing: {
|
||||
pageTitle: 'Willkommen bei der Fotobox!',
|
||||
headline: 'Willkommen bei der Fotobox!',
|
||||
subheadline: 'Dein Schlüssel zu unvergesslichen Momenten.',
|
||||
pageTitle: 'Willkommen bei der Fotospiel.App!',
|
||||
headline: 'Elegante Erinnerungen, live erzählt.',
|
||||
subheadline: 'Hier beginnt euer Fotoabenteuer – gemeinsam, intuitiv und live.',
|
||||
join: {
|
||||
title: 'Event beitreten',
|
||||
description: 'Scanne den QR-Code oder gib den Code manuell ein.',
|
||||
@@ -534,9 +534,9 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
},
|
||||
},
|
||||
landing: {
|
||||
pageTitle: 'Welcome to the photo booth!',
|
||||
headline: 'Welcome to the photo booth!',
|
||||
subheadline: 'Your key to unforgettable moments.',
|
||||
pageTitle: 'Welcome to the Fotospiel.App!',
|
||||
headline: 'An elegant way to tell memories live.',
|
||||
subheadline: 'Start your collaborative photo story—intuitive, fast, live.',
|
||||
join: {
|
||||
title: 'Join the event',
|
||||
description: 'Scan the QR code or enter the code manually.',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Page } from './_util';
|
||||
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';
|
||||
@@ -131,66 +131,219 @@ export default function LandingPage() {
|
||||
}
|
||||
}, [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 (
|
||||
<Page title={t('landing.pageTitle')}>
|
||||
{errorMessage && (
|
||||
<Alert className="mb-3" variant="destructive">
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-6 pb-20">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{t('landing.headline')}</h1>
|
||||
<p className="text-lg text-gray-600">{t('landing.subheadline')}</p>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<Card className="mx-auto w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl font-semibold">{t('landing.join.title')}</CardTitle>
|
||||
<CardDescription>{t('landing.join.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-6">
|
||||
<div className="flex flex-col items-center space-y-3">
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-lg bg-gray-200">
|
||||
<svg className="h-16 w-16 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
|
||||
</svg>
|
||||
<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>
|
||||
<div id="qr-reader" className="w-full" hidden={!isScanning} />
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={isScanning ? stopScanner : startScanner}
|
||||
disabled={loading}
|
||||
>
|
||||
{isScanning ? t('landing.scan.stop') : t('landing.scan.start')}
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
<div className="border-t border-gray-200 py-2 text-center text-sm text-gray-500">
|
||||
{t('landing.scan.manualDivider')}
|
||||
</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="space-y-2">
|
||||
<Input
|
||||
value={eventCode}
|
||||
onChange={(event) => setEventCode(event.target.value)}
|
||||
placeholder={t('landing.input.placeholder')}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
className="w-full bg-gradient-to-r from-pink-500 to-pink-600 text-white hover:from-pink-600 hover:to-pink-700"
|
||||
disabled={loading || !eventCode.trim()}
|
||||
onClick={() => join()}
|
||||
<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"
|
||||
>
|
||||
{loading ? t('landing.join.buttonLoading') : t('landing.join.button')}
|
||||
</Button>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user