feat: Mailwizz overview vs wizard flow and wizard step guard

Load Weeztix config for coupon mapping context, redirect incomplete
configs to step one, and expand admin Mailwizz UI and tests.

Made-with: Cursor
This commit is contained in:
2026-04-05 13:34:00 +02:00
parent 1e7ee14540
commit 91caa16e70
4 changed files with 385 additions and 214 deletions

View File

@@ -8,18 +8,29 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\UpdateMailwizzConfigRequest;
use App\Models\PreregistrationPage;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class MailwizzController extends Controller
{
public function edit(PreregistrationPage $page): View
public function edit(Request $request, PreregistrationPage $page): View|RedirectResponse
{
$this->authorize('update', $page);
$page->load('mailwizzConfig');
$page->load(['mailwizzConfig', 'weeztixConfig']);
return view('admin.mailwizz.edit', compact('page'));
$config = $page->mailwizzConfig;
$showWizard = $config === null || $request->boolean('wizard');
if ($showWizard && $config === null) {
$requestedStep = min(4, max(1, (int) $request->query('step', 1)));
if ($requestedStep !== 1) {
return redirect()
->route('admin.pages.mailwizz.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]);
}
}
return view('admin.mailwizz.edit', compact('page', 'showWizard'));
}
public function update(UpdateMailwizzConfigRequest $request, PreregistrationPage $page): RedirectResponse

View File

@@ -14,6 +14,7 @@
"Sending…": "Bezig met verzenden…",
"Something went wrong. Please try again.": "Er ging iets mis. Probeer het opnieuw.",
"This pre-registration period has ended.": "Deze preregistratieperiode is afgelopen.",
"You will be redirected in :seconds s…": "Je wordt over :seconds seconden doorgestuurd…",
"Visit ticket shop": "Ga naar de ticketshop",
"Thank you for registering!": "Bedankt voor je registratie!",
"You are already registered for this event.": "Je bent al geregistreerd voor dit evenement.",

View File

@@ -2,6 +2,7 @@
$config = $page->mailwizzConfig;
$page->loadMissing('weeztixConfig');
$hasWeeztixForCouponMap = $page->weeztixConfig !== null && $page->weeztixConfig->is_connected;
$mailwizzStatus = $page->mailwizzIntegrationStatus();
$existing = $config !== null
? [
'list_uid' => $config->list_uid,
@@ -24,7 +25,116 @@
@section('mobile_title', __('Mailwizz'))
@section('content')
<div class="mx-auto max-w-3xl" x-data="mailwizzWizard(@js([
<div class="mx-auto max-w-3xl">
<div class="mb-8">
<a href="{{ route('admin.pages.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500"> {{ __('Back to page') }}</a>
<h1 class="mt-4 text-2xl font-semibold text-slate-900">{{ __('Mailwizz') }}</h1>
<p class="mt-2 text-sm text-slate-600">{{ __('Page:') }} <span class="font-medium text-slate-800">{{ $page->title }}</span></p>
</div>
@include('admin.pages._save_flash')
@if ($errors->any())
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900" role="alert">
<p class="font-medium">{{ __('Please fix the following:') }}</p>
<ul class="mt-2 list-disc space-y-1 pl-5">
@foreach ($errors->all() as $message)
<li>{{ $message }}</li>
@endforeach
</ul>
</div>
@endif
@if (! $showWizard && $config !== null)
@if ($mailwizzStatus !== 'ready')
<div class="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
<p class="font-medium">{{ __('Setup incomplete') }}</p>
<p class="mt-1 text-amber-900">{{ __('Run the wizard again to finish Mailwizz (API key, list, and field mapping).') }}</p>
</div>
@endif
<div class="mb-6 flex flex-wrap items-center gap-3">
<a
href="{{ route('admin.pages.mailwizz.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]) }}"
class="inline-flex rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500"
>
{{ __('Change settings (wizard)') }}
</a>
<form action="{{ route('admin.pages.mailwizz.destroy', $page) }}" method="post" class="inline"
onsubmit="return confirm(@js(__('Remove Mailwizz integration for this page? Subscribers will stay in the database but will no longer sync.')));">
@csrf
@method('DELETE')
<button type="submit" class="rounded-lg border border-red-200 bg-white px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm hover:bg-red-50">
{{ __('Disconnect Mailwizz') }}
</button>
</form>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
<h2 class="text-lg font-semibold text-slate-900">{{ __('Current configuration') }}</h2>
<p class="mt-1 text-sm text-slate-600">{{ __('The API key is stored encrypted and is not shown here.') }}</p>
<dl class="mt-6 space-y-4 border-t border-slate-100 pt-6 text-sm">
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
<dt class="font-medium text-slate-700">{{ __('Connection') }}</dt>
<dd>
@if ($mailwizzStatus === 'ready')
<span class="inline-flex rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">{{ __('Ready to sync') }}</span>
@else
<span class="inline-flex rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-medium text-amber-900">{{ __('Incomplete') }}</span>
@endif
</dd>
</div>
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
<dt class="font-medium text-slate-700">{{ __('Mailing list') }}</dt>
<dd class="text-slate-800">{{ $config->list_name ?: '—' }}</dd>
</div>
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
<dt class="font-medium text-slate-700">{{ __('List UID') }}</dt>
<dd class="break-all font-mono text-xs text-slate-600">{{ $config->list_uid ?: '—' }}</dd>
</div>
</dl>
<h3 class="mt-8 border-t border-slate-100 pt-6 text-sm font-semibold text-slate-900">{{ __('Field mapping') }}</h3>
<p class="mt-1 text-xs text-slate-500">{{ __('Mailwizz custom fields are matched by tag.') }}</p>
<dl class="mt-4 space-y-4 text-sm">
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
<dt class="font-medium text-slate-700">{{ __('Email') }}</dt>
<dd class="font-mono text-xs text-slate-800">{{ $config->field_email ?: '—' }}</dd>
</div>
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
<dt class="font-medium text-slate-700">{{ __('First name') }}</dt>
<dd class="font-mono text-xs text-slate-800">{{ $config->field_first_name ?: '—' }}</dd>
</div>
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
<dt class="font-medium text-slate-700">{{ __('Last name') }}</dt>
<dd class="font-mono text-xs text-slate-800">{{ $config->field_last_name ?: '—' }}</dd>
</div>
@if ($page->isPhoneFieldEnabledForSubscribers())
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
<dt class="font-medium text-slate-700">{{ __('Phone') }}</dt>
<dd class="font-mono text-xs text-slate-800">{{ filled($config->field_phone) ? $config->field_phone : '—' }}</dd>
</div>
@endif
@if ($hasWeeztixForCouponMap)
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
<dt class="font-medium text-slate-700">{{ __('Kortingscode (Weeztix)') }}</dt>
<dd class="font-mono text-xs text-slate-800">{{ filled($config->field_coupon_code) ? $config->field_coupon_code : '—' }}</dd>
</div>
@endif
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
<dt class="font-medium text-slate-700">{{ __('Tag / source (checkbox list)') }}</dt>
<dd class="font-mono text-xs text-slate-800">{{ filled($config->tag_field) ? $config->tag_field : '—' }}</dd>
</div>
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
<dt class="font-medium text-slate-700">{{ __('Source tag option') }}</dt>
<dd class="font-mono text-xs text-slate-800">{{ filled($config->tag_value) ? $config->tag_value : '—' }}</dd>
</div>
</dl>
</div>
@else
<div
x-data="mailwizzWizard(@js([
'listsUrl' => route('admin.mailwizz.lists'),
'fieldsUrl' => route('admin.mailwizz.fields'),
'csrf' => csrf_token(),
@@ -41,50 +151,48 @@
'tagFieldError' => __('Select a checkbox list field for source / tag tracking.'),
'tagValueError' => __('Select the tag option that identifies this pre-registration.'),
],
]))">
<div class="mb-8">
<a href="{{ route('admin.pages.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500"> {{ __('Back to page') }}</a>
<h1 class="mt-4 text-2xl font-semibold text-slate-900">{{ __('Mailwizz') }}</h1>
<p class="mt-2 text-sm text-slate-600">{{ __('Page:') }} <span class="font-medium text-slate-800">{{ $page->title }}</span></p>
</div>
@if ($errors->any())
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900" role="alert">
<p class="font-medium">{{ __('Please fix the following:') }}</p>
<ul class="mt-2 list-disc space-y-1 pl-5">
@foreach ($errors->all() as $message)
<li>{{ $message }}</li>
@endforeach
</ul>
</div>
@endif
]))"
>
@if ($config !== null)
<div class="mb-8 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
<p class="font-medium">{{ __('Integration active') }}</p>
<p class="mt-1 text-emerald-800">
{{ __('List:') }}
<span class="font-mono text-xs">{{ $config->list_name ?: $config->list_uid }}</span>
</p>
<form action="{{ route('admin.pages.mailwizz.destroy', $page) }}" method="post" class="mt-3"
onsubmit="return confirm(@js(__('Remove Mailwizz integration for this page? Subscribers will stay in the database but will no longer sync.')));">
@csrf
@method('DELETE')
<button type="submit" class="text-sm font-semibold text-red-700 underline hover:text-red-800">
{{ __('Remove integration') }}
</button>
</form>
<div class="mb-6 flex flex-wrap items-center gap-3">
<a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-sm font-medium text-slate-600 hover:text-slate-900">
{{ __('Cancel and return to overview') }}
</a>
</div>
@endif
<div class="mb-6 flex flex-wrap gap-2 text-xs font-medium text-slate-500">
<span :class="step >= 1 ? 'text-indigo-600' : ''">1. {{ __('API key') }}</span>
<span aria-hidden="true"></span>
<span :class="step >= 2 ? 'text-indigo-600' : ''">2. {{ __('List') }}</span>
<span aria-hidden="true"></span>
<span :class="step >= 3 ? 'text-indigo-600' : ''">3. {{ __('Field mapping') }}</span>
<span aria-hidden="true"></span>
<span :class="step >= 4 ? 'text-indigo-600' : ''">4. {{ __('Tag / source') }}</span>
<div class="mb-8 flex flex-wrap items-center gap-2" aria-label="{{ __('Wizard steps') }}">
<span
class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium"
:class="step === 1 ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : (step > 1 ? 'border-emerald-200 bg-emerald-50 text-emerald-900' : 'border-slate-200 bg-slate-50 text-slate-500')"
>
<span class="tabular-nums">1</span>
{{ __('API key') }}
</span>
<span class="text-slate-300" aria-hidden="true"></span>
<span
class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium"
:class="step === 2 ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : (step > 2 ? 'border-emerald-200 bg-emerald-50 text-emerald-900' : 'border-slate-200 bg-slate-50 text-slate-500')"
>
<span class="tabular-nums">2</span>
{{ __('List') }}
</span>
<span class="text-slate-300" aria-hidden="true"></span>
<span
class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium"
:class="step === 3 ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : (step > 3 ? 'border-emerald-200 bg-emerald-50 text-emerald-900' : 'border-slate-200 bg-slate-50 text-slate-500')"
>
<span class="tabular-nums">3</span>
{{ __('Field mapping') }}
</span>
<span class="text-slate-300" aria-hidden="true"></span>
<span
class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium"
:class="step === 4 ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : 'border-slate-200 bg-slate-50 text-slate-500'"
>
<span class="tabular-nums">4</span>
{{ __('Tag / source') }}
</span>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
@@ -92,6 +200,7 @@
{{-- Step 1 --}}
<div x-show="step === 1" x-cloak class="space-y-4">
<h2 class="text-lg font-semibold text-slate-900">{{ __('Step 1: API key') }}</h2>
<p class="text-sm leading-relaxed text-slate-600">
{{ __('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.') }}
</p>
@@ -124,6 +233,7 @@
{{-- Step 2 --}}
<div x-show="step === 2" x-cloak class="space-y-4">
<h2 class="text-lg font-semibold text-slate-900">{{ __('Step 2: Mailing list') }}</h2>
<div>
<label for="mailwizz_list" class="block text-sm font-medium text-slate-700">{{ __('Mailing list') }}</label>
<select
@@ -156,6 +266,7 @@
{{-- Step 3 --}}
<div x-show="step === 3" x-cloak class="space-y-5">
<h2 class="text-lg font-semibold text-slate-900">{{ __('Step 3: Field mapping') }}</h2>
<p class="text-sm text-slate-600">{{ __('Map each local field to the matching Mailwizz custom field (by tag).') }}</p>
<div>
@@ -233,6 +344,7 @@
{{-- Step 4 --}}
<div x-show="step === 4" x-cloak class="space-y-5">
<h2 class="text-lg font-semibold text-slate-900">{{ __('Step 4: Tag / source') }}</h2>
<p class="text-sm text-slate-600">{{ __('Choose the checkbox option that marks subscribers from this pre-registration page.') }}</p>
<fieldset class="space-y-2">
@@ -274,4 +386,6 @@
</div>
</div>
</div>
@endif
</div>
@endsection

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Feature;
use App\Models\MailwizzConfig;
use App\Models\PreregistrationPage;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -36,6 +37,50 @@ class MailwizzConfigUiTest extends TestCase
$response->assertForbidden();
}
public function test_connected_mailwizz_shows_overview_until_wizard_requested(): void
{
$user = User::factory()->create(['role' => 'user']);
$page = $this->makePageForUser($user);
MailwizzConfig::query()->create([
'preregistration_page_id' => $page->id,
'api_key' => 'test-key',
'list_uid' => 'list-uid-1',
'list_name' => 'Main list',
'field_email' => 'EMAIL',
'field_first_name' => 'FNAME',
'field_last_name' => 'LNAME',
'field_phone' => null,
'field_coupon_code' => null,
'tag_field' => 'TAGS',
'tag_value' => 'preregister-source',
]);
$overview = $this->actingAs($user)->get(route('admin.pages.mailwizz.edit', $page));
$overview->assertOk();
$overview->assertSee('Current configuration', escape: false);
$overview->assertSee('Change settings (wizard)', escape: false);
$overview->assertDontSee('Step 1: API key', escape: false);
$wizard = $this->actingAs($user)->get(route('admin.pages.mailwizz.edit', [
'page' => $page,
'wizard' => 1,
'step' => 1,
]));
$wizard->assertOk();
$wizard->assertSee('Step 1: API key', escape: false);
$wizard->assertSee('Cancel and return to overview', escape: false);
}
public function test_mailwizz_wizard_redirects_to_step_one_when_no_config_and_step_gt_one(): void
{
$user = User::factory()->create(['role' => 'user']);
$page = $this->makePageForUser($user);
$this->actingAs($user)
->get(route('admin.pages.mailwizz.edit', ['page' => $page, 'wizard' => 1, 'step' => 3]))
->assertRedirect(route('admin.pages.mailwizz.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]));
}
private function makePageForUser(User $user): PreregistrationPage
{
return PreregistrationPage::query()->create([