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:
@@ -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');
|
||||||
|
|||||||
29
docs/todo/localized-seo-hreflang-strategy.md
Normal file
29
docs/todo/localized-seo-hreflang-strategy.md
Normal 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?
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 funktioniert’s.",
|
||||||
"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."
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "We’ll 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."
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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()}`;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
125
resources/js/pages/marketing/Demo.tsx
Normal file
125
resources/js/pages/marketing/Demo.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
|
|||||||
423
resources/js/pages/marketing/HowItWorks.tsx
Normal file
423
resources/js/pages/marketing/HowItWorks.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user