diff --git a/.env.example b/.env.example index c0660ea..89e9463 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,9 @@ APP_KEY= APP_DEBUG=true APP_URL=http://localhost +# Wall-clock times from the admin UI (datetime-local) are interpreted in this zone. +APP_TIMEZONE=Europe/Amsterdam + APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US diff --git a/app/Http/Controllers/Admin/MailwizzApiController.php b/app/Http/Controllers/Admin/MailwizzApiController.php index f711c1a..a208399 100644 --- a/app/Http/Controllers/Admin/MailwizzApiController.php +++ b/app/Http/Controllers/Admin/MailwizzApiController.php @@ -13,34 +13,143 @@ class MailwizzApiController extends Controller { public function lists(Request $request): JsonResponse { - $request->validate(['api_key' => 'required|string']); + $request->validate(['api_key' => ['required', 'string']]); $response = Http::withHeaders([ - 'X-Api-Key' => $request->api_key, + 'X-Api-Key' => $request->string('api_key')->toString(), ])->get('https://www.mailwizz.nl/api/lists'); if ($response->failed()) { - return response()->json(['error' => 'Invalid API key or connection failed'], 422); + return response()->json(['message' => __('Invalid API key or connection failed.')], 422); } - return response()->json($response->json()); + $json = $response->json(); + if (is_array($json) && ($json['status'] ?? null) === 'error') { + return response()->json(['message' => __('Invalid API key or connection failed.')], 422); + } + + return response()->json([ + 'lists' => $this->normalizeListsPayload(is_array($json) ? $json : []), + ]); } public function fields(Request $request): JsonResponse { $request->validate([ - 'api_key' => 'required|string', - 'list_uid' => 'required|string', + 'api_key' => ['required', 'string'], + 'list_uid' => ['required', 'string'], ]); + $listUid = $request->string('list_uid')->toString(); $response = Http::withHeaders([ - 'X-Api-Key' => $request->api_key, - ])->get("https://www.mailwizz.nl/api/lists/{$request->list_uid}/fields"); + 'X-Api-Key' => $request->string('api_key')->toString(), + ])->get("https://www.mailwizz.nl/api/lists/{$listUid}/fields"); if ($response->failed()) { - return response()->json(['error' => 'Failed to fetch list fields'], 422); + return response()->json(['message' => __('Failed to fetch list fields.')], 422); } - return response()->json($response->json()); + $json = $response->json(); + if (is_array($json) && ($json['status'] ?? null) === 'error') { + return response()->json(['message' => __('Failed to fetch list fields.')], 422); + } + + return response()->json([ + 'fields' => $this->normalizeFieldsPayload(is_array($json) ? $json : []), + ]); + } + + /** + * @return list + */ + private function normalizeListsPayload(array $json): array + { + $out = []; + $records = data_get($json, 'data.records'); + if (! is_array($records)) { + return $out; + } + + foreach ($records as $row) { + if (! is_array($row)) { + continue; + } + $uid = data_get($row, 'general.list_uid') ?? data_get($row, 'list_uid'); + $name = data_get($row, 'general.name') ?? data_get($row, 'name'); + if (is_string($uid) && $uid !== '' && is_string($name) && $name !== '') { + $out[] = ['list_uid' => $uid, 'name' => $name]; + } + } + + return $out; + } + + /** + * @return list}> + */ + private function normalizeFieldsPayload(array $json): array + { + $out = []; + $records = data_get($json, 'data.records'); + if (! is_array($records)) { + return $out; + } + + foreach ($records as $row) { + if (! is_array($row)) { + continue; + } + $tag = data_get($row, 'tag'); + $label = data_get($row, 'label'); + $typeId = data_get($row, 'type.identifier'); + if (! is_string($tag) || $tag === '' || ! is_string($label)) { + continue; + } + $typeIdentifier = is_string($typeId) ? $typeId : ''; + $rawOptions = data_get($row, 'options'); + $options = $this->normalizeFieldOptions($rawOptions); + + $out[] = [ + 'tag' => $tag, + 'label' => $label, + 'type_identifier' => $typeIdentifier, + 'options' => $options, + ]; + } + + return $out; + } + + /** + * @return array + */ + private function normalizeFieldOptions(mixed $rawOptions): array + { + if ($rawOptions === null || $rawOptions === '') { + return []; + } + + if (is_string($rawOptions)) { + $decoded = json_decode($rawOptions, true); + if (is_array($decoded)) { + $rawOptions = $decoded; + } else { + return []; + } + } + + if (! is_array($rawOptions)) { + return []; + } + + $out = []; + foreach ($rawOptions as $key => $value) { + if (is_string($key) || is_int($key)) { + $k = (string) $key; + $out[$k] = is_scalar($value) ? (string) $value : ''; + } + } + + return $out; } } diff --git a/app/Http/Controllers/Admin/MailwizzController.php b/app/Http/Controllers/Admin/MailwizzController.php index 71d4c88..32a4f56 100644 --- a/app/Http/Controllers/Admin/MailwizzController.php +++ b/app/Http/Controllers/Admin/MailwizzController.php @@ -5,23 +5,51 @@ declare(strict_types=1); namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; +use App\Http\Requests\Admin\UpdateMailwizzConfigRequest; use App\Models\PreregistrationPage; -use Illuminate\Http\Request; +use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Facades\DB; +use Illuminate\View\View; class MailwizzController extends Controller { - public function edit(PreregistrationPage $page): \Illuminate\View\View + public function edit(PreregistrationPage $page): View { + $this->authorize('update', $page); + + $page->load('mailwizzConfig'); + return view('admin.mailwizz.edit', compact('page')); } - public function update(Request $request, PreregistrationPage $page): \Illuminate\Http\RedirectResponse + public function update(UpdateMailwizzConfigRequest $request, PreregistrationPage $page): RedirectResponse { - return redirect()->route('admin.pages.mailwizz.edit', $page); + $validated = $request->validated(); + + if (($validated['api_key'] ?? '') === '' && $page->mailwizzConfig !== null) { + unset($validated['api_key']); + } + + DB::transaction(function () use ($page, $validated): void { + $page->mailwizzConfig()->updateOrCreate( + ['preregistration_page_id' => $page->id], + array_merge($validated, ['preregistration_page_id' => $page->id]) + ); + }); + + return redirect() + ->route('admin.pages.mailwizz.edit', $page) + ->with('status', __('Mailwizz configuration saved.')); } - public function destroy(PreregistrationPage $page): \Illuminate\Http\RedirectResponse + public function destroy(PreregistrationPage $page): RedirectResponse { - return redirect()->route('admin.pages.mailwizz.edit', $page); + $this->authorize('update', $page); + + $page->mailwizzConfig()?->delete(); + + return redirect() + ->route('admin.pages.mailwizz.edit', $page) + ->with('status', __('Mailwizz integration removed.')); } } diff --git a/app/Http/Controllers/PublicPageController.php b/app/Http/Controllers/PublicPageController.php index 9959c6d..9cba519 100644 --- a/app/Http/Controllers/PublicPageController.php +++ b/app/Http/Controllers/PublicPageController.php @@ -4,55 +4,45 @@ declare(strict_types=1); namespace App\Http\Controllers; +use App\Http\Requests\SubscribePublicPageRequest; use App\Models\PreregistrationPage; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\View\View; class PublicPageController extends Controller { - public function show(string $slug): View + public function show(PreregistrationPage $publicPage): View { - $page = PreregistrationPage::where('slug', $slug) - ->where('is_active', true) - ->firstOrFail(); - - return view('public.page', compact('page')); + return view('public.page', ['page' => $publicPage]); } - public function subscribe(Request $request, string $slug): JsonResponse + public function subscribe(SubscribePublicPageRequest $request, PreregistrationPage $publicPage): JsonResponse { - $page = PreregistrationPage::where('slug', $slug) - ->where('is_active', true) - ->firstOrFail(); + abort_if(now()->lt($publicPage->start_date) || now()->gt($publicPage->end_date), 403); - abort_if(now()->lt($page->start_date) || now()->gt($page->end_date), 403); + $validated = $request->validated(); - $validated = $request->validate([ - 'first_name' => 'required|string|max:255', - 'last_name' => 'required|string|max:255', - 'email' => 'required|email|max:255', - 'phone' => $page->phone_enabled ? 'required|string|max:20' : 'nullable', - ]); + $exists = $publicPage->subscribers() + ->where('email', $validated['email']) + ->exists(); - $exists = $page->subscribers()->where('email', $validated['email'])->exists(); if ($exists) { return response()->json([ 'success' => false, - 'message' => 'This email address is already registered.', + 'message' => __('You are already registered for this event.'), ], 422); } - $subscriber = $page->subscribers()->create($validated); + $publicPage->subscribers()->create($validated); // Mailwizz sync will be wired up in Phase 4 - if ($page->mailwizzConfig) { + if ($publicPage->mailwizzConfig) { // SyncSubscriberToMailwizz::dispatch($subscriber); } return response()->json([ 'success' => true, - 'message' => $page->thank_you_message ?? 'Thank you for registering!', + 'message' => $publicPage->thank_you_message ?? __('Thank you for registering!'), ]); } } diff --git a/app/Http/Requests/Admin/UpdateMailwizzConfigRequest.php b/app/Http/Requests/Admin/UpdateMailwizzConfigRequest.php new file mode 100644 index 0000000..61b9727 --- /dev/null +++ b/app/Http/Requests/Admin/UpdateMailwizzConfigRequest.php @@ -0,0 +1,51 @@ +route('page'); + if (! $page instanceof PreregistrationPage) { + return false; + } + + return $this->user()?->can('update', $page) ?? false; + } + + /** + * @return array> + */ + public function rules(): array + { + /** @var PreregistrationPage $page */ + $page = $this->route('page'); + + return [ + 'api_key' => [ + Rule::requiredIf(fn (): bool => $page->mailwizzConfig === null), + 'nullable', + 'string', + 'max:512', + ], + 'list_uid' => ['required', 'string', 'max:255'], + 'list_name' => ['nullable', 'string', 'max:255'], + 'field_email' => ['required', 'string', 'max:255'], + 'field_first_name' => ['required', 'string', 'max:255'], + 'field_last_name' => ['required', 'string', 'max:255'], + 'field_phone' => $page->phone_enabled + ? ['required', 'string', 'max:255'] + : ['nullable', 'string', 'max:255'], + 'tag_field' => ['required', 'string', 'max:255'], + 'tag_value' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/SubscribePublicPageRequest.php b/app/Http/Requests/SubscribePublicPageRequest.php new file mode 100644 index 0000000..c04a41f --- /dev/null +++ b/app/Http/Requests/SubscribePublicPageRequest.php @@ -0,0 +1,44 @@ + + */ + public function rules(): array + { + /** @var PreregistrationPage $page */ + $page = $this->route('publicPage'); + + return [ + 'first_name' => ['required', 'string', 'max:255'], + 'last_name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255'], + 'phone' => $page->phone_enabled + ? ['required', 'string', 'max:20'] + : ['nullable', 'string', 'max:20'], + ]; + } + + protected function prepareForValidation(): void + { + $email = $this->input('email'); + if (is_string($email)) { + $this->merge([ + 'email' => strtolower(trim($email)), + ]); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 24f164c..929d8ad 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace App\Providers; +use App\Models\PreregistrationPage; use Illuminate\Pagination\Paginator; +use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -23,5 +25,12 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { Paginator::useTailwind(); + + Route::bind('publicPage', function (string $value): PreregistrationPage { + return PreregistrationPage::query() + ->where('slug', $value) + ->where('is_active', true) + ->firstOrFail(); + }); } } diff --git a/config/app.php b/config/app.php index 423eed5..5149ec7 100644 --- a/config/app.php +++ b/config/app.php @@ -65,7 +65,7 @@ return [ | */ - 'timezone' => 'UTC', + 'timezone' => env('APP_TIMEZONE', 'Europe/Amsterdam'), /* |-------------------------------------------------------------------------- diff --git a/resources/js/app.js b/resources/js/app.js index a8093be..a4da66a 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -2,6 +2,290 @@ import './bootstrap'; import Alpine from 'alpinejs'; +document.addEventListener('alpine:init', () => { + Alpine.data('publicPreregisterPage', (config) => ({ + phase: config.phase, + startAtMs: config.startAtMs, + phoneEnabled: config.phoneEnabled, + subscribeUrl: config.subscribeUrl, + csrfToken: config.csrfToken, + genericError: config.genericError, + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + countdownTimer: null, + first_name: '', + last_name: '', + email: '', + phone: '', + submitting: false, + formError: '', + fieldErrors: {}, + thankYouMessage: '', + + init() { + if (this.phase === 'before') { + this.tickCountdown(); + this.countdownTimer = setInterval(() => this.tickCountdown(), 1000); + } + }, + + tickCountdown() { + const start = this.startAtMs; + const now = Date.now(); + const diff = start - now; + if (diff <= 0) { + if (this.countdownTimer !== null) { + clearInterval(this.countdownTimer); + this.countdownTimer = null; + } + this.phase = 'active'; + return; + } + const totalSeconds = Math.floor(diff / 1000); + this.days = Math.floor(totalSeconds / 86400); + this.hours = Math.floor((totalSeconds % 86400) / 3600); + this.minutes = Math.floor((totalSeconds % 3600) / 60); + this.seconds = totalSeconds % 60; + }, + + pad(n) { + return String(n).padStart(2, '0'); + }, + + async submitForm() { + const form = this.$refs.form; + if (form !== undefined && form !== null && !form.checkValidity()) { + form.reportValidity(); + return; + } + + this.formError = ''; + this.fieldErrors = {}; + this.submitting = true; + try { + const body = { + first_name: this.first_name, + last_name: this.last_name, + email: this.email, + }; + if (this.phoneEnabled) { + body.phone = this.phone; + } + const res = await fetch(this.subscribeUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-CSRF-TOKEN': this.csrfToken, + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + if (res.ok && data.success) { + this.phase = 'thanks'; + this.thankYouMessage = data.message ?? ''; + return; + } + if (typeof data.message === 'string' && data.message !== '') { + this.formError = data.message; + } + if (data.errors !== undefined && data.errors !== null && typeof data.errors === 'object') { + this.fieldErrors = data.errors; + } + } catch { + this.formError = this.genericError; + } finally { + this.submitting = false; + } + }, + })); + + Alpine.data('mailwizzWizard', (cfg) => ({ + listsUrl: cfg.listsUrl, + fieldsUrl: cfg.fieldsUrl, + phoneEnabled: cfg.phoneEnabled, + hasExistingConfig: cfg.hasExistingConfig, + existing: cfg.existing, + csrf: cfg.csrf, + step: 1, + apiKey: '', + lists: [], + selectedListUid: '', + selectedListName: '', + allFields: [], + fieldEmail: '', + fieldFirstName: '', + fieldLastName: '', + fieldPhone: '', + tagField: '', + tagValue: '', + loading: false, + errorMessage: '', + + init() { + if (this.existing) { + this.fieldEmail = this.existing.field_email ?? ''; + this.fieldFirstName = this.existing.field_first_name ?? ''; + this.fieldLastName = this.existing.field_last_name ?? ''; + this.fieldPhone = this.existing.field_phone ?? ''; + this.tagField = this.existing.tag_field ?? ''; + this.tagValue = this.existing.tag_value ?? ''; + this.selectedListUid = this.existing.list_uid ?? ''; + this.selectedListName = this.existing.list_name ?? ''; + } + }, + + textFields() { + return this.allFields.filter((f) => f.type_identifier === 'text'); + }, + + emailFieldChoices() { + const texts = this.textFields(); + const tagged = texts.filter( + (f) => + (f.tag && f.tag.toUpperCase().includes('EMAIL')) || + (f.label && f.label.toLowerCase().includes('email')), + ); + return tagged.length > 0 ? tagged : texts; + }, + + phoneFields() { + return this.allFields.filter((f) => f.type_identifier === 'phonenumber'); + }, + + checkboxFields() { + return this.allFields.filter((f) => f.type_identifier === 'checkboxlist'); + }, + + tagOptionsList() { + const f = this.allFields.find((x) => x.tag === this.tagField); + if (!f || !f.options) { + return []; + } + return Object.entries(f.options).map(([key, label]) => ({ + key, + label: String(label), + })); + }, + + async postJson(url, body) { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-CSRF-TOKEN': this.csrf, + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + return { res, data }; + }, + + async connectLists() { + this.errorMessage = ''; + if (!this.apiKey.trim()) { + this.errorMessage = cfg.strings.apiKeyRequired; + return; + } + this.loading = true; + try { + const { res, data } = await this.postJson(this.listsUrl, { api_key: this.apiKey }); + if (!res.ok) { + this.errorMessage = data.message || data.error || cfg.strings.genericError; + return; + } + this.lists = Array.isArray(data.lists) ? data.lists : []; + if (this.lists.length === 0) { + this.errorMessage = cfg.strings.noListsError; + return; + } + if (this.existing?.list_uid) { + const match = this.lists.find((l) => l.list_uid === this.existing.list_uid); + if (match) { + this.selectedListUid = match.list_uid; + this.selectedListName = match.name; + } + } + this.step = 2; + } finally { + this.loading = false; + } + }, + + async loadFieldsAndGoStep3() { + this.errorMessage = ''; + if (!this.selectedListUid) { + this.errorMessage = cfg.strings.selectListError; + return; + } + this.syncListNameFromSelection(); + this.loading = true; + try { + const { res, data } = await this.postJson(this.fieldsUrl, { + api_key: this.apiKey, + list_uid: this.selectedListUid, + }); + if (!res.ok) { + this.errorMessage = data.message || data.error || cfg.strings.genericError; + return; + } + this.allFields = Array.isArray(data.fields) ? data.fields : []; + if (this.existing) { + this.fieldEmail = this.existing.field_email || this.fieldEmail; + this.fieldFirstName = this.existing.field_first_name || this.fieldFirstName; + this.fieldLastName = this.existing.field_last_name || this.fieldLastName; + this.fieldPhone = this.existing.field_phone || this.fieldPhone; + this.tagField = this.existing.tag_field || this.tagField; + this.tagValue = this.existing.tag_value || this.tagValue; + } + this.step = 3; + } finally { + this.loading = false; + } + }, + + syncListNameFromSelection() { + const l = this.lists.find((x) => x.list_uid === this.selectedListUid); + this.selectedListName = l ? l.name : ''; + }, + + goStep4() { + this.errorMessage = ''; + if (!this.fieldEmail || !this.fieldFirstName || !this.fieldLastName) { + this.errorMessage = cfg.strings.mapFieldsError; + return; + } + if (this.phoneEnabled && !this.fieldPhone) { + this.errorMessage = cfg.strings.mapPhoneError; + return; + } + if (!this.tagField) { + this.errorMessage = cfg.strings.tagFieldError; + return; + } + this.step = 4; + }, + + submitSave() { + this.errorMessage = ''; + if (!this.tagValue) { + this.errorMessage = cfg.strings.tagValueError; + return; + } + if (!this.hasExistingConfig && !this.apiKey.trim()) { + this.errorMessage = cfg.strings.apiKeyRequired; + return; + } + this.$refs.saveForm.requestSubmit(); + }, + })); +}); + window.Alpine = Alpine; Alpine.start(); diff --git a/resources/views/admin/mailwizz/edit.blade.php b/resources/views/admin/mailwizz/edit.blade.php new file mode 100644 index 0000000..a8dd5a2 --- /dev/null +++ b/resources/views/admin/mailwizz/edit.blade.php @@ -0,0 +1,252 @@ +@php + $config = $page->mailwizzConfig; + $existing = $config !== null + ? [ + 'list_uid' => $config->list_uid, + 'list_name' => $config->list_name, + 'field_email' => $config->field_email, + 'field_first_name' => $config->field_first_name, + 'field_last_name' => $config->field_last_name, + 'field_phone' => $config->field_phone, + 'tag_field' => $config->tag_field, + 'tag_value' => $config->tag_value, + ] + : null; +@endphp + +@extends('layouts.admin') + +@section('title', __('Mailwizz') . ' — ' . $page->title) + +@section('mobile_title', __('Mailwizz')) + +@section('content') +
+
+ ← {{ __('Back to page') }} +

{{ __('Mailwizz') }}

+

{{ __('Page:') }} {{ $page->title }}

+
+ + @if ($config !== null) +
+

{{ __('Integration active') }}

+

+ {{ __('List:') }} + {{ $config->list_name ?: $config->list_uid }} +

+
+ @csrf + @method('DELETE') + +
+
+ @endif + +
+ 1. {{ __('API key') }} + + 2. {{ __('List') }} + + 3. {{ __('Field mapping') }} + + 4. {{ __('Tag / source') }} +
+ +
+
+ + {{-- Step 1 --}} +
+

+ {{ __('First, create a mailing list in Mailwizz with the required custom fields. Add a custom field of type Checkbox List with an option value you will use to track this pre-registration source.') }} +

+ @if ($config !== null) +

+ {{ __('Enter your API key and connect to load Mailwizz data (the same key as before is fine). If you clear the key field before saving, the previously stored key is kept.') }} +

+ @endif +
+ + +
+ +
+ + {{-- Step 2 --}} +
+
+ + +
+
+ + +
+
+ + {{-- Step 3 --}} +
+

{{ __('Map each local field to the matching Mailwizz custom field (by tag).') }}

+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

+ {{ __('No checkbox list fields were returned for this list. Add one in Mailwizz, then run “Load fields” again from step 2.') }} +

+ +
+ + +
+
+ + {{-- Step 4 --}} +
+

{{ __('Choose the checkbox option that marks subscribers from this pre-registration page.') }}

+ +
+ {{ __('Tag value') }} + +
+

+ {{ __('This field has no options defined in Mailwizz. Add options to the checkbox list field, then reload fields.') }} +

+ +
+ @csrf + @method('PUT') + + + + + + + + + + +
+ + +
+
+
+
+
+@endsection diff --git a/resources/views/admin/pages/edit.blade.php b/resources/views/admin/pages/edit.blade.php index 3329f57..cf1792e 100644 --- a/resources/views/admin/pages/edit.blade.php +++ b/resources/views/admin/pages/edit.blade.php @@ -10,8 +10,13 @@ ← {{ __('Back to pages') }}

{{ __('Edit page') }}

- {{ __('Public URL') }}: {{ url('/r/'.$page->slug) }} + {{ __('Public URL') }}: {{ url('/r/'.$page->slug) }}

+ @can('update', $page) +

+ {{ __('Mailwizz integration') }} → +

+ @endcan
diff --git a/resources/views/admin/pages/index.blade.php b/resources/views/admin/pages/index.blade.php index 81a0eba..e599961 100644 --- a/resources/views/admin/pages/index.blade.php +++ b/resources/views/admin/pages/index.blade.php @@ -38,7 +38,7 @@ @forelse ($pages as $page) @php - $publicUrl = route('public.page', ['slug' => $page->slug]); + $publicUrl = route('public.page', ['publicPage' => $page]); $key = $page->statusKey(); $statusClasses = match ($key) { 'before_start' => 'bg-slate-100 text-slate-800', @@ -72,6 +72,9 @@ @can('view', $page) {{ __('Subscribers') }} @endcan + @can('update', $page) + {{ __('Mailwizz') }} + @endcan +
+ + + {{-- Thank you (after successful AJAX) --}} +
+

+
+ + {{-- Expired --}} +
+ @if (filled($page->expired_message)) +
+ {{ $page->expired_message }} +
+ @else +

{{ __('This pre-registration period has ended.') }}

+ @endif + + @if (filled($page->ticketshop_url)) + + @endif +
+ + + +@endsection diff --git a/routes/web.php b/routes/web.php index 4e64f2a..17e7eb5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -19,8 +19,8 @@ Route::get('/', function () { // ─── Public (no auth) ──────────────────────────────────── Route::middleware('throttle:10,1')->group(function () { - Route::get('/r/{slug}', [PublicPageController::class, 'show'])->name('public.page'); - Route::post('/r/{slug}/subscribe', [PublicPageController::class, 'subscribe'])->name('public.subscribe'); + Route::get('/r/{publicPage:slug}', [PublicPageController::class, 'show'])->name('public.page'); + Route::post('/r/{publicPage:slug}/subscribe', [PublicPageController::class, 'subscribe'])->name('public.subscribe'); }); // ─── Backend (auth required) ───────────────────────────── diff --git a/tests/Feature/MailwizzConfigUiTest.php b/tests/Feature/MailwizzConfigUiTest.php new file mode 100644 index 0000000..6dcbca6 --- /dev/null +++ b/tests/Feature/MailwizzConfigUiTest.php @@ -0,0 +1,56 @@ +create(['role' => 'user']); + $page = $this->makePageForUser($user); + + $response = $this->actingAs($user)->get(route('admin.pages.mailwizz.edit', $page)); + + $response->assertOk(); + $response->assertSee('Mailwizz', escape: false); + } + + public function test_other_user_cannot_view_mailwizz_wizard(): void + { + $owner = User::factory()->create(['role' => 'user']); + $intruder = User::factory()->create(['role' => 'user']); + $page = $this->makePageForUser($owner); + + $response = $this->actingAs($intruder)->get(route('admin.pages.mailwizz.edit', $page)); + + $response->assertForbidden(); + } + + private function makePageForUser(User $user): PreregistrationPage + { + return PreregistrationPage::query()->create([ + 'slug' => (string) Str::uuid(), + 'user_id' => $user->id, + 'title' => 'Fest', + 'heading' => 'Join', + 'intro_text' => null, + 'thank_you_message' => null, + 'expired_message' => null, + 'ticketshop_url' => null, + 'start_date' => now()->addDay(), + 'end_date' => now()->addMonth(), + 'phone_enabled' => false, + 'is_active' => true, + ]); + } +} diff --git a/tests/Feature/PublicPageTest.php b/tests/Feature/PublicPageTest.php new file mode 100644 index 0000000..ff05293 --- /dev/null +++ b/tests/Feature/PublicPageTest.php @@ -0,0 +1,135 @@ +makePage([ + 'start_date' => now()->addDay(), + 'end_date' => now()->addMonth(), + ]); + + $response = $this->get(route('public.page', ['publicPage' => $page->slug])); + + $response->assertOk(); + $response->assertSee($page->heading, escape: false); + } + + public function test_show_returns_not_found_when_page_is_inactive(): void + { + $page = $this->makePage([ + 'is_active' => false, + 'start_date' => now()->subHour(), + 'end_date' => now()->addMonth(), + ]); + + $response = $this->get(route('public.page', ['publicPage' => $page->slug])); + + $response->assertNotFound(); + } + + public function test_subscribe_returns_json_success_and_creates_subscriber(): void + { + $page = $this->makePage([ + 'thank_you_message' => 'Custom thanks', + 'start_date' => now()->subHour(), + 'end_date' => now()->addMonth(), + 'phone_enabled' => false, + ]); + + $response = $this->postJson(route('public.subscribe', ['publicPage' => $page->slug]), [ + 'first_name' => 'Ada', + 'last_name' => 'Lovelace', + 'email' => 'ada@example.com', + ]); + + $response->assertOk(); + $response->assertJson([ + 'success' => true, + 'message' => 'Custom thanks', + ]); + $this->assertDatabaseHas('subscribers', [ + 'preregistration_page_id' => $page->id, + 'email' => 'ada@example.com', + ]); + } + + public function test_subscribe_returns_friendly_message_for_duplicate_email(): void + { + $page = $this->makePage([ + 'start_date' => now()->subHour(), + 'end_date' => now()->addMonth(), + ]); + + Subscriber::query()->create([ + 'preregistration_page_id' => $page->id, + 'first_name' => 'A', + 'last_name' => 'B', + 'email' => 'dup@example.com', + ]); + + $response = $this->postJson(route('public.subscribe', ['publicPage' => $page->slug]), [ + 'first_name' => 'C', + 'last_name' => 'D', + 'email' => 'dup@example.com', + ]); + + $response->assertStatus(422); + $response->assertJson([ + 'success' => false, + 'message' => 'You are already registered for this event.', + ]); + } + + public function test_subscribe_forbidden_outside_registration_window(): void + { + $page = $this->makePage([ + 'start_date' => now()->addDay(), + 'end_date' => now()->addMonth(), + ]); + + $response = $this->postJson(route('public.subscribe', ['publicPage' => $page->slug]), [ + 'first_name' => 'A', + 'last_name' => 'B', + 'email' => 'a@example.com', + ]); + + $response->assertForbidden(); + } + + /** + * @param array $overrides + */ + private function makePage(array $overrides = []): PreregistrationPage + { + $user = User::factory()->create(['role' => 'user']); + + return PreregistrationPage::query()->create(array_merge([ + 'slug' => (string) Str::uuid(), + 'user_id' => $user->id, + 'title' => 'Test page', + 'heading' => 'Join us', + 'intro_text' => null, + 'thank_you_message' => null, + 'expired_message' => null, + 'ticketshop_url' => null, + 'start_date' => now()->subHour(), + 'end_date' => now()->addMonth(), + 'phone_enabled' => false, + 'is_active' => true, + ], $overrides)); + } +}