chore: checkpoint before block builder refactor

This commit is contained in:
2026-04-03 23:03:09 +02:00
parent 330950cc6e
commit 4f3fefca5c
14 changed files with 315 additions and 99 deletions

View File

@@ -59,7 +59,7 @@ PreRegister is a Laravel 11 application for festival ticket pre-registration. Vi
## Security ## Security
- CSRF on all forms - CSRF on all forms
- Rate limiting on public endpoints (`throttle:10,1`) - Rate limiting on public endpoints (`config/preregister.php` → `public_requests_per_minute`, applied in `routes/web.php`)
- Never expose API keys in frontend, logs, or responses - Never expose API keys in frontend, logs, or responses
- Validate and restrict file uploads (image types, max size) - Validate and restrict file uploads (image types, max size)
- UUID slugs prevent URL enumeration - UUID slugs prevent URL enumeration

View File

@@ -4,6 +4,9 @@ APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://localhost APP_URL=http://localhost
# Optional: max requests/minute per IP for public /r/{slug} and subscribe (default: 1000 when APP_ENV is local|testing, else 60).
# PUBLIC_REQUESTS_PER_MINUTE=120
# Wall-clock times from the admin UI (datetime-local) are interpreted in this zone. # Wall-clock times from the admin UI (datetime-local) are interpreted in this zone.
APP_TIMEZONE=Europe/Amsterdam APP_TIMEZONE=Europe/Amsterdam

View File

@@ -41,9 +41,7 @@ class UpdateMailwizzConfigRequest extends FormRequest
'field_email' => ['required', 'string', 'max:255'], 'field_email' => ['required', 'string', 'max:255'],
'field_first_name' => ['required', 'string', 'max:255'], 'field_first_name' => ['required', 'string', 'max:255'],
'field_last_name' => ['required', 'string', 'max:255'], 'field_last_name' => ['required', 'string', 'max:255'],
'field_phone' => $page->phone_enabled 'field_phone' => ['nullable', 'string', 'max:255'],
? ['required', 'string', 'max:255']
: ['nullable', 'string', 'max:255'],
'tag_field' => ['required', 'string', 'max:255'], 'tag_field' => ['required', 'string', 'max:255'],
'tag_value' => ['required', 'string', 'max:255'], 'tag_value' => ['required', 'string', 'max:255'],
]; ];

View File

@@ -6,6 +6,7 @@ namespace App\Http\Requests;
use App\Models\PreregistrationPage; use App\Models\PreregistrationPage;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Email;
class SubscribePublicPageRequest extends FormRequest class SubscribePublicPageRequest extends FormRequest
{ {
@@ -22,13 +23,28 @@ class SubscribePublicPageRequest extends FormRequest
/** @var PreregistrationPage $page */ /** @var PreregistrationPage $page */
$page = $this->route('publicPage'); $page = $this->route('publicPage');
$emailRule = (new Email)
->rfcCompliant()
->preventSpoofing();
return [ return [
'first_name' => ['required', 'string', 'max:255'], 'first_name' => ['required', 'string', 'max:255'],
'last_name' => ['required', 'string', 'max:255'], 'last_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'], 'email' => ['required', 'string', 'max:255', $emailRule],
'phone' => $page->phone_enabled 'phone' => $page->phone_enabled
? ['required', 'string', 'max:20'] ? ['nullable', 'string', 'regex:/^[0-9]{8,15}$/']
: ['nullable', 'string', 'max:20'], : ['nullable', 'string', 'max:255'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'email' => __('Please enter a valid email address.'),
'phone.regex' => __('Please enter a valid phone number (815 digits).'),
]; ];
} }
@@ -53,5 +69,18 @@ class SubscribePublicPageRequest extends FormRequest
'email' => strtolower(trim($email)), 'email' => strtolower(trim($email)),
]); ]);
} }
/** @var PreregistrationPage $page */
$page = $this->route('publicPage');
$phone = $this->input('phone');
if (! $page->phone_enabled) {
$this->merge(['phone' => null]);
return;
}
if (is_string($phone)) {
$digits = preg_replace('/\D+/', '', $phone);
$this->merge(['phone' => $digits === '' ? null : $digits]);
}
} }
} }

View File

@@ -63,7 +63,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
return; return;
} }
if (! $this->configIsComplete($config, $page->phone_enabled)) { if (! $this->configIsComplete($config)) {
Log::warning('SyncSubscriberToMailwizz: incomplete Mailwizz config', [ Log::warning('SyncSubscriberToMailwizz: incomplete Mailwizz config', [
'subscriber_id' => $subscriber->id, 'subscriber_id' => $subscriber->id,
'page_id' => $page->id, 'page_id' => $page->id,
@@ -106,16 +106,12 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
]); ]);
} }
private function configIsComplete(MailwizzConfig $config, bool $phoneEnabled): bool private function configIsComplete(MailwizzConfig $config): bool
{ {
if ($config->list_uid === '' || $config->field_email === '' || $config->field_first_name === '' || $config->field_last_name === '') { if ($config->list_uid === '' || $config->field_email === '' || $config->field_first_name === '' || $config->field_last_name === '') {
return false; return false;
} }
if ($phoneEnabled && ($config->field_phone === null || $config->field_phone === '')) {
return false;
}
if ($config->tag_field === null || $config->tag_field === '' || $config->tag_value === null || $config->tag_value === '') { if ($config->tag_field === null || $config->tag_field === '' || $config->tag_value === null || $config->tag_value === '') {
return false; return false;
} }

23
config/preregister.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
$env = env('APP_ENV', 'production');
$defaultPerMinute = in_array($env, ['local', 'testing'], true) ? 1000 : 60;
return [
/*
|--------------------------------------------------------------------------
| Public routes rate limit
|--------------------------------------------------------------------------
|
| Max requests per minute per IP for the public landing page and subscribe
| endpoint. Local and testing use a high default so refresh-heavy dev work
| does not hit 429. Override with PUBLIC_REQUESTS_PER_MINUTE in .env.
|
*/
'public_requests_per_minute' => (int) env('PUBLIC_REQUESTS_PER_MINUTE', (string) $defaultPerMinute),
];

View File

@@ -3,6 +3,8 @@
"Last name": "Achternaam", "Last name": "Achternaam",
"Email": "E-mailadres", "Email": "E-mailadres",
"Phone": "Mobiel", "Phone": "Mobiel",
"optional": "optioneel",
"Phone (optional)": "Mobiel (optioneel)",
"Register": "Registreren", "Register": "Registreren",
"days": "dagen", "days": "dagen",
"day": "dag", "day": "dag",
@@ -14,5 +16,7 @@
"This pre-registration period has ended.": "Deze preregistratieperiode is afgelopen.", "This pre-registration period has ended.": "Deze preregistratieperiode is afgelopen.",
"Visit ticket shop": "Ga naar de ticketshop", "Visit ticket shop": "Ga naar de ticketshop",
"Thank you for registering!": "Bedankt voor je registratie!", "Thank you for registering!": "Bedankt voor je registratie!",
"You are already registered for this event.": "Je bent al geregistreerd voor dit evenement." "You are already registered for this event.": "Je bent al geregistreerd voor dit evenement.",
"Please enter a valid email address.": "Voer een geldig e-mailadres in.",
"Please enter a valid phone number (815 digits).": "Voer een geldig telefoonnummer in (8 tot 15 cijfers)."
} }

View File

@@ -5,3 +5,12 @@
[x-cloak] { [x-cloak] {
display: none !important; display: none !important;
} }
/* Public landing: respect reduced motion for entrance animation */
@media (prefers-reduced-motion: reduce) {
.animate-preregister-in {
animation: none;
opacity: 1;
transform: none;
}
}

View File

@@ -2,6 +2,36 @@ import './bootstrap';
import Alpine from 'alpinejs'; import Alpine from 'alpinejs';
/**
* Client-side email check (aligned loosely with RFC-style rules; server is authoritative).
*/
function isValidEmailFormat(value) {
const email = String(value).trim().toLowerCase();
if (email.length < 5 || email.length > 255) {
return false;
}
if (!email.includes('@')) {
return false;
}
const at = email.lastIndexOf('@');
const local = email.slice(0, at);
const domain = email.slice(at + 1);
if (!local || !domain || local.includes('..') || domain.includes('..')) {
return false;
}
if (domain.startsWith('.') || domain.endsWith('.')) {
return false;
}
if (!/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+$/i.test(local)) {
return false;
}
if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/i.test(domain)) {
return false;
}
const tld = domain.split('.').pop();
return Boolean(tld && tld.length >= 2);
}
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
Alpine.data('publicPreregisterPage', (config) => ({ Alpine.data('publicPreregisterPage', (config) => ({
phase: config.phase, phase: config.phase,
@@ -12,6 +42,8 @@ document.addEventListener('alpine:init', () => {
genericError: config.genericError, genericError: config.genericError,
labelDay: config.labelDay, labelDay: config.labelDay,
labelDays: config.labelDays, labelDays: config.labelDays,
invalidEmailMsg: config.invalidEmailMsg,
invalidPhoneMsg: config.invalidPhoneMsg,
days: 0, days: 0,
hours: 0, hours: 0,
minutes: 0, minutes: 0,
@@ -56,15 +88,36 @@ document.addEventListener('alpine:init', () => {
return String(n).padStart(2, '0'); return String(n).padStart(2, '0');
}, },
validateEmailAndPhone() {
this.fieldErrors = {};
let ok = true;
if (!isValidEmailFormat(this.email)) {
this.fieldErrors.email = [this.invalidEmailMsg];
ok = false;
}
if (this.phoneEnabled) {
const digits = String(this.phone).replace(/\D/g, '');
if (digits.length > 0 && (digits.length < 8 || digits.length > 15)) {
this.fieldErrors.phone = [this.invalidPhoneMsg];
ok = false;
}
}
return ok;
},
async submitForm() { async submitForm() {
this.formError = '';
this.fieldErrors = {};
if (!this.validateEmailAndPhone()) {
return;
}
const form = this.$refs.form; const form = this.$refs.form;
if (form !== undefined && form !== null && !form.checkValidity()) { if (form !== undefined && form !== null && !form.checkValidity()) {
form.reportValidity(); form.reportValidity();
return; return;
} }
this.formError = '';
this.fieldErrors = {};
this.submitting = true; this.submitting = true;
try { try {
const body = { const body = {
@@ -262,10 +315,6 @@ document.addEventListener('alpine:init', () => {
this.errorMessage = cfg.strings.mapFieldsError; this.errorMessage = cfg.strings.mapFieldsError;
return; return;
} }
if (this.phoneEnabled && !this.fieldPhone) {
this.errorMessage = cfg.strings.mapPhoneError;
return;
}
if (!this.tagField) { if (!this.tagField) {
this.errorMessage = cfg.strings.tagFieldError; this.errorMessage = cfg.strings.tagFieldError;
return; return;

View File

@@ -34,7 +34,6 @@
'noListsError' => __('No mailing lists were returned. Check your API key or create a list in Mailwizz.'), 'noListsError' => __('No mailing lists were returned. Check your API key or create a list in Mailwizz.'),
'selectListError' => __('Select a mailing list.'), 'selectListError' => __('Select a mailing list.'),
'mapFieldsError' => __('Map email, first name, and last name to Mailwizz fields.'), 'mapFieldsError' => __('Map email, first name, and last name to Mailwizz fields.'),
'mapPhoneError' => __('Map the phone field for this page.'),
'tagFieldError' => __('Select a checkbox list field for source / tag tracking.'), 'tagFieldError' => __('Select a checkbox list field for source / tag tracking.'),
'tagValueError' => __('Select the tag option that identifies this pre-registration.'), 'tagValueError' => __('Select the tag option that identifies this pre-registration.'),
], ],
@@ -183,7 +182,7 @@
</select> </select>
</div> </div>
<div x-show="phoneEnabled"> <div x-show="phoneEnabled">
<label class="block text-sm font-medium text-slate-700">{{ __('Phone') }}</label> <label class="block text-sm font-medium text-slate-700">{{ __('Phone') }} <span class="font-normal text-slate-500">({{ __('optional') }})</span></label>
<select x-model="fieldPhone" class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"> <select x-model="fieldPhone" class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
<option value="">{{ __('Select field…') }}</option> <option value="">{{ __('Select field…') }}</option>
<template x-for="f in phoneFields()" :key="'ph-' + f.tag"> <template x-for="f in phoneFields()" :key="'ph-' + f.tag">

View File

@@ -27,11 +27,11 @@
></div> ></div>
@endif @endif
<div class="absolute inset-0 bg-black/50" aria-hidden="true"></div> <div class="absolute inset-0 bg-black/30" aria-hidden="true"></div>
<div class="relative z-10 flex min-h-screen items-center justify-center px-4 py-10 sm:px-6 sm:py-12"> <div class="relative z-10 flex min-h-screen items-center justify-center px-4 py-6 sm:px-6 sm:py-10">
<div <div
class="w-full max-w-lg rounded-2xl border border-white/15 bg-white/10 px-6 py-8 shadow-2xl backdrop-blur-md sm:px-10 sm:py-10" class="animate-preregister-in w-full max-w-3xl rounded-3xl border border-white/20 bg-black/60 px-5 py-8 shadow-[0_25px_60px_-15px_rgba(0,0,0,0.65)] backdrop-blur-[4px] sm:px-10 sm:py-10"
x-cloak x-cloak
x-data="publicPreregisterPage(@js([ x-data="publicPreregisterPage(@js([
'phase' => $phase, 'phase' => $phase,
@@ -42,95 +42,105 @@
'genericError' => __('Something went wrong. Please try again.'), 'genericError' => __('Something went wrong. Please try again.'),
'labelDay' => __('day'), 'labelDay' => __('day'),
'labelDays' => __('days'), 'labelDays' => __('days'),
'invalidEmailMsg' => __('Please enter a valid email address.'),
'invalidPhoneMsg' => __('Please enter a valid phone number (815 digits).'),
]))" ]))"
> >
@if ($logoUrl !== null) <div class="flex flex-col items-center text-center">
<div class="mb-6 flex justify-center"> @if ($logoUrl !== null)
<img <div class="mb-4 flex w-full justify-center sm:mb-5">
src="{{ e($logoUrl) }}" <img
alt="" src="{{ e($logoUrl) }}"
class="max-h-20 w-auto object-contain object-center" alt=""
width="320" class="max-h-32 w-auto object-contain object-center drop-shadow-[0_8px_32px_rgba(0,0,0,0.45)] sm:max-h-44 md:max-h-48"
height="80" width="384"
> height="192"
</div> >
@endif
<h1 class="text-center text-3xl font-semibold leading-tight tracking-tight text-white sm:text-4xl">
{{ $page->heading }}
</h1>
{{-- Before start: intro + countdown --}}
<div x-show="phase === 'before'" x-cloak class="mt-6 space-y-6">
@if (filled($page->intro_text))
<div class="whitespace-pre-line text-center text-base leading-relaxed text-white/90">
{{ $page->intro_text }}
</div> </div>
@endif @endif
<h1 class="w-full max-w-none text-balance text-2xl font-bold leading-snug tracking-tight text-festival sm:text-3xl">
{{ $page->heading }}
</h1>
@if (filled($page->intro_text))
<div
x-show="phase === 'before' || phase === 'active'"
x-cloak
class="mt-0 w-full max-w-none whitespace-pre-line text-[15px] leading-[1.65] text-white sm:text-base sm:leading-relaxed"
>
{{ trim($page->intro_text) }}
</div>
@endif
</div>
{{-- Before start: countdown --}}
<div x-show="phase === 'before'" x-cloak class="mt-8 space-y-6 sm:mt-10">
<div <div
class="grid grid-cols-4 gap-2 rounded-xl border border-white/20 bg-black/25 px-3 py-4 text-center sm:gap-3 sm:px-4" class="grid grid-cols-4 gap-3 rounded-2xl border border-white/15 bg-black/35 px-3 py-4 text-center shadow-inner sm:gap-4 sm:px-4 sm:py-5"
role="timer" role="timer"
aria-live="polite" aria-live="polite"
> >
<div> <div>
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(days)"></div> <div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(days)"></div>
<div class="mt-1 text-xs uppercase tracking-wide text-white/60" x-text="days === 1 ? labelDay : labelDays"></div> <div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs" x-text="days === 1 ? labelDay : labelDays"></div>
</div> </div>
<div> <div>
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(hours)"></div> <div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(hours)"></div>
<div class="mt-1 text-xs uppercase tracking-wide text-white/60">{{ __('hrs') }}</div> <div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs">{{ __('hrs') }}</div>
</div> </div>
<div> <div>
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(minutes)"></div> <div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(minutes)"></div>
<div class="mt-1 text-xs uppercase tracking-wide text-white/60">{{ __('mins') }}</div> <div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs">{{ __('mins') }}</div>
</div> </div>
<div> <div>
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(seconds)"></div> <div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(seconds)"></div>
<div class="mt-1 text-xs uppercase tracking-wide text-white/60">{{ __('secs') }}</div> <div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs">{{ __('secs') }}</div>
</div> </div>
</div> </div>
</div> </div>
{{-- Active: registration form --}} {{-- Active: registration form --}}
<div x-show="phase === 'active'" x-cloak class="mt-8"> <div x-show="phase === 'active'" x-cloak class="mt-8 sm:mt-10">
<form x-ref="form" class="space-y-4" @submit.prevent="submitForm()"> <form x-ref="form" class="space-y-4" @submit.prevent="submitForm()">
<div x-show="formError !== ''" x-cloak class="rounded-lg border border-amber-400/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-100" x-text="formError"></div> <div x-show="formError !== ''" x-cloak class="rounded-xl border border-amber-400/50 bg-amber-500/15 px-4 py-3 text-sm leading-snug text-amber-50" x-text="formError"></div>
<div> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label for="first_name" class="mb-1 block text-sm font-medium text-white/90">{{ __('First name') }}</label> <div>
<input <label for="first_name" class="sr-only">{{ __('First name') }}</label>
id="first_name" <input
type="text" id="first_name"
name="first_name" type="text"
autocomplete="given-name" name="first_name"
required autocomplete="given-name"
maxlength="255" required
x-model="first_name" maxlength="255"
class="w-full rounded-lg border border-white/25 bg-white/10 px-3 py-2.5 text-white placeholder-white/40 shadow-sm backdrop-blur-sm focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30" x-model="first_name"
placeholder="{{ __('First name') }}" class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 px-4 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45"
> placeholder="{{ __('First name') }}"
<p x-show="fieldErrors.first_name" x-cloak class="mt-1 text-sm text-red-200" x-text="fieldErrors.first_name ? fieldErrors.first_name[0] : ''"></p> >
<p x-show="fieldErrors.first_name" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.first_name ? fieldErrors.first_name[0] : ''"></p>
</div>
<div>
<label for="last_name" class="sr-only">{{ __('Last name') }}</label>
<input
id="last_name"
type="text"
name="last_name"
autocomplete="family-name"
required
maxlength="255"
x-model="last_name"
class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 px-4 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45"
placeholder="{{ __('Last name') }}"
>
<p x-show="fieldErrors.last_name" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.last_name ? fieldErrors.last_name[0] : ''"></p>
</div>
</div> </div>
<div> <div>
<label for="last_name" class="mb-1 block text-sm font-medium text-white/90">{{ __('Last name') }}</label> <label for="email" class="sr-only">{{ __('Email') }}</label>
<input
id="last_name"
type="text"
name="last_name"
autocomplete="family-name"
required
maxlength="255"
x-model="last_name"
class="w-full rounded-lg border border-white/25 bg-white/10 px-3 py-2.5 text-white placeholder-white/40 shadow-sm backdrop-blur-sm focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30"
placeholder="{{ __('Last name') }}"
>
<p x-show="fieldErrors.last_name" x-cloak class="mt-1 text-sm text-red-200" x-text="fieldErrors.last_name ? fieldErrors.last_name[0] : ''"></p>
</div>
<div>
<label for="email" class="mb-1 block text-sm font-medium text-white/90">{{ __('Email') }}</label>
<input <input
id="email" id="email"
type="email" type="email"
@@ -139,31 +149,30 @@
required required
maxlength="255" maxlength="255"
x-model="email" x-model="email"
class="w-full rounded-lg border border-white/25 bg-white/10 px-3 py-2.5 text-white placeholder-white/40 shadow-sm backdrop-blur-sm focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30" class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 px-4 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45"
placeholder="{{ __('Email') }}" placeholder="{{ __('Email') }}"
> >
<p x-show="fieldErrors.email" x-cloak class="mt-1 text-sm text-red-200" x-text="fieldErrors.email ? fieldErrors.email[0] : ''"></p> <p x-show="fieldErrors.email" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.email ? fieldErrors.email[0] : ''"></p>
</div> </div>
<div x-show="phoneEnabled"> <div x-show="phoneEnabled">
<label for="phone" class="mb-1 block text-sm font-medium text-white/90">{{ __('Phone') }}</label> <label for="phone" class="sr-only">{{ __('Phone (optional)') }}</label>
<input <input
id="phone" id="phone"
type="tel" type="tel"
name="phone" name="phone"
autocomplete="tel" autocomplete="tel"
:required="phoneEnabled"
maxlength="20" maxlength="20"
x-model="phone" x-model="phone"
class="w-full rounded-lg border border-white/25 bg-white/10 px-3 py-2.5 text-white placeholder-white/40 shadow-sm backdrop-blur-sm focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30" class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 px-4 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45"
placeholder="{{ __('Phone') }}" placeholder="{{ __('Phone (optional)') }}"
> >
<p x-show="fieldErrors.phone" x-cloak class="mt-1 text-sm text-red-200" x-text="fieldErrors.phone ? fieldErrors.phone[0] : ''"></p> <p x-show="fieldErrors.phone" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.phone ? fieldErrors.phone[0] : ''"></p>
</div> </div>
<button <button
type="submit" type="submit"
class="mt-2 w-full rounded-lg bg-white px-4 py-3 text-sm font-semibold text-slate-900 shadow transition hover:bg-white/90 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-slate-900 disabled:cursor-not-allowed disabled:opacity-60" class="mt-2 min-h-[52px] w-full rounded-xl bg-festival px-6 py-3.5 text-base font-bold tracking-wide text-white shadow-lg shadow-festival/30 transition duration-200 ease-out hover:scale-[1.02] hover:brightness-110 focus:outline-none focus:ring-2 focus:ring-festival focus:ring-offset-2 focus:ring-offset-black/80 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:scale-100 sm:min-h-[56px] sm:text-lg"
:disabled="submitting" :disabled="submitting"
> >
<span x-show="!submitting">{{ __('public.register_button') }}</span> <span x-show="!submitting">{{ __('public.register_button') }}</span>
@@ -173,25 +182,25 @@
</div> </div>
{{-- Thank you (after successful AJAX) --}} {{-- Thank you (after successful AJAX) --}}
<div x-show="phase === 'thanks'" x-cloak class="mt-8"> <div x-show="phase === 'thanks'" x-cloak class="mt-8 sm:mt-10">
<p class="whitespace-pre-line text-center text-lg leading-relaxed text-white/95" x-text="thankYouMessage"></p> <p class="whitespace-pre-line text-center text-base leading-relaxed text-white/95 sm:text-lg" x-text="thankYouMessage"></p>
</div> </div>
{{-- Expired --}} {{-- Expired --}}
<div x-show="phase === 'expired'" x-cloak class="mt-8 space-y-6"> <div x-show="phase === 'expired'" x-cloak class="mt-8 space-y-6 sm:mt-10">
@if (filled($page->expired_message)) @if (filled($page->expired_message))
<div class="whitespace-pre-line text-center text-base leading-relaxed text-white/90"> <div class="whitespace-pre-line text-center text-[15px] leading-[1.65] text-white/92 sm:text-base sm:leading-relaxed">
{{ $page->expired_message }} {{ $page->expired_message }}
</div> </div>
@else @else
<p class="text-center text-base text-white/90">{{ __('This pre-registration period has ended.') }}</p> <p class="text-center text-[15px] leading-relaxed text-white/92 sm:text-base">{{ __('This pre-registration period has ended.') }}</p>
@endif @endif
@if (filled($page->ticketshop_url)) @if (filled($page->ticketshop_url))
<div class="text-center"> <div class="text-center">
<a <a
href="{{ e($page->ticketshop_url) }}" href="{{ e($page->ticketshop_url) }}"
class="inline-flex items-center justify-center rounded-lg bg-white px-5 py-2.5 text-sm font-semibold text-slate-900 shadow transition hover:bg-white/90 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-slate-900" class="inline-flex min-h-[52px] items-center justify-center rounded-xl bg-festival px-8 py-3.5 text-base font-bold tracking-wide text-white shadow-lg shadow-festival/30 transition duration-200 ease-out hover:scale-[1.02] hover:brightness-110 focus:outline-none focus:ring-2 focus:ring-festival focus:ring-offset-2 focus:ring-offset-black/80 active:scale-[0.99]"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >

View File

@@ -18,7 +18,7 @@ Route::get('/', function () {
}); });
// ─── Public (no auth) ──────────────────────────────────── // ─── Public (no auth) ────────────────────────────────────
Route::middleware('throttle:10,1')->group(function () { Route::middleware(sprintf('throttle:%d,1', max(1, config('preregister.public_requests_per_minute'))))->group(function () {
Route::get('/r/{publicPage:slug}', [PublicPageController::class, 'show'])->name('public.page'); Route::get('/r/{publicPage:slug}', [PublicPageController::class, 'show'])->name('public.page');
Route::post('/r/{publicPage:slug}/subscribe', [PublicPageController::class, 'subscribe'])->name('public.subscribe'); Route::post('/r/{publicPage:slug}/subscribe', [PublicPageController::class, 'subscribe'])->name('public.subscribe');
}); });

View File

@@ -11,9 +11,24 @@ export default {
theme: { theme: {
extend: { extend: {
colors: {
festival: {
DEFAULT: '#f06c05',
dark: '#f06c05',
},
},
fontFamily: { fontFamily: {
sans: ['Figtree', ...defaultTheme.fontFamily.sans], sans: ['Figtree', ...defaultTheme.fontFamily.sans],
}, },
keyframes: {
'preregister-in': {
from: { opacity: '0', transform: 'translateY(1rem)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
},
animation: {
'preregister-in': 'preregister-in 0.65s cubic-bezier(0.22, 1, 0.36, 1) forwards',
},
}, },
}, },

View File

@@ -110,6 +110,88 @@ class PublicPageTest extends TestCase
$response->assertForbidden(); $response->assertForbidden();
} }
public function test_subscribe_rejects_invalid_email(): void
{
$page = $this->makePage([
'start_date' => now()->subHour(),
'end_date' => now()->addMonth(),
]);
$response = $this->postJson(route('public.subscribe', ['publicPage' => $page->slug]), [
'first_name' => 'A',
'last_name' => 'B',
'email' => 'not-a-valid-email',
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['email']);
}
public function test_subscribe_accepts_empty_phone_when_phone_field_enabled(): void
{
$page = $this->makePage([
'start_date' => now()->subHour(),
'end_date' => now()->addMonth(),
'phone_enabled' => true,
]);
$response = $this->postJson(route('public.subscribe', ['publicPage' => $page->slug]), [
'first_name' => 'A',
'last_name' => 'B',
'email' => 'nophone@example.com',
'phone' => '',
]);
$response->assertOk();
$this->assertDatabaseHas('subscribers', [
'preregistration_page_id' => $page->id,
'email' => 'nophone@example.com',
'phone' => null,
]);
}
public function test_subscribe_rejects_phone_with_too_few_digits(): void
{
$page = $this->makePage([
'start_date' => now()->subHour(),
'end_date' => now()->addMonth(),
'phone_enabled' => true,
]);
$response = $this->postJson(route('public.subscribe', ['publicPage' => $page->slug]), [
'first_name' => 'A',
'last_name' => 'B',
'email' => 'a@example.com',
'phone' => '+31 12 3',
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['phone']);
}
public function test_subscribe_normalizes_phone_to_digits(): void
{
$page = $this->makePage([
'start_date' => now()->subHour(),
'end_date' => now()->addMonth(),
'phone_enabled' => true,
]);
$response = $this->postJson(route('public.subscribe', ['publicPage' => $page->slug]), [
'first_name' => 'A',
'last_name' => 'B',
'email' => 'phoneuser@example.com',
'phone' => '+31 6 1234 5678',
]);
$response->assertOk();
$this->assertDatabaseHas('subscribers', [
'preregistration_page_id' => $page->id,
'email' => 'phoneuser@example.com',
'phone' => '31612345678',
]);
}
/** /**
* @param array<string, mixed> $overrides * @param array<string, mixed> $overrides
*/ */