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,

This commit is contained in:
Codex Agent
2025-11-19 20:21:54 +01:00
parent 91d3e61b0e
commit 8d2075bdd2
24 changed files with 1000 additions and 363 deletions

View File

@@ -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()));

View File

@@ -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')]);
}
}

View File

@@ -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'));
}
}
}

View File

@@ -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,
]);
}

View File

@@ -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<string, class-string|string>
*/
protected $routeMiddleware = [
protected $middlewareAliases = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,

View File

@@ -69,6 +69,9 @@ class HandleInertiaRequests extends Middleware
'profile' => __('profile'),
'dashboard' => __('dashboard'),
],
'flash' => [
'verification' => fn () => $request->session()->get('verification'),
],
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class NormalizeSignedUrlParameters
{
public function handle(Request $request, Closure $next)
{
$queryString = $request->server->get('QUERY_STRING');
if (is_string($queryString) && str_contains($queryString, '&amp;')) {
$normalized = str_replace('&amp;', '&', $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);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Routing\Exceptions\InvalidSignatureException;
use Illuminate\Routing\Middleware\ValidateSignature as BaseValidateSignature;
class ValidateSignature extends BaseValidateSignature
{
public function handle($request, Closure $next, ...$args)
{
try {
return parent::handle($request, $next, ...$args);
} catch (InvalidSignatureException $exception) {
if ($request->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;
}
}
}

View File

@@ -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;
});

View File

@@ -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."
}
}

View File

@@ -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.",

View File

@@ -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."
}
}

View File

@@ -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.",

View File

@@ -31,8 +31,6 @@ interface CarouselProps {
const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
({ opts, plugins = [Autoplay()], setApi, className, children, ...props }, ref) => {
const [api, setApiInternal] = React.useState<CarouselApi | null>(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<HTMLDivElement, CarouselProps>(
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<HTMLDivElement, CarouselProps>(
"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}
>
<div

View File

@@ -37,11 +37,37 @@ type RegisterFormFields = {
package_id: number | null;
};
const getCookieValue = (name: string): string | null => {
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<string | null>(null);
const [serverErrorType, setServerErrorType] = useState<'generic' | 'session-expired'>('generic');
const { t } = useTranslation(['auth', 'common']);
const page = usePage<{ errors: Record<string, string>; 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<keyof RegisterFormFields> = 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
</div>
)}
{serverError && (
<div className="mb-6 rounded-md border border-red-200 bg-red-50 p-4 text-sm text-red-800">
<p className="font-semibold">
{serverErrorType === 'session-expired'
? t('register.session_expired_title', 'Sicherheitsprüfung abgelaufen')
: t('register.server_error_title', 'Registrierung konnte nicht abgeschlossen werden')}
</p>
<p className="mt-1">{serverError}</p>
</div>
)}
<button
type="button"
onClick={submit}
disabled={isSubmitting}
disabled={isSubmitting || !isFormValid}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-[#FFB6C1] hover:bg-[#FF69B4] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#FFB6C1] transition duration-300 disabled:opacity-50"
>
{isSubmitting && <LoaderCircle className="h-4 w-4 animate-spin mr-2" />}

View File

@@ -1,6 +1,6 @@
import { FormEvent, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { Head, useForm } from '@inertiajs/react';
import { Head, useForm, usePage } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import InputError from '@/components/input-error';
import TextLink from '@/components/text-link';
@@ -13,6 +13,7 @@ import AppLayout from '@/layouts/app/AppLayout';
import { register } from '@/routes';
import { request } from '@/routes/password';
import { LoaderCircle } from 'lucide-react';
import toast from 'react-hot-toast';
interface LoginProps {
status?: string;
@@ -24,6 +25,8 @@ export default function Login({ status, canResetPassword }: LoginProps) {
const [rawReturnTo, setRawReturnTo] = useState<string | null>(null);
const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false);
const { t } = useTranslation('auth');
const page = usePage<{ flash?: { verification?: { status: string; title?: string; message?: string } } }>();
const verificationFlash = page.props.flash?.verification;
const { data, setData, post, processing, errors, clearErrors } = useForm({
login: '',
@@ -50,7 +53,15 @@ export default function Login({ status, canResetPassword }: LoginProps) {
const searchParams = new URLSearchParams(window.location.search);
setRawReturnTo(searchParams.get('return_to'));
}, []);
if (searchParams.get('verified') === '1') {
toast.success(t('verification.toast_success', 'Email verified successfully.'));
searchParams.delete('verified');
const nextQuery = searchParams.toString();
const nextUrl = `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}`;
window.history.replaceState({}, '', nextUrl);
}
}, [t]);
useEffect(() => {
setData('return_to', rawReturnTo ?? '');
@@ -213,6 +224,20 @@ export default function Login({ status, canResetPassword }: LoginProps) {
</div>
)}
{verificationFlash && (
<div
className={[
'rounded-2xl border p-3 text-center font-medium shadow-sm',
verificationFlash.status === 'success'
? 'border-emerald-200/70 bg-emerald-50/90 text-emerald-700'
: 'border-rose-200/70 bg-rose-50/90 text-rose-700',
].join(' ')}
>
<div className="font-semibold">{verificationFlash.title ?? ''}</div>
<div className="text-sm">{verificationFlash.message}</div>
</div>
)}
{hasErrors && (
<div
key={`general-errors-${errorKeys.join('-')}`}

View File

@@ -1,7 +1,7 @@
// Components
import { store } from '@/actions/App/Http/Controllers/Auth/EmailVerificationNotificationController';
import { logout } from '@/routes';
import { Form, Head } from '@inertiajs/react';
import { Form, Head, usePage } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import TextLink from '@/components/text-link';
@@ -13,11 +13,26 @@ export default function VerifyEmail({ status }: { status?: string }) {
const description = isNewRegistration
? 'Thanks! Please confirm your email address to access your dashboard.'
: 'Please verify your email address by clicking on the link we just emailed to you.';
const page = usePage<{ flash?: { verification?: { status: string; title?: string; message?: string } } }>();
const verificationFlash = page.props.flash?.verification;
return (
<AuthLayout title="Verify email" description={description}>
<Head title="Email verification" />
{verificationFlash && (
<div
className={`mb-6 rounded-md border p-4 text-left text-sm font-medium ${
verificationFlash.status === 'success'
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
: 'border-rose-200 bg-rose-50 text-rose-800'
}`}
>
<p className="font-semibold">{verificationFlash.title}</p>
<p className="mt-1 text-sm font-normal">{verificationFlash.message}</p>
</div>
)}
{isNewRegistration && (
<div className="mb-6 rounded-md bg-blue-50 p-4 text-left text-sm text-blue-900">
<p className="font-semibold">Almost there! Confirm your email address to start using Fotospiel.</p>

View File

@@ -1,8 +1,11 @@
import React from "react";
import React, { useEffect } from "react";
import { Head, usePage } from "@inertiajs/react";
import MarketingLayout from "@/layouts/mainWebsite";
import type { CheckoutPackage, GoogleProfilePrefill } from "./checkout/types";
import { CheckoutWizard } from "./checkout/CheckoutWizard";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
interface CheckoutWizardPageProps {
package: CheckoutPackage;
@@ -26,9 +29,11 @@ const CheckoutWizardPage: React.FC<CheckoutWizardPageProps> = ({
googleAuth,
paddle,
}) => {
const page = usePage<{ auth?: { user?: { id: number; email: string; name?: string; pending_purchase?: boolean } | null } }>();
const page = usePage<{ auth?: { user?: { id: number; email: string; name?: string; pending_purchase?: boolean } | null }, flash?: { verification?: { status: string; title?: string; message?: string } } }>();
const currentUser = page.props.auth?.user ?? null;
const googleProfile = googleAuth?.profile ?? null;
const { t: tAuth } = useTranslation('auth');
const verificationFlash = page.props.flash?.verification;
const dedupedOptions = React.useMemo(() => {
@@ -43,11 +48,37 @@ const CheckoutWizardPage: React.FC<CheckoutWizardPageProps> = ({
});
}, [initialPackage, packageOptions]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const params = new URLSearchParams(window.location.search);
if (params.get('verified') === '1') {
toast.success(tAuth('verification.toast_success', 'Email verified successfully.'));
params.delete('verified');
const next = params.toString();
const nextUrl = `${window.location.pathname}${next ? `?${next}` : ''}`;
window.history.replaceState({}, '', nextUrl);
}
}, [tAuth]);
return (
<MarketingLayout title="Checkout Wizard">
<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">
{verificationFlash && (
<Alert
className={verificationFlash.status === 'success'
? 'mb-6 border-emerald-200 bg-emerald-50 text-emerald-800'
: 'mb-6 border-rose-200 bg-rose-50 text-rose-800'}
variant={verificationFlash.status === 'success' ? 'default' : 'destructive'}
>
<AlertTitle>{verificationFlash.title}</AlertTitle>
<AlertDescription>{verificationFlash.message}</AlertDescription>
</Alert>
)}
<CheckoutWizard
initialPackage={initialPackage}
packageOptions={dedupedOptions}

View File

@@ -9,11 +9,13 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import MarketingLayout from '@/layouts/mainWebsite';
import { useAnalytics } from '@/hooks/useAnalytics';
import { useCtaExperiment } from '@/hooks/useCtaExperiment';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { ArrowRight, ShoppingCart, Check, Users, Image, Shield, Star, Sparkles } from 'lucide-react';
interface Package {
@@ -38,6 +40,9 @@ interface Package {
branding_allowed?: boolean;
}
const sortPackagesByPrice = (packages: Package[]): Package[] =>
[...packages].sort((a, b) => Number(a.price ?? 0) - Number(b.price ?? 0));
interface PackageComparisonProps {
packages: Package[];
variant: 'endcustomer' | 'reseller';
@@ -210,10 +215,14 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
const [open, setOpen] = useState(false);
const [selectedPackage, setSelectedPackage] = useState<Package | null>(null);
const [currentStep, setCurrentStep] = useState<'overview' | 'testimonials'>('overview');
const [isMobile, setIsMobile] = useState(false);
const dialogScrollRef = useRef<HTMLDivElement | null>(null);
const dialogHeadingRef = useRef<HTMLDivElement | null>(null);
const mobileEndcustomerRef = useRef<HTMLDivElement | null>(null);
const mobileResellerRef = useRef<HTMLDivElement | null>(null);
const { props } = usePage();
const { auth } = props as any;
const { localizedPath } = useLocalizedRoutes();
const { t } = useTranslation('marketing');
const { t: tCommon } = useTranslation('common');
const {
@@ -241,34 +250,6 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
}
}, [open, selectedPackage]);
const testimonials = [
{ name: tCommon('testimonials.anna.name'), text: t('packages.testimonials.anna'), rating: 5 },
{ name: tCommon('testimonials.max.name'), text: t('packages.testimonials.max'), rating: 5 },
{ name: tCommon('testimonials.lisa.name'), text: t('packages.testimonials.lisa'), rating: 5 },
];
const allPackages = [...endcustomerPackages, ...resellerPackages];
const selectHighlightPackageId = (packages: Package[]): number | null => {
const count = packages.length;
if (count <= 1) {
return null;
}
const sortedByPrice = [...packages].sort((a, b) => a.price - b.price);
if (count === 2) {
return sortedByPrice[1]?.id ?? null;
}
if (count === 3) {
return sortedByPrice[1]?.id ?? null;
}
return sortedByPrice[count - 2]?.id ?? null;
};
const highlightEndcustomerId = useMemo(
() => selectHighlightPackageId(endcustomerPackages),
[endcustomerPackages],
@@ -279,6 +260,124 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
[resellerPackages],
);
const orderedEndcustomerPackages = useMemo(
() => sortPackagesByPrice(endcustomerPackages),
[endcustomerPackages],
);
const orderedResellerPackages = useMemo(
() => sortPackagesByPrice(resellerPackages),
[resellerPackages],
);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const media = window.matchMedia('(max-width: 768px)');
const update = () => setIsMobile(media.matches);
update();
media.addEventListener ? media.addEventListener('change', update) : media.addListener(update);
return () => {
media.removeEventListener ? media.removeEventListener('change', update) : media.removeListener(update);
};
}, []);
const scrollMobileListToHighlight = (
container: HTMLDivElement | null,
packages: Package[],
highlightId: number | null,
) => {
if (!container || !highlightId) {
return;
}
const index = packages.findIndex((pkg) => pkg.id === highlightId);
if (index < 0) {
return;
}
const child = container.children[index] as HTMLElement | undefined;
if (!child) {
return;
}
const targetLeft = child.offsetLeft - container.clientWidth / 2 + child.clientWidth / 2;
container.scrollTo({ left: Math.max(targetLeft, 0), behavior: 'smooth' });
};
useLayoutEffect(() => {
scrollMobileListToHighlight(mobileEndcustomerRef.current, orderedEndcustomerPackages, highlightEndcustomerId);
}, [orderedEndcustomerPackages, highlightEndcustomerId]);
useLayoutEffect(() => {
scrollMobileListToHighlight(mobileResellerRef.current, orderedResellerPackages, highlightResellerId);
}, [orderedResellerPackages, highlightResellerId]);
const testimonials = [
{ name: tCommon('testimonials.anna.name'), text: t('packages.testimonials.anna'), rating: 5 },
{ name: tCommon('testimonials.max.name'), text: t('packages.testimonials.max'), rating: 5 },
{ name: tCommon('testimonials.lisa.name'), text: t('packages.testimonials.lisa'), rating: 5 },
];
const renderDetailBody = (wrapperClass: string) => {
if (!selectedPackage) {
return null;
}
return (
<div ref={dialogScrollRef} className={wrapperClass}>
<div ref={dialogHeadingRef} tabIndex={-1} className="outline-none">
<DialogHeader className="space-y-3 text-left">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-gray-400">
{selectedVariant === 'reseller' ? t('packages.subscription') : t('packages.one_time')}
</p>
<DialogTitle className="text-3xl font-display text-gray-900">
{selectedPackage.name}
</DialogTitle>
<p className="text-base text-gray-600">{selectedPackage.description}</p>
</DialogHeader>
</div>
<PackageDetailGrid
packageData={selectedPackage}
variant={selectedVariant}
isHighlight={selectedHighlight}
purchaseUrl={purchaseUrl}
onCtaClick={() => {
handleCtaClick(selectedPackage, selectedVariant);
localStorage.setItem('preferred_package', JSON.stringify(selectedPackage));
}}
t={t}
tCommon={tCommon}
testimonials={testimonials}
close={() => setOpen(false)}
/>
</div>
);
};
function selectHighlightPackageId(packages: Package[]): number | null {
const count = packages.length;
if (count <= 1) {
return null;
}
const sortedByPrice = [...packages].sort((a, b) => a.price - b.price);
if (count === 2) {
return sortedByPrice[1]?.id ?? null;
}
if (count === 3) {
return sortedByPrice[1]?.id ?? null;
}
return sortedByPrice[count - 2]?.id ?? null;
}
function isHighlightedPackage(pkg: Package, variant: 'endcustomer' | 'reseller') {
return variant === 'reseller' ? pkg.id === highlightResellerId : pkg.id === highlightEndcustomerId;
}
@@ -341,7 +440,7 @@ const getAccentTheme = (variant: 'endcustomer' | 'reseller') =>
buttonHighlight: 'bg-gray-900 text-white hover:bg-gray-800',
buttonDefault: 'border border-amber-200 text-amber-700 hover:bg-amber-50',
cardBorder: 'border border-amber-100',
highlightShadow: 'shadow-lg shadow-amber-100/60',
highlightShadow: 'shadow-lg shadow-amber-100/60 bg-gradient-to-br from-amber-50/70 via-white to-amber-100/60',
}
: {
badge: 'bg-rose-50 text-rose-700',
@@ -349,7 +448,7 @@ const getAccentTheme = (variant: 'endcustomer' | 'reseller') =>
buttonHighlight: 'bg-gray-900 text-white hover:bg-gray-800',
buttonDefault: 'border border-rose-100 text-rose-700 hover:bg-rose-50',
cardBorder: 'border border-rose-100',
highlightShadow: 'shadow-lg shadow-rose-100/60',
highlightShadow: 'shadow-lg shadow-rose-100/60 bg-gradient-to-br from-rose-50/70 via-white to-rose-100/60',
};
type PackageMetric = {
@@ -463,15 +562,80 @@ function PackageCard({
const keyFeatures = pkg.features.slice(0, 3);
const metrics = resolvePackageMetrics(pkg, variant, t, tCommon);
const metricList = compact ? (
<div className="flex flex-wrap gap-2">
{metrics.map((metric) => (
<div key={metric.key} className="rounded-full border border-gray-200 px-3 py-1 text-xs font-semibold text-gray-700">
<span className="text-[11px] font-medium uppercase text-gray-400">{metric.label}</span>
<span className="ml-1 text-gray-900">{metric.value}</span>
</div>
))}
</div>
) : (
<div className="grid grid-cols-2 gap-3 text-sm">
{metrics.map((metric) => (
<div key={metric.key} className="rounded-xl bg-gray-50 px-4 py-3">
<p className="text-lg font-semibold text-gray-900">{metric.value}</p>
<p className="text-xs uppercase tracking-wide text-gray-500">{metric.label}</p>
</div>
))}
</div>
);
const featureList = compact ? (
<ul className="space-y-1 text-sm text-gray-700">
{keyFeatures.map((feature) => (
<li key={feature} className="flex items-start gap-2 text-xs">
<Check className="mt-0.5 h-3.5 w-3.5 text-gray-900" />
<span>{t(`packages.feature_${feature}`)}</span>
</li>
))}
{pkg.watermark_allowed === false && (
<li className="flex items-start gap-2 text-xs">
<Shield className="mt-0.5 h-3.5 w-3.5 text-gray-900" />
<span>{t('packages.no_watermark')}</span>
</li>
)}
{pkg.branding_allowed && (
<li className="flex items-start gap-2 text-xs">
<Sparkles className="mt-0.5 h-3.5 w-3.5 text-gray-900" />
<span>{t('packages.custom_branding')}</span>
</li>
)}
</ul>
) : (
<ul className="space-y-2 text-sm text-gray-700">
{keyFeatures.map((feature) => (
<li key={feature} className="flex items-center gap-2">
<Check className="h-4 w-4 text-gray-900" />
<span>{t(`packages.feature_${feature}`)}</span>
</li>
))}
{pkg.watermark_allowed === false && (
<li className="flex items-center gap-2">
<Shield className="h-4 w-4 text-gray-900" />
<span>{t('packages.no_watermark')}</span>
</li>
)}
{pkg.branding_allowed && (
<li className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-gray-900" />
<span>{t('packages.custom_branding')}</span>
</li>
)}
</ul>
);
return (
<Card
className={cn(
'flex h-full flex-col rounded-2xl border border-gray-100 bg-white shadow-sm transition hover:shadow-lg',
compact && 'p-3',
highlight && `${accent.cardBorder} ${accent.highlightShadow}`,
className,
)}
>
<CardHeader className="gap-4">
<CardHeader className={cn('gap-4', compact && 'gap-3 p-0')}>
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
<span>{typeLabel}</span>
{badgeLabel && (
@@ -490,10 +654,10 @@ function PackageCard({
<CardDescription className="text-sm text-gray-600">{pkg.description}</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<CardContent className={cn('flex flex-col gap-6', compact && 'gap-4 p-0 pt-2')}>
<div>
<div className="flex items-baseline gap-2">
<span className={cn('text-4xl font-semibold', accent.price)}>{priceLabel}</span>
<div className={cn('flex items-baseline gap-2', compact && 'flex-wrap text-balance')}>
<span className={cn('text-4xl font-semibold', accent.price, compact && 'text-3xl')}>{priceLabel}</span>
{pkg.price !== 0 && (
<span className="text-sm text-gray-500">/ {cadenceLabel}</span>
)}
@@ -504,42 +668,17 @@ function PackageCard({
</p>
)}
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
{metrics.map((metric) => (
<div key={metric.key} className="rounded-xl bg-gray-50 px-4 py-3">
<p className="text-lg font-semibold text-gray-900">{metric.value}</p>
<p className="text-xs uppercase tracking-wide text-gray-500">{metric.label}</p>
</div>
))}
</div>
<ul className="space-y-2 text-sm text-gray-700">
{keyFeatures.map((feature) => (
<li key={feature} className="flex items-center gap-2">
<Check className="h-4 w-4 text-gray-900" />
<span>{t(`packages.feature_${feature}`)}</span>
</li>
))}
{pkg.watermark_allowed === false && (
<li className="flex items-center gap-2">
<Shield className="h-4 w-4 text-gray-900" />
<span>{t('packages.no_watermark')}</span>
</li>
)}
{pkg.branding_allowed && (
<li className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-gray-900" />
<span>{t('packages.custom_branding')}</span>
</li>
)}
</ul>
{metricList}
{featureList}
</CardContent>
{showCTA && onSelect && (
<CardFooter className="mt-auto">
<CardFooter className={cn('mt-auto', compact && 'pt-4')}>
<Button
onClick={() => onSelect(pkg)}
className={cn(
'w-full justify-center rounded-full text-sm font-semibold',
highlight ? accent.buttonHighlight : accent.buttonDefault,
compact && 'py-4 text-base',
)}
variant={highlight ? 'default' : 'outline'}
>
@@ -552,43 +691,215 @@ function PackageCard({
);
}
interface PackageDetailGridProps {
packageData: Package;
variant: 'endcustomer' | 'reseller';
isHighlight: boolean;
purchaseUrl: string;
onCtaClick: () => void;
t: TFunction;
tCommon: TFunction;
testimonials: { name: string; text: string; rating: number }[];
close: () => void;
}
const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
packageData,
variant,
isHighlight,
purchaseUrl,
onCtaClick,
t,
tCommon,
testimonials,
close,
}) => {
const metrics = resolvePackageMetrics(packageData, variant, t, tCommon);
return (
<div className="grid gap-8 lg:grid-cols-[320px,1fr]">
<div className="space-y-6">
<div className="rounded-2xl border border-gray-100 bg-gray-50 p-6">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium text-gray-500">{t('packages.price')}</p>
<p className="text-4xl font-semibold text-gray-900">
{Number(packageData.price).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
{t('packages.currency.euro')}
</p>
{packageData.price > 0 && (
<p className="text-sm text-gray-500">
/ {variant === 'reseller' ? t('packages.billing_per_year') : t('packages.billing_per_event')}
</p>
)}
</div>
{isHighlight && (
<span className="rounded-full bg-gray-900 px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-white">
{variant === 'reseller' ? t('packages.badge_best_value') : t('packages.badge_most_popular')}
</span>
)}
</div>
<div className="mt-6 grid grid-cols-2 gap-3 text-sm">
{metrics.map((metric) => (
<div key={metric.key} className="rounded-xl bg-white px-4 py-3 text-center shadow-sm">
<p className="text-lg font-semibold text-gray-900">{metric.value}</p>
<p className="text-xs uppercase tracking-wide text-gray-500">{metric.label}</p>
</div>
))}
</div>
<Button
asChild
className="mt-6 w-full justify-center rounded-full bg-rose-600/90 py-3 text-base font-semibold text-white shadow-lg shadow-rose-500/30 transition hover:bg-rose-600"
>
<Link
href={purchaseUrl}
onClick={() => {
onCtaClick();
}}
>
{t('packages.to_order')}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
<p className="mt-3 text-xs text-gray-500">{t('packages.order_hint')}</p>
</div>
<div className="rounded-2xl border border-gray-100 bg-white p-6">
<h3 className="text-lg font-semibold text-gray-900">{t('packages.feature_highlights')}</h3>
<ul className="mt-4 space-y-3 text-sm text-gray-700">
{packageData.features.slice(0, 5).map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check className="mt-1 h-4 w-4 text-gray-900" />
<span>{t(`packages.feature_${feature}`)}</span>
</li>
))}
{packageData.watermark_allowed === false && (
<li className="flex items-start gap-2">
<Shield className="mt-1 h-4 w-4 text-gray-900" />
<span>{t('packages.no_watermark')}</span>
</li>
)}
{packageData.branding_allowed && (
<li className="flex items-start gap-2">
<Sparkles className="mt-1 h-4 w-4 text-gray-900" />
<span>{t('packages.custom_branding')}</span>
</li>
)}
</ul>
</div>
<div className="rounded-2xl border border-gray-100 bg-white p-6">
<h3 className="text-lg font-semibold text-gray-900">{t('packages.more_details_tab')}</h3>
<Tabs defaultValue="breakdown">
<TabsList className="grid grid-cols-2 rounded-full bg-gray-100 p-1 text-sm">
<TabsTrigger value="breakdown" className="rounded-full">
{t('packages.breakdown_label')}
</TabsTrigger>
<TabsTrigger value="testimonials" className="rounded-full">
{t('packages.testimonials_title')}
</TabsTrigger>
</TabsList>
<TabsContent value="breakdown" className="mt-6">
{packageData.description_breakdown?.length ? (
<Accordion type="multiple" className="space-y-4">
{packageData.description_breakdown.map((entry, index) => (
<AccordionItem key={index} value={`detail-${index}`} className="rounded-2xl border border-gray-100 bg-white px-4">
<AccordionTrigger className="text-left text-base font-medium text-gray-900 hover:no-underline">
{entry.title ?? t('packages.limits_label')}
</AccordionTrigger>
<AccordionContent className="pb-4 text-sm text-gray-600 whitespace-pre-line">
{entry.value}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
) : (
<p className="text-sm text-gray-500">{t('packages.breakdown_label_hint')}</p>
)}
</TabsContent>
<TabsContent value="testimonials" className="mt-6">
<div className="space-y-4">
{testimonials.map((testimonial, index) => (
<div key={index} className="rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-gray-900">{testimonial.name}</p>
<p className="text-xs text-gray-500">{packageData.name}</p>
</div>
<div className="flex gap-1 text-amber-400">
{[...Array(testimonial.rating)].map((_, i) => (
<Star key={i} className="h-3.5 w-3.5" fill="currentColor" />
))}
</div>
</div>
<p className="mt-3 text-sm text-gray-600">{testimonial.text}</p>
</div>
))}
<Button variant="outline" className="w-full rounded-full border-gray-200 text-sm font-medium text-gray-700" onClick={close}>
{t('packages.close')}
</Button>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</div>
);
};
return (
<MarketingLayout title={t('packages.title')}>
<section className="bg-aurora-enhanced text-gray-900 dark:text-gray-100 py-20 px-4">
<div className="container mx-auto text-center space-y-8">
<div className="space-y-4">
<p className="text-sm uppercase tracking-[0.2em] text-gray-600 dark:text-gray-300">
{t('packages.for_endcustomers')}
<section className="bg-aurora-enhanced text-gray-900 dark:text-gray-100 px-4 py-12 md:py-16">
<div className="container mx-auto text-center space-y-6 md:space-y-8">
<div className="space-y-3 md:space-y-4">
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-gray-600 dark:text-gray-300">
{t('packages.for_endcustomers')} · {t('packages.for_resellers')}
</p>
<h1 className="text-4xl md:text-6xl font-bold font-display">{t('packages.hero_title')}</h1>
<p className="text-xl md:text-2xl max-w-3xl mx-auto font-sans-marketing text-gray-700 dark:text-gray-200">
<h1 className="text-3xl font-bold font-display md:text-5xl">
{t('packages.hero_title')}
</h1>
<p className="mx-auto max-w-2xl font-sans-marketing text-base text-gray-700 dark:text-gray-200 md:text-xl">
{t('packages.hero_description')}
</p>
</div>
<div className="flex flex-col items-center gap-4">
<Link
href="/de/demo"
<div className="flex flex-wrap items-center justify-center gap-3">
<Button
asChild
size="lg"
className="rounded-full bg-gray-900 text-white shadow-lg shadow-gray-900/20 hover:bg-gray-800"
onClick={() => {
trackPackagesHeroClick();
trackEvent({
category: 'marketing_packages',
action: 'hero_cta',
name: `demo:${packagesHeroVariant}`,
name: `scroll:${packagesHeroVariant}`,
});
}}
className="inline-flex items-center gap-2 rounded-full bg-gradient-to-r from-rose-500 via-pink-500 to-amber-400 px-10 py-4 text-lg font-semibold text-white shadow-xl shadow-rose-500/40 transition hover:from-rose-500/95 hover:via-pink-500/95 hover:to-amber-400/95"
>
{t('packages.cta_demo')}
<ArrowRight className="h-5 w-5" />
</Link>
<p className="text-sm text-gray-600 dark:text-gray-300">
{t('packages.hero_secondary')}
</p>
<a href="#packages-showcase">
{t('packages.cta_explore')}
<ArrowRight className="ml-2 inline h-4 w-4" aria-hidden />
</a>
</Button>
<Button
asChild
size="lg"
variant="outline"
className="rounded-full border-white/40 bg-white/30 text-gray-900 backdrop-blur hover:bg-white/50 dark:border-gray-800 dark:bg-gray-900/40 dark:text-gray-100"
>
<Link href={localizedPath('/kontakt')}>
{t('packages.contact_us')}
</Link>
</Button>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300">
{t('packages.hero_secondary')}
</p>
</div>
</section>
<section className="py-20 px-4">
<section id="packages-showcase" className="px-4 py-16 md:py-20">
<div className="container mx-auto space-y-12">
<Tabs defaultValue="endcustomer" className="space-y-8">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
@@ -604,41 +915,85 @@ function PackageCard({
</div>
<TabsContent value="endcustomer" className="space-y-8">
<div className="overflow-x-auto pb-4">
<div className="flex min-w-full gap-6 md:grid md:grid-cols-2">
{endcustomerPackages.map((pkg) => (
<PackageCard
key={pkg.id}
pkg={pkg}
variant="endcustomer"
highlight={pkg.id === highlightEndcustomerId}
onSelect={(selected) => handleCardClick(selected, 'endcustomer')}
className="min-w-[280px]"
compact
/>
))}
<div className="md:hidden">
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-white to-transparent dark:from-gray-950" />
<div className="pointer-events-none absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-white to-transparent dark:from-gray-950" />
<div
ref={mobileEndcustomerRef}
className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-6"
style={{ scrollPaddingLeft: '16px', scrollBehavior: 'smooth' }}
>
{orderedEndcustomerPackages.map((pkg) => (
<div key={pkg.id} className="snap-start basis-[72vw] shrink-0 sm:basis-[60vw]">
<PackageCard
pkg={pkg}
variant="endcustomer"
highlight={pkg.id === highlightEndcustomerId}
onSelect={(selected) => handleCardClick(selected, 'endcustomer')}
className="h-full"
compact
/>
</div>
))}
</div>
</div>
</div>
<PackageComparison packages={endcustomerPackages} variant="endcustomer" />
<div className="hidden gap-6 md:grid md:grid-cols-2 xl:grid-cols-3">
{orderedEndcustomerPackages.map((pkg) => (
<PackageCard
key={pkg.id}
pkg={pkg}
variant="endcustomer"
highlight={pkg.id === highlightEndcustomerId}
onSelect={(selected) => handleCardClick(selected, 'endcustomer')}
className="h-full"
compact
/>
))}
</div>
<PackageComparison packages={orderedEndcustomerPackages} variant="endcustomer" />
</TabsContent>
<TabsContent value="reseller" className="space-y-8">
<div className="overflow-x-auto pb-4">
<div className="flex min-w-full gap-6 md:grid md:grid-cols-2 lg:grid-cols-3">
{resellerPackages.map((pkg) => (
<PackageCard
key={pkg.id}
pkg={pkg}
variant="reseller"
highlight={pkg.id === highlightResellerId}
onSelect={(selected) => handleCardClick(selected, 'reseller')}
className="min-w-[280px]"
compact
/>
))}
<div className="md:hidden">
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-white to-transparent dark:from-gray-950" />
<div className="pointer-events-none absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-white to-transparent dark:from-gray-950" />
<div
ref={mobileResellerRef}
className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-6"
style={{ scrollPaddingLeft: '16px', scrollBehavior: 'smooth' }}
>
{orderedResellerPackages.map((pkg) => (
<div key={pkg.id} className="snap-start basis-[72vw] shrink-0 sm:basis-[60vw]">
<PackageCard
pkg={pkg}
variant="reseller"
highlight={pkg.id === highlightResellerId}
onSelect={(selected) => handleCardClick(selected, 'reseller')}
className="h-full"
compact
/>
</div>
))}
</div>
</div>
</div>
<PackageComparison packages={resellerPackages} variant="reseller" />
<div className="hidden gap-6 md:grid md:grid-cols-2 xl:grid-cols-3">
{orderedResellerPackages.map((pkg) => (
<PackageCard
key={pkg.id}
pkg={pkg}
variant="reseller"
highlight={pkg.id === highlightResellerId}
onSelect={(selected) => handleCardClick(selected, 'reseller')}
className="h-full"
compact
/>
))}
</div>
<PackageComparison packages={orderedResellerPackages} variant="reseller" />
</TabsContent>
</Tabs>
</div>
@@ -670,208 +1025,41 @@ function PackageCard({
</div>
</section>
{/* Modal */}
{/* Details overlay */}
{selectedPackage && (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className="max-w-6xl border border-gray-100 bg-white px-0 py-0 sm:rounded-[32px]"
onOpenAutoFocus={(event) => {
event.preventDefault();
dialogScrollRef.current?.scrollTo({ top: 0 });
dialogHeadingRef.current?.focus();
}}
>
<div ref={dialogScrollRef} className="max-h-[88vh] overflow-y-auto space-y-8 p-6 md:p-10">
<div ref={dialogHeadingRef} tabIndex={-1} className="outline-none">
<DialogHeader className="space-y-3 text-left">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-gray-400">
{selectedVariant === 'reseller' ? t('packages.subscription') : t('packages.one_time')}
</p>
<DialogTitle className="text-3xl font-display text-gray-900">
{selectedPackage.name}
</DialogTitle>
<p className="text-base text-gray-600">{selectedPackage.description}</p>
</DialogHeader>
</div>
<div className="grid gap-8 lg:grid-cols-[320px,1fr]">
<div className="space-y-6">
<div className="rounded-2xl border border-gray-100 bg-gray-50 p-6">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium text-gray-500">{t('packages.price')}</p>
<p className="text-4xl font-semibold text-gray-900">
{Number(selectedPackage.price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {t('packages.currency.euro')}
</p>
{selectedPackage.price > 0 && (
<p className="text-sm text-gray-500">
/ {selectedVariant === 'reseller' ? t('packages.billing_per_year') : t('packages.billing_per_event')}
</p>
)}
</div>
{selectedHighlight && (
<span className="rounded-full bg-gray-900 px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-white">
{selectedVariant === 'reseller'
? t('packages.badge_best_value')
: t('packages.badge_most_popular')}
</span>
)}
</div>
<div className="mt-6 grid grid-cols-2 gap-3 text-sm">
{resolvePackageMetrics(selectedPackage, selectedVariant, t, tCommon).map((metric) => (
<div key={metric.key} className="rounded-xl bg-white px-4 py-3 text-center shadow-sm">
<p className="text-lg font-semibold text-gray-900">{metric.value}</p>
<p className="text-xs uppercase tracking-wide text-gray-500">{metric.label}</p>
</div>
))}
</div>
<Button
asChild
className="mt-6 w-full justify-center rounded-full bg-rose-600/90 py-3 text-base font-semibold text-white shadow-lg shadow-rose-500/30 transition hover:bg-rose-600"
>
<Link
href={purchaseUrl}
onClick={() => {
handleCtaClick(selectedPackage, selectedVariant);
localStorage.setItem('preferred_package', JSON.stringify(selectedPackage));
}}
>
{t('packages.to_order')}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
<p className="mt-3 text-xs text-gray-500">{t('packages.order_hint')}</p>
</div>
<div className="rounded-2xl border border-gray-100 bg-white p-6">
<h3 className="text-lg font-semibold text-gray-900">{t('packages.feature_highlights')}</h3>
<ul className="mt-4 space-y-3 text-sm text-gray-700">
{selectedPackage.features.slice(0, 5).map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check className="mt-1 h-4 w-4 text-gray-900" />
<span>{t(`packages.feature_${feature}`)}</span>
</li>
))}
{selectedPackage.watermark_allowed === false && (
<li className="flex items-start gap-2">
<Shield className="mt-1 h-4 w-4 text-gray-900" />
<span>{t('packages.no_watermark')}</span>
</li>
)}
{selectedPackage.branding_allowed && (
<li className="flex items-start gap-2">
<Sparkles className="mt-1 h-4 w-4 text-gray-900" />
<span>{t('packages.custom_branding')}</span>
</li>
)}
</ul>
</div>
</div>
<div className="space-y-4">
<Tabs value={currentStep} onValueChange={setCurrentStep}>
<TabsList className="grid w-full grid-cols-3 rounded-full bg-gray-100 p-1 text-sm">
<TabsTrigger className="rounded-full" value="overview">
{t('packages.details')}
</TabsTrigger>
<TabsTrigger className="rounded-full" value="testimonials">
{t('packages.customer_opinions')}
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-6 space-y-6">
<div className="rounded-2xl border border-gray-100 bg-white p-6">
<h4 className="text-lg font-semibold text-gray-900">{t('packages.quick_facts')}</h4>
<p className="text-sm text-gray-500">{t('packages.quick_facts_hint')}</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{resolvePackageMetrics(selectedPackage, selectedVariant, t, tCommon).map((metric) => (
<div key={metric.key} className="rounded-xl bg-gray-50 p-4">
<p className="text-xl font-semibold text-gray-900">{metric.value}</p>
<p className="text-xs uppercase tracking-wide text-gray-500">{metric.label}</p>
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-gray-100 bg-white p-6 space-y-3">
<h4 className="text-sm font-semibold text-gray-900">{t('packages.feature_highlights')}</h4>
<ul className="space-y-2 text-sm text-gray-700">
{selectedPackage.features.slice(0, 4).map((feature) => (
<li key={feature} className="flex items-center gap-2">
<Check className="h-4 w-4 text-gray-900" />
<span>{t(`packages.feature_${feature}`)}</span>
</li>
))}
{selectedPackage.watermark_allowed === false && (
<li className="flex items-center gap-2">
<Shield className="h-4 w-4 text-gray-900" />
<span>{t('packages.no_watermark')}</span>
</li>
)}
{selectedPackage.branding_allowed && (
<li className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-gray-900" />
<span>{t('packages.custom_branding')}</span>
</li>
)}
</ul>
</div>
</TabsContent>
<TabsContent value="deep" className="mt-6 space-y-6">
{selectedPackage.description_breakdown?.length ? (
<Accordion type="multiple" className="space-y-4">
{selectedPackage.description_breakdown.map((entry, index) => (
<AccordionItem key={index} value={`detail-${index}`} className="rounded-2xl border border-gray-100 bg-white px-4">
<AccordionTrigger className="text-left text-base font-medium text-gray-900 hover:no-underline">
{entry.title ?? t('packages.limits_label')}
</AccordionTrigger>
<AccordionContent className="pb-4 text-sm text-gray-600 whitespace-pre-line">
{entry.value}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
) : (
<p className="text-sm text-gray-500">{t('packages.breakdown_label_hint')}</p>
)}
</TabsContent>
<TabsContent value="testimonials" className="mt-6">
<div className="space-y-4">
{testimonials.map((testimonial, index) => (
<div
key={index}
className="rounded-2xl border border-gray-100 bg-white p-5 shadow-sm"
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-gray-900">{testimonial.name}</p>
<p className="text-xs text-gray-500">{selectedPackage.name}</p>
</div>
<div className="flex gap-1 text-amber-400">
{[...Array(testimonial.rating)].map((_, i) => (
<Star key={i} className="h-3.5 w-3.5" fill="currentColor" />
))}
</div>
</div>
<p className="mt-3 text-sm text-gray-600">{testimonial.text}</p>
</div>
))}
<Button
variant="outline"
className="w-full rounded-full border-gray-200 text-sm font-medium text-gray-700"
onClick={() => setOpen(false)}
>
{t('packages.close')}
</Button>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)}
{/* Testimonials Section entfernt, da nun im Dialog */}
isMobile ? (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="bottom"
className="h-[90vh] overflow-hidden rounded-t-[32px] border border-gray-200 bg-white p-0"
onOpenAutoFocus={handleDetailAutoFocus}
>
{renderDetailBody('h-full overflow-y-auto space-y-8 p-6')}
</SheetContent>
</Sheet>
) : (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className="max-w-6xl border border-gray-100 bg-white px-0 py-0 sm:rounded-[32px]"
onOpenAutoFocus={handleDetailAutoFocus}
>
{renderDetailBody('max-h-[88vh] overflow-y-auto space-y-8 p-6 md:p-10')}
</DialogContent>
</Dialog>
)
)} {/* Testimonials Section entfernt, da nun im Dialog */}
</MarketingLayout>
);
};
const handleDetailAutoFocus = (event: Event) => {
event.preventDefault();
dialogScrollRef.current?.scrollTo({ top: 0 });
dialogHeadingRef.current?.focus();
};
Packages.layout = (page: React.ReactNode) => page;
export default Packages;

View File

@@ -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<string, string> = {
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 (
<Card className={`shadow-sm ${isFree ? 'opacity-75' : ''}`}>
<Card className={cn('shadow-sm transition', isFree ? 'opacity-75' : accentGradient)}>
<CardHeader className="space-y-1">
<CardTitle className={`flex items-center gap-3 text-2xl ${isFree ? 'text-muted-foreground' : ''}`}>
<PackageIcon className={`h-6 w-6 ${isFree ? 'text-muted-foreground' : 'text-primary'}`} />
@@ -55,7 +90,9 @@ function PackageSummary({ pkg, t }: { pkg: CheckoutPackage; t: TFunction<'market
{pkg.description_breakdown.map((row, index) => (
<div key={index} className="rounded-lg border border-muted/40 bg-muted/20 px-3 py-2">
{row.title && (
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{row.title}</p>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{translateDetailLabel(row.title, t)}
</p>
)}
<p className="text-sm text-muted-foreground">{row.value}</p>
</div>
@@ -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 (
<button
type="button"
onClick={onSelect}
className={`w-full rounded-md border bg-background p-4 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 ${
className={cn(
'w-full rounded-md border bg-background p-4 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40',
isActive
? "border-primary shadow-sm"
? accentGradient
: isFree
? "border-border hover:border-primary/40 opacity-75 hover:opacity-100"
: "border-border hover:border-primary/40"
}`}
? 'border-border hover:border-primary/40 opacity-75 hover:opacity-100'
: 'border-border hover:border-primary/40',
)}
>
<div className="flex items-center justify-between text-sm font-medium">
<span className={isFree ? "text-muted-foreground" : ""}>{pkg.name}</span>

View File

@@ -47,7 +47,11 @@ Route::middleware('auth')->group(function () {
->name('verification.notice');
Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)
->middleware(['signed', 'throttle:6,1'])
->middleware([
\App\Http\Middleware\NormalizeSignedUrlParameters::class,
'signed:relative',
'throttle:6,1',
])
->name('verification.verify');
Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store'])

View File

@@ -221,6 +221,14 @@ Route::get('/demo', function (Request $request) use ($determinePreferredLocale)
return redirect("/{$locale}/demo", 301);
});
Route::get('/marketing/login', function (Request $request) use ($determinePreferredLocale) {
$locale = $determinePreferredLocale($request);
$destination = "/{$locale}/login";
$query = $request->getQueryString();
return redirect($query ? "{$destination}?{$query}" : $destination, 302);
})->name('marketing.login');
Route::get('/success/{packageId?}', function (Request $request, ?int $packageId = null) use ($determinePreferredLocale) {
$locale = $determinePreferredLocale($request);
$path = "/{$locale}/success";

View File

@@ -31,14 +31,37 @@ class EmailVerificationTest extends TestCase
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
['id' => $user->id, 'hash' => sha1($user->email)],
absolute: false,
);
$response = $this->actingAs($user)->get($verificationUrl);
Event::assertDispatched(Verified::class);
$this->assertTrue($user->fresh()->hasVerifiedEmail());
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
$response->assertRedirect(route('marketing.login', absolute: false).'?verified=1');
}
public function test_email_can_be_verified_when_link_contains_html_encoded_ampersand(): void
{
$user = User::factory()->unverified()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)],
absolute: false,
);
$encodedUrl = str_replace('&', '&amp;', $verificationUrl);
$this->actingAs($user)->get($encodedUrl)
->assertRedirect(route('marketing.login', absolute: false).'?verified=1');
$this->assertTrue($user->fresh()->hasVerifiedEmail());
Event::assertDispatched(Verified::class);
}
public function test_email_is_not_verified_with_invalid_hash()
@@ -48,7 +71,8 @@ class EmailVerificationTest extends TestCase
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1('wrong-email')]
['id' => $user->id, 'hash' => sha1('wrong-email')],
absolute: false,
);
$this->actingAs($user)->get($verificationUrl);
@@ -65,7 +89,8 @@ class EmailVerificationTest extends TestCase
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => 123, 'hash' => sha1($user->email)]
['id' => 123, 'hash' => sha1($user->email)],
absolute: false,
);
$this->actingAs($user)->get($verificationUrl);
@@ -95,13 +120,36 @@ class EmailVerificationTest extends TestCase
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
['id' => $user->id, 'hash' => sha1($user->email)],
absolute: false,
);
$this->actingAs($user)->get($verificationUrl)
->assertRedirect(route('dashboard', absolute: false).'?verified=1');
->assertRedirect(route('marketing.login', absolute: false).'?verified=1');
$this->assertTrue($user->fresh()->hasVerifiedEmail());
Event::assertNotDispatched(Verified::class);
}
public function test_invalid_signature_redirects_to_verification_prompt(): void
{
$user = User::factory()->unverified()->create();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)],
absolute: false,
);
$tampered = $verificationUrl.'-tamper';
$response = $this->actingAs($user)->get($tampered);
$response->assertRedirect(route('verification.notice', absolute: false));
$response->assertSessionHas('verification', function ($flash): bool {
return is_array($flash)
&& ($flash['status'] ?? null) === 'error';
});
}
}