Compare commits

...

23 Commits

Author SHA1 Message Date
845665c8be feat: per-subscriber Mailwizz sync button on admin list
Add POST route and form request to queue SyncSubscriberToMailwizz for one
subscriber when the page has Mailwizz configured. Include Dutch strings
and feature tests for auth and edge cases.

Made-with: Cursor
2026-04-05 13:45:30 +02:00
91caa16e70 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
2026-04-05 13:34:00 +02:00
1e7ee14540 fix: clear Mailwizz checkboxlist tag when deleting subscriber
Encode empty checkboxlist arrays as an empty scalar so multipart requests
include the field. On delete, PUT only coupon and tag fields to Mailwizz
after merging the tag CSV from getSubscriber.

Made-with: Cursor
2026-04-05 13:33:56 +02:00
627edbbb83 fix: queue Mailwizz sync by subscriber id and skip stale payloads
Serialize only the subscriber primary key to avoid ModelNotFound on
unserialize when the row is gone. Guard handle() when subscriberId is
missing after old payload shapes.

Made-with: Cursor
2026-04-05 13:33:50 +02:00
7eda51f52a feat: clean Weeztix and Mailwizz when admin deletes subscriber
Run CleanupSubscriberIntegrationsService before delete: remove coupon code
in Weeztix via list+DELETE API; update Mailwizz contact to strip configured
source tag from the tag field and clear the mapped coupon field.

Extract MailwizzCheckboxlistTags and MailwizzSubscriberFormPayload for
shared sync/cleanup behaviour. Add WeeztixService list and delete helpers.

Integration failures are logged only; local delete always proceeds.
Feature tests cover Mailwizz strip+clear and Weeztix delete paths.

Made-with: Cursor
2026-04-05 11:57:16 +02:00
de83a6fb76 fix: isolate public subscribe from integration job failures
Queue Weeztix/Mailwizz after the HTTP response and catch dispatch errors.
Jobs log Mailwizz/Weeztix API failures without rethrowing so sync driver
and terminating callbacks do not surface 500s after a successful create.

Add JS fallback for non-JSON error responses, deployment note, and a
regression test for failing Mailwizz under QUEUE_CONNECTION=sync.

Made-with: Cursor
2026-04-05 11:47:59 +02:00
d802ce2a7c feat(subscribe): queue Weeztix coupon, then Mailwizz; document queues
- RegisterSubscriberOnPage: persist subscriber then dispatch integrations
- IssueWeeztixCouponForSubscriber on weeztix queue; dispatches Mailwizz after
  coupon attempt (idempotent if coupon_code already set); failed() fallback
- SyncSubscriberToMailwizz implements ShouldQueueAfterCommit
- Deployment: worker listens weeztix,mailwizz,default; warn against sync in prod
- .env.example: QUEUE_CONNECTION notes for subscribe UX

Made-with: Cursor
2026-04-05 11:34:01 +02:00
7ed660ec55 chore(public): remove fixed post-submit coupon explanation text
Made-with: Cursor
2026-04-05 11:24:01 +02:00
9f8052f683 fix(public): do not expose coupon code after preregistration
Codes are still created and stored for Mailwizz; JSON response omits
coupon_code. Thank-you UI explains email delivery instead of showing code.

Made-with: Cursor
2026-04-05 11:22:38 +02:00
217e1d9afb fix(weeztix): allow OAuth reconnect in wizard step 2 and re-sync company
Always sync company from profile after OAuth; remove skip when company_guid
was already set. Step 2 shows reconnect for connected users plus link to step 3.

Made-with: Cursor
2026-04-05 11:16:49 +02:00
89931b817d feat(admin): Weeztix setup wizard, integration status badges
- Summary view when Weeztix is configured; edits only via 3-step wizard
- Step 1: reuse or replace OAuth client ID/secret; callback URL shown
- Step 2: OAuth connect (resume wizard after callback when started from wizard)
- Step 3: coupon, prefix, usage; finishing exits wizard
- PreregistrationPage: mailwizz/weeztix integration status helpers
- Pages index: Integrations column with MW/WZ badges; edit page: status cards

Made-with: Cursor
2026-04-05 11:12:10 +02:00
e0de8a05fa feat(weeztix): only list coupons with status enabled in admin
Made-with: Cursor
2026-04-05 11:04:10 +02:00
55434ce086 feat(weeztix): add button to refresh coupon list from API
Made-with: Cursor
2026-04-05 10:59:46 +02:00
6561bda30d feat(weeztix): auto company from OAuth, remove company UI
Store company_guid after OAuth via profile API; drop company select and
companies endpoint. Coupons AJAX uses stored company only. Form request
no longer accepts company fields from the browser.

Made-with: Cursor
2026-04-05 10:56:29 +02:00
977e09d8ac fix: show real Weeztix company trade names, not GUID-as-name
Merge nested company objects, prefer trade_name over name, skip UUID-like
labels; Alpine falls back to stored company_name when API label is junk.

Made-with: Cursor
2026-04-05 10:48:04 +02:00
70c1d25ad4 fix: preserve Weeztix saved company/coupon after reload (Alpine sync)
Stop clearing DB-backed labels when API omits names; inject select options
for saved GUIDs when lists fail or omit rows; parse usage_count from JSON;
show OAuth fields hint when credentials already stored.

Made-with: Cursor
2026-04-05 10:44:47 +02:00
a3158ffa34 fix: use official Weeztix OAuth login and token URLs
Redirect to login.weeztix.com/login per docs; default token host to
auth.weeztix.com. Open Ticket setups can override via env.

Made-with: Cursor
2026-04-05 09:51:11 +02:00
d3abdb7ed9 feat: add Weeztix OAuth, coupon codes, and Mailwizz mapping
Implement Weeztix integration per documentation: database config and
subscriber coupon_code, OAuth redirect/callback, admin setup UI with
company/coupon selection via AJAX, synchronous coupon creation on public
subscribe with duplicate and rate-limit handling, Mailwizz field mapping
for coupon codes, subscriber table and CSV export, and connection hint
on the pages list.

Made-with: Cursor
2026-04-04 14:52:41 +02:00
17e784fee7 feat: E.164 phone validation and storage with libphonenumber
- Add giggsey/libphonenumber-for-php, PhoneNumberNormalizer, ValidPhoneNumber rule

- Store subscribers as E.164; mutator normalizes on save; optional phone required from form block

- Migration to normalize legacy subscriber phones; Mailwizz/search/UI/tests updated

- Add run-deploy-from-local.sh and PREREGISTER_DEFAULT_PHONE_REGION in .env.example

Made-with: Cursor
2026-04-04 14:25:52 +02:00
5a67827c23 feat: optional fixed viewport background on public pages
Adds background_fixed column, admin checkbox, fixed-position layers on the public layout, Dutch strings, and store tests.

Made-with: Cursor
2026-04-04 13:36:26 +02:00
2603288881 chore: load fnm in deploy script for Node/npm
Export PATH and eval fnm env so npm run build uses the intended Node version on the VPS.

Made-with: Cursor
2026-04-04 13:27:31 +02:00
26258c5f8b fix: avoid visible leading space in whitespace-pre-line blocks
Render hero subheadline and expired message on one line so Blade indentation is not preserved by whitespace-pre-line.

Made-with: Cursor
2026-04-04 10:34:45 +02:00
6791c8349a chore: update deploy script for VPS paths and PHP 8.4
Point APP_DIR at /home/hausdesign/preregister and run artisan/composer via explicit PHP and Composer binaries.

Made-with: Cursor
2026-04-04 09:51:54 +02:00
61 changed files with 4725 additions and 340 deletions

View File

@@ -7,6 +7,9 @@ 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
# Default region for parsing national phone numbers (ISO 3166-1 alpha-2). Used by libphonenumber.
# PREREGISTER_DEFAULT_PHONE_REGION=NL
# Wall-clock times from the admin UI (datetime-local) are interpreted in this zone.
APP_TIMEZONE=Europe/Amsterdam
@@ -42,6 +45,10 @@ SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
# Use "database" or "redis" in production and run `php artisan queue:work` (see documentation/DEPLOYMENT-STRATEGY.md).
# Avoid "sync" in production: Mailwizz (and Weeztix coupon) jobs would run inside the HTTP request; a thrown error
# can return 5xx to the visitor even though the subscriber row is already saved — confusing UX.
QUEUE_CONNECTION=database
CACHE_STORE=database
@@ -70,3 +77,9 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Weeztix OAuth (defaults match https://docs.weeztix.com — only set if you use Open Ticket / another issuer)
# WEEZTIX_OAUTH_AUTHORIZE_URL=https://login.weeztix.com/login
# WEEZTIX_AUTH_BASE_URL=https://auth.weeztix.com
# WEEZTIX_USER_PROFILE_URL=https://auth.weeztix.com/users/me
# WEEZTIX_API_BASE_URL=https://api.weeztix.com

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use RuntimeException;
final class WeeztixCouponCodeConflictException extends RuntimeException {}

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

@@ -28,6 +28,7 @@ class PageController extends Controller
{
$query = PreregistrationPage::query()
->withCount('subscribers')
->with(['weeztixConfig', 'mailwizzConfig'])
->orderByDesc('start_date');
if (! $request->user()?->isSuperadmin()) {
@@ -85,7 +86,11 @@ class PageController extends Controller
public function edit(PreregistrationPage $page): View
{
$page->load(['blocks' => fn ($q) => $q->orderBy('sort_order')]);
$page->load([
'blocks' => fn ($q) => $q->orderBy('sort_order'),
'mailwizzConfig',
'weeztixConfig',
]);
return view('admin.pages.edit', compact('page'));
}

View File

@@ -8,8 +8,11 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\DestroySubscriberRequest;
use App\Http\Requests\Admin\IndexSubscriberRequest;
use App\Http\Requests\Admin\QueueMailwizzSyncRequest;
use App\Http\Requests\Admin\SyncSubscriberMailwizzRequest;
use App\Jobs\SyncSubscriberToMailwizz;
use App\Models\PreregistrationPage;
use App\Models\Subscriber;
use App\Services\CleanupSubscriberIntegrationsService;
use App\Services\DispatchUnsyncedMailwizzSyncJobsService;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
@@ -34,8 +37,13 @@ class SubscriberController extends Controller
return view('admin.subscribers.index', compact('page', 'subscribers', 'unsyncedMailwizzCount'));
}
public function destroy(DestroySubscriberRequest $request, PreregistrationPage $page, Subscriber $subscriber): RedirectResponse
{
public function destroy(
DestroySubscriberRequest $request,
PreregistrationPage $page,
Subscriber $subscriber,
CleanupSubscriberIntegrationsService $cleanupIntegrations
): RedirectResponse {
$cleanupIntegrations->runBeforeDelete($subscriber);
$subscriber->delete();
return redirect()
@@ -73,6 +81,26 @@ class SubscriberController extends Controller
));
}
public function syncSubscriberMailwizz(
SyncSubscriberMailwizzRequest $request,
PreregistrationPage $page,
Subscriber $subscriber
): RedirectResponse {
$page->loadMissing('mailwizzConfig');
if ($page->mailwizzConfig === null) {
return redirect()
->route('admin.pages.subscribers.index', $page)
->with('error', __('This page has no Mailwizz integration.'));
}
SyncSubscriberToMailwizz::dispatch($subscriber->fresh());
return redirect()
->route('admin.pages.subscribers.index', $page)
->with('status', __('Mailwizz sync has been queued for this subscriber.'));
}
public function export(IndexSubscriberRequest $request, PreregistrationPage $page): StreamedResponse
{
$search = $request->validated('search');
@@ -89,14 +117,15 @@ class SubscriberController extends Controller
if ($phoneEnabled) {
$headers[] = 'Phone';
}
$headers = array_merge($headers, ['Registered At', 'Synced to Mailwizz', 'Synced At']);
$headers = array_merge($headers, ['Coupon Code', 'Registered At', 'Synced to Mailwizz', 'Synced At']);
fputcsv($handle, $headers);
foreach ($subscribers as $sub) {
$row = [$sub->first_name, $sub->last_name, $sub->email];
if ($phoneEnabled) {
$row[] = $sub->phone ?? '';
$row[] = $sub->phoneDisplay() ?? '';
}
$row[] = $sub->coupon_code ?? '';
$row[] = $sub->created_at?->toDateTimeString() ?? '';
$row[] = $sub->synced_to_mailwizz ? 'Yes' : 'No';
$row[] = $sub->synced_at?->toDateTimeString() ?? '';

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\PreregistrationPage;
use App\Services\WeeztixService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
class WeeztixApiController extends Controller
{
public function coupons(Request $request): JsonResponse
{
$request->validate([
'page_id' => ['required', 'integer', 'exists:preregistration_pages,id'],
]);
$page = PreregistrationPage::query()->findOrFail($request->integer('page_id'));
$this->authorize('update', $page);
$config = $page->weeztixConfig;
if ($config === null || ! $config->is_connected) {
return response()->json([
'message' => __('Niet verbonden met Weeztix.'),
], 422);
}
$companyGuid = $config->company_guid;
if (! is_string($companyGuid) || $companyGuid === '') {
return response()->json([
'message' => __('Geen Weeztix-bedrijf gekoppeld. Verbind opnieuw met Weeztix.'),
], 422);
}
try {
$raw = (new WeeztixService($config))->getCoupons();
$coupons = $this->normalizeCouponsPayload($raw);
return response()->json(['coupons' => $coupons]);
} catch (RuntimeException) {
return response()->json([
'message' => __('Kon kortingsbonnen niet laden.'),
], 422);
}
}
/**
* @param array<string, mixed> $raw
* @return list<array{guid: string, name: string}>
*/
private function normalizeCouponsPayload(array $raw): array
{
$list = $raw;
if (isset($raw['data']) && is_array($raw['data'])) {
$list = $raw['data'];
}
if (! is_array($list)) {
return [];
}
$out = [];
foreach ($list as $row) {
if (! is_array($row)) {
continue;
}
if (! $this->couponRowHasEnabledStatus($row)) {
continue;
}
$guid = data_get($row, 'guid') ?? data_get($row, 'id');
if (! is_string($guid) || $guid === '') {
continue;
}
$name = data_get($row, 'name') ?? data_get($row, 'title') ?? $guid;
$out[] = [
'guid' => $guid,
'name' => is_string($name) ? $name : $guid,
];
}
return $out;
}
/**
* Weeztix coupon list items expose a string status; only "enabled" should appear in the admin picker.
*
* @param array<string, mixed> $row
*/
private function couponRowHasEnabledStatus(array $row): bool
{
$status = data_get($row, 'status');
return is_string($status) && strcasecmp(trim($status), 'enabled') === 0;
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\UpdateWeeztixConfigRequest;
use App\Models\PreregistrationPage;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class WeeztixController extends Controller
{
public function edit(Request $request, PreregistrationPage $page): View
{
$this->authorize('update', $page);
$page->load('weeztixConfig');
$showWizard = $page->weeztixConfig === null || $request->boolean('wizard');
$wizardStep = $showWizard ? min(3, max(1, (int) $request->query('step', 1))) : 1;
if ($showWizard && $page->weeztixConfig === null && $wizardStep !== 1) {
return redirect()
->route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]);
}
$hasStoredCredentials = $page->weeztixConfig !== null
&& is_string($page->weeztixConfig->client_id)
&& $page->weeztixConfig->client_id !== '';
return view('admin.weeztix.edit', compact(
'page',
'showWizard',
'wizardStep',
'hasStoredCredentials'
));
}
public function update(UpdateWeeztixConfigRequest $request, PreregistrationPage $page): RedirectResponse
{
$validated = $request->validated();
foreach (['client_id', 'client_secret'] as $key) {
if (array_key_exists($key, $validated) && $validated[$key] === '' && $page->weeztixConfig !== null) {
unset($validated[$key]);
}
}
$validated['redirect_uri'] = route('admin.weeztix.callback', absolute: true);
DB::transaction(function () use ($page, $validated): void {
$page->weeztixConfig()->updateOrCreate(
['preregistration_page_id' => $page->id],
array_merge($validated, ['preregistration_page_id' => $page->id])
);
});
$page->load('weeztixConfig');
if ($request->boolean('wizard')) {
if ($request->boolean('wizard_coupon_save')) {
return redirect()
->route('admin.pages.weeztix.edit', ['page' => $page])
->with('status', __('Weeztix-configuratie opgeslagen.'));
}
return redirect()
->route('admin.pages.weeztix.edit', [
'page' => $page,
'wizard' => 1,
'step' => 2,
])
->with('status', __('Weeztix-configuratie opgeslagen.'));
}
return redirect()
->route('admin.pages.weeztix.edit', ['page' => $page])
->with('status', __('Weeztix-configuratie opgeslagen.'));
}
public function destroy(PreregistrationPage $page): RedirectResponse
{
$this->authorize('update', $page);
$page->weeztixConfig()?->delete();
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('status', __('Weeztix-integratie verwijderd.'));
}
}

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\PreregistrationPage;
use App\Services\WeeztixService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use RuntimeException;
class WeeztixOAuthController extends Controller
{
public function redirect(Request $request, PreregistrationPage $page): RedirectResponse
{
$this->authorize('update', $page);
$page->load('weeztixConfig');
$config = $page->weeztixConfig;
if ($config === null) {
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('error', __('Sla eerst je client ID en client secret op.'));
}
$clientId = $config->client_id;
if (! is_string($clientId) || $clientId === '') {
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('error', __('Vul een geldige Weeztix client ID in.'));
}
$state = Str::random(40);
session([
'weeztix_oauth_state' => $state,
'weeztix_page_id' => $page->id,
'weeztix_oauth_resume_wizard' => $request->boolean('wizard'),
]);
$redirectUri = $config->redirect_uri;
if (! is_string($redirectUri) || $redirectUri === '') {
$redirectUri = route('admin.weeztix.callback', absolute: true);
}
$query = http_build_query([
'client_id' => $clientId,
'redirect_uri' => $redirectUri,
'response_type' => 'code',
'state' => $state,
]);
$authorizeUrl = config('weeztix.oauth_authorize_url');
return redirect()->away($authorizeUrl.'?'.$query);
}
public function callback(Request $request): RedirectResponse
{
if ($request->filled('error')) {
Log::warning('Weeztix OAuth provider error', [
'error' => $request->string('error')->toString(),
'description' => $request->string('error_description')->toString(),
]);
return $this->redirectToWeeztixEditWithSessionPage(__('Weeztix heeft de verbinding geweigerd. Probeer opnieuw.'));
}
$request->validate([
'state' => ['required', 'string'],
'code' => ['required', 'string'],
]);
$storedState = session('weeztix_oauth_state');
$pageId = session('weeztix_page_id');
if (! is_string($storedState) || $storedState === '' || ($pageId === null || (! is_int($pageId) && ! is_numeric($pageId)))) {
return redirect()
->route('admin.dashboard')
->with('error', __('Ongeldige OAuth-sessie. Start opnieuw vanaf de Weeztix-pagina.'));
}
if ($request->string('state')->toString() !== $storedState) {
abort(403, 'Invalid OAuth state');
}
$page = PreregistrationPage::query()->findOrFail((int) $pageId);
$this->authorize('update', $page);
$config = $page->weeztixConfig;
if ($config === null) {
$this->forgetOauthSession();
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('error', __('Geen Weeztix-configuratie gevonden voor deze pagina.'));
}
try {
$service = new WeeztixService($config);
$service->exchangeAuthorizationCode($request->string('code')->toString());
$config = $config->fresh();
if ($config !== null) {
(new WeeztixService($config))->ensureCompanyStoredFromWeeztix();
}
} catch (RuntimeException $e) {
Log::error('Weeztix OAuth callback failed', [
'page_id' => $page->id,
'message' => $e->getMessage(),
]);
$resumeWizard = $this->forgetOauthSession();
return redirect()
->route('admin.pages.weeztix.edit', $this->weeztixEditParams($page, $resumeWizard, 2))
->with('error', __('Verbinden met Weeztix is mislukt. Controleer je gegevens en probeer opnieuw.'));
}
$resumeWizard = $this->forgetOauthSession();
return redirect()
->route('admin.pages.weeztix.edit', $this->weeztixEditParams($page, $resumeWizard, 3))
->with('status', __('Succesvol verbonden met Weeztix.'));
}
/**
* @return array{page: PreregistrationPage, wizard?: int, step?: int}
*/
private function weeztixEditParams(PreregistrationPage $page, bool $resumeWizard, int $step): array
{
$params = ['page' => $page];
if ($resumeWizard) {
$params['wizard'] = 1;
$params['step'] = $step;
}
return $params;
}
private function forgetOauthSession(): bool
{
$resumeWizard = (bool) session()->pull('weeztix_oauth_resume_wizard', false);
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
return $resumeWizard;
}
private function redirectToWeeztixEditWithSessionPage(string $message): RedirectResponse
{
$pageId = session('weeztix_page_id');
$resumeWizard = $this->forgetOauthSession();
if (is_int($pageId) || is_numeric($pageId)) {
$page = PreregistrationPage::query()->find((int) $pageId);
if ($page !== null) {
return redirect()
->route('admin.pages.weeztix.edit', $this->weeztixEditParams($page, $resumeWizard, 2))
->with('error', $message);
}
}
return redirect()
->route('admin.dashboard')
->with('error', $message);
}
}

View File

@@ -5,15 +5,19 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Requests\SubscribePublicPageRequest;
use App\Jobs\SyncSubscriberToMailwizz;
use App\Models\PageBlock;
use App\Models\PreregistrationPage;
use App\Services\RegisterSubscriberOnPage;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
use Illuminate\View\View;
class PublicPageController extends Controller
{
public function __construct(
private readonly RegisterSubscriberOnPage $registerSubscriberOnPage
) {}
public function show(PreregistrationPage $publicPage): View
{
$publicPage->load(['blocks' => fn ($q) => $q->orderBy('sort_order')]);
@@ -50,11 +54,7 @@ class PublicPageController extends Controller
], 422);
}
$subscriber = $publicPage->subscribers()->create($validated);
if ($publicPage->mailwizzConfig !== null) {
SyncSubscriberToMailwizz::dispatch($subscriber);
}
$this->registerSubscriberOnPage->storeAndQueueIntegrations($publicPage, $validated);
return response()->json([
'success' => true,

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use App\Models\PreregistrationPage;
use App\Models\Subscriber;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SyncSubscriberMailwizzRequest extends FormRequest
{
public function authorize(): bool
{
$page = $this->route('page');
$subscriber = $this->route('subscriber');
if (! $page instanceof PreregistrationPage || ! $subscriber instanceof Subscriber) {
return false;
}
if ($subscriber->preregistration_page_id !== $page->id) {
return false;
}
return $this->user()?->can('update', $page) ?? false;
}
/**
* @return array<string, array<int, ValidationRule|string>>
*/
public function rules(): array
{
return [];
}
}

View File

@@ -42,6 +42,7 @@ class UpdateMailwizzConfigRequest extends FormRequest
'field_first_name' => ['required', 'string', 'max:255'],
'field_last_name' => ['required', 'string', 'max:255'],
'field_phone' => ['nullable', 'string', 'max:255'],
'field_coupon_code' => ['nullable', 'string', 'max:255'],
'tag_field' => ['required', 'string', 'max:255'],
'tag_value' => ['required', 'string', 'max:255'],
];

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use App\Models\PreregistrationPage;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateWeeztixConfigRequest extends FormRequest
{
public function authorize(): bool
{
$page = $this->route('page');
if (! $page instanceof PreregistrationPage) {
return false;
}
return $this->user()?->can('update', $page) ?? false;
}
/**
* @return array<string, array<int, ValidationRule|string>>
*/
public function rules(): array
{
/** @var PreregistrationPage $page */
$page = $this->route('page');
return [
'client_id' => [
'sometimes',
Rule::requiredIf(fn (): bool => $page->weeztixConfig === null),
'nullable',
'string',
'max:2048',
],
'client_secret' => [
'sometimes',
Rule::requiredIf(fn (): bool => $page->weeztixConfig === null),
'nullable',
'string',
'max:2048',
],
'coupon_guid' => ['nullable', 'string', 'max:255'],
'coupon_name' => ['nullable', 'string', 'max:255'],
'code_prefix' => ['nullable', 'string', 'max:32'],
'usage_count' => ['nullable', 'integer', 'min:1', 'max:99999'],
];
}
}

View File

@@ -23,6 +23,7 @@ trait ValidatesPreregistrationPageInput
'post_submit_redirect_url' => ['nullable', 'string', 'url:http,https', 'max:500'],
'background_overlay_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/'],
'background_overlay_opacity' => ['nullable', 'integer', 'min:0', 'max:100'],
'background_fixed' => ['sometimes', 'boolean'],
'page_background' => ['nullable', 'file', 'image', 'mimes:jpeg,png,jpg,webp', 'max:5120'],
'remove_page_background' => ['sometimes', 'boolean'],
'start_date' => ['required', 'date'],
@@ -69,6 +70,7 @@ trait ValidatesPreregistrationPageInput
$this->merge([
'is_active' => $this->boolean('is_active'),
'background_fixed' => $this->boolean('background_fixed'),
'remove_page_background' => $this->boolean('remove_page_background'),
'ticketshop_url' => $ticketshopNormalized,
'post_submit_redirect_url' => $redirectNormalized,

View File

@@ -5,7 +5,10 @@ declare(strict_types=1);
namespace App\Http\Requests;
use App\Models\PreregistrationPage;
use App\Rules\ValidPhoneNumber;
use App\Services\PhoneNumberNormalizer;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Email;
class SubscribePublicPageRequest extends FormRequest
@@ -28,13 +31,23 @@ class SubscribePublicPageRequest extends FormRequest
->rfcCompliant()
->preventSpoofing();
$phoneRules = ['nullable', 'string', 'max:255'];
if ($page->isPhoneFieldEnabledForSubscribers()) {
$phoneRules = [
Rule::requiredIf(fn (): bool => $page->isPhoneFieldRequiredForSubscribers()),
'nullable',
'string',
'max:32',
new ValidPhoneNumber(app(PhoneNumberNormalizer::class)),
];
}
return [
'first_name' => ['required', 'string', 'max:255'],
'last_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'max:255', $emailRule],
'phone' => $page->isPhoneFieldEnabledForSubscribers()
? ['nullable', 'string', 'regex:/^[0-9]{8,15}$/']
: ['nullable', 'string', 'max:255'],
'phone' => $phoneRules,
];
}
@@ -45,7 +58,6 @@ class SubscribePublicPageRequest extends FormRequest
{
return [
'email' => __('Please enter a valid email address.'),
'phone.regex' => __('Please enter a valid phone number (815 digits).'),
];
}
@@ -73,15 +85,22 @@ class SubscribePublicPageRequest extends FormRequest
/** @var PreregistrationPage $page */
$page = $this->route('publicPage');
$phone = $this->input('phone');
if (! $page->isPhoneFieldEnabledForSubscribers()) {
$this->merge(['phone' => null]);
return;
}
$phone = $this->input('phone');
if ($phone === null || $phone === '') {
$this->merge(['phone' => null]);
return;
}
if (is_string($phone)) {
$digits = preg_replace('/\D+/', '', $phone);
$this->merge(['phone' => $digits === '' ? null : $digits]);
$trimmed = trim($phone);
$this->merge(['phone' => $trimmed === '' ? null : $trimmed]);
}
}
}

View File

@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Exceptions\WeeztixCouponCodeConflictException;
use App\Models\Subscriber;
use App\Models\WeeztixConfig;
use App\Services\WeeztixService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Creates the Weeztix coupon code after the subscriber row exists, then queues Mailwizz sync
* so the external APIs never block the public HTTP response and Mailwizz runs after coupon_code is set when possible.
*/
final class IssueWeeztixCouponForSubscriber implements ShouldBeUnique, ShouldQueueAfterCommit
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
/**
* @var list<int>
*/
public array $backoff = [5, 15, 45];
/**
* Seconds before the unique lock expires if the worker dies before releasing it.
*/
public int $uniqueFor = 300;
public function __construct(public Subscriber $subscriber)
{
$this->onQueue('weeztix');
}
public function uniqueId(): string
{
return 'weeztix-coupon-subscriber-'.$this->subscriber->getKey();
}
public function handle(): void
{
try {
$subscriber = Subscriber::query()
->with(['preregistrationPage.weeztixConfig', 'preregistrationPage.mailwizzConfig'])
->find($this->subscriber->id);
if ($subscriber === null) {
return;
}
$page = $subscriber->preregistrationPage;
if ($page === null) {
return;
}
$config = $page->weeztixConfig;
$couponMissing = ! is_string($subscriber->coupon_code) || $subscriber->coupon_code === '';
if ($couponMissing && $this->weeztixCanIssueCodes($config)) {
$this->tryAttachWeeztixCouponCode($subscriber, $config);
}
$this->dispatchMailwizzIfNeeded($subscriber);
} catch (Throwable $e) {
Log::error('IssueWeeztixCouponForSubscriber: handle failed', [
'subscriber_id' => $this->subscriber->id,
'message' => $e->getMessage(),
]);
$subscriber = Subscriber::query()
->with(['preregistrationPage.mailwizzConfig'])
->find($this->subscriber->id);
if ($subscriber !== null) {
$this->dispatchMailwizzIfNeeded($subscriber);
}
}
}
public function failed(?Throwable $exception): void
{
Log::error('IssueWeeztixCouponForSubscriber failed', [
'subscriber_id' => $this->subscriber->id,
'message' => $exception?->getMessage(),
]);
$subscriber = Subscriber::query()
->with('preregistrationPage.mailwizzConfig')
->find($this->subscriber->id);
if ($subscriber !== null) {
$this->dispatchMailwizzIfNeeded($subscriber);
}
}
private function dispatchMailwizzIfNeeded(Subscriber $subscriber): void
{
$page = $subscriber->preregistrationPage;
$page?->loadMissing('mailwizzConfig');
if ($page?->mailwizzConfig !== null) {
try {
SyncSubscriberToMailwizz::dispatch($subscriber->fresh());
} catch (Throwable $e) {
Log::error('IssueWeeztixCouponForSubscriber: could not queue Mailwizz sync', [
'subscriber_id' => $subscriber->id,
'message' => $e->getMessage(),
]);
}
}
}
private function weeztixCanIssueCodes(?WeeztixConfig $config): bool
{
if ($config === null || ! $config->is_connected) {
return false;
}
$company = $config->company_guid;
$coupon = $config->coupon_guid;
return is_string($company) && $company !== '' && is_string($coupon) && $coupon !== '';
}
private function tryAttachWeeztixCouponCode(Subscriber $subscriber, WeeztixConfig $config): void
{
$freshConfig = $config->fresh();
if ($freshConfig === null) {
return;
}
$service = new WeeztixService($freshConfig);
$maxAttempts = 5;
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
try {
$code = WeeztixService::generateUniqueCode(
is_string($freshConfig->code_prefix) && $freshConfig->code_prefix !== ''
? $freshConfig->code_prefix
: 'PREREG'
);
$service->createCouponCode($code);
$subscriber->update(['coupon_code' => $code]);
return;
} catch (WeeztixCouponCodeConflictException) {
continue;
} catch (Throwable $e) {
Log::error('Weeztix coupon creation failed', [
'subscriber_id' => $subscriber->id,
'message' => $e->getMessage(),
]);
return;
}
}
Log::warning('Weeztix coupon: exhausted duplicate retries', [
'subscriber_id' => $subscriber->id,
]);
}
}

View File

@@ -6,23 +6,23 @@ namespace App\Jobs;
use App\Models\MailwizzConfig;
use App\Models\Subscriber;
use App\Services\MailwizzCheckboxlistTags;
use App\Services\MailwizzService;
use App\Services\MailwizzSubscriberFormPayload;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueueAfterCommit
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/**
* Seconds before the unique lock expires if the worker dies before releasing it.
@@ -36,21 +36,52 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
*/
public array $backoff = [10, 30, 60];
public function __construct(public Subscriber $subscriber)
/**
* Set in the constructor for new jobs. Remains null when an old queue payload (presubscriber-id refactor) is unserialized.
*/
public ?int $subscriberId = null;
/**
* @param Subscriber|int $subscriber Model is accepted when dispatching; only the id is serialized for the queue.
*/
public function __construct(Subscriber|int $subscriber)
{
$this->subscriberId = $subscriber instanceof Subscriber
? (int) $subscriber->getKey()
: $subscriber;
$this->onQueue('mailwizz');
}
public function uniqueId(): string
{
return (string) $this->subscriber->getKey();
return $this->subscriberId !== null
? (string) $this->subscriberId
: 'stale-mailwizz-sync-payload';
}
public function handle(): void
{
if ($this->subscriberId === null) {
Log::notice('SyncSubscriberToMailwizz: skipped job with missing subscriber id (stale queue payload). Clear the queue or re-dispatch sync jobs.');
return;
}
try {
$this->runSync();
} catch (Throwable $e) {
Log::error('SyncSubscriberToMailwizz: integration failed; subscriber remains local (use admin resync if needed)', [
'subscriber_id' => $this->subscriberId,
'message' => $e->getMessage(),
]);
}
}
private function runSync(): void
{
$subscriber = Subscriber::query()
->with(['preregistrationPage.mailwizzConfig'])
->find($this->subscriber->id);
->find($this->subscriberId);
if ($subscriber === null) {
return;
@@ -101,7 +132,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
public function failed(?Throwable $exception): void
{
Log::error('SyncSubscriberToMailwizz failed', [
'subscriber_id' => $this->subscriber->id,
'subscriber_id' => $this->subscriberId,
'message' => $exception?->getMessage(),
]);
}
@@ -119,24 +150,6 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
return true;
}
private function buildBasePayload(Subscriber $subscriber, MailwizzConfig $config, bool $phoneEnabled): array
{
$data = [
$config->field_email => $subscriber->email,
$config->field_first_name => $subscriber->first_name,
$config->field_last_name => $subscriber->last_name,
];
if ($phoneEnabled && $config->field_phone !== null && $config->field_phone !== '') {
$phone = $subscriber->phone;
if ($phone !== null && $phone !== '') {
$data[$config->field_phone] = $phone;
}
}
return $data;
}
private function createInMailwizz(
MailwizzService $service,
Subscriber $subscriber,
@@ -144,7 +157,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
string $listUid
): void {
$page = $subscriber->preregistrationPage;
$data = $this->buildBasePayload($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
$data = MailwizzSubscriberFormPayload::baseFields($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
$tagField = $config->tag_field;
$tagValue = $config->tag_value;
if ($tagField !== null && $tagField !== '' && $tagValue !== null && $tagValue !== '') {
@@ -162,7 +175,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
string $subscriberUid
): void {
$page = $subscriber->preregistrationPage;
$data = $this->buildBasePayload($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
$data = MailwizzSubscriberFormPayload::baseFields($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
$tagField = $config->tag_field;
$tagValue = $config->tag_value;
@@ -171,46 +184,11 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
if ($full === null) {
throw new RuntimeException('Mailwizz getSubscriber returned an empty payload.');
}
$existingCsv = $this->extractTagCsvFromResponse($full, $tagField);
$merged = $this->mergeCheckboxlistTags($existingCsv, $tagValue);
$existingCsv = MailwizzCheckboxlistTags::extractCsvFromSubscriberResponse($full, $tagField);
$merged = MailwizzCheckboxlistTags::mergeValueIntoCsv($existingCsv, $tagValue);
$data[$tagField] = $merged;
}
$service->updateSubscriber($listUid, $subscriberUid, $data);
}
/**
* @param array<string, mixed> $apiResponse
*/
private function extractTagCsvFromResponse(array $apiResponse, string $tagField): string
{
$record = data_get($apiResponse, 'data.record');
if (! is_array($record)) {
$record = data_get($apiResponse, 'data');
}
if (! is_array($record)) {
return '';
}
$raw = $record[$tagField] ?? data_get($record, "fields.{$tagField}");
if (is_array($raw)) {
return implode(',', array_map(static fn (mixed $v): string => is_scalar($v) ? (string) $v : '', $raw));
}
return is_string($raw) ? $raw : '';
}
/**
* @return list<string>
*/
private function mergeCheckboxlistTags(string $existingCsv, string $newValue): array
{
$parts = array_filter(array_map('trim', explode(',', $existingCsv)), static fn (string $s): bool => $s !== '');
if (! in_array($newValue, $parts, true)) {
$parts[] = $newValue;
}
return array_values($parts);
}
}

View File

@@ -21,6 +21,7 @@ class MailwizzConfig extends Model
'field_first_name',
'field_last_name',
'field_phone',
'field_coupon_code',
'tag_field',
'tag_value',
];

View File

@@ -32,6 +32,7 @@ class PreregistrationPage extends Model
'background_image',
'background_overlay_color',
'background_overlay_opacity',
'background_fixed',
'logo_image',
'is_active',
];
@@ -42,6 +43,7 @@ class PreregistrationPage extends Model
'start_date' => 'datetime',
'end_date' => 'datetime',
'phone_enabled' => 'boolean',
'background_fixed' => 'boolean',
'is_active' => 'boolean',
];
}
@@ -106,6 +108,19 @@ class PreregistrationPage extends Model
return (bool) $this->phone_enabled;
}
/**
* When the form block marks the phone field as required (only applies if phone is enabled).
*/
public function isPhoneFieldRequiredForSubscribers(): bool
{
$form = $this->getBlockByType('form');
if ($form !== null) {
return (bool) data_get($form->content, 'fields.phone.required', false);
}
return false;
}
public function headlineForMeta(): string
{
$hero = $this->getHeroBlock();
@@ -147,6 +162,11 @@ class PreregistrationPage extends Model
return $this->hasOne(MailwizzConfig::class);
}
public function weeztixConfig(): HasOne
{
return $this->hasOne(WeeztixConfig::class);
}
public function isBeforeStart(): bool
{
return Carbon::now()->lt($this->start_date);
@@ -184,4 +204,56 @@ class PreregistrationPage extends Model
return 'active';
}
/**
* Mailwizz setup depth for admin UI (API key + list + email field = ready to sync).
*
* @return 'none'|'partial'|'ready'
*/
public function mailwizzIntegrationStatus(): string
{
$c = $this->mailwizzConfig;
if ($c === null) {
return 'none';
}
$key = $c->api_key;
if (! is_string($key) || $key === '') {
return 'partial';
}
if (! is_string($c->list_uid) || $c->list_uid === '' || ! is_string($c->field_email) || $c->field_email === '') {
return 'partial';
}
return 'ready';
}
/**
* Weeztix setup depth for admin UI.
*
* @return 'none'|'credentials'|'connected'|'ready'
*/
public function weeztixIntegrationStatus(): string
{
$c = $this->weeztixConfig;
if ($c === null) {
return 'none';
}
$hasClient = is_string($c->client_id) && $c->client_id !== '';
if (! $hasClient) {
return 'none';
}
if (! $c->is_connected) {
return 'credentials';
}
if (! is_string($c->coupon_guid) || $c->coupon_guid === '') {
return 'connected';
}
return 'ready';
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models;
use App\Services\PhoneNumberNormalizer;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -21,6 +22,7 @@ class Subscriber extends Model
'phone',
'synced_to_mailwizz',
'synced_at',
'coupon_code',
];
protected function casts(): array
@@ -36,6 +38,52 @@ class Subscriber extends Model
return $this->belongsTo(PreregistrationPage::class);
}
/**
* @param string|null $value
*/
public function setPhoneAttribute(mixed $value): void
{
if ($value === null) {
$this->attributes['phone'] = null;
return;
}
if (! is_string($value)) {
$this->attributes['phone'] = null;
return;
}
$trimmed = trim($value);
if ($trimmed === '') {
$this->attributes['phone'] = null;
return;
}
$normalized = app(PhoneNumberNormalizer::class)->normalizeToE164($trimmed);
$this->attributes['phone'] = $normalized;
}
/**
* Phones are stored as E.164 (e.g. +31612345678). Legacy rows may still be digits-only.
*/
public function phoneDisplay(): ?string
{
$phone = $this->phone;
if ($phone === null || $phone === '') {
return null;
}
$p = (string) $phone;
if (str_starts_with($p, '+')) {
return $p;
}
return preg_match('/^\d{8,15}$/', $p) === 1 ? '+'.$p : $p;
}
public function scopeSearch(Builder $query, ?string $term): Builder
{
if ($term === null || $term === '') {
@@ -47,7 +95,9 @@ class Subscriber extends Model
return $query->where(function (Builder $q) use ($like): void {
$q->where('first_name', 'like', $like)
->orWhere('last_name', 'like', $like)
->orWhere('email', 'like', $like);
->orWhere('email', 'like', $like)
->orWhere('phone', 'like', $like)
->orWhere('coupon_code', 'like', $like);
});
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WeeztixConfig extends Model
{
use HasFactory;
protected $fillable = [
'preregistration_page_id',
'client_id',
'client_secret',
'redirect_uri',
'access_token',
'refresh_token',
'token_expires_at',
'refresh_token_expires_at',
'company_guid',
'company_name',
'coupon_guid',
'coupon_name',
'code_prefix',
'usage_count',
'is_connected',
];
protected function casts(): array
{
return [
'client_id' => 'encrypted',
'client_secret' => 'encrypted',
'access_token' => 'encrypted',
'refresh_token' => 'encrypted',
'token_expires_at' => 'datetime',
'refresh_token_expires_at' => 'datetime',
'is_connected' => 'boolean',
];
}
public function preregistrationPage(): BelongsTo
{
return $this->belongsTo(PreregistrationPage::class);
}
public function isTokenExpired(): bool
{
return ! $this->token_expires_at || $this->token_expires_at->isPast();
}
public function isRefreshTokenExpired(): bool
{
if ($this->refresh_token_expires_at === null) {
return false;
}
return $this->refresh_token_expires_at->isPast();
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Providers;
use App\Models\PreregistrationPage;
use App\Services\PhoneNumberNormalizer;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
@@ -16,7 +17,11 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
$this->app->singleton(PhoneNumberNormalizer::class, function (): PhoneNumberNormalizer {
return new PhoneNumberNormalizer(
(string) config('preregister.default_phone_region', 'NL')
);
});
}
/**

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Rules;
use App\Services\PhoneNumberNormalizer;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
final class ValidPhoneNumber implements ValidationRule
{
public function __construct(
private readonly PhoneNumberNormalizer $normalizer
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value === null || $value === '') {
return;
}
if (! is_string($value)) {
$fail(__('Please enter a valid phone number.'));
return;
}
if ($this->normalizer->normalizeToE164(trim($value)) === null) {
$fail(__('Please enter a valid phone number.'));
}
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\MailwizzConfig;
use App\Models\PreregistrationPage;
use App\Models\Subscriber;
use App\Models\WeeztixConfig;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
/**
* Before a subscriber row is deleted, best-effort cleanup in Weeztix (coupon code) and Mailwizz (strip source tag, clear coupon field).
* Failures are logged only; local delete must still proceed.
*/
final class CleanupSubscriberIntegrationsService
{
public function runBeforeDelete(Subscriber $subscriber): void
{
$subscriber->loadMissing(['preregistrationPage.mailwizzConfig', 'preregistrationPage.weeztixConfig']);
$page = $subscriber->preregistrationPage;
if ($page === null) {
return;
}
try {
$this->cleanupWeeztixIfApplicable($subscriber, $page);
} catch (Throwable $e) {
Log::error('CleanupSubscriberIntegrations: Weeztix cleanup failed', [
'subscriber_id' => $subscriber->id,
'preregistration_page_id' => $page->id,
'message' => $e->getMessage(),
]);
}
try {
$this->cleanupMailwizzIfApplicable($subscriber, $page);
} catch (Throwable $e) {
Log::error('CleanupSubscriberIntegrations: Mailwizz cleanup failed', [
'subscriber_id' => $subscriber->id,
'preregistration_page_id' => $page->id,
'message' => $e->getMessage(),
]);
}
}
private function cleanupWeeztixIfApplicable(Subscriber $subscriber, PreregistrationPage $page): void
{
$config = $page->weeztixConfig;
if ($config === null || ! $config->is_connected) {
return;
}
$code = $subscriber->coupon_code;
if (! is_string($code) || trim($code) === '') {
return;
}
if (! $this->weeztixCanManageCodes($config)) {
return;
}
$fresh = $config->fresh();
if ($fresh === null) {
return;
}
(new WeeztixService($fresh))->deleteCouponCodeByCodeString($code);
}
private function weeztixCanManageCodes(WeeztixConfig $config): bool
{
$company = $config->company_guid;
$coupon = $config->coupon_guid;
return is_string($company) && $company !== '' && is_string($coupon) && $coupon !== '';
}
private function cleanupMailwizzIfApplicable(Subscriber $subscriber, PreregistrationPage $page): void
{
$config = $page->mailwizzConfig;
if ($config === null) {
return;
}
if (! $this->mailwizzConfigAllowsUpdate($config)) {
return;
}
$apiKey = $config->api_key;
if (! is_string($apiKey) || $apiKey === '') {
return;
}
$service = new MailwizzService($apiKey);
$listUid = $config->list_uid;
$search = $service->searchSubscriber($listUid, $subscriber->email);
if ($search === null) {
return;
}
$subscriberUid = $search['subscriber_uid'];
/** @var array<string, mixed> $data */
$data = [];
$couponField = $config->field_coupon_code;
if (is_string($couponField) && $couponField !== '') {
$data[$couponField] = '';
}
$tagField = $config->tag_field;
$tagValue = $config->tag_value;
if ($tagField !== null && $tagField !== '' && $tagValue !== null && $tagValue !== '') {
$full = $service->getSubscriber($listUid, $subscriberUid);
if ($full === null) {
throw new RuntimeException('Mailwizz getSubscriber returned an empty payload.');
}
$existingCsv = MailwizzCheckboxlistTags::extractCsvFromSubscriberResponse($full, $tagField);
$data[$tagField] = MailwizzCheckboxlistTags::removeValueFromCsv($existingCsv, $tagValue);
}
if ($data === []) {
return;
}
$service->updateSubscriber($listUid, $subscriberUid, $data);
}
private function mailwizzConfigAllowsUpdate(MailwizzConfig $config): bool
{
if ($config->list_uid === '' || $config->field_email === '' || $config->field_first_name === '' || $config->field_last_name === '') {
return false;
}
$hasTagWork = $config->tag_field !== null && $config->tag_field !== ''
&& $config->tag_value !== null && $config->tag_value !== '';
$hasCouponField = is_string($config->field_coupon_code) && $config->field_coupon_code !== '';
return $hasTagWork || $hasCouponField;
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Services;
/**
* Parses and mutates Mailwizz checkboxlist-style tag fields (comma-separated in API, array on write).
*/
final class MailwizzCheckboxlistTags
{
/**
* @param array<string, mixed> $apiResponse getSubscriber JSON payload
*/
public static function extractCsvFromSubscriberResponse(array $apiResponse, string $tagField): string
{
$record = data_get($apiResponse, 'data.record');
if (! is_array($record)) {
$record = data_get($apiResponse, 'data');
}
if (! is_array($record)) {
return '';
}
$raw = $record[$tagField] ?? data_get($record, "fields.{$tagField}");
if (is_array($raw)) {
return implode(',', array_map(static fn (mixed $v): string => is_scalar($v) ? (string) $v : '', $raw));
}
return is_string($raw) ? $raw : '';
}
/**
* @return list<string>
*/
public static function mergeValueIntoCsv(string $existingCsv, string $newValue): array
{
$parts = array_filter(array_map('trim', explode(',', $existingCsv)), static fn (string $s): bool => $s !== '');
if (! in_array($newValue, $parts, true)) {
$parts[] = $newValue;
}
return array_values($parts);
}
/**
* @return list<string>
*/
public static function removeValueFromCsv(string $existingCsv, string $valueToRemove): array
{
$needle = trim($valueToRemove);
$parts = array_filter(array_map('trim', explode(',', $existingCsv)), static fn (string $s): bool => $s !== '');
if ($needle === '') {
return array_values($parts);
}
return array_values(array_filter(
$parts,
static fn (string $s): bool => $s !== $needle
));
}
}

View File

@@ -202,7 +202,8 @@ final class MailwizzService
$out = [];
foreach ($data as $key => $value) {
if (is_array($value)) {
$out[$key] = $value;
// Empty arrays are omitted by Laravel's multipart encoder (no KEY[] parts), so Mailwizz never clears checkboxlist fields.
$out[$key] = $value === [] ? '' : $value;
continue;
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\MailwizzConfig;
use App\Models\Subscriber;
/**
* Shared Mailwizz list subscriber field payload for create/update (excluding tag merge logic).
*/
final class MailwizzSubscriberFormPayload
{
/**
* @return array<string, mixed>
*/
public static function baseFields(Subscriber $subscriber, MailwizzConfig $config, bool $phoneEnabled): array
{
$data = [
$config->field_email => $subscriber->email,
$config->field_first_name => $subscriber->first_name,
$config->field_last_name => $subscriber->last_name,
];
if ($phoneEnabled && $config->field_phone !== null && $config->field_phone !== '') {
$phone = $subscriber->phoneDisplay();
if ($phone !== null && $phone !== '') {
$data[$config->field_phone] = $phone;
}
}
$couponField = $config->field_coupon_code;
if (is_string($couponField) && $couponField !== '' && $subscriber->coupon_code !== null && $subscriber->coupon_code !== '') {
$data[$couponField] = $subscriber->coupon_code;
}
return $data;
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Services;
use libphonenumber\NumberParseException;
use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberUtil;
/**
* Parses international and national phone input and normalizes to E.164 for storage (includes leading +).
*/
final class PhoneNumberNormalizer
{
public function __construct(
private readonly string $defaultRegion
) {}
/**
* Returns E.164 (e.g. +31612345678) or null if empty/invalid.
*/
public function normalizeToE164(?string $input): ?string
{
if ($input === null) {
return null;
}
$trimmed = trim($input);
if ($trimmed === '') {
return null;
}
$util = PhoneNumberUtil::getInstance();
try {
$number = $util->parse($trimmed, $this->defaultRegion);
} catch (NumberParseException) {
return null;
}
if (! $util->isValidNumber($number)) {
return null;
}
return $util->format($number, PhoneNumberFormat::E164);
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Jobs\IssueWeeztixCouponForSubscriber;
use App\Jobs\SyncSubscriberToMailwizz;
use App\Models\PreregistrationPage;
use App\Models\Subscriber;
use App\Models\WeeztixConfig;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Orchestrates public registration: local persist first, then queue external integrations
* so Weeztix/Mailwizz failures never prevent a subscriber row from being stored.
*/
final class RegisterSubscriberOnPage
{
/**
* @param array<string, mixed> $validated
*/
public function storeAndQueueIntegrations(PreregistrationPage $page, array $validated): Subscriber
{
$subscriber = $page->subscribers()->create($validated);
$page->loadMissing('weeztixConfig', 'mailwizzConfig');
$weeztix = $page->weeztixConfig;
try {
if ($this->weeztixCanIssueCodes($weeztix)) {
IssueWeeztixCouponForSubscriber::dispatch($subscriber)->afterResponse();
} elseif ($page->mailwizzConfig !== null) {
SyncSubscriberToMailwizz::dispatch($subscriber->fresh())->afterResponse();
}
} catch (Throwable $e) {
Log::error('RegisterSubscriberOnPage: could not queue integration jobs', [
'subscriber_id' => $subscriber->id,
'preregistration_page_id' => $page->id,
'message' => $e->getMessage(),
]);
}
return $subscriber;
}
private function weeztixCanIssueCodes(?WeeztixConfig $config): bool
{
if ($config === null || ! $config->is_connected) {
return false;
}
$company = $config->company_guid;
$coupon = $config->coupon_guid;
return is_string($company) && $company !== '' && is_string($coupon) && $coupon !== '';
}
}

View File

@@ -0,0 +1,737 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Exceptions\WeeztixCouponCodeConflictException;
use App\Models\WeeztixConfig;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
use LogicException;
use RuntimeException;
use Throwable;
final class WeeztixService
{
public function __construct(
private WeeztixConfig $config
) {}
/**
* Bearer token for API calls, refreshing from the stored refresh token when needed.
*/
public function getValidAccessToken(): string
{
if ($this->config->access_token && ! $this->config->isTokenExpired()) {
return (string) $this->config->access_token;
}
$this->refreshAccessToken();
$this->config->refresh();
$token = $this->config->access_token;
if (! is_string($token) || $token === '') {
throw new RuntimeException('Weeztix access token missing after refresh.');
}
return $token;
}
/**
* @return list<array{guid: string, name: string|null}>
*/
public function getCompanies(): array
{
$token = $this->getValidAccessToken();
$url = config('weeztix.user_profile_url');
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$token,
])
->acceptJson()
->get($url);
if ($response->status() === 401) {
$this->refreshAccessToken();
$this->config->refresh();
$response = Http::withHeaders([
'Authorization' => 'Bearer '.(string) $this->config->access_token,
])
->acceptJson()
->get($url);
}
if ($response->failed()) {
$this->logFailedResponse('getCompanies', $url, $response);
throw new RuntimeException('Weeztix user profile request failed: '.$response->status());
}
Log::debug('Weeztix API', [
'action' => 'getCompanies',
'url' => $url,
'http_status' => $response->status(),
]);
$json = $response->json();
if (! is_array($json)) {
return [];
}
return $this->normalizeCompaniesFromProfile($json);
}
/**
* Sync company_guid/name from the Weeztix user profile for the current access token (after OAuth).
* Runs on every successful connect or reconnect so a different company chosen in Weeztix is stored here.
* Uses the first company returned from the profile when several are present.
*/
public function ensureCompanyStoredFromWeeztix(): void
{
$this->config->refresh();
try {
$companies = $this->getCompanies();
if ($companies === []) {
Log::warning('Weeztix: geen bedrijf uit profiel voor automatische koppeling.', [
'weeztix_config_id' => $this->config->id,
]);
return;
}
$row = $companies[0];
$this->config->update([
'company_guid' => $row['guid'],
'company_name' => $row['name'],
]);
$this->config->refresh();
} catch (Throwable $e) {
Log::warning('Weeztix: automatisch bedrijf vastleggen mislukt', [
'weeztix_config_id' => $this->config->id,
'message' => $e->getMessage(),
]);
}
}
/**
* Exchange OAuth authorization code for tokens (admin callback).
*/
public function exchangeAuthorizationCode(string $code): void
{
$redirectUri = $this->config->redirect_uri;
if (! is_string($redirectUri) || $redirectUri === '') {
throw new LogicException('Weeztix redirect_uri is not set.');
}
$tokenUrl = config('weeztix.auth_base_url').'/tokens';
$response = Http::asForm()->post($tokenUrl, [
'grant_type' => 'authorization_code',
'client_id' => $this->config->client_id,
'client_secret' => $this->config->client_secret,
'redirect_uri' => $redirectUri,
'code' => $code,
]);
if ($response->failed()) {
Log::error('Weeztix OAuth code exchange failed', [
'url' => $tokenUrl,
'status' => $response->status(),
'body' => $response->json(),
]);
throw new RuntimeException('Weeztix OAuth code exchange failed: '.$response->status());
}
$json = $response->json();
if (! is_array($json)) {
throw new RuntimeException('Weeztix token response was not valid JSON.');
}
$this->applyTokenResponseToConfig($json);
$this->hydrateCompanyFromTokenInfo($json);
Log::debug('Weeztix API', [
'action' => 'oauth_authorization_code',
'url' => $tokenUrl,
'http_status' => $response->status(),
]);
}
/**
* @return array<string, mixed>
*/
public function getCoupons(): array
{
$this->assertCompanyGuid();
$url = config('weeztix.api_base_url').'/coupon';
return $this->apiRequest('get', $url, []);
}
/**
* Creates a single coupon code on the coupon selected in config.
*
* @return array<string, mixed>
*/
/**
* @return array<string, mixed>
*
* @throws WeeztixCouponCodeConflictException When the code already exists (HTTP 409).
*/
public function createCouponCode(string $code): array
{
$this->assertCompanyGuid();
$couponGuid = $this->config->coupon_guid;
if (! is_string($couponGuid) || $couponGuid === '') {
throw new LogicException('Weeztix coupon is not configured.');
}
$url = config('weeztix.api_base_url').'/coupon/'.$couponGuid.'/codes';
$payload = [
'usage_count' => $this->config->usage_count,
'applies_to_count' => null,
'codes' => [
['code' => $code],
],
];
$rateAttempts = 3;
for ($rateAttempt = 0; $rateAttempt < $rateAttempts; $rateAttempt++) {
$token = $this->getValidAccessToken();
$response = $this->sendApiRequest('put', $url, $payload, $token);
if ($response->status() === 401) {
$this->refreshAccessToken();
$this->config->refresh();
$response = $this->sendApiRequest('put', $url, $payload, (string) $this->config->access_token);
}
if ($response->status() === 429) {
$waitSeconds = min(8, 2 ** $rateAttempt);
Log::warning('Weeztix API rate limited', [
'url' => $url,
'retry_in_seconds' => $waitSeconds,
]);
sleep($waitSeconds);
continue;
}
if ($response->status() === 409) {
throw new WeeztixCouponCodeConflictException('Weeztix coupon code already exists.');
}
if ($response->failed()) {
$this->logFailedResponse('createCouponCode', $url, $response);
throw new RuntimeException('Weeztix API request failed: '.$response->status());
}
Log::debug('Weeztix API', [
'action' => 'createCouponCode',
'url' => $url,
'http_status' => $response->status(),
]);
$json = $response->json();
return is_array($json) ? $json : [];
}
Log::error('Weeztix API rate limited after retries', ['url' => $url]);
throw new RuntimeException('Weeztix API rate limited after retries.');
}
/**
* Lists coupon codes for the coupon selected in config (GET /coupon/{guid}/codes).
*
* @return list<array{guid: string, code: string}>
*/
public function listCouponCodesForConfiguredCoupon(): array
{
$this->assertCompanyGuid();
$couponGuid = $this->config->coupon_guid;
if (! is_string($couponGuid) || $couponGuid === '') {
throw new LogicException('Weeztix coupon is not configured.');
}
$url = config('weeztix.api_base_url').'/coupon/'.$couponGuid.'/codes';
$json = $this->apiRequest('get', $url, []);
return $this->normalizeCouponCodeListResponse($json);
}
/**
* Soft-deletes a coupon code in Weeztix by matching the human-readable code string.
*/
public function deleteCouponCodeByCodeString(string $code): void
{
$trimmed = trim($code);
if ($trimmed === '') {
return;
}
$this->assertCompanyGuid();
$couponGuid = $this->config->coupon_guid;
if (! is_string($couponGuid) || $couponGuid === '') {
throw new LogicException('Weeztix coupon is not configured.');
}
$rows = $this->listCouponCodesForConfiguredCoupon();
$codeGuid = null;
foreach ($rows as $row) {
if (strcasecmp($row['code'], $trimmed) === 0) {
$codeGuid = $row['guid'];
break;
}
}
if ($codeGuid === null) {
Log::info('Weeztix: coupon code not found when deleting (already removed or unknown)', [
'code' => $trimmed,
]);
return;
}
$url = config('weeztix.api_base_url').'/coupon/'.$couponGuid.'/codes/'.$codeGuid;
$token = $this->getValidAccessToken();
$response = $this->sendApiRequest('delete', $url, [], $token);
if ($response->status() === 401) {
$this->refreshAccessToken();
$this->config->refresh();
$response = $this->sendApiRequest('delete', $url, [], (string) $this->config->access_token);
}
if ($response->status() === 404) {
Log::info('Weeztix: coupon code already deleted remotely', [
'code' => $trimmed,
'code_guid' => $codeGuid,
]);
return;
}
if ($response->failed()) {
$this->logFailedResponse('deleteCouponCodeByCodeString', $url, $response);
throw new RuntimeException('Weeztix API delete coupon code failed: '.$response->status());
}
Log::debug('Weeztix API', [
'action' => 'deleteCouponCodeByCodeString',
'url' => $url,
'http_status' => $response->status(),
]);
}
/**
* @param array<string, mixed> $json
* @return list<array{guid: string, code: string}>
*/
private function normalizeCouponCodeListResponse(array $json): array
{
$candidates = [
data_get($json, 'data'),
data_get($json, 'data.codes'),
data_get($json, 'data.records'),
data_get($json, 'codes'),
$json,
];
foreach ($candidates as $raw) {
if (! is_array($raw)) {
continue;
}
if ($this->isListArray($raw)) {
$normalized = $this->normalizeCouponCodeRows($raw);
if ($normalized !== []) {
return $normalized;
}
}
}
return [];
}
/**
* @param array<int, mixed> $rows
* @return list<array{guid: string, code: string}>
*/
private function normalizeCouponCodeRows(array $rows): array
{
$out = [];
foreach ($rows as $row) {
if (! is_array($row)) {
continue;
}
$guid = data_get($row, 'guid')
?? data_get($row, 'id')
?? data_get($row, 'coupon_code_guid');
$code = data_get($row, 'code')
?? data_get($row, 'coupon_code')
?? data_get($row, 'name');
if (! is_string($guid) || $guid === '' || ! is_string($code) || $code === '') {
continue;
}
$out[] = ['guid' => $guid, 'code' => $code];
}
return $out;
}
/**
* @param array<int, mixed> $arr
*/
private function isListArray(array $arr): bool
{
if ($arr === []) {
return true;
}
return array_keys($arr) === range(0, count($arr) - 1);
}
public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string
{
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$code = '';
for ($i = 0; $i < $length; $i++) {
$code .= $chars[random_int(0, strlen($chars) - 1)];
}
return strtoupper($prefix).'-'.$code;
}
private function assertCompanyGuid(): void
{
$guid = $this->config->company_guid;
if (! is_string($guid) || $guid === '') {
throw new LogicException('Weeztix company is not configured.');
}
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function apiRequest(string $method, string $url, array $data = []): array
{
$this->assertCompanyGuid();
$token = $this->getValidAccessToken();
$response = $this->sendApiRequest($method, $url, $data, $token);
if ($response->status() === 401) {
$this->refreshAccessToken();
$this->config->refresh();
$response = $this->sendApiRequest($method, $url, $data, (string) $this->config->access_token);
}
if ($response->failed()) {
$this->logFailedResponse('apiRequest', $url, $response);
throw new RuntimeException('Weeztix API request failed: '.$response->status());
}
Log::debug('Weeztix API', [
'action' => 'apiRequest',
'method' => $method,
'url' => $url,
'http_status' => $response->status(),
]);
$json = $response->json();
return is_array($json) ? $json : [];
}
/**
* @param array<string, mixed> $data
*/
private function sendApiRequest(string $method, string $url, array $data, string $token): Response
{
$client = Http::withHeaders([
'Authorization' => 'Bearer '.$token,
'Company' => (string) $this->config->company_guid,
])->acceptJson();
return match (strtolower($method)) {
'get' => $client->get($url, $data),
'post' => $client->asJson()->post($url, $data),
'put' => $client->asJson()->put($url, $data),
'patch' => $client->asJson()->patch($url, $data),
'delete' => $client->delete($url, $data),
default => throw new InvalidArgumentException('Unsupported HTTP method: '.$method),
};
}
private function refreshAccessToken(): void
{
if (! $this->config->refresh_token || $this->config->isRefreshTokenExpired()) {
$this->config->update([
'is_connected' => false,
]);
$this->config->refresh();
throw new RuntimeException('Weeztix refresh token missing or expired; reconnect OAuth.');
}
$tokenUrl = config('weeztix.auth_base_url').'/tokens';
$response = Http::asForm()->post($tokenUrl, [
'grant_type' => 'refresh_token',
'refresh_token' => $this->config->refresh_token,
'client_id' => $this->config->client_id,
'client_secret' => $this->config->client_secret,
]);
if ($response->failed()) {
Log::error('Weeztix token refresh failed', [
'url' => $tokenUrl,
'status' => $response->status(),
'body' => $response->json(),
]);
$this->config->update([
'is_connected' => false,
]);
$this->config->refresh();
throw new RuntimeException('Weeztix token refresh failed: '.$response->status());
}
Log::debug('Weeztix API', [
'action' => 'refresh_token',
'url' => $tokenUrl,
'http_status' => $response->status(),
]);
$json = $response->json();
if (! is_array($json)) {
throw new RuntimeException('Weeztix token refresh returned invalid JSON.');
}
$this->applyTokenResponseToConfig($json);
}
/**
* @param array<string, mixed> $json
*/
private function applyTokenResponseToConfig(array $json): void
{
$access = $json['access_token'] ?? null;
$refresh = $json['refresh_token'] ?? null;
if (! is_string($access) || $access === '') {
throw new RuntimeException('Weeztix token response missing access_token.');
}
$expiresIn = isset($json['expires_in']) ? (int) $json['expires_in'] : 0;
$refreshExpiresIn = isset($json['refresh_token_expires_in']) ? (int) $json['refresh_token_expires_in'] : 0;
$updates = [
'access_token' => $access,
'token_expires_at' => $expiresIn > 0 ? Carbon::now()->addSeconds($expiresIn) : null,
'is_connected' => true,
];
if (is_string($refresh) && $refresh !== '') {
$updates['refresh_token'] = $refresh;
$updates['refresh_token_expires_at'] = $refreshExpiresIn > 0
? Carbon::now()->addSeconds($refreshExpiresIn)
: null;
}
$this->config->update($updates);
$this->config->refresh();
}
/**
* When the token response includes exactly one company, store it to reduce admin steps.
*
* @param array<string, mixed> $json
*/
private function hydrateCompanyFromTokenInfo(array $json): void
{
$companies = data_get($json, 'info.companies');
if (! is_array($companies) || count($companies) !== 1) {
return;
}
$row = $companies[0];
if (! is_array($row)) {
return;
}
$merged = $this->mergeCompanyRowWithNested($row);
$guid = data_get($merged, 'guid') ?? data_get($merged, 'id');
if (! is_string($guid) || $guid === '') {
return;
}
$name = $this->resolveCompanyNameFromRow($merged, $guid);
$this->config->update([
'company_guid' => $guid,
'company_name' => $name,
]);
$this->config->refresh();
}
/**
* @param array<string, mixed> $profile
* @return list<array{guid: string, name: string|null}>
*/
private function normalizeCompaniesFromProfile(array $profile): array
{
$fromInfo = data_get($profile, 'info.companies');
if (is_array($fromInfo) && $fromInfo !== []) {
$normalized = $this->normalizeCompanyRows($fromInfo);
if ($normalized !== []) {
return $normalized;
}
}
$companies = data_get($profile, 'companies');
if (is_array($companies) && $companies !== []) {
return $this->normalizeCompanyRows($companies);
}
$defaultCompany = data_get($profile, 'default_company');
if (is_array($defaultCompany)) {
$merged = $this->mergeCompanyRowWithNested($defaultCompany);
$guid = data_get($merged, 'guid') ?? data_get($merged, 'id');
if (is_string($guid) && $guid !== '') {
return [
[
'guid' => $guid,
'name' => $this->resolveCompanyNameFromRow($merged, $guid),
],
];
}
}
$defaultId = data_get($profile, 'default_company_id');
if (is_string($defaultId) && $defaultId !== '') {
return [
['guid' => $defaultId, 'name' => null],
];
}
return [];
}
/**
* @param array<int, mixed> $rows
* @return list<array{guid: string, name: string|null}>
*/
private function normalizeCompanyRows(array $rows): array
{
$out = [];
foreach ($rows as $row) {
if (! is_array($row)) {
continue;
}
$merged = $this->mergeCompanyRowWithNested($row);
$guid = data_get($merged, 'guid')
?? data_get($merged, 'id')
?? data_get($merged, 'company_id');
if (! is_string($guid) || $guid === '') {
continue;
}
$out[] = [
'guid' => $guid,
'name' => $this->resolveCompanyNameFromRow($merged, $guid),
];
}
return $out;
}
/**
* Flatten `{ "company": { ... } }` style payloads so name fields resolve reliably.
*
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function mergeCompanyRowWithNested(array $row): array
{
$nested = data_get($row, 'company');
if (! is_array($nested)) {
return $row;
}
return array_merge($row, $nested);
}
/**
* Weeztix / Open Ticket payloads use varying keys; `name` is sometimes a duplicate of the GUID.
*
* @param array<string, mixed> $row
*/
private function resolveCompanyNameFromRow(array $row, ?string $companyGuid = null): ?string
{
$candidates = [
data_get($row, 'trade_name'),
data_get($row, 'commercial_name'),
data_get($row, 'business_name'),
data_get($row, 'legal_name'),
data_get($row, 'company_name'),
data_get($row, 'display_name'),
data_get($row, 'title'),
data_get($row, 'label'),
data_get($row, 'general.name'),
data_get($row, 'company.trade_name'),
data_get($row, 'company.legal_name'),
data_get($row, 'company.name'),
data_get($row, 'name'),
];
foreach ($candidates as $value) {
if (! is_string($value)) {
continue;
}
$trimmed = trim($value);
if ($trimmed === '') {
continue;
}
if ($companyGuid !== null && strcasecmp($trimmed, $companyGuid) === 0) {
continue;
}
if ($this->stringLooksLikeUuid($trimmed)) {
continue;
}
return $trimmed;
}
return null;
}
private function stringLooksLikeUuid(string $value): bool
{
return preg_match(
'/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/',
$value
) === 1;
}
private function logFailedResponse(string $action, string $url, Response $response): void
{
Log::error('Weeztix API request failed', [
'action' => $action,
'url' => $url,
'status' => $response->status(),
'body' => $response->json(),
]);
}
}

View File

@@ -7,6 +7,7 @@
"license": "MIT",
"require": {
"php": "^8.3",
"giggsey/libphonenumber-for-php": "^9.0",
"laravel/framework": "^13.0",
"laravel/tinker": "^3.0"
},

136
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "0cbc4dc77a1eeff75f257f6ffae9168b",
"content-hash": "505a0bb04eb0eb77eddad8d9e0ef372b",
"packages": [
{
"name": "brick/math",
@@ -579,6 +579,140 @@
],
"time": "2025-12-03T09:33:47+00:00"
},
{
"name": "giggsey/libphonenumber-for-php",
"version": "9.0.27",
"source": {
"type": "git",
"url": "https://github.com/giggsey/libphonenumber-for-php.git",
"reference": "7973753b3efe38fb57dc949a6014a4d1cfce0ffd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/giggsey/libphonenumber-for-php/zipball/7973753b3efe38fb57dc949a6014a4d1cfce0ffd",
"reference": "7973753b3efe38fb57dc949a6014a4d1cfce0ffd",
"shasum": ""
},
"require": {
"giggsey/locale": "^2.7",
"php": "^8.1",
"symfony/polyfill-mbstring": "^1.31"
},
"replace": {
"giggsey/libphonenumber-for-php-lite": "self.version"
},
"require-dev": {
"ext-dom": "*",
"friendsofphp/php-cs-fixer": "^3.71",
"infection/infection": "^0.29|^0.31.0",
"nette/php-generator": "^4.1",
"php-coveralls/php-coveralls": "^2.7",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^2.1.7",
"phpstan/phpstan-deprecation-rules": "^2.0.1",
"phpstan/phpstan-phpunit": "^2.0.4",
"phpstan/phpstan-strict-rules": "^2.0.3",
"phpunit/phpunit": "^10.5.45",
"symfony/console": "^6.4",
"symfony/filesystem": "^6.4",
"symfony/process": "^6.4"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "9.x-dev"
}
},
"autoload": {
"psr-4": {
"libphonenumber\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Joshua Gigg",
"email": "giggsey@gmail.com",
"homepage": "https://giggsey.com/"
}
],
"description": "A library for parsing, formatting, storing and validating international phone numbers, a PHP Port of Google's libphonenumber.",
"homepage": "https://github.com/giggsey/libphonenumber-for-php",
"keywords": [
"geocoding",
"geolocation",
"libphonenumber",
"mobile",
"phonenumber",
"validation"
],
"support": {
"issues": "https://github.com/giggsey/libphonenumber-for-php/issues",
"source": "https://github.com/giggsey/libphonenumber-for-php"
},
"time": "2026-04-01T12:18:23+00:00"
},
{
"name": "giggsey/locale",
"version": "2.9.0",
"source": {
"type": "git",
"url": "https://github.com/giggsey/Locale.git",
"reference": "fe741e99ae6ccbe8132f3d63d8ec89924e689778"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/giggsey/Locale/zipball/fe741e99ae6ccbe8132f3d63d8ec89924e689778",
"reference": "fe741e99ae6ccbe8132f3d63d8ec89924e689778",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"ext-json": "*",
"friendsofphp/php-cs-fixer": "^3.66",
"infection/infection": "^0.29|^0.32.0",
"php-coveralls/php-coveralls": "^2.7",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^2.1.7",
"phpstan/phpstan-deprecation-rules": "^2.0.1",
"phpstan/phpstan-phpunit": "^2.0.4",
"phpstan/phpstan-strict-rules": "^2.0.3",
"phpunit/phpunit": "^10.5.45",
"symfony/console": "^6.4",
"symfony/filesystem": "^6.4",
"symfony/finder": "^6.4",
"symfony/process": "^6.4",
"symfony/var-exporter": "^6.4"
},
"type": "library",
"autoload": {
"psr-4": {
"Giggsey\\Locale\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Joshua Gigg",
"email": "giggsey@gmail.com",
"homepage": "https://giggsey.com/"
}
],
"description": "Locale functions required by libphonenumber-for-php",
"support": {
"issues": "https://github.com/giggsey/Locale/issues",
"source": "https://github.com/giggsey/Locale/tree/2.9.0"
},
"time": "2026-02-24T15:32:13+00:00"
},
{
"name": "graham-campbell/result-type",
"version": "v1.1.4",

View File

@@ -7,6 +7,18 @@ $defaultPerMinute = in_array($env, ['local', 'testing'], true) ? 1000 : 60;
return [
/*
|--------------------------------------------------------------------------
| Default phone region (ISO 3166-1 alpha-2)
|--------------------------------------------------------------------------
|
| Used when parsing numbers without a country prefix (e.g. national format).
| Override with PREREGISTER_DEFAULT_PHONE_REGION in .env.
|
*/
'default_phone_region' => strtoupper((string) env('PREREGISTER_DEFAULT_PHONE_REGION', 'NL')),
/*
|--------------------------------------------------------------------------
| Public routes rate limit

57
config/weeztix.php Normal file
View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| OAuth login / authorization (browser redirect)
|--------------------------------------------------------------------------
|
| Official Weeztix: users must be sent to login.weeztix.com with client_id,
| redirect_uri, response_type=code, and state. Do NOT use auth.../tokens/authorize
| unless your OAuth provider documents that path (e.g. some Open Ticket setups).
|
| Open Ticket example (if your client was created there):
| WEEZTIX_OAUTH_AUTHORIZE_URL=https://auth.openticket.tech/tokens/authorize
| WEEZTIX_AUTH_BASE_URL=https://auth.openticket.tech
|
*/
'oauth_authorize_url' => rtrim((string) env(
'WEEZTIX_OAUTH_AUTHORIZE_URL',
'https://login.weeztix.com/login'
), '/'),
/*
|--------------------------------------------------------------------------
| Token endpoint base (authorization code + refresh)
|--------------------------------------------------------------------------
|
| POST {auth_base_url}/tokens official Weeztix: https://auth.weeztix.com/tokens
|
*/
'auth_base_url' => rtrim((string) env('WEEZTIX_AUTH_BASE_URL', 'https://auth.weeztix.com'), '/'),
/*
|--------------------------------------------------------------------------
| Weeztix API base URL
|--------------------------------------------------------------------------
*/
'api_base_url' => rtrim((string) env('WEEZTIX_API_BASE_URL', 'https://api.weeztix.com'), '/'),
/*
|--------------------------------------------------------------------------
| Current user profile (token validity + company hints)
|--------------------------------------------------------------------------
|
| Should match the issuer of your access_token (usually same host as auth_base_url).
|
*/
'user_profile_url' => (string) env('WEEZTIX_USER_PROFILE_URL', 'https://auth.weeztix.com/users/me'),
];

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('preregistration_pages', function (Blueprint $table): void {
$table->boolean('background_fixed')->default(false)->after('background_overlay_opacity');
});
}
public function down(): void
{
Schema::table('preregistration_pages', function (Blueprint $table): void {
$table->dropColumn('background_fixed');
});
}
};

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
use App\Models\Subscriber;
use App\Services\PhoneNumberNormalizer;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('subscribers')) {
return;
}
/** @var PhoneNumberNormalizer $normalizer */
$normalizer = app(PhoneNumberNormalizer::class);
Subscriber::query()
->whereNotNull('phone')
->where('phone', '!=', '')
->orderBy('id')
->chunkById(100, function ($subscribers) use ($normalizer): void {
foreach ($subscribers as $subscriber) {
$p = $subscriber->phone;
if (! is_string($p) || $p === '') {
continue;
}
if (str_starts_with($p, '+')) {
$normalized = $normalizer->normalizeToE164($p);
if ($normalized !== null && $normalized !== $p) {
$subscriber->update(['phone' => $normalized]);
}
continue;
}
if (preg_match('/^\d{8,15}$/', $p) !== 1) {
continue;
}
$normalized = $normalizer->normalizeToE164('+'.$p);
if ($normalized !== null) {
$subscriber->update(['phone' => $normalized]);
}
}
});
}
public function down(): void
{
// Irreversible: we cannot recover original user input formatting.
}
};

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('weeztix_configs', function (Blueprint $table) {
$table->id();
$table->foreignId('preregistration_page_id')->constrained()->cascadeOnDelete();
$table->text('client_id');
$table->text('client_secret');
$table->string('redirect_uri');
$table->text('access_token')->nullable();
$table->text('refresh_token')->nullable();
$table->timestamp('token_expires_at')->nullable();
$table->timestamp('refresh_token_expires_at')->nullable();
$table->string('company_guid')->nullable();
$table->string('company_name')->nullable();
$table->string('coupon_guid')->nullable();
$table->string('coupon_name')->nullable();
$table->string('code_prefix')->default('PREREG');
$table->integer('usage_count')->default(1);
$table->boolean('is_connected')->default(false);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('weeztix_configs');
}
};

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('subscribers', function (Blueprint $table) {
$table->string('coupon_code')->nullable()->after('synced_at');
});
}
public function down(): void
{
Schema::table('subscribers', function (Blueprint $table) {
$table->dropColumn('coupon_code');
});
}
};

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('mailwizz_configs', function (Blueprint $table) {
$table->string('field_coupon_code')->nullable()->after('field_phone');
});
}
public function down(): void
{
Schema::table('mailwizz_configs', function (Blueprint $table) {
$table->dropColumn('field_coupon_code');
});
}
};

View File

@@ -9,10 +9,15 @@ set -e
# ./deploy.sh v1.2.0 → deploys specific tag
# ──────────────────────────────────────────
# !! UPDATE THIS PATH TO YOUR VPS DIRECTORY !!
APP_DIR="/home/hausdesign/domains/preregister.hausdesign.nl/public_html"
APP_DIR="/home/hausdesign/preregister"
PHP="/usr/local/php84/bin/php"
COMPOSER="/usr/local/bin/composer"
TAG="${1:-}"
# Load fnm (Node version manager)
export PATH="$HOME/.local/share/fnm:$PATH"
eval "$(fnm env)"
echo "══════════════════════════════════════"
echo " PreRegister — Deploy"
echo "══════════════════════════════════════"
@@ -21,7 +26,7 @@ cd "$APP_DIR"
# 1. Maintenance mode
echo "→ Enabling maintenance mode..."
php artisan down --retry=30 || true
$PHP artisan down --retry=30 || true
# 2. Pull latest code
echo "→ Pulling from Gitea..."
@@ -38,7 +43,7 @@ fi
# 3. Install PHP dependencies
echo "→ Installing Composer dependencies..."
composer install --no-dev --optimize-autoloader --no-interaction
$PHP $COMPOSER install --no-dev --optimize-autoloader --no-interaction
# 4. Install Node dependencies and build
echo "→ Installing npm packages..."
@@ -49,25 +54,25 @@ npm run build
# 5. Run migrations
echo "→ Running migrations..."
php artisan migrate --force
$PHP artisan migrate --force
# 6. Clear and rebuild caches
echo "→ Clearing caches..."
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
$PHP artisan config:cache
$PHP artisan route:cache
$PHP artisan view:cache
$PHP artisan event:cache
# 7. Restart queue (process any pending jobs with new code)
echo "→ Restarting queue workers..."
php artisan queue:restart
$PHP artisan queue:restart
# 8. Storage link (idempotent)
php artisan storage:link 2>/dev/null || true
$PHP artisan storage:link 2>/dev/null || true
# 9. Disable maintenance mode
echo "→ Going live!"
php artisan up
$PHP artisan up
echo ""
echo "══════════════════════════════════════"

View File

@@ -0,0 +1,709 @@
# Cursor Prompt — Weeztix Coupon Code Integration
> Paste this into Cursor chat with `@Codebase` and `@PreRegister-Development-Prompt.md` as context.
---
## Role & Context
You are a senior full-stack developer and integration architect. You're working on the PreRegister Laravel 11 application (Blade + Tailwind CSS + Alpine.js). No React, no Vue, no Livewire.
The application already has a Mailwizz integration that syncs subscribers to an email marketing platform. You are now adding a **second integration: Weeztix** — a ticket sales platform. When a visitor pre-registers on a page, a unique coupon code is generated via the Weeztix API and assigned to the subscriber. This coupon code can then be forwarded to Mailwizz so the subscriber receives a personalized discount email.
---
## Part 1: Weeztix Platform Concepts
### Coupon vs CouponCode
Weeztix separates these into two resources:
- **Coupon** = a template/definition. Defines the discount type (percentage, fixed amount), value, what it applies to (orders or products). The Coupon is configured by the event organizer in Weeztix dashboard.
- **CouponCode** = the actual code a visitor enters in the ticket shop to get the discount. Each CouponCode belongs to a Coupon and inherits its discount settings. CouponCodes are unique strings like `PREREG-A7X9K2`.
**In our flow:** The user selects an existing Coupon in the backend. When a visitor registers, we create a unique CouponCode under that Coupon via the API.
### Authentication: OAuth2 Authorization Code Grant
Weeztix uses the OAuth2 Authorization Code flow. Key details:
**Endpoints:**
| Action | Method | URL |
|---|---|---|
| Authorize (redirect user) | GET | `https://auth.openticket.tech/tokens/authorize` |
| Exchange code for token | POST | `https://auth.openticket.tech/tokens` |
| Refresh token | POST | `https://auth.openticket.tech/tokens` |
| API requests | Various | `https://api.weeztix.com/...` |
**Authorization redirect parameters:**
```
https://auth.openticket.tech/tokens/authorize?
client_id={OAUTH_CLIENT_ID}
&redirect_uri={OAUTH_CLIENT_REDIRECT}
&response_type=code
&state={random_state}
```
**Token exchange (POST to `https://auth.openticket.tech/tokens`):**
```json
{
"grant_type": "authorization_code",
"client_id": "...",
"client_secret": "...",
"redirect_uri": "...",
"code": "..."
}
```
**Token response:**
```json
{
"token_type": "Bearer",
"expires_in": 259200,
"access_token": "THE_ACTUAL_TOKEN",
"refresh_token": "REFRESH_TOKEN",
"refresh_token_expires_in": 31535999
}
```
- `access_token` expires in ~3 days (259200 seconds)
- `refresh_token` expires in ~365 days, can only be used once
**Refresh token (POST to `https://auth.openticket.tech/tokens`):**
```json
{
"grant_type": "refresh_token",
"refresh_token": "...",
"client_id": "...",
"client_secret": "..."
}
```
Returns a new `access_token` and a new `refresh_token`.
**API requests require:**
- Header: `Authorization: Bearer {access_token}`
- Header: `Company: {company_guid}` (to scope requests to a specific company)
### API Endpoints We Need
| Action | Method | URL | Notes |
|---|---|---|---|
| Get coupons | GET | `https://api.weeztix.com/coupon` | Returns coupons for the company (Company header) |
| Add coupon codes | POST | `https://api.weeztix.com/coupon/{coupon_guid}/couponCode` | Creates one or more codes under a coupon |
**Add CouponCodes request body:**
```json
{
"usage_count": 1,
"applies_to_count": null,
"codes": [
{
"code": "PREREG-A7X9K2"
}
]
}
```
- `usage_count`: how many times the code can be used (1 = single use per subscriber)
- `applies_to_count`: null = unlimited items in the order can use the discount
- `codes`: array of code objects, each with a unique `code` string
**Important:** Duplicate CouponCodes cannot be added to a Coupon. Generate unique codes.
---
## Part 2: Database Changes
### New Migration: `weeztix_configs` table
```php
Schema::create('weeztix_configs', function (Blueprint $table) {
$table->id();
$table->foreignId('preregistration_page_id')->constrained()->cascadeOnDelete();
// OAuth credentials (encrypted at rest)
$table->text('client_id');
$table->text('client_secret');
$table->string('redirect_uri');
// OAuth tokens (encrypted at rest)
$table->text('access_token')->nullable();
$table->text('refresh_token')->nullable();
$table->timestamp('token_expires_at')->nullable();
$table->timestamp('refresh_token_expires_at')->nullable();
// Company context
$table->string('company_guid')->nullable();
$table->string('company_name')->nullable();
// Selected coupon
$table->string('coupon_guid')->nullable();
$table->string('coupon_name')->nullable();
// CouponCode settings
$table->string('code_prefix')->default('PREREG'); // prefix for generated codes
$table->integer('usage_count')->default(1); // how many times a code can be used
$table->boolean('is_connected')->default(false); // OAuth flow completed?
$table->timestamps();
});
```
### Modify `subscribers` table
Add a column to store the generated coupon code per subscriber:
```php
Schema::table('subscribers', function (Blueprint $table) {
$table->string('coupon_code')->nullable()->after('synced_at');
});
```
### Model: `WeeztixConfig`
```php
class WeeztixConfig extends Model
{
protected $fillable = [
'preregistration_page_id', 'client_id', 'client_secret', 'redirect_uri',
'access_token', 'refresh_token', 'token_expires_at', 'refresh_token_expires_at',
'company_guid', 'company_name', 'coupon_guid', 'coupon_name',
'code_prefix', 'usage_count', 'is_connected',
];
protected $casts = [
'client_id' => 'encrypted',
'client_secret' => 'encrypted',
'access_token' => 'encrypted',
'refresh_token' => 'encrypted',
'token_expires_at' => 'datetime',
'refresh_token_expires_at' => 'datetime',
'is_connected' => 'boolean',
];
public function preregistrationPage(): BelongsTo
{
return $this->belongsTo(PreregistrationPage::class);
}
public function isTokenExpired(): bool
{
return !$this->token_expires_at || $this->token_expires_at->isPast();
}
public function isRefreshTokenExpired(): bool
{
return !$this->refresh_token_expires_at || $this->refresh_token_expires_at->isPast();
}
}
```
### Update `PreregistrationPage` model
Add:
```php
public function weeztixConfig(): HasOne
{
return $this->hasOne(WeeztixConfig::class);
}
```
---
## Part 3: WeeztixService
Create `app/Services/WeeztixService.php` — encapsulates all Weeztix API communication with automatic token refresh.
```php
class WeeztixService
{
private WeeztixConfig $config;
public function __construct(WeeztixConfig $config)
{
$this->config = $config;
}
/**
* Get a valid access token, refreshing if necessary.
* Updates the config model with new tokens.
*/
public function getValidAccessToken(): string
/**
* Refresh the access token using the refresh token.
* Stores the new tokens in the config.
* Throws exception if refresh token is also expired (re-auth needed).
*/
private function refreshAccessToken(): void
/**
* Make an authenticated API request.
* Automatically refreshes token if expired.
*/
private function apiRequest(string $method, string $url, array $data = []): array
/**
* Get all companies the token has access to.
* GET https://auth.weeztix.com/users/me (or similar endpoint)
*/
public function getCompanies(): array
/**
* Get all coupons for the configured company.
* GET https://api.weeztix.com/coupon
* Header: Company: {company_guid}
*/
public function getCoupons(): array
/**
* Create a unique coupon code under the configured coupon.
* POST https://api.weeztix.com/coupon/{coupon_guid}/couponCode
* Header: Company: {company_guid}
*/
public function createCouponCode(string $code): array
/**
* Generate a unique code string.
* Format: {prefix}-{random alphanumeric}
* Example: PREREG-A7X9K2
*/
public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string
}
```
### Key Implementation Details for WeeztixService
**Token refresh logic:**
```
1. Check if access_token exists and is not expired
2. If expired, check if refresh_token exists and is not expired
3. If refresh_token valid: POST to https://auth.openticket.tech/tokens with grant_type=refresh_token
4. Store new access_token, refresh_token, and their expiry timestamps
5. If refresh_token also expired: mark config as disconnected, throw exception (user must re-authorize)
```
**API request wrapper:**
```php
private function apiRequest(string $method, string $url, array $data = []): array
{
$token = $this->getValidAccessToken();
$response = Http::withHeaders([
'Authorization' => "Bearer {$token}",
'Company' => $this->config->company_guid,
])->{$method}($url, $data);
if ($response->status() === 401) {
// Token might have been revoked, try refresh once
$this->refreshAccessToken();
$token = $this->config->access_token;
$response = Http::withHeaders([
'Authorization' => "Bearer {$token}",
'Company' => $this->config->company_guid,
])->{$method}($url, $data);
}
if ($response->failed()) {
Log::error('Weeztix API request failed', [
'url' => $url,
'status' => $response->status(),
'body' => $response->json(),
]);
throw new \RuntimeException("Weeztix API request failed: {$response->status()}");
}
return $response->json();
}
```
**Unique code generation:**
```php
public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string
{
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O/1/I to avoid confusion
$code = '';
for ($i = 0; $i < $length; $i++) {
$code .= $chars[random_int(0, strlen($chars) - 1)];
}
return strtoupper($prefix) . '-' . $code;
}
```
---
## Part 4: OAuth Flow — Routes & Controller
### Routes
```php
// Inside the admin auth middleware group:
// Weeztix configuration (per page)
Route::get('pages/{page}/weeztix', [WeeztixController::class, 'edit'])->name('pages.weeztix.edit');
Route::put('pages/{page}/weeztix', [WeeztixController::class, 'update'])->name('pages.weeztix.update');
Route::delete('pages/{page}/weeztix', [WeeztixController::class, 'destroy'])->name('pages.weeztix.destroy');
// OAuth callback (needs to be accessible during OAuth flow)
Route::get('weeztix/callback', [WeeztixOAuthController::class, 'callback'])->name('weeztix.callback');
// AJAX endpoints for dynamic loading
Route::post('weeztix/coupons', [WeeztixApiController::class, 'coupons'])->name('weeztix.coupons');
```
### OAuth Flow
**Step 1: User enters client_id and client_secret in the backend form.**
**Step 2: User clicks "Connect to Weeztix" button.**
The controller builds the authorization URL and redirects:
```php
public function redirect(PreregistrationPage $page)
{
$config = $page->weeztixConfig;
$state = Str::random(40);
// Store state + page ID in session for the callback
session(['weeztix_oauth_state' => $state, 'weeztix_page_id' => $page->id]);
$query = http_build_query([
'client_id' => $config->client_id,
'redirect_uri' => route('admin.weeztix.callback'),
'response_type' => 'code',
'state' => $state,
]);
return redirect("https://auth.openticket.tech/tokens/authorize?{$query}");
}
```
**Step 3: Weeztix redirects back to our callback URL with `code` and `state`.**
```php
public function callback(Request $request)
{
// Verify state
$storedState = session('weeztix_oauth_state');
$pageId = session('weeztix_page_id');
abort_if($request->state !== $storedState, 403, 'Invalid state');
// Exchange code for tokens
$response = Http::post('https://auth.openticket.tech/tokens', [
'grant_type' => 'authorization_code',
'client_id' => $config->client_id,
'client_secret' => $config->client_secret,
'redirect_uri' => route('admin.weeztix.callback'),
'code' => $request->code,
]);
// Store tokens in weeztix_configs
$config->update([
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'],
'token_expires_at' => now()->addSeconds($data['expires_in']),
'refresh_token_expires_at' => now()->addSeconds($data['refresh_token_expires_in']),
'is_connected' => true,
]);
// Redirect back to the Weeztix config page
return redirect()->route('admin.pages.weeztix.edit', $pageId)
->with('success', 'Successfully connected to Weeztix!');
}
```
**Step 4: After OAuth, the user selects a Company and Coupon (see Part 5).**
---
## Part 5: Backend Configuration UI
### Weeztix Configuration Page (tab/section within page edit)
Build a multi-step configuration interface similar to the Mailwizz config:
```
┌─── Weeztix Integration ────────────────────────────────┐
│ │
│ Step 1: OAuth Credentials │
│ ┌────────────────────────────────────────────────┐ │
│ │ Client ID: [________________________] │ │
│ │ Client Secret: [________________________] │ │
│ │ Redirect URI: https://preregister.crewli.nl │ │
│ │ /admin/weeztix/callback │ │
│ │ (auto-generated, read-only) │ │
│ │ │ │
│ │ [Connect to Weeztix] ← OAuth redirect button │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ Step 2: Select Coupon (shown after OAuth success) │
│ ┌────────────────────────────────────────────────┐ │
│ │ Status: ✅ Connected │ │
│ │ Token expires: 2026-04-07 14:30 │ │
│ │ │ │
│ │ Coupon: [▼ Select a coupon ] │ │
│ │ - Early Bird 20% discount │ │
│ │ - Pre-register €5 korting │ │
│ │ │ │
│ │ Code Prefix: [PREREG ] │ │
│ │ Usage per Code: [1] │ │
│ │ │ │
│ │ [Save Configuration] │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ [Disconnect Weeztix] │
└─────────────────────────────────────────────────────────┘
```
**Instructions to show the user:**
> "Before connecting, create an OAuth Client in your Weeztix dashboard and set the redirect URI to: `{route('admin.weeztix.callback')}`. Also create a Coupon with the desired discount settings. You will select this Coupon here after connecting."
**Loading Coupons (AJAX):**
After successful OAuth, use Alpine.js to fetch coupons via:
```
POST /admin/weeztix/coupons
Body: { page_id: ... }
```
The controller uses `WeeztixService::getCoupons()` and returns the list as JSON.
---
## Part 6: Frontend — Coupon Code Generation on Registration
### Updated Subscription Flow
When a visitor registers on a public page:
```
1. Validate form input
2. Check for duplicate email
3. Store subscriber in database
4. IF Weeztix is configured AND connected:
a. Generate a unique coupon code: PREREG-A7X9K2
b. Create the coupon code in Weeztix via API
c. Store the coupon code on the subscriber record
5. IF Mailwizz is configured:
a. Dispatch SyncSubscriberToMailwizz job
b. The job should include the coupon_code in the Mailwizz subscriber data
(if a Mailwizz field mapping for coupon_code is configured)
6. Return success with thank-you message
```
### Job: `CreateWeeztixCouponCode`
Create a queued job (or execute synchronously if speed is critical — the visitor should see the coupon code in the thank-you message):
```php
class CreateWeeztixCouponCode implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
private Subscriber $subscriber
) {}
public function handle(): void
{
$page = $this->subscriber->preregistrationPage;
$config = $page->weeztixConfig;
if (!$config || !$config->is_connected || !$config->coupon_guid) {
return;
}
$service = new WeeztixService($config);
$code = WeeztixService::generateUniqueCode($config->code_prefix);
try {
$service->createCouponCode($code);
$this->subscriber->update(['coupon_code' => $code]);
} catch (\Exception $e) {
Log::error('Failed to create Weeztix coupon code', [
'subscriber_id' => $this->subscriber->id,
'error' => $e->getMessage(),
]);
throw $e; // Let the job retry
}
}
}
```
**IMPORTANT DECISION: Sync vs Async**
If the coupon code should be shown in the thank-you message immediately after registration, the Weeztix API call must be **synchronous** (not queued). In that case, call the service directly in `PublicPageController@subscribe` instead of dispatching a job:
```php
// In PublicPageController@subscribe, after storing the subscriber:
if ($page->weeztixConfig && $page->weeztixConfig->is_connected) {
try {
$service = new WeeztixService($page->weeztixConfig);
$code = WeeztixService::generateUniqueCode($page->weeztixConfig->code_prefix);
$service->createCouponCode($code);
$subscriber->update(['coupon_code' => $code]);
} catch (\Exception $e) {
Log::error('Weeztix coupon creation failed', ['error' => $e->getMessage()]);
// Don't fail the registration — the subscriber is already saved
}
}
// Then dispatch the Mailwizz sync job (which includes the coupon_code)
if ($page->mailwizzConfig) {
SyncSubscriberToMailwizz::dispatch($subscriber->fresh());
}
return response()->json([
'success' => true,
'message' => $page->thank_you_message ?? 'Bedankt voor je registratie!',
'coupon_code' => $subscriber->coupon_code, // null if Weeztix not configured
]);
```
### Show Coupon Code in Thank-You State
Update the Alpine.js success state to display the coupon code if present:
```html
<div x-show="submitted" class="text-center text-white">
<p x-text="successMessage"></p>
<template x-if="couponCode">
<div class="mt-6 bg-white/10 backdrop-blur rounded-xl p-6 border border-white/20">
<p class="text-sm text-white/70 mb-2">Jouw kortingscode:</p>
<div class="flex items-center justify-center gap-3">
<span class="text-2xl font-mono font-bold tracking-wider text-orange-400"
x-text="couponCode"></span>
<button @click="copyCode()"
class="text-white/50 hover:text-white transition">
<!-- Copy icon (Heroicon clipboard) -->
<svg>...</svg>
</button>
</div>
<p class="text-xs text-white/50 mt-3">
Gebruik deze code bij het afrekenen in de ticketshop.
</p>
</div>
</template>
</div>
```
---
## Part 7: Mailwizz Integration — Forward Coupon Code
### Add Coupon Code Field Mapping to Mailwizz Config
Add a new field to `mailwizz_configs`:
```php
// New migration
Schema::table('mailwizz_configs', function (Blueprint $table) {
$table->string('field_coupon_code')->nullable()->after('field_phone');
// This maps to a Mailwizz custom field (e.g., 'COUPON' tag)
});
```
### Update Mailwizz Configuration Wizard
In the field mapping step, add an additional mapping:
- **Coupon Code** → show text fields from the Mailwizz list → let user select the field where the coupon code should be stored (e.g., a custom field with tag `COUPON`)
- This mapping is optional — only shown if Weeztix is also configured
### Update SyncSubscriberToMailwizz Job
When building the Mailwizz subscriber data array, include the coupon code if the mapping exists:
```php
// In the sync job, when building the data array:
if ($config->field_coupon_code && $subscriber->coupon_code) {
$data[$config->field_coupon_code] = $subscriber->coupon_code;
}
```
This allows the Mailwizz email template to include `[COUPON]` as a merge tag, so each subscriber receives their personal coupon code in the email.
---
## Part 8: Subscriber Management — Show Coupon Codes
### Update Subscribers Index
Add the coupon code column to the subscribers table in the backend:
| First Name | Last Name | Email | Phone | Coupon Code | Synced | Registered |
|---|---|---|---|---|---|---|
| Bert | Hausmans | bert@... | +316... | PREREG-A7X9K2 | ✅ | 2026-04-04 |
### Update CSV Export
Add the `coupon_code` column to the CSV export.
---
## Part 9: Implementation Order
### Step 1: Database
- Create `weeztix_configs` migration
- Add `coupon_code` to `subscribers` migration
- Add `field_coupon_code` to `mailwizz_configs` migration
- Create `WeeztixConfig` model
- Update `PreregistrationPage` model with `weeztixConfig` relationship
- Run migrations
### Step 2: WeeztixService
- Create `app/Services/WeeztixService.php`
- Implement token management (get valid token, refresh, detect expiry)
- Implement `getCoupons()` and `createCouponCode()`
- Add unique code generation
### Step 3: OAuth Flow
- Create `WeeztixOAuthController` (redirect + callback)
- Add routes for OAuth
- Handle state validation and token storage
### Step 4: Backend Configuration UI
- Create `WeeztixController` (edit, update, destroy)
- Create `WeeztixApiController` (coupons AJAX endpoint)
- Create Blade view `admin/weeztix/edit.blade.php`
- Build the multi-step form with Alpine.js
- Add link/tab in the page edit navigation
### Step 5: Frontend Coupon Generation
- Update `PublicPageController@subscribe` to generate coupon codes
- Update Alpine.js success state to show coupon code
- Add copy-to-clipboard functionality
### Step 6: Mailwizz Coupon Forwarding
- Update Mailwizz config migration and model
- Update Mailwizz configuration wizard (add coupon_code field mapping)
- Update `SyncSubscriberToMailwizz` job to include coupon code
### Step 7: Backend Updates
- Update subscribers index to show coupon codes
- Update CSV export to include coupon codes
- Add Weeztix connection status indicator on the pages index
### Step 8: Error Handling & Edge Cases
- Handle Weeztix API downtime (don't fail registration)
- Handle expired refresh tokens (show "Reconnect" button)
- Handle duplicate coupon codes (retry with new code)
- Handle Weeztix rate limiting
- Log all API interactions
---
## Important Rules
- **OAuth credentials must be encrypted** — use Laravel's `encrypted` cast
- **Token refresh must be automatic** — the user should never have to manually refresh
- **Registration must not fail** if Weeztix is down — coupon code is a bonus, not a requirement
- **Coupon codes must be unique** — use the unambiguous character set (no 0/O/1/I)
- **Blade + Alpine.js + Tailwind only** — no additional JS frameworks
- **All text in Dutch** — labels, messages, instructions
- **Don't break existing integrations** — Mailwizz sync must continue working, form blocks must remain functional
- **Refer to the Weeztix API documentation** for exact request/response formats:
- Authentication: https://docs.weeztix.com/docs/introduction/authentication/
- Request token: https://docs.weeztix.com/docs/introduction/authentication/request-token
- Refresh token: https://docs.weeztix.com/docs/introduction/authentication/refresh-token
- Get Coupons: https://docs.weeztix.com/api/dashboard/get-coupons
- Add CouponCodes: https://docs.weeztix.com/api/dashboard/add-coupon-codes
- Issuing requests (Company header): https://docs.weeztix.com/docs/introduction/issue-request/

View File

@@ -269,6 +269,12 @@ Laravel ships with a `public/.htaccess` that works with Apache. Verify `mod_rewr
### 4.7 Set up cron for queue worker + scheduler
Public registration saves the subscriber in the database first, then queues **Weeztix** (coupon code) and **Mailwizz** sync jobs. Those jobs must be processed by a worker.
- **Visitor-facing behaviour:** the public subscribe endpoint returns **HTTP 200 with `success: true`** as soon as the subscriber row is stored. Failures in Mailwizz or Weeztix are **logged** (and visible via failed jobs when using a real queue); they do **not** change the JSON shown to the visitor. Use logs and admin resync to diagnose integration issues.
- **Production:** set `QUEUE_CONNECTION=database` (or `redis`) so retries and `queue:failed` work as intended. `sync` is acceptable for small installs but runs integration work in-process; still, the visitor should not see 5xx from a broken Mailwizz/Weeztix API after subscribe.
- **Queues:** coupon jobs use `weeztix`; Mailwizz uses `mailwizz`. The worker should listen to both (order below prioritises `weeztix` so coupon creation tends to run before sync when both are pending).
In DirectAdmin → Cron Jobs, add:
```
@@ -276,9 +282,11 @@ In DirectAdmin → Cron Jobs, add:
* * * * * cd /home/username/preregister && php artisan schedule:run >> /dev/null 2>&1
# Queue worker - process one job per run (every minute)
* * * * * cd /home/username/preregister && php artisan queue:work --once --queue=mailwizz >> /dev/null 2>&1
* * * * * cd /home/username/preregister && php artisan queue:work --once --queue=weeztix,mailwizz,default >> /dev/null 2>&1
```
For higher throughput, use a persistent supervisor-managed worker instead of `--once` in cron; keep the same `--queue=weeztix,mailwizz,default` order.
### 4.8 Directory permissions
```bash

View File

@@ -14,13 +14,20 @@
"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.",
"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).",
"Please enter a valid phone number.": "Voer een geldig telefoonnummer in.",
"Subscriber removed.": "Abonnee verwijderd.",
"Delete this subscriber? This cannot be undone.": "Deze abonnee verwijderen? Dit kan niet ongedaan worden gemaakt.",
"Remove": "Verwijderen",
"Actions": "Acties"
"Sync Mailwizz": "Mailwizz sync",
"Mailwizz sync has been queued for this subscriber.": "Mailwizz-synchronisatie is in de wachtrij gezet voor deze abonnee.",
"Queue a Mailwizz sync for this subscriber? The tag and coupon code will be sent when the queue worker runs.": "Mailwizz-synchronisatie voor deze abonnee in de wachtrij zetten? De tag en kortingscode worden verstuurd zodra de queue-worker draait.",
"Actions": "Acties",
"Fix background to viewport": "Achtergrond vastzetten op het scherm",
"When enabled, the background image and overlay stay fixed while visitors scroll long content.": "Als dit aan staat, blijven de achtergrondafbeelding en de overlay stilstaan terwijl bezoekers door lange inhoud scrollen."
}

View File

@@ -390,6 +390,7 @@ document.addEventListener('alpine:init', () => {
phase: config.phase,
startAtMs: config.startAtMs,
phoneEnabled: config.phoneEnabled,
phoneRequired: config.phoneRequired === true,
subscribeUrl: config.subscribeUrl,
csrfToken: config.csrfToken,
genericError: config.genericError,
@@ -499,10 +500,16 @@ document.addEventListener('alpine:init', () => {
ok = false;
}
if (this.phoneEnabled) {
const digits = String(this.phone).replace(/\D/g, '');
if (digits.length > 0 && (digits.length < 8 || digits.length > 15)) {
const trimmed = String(this.phone).trim();
if (this.phoneRequired && trimmed === '') {
this.fieldErrors.phone = [this.invalidPhoneMsg];
ok = false;
} else if (trimmed !== '') {
const digits = trimmed.replace(/\D/g, '');
if (digits.length < 8 || digits.length > 15) {
this.fieldErrors.phone = [this.invalidPhoneMsg];
ok = false;
}
}
}
return ok;
@@ -548,12 +555,21 @@ document.addEventListener('alpine:init', () => {
this.startRedirectCountdownIfNeeded();
return;
}
if (typeof data.message === 'string' && data.message !== '') {
const hasServerMessage = typeof data.message === 'string' && data.message !== '';
const hasFieldErrors =
data.errors !== undefined &&
data.errors !== null &&
typeof data.errors === 'object' &&
Object.keys(data.errors).length > 0;
if (hasServerMessage) {
this.formError = data.message;
}
if (data.errors !== undefined && data.errors !== null && typeof data.errors === 'object') {
if (hasFieldErrors) {
this.fieldErrors = data.errors;
}
if (!res.ok && !hasServerMessage && !hasFieldErrors) {
this.formError = this.genericError;
}
} catch {
this.formError = this.genericError;
} finally {
@@ -567,6 +583,7 @@ document.addEventListener('alpine:init', () => {
fieldsUrl: cfg.fieldsUrl,
phoneEnabled: cfg.phoneEnabled,
hasExistingConfig: cfg.hasExistingConfig,
hasWeeztixIntegration: cfg.hasWeeztixIntegration === true,
existing: cfg.existing,
csrf: cfg.csrf,
step: 1,
@@ -579,6 +596,7 @@ document.addEventListener('alpine:init', () => {
fieldFirstName: '',
fieldLastName: '',
fieldPhone: '',
fieldCouponCode: '',
tagField: '',
tagValue: '',
loading: false,
@@ -590,6 +608,7 @@ document.addEventListener('alpine:init', () => {
this.fieldFirstName = this.existing.field_first_name ?? '';
this.fieldLastName = this.existing.field_last_name ?? '';
this.fieldPhone = this.existing.field_phone ?? '';
this.fieldCouponCode = this.existing.field_coupon_code ?? '';
this.tagField = this.existing.tag_field ?? '';
this.tagValue = this.existing.tag_value ?? '';
this.selectedListUid = this.existing.list_uid ?? '';
@@ -699,6 +718,7 @@ document.addEventListener('alpine:init', () => {
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.fieldCouponCode = this.existing.field_coupon_code || this.fieldCouponCode;
this.tagField = this.existing.tag_field || this.tagField;
this.tagValue = this.existing.tag_value || this.tagValue;
}
@@ -739,6 +759,107 @@ document.addEventListener('alpine:init', () => {
this.$refs.saveForm.requestSubmit();
},
}));
Alpine.data('weeztixSetup', (cfg) => ({
pageId: cfg.pageId,
couponsUrl: cfg.couponsUrl,
csrf: cfg.csrf,
isConnected: cfg.isConnected === true,
callbackUrl: cfg.callbackUrl,
errorMessage: '',
coupons: [],
couponGuid: '',
couponName: '',
codePrefix: 'PREREG',
usageCount: 1,
couponsRefreshing: false,
strings: cfg.strings || {},
async init() {
if (cfg.existing) {
this.codePrefix = cfg.existing.code_prefix || 'PREREG';
const uc = cfg.existing.usage_count;
if (typeof uc === 'number' && !Number.isNaN(uc)) {
this.usageCount = uc;
} else if (uc !== null && uc !== undefined && String(uc).trim() !== '') {
const parsed = parseInt(String(uc), 10);
this.usageCount = Number.isNaN(parsed) ? 1 : parsed;
} else {
this.usageCount = 1;
}
this.couponGuid = cfg.existing.coupon_guid || '';
this.couponName = cfg.existing.coupon_name || '';
}
if (this.isConnected) {
await this.loadCoupons();
} else if (cfg.existing && cfg.existing.coupon_guid) {
this.ensureSelectedCouponInList();
}
},
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 };
},
syncCouponName() {
if (!this.couponGuid) {
this.couponName = '';
return;
}
const c = this.coupons.find((x) => x.guid === this.couponGuid);
if (c && typeof c.name === 'string' && c.name.trim() !== '') {
this.couponName = c.name.trim();
}
},
ensureSelectedCouponInList() {
const guid = this.couponGuid;
if (!guid || this.coupons.some((x) => x.guid === guid)) {
return;
}
const label =
typeof this.couponName === 'string' && this.couponName.trim() !== ''
? this.couponName.trim()
: guid;
this.coupons = [{ guid, name: label }, ...this.coupons];
},
async loadCoupons() {
this.errorMessage = '';
const { res, data } = await this.postJson(this.couponsUrl, { page_id: this.pageId });
if (!res.ok) {
this.errorMessage = data.message || this.strings.loadCouponsError;
this.ensureSelectedCouponInList();
return;
}
this.coupons = Array.isArray(data.coupons) ? data.coupons : [];
this.ensureSelectedCouponInList();
this.syncCouponName();
},
async refreshCoupons() {
if (!this.isConnected) {
return;
}
this.couponsRefreshing = true;
try {
await this.loadCoupons();
} finally {
this.couponsRefreshing = false;
}
},
}));
});
window.Alpine = Alpine;

View File

@@ -1,5 +1,8 @@
@php
$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,
@@ -8,6 +11,7 @@
'field_first_name' => $config->field_first_name,
'field_last_name' => $config->field_last_name,
'field_phone' => $config->field_phone,
'field_coupon_code' => $config->field_coupon_code,
'tag_field' => $config->tag_field,
'tag_value' => $config->tag_value,
]
@@ -21,29 +25,15 @@
@section('mobile_title', __('Mailwizz'))
@section('content')
<div class="mx-auto max-w-3xl" x-data="mailwizzWizard(@js([
'listsUrl' => route('admin.mailwizz.lists'),
'fieldsUrl' => route('admin.mailwizz.fields'),
'csrf' => csrf_token(),
'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(),
'hasExistingConfig' => $config !== null,
'existing' => $existing,
'strings' => [
'apiKeyRequired' => __('Enter your Mailwizz API key to continue.'),
'genericError' => __('Something went wrong. Please try again.'),
'noListsError' => __('No mailing lists were returned. Check your API key or create a list in Mailwizz.'),
'selectListError' => __('Select a mailing list.'),
'mapFieldsError' => __('Map email, first name, and last name to Mailwizz fields.'),
'tagFieldError' => __('Select a checkbox list field for source / tag tracking.'),
'tagValueError' => __('Select the tag option that identifies this pre-registration.'),
],
]))">
<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>
@@ -55,208 +45,347 @@
</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"
@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="text-sm font-semibold text-red-700 underline hover:text-red-800">
{{ __('Remove integration') }}
<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>
@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>
<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>
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
<div x-show="errorMessage !== ''" x-cloak class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" x-text="errorMessage"></div>
<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>
{{-- Step 1 --}}
<div x-show="step === 1" x-cloak class="space-y-4">
<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>
<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(),
'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(),
'hasExistingConfig' => $config !== null,
'hasWeeztixIntegration' => $hasWeeztixForCouponMap,
'existing' => $existing,
'strings' => [
'apiKeyRequired' => __('Enter your Mailwizz API key to continue.'),
'genericError' => __('Something went wrong. Please try again.'),
'noListsError' => __('No mailing lists were returned. Check your API key or create a list in Mailwizz.'),
'selectListError' => __('Select a mailing list.'),
'mapFieldsError' => __('Map email, first name, and last name to Mailwizz fields.'),
'tagFieldError' => __('Select a checkbox list field for source / tag tracking.'),
'tagValueError' => __('Select the tag option that identifies this pre-registration.'),
],
]))"
>
@if ($config !== null)
<p class="text-sm text-amber-800">
{{ __('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.') }}
</p>
<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>
<label for="mailwizz_api_key" class="block text-sm font-medium text-slate-700">{{ __('Mailwizz API key') }}</label>
<input
id="mailwizz_api_key"
type="password"
autocomplete="off"
x-model="apiKey"
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
placeholder="{{ __('Paste API key') }}"
<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')"
>
</div>
<button
type="button"
class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50"
:disabled="loading"
@click="connectLists()"
>
<span x-show="!loading">{{ __('Connect & load lists') }}</span>
<span x-show="loading" x-cloak>{{ __('Connecting…') }}</span>
</button>
</div>
{{-- Step 2 --}}
<div x-show="step === 2" x-cloak class="space-y-4">
<div>
<label for="mailwizz_list" class="block text-sm font-medium text-slate-700">{{ __('Mailing list') }}</label>
<select
id="mailwizz_list"
x-model="selectedListUid"
@change="syncListNameFromSelection()"
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-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')"
>
<option value="">{{ __('Select a list…') }}</option>
<template x-for="list in lists" :key="list.list_uid">
<option :value="list.list_uid" x-text="list.name"></option>
</template>
</select>
</div>
<div class="flex flex-wrap gap-3">
<button type="button" class="rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50" @click="step = 1">
{{ __('Back') }}
</button>
<button
type="button"
class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50"
:disabled="loading"
@click="loadFieldsAndGoStep3()"
<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 x-show="!loading">{{ __('Load fields') }}</span>
<span x-show="loading" x-cloak>{{ __('Loading') }}</span>
</button>
</div>
</div>
{{-- Step 3 --}}
<div x-show="step === 3" x-cloak class="space-y-5">
<p class="text-sm text-slate-600">{{ __('Map each local field to the matching Mailwizz custom field (by tag).') }}</p>
<div>
<label class="block text-sm font-medium text-slate-700">{{ __('Email') }}</label>
<select x-model="fieldEmail" 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>
<template x-for="f in emailFieldChoices()" :key="f.tag">
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
</template>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">{{ __('First name') }}</label>
<select x-model="fieldFirstName" 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>
<template x-for="f in textFields()" :key="'fn-' + f.tag">
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
</template>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">{{ __('Last name') }}</label>
<select x-model="fieldLastName" 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>
<template x-for="f in textFields()" :key="'ln-' + f.tag">
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
</template>
</select>
</div>
<div x-show="phoneEnabled">
<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">
<option value="">{{ __('Select field…') }}</option>
<template x-for="f in phoneFields()" :key="'ph-' + f.tag">
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
</template>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">{{ __('Tag / source (checkbox list)') }}</label>
<select
x-model="tagField"
@change="tagValue = ''"
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"
<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'"
>
<option value="">{{ __('Select checkbox list field…') }}</option>
<template x-for="f in checkboxFields()" :key="'cb-' + f.tag">
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
</template>
</select>
<span class="tabular-nums">4</span>
{{ __('Tag / source') }}
</span>
</div>
<p x-show="checkboxFields().length === 0" class="text-sm text-amber-800">
{{ __('No checkbox list fields were returned for this list. Add one in Mailwizz, then run “Load fields” again from step 2.') }}
</p>
<div class="flex flex-wrap gap-3">
<button type="button" class="rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50" @click="step = 2">
{{ __('Back') }}
</button>
<button type="button" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500" @click="goStep4()">
{{ __('Continue') }}
</button>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
<div x-show="errorMessage !== ''" x-cloak class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" x-text="errorMessage"></div>
{{-- Step 4 --}}
<div x-show="step === 4" x-cloak class="space-y-5">
<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">
<legend class="sr-only">{{ __('Tag value') }}</legend>
<template x-for="opt in tagOptionsList()" :key="opt.key">
<label class="flex cursor-pointer items-start gap-3 rounded-lg border border-slate-200 p-3 hover:bg-slate-50">
<input type="radio" name="tag_value_choice" class="mt-1 text-indigo-600" :value="opt.key" x-model="tagValue">
<span class="text-sm text-slate-800" x-text="opt.label"></span>
</label>
</template>
</fieldset>
<p x-show="tagField && tagOptionsList().length === 0" class="text-sm text-amber-800">
{{ __('This field has no options defined in Mailwizz. Add options to the checkbox list field, then reload fields.') }}
</p>
<form x-ref="saveForm" method="post" action="{{ route('admin.pages.mailwizz.update', $page) }}" class="space-y-4">
@csrf
@method('PUT')
<input type="hidden" name="api_key" x-bind:value="apiKey">
<input type="hidden" name="list_uid" x-bind:value="selectedListUid">
<input type="hidden" name="list_name" x-bind:value="selectedListName">
<input type="hidden" name="field_email" x-bind:value="fieldEmail">
<input type="hidden" name="field_first_name" x-bind:value="fieldFirstName">
<input type="hidden" name="field_last_name" x-bind:value="fieldLastName">
<input type="hidden" name="field_phone" x-bind:value="phoneEnabled ? fieldPhone : ''">
<input type="hidden" name="tag_field" x-bind:value="tagField">
<input type="hidden" name="tag_value" x-bind:value="tagValue">
<div class="flex flex-wrap gap-3">
<button type="button" class="rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50" @click="step = 3">
{{ __('Back') }}
</button>
<button type="button" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500" @click="submitSave()">
{{ __('Save configuration') }}
{{-- 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>
@if ($config !== null)
<p class="text-sm text-amber-800">
{{ __('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.') }}
</p>
@endif
<div>
<label for="mailwizz_api_key" class="block text-sm font-medium text-slate-700">{{ __('Mailwizz API key') }}</label>
<input
id="mailwizz_api_key"
type="password"
autocomplete="off"
x-model="apiKey"
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
placeholder="{{ __('Paste API key') }}"
>
</div>
<button
type="button"
class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50"
:disabled="loading"
@click="connectLists()"
>
<span x-show="!loading">{{ __('Connect & load lists') }}</span>
<span x-show="loading" x-cloak>{{ __('Connecting…') }}</span>
</button>
</div>
</form>
{{-- 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
id="mailwizz_list"
x-model="selectedListUid"
@change="syncListNameFromSelection()"
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
<option value="">{{ __('Select a list…') }}</option>
<template x-for="list in lists" :key="list.list_uid">
<option :value="list.list_uid" x-text="list.name"></option>
</template>
</select>
</div>
<div class="flex flex-wrap gap-3">
<button type="button" class="rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50" @click="step = 1">
{{ __('Back') }}
</button>
<button
type="button"
class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50"
:disabled="loading"
@click="loadFieldsAndGoStep3()"
>
<span x-show="!loading">{{ __('Load fields') }}</span>
<span x-show="loading" x-cloak>{{ __('Loading…') }}</span>
</button>
</div>
</div>
{{-- 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>
<label class="block text-sm font-medium text-slate-700">{{ __('Email') }}</label>
<select x-model="fieldEmail" 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>
<template x-for="f in emailFieldChoices()" :key="f.tag">
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
</template>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">{{ __('First name') }}</label>
<select x-model="fieldFirstName" 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>
<template x-for="f in textFields()" :key="'fn-' + f.tag">
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
</template>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">{{ __('Last name') }}</label>
<select x-model="fieldLastName" 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>
<template x-for="f in textFields()" :key="'ln-' + f.tag">
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
</template>
</select>
</div>
<div x-show="phoneEnabled">
<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">
<option value="">{{ __('Select field…') }}</option>
<template x-for="f in phoneFields()" :key="'ph-' + f.tag">
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
</template>
</select>
</div>
<div x-show="hasWeeztixIntegration">
<label class="block text-sm font-medium text-slate-700">{{ __('Kortingscode (Weeztix)') }} <span class="font-normal text-slate-500">({{ __('optional') }})</span></label>
<p class="mt-1 text-xs text-slate-500">{{ __('Koppel aan een tekstveld in Mailwizz om de persoonlijke code in e-mails te tonen.') }}</p>
<select x-model="fieldCouponCode" 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>
<template x-for="f in textFields()" :key="'cp-' + f.tag">
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
</template>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">{{ __('Tag / source (checkbox list)') }}</label>
<select
x-model="tagField"
@change="tagValue = ''"
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 checkbox list field…') }}</option>
<template x-for="f in checkboxFields()" :key="'cb-' + f.tag">
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
</template>
</select>
</div>
<p x-show="checkboxFields().length === 0" class="text-sm text-amber-800">
{{ __('No checkbox list fields were returned for this list. Add one in Mailwizz, then run “Load fields” again from step 2.') }}
</p>
<div class="flex flex-wrap gap-3">
<button type="button" class="rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50" @click="step = 2">
{{ __('Back') }}
</button>
<button type="button" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500" @click="goStep4()">
{{ __('Continue') }}
</button>
</div>
</div>
{{-- 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">
<legend class="sr-only">{{ __('Tag value') }}</legend>
<template x-for="opt in tagOptionsList()" :key="opt.key">
<label class="flex cursor-pointer items-start gap-3 rounded-lg border border-slate-200 p-3 hover:bg-slate-50">
<input type="radio" name="tag_value_choice" class="mt-1 text-indigo-600" :value="opt.key" x-model="tagValue">
<span class="text-sm text-slate-800" x-text="opt.label"></span>
</label>
</template>
</fieldset>
<p x-show="tagField && tagOptionsList().length === 0" class="text-sm text-amber-800">
{{ __('This field has no options defined in Mailwizz. Add options to the checkbox list field, then reload fields.') }}
</p>
<form x-ref="saveForm" method="post" action="{{ route('admin.pages.mailwizz.update', $page) }}" class="space-y-4">
@csrf
@method('PUT')
<input type="hidden" name="api_key" x-bind:value="apiKey">
<input type="hidden" name="list_uid" x-bind:value="selectedListUid">
<input type="hidden" name="list_name" x-bind:value="selectedListName">
<input type="hidden" name="field_email" x-bind:value="fieldEmail">
<input type="hidden" name="field_first_name" x-bind:value="fieldFirstName">
<input type="hidden" name="field_last_name" x-bind:value="fieldLastName">
<input type="hidden" name="field_phone" x-bind:value="phoneEnabled ? fieldPhone : ''">
<input type="hidden" name="field_coupon_code" x-bind:value="hasWeeztixIntegration ? fieldCouponCode : ''">
<input type="hidden" name="tag_field" x-bind:value="tagField">
<input type="hidden" name="tag_value" x-bind:value="tagValue">
<div class="flex flex-wrap gap-3">
<button type="button" class="rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50" @click="step = 3">
{{ __('Back') }}
</button>
<button type="button" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500" @click="submitSave()">
{{ __('Save configuration') }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@endif
</div>
@endsection

View File

@@ -98,6 +98,19 @@
@enderror
</div>
</div>
<div class="mt-4">
<label class="inline-flex cursor-pointer items-center gap-2 text-sm text-slate-800">
<input
type="checkbox"
name="background_fixed"
value="1"
class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
@checked(old('background_fixed', $page?->background_fixed ?? false))
/>
{{ __('Fix background to viewport') }}
</label>
<p class="mt-1 text-xs text-slate-600">{{ __('When enabled, the background image and overlay stay fixed while visitors scroll long content.') }}</p>
</div>
</div>
<div class="grid gap-6 sm:grid-cols-2">

View File

@@ -0,0 +1,55 @@
@php
$only = $only ?? null;
$integrationBadgeClass = $integrationBadgeClass ?? '';
if (! in_array($only, [null, 'mailwizz', 'weeztix'], true)) {
$only = null;
}
$mailwizz = $page->mailwizzIntegrationStatus();
$weeztix = $page->weeztixIntegrationStatus();
$mailwizzClasses = match ($mailwizz) {
'ready' => 'border-emerald-200 bg-emerald-50 text-emerald-900',
'partial' => 'border-amber-200 bg-amber-50 text-amber-950',
default => 'border-slate-200 bg-slate-50 text-slate-600',
};
$mailwizzLabel = match ($mailwizz) {
'ready' => __('Ready'),
'partial' => __('Incomplete'),
default => __('Off'),
};
$weeztixClasses = match ($weeztix) {
'ready' => 'border-emerald-200 bg-emerald-50 text-emerald-900',
'connected' => 'border-sky-200 bg-sky-50 text-sky-950',
'credentials' => 'border-amber-200 bg-amber-50 text-amber-950',
default => 'border-slate-200 bg-slate-50 text-slate-600',
};
$weeztixLabel = match ($weeztix) {
'ready' => __('Ready'),
'connected' => __('Connected'),
'credentials' => __('OAuth only'),
default => __('Off'),
};
$showMailwizz = $only === null || $only === 'mailwizz';
$showWeeztix = $only === null || $only === 'weeztix';
@endphp
<div class="flex flex-wrap items-center gap-1.5 {{ $integrationBadgeClass }}">
@if ($showMailwizz)
<span
class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium {{ $mailwizzClasses }}"
title="{{ __('Mailwizz: :state', ['state' => $mailwizzLabel]) }}"
>
{{ __('MW') }} · {{ $mailwizzLabel }}
</span>
@endif
@if ($showWeeztix)
<span
class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium {{ $weeztixClasses }}"
title="{{ __('Weeztix: :state', ['state' => $weeztixLabel]) }}"
>
{{ __('WZ') }} · {{ $weeztixLabel }}
</span>
@endif
</div>

View File

@@ -13,9 +13,30 @@
{{ __('Public URL') }}: <a href="{{ route('public.page', ['publicPage' => $page]) }}" class="text-indigo-600 hover:underline" target="_blank" rel="noopener">{{ url('/r/'.$page->slug) }}</a>
</p>
@can('update', $page)
<p class="mt-3">
<a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">{{ __('Mailwizz integration') }} </a>
</p>
<div class="mt-6 grid gap-4 sm:grid-cols-2">
<a
href="{{ route('admin.pages.mailwizz.edit', $page) }}"
class="group flex flex-col rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-indigo-200 hover:shadow-md"
>
<div class="flex items-start justify-between gap-3">
<h2 class="text-sm font-semibold text-slate-900">{{ __('Mailwizz') }}</h2>
@include('admin.pages._integration_badges', ['page' => $page, 'only' => 'mailwizz', 'integrationBadgeClass' => 'justify-end'])
</div>
<p class="mt-2 flex-1 text-xs text-slate-600">{{ __('Sync subscribers to your Mailwizz list and map fields.') }}</p>
<span class="mt-3 text-sm font-medium text-indigo-600 group-hover:text-indigo-500">{{ __('Open Mailwizz') }} </span>
</a>
<a
href="{{ route('admin.pages.weeztix.edit', $page) }}"
class="group flex flex-col rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-indigo-200 hover:shadow-md"
>
<div class="flex items-start justify-between gap-3">
<h2 class="text-sm font-semibold text-slate-900">{{ __('Weeztix') }}</h2>
@include('admin.pages._integration_badges', ['page' => $page, 'only' => 'weeztix', 'integrationBadgeClass' => 'justify-end'])
</div>
<p class="mt-2 flex-1 text-xs text-slate-600">{{ __('Issue unique discount codes via Weeztix when visitors sign up.') }}</p>
<span class="mt-3 text-sm font-medium text-indigo-600 group-hover:text-indigo-500">{{ __('Open Weeztix') }} </span>
</a>
</div>
@endcan
</div>

View File

@@ -32,6 +32,7 @@
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('Start') }}</th>
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('End') }}</th>
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('Subscribers') }}</th>
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('Integrations') }}</th>
<th scope="col" class="px-4 py-3 text-right font-semibold text-slate-700">{{ __('Actions') }}</th>
</tr>
</thead>
@@ -64,6 +65,9 @@
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $page->start_date->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td>
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $page->end_date->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td>
<td class="whitespace-nowrap px-4 py-3 tabular-nums text-slate-600">{{ number_format($page->subscribers_count) }}</td>
<td class="px-4 py-3">
@include('admin.pages._integration_badges', ['page' => $page])
</td>
<td class="whitespace-nowrap px-4 py-3 text-right">
<div class="flex flex-wrap items-center justify-end gap-2">
@can('update', $page)
@@ -75,6 +79,9 @@
@can('update', $page)
<a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-indigo-600 hover:text-indigo-500">{{ __('Mailwizz') }}</a>
@endcan
@can('update', $page)
<a href="{{ route('admin.pages.weeztix.edit', $page) }}" class="text-indigo-600 hover:text-indigo-500">{{ __('Weeztix') }}</a>
@endcan
<button
type="button"
x-data="{ copied: false }"
@@ -97,7 +104,7 @@
</tr>
@empty
<tr>
<td colspan="{{ auth()->user()->isSuperadmin() ? 7 : 6 }}" class="px-4 py-12 text-center text-slate-500">
<td colspan="{{ auth()->user()->isSuperadmin() ? 8 : 7 }}" class="px-4 py-12 text-center text-slate-500">
{{ __('No pages yet.') }}
@can('create', \App\Models\PreregistrationPage::class)
<a href="{{ route('admin.pages.create') }}" class="font-medium text-indigo-600 hover:text-indigo-500">{{ __('Create one') }}</a>

View File

@@ -52,6 +52,7 @@
@if ($page->isPhoneFieldEnabledForSubscribers())
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Phone') }}</th>
@endif
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Kortingscode') }}</th>
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Registered at') }}</th>
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Mailwizz') }}</th>
<th class="w-px whitespace-nowrap px-4 py-3 font-semibold text-slate-700">{{ __('Actions') }}</th>
@@ -64,8 +65,9 @@
<td class="px-4 py-3 text-slate-900">{{ $subscriber->last_name }}</td>
<td class="px-4 py-3 text-slate-600">{{ $subscriber->email }}</td>
@if ($page->isPhoneFieldEnabledForSubscribers())
<td class="px-4 py-3 text-slate-600">{{ $subscriber->phone ?? '—' }}</td>
<td class="px-4 py-3 text-slate-600">{{ $subscriber->phoneDisplay() ?? '—' }}</td>
@endif
<td class="px-4 py-3 font-mono text-xs text-slate-700">{{ $subscriber->coupon_code ?? '—' }}</td>
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $subscriber->created_at->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td>
<td class="px-4 py-3">
@if ($subscriber->synced_to_mailwizz)
@@ -80,27 +82,45 @@
</td>
<td class="whitespace-nowrap px-4 py-3 text-right">
@can('update', $page)
<form
method="post"
action="{{ route('admin.pages.subscribers.destroy', [$page, $subscriber]) }}"
class="inline"
onsubmit="return confirm(@js(__('Delete this subscriber? This cannot be undone.')));"
>
@csrf
@method('DELETE')
<button
type="submit"
class="rounded-lg border border-red-200 bg-white px-2.5 py-1 text-xs font-semibold text-red-700 hover:bg-red-50"
<div class="inline-flex flex-wrap items-center justify-end gap-1.5">
@if ($page->mailwizzConfig !== null)
<form
method="post"
action="{{ route('admin.pages.subscribers.sync-mailwizz', [$page, $subscriber]) }}"
class="inline"
onsubmit="return confirm(@js(__('Queue a Mailwizz sync for this subscriber? The tag and coupon code will be sent when the queue worker runs.')));"
>
@csrf
<button
type="submit"
class="rounded-lg border border-indigo-200 bg-white px-2.5 py-1 text-xs font-semibold text-indigo-700 hover:bg-indigo-50"
>
{{ __('Sync Mailwizz') }}
</button>
</form>
@endif
<form
method="post"
action="{{ route('admin.pages.subscribers.destroy', [$page, $subscriber]) }}"
class="inline"
onsubmit="return confirm(@js(__('Delete this subscriber? This cannot be undone.')));"
>
{{ __('Remove') }}
</button>
</form>
@csrf
@method('DELETE')
<button
type="submit"
class="rounded-lg border border-red-200 bg-white px-2.5 py-1 text-xs font-semibold text-red-700 hover:bg-red-50"
>
{{ __('Remove') }}
</button>
</form>
</div>
@endcan
</td>
</tr>
@empty
<tr>
<td colspan="{{ $page->isPhoneFieldEnabledForSubscribers() ? 7 : 6 }}" class="px-4 py-12 text-center text-slate-500">
<td colspan="{{ $page->isPhoneFieldEnabledForSubscribers() ? 8 : 7 }}" class="px-4 py-12 text-center text-slate-500">
{{ __('No subscribers match your criteria.') }}
</td>
</tr>

View File

@@ -0,0 +1,351 @@
@php
use Illuminate\Support\Carbon;
$wz = $page->weeztixConfig;
$existing = $wz !== null
? [
'coupon_guid' => $wz->coupon_guid,
'coupon_name' => $wz->coupon_name,
'code_prefix' => $wz->code_prefix,
'usage_count' => $wz->usage_count,
]
: null;
$credentialsEdit = ! $hasStoredCredentials || request()->query('credentials') === 'edit';
$oauthUrl = route('admin.pages.weeztix.oauth.redirect', ['page' => $page, 'wizard' => 1]);
@endphp
@extends('layouts.admin')
@section('title', __('Weeztix') . ' — ' . $page->title)
@section('mobile_title', __('Weeztix'))
@section('content')
<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"> {{ __('Terug naar pagina') }}</a>
<h1 class="mt-4 text-2xl font-semibold text-slate-900">{{ __('Weeztix') }}</h1>
<p class="mt-2 text-sm text-slate-600">{{ __('Pagina:') }} <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">{{ __('Controleer het volgende:') }}</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 && $wz !== null)
{{-- Summary (read-only): change only via wizard --}}
@if ($wz->is_connected && ($wz->company_guid === null || $wz->company_guid === ''))
<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">{{ __('Bedrijf nog niet vastgelegd') }}</p>
<p class="mt-1 text-amber-900">{{ __('Start de wizard en verbind opnieuw met Weeztix zodat het bedrijf automatisch wordt gekoppeld.') }}</p>
</div>
@endif
<div class="mb-6 flex flex-wrap items-center gap-3">
<a
href="{{ route('admin.pages.weeztix.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"
>
{{ __('Instellingen wijzigen (wizard)') }}
</a>
<form action="{{ route('admin.pages.weeztix.destroy', $page) }}" method="post" class="inline"
onsubmit="return confirm(@js(__('Weeztix-integratie voor deze pagina verwijderen? Opgeslagen tokens worden gewist.')));">
@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">
{{ __('Weeztix loskoppelen') }}
</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">{{ __('Huidige configuratie') }}</h2>
<p class="mt-1 text-sm text-slate-600">{{ __('OAuth-gegevens zijn opgeslagen maar worden om veiligheidsredenen niet getoond.') }}</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">{{ __('Verbinding') }}</dt>
<dd>
@if ($wz->is_connected)
<span class="inline-flex rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">{{ __('Verbonden') }}</span>
@else
<span class="inline-flex rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-medium text-amber-900">{{ __('Niet verbonden') }}</span>
@endif
</dd>
</div>
@if ($wz->is_connected && $wz->token_expires_at)
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
<dt class="font-medium text-slate-700">{{ __('Toegangstoken tot') }}</dt>
<dd class="font-mono text-xs text-slate-800">{{ $wz->token_expires_at->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</dd>
</div>
@endif
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
<dt class="font-medium text-slate-700">{{ __('Callback-URL (in Weeztix-dashboard)') }}</dt>
<dd class="break-all font-mono text-xs text-slate-600">{{ route('admin.weeztix.callback', absolute: true) }}</dd>
</div>
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
<dt class="font-medium text-slate-700">{{ __('Coupon') }}</dt>
<dd class="text-slate-800">{{ $wz->coupon_name ?: ($wz->coupon_guid ? $wz->coupon_guid : '—') }}</dd>
</div>
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
<dt class="font-medium text-slate-700">{{ __('Codevoorvoegsel') }}</dt>
<dd class="font-mono text-slate-800">{{ $wz->code_prefix ?? '—' }}</dd>
</div>
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
<dt class="font-medium text-slate-700">{{ __('Gebruik per code') }}</dt>
<dd class="text-slate-800">{{ (int) ($wz->usage_count ?? 1) }}</dd>
</div>
</dl>
</div>
@else
{{-- Wizard --}}
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
@if ($wz !== null)
<a href="{{ route('admin.pages.weeztix.edit', ['page' => $page]) }}" class="text-sm font-medium text-slate-600 hover:text-slate-900">
{{ __('Annuleren en terug naar overzicht') }}
</a>
@endif
</div>
<div class="mb-8 flex flex-wrap items-center gap-2" aria-label="{{ __('Wizardstappen') }}">
@foreach ([1 => __('OAuth'), 2 => __('Verbinden'), 3 => __('Coupon')] as $num => $label)
@php
$active = $wizardStep === $num;
$done = $wizardStep > $num;
@endphp
<span class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium
{{ $active ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : '' }}
{{ $done ? 'border-emerald-200 bg-emerald-50 text-emerald-900' : '' }}
{{ ! $active && ! $done ? 'border-slate-200 bg-slate-50 text-slate-500' : '' }}">
<span class="tabular-nums">{{ $num }}</span>
{{ $label }}
</span>
@if ($num < 3)
<span class="text-slate-300" aria-hidden="true"></span>
@endif
@endforeach
</div>
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8 space-y-10">
@if ($wizardStep === 1)
<section class="space-y-4" aria-labelledby="wz-wizard-step1">
<h2 id="wz-wizard-step1" class="text-lg font-semibold text-slate-900">{{ __('Stap 1: OAuth-client') }}</h2>
@if ($hasStoredCredentials && ! $credentialsEdit)
<p class="text-sm text-slate-600">{{ __('Wil je Client ID, client secret of de callback-URI in Weeztix wijzigen? De callback-URL van deze applicatie is hieronder; die moet exact overeenkomen in het Weeztix-dashboard.') }}</p>
<p class="rounded-lg bg-slate-50 px-3 py-2 font-mono text-xs text-slate-800 break-all">{{ route('admin.weeztix.callback', absolute: true) }}</p>
<div class="flex flex-wrap gap-3 pt-2">
<a
href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 2]) }}"
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"
>
{{ __('Nee, huidige gegevens behouden') }}
</a>
<a
href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 1, 'credentials' => 'edit']) }}"
class="inline-flex rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-800 shadow-sm hover:bg-slate-50"
>
{{ __('Ja, Client ID en secret wijzigen') }}
</a>
</div>
@else
<p class="text-sm text-slate-600">{{ __('Vul de OAuth-client uit het Weeztix-dashboard in. Zet de redirect-URI exact op de onderstaande URL.') }}</p>
<p class="rounded-lg bg-slate-50 px-3 py-2 font-mono text-xs text-slate-800 break-all">{{ route('admin.weeztix.callback', absolute: true) }}</p>
<form method="post" action="{{ route('admin.pages.weeztix.update', $page) }}" class="space-y-4 pt-2">
@csrf
@method('PUT')
<input type="hidden" name="wizard" value="1">
<input type="hidden" name="wizard_credential_save" value="1">
<div>
<label for="weeztix_client_id" class="block text-sm font-medium text-slate-700">{{ __('Client ID') }}</label>
<input
id="weeztix_client_id"
name="client_id"
type="text"
autocomplete="off"
value="{{ old('client_id') }}"
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
placeholder="{{ $wz !== null ? __('Laat leeg om ongewijzigd te laten') : '' }}"
@if ($wz === null) required @endif
>
</div>
<div>
<label for="weeztix_client_secret" class="block text-sm font-medium text-slate-700">{{ __('Client secret') }}</label>
<input
id="weeztix_client_secret"
name="client_secret"
type="password"
autocomplete="off"
value=""
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
placeholder="{{ $wz !== null ? __('Laat leeg om ongewijzigd te laten') : '' }}"
@if ($wz === null) required @endif
>
</div>
@if ($wz !== null)
<p class="text-xs text-slate-500">
{{ __('Laat velden leeg om opgeslagen waarden te behouden.') }}
</p>
@endif
<button type="submit" class="rounded-lg bg-slate-800 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-slate-700">
{{ __('Opslaan en verder naar verbinden') }}
</button>
</form>
@endif
</section>
@endif
@if ($wizardStep === 2)
<section class="space-y-4" aria-labelledby="wz-wizard-step2">
<h2 id="wz-wizard-step2" class="text-lg font-semibold text-slate-900">{{ __('Stap 2: Verbinden met Weeztix') }}</h2>
<p class="text-sm text-slate-600">{{ __('Log in bij Weeztix en keur de toegang goed. Daarna ga je automatisch verder naar de coupon.') }}</p>
<p class="rounded-lg bg-slate-50 px-3 py-2 font-mono text-xs text-slate-800 break-all">{{ route('admin.weeztix.callback', absolute: true) }}</p>
@if ($wz !== null && $wz->is_connected)
<div class="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
{{ __('Je bent verbonden. Start opnieuw de Weeztix-login hieronder om een ander bedrijf te kiezen; het gekoppelde bedrijf wordt daarna automatisch bijgewerkt.') }}
</div>
@endif
<div class="flex flex-wrap items-center gap-3">
<a
href="{{ $oauthUrl }}"
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"
>
@if ($wz !== null && $wz->is_connected)
{{ __('Opnieuw verbinden met Weeztix') }}
@else
{{ __('Verbind met Weeztix') }}
@endif
</a>
@if ($wz !== null && $wz->is_connected)
<a
href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 3]) }}"
class="inline-flex rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-800 shadow-sm hover:bg-slate-50"
>
{{ __('Naar stap 3: coupon') }}
</a>
@endif
</div>
<p class="pt-4">
<a href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]) }}" class="text-sm font-medium text-slate-600 hover:text-slate-900">{{ __('← Terug naar stap 1') }}</a>
</p>
</section>
@endif
@if ($wizardStep === 3)
<div
x-data="weeztixSetup(@js([
'pageId' => $page->id,
'couponsUrl' => route('admin.weeztix.coupons'),
'csrf' => csrf_token(),
'isConnected' => $wz?->is_connected ?? false,
'callbackUrl' => route('admin.weeztix.callback', absolute: true),
'existing' => $existing,
'strings' => [
'genericError' => __('Er ging iets mis. Probeer het opnieuw.'),
'loadCouponsError' => __('Kon kortingsbonnen niet laden.'),
'refreshCoupons' => __('Vernieuwen'),
'refreshCouponsBusy' => __('Bezig…'),
'refreshCouponsTitle' => __('Couponlijst opnieuw ophalen van Weeztix'),
],
]))"
>
<div x-show="errorMessage !== ''" x-cloak class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" x-text="errorMessage"></div>
<section class="space-y-4" aria-labelledby="wz-wizard-step3">
<h2 id="wz-wizard-step3" class="text-lg font-semibold text-slate-900">{{ __('Stap 3: Coupon en codes') }}</h2>
<p class="text-sm text-slate-600">{{ __('Kies een bestaande coupon in Weeztix en stel het voorvoegsel en aantal gebruiken per code in.') }}</p>
<form method="post" action="{{ route('admin.pages.weeztix.update', $page) }}" class="space-y-4">
@csrf
@method('PUT')
<input type="hidden" name="wizard" value="1">
<input type="hidden" name="wizard_coupon_save" value="1">
<div>
<div class="flex flex-wrap items-end justify-between gap-2">
<label for="weeztix_coupon" class="block text-sm font-medium text-slate-700">{{ __('Coupon (kortingssjabloon)') }}</label>
<button
type="button"
x-show="isConnected"
x-cloak
@click="refreshCoupons()"
:disabled="couponsRefreshing"
:aria-busy="couponsRefreshing"
:title="strings.refreshCouponsTitle"
class="shrink-0 rounded-md border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<span x-show="!couponsRefreshing" x-text="strings.refreshCoupons"></span>
<span x-show="couponsRefreshing" x-cloak x-text="strings.refreshCouponsBusy"></span>
</button>
</div>
<select
id="weeztix_coupon"
name="coupon_guid"
x-model="couponGuid"
@change="syncCouponName()"
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
<option value="">{{ __('Selecteer een coupon…') }}</option>
<template x-for="c in coupons" :key="c.guid">
<option :value="c.guid" x-text="c.name"></option>
</template>
</select>
<input type="hidden" name="coupon_name" :value="couponName">
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="weeztix_code_prefix" class="block text-sm font-medium text-slate-700">{{ __('Codevoorvoegsel') }}</label>
<input
id="weeztix_code_prefix"
name="code_prefix"
type="text"
maxlength="32"
x-model="codePrefix"
value="{{ old('code_prefix', $wz->code_prefix ?? 'PREREG') }}"
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
</div>
<div>
<label for="weeztix_usage_count" class="block text-sm font-medium text-slate-700">{{ __('Gebruik per code') }}</label>
<input
id="weeztix_usage_count"
name="usage_count"
type="number"
min="1"
max="99999"
x-model.number="usageCount"
value="{{ old('usage_count', $wz->usage_count ?? 1) }}"
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
</div>
</div>
<div class="flex flex-wrap gap-3 pt-2">
<button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">
{{ __('Opslaan en wizard afronden') }}
</button>
<a href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 2]) }}" class="inline-flex items-center rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50">
{{ __('Terug naar stap 2') }}
</a>
</div>
</form>
</section>
</div>
@endif
</div>
@endif
</div>
@endsection

View File

@@ -16,6 +16,8 @@
};
$eyebrow = data_get($c, 'eyebrow_text');
$eyebrowStyle = (string) data_get($c, 'eyebrow_style', 'badge');
$subheadlineRaw = data_get($c, 'subheadline');
$subheadline = is_string($subheadlineRaw) ? trim($subheadlineRaw) : '';
@endphp
<div class="flex w-full flex-col {{ $alignClass }} space-y-4">
@@ -35,9 +37,8 @@
</h1>
@endif
@if ($pageState !== 'expired' && filled(data_get($c, 'subheadline')))
<div class="w-full max-w-none whitespace-pre-line text-[15px] leading-[1.65] text-white sm:text-base sm:leading-relaxed">
{{ trim((string) data_get($c, 'subheadline')) }}
</div>
{{-- Subheadline must sit on one line inside the div: whitespace-pre-line turns Blade indentation into visible leading space. --}}
@if ($pageState !== 'expired' && $subheadline !== '')
<div class="w-full max-w-none whitespace-pre-line text-[15px] leading-[1.65] text-white sm:text-base sm:leading-relaxed">{{ $subheadline }}</div>
@endif
</div>

View File

@@ -20,6 +20,9 @@
$formButtonLabel = (string) (data_get($formContent, 'button_label') ?: __('public.register_button'));
$formButtonColor = (string) data_get($formContent, 'button_color', '#F47B20');
$formButtonTextColor = (string) data_get($formContent, 'button_text_color', '#FFFFFF');
$bgFixed = $page->background_fixed;
$bgLayerPosition = $bgFixed ? 'fixed inset-0 pointer-events-none z-0' : 'absolute inset-0';
$overlayPosition = $bgFixed ? 'fixed inset-0 pointer-events-none z-[1]' : 'absolute inset-0';
@endphp
@extends('layouts.public')
@@ -28,19 +31,19 @@
<div class="relative min-h-screen w-full overflow-x-hidden">
@if ($bgUrl !== null)
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
class="{{ $bgLayerPosition }} bg-cover bg-center bg-no-repeat"
style="background-image: url('{{ e($bgUrl) }}')"
aria-hidden="true"
></div>
@else
<div
class="absolute inset-0 bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950"
class="{{ $bgLayerPosition }} bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950"
aria-hidden="true"
></div>
@endif
<div
class="absolute inset-0"
class="{{ $overlayPosition }}"
style="background-color: {{ e($overlayColor) }}; opacity: {{ $overlayOpacity }}"
aria-hidden="true"
></div>
@@ -53,13 +56,14 @@
'phase' => $alpinePhase,
'startAtMs' => $page->start_date->getTimestamp() * 1000,
'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(),
'phoneRequired' => $page->isPhoneFieldEnabledForSubscribers() && $page->isPhoneFieldRequiredForSubscribers(),
'subscribeUrl' => route('public.subscribe', ['publicPage' => $page]),
'csrfToken' => csrf_token(),
'genericError' => __('Something went wrong. Please try again.'),
'labelDay' => __('day'),
'labelDays' => __('days'),
'invalidEmailMsg' => __('Please enter a valid email address.'),
'invalidPhoneMsg' => __('Please enter a valid phone number (815 digits).'),
'invalidPhoneMsg' => __('Please enter a valid phone number.'),
'formButtonLabel' => $formButtonLabel,
'formButtonColor' => $formButtonColor,
'formButtonTextColor' => $formButtonTextColor,
@@ -85,9 +89,8 @@
@if ($pageState === 'expired')
<div class="space-y-6">
@if (filled($page->expired_message))
<div class="whitespace-pre-line text-center text-[15px] leading-[1.65] text-white/92 sm:text-base sm:leading-relaxed">
{{ $page->expired_message }}
</div>
{{-- Same as text/hero blocks: no line breaks inside whitespace-pre-line wrapper. --}}
<div class="whitespace-pre-line text-center text-[15px] leading-[1.65] text-white/92 sm:text-base sm:leading-relaxed">{{ trim((string) $page->expired_message) }}</div>
@else
<p class="text-center text-[15px] leading-relaxed text-white/92 sm:text-base">{{ __('This pre-registration period has ended.') }}</p>
@endif

View File

@@ -8,6 +8,9 @@ use App\Http\Controllers\Admin\MailwizzController;
use App\Http\Controllers\Admin\PageController;
use App\Http\Controllers\Admin\SubscriberController;
use App\Http\Controllers\Admin\UserController;
use App\Http\Controllers\Admin\WeeztixApiController;
use App\Http\Controllers\Admin\WeeztixController;
use App\Http\Controllers\Admin\WeeztixOAuthController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\PublicPageController;
use Illuminate\Support\Facades\Route;
@@ -35,6 +38,7 @@ Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(
Route::get('pages/{page}/subscribers/export', [SubscriberController::class, 'export'])->name('pages.subscribers.export');
Route::delete('pages/{page}/subscribers/{subscriber}', [SubscriberController::class, 'destroy'])->name('pages.subscribers.destroy');
Route::post('pages/{page}/subscribers/queue-mailwizz-sync', [SubscriberController::class, 'queueMailwizzSync'])->name('pages.subscribers.queue-mailwizz-sync');
Route::post('pages/{page}/subscribers/{subscriber}/sync-mailwizz', [SubscriberController::class, 'syncSubscriberMailwizz'])->name('pages.subscribers.sync-mailwizz');
Route::get('pages/{page}/subscribers', [SubscriberController::class, 'index'])->name('pages.subscribers.index');
// Mailwizz configuration (nested under pages)
@@ -46,6 +50,15 @@ Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(
Route::post('mailwizz/lists', [MailwizzApiController::class, 'lists'])->name('mailwizz.lists');
Route::post('mailwizz/fields', [MailwizzApiController::class, 'fields'])->name('mailwizz.fields');
// Weeztix configuration (nested under pages)
Route::get('pages/{page}/weeztix', [WeeztixController::class, 'edit'])->name('pages.weeztix.edit');
Route::put('pages/{page}/weeztix', [WeeztixController::class, 'update'])->name('pages.weeztix.update');
Route::delete('pages/{page}/weeztix', [WeeztixController::class, 'destroy'])->name('pages.weeztix.destroy');
Route::get('pages/{page}/weeztix/oauth/redirect', [WeeztixOAuthController::class, 'redirect'])->name('pages.weeztix.oauth.redirect');
Route::get('weeztix/callback', [WeeztixOAuthController::class, 'callback'])->name('weeztix.callback');
Route::post('weeztix/coupons', [WeeztixApiController::class, 'coupons'])->name('weeztix.coupons');
// User management (superadmin only)
Route::middleware('role:superadmin')->group(function () {
Route::resource('users', UserController::class)->except(['show']);

5
run-deploy-from-local.sh Normal file
View File

@@ -0,0 +1,5 @@
ssh hausdesign-vps "sudo -u hausdesign bash -c '
export PATH=\"\$HOME/.local/share/fnm:\$PATH\"
eval \"\$(fnm env)\"
cd /home/hausdesign/preregister && ./deploy.sh
'"

View File

@@ -4,10 +4,14 @@ declare(strict_types=1);
namespace Tests\Feature;
use App\Models\MailwizzConfig;
use App\Models\PreregistrationPage;
use App\Models\Subscriber;
use App\Models\User;
use App\Models\WeeztixConfig;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Tests\TestCase;
@@ -128,4 +132,202 @@ class DestroySubscriberTest extends TestCase
$response->assertForbidden();
$this->assertDatabaseHas('subscribers', ['id' => $subscriber->id]);
}
public function test_delete_strips_mailwizz_source_tag_and_clears_coupon_field(): void
{
Http::fake(function (Request $request) {
$url = $request->url();
if (str_contains($url, 'search-by-email')) {
return Http::response(['status' => 'success', 'data' => ['subscriber_uid' => 'sub-to-clean']]);
}
if ($request->method() === 'GET' && str_contains($url, '/subscribers/sub-to-clean') && ! str_contains($url, 'search-by-email')) {
return Http::response([
'status' => 'success',
'data' => [
'record' => [
'TAGS' => 'preregister-source,other-tag',
'COUPON' => 'PREREG-OLD',
],
],
]);
}
if ($request->method() === 'PUT' && str_contains($url, '/subscribers/sub-to-clean')) {
$body = $request->body();
$this->assertStringContainsString('other-tag', $body);
$this->assertStringNotContainsString('preregister-source', $body);
$this->assertStringContainsString('COUPON', $body);
return Http::response(['status' => 'success']);
}
return Http::response(['status' => 'error'], 500);
});
$user = User::factory()->create(['role' => 'user']);
$page = $this->makePageForDestroyTest($user);
MailwizzConfig::query()->create([
'preregistration_page_id' => $page->id,
'api_key' => 'fake-api-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' => 'COUPON',
'tag_field' => 'TAGS',
'tag_value' => 'preregister-source',
]);
$subscriber = Subscriber::query()->create([
'preregistration_page_id' => $page->id,
'first_name' => 'Clean',
'last_name' => 'Up',
'email' => 'cleanup@example.com',
'coupon_code' => 'PREREG-LOCAL',
]);
$response = $this->actingAs($user)->delete(route('admin.pages.subscribers.destroy', [$page, $subscriber]));
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
$this->assertDatabaseMissing('subscribers', ['id' => $subscriber->id]);
Http::assertSentCount(3);
}
public function test_delete_clears_mailwizz_checkboxlist_when_only_configured_tag_is_present(): void
{
Http::fake(function (Request $request) {
$url = $request->url();
if (str_contains($url, 'search-by-email')) {
return Http::response(['status' => 'success', 'data' => ['subscriber_uid' => 'sub-single-tag']]);
}
if ($request->method() === 'GET' && str_contains($url, '/subscribers/sub-single-tag') && ! str_contains($url, 'search-by-email')) {
return Http::response([
'status' => 'success',
'data' => [
'record' => [
'TAGS' => 'preregister-source',
'COUPON' => 'PREREG-OLD',
],
],
]);
}
if ($request->method() === 'PUT' && str_contains($url, '/subscribers/sub-single-tag')) {
$body = $request->body();
$this->assertStringContainsString('TAGS', $body);
$this->assertStringNotContainsString('preregister-source', $body);
$this->assertStringNotContainsString('TAGS[]', $body);
$this->assertStringContainsString('COUPON', $body);
return Http::response(['status' => 'success']);
}
return Http::response(['status' => 'error'], 500);
});
$user = User::factory()->create(['role' => 'user']);
$page = $this->makePageForDestroyTest($user);
MailwizzConfig::query()->create([
'preregistration_page_id' => $page->id,
'api_key' => 'fake-api-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' => 'COUPON',
'tag_field' => 'TAGS',
'tag_value' => 'preregister-source',
]);
$subscriber = Subscriber::query()->create([
'preregistration_page_id' => $page->id,
'first_name' => 'Solo',
'last_name' => 'Tag',
'email' => 'solo-tag@example.com',
'coupon_code' => 'PREREG-LOCAL',
]);
$response = $this->actingAs($user)->delete(route('admin.pages.subscribers.destroy', [$page, $subscriber]));
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
$this->assertDatabaseMissing('subscribers', ['id' => $subscriber->id]);
Http::assertSentCount(3);
}
public function test_delete_removes_coupon_code_in_weeztix_when_configured(): void
{
Http::fake(function (Request $request) {
$url = $request->url();
if ($request->method() === 'GET' && preg_match('#/coupon/coupon-guid-test/codes$#', $url) === 1) {
return Http::response([
'data' => [
['guid' => 'wzx-code-guid', 'code' => 'PREREG-DEL99'],
],
], 200);
}
if ($request->method() === 'DELETE' && str_contains($url, '/coupon/coupon-guid-test/codes/wzx-code-guid')) {
return Http::response(null, 204);
}
return Http::response(['status' => 'error'], 500);
});
$user = User::factory()->create(['role' => 'user']);
$page = $this->makePageForDestroyTest($user);
WeeztixConfig::query()->create([
'preregistration_page_id' => $page->id,
'client_id' => 'client-id',
'client_secret' => 'client-secret',
'redirect_uri' => 'https://app.test/callback',
'access_token' => 'access-token',
'refresh_token' => 'refresh-token',
'token_expires_at' => now()->addHour(),
'refresh_token_expires_at' => now()->addMonth(),
'company_guid' => 'company-guid-test',
'company_name' => 'Test Co',
'coupon_guid' => 'coupon-guid-test',
'coupon_name' => 'PreReg',
'is_connected' => true,
]);
$subscriber = Subscriber::query()->create([
'preregistration_page_id' => $page->id,
'first_name' => 'Weez',
'last_name' => 'Tix',
'email' => 'weez@example.com',
'coupon_code' => 'PREREG-DEL99',
]);
$response = $this->actingAs($user)->delete(route('admin.pages.subscribers.destroy', [$page, $subscriber]));
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
$this->assertDatabaseMissing('subscribers', ['id' => $subscriber->id]);
Http::assertSent(function (Request $request): bool {
return $request->method() === 'DELETE'
&& str_contains($request->url(), '/coupon/coupon-guid-test/codes/wzx-code-guid');
});
}
private function makePageForDestroyTest(User $user): PreregistrationPage
{
return PreregistrationPage::query()->create([
'slug' => (string) Str::uuid(),
'user_id' => $user->id,
'title' => 'Fest',
'heading' => 'Fest',
'intro_text' => null,
'thank_you_message' => null,
'expired_message' => null,
'ticketshop_url' => null,
'start_date' => now()->subDay(),
'end_date' => now()->addMonth(),
'phone_enabled' => false,
'background_image' => null,
'logo_image' => null,
'is_active' => true,
]);
}
}

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([

View File

@@ -170,7 +170,7 @@ class PublicPageTest extends TestCase
$response->assertJsonValidationErrors(['phone']);
}
public function test_subscribe_normalizes_phone_to_digits(): void
public function test_subscribe_stores_phone_as_e164(): void
{
$page = $this->makePage([
'start_date' => now()->subHour(),
@@ -189,7 +189,7 @@ class PublicPageTest extends TestCase
$this->assertDatabaseHas('subscribers', [
'preregistration_page_id' => $page->id,
'email' => 'phoneuser@example.com',
'phone' => '31612345678',
'phone' => '+31612345678',
]);
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Feature;
use App\Jobs\SyncSubscriberToMailwizz;
use App\Models\MailwizzConfig;
use App\Models\PreregistrationPage;
use App\Models\Subscriber;
@@ -12,6 +13,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Tests\TestCase;
@@ -80,6 +82,135 @@ class QueueUnsyncedMailwizzSubscribersTest extends TestCase
$response->assertForbidden();
}
public function test_owner_can_queue_single_subscriber_mailwizz_sync(): void
{
Queue::fake();
$user = User::factory()->create(['role' => 'user']);
$page = $this->makePageWithMailwizzForUser($user);
$subscriber = Subscriber::query()->create([
'preregistration_page_id' => $page->id,
'first_name' => 'One',
'last_name' => 'Off',
'email' => 'oneoff@example.com',
'synced_to_mailwizz' => true,
]);
$response = $this->actingAs($user)->post(
route('admin.pages.subscribers.sync-mailwizz', [$page, $subscriber])
);
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
$response->assertSessionHas('status');
Queue::assertPushed(SyncSubscriberToMailwizz::class, function (SyncSubscriberToMailwizz $job) use ($subscriber): bool {
return $job->subscriberId === $subscriber->id;
});
}
public function test_other_user_cannot_queue_single_subscriber_mailwizz_sync(): void
{
Queue::fake();
$owner = User::factory()->create(['role' => 'user']);
$intruder = User::factory()->create(['role' => 'user']);
$page = $this->makePageWithMailwizzForUser($owner);
$subscriber = Subscriber::query()->create([
'preregistration_page_id' => $page->id,
'first_name' => 'A',
'last_name' => 'B',
'email' => 'ab@example.com',
]);
$response = $this->actingAs($intruder)->post(
route('admin.pages.subscribers.sync-mailwizz', [$page, $subscriber])
);
$response->assertForbidden();
Queue::assertNothingPushed();
}
public function test_single_subscriber_mailwizz_sync_redirects_with_error_when_page_has_no_mailwizz(): void
{
Queue::fake();
$user = User::factory()->create(['role' => 'user']);
$page = 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()->subHour(),
'end_date' => now()->addMonth(),
'phone_enabled' => false,
'is_active' => true,
]);
$subscriber = Subscriber::query()->create([
'preregistration_page_id' => $page->id,
'first_name' => 'A',
'last_name' => 'B',
'email' => 'nomw@example.com',
]);
$response = $this->actingAs($user)->post(
route('admin.pages.subscribers.sync-mailwizz', [$page, $subscriber])
);
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
$response->assertSessionHas('error');
Queue::assertNothingPushed();
}
public function test_cannot_queue_single_subscriber_mailwizz_sync_with_mismatched_page(): void
{
Queue::fake();
$user = User::factory()->create(['role' => 'user']);
$pageA = $this->makePageWithMailwizzForUser($user);
$pageB = PreregistrationPage::query()->create([
'slug' => (string) Str::uuid(),
'user_id' => $user->id,
'title' => 'Other',
'heading' => 'Other',
'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,
]);
MailwizzConfig::query()->create([
'preregistration_page_id' => $pageB->id,
'api_key' => 'fake-api-key',
'list_uid' => 'list-uid-2',
'list_name' => 'List B',
'field_email' => 'EMAIL',
'field_first_name' => 'FNAME',
'field_last_name' => 'LNAME',
'field_phone' => null,
'tag_field' => 'TAGS',
'tag_value' => 'b-source',
]);
$subscriber = Subscriber::query()->create([
'preregistration_page_id' => $pageB->id,
'first_name' => 'A',
'last_name' => 'B',
'email' => 'on-b@example.com',
]);
$response = $this->actingAs($user)->post(
route('admin.pages.subscribers.sync-mailwizz', [$pageA, $subscriber])
);
$response->assertForbidden();
Queue::assertNothingPushed();
}
private function makePageWithMailwizz(): PreregistrationPage
{
$user = User::factory()->create(['role' => 'user']);

View File

@@ -32,9 +32,31 @@ class StorePreregistrationPageTest extends TestCase
$page = PreregistrationPage::query()->first();
$response->assertRedirect(route('admin.pages.edit', $page));
$this->assertSame('Summer Fest', $page?->title);
$this->assertFalse($page?->background_fixed);
$this->assertGreaterThanOrEqual(4, PageBlock::query()->where('preregistration_page_id', $page?->id)->count());
}
public function test_store_can_enable_fixed_background(): void
{
$user = User::factory()->create(['role' => 'user']);
$response = $this->actingAs($user)->post(route('admin.pages.store'), [
'title' => 'Winter Fest',
'thank_you_message' => null,
'expired_message' => null,
'ticketshop_url' => null,
'start_date' => '2026-06-01T10:00',
'end_date' => '2026-06-30T18:00',
'is_active' => true,
'background_fixed' => true,
]);
$page = PreregistrationPage::query()->where('title', 'Winter Fest')->first();
$response->assertRedirect(route('admin.pages.edit', $page));
$this->assertNotNull($page);
$this->assertTrue($page->background_fixed);
}
public function test_validation_failure_redirects_back_with_input(): void
{
$user = User::factory()->create(['role' => 'user']);

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Feature;
use App\Jobs\SyncSubscriberToMailwizz;
use App\Models\MailwizzConfig;
use App\Models\PreregistrationPage;
use App\Models\Subscriber;
@@ -18,6 +19,27 @@ class SyncSubscriberToMailwizzTest extends TestCase
{
use RefreshDatabase;
public function test_subscribe_returns_ok_when_mailwizz_api_fails_under_sync_queue(): void
{
Http::fake([
'*' => Http::response(['status' => 'error', 'message' => 'service unavailable'], 503),
]);
$page = $this->makePageWithMailwizz();
$this->postJson(route('public.subscribe', ['publicPage' => $page->slug]), [
'first_name' => 'Broken',
'last_name' => 'Mailwizz',
'email' => 'broken-mailwizz@example.com',
])
->assertOk()
->assertJson(['success' => true]);
$subscriber = Subscriber::query()->where('email', 'broken-mailwizz@example.com')->first();
$this->assertNotNull($subscriber);
$this->assertFalse($subscriber->synced_to_mailwizz);
}
public function test_subscribe_with_mailwizz_config_runs_sync_create_path_and_marks_synced(): void
{
Http::fake(function (Request $request) {
@@ -91,6 +113,84 @@ class SyncSubscriberToMailwizzTest extends TestCase
$this->assertTrue($subscriber->synced_to_mailwizz);
}
public function test_mailwizz_sync_sends_phone_with_e164_plus_prefix(): void
{
Http::fake(function (Request $request) {
$url = $request->url();
if (str_contains($url, 'search-by-email')) {
return Http::response(['status' => 'error']);
}
if ($request->method() === 'POST' && preg_match('#/lists/[^/]+/subscribers$#', $url) === 1) {
$body = $request->body();
$this->assertStringContainsString('PHONE', $body);
$this->assertTrue(
str_contains($body, '+31612345678') || str_contains($body, '%2B31612345678'),
'Expected E.164 phone with + in Mailwizz request body'
);
return Http::response(['status' => 'success']);
}
return Http::response(['status' => 'error'], 500);
});
$page = $this->makePageWithMailwizz([
'field_phone' => 'PHONE',
]);
$page->update(['phone_enabled' => true]);
$subscriber = Subscriber::query()->create([
'preregistration_page_id' => $page->id,
'first_name' => 'Test',
'last_name' => 'User',
'email' => 'phone-e164@example.com',
'phone' => '+31612345678',
'synced_to_mailwizz' => false,
]);
SyncSubscriberToMailwizz::dispatchSync($subscriber);
$subscriber->refresh();
$this->assertTrue($subscriber->synced_to_mailwizz);
}
public function test_mailwizz_sync_includes_coupon_code_when_mapped(): void
{
Http::fake(function (Request $request) {
$url = $request->url();
if (str_contains($url, 'search-by-email')) {
return Http::response(['status' => 'error']);
}
if ($request->method() === 'POST' && preg_match('#/lists/[^/]+/subscribers$#', $url) === 1) {
$body = $request->body();
$this->assertStringContainsString('COUPON', $body);
$this->assertStringContainsString('PREREG-TEST99', $body);
return Http::response(['status' => 'success']);
}
return Http::response(['status' => 'error'], 500);
});
$page = $this->makePageWithMailwizz([
'field_coupon_code' => 'COUPON',
]);
$subscriber = Subscriber::query()->create([
'preregistration_page_id' => $page->id,
'first_name' => 'Coupon',
'last_name' => 'User',
'email' => 'coupon-map@example.com',
'coupon_code' => 'PREREG-TEST99',
'synced_to_mailwizz' => false,
]);
SyncSubscriberToMailwizz::dispatchSync($subscriber);
$subscriber->refresh();
$this->assertTrue($subscriber->synced_to_mailwizz);
}
/**
* @param array<string, mixed> $configOverrides
*/
@@ -122,6 +222,7 @@ class SyncSubscriberToMailwizzTest extends TestCase
'field_first_name' => 'FNAME',
'field_last_name' => 'LNAME',
'field_phone' => null,
'field_coupon_code' => null,
'tag_field' => 'TAGS',
'tag_value' => 'preregister-source',
], $configOverrides));

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Models\Subscriber;
use Tests\TestCase;
class SubscriberPhoneDisplayTest extends TestCase
{
public function test_phone_display_keeps_e164_with_plus(): void
{
$subscriber = new Subscriber(['phone' => '+31613210095']);
$this->assertSame('+31613210095', $subscriber->phoneDisplay());
}
public function test_phone_display_prefixes_plus_for_legacy_digit_only_storage(): void
{
$subscriber = new Subscriber(['phone' => '31613210095']);
$this->assertSame('+31613210095', $subscriber->phoneDisplay());
}
public function test_phone_display_returns_null_when_empty(): void
{
$subscriber = new Subscriber(['phone' => null]);
$this->assertNull($subscriber->phoneDisplay());
}
}