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

@@ -282,6 +282,16 @@ class MarketingController extends Controller
return Inertia::render('marketing/BlogShow', compact('post')); return Inertia::render('marketing/BlogShow', compact('post'));
} }
public function howItWorks()
{
return Inertia::render('marketing/HowItWorks');
}
public function demo()
{
return Inertia::render('marketing/Demo');
}
public function packagesIndex() public function packagesIndex()
{ {
$endcustomerPackages = Package::where('type', 'endcustomer') $endcustomerPackages = Package::where('type', 'endcustomer')
@@ -314,7 +324,7 @@ class MarketingController extends Controller
'isInertia' => request()->header('X-Inertia'), 'isInertia' => request()->header('X-Inertia'),
]); ]);
$validTypes = ['hochzeit', 'geburtstag', 'firmenevent']; $validTypes = ['hochzeit', 'geburtstag', 'firmenevent', 'konfirmation'];
if (! in_array($type, $validTypes)) { if (! in_array($type, $validTypes)) {
Log::warning('Invalid occasion type accessed', ['type' => $type]); Log::warning('Invalid occasion type accessed', ['type' => $type]);
abort(404, 'Invalid occasion type'); abort(404, 'Invalid occasion type');

View File

@@ -0,0 +1,29 @@
# Localized SEO hreflang Strategy TODO
## Goal
Establish a consistent canonical and hreflang setup for the marketing site so search engines can index German and English content without duplicate-content penalties.
## Status (Stand 17.02.2026)
- **Discovery:** Not started.
- **Implementation:** Not started.
- **Validation:** Not started.
## Discovery
- [ ] Audit current route map and localized content coverage (marketing pages, blog, checkout flow).
- [ ] Decide on URL strategy (session-based locale vs. language-prefixed routes) and document migration implications.
- [ ] Identify required updates to `MarketingLayout`, sitemap generation, and Inertia responses for localized alternates.
## Implementation
- [ ] Ensure canonical URLs and hreflang tags are generated per locale with reciprocal references.
- [ ] Expose locale-specific URLs in navigation, Open Graph tags, and any structured data.
- [ ] Update translation files and config to support the chosen URL strategy.
## Validation
- [ ] Add automated checks (feature test or Playwright) verifying hreflang/canonical tags for both locales.
- [ ] Validate via Search Console-style inspection or lighthouse to confirm alternate links render correctly.
- [ ] Update docs (PRP + marketing playbooks) with the final hreflang strategy and operational guidance.
## Open Questions
- Should blog posts use language-specific slugs or shared identifiers with query parameters?
- How will we handle locale fallbacks for missing translations when hreflang is enforced?
- Do we need localized sitemap indexes per language or a unified sitemap with hreflang annotations?

View File

@@ -1,17 +1,25 @@
{ {
"header": { "header": {
"home": "Startseite", "home": "Startseite",
"how_it_works": "So geht's",
"packages": "Pakete", "packages": "Pakete",
"blog": "Blog", "blog": "Blog",
"occasions": { "occasions": {
"wedding": "Hochzeit", "wedding": "Hochzeiten",
"birthday": "Geburtstag", "birthday": "Geburtstage",
"corporate": "Firmenevent", "corporate": "Firmenfeiern",
"confirmation": "Konfirmation/Jugendweihe",
"label": "Anlässe" "label": "Anlässe"
}, },
"contact": "Kontakt", "contact": "Kontakt",
"login": "Anmelden", "login": "Anmelden",
"register": "Registrieren" "register": "Registrieren",
"cta": "Jetzt ausprobieren",
"utility": "Darstellung & Sprache",
"appearance": "Darstellung",
"appearance_light": "Hell",
"appearance_dark": "Dunkel",
"language": "Sprache"
}, },
"login_failed": "Ungültige E-Mail oder Passwort.", "login_failed": "Ungültige E-Mail oder Passwort.",
"login_success": "Sie sind nun eingeloggt.", "login_success": "Sie sind nun eingeloggt.",
@@ -21,14 +29,37 @@
"failed_credentials": "Falsche Anmeldedaten.", "failed_credentials": "Falsche Anmeldedaten.",
"login": { "login": {
"title": "Anmelden", "title": "Anmelden",
"description": "Melde dich mit deinem Fotospiel Konto an und begleite dein Event vom ersten Upload bis zum finalen Download.",
"identifier": "E-Mail oder Username", "identifier": "E-Mail oder Username",
"identifier_placeholder": "Geben Sie Ihre E-Mail oder Ihren Username ein", "identifier_placeholder": "Geben Sie Ihre E-Mail oder Ihren Username ein",
"username_or_email": "Username oder E-Mail", "username_or_email": "Username oder E-Mail",
"email": "E-Mail-Adresse",
"email_placeholder": "Deine E-Mail-Adresse",
"password": "Passwort", "password": "Passwort",
"password_placeholder": "Geben Sie Ihr Passwort ein", "password_placeholder": "Geben Sie Ihr Passwort ein",
"forgot": "Passwort vergessen?", "forgot": "Passwort vergessen?",
"remember": "Angemeldet bleiben", "remember": "Angemeldet bleiben",
"submit": "Anmelden" "submit": "Anmelden",
"no_account": "Noch keinen Account?",
"sign_up": "Jetzt registrieren",
"success_toast": "Login erfolgreich",
"unexpected_error": "Beim Login ist ein Fehler aufgetreten.",
"highlights": {
"moments": "Momente in Echtzeit teilen",
"moments_description": "Uploads landen sofort in der Event-Galerie ohne App-Download.",
"branding": "Branding & Slideshows, die begeistern",
"branding_description": "Konfiguriere Slideshow, Wasserzeichen und Aufgaben für dein Event.",
"privacy": "Sicherer Zugang über Tokens",
"privacy_description": "Eventzugänge bleiben geschützt DSGVO-konform mit Join Tokens."
},
"hero_tagline": "Event-Tech mit Herz",
"hero_heading": "Willkommen zurück bei Fotospiel",
"hero_subheading": "Verwalte Events, Galerien und Gästelisten in einem liebevoll gestalteten Dashboard.",
"hero_footer": {
"headline": "Noch kein Account?",
"subline": "Entdecke unsere Packages und erlebe Fotospiel live.",
"cta": "Packages entdecken"
}
}, },
"register": { "register": {
"title": "Registrieren", "title": "Registrieren",

View File

@@ -9,43 +9,83 @@
}, },
"home": { "home": {
"title": "Startseite - Fotospiel", "title": "Startseite - Fotospiel",
"hero_title": "Fotospiel", "hero_tagline": "Eventfotos ohne App-Zwang",
"hero_description": "Sammle Gastfotos für Events mit QR-Codes. Unsere sichere PWA-Plattform für Gäste und Organisatoren einfach, mobil und datenschutzkonform. Besser als Konkurrenz, geliebt von Tausenden.", "hero_title": "Dein Event. Eure Fotos. Echtzeit bereit.",
"cta_explore": "Pakete entdecken", "hero_description": "Fotospiel bündelt QR-Zugänge, Live-Galerien und Moderation in einer einzigen Plattform für Hochzeiten, Firmenfeiern und jedes Fest, das Erinnerungen verdient.",
"cta_explore_highlight": "Jetzt loslegen", "hero_bullets": [
"hero_image_alt": "Event-Fotos mit QR-Code", "Live-Galerie in Sekunden startklar",
"how_title": "So funktioniert es", "Join Tokens schützen jeden Zugang",
"step1_title": "Paket wählen", "Slideshows, Branding und Aufgaben on-the-fly"
"step1_desc": "Wähle das passende Paket für dein Event.", ],
"step2_title": "QR-Code teilen", "cta_demo": "Demo ansehen",
"step2_desc": "Teile den QR-Code mit deinen Gästen.", "cta_demo_highlight": "Live-Demo starten",
"step3_title": "Fotos sammeln", "cta_how": "So funktioniert's",
"step3_desc": "Gäste laden Fotos hoch sicher und einfach.", "cta_packages": "Pakete ansehen",
"features_title": "Warum Fotospiel?", "cta_explore": "Pakete ansehen",
"feature1_title": "Sicher & Datenschutzkonform", "cta_explore_highlight": "Jetzt Fotospiel testen",
"feature1_desc": "GDPR-konform, keine PII-Speicherung.", "hero_image_alt": "Gäste teilen Fotos per QR-Code auf ihrem Smartphone",
"feature2_title": "Mobil & PWA", "how_title": "So läuft Fotospiel",
"feature2_desc": "Funktioniert offline, installierbar wie App.", "how_subtitle": "Von der Einladung bis zur fertigen Galerie in drei cleveren Schritten.",
"feature3_title": "Einfach zu bedienen", "step1_title": "Event erstellen & Paket wählen",
"feature3_desc": "Intuitive UI für Gäste und Organisatoren.", "step1_desc": "In wenigen Klicks zum Event: Grenzen für Fotos, Gäste und Branding festlegen.",
"packages_title": "Unsere Pakete", "step2_title": "Join Token & QR-Code teilen",
"step2_desc": "Gäste scannen, wählen Emotionen oder Aufgaben und laden direkt hoch ohne App-Store.",
"step3_title": "Live moderieren & Highlights zeigen",
"step3_desc": "Schalte Beiträge frei, triggere Slideshows und exportiere Lieblingsfotos sofort.",
"demo_title": "Erlebe die Fotospiel Demo",
"demo_description": "Unser Demo-Event zeigt dir die Gäste-PWA im echten 9:16-Frame. Öffne es auf dem Handy und teste Uploads, Emotionen und Likes live.",
"demo_hint": "Tipp: Teile den Link mit deinem Team, um gemeinsam auszuprobieren.",
"demo_cta": "Zur Demo-Seite",
"demo_media_alt": "Smartphone Rahmen mit geöffneter Fotospiel Demo",
"features_title": "Alles, was dein Event braucht",
"features_highlight": [
{
"title": "Branding & Slideshows",
"description": "Passe Farben, Overlays und Aufgaben an deinen Event an inklusive Live-Slideshow."
},
{
"title": "Moderation in Echtzeit",
"description": "Sperre Inhalte mit einem Klick, markiere Favoriten und exportiere Best-of-Galerien."
},
{
"title": "Analytics & Archiv",
"description": "Verfolge Uploads, Reaktionen und Downloads DSGVO-konform archiviert."
}
],
"occasions_title": "Anlässe, die Fotospiel liebt",
"occasions_description": "Wähle einen Einstieg und entdecke Best-Practices für dein Eventformat.",
"occasions": {
"wedding": "Hochzeiten romantische Momente sammeln",
"birthday": "Geburtstage Erinnerungen von 7 bis 70",
"corporate": "Firmenfeiern Branding & Sicherheit im Fokus",
"confirmation": "Konfirmation & Jugendweihe Familienalbum to go"
},
"blog_teaser_title": "Insights aus dem Fotospiel Blog",
"blog_teaser_description": "Trends, Setups und Playbooks für deine nächste Veranstaltung.",
"blog_teaser_cta": "Zum Blog",
"packages_title": "Packages & Preise",
"packages_subtitle": "Flexibel für Einzelevents oder Agenturen mit Paddle-Checkout in Minuten aktiviert.",
"view_details": "Details ansehen", "view_details": "Details ansehen",
"all_packages": "Alle Pakete ansehen", "all_packages": "Alle Pakete vergleichen",
"contact_title": "Kontakt", "contact_title": "Lass uns über dein Event sprechen",
"contact_lead": "Wir beraten dich zu Aufgaben, Tokens, Hardware-Setups oder individuellen Workflows.",
"name_label": "Name", "name_label": "Name",
"email_label": "E-Mail", "email_label": "E-Mail",
"message_label": "Nachricht", "message_label": "Nachricht",
"sending": "Wird gesendet...", "contact_privacy": "Mit dem Absenden bestätigst du unsere Datenschutzhinweise. Wir melden uns innerhalb von 24 Stunden.",
"send": "Senden", "sending": "Wird gesendet …",
"testimonials_title": "Was unsere Kunden sagen", "send": "Nachricht senden",
"testimonial1": "Toll für Hochzeiten! Einfach und sicher.", "testimonials_title": "Stimmen aus der Community",
"testimonial2": "Beste App für Event-Fotos.", "testimonials_subtitle": "Über 1.200 Events wurden bereits mit Fotospiel begleitet.",
"testimonial3": "Schnell und benutzerfreundlich.", "testimonial1": "„Unsere Gäste haben das Event förmlich dokumentiert und wir hatten alles in einem sicheren Archiv.“",
"faq_title": "Häufige Fragen", "testimonial2": "„Branding, Moderation und Analytics alles genau da, wo ich es im Event brauche.“",
"faq1_q": "Ist es kostenlos?", "testimonial3": "„Konfirmation ohne WhatsApp-Chaos. QR-Code raus, Emojis rein, Bilder für alle!“",
"faq1_a": "Ja, es gibt ein kostenloses Paket für kleine Events.", "faq_title": "Noch Fragen?",
"faq2_q": "Wie funktioniert der QR-Code?", "faq_subtitle": "Hier findest du schnelle Antworten. Mehr Details gibt es in So funktionierts.",
"faq2_a": "Gäste scannen und laden Fotos hoch einfach!" "faq1_q": "Kann ich Fotospiel vorab testen?",
"faq1_a": "Ja! Nutze unser Demo-Event oder buche das Free Package und teste alle Grundfunktionen.",
"faq2_q": "Brauchen Gäste ein Konto?",
"faq2_a": "Nein. Ein Join Token reicht. Auf Wunsch lässt sich die Galerie zusätzlich mit PIN schützen."
}, },
"packages": { "packages": {
"title": "Unsere Packages", "title": "Unsere Packages",
@@ -234,6 +274,16 @@
"benefit4": "GDPR-sicher: Keine PII gespeichert.", "benefit4": "GDPR-sicher: Keine PII gespeichert.",
"image_alt": "Firmenevent-Fotos" "image_alt": "Firmenevent-Fotos"
}, },
"confirmation": {
"title": "Konfirmation & Jugendweihe feiern",
"description": "Halte den Übergang ins Erwachsenenleben fest: Gäste teilen Fotos direkt aus Kirche und Feier ohne App-Installation.",
"benefits_title": "Vorteile für Konfirmation & Jugendweihe",
"benefit1": "Familienfreundlicher QR-Code für alle Generationen.",
"benefit2": "Sammlungen für Kirche, Feier und Gruppenbilder.",
"benefit3": "Moderation: Eltern entscheiden, welche Fotos sichtbar sind.",
"benefit4": "Download-Paket für Dankeskarten und Fotoalbum.",
"image_alt": "Konfirmationsfeier"
},
"family": { "family": {
"title": "Familienfeiern", "title": "Familienfeiern",
"description": "Von Taufen bis Jubiläen: Erinnerungen von allen Verwandten sammeln.", "description": "Von Taufen bis Jubiläen: Erinnerungen von allen Verwandten sammeln.",
@@ -469,5 +519,307 @@
"google_missing_email": "Wir konnten deine Google-E-Mail-Adresse nicht abrufen.", "google_missing_email": "Wir konnten deine Google-E-Mail-Adresse nicht abrufen.",
"google_error_fallback": "Die Google-Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut." "google_error_fallback": "Die Google-Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut."
} }
},
"how_it_works_page": {
"hero": {
"title": "So funktioniert Fotospiel",
"subtitle": "Teile deinen QR-Code, sammle Fotos in Echtzeit und behalte die Moderation. Alles läuft im Browser ganz ohne App.",
"primaryCta": "Event starten",
"secondaryCta": "Kontakt aufnehmen",
"stats": [
{
"value": "5 Min",
"label": "Setup vom Account zur Galerie"
},
{
"value": "0 Apps",
"label": "Gäste nutzen nur ihren Browser"
},
{
"value": "100 %",
"label": "Hosting & Datenschutz in der EU"
}
],
"demoNote": "Demo anschauen",
"demoLabel": "Demo ansehen"
},
"experience": {
"host": {
"label": "Veranstalter:innen",
"intro": "Von der ersten Idee bis zum Export deiner Lieblingsmomente du steuerst alles aus einem Dashboard.",
"steps": [
{
"title": "Event anlegen",
"description": "Titel, Datum und Paket wählen, optionale Challenges und Freigaberichtlinien festlegen."
},
{
"title": "Material teilen",
"description": "QR-Code als PNG/PDF exportieren, Link in Einladungen oder Displays integrieren, Liveshow optional aktivieren."
},
{
"title": "Moderieren & sichern",
"description": "Uploads freigeben oder kuratieren, Favoriten markieren, ZIP-Download und Dankesnachricht vorbereiten."
}
],
"callouts": [
"Co-Hosts für Moderation & Liveshow hinzufügen",
"Offline-Uploads werden automatisch nachgesendet",
"Integrationen über Paddle-Abrechnung und RevenueCat für Apps"
]
},
"guest": {
"label": "Gäste",
"intro": "Für deine Gäste zählt nur der Moment: scannen, fotografieren, fertig. Keine Registrierung, keine Hürden.",
"steps": [
{
"title": "QR-Code scannen",
"description": "Der Browser öffnet sich mit deiner gebrandeten Eventseite kein Download nötig."
},
{
"title": "Foto aufnehmen",
"description": "Direkt mit der Kamera oder aus der Galerie, optionale Challenges machen es spielerisch."
},
{
"title": "Galerie erleben",
"description": "Uploads erscheinen nach Freigabe in der Galerie und in der Live-Show, Downloads bleiben jederzeit möglich."
}
],
"callouts": [
"Progressiver Web-App Modus mit Homescreen-Icon",
"Lokale Zwischenspeicherung bei schwacher Verbindung",
"Barrierearme UI, unterstützt aktuelle iOS/Android Browser"
]
}
},
"pillars": [
{
"title": "Launch in Minuten",
"description": "QR-Code, Link und Liveshow sind sofort einsatzbereit. Druckvorlagen und Textbausteine liefern wir mit."
},
{
"title": "Moderation & Sicherheit",
"description": "Freigabemodi, Löschrichtlinien und Co-Hosts sichern eure Inhalte vollständig DSGVO-konform."
},
{
"title": "Erlebnis vor Ort",
"description": "Aufgaben, Reaktionen und Live-Slideshow motivieren zum Mitmachen ohne zusätzliche Technik."
},
{
"title": "Nach dem Event",
"description": "ZIP-Exports, Favoritenlisten, Follow-up-Mailings alles direkt aus dem Dashboard steuerbar."
}
],
"timeline": [
{
"title": "Event vorbereiten",
"body": "Account registrieren, Paket wählen und Branding setzen. Abos laufen über Paddle, Mobile-Apps über RevenueCat.",
"tips": [
"Testevent anlegen, um Upload-Flow vorab zu prüfen",
"Trauzeug:innen oder Kolleg:innen als Co-Hosts einladen"
]
},
{
"title": "Challenges & Regeln wählen",
"body": "Aktiviere optionale Aufgaben, Live-Slideshow oder Vorab-Freigabe. Definiere Hausregeln für Uploads.",
"tips": [
"Kurze Belohnungen motivieren zum Mitmachen",
"Bei Kinderfotos klare Hinweise platzieren"
]
},
{
"title": "Material bereitstellen",
"body": "QR-Code als PNG, PDF oder Druckbogen herunterladen. Nutze Templates für Tische, Namensschilder und Bildschirm-Slides.",
"tips": [
"QR-Code an Eingängen, Buffet und Bühne platzieren",
"Link in Einladung und Reminder-Mail aufnehmen"
]
},
{
"title": "Event starten",
"body": "Liveshow starten, Uploads freischalten und optional Reaktionen erlauben. Offline-Uploads werden automatisch synchronisiert.",
"tips": [
"MC oder Moderator:in weist in der Begrüßung auf Fotospiel hin",
"Zeige die Liveshow auf TV, Beamer oder Tablet"
]
},
{
"title": "Moderieren & kuratieren",
"body": "Uploads freigeben, markieren, kommentieren und bei Bedarf ausblenden. Alle Aktionen sind protokolliert.",
"tips": [
"Favoritenlisten für Best-of-Exports nutzen",
"Verstöße konsequent entfernen Datenschutz zuerst"
]
},
{
"title": "Nachbereitung",
"body": "Galerie Zeitraum festlegen, ZIP-Export herunterladen und Dankesmail mit Galerie-Link versenden.",
"tips": [
"Erinnerung 24 Stunden nach dem Event schicken",
"Galerie bei Bedarf frühzeitig schließen oder verlängern"
]
}
],
"use_cases": {
"title": "Passend für jedes Event",
"description": "Wähle den passenden Fokus die Galerie bleibt dieselbe.",
"tabs": [
{
"value": "wedding",
"label": "Hochzeiten",
"goal": "Emotionen und Perspektiven der Gäste festhalten.",
"recommendations": [
"QR-Code in Kirchenheften und auf Tischen platzieren",
"Trauzeug:innen als Co-Hosts einsetzen",
"Liveshow beim Empfang zeigen"
],
"ideas": [
"Selfie mit dem Brautpaar",
"Lieblingsmoment der Party",
"Etwas Blaues entdecken"
]
},
{
"value": "birthday",
"label": "Geburtstage",
"goal": "Spontane Schnappschüsse und Gratulationen sammeln.",
"recommendations": [
"Hinweis in der Geburtstagsrede",
"Kleinen Preis für kreativstes Foto ausloben",
"Kuchen-Cam neben dem Dessertbuffet"
],
"ideas": [
"Throwback nachstellen",
"Größtes Gruppenlächeln",
"Geburtstagskuchen close-up"
]
},
{
"value": "corporate",
"label": "Firmenfeiern",
"goal": "Authentische Einblicke für Employer Branding und Recap.",
"recommendations": [
"Gebrandete Startseite und Liveshow",
"HR oder Moderation für Ankündigungen nutzen",
"Fotos für Post-Event-Kommunikation kuratieren"
],
"ideas": [
"Bestes Team-Selfie",
"Behind the Scenes",
"Neue Kolleg:innen kennenlernen"
]
},
{
"value": "confirmation",
"label": "Konfirmation & Jugendweihe",
"goal": "Familienmomente sicher bündeln und teilen.",
"recommendations": [
"Separate Galerieabschnitte für Kirche und Feier",
"Moderation aktiv lassen, um sensible Inhalte zu prüfen",
"Download-Link nur an Familie weitergeben"
],
"ideas": [
"Gruppenfoto mit Paten",
"Highlight des Tages",
"Selfie mit dem Konfirmanden / der Konfirmandin"
]
},
{
"value": "public",
"label": "Public Events",
"goal": "Community-Momente und Social-Media-Content generieren.",
"recommendations": [
"Große QR-Poster und Hashtags kombinieren",
"Liveshow auf LED-Wänden zeigen",
"Upload-Regeln klar kommunizieren"
],
"ideas": [
"Buntestes Outfit",
"Lieblingsact",
"Geheime Festival-Ecke"
]
}
]
},
"checklist": {
"title": "Checklist: In 10 Minuten startklar",
"items": [
"Event angelegt, Paket & Branding festgelegt",
"Freigabe-Modus und Moderationsteam definiert",
"QR-Code & Link getestet (eigenes Smartphone!)",
"Druck- und Screen-Material vorbereitet",
"Liveshow / Displays geprüft",
"Kurzansage und Follow-up vorbereitet"
],
"cta": "Event jetzt starten"
},
"faq": {
"title": "Häufige Fragen",
"items": [
{
"question": "Brauchen Gäste eine App?",
"answer": "Nein. Alles läuft im mobilen Browser. Auf Wunsch kann die Seite als PWA auf den Homescreen gelegt werden."
},
{
"question": "Wie funktioniert der Upload ohne Internet?",
"answer": "Fotos werden lokal zwischengespeichert. Sobald wieder WLAN oder LTE verfügbar ist, werden sie automatisch hochgeladen."
},
{
"question": "Kann ich Uploads zuerst prüfen?",
"answer": "Ja. Aktiviere den Freigabe-Modus in den Event-Einstellungen, um Beiträge vor Veröffentlichung zu moderieren."
},
{
"question": "Wie lange bleiben Fotos online?",
"answer": "Je nach Paket bleiben Galerien zwischen 14 und 90 Tagen aktiv. Die genaue Dauer steht in der Paketübersicht."
},
{
"question": "Wie läuft die Bezahlung?",
"answer": "Web-Pakete werden über Paddle abgerechnet (inklusive Rechnung & Steuerhandling). Mobile Abos verwalten wir über RevenueCat."
},
{
"question": "Welche Dateiformate sind erlaubt?",
"answer": "Aktuell akzeptieren wir Fotos (JPEG, PNG, HEIC). Videos werden aus Datenschutzgründen nicht unterstützt."
}
]
},
"support": {
"title": "Noch Fragen?",
"description": "Unser Team hilft dir bei der Einrichtung oder plant mit dir ein Pilot-Event.",
"cta": "Kontakt aufnehmen"
},
"timeline_title": "Der Ablauf im Detail"
},
"labels": {
"recommendations": "Empfehlungen",
"challengeIdeas": "Ideen für Challenges",
"prepHint": "Alles, was du vor dem Event abhaken solltest.",
"good_to_know": "Gut zu wissen",
"openDemoFull": "Demo im neuen Tab öffnen",
"readyToLaunch": "Bereit für dein Event?",
"readyToLaunchCopy": "Registriere dich kostenlos und lege noch heute dein erstes Event an."
},
"actions": {
"tips": "Tipps"
},
"demo_page": {
"title": "Jetzt ausprobieren",
"subtitle": "Scanne den QR-Code, lade Fotos hoch und erlebe die Fotospiel-Galerie genau so, wie deine Gäste sie sehen komplett im Browser.",
"primaryCta": "Pakete entdecken",
"secondaryCta": "Zum Leitfaden",
"iframeNote": "Demo-Uploads werden regelmäßig zurückgesetzt nutze den QR-Code im Frame oder öffne die Demo im neuen Tab.",
"openFull": "Demo im neuen Tab öffnen",
"features": [
{
"title": "Echter Gast-Flow",
"description": "Direkter Zugang zur Galerie ohne App oder Login inklusive Offline-Puffer."
},
{
"title": "Live-Slideshow ready",
"description": "Zeige neue Uploads sofort auf jedem Bildschirm über die Live-Show."
},
{
"title": "Moderation inklusive",
"description": "Teste Freigabemodus, Reaktionen und Favoriten alles DSGVO-konform."
}
]
} }
} }

View File

@@ -1,17 +1,25 @@
{ {
"header": { "header": {
"home": "Home", "home": "Home",
"how_it_works": "How it works",
"packages": "Packages", "packages": "Packages",
"blog": "Blog", "blog": "Blog",
"occasions": { "occasions": {
"wedding": "Wedding", "wedding": "Weddings",
"birthday": "Birthday", "birthday": "Birthdays",
"corporate": "Corporate Event", "corporate": "Corporate Events",
"confirmation": "Confirmation/Coming of Age",
"label": "Occasions" "label": "Occasions"
}, },
"contact": "Contact", "contact": "Contact",
"login": "Login", "login": "Login",
"register": "Register" "register": "Register",
"cta": "Try now",
"utility": "Display & language",
"appearance": "Appearance",
"appearance_light": "Light",
"appearance_dark": "Dark",
"language": "Language"
}, },
"login_failed": "Invalid email or password.", "login_failed": "Invalid email or password.",
"login_success": "You are now logged in.", "login_success": "You are now logged in.",
@@ -21,6 +29,7 @@
"failed_credentials": "Invalid credentials.", "failed_credentials": "Invalid credentials.",
"login": { "login": {
"title": "Login", "title": "Login",
"description": "Sign in to your Fotospiel account and keep your event experience flowing from upload to download.",
"identifier": "Email or Username", "identifier": "Email or Username",
"identifier_placeholder": "Enter your email or username", "identifier_placeholder": "Enter your email or username",
"email": "Email", "email": "Email",
@@ -29,7 +38,27 @@
"password_placeholder": "Enter your password", "password_placeholder": "Enter your password",
"forgot": "Forgot Password?", "forgot": "Forgot Password?",
"remember": "Remember me", "remember": "Remember me",
"submit": "Login" "submit": "Login",
"no_account": "No account yet?",
"sign_up": "Create one now",
"success_toast": "Login successful",
"unexpected_error": "Unable to log in right now.",
"highlights": {
"moments": "Share moments in real time",
"moments_description": "Uploads appear instantly in the event gallery no app installation required.",
"branding": "Branding & slideshows that delight",
"branding_description": "Fine-tune slideshows, watermarks, and event tasks with a few clicks.",
"privacy": "Secure access via join tokens",
"privacy_description": "Keep events private and GDPR compliant with protected join tokens."
},
"hero_tagline": "Event-tech with heart",
"hero_heading": "Welcome back to Fotospiel",
"hero_subheading": "Manage events, galleries, and guest lists in a lovingly crafted dashboard.",
"hero_footer": {
"headline": "Need an account?",
"subline": "Explore our packages and experience Fotospiel live.",
"cta": "Explore packages"
}
}, },
"register": { "register": {
"title": "Register", "title": "Register",

View File

@@ -1,43 +1,83 @@
{ {
"home": { "home": {
"title": "Home - Fotospiel", "title": "Home - Fotospiel",
"hero_title": "Fotospiel", "hero_tagline": "Event photos without app downloads",
"hero_description": "Collect guest photos for events with QR codes. Our secure PWA platform for guests and organizers simple, mobile, and privacy-compliant. Better than competitors, loved by thousands.", "hero_title": "Your event. Their photos. Ready in real time.",
"cta_explore": "Discover Packages", "hero_description": "Fotospiel combines QR access, live galleries, and moderation in one platform—perfect for weddings, corporate events, and every celebration that deserves a highlight reel.",
"cta_explore_highlight": "Get started now", "hero_bullets": [
"hero_image_alt": "Event photos with QR code", "Launch a live gallery in seconds",
"how_title": "How it works", "Join tokens keep every access private",
"step1_title": "Choose Package", "Slideshows, branding, and tasks on the fly"
"step1_desc": "Choose the right package for your event.", ],
"step2_title": "Share QR Code", "cta_demo": "View demo",
"step2_desc": "Share the QR code with your guests.", "cta_demo_highlight": "Launch live demo",
"step3_title": "Collect Photos", "cta_how": "How Fotospiel works",
"step3_desc": "Guests upload photos secure and easy.", "cta_packages": "See packages",
"features_title": "Why Fotospiel?", "cta_explore": "See packages",
"feature1_title": "Secure & Privacy Compliant", "cta_explore_highlight": "Start your Fotospiel trial",
"feature1_desc": "GDPR compliant, no PII storage.", "hero_image_alt": "Guests sharing photos via QR code on their phone",
"feature2_title": "Mobile & PWA", "how_title": "How Fotospiel flows",
"feature2_desc": "Works offline, installable like an app.", "how_subtitle": "From invitation to finished gallery in three smart steps.",
"feature3_title": "Easy to Use", "step1_title": "Create event & pick a package",
"feature3_desc": "Intuitive UI for guests and organizers.", "step1_desc": "Set limits for photos, guests, and branding in just a few clicks.",
"packages_title": "Our Packages", "step2_title": "Share join token & QR code",
"view_details": "View Details", "step2_desc": "Guests scan, choose emotions or tasks, and upload instantly—no app store required.",
"all_packages": "View All Packages", "step3_title": "Moderate live & spotlight favorites",
"contact_title": "Contact", "step3_desc": "Approve posts, trigger slideshows, and export highlight galleries on demand.",
"demo_title": "Experience the Fotospiel demo",
"demo_description": "Our demo event shows the guest PWA inside a true 9:16 frame. Open it on your phone to try uploads, emotions, and likes live.",
"demo_hint": "Pro tip: share the link with your team so everyone can explore together.",
"demo_cta": "Go to demo",
"demo_media_alt": "Smartphone frame displaying the Fotospiel demo",
"features_title": "Everything your event needs",
"features_highlight": [
{
"title": "Branding & slideshows",
"description": "Match colors, overlays, and tasks to your event—complete with a live slideshow."
},
{
"title": "Real-time moderation",
"description": "Block posts with one tap, mark favorites, and export best-of galleries effortlessly."
},
{
"title": "Analytics & archive",
"description": "Track uploads, reactions, and downloads—archived in a GDPR-compliant workflow."
}
],
"occasions_title": "Occasions we love",
"occasions_description": "Pick a starting point and explore best practices for your format.",
"occasions": {
"wedding": "Weddings capture every candid",
"birthday": "Birthdays memories from 7 to 70",
"corporate": "Corporate events branding & security built in",
"confirmation": "Confirmation & coming of age the family album to go"
},
"blog_teaser_title": "Insights from the Fotospiel blog",
"blog_teaser_description": "Trends, setups, and playbooks for your next celebration.",
"blog_teaser_cta": "Visit the blog",
"packages_title": "Packages & pricing",
"packages_subtitle": "Flexible for single events or agencies—activated within minutes via Paddle checkout.",
"view_details": "View details",
"all_packages": "Compare all packages",
"contact_title": "Let's plan your event",
"contact_lead": "Well walk you through tasks, tokens, hardware setups, or custom workflows.",
"name_label": "Name", "name_label": "Name",
"email_label": "Email", "email_label": "Email",
"message_label": "Message", "message_label": "Message",
"sending": "Sending...", "contact_privacy": "By submitting you confirm our privacy notice. We typically reply within 24 hours.",
"send": "Send", "sending": "Sending …",
"testimonials_title": "What our customers say", "send": "Send message",
"testimonial1": "Great for weddings! Simple and secure.", "testimonials_title": "Voices from the community",
"testimonial2": "Best app for event photos.", "testimonials_subtitle": "Over 1,200 events have already run on Fotospiel.",
"testimonial3": "Fast and user-friendly.", "testimonial1": "\"Our guests documented the day for us—and everything landed in one secure archive.\"",
"faq_title": "Frequently Asked Questions", "testimonial2": "\"Branding, moderation, analytics—all right where I need them during an event.\"",
"faq1_q": "Is it free?", "testimonial3": "\"Confirmation without messaging chaos. QR out, emojis in, photos for everyone!\"",
"faq1_a": "Yes, there is a free package for small events.", "faq_title": "Still curious?",
"faq2_q": "How does the QR code work?", "faq_subtitle": "Find quick answers here. For deep dives visit How it works.",
"faq2_a": "Guests scan and upload photos easy!" "faq1_q": "Can I try Fotospiel first?",
"faq1_a": "Absolutely! Use our demo event or pick the Free package to explore all core features.",
"faq2_q": "Do guests need an account?",
"faq2_a": "No. A join token is enough, and you can add an optional PIN for extra gallery protection."
}, },
"packages": { "packages": {
"title": "Our Packages", "title": "Our Packages",
@@ -220,6 +260,16 @@
"benefit4": "GDPR-secure: No PII stored.", "benefit4": "GDPR-secure: No PII stored.",
"image_alt": "Corporate event photos" "image_alt": "Corporate event photos"
}, },
"confirmation": {
"title": "Confirmation & Coming-of-Age Celebrations",
"description": "Capture the rite of passage: Guests share photos from ceremony and party without installing an app.",
"benefits_title": "Benefits for Confirmation & Coming of Age",
"benefit1": "Family-friendly QR code accessible to every generation.",
"benefit2": "Collections for ceremony, celebration, and group shots.",
"benefit3": "Moderation keeps parents in control of what is visible.",
"benefit4": "Download bundle for thank-you cards and keepsake albums.",
"image_alt": "Confirmation celebration"
},
"family": { "family": {
"title": "Family Celebrations", "title": "Family Celebrations",
"description": "From baptisms to anniversaries: Collect memories from all relatives.", "description": "From baptisms to anniversaries: Collect memories from all relatives.",
@@ -463,5 +513,307 @@
"google_missing_email": "We could not retrieve your Google email address.", "google_missing_email": "We could not retrieve your Google email address.",
"google_error_fallback": "We couldn't complete the Google login. Please try again." "google_error_fallback": "We couldn't complete the Google login. Please try again."
} }
},
"how_it_works_page": {
"hero": {
"title": "How Fotospiel Works",
"subtitle": "Share your QR code, collect guest photos in real time, and stay in full control all inside the browser.",
"primaryCta": "Create an event",
"secondaryCta": "Talk to our team",
"stats": [
{
"value": "5 min",
"label": "From sign-up to your first upload"
},
{
"value": "0 apps",
"label": "Guests only need their browser"
},
{
"value": "100%",
"label": "EU hosting & GDPR compliance"
}
],
"demoNote": "See the demo",
"demoLabel": "View demo"
},
"experience": {
"host": {
"label": "Hosts",
"intro": "Plan, moderate, and export your event memories from a single dashboard.",
"steps": [
{
"title": "Create your event",
"description": "Pick a package, set the basics, define optional challenges and approval rules."
},
{
"title": "Share materials",
"description": "Export the QR code, embed the link in invitations or displays, and enable the live gallery if you like."
},
{
"title": "Moderate & secure",
"description": "Approve uploads, highlight favorites, schedule the follow-up email, and download everything as a ZIP."
}
],
"callouts": [
"Add co-hosts for moderation and the live show",
"Offline uploads sync automatically once back online",
"Billing handled via Paddle, mobile apps through RevenueCat"
]
},
"guest": {
"label": "Guests",
"intro": "Your guests simply scan, shoot, and share. No login, no download, no friction.",
"steps": [
{
"title": "Scan the QR code",
"description": "The branded event page opens instantly in the browser no install required."
},
{
"title": "Snap a photo",
"description": "Use the camera or pick from the gallery, optional challenges keep it fun."
},
{
"title": "Enjoy the gallery",
"description": "Uploads appear after approval in the gallery and live show, downloads stay accessible whenever needed."
}
],
"callouts": [
"Progressive Web App mode with optional homescreen icon",
"Local caching if the connection drops auto sync later",
"Accessible UI for modern iOS and Android browsers"
]
}
},
"pillars": [
{
"title": "Launch in minutes",
"description": "QR code, link, and live show are ready instantly. We provide print templates and copy blocks."
},
{
"title": "Moderation & safety",
"description": "Approval modes, takedowns, and co-hosts keep your content secure and compliant."
},
{
"title": "On-site engagement",
"description": "Challenges, reactions, and the live slideshow spark participation without extra hardware."
},
{
"title": "Post-event follow-up",
"description": "Export ZIPs, build best-of highlights, and send thank-you emails straight from the dashboard."
}
],
"timeline": [
{
"title": "Prepare your event",
"body": "Register, choose a package, and apply your branding. Web payments run through Paddle, mobile apps via RevenueCat.",
"tips": [
"Create a test event to experience the upload flow",
"Invite co-hosts like MCs or colleagues"
]
},
{
"title": "Configure rules & challenges",
"body": "Enable optional challenges, the live slideshow, or pre-approval. Communicate your house rules.",
"tips": [
"Small rewards boost participation",
"Use extra guidance for photos of kids"
]
},
{
"title": "Distribute assets",
"body": "Download the QR code as PNG/PDF, print table cards, and add the link to invitation emails and slides.",
"tips": [
"Place the QR at entrances and high-traffic spots",
"Add the link to reminders before the event"
]
},
{
"title": "Go live",
"body": "Start the live show, approve uploads, and optionally allow reactions. Offline uploads sync when the connection returns.",
"tips": [
"Have the MC mention Fotospiel during the welcome",
"Stream the live gallery on TV, projector, or tablet"
]
},
{
"title": "Moderate & curate",
"body": "Approve, pin, or remove uploads. Every action is logged for compliance.",
"tips": [
"Use favorites for best-of highlight reels",
"Remove inappropriate content immediately"
]
},
{
"title": "Wrap up",
"body": "Define the gallery duration, export a ZIP, and send your thank-you message with the gallery link.",
"tips": [
"Send a reminder 24 hours after the event",
"Close or extend the gallery with one click"
]
}
],
"use_cases": {
"title": "Fits every event",
"description": "Pick the focus that matches your format the gallery stays the same.",
"tabs": [
{
"value": "wedding",
"label": "Weddings",
"goal": "Capture genuine guest perspectives beyond staged photos.",
"recommendations": [
"Place QR codes on programs and tables",
"Assign co-hosts (best man, maid of honor)",
"Run the live show during reception or dinner"
],
"ideas": [
"Selfie with the couple",
"Favorite dance move",
"Something blue"
]
},
{
"value": "birthday",
"label": "Birthdays",
"goal": "Collect candid greetings and fun surprises.",
"recommendations": [
"Mention Fotospiel in the birthday speech",
"Offer a small prize for the most creative photo",
"Place a QR sign next to the cake table"
],
"ideas": [
"Recreate an old memory",
"Biggest group smile",
"Cake close-up"
]
},
{
"value": "corporate",
"label": "Corporate",
"goal": "Generate authentic employer-branding content and recap material.",
"recommendations": [
"Use branded start screen and live show",
"Let HR or moderation introduce Fotospiel",
"Curate highlights for internal comms"
],
"ideas": [
"Best team selfie",
"Behind the scenes",
"Meet someone new"
]
},
{
"value": "confirmation",
"label": "Coming of age",
"goal": "Keep family moments secure and easy to share.",
"recommendations": [
"Separate sections for ceremony and celebration",
"Keep approval mode on for sensitive content",
"Share the download link only with close family"
],
"ideas": [
"Portrait with godparents",
"Highlight of the day",
"Selfie with the celebrant"
]
},
{
"value": "public",
"label": "Public events",
"goal": "Activate communities and gather social media content.",
"recommendations": [
"Combine large QR posters with hashtags",
"Show the live feed on LED walls",
"Communicate upload guidelines clearly"
],
"ideas": [
"Most colourful outfit",
"Favourite act",
"Hidden festival gem"
]
}
]
},
"checklist": {
"title": "Checklist: ready in 10 minutes",
"items": [
"Event created, package & branding confirmed",
"Approval mode and moderation team defined",
"QR code & link tested (use your own phone!)",
"Printed and screen materials prepared",
"Live show / displays checked",
"Announcement and follow-up drafted"
],
"cta": "Create your event"
},
"faq": {
"title": "FAQ",
"items": [
{
"question": "Do guests need an app?",
"answer": "No. Everything runs in the mobile browser. Guests can optionally save it as a PWA on their homescreen."
},
{
"question": "What if the internet is unstable?",
"answer": "Uploads are cached locally and synchronised automatically once the connection is back."
},
{
"question": "Can I approve uploads first?",
"answer": "Yes. Enable approval mode in the event settings to moderate before publishing."
},
{
"question": "How long are photos available?",
"answer": "Gallery retention depends on the package typically 14 to 90 days. See the pricing table for details."
},
{
"question": "How do payments work?",
"answer": "Web packages are billed through Paddle (with invoices and tax handling). Mobile subscriptions are managed via RevenueCat."
},
{
"question": "Which file formats are supported?",
"answer": "We currently accept photos (JPEG, PNG, HEIC). Videos are disabled for privacy reasons."
}
]
},
"support": {
"title": "Need a hand?",
"description": "Our team is happy to set up a pilot event or walk you through the dashboard.",
"cta": "Contact us"
},
"timeline_title": "The detailed flow"
},
"labels": {
"recommendations": "Recommendations",
"challengeIdeas": "Challenge ideas",
"prepHint": "Everything you should tick off before the event.",
"good_to_know": "Good to know",
"openDemoFull": "Open demo in new tab",
"readyToLaunch": "Ready to launch?",
"readyToLaunchCopy": "Sign up for free and create your first event today."
},
"actions": {
"tips": "Tips"
},
"demo_page": {
"title": "Try Fotospiel now",
"subtitle": "Scan the QR code, upload photos, and experience the guest gallery exactly as attendees do all inside the browser.",
"primaryCta": "Explore packages",
"secondaryCta": "Read the guide",
"iframeNote": "Demo uploads reset regularly—use the QR inside the frame or open the demo in a new tab.",
"openFull": "Open demo in new tab",
"features": [
{
"title": "Authentic guest flow",
"description": "Instant gallery access without apps or logins offline uploads queue automatically."
},
{
"title": "Live slideshow ready",
"description": "Show fresh uploads on any screen using the built-in live show."
},
{
"title": "Moderation included",
"description": "Test approval, reactions, and favourites fully GDPR compliant."
}
]
} }
} }

View File

@@ -1,21 +1,23 @@
{ {
"login": { "login": {
"title": "Tenant-Admin", "title": "Event-Admin",
"badge": "Fotospiel Tenant Admin", "badge": "Fotospiel Event Admin",
"hero_title": "Event-Steuerung, die sich wie Zuhause anfühlt.", "hero_tagline": "Kontrolle behalten, entspannt bleiben",
"hero_subtitle": "Wechsle mühelos zwischen Mandanten, behalte Live-Uploads im Blick und teile elegante Einladungen alles in einer ruhigen Oberfläche.", "hero_title": "Das Cockpit für dein Fotospiel Event",
"hero_subtitle": "Moderation, Uploads und Kommunikation laufen hier zusammen mobil wie auf dem Desktop.",
"features": [ "features": [
"Gestalte QR-Einladungen und druckfertige Layouts in wenigen Klicks passend zu eurer Marke.", "Überwache Uploads in Echtzeit und archiviere Highlights ohne Aufwand.",
"Organisiere Aufgaben, Emotionen und Sammlungen für jeden Eventtyp ohne Excel-Chaos.", "Erstelle Einladungen mit personalisierten QR-Codes und teile sie sofort.",
"Bleib am Eventtag souverän mit Dashboards, Live-Statistiken und sofortiger Moderation." "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.", "lead": "Du meldest dich über unseren sicheren OAuth-Login an und landest direkt im Event-Dashboard.",
"panel_copy": "Melde dich mit deinen Fotospiel-Admin-Zugangsdaten an. Wir schützen dein Konto mit OAuth 2.1 und mandantenbewussten Berechtigungen.", "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", "cta": "Mit Fotospiel-Login fortfahren",
"loading": "Bitte warten …", "loading": "Bitte warten …",
"oauth_error_title": "Login aktuell nicht möglich", "oauth_error_title": "Login aktuell nicht möglich",
"oauth_error": "Anmeldung fehlgeschlagen: {{message}}", "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" "appearance_label": "Darstellung"
} }
} }

View File

@@ -1,21 +1,23 @@
{ {
"login": { "login": {
"title": "Tenant Admin", "title": "Event Admin",
"badge": "Fotospiel Tenant Admin", "badge": "Fotospiel Event Admin",
"hero_title": "Event control that feels at home.", "hero_tagline": "Stay in control, stay relaxed",
"hero_subtitle": "Switch between tenants, monitor live uploads, and share beautiful invites — all in one calm workspace.", "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": [ "features": [
"Design QR invites and print-ready layouts that match your brand in minutes.", "Monitor uploads in real time and archive highlights effortlessly.",
"Coordinate tasks, emotions, and achievements for every event flow.", "Create invites with personalized QR codes and share them instantly.",
"Stay confident on event day with dashboards, live stats, and instant moderation." "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.", "lead": "Use our secure OAuth login and land directly in the event dashboard.",
"panel_copy": "Sign in with your Fotospiel admin credentials to continue. We secure your account with OAuth 2.1 and tenant-aware permissions.", "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", "cta": "Continue with Fotospiel login",
"loading": "Signing you in …", "loading": "Signing you in …",
"oauth_error_title": "Login not possible right now", "oauth_error_title": "Login not possible right now",
"oauth_error": "Sign-in failed: {{message}}", "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" "appearance_label": "Appearance"
} }
} }

View File

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

View File

@@ -53,108 +53,149 @@ export default function LoginPage(): JSX.Element {
})); }));
}, [t]); }, [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'; const isLoading = status === 'loading';
return ( return (
<div className="relative min-h-screen overflow-hidden bg-[var(--brand-navy)] text-white"> <div className="relative min-h-svh overflow-hidden bg-slate-950 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
<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" /> aria-hidden
<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" /> 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"> <div className="relative z-10 flex min-h-svh flex-col">
<header className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 pt-10"> <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"> <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"> <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" /> <AppLogoIcon className="h-7 w-7 text-white" />
</span> </span>
<div> <div className="space-y-1">
<p className="text-xs uppercase tracking-[0.35em] text-white/70">{t('login.badge')}</p> <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> <p className="text-lg font-semibold">Fotospiel</p>
</div> </div>
</div> </div>
<AppearanceToggleDropdown /> <AppearanceToggleDropdown />
</header> </header>
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-6 pb-16 pt-12"> <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="grid flex-1 gap-12 lg:grid-cols-[0.95fr_1.05fr]" data-testid="tenant-login-layout"> <div className="mb-10 space-y-5 text-center md:hidden">
<section className="order-2 space-y-10 lg:order-1"> <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">
<div className="space-y-5"> <Sparkles className="h-3.5 w-3.5" aria-hidden />
<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"> {heroTagline}
<Sparkles className="h-4 w-4 text-[var(--brand-gold)]" /> </span>
{t('login.badge')} <h1 className="text-3xl font-semibold leading-tight sm:text-4xl">{heroTitle}</h1>
</span> <p className="mx-auto max-w-xl text-sm text-white/80 sm:text-base">{heroSubtitle}</p>
<h1 className="text-4xl font-semibold leading-tight sm:text-5xl"> </div>
{t('login.hero_title')}
</h1> <div className="grid flex-1 gap-10 md:grid-cols-[1.08fr_1fr]" data-testid="tenant-login-layout">
<p className="max-w-xl text-base text-white/80 sm:text-lg"> <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">
{t('login.hero_subtitle')} <div aria-hidden className="pointer-events-none absolute inset-0 opacity-40">
</p> <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> </div>
{featureList.length ? ( <div className="relative z-10 flex flex-col gap-6">
<div className="grid gap-4 sm:grid-cols-2"> <div className="flex items-center gap-3 text-xs font-semibold uppercase tracking-[0.45em] text-white/70">
{featureList.map(({ text, Icon }, index) => ( <Sparkles className="h-4 w-4" aria-hidden />
<div <span className="font-sans-marketing">{heroTagline}</span>
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> </div>
) : null}
<p className="flex items-center gap-2 text-sm text-white/70"> <div className="space-y-4">
<ArrowRight className="h-4 w-4" /> <h2 className="font-display text-3xl leading-tight sm:text-4xl">{heroTitle}</h2>
{t('login.lead')} <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> </p>
</section> </section>
<section className="order-1 lg:order-2"> <section className="relative">
<div 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="absolute inset-0 -translate-y-4 translate-x-6 scale-95 rounded-3xl bg-white/20 opacity-50 blur-2xl" /> <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="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-3">
<div className="space-y-6"> <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">
<div className="space-y-2"> {t('login.badge', 'Fotospiel Event Admin')}
<h2 className="text-2xl font-semibold">{t('login.title')}</h2> </span>
<p className="text-sm text-slate-600 dark:text-slate-300">{t('login.panel_copy')}</p> <div className="space-y-1">
</div> <h2 className="text-2xl font-semibold">{panelTitle}</h2>
<p className="text-sm text-slate-600 dark:text-slate-300">{panelCopy}</p>
{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>
</div> </div>
</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> </div>
</section> </section>
</div> </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> </main>
</div> </div>
</div> </div>

View File

@@ -54,6 +54,7 @@ import {
triggerDownloadFromBlob, triggerDownloadFromBlob,
triggerDownloadFromDataUrl, triggerDownloadFromDataUrl,
} from './invite-layout/export-utils'; } from './invite-layout/export-utils';
import { buildDownloadFilename, normalizeEventDateSegment } from './invite-layout/fileNames';
export type { QrLayoutCustomization } from './invite-layout/schema'; export type { QrLayoutCustomization } from './invite-layout/schema';
@@ -182,6 +183,7 @@ function serializeElements(elements: LayoutElement[], context: LayoutSerializati
type InviteLayoutCustomizerPanelProps = { type InviteLayoutCustomizerPanelProps = {
invite: EventQrInvite | null; invite: EventQrInvite | null;
eventName: string; eventName: string;
eventDate: string | null;
saving: boolean; saving: boolean;
resetting: boolean; resetting: boolean;
onSave: (customization: QrLayoutCustomization) => Promise<void>; onSave: (customization: QrLayoutCustomization) => Promise<void>;
@@ -199,6 +201,7 @@ const ZOOM_STEP = 0.05;
export function InviteLayoutCustomizerPanel({ export function InviteLayoutCustomizerPanel({
invite, invite,
eventName, eventName,
eventDate,
saving, saving,
resetting, resetting,
onSave, onSave,
@@ -1391,7 +1394,12 @@ export function InviteLayoutCustomizerPanel({
} }
const normalizedFormat = format.toLowerCase(); 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); setDownloadBusy(normalizedFormat);
setError(null); setError(null);
@@ -1412,14 +1420,14 @@ export function InviteLayoutCustomizerPanel({
if (normalizedFormat === 'png') { if (normalizedFormat === 'png') {
const dataUrl = await generatePngDataUrl(exportOptions); const dataUrl = await generatePngDataUrl(exportOptions);
await triggerDownloadFromDataUrl(dataUrl, `${filenameStem}.png`); await triggerDownloadFromDataUrl(dataUrl, filename);
} else if (normalizedFormat === 'pdf') { } else if (normalizedFormat === 'pdf') {
const pdfBytes = await generatePdfBytes( const pdfBytes = await generatePdfBytes(
exportOptions, exportOptions,
'a4', 'a4',
'portrait', '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 { } else {
throw new Error(`Unsupported format: ${normalizedFormat}`); throw new Error(`Unsupported format: ${normalizedFormat}`);
} }
@@ -1509,34 +1517,8 @@ export function InviteLayoutCustomizerPanel({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div className="hidden flex-wrap items-center justify-end gap-2 lg:flex">
<div> {renderActionButtons('inline')}
<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> </div>
{error ? ( {error ? (
@@ -1845,7 +1827,7 @@ export function InviteLayoutCustomizerPanel({
</Tabs> </Tabs>
</section> </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')} {renderActionButtons('inline')}
</div> </div>
<div ref={actionsSentinelRef} className="h-1 w-full" /> <div ref={actionsSentinelRef} className="h-1 w-full" />

View File

@@ -239,7 +239,7 @@ export function DesignerCanvas({
const elementId = target.elementId; const elementId = target.elementId;
const bounds = target.getBoundingRect(); const bounds = target.getBoundingRect();
let nextPatch: Partial<LayoutElement> = { const nextPatch: Partial<LayoutElement> = {
x: clamp(Math.round(bounds.left ?? 0), 20, CANVAS_WIDTH - 20), x: clamp(Math.round(bounds.left ?? 0), 20, CANVAS_WIDTH - 20),
y: clamp(Math.round(bounds.top ?? 0), 20, CANVAS_HEIGHT - 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 { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; 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 { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; 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 { cn } from '@/lib/utils';
import { import {
NavigationMenu, NavigationMenu,
@@ -31,10 +31,15 @@ const Header: React.FC = () => {
const { localizedPath } = useLocalizedRoutes(); const { localizedPath } = useLocalizedRoutes();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const toggleTheme = () => { const setTheme = useCallback((mode: 'light' | 'dark') => {
const newAppearance = appearance === 'dark' ? 'light' : 'dark'; if (appearance !== mode) {
updateAppearance(newAppearance); updateAppearance(mode);
}
setMobileMenuOpen(false); setMobileMenuOpen(false);
}, [appearance, updateAppearance]);
const toggleTheme = () => {
setTheme(appearance === 'dark' ? 'light' : 'dark');
}; };
const handleLanguageChange = useCallback(async (value: string) => { const handleLanguageChange = useCallback(async (value: string) => {
@@ -68,49 +73,61 @@ const Header: React.FC = () => {
}); });
}; };
const navItems = useMemo(() => ([ const ctaHref = localizedPath('/demo');
{
key: 'home', const navItems = useMemo(() => {
label: t('header.home', 'Home'), const homeHref = localizedPath('/');
href: localizedPath('/'), const howItWorksHref = localizedPath('/so-funktionierts');
},
{ return [
key: 'packages', {
label: t('header.packages', 'Pakete'), key: 'howItWorks',
href: localizedPath('/packages'), label: t('header.how_it_works', "So geht's"),
}, href: howItWorksHref,
{ },
key: 'blog', {
label: t('header.blog', 'Blog'), key: 'packages',
href: localizedPath('/blog'), label: t('header.packages', 'Pakete'),
}, href: localizedPath('/packages'),
{ },
key: 'occasions', {
label: t('header.occasions.label', 'Anlässe'), key: 'occasions',
children: [ label: t('header.occasions.label', 'Anlässe'),
{ children: [
key: 'wedding', {
label: t('header.occasions.wedding', 'Hochzeit'), key: 'wedding',
href: localizedPath('/anlaesse/hochzeit'), label: t('header.occasions.wedding', 'Hochzeiten'),
}, href: localizedPath('/anlaesse/hochzeit'),
{ },
key: 'birthday', {
label: t('header.occasions.birthday', 'Geburtstag'), key: 'birthday',
href: localizedPath('/anlaesse/geburtstag'), label: t('header.occasions.birthday', 'Geburtstage'),
}, href: localizedPath('/anlaesse/geburtstag'),
{ },
key: 'corporate', {
label: t('header.occasions.corporate', 'Firmenevent'), key: 'corporate',
href: localizedPath('/anlaesse/firmenevent'), label: t('header.occasions.corporate', 'Firmenfeiern'),
}, href: localizedPath('/anlaesse/firmenevent'),
], },
}, {
{ key: 'confirmation',
key: 'contact', label: t('header.occasions.confirmation', 'Konfirmation/Jugendweihe'),
label: t('header.contact', 'Kontakt'), href: localizedPath('/anlaesse/konfirmation'),
href: localizedPath('/kontakt'), },
}, ],
]), [localizedPath, t]); },
{
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), []); const handleNavSelect = useCallback(() => setMobileMenuOpen(false), []);
@@ -125,25 +142,27 @@ const Header: React.FC = () => {
</span> </span>
</Link> </Link>
<NavigationMenu className="hidden lg:flex flex-1 justify-center" viewport={false}> <NavigationMenu className="hidden lg:flex flex-1 justify-center" viewport={false}>
<NavigationMenuList className="gap-2"> <NavigationMenuList className="gap-1.5">
{navItems.map((item) => ( {navItems.map((item) => (
<NavigationMenuItem key={item.key}> <NavigationMenuItem key={item.key}>
{item.children ? ( {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} {item.label}
</NavigationMenuTrigger> </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"> <ul className="flex flex-col gap-1">
{item.children.map((child) => ( {item.children.map((child) => (
<li key={child.key}> <li key={child.key}>
<NavigationMenuLink asChild> <NavigationMenuLink asChild>
<Link <Link
href={child.href} 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} <span aria-hidden className="mr-2 flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
<ChevronRight className="h-4 w-4" />
</span>
<span className="whitespace-nowrap">{child.label}</span>
</Link> </Link>
</NavigationMenuLink> </NavigationMenuLink>
</li> </li>
@@ -157,7 +176,7 @@ const Header: React.FC = () => {
href={item.href} href={item.href}
className={cn( className={cn(
navigationMenuTriggerStyle(), 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} {item.label}
@@ -168,26 +187,49 @@ const Header: React.FC = () => {
))} ))}
</NavigationMenuList> </NavigationMenuList>
</NavigationMenu> </NavigationMenu>
<div className="hidden lg:flex items-center space-x-4"> <div className="hidden lg:flex items-center space-x-2">
<Button <Button asChild size="sm" className="bg-[#FF5F87] hover:bg-[#ff4674] text-white shadow-md shadow-rose-500/20">
variant="ghost" <Link href={ctaHref} className="font-sans-marketing font-semibold px-3">
size="icon" {t('header.cta', 'Jetzt ausprobieren')}
onClick={toggleTheme} </Link>
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>
</Button> </Button>
<Select value={i18n.language || 'de'} onValueChange={handleLanguageChange}> <DropdownMenu>
<SelectTrigger className="w-[70px] h-8"> <DropdownMenuTrigger asChild>
<SelectValue placeholder={t('common.ui.language_select')} /> <Button
</SelectTrigger> variant="ghost"
<SelectContent> size="icon"
<SelectItem value="de">DE</SelectItem> className="h-8 w-8"
<SelectItem value="en">EN</SelectItem> aria-label={t('header.utility', 'Darstellung und Sprache öffnen')}
</SelectContent> >
</Select> <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 ? ( {auth.user ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -223,14 +265,16 @@ const Header: React.FC = () => {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : ( ) : (
<> <Button asChild variant="ghost" size="icon" className="h-8 w-8">
<Link <Link
href={localizedPath('/login')} 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> </Link>
</> </Button>
)} )}
</div> </div>
<div className="flex items-center lg:hidden"> <div className="flex items-center lg:hidden">
@@ -279,11 +323,11 @@ const Header: React.FC = () => {
<SheetClose asChild key={child.key}> <SheetClose asChild key={child.key}>
<Link <Link
href={child.href} 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} onClick={handleNavSelect}
> >
<span aria-hidden className="mr-2 text-muted-foreground"></span>
<span>{child.label}</span> <span>{child.label}</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</Link> </Link>
</SheetClose> </SheetClose>
))} ))}
@@ -305,6 +349,15 @@ const Header: React.FC = () => {
</nav> </nav>
<Separator /> <Separator />
<div className="flex flex-col gap-4"> <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"> <div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Darstellung</span> <span className="text-sm font-medium text-muted-foreground">Darstellung</span>
<Button <Button

View File

@@ -1,7 +1,10 @@
import AppLogoIcon from '@/components/app-logo-icon'; 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 { Link } from '@inertiajs/react';
import { Sparkles, Camera, ShieldCheck } from 'lucide-react';
import { type PropsWithChildren } from 'react'; import { type PropsWithChildren } from 'react';
import { useTranslation } from 'react-i18next';
interface AuthLayoutProps { interface AuthLayoutProps {
name?: string; name?: string;
@@ -10,24 +13,105 @@ interface AuthLayoutProps {
} }
export default function AuthSimpleLayout({ children, title, description }: PropsWithChildren<AuthLayoutProps>) { export default function AuthSimpleLayout({ children, title, description }: PropsWithChildren<AuthLayoutProps>) {
return ( const { t } = useTranslation('auth');
<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>
<div className="space-y-2 text-center"> const highlights = [
<h1 className="text-xl font-medium">{title}</h1> {
<p className="text-center text-sm text-muted-foreground">{description}</p> 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>
</div> </div>
{children}
</div> </div>
</div> </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(() => { useEffect(() => {
if (!hasTriedSubmit) { if (!hasTriedSubmit) {
return; return;
} }
const errorKeys = Object.keys(errors); const keys = Object.keys(errors);
if (errorKeys.length === 0) { if (keys.length === 0) {
return; return;
} }
const field = document.querySelector<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(`[name="${errorKeys[0]}"]`); const field = document.querySelector<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(`[name="${keys[0]}"]`);
if (field) { if (field) {
field.scrollIntoView({ behavior: 'smooth', block: 'center' }); 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')}> <AuthLayout title={t('login.title')} description={t('login.description')}>
<Head title={t('login.title')} /> <Head title={t('login.title')} />
<form onSubmit={submit} className="flex flex-col gap-6"> <form
<div className="grid gap-6"> 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"> <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 <Input
id="email" id="email"
type="email" type="email"
@@ -77,15 +87,26 @@ export default function Login({ status, canResetPassword }: LoginProps) {
clearErrors('email'); 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>
<div className="grid gap-2"> <div className="grid gap-2">
<div className="flex items-center"> <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 && ( {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')} {t('login.forgot')}
</TextLink> </TextLink>
)} )}
@@ -105,44 +126,69 @@ export default function Login({ status, canResetPassword }: LoginProps) {
clearErrors('password'); 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>
<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 <Checkbox
id="remember" id="remember"
name="remember" name="remember"
tabIndex={3} tabIndex={3}
checked={data.remember} 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))} 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> </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>
<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')}{' '} {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')} {t('login.sign_up')}
</TextLink> </TextLink>
</div> </div>
</form> </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> </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 { Head, Link, useForm } from '@inertiajs/react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes'; import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import MarketingLayout from '@/layouts/mainWebsite'; import MarketingLayout from '@/layouts/mainWebsite';
import { useAnalytics } from '@/hooks/useAnalytics'; import { useAnalytics } from '@/hooks/useAnalytics';
import { useCtaExperiment } from '@/hooks/useCtaExperiment'; 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 { interface Package {
id: number; id: number;
@@ -17,6 +23,9 @@ interface Props {
packages: Package[]; packages: Package[];
} }
const heroBulletIcons = [Sparkles, ShieldCheck, Camera];
const howStepIcons = [QrCode, Smartphone, ShieldCheck];
const Home: React.FC<Props> = ({ packages }) => { const Home: React.FC<Props> = ({ packages }) => {
const { t } = useTranslation('marketing'); const { t } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes(); const { localizedPath } = useLocalizedRoutes();
@@ -31,8 +40,49 @@ const Home: React.FC<Props> = ({ packages }) => {
message: '', message: '',
}); });
const handleSubmit = (e: React.FormEvent) => { const heroBulletsRaw = t('home.hero_bullets', { returnObjects: true });
e.preventDefault(); 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'), { post(localizedPath('/kontakt'), {
onSuccess: () => { onSuccess: () => {
trackEvent({ trackEvent({
@@ -54,226 +104,471 @@ const Home: React.FC<Props> = ({ packages }) => {
<MarketingLayout title={t('home.title')}> <MarketingLayout title={t('home.title')}>
<Head title={t('home.hero_title')} /> <Head title={t('home.hero_title')} />
{/* Hero Section */} <section id="hero" className="bg-aurora-enhanced py-20 px-4 text-gray-900 dark:text-gray-100">
<section id="hero" className="bg-aurora-enhanced text-gray-900 dark:text-gray-100 py-20 px-4"> <div className="container mx-auto flex max-w-6xl flex-col items-center gap-12 md:flex-row">
<div className="container mx-auto flex flex-col md:flex-row items-center gap-8 max-w-6xl"> <div className="flex flex-col gap-8 text-center md:w-1/2 md:text-left">
<div className="md:w-1/2 text-center md:text-left"> <div className="flex flex-col gap-4">
<h1 className="text-4xl md:text-6xl font-bold mb-4 font-display">{t('home.hero_title')}</h1> <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]">
<p className="text-xl md:text-2xl mb-8 font-sans-marketing">{t('home.hero_description')}</p> {t('home.hero_tagline')}
<Link </Badge>
href={localizedPath('/packages')} <h1 className="font-display text-4xl font-bold leading-tight md:text-5xl lg:text-6xl">
onClick={() => { {t('home.hero_title')}
trackHeroCtaClick(); </h1>
trackEvent({ <p className="text-lg text-gray-700 dark:text-gray-200 md:text-xl">
category: 'marketing_home', {t('home.hero_description')}
action: 'hero_cta', </p>
name: `packages:${heroCtaVariant}`, {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) => {
className={[ const Icon = heroBulletIcons[index % heroBulletIcons.length] ?? Sparkles;
'inline-block rounded-full px-8 py-4 font-bold transition duration-300', return (
heroCtaVariant === 'gradient' <li key={`hero-bullet-${index}`} className="flex items-start gap-3">
? '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' <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">
: 'bg-white text-[#FFB6C1] hover:bg-gray-100 dark:bg-gray-800 dark:text-rose-200 dark:hover:bg-gray-700', <Icon className="h-4 w-4" aria-hidden />
].join(' ')} </span>
> <span className="flex-1 text-base">{item}</span>
{heroCtaVariant === 'gradient' ? t('home.cta_explore_highlight') : t('home.cta_explore')} </li>
</Link> );
})}
</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>
<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 <img
src="/joyous_wedding_guests_posing.jpg" src="/joyous_wedding_guests_posing.jpg"
alt={t('home.hero_image_alt')} 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>
</div> </div>
</section> </section>
{/* How it Works Section */} <section id="how-it-works" className="bg-gray-50 py-20 px-4 dark:bg-gray-950">
<section className="py-20 px-4 bg-gray-50 dark:bg-gray-900">
<div className="container mx-auto max-w-6xl"> <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="mx-auto max-w-3xl text-center">
<div className="grid md:grid-cols-3 gap-8"> <h2 className="font-display text-3xl font-bold md:text-4xl">{t('home.how_title')}</h2>
<div className="text-center"> <p className="mt-4 text-base text-muted-foreground md:text-lg">
<div className="w-16 h-16 bg-[#FFB6C1] rounded-full flex items-center justify-center mx-auto mb-4"> {t('home.how_subtitle')}
<span className="text-2xl">1</span> </p>
</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> </div>
</div> <div className="mt-12 grid gap-6 md:grid-cols-3">
</section> {howSteps.map(({ icon: Icon, title, description }, index) => (
<Card
{/* Features Section */} key={`how-step-${index}`}
<section className="py-20 px-4 dark:bg-gray-700"> 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"
<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> <CardHeader className="flex flex-col gap-4">
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8"> <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">
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md"> <Icon className="h-6 w-6" aria-hidden />
<h3 className="text-xl font-semibold mb-2">{t('home.feature1_title')}</h3> </span>
<p>{t('home.feature1_desc')}</p> <CardTitle className="text-xl">{title}</CardTitle>
</div> <CardDescription className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md"> {description}
<h3 className="text-xl font-semibold mb-2">{t('home.feature2_title')}</h3> </CardDescription>
<p>{t('home.feature2_desc')}</p> </CardHeader>
</div> </Card>
<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> </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> </div>
</section> </section>
{/* Contact Section */} <section className="bg-slate-950 py-20 px-4 text-white">
<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">
<div className="container mx-auto max-w-6xl"> <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 gap-10 md:grid-cols-[1.1fr_0.9fr] md:items-center">
<div className="grid md:grid-cols-3 gap-8"> <div className="flex flex-col gap-6">
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md"> <Badge className="w-fit bg-white/15 px-3 py-1 text-xs uppercase tracking-[0.35em] text-white/80">
<p className="italic mb-4">"{t('home.testimonial1')}"</p> {t('home.demo_title')}
<p className="font-semibold">{t('common.testimonials.anna.name')}</p> </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>
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md"> <div className="relative mx-auto w-full max-w-sm">
<p className="italic mb-4">"{t('home.testimonial2')}"</p> <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 />
<p className="font-semibold">{t('common.testimonials.max.name')}</p> <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> <div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md"> <Smartphone className="h-12 w-12 text-white/60" aria-hidden />
<p className="italic mb-4">"{t('home.testimonial3')}"</p> <p className="text-sm text-white/75">{t('home.demo_media_alt')}</p>
<p className="font-semibold">{t('common.testimonials.lisa.name')}</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> </div>
</div> </div>
</section> </section>
{/* FAQ Section */} <section className="bg-white py-20 px-4 dark:bg-gray-950">
<section className="py-20 px-4 dark:bg-gray-700"> <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"> <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> <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">
<div className="space-y-4"> <CardHeader className="text-center">
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg"> <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">
<h3 className="font-semibold">{t('home.faq1_q')}</h3> {t('home.contact_title')}
<p>{t('home.faq1_a')}</p> </Badge>
</div> <CardTitle className="text-2xl md:text-3xl">{t('home.contact_lead')}</CardTitle>
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg"> </CardHeader>
<h3 className="font-semibold">{t('home.faq2_q')}</h3> <CardContent>
<p>{t('home.faq2_a')}</p> <form onSubmit={handleSubmit} className="space-y-6">
</div> <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>
</div> </div>
</section> </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'), 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; 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 { Head, Link, usePage } from '@inertiajs/react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { TFunction } from '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 { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -484,27 +483,7 @@ function PackageCard({
<div className="container mx-auto"> <div className="container mx-auto">
<h2 className="text-3xl font-bold text-center mb-12 font-display">{t('packages.section_endcustomer')}</h2> <h2 className="text-3xl font-bold text-center mb-12 font-display">{t('packages.section_endcustomer')}</h2>
<div className="block md:hidden"> <div className="grid gap-6 sm:grid-cols-2 lg:gap-8 lg:grid-cols-3">
<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">
{endcustomerPackages.map((pkg) => ( {endcustomerPackages.map((pkg) => (
<PackageCard <PackageCard
key={pkg.id} key={pkg.id}
@@ -652,27 +631,7 @@ function PackageCard({
<div className="container mx-auto"> <div className="container mx-auto">
<h2 className="text-3xl font-bold text-center mb-12 font-display">{t('packages.section_reseller')}</h2> <h2 className="text-3xl font-bold text-center mb-12 font-display">{t('packages.section_reseller')}</h2>
<div className="block md:hidden"> <div className="grid gap-6 sm:grid-cols-2 lg:gap-8 xl:grid-cols-3">
<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">
{resellerPackages.map((pkg) => ( {resellerPackages.map((pkg) => (
<PackageCard <PackageCard
key={pkg.id} key={pkg.id}

View File

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

View File

@@ -41,6 +41,8 @@ Route::get('/blog', [MarketingController::class, 'blogIndex'])->name('blog');
Route::get('/blog/{slug}', [MarketingController::class, 'blogShow'])->name('blog.show'); Route::get('/blog/{slug}', [MarketingController::class, 'blogShow'])->name('blog.show');
Route::get('/packages', [MarketingController::class, 'packagesIndex'])->name('packages'); Route::get('/packages', [MarketingController::class, 'packagesIndex'])->name('packages');
Route::get('/anlaesse/{type}', [MarketingController::class, 'occasionsType'])->name('anlaesse.type'); Route::get('/anlaesse/{type}', [MarketingController::class, 'occasionsType'])->name('anlaesse.type');
Route::get('/so-funktionierts', [MarketingController::class, 'howItWorks'])->name('how-it-works');
Route::get('/demo', [MarketingController::class, 'demo'])->name('demo');
Route::get('/success/{packageId?}', [MarketingController::class, 'success'])->name('marketing.success'); Route::get('/success/{packageId?}', [MarketingController::class, 'success'])->name('marketing.success');
Route::prefix('event-admin')->group(function () { Route::prefix('event-admin')->group(function () {
$renderAdmin = fn () => view('admin'); $renderAdmin = fn () => view('admin');