From 8d2075bdd28c67d37b2149977b80265e3d163eca Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 19 Nov 2025 20:21:54 +0100 Subject: [PATCH] nicer package layout, also in checkout step 1, fixed missing registration language strings, registration error handling, email verification redirect, email verification error handling and messaging, --- __pycache__/translate.cpython-312.pyc | Bin 519 -> 0 bytes app/Exceptions/Handler.php | 11 + .../EmailVerificationPromptController.php | 16 +- .../Auth/VerifyEmailController.php | 57 +- app/Http/Controllers/CheckoutController.php | 20 +- app/Http/Kernel.php | 4 +- app/Http/Middleware/HandleInertiaRequests.php | 3 + .../NormalizeSignedUrlParameters.php | 29 + app/Http/Middleware/ValidateSignature.php | 33 + app/Providers/AuthServiceProvider.php | 16 + public/lang/de/auth.json | 15 +- public/lang/de/marketing.json | 8 + public/lang/en/auth.json | 15 +- public/lang/en/marketing.json | 8 + resources/js/components/ui/carousel.tsx | 19 +- resources/js/pages/auth/RegisterForm.tsx | 97 ++- resources/js/pages/auth/login.tsx | 29 +- resources/js/pages/auth/verify-email.tsx | 17 +- .../js/pages/marketing/CheckoutWizardPage.tsx | 35 +- resources/js/pages/marketing/Packages.tsx | 802 +++++++++++------- .../marketing/checkout/steps/PackageStep.tsx | 55 +- routes/auth.php | 6 +- routes/web.php | 8 + tests/Feature/Auth/EmailVerificationTest.php | 60 +- 24 files changed, 1000 insertions(+), 363 deletions(-) delete mode 100644 __pycache__/translate.cpython-312.pyc create mode 100644 app/Http/Middleware/NormalizeSignedUrlParameters.php create mode 100644 app/Http/Middleware/ValidateSignature.php diff --git a/__pycache__/translate.cpython-312.pyc b/__pycache__/translate.cpython-312.pyc deleted file mode 100644 index d274f526b1fca354e9c5f67bcdd992b06a8b6858..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 519 zcmZ`zze~eF7`@AnCQXSV4#h&Tju|wILqQSz2Rb=rkzme}kS5`BwPq>lR5~dxI=V_7 z^q+8XDphJv1i`7B(5;hq+E7INhI{XQ-+SM8kGie_Ru}hW_m1HQ7g@=3GJRm=5;(wt zKCn0nK5Ph<009DCF0(Jt5!aZoBsTrtzL;?4>Ay8s{dvn<{(okv0zd+iZlyl_aanoR z(NNwK|E7?dxiW+xfrdQc)|vsSc7W|5_H7d43E#(B3JDK=JE$84D#l4n^JH#8m|vih zjq4{=<(8&hM^ui9;|5g1$ac8EZbT)5P6^erWQeI8VK*Q*V9GDg46_js^T=$qS~v`e zc^r~3j$Gt#+EHX?F|-vWGj+ESI!zz#;Z>I8e~fGF`4l09uRwbPmG{EZS+!R^-|y|D z>WiXvlTOlYciKaxI8w?3r94#DQ)#Rhuhl2rNVPGLQ*V?j4RWQc?P0F+3^wozTlobg C(shLZ diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 1cd9727..7bd4428 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -9,6 +9,7 @@ use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Http\Client\ConnectionException as HttpClientConnectionException; use Illuminate\Queue\InvalidQueueException; use Illuminate\Queue\MaxAttemptsExceededException; +use Illuminate\Routing\Exceptions\InvalidSignatureException; use Illuminate\Validation\ValidationException; use League\Flysystem\FilesystemException; use PDOException; @@ -70,6 +71,16 @@ class Handler extends ExceptionHandler } } + if ($e instanceof InvalidSignatureException && ! $request->expectsJson()) { + $request->session()->flash('verification', [ + 'status' => 'error', + 'title' => __('auth.verification.expired_title'), + 'message' => __('auth.verification.expired_message'), + ]); + + return redirect()->route('verification.notice'); + } + if (! $request->expectsJson() && ! $request->inertia()) { if ($hintKey = $this->resolveServerErrorHint($e)) { $request->attributes->set('serverErrorHint', __($hintKey, [], app()->getLocale())); diff --git a/app/Http/Controllers/Auth/EmailVerificationPromptController.php b/app/Http/Controllers/Auth/EmailVerificationPromptController.php index 672f7cf..8a70d28 100644 --- a/app/Http/Controllers/Auth/EmailVerificationPromptController.php +++ b/app/Http/Controllers/Auth/EmailVerificationPromptController.php @@ -15,8 +15,18 @@ class EmailVerificationPromptController extends Controller */ public function __invoke(Request $request): Response|RedirectResponse { - return $request->user()->hasVerifiedEmail() - ? redirect()->intended(route('dashboard', absolute: false)) - : Inertia::render('auth/verify-email', ['status' => $request->session()->get('status')]); + if ($request->user()->hasVerifiedEmail()) { + $redirectToCheckout = $request->session()->pull('checkout.verify_redirect'); + + if ($redirectToCheckout) { + $separator = str_contains($redirectToCheckout, '?') ? '&' : '?'; + + return redirect()->to($redirectToCheckout.$separator.'verified=1'); + } + + return redirect()->intended(route('dashboard', absolute: false)); + } + + return Inertia::render('auth/verify-email', ['status' => $request->session()->get('status')]); } } diff --git a/app/Http/Controllers/Auth/VerifyEmailController.php b/app/Http/Controllers/Auth/VerifyEmailController.php index db389f2..324b89d 100644 --- a/app/Http/Controllers/Auth/VerifyEmailController.php +++ b/app/Http/Controllers/Auth/VerifyEmailController.php @@ -13,12 +13,61 @@ class VerifyEmailController extends Controller */ public function __invoke(EmailVerificationRequest $request): RedirectResponse { - if ($request->user()->hasVerifiedEmail()) { - return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); + if (! $request->user()->hasVerifiedEmail()) { + $request->fulfill(); } - $request->fulfill(); + return $this->redirectAfterVerification($request); + } - return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); + protected function redirectAfterVerification(EmailVerificationRequest $request): RedirectResponse + { + $redirectToCheckout = $request->session()->pull('checkout.verify_redirect'); + + if (! $redirectToCheckout && $request->user()->pending_purchase) { + $packageId = $request->session()->pull('checkout.pending_package_id'); + + if (! $packageId) { + $packageId = optional($request->user()->tenant) + ?->packages() + ->latest('tenant_packages.created_at') + ->value('packages.id'); + } + + if ($packageId) { + $redirectToCheckout = route('checkout.show', ['package' => $packageId]); + } + } + + $this->flashVerificationSuccess($request, (bool) $redirectToCheckout); + + if ($redirectToCheckout) { + $request->session()->forget('checkout.pending_package_id'); + $separator = str_contains($redirectToCheckout, '?') ? '&' : '?'; + + return redirect()->to($redirectToCheckout.$separator.'verified=1'); + } + + $fallbackLogin = route('marketing.login'); + $separator = str_contains($fallbackLogin, '?') ? '&' : '?'; + + return redirect()->intended($fallbackLogin.$separator.'verified=1'); + } + + private function flashVerificationSuccess(EmailVerificationRequest $request, bool $forCheckout): void + { + $message = $forCheckout + ? __('auth.verification.checkout_success_message') + : __('auth.verification.success_message'); + + $request->session()->flash('verification', [ + 'status' => 'success', + 'title' => __('auth.verification.success_title'), + 'message' => $message, + ]); + + if (! $forCheckout) { + $request->session()->flash('status', __('auth.verification.success_message')); + } } } diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 2cc4bb4..286c2f6 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -76,7 +76,7 @@ class CheckoutController extends Controller $package = Package::findOrFail($request->package_id); $validated = $validator->validated(); - DB::transaction(function () use ($request, $package, $validated) { + $user = DB::transaction(function () use ($request, $package, $validated) { // User erstellen $user = User::create([ @@ -138,10 +138,28 @@ class CheckoutController extends Controller Mail::to($user) ->locale($user->preferred_locale ?? app()->getLocale()) ->queue(new Welcome($user)); + + return $user; }); + Auth::login($user); + $request->session()->put('checkout.pending_package_id', $package->id); + $redirectUrl = route('checkout.show', ['package' => $package->id]); + $request->session()->put('checkout.verify_redirect', $redirectUrl); + $request->session()->put('url.intended', $redirectUrl); + return response()->json([ + 'success' => true, 'message' => 'Registrierung erfolgreich. Bitte überprüfen Sie Ihre E-Mail zur Verifizierung.', + 'redirect' => $redirectUrl, + 'user' => [ + 'id' => $user->id, + 'email' => $user->email, + 'name' => $user->name ?? trim($user->first_name.' '.$user->last_name), + 'pending_purchase' => $user->pending_purchase ?? true, + 'email_verified_at' => $user->email_verified_at, + ], + 'pending_purchase' => $user->pending_purchase ?? true, ]); } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index bc71752..7b26172 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -48,13 +48,13 @@ class Kernel extends HttpKernel ]; /** - * The application's route middleware. + * The application's middleware aliases. * * These middleware may be assigned to groups or used individually. * * @var array */ - protected $routeMiddleware = [ + protected $middlewareAliases = [ 'auth' => \App\Http\Middleware\Authenticate::class, 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class, diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index db6ef5f..214bd3a 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -69,6 +69,9 @@ class HandleInertiaRequests extends Middleware 'profile' => __('profile'), 'dashboard' => __('dashboard'), ], + 'flash' => [ + 'verification' => fn () => $request->session()->get('verification'), + ], ]; } } diff --git a/app/Http/Middleware/NormalizeSignedUrlParameters.php b/app/Http/Middleware/NormalizeSignedUrlParameters.php new file mode 100644 index 0000000..99016a8 --- /dev/null +++ b/app/Http/Middleware/NormalizeSignedUrlParameters.php @@ -0,0 +1,29 @@ +server->get('QUERY_STRING'); + + if (is_string($queryString) && str_contains($queryString, '&')) { + $normalized = str_replace('&', '&', $queryString); + + if ($normalized !== $queryString) { + $request->server->set('QUERY_STRING', $normalized); + parse_str($normalized, $params); + + if (is_array($params) && ! empty($params)) { + $request->query->replace(array_merge($request->query->all(), $params)); + } + } + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/ValidateSignature.php b/app/Http/Middleware/ValidateSignature.php new file mode 100644 index 0000000..e92573a --- /dev/null +++ b/app/Http/Middleware/ValidateSignature.php @@ -0,0 +1,33 @@ +expectsJson()) { + throw $exception; + } + + if ($request->routeIs('verification.verify')) { + $request->session()->flash('verification', [ + 'status' => 'error', + 'title' => __('auth.verification.expired_title'), + 'message' => __('auth.verification.expired_message'), + ]); + + return redirect()->route('verification.notice'); + } + + throw $exception; + } + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 175d7ed..703b1a0 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -7,8 +7,10 @@ use App\Models\Tenant; use App\Models\User; use App\Policies\PurchaseHistoryPolicy; use App\Policies\TenantPolicy; +use Illuminate\Auth\Notifications\VerifyEmail; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\URL; class AuthServiceProvider extends ServiceProvider { @@ -29,6 +31,20 @@ class AuthServiceProvider extends ServiceProvider { $this->registerPolicies(); + VerifyEmail::createUrlUsing(function (User $notifiable): string { + $relativeUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes((int) config('auth.verification.expire', 60)), + [ + 'id' => $notifiable->getKey(), + 'hash' => sha1($notifiable->getEmailForVerification()), + ], + absolute: false, + ); + + return URL::to($relativeUrl); + }); + Gate::before(function (User $user): ?bool { return $user->role === 'super_admin' ? true : null; }); diff --git a/public/lang/de/auth.json b/public/lang/de/auth.json index cc575a5..0ba8a46 100644 --- a/public/lang/de/auth.json +++ b/public/lang/de/auth.json @@ -60,10 +60,21 @@ "phone_placeholder": "+49 170 1234567", "username_placeholder": "z. B. hochzeit_julia", "password_placeholder": "Mindestens 8 Zeichen", - "password_confirmation_placeholder": "Passwort erneut eingeben" + "password_confirmation_placeholder": "Passwort erneut eingeben", + "server_error_title": "Registrierung konnte nicht abgeschlossen werden", + "server_error_message": "Auf unserer Seite ist ein Fehler aufgetreten. Bitte versuche es später erneut oder kontaktiere support@fotospiel.de.", + "session_expired_title": "Sicherheitsprüfung abgelaufen", + "session_expired_message": "Deine Sitzung ist abgelaufen. Lade die Seite neu und versuche es erneut." }, "verification": { "notice": "Bitte bestätigen Sie Ihre E-Mail-Adresse.", - "resend": "E-Mail erneut senden" + "resend": "E-Mail erneut senden", + "success_title": "E-Mail bestätigt", + "success_message": "Deine E-Mail ist bestätigt. Du kannst dich jetzt anmelden.", + "checkout_success_message": "E-Mail bestätigt. Du kannst mit dem Checkout fortfahren.", + "toast_success": "E-Mail erfolgreich bestätigt.", + "expired_title": "Bestätigungslink abgelaufen", + "expired_message": "Dieser Bestätigungslink ist nicht mehr gültig. Fordere unten einen neuen Link an.", + "toast_error": "Bestätigungslink abgelaufen. Bitte fordere einen neuen Link an." } } diff --git a/public/lang/de/marketing.json b/public/lang/de/marketing.json index cf2ae10..6334cc0 100644 --- a/public/lang/de/marketing.json +++ b/public/lang/de/marketing.json @@ -164,6 +164,14 @@ "order_hint": "Sofort startklar – keine versteckten Kosten, sichere Zahlung über Paddle.", "features_label": "Features", "feature_highlights": "Feature-Highlights", + "detail_labels": { + "photos": "Fotos", + "guests": "Gäste", + "tasks": "Aufgaben", + "gallery": "Galerie", + "branding": "Branding", + "events_per_year": "Events pro Jahr" + }, "more_details_tab": "Mehr Details", "quick_facts": "Schnelle Fakten", "quick_facts_hint": "Der schnelle Überblick über die wichtigsten Kennzahlen.", diff --git a/public/lang/en/auth.json b/public/lang/en/auth.json index 288d0d0..2e59574 100644 --- a/public/lang/en/auth.json +++ b/public/lang/en/auth.json @@ -60,10 +60,21 @@ "phone_placeholder": "+1 555 123 4567", "username_placeholder": "e.g. wedding_julia", "password_placeholder": "At least 8 characters", - "password_confirmation_placeholder": "Repeat your password" + "password_confirmation_placeholder": "Repeat your password", + "server_error_title": "We couldn't finish your registration", + "server_error_message": "Something went wrong on our side. Please try again in a moment or contact support@fotospiel.de.", + "session_expired_title": "Security check expired", + "session_expired_message": "Your session expired. Refresh the page and try again." }, "verification": { "notice": "Please verify your email address.", - "resend": "Resend email" + "resend": "Resend email", + "success_title": "Email verified", + "success_message": "Your email is confirmed. You can sign in now.", + "checkout_success_message": "Email confirmed. Continue your checkout to finish the order.", + "toast_success": "Email verified successfully.", + "expired_title": "Verification link expired", + "expired_message": "That verification link is no longer valid. Request a new email below.", + "toast_error": "Verification link expired. Request a new one." } } diff --git a/public/lang/en/marketing.json b/public/lang/en/marketing.json index e9562a4..8d19317 100644 --- a/public/lang/en/marketing.json +++ b/public/lang/en/marketing.json @@ -151,6 +151,14 @@ "order_hint": "Launch instantly – secure Paddle checkout, no hidden fees.", "features_label": "Features", "feature_highlights": "Feature Highlights", + "detail_labels": { + "photos": "Photos", + "guests": "Guests", + "tasks": "Challenges", + "gallery": "Gallery", + "branding": "Branding", + "events_per_year": "Events per year" + }, "more_details_tab": "More Details", "quick_facts": "Quick Facts", "quick_facts_hint": "Your at-a-glance snapshot of core limits.", diff --git a/resources/js/components/ui/carousel.tsx b/resources/js/components/ui/carousel.tsx index cd8695f..39658d8 100644 --- a/resources/js/components/ui/carousel.tsx +++ b/resources/js/components/ui/carousel.tsx @@ -31,8 +31,6 @@ interface CarouselProps { const Carousel = React.forwardRef( ({ opts, plugins = [Autoplay()], setApi, className, children, ...props }, ref) => { const [api, setApiInternal] = React.useState(null) - const [current, setCurrent] = React.useState(0) - const [count, setCount] = React.useState(0) const [emblaRef] = useEmblaCarousel(opts, plugins) @@ -41,18 +39,6 @@ const Carousel = React.forwardRef( return } - console.log('Embla API initialized:', api) - console.log('Embla options:', opts) - console.log('Embla plugins:', plugins) - - setCount(api.slideNodes().length) - api.on("reInit", setCount) - api.on("slideChanged", ({ slide }: { slide: number }) => { - console.log('Slide changed to:', slide) - setCurrent(slide) - }) - api.on("pointerDown", () => console.log('Pointer down event')) - api.on("pointerUp", () => console.log('Pointer up event')) setApi?.(api) }, [api, setApi]) @@ -64,9 +50,6 @@ const Carousel = React.forwardRef( "relative w-full", className )} - onTouchStart={(e) => console.log('Carousel touch start:', e.touches.length)} - onTouchMove={(e) => console.log('Carousel touch move:', e.touches.length)} - onTouchEnd={(e) => console.log('Carousel touch end')} {...props} >
{ + if (typeof document === 'undefined') { + return null; + } + + const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); + + return match ? decodeURIComponent(match[1]) : null; +}; + +const resolveCsrfToken = (): string => { + if (typeof document === 'undefined') { + return ''; + } + + const metaToken = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content; + + if (metaToken && metaToken.length > 0) { + return metaToken; + } + + return getCookieValue('XSRF-TOKEN') ?? ''; +}; + export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale, prefill, onClearGoogleProfile }: RegisterFormProps) { const [privacyOpen, setPrivacyOpen] = useState(false); const [hasTriedSubmit, setHasTriedSubmit] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [prefillApplied, setPrefillApplied] = useState(false); + const [serverError, setServerError] = useState(null); + const [serverErrorType, setServerErrorType] = useState<'generic' | 'session-expired'>('generic'); const { t } = useTranslation(['auth', 'common']); const page = usePage<{ errors: Record; locale?: string; auth?: { user?: any | null } }>(); const resolvedLocale = locale ?? page.props.locale ?? 'de'; @@ -68,6 +94,30 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale const registerEndpoint = '/checkout/register'; + const requiredStringFields: Array = useMemo(() => ( + ['first_name', 'last_name', 'username', 'email', 'password', 'password_confirmation', 'address', 'phone'] + ), []); + + const isFormValid = useMemo(() => { + const stringsValid = requiredStringFields.every((field) => { + const value = data[field]; + if (typeof value !== 'string') { + return false; + } + + return value.trim().length > 0; + }); + + if (!stringsValid) { + return false; + } + + const emailValid = /.+@.+\..+/.test(data.email.trim()); + const passwordsMatch = data.password === data.password_confirmation; + + return emailValid && passwordsMatch && data.privacy_consent && data.terms; + }, [data, requiredStringFields]); + const namePrefill = useMemo(() => { const rawFirst = prefill?.given_name ?? prefill?.name?.split(' ')[0] ?? ''; const remaining = prefill?.name ? prefill.name.split(' ').slice(1).join(' ') : ''; @@ -126,24 +176,28 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale const submit = async (event: React.FormEvent) => { event.preventDefault(); + setServerError(null); + setServerErrorType('generic'); setHasTriedSubmit(true); setIsSubmitting(true); clearErrors(); + const csrfToken = resolveCsrfToken(); const body = { ...data, locale: resolvedLocale, package_id: data.package_id ?? packageId ?? null, + _token: csrfToken, }; try { - const response = await fetch(registerEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', - 'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? '', + 'X-CSRF-TOKEN': csrfToken, + 'X-XSRF-TOKEN': csrfToken, }, credentials: 'same-origin', body: JSON.stringify(body), @@ -179,10 +233,32 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale return; } - toast.error(t('register.unexpected_error', 'Registrierung nicht moeglich.')); + if (response.status === 419) { + const expiredMessage = t('register.session_expired_message', 'Deine Sitzung ist abgelaufen. Bitte lade die Seite neu und versuche es erneut.'); + setServerErrorType('session-expired'); + setServerError(expiredMessage); + toast.error(expiredMessage); + return; + } + + let message: string | null = null; + try { + const json = await response.clone().json(); + message = json?.message ?? null; + } catch (error) { + message = null; + } + + const fallbackMessage = message ?? t('register.server_error_message', 'Etwas ist schiefgelaufen. Bitte versuche es erneut.'); + setServerErrorType('generic'); + setServerError(fallbackMessage); + toast.error(fallbackMessage); } catch (error) { console.error('Register request failed', error); - toast.error(t('register.unexpected_error', 'Registrierung nicht moeglich.')); + const fallbackMessage = t('register.server_error_message', 'Etwas ist schiefgelaufen. Bitte versuche es erneut.'); + setServerErrorType('generic'); + setServerError(fallbackMessage); + toast.error(fallbackMessage); } finally { setIsSubmitting(false); } @@ -463,10 +539,21 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
)} + {serverError && ( +
+

+ {serverErrorType === 'session-expired' + ? t('register.session_expired_title', 'Sicherheitsprüfung abgelaufen') + : t('register.server_error_title', 'Registrierung konnte nicht abgeschlossen werden')} +

+

{serverError}

+
+ )} + +

{t('packages.order_hint')}

+ +
+

{t('packages.feature_highlights')}

+
    + {packageData.features.slice(0, 5).map((feature) => ( +
  • + + {t(`packages.feature_${feature}`)} +
  • + ))} + {packageData.watermark_allowed === false && ( +
  • + + {t('packages.no_watermark')} +
  • + )} + {packageData.branding_allowed && ( +
  • + + {t('packages.custom_branding')} +
  • + )} +
+
+
+

{t('packages.more_details_tab')}

+ + + + {t('packages.breakdown_label')} + + + {t('packages.testimonials_title')} + + + + {packageData.description_breakdown?.length ? ( + + {packageData.description_breakdown.map((entry, index) => ( + + + {entry.title ?? t('packages.limits_label')} + + + {entry.value} + + + ))} + + ) : ( +

{t('packages.breakdown_label_hint')}

+ )} +
+ +
+ {testimonials.map((testimonial, index) => ( +
+
+
+

{testimonial.name}

+

{packageData.name}

+
+
+ {[...Array(testimonial.rating)].map((_, i) => ( + + ))} +
+
+

“{testimonial.text}”

+
+ ))} + +
+
+
+
+ + + ); +}; + return ( -
-
-
-

- {t('packages.for_endcustomers')} +

+
+
+

+ {t('packages.for_endcustomers')} · {t('packages.for_resellers')}

-

{t('packages.hero_title')}

-

+

+ {t('packages.hero_title')} +

+

{t('packages.hero_description')}

-
- + +
+

+ {t('packages.hero_secondary')} +

-
+
@@ -604,41 +915,85 @@ function PackageCard({
-
-
- {endcustomerPackages.map((pkg) => ( - handleCardClick(selected, 'endcustomer')} - className="min-w-[280px]" - compact - /> - ))} +
+
+
+
+
+ {orderedEndcustomerPackages.map((pkg) => ( +
+ handleCardClick(selected, 'endcustomer')} + className="h-full" + compact + /> +
+ ))} +
- +
+ {orderedEndcustomerPackages.map((pkg) => ( + handleCardClick(selected, 'endcustomer')} + className="h-full" + compact + /> + ))} +
+ -
-
- {resellerPackages.map((pkg) => ( - handleCardClick(selected, 'reseller')} - className="min-w-[280px]" - compact - /> - ))} +
+
+
+
+
+ {orderedResellerPackages.map((pkg) => ( +
+ handleCardClick(selected, 'reseller')} + className="h-full" + compact + /> +
+ ))} +
- +
+ {orderedResellerPackages.map((pkg) => ( + handleCardClick(selected, 'reseller')} + className="h-full" + compact + /> + ))} +
+
@@ -670,208 +1025,41 @@ function PackageCard({
- {/* Modal */} + {/* Details overlay */} {selectedPackage && ( - - { - event.preventDefault(); - dialogScrollRef.current?.scrollTo({ top: 0 }); - dialogHeadingRef.current?.focus(); - }} - > -
-
- -

- {selectedVariant === 'reseller' ? t('packages.subscription') : t('packages.one_time')} -

- - {selectedPackage.name} - -

{selectedPackage.description}

-
-
-
-
-
-
-
-

{t('packages.price')}

-

- {Number(selectedPackage.price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {t('packages.currency.euro')} -

- {selectedPackage.price > 0 && ( -

- / {selectedVariant === 'reseller' ? t('packages.billing_per_year') : t('packages.billing_per_event')} -

- )} -
- {selectedHighlight && ( - - {selectedVariant === 'reseller' - ? t('packages.badge_best_value') - : t('packages.badge_most_popular')} - - )} -
-
- {resolvePackageMetrics(selectedPackage, selectedVariant, t, tCommon).map((metric) => ( -
-

{metric.value}

-

{metric.label}

-
- ))} -
- -

{t('packages.order_hint')}

-
-
-

{t('packages.feature_highlights')}

-
    - {selectedPackage.features.slice(0, 5).map((feature) => ( -
  • - - {t(`packages.feature_${feature}`)} -
  • - ))} - {selectedPackage.watermark_allowed === false && ( -
  • - - {t('packages.no_watermark')} -
  • - )} - {selectedPackage.branding_allowed && ( -
  • - - {t('packages.custom_branding')} -
  • - )} -
-
-
-
- - - - {t('packages.details')} - - - {t('packages.customer_opinions')} - - - -
-

{t('packages.quick_facts')}

-

{t('packages.quick_facts_hint')}

-
- {resolvePackageMetrics(selectedPackage, selectedVariant, t, tCommon).map((metric) => ( -
-

{metric.value}

-

{metric.label}

-
- ))} -
-
-
-

{t('packages.feature_highlights')}

-
    - {selectedPackage.features.slice(0, 4).map((feature) => ( -
  • - - {t(`packages.feature_${feature}`)} -
  • - ))} - {selectedPackage.watermark_allowed === false && ( -
  • - - {t('packages.no_watermark')} -
  • - )} - {selectedPackage.branding_allowed && ( -
  • - - {t('packages.custom_branding')} -
  • - )} -
-
-
- - {selectedPackage.description_breakdown?.length ? ( - - {selectedPackage.description_breakdown.map((entry, index) => ( - - - {entry.title ?? t('packages.limits_label')} - - - {entry.value} - - - ))} - - ) : ( -

{t('packages.breakdown_label_hint')}

- )} -
- -
- {testimonials.map((testimonial, index) => ( -
-
-
-

{testimonial.name}

-

{selectedPackage.name}

-
-
- {[...Array(testimonial.rating)].map((_, i) => ( - - ))} -
-
-

“{testimonial.text}”

-
- ))} - -
-
-
-
-
-
-
-
- )} - {/* Testimonials Section entfernt, da nun im Dialog */} + isMobile ? ( + + + {renderDetailBody('h-full overflow-y-auto space-y-8 p-6')} + + + ) : ( + + + {renderDetailBody('max-h-[88vh] overflow-y-auto space-y-8 p-6 md:p-10')} + + + ) + )} {/* Testimonials Section entfernt, da nun im Dialog */} ); }; + + +const handleDetailAutoFocus = (event: Event) => { + event.preventDefault(); + dialogScrollRef.current?.scrollTo({ top: 0 }); + dialogHeadingRef.current?.focus(); +}; + Packages.layout = (page: React.ReactNode) => page; export default Packages; diff --git a/resources/js/pages/marketing/checkout/steps/PackageStep.tsx b/resources/js/pages/marketing/checkout/steps/PackageStep.tsx index f248b0e..5b0bb14 100644 --- a/resources/js/pages/marketing/checkout/steps/PackageStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/PackageStep.tsx @@ -4,6 +4,7 @@ import type { TFunction } from 'i18next'; 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 { cn } from "@/lib/utils"; import { useCheckoutWizard } from "../WizardContext"; import type { CheckoutPackage } from "../types"; @@ -18,11 +19,45 @@ function translateFeature(feature: string, t: TFunction<'marketing'>) { return t(`packages.feature_${feature}`, { defaultValue: fallback }); } +const DETAIL_LABEL_MAP: Record = { + fotos: 'photos', + photos: 'photos', + gaeste: 'guests', + gäste: 'guests', + guests: 'guests', + aufgaben: 'tasks', + challenges: 'tasks', + galerie: 'gallery', + gallery: 'gallery', + branding: 'branding', + 'events_jahr': 'events_per_year', + eventsjahr: 'events_per_year', +}; + +function translateDetailLabel(label: string | undefined, t: TFunction<'marketing'>): string | undefined { + if (!label) { + return label; + } + + const normalised = label + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_|_$/g, '') || label.toLowerCase(); + + const key = DETAIL_LABEL_MAP[normalised] ?? normalised; + return t(`packages.detail_labels.${key}`, { defaultValue: label }); +} + function PackageSummary({ pkg, t }: { pkg: CheckoutPackage; t: TFunction<'marketing'> }) { const isFree = pkg.price === 0; + const accentGradient = pkg.type === 'reseller' + ? 'border-amber-100 bg-gradient-to-br from-amber-50/80 via-white to-amber-100/70 shadow-lg shadow-amber-100/60' + : 'border-rose-100 bg-gradient-to-br from-rose-50/80 via-white to-rose-100/70 shadow-lg shadow-rose-100/60'; return ( - + @@ -55,7 +90,9 @@ function PackageSummary({ pkg, t }: { pkg: CheckoutPackage; t: TFunction<'market {pkg.description_breakdown.map((row, index) => (
{row.title && ( -

{row.title}

+

+ {translateDetailLabel(row.title, t)} +

)}

{row.value}

@@ -80,18 +117,22 @@ function PackageSummary({ pkg, t }: { pkg: CheckoutPackage; t: TFunction<'market function PackageOption({ pkg, isActive, onSelect, t }: { pkg: CheckoutPackage; isActive: boolean; onSelect: () => void; t: TFunction<'marketing'> }) { const isFree = pkg.price === 0; + const accentGradient = pkg.type === 'reseller' + ? 'border-amber-100 bg-gradient-to-r from-amber-50/70 via-white to-amber-100/60 shadow-md shadow-amber-100/60' + : 'border-rose-100 bg-gradient-to-r from-rose-50/70 via-white to-rose-100/60 shadow-md shadow-rose-100/60'; return (