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:
Binary file not shown.
@@ -9,6 +9,7 @@ use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
|||||||
use Illuminate\Http\Client\ConnectionException as HttpClientConnectionException;
|
use Illuminate\Http\Client\ConnectionException as HttpClientConnectionException;
|
||||||
use Illuminate\Queue\InvalidQueueException;
|
use Illuminate\Queue\InvalidQueueException;
|
||||||
use Illuminate\Queue\MaxAttemptsExceededException;
|
use Illuminate\Queue\MaxAttemptsExceededException;
|
||||||
|
use Illuminate\Routing\Exceptions\InvalidSignatureException;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use League\Flysystem\FilesystemException;
|
use League\Flysystem\FilesystemException;
|
||||||
use PDOException;
|
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 (! $request->expectsJson() && ! $request->inertia()) {
|
||||||
if ($hintKey = $this->resolveServerErrorHint($e)) {
|
if ($hintKey = $this->resolveServerErrorHint($e)) {
|
||||||
$request->attributes->set('serverErrorHint', __($hintKey, [], app()->getLocale()));
|
$request->attributes->set('serverErrorHint', __($hintKey, [], app()->getLocale()));
|
||||||
|
|||||||
@@ -15,8 +15,18 @@ class EmailVerificationPromptController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function __invoke(Request $request): Response|RedirectResponse
|
public function __invoke(Request $request): Response|RedirectResponse
|
||||||
{
|
{
|
||||||
return $request->user()->hasVerifiedEmail()
|
if ($request->user()->hasVerifiedEmail()) {
|
||||||
? redirect()->intended(route('dashboard', absolute: false))
|
$redirectToCheckout = $request->session()->pull('checkout.verify_redirect');
|
||||||
: Inertia::render('auth/verify-email', ['status' => $request->session()->get('status')]);
|
|
||||||
|
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')]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,61 @@ class VerifyEmailController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
if ($request->user()->hasVerifiedEmail()) {
|
if (! $request->user()->hasVerifiedEmail()) {
|
||||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
$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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class CheckoutController extends Controller
|
|||||||
|
|
||||||
$package = Package::findOrFail($request->package_id);
|
$package = Package::findOrFail($request->package_id);
|
||||||
$validated = $validator->validated();
|
$validated = $validator->validated();
|
||||||
DB::transaction(function () use ($request, $package, $validated) {
|
$user = DB::transaction(function () use ($request, $package, $validated) {
|
||||||
|
|
||||||
// User erstellen
|
// User erstellen
|
||||||
$user = User::create([
|
$user = User::create([
|
||||||
@@ -138,10 +138,28 @@ class CheckoutController extends Controller
|
|||||||
Mail::to($user)
|
Mail::to($user)
|
||||||
->locale($user->preferred_locale ?? app()->getLocale())
|
->locale($user->preferred_locale ?? app()->getLocale())
|
||||||
->queue(new Welcome($user));
|
->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([
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
'message' => 'Registrierung erfolgreich. Bitte überprüfen Sie Ihre E-Mail zur Verifizierung.',
|
'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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
* These middleware may be assigned to groups or used individually.
|
||||||
*
|
*
|
||||||
* @var array<string, class-string|string>
|
* @var array<string, class-string|string>
|
||||||
*/
|
*/
|
||||||
protected $routeMiddleware = [
|
protected $middlewareAliases = [
|
||||||
'auth' => \App\Http\Middleware\Authenticate::class,
|
'auth' => \App\Http\Middleware\Authenticate::class,
|
||||||
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
|
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
|
||||||
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
|
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'profile' => __('profile'),
|
'profile' => __('profile'),
|
||||||
'dashboard' => __('dashboard'),
|
'dashboard' => __('dashboard'),
|
||||||
],
|
],
|
||||||
|
'flash' => [
|
||||||
|
'verification' => fn () => $request->session()->get('verification'),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
app/Http/Middleware/NormalizeSignedUrlParameters.php
Normal file
29
app/Http/Middleware/NormalizeSignedUrlParameters.php
Normal 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, '&')) {
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Http/Middleware/ValidateSignature.php
Normal file
33
app/Http/Middleware/ValidateSignature.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,10 @@ use App\Models\Tenant;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Policies\PurchaseHistoryPolicy;
|
use App\Policies\PurchaseHistoryPolicy;
|
||||||
use App\Policies\TenantPolicy;
|
use App\Policies\TenantPolicy;
|
||||||
|
use Illuminate\Auth\Notifications\VerifyEmail;
|
||||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
class AuthServiceProvider extends ServiceProvider
|
class AuthServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -29,6 +31,20 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
$this->registerPolicies();
|
$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 {
|
Gate::before(function (User $user): ?bool {
|
||||||
return $user->role === 'super_admin' ? true : null;
|
return $user->role === 'super_admin' ? true : null;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,10 +60,21 @@
|
|||||||
"phone_placeholder": "+49 170 1234567",
|
"phone_placeholder": "+49 170 1234567",
|
||||||
"username_placeholder": "z. B. hochzeit_julia",
|
"username_placeholder": "z. B. hochzeit_julia",
|
||||||
"password_placeholder": "Mindestens 8 Zeichen",
|
"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": {
|
"verification": {
|
||||||
"notice": "Bitte bestätigen Sie Ihre E-Mail-Adresse.",
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,14 @@
|
|||||||
"order_hint": "Sofort startklar – keine versteckten Kosten, sichere Zahlung über Paddle.",
|
"order_hint": "Sofort startklar – keine versteckten Kosten, sichere Zahlung über Paddle.",
|
||||||
"features_label": "Features",
|
"features_label": "Features",
|
||||||
"feature_highlights": "Feature-Highlights",
|
"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",
|
"more_details_tab": "Mehr Details",
|
||||||
"quick_facts": "Schnelle Fakten",
|
"quick_facts": "Schnelle Fakten",
|
||||||
"quick_facts_hint": "Der schnelle Überblick über die wichtigsten Kennzahlen.",
|
"quick_facts_hint": "Der schnelle Überblick über die wichtigsten Kennzahlen.",
|
||||||
|
|||||||
@@ -60,10 +60,21 @@
|
|||||||
"phone_placeholder": "+1 555 123 4567",
|
"phone_placeholder": "+1 555 123 4567",
|
||||||
"username_placeholder": "e.g. wedding_julia",
|
"username_placeholder": "e.g. wedding_julia",
|
||||||
"password_placeholder": "At least 8 characters",
|
"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": {
|
"verification": {
|
||||||
"notice": "Please verify your email address.",
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,14 @@
|
|||||||
"order_hint": "Launch instantly – secure Paddle checkout, no hidden fees.",
|
"order_hint": "Launch instantly – secure Paddle checkout, no hidden fees.",
|
||||||
"features_label": "Features",
|
"features_label": "Features",
|
||||||
"feature_highlights": "Feature Highlights",
|
"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",
|
"more_details_tab": "More Details",
|
||||||
"quick_facts": "Quick Facts",
|
"quick_facts": "Quick Facts",
|
||||||
"quick_facts_hint": "Your at-a-glance snapshot of core limits.",
|
"quick_facts_hint": "Your at-a-glance snapshot of core limits.",
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ interface CarouselProps {
|
|||||||
const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
|
const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
|
||||||
({ opts, plugins = [Autoplay()], setApi, className, children, ...props }, ref) => {
|
({ opts, plugins = [Autoplay()], setApi, className, children, ...props }, ref) => {
|
||||||
const [api, setApiInternal] = React.useState<CarouselApi | null>(null)
|
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)
|
const [emblaRef] = useEmblaCarousel(opts, plugins)
|
||||||
|
|
||||||
@@ -41,18 +39,6 @@ const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
|
|||||||
return
|
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)
|
setApi?.(api)
|
||||||
}, [api, setApi])
|
}, [api, setApi])
|
||||||
|
|
||||||
@@ -64,9 +50,6 @@ const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
|
|||||||
"relative w-full",
|
"relative w-full",
|
||||||
className
|
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}
|
{...props}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -37,11 +37,37 @@ type RegisterFormFields = {
|
|||||||
package_id: number | null;
|
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) {
|
export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale, prefill, onClearGoogleProfile }: RegisterFormProps) {
|
||||||
const [privacyOpen, setPrivacyOpen] = useState(false);
|
const [privacyOpen, setPrivacyOpen] = useState(false);
|
||||||
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
|
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [prefillApplied, setPrefillApplied] = 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 { t } = useTranslation(['auth', 'common']);
|
||||||
const page = usePage<{ errors: Record<string, string>; locale?: string; auth?: { user?: any | null } }>();
|
const page = usePage<{ errors: Record<string, string>; locale?: string; auth?: { user?: any | null } }>();
|
||||||
const resolvedLocale = locale ?? page.props.locale ?? 'de';
|
const resolvedLocale = locale ?? page.props.locale ?? 'de';
|
||||||
@@ -68,6 +94,30 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
|||||||
|
|
||||||
const registerEndpoint = '/checkout/register';
|
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 namePrefill = useMemo(() => {
|
||||||
const rawFirst = prefill?.given_name ?? prefill?.name?.split(' ')[0] ?? '';
|
const rawFirst = prefill?.given_name ?? prefill?.name?.split(' ')[0] ?? '';
|
||||||
const remaining = prefill?.name ? prefill.name.split(' ').slice(1).join(' ') : '';
|
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) => {
|
const submit = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
setServerError(null);
|
||||||
|
setServerErrorType('generic');
|
||||||
setHasTriedSubmit(true);
|
setHasTriedSubmit(true);
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
clearErrors();
|
clearErrors();
|
||||||
|
|
||||||
|
const csrfToken = resolveCsrfToken();
|
||||||
const body = {
|
const body = {
|
||||||
...data,
|
...data,
|
||||||
locale: resolvedLocale,
|
locale: resolvedLocale,
|
||||||
package_id: data.package_id ?? packageId ?? null,
|
package_id: data.package_id ?? packageId ?? null,
|
||||||
|
_token: csrfToken,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const response = await fetch(registerEndpoint, {
|
const response = await fetch(registerEndpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Accept: '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',
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -179,10 +233,32 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
|||||||
return;
|
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) {
|
} catch (error) {
|
||||||
console.error('Register request failed', 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 {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -463,10 +539,21 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
|||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={submit}
|
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"
|
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" />}
|
{isSubmitting && <LoaderCircle className="h-4 w-4 animate-spin mr-2" />}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||||
import type { ReactNode } 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 { useTranslation } from 'react-i18next';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
import TextLink from '@/components/text-link';
|
import TextLink from '@/components/text-link';
|
||||||
@@ -13,6 +13,7 @@ import AppLayout from '@/layouts/app/AppLayout';
|
|||||||
import { register } from '@/routes';
|
import { register } from '@/routes';
|
||||||
import { request } from '@/routes/password';
|
import { request } from '@/routes/password';
|
||||||
import { LoaderCircle } from 'lucide-react';
|
import { LoaderCircle } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
interface LoginProps {
|
interface LoginProps {
|
||||||
status?: string;
|
status?: string;
|
||||||
@@ -24,6 +25,8 @@ export default function Login({ status, canResetPassword }: LoginProps) {
|
|||||||
const [rawReturnTo, setRawReturnTo] = useState<string | null>(null);
|
const [rawReturnTo, setRawReturnTo] = useState<string | null>(null);
|
||||||
const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false);
|
const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false);
|
||||||
const { t } = useTranslation('auth');
|
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({
|
const { data, setData, post, processing, errors, clearErrors } = useForm({
|
||||||
login: '',
|
login: '',
|
||||||
@@ -50,7 +53,15 @@ export default function Login({ status, canResetPassword }: LoginProps) {
|
|||||||
|
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
setRawReturnTo(searchParams.get('return_to'));
|
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(() => {
|
useEffect(() => {
|
||||||
setData('return_to', rawReturnTo ?? '');
|
setData('return_to', rawReturnTo ?? '');
|
||||||
@@ -213,6 +224,20 @@ export default function Login({ status, canResetPassword }: LoginProps) {
|
|||||||
</div>
|
</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 && (
|
{hasErrors && (
|
||||||
<div
|
<div
|
||||||
key={`general-errors-${errorKeys.join('-')}`}
|
key={`general-errors-${errorKeys.join('-')}`}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Components
|
// Components
|
||||||
import { store } from '@/actions/App/Http/Controllers/Auth/EmailVerificationNotificationController';
|
import { store } from '@/actions/App/Http/Controllers/Auth/EmailVerificationNotificationController';
|
||||||
import { logout } from '@/routes';
|
import { logout } from '@/routes';
|
||||||
import { Form, Head } from '@inertiajs/react';
|
import { Form, Head, usePage } from '@inertiajs/react';
|
||||||
import { LoaderCircle } from 'lucide-react';
|
import { LoaderCircle } from 'lucide-react';
|
||||||
|
|
||||||
import TextLink from '@/components/text-link';
|
import TextLink from '@/components/text-link';
|
||||||
@@ -13,11 +13,26 @@ export default function VerifyEmail({ status }: { status?: string }) {
|
|||||||
const description = isNewRegistration
|
const description = isNewRegistration
|
||||||
? 'Thanks! Please confirm your email address to access your dashboard.'
|
? '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.';
|
: '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 (
|
return (
|
||||||
<AuthLayout title="Verify email" description={description}>
|
<AuthLayout title="Verify email" description={description}>
|
||||||
<Head title="Email verification" />
|
<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 && (
|
{isNewRegistration && (
|
||||||
<div className="mb-6 rounded-md bg-blue-50 p-4 text-left text-sm text-blue-900">
|
<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>
|
<p className="font-semibold">Almost there! Confirm your email address to start using Fotospiel.</p>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import React from "react";
|
import React, { useEffect } from "react";
|
||||||
import { Head, usePage } from "@inertiajs/react";
|
import { Head, usePage } from "@inertiajs/react";
|
||||||
import MarketingLayout from "@/layouts/mainWebsite";
|
import MarketingLayout from "@/layouts/mainWebsite";
|
||||||
import type { CheckoutPackage, GoogleProfilePrefill } from "./checkout/types";
|
import type { CheckoutPackage, GoogleProfilePrefill } from "./checkout/types";
|
||||||
import { CheckoutWizard } from "./checkout/CheckoutWizard";
|
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 {
|
interface CheckoutWizardPageProps {
|
||||||
package: CheckoutPackage;
|
package: CheckoutPackage;
|
||||||
@@ -26,9 +29,11 @@ const CheckoutWizardPage: React.FC<CheckoutWizardPageProps> = ({
|
|||||||
googleAuth,
|
googleAuth,
|
||||||
paddle,
|
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 currentUser = page.props.auth?.user ?? null;
|
||||||
const googleProfile = googleAuth?.profile ?? null;
|
const googleProfile = googleAuth?.profile ?? null;
|
||||||
|
const { t: tAuth } = useTranslation('auth');
|
||||||
|
const verificationFlash = page.props.flash?.verification;
|
||||||
|
|
||||||
|
|
||||||
const dedupedOptions = React.useMemo(() => {
|
const dedupedOptions = React.useMemo(() => {
|
||||||
@@ -43,11 +48,37 @@ const CheckoutWizardPage: React.FC<CheckoutWizardPageProps> = ({
|
|||||||
});
|
});
|
||||||
}, [initialPackage, packageOptions]);
|
}, [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 (
|
return (
|
||||||
<MarketingLayout title="Checkout Wizard">
|
<MarketingLayout title="Checkout Wizard">
|
||||||
<Head title="Checkout Wizard" />
|
<Head title="Checkout Wizard" />
|
||||||
<div className="min-h-screen bg-muted/20 py-12">
|
<div className="min-h-screen bg-muted/20 py-12">
|
||||||
<div className="mx-auto w-full max-w-4xl px-4">
|
<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
|
<CheckoutWizard
|
||||||
initialPackage={initialPackage}
|
initialPackage={initialPackage}
|
||||||
packageOptions={dedupedOptions}
|
packageOptions={dedupedOptions}
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { Separator } from '@/components/ui/separator';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import MarketingLayout from '@/layouts/mainWebsite';
|
import MarketingLayout from '@/layouts/mainWebsite';
|
||||||
import { useAnalytics } from '@/hooks/useAnalytics';
|
import { useAnalytics } from '@/hooks/useAnalytics';
|
||||||
import { useCtaExperiment } from '@/hooks/useCtaExperiment';
|
import { useCtaExperiment } from '@/hooks/useCtaExperiment';
|
||||||
|
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||||
import { ArrowRight, ShoppingCart, Check, Users, Image, Shield, Star, Sparkles } from 'lucide-react';
|
import { ArrowRight, ShoppingCart, Check, Users, Image, Shield, Star, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
interface Package {
|
interface Package {
|
||||||
@@ -38,6 +40,9 @@ interface Package {
|
|||||||
branding_allowed?: boolean;
|
branding_allowed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sortPackagesByPrice = (packages: Package[]): Package[] =>
|
||||||
|
[...packages].sort((a, b) => Number(a.price ?? 0) - Number(b.price ?? 0));
|
||||||
|
|
||||||
interface PackageComparisonProps {
|
interface PackageComparisonProps {
|
||||||
packages: Package[];
|
packages: Package[];
|
||||||
variant: 'endcustomer' | 'reseller';
|
variant: 'endcustomer' | 'reseller';
|
||||||
@@ -210,10 +215,14 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [selectedPackage, setSelectedPackage] = useState<Package | null>(null);
|
const [selectedPackage, setSelectedPackage] = useState<Package | null>(null);
|
||||||
const [currentStep, setCurrentStep] = useState<'overview' | 'testimonials'>('overview');
|
const [currentStep, setCurrentStep] = useState<'overview' | 'testimonials'>('overview');
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
const dialogScrollRef = useRef<HTMLDivElement | null>(null);
|
const dialogScrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const dialogHeadingRef = 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 { props } = usePage();
|
||||||
const { auth } = props as any;
|
const { auth } = props as any;
|
||||||
|
const { localizedPath } = useLocalizedRoutes();
|
||||||
const { t } = useTranslation('marketing');
|
const { t } = useTranslation('marketing');
|
||||||
const { t: tCommon } = useTranslation('common');
|
const { t: tCommon } = useTranslation('common');
|
||||||
const {
|
const {
|
||||||
@@ -241,34 +250,6 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
}
|
}
|
||||||
}, [open, selectedPackage]);
|
}, [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(
|
const highlightEndcustomerId = useMemo(
|
||||||
() => selectHighlightPackageId(endcustomerPackages),
|
() => selectHighlightPackageId(endcustomerPackages),
|
||||||
[endcustomerPackages],
|
[endcustomerPackages],
|
||||||
@@ -279,6 +260,124 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
[resellerPackages],
|
[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') {
|
function isHighlightedPackage(pkg: Package, variant: 'endcustomer' | 'reseller') {
|
||||||
return variant === 'reseller' ? pkg.id === highlightResellerId : pkg.id === highlightEndcustomerId;
|
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',
|
buttonHighlight: 'bg-gray-900 text-white hover:bg-gray-800',
|
||||||
buttonDefault: 'border border-amber-200 text-amber-700 hover:bg-amber-50',
|
buttonDefault: 'border border-amber-200 text-amber-700 hover:bg-amber-50',
|
||||||
cardBorder: 'border border-amber-100',
|
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',
|
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',
|
buttonHighlight: 'bg-gray-900 text-white hover:bg-gray-800',
|
||||||
buttonDefault: 'border border-rose-100 text-rose-700 hover:bg-rose-50',
|
buttonDefault: 'border border-rose-100 text-rose-700 hover:bg-rose-50',
|
||||||
cardBorder: 'border border-rose-100',
|
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 = {
|
type PackageMetric = {
|
||||||
@@ -463,15 +562,80 @@ function PackageCard({
|
|||||||
const keyFeatures = pkg.features.slice(0, 3);
|
const keyFeatures = pkg.features.slice(0, 3);
|
||||||
const metrics = resolvePackageMetrics(pkg, variant, t, tCommon);
|
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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-full flex-col rounded-2xl border border-gray-100 bg-white shadow-sm transition hover:shadow-lg',
|
'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}`,
|
highlight && `${accent.cardBorder} ${accent.highlightShadow}`,
|
||||||
className,
|
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">
|
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
|
||||||
<span>{typeLabel}</span>
|
<span>{typeLabel}</span>
|
||||||
{badgeLabel && (
|
{badgeLabel && (
|
||||||
@@ -490,10 +654,10 @@ function PackageCard({
|
|||||||
<CardDescription className="text-sm text-gray-600">{pkg.description}</CardDescription>
|
<CardDescription className="text-sm text-gray-600">{pkg.description}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</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>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className={cn('flex items-baseline gap-2', compact && 'flex-wrap text-balance')}>
|
||||||
<span className={cn('text-4xl font-semibold', accent.price)}>{priceLabel}</span>
|
<span className={cn('text-4xl font-semibold', accent.price, compact && 'text-3xl')}>{priceLabel}</span>
|
||||||
{pkg.price !== 0 && (
|
{pkg.price !== 0 && (
|
||||||
<span className="text-sm text-gray-500">/ {cadenceLabel}</span>
|
<span className="text-sm text-gray-500">/ {cadenceLabel}</span>
|
||||||
)}
|
)}
|
||||||
@@ -504,42 +668,17 @@ function PackageCard({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
{metricList}
|
||||||
{metrics.map((metric) => (
|
{featureList}
|
||||||
<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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
{showCTA && onSelect && (
|
{showCTA && onSelect && (
|
||||||
<CardFooter className="mt-auto">
|
<CardFooter className={cn('mt-auto', compact && 'pt-4')}>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onSelect(pkg)}
|
onClick={() => onSelect(pkg)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full justify-center rounded-full text-sm font-semibold',
|
'w-full justify-center rounded-full text-sm font-semibold',
|
||||||
highlight ? accent.buttonHighlight : accent.buttonDefault,
|
highlight ? accent.buttonHighlight : accent.buttonDefault,
|
||||||
|
compact && 'py-4 text-base',
|
||||||
)}
|
)}
|
||||||
variant={highlight ? 'default' : 'outline'}
|
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 (
|
return (
|
||||||
<MarketingLayout title={t('packages.title')}>
|
<MarketingLayout title={t('packages.title')}>
|
||||||
<section className="bg-aurora-enhanced text-gray-900 dark:text-gray-100 py-20 px-4">
|
<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-8">
|
<div className="container mx-auto text-center space-y-6 md:space-y-8">
|
||||||
<div className="space-y-4">
|
<div className="space-y-3 md:space-y-4">
|
||||||
<p className="text-sm uppercase tracking-[0.2em] text-gray-600 dark:text-gray-300">
|
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-gray-600 dark:text-gray-300">
|
||||||
{t('packages.for_endcustomers')}
|
{t('packages.for_endcustomers')} · {t('packages.for_resellers')}
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-4xl md:text-6xl font-bold font-display">{t('packages.hero_title')}</h1>
|
<h1 className="text-3xl font-bold font-display md:text-5xl">
|
||||||
<p className="text-xl md:text-2xl max-w-3xl mx-auto font-sans-marketing text-gray-700 dark:text-gray-200">
|
{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')}
|
{t('packages.hero_description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||||
<Link
|
<Button
|
||||||
href="/de/demo"
|
asChild
|
||||||
|
size="lg"
|
||||||
|
className="rounded-full bg-gray-900 text-white shadow-lg shadow-gray-900/20 hover:bg-gray-800"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackPackagesHeroClick();
|
trackPackagesHeroClick();
|
||||||
trackEvent({
|
trackEvent({
|
||||||
category: 'marketing_packages',
|
category: 'marketing_packages',
|
||||||
action: 'hero_cta',
|
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')}
|
<a href="#packages-showcase">
|
||||||
<ArrowRight className="h-5 w-5" />
|
{t('packages.cta_explore')}
|
||||||
</Link>
|
<ArrowRight className="ml-2 inline h-4 w-4" aria-hidden />
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
</a>
|
||||||
{t('packages.hero_secondary')}
|
</Button>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{t('packages.hero_secondary')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<div className="container mx-auto space-y-12">
|
||||||
<Tabs defaultValue="endcustomer" className="space-y-8">
|
<Tabs defaultValue="endcustomer" className="space-y-8">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
@@ -604,41 +915,85 @@ function PackageCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="endcustomer" className="space-y-8">
|
<TabsContent value="endcustomer" className="space-y-8">
|
||||||
<div className="overflow-x-auto pb-4">
|
<div className="md:hidden">
|
||||||
<div className="flex min-w-full gap-6 md:grid md:grid-cols-2">
|
<div className="relative">
|
||||||
{endcustomerPackages.map((pkg) => (
|
<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" />
|
||||||
<PackageCard
|
<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" />
|
||||||
key={pkg.id}
|
<div
|
||||||
pkg={pkg}
|
ref={mobileEndcustomerRef}
|
||||||
variant="endcustomer"
|
className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-6"
|
||||||
highlight={pkg.id === highlightEndcustomerId}
|
style={{ scrollPaddingLeft: '16px', scrollBehavior: 'smooth' }}
|
||||||
onSelect={(selected) => handleCardClick(selected, 'endcustomer')}
|
>
|
||||||
className="min-w-[280px]"
|
{orderedEndcustomerPackages.map((pkg) => (
|
||||||
compact
|
<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>
|
||||||
</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>
|
||||||
|
|
||||||
<TabsContent value="reseller" className="space-y-8">
|
<TabsContent value="reseller" className="space-y-8">
|
||||||
<div className="overflow-x-auto pb-4">
|
<div className="md:hidden">
|
||||||
<div className="flex min-w-full gap-6 md:grid md:grid-cols-2 lg:grid-cols-3">
|
<div className="relative">
|
||||||
{resellerPackages.map((pkg) => (
|
<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" />
|
||||||
<PackageCard
|
<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" />
|
||||||
key={pkg.id}
|
<div
|
||||||
pkg={pkg}
|
ref={mobileResellerRef}
|
||||||
variant="reseller"
|
className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-6"
|
||||||
highlight={pkg.id === highlightResellerId}
|
style={{ scrollPaddingLeft: '16px', scrollBehavior: 'smooth' }}
|
||||||
onSelect={(selected) => handleCardClick(selected, 'reseller')}
|
>
|
||||||
className="min-w-[280px]"
|
{orderedResellerPackages.map((pkg) => (
|
||||||
compact
|
<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>
|
||||||
</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>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
@@ -670,208 +1025,41 @@ function PackageCard({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Details overlay */}
|
||||||
{selectedPackage && (
|
{selectedPackage && (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
isMobile ? (
|
||||||
<DialogContent
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
className="max-w-6xl border border-gray-100 bg-white px-0 py-0 sm:rounded-[32px]"
|
<SheetContent
|
||||||
onOpenAutoFocus={(event) => {
|
side="bottom"
|
||||||
event.preventDefault();
|
className="h-[90vh] overflow-hidden rounded-t-[32px] border border-gray-200 bg-white p-0"
|
||||||
dialogScrollRef.current?.scrollTo({ top: 0 });
|
onOpenAutoFocus={handleDetailAutoFocus}
|
||||||
dialogHeadingRef.current?.focus();
|
>
|
||||||
}}
|
{renderDetailBody('h-full overflow-y-auto space-y-8 p-6')}
|
||||||
>
|
</SheetContent>
|
||||||
<div ref={dialogScrollRef} className="max-h-[88vh] overflow-y-auto space-y-8 p-6 md:p-10">
|
</Sheet>
|
||||||
<div ref={dialogHeadingRef} tabIndex={-1} className="outline-none">
|
) : (
|
||||||
<DialogHeader className="space-y-3 text-left">
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-gray-400">
|
<DialogContent
|
||||||
{selectedVariant === 'reseller' ? t('packages.subscription') : t('packages.one_time')}
|
className="max-w-6xl border border-gray-100 bg-white px-0 py-0 sm:rounded-[32px]"
|
||||||
</p>
|
onOpenAutoFocus={handleDetailAutoFocus}
|
||||||
<DialogTitle className="text-3xl font-display text-gray-900">
|
>
|
||||||
{selectedPackage.name}
|
{renderDetailBody('max-h-[88vh] overflow-y-auto space-y-8 p-6 md:p-10')}
|
||||||
</DialogTitle>
|
</DialogContent>
|
||||||
<p className="text-base text-gray-600">{selectedPackage.description}</p>
|
</Dialog>
|
||||||
</DialogHeader>
|
)
|
||||||
</div>
|
)} {/* Testimonials Section entfernt, da nun im Dialog */}
|
||||||
<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 */}
|
|
||||||
</MarketingLayout>
|
</MarketingLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const handleDetailAutoFocus = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
dialogScrollRef.current?.scrollTo({ top: 0 });
|
||||||
|
dialogHeadingRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
Packages.layout = (page: React.ReactNode) => page;
|
Packages.layout = (page: React.ReactNode) => page;
|
||||||
|
|
||||||
export default Packages;
|
export default Packages;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { TFunction } from 'i18next';
|
|||||||
import { Check, Package as PackageIcon } from "lucide-react";
|
import { Check, Package as PackageIcon } from "lucide-react";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { useCheckoutWizard } from "../WizardContext";
|
import { useCheckoutWizard } from "../WizardContext";
|
||||||
import type { CheckoutPackage } from "../types";
|
import type { CheckoutPackage } from "../types";
|
||||||
|
|
||||||
@@ -18,11 +19,45 @@ function translateFeature(feature: string, t: TFunction<'marketing'>) {
|
|||||||
return t(`packages.feature_${feature}`, { defaultValue: fallback });
|
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'> }) {
|
function PackageSummary({ pkg, t }: { pkg: CheckoutPackage; t: TFunction<'marketing'> }) {
|
||||||
const isFree = pkg.price === 0;
|
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 (
|
return (
|
||||||
<Card className={`shadow-sm ${isFree ? 'opacity-75' : ''}`}>
|
<Card className={cn('shadow-sm transition', isFree ? 'opacity-75' : accentGradient)}>
|
||||||
<CardHeader className="space-y-1">
|
<CardHeader className="space-y-1">
|
||||||
<CardTitle className={`flex items-center gap-3 text-2xl ${isFree ? 'text-muted-foreground' : ''}`}>
|
<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'}`} />
|
<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) => (
|
{pkg.description_breakdown.map((row, index) => (
|
||||||
<div key={index} className="rounded-lg border border-muted/40 bg-muted/20 px-3 py-2">
|
<div key={index} className="rounded-lg border border-muted/40 bg-muted/20 px-3 py-2">
|
||||||
{row.title && (
|
{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>
|
<p className="text-sm text-muted-foreground">{row.value}</p>
|
||||||
</div>
|
</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'> }) {
|
function PackageOption({ pkg, isActive, onSelect, t }: { pkg: CheckoutPackage; isActive: boolean; onSelect: () => void; t: TFunction<'marketing'> }) {
|
||||||
const isFree = pkg.price === 0;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSelect}
|
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
|
isActive
|
||||||
? "border-primary shadow-sm"
|
? accentGradient
|
||||||
: isFree
|
: isFree
|
||||||
? "border-border hover:border-primary/40 opacity-75 hover:opacity-100"
|
? 'border-border hover:border-primary/40 opacity-75 hover:opacity-100'
|
||||||
: "border-border hover:border-primary/40"
|
: 'border-border hover:border-primary/40',
|
||||||
}`}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between text-sm font-medium">
|
<div className="flex items-center justify-between text-sm font-medium">
|
||||||
<span className={isFree ? "text-muted-foreground" : ""}>{pkg.name}</span>
|
<span className={isFree ? "text-muted-foreground" : ""}>{pkg.name}</span>
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ Route::middleware('auth')->group(function () {
|
|||||||
->name('verification.notice');
|
->name('verification.notice');
|
||||||
|
|
||||||
Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)
|
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');
|
->name('verification.verify');
|
||||||
|
|
||||||
Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
|
Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
|
||||||
|
|||||||
@@ -221,6 +221,14 @@ Route::get('/demo', function (Request $request) use ($determinePreferredLocale)
|
|||||||
return redirect("/{$locale}/demo", 301);
|
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) {
|
Route::get('/success/{packageId?}', function (Request $request, ?int $packageId = null) use ($determinePreferredLocale) {
|
||||||
$locale = $determinePreferredLocale($request);
|
$locale = $determinePreferredLocale($request);
|
||||||
$path = "/{$locale}/success";
|
$path = "/{$locale}/success";
|
||||||
|
|||||||
@@ -31,14 +31,37 @@ class EmailVerificationTest extends TestCase
|
|||||||
$verificationUrl = URL::temporarySignedRoute(
|
$verificationUrl = URL::temporarySignedRoute(
|
||||||
'verification.verify',
|
'verification.verify',
|
||||||
now()->addMinutes(60),
|
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);
|
$response = $this->actingAs($user)->get($verificationUrl);
|
||||||
|
|
||||||
Event::assertDispatched(Verified::class);
|
Event::assertDispatched(Verified::class);
|
||||||
$this->assertTrue($user->fresh()->hasVerifiedEmail());
|
$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('&', '&', $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()
|
public function test_email_is_not_verified_with_invalid_hash()
|
||||||
@@ -48,7 +71,8 @@ class EmailVerificationTest extends TestCase
|
|||||||
$verificationUrl = URL::temporarySignedRoute(
|
$verificationUrl = URL::temporarySignedRoute(
|
||||||
'verification.verify',
|
'verification.verify',
|
||||||
now()->addMinutes(60),
|
now()->addMinutes(60),
|
||||||
['id' => $user->id, 'hash' => sha1('wrong-email')]
|
['id' => $user->id, 'hash' => sha1('wrong-email')],
|
||||||
|
absolute: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->actingAs($user)->get($verificationUrl);
|
$this->actingAs($user)->get($verificationUrl);
|
||||||
@@ -65,7 +89,8 @@ class EmailVerificationTest extends TestCase
|
|||||||
$verificationUrl = URL::temporarySignedRoute(
|
$verificationUrl = URL::temporarySignedRoute(
|
||||||
'verification.verify',
|
'verification.verify',
|
||||||
now()->addMinutes(60),
|
now()->addMinutes(60),
|
||||||
['id' => 123, 'hash' => sha1($user->email)]
|
['id' => 123, 'hash' => sha1($user->email)],
|
||||||
|
absolute: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->actingAs($user)->get($verificationUrl);
|
$this->actingAs($user)->get($verificationUrl);
|
||||||
@@ -95,13 +120,36 @@ class EmailVerificationTest extends TestCase
|
|||||||
$verificationUrl = URL::temporarySignedRoute(
|
$verificationUrl = URL::temporarySignedRoute(
|
||||||
'verification.verify',
|
'verification.verify',
|
||||||
now()->addMinutes(60),
|
now()->addMinutes(60),
|
||||||
['id' => $user->id, 'hash' => sha1($user->email)]
|
['id' => $user->id, 'hash' => sha1($user->email)],
|
||||||
|
absolute: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->actingAs($user)->get($verificationUrl)
|
$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());
|
$this->assertTrue($user->fresh()->hasVerifiedEmail());
|
||||||
Event::assertNotDispatched(Verified::class);
|
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';
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user