Compare commits

..

13 Commits

Author SHA1 Message Date
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
32 changed files with 1911 additions and 735 deletions

View File

@@ -45,6 +45,10 @@ SESSION_DOMAIN=null
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local 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 QUEUE_CONNECTION=database
CACHE_STORE=database CACHE_STORE=database

View File

@@ -8,18 +8,29 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\UpdateMailwizzConfigRequest; use App\Http\Requests\Admin\UpdateMailwizzConfigRequest;
use App\Models\PreregistrationPage; use App\Models\PreregistrationPage;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\View\View; use Illuminate\View\View;
class MailwizzController extends Controller class MailwizzController extends Controller
{ {
public function edit(PreregistrationPage $page): View public function edit(Request $request, PreregistrationPage $page): View|RedirectResponse
{ {
$this->authorize('update', $page); $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 public function update(UpdateMailwizzConfigRequest $request, PreregistrationPage $page): RedirectResponse

View File

@@ -28,7 +28,7 @@ class PageController extends Controller
{ {
$query = PreregistrationPage::query() $query = PreregistrationPage::query()
->withCount('subscribers') ->withCount('subscribers')
->with('weeztixConfig') ->with(['weeztixConfig', 'mailwizzConfig'])
->orderByDesc('start_date'); ->orderByDesc('start_date');
if (! $request->user()?->isSuperadmin()) { if (! $request->user()?->isSuperadmin()) {
@@ -86,7 +86,11 @@ class PageController extends Controller
public function edit(PreregistrationPage $page): View 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')); return view('admin.pages.edit', compact('page'));
} }

View File

@@ -10,6 +10,7 @@ use App\Http\Requests\Admin\IndexSubscriberRequest;
use App\Http\Requests\Admin\QueueMailwizzSyncRequest; use App\Http\Requests\Admin\QueueMailwizzSyncRequest;
use App\Models\PreregistrationPage; use App\Models\PreregistrationPage;
use App\Models\Subscriber; use App\Models\Subscriber;
use App\Services\CleanupSubscriberIntegrationsService;
use App\Services\DispatchUnsyncedMailwizzSyncJobsService; use App\Services\DispatchUnsyncedMailwizzSyncJobsService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\View\View; use Illuminate\View\View;
@@ -34,8 +35,13 @@ class SubscriberController extends Controller
return view('admin.subscribers.index', compact('page', 'subscribers', 'unsyncedMailwizzCount')); 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(); $subscriber->delete();
return redirect() return redirect()

View File

@@ -13,38 +13,10 @@ use RuntimeException;
class WeeztixApiController extends Controller class WeeztixApiController extends Controller
{ {
public function companies(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);
}
try {
$companies = (new WeeztixService($config))->getCompanies();
return response()->json(['companies' => $companies]);
} catch (RuntimeException) {
return response()->json([
'message' => __('Kon bedrijven niet laden. Vernieuw de verbinding indien nodig.'),
], 422);
}
}
public function coupons(Request $request): JsonResponse public function coupons(Request $request): JsonResponse
{ {
$request->validate([ $request->validate([
'page_id' => ['required', 'integer', 'exists:preregistration_pages,id'], 'page_id' => ['required', 'integer', 'exists:preregistration_pages,id'],
'company_guid' => ['required', 'string', 'max:255'],
]); ]);
$page = PreregistrationPage::query()->findOrFail($request->integer('page_id')); $page = PreregistrationPage::query()->findOrFail($request->integer('page_id'));
@@ -57,9 +29,12 @@ class WeeztixApiController extends Controller
], 422); ], 422);
} }
$companyGuid = $request->string('company_guid')->toString(); $companyGuid = $config->company_guid;
$previousGuid = $config->company_guid; if (! is_string($companyGuid) || $companyGuid === '') {
$config->setAttribute('company_guid', $companyGuid); return response()->json([
'message' => __('Geen Weeztix-bedrijf gekoppeld. Verbind opnieuw met Weeztix.'),
], 422);
}
try { try {
$raw = (new WeeztixService($config))->getCoupons(); $raw = (new WeeztixService($config))->getCoupons();
@@ -70,8 +45,6 @@ class WeeztixApiController extends Controller
return response()->json([ return response()->json([
'message' => __('Kon kortingsbonnen niet laden.'), 'message' => __('Kon kortingsbonnen niet laden.'),
], 422); ], 422);
} finally {
$config->setAttribute('company_guid', $previousGuid);
} }
} }
@@ -95,6 +68,9 @@ class WeeztixApiController extends Controller
if (! is_array($row)) { if (! is_array($row)) {
continue; continue;
} }
if (! $this->couponRowHasEnabledStatus($row)) {
continue;
}
$guid = data_get($row, 'guid') ?? data_get($row, 'id'); $guid = data_get($row, 'guid') ?? data_get($row, 'id');
if (! is_string($guid) || $guid === '') { if (! is_string($guid) || $guid === '') {
continue; continue;
@@ -108,4 +84,16 @@ class WeeztixApiController extends Controller
return $out; 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

@@ -8,18 +8,34 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\UpdateWeeztixConfigRequest; use App\Http\Requests\Admin\UpdateWeeztixConfigRequest;
use App\Models\PreregistrationPage; use App\Models\PreregistrationPage;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\View\View; use Illuminate\View\View;
class WeeztixController extends Controller class WeeztixController extends Controller
{ {
public function edit(PreregistrationPage $page): View public function edit(Request $request, PreregistrationPage $page): View
{ {
$this->authorize('update', $page); $this->authorize('update', $page);
$page->load('weeztixConfig'); $page->load('weeztixConfig');
return view('admin.weeztix.edit', compact('page')); $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 public function update(UpdateWeeztixConfigRequest $request, PreregistrationPage $page): RedirectResponse
@@ -41,8 +57,26 @@ class WeeztixController extends Controller
); );
}); });
$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() return redirect()
->route('admin.pages.weeztix.edit', $page) ->route('admin.pages.weeztix.edit', ['page' => $page])
->with('status', __('Weeztix-configuratie opgeslagen.')); ->with('status', __('Weeztix-configuratie opgeslagen.'));
} }

View File

@@ -15,7 +15,7 @@ use RuntimeException;
class WeeztixOAuthController extends Controller class WeeztixOAuthController extends Controller
{ {
public function redirect(PreregistrationPage $page): RedirectResponse public function redirect(Request $request, PreregistrationPage $page): RedirectResponse
{ {
$this->authorize('update', $page); $this->authorize('update', $page);
@@ -38,6 +38,7 @@ class WeeztixOAuthController extends Controller
session([ session([
'weeztix_oauth_state' => $state, 'weeztix_oauth_state' => $state,
'weeztix_page_id' => $page->id, 'weeztix_page_id' => $page->id,
'weeztix_oauth_resume_wizard' => $request->boolean('wizard'),
]); ]);
$redirectUri = $config->redirect_uri; $redirectUri = $config->redirect_uri;
@@ -90,7 +91,7 @@ class WeeztixOAuthController extends Controller
$config = $page->weeztixConfig; $config = $page->weeztixConfig;
if ($config === null) { if ($config === null) {
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']); $this->forgetOauthSession();
return redirect() return redirect()
->route('admin.pages.weeztix.edit', $page) ->route('admin.pages.weeztix.edit', $page)
@@ -100,36 +101,62 @@ class WeeztixOAuthController extends Controller
try { try {
$service = new WeeztixService($config); $service = new WeeztixService($config);
$service->exchangeAuthorizationCode($request->string('code')->toString()); $service->exchangeAuthorizationCode($request->string('code')->toString());
$config = $config->fresh();
if ($config !== null) {
(new WeeztixService($config))->ensureCompanyStoredFromWeeztix();
}
} catch (RuntimeException $e) { } catch (RuntimeException $e) {
Log::error('Weeztix OAuth callback failed', [ Log::error('Weeztix OAuth callback failed', [
'page_id' => $page->id, 'page_id' => $page->id,
'message' => $e->getMessage(), 'message' => $e->getMessage(),
]); ]);
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']); $resumeWizard = $this->forgetOauthSession();
return redirect() return redirect()
->route('admin.pages.weeztix.edit', $page) ->route('admin.pages.weeztix.edit', $this->weeztixEditParams($page, $resumeWizard, 2))
->with('error', __('Verbinden met Weeztix is mislukt. Controleer je gegevens en probeer opnieuw.')); ->with('error', __('Verbinden met Weeztix is mislukt. Controleer je gegevens en probeer opnieuw.'));
} }
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']); $resumeWizard = $this->forgetOauthSession();
return redirect() return redirect()
->route('admin.pages.weeztix.edit', $page) ->route('admin.pages.weeztix.edit', $this->weeztixEditParams($page, $resumeWizard, 3))
->with('status', __('Succesvol verbonden met Weeztix.')); ->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 private function redirectToWeeztixEditWithSessionPage(string $message): RedirectResponse
{ {
$pageId = session('weeztix_page_id'); $pageId = session('weeztix_page_id');
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']); $resumeWizard = $this->forgetOauthSession();
if (is_int($pageId) || is_numeric($pageId)) { if (is_int($pageId) || is_numeric($pageId)) {
$page = PreregistrationPage::query()->find((int) $pageId); $page = PreregistrationPage::query()->find((int) $pageId);
if ($page !== null) { if ($page !== null) {
return redirect() return redirect()
->route('admin.pages.weeztix.edit', $page) ->route('admin.pages.weeztix.edit', $this->weeztixEditParams($page, $resumeWizard, 2))
->with('error', $message); ->with('error', $message);
} }
} }

View File

@@ -4,22 +4,20 @@ declare(strict_types=1);
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Exceptions\WeeztixCouponCodeConflictException;
use App\Http\Requests\SubscribePublicPageRequest; use App\Http\Requests\SubscribePublicPageRequest;
use App\Jobs\SyncSubscriberToMailwizz;
use App\Models\PageBlock; use App\Models\PageBlock;
use App\Models\PreregistrationPage; use App\Models\PreregistrationPage;
use App\Models\Subscriber; use App\Services\RegisterSubscriberOnPage;
use App\Models\WeeztixConfig;
use App\Services\WeeztixService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View; use Illuminate\View\View;
use Throwable;
class PublicPageController extends Controller class PublicPageController extends Controller
{ {
public function __construct(
private readonly RegisterSubscriberOnPage $registerSubscriberOnPage
) {}
public function show(PreregistrationPage $publicPage): View public function show(PreregistrationPage $publicPage): View
{ {
$publicPage->load(['blocks' => fn ($q) => $q->orderBy('sort_order')]); $publicPage->load(['blocks' => fn ($q) => $q->orderBy('sort_order')]);
@@ -56,72 +54,11 @@ class PublicPageController extends Controller
], 422); ], 422);
} }
$subscriber = $publicPage->subscribers()->create($validated); $this->registerSubscriberOnPage->storeAndQueueIntegrations($publicPage, $validated);
$publicPage->loadMissing('weeztixConfig');
$weeztix = $publicPage->weeztixConfig;
if ($this->weeztixCanIssueCodes($weeztix)) {
$this->tryAttachWeeztixCouponCode($subscriber, $weeztix);
}
if ($publicPage->mailwizzConfig !== null) {
SyncSubscriberToMailwizz::dispatch($subscriber->fresh());
}
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'message' => $publicPage->thank_you_message ?? __('Thank you for registering!'), 'message' => $publicPage->thank_you_message ?? __('Thank you for registering!'),
'coupon_code' => $subscriber->fresh()?->coupon_code,
]);
}
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

@@ -44,8 +44,6 @@ class UpdateWeeztixConfigRequest extends FormRequest
'string', 'string',
'max:2048', 'max:2048',
], ],
'company_guid' => ['nullable', 'string', 'max:255'],
'company_name' => ['nullable', 'string', 'max:255'],
'coupon_guid' => ['nullable', 'string', 'max:255'], 'coupon_guid' => ['nullable', 'string', 'max:255'],
'coupon_name' => ['nullable', 'string', 'max:255'], 'coupon_name' => ['nullable', 'string', 'max:255'],
'code_prefix' => ['nullable', 'string', 'max:32'], 'code_prefix' => ['nullable', 'string', 'max:32'],

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\MailwizzConfig;
use App\Models\Subscriber; use App\Models\Subscriber;
use App\Services\MailwizzCheckboxlistTags;
use App\Services\MailwizzService; use App\Services\MailwizzService;
use App\Services\MailwizzSubscriberFormPayload;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use RuntimeException; use RuntimeException;
use Throwable; use Throwable;
class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueueAfterCommit
{ {
use Dispatchable; use Dispatchable;
use InteractsWithQueue; use InteractsWithQueue;
use Queueable; use Queueable;
use SerializesModels;
/** /**
* Seconds before the unique lock expires if the worker dies before releasing it. * 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 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'); $this->onQueue('mailwizz');
} }
public function uniqueId(): string public function uniqueId(): string
{ {
return (string) $this->subscriber->getKey(); return $this->subscriberId !== null
? (string) $this->subscriberId
: 'stale-mailwizz-sync-payload';
} }
public function handle(): void 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() $subscriber = Subscriber::query()
->with(['preregistrationPage.mailwizzConfig']) ->with(['preregistrationPage.mailwizzConfig'])
->find($this->subscriber->id); ->find($this->subscriberId);
if ($subscriber === null) { if ($subscriber === null) {
return; return;
@@ -101,7 +132,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
public function failed(?Throwable $exception): void public function failed(?Throwable $exception): void
{ {
Log::error('SyncSubscriberToMailwizz failed', [ Log::error('SyncSubscriberToMailwizz failed', [
'subscriber_id' => $this->subscriber->id, 'subscriber_id' => $this->subscriberId,
'message' => $exception?->getMessage(), 'message' => $exception?->getMessage(),
]); ]);
} }
@@ -119,29 +150,6 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
return true; 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->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;
}
private function createInMailwizz( private function createInMailwizz(
MailwizzService $service, MailwizzService $service,
Subscriber $subscriber, Subscriber $subscriber,
@@ -149,7 +157,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
string $listUid string $listUid
): void { ): void {
$page = $subscriber->preregistrationPage; $page = $subscriber->preregistrationPage;
$data = $this->buildBasePayload($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers()); $data = MailwizzSubscriberFormPayload::baseFields($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
$tagField = $config->tag_field; $tagField = $config->tag_field;
$tagValue = $config->tag_value; $tagValue = $config->tag_value;
if ($tagField !== null && $tagField !== '' && $tagValue !== null && $tagValue !== '') { if ($tagField !== null && $tagField !== '' && $tagValue !== null && $tagValue !== '') {
@@ -167,7 +175,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
string $subscriberUid string $subscriberUid
): void { ): void {
$page = $subscriber->preregistrationPage; $page = $subscriber->preregistrationPage;
$data = $this->buildBasePayload($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers()); $data = MailwizzSubscriberFormPayload::baseFields($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
$tagField = $config->tag_field; $tagField = $config->tag_field;
$tagValue = $config->tag_value; $tagValue = $config->tag_value;
@@ -176,46 +184,11 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
if ($full === null) { if ($full === null) {
throw new RuntimeException('Mailwizz getSubscriber returned an empty payload.'); throw new RuntimeException('Mailwizz getSubscriber returned an empty payload.');
} }
$existingCsv = $this->extractTagCsvFromResponse($full, $tagField); $existingCsv = MailwizzCheckboxlistTags::extractCsvFromSubscriberResponse($full, $tagField);
$merged = $this->mergeCheckboxlistTags($existingCsv, $tagValue); $merged = MailwizzCheckboxlistTags::mergeValueIntoCsv($existingCsv, $tagValue);
$data[$tagField] = $merged; $data[$tagField] = $merged;
} }
$service->updateSubscriber($listUid, $subscriberUid, $data); $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

@@ -204,4 +204,56 @@ class PreregistrationPage extends Model
return 'active'; 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

@@ -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 = []; $out = [];
foreach ($data as $key => $value) { foreach ($data as $key => $value) {
if (is_array($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; 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,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

@@ -13,6 +13,7 @@ use Illuminate\Support\Facades\Log;
use InvalidArgumentException; use InvalidArgumentException;
use LogicException; use LogicException;
use RuntimeException; use RuntimeException;
use Throwable;
final class WeeztixService final class WeeztixService
{ {
@@ -84,6 +85,39 @@ final class WeeztixService
return $this->normalizeCompaniesFromProfile($json); 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). * Exchange OAuth authorization code for tokens (admin callback).
*/ */
@@ -216,6 +250,159 @@ final class WeeztixService
throw new RuntimeException('Weeztix API rate limited after retries.'); 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 public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string
{ {
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';

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 ### 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: 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 * * * * * cd /home/username/preregister && php artisan schedule:run >> /dev/null 2>&1
# Queue worker - process one job per run (every minute) # 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 ### 4.8 Directory permissions
```bash ```bash

View File

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

View File

@@ -420,7 +420,6 @@ document.addEventListener('alpine:init', () => {
redirectSecondsLeft: null, redirectSecondsLeft: null,
redirectTimer: null, redirectTimer: null,
strings: config.strings || {}, strings: config.strings || {},
couponCode: '',
copyPageLink() { copyPageLink() {
const url = this.pageShareUrl; const url = this.pageShareUrl;
@@ -435,19 +434,6 @@ document.addEventListener('alpine:init', () => {
}); });
}, },
copyCouponCode() {
const code = this.couponCode;
if (!code) {
return;
}
navigator.clipboard.writeText(code).then(() => {
this.copyFeedback = this.strings?.couponCopied || '';
setTimeout(() => {
this.copyFeedback = '';
}, 2500);
});
},
init() { init() {
if (this.phase === 'before') { if (this.phase === 'before') {
this.tickCountdown(); this.tickCountdown();
@@ -566,17 +552,24 @@ document.addEventListener('alpine:init', () => {
if (res.ok && data.success) { if (res.ok && data.success) {
this.phase = 'thanks'; this.phase = 'thanks';
this.thankYouMessage = data.message ?? ''; this.thankYouMessage = data.message ?? '';
this.couponCode =
typeof data.coupon_code === 'string' && data.coupon_code !== '' ? data.coupon_code : '';
this.startRedirectCountdownIfNeeded(); this.startRedirectCountdownIfNeeded();
return; 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; this.formError = data.message;
} }
if (data.errors !== undefined && data.errors !== null && typeof data.errors === 'object') { if (hasFieldErrors) {
this.fieldErrors = data.errors; this.fieldErrors = data.errors;
} }
if (!res.ok && !hasServerMessage && !hasFieldErrors) {
this.formError = this.genericError;
}
} catch { } catch {
this.formError = this.genericError; this.formError = this.genericError;
} finally { } finally {
@@ -769,20 +762,17 @@ document.addEventListener('alpine:init', () => {
Alpine.data('weeztixSetup', (cfg) => ({ Alpine.data('weeztixSetup', (cfg) => ({
pageId: cfg.pageId, pageId: cfg.pageId,
companiesUrl: cfg.companiesUrl,
couponsUrl: cfg.couponsUrl, couponsUrl: cfg.couponsUrl,
csrf: cfg.csrf, csrf: cfg.csrf,
isConnected: cfg.isConnected === true, isConnected: cfg.isConnected === true,
callbackUrl: cfg.callbackUrl, callbackUrl: cfg.callbackUrl,
errorMessage: '', errorMessage: '',
companies: [],
coupons: [], coupons: [],
companyGuid: '',
companyName: '',
couponGuid: '', couponGuid: '',
couponName: '', couponName: '',
codePrefix: 'PREREG', codePrefix: 'PREREG',
usageCount: 1, usageCount: 1,
couponsRefreshing: false,
strings: cfg.strings || {}, strings: cfg.strings || {},
async init() { async init() {
@@ -797,19 +787,12 @@ document.addEventListener('alpine:init', () => {
} else { } else {
this.usageCount = 1; this.usageCount = 1;
} }
this.companyGuid = cfg.existing.company_guid || '';
this.companyName = cfg.existing.company_name || '';
this.couponGuid = cfg.existing.coupon_guid || ''; this.couponGuid = cfg.existing.coupon_guid || '';
this.couponName = cfg.existing.coupon_name || ''; this.couponName = cfg.existing.coupon_name || '';
} }
if (this.isConnected) { if (this.isConnected) {
await this.loadCompanies(); await this.loadCoupons();
if (this.companyGuid) { } else if (cfg.existing && cfg.existing.coupon_guid) {
await this.loadCouponsForGuid(this.companyGuid);
}
} else if (cfg.existing && (cfg.existing.company_guid || cfg.existing.coupon_guid)) {
// Show saved choices even when not connected (e.g. expired refresh); lists are from DB only.
this.ensureSelectedCompanyInList();
this.ensureSelectedCouponInList(); this.ensureSelectedCouponInList();
} }
}, },
@@ -829,18 +812,6 @@ document.addEventListener('alpine:init', () => {
return { res, data }; return { res, data };
}, },
syncCompanyNameFromSelection() {
if (!this.companyGuid) {
this.companyName = '';
return;
}
const c = this.companies.find((x) => x.guid === this.companyGuid);
if (c && typeof c.name === 'string' && c.name.trim() !== '') {
this.companyName = c.name.trim();
}
// If API row has no name or list is still loading, keep companyName from server (DB).
},
syncCouponName() { syncCouponName() {
if (!this.couponGuid) { if (!this.couponGuid) {
this.couponName = ''; this.couponName = '';
@@ -852,18 +823,6 @@ document.addEventListener('alpine:init', () => {
} }
}, },
ensureSelectedCompanyInList() {
const guid = this.companyGuid;
if (!guid || this.companies.some((x) => x.guid === guid)) {
return;
}
const label =
typeof this.companyName === 'string' && this.companyName.trim() !== ''
? this.companyName.trim()
: guid;
this.companies = [{ guid, name: label }, ...this.companies];
},
ensureSelectedCouponInList() { ensureSelectedCouponInList() {
const guid = this.couponGuid; const guid = this.couponGuid;
if (!guid || this.coupons.some((x) => x.guid === guid)) { if (!guid || this.coupons.some((x) => x.guid === guid)) {
@@ -876,36 +835,9 @@ document.addEventListener('alpine:init', () => {
this.coupons = [{ guid, name: label }, ...this.coupons]; this.coupons = [{ guid, name: label }, ...this.coupons];
}, },
async loadCompanies() { async loadCoupons() {
this.errorMessage = ''; this.errorMessage = '';
const { res, data } = await this.postJson(this.companiesUrl, { page_id: this.pageId }); const { res, data } = await this.postJson(this.couponsUrl, { page_id: this.pageId });
if (!res.ok) {
this.errorMessage = data.message || this.strings.genericError;
this.ensureSelectedCompanyInList();
return;
}
this.companies = Array.isArray(data.companies) ? data.companies : [];
this.ensureSelectedCompanyInList();
this.syncCompanyNameFromSelection();
},
async onCompanyChange() {
this.syncCompanyNameFromSelection();
this.couponGuid = '';
this.couponName = '';
this.coupons = [];
if (!this.companyGuid) {
return;
}
await this.loadCouponsForGuid(this.companyGuid);
},
async loadCouponsForGuid(guid) {
this.errorMessage = '';
const { res, data } = await this.postJson(this.couponsUrl, {
page_id: this.pageId,
company_guid: guid,
});
if (!res.ok) { if (!res.ok) {
this.errorMessage = data.message || this.strings.loadCouponsError; this.errorMessage = data.message || this.strings.loadCouponsError;
this.ensureSelectedCouponInList(); this.ensureSelectedCouponInList();
@@ -916,35 +848,16 @@ document.addEventListener('alpine:init', () => {
this.syncCouponName(); this.syncCouponName();
}, },
/** Prefer human-readable label; skip API "names" that are just the GUID / UUID. */ async refreshCoupons() {
companyLabel(c) { if (!this.isConnected) {
if (!c || typeof c.guid !== 'string') { return;
return '';
} }
const g = c.guid; this.couponsRefreshing = true;
const isBadLabel = (s) => { try {
const t = typeof s === 'string' ? s.trim() : ''; await this.loadCoupons();
return ( } finally {
t === '' || this.couponsRefreshing = false;
t.toLowerCase() === g.toLowerCase() ||
this.stringLooksLikeUuid(t)
);
};
const fromApi = typeof c.name === 'string' ? c.name : '';
if (!isBadLabel(fromApi)) {
return fromApi.trim();
} }
if (this.companyGuid === g && !isBadLabel(this.companyName)) {
return String(this.companyName).trim();
}
return g;
},
stringLooksLikeUuid(s) {
return /^[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}$/.test(
String(s),
);
}, },
})); }));
}); });

View File

@@ -2,6 +2,7 @@
$config = $page->mailwizzConfig; $config = $page->mailwizzConfig;
$page->loadMissing('weeztixConfig'); $page->loadMissing('weeztixConfig');
$hasWeeztixForCouponMap = $page->weeztixConfig !== null && $page->weeztixConfig->is_connected; $hasWeeztixForCouponMap = $page->weeztixConfig !== null && $page->weeztixConfig->is_connected;
$mailwizzStatus = $page->mailwizzIntegrationStatus();
$existing = $config !== null $existing = $config !== null
? [ ? [
'list_uid' => $config->list_uid, 'list_uid' => $config->list_uid,
@@ -24,30 +25,15 @@
@section('mobile_title', __('Mailwizz')) @section('mobile_title', __('Mailwizz'))
@section('content') @section('content')
<div class="mx-auto max-w-3xl" x-data="mailwizzWizard(@js([ <div class="mx-auto max-w-3xl">
'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.'),
],
]))">
<div class="mb-8"> <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> <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> <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> <p class="mt-2 text-sm text-slate-600">{{ __('Page:') }} <span class="font-medium text-slate-800">{{ $page->title }}</span></p>
</div> </div>
@include('admin.pages._save_flash')
@if ($errors->any()) @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"> <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> <p class="font-medium">{{ __('Please fix the following:') }}</p>
@@ -59,219 +45,347 @@
</div> </div>
@endif @endif
@if ($config !== null) @if (! $showWizard && $config !== null)
<div class="mb-8 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900"> @if ($mailwizzStatus !== 'ready')
<p class="font-medium">{{ __('Integration active') }}</p> <div class="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
<p class="mt-1 text-emerald-800"> <p class="font-medium">{{ __('Setup incomplete') }}</p>
{{ __('List:') }} <p class="mt-1 text-amber-900">{{ __('Run the wizard again to finish Mailwizz (API key, list, and field mapping).') }}</p>
<span class="font-mono text-xs">{{ $config->list_name ?: $config->list_uid }}</span> </div>
</p> @endif
<form action="{{ route('admin.pages.mailwizz.destroy', $page) }}" method="post" class="mt-3"
<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.')));"> onsubmit="return confirm(@js(__('Remove Mailwizz integration for this page? Subscribers will stay in the database but will no longer sync.')));">
@csrf @csrf
@method('DELETE') @method('DELETE')
<button type="submit" class="text-sm font-semibold text-red-700 underline hover:text-red-800"> <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">
{{ __('Remove integration') }} {{ __('Disconnect Mailwizz') }}
</button> </button>
</form> </form>
</div> </div>
@endif
<div class="mb-6 flex flex-wrap gap-2 text-xs font-medium text-slate-500"> <div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
<span :class="step >= 1 ? 'text-indigo-600' : ''">1. {{ __('API key') }}</span> <h2 class="text-lg font-semibold text-slate-900">{{ __('Current configuration') }}</h2>
<span aria-hidden="true"></span> <p class="mt-1 text-sm text-slate-600">{{ __('The API key is stored encrypted and is not shown here.') }}</p>
<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"> <dl class="mt-6 space-y-4 border-t border-slate-100 pt-6 text-sm">
<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> <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 --}} <h3 class="mt-8 border-t border-slate-100 pt-6 text-sm font-semibold text-slate-900">{{ __('Field mapping') }}</h3>
<div x-show="step === 1" x-cloak class="space-y-4"> <p class="mt-1 text-xs text-slate-500">{{ __('Mailwizz custom fields are matched by tag.') }}</p>
<p class="text-sm leading-relaxed text-slate-600"> <dl class="mt-4 space-y-4 text-sm">
{{ __('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.') }} <div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
</p> <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) @if ($config !== null)
<p class="text-sm text-amber-800"> <div class="mb-6 flex flex-wrap items-center gap-3">
{{ __('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.') }} <a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-sm font-medium text-slate-600 hover:text-slate-900">
</p> {{ __('Cancel and return to overview') }}
</a>
</div>
@endif @endif
<div>
<label for="mailwizz_api_key" class="block text-sm font-medium text-slate-700">{{ __('Mailwizz API key') }}</label> <div class="mb-8 flex flex-wrap items-center gap-2" aria-label="{{ __('Wizard steps') }}">
<input <span
id="mailwizz_api_key" class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium"
type="password" :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')"
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> <span class="tabular-nums">1</span>
<button {{ __('API key') }}
type="button" </span>
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" <span class="text-slate-300" aria-hidden="true"></span>
:disabled="loading" <span
@click="connectLists()" class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium"
> :class="step === 2 ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : (step > 2 ? 'border-emerald-200 bg-emerald-50 text-emerald-900' : 'border-slate-200 bg-slate-50 text-slate-500')"
<span 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"
> >
<option value="">{{ __('Select a list…') }}</option> <span class="tabular-nums">2</span>
<template x-for="list in lists" :key="list.list_uid"> {{ __('List') }}
<option :value="list.list_uid" x-text="list.name"></option> </span>
</template> <span class="text-slate-300" aria-hidden="true"></span>
</select> <span
</div> class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium"
<div class="flex flex-wrap gap-3"> :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')"
<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 class="tabular-nums">3</span>
<span x-show="loading" x-cloak>{{ __('Loading') }}</span> {{ __('Field mapping') }}
</button> </span>
</div> <span class="text-slate-300" aria-hidden="true"></span>
</div> <span
class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium"
{{-- Step 3 --}} :class="step === 4 ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : 'border-slate-200 bg-slate-50 text-slate-500'"
<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 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> <span class="tabular-nums">4</span>
<template x-for="f in checkboxFields()" :key="'cb-' + f.tag"> {{ __('Tag / source') }}
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option> </span>
</template>
</select>
</div> </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"> <div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
<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"> <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>
{{ __('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 --}} {{-- Step 1 --}}
<div x-show="step === 4" x-cloak class="space-y-5"> <div x-show="step === 1" x-cloak class="space-y-4">
<p class="text-sm text-slate-600">{{ __('Choose the checkbox option that marks subscribers from this pre-registration page.') }}</p> <h2 class="text-lg font-semibold text-slate-900">{{ __('Step 1: API key') }}</h2>
<p class="text-sm leading-relaxed text-slate-600">
<fieldset class="space-y-2"> {{ __('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.') }}
<legend class="sr-only">{{ __('Tag value') }}</legend> </p>
<template x-for="opt in tagOptionsList()" :key="opt.key"> @if ($config !== null)
<label class="flex cursor-pointer items-start gap-3 rounded-lg border border-slate-200 p-3 hover:bg-slate-50"> <p class="text-sm text-amber-800">
<input type="radio" name="tag_value_choice" class="mt-1 text-indigo-600" :value="opt.key" x-model="tagValue"> {{ __('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.') }}
<span class="text-sm text-slate-800" x-text="opt.label"></span> </p>
</label> @endif
</template> <div>
</fieldset> <label for="mailwizz_api_key" class="block text-sm font-medium text-slate-700">{{ __('Mailwizz API key') }}</label>
<p x-show="tagField && tagOptionsList().length === 0" class="text-sm text-amber-800"> <input
{{ __('This field has no options defined in Mailwizz. Add options to the checkbox list field, then reload fields.') }} id="mailwizz_api_key"
</p> type="password"
autocomplete="off"
<form x-ref="saveForm" method="post" action="{{ route('admin.pages.mailwizz.update', $page) }}" class="space-y-4"> x-model="apiKey"
@csrf 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"
@method('PUT') placeholder="{{ __('Paste API key') }}"
<input type="hidden" name="api_key" x-bind:value="apiKey"> >
<input type="hidden" name="list_uid" x-bind:value="selectedListUid"> </div>
<input type="hidden" name="list_name" x-bind:value="selectedListName"> <button
<input type="hidden" name="field_email" x-bind:value="fieldEmail"> type="button"
<input type="hidden" name="field_first_name" x-bind:value="fieldFirstName"> 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"
<input type="hidden" name="field_last_name" x-bind:value="fieldLastName"> :disabled="loading"
<input type="hidden" name="field_phone" x-bind:value="phoneEnabled ? fieldPhone : ''"> @click="connectLists()"
<input type="hidden" name="field_coupon_code" x-bind:value="hasWeeztixIntegration ? fieldCouponCode : ''"> >
<input type="hidden" name="tag_field" x-bind:value="tagField"> <span x-show="!loading">{{ __('Connect & load lists') }}</span>
<input type="hidden" name="tag_value" x-bind:value="tagValue"> <span x-show="loading" x-cloak>{{ __('Connecting…') }}</span>
<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> </button>
</div> </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>
</div> @endif
</div> </div>
@endsection @endsection

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,10 +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> {{ __('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> </p>
@can('update', $page) @can('update', $page)
<p class="mt-3 flex flex-wrap gap-x-4 gap-y-1"> <div class="mt-6 grid gap-4 sm:grid-cols-2">
<a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">{{ __('Mailwizz integration') }} </a> <a
<a href="{{ route('admin.pages.weeztix.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">{{ __('Weeztix integration') }} </a> href="{{ route('admin.pages.mailwizz.edit', $page) }}"
</p> 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 @endcan
</div> </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">{{ __('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">{{ __('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">{{ __('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> <th scope="col" class="px-4 py-3 text-right font-semibold text-slate-700">{{ __('Actions') }}</th>
</tr> </tr>
</thead> </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->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 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="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"> <td class="whitespace-nowrap px-4 py-3 text-right">
<div class="flex flex-wrap items-center justify-end gap-2"> <div class="flex flex-wrap items-center justify-end gap-2">
@can('update', $page) @can('update', $page)
@@ -77,9 +81,6 @@
@endcan @endcan
@can('update', $page) @can('update', $page)
<a href="{{ route('admin.pages.weeztix.edit', $page) }}" class="text-indigo-600 hover:text-indigo-500">{{ __('Weeztix') }}</a> <a href="{{ route('admin.pages.weeztix.edit', $page) }}" class="text-indigo-600 hover:text-indigo-500">{{ __('Weeztix') }}</a>
@if ($page->weeztixConfig?->is_connected)
<span class="text-xs font-medium text-emerald-600" title="{{ __('Weeztix verbonden') }}"></span>
@endif
@endcan @endcan
<button <button
type="button" type="button"
@@ -103,7 +104,7 @@
</tr> </tr>
@empty @empty
<tr> <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.') }} {{ __('No pages yet.') }}
@can('create', \App\Models\PreregistrationPage::class) @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> <a href="{{ route('admin.pages.create') }}" class="font-medium text-indigo-600 hover:text-indigo-500">{{ __('Create one') }}</a>

View File

@@ -3,14 +3,14 @@
$wz = $page->weeztixConfig; $wz = $page->weeztixConfig;
$existing = $wz !== null $existing = $wz !== null
? [ ? [
'company_guid' => $wz->company_guid,
'company_name' => $wz->company_name,
'coupon_guid' => $wz->coupon_guid, 'coupon_guid' => $wz->coupon_guid,
'coupon_name' => $wz->coupon_name, 'coupon_name' => $wz->coupon_name,
'code_prefix' => $wz->code_prefix, 'code_prefix' => $wz->code_prefix,
'usage_count' => $wz->usage_count, 'usage_count' => $wz->usage_count,
] ]
: null; : null;
$credentialsEdit = ! $hasStoredCredentials || request()->query('credentials') === 'edit';
$oauthUrl = route('admin.pages.weeztix.oauth.redirect', ['page' => $page, 'wizard' => 1]);
@endphp @endphp
@extends('layouts.admin') @extends('layouts.admin')
@@ -20,24 +20,7 @@
@section('mobile_title', __('Weeztix')) @section('mobile_title', __('Weeztix'))
@section('content') @section('content')
<div <div class="mx-auto max-w-3xl">
class="mx-auto max-w-3xl"
x-data="weeztixSetup(@js([
'pageId' => $page->id,
'companiesUrl' => route('admin.weeztix.companies'),
'couponsUrl' => route('admin.weeztix.coupons'),
'csrf' => csrf_token(),
'isConnected' => $wz?->is_connected ?? false,
'tokenExpiresAt' => $wz?->token_expires_at instanceof Carbon ? $wz->token_expires_at->timezone(config('app.timezone'))->format('Y-m-d H:i') : null,
'callbackUrl' => route('admin.weeztix.callback', absolute: true),
'existing' => $existing,
'strings' => [
'genericError' => __('Er ging iets mis. Probeer het opnieuw.'),
'selectCompany' => __('Selecteer een bedrijf.'),
'loadCouponsError' => __('Kon kortingsbonnen niet laden.'),
],
]))"
>
<div class="mb-8"> <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> <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> <h1 class="mt-4 text-2xl font-semibold text-slate-900">{{ __('Weeztix') }}</h1>
@@ -57,178 +40,312 @@
</div> </div>
@endif @endif
@if ($wz !== null && $wz->is_connected) @if (! $showWizard && $wz !== null)
<div class="mb-8 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900"> {{-- Summary (read-only): change only via wizard --}}
<p class="font-medium">{{ __('Verbonden met Weeztix') }}</p> @if ($wz->is_connected && ($wz->company_guid === null || $wz->company_guid === ''))
@if ($wz->token_expires_at) <div class="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
<p class="mt-1 text-emerald-800"> <p class="font-medium">{{ __('Bedrijf nog niet vastgelegd') }}</p>
{{ __('Huidig toegangstoken tot:') }} <p class="mt-1 text-amber-900">{{ __('Start de wizard en verbind opnieuw met Weeztix zodat het bedrijf automatisch wordt gekoppeld.') }}</p>
<span class="font-mono text-xs">{{ $wz->token_expires_at->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</span> </div>
</p> @endif
<p class="mt-2 text-emerald-800/90">
{{ __('Het toegangstoken wordt automatisch vernieuwd bij API-gebruik (o.a. kortingscodes), zolang de refresh-token geldig is. Je hoeft niet opnieuw te verbinden.') }}
</p>
@endif
</div>
@elseif ($wz !== null && ! $wz->is_connected)
<div class="mb-8 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
<p class="font-medium">{{ __('Niet verbonden') }}</p>
<p class="mt-1 text-amber-900">{{ __('Je moet opnieuw verbinden om kortingscodes aan te maken. Gebruik de knop “Verbind met Weeztix”.') }}</p>
</div>
@endif
@if ($wz !== null) <div class="mb-6 flex flex-wrap items-center gap-3">
<form action="{{ route('admin.pages.weeztix.destroy', $page) }}" method="post" class="mb-8" <a
onsubmit="return confirm(@js(__('Weeztix-integratie voor deze pagina verwijderen? Opgeslagen tokens worden gewist.')));"> href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]) }}"
@csrf 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"
@method('DELETE') >
<button type="submit" class="text-sm font-semibold text-red-700 underline hover:text-red-800"> {{ __('Instellingen wijzigen (wizard)') }}
{{ __('Weeztix loskoppelen') }} </a>
</button> <form action="{{ route('admin.pages.weeztix.destroy', $page) }}" method="post" class="inline"
</form> onsubmit="return confirm(@js(__('Weeztix-integratie voor deze pagina verwijderen? Opgeslagen tokens worden gewist.')));">
@endif
<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>
<section class="space-y-4 border-b border-slate-100 pb-8">
<h2 class="text-lg font-semibold text-slate-900">{{ __('Stap 1: OAuth-gegevens') }}</h2>
<p class="text-sm leading-relaxed text-slate-600">
{{ __('Maak eerst een OAuth-client in het Weeztix-dashboard en stel de redirect-URI exact in op:') }}
</p>
<p class="rounded-lg bg-slate-50 px-3 py-2 font-mono text-xs text-slate-800 break-all" x-text="callbackUrl"></p>
<p class="text-sm text-slate-600">
{{ __('Maak daarna een korting (coupon) in Weeztix; die kies je hierna in stap 2.') }}
</p>
<form method="post" action="{{ route('admin.pages.weeztix.update', $page) }}" class="space-y-4">
@csrf @csrf
@method('PUT') @method('DELETE')
<div> <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">
<label for="weeztix_client_id" class="block text-sm font-medium text-slate-700">{{ __('Client ID') }}</label> {{ __('Weeztix loskoppelen') }}
<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">
{{ __('Client ID en secret zijn opgeslagen maar worden niet opnieuw getoond. Laat de velden leeg om ze te behouden; vul ze alleen in als je ze wilt wijzigen.') }}
</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">
{{ __('Gegevens opslaan') }}
</button> </button>
</form> </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) @if ($wz !== null)
<div class="pt-2"> <a href="{{ route('admin.pages.weeztix.edit', ['page' => $page]) }}" class="text-sm font-medium text-slate-600 hover:text-slate-900">
<a {{ __('Annuleren en terug naar overzicht') }}
href="{{ route('admin.pages.weeztix.oauth.redirect', $page) }}" </a>
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" @endif
> </div>
{{ __('Verbind met Weeztix') }}
</a> <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> </div>
@endif @endif
</section> </div>
@endif
@if ($wz !== null)
<section class="space-y-4 pt-8">
<h2 class="text-lg font-semibold text-slate-900">{{ __('Stap 2: Bedrijf en kortingsbon') }}</h2>
<p class="text-sm text-slate-600">{{ __('Na een geslaagde verbinding kun je een bedrijf en bestaande coupon uit Weeztix kiezen.') }}</p>
<form method="post" action="{{ route('admin.pages.weeztix.update', $page) }}" class="space-y-4">
@csrf
@method('PUT')
<div>
<label for="weeztix_company" class="block text-sm font-medium text-slate-700">{{ __('Bedrijf') }}</label>
<select
id="weeztix_company"
name="company_guid"
x-model="companyGuid"
@change="onCompanyChange()"
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 bedrijf…') }}</option>
<template x-for="c in companies" :key="c.guid">
<option :value="c.guid" x-text="companyLabel(c)"></option>
</template>
</select>
<input type="hidden" name="company_name" :value="companyName">
</div>
<div>
<label for="weeztix_coupon" class="block text-sm font-medium text-slate-700">{{ __('Coupon (kortingssjabloon)') }}</label>
<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>
<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">
{{ __('Configuratie opslaan') }}
</button>
</form>
</section>
@endif
</div>
</div> </div>
@endsection @endsection

View File

@@ -156,30 +156,6 @@
</div> </div>
</div> </div>
<p class="whitespace-pre-line text-center text-base leading-relaxed text-white/95 sm:text-lg" x-text="thankYouMessage"></p> <p class="whitespace-pre-line text-center text-base leading-relaxed text-white/95 sm:text-lg" x-text="thankYouMessage"></p>
<template x-if="couponCode">
<div class="mt-6 rounded-xl border border-white/20 bg-white/10 p-6 backdrop-blur">
<p class="text-center text-sm text-white/70">{{ __('Jouw kortingscode') }}</p>
<div class="mt-3 flex flex-wrap items-center justify-center gap-3">
<span
class="font-mono text-2xl font-bold tracking-wider text-festival"
x-text="couponCode"
></span>
<button
type="button"
class="rounded-lg border border-white/25 p-2 text-white/70 transition hover:border-white/40 hover:text-white"
@click="copyCouponCode()"
aria-label="{{ __('Kortingscode kopiëren') }}"
>
<svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
</svg>
</button>
</div>
<p class="mt-3 text-center text-xs text-white/50">
{{ __('Gebruik deze code bij het afrekenen in de ticketshop.') }}
</p>
</div>
</template>
<p <p
x-show="redirectSecondsLeft !== null && redirectSecondsLeft > 0" x-show="redirectSecondsLeft !== null && redirectSecondsLeft > 0"
x-cloak x-cloak

View File

@@ -71,7 +71,6 @@
'redirectUrl' => filled($redirectAfterSubmit) ? $redirectAfterSubmit : null, 'redirectUrl' => filled($redirectAfterSubmit) ? $redirectAfterSubmit : null,
'strings' => [ 'strings' => [
'linkCopied' => __('Link gekopieerd!'), 'linkCopied' => __('Link gekopieerd!'),
'couponCopied' => __('Kortingscode gekopieerd!'),
'redirectCountdown' => __('You will be redirected in :seconds s…'), 'redirectCountdown' => __('You will be redirected in :seconds s…'),
], ],
]))" ]))"

View File

@@ -56,7 +56,6 @@ Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(
Route::get('pages/{page}/weeztix/oauth/redirect', [WeeztixOAuthController::class, 'redirect'])->name('pages.weeztix.oauth.redirect'); 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::get('weeztix/callback', [WeeztixOAuthController::class, 'callback'])->name('weeztix.callback');
Route::post('weeztix/companies', [WeeztixApiController::class, 'companies'])->name('weeztix.companies');
Route::post('weeztix/coupons', [WeeztixApiController::class, 'coupons'])->name('weeztix.coupons'); Route::post('weeztix/coupons', [WeeztixApiController::class, 'coupons'])->name('weeztix.coupons');
// User management (superadmin only) // User management (superadmin only)

View File

@@ -4,10 +4,14 @@ declare(strict_types=1);
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\MailwizzConfig;
use App\Models\PreregistrationPage; use App\Models\PreregistrationPage;
use App\Models\Subscriber; use App\Models\Subscriber;
use App\Models\User; use App\Models\User;
use App\Models\WeeztixConfig;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Tests\TestCase; use Tests\TestCase;
@@ -128,4 +132,202 @@ class DestroySubscriberTest extends TestCase
$response->assertForbidden(); $response->assertForbidden();
$this->assertDatabaseHas('subscribers', ['id' => $subscriber->id]); $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; namespace Tests\Feature;
use App\Models\MailwizzConfig;
use App\Models\PreregistrationPage; use App\Models\PreregistrationPage;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -36,6 +37,50 @@ class MailwizzConfigUiTest extends TestCase
$response->assertForbidden(); $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 private function makePageForUser(User $user): PreregistrationPage
{ {
return PreregistrationPage::query()->create([ return PreregistrationPage::query()->create([

View File

@@ -19,6 +19,27 @@ class SyncSubscriberToMailwizzTest extends TestCase
{ {
use RefreshDatabase; 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 public function test_subscribe_with_mailwizz_config_runs_sync_create_path_and_marks_synced(): void
{ {
Http::fake(function (Request $request) { Http::fake(function (Request $request) {