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\Queue\InvalidQueueException;
|
||||
use Illuminate\Queue\MaxAttemptsExceededException;
|
||||
use Illuminate\Routing\Exceptions\InvalidSignatureException;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use League\Flysystem\FilesystemException;
|
||||
use PDOException;
|
||||
@@ -70,6 +71,16 @@ class Handler extends ExceptionHandler
|
||||
}
|
||||
}
|
||||
|
||||
if ($e instanceof InvalidSignatureException && ! $request->expectsJson()) {
|
||||
$request->session()->flash('verification', [
|
||||
'status' => 'error',
|
||||
'title' => __('auth.verification.expired_title'),
|
||||
'message' => __('auth.verification.expired_message'),
|
||||
]);
|
||||
|
||||
return redirect()->route('verification.notice');
|
||||
}
|
||||
|
||||
if (! $request->expectsJson() && ! $request->inertia()) {
|
||||
if ($hintKey = $this->resolveServerErrorHint($e)) {
|
||||
$request->attributes->set('serverErrorHint', __($hintKey, [], app()->getLocale()));
|
||||
|
||||
@@ -15,8 +15,18 @@ class EmailVerificationPromptController extends Controller
|
||||
*/
|
||||
public function __invoke(Request $request): Response|RedirectResponse
|
||||
{
|
||||
return $request->user()->hasVerifiedEmail()
|
||||
? redirect()->intended(route('dashboard', absolute: false))
|
||||
: Inertia::render('auth/verify-email', ['status' => $request->session()->get('status')]);
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
$redirectToCheckout = $request->session()->pull('checkout.verify_redirect');
|
||||
|
||||
if ($redirectToCheckout) {
|
||||
$separator = str_contains($redirectToCheckout, '?') ? '&' : '?';
|
||||
|
||||
return redirect()->to($redirectToCheckout.$separator.'verified=1');
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
return Inertia::render('auth/verify-email', ['status' => $request->session()->get('status')]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,61 @@ class VerifyEmailController extends Controller
|
||||
*/
|
||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
if (! $request->user()->hasVerifiedEmail()) {
|
||||
$request->fulfill();
|
||||
}
|
||||
|
||||
$request->fulfill();
|
||||
return $this->redirectAfterVerification($request);
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
protected function redirectAfterVerification(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
$redirectToCheckout = $request->session()->pull('checkout.verify_redirect');
|
||||
|
||||
if (! $redirectToCheckout && $request->user()->pending_purchase) {
|
||||
$packageId = $request->session()->pull('checkout.pending_package_id');
|
||||
|
||||
if (! $packageId) {
|
||||
$packageId = optional($request->user()->tenant)
|
||||
?->packages()
|
||||
->latest('tenant_packages.created_at')
|
||||
->value('packages.id');
|
||||
}
|
||||
|
||||
if ($packageId) {
|
||||
$redirectToCheckout = route('checkout.show', ['package' => $packageId]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->flashVerificationSuccess($request, (bool) $redirectToCheckout);
|
||||
|
||||
if ($redirectToCheckout) {
|
||||
$request->session()->forget('checkout.pending_package_id');
|
||||
$separator = str_contains($redirectToCheckout, '?') ? '&' : '?';
|
||||
|
||||
return redirect()->to($redirectToCheckout.$separator.'verified=1');
|
||||
}
|
||||
|
||||
$fallbackLogin = route('marketing.login');
|
||||
$separator = str_contains($fallbackLogin, '?') ? '&' : '?';
|
||||
|
||||
return redirect()->intended($fallbackLogin.$separator.'verified=1');
|
||||
}
|
||||
|
||||
private function flashVerificationSuccess(EmailVerificationRequest $request, bool $forCheckout): void
|
||||
{
|
||||
$message = $forCheckout
|
||||
? __('auth.verification.checkout_success_message')
|
||||
: __('auth.verification.success_message');
|
||||
|
||||
$request->session()->flash('verification', [
|
||||
'status' => 'success',
|
||||
'title' => __('auth.verification.success_title'),
|
||||
'message' => $message,
|
||||
]);
|
||||
|
||||
if (! $forCheckout) {
|
||||
$request->session()->flash('status', __('auth.verification.success_message'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ class CheckoutController extends Controller
|
||||
|
||||
$package = Package::findOrFail($request->package_id);
|
||||
$validated = $validator->validated();
|
||||
DB::transaction(function () use ($request, $package, $validated) {
|
||||
$user = DB::transaction(function () use ($request, $package, $validated) {
|
||||
|
||||
// User erstellen
|
||||
$user = User::create([
|
||||
@@ -138,10 +138,28 @@ class CheckoutController extends Controller
|
||||
Mail::to($user)
|
||||
->locale($user->preferred_locale ?? app()->getLocale())
|
||||
->queue(new Welcome($user));
|
||||
|
||||
return $user;
|
||||
});
|
||||
|
||||
Auth::login($user);
|
||||
$request->session()->put('checkout.pending_package_id', $package->id);
|
||||
$redirectUrl = route('checkout.show', ['package' => $package->id]);
|
||||
$request->session()->put('checkout.verify_redirect', $redirectUrl);
|
||||
$request->session()->put('url.intended', $redirectUrl);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Registrierung erfolgreich. Bitte überprüfen Sie Ihre E-Mail zur Verifizierung.',
|
||||
'redirect' => $redirectUrl,
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'name' => $user->name ?? trim($user->first_name.' '.$user->last_name),
|
||||
'pending_purchase' => $user->pending_purchase ?? true,
|
||||
'email_verified_at' => $user->email_verified_at,
|
||||
],
|
||||
'pending_purchase' => $user->pending_purchase ?? true,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,13 +48,13 @@ class Kernel extends HttpKernel
|
||||
];
|
||||
|
||||
/**
|
||||
* The application's route middleware.
|
||||
* The application's middleware aliases.
|
||||
*
|
||||
* These middleware may be assigned to groups or used individually.
|
||||
*
|
||||
* @var array<string, class-string|string>
|
||||
*/
|
||||
protected $routeMiddleware = [
|
||||
protected $middlewareAliases = [
|
||||
'auth' => \App\Http\Middleware\Authenticate::class,
|
||||
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
|
||||
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
|
||||
@@ -69,6 +69,9 @@ class HandleInertiaRequests extends Middleware
|
||||
'profile' => __('profile'),
|
||||
'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\Policies\PurchaseHistoryPolicy;
|
||||
use App\Policies\TenantPolicy;
|
||||
use Illuminate\Auth\Notifications\VerifyEmail;
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class AuthServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -29,6 +31,20 @@ class AuthServiceProvider extends ServiceProvider
|
||||
{
|
||||
$this->registerPolicies();
|
||||
|
||||
VerifyEmail::createUrlUsing(function (User $notifiable): string {
|
||||
$relativeUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes((int) config('auth.verification.expire', 60)),
|
||||
[
|
||||
'id' => $notifiable->getKey(),
|
||||
'hash' => sha1($notifiable->getEmailForVerification()),
|
||||
],
|
||||
absolute: false,
|
||||
);
|
||||
|
||||
return URL::to($relativeUrl);
|
||||
});
|
||||
|
||||
Gate::before(function (User $user): ?bool {
|
||||
return $user->role === 'super_admin' ? true : null;
|
||||
});
|
||||
|
||||
@@ -60,10 +60,21 @@
|
||||
"phone_placeholder": "+49 170 1234567",
|
||||
"username_placeholder": "z. B. hochzeit_julia",
|
||||
"password_placeholder": "Mindestens 8 Zeichen",
|
||||
"password_confirmation_placeholder": "Passwort erneut eingeben"
|
||||
"password_confirmation_placeholder": "Passwort erneut eingeben",
|
||||
"server_error_title": "Registrierung konnte nicht abgeschlossen werden",
|
||||
"server_error_message": "Auf unserer Seite ist ein Fehler aufgetreten. Bitte versuche es später erneut oder kontaktiere support@fotospiel.de.",
|
||||
"session_expired_title": "Sicherheitsprüfung abgelaufen",
|
||||
"session_expired_message": "Deine Sitzung ist abgelaufen. Lade die Seite neu und versuche es erneut."
|
||||
},
|
||||
"verification": {
|
||||
"notice": "Bitte bestätigen Sie Ihre E-Mail-Adresse.",
|
||||
"resend": "E-Mail erneut senden"
|
||||
"resend": "E-Mail erneut senden",
|
||||
"success_title": "E-Mail bestätigt",
|
||||
"success_message": "Deine E-Mail ist bestätigt. Du kannst dich jetzt anmelden.",
|
||||
"checkout_success_message": "E-Mail bestätigt. Du kannst mit dem Checkout fortfahren.",
|
||||
"toast_success": "E-Mail erfolgreich bestätigt.",
|
||||
"expired_title": "Bestätigungslink abgelaufen",
|
||||
"expired_message": "Dieser Bestätigungslink ist nicht mehr gültig. Fordere unten einen neuen Link an.",
|
||||
"toast_error": "Bestätigungslink abgelaufen. Bitte fordere einen neuen Link an."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,14 @@
|
||||
"order_hint": "Sofort startklar – keine versteckten Kosten, sichere Zahlung über Paddle.",
|
||||
"features_label": "Features",
|
||||
"feature_highlights": "Feature-Highlights",
|
||||
"detail_labels": {
|
||||
"photos": "Fotos",
|
||||
"guests": "Gäste",
|
||||
"tasks": "Aufgaben",
|
||||
"gallery": "Galerie",
|
||||
"branding": "Branding",
|
||||
"events_per_year": "Events pro Jahr"
|
||||
},
|
||||
"more_details_tab": "Mehr Details",
|
||||
"quick_facts": "Schnelle Fakten",
|
||||
"quick_facts_hint": "Der schnelle Überblick über die wichtigsten Kennzahlen.",
|
||||
|
||||
@@ -60,10 +60,21 @@
|
||||
"phone_placeholder": "+1 555 123 4567",
|
||||
"username_placeholder": "e.g. wedding_julia",
|
||||
"password_placeholder": "At least 8 characters",
|
||||
"password_confirmation_placeholder": "Repeat your password"
|
||||
"password_confirmation_placeholder": "Repeat your password",
|
||||
"server_error_title": "We couldn't finish your registration",
|
||||
"server_error_message": "Something went wrong on our side. Please try again in a moment or contact support@fotospiel.de.",
|
||||
"session_expired_title": "Security check expired",
|
||||
"session_expired_message": "Your session expired. Refresh the page and try again."
|
||||
},
|
||||
"verification": {
|
||||
"notice": "Please verify your email address.",
|
||||
"resend": "Resend email"
|
||||
"resend": "Resend email",
|
||||
"success_title": "Email verified",
|
||||
"success_message": "Your email is confirmed. You can sign in now.",
|
||||
"checkout_success_message": "Email confirmed. Continue your checkout to finish the order.",
|
||||
"toast_success": "Email verified successfully.",
|
||||
"expired_title": "Verification link expired",
|
||||
"expired_message": "That verification link is no longer valid. Request a new email below.",
|
||||
"toast_error": "Verification link expired. Request a new one."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,14 @@
|
||||
"order_hint": "Launch instantly – secure Paddle checkout, no hidden fees.",
|
||||
"features_label": "Features",
|
||||
"feature_highlights": "Feature Highlights",
|
||||
"detail_labels": {
|
||||
"photos": "Photos",
|
||||
"guests": "Guests",
|
||||
"tasks": "Challenges",
|
||||
"gallery": "Gallery",
|
||||
"branding": "Branding",
|
||||
"events_per_year": "Events per year"
|
||||
},
|
||||
"more_details_tab": "More Details",
|
||||
"quick_facts": "Quick Facts",
|
||||
"quick_facts_hint": "Your at-a-glance snapshot of core limits.",
|
||||
|
||||
@@ -31,8 +31,6 @@ interface CarouselProps {
|
||||
const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
|
||||
({ opts, plugins = [Autoplay()], setApi, className, children, ...props }, ref) => {
|
||||
const [api, setApiInternal] = React.useState<CarouselApi | null>(null)
|
||||
const [current, setCurrent] = React.useState(0)
|
||||
const [count, setCount] = React.useState(0)
|
||||
|
||||
const [emblaRef] = useEmblaCarousel(opts, plugins)
|
||||
|
||||
@@ -41,18 +39,6 @@ const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Embla API initialized:', api)
|
||||
console.log('Embla options:', opts)
|
||||
console.log('Embla plugins:', plugins)
|
||||
|
||||
setCount(api.slideNodes().length)
|
||||
api.on("reInit", setCount)
|
||||
api.on("slideChanged", ({ slide }: { slide: number }) => {
|
||||
console.log('Slide changed to:', slide)
|
||||
setCurrent(slide)
|
||||
})
|
||||
api.on("pointerDown", () => console.log('Pointer down event'))
|
||||
api.on("pointerUp", () => console.log('Pointer up event'))
|
||||
setApi?.(api)
|
||||
}, [api, setApi])
|
||||
|
||||
@@ -64,9 +50,6 @@ const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
|
||||
"relative w-full",
|
||||
className
|
||||
)}
|
||||
onTouchStart={(e) => console.log('Carousel touch start:', e.touches.length)}
|
||||
onTouchMove={(e) => console.log('Carousel touch move:', e.touches.length)}
|
||||
onTouchEnd={(e) => console.log('Carousel touch end')}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -37,11 +37,37 @@ type RegisterFormFields = {
|
||||
package_id: number | null;
|
||||
};
|
||||
|
||||
const getCookieValue = (name: string): string | null => {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||||
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
};
|
||||
|
||||
const resolveCsrfToken = (): string => {
|
||||
if (typeof document === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const metaToken = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content;
|
||||
|
||||
if (metaToken && metaToken.length > 0) {
|
||||
return metaToken;
|
||||
}
|
||||
|
||||
return getCookieValue('XSRF-TOKEN') ?? '';
|
||||
};
|
||||
|
||||
export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale, prefill, onClearGoogleProfile }: RegisterFormProps) {
|
||||
const [privacyOpen, setPrivacyOpen] = useState(false);
|
||||
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [prefillApplied, setPrefillApplied] = useState(false);
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
const [serverErrorType, setServerErrorType] = useState<'generic' | 'session-expired'>('generic');
|
||||
const { t } = useTranslation(['auth', 'common']);
|
||||
const page = usePage<{ errors: Record<string, string>; locale?: string; auth?: { user?: any | null } }>();
|
||||
const resolvedLocale = locale ?? page.props.locale ?? 'de';
|
||||
@@ -68,6 +94,30 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
||||
|
||||
const registerEndpoint = '/checkout/register';
|
||||
|
||||
const requiredStringFields: Array<keyof RegisterFormFields> = useMemo(() => (
|
||||
['first_name', 'last_name', 'username', 'email', 'password', 'password_confirmation', 'address', 'phone']
|
||||
), []);
|
||||
|
||||
const isFormValid = useMemo(() => {
|
||||
const stringsValid = requiredStringFields.every((field) => {
|
||||
const value = data[field];
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return value.trim().length > 0;
|
||||
});
|
||||
|
||||
if (!stringsValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const emailValid = /.+@.+\..+/.test(data.email.trim());
|
||||
const passwordsMatch = data.password === data.password_confirmation;
|
||||
|
||||
return emailValid && passwordsMatch && data.privacy_consent && data.terms;
|
||||
}, [data, requiredStringFields]);
|
||||
|
||||
const namePrefill = useMemo(() => {
|
||||
const rawFirst = prefill?.given_name ?? prefill?.name?.split(' ')[0] ?? '';
|
||||
const remaining = prefill?.name ? prefill.name.split(' ').slice(1).join(' ') : '';
|
||||
@@ -126,24 +176,28 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setServerError(null);
|
||||
setServerErrorType('generic');
|
||||
setHasTriedSubmit(true);
|
||||
setIsSubmitting(true);
|
||||
clearErrors();
|
||||
|
||||
const csrfToken = resolveCsrfToken();
|
||||
const body = {
|
||||
...data,
|
||||
locale: resolvedLocale,
|
||||
package_id: data.package_id ?? packageId ?? null,
|
||||
_token: csrfToken,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
const response = await fetch(registerEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? '',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'X-XSRF-TOKEN': csrfToken,
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body),
|
||||
@@ -179,10 +233,32 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(t('register.unexpected_error', 'Registrierung nicht moeglich.'));
|
||||
if (response.status === 419) {
|
||||
const expiredMessage = t('register.session_expired_message', 'Deine Sitzung ist abgelaufen. Bitte lade die Seite neu und versuche es erneut.');
|
||||
setServerErrorType('session-expired');
|
||||
setServerError(expiredMessage);
|
||||
toast.error(expiredMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
let message: string | null = null;
|
||||
try {
|
||||
const json = await response.clone().json();
|
||||
message = json?.message ?? null;
|
||||
} catch (error) {
|
||||
message = null;
|
||||
}
|
||||
|
||||
const fallbackMessage = message ?? t('register.server_error_message', 'Etwas ist schiefgelaufen. Bitte versuche es erneut.');
|
||||
setServerErrorType('generic');
|
||||
setServerError(fallbackMessage);
|
||||
toast.error(fallbackMessage);
|
||||
} catch (error) {
|
||||
console.error('Register request failed', error);
|
||||
toast.error(t('register.unexpected_error', 'Registrierung nicht moeglich.'));
|
||||
const fallbackMessage = t('register.server_error_message', 'Etwas ist schiefgelaufen. Bitte versuche es erneut.');
|
||||
setServerErrorType('generic');
|
||||
setServerError(fallbackMessage);
|
||||
toast.error(fallbackMessage);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -463,10 +539,21 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
||||
</div>
|
||||
)}
|
||||
|
||||
{serverError && (
|
||||
<div className="mb-6 rounded-md border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
<p className="font-semibold">
|
||||
{serverErrorType === 'session-expired'
|
||||
? t('register.session_expired_title', 'Sicherheitsprüfung abgelaufen')
|
||||
: t('register.server_error_title', 'Registrierung konnte nicht abgeschlossen werden')}
|
||||
</p>
|
||||
<p className="mt-1">{serverError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || !isFormValid}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-[#FFB6C1] hover:bg-[#FF69B4] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#FFB6C1] transition duration-300 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting && <LoaderCircle className="h-4 w-4 animate-spin mr-2" />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { Head, useForm, usePage } from '@inertiajs/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import InputError from '@/components/input-error';
|
||||
import TextLink from '@/components/text-link';
|
||||
@@ -13,6 +13,7 @@ import AppLayout from '@/layouts/app/AppLayout';
|
||||
import { register } from '@/routes';
|
||||
import { request } from '@/routes/password';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface LoginProps {
|
||||
status?: string;
|
||||
@@ -24,6 +25,8 @@ export default function Login({ status, canResetPassword }: LoginProps) {
|
||||
const [rawReturnTo, setRawReturnTo] = useState<string | null>(null);
|
||||
const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false);
|
||||
const { t } = useTranslation('auth');
|
||||
const page = usePage<{ flash?: { verification?: { status: string; title?: string; message?: string } } }>();
|
||||
const verificationFlash = page.props.flash?.verification;
|
||||
|
||||
const { data, setData, post, processing, errors, clearErrors } = useForm({
|
||||
login: '',
|
||||
@@ -50,7 +53,15 @@ export default function Login({ status, canResetPassword }: LoginProps) {
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
setRawReturnTo(searchParams.get('return_to'));
|
||||
}, []);
|
||||
|
||||
if (searchParams.get('verified') === '1') {
|
||||
toast.success(t('verification.toast_success', 'Email verified successfully.'));
|
||||
searchParams.delete('verified');
|
||||
const nextQuery = searchParams.toString();
|
||||
const nextUrl = `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}`;
|
||||
window.history.replaceState({}, '', nextUrl);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
setData('return_to', rawReturnTo ?? '');
|
||||
@@ -213,6 +224,20 @@ export default function Login({ status, canResetPassword }: LoginProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verificationFlash && (
|
||||
<div
|
||||
className={[
|
||||
'rounded-2xl border p-3 text-center font-medium shadow-sm',
|
||||
verificationFlash.status === 'success'
|
||||
? 'border-emerald-200/70 bg-emerald-50/90 text-emerald-700'
|
||||
: 'border-rose-200/70 bg-rose-50/90 text-rose-700',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="font-semibold">{verificationFlash.title ?? ''}</div>
|
||||
<div className="text-sm">{verificationFlash.message}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasErrors && (
|
||||
<div
|
||||
key={`general-errors-${errorKeys.join('-')}`}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Components
|
||||
import { store } from '@/actions/App/Http/Controllers/Auth/EmailVerificationNotificationController';
|
||||
import { logout } from '@/routes';
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { Form, Head, usePage } from '@inertiajs/react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
|
||||
import TextLink from '@/components/text-link';
|
||||
@@ -13,11 +13,26 @@ export default function VerifyEmail({ status }: { status?: string }) {
|
||||
const description = isNewRegistration
|
||||
? 'Thanks! Please confirm your email address to access your dashboard.'
|
||||
: 'Please verify your email address by clicking on the link we just emailed to you.';
|
||||
const page = usePage<{ flash?: { verification?: { status: string; title?: string; message?: string } } }>();
|
||||
const verificationFlash = page.props.flash?.verification;
|
||||
|
||||
return (
|
||||
<AuthLayout title="Verify email" description={description}>
|
||||
<Head title="Email verification" />
|
||||
|
||||
{verificationFlash && (
|
||||
<div
|
||||
className={`mb-6 rounded-md border p-4 text-left text-sm font-medium ${
|
||||
verificationFlash.status === 'success'
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
|
||||
: 'border-rose-200 bg-rose-50 text-rose-800'
|
||||
}`}
|
||||
>
|
||||
<p className="font-semibold">{verificationFlash.title}</p>
|
||||
<p className="mt-1 text-sm font-normal">{verificationFlash.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNewRegistration && (
|
||||
<div className="mb-6 rounded-md bg-blue-50 p-4 text-left text-sm text-blue-900">
|
||||
<p className="font-semibold">Almost there! Confirm your email address to start using Fotospiel.</p>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { Head, usePage } from "@inertiajs/react";
|
||||
import MarketingLayout from "@/layouts/mainWebsite";
|
||||
import type { CheckoutPackage, GoogleProfilePrefill } from "./checkout/types";
|
||||
import { CheckoutWizard } from "./checkout/CheckoutWizard";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CheckoutWizardPageProps {
|
||||
package: CheckoutPackage;
|
||||
@@ -26,9 +29,11 @@ const CheckoutWizardPage: React.FC<CheckoutWizardPageProps> = ({
|
||||
googleAuth,
|
||||
paddle,
|
||||
}) => {
|
||||
const page = usePage<{ auth?: { user?: { id: number; email: string; name?: string; pending_purchase?: boolean } | null } }>();
|
||||
const page = usePage<{ auth?: { user?: { id: number; email: string; name?: string; pending_purchase?: boolean } | null }, flash?: { verification?: { status: string; title?: string; message?: string } } }>();
|
||||
const currentUser = page.props.auth?.user ?? null;
|
||||
const googleProfile = googleAuth?.profile ?? null;
|
||||
const { t: tAuth } = useTranslation('auth');
|
||||
const verificationFlash = page.props.flash?.verification;
|
||||
|
||||
|
||||
const dedupedOptions = React.useMemo(() => {
|
||||
@@ -43,11 +48,37 @@ const CheckoutWizardPage: React.FC<CheckoutWizardPageProps> = ({
|
||||
});
|
||||
}, [initialPackage, packageOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('verified') === '1') {
|
||||
toast.success(tAuth('verification.toast_success', 'Email verified successfully.'));
|
||||
params.delete('verified');
|
||||
const next = params.toString();
|
||||
const nextUrl = `${window.location.pathname}${next ? `?${next}` : ''}`;
|
||||
window.history.replaceState({}, '', nextUrl);
|
||||
}
|
||||
}, [tAuth]);
|
||||
|
||||
return (
|
||||
<MarketingLayout title="Checkout Wizard">
|
||||
<Head title="Checkout Wizard" />
|
||||
<div className="min-h-screen bg-muted/20 py-12">
|
||||
<div className="mx-auto w-full max-w-4xl px-4">
|
||||
{verificationFlash && (
|
||||
<Alert
|
||||
className={verificationFlash.status === 'success'
|
||||
? 'mb-6 border-emerald-200 bg-emerald-50 text-emerald-800'
|
||||
: 'mb-6 border-rose-200 bg-rose-50 text-rose-800'}
|
||||
variant={verificationFlash.status === 'success' ? 'default' : 'destructive'}
|
||||
>
|
||||
<AlertTitle>{verificationFlash.title}</AlertTitle>
|
||||
<AlertDescription>{verificationFlash.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<CheckoutWizard
|
||||
initialPackage={initialPackage}
|
||||
packageOptions={dedupedOptions}
|
||||
|
||||
@@ -9,11 +9,13 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
import MarketingLayout from '@/layouts/mainWebsite';
|
||||
import { useAnalytics } from '@/hooks/useAnalytics';
|
||||
import { useCtaExperiment } from '@/hooks/useCtaExperiment';
|
||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||
import { ArrowRight, ShoppingCart, Check, Users, Image, Shield, Star, Sparkles } from 'lucide-react';
|
||||
|
||||
interface Package {
|
||||
@@ -38,6 +40,9 @@ interface Package {
|
||||
branding_allowed?: boolean;
|
||||
}
|
||||
|
||||
const sortPackagesByPrice = (packages: Package[]): Package[] =>
|
||||
[...packages].sort((a, b) => Number(a.price ?? 0) - Number(b.price ?? 0));
|
||||
|
||||
interface PackageComparisonProps {
|
||||
packages: Package[];
|
||||
variant: 'endcustomer' | 'reseller';
|
||||
@@ -210,10 +215,14 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedPackage, setSelectedPackage] = useState<Package | null>(null);
|
||||
const [currentStep, setCurrentStep] = useState<'overview' | 'testimonials'>('overview');
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const dialogScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const dialogHeadingRef = useRef<HTMLDivElement | null>(null);
|
||||
const mobileEndcustomerRef = useRef<HTMLDivElement | null>(null);
|
||||
const mobileResellerRef = useRef<HTMLDivElement | null>(null);
|
||||
const { props } = usePage();
|
||||
const { auth } = props as any;
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
const { t } = useTranslation('marketing');
|
||||
const { t: tCommon } = useTranslation('common');
|
||||
const {
|
||||
@@ -241,34 +250,6 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
||||
}
|
||||
}, [open, selectedPackage]);
|
||||
|
||||
|
||||
const testimonials = [
|
||||
{ name: tCommon('testimonials.anna.name'), text: t('packages.testimonials.anna'), rating: 5 },
|
||||
{ name: tCommon('testimonials.max.name'), text: t('packages.testimonials.max'), rating: 5 },
|
||||
{ name: tCommon('testimonials.lisa.name'), text: t('packages.testimonials.lisa'), rating: 5 },
|
||||
];
|
||||
|
||||
const allPackages = [...endcustomerPackages, ...resellerPackages];
|
||||
|
||||
const selectHighlightPackageId = (packages: Package[]): number | null => {
|
||||
const count = packages.length;
|
||||
if (count <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sortedByPrice = [...packages].sort((a, b) => a.price - b.price);
|
||||
|
||||
if (count === 2) {
|
||||
return sortedByPrice[1]?.id ?? null;
|
||||
}
|
||||
|
||||
if (count === 3) {
|
||||
return sortedByPrice[1]?.id ?? null;
|
||||
}
|
||||
|
||||
return sortedByPrice[count - 2]?.id ?? null;
|
||||
};
|
||||
|
||||
const highlightEndcustomerId = useMemo(
|
||||
() => selectHighlightPackageId(endcustomerPackages),
|
||||
[endcustomerPackages],
|
||||
@@ -279,6 +260,124 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
||||
[resellerPackages],
|
||||
);
|
||||
|
||||
const orderedEndcustomerPackages = useMemo(
|
||||
() => sortPackagesByPrice(endcustomerPackages),
|
||||
[endcustomerPackages],
|
||||
);
|
||||
|
||||
const orderedResellerPackages = useMemo(
|
||||
() => sortPackagesByPrice(resellerPackages),
|
||||
[resellerPackages],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const media = window.matchMedia('(max-width: 768px)');
|
||||
const update = () => setIsMobile(media.matches);
|
||||
update();
|
||||
media.addEventListener ? media.addEventListener('change', update) : media.addListener(update);
|
||||
|
||||
return () => {
|
||||
media.removeEventListener ? media.removeEventListener('change', update) : media.removeListener(update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scrollMobileListToHighlight = (
|
||||
container: HTMLDivElement | null,
|
||||
packages: Package[],
|
||||
highlightId: number | null,
|
||||
) => {
|
||||
if (!container || !highlightId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = packages.findIndex((pkg) => pkg.id === highlightId);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const child = container.children[index] as HTMLElement | undefined;
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetLeft = child.offsetLeft - container.clientWidth / 2 + child.clientWidth / 2;
|
||||
container.scrollTo({ left: Math.max(targetLeft, 0), behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
scrollMobileListToHighlight(mobileEndcustomerRef.current, orderedEndcustomerPackages, highlightEndcustomerId);
|
||||
}, [orderedEndcustomerPackages, highlightEndcustomerId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
scrollMobileListToHighlight(mobileResellerRef.current, orderedResellerPackages, highlightResellerId);
|
||||
}, [orderedResellerPackages, highlightResellerId]);
|
||||
|
||||
|
||||
const testimonials = [
|
||||
{ name: tCommon('testimonials.anna.name'), text: t('packages.testimonials.anna'), rating: 5 },
|
||||
{ name: tCommon('testimonials.max.name'), text: t('packages.testimonials.max'), rating: 5 },
|
||||
{ name: tCommon('testimonials.lisa.name'), text: t('packages.testimonials.lisa'), rating: 5 },
|
||||
];
|
||||
|
||||
const renderDetailBody = (wrapperClass: string) => {
|
||||
if (!selectedPackage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={dialogScrollRef} className={wrapperClass}>
|
||||
<div ref={dialogHeadingRef} tabIndex={-1} className="outline-none">
|
||||
<DialogHeader className="space-y-3 text-left">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-gray-400">
|
||||
{selectedVariant === 'reseller' ? t('packages.subscription') : t('packages.one_time')}
|
||||
</p>
|
||||
<DialogTitle className="text-3xl font-display text-gray-900">
|
||||
{selectedPackage.name}
|
||||
</DialogTitle>
|
||||
<p className="text-base text-gray-600">{selectedPackage.description}</p>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<PackageDetailGrid
|
||||
packageData={selectedPackage}
|
||||
variant={selectedVariant}
|
||||
isHighlight={selectedHighlight}
|
||||
purchaseUrl={purchaseUrl}
|
||||
onCtaClick={() => {
|
||||
handleCtaClick(selectedPackage, selectedVariant);
|
||||
localStorage.setItem('preferred_package', JSON.stringify(selectedPackage));
|
||||
}}
|
||||
t={t}
|
||||
tCommon={tCommon}
|
||||
testimonials={testimonials}
|
||||
close={() => setOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function selectHighlightPackageId(packages: Package[]): number | null {
|
||||
const count = packages.length;
|
||||
if (count <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sortedByPrice = [...packages].sort((a, b) => a.price - b.price);
|
||||
|
||||
if (count === 2) {
|
||||
return sortedByPrice[1]?.id ?? null;
|
||||
}
|
||||
|
||||
if (count === 3) {
|
||||
return sortedByPrice[1]?.id ?? null;
|
||||
}
|
||||
|
||||
return sortedByPrice[count - 2]?.id ?? null;
|
||||
}
|
||||
|
||||
function isHighlightedPackage(pkg: Package, variant: 'endcustomer' | 'reseller') {
|
||||
return variant === 'reseller' ? pkg.id === highlightResellerId : pkg.id === highlightEndcustomerId;
|
||||
}
|
||||
@@ -341,7 +440,7 @@ const getAccentTheme = (variant: 'endcustomer' | 'reseller') =>
|
||||
buttonHighlight: 'bg-gray-900 text-white hover:bg-gray-800',
|
||||
buttonDefault: 'border border-amber-200 text-amber-700 hover:bg-amber-50',
|
||||
cardBorder: 'border border-amber-100',
|
||||
highlightShadow: 'shadow-lg shadow-amber-100/60',
|
||||
highlightShadow: 'shadow-lg shadow-amber-100/60 bg-gradient-to-br from-amber-50/70 via-white to-amber-100/60',
|
||||
}
|
||||
: {
|
||||
badge: 'bg-rose-50 text-rose-700',
|
||||
@@ -349,7 +448,7 @@ const getAccentTheme = (variant: 'endcustomer' | 'reseller') =>
|
||||
buttonHighlight: 'bg-gray-900 text-white hover:bg-gray-800',
|
||||
buttonDefault: 'border border-rose-100 text-rose-700 hover:bg-rose-50',
|
||||
cardBorder: 'border border-rose-100',
|
||||
highlightShadow: 'shadow-lg shadow-rose-100/60',
|
||||
highlightShadow: 'shadow-lg shadow-rose-100/60 bg-gradient-to-br from-rose-50/70 via-white to-rose-100/60',
|
||||
};
|
||||
|
||||
type PackageMetric = {
|
||||
@@ -463,15 +562,80 @@ function PackageCard({
|
||||
const keyFeatures = pkg.features.slice(0, 3);
|
||||
const metrics = resolvePackageMetrics(pkg, variant, t, tCommon);
|
||||
|
||||
const metricList = compact ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{metrics.map((metric) => (
|
||||
<div key={metric.key} className="rounded-full border border-gray-200 px-3 py-1 text-xs font-semibold text-gray-700">
|
||||
<span className="text-[11px] font-medium uppercase text-gray-400">{metric.label}</span>
|
||||
<span className="ml-1 text-gray-900">{metric.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{metrics.map((metric) => (
|
||||
<div key={metric.key} className="rounded-xl bg-gray-50 px-4 py-3">
|
||||
<p className="text-lg font-semibold text-gray-900">{metric.value}</p>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500">{metric.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const featureList = compact ? (
|
||||
<ul className="space-y-1 text-sm text-gray-700">
|
||||
{keyFeatures.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2 text-xs">
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 text-gray-900" />
|
||||
<span>{t(`packages.feature_${feature}`)}</span>
|
||||
</li>
|
||||
))}
|
||||
{pkg.watermark_allowed === false && (
|
||||
<li className="flex items-start gap-2 text-xs">
|
||||
<Shield className="mt-0.5 h-3.5 w-3.5 text-gray-900" />
|
||||
<span>{t('packages.no_watermark')}</span>
|
||||
</li>
|
||||
)}
|
||||
{pkg.branding_allowed && (
|
||||
<li className="flex items-start gap-2 text-xs">
|
||||
<Sparkles className="mt-0.5 h-3.5 w-3.5 text-gray-900" />
|
||||
<span>{t('packages.custom_branding')}</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
) : (
|
||||
<ul className="space-y-2 text-sm text-gray-700">
|
||||
{keyFeatures.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-gray-900" />
|
||||
<span>{t(`packages.feature_${feature}`)}</span>
|
||||
</li>
|
||||
))}
|
||||
{pkg.watermark_allowed === false && (
|
||||
<li className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-gray-900" />
|
||||
<span>{t('packages.no_watermark')}</span>
|
||||
</li>
|
||||
)}
|
||||
{pkg.branding_allowed && (
|
||||
<li className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-gray-900" />
|
||||
<span>{t('packages.custom_branding')}</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'flex h-full flex-col rounded-2xl border border-gray-100 bg-white shadow-sm transition hover:shadow-lg',
|
||||
compact && 'p-3',
|
||||
highlight && `${accent.cardBorder} ${accent.highlightShadow}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<CardHeader className="gap-4">
|
||||
<CardHeader className={cn('gap-4', compact && 'gap-3 p-0')}>
|
||||
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
|
||||
<span>{typeLabel}</span>
|
||||
{badgeLabel && (
|
||||
@@ -490,10 +654,10 @@ function PackageCard({
|
||||
<CardDescription className="text-sm text-gray-600">{pkg.description}</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-6">
|
||||
<CardContent className={cn('flex flex-col gap-6', compact && 'gap-4 p-0 pt-2')}>
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={cn('text-4xl font-semibold', accent.price)}>{priceLabel}</span>
|
||||
<div className={cn('flex items-baseline gap-2', compact && 'flex-wrap text-balance')}>
|
||||
<span className={cn('text-4xl font-semibold', accent.price, compact && 'text-3xl')}>{priceLabel}</span>
|
||||
{pkg.price !== 0 && (
|
||||
<span className="text-sm text-gray-500">/ {cadenceLabel}</span>
|
||||
)}
|
||||
@@ -504,42 +668,17 @@ function PackageCard({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{metrics.map((metric) => (
|
||||
<div key={metric.key} className="rounded-xl bg-gray-50 px-4 py-3">
|
||||
<p className="text-lg font-semibold text-gray-900">{metric.value}</p>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500">{metric.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-gray-700">
|
||||
{keyFeatures.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-gray-900" />
|
||||
<span>{t(`packages.feature_${feature}`)}</span>
|
||||
</li>
|
||||
))}
|
||||
{pkg.watermark_allowed === false && (
|
||||
<li className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-gray-900" />
|
||||
<span>{t('packages.no_watermark')}</span>
|
||||
</li>
|
||||
)}
|
||||
{pkg.branding_allowed && (
|
||||
<li className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-gray-900" />
|
||||
<span>{t('packages.custom_branding')}</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{metricList}
|
||||
{featureList}
|
||||
</CardContent>
|
||||
{showCTA && onSelect && (
|
||||
<CardFooter className="mt-auto">
|
||||
<CardFooter className={cn('mt-auto', compact && 'pt-4')}>
|
||||
<Button
|
||||
onClick={() => onSelect(pkg)}
|
||||
className={cn(
|
||||
'w-full justify-center rounded-full text-sm font-semibold',
|
||||
highlight ? accent.buttonHighlight : accent.buttonDefault,
|
||||
compact && 'py-4 text-base',
|
||||
)}
|
||||
variant={highlight ? 'default' : 'outline'}
|
||||
>
|
||||
@@ -552,43 +691,215 @@ function PackageCard({
|
||||
);
|
||||
}
|
||||
|
||||
interface PackageDetailGridProps {
|
||||
packageData: Package;
|
||||
variant: 'endcustomer' | 'reseller';
|
||||
isHighlight: boolean;
|
||||
purchaseUrl: string;
|
||||
onCtaClick: () => void;
|
||||
t: TFunction;
|
||||
tCommon: TFunction;
|
||||
testimonials: { name: string; text: string; rating: number }[];
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
||||
packageData,
|
||||
variant,
|
||||
isHighlight,
|
||||
purchaseUrl,
|
||||
onCtaClick,
|
||||
t,
|
||||
tCommon,
|
||||
testimonials,
|
||||
close,
|
||||
}) => {
|
||||
const metrics = resolvePackageMetrics(packageData, variant, t, tCommon);
|
||||
|
||||
return (
|
||||
<div className="grid gap-8 lg:grid-cols-[320px,1fr]">
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-gray-100 bg-gray-50 p-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">{t('packages.price')}</p>
|
||||
<p className="text-4xl font-semibold text-gray-900">
|
||||
{Number(packageData.price).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}{' '}
|
||||
{t('packages.currency.euro')}
|
||||
</p>
|
||||
{packageData.price > 0 && (
|
||||
<p className="text-sm text-gray-500">
|
||||
/ {variant === 'reseller' ? t('packages.billing_per_year') : t('packages.billing_per_event')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isHighlight && (
|
||||
<span className="rounded-full bg-gray-900 px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-white">
|
||||
{variant === 'reseller' ? t('packages.badge_best_value') : t('packages.badge_most_popular')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-2 gap-3 text-sm">
|
||||
{metrics.map((metric) => (
|
||||
<div key={metric.key} className="rounded-xl bg-white px-4 py-3 text-center shadow-sm">
|
||||
<p className="text-lg font-semibold text-gray-900">{metric.value}</p>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500">{metric.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
className="mt-6 w-full justify-center rounded-full bg-rose-600/90 py-3 text-base font-semibold text-white shadow-lg shadow-rose-500/30 transition hover:bg-rose-600"
|
||||
>
|
||||
<Link
|
||||
href={purchaseUrl}
|
||||
onClick={() => {
|
||||
onCtaClick();
|
||||
}}
|
||||
>
|
||||
{t('packages.to_order')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="mt-3 text-xs text-gray-500">{t('packages.order_hint')}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-100 bg-white p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{t('packages.feature_highlights')}</h3>
|
||||
<ul className="mt-4 space-y-3 text-sm text-gray-700">
|
||||
{packageData.features.slice(0, 5).map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2">
|
||||
<Check className="mt-1 h-4 w-4 text-gray-900" />
|
||||
<span>{t(`packages.feature_${feature}`)}</span>
|
||||
</li>
|
||||
))}
|
||||
{packageData.watermark_allowed === false && (
|
||||
<li className="flex items-start gap-2">
|
||||
<Shield className="mt-1 h-4 w-4 text-gray-900" />
|
||||
<span>{t('packages.no_watermark')}</span>
|
||||
</li>
|
||||
)}
|
||||
{packageData.branding_allowed && (
|
||||
<li className="flex items-start gap-2">
|
||||
<Sparkles className="mt-1 h-4 w-4 text-gray-900" />
|
||||
<span>{t('packages.custom_branding')}</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-100 bg-white p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{t('packages.more_details_tab')}</h3>
|
||||
<Tabs defaultValue="breakdown">
|
||||
<TabsList className="grid grid-cols-2 rounded-full bg-gray-100 p-1 text-sm">
|
||||
<TabsTrigger value="breakdown" className="rounded-full">
|
||||
{t('packages.breakdown_label')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="testimonials" className="rounded-full">
|
||||
{t('packages.testimonials_title')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="breakdown" className="mt-6">
|
||||
{packageData.description_breakdown?.length ? (
|
||||
<Accordion type="multiple" className="space-y-4">
|
||||
{packageData.description_breakdown.map((entry, index) => (
|
||||
<AccordionItem key={index} value={`detail-${index}`} className="rounded-2xl border border-gray-100 bg-white px-4">
|
||||
<AccordionTrigger className="text-left text-base font-medium text-gray-900 hover:no-underline">
|
||||
{entry.title ?? t('packages.limits_label')}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4 text-sm text-gray-600 whitespace-pre-line">
|
||||
{entry.value}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">{t('packages.breakdown_label_hint')}</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="testimonials" className="mt-6">
|
||||
<div className="space-y-4">
|
||||
{testimonials.map((testimonial, index) => (
|
||||
<div key={index} className="rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">{testimonial.name}</p>
|
||||
<p className="text-xs text-gray-500">{packageData.name}</p>
|
||||
</div>
|
||||
<div className="flex gap-1 text-amber-400">
|
||||
{[...Array(testimonial.rating)].map((_, i) => (
|
||||
<Star key={i} className="h-3.5 w-3.5" fill="currentColor" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-gray-600">“{testimonial.text}”</p>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" className="w-full rounded-full border-gray-200 text-sm font-medium text-gray-700" onClick={close}>
|
||||
{t('packages.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<MarketingLayout title={t('packages.title')}>
|
||||
<section className="bg-aurora-enhanced text-gray-900 dark:text-gray-100 py-20 px-4">
|
||||
<div className="container mx-auto text-center space-y-8">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-gray-600 dark:text-gray-300">
|
||||
{t('packages.for_endcustomers')}
|
||||
<section className="bg-aurora-enhanced text-gray-900 dark:text-gray-100 px-4 py-12 md:py-16">
|
||||
<div className="container mx-auto text-center space-y-6 md:space-y-8">
|
||||
<div className="space-y-3 md:space-y-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-gray-600 dark:text-gray-300">
|
||||
{t('packages.for_endcustomers')} · {t('packages.for_resellers')}
|
||||
</p>
|
||||
<h1 className="text-4xl md:text-6xl font-bold font-display">{t('packages.hero_title')}</h1>
|
||||
<p className="text-xl md:text-2xl max-w-3xl mx-auto font-sans-marketing text-gray-700 dark:text-gray-200">
|
||||
<h1 className="text-3xl font-bold font-display md:text-5xl">
|
||||
{t('packages.hero_title')}
|
||||
</h1>
|
||||
<p className="mx-auto max-w-2xl font-sans-marketing text-base text-gray-700 dark:text-gray-200 md:text-xl">
|
||||
{t('packages.hero_description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Link
|
||||
href="/de/demo"
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="rounded-full bg-gray-900 text-white shadow-lg shadow-gray-900/20 hover:bg-gray-800"
|
||||
onClick={() => {
|
||||
trackPackagesHeroClick();
|
||||
trackEvent({
|
||||
category: 'marketing_packages',
|
||||
action: 'hero_cta',
|
||||
name: `demo:${packagesHeroVariant}`,
|
||||
name: `scroll:${packagesHeroVariant}`,
|
||||
});
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-gradient-to-r from-rose-500 via-pink-500 to-amber-400 px-10 py-4 text-lg font-semibold text-white shadow-xl shadow-rose-500/40 transition hover:from-rose-500/95 hover:via-pink-500/95 hover:to-amber-400/95"
|
||||
>
|
||||
{t('packages.cta_demo')}
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Link>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{t('packages.hero_secondary')}
|
||||
</p>
|
||||
<a href="#packages-showcase">
|
||||
{t('packages.cta_explore')}
|
||||
<ArrowRight className="ml-2 inline h-4 w-4" aria-hidden />
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="rounded-full border-white/40 bg-white/30 text-gray-900 backdrop-blur hover:bg-white/50 dark:border-gray-800 dark:bg-gray-900/40 dark:text-gray-100"
|
||||
>
|
||||
<Link href={localizedPath('/kontakt')}>
|
||||
{t('packages.contact_us')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{t('packages.hero_secondary')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-20 px-4">
|
||||
<section id="packages-showcase" className="px-4 py-16 md:py-20">
|
||||
<div className="container mx-auto space-y-12">
|
||||
<Tabs defaultValue="endcustomer" className="space-y-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
@@ -604,41 +915,85 @@ function PackageCard({
|
||||
</div>
|
||||
|
||||
<TabsContent value="endcustomer" className="space-y-8">
|
||||
<div className="overflow-x-auto pb-4">
|
||||
<div className="flex min-w-full gap-6 md:grid md:grid-cols-2">
|
||||
{endcustomerPackages.map((pkg) => (
|
||||
<PackageCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
variant="endcustomer"
|
||||
highlight={pkg.id === highlightEndcustomerId}
|
||||
onSelect={(selected) => handleCardClick(selected, 'endcustomer')}
|
||||
className="min-w-[280px]"
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
<div className="md:hidden">
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-white to-transparent dark:from-gray-950" />
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-white to-transparent dark:from-gray-950" />
|
||||
<div
|
||||
ref={mobileEndcustomerRef}
|
||||
className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-6"
|
||||
style={{ scrollPaddingLeft: '16px', scrollBehavior: 'smooth' }}
|
||||
>
|
||||
{orderedEndcustomerPackages.map((pkg) => (
|
||||
<div key={pkg.id} className="snap-start basis-[72vw] shrink-0 sm:basis-[60vw]">
|
||||
<PackageCard
|
||||
pkg={pkg}
|
||||
variant="endcustomer"
|
||||
highlight={pkg.id === highlightEndcustomerId}
|
||||
onSelect={(selected) => handleCardClick(selected, 'endcustomer')}
|
||||
className="h-full"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PackageComparison packages={endcustomerPackages} variant="endcustomer" />
|
||||
<div className="hidden gap-6 md:grid md:grid-cols-2 xl:grid-cols-3">
|
||||
{orderedEndcustomerPackages.map((pkg) => (
|
||||
<PackageCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
variant="endcustomer"
|
||||
highlight={pkg.id === highlightEndcustomerId}
|
||||
onSelect={(selected) => handleCardClick(selected, 'endcustomer')}
|
||||
className="h-full"
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<PackageComparison packages={orderedEndcustomerPackages} variant="endcustomer" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="reseller" className="space-y-8">
|
||||
<div className="overflow-x-auto pb-4">
|
||||
<div className="flex min-w-full gap-6 md:grid md:grid-cols-2 lg:grid-cols-3">
|
||||
{resellerPackages.map((pkg) => (
|
||||
<PackageCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
variant="reseller"
|
||||
highlight={pkg.id === highlightResellerId}
|
||||
onSelect={(selected) => handleCardClick(selected, 'reseller')}
|
||||
className="min-w-[280px]"
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
<div className="md:hidden">
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-white to-transparent dark:from-gray-950" />
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-white to-transparent dark:from-gray-950" />
|
||||
<div
|
||||
ref={mobileResellerRef}
|
||||
className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-6"
|
||||
style={{ scrollPaddingLeft: '16px', scrollBehavior: 'smooth' }}
|
||||
>
|
||||
{orderedResellerPackages.map((pkg) => (
|
||||
<div key={pkg.id} className="snap-start basis-[72vw] shrink-0 sm:basis-[60vw]">
|
||||
<PackageCard
|
||||
pkg={pkg}
|
||||
variant="reseller"
|
||||
highlight={pkg.id === highlightResellerId}
|
||||
onSelect={(selected) => handleCardClick(selected, 'reseller')}
|
||||
className="h-full"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PackageComparison packages={resellerPackages} variant="reseller" />
|
||||
<div className="hidden gap-6 md:grid md:grid-cols-2 xl:grid-cols-3">
|
||||
{orderedResellerPackages.map((pkg) => (
|
||||
<PackageCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
variant="reseller"
|
||||
highlight={pkg.id === highlightResellerId}
|
||||
onSelect={(selected) => handleCardClick(selected, 'reseller')}
|
||||
className="h-full"
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<PackageComparison packages={orderedResellerPackages} variant="reseller" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -670,208 +1025,41 @@ function PackageCard({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Modal */}
|
||||
{/* Details overlay */}
|
||||
{selectedPackage && (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
className="max-w-6xl border border-gray-100 bg-white px-0 py-0 sm:rounded-[32px]"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
dialogScrollRef.current?.scrollTo({ top: 0 });
|
||||
dialogHeadingRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<div ref={dialogScrollRef} className="max-h-[88vh] overflow-y-auto space-y-8 p-6 md:p-10">
|
||||
<div ref={dialogHeadingRef} tabIndex={-1} className="outline-none">
|
||||
<DialogHeader className="space-y-3 text-left">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-gray-400">
|
||||
{selectedVariant === 'reseller' ? t('packages.subscription') : t('packages.one_time')}
|
||||
</p>
|
||||
<DialogTitle className="text-3xl font-display text-gray-900">
|
||||
{selectedPackage.name}
|
||||
</DialogTitle>
|
||||
<p className="text-base text-gray-600">{selectedPackage.description}</p>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<div className="grid gap-8 lg:grid-cols-[320px,1fr]">
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-gray-100 bg-gray-50 p-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">{t('packages.price')}</p>
|
||||
<p className="text-4xl font-semibold text-gray-900">
|
||||
{Number(selectedPackage.price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {t('packages.currency.euro')}
|
||||
</p>
|
||||
{selectedPackage.price > 0 && (
|
||||
<p className="text-sm text-gray-500">
|
||||
/ {selectedVariant === 'reseller' ? t('packages.billing_per_year') : t('packages.billing_per_event')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{selectedHighlight && (
|
||||
<span className="rounded-full bg-gray-900 px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-white">
|
||||
{selectedVariant === 'reseller'
|
||||
? t('packages.badge_best_value')
|
||||
: t('packages.badge_most_popular')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-2 gap-3 text-sm">
|
||||
{resolvePackageMetrics(selectedPackage, selectedVariant, t, tCommon).map((metric) => (
|
||||
<div key={metric.key} className="rounded-xl bg-white px-4 py-3 text-center shadow-sm">
|
||||
<p className="text-lg font-semibold text-gray-900">{metric.value}</p>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500">{metric.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
className="mt-6 w-full justify-center rounded-full bg-rose-600/90 py-3 text-base font-semibold text-white shadow-lg shadow-rose-500/30 transition hover:bg-rose-600"
|
||||
>
|
||||
<Link
|
||||
href={purchaseUrl}
|
||||
onClick={() => {
|
||||
handleCtaClick(selectedPackage, selectedVariant);
|
||||
localStorage.setItem('preferred_package', JSON.stringify(selectedPackage));
|
||||
}}
|
||||
>
|
||||
{t('packages.to_order')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="mt-3 text-xs text-gray-500">{t('packages.order_hint')}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-100 bg-white p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{t('packages.feature_highlights')}</h3>
|
||||
<ul className="mt-4 space-y-3 text-sm text-gray-700">
|
||||
{selectedPackage.features.slice(0, 5).map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2">
|
||||
<Check className="mt-1 h-4 w-4 text-gray-900" />
|
||||
<span>{t(`packages.feature_${feature}`)}</span>
|
||||
</li>
|
||||
))}
|
||||
{selectedPackage.watermark_allowed === false && (
|
||||
<li className="flex items-start gap-2">
|
||||
<Shield className="mt-1 h-4 w-4 text-gray-900" />
|
||||
<span>{t('packages.no_watermark')}</span>
|
||||
</li>
|
||||
)}
|
||||
{selectedPackage.branding_allowed && (
|
||||
<li className="flex items-start gap-2">
|
||||
<Sparkles className="mt-1 h-4 w-4 text-gray-900" />
|
||||
<span>{t('packages.custom_branding')}</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Tabs value={currentStep} onValueChange={setCurrentStep}>
|
||||
<TabsList className="grid w-full grid-cols-3 rounded-full bg-gray-100 p-1 text-sm">
|
||||
<TabsTrigger className="rounded-full" value="overview">
|
||||
{t('packages.details')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="rounded-full" value="testimonials">
|
||||
{t('packages.customer_opinions')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview" className="mt-6 space-y-6">
|
||||
<div className="rounded-2xl border border-gray-100 bg-white p-6">
|
||||
<h4 className="text-lg font-semibold text-gray-900">{t('packages.quick_facts')}</h4>
|
||||
<p className="text-sm text-gray-500">{t('packages.quick_facts_hint')}</p>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
{resolvePackageMetrics(selectedPackage, selectedVariant, t, tCommon).map((metric) => (
|
||||
<div key={metric.key} className="rounded-xl bg-gray-50 p-4">
|
||||
<p className="text-xl font-semibold text-gray-900">{metric.value}</p>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500">{metric.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-100 bg-white p-6 space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-900">{t('packages.feature_highlights')}</h4>
|
||||
<ul className="space-y-2 text-sm text-gray-700">
|
||||
{selectedPackage.features.slice(0, 4).map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-gray-900" />
|
||||
<span>{t(`packages.feature_${feature}`)}</span>
|
||||
</li>
|
||||
))}
|
||||
{selectedPackage.watermark_allowed === false && (
|
||||
<li className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-gray-900" />
|
||||
<span>{t('packages.no_watermark')}</span>
|
||||
</li>
|
||||
)}
|
||||
{selectedPackage.branding_allowed && (
|
||||
<li className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-gray-900" />
|
||||
<span>{t('packages.custom_branding')}</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="deep" className="mt-6 space-y-6">
|
||||
{selectedPackage.description_breakdown?.length ? (
|
||||
<Accordion type="multiple" className="space-y-4">
|
||||
{selectedPackage.description_breakdown.map((entry, index) => (
|
||||
<AccordionItem key={index} value={`detail-${index}`} className="rounded-2xl border border-gray-100 bg-white px-4">
|
||||
<AccordionTrigger className="text-left text-base font-medium text-gray-900 hover:no-underline">
|
||||
{entry.title ?? t('packages.limits_label')}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4 text-sm text-gray-600 whitespace-pre-line">
|
||||
{entry.value}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">{t('packages.breakdown_label_hint')}</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="testimonials" className="mt-6">
|
||||
<div className="space-y-4">
|
||||
{testimonials.map((testimonial, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-2xl border border-gray-100 bg-white p-5 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">{testimonial.name}</p>
|
||||
<p className="text-xs text-gray-500">{selectedPackage.name}</p>
|
||||
</div>
|
||||
<div className="flex gap-1 text-amber-400">
|
||||
{[...Array(testimonial.rating)].map((_, i) => (
|
||||
<Star key={i} className="h-3.5 w-3.5" fill="currentColor" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-gray-600">“{testimonial.text}”</p>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full rounded-full border-gray-200 text-sm font-medium text-gray-700"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t('packages.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
{/* Testimonials Section entfernt, da nun im Dialog */}
|
||||
isMobile ? (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
className="h-[90vh] overflow-hidden rounded-t-[32px] border border-gray-200 bg-white p-0"
|
||||
onOpenAutoFocus={handleDetailAutoFocus}
|
||||
>
|
||||
{renderDetailBody('h-full overflow-y-auto space-y-8 p-6')}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
) : (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
className="max-w-6xl border border-gray-100 bg-white px-0 py-0 sm:rounded-[32px]"
|
||||
onOpenAutoFocus={handleDetailAutoFocus}
|
||||
>
|
||||
{renderDetailBody('max-h-[88vh] overflow-y-auto space-y-8 p-6 md:p-10')}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
)} {/* Testimonials Section entfernt, da nun im Dialog */}
|
||||
</MarketingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleDetailAutoFocus = (event: Event) => {
|
||||
event.preventDefault();
|
||||
dialogScrollRef.current?.scrollTo({ top: 0 });
|
||||
dialogHeadingRef.current?.focus();
|
||||
};
|
||||
|
||||
Packages.layout = (page: React.ReactNode) => page;
|
||||
|
||||
export default Packages;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { TFunction } from 'i18next';
|
||||
import { Check, Package as PackageIcon } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCheckoutWizard } from "../WizardContext";
|
||||
import type { CheckoutPackage } from "../types";
|
||||
|
||||
@@ -18,11 +19,45 @@ function translateFeature(feature: string, t: TFunction<'marketing'>) {
|
||||
return t(`packages.feature_${feature}`, { defaultValue: fallback });
|
||||
}
|
||||
|
||||
const DETAIL_LABEL_MAP: Record<string, string> = {
|
||||
fotos: 'photos',
|
||||
photos: 'photos',
|
||||
gaeste: 'guests',
|
||||
gäste: 'guests',
|
||||
guests: 'guests',
|
||||
aufgaben: 'tasks',
|
||||
challenges: 'tasks',
|
||||
galerie: 'gallery',
|
||||
gallery: 'gallery',
|
||||
branding: 'branding',
|
||||
'events_jahr': 'events_per_year',
|
||||
eventsjahr: 'events_per_year',
|
||||
};
|
||||
|
||||
function translateDetailLabel(label: string | undefined, t: TFunction<'marketing'>): string | undefined {
|
||||
if (!label) {
|
||||
return label;
|
||||
}
|
||||
|
||||
const normalised = label
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_|_$/g, '') || label.toLowerCase();
|
||||
|
||||
const key = DETAIL_LABEL_MAP[normalised] ?? normalised;
|
||||
return t(`packages.detail_labels.${key}`, { defaultValue: label });
|
||||
}
|
||||
|
||||
function PackageSummary({ pkg, t }: { pkg: CheckoutPackage; t: TFunction<'marketing'> }) {
|
||||
const isFree = pkg.price === 0;
|
||||
const accentGradient = pkg.type === 'reseller'
|
||||
? 'border-amber-100 bg-gradient-to-br from-amber-50/80 via-white to-amber-100/70 shadow-lg shadow-amber-100/60'
|
||||
: 'border-rose-100 bg-gradient-to-br from-rose-50/80 via-white to-rose-100/70 shadow-lg shadow-rose-100/60';
|
||||
|
||||
return (
|
||||
<Card className={`shadow-sm ${isFree ? 'opacity-75' : ''}`}>
|
||||
<Card className={cn('shadow-sm transition', isFree ? 'opacity-75' : accentGradient)}>
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className={`flex items-center gap-3 text-2xl ${isFree ? 'text-muted-foreground' : ''}`}>
|
||||
<PackageIcon className={`h-6 w-6 ${isFree ? 'text-muted-foreground' : 'text-primary'}`} />
|
||||
@@ -55,7 +90,9 @@ function PackageSummary({ pkg, t }: { pkg: CheckoutPackage; t: TFunction<'market
|
||||
{pkg.description_breakdown.map((row, index) => (
|
||||
<div key={index} className="rounded-lg border border-muted/40 bg-muted/20 px-3 py-2">
|
||||
{row.title && (
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{row.title}</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{translateDetailLabel(row.title, t)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">{row.value}</p>
|
||||
</div>
|
||||
@@ -80,18 +117,22 @@ function PackageSummary({ pkg, t }: { pkg: CheckoutPackage; t: TFunction<'market
|
||||
|
||||
function PackageOption({ pkg, isActive, onSelect, t }: { pkg: CheckoutPackage; isActive: boolean; onSelect: () => void; t: TFunction<'marketing'> }) {
|
||||
const isFree = pkg.price === 0;
|
||||
const accentGradient = pkg.type === 'reseller'
|
||||
? 'border-amber-100 bg-gradient-to-r from-amber-50/70 via-white to-amber-100/60 shadow-md shadow-amber-100/60'
|
||||
: 'border-rose-100 bg-gradient-to-r from-rose-50/70 via-white to-rose-100/60 shadow-md shadow-rose-100/60';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={`w-full rounded-md border bg-background p-4 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 ${
|
||||
className={cn(
|
||||
'w-full rounded-md border bg-background p-4 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40',
|
||||
isActive
|
||||
? "border-primary shadow-sm"
|
||||
? accentGradient
|
||||
: isFree
|
||||
? "border-border hover:border-primary/40 opacity-75 hover:opacity-100"
|
||||
: "border-border hover:border-primary/40"
|
||||
}`}
|
||||
? 'border-border hover:border-primary/40 opacity-75 hover:opacity-100'
|
||||
: 'border-border hover:border-primary/40',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between text-sm font-medium">
|
||||
<span className={isFree ? "text-muted-foreground" : ""}>{pkg.name}</span>
|
||||
|
||||
@@ -47,7 +47,11 @@ Route::middleware('auth')->group(function () {
|
||||
->name('verification.notice');
|
||||
|
||||
Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)
|
||||
->middleware(['signed', 'throttle:6,1'])
|
||||
->middleware([
|
||||
\App\Http\Middleware\NormalizeSignedUrlParameters::class,
|
||||
'signed:relative',
|
||||
'throttle:6,1',
|
||||
])
|
||||
->name('verification.verify');
|
||||
|
||||
Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
|
||||
|
||||
@@ -221,6 +221,14 @@ Route::get('/demo', function (Request $request) use ($determinePreferredLocale)
|
||||
return redirect("/{$locale}/demo", 301);
|
||||
});
|
||||
|
||||
Route::get('/marketing/login', function (Request $request) use ($determinePreferredLocale) {
|
||||
$locale = $determinePreferredLocale($request);
|
||||
$destination = "/{$locale}/login";
|
||||
$query = $request->getQueryString();
|
||||
|
||||
return redirect($query ? "{$destination}?{$query}" : $destination, 302);
|
||||
})->name('marketing.login');
|
||||
|
||||
Route::get('/success/{packageId?}', function (Request $request, ?int $packageId = null) use ($determinePreferredLocale) {
|
||||
$locale = $determinePreferredLocale($request);
|
||||
$path = "/{$locale}/success";
|
||||
|
||||
@@ -31,14 +31,37 @@ class EmailVerificationTest extends TestCase
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1($user->email)]
|
||||
['id' => $user->id, 'hash' => sha1($user->email)],
|
||||
absolute: false,
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->get($verificationUrl);
|
||||
|
||||
Event::assertDispatched(Verified::class);
|
||||
$this->assertTrue($user->fresh()->hasVerifiedEmail());
|
||||
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
|
||||
$response->assertRedirect(route('marketing.login', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
public function test_email_can_be_verified_when_link_contains_html_encoded_ampersand(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
Event::fake();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1($user->email)],
|
||||
absolute: false,
|
||||
);
|
||||
|
||||
$encodedUrl = str_replace('&', '&', $verificationUrl);
|
||||
|
||||
$this->actingAs($user)->get($encodedUrl)
|
||||
->assertRedirect(route('marketing.login', absolute: false).'?verified=1');
|
||||
|
||||
$this->assertTrue($user->fresh()->hasVerifiedEmail());
|
||||
Event::assertDispatched(Verified::class);
|
||||
}
|
||||
|
||||
public function test_email_is_not_verified_with_invalid_hash()
|
||||
@@ -48,7 +71,8 @@ class EmailVerificationTest extends TestCase
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1('wrong-email')]
|
||||
['id' => $user->id, 'hash' => sha1('wrong-email')],
|
||||
absolute: false,
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl);
|
||||
@@ -65,7 +89,8 @@ class EmailVerificationTest extends TestCase
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => 123, 'hash' => sha1($user->email)]
|
||||
['id' => 123, 'hash' => sha1($user->email)],
|
||||
absolute: false,
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl);
|
||||
@@ -95,13 +120,36 @@ class EmailVerificationTest extends TestCase
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1($user->email)]
|
||||
['id' => $user->id, 'hash' => sha1($user->email)],
|
||||
absolute: false,
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl)
|
||||
->assertRedirect(route('dashboard', absolute: false).'?verified=1');
|
||||
->assertRedirect(route('marketing.login', absolute: false).'?verified=1');
|
||||
|
||||
$this->assertTrue($user->fresh()->hasVerifiedEmail());
|
||||
Event::assertNotDispatched(Verified::class);
|
||||
}
|
||||
|
||||
public function test_invalid_signature_redirects_to_verification_prompt(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1($user->email)],
|
||||
absolute: false,
|
||||
);
|
||||
|
||||
$tampered = $verificationUrl.'-tamper';
|
||||
|
||||
$response = $this->actingAs($user)->get($tampered);
|
||||
|
||||
$response->assertRedirect(route('verification.notice', absolute: false));
|
||||
$response->assertSessionHas('verification', function ($flash): bool {
|
||||
return is_array($flash)
|
||||
&& ($flash['status'] ?? null) === 'error';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user