checkout: buttons verbessert, paddle zahlungsschritt schicker gemacht, schritt 4 optimiert+schick gemacht. Dashboard: translations ergänzt. Startseite vom Event Admin optimiert.

This commit is contained in:
Codex Agent
2025-11-17 11:06:46 +01:00
parent 5290072ffe
commit 167734f87a
25 changed files with 1981 additions and 1002 deletions

View File

@@ -23,6 +23,8 @@
"description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.",
"brand": "Die Fotospiel App",
"logo_alt": "Logo Die Fotospiel App",
"identifier": "E-Mail oder Username",
"identifier_placeholder": "z. B. name@beispiel.de oder hochzeit_julia",
"username_or_email": "Username oder E-Mail",
"email": "E-Mail-Adresse",
"email_placeholder": "ihre@email.de",
@@ -49,7 +51,16 @@
"address": "Adresse",
"phone": "Telefonnummer",
"privacy_consent": "Ich stimme der Datenschutzerklärung zu und akzeptiere die Verarbeitung meiner persönlichen Daten.",
"submit": "Registrieren"
"privacy_policy_link": "Datenschutzerklärung",
"submit": "Registrieren",
"first_name_placeholder": "Vorname",
"last_name_placeholder": "Nachname",
"email_placeholder": "beispiel@email.de",
"address_placeholder": "Straße Hausnummer, PLZ Ort",
"phone_placeholder": "+49 170 1234567",
"username_placeholder": "z. B. hochzeit_julia",
"password_placeholder": "Mindestens 8 Zeichen",
"password_confirmation_placeholder": "Passwort erneut eingeben"
},
"verification": {
"notice": "Bitte bestätigen Sie Ihre E-Mail-Adresse.",

View File

@@ -98,10 +98,15 @@
"reseller_benefits": "Vorteile für Reseller",
"unlimited_events": "Unbegrenzte Events",
"custom_branding": "Benutzerdefiniertes Branding",
"available": "Verfügbar",
"not_available": "Nicht verfügbar",
"standard_support": "Standard-Support",
"priority_support": "Priorisierter Support",
"cancel_link": "Abo kündigen: :link",
"hero_title": "Entdecken Sie unsere flexiblen Packages",
"hero_description": "Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.",
"hero_secondary": "Teste den kompletten Gäste-Flow in unserer Live-Demo kein Login, kein App-Store.",
"cta_demo": "Demo ansehen",
"cta_explore": "Pakete entdecken",
"cta_explore_highlight": "Lieblingspaket sichern",
"tab_endcustomer": "Endkunden",
@@ -121,6 +126,7 @@
"register_buy": "Registrieren und kaufen",
"register_subscribe": "Registrieren und abonnieren",
"faq_title": "Häufige Fragen zu Packages",
"faq_lead": "Antworten auf die wichtigsten Fragen mehr Details findest du im Guide „So funktionierts“.",
"faq_q1": "Was ist ein Package?",
"faq_a1": "Ein Package definiert Limits und Features für Ihr Event, z.B. Anzahl Fotos und Galerie-Dauer.",
"faq_q2": "Kann ich upgraden?",
@@ -171,6 +177,10 @@
"for_resellers": "Für Reseller",
"details_show": "Details anzeigen",
"comparison_title": "Packages vergleichen",
"comparison_subtitle": "Alle Limits und Features auf einen Blick.",
"comparison_hint": "Wähle deine Zielgruppe und scrolle durch die Karten. Die Tabelle zeigt genaue Unterschiede.",
"comparison_limits": "Limits",
"comparison_features": "Features",
"max_photos_label": "Max. Fotos",
"max_guests_label": "Max. Gäste",
"gallery_days_label": "Galerie-Tage",
@@ -388,6 +398,33 @@
"currency": {
"euro": "€"
},
"coupon": {
"label": "Gutscheincode",
"placeholder": "Gutscheincode eingeben",
"apply": "Gutschein anwenden",
"remove": "Gutschein entfernen",
"applied": "Gutschein {{code}} aktiviert. Du sparst {{amount}}.",
"summary_title": "Aktualisierte Bestellsumme",
"fields": {
"subtotal": "Zwischensumme",
"discount": "Rabatt",
"tax": "MwSt.",
"total": "Gesamtsumme nach Rabatt"
},
"errors": {
"required": "Bitte gib einen Gutscheincode ein.",
"not_found": "Dieser Gutschein konnte nicht gefunden werden.",
"inactive": "Dieser Gutschein ist nicht aktiv.",
"disabled": "Dieser Gutschein kann derzeit nicht eingelöst werden.",
"not_applicable": "Dieser Gutschein gilt nicht für das ausgewählte Package.",
"limit_reached": "Dieser Gutschein wurde bereits maximal genutzt.",
"currency_mismatch": "Dieser Gutschein passt nicht zur gewählten Währung.",
"not_synced": "Dieser Gutschein ist noch nicht bereit. Bitte versuche es später erneut.",
"package_not_configured": "Dieses Package unterstützt aktuell keine Gutscheine.",
"login_required": "Bitte melde dich an, um diesen Gutschein zu nutzen.",
"generic": "Der Gutschein konnte nicht angewendet werden. Bitte versuche einen anderen."
}
},
"meta": {
"title": "Fotospiel - Sammle Gastfotos für Events mit QR-Codes",
"description": "Sammle Gastfotos für Events mit QR-Codes. Unsere sichere PWA-Plattform für Gäste und Organisatoren einfach, mobil und datenschutzkonform."
@@ -436,9 +473,7 @@
"description": "Wähle das passende Paket für deine Bedürfnisse",
"no_package_selected": "Kein Paket ausgewählt. Bitte wähle ein Paket aus der Paketübersicht.",
"alternatives_title": "Alternative Pakete",
"no_alternatives": "Keine weiteren Pakete in dieser Kategorie verfügbar.",
"next_to_account": "Weiter zum Konto",
"loading": "Wird geladen..."
"no_alternatives": "Keine weiteren Pakete in dieser Kategorie verfügbar."
},
"auth_step": {
"title": "Konto",
@@ -455,7 +490,9 @@
"google_error_title": "Google-Anmeldung fehlgeschlagen",
"google_missing_package": "Bitte wähle zuerst ein Paket aus, bevor du Google Login nutzt.",
"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.",
"google_helper": "Schneller Login über Google deine Daten werden ausschließlich zur Kontoeinrichtung verwendet.",
"google_helper_badge": "Warum Google?"
},
"payment_step": {
"title": "Zahlung",
@@ -467,6 +504,14 @@
"loading_payment": "Zahlungsdaten werden geladen...",
"secure_payment_desc": "Sichere Zahlung über Paddle.",
"paddle_intro": "Starte den Paddle-Checkout direkt hier im Wizard ganz ohne Seitenwechsel.",
"guided_title": "Sichere Zahlung mit Paddle unserem geprüften Partner",
"guided_body": "Wir führen dich Schritt für Schritt durch den Bezahlprozess. Paddle wickelt den Kauf als Merchant of Record ab und sorgt dafür, dass Steuern und Rechnungen automatisch korrekt erstellt werden.",
"paddle_partner": "Powered by Paddle",
"trust_secure": "Verschlüsselte Zahlung",
"trust_tax": "Automatische Steuerberechnung",
"trust_support": "Support in Minuten",
"guided_cta_hint": "Paddle wickelt deine Zahlung als Merchant of Record ab",
"toast_success": "Zahlung erfolgreich wir bereiten alles vor.",
"paddle_preparing": "Paddle-Checkout wird vorbereitet…",
"paddle_overlay_ready": "Der Paddle-Checkout läuft jetzt in einem Overlay. Schließe die Zahlung dort ab und kehre anschließend hierher zurück.",
"paddle_ready": "Paddle-Checkout wurde in einem neuen Tab geöffnet. Schließe die Zahlung dort ab und kehre dann hierher zurück.",
@@ -511,6 +556,30 @@
"welcome": "Danke, dass du die Fotospiel App gewählt hast!",
"package_summary": "Dein Paket <strong>{name}</strong> ist jetzt freigeschaltet. Du kannst sofort mit der Einrichtung loslegen.",
"email_followup": "Wir haben dir gerade alle Details per E-Mail geschickt inklusive Rechnung und den nächsten Schritten.",
"hero_badge": "Checkout abgeschlossen",
"hero_title": "Weiter geht's im Marketing-Dashboard",
"hero_body": "Wir haben deinen Zugang aktiviert und Paddle synchronisiert. Mit diesen Aufgaben startest du direkt durch.",
"hero_next": "Nutze den Button unten, um in deinen Kundenbereich zu wechseln diese Übersicht kannst du jederzeit erneut öffnen.",
"onboarding_title": "Vorschau auf deine Onboarding-Schritte",
"onboarding_subtitle": "Diese Aufgaben erwarten dich direkt nach dem Login.",
"onboarding_badge": "Nächste Schritte",
"onboarding_items": {
"event": {
"title": "Erstes Event anlegen",
"body": "Titel, Datum und Highlights festlegen alles bleibt anpassbar."
},
"invites": {
"title": "QR-Einladungen aktivieren",
"body": "Teile deinen Event-QR-Code oder den Shortcut-Link mit Gästen."
},
"tasks": {
"title": "Fotoaufgaben planen",
"body": "Nutze Vorlagen oder füge eigene kreative Aufgaben hinzu."
}
},
"control_center_title": "Event Control Center (PWA)",
"control_center_body": "Alle Live-Aufgaben erledigst du später im Control Center optimiert für Mobilgeräte und offlinefähig.",
"control_center_hint": "Installiere die PWA direkt aus dem Dashboard.",
"package_activated": "Ihr Paket '{name}' ist aktiviert.",
"email_sent": "Wir haben Ihnen eine Bestätigungs-E-Mail gesendet.",
"open_profile": "Profil öffnen",

View File

@@ -23,6 +23,8 @@
"description": "Sign in with your Fotospiel account to manage every event in one place.",
"brand": "Die Fotospiel App",
"logo_alt": "Fotospiel App logo",
"identifier": "Email or Username",
"identifier_placeholder": "you@example.com or username",
"username_or_email": "Username or Email",
"email": "Email Address",
"email_placeholder": "your@email.com",
@@ -49,7 +51,16 @@
"address": "Address",
"phone": "Phone Number",
"privacy_consent": "I agree to the privacy policy and accept the processing of my personal data.",
"submit": "Register"
"privacy_policy_link": "Privacy Policy",
"submit": "Register",
"first_name_placeholder": "First name",
"last_name_placeholder": "Last name",
"email_placeholder": "you@example.com",
"address_placeholder": "Street, ZIP, City",
"phone_placeholder": "+1 555 123 4567",
"username_placeholder": "e.g. wedding_julia",
"password_placeholder": "At least 8 characters",
"password_confirmation_placeholder": "Repeat your password"
},
"verification": {
"notice": "Please verify your email address.",

View File

@@ -92,6 +92,8 @@
"cancel_link": "Cancel Subscription: :link",
"hero_title": "Discover our flexible Packages",
"hero_description": "From free entry to premium features: Tailor your event package to your needs. Simple, secure, and scalable.",
"hero_secondary": "Experience the full guest flow in our live demo no login, no install.",
"cta_demo": "View demo",
"cta_explore": "Discover Packages",
"cta_explore_highlight": "Explore top packages",
"tab_endcustomer": "End Customers",
@@ -111,6 +113,7 @@
"register_buy": "Register and Buy",
"register_subscribe": "Register and Subscribe",
"faq_title": "Frequently Asked Questions about Packages",
"faq_lead": "Quick answers to the essentials check “How it works” for the full deep dive.",
"faq_q1": "What is a Package?",
"faq_a1": "A Package defines limits and features for your event, e.g. number of photos and gallery duration.",
"faq_q2": "Can I upgrade?",
@@ -161,6 +164,10 @@
"for_resellers": "For Resellers",
"details_show": "Show Details",
"comparison_title": "Compare Packages",
"comparison_subtitle": "Every limit and feature side by side.",
"comparison_hint": "Pick your audience and slide through the cards. The table surfaces the fine print.",
"comparison_limits": "Limits",
"comparison_features": "Features",
"price": "Price",
"max_photos_label": "Max. Photos",
"max_guests_label": "Max. Guests",
@@ -168,6 +175,9 @@
"watermark_label": "Watermark",
"no_watermark": "No Watermark",
"custom_branding": "Custom Branding",
"available": "Available",
"not_available": "Not available",
"standard_support": "Standard support",
"max_tenants": "Max. Tenants",
"max_events": "Max. Events/Year",
"faq_free": "What is the Free Package?",
@@ -382,6 +392,33 @@
"currency": {
"euro": "€"
},
"coupon": {
"label": "Coupon code",
"placeholder": "Enter your coupon code",
"apply": "Apply coupon",
"remove": "Remove coupon",
"applied": "Coupon {{code}} applied. You save {{amount}}.",
"summary_title": "Updated order summary",
"fields": {
"subtotal": "Subtotal",
"discount": "Discount",
"tax": "Tax",
"total": "Total after discount"
},
"errors": {
"required": "Please enter a coupon code.",
"not_found": "We could not find this coupon.",
"inactive": "This coupon is not active anymore.",
"disabled": "This coupon cannot be used at checkout.",
"not_applicable": "This coupon is not valid for the selected package.",
"limit_reached": "This coupon has already been used the maximum number of times.",
"currency_mismatch": "This coupon cannot be used with the selected currency.",
"not_synced": "This coupon is not ready yet. Please try again later.",
"package_not_configured": "This package is not configured for coupon redemption.",
"login_required": "Please log in to use this coupon.",
"generic": "We could not apply this coupon. Please try another one."
}
},
"meta": {
"title": "Fotospiel - Collect Guest Photos for Events with QR Codes",
"description": "Collect guest photos for events with QR codes. Our secure PWA platform for guests and organizers simple, mobile, and privacy-compliant."
@@ -430,9 +467,7 @@
"description": "Choose the right package for your needs",
"no_package_selected": "No package selected. Please choose a package from the overview.",
"alternatives_title": "Alternative Packages",
"no_alternatives": "No further packages in this category available.",
"next_to_account": "Next to Account",
"loading": "Loading..."
"no_alternatives": "No further packages in this category available."
},
"auth_step": {
"title": "Account",
@@ -449,7 +484,9 @@
"google_error_title": "Google login failed",
"google_missing_package": "Please choose a package before using Google login.",
"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.",
"google_helper": "Sign in faster with Google we only use your details to create your Fotospiel account.",
"google_helper_badge": "Why Google?"
},
"payment_step": {
"title": "Payment",
@@ -461,6 +498,14 @@
"loading_payment": "Payment data is loading...",
"secure_payment_desc": "Secure payment with Paddle.",
"paddle_intro": "Launch the Paddle checkout right here in the wizard—no page changes required.",
"guided_title": "Secure checkout, powered by Paddle",
"guided_body": "We walk you through every step. Paddle acts as merchant of record, handles taxes automatically, and delivers compliant invoices instantly.",
"paddle_partner": "Powered by Paddle",
"trust_secure": "Encrypted payment",
"trust_tax": "Automatic tax handling",
"trust_support": "Live support within minutes",
"guided_cta_hint": "Securely processed by Paddle as Merchant of Record",
"toast_success": "Payment received setting everything up for you.",
"paddle_preparing": "Preparing Paddle checkout…",
"paddle_overlay_ready": "Paddle checkout is running in a secure overlay. Complete the payment there and then continue here.",
"paddle_ready": "Paddle checkout opened in a new tab. Complete the payment and then continue here.",
@@ -505,6 +550,30 @@
"welcome": "Thank you for choosing the Fotospiel App!",
"package_summary": "Your <strong>{name}</strong> package is now active. You're ready to get everything set up.",
"email_followup": "We've just sent a confirmation email with your receipt and the next steps.",
"hero_badge": "Checkout complete",
"hero_title": "You're ready for the Marketing Dashboard",
"hero_body": "We activated your access and synced Paddle. Follow the checklist below to launch your first event.",
"hero_next": "Use the button below whenever you're ready to jump into your customer area—this summary is always available.",
"onboarding_title": "Preview your onboarding steps",
"onboarding_subtitle": "These are the first tasks you'll see after logging in.",
"onboarding_badge": "Next steps",
"onboarding_items": {
"event": {
"title": "Create your first event",
"body": "Set title, date, and highlights. You can adjust everything later."
},
"invites": {
"title": "Activate QR invites",
"body": "Share your event QR code or shortcut link with guests."
},
"tasks": {
"title": "Plan photo tasks",
"body": "Pick from the library or add your own creative prompts."
}
},
"control_center_title": "Event Control Center (PWA)",
"control_center_body": "You handle live moderation and uploads in the Control Center — mobile-first and offline-ready.",
"control_center_hint": "Install the PWA directly from the dashboard.",
"package_activated": "Your package '{name}' is activated.",
"email_sent": "We have sent you a confirmation email.",
"open_profile": "Open Profile",

3
public/paddle.logo.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="105" height="32" viewBox="0 0 105 32" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path fill-rule="evenodd" clip-rule="evenodd" d="M49.9213 7.48787C52.468 7.48787 54.724 8.7676 56.1418 10.7844V0H59.0423V24.0002H56.1418V21.2156C54.724 23.2363 52.468 24.5121 49.9213 24.5121C45.1514 24.5121 41.123 20.7682 41.123 16.0005C41.123 11.2328 45.1514 7.48787 49.9213 7.48787ZM49.9213 21.9517C53.692 21.9517 56.1418 19.424 56.1418 16.0005C56.1418 12.577 53.692 10.0483 49.9213 10.0483C46.5702 10.0483 44.0234 12.448 44.0234 16.0005C44.0234 19.5529 46.5702 21.9517 49.9213 21.9517ZM36.2891 10.7843C34.8713 8.76752 32.6153 7.48779 30.0695 7.48779C25.2987 7.48779 21.2703 11.2327 21.2703 16.0004C21.2703 20.7681 25.2987 24.5121 30.0695 24.5121C32.5833 24.5121 34.8355 23.2362 36.2891 21.2156V24.0002H39.1896V7.99969H36.2891V10.7843ZM36.2891 16.0004C36.2891 19.3922 33.8073 21.9516 30.0695 21.9516C26.7175 21.9516 24.1707 19.5529 24.1707 16.0004C24.1707 12.448 26.7175 10.0482 30.0695 10.0482C33.8402 10.0482 36.2891 12.5759 36.2891 16.0004ZM0 15.5818V16.4478C0.529481 16.4479 1.05375 16.5517 1.54282 16.7531C2.0319 16.9545 2.47621 17.2497 2.85034 17.6217C3.22446 17.9937 3.52108 18.4353 3.72324 18.9212C3.92539 19.4071 4.02911 19.9277 4.02848 20.4535H4.83475C4.83501 19.3927 5.25952 18.3755 6.01495 17.6254C6.77038 16.8753 7.79489 16.4538 8.86323 16.4536V15.5876C8.33375 15.5875 7.80948 15.4837 7.3204 15.2823C6.83133 15.0809 6.38702 14.7858 6.01289 14.4137C5.63876 14.0417 5.34214 13.6002 5.13999 13.1143C4.93784 12.6284 4.83412 12.1077 4.83475 11.582H4.02848C4.02822 12.6427 3.60371 13.6599 2.84828 14.41C2.09285 15.1601 1.06834 15.5816 0 15.5818ZM10.9574 10.08H4.02848V7.51953H10.9574C15.8241 7.51953 19.6598 11.3289 19.6598 16.0004C19.6598 20.6719 15.8241 24.4803 10.9574 24.4803H6.92894V31.9999H4.02848V21.9199H10.9574C14.3424 21.9199 16.7593 19.5211 16.7593 16.0004C16.7593 12.4797 14.3415 10.08 10.9574 10.08ZM76.3185 10.7844C74.8998 8.7676 72.6437 7.48787 70.098 7.48787C65.3281 7.48787 61.2997 11.2328 61.2997 16.0005C61.2997 20.7682 65.3281 24.5121 70.098 24.5121C72.6437 24.5121 74.8998 23.2363 76.3185 21.2156V24.0002H79.219V0H76.3185V10.7844ZM76.3185 16.0005C76.3185 19.424 73.8687 21.9517 70.098 21.9517C66.7459 21.9517 64.2001 19.5529 64.2001 16.0005C64.2001 12.448 66.7459 10.0483 70.098 10.0483C73.8687 10.0483 76.3185 12.577 76.3185 16.0005ZM82.116 24.0002V0H85.0175V24.0002H82.116ZM104.001 17.1214C104.001 11.8408 101.326 7.52051 95.7513 7.52051C90.8846 7.52051 87.1139 11.3943 87.1139 16.0014C87.1139 20.6084 90.8846 24.4813 95.7513 24.4813C99.6189 24.4813 102.486 22.0488 103.582 19.5846H100.456C99.2962 21.3445 97.7496 22.2413 95.7513 22.2413C92.8508 22.2413 90.5299 20.2245 90.0793 17.1214H104.001ZM95.7513 9.76052C98.8446 9.76052 101.101 12.2565 101.101 14.8814H90.0793C90.5299 11.7811 92.8508 9.76052 95.7513 9.76052Z" fill="#FCFCFC"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -97,6 +97,42 @@
"description": "Aktives Paket und Historie einsehen."
}
},
"onboarding": {
"hero": {
"cta": "Setup fortsetzen"
},
"card": {
"title": "Dein Start in fünf Schritten",
"description": "Bearbeite die Schritte in der Admin-App das Dashboard zeigt dir den Status.",
"completed": "Alle Schritte abgeschlossen großartig! Du kannst jederzeit zur Admin-App wechseln.",
"cta_fallback": "Jetzt starten"
},
"admin_app": {
"title": "Admin-App öffnen",
"description": "Verwalte Events, Uploads und Gäste direkt in der Admin-App. Die mobile Oberfläche ist für Live-Einsätze optimiert.",
"cta": "Admin-App starten"
},
"event_setup": {
"title": "Erstes Event vorbereiten",
"description": "Lege in der Admin-App Name, Datum und Aufgaben fest. So wissen Gäste, welche Fotos ihr euch wünscht.",
"cta": "Event anlegen"
},
"invite_guests": {
"title": "Gäste einladen",
"description": "Teile QR-Codes oder Links, damit Gäste direkt mit dem Hochladen beginnen können.",
"cta": "QR-Links öffnen"
},
"collect_photos": {
"title": "Erste Fotos einsammeln",
"description": "Sobald die ersten Uploads eintrudeln, erscheint alles in eurer Galerie. Moderation und Freigaben laufen in der Admin-App.",
"cta": "Uploads prüfen"
},
"branding": {
"title": "Branding & Aufgaben verfeinern",
"description": "Passt Farbwelt und Aufgabenpakete an euren Anlass an so fühlt sich alles wie aus einem Guss an.",
"cta": "Branding öffnen"
}
},
"limitsCard": {
"title": "Kontingente & Laufzeiten",
"description": "Fokus-Event: {{name}}",
@@ -251,6 +287,42 @@
"planning": "In Planung",
"noDate": "Kein Datum"
}
},
"onboarding": {
"hero": {
"cta": "Setup fortsetzen"
},
"card": {
"title": "Dein Start in fünf Schritten",
"description": "Bearbeite die Schritte in der Admin-App das Dashboard zeigt dir den Status.",
"completed": "Alle Schritte abgeschlossen großartig! Du kannst jederzeit zur Admin-App wechseln.",
"cta_fallback": "Jetzt starten"
},
"admin_app": {
"title": "Admin-App öffnen",
"description": "Verwalte Events, Uploads und Gäste direkt in der Admin-App. Die mobile Oberfläche ist für Live-Einsätze optimiert.",
"cta": "Admin-App starten"
},
"event_setup": {
"title": "Erstes Event vorbereiten",
"description": "Lege in der Admin-App Name, Datum und Aufgaben fest. So wissen Gäste, welche Fotos ihr euch wünscht.",
"cta": "Event anlegen"
},
"invite_guests": {
"title": "Gäste einladen",
"description": "Teile QR-Codes oder Links, damit Gäste direkt mit dem Hochladen beginnen können.",
"cta": "QR-Links öffnen"
},
"collect_photos": {
"title": "Erste Fotos einsammeln",
"description": "Sobald die ersten Uploads eintrudeln, erscheint alles in eurer Galerie. Moderation und Freigaben laufen in der Admin-App.",
"cta": "Uploads prüfen"
},
"branding": {
"title": "Branding & Aufgaben verfeinern",
"description": "Passt Farbwelt und Aufgabenpakete an euren Anlass an so fühlt sich alles wie aus einem Guss an.",
"cta": "Branding öffnen"
}
}
}
}

View File

@@ -97,6 +97,42 @@
"description": "View your active package and history."
}
},
"onboarding": {
"hero": {
"cta": "Continue setup"
},
"card": {
"title": "Your start in five steps",
"description": "Complete the steps inside the Admin App — the dashboard keeps track of your status.",
"completed": "All steps finished — fantastic! You can switch to the Admin App at any time.",
"cta_fallback": "Start now"
},
"admin_app": {
"title": "Open the Admin App",
"description": "Manage events, uploads, and guests inside the Admin App. The mobile interface is optimised for live operations.",
"cta": "Launch Admin App"
},
"event_setup": {
"title": "Prepare first event",
"description": "Define name, date, and tasks inside the Admin App so guests know which photos you expect.",
"cta": "Create event"
},
"invite_guests": {
"title": "Invite guests",
"description": "Share QR codes or links so guests can start uploading instantly.",
"cta": "Open QR links"
},
"collect_photos": {
"title": "Collect first photos",
"description": "As soon as uploads arrive, they show up in your gallery. Moderation happens inside the Admin App.",
"cta": "Review uploads"
},
"branding": {
"title": "Fine-tune branding & tasks",
"description": "Adjust colours and task bundles to match your occasion — everything feels tailor-made.",
"cta": "Open branding"
}
},
"limitsCard": {
"title": "Limits & gallery status",
"description": "Focus event: {{name}}",
@@ -251,6 +287,42 @@
"planning": "In planning",
"noDate": "No date"
}
},
"onboarding": {
"hero": {
"cta": "Continue setup"
},
"card": {
"title": "Your start in five steps",
"description": "Complete the steps inside the Admin App — the dashboard keeps track of your status.",
"completed": "All steps finished — fantastic! You can switch to the Admin App at any time.",
"cta_fallback": "Start now"
},
"admin_app": {
"title": "Open the Admin App",
"description": "Manage events, uploads, and guests inside the Admin App. The mobile interface is optimised for live operations.",
"cta": "Launch Admin App"
},
"event_setup": {
"title": "Prepare first event",
"description": "Define name, date, and tasks inside the Admin App so guests know which photos you expect.",
"cta": "Create event"
},
"invite_guests": {
"title": "Invite guests",
"description": "Share QR codes or links so guests can start uploading instantly.",
"cta": "Open QR links"
},
"collect_photos": {
"title": "Collect first photos",
"description": "As soon as uploads arrive, they show up in your gallery. Moderation happens inside the Admin App.",
"cta": "Review uploads"
},
"branding": {
"title": "Fine-tune branding & tasks",
"description": "Adjust colours and task bundles to match your occasion — everything feels tailor-made.",
"cta": "Open branding"
}
}
}
}

View File

@@ -0,0 +1,3 @@
export function navigateToHref(target: string): void {
window.location.assign(target);
}

View File

@@ -1,31 +1,148 @@
import React from 'react';
import { Sparkles, Users, Camera, Heart, ArrowRight } from 'lucide-react';
import {
ArrowRight,
Camera,
Clock3,
Heart,
MessageCircle,
Moon,
QrCode,
ShieldCheck,
Sparkles,
SunMedium,
Users,
Wand2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { LanguageSwitcher } from '../components/LanguageSwitcher';
import { FrostedSurface } from '../components/tenant';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_LOGIN_PATH } from '../constants';
import { encodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
import { navigateToHref } from '../lib/navigation';
const highlights = [
const heroStats = [
{ label: 'Events begleitet', value: '2.100+' },
{ label: 'Fotos kuratiert', value: '680k' },
{ label: 'Mission Cards live', value: '120+' },
];
const featureCards = [
{
icon: Sparkles,
title: 'Momente lenken, nicht das Handy',
badge: 'Welcome Flow',
title: 'Geführte Einrichtung',
description:
'Fotospiel liefert euch spielerische Aufgaben, damit eure Gäste das Fest genießen und gleichzeitig emotionale Motive festhalten.',
'Ein roter Faden von eurer ersten Mission bis zum Event-Branding. Alles läuft dort, wo ihr später auch Events steuert.',
},
{
icon: Users,
title: 'Alle Gäste auf einer Reise',
icon: QrCode,
badge: 'Gäste einladen',
title: 'Links & QR-Cards teilen',
description:
'Einladungslinks und QR-Codes führen direkt in eure Event-Galerie. Kein Technik-Know-how nötig nur teilen und loslegen.',
'Mit einem Fingertipp entstehen Einladungslinks, QR-Codes und TWA-Links für Android & iOS ganz ohne App Store Hürden.',
},
{
icon: Camera,
title: 'Live-Galerie und Moderation',
badge: 'Live Galerie',
title: 'Moderieren wie im Dashboard',
description:
'Sammelt Bilder in Echtzeit, markiert Highlights und entscheidet gemeinsam, welche Erinnerungen groß rauskommen.',
'Markiert Highlights, ordnet Emotions oder sperrt Fotos exakt die Tools aus dem Event Admin, nur mit Willkommens-Glow.',
},
];
const timelineSteps = [
{
title: 'Mission Cards wählen',
description: 'Kuratiert Aufgaben, die eure Gäste ins Erzählen bringen. Jeder Schritt speichert automatisch.',
},
{
title: 'Event Branding festlegen',
description: 'Farben, Story, Cover alles in einer Ansicht. Vorschau zeigt sofort, wie der Gastzugang wirkt.',
},
{
title: 'Link teilen & feiern',
description: 'QR-Code oder Link verschicken, fertig. Gäste landen direkt in eurer Galerie und können ohne Login starten.',
},
];
const supportHighlights = [
{
icon: ShieldCheck,
title: 'Datenschutz-ready',
description: 'Keine versteckten Tracker, DSGVO-konformes Hosting und Kontrolle, wer Fotos sieht.',
},
{
icon: Clock3,
title: 'Offline nutzbar',
description: 'Uploads puffern automatisch, falls das WLAN in der Location aussetzt.',
},
{
icon: MessageCircle,
title: 'Crew an eurer Seite',
description: 'Direkter Chat zum Fotospiel-Team aus der App heraus oder via hallo@fotospiel.de.',
},
];
export default function WelcomeTeaserPage() {
const [isRedirecting, setIsRedirecting] = React.useState(false);
const [mode, setMode] = React.useState<'dark' | 'light'>('dark');
const isLightMode = mode === 'light';
const flowSectionRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
document.body.classList.add('tenant-admin-theme', 'tenant-admin-welcome-theme');
return () => {
document.body.classList.remove('tenant-admin-theme', 'tenant-admin-welcome-theme');
};
}, []);
React.useEffect(() => {
document.body.classList.toggle('tenant-admin-welcome-light', isLightMode);
document.body.classList.toggle('tenant-admin-welcome-dark', !isLightMode);
}, [isLightMode]);
const theme = React.useMemo(
() => ({
rootBackground: isLightMode
? 'bg-gradient-to-br from-rose-50 via-white to-sky-50 text-slate-900'
: 'bg-slate-950 text-white',
aurora: isLightMode
? 'bg-[radial-gradient(ellipse_at_top,_rgba(255,179,205,0.55),_transparent_55%),radial-gradient(ellipse_at_bottom,_rgba(148,187,233,0.45),_transparent_60%)]'
: 'bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.25),_transparent_60%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.25),_transparent_62%)]',
overlay: isLightMode
? 'bg-gradient-to-br from-white/85 via-rose-50/70 to-sky-50/70'
: 'bg-gradient-to-br from-slate-950/95 via-slate-950/80 to-[#150b1f]/90',
headerBadge: isLightMode ? 'border-rose-100 bg-white text-rose-500' : 'border-white/30 bg-white/5 text-white',
headerSubtitle: isLightMode ? 'text-slate-600' : 'text-white/70',
heroEyebrow: isLightMode ? 'text-rose-500' : 'text-rose-200',
heroTitle: isLightMode ? 'text-slate-900' : 'text-white',
heroBody: isLightMode ? 'text-slate-600' : 'text-white/75',
ghostButton: isLightMode ? 'text-slate-700 hover:bg-slate-100' : 'text-white/80 hover:bg-white/10',
statCard: isLightMode
? 'border-rose-100/70 bg-white/90 text-slate-900 shadow-rose-100/50'
: 'border-white/15 bg-white/10 text-white shadow-rose-500/30',
featureCard: isLightMode
? 'border-rose-100 bg-white text-slate-900 shadow-rose-100/40'
: 'border-white/15 bg-white/10 text-white shadow-slate-900/40',
timelineCard: isLightMode ? 'border-slate-200 bg-white text-slate-900' : 'border-white/15 bg-white/5 text-white',
timelineDescription: isLightMode ? 'text-slate-600' : 'text-white/70',
supportCard: isLightMode ? 'border-slate-200 bg-white text-slate-900' : 'border-white/15 bg-white/10 text-white',
supportDescription: isLightMode ? 'text-slate-600' : 'text-white/70',
ctaSurface: isLightMode ? 'border-slate-200 bg-white text-slate-900' : 'border-white/15 bg-white/10 text-white',
ctaDetail: isLightMode ? 'text-slate-600' : 'text-white/70',
footer: isLightMode
? 'border-t border-slate-200/80 bg-white/70 text-slate-500'
: 'border-t border-white/10 bg-black/20 text-white/60',
}),
[isLightMode]
);
const toggleMode = React.useCallback(() => {
setMode((prev) => (prev === 'dark' ? 'light' : 'dark'));
}, []);
const handleLoginRedirect = React.useCallback(() => {
if (isRedirecting) {
@@ -39,90 +156,339 @@ export default function WelcomeTeaserPage() {
const { finalTarget, encodedFinal } = resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH);
const target = new URL(ADMIN_LOGIN_PATH, window.location.origin);
target.searchParams.set('return_to', encodedFinal ?? encodeReturnTo(finalTarget));
window.location.href = `${target.pathname}${target.search}`;
const nextHref = `${target.pathname}${target.search}`;
navigateToHref(nextHref);
}, [isRedirecting]);
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 via-white to-sky-50 text-slate-800">
<header className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-6 pb-12 pt-16 text-center md:pt-20">
<div className="mx-auto w-fit rounded-full border border-rose-100 bg-white/80 px-4 py-1 text-sm font-medium text-rose-500 shadow-sm">
Willkommen bei Fotospiel
</div>
<h1 className="font-display text-4xl font-semibold tracking-tight text-slate-900 md:text-5xl">
Eure Gäste als Geschichtenerzähler ohne Technikstress
</h1>
<p className="mx-auto max-w-2xl text-base leading-relaxed text-slate-600 md:text-lg">
Dieses Kontrollzentrum zeigt euch, wie ihr Fotospiel für Hochzeit, Jubiläum oder Team-Event einsetzt.
Wir führen euch Schritt für Schritt durch Aufgaben, Event-Setup und Einladungen.
</p>
<div className="flex flex-col justify-center gap-3 md:flex-row">
const handleScrollToFlow = React.useCallback(() => {
flowSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, []);
<button
return (
<div className={cn('relative min-h-svh overflow-hidden transition-colors duration-500', theme.rootBackground)}>
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 motion-safe:animate-[aurora_24s_ease-in-out_infinite]',
theme.aurora
)}
/>
<div aria-hidden className={cn('absolute inset-0 transition-colors duration-500', theme.overlay)} />
<div className="relative z-10 flex min-h-svh flex-col">
<header className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-4 px-6 pb-4 pt-8">
<div className="space-y-1">
<Badge
variant="outline"
className={cn(
'border text-[11px] font-semibold uppercase tracking-[0.4em] transition-colors duration-300',
theme.headerBadge
)}
>
Event Admin · Fotospiel
</Badge>
<p className={cn('text-sm transition-colors duration-300', theme.headerSubtitle)}>
Euer mobiles Kontrollzentrum für Events, Workshops und Feiern
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<LanguageSwitcher />
<Button
type="button"
className="group inline-flex items-center justify-center gap-2 rounded-full border border-transparent bg-white px-6 py-3 text-base font-semibold text-rose-500 shadow-sm transition hover:border-rose-200 hover:text-rose-600"
variant="outline"
className={cn(
'items-center gap-2 border text-xs font-semibold uppercase tracking-wide',
isLightMode
? 'border-slate-200 text-slate-700 hover:bg-slate-100'
: 'border-white/30 text-white hover:bg-white/10'
)}
onClick={toggleMode}
>
{isLightMode ? (
<>
<Moon className="h-4 w-4" />
Dark Mode
</>
) : (
<>
<SunMedium className="h-4 w-4" />
Light Mode
</>
)}
</Button>
<Button
type="button"
variant="outline"
className={cn(
'border text-sm font-semibold transition-colors duration-300',
isLightMode ? 'border-slate-200 text-slate-700 hover:bg-slate-100' : 'border-white/30 text-white hover:bg-white/10'
)}
onClick={handleLoginRedirect}
disabled={isRedirecting}
>
{isRedirecting ? 'Weiterleitung ...' : 'Ich habe bereits Zugang'}
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-0.5" />
</button>
{isRedirecting ? 'Weiterleitung ' : 'Login öffnen'}
</Button>
</div>
</header>
<main className="mx-auto w-full max-w-5xl space-y-12 px-6 pb-16">
<section className="grid gap-6 rounded-3xl border border-white/60 bg-white/80 p-6 shadow-xl shadow-rose-100/40 backdrop-blur-md md:grid-cols-3 md:p-8">
{highlights.map((item) => (
<article key={item.title} className="flex flex-col gap-4 rounded-2xl bg-white/70 p-5 shadow-sm">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-rose-100 text-rose-500">
<item.icon className="h-6 w-6" />
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-12 px-6 pb-16 pt-4">
<section className="grid gap-10 lg:grid-cols-[1.1fr_0.9fr]">
<div className="space-y-8">
<div className="space-y-4">
<p
className={cn(
'text-sm font-semibold uppercase tracking-[0.4em] transition-colors duration-300',
theme.heroEyebrow
)}
>
Willkommen im Event-Kontrollzentrum
</p>
<h1
className={cn(
'font-display text-4xl font-semibold leading-tight transition-colors duration-300 md:text-5xl',
theme.heroTitle
)}
>
Alles für eure Gästegeschichte in einer App
</h1>
<p
className={cn(
'text-base leading-relaxed transition-colors duration-300 md:text-lg',
theme.heroBody
)}
>
Der neue Startscreen begrüßt euch wie eine mobile App, führt euch in den Welcome Flow und lässt euch
jederzeit zurück ins Dashboard springen, sobald ihr bereit seid.
</p>
</div>
<h2 className="text-lg font-semibold text-slate-900">{item.title}</h2>
<p className="text-sm leading-relaxed text-slate-600">{item.description}</p>
</article>
<div className="flex flex-col gap-3 sm:flex-row">
<Button type="button" size="lg" onClick={handleLoginRedirect} disabled={isRedirecting}>
{isRedirecting ? 'Weiterleitung …' : 'Event Admin öffnen'}
<ArrowRight className="h-4 w-4" />
</Button>
<Button
type="button"
size="lg"
variant="ghost"
className={cn('transition-colors duration-300', theme.ghostButton)}
onClick={handleScrollToFlow}
>
Geführten Ablauf ansehen
<Heart className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-3">
{heroStats.map((stat) => (
<FrostedSurface
key={stat.label}
className={cn(
'rounded-3xl border px-5 py-4 shadow-2xl backdrop-blur transition-colors duration-300',
theme.statCard
)}
>
<p className={cn('text-2xl font-semibold', isLightMode ? 'text-slate-900' : 'text-white')}>{stat.value}</p>
<p
className={cn(
'text-xs font-semibold uppercase tracking-wide',
isLightMode ? 'text-slate-500' : 'text-white/70'
)}
>
{stat.label}
</p>
</FrostedSurface>
))}
</div>
</div>
<div className="relative">
<div className="pointer-events-none absolute inset-0 -z-10 translate-y-6 scale-105 rounded-[40px] bg-gradient-to-br from-rose-500/40 via-white/5 to-sky-500/30 blur-3xl" />
<div className="relative rounded-[40px] border border-white/15 bg-white/95 p-6 text-slate-900 shadow-[0_40px_90px_rgba(15,23,42,0.55)]">
<div className="flex items-center justify-between text-xs font-semibold text-slate-500">
<span>Fotospiel</span>
<span>Event Admin</span>
</div>
<div className="mt-4 rounded-3xl bg-slate-900/5 p-4">
<div className="flex items-center justify-between text-xs font-semibold text-slate-500">
<span>Heute</span>
<span>Live</span>
</div>
<div className="mt-4 space-y-3">
{['Mission Pack auswählen', 'Event Branding finalisieren', 'Einladungslink teilen'].map((item) => (
<div key={item} className="flex items-center justify-between rounded-2xl bg-white p-3 text-sm shadow">
<div>
<p className="font-semibold text-slate-900">{item}</p>
<p className="text-xs text-slate-500">Welcome Flow</p>
</div>
<Wand2 className="h-5 w-5 text-rose-500" />
</div>
))}
</div>
</div>
<div className="mt-6 rounded-3xl bg-slate-900 text-white">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-3 text-xs uppercase tracking-[0.3em] text-white/70">
<span>Live Moments</span>
<span>+28</span>
</div>
<div className="space-y-4 px-5 py-6">
{[
{ title: 'Anna lädt 6 Fotos hoch', subtitle: 'Event · Gartenpalast' },
{ title: 'Max reagiert auf Mission „Emotionen“', subtitle: 'Tasks · Emotion Board' },
{ title: 'QR-Link 124x gescannt', subtitle: 'Einladungen · Photobooth' },
].map((entry) => (
<div key={entry.title} className="space-y-1">
<p className="text-sm font-semibold">{entry.title}</p>
<p className="text-xs text-white/70">{entry.subtitle}</p>
</div>
))}
</div>
</div>
</div>
</div>
</section>
<section className="grid gap-6 lg:grid-cols-3">
{featureCards.map((feature) => (
<FrostedSurface
key={feature.title}
className={cn(
'flex flex-col gap-4 rounded-3xl border p-6 shadow-lg backdrop-blur transition-colors duration-300',
theme.featureCard
)}
>
<div
className={cn('flex items-center gap-3 text-sm', isLightMode ? 'text-rose-600' : 'text-white/80')}
>
<feature.icon className={cn('h-5 w-5', isLightMode ? 'text-rose-400' : 'text-rose-200')} />
<span>{feature.badge}</span>
</div>
<h2 className={cn('text-2xl font-semibold', isLightMode ? 'text-slate-900' : 'text-white')}>
{feature.title}
</h2>
<p
className={cn('text-sm leading-relaxed', isLightMode ? 'text-slate-600' : 'text-white/75')}
>
{feature.description}
</p>
</FrostedSurface>
))}
</section>
<section className="grid gap-8 rounded-3xl border border-sky-100 bg-gradient-to-br from-white via-sky-50 to-white p-6 shadow-lg md:grid-cols-2 md:p-10">
<div className="space-y-5">
<div className="inline-flex items-center gap-2 rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold text-sky-700">
<Heart className="h-4 w-4" />
So startet ihr
</div>
<h2 className="text-2xl font-semibold text-slate-900 md:text-3xl">In drei Schritten zu eurer Story</h2>
<ol className="space-y-4 text-sm leading-relaxed text-slate-600">
<li>
<span className="font-semibold text-slate-900">1. Aufgaben entdecken&nbsp;</span>
Stellt euer erstes Aufgabenpaket zusammen, das zu eurer Feier passt.
</li>
<li>
<span className="font-semibold text-slate-900">2. Event anlegen&nbsp;</span>
Benennt euer Event, legt Farben fest und erstellt den QR-Einladungslink.
</li>
<li>
<span className="font-semibold text-slate-900">3. Link teilen&nbsp;</span>
Gäste scannen, laden Fotos hoch und ihr entscheidet, was in euer Album kommt.
</li>
</ol>
</div>
<aside className="space-y-4 rounded-2xl border border-rose-100 bg-white/90 p-6 text-sm leading-relaxed text-slate-600 shadow-md">
<p>
Ihr könnt jederzeit unterbrechen und später weiter machen. Falls ihr Fragen habt, meldet euch unter{' '}
<a className="font-medium text-rose-500 underline" href="mailto:hallo@fotospiel.de">
hallo@fotospiel.de
</a>
.
<section ref={flowSectionRef} className="grid gap-8 lg:grid-cols-[0.95fr_1.05fr]">
<FrostedSurface
className={cn('rounded-3xl border p-6 transition-colors duration-300', theme.timelineCard)}
>
<p
className={cn(
'text-xs font-semibold uppercase tracking-[0.4em]',
isLightMode ? 'text-rose-500' : 'text-rose-200'
)}
>
Guided Journey
</p>
<p>
Nach dem Login geleiten wir euch automatisch zur geführten Einrichtung. Dort entscheidet ihr auch,
wann eure Gästegalerie sichtbar wird.
<h3 className={cn('mt-3 text-3xl font-semibold', isLightMode ? 'text-slate-900' : 'text-white')}>
So leitet euch der Welcome Flow
</h3>
<div className="mt-6 space-y-4">
{timelineSteps.map((step, index) => (
<div
key={step.title}
className={cn(
'flex gap-4 rounded-2xl border p-4 transition-colors duration-300',
isLightMode ? 'border-slate-200 bg-white' : 'border-white/15 bg-white/5'
)}
>
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-2xl text-lg font-semibold',
isLightMode ? 'bg-rose-100 text-rose-600' : 'bg-rose-500/15 text-white'
)}
>
{index + 1}
</div>
<div>
<p className={cn('font-semibold', isLightMode ? 'text-slate-900' : 'text-white')}>
{step.title}
</p>
</aside>
<p className={cn('text-sm', theme.timelineDescription)}>{step.description}</p>
</div>
</div>
))}
</div>
</FrostedSurface>
<div className="space-y-6">
<FrostedSurface className="flex flex-col gap-4 rounded-3xl border-white/15 bg-white/95 p-6 text-slate-900 shadow-2xl">
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-[0.3em] text-slate-500">
<span>Mobile Greeting</span>
<span>Neu</span>
</div>
<p className="text-2xl font-semibold text-slate-900">Fühlt sich an wie eine native App</p>
<p className="text-sm text-slate-600">
Mit großen Touch-Flächen, Animationen und frosted Cards wirkt der Startscreen wie die mobile Version
des Event Admins perfekt für TWA und Capacitor Builds.
</p>
<div className="mt-2 grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl bg-slate-900/5 p-4">
<p className="text-xs text-slate-500">CTA</p>
<p className="text-lg font-semibold text-slate-900">Event Admin öffnen</p>
<p className="text-sm text-slate-600">Leitet direkt zum OAuth Login</p>
</div>
<div className="rounded-2xl bg-slate-900/5 p-4">
<p className="text-xs text-slate-500">Scroll Action</p>
<p className="text-lg font-semibold text-slate-900">Journey ansehen</p>
<p className="text-sm text-slate-600">Smooth Scroll bis zur Timeline</p>
</div>
</div>
</FrostedSurface>
<div className="grid gap-4 sm:grid-cols-3">
{supportHighlights.map((support) => (
<FrostedSurface
key={support.title}
className={cn(
'flex flex-col gap-2 rounded-2xl border p-4 transition-colors duration-300',
theme.supportCard
)}
>
<support.icon className={cn('h-5 w-5', isLightMode ? 'text-rose-500' : 'text-rose-200')} />
<p className={cn('font-semibold', isLightMode ? 'text-slate-900' : 'text-white')}>
{support.title}
</p>
<p className={cn('text-sm', theme.supportDescription)}>{support.description}</p>
</FrostedSurface>
))}
</div>
</div>
</section>
<FrostedSurface
className={cn(
'flex flex-col gap-4 rounded-3xl border px-6 py-5 transition-colors duration-300 md:flex-row md:items-center md:justify-between',
theme.ctaSurface
)}
>
<div>
<p className={cn('text-sm uppercase tracking-[0.4em]', theme.ctaDetail)}>Bereit?</p>
<h3 className={cn('text-2xl font-semibold', isLightMode ? 'text-slate-900' : 'text-white')}>
Wechselt ins Dashboard, sobald euer Flow steht.
</h3>
<p className={cn('text-sm', theme.ctaDetail)}>
Alle Schritte lassen sich später direkt aus dem Event Admin wieder öffnen.
</p>
</div>
<Button type="button" size="lg" className="self-start md:self-auto" onClick={handleLoginRedirect} disabled={isRedirecting}>
{isRedirecting ? 'Weiterleitung …' : 'Zum Login'}
<ArrowRight className="h-4 w-4" />
</Button>
</FrostedSurface>
</main>
<footer className="border-t border-white/60 bg-white/70 py-6 text-center text-xs text-slate-500">
Fotospiel Eure Gäste gestalten eure Lieblingsmomente
<footer className={cn('py-6 text-center text-xs transition-colors duration-300', theme.footer)}>
Fotospiel · Eure Gäste gestalten eure Lieblingsmomente
</footer>
</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { describe, expect, it, afterEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import WelcomeTeaserPage from '../WelcomeTeaserPage';
const navigateMock = vi.fn();
vi.mock('../../components/LanguageSwitcher', () => ({
LanguageSwitcher: () => <div data-testid="language-switcher" />,
}));
vi.mock('../../lib/navigation', () => ({
navigateToHref: (href: string) => navigateMock(href),
}));
describe('WelcomeTeaserPage', () => {
afterEach(() => {
document.body.classList.remove('tenant-admin-theme', 'tenant-admin-welcome-theme');
vi.clearAllMocks();
navigateMock.mockReset();
});
it('applies the tenant admin theme classes while mounted', () => {
const { unmount } = render(<WelcomeTeaserPage />);
expect(document.body.classList.contains('tenant-admin-theme')).toBe(true);
expect(document.body.classList.contains('tenant-admin-welcome-theme')).toBe(true);
unmount();
expect(document.body.classList.contains('tenant-admin-theme')).toBe(false);
expect(document.body.classList.contains('tenant-admin-welcome-theme')).toBe(false);
});
it('shows the hero stats and triggers the login redirect CTA', async () => {
render(<WelcomeTeaserPage />);
expect(screen.getByText('Events begleitet')).toBeInTheDocument();
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /event admin öffnen/i }));
expect(navigateMock).toHaveBeenCalledWith(expect.stringContaining('/event-admin/login'));
});
it('allows switching between light and dark presentation modes', async () => {
render(<WelcomeTeaserPage />);
const user = userEvent.setup();
const toggle = screen.getByRole('button', { name: /light mode/i });
await user.click(toggle);
expect(toggle).toHaveTextContent(/dark mode/i);
});
});

View File

@@ -50,6 +50,7 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale }
const [errors, setErrors] = useState<FieldErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
const [shouldFocusError, setShouldFocusError] = useState(false);
useEffect(() => {
if (!hasTriedSubmit) {
@@ -63,7 +64,7 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale }
}, [errors, hasTriedSubmit]);
useEffect(() => {
if (!hasTriedSubmit) {
if (!hasTriedSubmit || !shouldFocusError) {
return;
}
@@ -75,11 +76,13 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale }
const field = document.querySelector<HTMLInputElement>(`[name="${firstKey}"]`);
field?.scrollIntoView({ behavior: "smooth", block: "center" });
field?.focus();
}, [errors, hasTriedSubmit]);
setShouldFocusError(false);
}, [errors, hasTriedSubmit, shouldFocusError]);
const updateValue = (key: keyof typeof values, value: string | boolean) => {
setValues((current) => ({ ...current, [key]: value }));
setErrors((current) => ({ ...current, [key as string]: "" }));
setShouldFocusError(false);
};
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
@@ -129,11 +132,13 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale }
});
setErrors(fieldErrors);
setShouldFocusError(true);
toast.error(t("login.failed_generic", "Ungueltige Anmeldedaten"));
return;
}
toast.error(t("login.unexpected_error", "Beim Login ist ein Fehler aufgetreten."));
setShouldFocusError(false);
} catch (error) {
console.error("Login request failed", error);
toast.error(t("login.unexpected_error", "Beim Login ist ein Fehler aufgetreten."));
@@ -205,4 +210,3 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale }
</form>
);
}

View File

@@ -390,7 +390,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
<div className="md:col-span-1">
<label htmlFor="password_confirmation" className="block text-sm font-medium text-gray-700 mb-1">
{t('register.confirm_password')} {t('common:required')}
{t('register.password_confirmation')} {t('common:required')}
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
@@ -410,7 +410,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
}
}}
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.password_confirmation ? 'border-red-500' : 'border-gray-300'}`}
placeholder={t('register.confirm_password_placeholder')}
placeholder={t('register.password_confirmation_placeholder')}
/>
</div>
{errors.password_confirmation && <p className="text-sm text-red-600 mt-1">{errors.password_confirmation}</p>}
@@ -442,7 +442,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
onClick={() => setPrivacyOpen(true)}
className="text-[#FFB6C1] hover:underline inline bg-transparent border-none cursor-pointer p-0 font-medium"
>
{t('register.privacy_policy')}
{t('register.privacy_policy_link')}
</button>.
</label>
{errors.privacy_consent && <p className="mt-2 text-sm text-red-600">{errors.privacy_consent}</p>}
@@ -485,5 +485,3 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
</div>
);
}

View File

@@ -265,7 +265,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
<div className="md:col-span-1">
<label htmlFor="password_confirmation" className="block text-sm font-medium text-gray-700 mb-1">
{t('register.confirm_password')} {t('common:required')}
{t('register.password_confirmation')} {t('common:required')}
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
@@ -285,7 +285,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
}
}}
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.password_confirmation ? 'border-red-500' : 'border-gray-300'}`}
placeholder={t('register.confirm_password_placeholder')}
placeholder={t('register.password_confirmation_placeholder')}
/>
</div>
{errors.password_confirmation && <p key={`error-password_confirmation`} className="text-sm text-red-600 mt-1">{errors.password_confirmation}</p>}
@@ -313,7 +313,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
onClick={() => setPrivacyOpen(true)}
className="text-[#FFB6C1] hover:underline inline bg-transparent border-none cursor-pointer p-0 font-medium"
>
{t('register.privacy_policy')}
{t('register.privacy_policy_link')}
</button>.
</label>
{errors.privacy_consent && <p className="mt-2 text-sm text-red-600">{errors.privacy_consent}</p>}

View File

@@ -3,8 +3,6 @@ import { Head, usePage } from "@inertiajs/react";
import MarketingLayout from "@/layouts/mainWebsite";
import type { CheckoutPackage, GoogleProfilePrefill } from "./checkout/types";
import { CheckoutWizard } from "./checkout/CheckoutWizard";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
interface CheckoutWizardPageProps {
package: CheckoutPackage;
@@ -50,19 +48,6 @@ const CheckoutWizardPage: React.FC<CheckoutWizardPageProps> = ({
<Head title="Checkout Wizard" />
<div className="min-h-screen bg-muted/20 py-12">
<div className="mx-auto w-full max-w-4xl px-4">
{/* Abbruch-Button oben rechts */}
<div className="flex justify-end mb-4">
<Button
variant="ghost"
size="sm"
onClick={() => window.location.href = '/packages'}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4 mr-2" />
Abbrechen
</Button>
</div>
<CheckoutWizard
initialPackage={initialPackage}
packageOptions={dedupedOptions}

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import { PackageStep } from "./steps/PackageStep";
import { AuthStep } from "./steps/AuthStep";
import { ConfirmationStep } from "./steps/ConfirmationStep";
import { useAnalytics } from '@/hooks/useAnalytics';
import { cn } from "@/lib/utils";
const PaymentStep = lazy(() => import('./steps/PaymentStep').then((module) => ({ default: module.PaymentStep })));
@@ -69,6 +70,7 @@ const WizardBody: React.FC<{
googleProfile?: GoogleProfilePrefill | null;
onClearGoogleProfile?: () => void;
}> = ({ privacyHtml, googleProfile, onClearGoogleProfile }) => {
const primaryCtaClassName = "min-w-[160px] disabled:bg-muted disabled:text-muted-foreground disabled:hover:bg-muted disabled:hover:text-muted-foreground";
const { t } = useTranslation('marketing');
const {
currentStep,
@@ -160,13 +162,8 @@ const WizardBody: React.FC<{
return true;
}, [atLastStep, authUser, currentStep, isAuthenticated, isFreeSelected, paymentCompleted, selectedPackage]);
const shouldShowNextButton = useMemo(() => {
if (currentStep !== 'payment') {
return true;
}
return isFreeSelected || paymentCompleted;
}, [currentStep, isFreeSelected, paymentCompleted]);
const shouldShowNextButton = useMemo(() => currentStep !== 'confirmation', [currentStep]);
const highlightNextCta = currentStep === 'payment' && paymentCompleted;
const handleNext = useCallback(() => {
if (!canProceedToNextStep) {
@@ -200,8 +197,42 @@ const WizardBody: React.FC<{
window.location.href = '/event-admin';
}, []);
const primaryCta = useMemo(() => {
if (currentStep === 'confirmation') {
return {
label: t('checkout.confirmation_step.to_admin'),
onClick: handleGoToAdmin,
disabled: false,
};
}
if (!shouldShowNextButton) {
return null;
}
return {
label: t('checkout.next'),
onClick: handleNext,
disabled: !canProceedToNextStep,
};
}, [currentStep, handleGoToAdmin, handleNext, shouldShowNextButton, t, canProceedToNextStep]);
const ctaClassName = cn(primaryCtaClassName, highlightNextCta && 'animate-pulse ring-2 ring-primary/50 ring-offset-2 ring-offset-background');
return (
<div className="space-y-8">
{primaryCta && (
<div className="flex justify-end">
<Button
size="lg"
className={ctaClassName}
onClick={primaryCta.onClick}
disabled={primaryCta.disabled}
>
{primaryCta.label}
</Button>
</div>
)}
<div ref={progressRef} className="space-y-4">
<Progress value={progress} />
<Steps steps={stepConfig} currentStep={currentIndex >= 0 ? currentIndex : 0} />
@@ -226,13 +257,18 @@ const WizardBody: React.FC<{
)}
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Button variant="ghost" onClick={handlePrevious} disabled={currentIndex <= 0}>
{t('checkout.back')}
</Button>
{shouldShowNextButton ? (
<Button onClick={handleNext} disabled={!canProceedToNextStep}>
{t('checkout.next')}
{primaryCta ? (
<Button
size="lg"
className={ctaClassName}
onClick={primaryCta.onClick}
disabled={primaryCta.disabled}
>
{primaryCta.label}
</Button>
) : (
<div className="h-10 min-w-[128px]" aria-hidden="true" />

View File

@@ -64,8 +64,8 @@ describe('CheckoutWizard auth step navigation guard', () => {
/>,
);
const nextButton = screen.getByRole('button', { name: 'checkout.next' });
expect(nextButton).toBeDisabled();
const nextButtons = screen.getAllByRole('button', { name: 'checkout.next' });
nextButtons.forEach((button) => expect(button).toBeDisabled());
});
it('enables the next button once the user is authenticated on the auth step', () => {
@@ -79,8 +79,8 @@ describe('CheckoutWizard auth step navigation guard', () => {
/>,
);
const nextButton = screen.getByRole('button', { name: 'checkout.next' });
expect(nextButton).not.toBeDisabled();
const nextButtons = screen.getAllByRole('button', { name: 'checkout.next' });
nextButtons.forEach((button) => expect(button).not.toBeDisabled());
});
it('only renders the next button on the payment step after the payment is completed', async () => {
@@ -98,10 +98,12 @@ describe('CheckoutWizard auth step navigation guard', () => {
await screen.findByTestId('payment-step');
expect(screen.queryByRole('button', { name: 'checkout.next' })).toBeNull();
const nextButtons = screen.getAllByRole('button', { name: 'checkout.next' });
nextButtons.forEach((button) => expect(button).toBeDisabled());
fireEvent.click(screen.getByRole('button', { name: 'mark-complete' }));
expect(await screen.findByRole('button', { name: 'checkout.next' })).toBeEnabled();
const activatedButtons = await screen.findAllByRole('button', { name: 'checkout.next' });
activatedButtons.forEach((button) => expect(button).toBeEnabled());
});
});

View File

@@ -8,7 +8,9 @@ import LoginForm, { AuthUserPayload } from "../../../auth/LoginForm";
import RegisterForm, { RegisterSuccessPayload } from "../../../auth/RegisterForm";
import { Trans, useTranslation } from 'react-i18next';
import toast from 'react-hot-toast';
import { LoaderCircle } from "lucide-react";
import { ChevronDown, LoaderCircle } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
interface AuthStepProps {
privacyHtml: string;
@@ -43,6 +45,7 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml, googleProfile,
const { isAuthenticated, authUser, setAuthUser, nextStep, selectedPackage } = useCheckoutWizard();
const [mode, setMode] = useState<'login' | 'register'>('register');
const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false);
const [showGoogleHelper, setShowGoogleHelper] = useState(false);
useEffect(() => {
if (googleAuth?.status === 'signin') {
@@ -131,6 +134,7 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml, googleProfile,
return (
<div className="space-y-6">
<Collapsible open={showGoogleHelper} onOpenChange={setShowGoogleHelper} className="w-full space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Button
variant={mode === 'register' ? 'default' : 'outline'}
@@ -144,6 +148,7 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml, googleProfile,
>
{t('checkout.auth_step.switch_to_login')}
</Button>
<div className="flex flex-1 justify-start gap-2 sm:flex-none">
<Button
variant="outline"
onClick={handleGoogleLogin}
@@ -157,7 +162,26 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml, googleProfile,
)}
{t('checkout.auth_step.continue_with_google')}
</Button>
<CollapsibleTrigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center gap-1 rounded-full border border-muted-foreground/30 px-3 py-1 text-xs font-medium text-muted-foreground transition hover:border-muted-foreground/60 hover:text-foreground",
showGoogleHelper && "bg-muted/60 text-foreground"
)}
>
{t('checkout.auth_step.google_helper_badge')}
<ChevronDown className={cn("h-3 w-3 transition-transform", showGoogleHelper && "rotate-180")} />
</button>
</CollapsibleTrigger>
</div>
</div>
<CollapsibleContent>
<Alert className="border-dashed border-muted/60 bg-muted/20 text-xs sm:text-sm">
<AlertDescription>{t('checkout.auth_step.google_helper')}</AlertDescription>
</Alert>
</CollapsibleContent>
</Collapsible>
{googleAuth?.error && (
<Alert variant="destructive">

View File

@@ -1,8 +1,10 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useCheckoutWizard } from "../WizardContext";
import { Trans, useTranslation } from 'react-i18next';
import { Badge } from "@/components/ui/badge";
import { CalendarDays, QrCode, ClipboardList, Smartphone, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
interface ConfirmationStepProps {
onViewProfile?: () => void;
@@ -30,29 +32,99 @@ export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfil
const packageName = selectedPackage?.name ?? '';
const onboardingItems = [
{
key: 'event',
icon: CalendarDays,
},
{
key: 'invites',
icon: QrCode,
},
{
key: 'tasks',
icon: ClipboardList,
},
] as const;
return (
<div className="space-y-6">
<Alert className="border-primary/40 bg-primary/5">
<AlertTitle className="text-xl font-semibold">
{t('checkout.confirmation_step.welcome')}
</AlertTitle>
<AlertDescription className="space-y-2 text-base leading-relaxed">
<div className="overflow-hidden rounded-2xl border bg-gradient-to-br from-primary via-primary/70 to-primary/60 p-6 text-primary-foreground shadow-lg">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-3">
<Badge variant="secondary" className="bg-white/15 text-white shadow-sm ring-1 ring-white/30 backdrop-blur">
<Sparkles className="mr-1 h-3.5 w-3.5" />
{t('checkout.confirmation_step.hero_badge')}
</Badge>
<div className="space-y-2">
<h3 className="text-2xl font-semibold">{t('checkout.confirmation_step.hero_title')}</h3>
<p className="text-sm text-white/80">
<Trans
t={t}
i18nKey="checkout.confirmation_step.package_summary"
components={{ strong: <span className="font-semibold" /> }}
values={{ name: packageName }}
/>
<p className="text-sm text-muted-foreground">
{t('checkout.confirmation_step.email_followup')}
</p>
</AlertDescription>
</Alert>
<p className="text-sm text-white/80">{t('checkout.confirmation_step.hero_body')}</p>
</div>
</div>
<div className="rounded-xl border border-white/30 bg-white/10 px-5 py-4 text-sm text-white/90 shadow-inner backdrop-blur lg:max-w-sm">
<p>{t('checkout.confirmation_step.hero_next')}</p>
</div>
</div>
</div>
<div className="rounded-xl border bg-card/60 p-6 shadow-sm">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('checkout.confirmation_step.onboarding_title')}
</p>
<p className="text-sm text-muted-foreground">{t('checkout.confirmation_step.onboarding_subtitle')}</p>
</div>
<Badge variant="outline" className="text-xs font-medium">
{t('checkout.confirmation_step.onboarding_badge')}
</Badge>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-3">
{onboardingItems.map(({ key, icon: Icon }) => (
<div key={key} className="rounded-lg border bg-background/60 p-4 shadow-inner">
<div className={cn("mb-3 inline-flex rounded-full bg-primary/10 p-2 text-primary")}>
<Icon className="h-4 w-4" />
</div>
<p className="text-sm font-semibold">
{t(`checkout.confirmation_step.onboarding_items.${key}.title`)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t(`checkout.confirmation_step.onboarding_items.${key}.body`)}
</p>
</div>
))}
</div>
</div>
<div className="rounded-xl border bg-muted/30 p-6 shadow-inner">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold text-foreground">
{t('checkout.confirmation_step.control_center_title')}
</p>
<p className="text-xs text-muted-foreground">
{t('checkout.confirmation_step.control_center_body')}
</p>
</div>
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground">
<Smartphone className="h-4 w-4" />
{t('checkout.confirmation_step.control_center_hint')}
</div>
</div>
</div>
<div className="flex flex-wrap gap-3 justify-end">
<Button variant="outline" onClick={handleProfile}>
{t('checkout.confirmation_step.open_profile')}
</Button>
<Button onClick={handleAdmin}>{t('checkout.confirmation_step.to_admin')}</Button>
</div>
</div>
);

View File

@@ -1,8 +1,7 @@
import React, { useMemo, useState } from "react";
import React, { useMemo } from "react";
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { Check, Package as PackageIcon, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Check, Package as PackageIcon } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useCheckoutWizard } from "../WizardContext";
@@ -107,8 +106,7 @@ function PackageOption({ pkg, isActive, onSelect, t }: { pkg: CheckoutPackage; i
export const PackageStep: React.FC = () => {
const { t } = useTranslation('marketing');
const { selectedPackage, packageOptions, setSelectedPackage, resetPaymentState, nextStep } = useCheckoutWizard();
const [isLoading, setIsLoading] = useState(false);
const { selectedPackage, packageOptions, setSelectedPackage, resetPaymentState } = useCheckoutWizard();
// Early return if no package is selected
@@ -141,31 +139,10 @@ export const PackageStep: React.FC = () => {
resetPaymentState();
};
const handleNextStep = async () => {
setIsLoading(true);
// Kleine Verzögerung für bessere UX
setTimeout(() => {
nextStep();
setIsLoading(false);
}, 300);
};
return (
<div className="grid gap-8 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6">
<PackageSummary pkg={selectedPackage} t={t} />
<div className="flex justify-end">
<Button size="lg" onClick={handleNextStep} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('checkout.package_step.loading')}
</>
) : (
t('checkout.package_step.next_to_account')
)}
</Button>
</div>
</div>
<aside className="space-y-4">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">

View File

@@ -2,12 +2,14 @@ import React, { FormEvent, useCallback, useEffect, useMemo, useRef, useState } f
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { LoaderCircle, CheckCircle2, XCircle } from 'lucide-react';
import { LoaderCircle, CheckCircle2, XCircle, ShieldCheck, Receipt, Headphones } from 'lucide-react';
import { useCheckoutWizard } from '../WizardContext';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { previewCoupon as requestCouponPreview } from '@/lib/coupons';
import type { CouponPreviewResponse } from '@/types/coupon';
import { cn } from '@/lib/utils';
import toast from 'react-hot-toast';
type PaymentStatus = 'idle' | 'processing' | 'ready' | 'error';
@@ -27,6 +29,7 @@ declare global {
const PADDLE_SCRIPT_URL = 'https://cdn.paddle.com/paddle/v2/paddle.js';
const PADDLE_SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es', 'it', 'nl', 'pt', 'sv', 'da', 'fi', 'no'];
const PRIMARY_CTA_STYLES = 'min-w-[200px] disabled:bg-muted disabled:text-muted-foreground disabled:hover:bg-muted disabled:hover:text-muted-foreground';
export function resolvePaddleLocale(rawLocale?: string | null): string {
if (!rawLocale) {
@@ -94,11 +97,16 @@ async function loadPaddle(environment: PaddleEnvironment): Promise<typeof window
return configurePaddle(paddle, environment);
}
const PaddleCta: React.FC<{ onCheckout: () => Promise<void>; disabled: boolean; isProcessing: boolean }> = ({ onCheckout, disabled, isProcessing }) => {
const PaddleCta: React.FC<{ onCheckout: () => Promise<void>; disabled: boolean; isProcessing: boolean; className?: string }> = ({ onCheckout, disabled, isProcessing, className }) => {
const { t } = useTranslation('marketing');
return (
<Button size="lg" className="w-full sm:w-auto" disabled={disabled} onClick={onCheckout}>
<Button
size="lg"
className={cn('w-full sm:w-auto', className)}
disabled={disabled}
onClick={onCheckout}
>
{isProcessing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
{t('checkout.payment_step.pay_with_paddle')}
</Button>
@@ -130,6 +138,7 @@ export const PaymentStep: React.FC = () => {
const [couponNotice, setCouponNotice] = useState<string | null>(null);
const [couponLoading, setCouponLoading] = useState(false);
const paddleRef = useRef<typeof window.Paddle | null>(null);
const checkoutContainerRef = useRef<HTMLDivElement | null>(null);
const eventCallbackRef = useRef<(event: any) => void>();
const hasAutoAppliedCoupon = useRef(false);
const checkoutContainerClass = 'paddle-checkout-container';
@@ -297,6 +306,17 @@ export const PaymentStep: React.FC = () => {
setInlineActive(true);
setStatus('ready');
setMessage(t('checkout.payment_step.paddle_overlay_ready'));
if (typeof window !== 'undefined' && checkoutContainerRef.current) {
window.requestAnimationFrame(() => {
const rect = checkoutContainerRef.current?.getBoundingClientRect();
if (!rect) {
return;
}
const offset = 120;
const target = Math.max(window.scrollY + rect.top - offset, 0);
window.scrollTo({ top: target, behavior: 'smooth' });
});
}
return;
}
@@ -380,6 +400,7 @@ export const PaymentStep: React.FC = () => {
setMessage(t('checkout.payment_step.paddle_overlay_ready'));
setInlineActive(false);
setPaymentCompleted(true);
toast.success(t('checkout.payment_step.toast_success'));
}
if (event.name === 'checkout.closed') {
@@ -494,10 +515,60 @@ export const PaymentStep: React.FC = () => {
);
}
const TrustPill = ({ icon: Icon, label }: { icon: React.ElementType; label: string }) => (
<div className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-white backdrop-blur-sm">
<Icon className="h-4 w-4 text-white/80" />
<span>{label}</span>
</div>
);
const PaddleLogo = () => (
<div className="flex items-center gap-3 rounded-full bg-white/15 px-4 py-2 text-white shadow-inner backdrop-blur-sm">
<img
src="/paddle.logo.svg"
alt="Paddle"
className="h-6 w-auto brightness-0 invert"
loading="lazy"
decoding="async"
/>
<span className="text-xs font-semibold">{t('checkout.payment_step.paddle_partner')}</span>
</div>
);
return (
<div className="space-y-4">
<div className="rounded-lg border bg-card p-6 shadow-sm">
<div className="rounded-2xl border bg-card p-6 shadow-sm">
<div className="space-y-6">
{!inlineActive && (
<div className="overflow-hidden rounded-2xl border bg-gradient-to-br from-[#001835] via-[#002b55] to-[#00407c] p-6 text-white shadow-md">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-4">
<PaddleLogo />
<div className="space-y-2">
<h3 className="text-2xl font-semibold">{t('checkout.payment_step.guided_title')}</h3>
<p className="text-sm text-white/80">{t('checkout.payment_step.guided_body')}</p>
</div>
<div className="flex flex-wrap gap-2">
<TrustPill icon={ShieldCheck} label={t('checkout.payment_step.trust_secure')} />
<TrustPill icon={Receipt} label={t('checkout.payment_step.trust_tax')} />
<TrustPill icon={Headphones} label={t('checkout.payment_step.trust_support')} />
</div>
</div>
<div className="flex flex-col items-stretch gap-3 w-full max-w-sm">
<PaddleCta
onCheckout={startPaddleCheckout}
disabled={status === 'processing'}
isProcessing={status === 'processing'}
className={cn('bg-white text-[#001835] hover:bg-white/90', PRIMARY_CTA_STYLES)}
/>
<p className="text-xs text-white/70 text-center">
{t('checkout.payment_step.guided_cta_hint')}
</p>
</div>
</div>
</div>
)}
<div className="space-y-3">
<form className="flex flex-col gap-2 sm:flex-row" onSubmit={handleCouponSubmit}>
<Input
@@ -564,6 +635,7 @@ export const PaymentStep: React.FC = () => {
onCheckout={startPaddleCheckout}
disabled={status === 'processing'}
isProcessing={status === 'processing'}
className={PRIMARY_CTA_STYLES}
/>
</div>
)}
@@ -586,7 +658,7 @@ export const PaymentStep: React.FC = () => {
</Alert>
)}
<div className={`${checkoutContainerClass} min-h-[360px]`} />
<div ref={checkoutContainerRef} className={`${checkoutContainerClass} min-h-[360px]`} />
<p className={`text-xs text-muted-foreground ${inlineActive ? 'text-center' : 'sm:text-right'}`}>
{t('checkout.payment_step.paddle_disclaimer')}

View File

@@ -23,6 +23,8 @@
"description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.",
"brand": "Die Fotospiel App",
"logo_alt": "Logo Die Fotospiel App",
"identifier": "E-Mail oder Username",
"identifier_placeholder": "z. B. name@beispiel.de oder hochzeit_julia",
"username_or_email": "Username oder E-Mail",
"email": "E-Mail-Adresse",
"email_placeholder": "ihre@email.de",
@@ -49,7 +51,16 @@
"address": "Adresse",
"phone": "Telefonnummer",
"privacy_consent": "Ich stimme der Datenschutzerklärung zu und akzeptiere die Verarbeitung meiner persönlichen Daten.",
"submit": "Registrieren"
"privacy_policy_link": "Datenschutzerklärung",
"submit": "Registrieren",
"first_name_placeholder": "Vorname",
"last_name_placeholder": "Nachname",
"email_placeholder": "beispiel@email.de",
"address_placeholder": "Straße Hausnummer, PLZ Ort",
"phone_placeholder": "+49 170 1234567",
"username_placeholder": "z. B. hochzeit_julia",
"password_placeholder": "Mindestens 8 Zeichen",
"password_confirmation_placeholder": "Passwort erneut eingeben"
},
"verification": {
"notice": "Bitte bestätigen Sie Ihre E-Mail-Adresse.",

View File

@@ -0,0 +1,168 @@
<?php
return [
'navigation' => [
'group_label' => 'Kundenbereich',
],
'hero' => [
'badge' => 'Kundenbereich',
'title' => 'Willkommen zurück, :name!',
'subtitle' => 'Dein Überblick über Pakete, Rechnungen und Fortschritt für alle Details öffne die Admin-App.',
'description' => 'Nutze das Dashboard für Überblick, Abrechnung und Insights die Admin-App begleitet dich bei allen operativen Aufgaben vor Ort.',
],
'language_switcher' => [
'label' => 'Sprache',
'change' => 'Sprache wechseln',
],
'email_verification' => [
'title' => 'Bitte bestätige deine E-Mail-Adresse',
'description' => 'Du kannst alle Funktionen erst vollständig nutzen, sobald deine E-Mail-Adresse bestätigt ist. Prüfe dein Postfach oder fordere einen neuen Link an.',
'cta' => 'Link erneut senden',
'cta_pending' => 'Sende...',
'success' => 'Wir haben dir gerade einen neuen Bestätigungslink geschickt.',
],
'stats' => [
'active_events' => [
'label' => 'Aktive Events',
'description_positive' => 'Events sind live und für Gäste sichtbar.',
'description_zero' => 'Noch kein Event veröffentlicht starte heute!',
],
'upcoming_events' => [
'label' => 'Bevorstehende Events',
'description_positive' => 'Planung läuft behalte Checklisten und Aufgaben im Blick.',
'description_zero' => 'Lass dich vom Assistenten beim Planen unterstützen.',
],
'new_photos' => [
'label' => 'Neue Fotos (7 Tage)',
'description_positive' => 'Frisch eingetroffene Erinnerungen deiner Gäste.',
'description_zero' => 'Sammle erste Uploads über QR-Code oder Direktlink.',
],
'task_progress' => [
'label' => 'Event-Checkliste',
'description_positive' => 'Starker Fortschritt! Halte deine Aufgabenliste aktuell.',
'description_zero' => 'Nutze Aufgaben und Vorlagen für einen strukturierten Ablauf.',
'note' => ':value% deiner Event-Checkliste erledigt.',
],
],
'spotlight' => [
'title' => 'Admin-App als Schaltzentrale',
'description' => 'Events planen, Uploads freigeben, Gäste begleiten mobil und in Echtzeit.',
'cta' => 'Admin-App öffnen',
'items' => [
'live' => [
'title' => 'Live auf dem Event',
'description' => 'Moderation der Uploads, Freigaben & Push-Updates jederzeit griffbereit.',
],
'mobile' => [
'title' => 'Optimiert für mobile Einsätze',
'description' => 'PWA heute, native Apps morgen ein Zugang für das ganze Team.',
],
'overview' => [
'title' => 'Dashboard als Überblick',
'description' => 'Pakete, Rechnungen und Fortschritt siehst du weiterhin hier im Webportal.',
],
],
],
'onboarding' => [
'card' => [
'title' => 'Dein Start in fünf Schritten',
'description' => 'Bearbeite die Schritte in der Admin-App das Dashboard zeigt dir den Status.',
'completed' => 'Alle Schritte abgeschlossen großartig! Du kannst jederzeit zur Admin-App wechseln.',
'cta_fallback' => 'Jetzt starten',
],
'admin_app' => [
'title' => 'Admin-App öffnen',
'description' => 'Verwalte Events, Uploads und Gäste direkt in der Admin-App. Die mobile Oberfläche ist für Live-Einsätze optimiert.',
'cta' => 'Admin-App starten',
],
'event_setup' => [
'title' => 'Erstes Event vorbereiten',
'description' => 'Lege in der Admin-App Name, Datum und Aufgaben fest. So wissen Gäste, welche Fotos ihr euch wünscht.',
'cta' => 'Event anlegen',
],
'invite_guests' => [
'title' => 'Gäste einladen',
'description' => 'Teile QR-Codes oder Links, damit Gäste direkt mit dem Hochladen beginnen können.',
'cta' => 'QR-Links öffnen',
],
'collect_photos' => [
'title' => 'Erste Fotos einsammeln',
'description' => 'Sobald die ersten Uploads eintrudeln, erscheint alles in eurer Galerie. Moderation und Freigaben laufen in der Admin-App.',
'cta' => 'Uploads prüfen',
],
'branding' => [
'title' => 'Branding & Aufgaben verfeinern',
'description' => 'Passt Farbwelt und Aufgabenpakete an euren Anlass an so fühlt sich alles wie aus einem Guss an.',
'cta' => 'Branding öffnen',
],
],
'events' => [
'card' => [
'title' => 'Bevorstehende Events',
'description' => 'Status, Uploads und Aufgaben deiner nächsten Events im Überblick.',
'badge' => [
'plural' => ':count geplant',
'empty' => 'Noch kein Event geplant',
],
'empty' => 'Plane dein erstes Event und begleite den gesamten Ablauf vom Briefing bis zur Nachbereitung direkt in der Admin-App.',
],
'status' => [
'live' => 'Live',
'upcoming' => 'In Vorbereitung',
],
'badges' => [
'photos' => 'Fotos',
'tasks' => 'Aufgaben',
'links' => 'Links',
],
],
'cards_section' => [
'package' => [
'title' => 'Aktuelles Paket',
'description' => 'Behalte Laufzeiten und verfügbaren Umfang stets im Blick.',
'remaining' => ':count Events',
'expires_at' => 'Läuft ab',
'price' => 'Preis',
'latest' => 'Zuletzt gebucht am :date via :provider.',
'empty' => 'Noch kein aktives Paket.',
'empty_cta' => 'Jetzt Paket auswählen',
'empty_suffix' => 'und anschließend in der Admin-App Events anlegen.',
],
'purchases' => [
'title' => 'Aktuelle Buchungen',
'description' => 'Verfolge deine gebuchten Pakete und Erweiterungen.',
'badge' => ':count Einträge',
'empty' => 'Noch keine Buchungen sichtbar. Starte jetzt und sichere dir dein erstes Kontingent.',
'table' => [
'package' => 'Paket',
'type' => 'Typ',
'provider' => 'Anbieter',
'date' => 'Datum',
'price' => 'Preis',
],
],
'quick_actions' => [
'title' => 'Schnellzugriff',
'description' => 'Alles Wichtige für den nächsten Schritt nur einen Klick entfernt.',
'cta' => 'Weiter',
'items' => [
'tenant_admin' => [
'label' => 'Event-Admin öffnen',
'description' => 'Detaillierte Eventverwaltung, Moderation und Live-Features.',
],
'profile' => [
'label' => 'Profil verwalten',
'description' => 'Kontaktinformationen, Sprache und E-Mail-Adresse anpassen.',
],
'password' => [
'label' => 'Passwort aktualisieren',
'description' => 'Sichere dein Konto mit einem aktuellen Passwort.',
],
'packages' => [
'label' => 'Pakete entdecken',
'description' => 'Mehr Events oder Speicher buchen du bleibst flexibel.',
],
],
],
],
];

View File

@@ -23,6 +23,8 @@
"description": "Sign in with your Fotospiel account to manage every event in one place.",
"brand": "Die Fotospiel App",
"logo_alt": "Fotospiel App logo",
"identifier": "Email or Username",
"identifier_placeholder": "you@example.com or username",
"username_or_email": "Username or Email",
"email": "Email Address",
"email_placeholder": "your@email.com",
@@ -49,7 +51,16 @@
"address": "Address",
"phone": "Phone Number",
"privacy_consent": "I agree to the privacy policy and accept the processing of my personal data.",
"submit": "Register"
"privacy_policy_link": "Privacy Policy",
"submit": "Register",
"first_name_placeholder": "First name",
"last_name_placeholder": "Last name",
"email_placeholder": "you@example.com",
"address_placeholder": "Street, ZIP, City",
"phone_placeholder": "+1 555 123 4567",
"username_placeholder": "e.g. wedding_julia",
"password_placeholder": "At least 8 characters",
"password_confirmation_placeholder": "Repeat your password"
},
"verification": {
"notice": "Please verify your email address.",

View File

@@ -0,0 +1,168 @@
<?php
return [
'navigation' => [
'group_label' => 'Customer Hub',
],
'hero' => [
'badge' => 'Customer Hub',
'title' => 'Welcome back, :name!',
'subtitle' => 'Your overview of packages, invoices, and progress — open the Admin App for every detail.',
'description' => 'Use the dashboard for oversight, billing, and insights — the Admin App supports every operational task on site.',
],
'language_switcher' => [
'label' => 'Language',
'change' => 'Change language',
],
'email_verification' => [
'title' => 'Please verify your email address',
'description' => 'You can use all features once your email address is confirmed. Check your inbox or request a new link.',
'cta' => 'Resend link',
'cta_pending' => 'Sending…',
'success' => 'We just sent another verification link to your inbox.',
],
'stats' => [
'active_events' => [
'label' => 'Active events',
'description_positive' => 'Events are live and visible to guests.',
'description_zero' => 'No event published yet — start today!',
],
'upcoming_events' => [
'label' => 'Upcoming events',
'description_positive' => 'Planning is underway — keep an eye on checklists and tasks.',
'description_zero' => 'Let the assistant help you plan the first event.',
],
'new_photos' => [
'label' => 'New photos (7 days)',
'description_positive' => 'Fresh memories submitted by your guests.',
'description_zero' => 'Collect first uploads via QR code or direct link.',
],
'task_progress' => [
'label' => 'Event checklist',
'description_positive' => 'Great progress! Keep your task list up to date.',
'description_zero' => 'Use tasks and templates for a structured flow.',
'note' => ':value% of your event checklist finished.',
],
],
'spotlight' => [
'title' => 'Admin App as command center',
'description' => 'Plan events, approve uploads, and guide guests — mobile and in real time.',
'cta' => 'Open Admin App',
'items' => [
'live' => [
'title' => 'Live at the event',
'description' => 'Moderate uploads, approve content, and send push updates at any time.',
],
'mobile' => [
'title' => 'Optimised for mobile use',
'description' => 'PWA today, native apps tomorrow — one access for the whole team.',
],
'overview' => [
'title' => 'Dashboard for overview',
'description' => 'Keep packages, invoices, and progress in sight inside the web portal.',
],
],
],
'onboarding' => [
'card' => [
'title' => 'Your start in five steps',
'description' => 'Complete each step inside the Admin App — the dashboard keeps track of your status.',
'completed' => 'All steps finished — fantastic! You can switch to the Admin App at any time.',
'cta_fallback' => 'Start now',
],
'admin_app' => [
'title' => 'Open the Admin App',
'description' => 'Manage events, uploads, and guests inside the Admin App. The mobile interface is optimised for live operations.',
'cta' => 'Launch Admin App',
],
'event_setup' => [
'title' => 'Prepare first event',
'description' => 'Define name, date, and tasks inside the Admin App so guests know which photos you expect.',
'cta' => 'Create event',
],
'invite_guests' => [
'title' => 'Invite guests',
'description' => 'Share QR codes or links so guests can start uploading instantly.',
'cta' => 'Open QR links',
],
'collect_photos' => [
'title' => 'Collect first photos',
'description' => 'As soon as uploads arrive, they show up in your gallery. Moderation happens inside the Admin App.',
'cta' => 'Review uploads',
],
'branding' => [
'title' => 'Fine-tune branding & tasks',
'description' => 'Adjust colours and task bundles to match your occasion — everything feels tailor-made.',
'cta' => 'Open branding',
],
],
'events' => [
'card' => [
'title' => 'Upcoming events',
'description' => 'Keep track of status, uploads, and tasks for your next events.',
'badge' => [
'plural' => ':count scheduled',
'empty' => 'No event scheduled yet',
],
'empty' => 'Plan your first event and steer the full journey — from briefing to follow-up — in the Admin App.',
],
'status' => [
'live' => 'Live',
'upcoming' => 'In preparation',
],
'badges' => [
'photos' => 'Photos',
'tasks' => 'Tasks',
'links' => 'Links',
],
],
'cards_section' => [
'package' => [
'title' => 'Current package',
'description' => 'Stay on top of durations and available volume.',
'remaining' => ':count events',
'expires_at' => 'Expires on',
'price' => 'Price',
'latest' => 'Last booked on :date via :provider.',
'empty' => 'No active package yet.',
'empty_cta' => 'Choose a package now',
'empty_suffix' => 'and then create events inside the Admin App.',
],
'purchases' => [
'title' => 'Recent purchases',
'description' => 'Track your booked packages and add-ons.',
'badge' => ':count entries',
'empty' => 'No purchases visible yet. Secure your first bundle now.',
'table' => [
'package' => 'Package',
'type' => 'Type',
'provider' => 'Provider',
'date' => 'Date',
'price' => 'Price',
],
],
'quick_actions' => [
'title' => 'Quick actions',
'description' => 'Everything you need for the next step is one click away.',
'cta' => 'Continue',
'items' => [
'tenant_admin' => [
'label' => 'Open Admin App',
'description' => 'Detailed event management, moderation, and live features.',
],
'profile' => [
'label' => 'Manage profile',
'description' => 'Update contact data, language, and email address.',
],
'password' => [
'label' => 'Update password',
'description' => 'Protect your account with a fresh password.',
],
'packages' => [
'label' => 'Explore packages',
'description' => 'Book more events or storage — stay flexible.',
],
],
],
],
];