Compare commits
23 Commits
ed85e5c537
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 845665c8be | |||
| 91caa16e70 | |||
| 1e7ee14540 | |||
| 627edbbb83 | |||
| 7eda51f52a | |||
| de83a6fb76 | |||
| d802ce2a7c | |||
| 7ed660ec55 | |||
| 9f8052f683 | |||
| 217e1d9afb | |||
| 89931b817d | |||
| e0de8a05fa | |||
| 55434ce086 | |||
| 6561bda30d | |||
| 977e09d8ac | |||
| 70c1d25ad4 | |||
| a3158ffa34 | |||
| d3abdb7ed9 | |||
| 17e784fee7 | |||
| 5a67827c23 | |||
| 2603288881 | |||
| 26258c5f8b | |||
| 6791c8349a |
13
.env.example
13
.env.example
@@ -7,6 +7,9 @@ APP_URL=http://localhost
|
||||
# Optional: max requests/minute per IP for public /r/{slug} and subscribe (default: 1000 when APP_ENV is local|testing, else 60).
|
||||
# PUBLIC_REQUESTS_PER_MINUTE=120
|
||||
|
||||
# Default region for parsing national phone numbers (ISO 3166-1 alpha-2). Used by libphonenumber.
|
||||
# PREREGISTER_DEFAULT_PHONE_REGION=NL
|
||||
|
||||
# Wall-clock times from the admin UI (datetime-local) are interpreted in this zone.
|
||||
APP_TIMEZONE=Europe/Amsterdam
|
||||
|
||||
@@ -42,6 +45,10 @@ SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
# Use "database" or "redis" in production and run `php artisan queue:work` (see documentation/DEPLOYMENT-STRATEGY.md).
|
||||
# Avoid "sync" in production: Mailwizz (and Weeztix coupon) jobs would run inside the HTTP request; a thrown error
|
||||
# can return 5xx to the visitor even though the subscriber row is already saved — confusing UX.
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
@@ -70,3 +77,9 @@ AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# Weeztix OAuth (defaults match https://docs.weeztix.com — only set if you use Open Ticket / another issuer)
|
||||
# WEEZTIX_OAUTH_AUTHORIZE_URL=https://login.weeztix.com/login
|
||||
# WEEZTIX_AUTH_BASE_URL=https://auth.weeztix.com
|
||||
# WEEZTIX_USER_PROFILE_URL=https://auth.weeztix.com/users/me
|
||||
# WEEZTIX_API_BASE_URL=https://api.weeztix.com
|
||||
|
||||
9
app/Exceptions/WeeztixCouponCodeConflictException.php
Normal file
9
app/Exceptions/WeeztixCouponCodeConflictException.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class WeeztixCouponCodeConflictException extends RuntimeException {}
|
||||
@@ -8,18 +8,29 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\UpdateMailwizzConfigRequest;
|
||||
use App\Models\PreregistrationPage;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MailwizzController extends Controller
|
||||
{
|
||||
public function edit(PreregistrationPage $page): View
|
||||
public function edit(Request $request, PreregistrationPage $page): View|RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $page);
|
||||
|
||||
$page->load('mailwizzConfig');
|
||||
$page->load(['mailwizzConfig', 'weeztixConfig']);
|
||||
|
||||
return view('admin.mailwizz.edit', compact('page'));
|
||||
$config = $page->mailwizzConfig;
|
||||
$showWizard = $config === null || $request->boolean('wizard');
|
||||
if ($showWizard && $config === null) {
|
||||
$requestedStep = min(4, max(1, (int) $request->query('step', 1)));
|
||||
if ($requestedStep !== 1) {
|
||||
return redirect()
|
||||
->route('admin.pages.mailwizz.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]);
|
||||
}
|
||||
}
|
||||
|
||||
return view('admin.mailwizz.edit', compact('page', 'showWizard'));
|
||||
}
|
||||
|
||||
public function update(UpdateMailwizzConfigRequest $request, PreregistrationPage $page): RedirectResponse
|
||||
|
||||
@@ -28,6 +28,7 @@ class PageController extends Controller
|
||||
{
|
||||
$query = PreregistrationPage::query()
|
||||
->withCount('subscribers')
|
||||
->with(['weeztixConfig', 'mailwizzConfig'])
|
||||
->orderByDesc('start_date');
|
||||
|
||||
if (! $request->user()?->isSuperadmin()) {
|
||||
@@ -85,7 +86,11 @@ class PageController extends Controller
|
||||
|
||||
public function edit(PreregistrationPage $page): View
|
||||
{
|
||||
$page->load(['blocks' => fn ($q) => $q->orderBy('sort_order')]);
|
||||
$page->load([
|
||||
'blocks' => fn ($q) => $q->orderBy('sort_order'),
|
||||
'mailwizzConfig',
|
||||
'weeztixConfig',
|
||||
]);
|
||||
|
||||
return view('admin.pages.edit', compact('page'));
|
||||
}
|
||||
|
||||
@@ -8,8 +8,11 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\DestroySubscriberRequest;
|
||||
use App\Http\Requests\Admin\IndexSubscriberRequest;
|
||||
use App\Http\Requests\Admin\QueueMailwizzSyncRequest;
|
||||
use App\Http\Requests\Admin\SyncSubscriberMailwizzRequest;
|
||||
use App\Jobs\SyncSubscriberToMailwizz;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
use App\Services\CleanupSubscriberIntegrationsService;
|
||||
use App\Services\DispatchUnsyncedMailwizzSyncJobsService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
@@ -34,8 +37,13 @@ class SubscriberController extends Controller
|
||||
return view('admin.subscribers.index', compact('page', 'subscribers', 'unsyncedMailwizzCount'));
|
||||
}
|
||||
|
||||
public function destroy(DestroySubscriberRequest $request, PreregistrationPage $page, Subscriber $subscriber): RedirectResponse
|
||||
{
|
||||
public function destroy(
|
||||
DestroySubscriberRequest $request,
|
||||
PreregistrationPage $page,
|
||||
Subscriber $subscriber,
|
||||
CleanupSubscriberIntegrationsService $cleanupIntegrations
|
||||
): RedirectResponse {
|
||||
$cleanupIntegrations->runBeforeDelete($subscriber);
|
||||
$subscriber->delete();
|
||||
|
||||
return redirect()
|
||||
@@ -73,6 +81,26 @@ class SubscriberController extends Controller
|
||||
));
|
||||
}
|
||||
|
||||
public function syncSubscriberMailwizz(
|
||||
SyncSubscriberMailwizzRequest $request,
|
||||
PreregistrationPage $page,
|
||||
Subscriber $subscriber
|
||||
): RedirectResponse {
|
||||
$page->loadMissing('mailwizzConfig');
|
||||
|
||||
if ($page->mailwizzConfig === null) {
|
||||
return redirect()
|
||||
->route('admin.pages.subscribers.index', $page)
|
||||
->with('error', __('This page has no Mailwizz integration.'));
|
||||
}
|
||||
|
||||
SyncSubscriberToMailwizz::dispatch($subscriber->fresh());
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.subscribers.index', $page)
|
||||
->with('status', __('Mailwizz sync has been queued for this subscriber.'));
|
||||
}
|
||||
|
||||
public function export(IndexSubscriberRequest $request, PreregistrationPage $page): StreamedResponse
|
||||
{
|
||||
$search = $request->validated('search');
|
||||
@@ -89,14 +117,15 @@ class SubscriberController extends Controller
|
||||
if ($phoneEnabled) {
|
||||
$headers[] = 'Phone';
|
||||
}
|
||||
$headers = array_merge($headers, ['Registered At', 'Synced to Mailwizz', 'Synced At']);
|
||||
$headers = array_merge($headers, ['Coupon Code', 'Registered At', 'Synced to Mailwizz', 'Synced At']);
|
||||
fputcsv($handle, $headers);
|
||||
|
||||
foreach ($subscribers as $sub) {
|
||||
$row = [$sub->first_name, $sub->last_name, $sub->email];
|
||||
if ($phoneEnabled) {
|
||||
$row[] = $sub->phone ?? '';
|
||||
$row[] = $sub->phoneDisplay() ?? '';
|
||||
}
|
||||
$row[] = $sub->coupon_code ?? '';
|
||||
$row[] = $sub->created_at?->toDateTimeString() ?? '';
|
||||
$row[] = $sub->synced_to_mailwizz ? 'Yes' : 'No';
|
||||
$row[] = $sub->synced_at?->toDateTimeString() ?? '';
|
||||
|
||||
99
app/Http/Controllers/Admin/WeeztixApiController.php
Normal file
99
app/Http/Controllers/Admin/WeeztixApiController.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Services\WeeztixService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use RuntimeException;
|
||||
|
||||
class WeeztixApiController extends Controller
|
||||
{
|
||||
public function coupons(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'page_id' => ['required', 'integer', 'exists:preregistration_pages,id'],
|
||||
]);
|
||||
|
||||
$page = PreregistrationPage::query()->findOrFail($request->integer('page_id'));
|
||||
$this->authorize('update', $page);
|
||||
|
||||
$config = $page->weeztixConfig;
|
||||
if ($config === null || ! $config->is_connected) {
|
||||
return response()->json([
|
||||
'message' => __('Niet verbonden met Weeztix.'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$companyGuid = $config->company_guid;
|
||||
if (! is_string($companyGuid) || $companyGuid === '') {
|
||||
return response()->json([
|
||||
'message' => __('Geen Weeztix-bedrijf gekoppeld. Verbind opnieuw met Weeztix.'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$raw = (new WeeztixService($config))->getCoupons();
|
||||
$coupons = $this->normalizeCouponsPayload($raw);
|
||||
|
||||
return response()->json(['coupons' => $coupons]);
|
||||
} catch (RuntimeException) {
|
||||
return response()->json([
|
||||
'message' => __('Kon kortingsbonnen niet laden.'),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $raw
|
||||
* @return list<array{guid: string, name: string}>
|
||||
*/
|
||||
private function normalizeCouponsPayload(array $raw): array
|
||||
{
|
||||
$list = $raw;
|
||||
if (isset($raw['data']) && is_array($raw['data'])) {
|
||||
$list = $raw['data'];
|
||||
}
|
||||
|
||||
if (! is_array($list)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($list as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
if (! $this->couponRowHasEnabledStatus($row)) {
|
||||
continue;
|
||||
}
|
||||
$guid = data_get($row, 'guid') ?? data_get($row, 'id');
|
||||
if (! is_string($guid) || $guid === '') {
|
||||
continue;
|
||||
}
|
||||
$name = data_get($row, 'name') ?? data_get($row, 'title') ?? $guid;
|
||||
$out[] = [
|
||||
'guid' => $guid,
|
||||
'name' => is_string($name) ? $name : $guid,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weeztix coupon list items expose a string status; only "enabled" should appear in the admin picker.
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function couponRowHasEnabledStatus(array $row): bool
|
||||
{
|
||||
$status = data_get($row, 'status');
|
||||
|
||||
return is_string($status) && strcasecmp(trim($status), 'enabled') === 0;
|
||||
}
|
||||
}
|
||||
93
app/Http/Controllers/Admin/WeeztixController.php
Normal file
93
app/Http/Controllers/Admin/WeeztixController.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\UpdateWeeztixConfigRequest;
|
||||
use App\Models\PreregistrationPage;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class WeeztixController extends Controller
|
||||
{
|
||||
public function edit(Request $request, PreregistrationPage $page): View
|
||||
{
|
||||
$this->authorize('update', $page);
|
||||
|
||||
$page->load('weeztixConfig');
|
||||
|
||||
$showWizard = $page->weeztixConfig === null || $request->boolean('wizard');
|
||||
$wizardStep = $showWizard ? min(3, max(1, (int) $request->query('step', 1))) : 1;
|
||||
if ($showWizard && $page->weeztixConfig === null && $wizardStep !== 1) {
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]);
|
||||
}
|
||||
$hasStoredCredentials = $page->weeztixConfig !== null
|
||||
&& is_string($page->weeztixConfig->client_id)
|
||||
&& $page->weeztixConfig->client_id !== '';
|
||||
|
||||
return view('admin.weeztix.edit', compact(
|
||||
'page',
|
||||
'showWizard',
|
||||
'wizardStep',
|
||||
'hasStoredCredentials'
|
||||
));
|
||||
}
|
||||
|
||||
public function update(UpdateWeeztixConfigRequest $request, PreregistrationPage $page): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
foreach (['client_id', 'client_secret'] as $key) {
|
||||
if (array_key_exists($key, $validated) && $validated[$key] === '' && $page->weeztixConfig !== null) {
|
||||
unset($validated[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
$validated['redirect_uri'] = route('admin.weeztix.callback', absolute: true);
|
||||
|
||||
DB::transaction(function () use ($page, $validated): void {
|
||||
$page->weeztixConfig()->updateOrCreate(
|
||||
['preregistration_page_id' => $page->id],
|
||||
array_merge($validated, ['preregistration_page_id' => $page->id])
|
||||
);
|
||||
});
|
||||
|
||||
$page->load('weeztixConfig');
|
||||
|
||||
if ($request->boolean('wizard')) {
|
||||
if ($request->boolean('wizard_coupon_save')) {
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', ['page' => $page])
|
||||
->with('status', __('Weeztix-configuratie opgeslagen.'));
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', [
|
||||
'page' => $page,
|
||||
'wizard' => 1,
|
||||
'step' => 2,
|
||||
])
|
||||
->with('status', __('Weeztix-configuratie opgeslagen.'));
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', ['page' => $page])
|
||||
->with('status', __('Weeztix-configuratie opgeslagen.'));
|
||||
}
|
||||
|
||||
public function destroy(PreregistrationPage $page): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $page);
|
||||
|
||||
$page->weeztixConfig()?->delete();
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', $page)
|
||||
->with('status', __('Weeztix-integratie verwijderd.'));
|
||||
}
|
||||
}
|
||||
168
app/Http/Controllers/Admin/WeeztixOAuthController.php
Normal file
168
app/Http/Controllers/Admin/WeeztixOAuthController.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Services\WeeztixService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class WeeztixOAuthController extends Controller
|
||||
{
|
||||
public function redirect(Request $request, PreregistrationPage $page): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $page);
|
||||
|
||||
$page->load('weeztixConfig');
|
||||
$config = $page->weeztixConfig;
|
||||
if ($config === null) {
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', $page)
|
||||
->with('error', __('Sla eerst je client ID en client secret op.'));
|
||||
}
|
||||
|
||||
$clientId = $config->client_id;
|
||||
if (! is_string($clientId) || $clientId === '') {
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', $page)
|
||||
->with('error', __('Vul een geldige Weeztix client ID in.'));
|
||||
}
|
||||
|
||||
$state = Str::random(40);
|
||||
session([
|
||||
'weeztix_oauth_state' => $state,
|
||||
'weeztix_page_id' => $page->id,
|
||||
'weeztix_oauth_resume_wizard' => $request->boolean('wizard'),
|
||||
]);
|
||||
|
||||
$redirectUri = $config->redirect_uri;
|
||||
if (! is_string($redirectUri) || $redirectUri === '') {
|
||||
$redirectUri = route('admin.weeztix.callback', absolute: true);
|
||||
}
|
||||
|
||||
$query = http_build_query([
|
||||
'client_id' => $clientId,
|
||||
'redirect_uri' => $redirectUri,
|
||||
'response_type' => 'code',
|
||||
'state' => $state,
|
||||
]);
|
||||
|
||||
$authorizeUrl = config('weeztix.oauth_authorize_url');
|
||||
|
||||
return redirect()->away($authorizeUrl.'?'.$query);
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
if ($request->filled('error')) {
|
||||
Log::warning('Weeztix OAuth provider error', [
|
||||
'error' => $request->string('error')->toString(),
|
||||
'description' => $request->string('error_description')->toString(),
|
||||
]);
|
||||
|
||||
return $this->redirectToWeeztixEditWithSessionPage(__('Weeztix heeft de verbinding geweigerd. Probeer opnieuw.'));
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'state' => ['required', 'string'],
|
||||
'code' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$storedState = session('weeztix_oauth_state');
|
||||
$pageId = session('weeztix_page_id');
|
||||
if (! is_string($storedState) || $storedState === '' || ($pageId === null || (! is_int($pageId) && ! is_numeric($pageId)))) {
|
||||
return redirect()
|
||||
->route('admin.dashboard')
|
||||
->with('error', __('Ongeldige OAuth-sessie. Start opnieuw vanaf de Weeztix-pagina.'));
|
||||
}
|
||||
|
||||
if ($request->string('state')->toString() !== $storedState) {
|
||||
abort(403, 'Invalid OAuth state');
|
||||
}
|
||||
|
||||
$page = PreregistrationPage::query()->findOrFail((int) $pageId);
|
||||
$this->authorize('update', $page);
|
||||
|
||||
$config = $page->weeztixConfig;
|
||||
if ($config === null) {
|
||||
$this->forgetOauthSession();
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', $page)
|
||||
->with('error', __('Geen Weeztix-configuratie gevonden voor deze pagina.'));
|
||||
}
|
||||
|
||||
try {
|
||||
$service = new WeeztixService($config);
|
||||
$service->exchangeAuthorizationCode($request->string('code')->toString());
|
||||
$config = $config->fresh();
|
||||
if ($config !== null) {
|
||||
(new WeeztixService($config))->ensureCompanyStoredFromWeeztix();
|
||||
}
|
||||
} catch (RuntimeException $e) {
|
||||
Log::error('Weeztix OAuth callback failed', [
|
||||
'page_id' => $page->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$resumeWizard = $this->forgetOauthSession();
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', $this->weeztixEditParams($page, $resumeWizard, 2))
|
||||
->with('error', __('Verbinden met Weeztix is mislukt. Controleer je gegevens en probeer opnieuw.'));
|
||||
}
|
||||
|
||||
$resumeWizard = $this->forgetOauthSession();
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', $this->weeztixEditParams($page, $resumeWizard, 3))
|
||||
->with('status', __('Succesvol verbonden met Weeztix.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{page: PreregistrationPage, wizard?: int, step?: int}
|
||||
*/
|
||||
private function weeztixEditParams(PreregistrationPage $page, bool $resumeWizard, int $step): array
|
||||
{
|
||||
$params = ['page' => $page];
|
||||
if ($resumeWizard) {
|
||||
$params['wizard'] = 1;
|
||||
$params['step'] = $step;
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
private function forgetOauthSession(): bool
|
||||
{
|
||||
$resumeWizard = (bool) session()->pull('weeztix_oauth_resume_wizard', false);
|
||||
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
|
||||
|
||||
return $resumeWizard;
|
||||
}
|
||||
|
||||
private function redirectToWeeztixEditWithSessionPage(string $message): RedirectResponse
|
||||
{
|
||||
$pageId = session('weeztix_page_id');
|
||||
$resumeWizard = $this->forgetOauthSession();
|
||||
|
||||
if (is_int($pageId) || is_numeric($pageId)) {
|
||||
$page = PreregistrationPage::query()->find((int) $pageId);
|
||||
if ($page !== null) {
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', $this->weeztixEditParams($page, $resumeWizard, 2))
|
||||
->with('error', $message);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.dashboard')
|
||||
->with('error', $message);
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,19 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\SubscribePublicPageRequest;
|
||||
use App\Jobs\SyncSubscriberToMailwizz;
|
||||
use App\Models\PageBlock;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Services\RegisterSubscriberOnPage;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PublicPageController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RegisterSubscriberOnPage $registerSubscriberOnPage
|
||||
) {}
|
||||
|
||||
public function show(PreregistrationPage $publicPage): View
|
||||
{
|
||||
$publicPage->load(['blocks' => fn ($q) => $q->orderBy('sort_order')]);
|
||||
@@ -50,11 +54,7 @@ class PublicPageController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$subscriber = $publicPage->subscribers()->create($validated);
|
||||
|
||||
if ($publicPage->mailwizzConfig !== null) {
|
||||
SyncSubscriberToMailwizz::dispatch($subscriber);
|
||||
}
|
||||
$this->registerSubscriberOnPage->storeAndQueueIntegrations($publicPage, $validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
|
||||
36
app/Http/Requests/Admin/SyncSubscriberMailwizzRequest.php
Normal file
36
app/Http/Requests/Admin/SyncSubscriberMailwizzRequest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SyncSubscriberMailwizzRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$page = $this->route('page');
|
||||
$subscriber = $this->route('subscriber');
|
||||
if (! $page instanceof PreregistrationPage || ! $subscriber instanceof Subscriber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($subscriber->preregistration_page_id !== $page->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->user()?->can('update', $page) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, ValidationRule|string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ class UpdateMailwizzConfigRequest extends FormRequest
|
||||
'field_first_name' => ['required', 'string', 'max:255'],
|
||||
'field_last_name' => ['required', 'string', 'max:255'],
|
||||
'field_phone' => ['nullable', 'string', 'max:255'],
|
||||
'field_coupon_code' => ['nullable', 'string', 'max:255'],
|
||||
'tag_field' => ['required', 'string', 'max:255'],
|
||||
'tag_value' => ['required', 'string', 'max:255'],
|
||||
];
|
||||
|
||||
53
app/Http/Requests/Admin/UpdateWeeztixConfigRequest.php
Normal file
53
app/Http/Requests/Admin/UpdateWeeztixConfigRequest.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Models\PreregistrationPage;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateWeeztixConfigRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$page = $this->route('page');
|
||||
if (! $page instanceof PreregistrationPage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->user()?->can('update', $page) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, ValidationRule|string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var PreregistrationPage $page */
|
||||
$page = $this->route('page');
|
||||
|
||||
return [
|
||||
'client_id' => [
|
||||
'sometimes',
|
||||
Rule::requiredIf(fn (): bool => $page->weeztixConfig === null),
|
||||
'nullable',
|
||||
'string',
|
||||
'max:2048',
|
||||
],
|
||||
'client_secret' => [
|
||||
'sometimes',
|
||||
Rule::requiredIf(fn (): bool => $page->weeztixConfig === null),
|
||||
'nullable',
|
||||
'string',
|
||||
'max:2048',
|
||||
],
|
||||
'coupon_guid' => ['nullable', 'string', 'max:255'],
|
||||
'coupon_name' => ['nullable', 'string', 'max:255'],
|
||||
'code_prefix' => ['nullable', 'string', 'max:32'],
|
||||
'usage_count' => ['nullable', 'integer', 'min:1', 'max:99999'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ trait ValidatesPreregistrationPageInput
|
||||
'post_submit_redirect_url' => ['nullable', 'string', 'url:http,https', 'max:500'],
|
||||
'background_overlay_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/'],
|
||||
'background_overlay_opacity' => ['nullable', 'integer', 'min:0', 'max:100'],
|
||||
'background_fixed' => ['sometimes', 'boolean'],
|
||||
'page_background' => ['nullable', 'file', 'image', 'mimes:jpeg,png,jpg,webp', 'max:5120'],
|
||||
'remove_page_background' => ['sometimes', 'boolean'],
|
||||
'start_date' => ['required', 'date'],
|
||||
@@ -69,6 +70,7 @@ trait ValidatesPreregistrationPageInput
|
||||
|
||||
$this->merge([
|
||||
'is_active' => $this->boolean('is_active'),
|
||||
'background_fixed' => $this->boolean('background_fixed'),
|
||||
'remove_page_background' => $this->boolean('remove_page_background'),
|
||||
'ticketshop_url' => $ticketshopNormalized,
|
||||
'post_submit_redirect_url' => $redirectNormalized,
|
||||
|
||||
@@ -5,7 +5,10 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Rules\ValidPhoneNumber;
|
||||
use App\Services\PhoneNumberNormalizer;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Email;
|
||||
|
||||
class SubscribePublicPageRequest extends FormRequest
|
||||
@@ -28,13 +31,23 @@ class SubscribePublicPageRequest extends FormRequest
|
||||
->rfcCompliant()
|
||||
->preventSpoofing();
|
||||
|
||||
$phoneRules = ['nullable', 'string', 'max:255'];
|
||||
|
||||
if ($page->isPhoneFieldEnabledForSubscribers()) {
|
||||
$phoneRules = [
|
||||
Rule::requiredIf(fn (): bool => $page->isPhoneFieldRequiredForSubscribers()),
|
||||
'nullable',
|
||||
'string',
|
||||
'max:32',
|
||||
new ValidPhoneNumber(app(PhoneNumberNormalizer::class)),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'first_name' => ['required', 'string', 'max:255'],
|
||||
'last_name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'max:255', $emailRule],
|
||||
'phone' => $page->isPhoneFieldEnabledForSubscribers()
|
||||
? ['nullable', 'string', 'regex:/^[0-9]{8,15}$/']
|
||||
: ['nullable', 'string', 'max:255'],
|
||||
'phone' => $phoneRules,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -45,7 +58,6 @@ class SubscribePublicPageRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'email' => __('Please enter a valid email address.'),
|
||||
'phone.regex' => __('Please enter a valid phone number (8–15 digits).'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -73,15 +85,22 @@ class SubscribePublicPageRequest extends FormRequest
|
||||
|
||||
/** @var PreregistrationPage $page */
|
||||
$page = $this->route('publicPage');
|
||||
$phone = $this->input('phone');
|
||||
if (! $page->isPhoneFieldEnabledForSubscribers()) {
|
||||
$this->merge(['phone' => null]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$phone = $this->input('phone');
|
||||
if ($phone === null || $phone === '') {
|
||||
$this->merge(['phone' => null]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_string($phone)) {
|
||||
$digits = preg_replace('/\D+/', '', $phone);
|
||||
$this->merge(['phone' => $digits === '' ? null : $digits]);
|
||||
$trimmed = trim($phone);
|
||||
$this->merge(['phone' => $trimmed === '' ? null : $trimmed]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
175
app/Jobs/IssueWeeztixCouponForSubscriber.php
Normal file
175
app/Jobs/IssueWeeztixCouponForSubscriber.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,23 +6,23 @@ namespace App\Jobs;
|
||||
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\Subscriber;
|
||||
use App\Services\MailwizzCheckboxlistTags;
|
||||
use App\Services\MailwizzService;
|
||||
use App\Services\MailwizzSubscriberFormPayload;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueueAfterCommit
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* Seconds before the unique lock expires if the worker dies before releasing it.
|
||||
@@ -36,21 +36,52 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
*/
|
||||
public array $backoff = [10, 30, 60];
|
||||
|
||||
public function __construct(public Subscriber $subscriber)
|
||||
/**
|
||||
* Set in the constructor for new jobs. Remains null when an old queue payload (pre–subscriber-id refactor) is unserialized.
|
||||
*/
|
||||
public ?int $subscriberId = null;
|
||||
|
||||
/**
|
||||
* @param Subscriber|int $subscriber Model is accepted when dispatching; only the id is serialized for the queue.
|
||||
*/
|
||||
public function __construct(Subscriber|int $subscriber)
|
||||
{
|
||||
$this->subscriberId = $subscriber instanceof Subscriber
|
||||
? (int) $subscriber->getKey()
|
||||
: $subscriber;
|
||||
$this->onQueue('mailwizz');
|
||||
}
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return (string) $this->subscriber->getKey();
|
||||
return $this->subscriberId !== null
|
||||
? (string) $this->subscriberId
|
||||
: 'stale-mailwizz-sync-payload';
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
if ($this->subscriberId === null) {
|
||||
Log::notice('SyncSubscriberToMailwizz: skipped job with missing subscriber id (stale queue payload). Clear the queue or re-dispatch sync jobs.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->runSync();
|
||||
} catch (Throwable $e) {
|
||||
Log::error('SyncSubscriberToMailwizz: integration failed; subscriber remains local (use admin resync if needed)', [
|
||||
'subscriber_id' => $this->subscriberId,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function runSync(): void
|
||||
{
|
||||
$subscriber = Subscriber::query()
|
||||
->with(['preregistrationPage.mailwizzConfig'])
|
||||
->find($this->subscriber->id);
|
||||
->find($this->subscriberId);
|
||||
|
||||
if ($subscriber === null) {
|
||||
return;
|
||||
@@ -101,7 +132,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
Log::error('SyncSubscriberToMailwizz failed', [
|
||||
'subscriber_id' => $this->subscriber->id,
|
||||
'subscriber_id' => $this->subscriberId,
|
||||
'message' => $exception?->getMessage(),
|
||||
]);
|
||||
}
|
||||
@@ -119,24 +150,6 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
return true;
|
||||
}
|
||||
|
||||
private function buildBasePayload(Subscriber $subscriber, MailwizzConfig $config, bool $phoneEnabled): array
|
||||
{
|
||||
$data = [
|
||||
$config->field_email => $subscriber->email,
|
||||
$config->field_first_name => $subscriber->first_name,
|
||||
$config->field_last_name => $subscriber->last_name,
|
||||
];
|
||||
|
||||
if ($phoneEnabled && $config->field_phone !== null && $config->field_phone !== '') {
|
||||
$phone = $subscriber->phone;
|
||||
if ($phone !== null && $phone !== '') {
|
||||
$data[$config->field_phone] = $phone;
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function createInMailwizz(
|
||||
MailwizzService $service,
|
||||
Subscriber $subscriber,
|
||||
@@ -144,7 +157,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
string $listUid
|
||||
): void {
|
||||
$page = $subscriber->preregistrationPage;
|
||||
$data = $this->buildBasePayload($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
|
||||
$data = MailwizzSubscriberFormPayload::baseFields($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
|
||||
$tagField = $config->tag_field;
|
||||
$tagValue = $config->tag_value;
|
||||
if ($tagField !== null && $tagField !== '' && $tagValue !== null && $tagValue !== '') {
|
||||
@@ -162,7 +175,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
string $subscriberUid
|
||||
): void {
|
||||
$page = $subscriber->preregistrationPage;
|
||||
$data = $this->buildBasePayload($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
|
||||
$data = MailwizzSubscriberFormPayload::baseFields($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
|
||||
|
||||
$tagField = $config->tag_field;
|
||||
$tagValue = $config->tag_value;
|
||||
@@ -171,46 +184,11 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
if ($full === null) {
|
||||
throw new RuntimeException('Mailwizz getSubscriber returned an empty payload.');
|
||||
}
|
||||
$existingCsv = $this->extractTagCsvFromResponse($full, $tagField);
|
||||
$merged = $this->mergeCheckboxlistTags($existingCsv, $tagValue);
|
||||
$existingCsv = MailwizzCheckboxlistTags::extractCsvFromSubscriberResponse($full, $tagField);
|
||||
$merged = MailwizzCheckboxlistTags::mergeValueIntoCsv($existingCsv, $tagValue);
|
||||
$data[$tagField] = $merged;
|
||||
}
|
||||
|
||||
$service->updateSubscriber($listUid, $subscriberUid, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $apiResponse
|
||||
*/
|
||||
private function extractTagCsvFromResponse(array $apiResponse, string $tagField): string
|
||||
{
|
||||
$record = data_get($apiResponse, 'data.record');
|
||||
if (! is_array($record)) {
|
||||
$record = data_get($apiResponse, 'data');
|
||||
}
|
||||
if (! is_array($record)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$raw = $record[$tagField] ?? data_get($record, "fields.{$tagField}");
|
||||
|
||||
if (is_array($raw)) {
|
||||
return implode(',', array_map(static fn (mixed $v): string => is_scalar($v) ? (string) $v : '', $raw));
|
||||
}
|
||||
|
||||
return is_string($raw) ? $raw : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function mergeCheckboxlistTags(string $existingCsv, string $newValue): array
|
||||
{
|
||||
$parts = array_filter(array_map('trim', explode(',', $existingCsv)), static fn (string $s): bool => $s !== '');
|
||||
if (! in_array($newValue, $parts, true)) {
|
||||
$parts[] = $newValue;
|
||||
}
|
||||
|
||||
return array_values($parts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class MailwizzConfig extends Model
|
||||
'field_first_name',
|
||||
'field_last_name',
|
||||
'field_phone',
|
||||
'field_coupon_code',
|
||||
'tag_field',
|
||||
'tag_value',
|
||||
];
|
||||
|
||||
@@ -32,6 +32,7 @@ class PreregistrationPage extends Model
|
||||
'background_image',
|
||||
'background_overlay_color',
|
||||
'background_overlay_opacity',
|
||||
'background_fixed',
|
||||
'logo_image',
|
||||
'is_active',
|
||||
];
|
||||
@@ -42,6 +43,7 @@ class PreregistrationPage extends Model
|
||||
'start_date' => 'datetime',
|
||||
'end_date' => 'datetime',
|
||||
'phone_enabled' => 'boolean',
|
||||
'background_fixed' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
@@ -106,6 +108,19 @@ class PreregistrationPage extends Model
|
||||
return (bool) $this->phone_enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the form block marks the phone field as required (only applies if phone is enabled).
|
||||
*/
|
||||
public function isPhoneFieldRequiredForSubscribers(): bool
|
||||
{
|
||||
$form = $this->getBlockByType('form');
|
||||
if ($form !== null) {
|
||||
return (bool) data_get($form->content, 'fields.phone.required', false);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function headlineForMeta(): string
|
||||
{
|
||||
$hero = $this->getHeroBlock();
|
||||
@@ -147,6 +162,11 @@ class PreregistrationPage extends Model
|
||||
return $this->hasOne(MailwizzConfig::class);
|
||||
}
|
||||
|
||||
public function weeztixConfig(): HasOne
|
||||
{
|
||||
return $this->hasOne(WeeztixConfig::class);
|
||||
}
|
||||
|
||||
public function isBeforeStart(): bool
|
||||
{
|
||||
return Carbon::now()->lt($this->start_date);
|
||||
@@ -184,4 +204,56 @@ class PreregistrationPage extends Model
|
||||
|
||||
return 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mailwizz setup depth for admin UI (API key + list + email field = ready to sync).
|
||||
*
|
||||
* @return 'none'|'partial'|'ready'
|
||||
*/
|
||||
public function mailwizzIntegrationStatus(): string
|
||||
{
|
||||
$c = $this->mailwizzConfig;
|
||||
if ($c === null) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
$key = $c->api_key;
|
||||
if (! is_string($key) || $key === '') {
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
if (! is_string($c->list_uid) || $c->list_uid === '' || ! is_string($c->field_email) || $c->field_email === '') {
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
/**
|
||||
* Weeztix setup depth for admin UI.
|
||||
*
|
||||
* @return 'none'|'credentials'|'connected'|'ready'
|
||||
*/
|
||||
public function weeztixIntegrationStatus(): string
|
||||
{
|
||||
$c = $this->weeztixConfig;
|
||||
if ($c === null) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
$hasClient = is_string($c->client_id) && $c->client_id !== '';
|
||||
if (! $hasClient) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
if (! $c->is_connected) {
|
||||
return 'credentials';
|
||||
}
|
||||
|
||||
if (! is_string($c->coupon_guid) || $c->coupon_guid === '') {
|
||||
return 'connected';
|
||||
}
|
||||
|
||||
return 'ready';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\PhoneNumberNormalizer;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -21,6 +22,7 @@ class Subscriber extends Model
|
||||
'phone',
|
||||
'synced_to_mailwizz',
|
||||
'synced_at',
|
||||
'coupon_code',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -36,6 +38,52 @@ class Subscriber extends Model
|
||||
return $this->belongsTo(PreregistrationPage::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $value
|
||||
*/
|
||||
public function setPhoneAttribute(mixed $value): void
|
||||
{
|
||||
if ($value === null) {
|
||||
$this->attributes['phone'] = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
$this->attributes['phone'] = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$trimmed = trim($value);
|
||||
if ($trimmed === '') {
|
||||
$this->attributes['phone'] = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$normalized = app(PhoneNumberNormalizer::class)->normalizeToE164($trimmed);
|
||||
$this->attributes['phone'] = $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phones are stored as E.164 (e.g. +31612345678). Legacy rows may still be digits-only.
|
||||
*/
|
||||
public function phoneDisplay(): ?string
|
||||
{
|
||||
$phone = $this->phone;
|
||||
if ($phone === null || $phone === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$p = (string) $phone;
|
||||
if (str_starts_with($p, '+')) {
|
||||
return $p;
|
||||
}
|
||||
|
||||
return preg_match('/^\d{8,15}$/', $p) === 1 ? '+'.$p : $p;
|
||||
}
|
||||
|
||||
public function scopeSearch(Builder $query, ?string $term): Builder
|
||||
{
|
||||
if ($term === null || $term === '') {
|
||||
@@ -47,7 +95,9 @@ class Subscriber extends Model
|
||||
return $query->where(function (Builder $q) use ($like): void {
|
||||
$q->where('first_name', 'like', $like)
|
||||
->orWhere('last_name', 'like', $like)
|
||||
->orWhere('email', 'like', $like);
|
||||
->orWhere('email', 'like', $like)
|
||||
->orWhere('phone', 'like', $like)
|
||||
->orWhere('coupon_code', 'like', $like);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
64
app/Models/WeeztixConfig.php
Normal file
64
app/Models/WeeztixConfig.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WeeztixConfig extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'preregistration_page_id',
|
||||
'client_id',
|
||||
'client_secret',
|
||||
'redirect_uri',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'token_expires_at',
|
||||
'refresh_token_expires_at',
|
||||
'company_guid',
|
||||
'company_name',
|
||||
'coupon_guid',
|
||||
'coupon_name',
|
||||
'code_prefix',
|
||||
'usage_count',
|
||||
'is_connected',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'client_id' => 'encrypted',
|
||||
'client_secret' => 'encrypted',
|
||||
'access_token' => 'encrypted',
|
||||
'refresh_token' => 'encrypted',
|
||||
'token_expires_at' => 'datetime',
|
||||
'refresh_token_expires_at' => 'datetime',
|
||||
'is_connected' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function preregistrationPage(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PreregistrationPage::class);
|
||||
}
|
||||
|
||||
public function isTokenExpired(): bool
|
||||
{
|
||||
return ! $this->token_expires_at || $this->token_expires_at->isPast();
|
||||
}
|
||||
|
||||
public function isRefreshTokenExpired(): bool
|
||||
{
|
||||
if ($this->refresh_token_expires_at === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->refresh_token_expires_at->isPast();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Services\PhoneNumberNormalizer;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
@@ -16,7 +17,11 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
$this->app->singleton(PhoneNumberNormalizer::class, function (): PhoneNumberNormalizer {
|
||||
return new PhoneNumberNormalizer(
|
||||
(string) config('preregister.default_phone_region', 'NL')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
33
app/Rules/ValidPhoneNumber.php
Normal file
33
app/Rules/ValidPhoneNumber.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Services\PhoneNumberNormalizer;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
final class ValidPhoneNumber implements ValidationRule
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PhoneNumberNormalizer $normalizer
|
||||
) {}
|
||||
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
$fail(__('Please enter a valid phone number.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->normalizer->normalizeToE164(trim($value)) === null) {
|
||||
$fail(__('Please enter a valid phone number.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
145
app/Services/CleanupSubscriberIntegrationsService.php
Normal file
145
app/Services/CleanupSubscriberIntegrationsService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
64
app/Services/MailwizzCheckboxlistTags.php
Normal file
64
app/Services/MailwizzCheckboxlistTags.php
Normal 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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -202,7 +202,8 @@ final class MailwizzService
|
||||
$out = [];
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$out[$key] = $value;
|
||||
// Empty arrays are omitted by Laravel's multipart encoder (no KEY[] parts), so Mailwizz never clears checkboxlist fields.
|
||||
$out[$key] = $value === [] ? '' : $value;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
40
app/Services/MailwizzSubscriberFormPayload.php
Normal file
40
app/Services/MailwizzSubscriberFormPayload.php
Normal 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;
|
||||
}
|
||||
}
|
||||
48
app/Services/PhoneNumberNormalizer.php
Normal file
48
app/Services/PhoneNumberNormalizer.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use libphonenumber\NumberParseException;
|
||||
use libphonenumber\PhoneNumberFormat;
|
||||
use libphonenumber\PhoneNumberUtil;
|
||||
|
||||
/**
|
||||
* Parses international and national phone input and normalizes to E.164 for storage (includes leading +).
|
||||
*/
|
||||
final class PhoneNumberNormalizer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $defaultRegion
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns E.164 (e.g. +31612345678) or null if empty/invalid.
|
||||
*/
|
||||
public function normalizeToE164(?string $input): ?string
|
||||
{
|
||||
if ($input === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = trim($input);
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$util = PhoneNumberUtil::getInstance();
|
||||
|
||||
try {
|
||||
$number = $util->parse($trimmed, $this->defaultRegion);
|
||||
} catch (NumberParseException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $util->isValidNumber($number)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $util->format($number, PhoneNumberFormat::E164);
|
||||
}
|
||||
}
|
||||
59
app/Services/RegisterSubscriberOnPage.php
Normal file
59
app/Services/RegisterSubscriberOnPage.php
Normal 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 !== '';
|
||||
}
|
||||
}
|
||||
737
app/Services/WeeztixService.php
Normal file
737
app/Services/WeeztixService.php
Normal file
@@ -0,0 +1,737 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\WeeztixCouponCodeConflictException;
|
||||
use App\Models\WeeztixConfig;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use InvalidArgumentException;
|
||||
use LogicException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class WeeztixService
|
||||
{
|
||||
public function __construct(
|
||||
private WeeztixConfig $config
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Bearer token for API calls, refreshing from the stored refresh token when needed.
|
||||
*/
|
||||
public function getValidAccessToken(): string
|
||||
{
|
||||
if ($this->config->access_token && ! $this->config->isTokenExpired()) {
|
||||
return (string) $this->config->access_token;
|
||||
}
|
||||
|
||||
$this->refreshAccessToken();
|
||||
$this->config->refresh();
|
||||
|
||||
$token = $this->config->access_token;
|
||||
if (! is_string($token) || $token === '') {
|
||||
throw new RuntimeException('Weeztix access token missing after refresh.');
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{guid: string, name: string|null}>
|
||||
*/
|
||||
public function getCompanies(): array
|
||||
{
|
||||
$token = $this->getValidAccessToken();
|
||||
$url = config('weeztix.user_profile_url');
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])
|
||||
->acceptJson()
|
||||
->get($url);
|
||||
|
||||
if ($response->status() === 401) {
|
||||
$this->refreshAccessToken();
|
||||
$this->config->refresh();
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.(string) $this->config->access_token,
|
||||
])
|
||||
->acceptJson()
|
||||
->get($url);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->logFailedResponse('getCompanies', $url, $response);
|
||||
|
||||
throw new RuntimeException('Weeztix user profile request failed: '.$response->status());
|
||||
}
|
||||
|
||||
Log::debug('Weeztix API', [
|
||||
'action' => 'getCompanies',
|
||||
'url' => $url,
|
||||
'http_status' => $response->status(),
|
||||
]);
|
||||
|
||||
$json = $response->json();
|
||||
if (! is_array($json)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->normalizeCompaniesFromProfile($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync company_guid/name from the Weeztix user profile for the current access token (after OAuth).
|
||||
* Runs on every successful connect or reconnect so a different company chosen in Weeztix is stored here.
|
||||
* Uses the first company returned from the profile when several are present.
|
||||
*/
|
||||
public function ensureCompanyStoredFromWeeztix(): void
|
||||
{
|
||||
$this->config->refresh();
|
||||
|
||||
try {
|
||||
$companies = $this->getCompanies();
|
||||
if ($companies === []) {
|
||||
Log::warning('Weeztix: geen bedrijf uit profiel voor automatische koppeling.', [
|
||||
'weeztix_config_id' => $this->config->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$row = $companies[0];
|
||||
$this->config->update([
|
||||
'company_guid' => $row['guid'],
|
||||
'company_name' => $row['name'],
|
||||
]);
|
||||
$this->config->refresh();
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Weeztix: automatisch bedrijf vastleggen mislukt', [
|
||||
'weeztix_config_id' => $this->config->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange OAuth authorization code for tokens (admin callback).
|
||||
*/
|
||||
public function exchangeAuthorizationCode(string $code): void
|
||||
{
|
||||
$redirectUri = $this->config->redirect_uri;
|
||||
if (! is_string($redirectUri) || $redirectUri === '') {
|
||||
throw new LogicException('Weeztix redirect_uri is not set.');
|
||||
}
|
||||
|
||||
$tokenUrl = config('weeztix.auth_base_url').'/tokens';
|
||||
$response = Http::asForm()->post($tokenUrl, [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => $this->config->client_id,
|
||||
'client_secret' => $this->config->client_secret,
|
||||
'redirect_uri' => $redirectUri,
|
||||
'code' => $code,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Weeztix OAuth code exchange failed', [
|
||||
'url' => $tokenUrl,
|
||||
'status' => $response->status(),
|
||||
'body' => $response->json(),
|
||||
]);
|
||||
|
||||
throw new RuntimeException('Weeztix OAuth code exchange failed: '.$response->status());
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
if (! is_array($json)) {
|
||||
throw new RuntimeException('Weeztix token response was not valid JSON.');
|
||||
}
|
||||
|
||||
$this->applyTokenResponseToConfig($json);
|
||||
$this->hydrateCompanyFromTokenInfo($json);
|
||||
|
||||
Log::debug('Weeztix API', [
|
||||
'action' => 'oauth_authorization_code',
|
||||
'url' => $tokenUrl,
|
||||
'http_status' => $response->status(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getCoupons(): array
|
||||
{
|
||||
$this->assertCompanyGuid();
|
||||
|
||||
$url = config('weeztix.api_base_url').'/coupon';
|
||||
|
||||
return $this->apiRequest('get', $url, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a single coupon code on the coupon selected in config.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @throws WeeztixCouponCodeConflictException When the code already exists (HTTP 409).
|
||||
*/
|
||||
public function createCouponCode(string $code): array
|
||||
{
|
||||
$this->assertCompanyGuid();
|
||||
$couponGuid = $this->config->coupon_guid;
|
||||
if (! is_string($couponGuid) || $couponGuid === '') {
|
||||
throw new LogicException('Weeztix coupon is not configured.');
|
||||
}
|
||||
|
||||
$url = config('weeztix.api_base_url').'/coupon/'.$couponGuid.'/codes';
|
||||
|
||||
$payload = [
|
||||
'usage_count' => $this->config->usage_count,
|
||||
'applies_to_count' => null,
|
||||
'codes' => [
|
||||
['code' => $code],
|
||||
],
|
||||
];
|
||||
|
||||
$rateAttempts = 3;
|
||||
for ($rateAttempt = 0; $rateAttempt < $rateAttempts; $rateAttempt++) {
|
||||
$token = $this->getValidAccessToken();
|
||||
$response = $this->sendApiRequest('put', $url, $payload, $token);
|
||||
|
||||
if ($response->status() === 401) {
|
||||
$this->refreshAccessToken();
|
||||
$this->config->refresh();
|
||||
$response = $this->sendApiRequest('put', $url, $payload, (string) $this->config->access_token);
|
||||
}
|
||||
|
||||
if ($response->status() === 429) {
|
||||
$waitSeconds = min(8, 2 ** $rateAttempt);
|
||||
Log::warning('Weeztix API rate limited', [
|
||||
'url' => $url,
|
||||
'retry_in_seconds' => $waitSeconds,
|
||||
]);
|
||||
sleep($waitSeconds);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($response->status() === 409) {
|
||||
throw new WeeztixCouponCodeConflictException('Weeztix coupon code already exists.');
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->logFailedResponse('createCouponCode', $url, $response);
|
||||
|
||||
throw new RuntimeException('Weeztix API request failed: '.$response->status());
|
||||
}
|
||||
|
||||
Log::debug('Weeztix API', [
|
||||
'action' => 'createCouponCode',
|
||||
'url' => $url,
|
||||
'http_status' => $response->status(),
|
||||
]);
|
||||
|
||||
$json = $response->json();
|
||||
|
||||
return is_array($json) ? $json : [];
|
||||
}
|
||||
|
||||
Log::error('Weeztix API rate limited after retries', ['url' => $url]);
|
||||
|
||||
throw new RuntimeException('Weeztix API rate limited after retries.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists coupon codes for the coupon selected in config (GET /coupon/{guid}/codes).
|
||||
*
|
||||
* @return list<array{guid: string, code: string}>
|
||||
*/
|
||||
public function listCouponCodesForConfiguredCoupon(): array
|
||||
{
|
||||
$this->assertCompanyGuid();
|
||||
$couponGuid = $this->config->coupon_guid;
|
||||
if (! is_string($couponGuid) || $couponGuid === '') {
|
||||
throw new LogicException('Weeztix coupon is not configured.');
|
||||
}
|
||||
|
||||
$url = config('weeztix.api_base_url').'/coupon/'.$couponGuid.'/codes';
|
||||
$json = $this->apiRequest('get', $url, []);
|
||||
|
||||
return $this->normalizeCouponCodeListResponse($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-deletes a coupon code in Weeztix by matching the human-readable code string.
|
||||
*/
|
||||
public function deleteCouponCodeByCodeString(string $code): void
|
||||
{
|
||||
$trimmed = trim($code);
|
||||
if ($trimmed === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->assertCompanyGuid();
|
||||
$couponGuid = $this->config->coupon_guid;
|
||||
if (! is_string($couponGuid) || $couponGuid === '') {
|
||||
throw new LogicException('Weeztix coupon is not configured.');
|
||||
}
|
||||
|
||||
$rows = $this->listCouponCodesForConfiguredCoupon();
|
||||
$codeGuid = null;
|
||||
foreach ($rows as $row) {
|
||||
if (strcasecmp($row['code'], $trimmed) === 0) {
|
||||
$codeGuid = $row['guid'];
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($codeGuid === null) {
|
||||
Log::info('Weeztix: coupon code not found when deleting (already removed or unknown)', [
|
||||
'code' => $trimmed,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$url = config('weeztix.api_base_url').'/coupon/'.$couponGuid.'/codes/'.$codeGuid;
|
||||
$token = $this->getValidAccessToken();
|
||||
$response = $this->sendApiRequest('delete', $url, [], $token);
|
||||
|
||||
if ($response->status() === 401) {
|
||||
$this->refreshAccessToken();
|
||||
$this->config->refresh();
|
||||
$response = $this->sendApiRequest('delete', $url, [], (string) $this->config->access_token);
|
||||
}
|
||||
|
||||
if ($response->status() === 404) {
|
||||
Log::info('Weeztix: coupon code already deleted remotely', [
|
||||
'code' => $trimmed,
|
||||
'code_guid' => $codeGuid,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->logFailedResponse('deleteCouponCodeByCodeString', $url, $response);
|
||||
|
||||
throw new RuntimeException('Weeztix API delete coupon code failed: '.$response->status());
|
||||
}
|
||||
|
||||
Log::debug('Weeztix API', [
|
||||
'action' => 'deleteCouponCodeByCodeString',
|
||||
'url' => $url,
|
||||
'http_status' => $response->status(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $json
|
||||
* @return list<array{guid: string, code: string}>
|
||||
*/
|
||||
private function normalizeCouponCodeListResponse(array $json): array
|
||||
{
|
||||
$candidates = [
|
||||
data_get($json, 'data'),
|
||||
data_get($json, 'data.codes'),
|
||||
data_get($json, 'data.records'),
|
||||
data_get($json, 'codes'),
|
||||
$json,
|
||||
];
|
||||
|
||||
foreach ($candidates as $raw) {
|
||||
if (! is_array($raw)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->isListArray($raw)) {
|
||||
$normalized = $this->normalizeCouponCodeRows($raw);
|
||||
if ($normalized !== []) {
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $rows
|
||||
* @return list<array{guid: string, code: string}>
|
||||
*/
|
||||
private function normalizeCouponCodeRows(array $rows): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$guid = data_get($row, 'guid')
|
||||
?? data_get($row, 'id')
|
||||
?? data_get($row, 'coupon_code_guid');
|
||||
$code = data_get($row, 'code')
|
||||
?? data_get($row, 'coupon_code')
|
||||
?? data_get($row, 'name');
|
||||
if (! is_string($guid) || $guid === '' || ! is_string($code) || $code === '') {
|
||||
continue;
|
||||
}
|
||||
$out[] = ['guid' => $guid, 'code' => $code];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $arr
|
||||
*/
|
||||
private function isListArray(array $arr): bool
|
||||
{
|
||||
if ($arr === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return array_keys($arr) === range(0, count($arr) - 1);
|
||||
}
|
||||
|
||||
public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string
|
||||
{
|
||||
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
$code = '';
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$code .= $chars[random_int(0, strlen($chars) - 1)];
|
||||
}
|
||||
|
||||
return strtoupper($prefix).'-'.$code;
|
||||
}
|
||||
|
||||
private function assertCompanyGuid(): void
|
||||
{
|
||||
$guid = $this->config->company_guid;
|
||||
if (! is_string($guid) || $guid === '') {
|
||||
throw new LogicException('Weeztix company is not configured.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function apiRequest(string $method, string $url, array $data = []): array
|
||||
{
|
||||
$this->assertCompanyGuid();
|
||||
|
||||
$token = $this->getValidAccessToken();
|
||||
$response = $this->sendApiRequest($method, $url, $data, $token);
|
||||
|
||||
if ($response->status() === 401) {
|
||||
$this->refreshAccessToken();
|
||||
$this->config->refresh();
|
||||
$response = $this->sendApiRequest($method, $url, $data, (string) $this->config->access_token);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->logFailedResponse('apiRequest', $url, $response);
|
||||
|
||||
throw new RuntimeException('Weeztix API request failed: '.$response->status());
|
||||
}
|
||||
|
||||
Log::debug('Weeztix API', [
|
||||
'action' => 'apiRequest',
|
||||
'method' => $method,
|
||||
'url' => $url,
|
||||
'http_status' => $response->status(),
|
||||
]);
|
||||
|
||||
$json = $response->json();
|
||||
|
||||
return is_array($json) ? $json : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function sendApiRequest(string $method, string $url, array $data, string $token): Response
|
||||
{
|
||||
$client = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
'Company' => (string) $this->config->company_guid,
|
||||
])->acceptJson();
|
||||
|
||||
return match (strtolower($method)) {
|
||||
'get' => $client->get($url, $data),
|
||||
'post' => $client->asJson()->post($url, $data),
|
||||
'put' => $client->asJson()->put($url, $data),
|
||||
'patch' => $client->asJson()->patch($url, $data),
|
||||
'delete' => $client->delete($url, $data),
|
||||
default => throw new InvalidArgumentException('Unsupported HTTP method: '.$method),
|
||||
};
|
||||
}
|
||||
|
||||
private function refreshAccessToken(): void
|
||||
{
|
||||
if (! $this->config->refresh_token || $this->config->isRefreshTokenExpired()) {
|
||||
$this->config->update([
|
||||
'is_connected' => false,
|
||||
]);
|
||||
$this->config->refresh();
|
||||
|
||||
throw new RuntimeException('Weeztix refresh token missing or expired; reconnect OAuth.');
|
||||
}
|
||||
|
||||
$tokenUrl = config('weeztix.auth_base_url').'/tokens';
|
||||
$response = Http::asForm()->post($tokenUrl, [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $this->config->refresh_token,
|
||||
'client_id' => $this->config->client_id,
|
||||
'client_secret' => $this->config->client_secret,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Weeztix token refresh failed', [
|
||||
'url' => $tokenUrl,
|
||||
'status' => $response->status(),
|
||||
'body' => $response->json(),
|
||||
]);
|
||||
$this->config->update([
|
||||
'is_connected' => false,
|
||||
]);
|
||||
$this->config->refresh();
|
||||
|
||||
throw new RuntimeException('Weeztix token refresh failed: '.$response->status());
|
||||
}
|
||||
|
||||
Log::debug('Weeztix API', [
|
||||
'action' => 'refresh_token',
|
||||
'url' => $tokenUrl,
|
||||
'http_status' => $response->status(),
|
||||
]);
|
||||
|
||||
$json = $response->json();
|
||||
if (! is_array($json)) {
|
||||
throw new RuntimeException('Weeztix token refresh returned invalid JSON.');
|
||||
}
|
||||
|
||||
$this->applyTokenResponseToConfig($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $json
|
||||
*/
|
||||
private function applyTokenResponseToConfig(array $json): void
|
||||
{
|
||||
$access = $json['access_token'] ?? null;
|
||||
$refresh = $json['refresh_token'] ?? null;
|
||||
if (! is_string($access) || $access === '') {
|
||||
throw new RuntimeException('Weeztix token response missing access_token.');
|
||||
}
|
||||
|
||||
$expiresIn = isset($json['expires_in']) ? (int) $json['expires_in'] : 0;
|
||||
$refreshExpiresIn = isset($json['refresh_token_expires_in']) ? (int) $json['refresh_token_expires_in'] : 0;
|
||||
|
||||
$updates = [
|
||||
'access_token' => $access,
|
||||
'token_expires_at' => $expiresIn > 0 ? Carbon::now()->addSeconds($expiresIn) : null,
|
||||
'is_connected' => true,
|
||||
];
|
||||
|
||||
if (is_string($refresh) && $refresh !== '') {
|
||||
$updates['refresh_token'] = $refresh;
|
||||
$updates['refresh_token_expires_at'] = $refreshExpiresIn > 0
|
||||
? Carbon::now()->addSeconds($refreshExpiresIn)
|
||||
: null;
|
||||
}
|
||||
|
||||
$this->config->update($updates);
|
||||
$this->config->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the token response includes exactly one company, store it to reduce admin steps.
|
||||
*
|
||||
* @param array<string, mixed> $json
|
||||
*/
|
||||
private function hydrateCompanyFromTokenInfo(array $json): void
|
||||
{
|
||||
$companies = data_get($json, 'info.companies');
|
||||
if (! is_array($companies) || count($companies) !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$row = $companies[0];
|
||||
if (! is_array($row)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$merged = $this->mergeCompanyRowWithNested($row);
|
||||
$guid = data_get($merged, 'guid') ?? data_get($merged, 'id');
|
||||
if (! is_string($guid) || $guid === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$name = $this->resolveCompanyNameFromRow($merged, $guid);
|
||||
|
||||
$this->config->update([
|
||||
'company_guid' => $guid,
|
||||
'company_name' => $name,
|
||||
]);
|
||||
$this->config->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $profile
|
||||
* @return list<array{guid: string, name: string|null}>
|
||||
*/
|
||||
private function normalizeCompaniesFromProfile(array $profile): array
|
||||
{
|
||||
$fromInfo = data_get($profile, 'info.companies');
|
||||
if (is_array($fromInfo) && $fromInfo !== []) {
|
||||
$normalized = $this->normalizeCompanyRows($fromInfo);
|
||||
if ($normalized !== []) {
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
$companies = data_get($profile, 'companies');
|
||||
if (is_array($companies) && $companies !== []) {
|
||||
return $this->normalizeCompanyRows($companies);
|
||||
}
|
||||
|
||||
$defaultCompany = data_get($profile, 'default_company');
|
||||
if (is_array($defaultCompany)) {
|
||||
$merged = $this->mergeCompanyRowWithNested($defaultCompany);
|
||||
$guid = data_get($merged, 'guid') ?? data_get($merged, 'id');
|
||||
if (is_string($guid) && $guid !== '') {
|
||||
return [
|
||||
[
|
||||
'guid' => $guid,
|
||||
'name' => $this->resolveCompanyNameFromRow($merged, $guid),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$defaultId = data_get($profile, 'default_company_id');
|
||||
if (is_string($defaultId) && $defaultId !== '') {
|
||||
return [
|
||||
['guid' => $defaultId, 'name' => null],
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $rows
|
||||
* @return list<array{guid: string, name: string|null}>
|
||||
*/
|
||||
private function normalizeCompanyRows(array $rows): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$merged = $this->mergeCompanyRowWithNested($row);
|
||||
$guid = data_get($merged, 'guid')
|
||||
?? data_get($merged, 'id')
|
||||
?? data_get($merged, 'company_id');
|
||||
if (! is_string($guid) || $guid === '') {
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
'guid' => $guid,
|
||||
'name' => $this->resolveCompanyNameFromRow($merged, $guid),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten `{ "company": { ... } }` style payloads so name fields resolve reliably.
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mergeCompanyRowWithNested(array $row): array
|
||||
{
|
||||
$nested = data_get($row, 'company');
|
||||
if (! is_array($nested)) {
|
||||
return $row;
|
||||
}
|
||||
|
||||
return array_merge($row, $nested);
|
||||
}
|
||||
|
||||
/**
|
||||
* Weeztix / Open Ticket payloads use varying keys; `name` is sometimes a duplicate of the GUID.
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function resolveCompanyNameFromRow(array $row, ?string $companyGuid = null): ?string
|
||||
{
|
||||
$candidates = [
|
||||
data_get($row, 'trade_name'),
|
||||
data_get($row, 'commercial_name'),
|
||||
data_get($row, 'business_name'),
|
||||
data_get($row, 'legal_name'),
|
||||
data_get($row, 'company_name'),
|
||||
data_get($row, 'display_name'),
|
||||
data_get($row, 'title'),
|
||||
data_get($row, 'label'),
|
||||
data_get($row, 'general.name'),
|
||||
data_get($row, 'company.trade_name'),
|
||||
data_get($row, 'company.legal_name'),
|
||||
data_get($row, 'company.name'),
|
||||
data_get($row, 'name'),
|
||||
];
|
||||
|
||||
foreach ($candidates as $value) {
|
||||
if (! is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
$trimmed = trim($value);
|
||||
if ($trimmed === '') {
|
||||
continue;
|
||||
}
|
||||
if ($companyGuid !== null && strcasecmp($trimmed, $companyGuid) === 0) {
|
||||
continue;
|
||||
}
|
||||
if ($this->stringLooksLikeUuid($trimmed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function stringLooksLikeUuid(string $value): bool
|
||||
{
|
||||
return preg_match(
|
||||
'/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/',
|
||||
$value
|
||||
) === 1;
|
||||
}
|
||||
|
||||
private function logFailedResponse(string $action, string $url, Response $response): void
|
||||
{
|
||||
Log::error('Weeztix API request failed', [
|
||||
'action' => $action,
|
||||
'url' => $url,
|
||||
'status' => $response->status(),
|
||||
'body' => $response->json(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"giggsey/libphonenumber-for-php": "^9.0",
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/tinker": "^3.0"
|
||||
},
|
||||
|
||||
136
composer.lock
generated
136
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "0cbc4dc77a1eeff75f257f6ffae9168b",
|
||||
"content-hash": "505a0bb04eb0eb77eddad8d9e0ef372b",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -579,6 +579,140 @@
|
||||
],
|
||||
"time": "2025-12-03T09:33:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "giggsey/libphonenumber-for-php",
|
||||
"version": "9.0.27",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/giggsey/libphonenumber-for-php.git",
|
||||
"reference": "7973753b3efe38fb57dc949a6014a4d1cfce0ffd"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/giggsey/libphonenumber-for-php/zipball/7973753b3efe38fb57dc949a6014a4d1cfce0ffd",
|
||||
"reference": "7973753b3efe38fb57dc949a6014a4d1cfce0ffd",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"giggsey/locale": "^2.7",
|
||||
"php": "^8.1",
|
||||
"symfony/polyfill-mbstring": "^1.31"
|
||||
},
|
||||
"replace": {
|
||||
"giggsey/libphonenumber-for-php-lite": "self.version"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-dom": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.71",
|
||||
"infection/infection": "^0.29|^0.31.0",
|
||||
"nette/php-generator": "^4.1",
|
||||
"php-coveralls/php-coveralls": "^2.7",
|
||||
"phpstan/extension-installer": "^1.4.3",
|
||||
"phpstan/phpstan": "^2.1.7",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0.1",
|
||||
"phpstan/phpstan-phpunit": "^2.0.4",
|
||||
"phpstan/phpstan-strict-rules": "^2.0.3",
|
||||
"phpunit/phpunit": "^10.5.45",
|
||||
"symfony/console": "^6.4",
|
||||
"symfony/filesystem": "^6.4",
|
||||
"symfony/process": "^6.4"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "9.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"libphonenumber\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Joshua Gigg",
|
||||
"email": "giggsey@gmail.com",
|
||||
"homepage": "https://giggsey.com/"
|
||||
}
|
||||
],
|
||||
"description": "A library for parsing, formatting, storing and validating international phone numbers, a PHP Port of Google's libphonenumber.",
|
||||
"homepage": "https://github.com/giggsey/libphonenumber-for-php",
|
||||
"keywords": [
|
||||
"geocoding",
|
||||
"geolocation",
|
||||
"libphonenumber",
|
||||
"mobile",
|
||||
"phonenumber",
|
||||
"validation"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/giggsey/libphonenumber-for-php/issues",
|
||||
"source": "https://github.com/giggsey/libphonenumber-for-php"
|
||||
},
|
||||
"time": "2026-04-01T12:18:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "giggsey/locale",
|
||||
"version": "2.9.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/giggsey/Locale.git",
|
||||
"reference": "fe741e99ae6ccbe8132f3d63d8ec89924e689778"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/giggsey/Locale/zipball/fe741e99ae6ccbe8132f3d63d8ec89924e689778",
|
||||
"reference": "fe741e99ae6ccbe8132f3d63d8ec89924e689778",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-json": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.66",
|
||||
"infection/infection": "^0.29|^0.32.0",
|
||||
"php-coveralls/php-coveralls": "^2.7",
|
||||
"phpstan/extension-installer": "^1.4.3",
|
||||
"phpstan/phpstan": "^2.1.7",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0.1",
|
||||
"phpstan/phpstan-phpunit": "^2.0.4",
|
||||
"phpstan/phpstan-strict-rules": "^2.0.3",
|
||||
"phpunit/phpunit": "^10.5.45",
|
||||
"symfony/console": "^6.4",
|
||||
"symfony/filesystem": "^6.4",
|
||||
"symfony/finder": "^6.4",
|
||||
"symfony/process": "^6.4",
|
||||
"symfony/var-exporter": "^6.4"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Giggsey\\Locale\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Joshua Gigg",
|
||||
"email": "giggsey@gmail.com",
|
||||
"homepage": "https://giggsey.com/"
|
||||
}
|
||||
],
|
||||
"description": "Locale functions required by libphonenumber-for-php",
|
||||
"support": {
|
||||
"issues": "https://github.com/giggsey/Locale/issues",
|
||||
"source": "https://github.com/giggsey/Locale/tree/2.9.0"
|
||||
},
|
||||
"time": "2026-02-24T15:32:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "graham-campbell/result-type",
|
||||
"version": "v1.1.4",
|
||||
|
||||
@@ -7,6 +7,18 @@ $defaultPerMinute = in_array($env, ['local', 'testing'], true) ? 1000 : 60;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default phone region (ISO 3166-1 alpha-2)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Used when parsing numbers without a country prefix (e.g. national format).
|
||||
| Override with PREREGISTER_DEFAULT_PHONE_REGION in .env.
|
||||
|
|
||||
*/
|
||||
|
||||
'default_phone_region' => strtoupper((string) env('PREREGISTER_DEFAULT_PHONE_REGION', 'NL')),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Public routes rate limit
|
||||
|
||||
57
config/weeztix.php
Normal file
57
config/weeztix.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| OAuth login / authorization (browser redirect)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Official Weeztix: users must be sent to login.weeztix.com with client_id,
|
||||
| redirect_uri, response_type=code, and state. Do NOT use auth.../tokens/authorize
|
||||
| unless your OAuth provider documents that path (e.g. some Open Ticket setups).
|
||||
|
|
||||
| Open Ticket example (if your client was created there):
|
||||
| WEEZTIX_OAUTH_AUTHORIZE_URL=https://auth.openticket.tech/tokens/authorize
|
||||
| WEEZTIX_AUTH_BASE_URL=https://auth.openticket.tech
|
||||
|
|
||||
*/
|
||||
|
||||
'oauth_authorize_url' => rtrim((string) env(
|
||||
'WEEZTIX_OAUTH_AUTHORIZE_URL',
|
||||
'https://login.weeztix.com/login'
|
||||
), '/'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Token endpoint base (authorization code + refresh)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| POST {auth_base_url}/tokens — official Weeztix: https://auth.weeztix.com/tokens
|
||||
|
|
||||
*/
|
||||
|
||||
'auth_base_url' => rtrim((string) env('WEEZTIX_AUTH_BASE_URL', 'https://auth.weeztix.com'), '/'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Weeztix API base URL
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
'api_base_url' => rtrim((string) env('WEEZTIX_API_BASE_URL', 'https://api.weeztix.com'), '/'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Current user profile (token validity + company hints)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Should match the issuer of your access_token (usually same host as auth_base_url).
|
||||
|
|
||||
*/
|
||||
|
||||
'user_profile_url' => (string) env('WEEZTIX_USER_PROFILE_URL', 'https://auth.weeztix.com/users/me'),
|
||||
|
||||
];
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('preregistration_pages', function (Blueprint $table): void {
|
||||
$table->boolean('background_fixed')->default(false)->after('background_overlay_opacity');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('preregistration_pages', function (Blueprint $table): void {
|
||||
$table->dropColumn('background_fixed');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Subscriber;
|
||||
use App\Services\PhoneNumberNormalizer;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('subscribers')) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var PhoneNumberNormalizer $normalizer */
|
||||
$normalizer = app(PhoneNumberNormalizer::class);
|
||||
|
||||
Subscriber::query()
|
||||
->whereNotNull('phone')
|
||||
->where('phone', '!=', '')
|
||||
->orderBy('id')
|
||||
->chunkById(100, function ($subscribers) use ($normalizer): void {
|
||||
foreach ($subscribers as $subscriber) {
|
||||
$p = $subscriber->phone;
|
||||
if (! is_string($p) || $p === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($p, '+')) {
|
||||
$normalized = $normalizer->normalizeToE164($p);
|
||||
if ($normalized !== null && $normalized !== $p) {
|
||||
$subscriber->update(['phone' => $normalized]);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{8,15}$/', $p) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized = $normalizer->normalizeToE164('+'.$p);
|
||||
if ($normalized !== null) {
|
||||
$subscriber->update(['phone' => $normalized]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Irreversible: we cannot recover original user input formatting.
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('weeztix_configs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('preregistration_page_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
$table->text('client_id');
|
||||
$table->text('client_secret');
|
||||
$table->string('redirect_uri');
|
||||
|
||||
$table->text('access_token')->nullable();
|
||||
$table->text('refresh_token')->nullable();
|
||||
$table->timestamp('token_expires_at')->nullable();
|
||||
$table->timestamp('refresh_token_expires_at')->nullable();
|
||||
|
||||
$table->string('company_guid')->nullable();
|
||||
$table->string('company_name')->nullable();
|
||||
|
||||
$table->string('coupon_guid')->nullable();
|
||||
$table->string('coupon_name')->nullable();
|
||||
|
||||
$table->string('code_prefix')->default('PREREG');
|
||||
$table->integer('usage_count')->default(1);
|
||||
|
||||
$table->boolean('is_connected')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('weeztix_configs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('subscribers', function (Blueprint $table) {
|
||||
$table->string('coupon_code')->nullable()->after('synced_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('subscribers', function (Blueprint $table) {
|
||||
$table->dropColumn('coupon_code');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('mailwizz_configs', function (Blueprint $table) {
|
||||
$table->string('field_coupon_code')->nullable()->after('field_phone');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('mailwizz_configs', function (Blueprint $table) {
|
||||
$table->dropColumn('field_coupon_code');
|
||||
});
|
||||
}
|
||||
};
|
||||
29
deploy.sh
29
deploy.sh
@@ -9,10 +9,15 @@ set -e
|
||||
# ./deploy.sh v1.2.0 → deploys specific tag
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
# !! UPDATE THIS PATH TO YOUR VPS DIRECTORY !!
|
||||
APP_DIR="/home/hausdesign/domains/preregister.hausdesign.nl/public_html"
|
||||
APP_DIR="/home/hausdesign/preregister"
|
||||
PHP="/usr/local/php84/bin/php"
|
||||
COMPOSER="/usr/local/bin/composer"
|
||||
TAG="${1:-}"
|
||||
|
||||
# Load fnm (Node version manager)
|
||||
export PATH="$HOME/.local/share/fnm:$PATH"
|
||||
eval "$(fnm env)"
|
||||
|
||||
echo "══════════════════════════════════════"
|
||||
echo " PreRegister — Deploy"
|
||||
echo "══════════════════════════════════════"
|
||||
@@ -21,7 +26,7 @@ cd "$APP_DIR"
|
||||
|
||||
# 1. Maintenance mode
|
||||
echo "→ Enabling maintenance mode..."
|
||||
php artisan down --retry=30 || true
|
||||
$PHP artisan down --retry=30 || true
|
||||
|
||||
# 2. Pull latest code
|
||||
echo "→ Pulling from Gitea..."
|
||||
@@ -38,7 +43,7 @@ fi
|
||||
|
||||
# 3. Install PHP dependencies
|
||||
echo "→ Installing Composer dependencies..."
|
||||
composer install --no-dev --optimize-autoloader --no-interaction
|
||||
$PHP $COMPOSER install --no-dev --optimize-autoloader --no-interaction
|
||||
|
||||
# 4. Install Node dependencies and build
|
||||
echo "→ Installing npm packages..."
|
||||
@@ -49,25 +54,25 @@ npm run build
|
||||
|
||||
# 5. Run migrations
|
||||
echo "→ Running migrations..."
|
||||
php artisan migrate --force
|
||||
$PHP artisan migrate --force
|
||||
|
||||
# 6. Clear and rebuild caches
|
||||
echo "→ Clearing caches..."
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
php artisan event:cache
|
||||
$PHP artisan config:cache
|
||||
$PHP artisan route:cache
|
||||
$PHP artisan view:cache
|
||||
$PHP artisan event:cache
|
||||
|
||||
# 7. Restart queue (process any pending jobs with new code)
|
||||
echo "→ Restarting queue workers..."
|
||||
php artisan queue:restart
|
||||
$PHP artisan queue:restart
|
||||
|
||||
# 8. Storage link (idempotent)
|
||||
php artisan storage:link 2>/dev/null || true
|
||||
$PHP artisan storage:link 2>/dev/null || true
|
||||
|
||||
# 9. Disable maintenance mode
|
||||
echo "→ Going live!"
|
||||
php artisan up
|
||||
$PHP artisan up
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════"
|
||||
|
||||
709
documentation/CURSOR-PROMPT-WEEZTIX.md
Normal file
709
documentation/CURSOR-PROMPT-WEEZTIX.md
Normal file
@@ -0,0 +1,709 @@
|
||||
# Cursor Prompt — Weeztix Coupon Code Integration
|
||||
|
||||
> Paste this into Cursor chat with `@Codebase` and `@PreRegister-Development-Prompt.md` as context.
|
||||
|
||||
---
|
||||
|
||||
## Role & Context
|
||||
|
||||
You are a senior full-stack developer and integration architect. You're working on the PreRegister Laravel 11 application (Blade + Tailwind CSS + Alpine.js). No React, no Vue, no Livewire.
|
||||
|
||||
The application already has a Mailwizz integration that syncs subscribers to an email marketing platform. You are now adding a **second integration: Weeztix** — a ticket sales platform. When a visitor pre-registers on a page, a unique coupon code is generated via the Weeztix API and assigned to the subscriber. This coupon code can then be forwarded to Mailwizz so the subscriber receives a personalized discount email.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Weeztix Platform Concepts
|
||||
|
||||
### Coupon vs CouponCode
|
||||
|
||||
Weeztix separates these into two resources:
|
||||
|
||||
- **Coupon** = a template/definition. Defines the discount type (percentage, fixed amount), value, what it applies to (orders or products). The Coupon is configured by the event organizer in Weeztix dashboard.
|
||||
- **CouponCode** = the actual code a visitor enters in the ticket shop to get the discount. Each CouponCode belongs to a Coupon and inherits its discount settings. CouponCodes are unique strings like `PREREG-A7X9K2`.
|
||||
|
||||
**In our flow:** The user selects an existing Coupon in the backend. When a visitor registers, we create a unique CouponCode under that Coupon via the API.
|
||||
|
||||
### Authentication: OAuth2 Authorization Code Grant
|
||||
|
||||
Weeztix uses the OAuth2 Authorization Code flow. Key details:
|
||||
|
||||
**Endpoints:**
|
||||
| Action | Method | URL |
|
||||
|---|---|---|
|
||||
| Authorize (redirect user) | GET | `https://auth.openticket.tech/tokens/authorize` |
|
||||
| Exchange code for token | POST | `https://auth.openticket.tech/tokens` |
|
||||
| Refresh token | POST | `https://auth.openticket.tech/tokens` |
|
||||
| API requests | Various | `https://api.weeztix.com/...` |
|
||||
|
||||
**Authorization redirect parameters:**
|
||||
```
|
||||
https://auth.openticket.tech/tokens/authorize?
|
||||
client_id={OAUTH_CLIENT_ID}
|
||||
&redirect_uri={OAUTH_CLIENT_REDIRECT}
|
||||
&response_type=code
|
||||
&state={random_state}
|
||||
```
|
||||
|
||||
**Token exchange (POST to `https://auth.openticket.tech/tokens`):**
|
||||
```json
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": "...",
|
||||
"client_secret": "...",
|
||||
"redirect_uri": "...",
|
||||
"code": "..."
|
||||
}
|
||||
```
|
||||
|
||||
**Token response:**
|
||||
```json
|
||||
{
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 259200,
|
||||
"access_token": "THE_ACTUAL_TOKEN",
|
||||
"refresh_token": "REFRESH_TOKEN",
|
||||
"refresh_token_expires_in": 31535999
|
||||
}
|
||||
```
|
||||
- `access_token` expires in ~3 days (259200 seconds)
|
||||
- `refresh_token` expires in ~365 days, can only be used once
|
||||
|
||||
**Refresh token (POST to `https://auth.openticket.tech/tokens`):**
|
||||
```json
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": "...",
|
||||
"client_id": "...",
|
||||
"client_secret": "..."
|
||||
}
|
||||
```
|
||||
Returns a new `access_token` and a new `refresh_token`.
|
||||
|
||||
**API requests require:**
|
||||
- Header: `Authorization: Bearer {access_token}`
|
||||
- Header: `Company: {company_guid}` (to scope requests to a specific company)
|
||||
|
||||
### API Endpoints We Need
|
||||
|
||||
| Action | Method | URL | Notes |
|
||||
|---|---|---|---|
|
||||
| Get coupons | GET | `https://api.weeztix.com/coupon` | Returns coupons for the company (Company header) |
|
||||
| Add coupon codes | POST | `https://api.weeztix.com/coupon/{coupon_guid}/couponCode` | Creates one or more codes under a coupon |
|
||||
|
||||
**Add CouponCodes request body:**
|
||||
```json
|
||||
{
|
||||
"usage_count": 1,
|
||||
"applies_to_count": null,
|
||||
"codes": [
|
||||
{
|
||||
"code": "PREREG-A7X9K2"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
- `usage_count`: how many times the code can be used (1 = single use per subscriber)
|
||||
- `applies_to_count`: null = unlimited items in the order can use the discount
|
||||
- `codes`: array of code objects, each with a unique `code` string
|
||||
|
||||
**Important:** Duplicate CouponCodes cannot be added to a Coupon. Generate unique codes.
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Database Changes
|
||||
|
||||
### New Migration: `weeztix_configs` table
|
||||
|
||||
```php
|
||||
Schema::create('weeztix_configs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('preregistration_page_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
// OAuth credentials (encrypted at rest)
|
||||
$table->text('client_id');
|
||||
$table->text('client_secret');
|
||||
$table->string('redirect_uri');
|
||||
|
||||
// OAuth tokens (encrypted at rest)
|
||||
$table->text('access_token')->nullable();
|
||||
$table->text('refresh_token')->nullable();
|
||||
$table->timestamp('token_expires_at')->nullable();
|
||||
$table->timestamp('refresh_token_expires_at')->nullable();
|
||||
|
||||
// Company context
|
||||
$table->string('company_guid')->nullable();
|
||||
$table->string('company_name')->nullable();
|
||||
|
||||
// Selected coupon
|
||||
$table->string('coupon_guid')->nullable();
|
||||
$table->string('coupon_name')->nullable();
|
||||
|
||||
// CouponCode settings
|
||||
$table->string('code_prefix')->default('PREREG'); // prefix for generated codes
|
||||
$table->integer('usage_count')->default(1); // how many times a code can be used
|
||||
|
||||
$table->boolean('is_connected')->default(false); // OAuth flow completed?
|
||||
$table->timestamps();
|
||||
});
|
||||
```
|
||||
|
||||
### Modify `subscribers` table
|
||||
|
||||
Add a column to store the generated coupon code per subscriber:
|
||||
|
||||
```php
|
||||
Schema::table('subscribers', function (Blueprint $table) {
|
||||
$table->string('coupon_code')->nullable()->after('synced_at');
|
||||
});
|
||||
```
|
||||
|
||||
### Model: `WeeztixConfig`
|
||||
|
||||
```php
|
||||
class WeeztixConfig extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'preregistration_page_id', 'client_id', 'client_secret', 'redirect_uri',
|
||||
'access_token', 'refresh_token', 'token_expires_at', 'refresh_token_expires_at',
|
||||
'company_guid', 'company_name', 'coupon_guid', 'coupon_name',
|
||||
'code_prefix', 'usage_count', 'is_connected',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'client_id' => 'encrypted',
|
||||
'client_secret' => 'encrypted',
|
||||
'access_token' => 'encrypted',
|
||||
'refresh_token' => 'encrypted',
|
||||
'token_expires_at' => 'datetime',
|
||||
'refresh_token_expires_at' => 'datetime',
|
||||
'is_connected' => 'boolean',
|
||||
];
|
||||
|
||||
public function preregistrationPage(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PreregistrationPage::class);
|
||||
}
|
||||
|
||||
public function isTokenExpired(): bool
|
||||
{
|
||||
return !$this->token_expires_at || $this->token_expires_at->isPast();
|
||||
}
|
||||
|
||||
public function isRefreshTokenExpired(): bool
|
||||
{
|
||||
return !$this->refresh_token_expires_at || $this->refresh_token_expires_at->isPast();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Update `PreregistrationPage` model
|
||||
|
||||
Add:
|
||||
```php
|
||||
public function weeztixConfig(): HasOne
|
||||
{
|
||||
return $this->hasOne(WeeztixConfig::class);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 3: WeeztixService
|
||||
|
||||
Create `app/Services/WeeztixService.php` — encapsulates all Weeztix API communication with automatic token refresh.
|
||||
|
||||
```php
|
||||
class WeeztixService
|
||||
{
|
||||
private WeeztixConfig $config;
|
||||
|
||||
public function __construct(WeeztixConfig $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid access token, refreshing if necessary.
|
||||
* Updates the config model with new tokens.
|
||||
*/
|
||||
public function getValidAccessToken(): string
|
||||
|
||||
/**
|
||||
* Refresh the access token using the refresh token.
|
||||
* Stores the new tokens in the config.
|
||||
* Throws exception if refresh token is also expired (re-auth needed).
|
||||
*/
|
||||
private function refreshAccessToken(): void
|
||||
|
||||
/**
|
||||
* Make an authenticated API request.
|
||||
* Automatically refreshes token if expired.
|
||||
*/
|
||||
private function apiRequest(string $method, string $url, array $data = []): array
|
||||
|
||||
/**
|
||||
* Get all companies the token has access to.
|
||||
* GET https://auth.weeztix.com/users/me (or similar endpoint)
|
||||
*/
|
||||
public function getCompanies(): array
|
||||
|
||||
/**
|
||||
* Get all coupons for the configured company.
|
||||
* GET https://api.weeztix.com/coupon
|
||||
* Header: Company: {company_guid}
|
||||
*/
|
||||
public function getCoupons(): array
|
||||
|
||||
/**
|
||||
* Create a unique coupon code under the configured coupon.
|
||||
* POST https://api.weeztix.com/coupon/{coupon_guid}/couponCode
|
||||
* Header: Company: {company_guid}
|
||||
*/
|
||||
public function createCouponCode(string $code): array
|
||||
|
||||
/**
|
||||
* Generate a unique code string.
|
||||
* Format: {prefix}-{random alphanumeric}
|
||||
* Example: PREREG-A7X9K2
|
||||
*/
|
||||
public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string
|
||||
}
|
||||
```
|
||||
|
||||
### Key Implementation Details for WeeztixService
|
||||
|
||||
**Token refresh logic:**
|
||||
```
|
||||
1. Check if access_token exists and is not expired
|
||||
2. If expired, check if refresh_token exists and is not expired
|
||||
3. If refresh_token valid: POST to https://auth.openticket.tech/tokens with grant_type=refresh_token
|
||||
4. Store new access_token, refresh_token, and their expiry timestamps
|
||||
5. If refresh_token also expired: mark config as disconnected, throw exception (user must re-authorize)
|
||||
```
|
||||
|
||||
**API request wrapper:**
|
||||
```php
|
||||
private function apiRequest(string $method, string $url, array $data = []): array
|
||||
{
|
||||
$token = $this->getValidAccessToken();
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => "Bearer {$token}",
|
||||
'Company' => $this->config->company_guid,
|
||||
])->{$method}($url, $data);
|
||||
|
||||
if ($response->status() === 401) {
|
||||
// Token might have been revoked, try refresh once
|
||||
$this->refreshAccessToken();
|
||||
$token = $this->config->access_token;
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => "Bearer {$token}",
|
||||
'Company' => $this->config->company_guid,
|
||||
])->{$method}($url, $data);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Weeztix API request failed', [
|
||||
'url' => $url,
|
||||
'status' => $response->status(),
|
||||
'body' => $response->json(),
|
||||
]);
|
||||
throw new \RuntimeException("Weeztix API request failed: {$response->status()}");
|
||||
}
|
||||
|
||||
return $response->json();
|
||||
}
|
||||
```
|
||||
|
||||
**Unique code generation:**
|
||||
```php
|
||||
public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string
|
||||
{
|
||||
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O/1/I to avoid confusion
|
||||
$code = '';
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$code .= $chars[random_int(0, strlen($chars) - 1)];
|
||||
}
|
||||
return strtoupper($prefix) . '-' . $code;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 4: OAuth Flow — Routes & Controller
|
||||
|
||||
### Routes
|
||||
|
||||
```php
|
||||
// Inside the admin auth middleware group:
|
||||
|
||||
// Weeztix configuration (per page)
|
||||
Route::get('pages/{page}/weeztix', [WeeztixController::class, 'edit'])->name('pages.weeztix.edit');
|
||||
Route::put('pages/{page}/weeztix', [WeeztixController::class, 'update'])->name('pages.weeztix.update');
|
||||
Route::delete('pages/{page}/weeztix', [WeeztixController::class, 'destroy'])->name('pages.weeztix.destroy');
|
||||
|
||||
// OAuth callback (needs to be accessible during OAuth flow)
|
||||
Route::get('weeztix/callback', [WeeztixOAuthController::class, 'callback'])->name('weeztix.callback');
|
||||
|
||||
// AJAX endpoints for dynamic loading
|
||||
Route::post('weeztix/coupons', [WeeztixApiController::class, 'coupons'])->name('weeztix.coupons');
|
||||
```
|
||||
|
||||
### OAuth Flow
|
||||
|
||||
**Step 1: User enters client_id and client_secret in the backend form.**
|
||||
|
||||
**Step 2: User clicks "Connect to Weeztix" button.**
|
||||
|
||||
The controller builds the authorization URL and redirects:
|
||||
```php
|
||||
public function redirect(PreregistrationPage $page)
|
||||
{
|
||||
$config = $page->weeztixConfig;
|
||||
$state = Str::random(40);
|
||||
|
||||
// Store state + page ID in session for the callback
|
||||
session(['weeztix_oauth_state' => $state, 'weeztix_page_id' => $page->id]);
|
||||
|
||||
$query = http_build_query([
|
||||
'client_id' => $config->client_id,
|
||||
'redirect_uri' => route('admin.weeztix.callback'),
|
||||
'response_type' => 'code',
|
||||
'state' => $state,
|
||||
]);
|
||||
|
||||
return redirect("https://auth.openticket.tech/tokens/authorize?{$query}");
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Weeztix redirects back to our callback URL with `code` and `state`.**
|
||||
|
||||
```php
|
||||
public function callback(Request $request)
|
||||
{
|
||||
// Verify state
|
||||
$storedState = session('weeztix_oauth_state');
|
||||
$pageId = session('weeztix_page_id');
|
||||
abort_if($request->state !== $storedState, 403, 'Invalid state');
|
||||
|
||||
// Exchange code for tokens
|
||||
$response = Http::post('https://auth.openticket.tech/tokens', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => $config->client_id,
|
||||
'client_secret' => $config->client_secret,
|
||||
'redirect_uri' => route('admin.weeztix.callback'),
|
||||
'code' => $request->code,
|
||||
]);
|
||||
|
||||
// Store tokens in weeztix_configs
|
||||
$config->update([
|
||||
'access_token' => $data['access_token'],
|
||||
'refresh_token' => $data['refresh_token'],
|
||||
'token_expires_at' => now()->addSeconds($data['expires_in']),
|
||||
'refresh_token_expires_at' => now()->addSeconds($data['refresh_token_expires_in']),
|
||||
'is_connected' => true,
|
||||
]);
|
||||
|
||||
// Redirect back to the Weeztix config page
|
||||
return redirect()->route('admin.pages.weeztix.edit', $pageId)
|
||||
->with('success', 'Successfully connected to Weeztix!');
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: After OAuth, the user selects a Company and Coupon (see Part 5).**
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Backend Configuration UI
|
||||
|
||||
### Weeztix Configuration Page (tab/section within page edit)
|
||||
|
||||
Build a multi-step configuration interface similar to the Mailwizz config:
|
||||
|
||||
```
|
||||
┌─── Weeztix Integration ────────────────────────────────┐
|
||||
│ │
|
||||
│ Step 1: OAuth Credentials │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ Client ID: [________________________] │ │
|
||||
│ │ Client Secret: [________________________] │ │
|
||||
│ │ Redirect URI: https://preregister.crewli.nl │ │
|
||||
│ │ /admin/weeztix/callback │ │
|
||||
│ │ (auto-generated, read-only) │ │
|
||||
│ │ │ │
|
||||
│ │ [Connect to Weeztix] ← OAuth redirect button │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Step 2: Select Coupon (shown after OAuth success) │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ Status: ✅ Connected │ │
|
||||
│ │ Token expires: 2026-04-07 14:30 │ │
|
||||
│ │ │ │
|
||||
│ │ Coupon: [▼ Select a coupon ] │ │
|
||||
│ │ - Early Bird 20% discount │ │
|
||||
│ │ - Pre-register €5 korting │ │
|
||||
│ │ │ │
|
||||
│ │ Code Prefix: [PREREG ] │ │
|
||||
│ │ Usage per Code: [1] │ │
|
||||
│ │ │ │
|
||||
│ │ [Save Configuration] │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Disconnect Weeztix] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Instructions to show the user:**
|
||||
> "Before connecting, create an OAuth Client in your Weeztix dashboard and set the redirect URI to: `{route('admin.weeztix.callback')}`. Also create a Coupon with the desired discount settings. You will select this Coupon here after connecting."
|
||||
|
||||
**Loading Coupons (AJAX):**
|
||||
After successful OAuth, use Alpine.js to fetch coupons via:
|
||||
```
|
||||
POST /admin/weeztix/coupons
|
||||
Body: { page_id: ... }
|
||||
```
|
||||
The controller uses `WeeztixService::getCoupons()` and returns the list as JSON.
|
||||
|
||||
---
|
||||
|
||||
## Part 6: Frontend — Coupon Code Generation on Registration
|
||||
|
||||
### Updated Subscription Flow
|
||||
|
||||
When a visitor registers on a public page:
|
||||
|
||||
```
|
||||
1. Validate form input
|
||||
2. Check for duplicate email
|
||||
3. Store subscriber in database
|
||||
4. IF Weeztix is configured AND connected:
|
||||
a. Generate a unique coupon code: PREREG-A7X9K2
|
||||
b. Create the coupon code in Weeztix via API
|
||||
c. Store the coupon code on the subscriber record
|
||||
5. IF Mailwizz is configured:
|
||||
a. Dispatch SyncSubscriberToMailwizz job
|
||||
b. The job should include the coupon_code in the Mailwizz subscriber data
|
||||
(if a Mailwizz field mapping for coupon_code is configured)
|
||||
6. Return success with thank-you message
|
||||
```
|
||||
|
||||
### Job: `CreateWeeztixCouponCode`
|
||||
|
||||
Create a queued job (or execute synchronously if speed is critical — the visitor should see the coupon code in the thank-you message):
|
||||
|
||||
```php
|
||||
class CreateWeeztixCouponCode implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
private Subscriber $subscriber
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$page = $this->subscriber->preregistrationPage;
|
||||
$config = $page->weeztixConfig;
|
||||
|
||||
if (!$config || !$config->is_connected || !$config->coupon_guid) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = new WeeztixService($config);
|
||||
$code = WeeztixService::generateUniqueCode($config->code_prefix);
|
||||
|
||||
try {
|
||||
$service->createCouponCode($code);
|
||||
|
||||
$this->subscriber->update(['coupon_code' => $code]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to create Weeztix coupon code', [
|
||||
'subscriber_id' => $this->subscriber->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e; // Let the job retry
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**IMPORTANT DECISION: Sync vs Async**
|
||||
|
||||
If the coupon code should be shown in the thank-you message immediately after registration, the Weeztix API call must be **synchronous** (not queued). In that case, call the service directly in `PublicPageController@subscribe` instead of dispatching a job:
|
||||
|
||||
```php
|
||||
// In PublicPageController@subscribe, after storing the subscriber:
|
||||
if ($page->weeztixConfig && $page->weeztixConfig->is_connected) {
|
||||
try {
|
||||
$service = new WeeztixService($page->weeztixConfig);
|
||||
$code = WeeztixService::generateUniqueCode($page->weeztixConfig->code_prefix);
|
||||
$service->createCouponCode($code);
|
||||
$subscriber->update(['coupon_code' => $code]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Weeztix coupon creation failed', ['error' => $e->getMessage()]);
|
||||
// Don't fail the registration — the subscriber is already saved
|
||||
}
|
||||
}
|
||||
|
||||
// Then dispatch the Mailwizz sync job (which includes the coupon_code)
|
||||
if ($page->mailwizzConfig) {
|
||||
SyncSubscriberToMailwizz::dispatch($subscriber->fresh());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $page->thank_you_message ?? 'Bedankt voor je registratie!',
|
||||
'coupon_code' => $subscriber->coupon_code, // null if Weeztix not configured
|
||||
]);
|
||||
```
|
||||
|
||||
### Show Coupon Code in Thank-You State
|
||||
|
||||
Update the Alpine.js success state to display the coupon code if present:
|
||||
|
||||
```html
|
||||
<div x-show="submitted" class="text-center text-white">
|
||||
<p x-text="successMessage"></p>
|
||||
|
||||
<template x-if="couponCode">
|
||||
<div class="mt-6 bg-white/10 backdrop-blur rounded-xl p-6 border border-white/20">
|
||||
<p class="text-sm text-white/70 mb-2">Jouw kortingscode:</p>
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<span class="text-2xl font-mono font-bold tracking-wider text-orange-400"
|
||||
x-text="couponCode"></span>
|
||||
<button @click="copyCode()"
|
||||
class="text-white/50 hover:text-white transition">
|
||||
<!-- Copy icon (Heroicon clipboard) -->
|
||||
<svg>...</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 mt-3">
|
||||
Gebruik deze code bij het afrekenen in de ticketshop.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 7: Mailwizz Integration — Forward Coupon Code
|
||||
|
||||
### Add Coupon Code Field Mapping to Mailwizz Config
|
||||
|
||||
Add a new field to `mailwizz_configs`:
|
||||
|
||||
```php
|
||||
// New migration
|
||||
Schema::table('mailwizz_configs', function (Blueprint $table) {
|
||||
$table->string('field_coupon_code')->nullable()->after('field_phone');
|
||||
// This maps to a Mailwizz custom field (e.g., 'COUPON' tag)
|
||||
});
|
||||
```
|
||||
|
||||
### Update Mailwizz Configuration Wizard
|
||||
|
||||
In the field mapping step, add an additional mapping:
|
||||
- **Coupon Code** → show text fields from the Mailwizz list → let user select the field where the coupon code should be stored (e.g., a custom field with tag `COUPON`)
|
||||
- This mapping is optional — only shown if Weeztix is also configured
|
||||
|
||||
### Update SyncSubscriberToMailwizz Job
|
||||
|
||||
When building the Mailwizz subscriber data array, include the coupon code if the mapping exists:
|
||||
|
||||
```php
|
||||
// In the sync job, when building the data array:
|
||||
if ($config->field_coupon_code && $subscriber->coupon_code) {
|
||||
$data[$config->field_coupon_code] = $subscriber->coupon_code;
|
||||
}
|
||||
```
|
||||
|
||||
This allows the Mailwizz email template to include `[COUPON]` as a merge tag, so each subscriber receives their personal coupon code in the email.
|
||||
|
||||
---
|
||||
|
||||
## Part 8: Subscriber Management — Show Coupon Codes
|
||||
|
||||
### Update Subscribers Index
|
||||
|
||||
Add the coupon code column to the subscribers table in the backend:
|
||||
|
||||
| First Name | Last Name | Email | Phone | Coupon Code | Synced | Registered |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Bert | Hausmans | bert@... | +316... | PREREG-A7X9K2 | ✅ | 2026-04-04 |
|
||||
|
||||
### Update CSV Export
|
||||
|
||||
Add the `coupon_code` column to the CSV export.
|
||||
|
||||
---
|
||||
|
||||
## Part 9: Implementation Order
|
||||
|
||||
### Step 1: Database
|
||||
- Create `weeztix_configs` migration
|
||||
- Add `coupon_code` to `subscribers` migration
|
||||
- Add `field_coupon_code` to `mailwizz_configs` migration
|
||||
- Create `WeeztixConfig` model
|
||||
- Update `PreregistrationPage` model with `weeztixConfig` relationship
|
||||
- Run migrations
|
||||
|
||||
### Step 2: WeeztixService
|
||||
- Create `app/Services/WeeztixService.php`
|
||||
- Implement token management (get valid token, refresh, detect expiry)
|
||||
- Implement `getCoupons()` and `createCouponCode()`
|
||||
- Add unique code generation
|
||||
|
||||
### Step 3: OAuth Flow
|
||||
- Create `WeeztixOAuthController` (redirect + callback)
|
||||
- Add routes for OAuth
|
||||
- Handle state validation and token storage
|
||||
|
||||
### Step 4: Backend Configuration UI
|
||||
- Create `WeeztixController` (edit, update, destroy)
|
||||
- Create `WeeztixApiController` (coupons AJAX endpoint)
|
||||
- Create Blade view `admin/weeztix/edit.blade.php`
|
||||
- Build the multi-step form with Alpine.js
|
||||
- Add link/tab in the page edit navigation
|
||||
|
||||
### Step 5: Frontend Coupon Generation
|
||||
- Update `PublicPageController@subscribe` to generate coupon codes
|
||||
- Update Alpine.js success state to show coupon code
|
||||
- Add copy-to-clipboard functionality
|
||||
|
||||
### Step 6: Mailwizz Coupon Forwarding
|
||||
- Update Mailwizz config migration and model
|
||||
- Update Mailwizz configuration wizard (add coupon_code field mapping)
|
||||
- Update `SyncSubscriberToMailwizz` job to include coupon code
|
||||
|
||||
### Step 7: Backend Updates
|
||||
- Update subscribers index to show coupon codes
|
||||
- Update CSV export to include coupon codes
|
||||
- Add Weeztix connection status indicator on the pages index
|
||||
|
||||
### Step 8: Error Handling & Edge Cases
|
||||
- Handle Weeztix API downtime (don't fail registration)
|
||||
- Handle expired refresh tokens (show "Reconnect" button)
|
||||
- Handle duplicate coupon codes (retry with new code)
|
||||
- Handle Weeztix rate limiting
|
||||
- Log all API interactions
|
||||
|
||||
---
|
||||
|
||||
## Important Rules
|
||||
|
||||
- **OAuth credentials must be encrypted** — use Laravel's `encrypted` cast
|
||||
- **Token refresh must be automatic** — the user should never have to manually refresh
|
||||
- **Registration must not fail** if Weeztix is down — coupon code is a bonus, not a requirement
|
||||
- **Coupon codes must be unique** — use the unambiguous character set (no 0/O/1/I)
|
||||
- **Blade + Alpine.js + Tailwind only** — no additional JS frameworks
|
||||
- **All text in Dutch** — labels, messages, instructions
|
||||
- **Don't break existing integrations** — Mailwizz sync must continue working, form blocks must remain functional
|
||||
- **Refer to the Weeztix API documentation** for exact request/response formats:
|
||||
- Authentication: https://docs.weeztix.com/docs/introduction/authentication/
|
||||
- Request token: https://docs.weeztix.com/docs/introduction/authentication/request-token
|
||||
- Refresh token: https://docs.weeztix.com/docs/introduction/authentication/refresh-token
|
||||
- Get Coupons: https://docs.weeztix.com/api/dashboard/get-coupons
|
||||
- Add CouponCodes: https://docs.weeztix.com/api/dashboard/add-coupon-codes
|
||||
- Issuing requests (Company header): https://docs.weeztix.com/docs/introduction/issue-request/
|
||||
@@ -269,6 +269,12 @@ Laravel ships with a `public/.htaccess` that works with Apache. Verify `mod_rewr
|
||||
|
||||
### 4.7 Set up cron for queue worker + scheduler
|
||||
|
||||
Public registration saves the subscriber in the database first, then queues **Weeztix** (coupon code) and **Mailwizz** sync jobs. Those jobs must be processed by a worker.
|
||||
|
||||
- **Visitor-facing behaviour:** the public subscribe endpoint returns **HTTP 200 with `success: true`** as soon as the subscriber row is stored. Failures in Mailwizz or Weeztix are **logged** (and visible via failed jobs when using a real queue); they do **not** change the JSON shown to the visitor. Use logs and admin resync to diagnose integration issues.
|
||||
- **Production:** set `QUEUE_CONNECTION=database` (or `redis`) so retries and `queue:failed` work as intended. `sync` is acceptable for small installs but runs integration work in-process; still, the visitor should not see 5xx from a broken Mailwizz/Weeztix API after subscribe.
|
||||
- **Queues:** coupon jobs use `weeztix`; Mailwizz uses `mailwizz`. The worker should listen to both (order below prioritises `weeztix` so coupon creation tends to run before sync when both are pending).
|
||||
|
||||
In DirectAdmin → Cron Jobs, add:
|
||||
|
||||
```
|
||||
@@ -276,9 +282,11 @@ In DirectAdmin → Cron Jobs, add:
|
||||
* * * * * cd /home/username/preregister && php artisan schedule:run >> /dev/null 2>&1
|
||||
|
||||
# Queue worker - process one job per run (every minute)
|
||||
* * * * * cd /home/username/preregister && php artisan queue:work --once --queue=mailwizz >> /dev/null 2>&1
|
||||
* * * * * cd /home/username/preregister && php artisan queue:work --once --queue=weeztix,mailwizz,default >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
For higher throughput, use a persistent supervisor-managed worker instead of `--once` in cron; keep the same `--queue=weeztix,mailwizz,default` order.
|
||||
|
||||
### 4.8 Directory permissions
|
||||
|
||||
```bash
|
||||
|
||||
@@ -14,13 +14,20 @@
|
||||
"Sending…": "Bezig met verzenden…",
|
||||
"Something went wrong. Please try again.": "Er ging iets mis. Probeer het opnieuw.",
|
||||
"This pre-registration period has ended.": "Deze preregistratieperiode is afgelopen.",
|
||||
"You will be redirected in :seconds s…": "Je wordt over :seconds seconden doorgestuurd…",
|
||||
"Visit ticket shop": "Ga naar de ticketshop",
|
||||
"Thank you for registering!": "Bedankt voor je registratie!",
|
||||
"You are already registered for this event.": "Je bent al geregistreerd voor dit evenement.",
|
||||
"Please enter a valid email address.": "Voer een geldig e-mailadres in.",
|
||||
"Please enter a valid phone number (8–15 digits).": "Voer een geldig telefoonnummer in (8 tot 15 cijfers).",
|
||||
"Please enter a valid phone number.": "Voer een geldig telefoonnummer in.",
|
||||
"Subscriber removed.": "Abonnee verwijderd.",
|
||||
"Delete this subscriber? This cannot be undone.": "Deze abonnee verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||
"Remove": "Verwijderen",
|
||||
"Actions": "Acties"
|
||||
"Sync Mailwizz": "Mailwizz sync",
|
||||
"Mailwizz sync has been queued for this subscriber.": "Mailwizz-synchronisatie is in de wachtrij gezet voor deze abonnee.",
|
||||
"Queue a Mailwizz sync for this subscriber? The tag and coupon code will be sent when the queue worker runs.": "Mailwizz-synchronisatie voor deze abonnee in de wachtrij zetten? De tag en kortingscode worden verstuurd zodra de queue-worker draait.",
|
||||
"Actions": "Acties",
|
||||
"Fix background to viewport": "Achtergrond vastzetten op het scherm",
|
||||
"When enabled, the background image and overlay stay fixed while visitors scroll long content.": "Als dit aan staat, blijven de achtergrondafbeelding en de overlay stilstaan terwijl bezoekers door lange inhoud scrollen."
|
||||
}
|
||||
|
||||
@@ -390,6 +390,7 @@ document.addEventListener('alpine:init', () => {
|
||||
phase: config.phase,
|
||||
startAtMs: config.startAtMs,
|
||||
phoneEnabled: config.phoneEnabled,
|
||||
phoneRequired: config.phoneRequired === true,
|
||||
subscribeUrl: config.subscribeUrl,
|
||||
csrfToken: config.csrfToken,
|
||||
genericError: config.genericError,
|
||||
@@ -499,10 +500,16 @@ document.addEventListener('alpine:init', () => {
|
||||
ok = false;
|
||||
}
|
||||
if (this.phoneEnabled) {
|
||||
const digits = String(this.phone).replace(/\D/g, '');
|
||||
if (digits.length > 0 && (digits.length < 8 || digits.length > 15)) {
|
||||
const trimmed = String(this.phone).trim();
|
||||
if (this.phoneRequired && trimmed === '') {
|
||||
this.fieldErrors.phone = [this.invalidPhoneMsg];
|
||||
ok = false;
|
||||
} else if (trimmed !== '') {
|
||||
const digits = trimmed.replace(/\D/g, '');
|
||||
if (digits.length < 8 || digits.length > 15) {
|
||||
this.fieldErrors.phone = [this.invalidPhoneMsg];
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
@@ -548,12 +555,21 @@ document.addEventListener('alpine:init', () => {
|
||||
this.startRedirectCountdownIfNeeded();
|
||||
return;
|
||||
}
|
||||
if (typeof data.message === 'string' && data.message !== '') {
|
||||
const hasServerMessage = typeof data.message === 'string' && data.message !== '';
|
||||
const hasFieldErrors =
|
||||
data.errors !== undefined &&
|
||||
data.errors !== null &&
|
||||
typeof data.errors === 'object' &&
|
||||
Object.keys(data.errors).length > 0;
|
||||
if (hasServerMessage) {
|
||||
this.formError = data.message;
|
||||
}
|
||||
if (data.errors !== undefined && data.errors !== null && typeof data.errors === 'object') {
|
||||
if (hasFieldErrors) {
|
||||
this.fieldErrors = data.errors;
|
||||
}
|
||||
if (!res.ok && !hasServerMessage && !hasFieldErrors) {
|
||||
this.formError = this.genericError;
|
||||
}
|
||||
} catch {
|
||||
this.formError = this.genericError;
|
||||
} finally {
|
||||
@@ -567,6 +583,7 @@ document.addEventListener('alpine:init', () => {
|
||||
fieldsUrl: cfg.fieldsUrl,
|
||||
phoneEnabled: cfg.phoneEnabled,
|
||||
hasExistingConfig: cfg.hasExistingConfig,
|
||||
hasWeeztixIntegration: cfg.hasWeeztixIntegration === true,
|
||||
existing: cfg.existing,
|
||||
csrf: cfg.csrf,
|
||||
step: 1,
|
||||
@@ -579,6 +596,7 @@ document.addEventListener('alpine:init', () => {
|
||||
fieldFirstName: '',
|
||||
fieldLastName: '',
|
||||
fieldPhone: '',
|
||||
fieldCouponCode: '',
|
||||
tagField: '',
|
||||
tagValue: '',
|
||||
loading: false,
|
||||
@@ -590,6 +608,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.fieldFirstName = this.existing.field_first_name ?? '';
|
||||
this.fieldLastName = this.existing.field_last_name ?? '';
|
||||
this.fieldPhone = this.existing.field_phone ?? '';
|
||||
this.fieldCouponCode = this.existing.field_coupon_code ?? '';
|
||||
this.tagField = this.existing.tag_field ?? '';
|
||||
this.tagValue = this.existing.tag_value ?? '';
|
||||
this.selectedListUid = this.existing.list_uid ?? '';
|
||||
@@ -699,6 +718,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.fieldFirstName = this.existing.field_first_name || this.fieldFirstName;
|
||||
this.fieldLastName = this.existing.field_last_name || this.fieldLastName;
|
||||
this.fieldPhone = this.existing.field_phone || this.fieldPhone;
|
||||
this.fieldCouponCode = this.existing.field_coupon_code || this.fieldCouponCode;
|
||||
this.tagField = this.existing.tag_field || this.tagField;
|
||||
this.tagValue = this.existing.tag_value || this.tagValue;
|
||||
}
|
||||
@@ -739,6 +759,107 @@ document.addEventListener('alpine:init', () => {
|
||||
this.$refs.saveForm.requestSubmit();
|
||||
},
|
||||
}));
|
||||
|
||||
Alpine.data('weeztixSetup', (cfg) => ({
|
||||
pageId: cfg.pageId,
|
||||
couponsUrl: cfg.couponsUrl,
|
||||
csrf: cfg.csrf,
|
||||
isConnected: cfg.isConnected === true,
|
||||
callbackUrl: cfg.callbackUrl,
|
||||
errorMessage: '',
|
||||
coupons: [],
|
||||
couponGuid: '',
|
||||
couponName: '',
|
||||
codePrefix: 'PREREG',
|
||||
usageCount: 1,
|
||||
couponsRefreshing: false,
|
||||
strings: cfg.strings || {},
|
||||
|
||||
async init() {
|
||||
if (cfg.existing) {
|
||||
this.codePrefix = cfg.existing.code_prefix || 'PREREG';
|
||||
const uc = cfg.existing.usage_count;
|
||||
if (typeof uc === 'number' && !Number.isNaN(uc)) {
|
||||
this.usageCount = uc;
|
||||
} else if (uc !== null && uc !== undefined && String(uc).trim() !== '') {
|
||||
const parsed = parseInt(String(uc), 10);
|
||||
this.usageCount = Number.isNaN(parsed) ? 1 : parsed;
|
||||
} else {
|
||||
this.usageCount = 1;
|
||||
}
|
||||
this.couponGuid = cfg.existing.coupon_guid || '';
|
||||
this.couponName = cfg.existing.coupon_name || '';
|
||||
}
|
||||
if (this.isConnected) {
|
||||
await this.loadCoupons();
|
||||
} else if (cfg.existing && cfg.existing.coupon_guid) {
|
||||
this.ensureSelectedCouponInList();
|
||||
}
|
||||
},
|
||||
|
||||
async postJson(url, body) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': this.csrf,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { res, data };
|
||||
},
|
||||
|
||||
syncCouponName() {
|
||||
if (!this.couponGuid) {
|
||||
this.couponName = '';
|
||||
return;
|
||||
}
|
||||
const c = this.coupons.find((x) => x.guid === this.couponGuid);
|
||||
if (c && typeof c.name === 'string' && c.name.trim() !== '') {
|
||||
this.couponName = c.name.trim();
|
||||
}
|
||||
},
|
||||
|
||||
ensureSelectedCouponInList() {
|
||||
const guid = this.couponGuid;
|
||||
if (!guid || this.coupons.some((x) => x.guid === guid)) {
|
||||
return;
|
||||
}
|
||||
const label =
|
||||
typeof this.couponName === 'string' && this.couponName.trim() !== ''
|
||||
? this.couponName.trim()
|
||||
: guid;
|
||||
this.coupons = [{ guid, name: label }, ...this.coupons];
|
||||
},
|
||||
|
||||
async loadCoupons() {
|
||||
this.errorMessage = '';
|
||||
const { res, data } = await this.postJson(this.couponsUrl, { page_id: this.pageId });
|
||||
if (!res.ok) {
|
||||
this.errorMessage = data.message || this.strings.loadCouponsError;
|
||||
this.ensureSelectedCouponInList();
|
||||
return;
|
||||
}
|
||||
this.coupons = Array.isArray(data.coupons) ? data.coupons : [];
|
||||
this.ensureSelectedCouponInList();
|
||||
this.syncCouponName();
|
||||
},
|
||||
|
||||
async refreshCoupons() {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
this.couponsRefreshing = true;
|
||||
try {
|
||||
await this.loadCoupons();
|
||||
} finally {
|
||||
this.couponsRefreshing = false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
window.Alpine = Alpine;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
@php
|
||||
$config = $page->mailwizzConfig;
|
||||
$page->loadMissing('weeztixConfig');
|
||||
$hasWeeztixForCouponMap = $page->weeztixConfig !== null && $page->weeztixConfig->is_connected;
|
||||
$mailwizzStatus = $page->mailwizzIntegrationStatus();
|
||||
$existing = $config !== null
|
||||
? [
|
||||
'list_uid' => $config->list_uid,
|
||||
@@ -8,6 +11,7 @@
|
||||
'field_first_name' => $config->field_first_name,
|
||||
'field_last_name' => $config->field_last_name,
|
||||
'field_phone' => $config->field_phone,
|
||||
'field_coupon_code' => $config->field_coupon_code,
|
||||
'tag_field' => $config->tag_field,
|
||||
'tag_value' => $config->tag_value,
|
||||
]
|
||||
@@ -21,29 +25,15 @@
|
||||
@section('mobile_title', __('Mailwizz'))
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-3xl" x-data="mailwizzWizard(@js([
|
||||
'listsUrl' => route('admin.mailwizz.lists'),
|
||||
'fieldsUrl' => route('admin.mailwizz.fields'),
|
||||
'csrf' => csrf_token(),
|
||||
'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(),
|
||||
'hasExistingConfig' => $config !== null,
|
||||
'existing' => $existing,
|
||||
'strings' => [
|
||||
'apiKeyRequired' => __('Enter your Mailwizz API key to continue.'),
|
||||
'genericError' => __('Something went wrong. Please try again.'),
|
||||
'noListsError' => __('No mailing lists were returned. Check your API key or create a list in Mailwizz.'),
|
||||
'selectListError' => __('Select a mailing list.'),
|
||||
'mapFieldsError' => __('Map email, first name, and last name to Mailwizz fields.'),
|
||||
'tagFieldError' => __('Select a checkbox list field for source / tag tracking.'),
|
||||
'tagValueError' => __('Select the tag option that identifies this pre-registration.'),
|
||||
],
|
||||
]))">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="mb-8">
|
||||
<a href="{{ route('admin.pages.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">← {{ __('Back to page') }}</a>
|
||||
<h1 class="mt-4 text-2xl font-semibold text-slate-900">{{ __('Mailwizz') }}</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">{{ __('Page:') }} <span class="font-medium text-slate-800">{{ $page->title }}</span></p>
|
||||
</div>
|
||||
|
||||
@include('admin.pages._save_flash')
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900" role="alert">
|
||||
<p class="font-medium">{{ __('Please fix the following:') }}</p>
|
||||
@@ -55,208 +45,347 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($config !== null)
|
||||
<div class="mb-8 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
|
||||
<p class="font-medium">{{ __('Integration active') }}</p>
|
||||
<p class="mt-1 text-emerald-800">
|
||||
{{ __('List:') }}
|
||||
<span class="font-mono text-xs">{{ $config->list_name ?: $config->list_uid }}</span>
|
||||
</p>
|
||||
<form action="{{ route('admin.pages.mailwizz.destroy', $page) }}" method="post" class="mt-3"
|
||||
@if (! $showWizard && $config !== null)
|
||||
@if ($mailwizzStatus !== 'ready')
|
||||
<div class="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||
<p class="font-medium">{{ __('Setup incomplete') }}</p>
|
||||
<p class="mt-1 text-amber-900">{{ __('Run the wizard again to finish Mailwizz (API key, list, and field mapping).') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mb-6 flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href="{{ route('admin.pages.mailwizz.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]) }}"
|
||||
class="inline-flex rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500"
|
||||
>
|
||||
{{ __('Change settings (wizard)') }}
|
||||
</a>
|
||||
<form action="{{ route('admin.pages.mailwizz.destroy', $page) }}" method="post" class="inline"
|
||||
onsubmit="return confirm(@js(__('Remove Mailwizz integration for this page? Subscribers will stay in the database but will no longer sync.')));">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-sm font-semibold text-red-700 underline hover:text-red-800">
|
||||
{{ __('Remove integration') }}
|
||||
<button type="submit" class="rounded-lg border border-red-200 bg-white px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm hover:bg-red-50">
|
||||
{{ __('Disconnect Mailwizz') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mb-6 flex flex-wrap gap-2 text-xs font-medium text-slate-500">
|
||||
<span :class="step >= 1 ? 'text-indigo-600' : ''">1. {{ __('API key') }}</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
<span :class="step >= 2 ? 'text-indigo-600' : ''">2. {{ __('List') }}</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
<span :class="step >= 3 ? 'text-indigo-600' : ''">3. {{ __('Field mapping') }}</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
<span :class="step >= 4 ? 'text-indigo-600' : ''">4. {{ __('Tag / source') }}</span>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ __('Current configuration') }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">{{ __('The API key is stored encrypted and is not shown here.') }}</p>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
|
||||
<div x-show="errorMessage !== ''" x-cloak class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" x-text="errorMessage"></div>
|
||||
<dl class="mt-6 space-y-4 border-t border-slate-100 pt-6 text-sm">
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Connection') }}</dt>
|
||||
<dd>
|
||||
@if ($mailwizzStatus === 'ready')
|
||||
<span class="inline-flex rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">{{ __('Ready to sync') }}</span>
|
||||
@else
|
||||
<span class="inline-flex rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-medium text-amber-900">{{ __('Incomplete') }}</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Mailing list') }}</dt>
|
||||
<dd class="text-slate-800">{{ $config->list_name ?: '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('List UID') }}</dt>
|
||||
<dd class="break-all font-mono text-xs text-slate-600">{{ $config->list_uid ?: '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{{-- Step 1 --}}
|
||||
<div x-show="step === 1" x-cloak class="space-y-4">
|
||||
<p class="text-sm leading-relaxed text-slate-600">
|
||||
{{ __('First, create a mailing list in Mailwizz with the required custom fields. Add a custom field of type Checkbox List with an option value you will use to track this pre-registration source.') }}
|
||||
</p>
|
||||
<h3 class="mt-8 border-t border-slate-100 pt-6 text-sm font-semibold text-slate-900">{{ __('Field mapping') }}</h3>
|
||||
<p class="mt-1 text-xs text-slate-500">{{ __('Mailwizz custom fields are matched by tag.') }}</p>
|
||||
<dl class="mt-4 space-y-4 text-sm">
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Email') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ $config->field_email ?: '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('First name') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ $config->field_first_name ?: '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Last name') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ $config->field_last_name ?: '—' }}</dd>
|
||||
</div>
|
||||
@if ($page->isPhoneFieldEnabledForSubscribers())
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Phone') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ filled($config->field_phone) ? $config->field_phone : '—' }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
@if ($hasWeeztixForCouponMap)
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Kortingscode (Weeztix)') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ filled($config->field_coupon_code) ? $config->field_coupon_code : '—' }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Tag / source (checkbox list)') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ filled($config->tag_field) ? $config->tag_field : '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Source tag option') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ filled($config->tag_value) ? $config->tag_value : '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
x-data="mailwizzWizard(@js([
|
||||
'listsUrl' => route('admin.mailwizz.lists'),
|
||||
'fieldsUrl' => route('admin.mailwizz.fields'),
|
||||
'csrf' => csrf_token(),
|
||||
'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(),
|
||||
'hasExistingConfig' => $config !== null,
|
||||
'hasWeeztixIntegration' => $hasWeeztixForCouponMap,
|
||||
'existing' => $existing,
|
||||
'strings' => [
|
||||
'apiKeyRequired' => __('Enter your Mailwizz API key to continue.'),
|
||||
'genericError' => __('Something went wrong. Please try again.'),
|
||||
'noListsError' => __('No mailing lists were returned. Check your API key or create a list in Mailwizz.'),
|
||||
'selectListError' => __('Select a mailing list.'),
|
||||
'mapFieldsError' => __('Map email, first name, and last name to Mailwizz fields.'),
|
||||
'tagFieldError' => __('Select a checkbox list field for source / tag tracking.'),
|
||||
'tagValueError' => __('Select the tag option that identifies this pre-registration.'),
|
||||
],
|
||||
]))"
|
||||
>
|
||||
@if ($config !== null)
|
||||
<p class="text-sm text-amber-800">
|
||||
{{ __('Enter your API key and connect to load Mailwizz data (the same key as before is fine). If you clear the key field before saving, the previously stored key is kept.') }}
|
||||
</p>
|
||||
<div class="mb-6 flex flex-wrap items-center gap-3">
|
||||
<a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-sm font-medium text-slate-600 hover:text-slate-900">
|
||||
{{ __('Cancel and return to overview') }}
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
<div>
|
||||
<label for="mailwizz_api_key" class="block text-sm font-medium text-slate-700">{{ __('Mailwizz API key') }}</label>
|
||||
<input
|
||||
id="mailwizz_api_key"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
x-model="apiKey"
|
||||
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
placeholder="{{ __('Paste API key') }}"
|
||||
|
||||
<div class="mb-8 flex flex-wrap items-center gap-2" aria-label="{{ __('Wizard steps') }}">
|
||||
<span
|
||||
class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium"
|
||||
:class="step === 1 ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : (step > 1 ? 'border-emerald-200 bg-emerald-50 text-emerald-900' : 'border-slate-200 bg-slate-50 text-slate-500')"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50"
|
||||
:disabled="loading"
|
||||
@click="connectLists()"
|
||||
>
|
||||
<span x-show="!loading">{{ __('Connect & load lists') }}</span>
|
||||
<span x-show="loading" x-cloak>{{ __('Connecting…') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Step 2 --}}
|
||||
<div x-show="step === 2" x-cloak class="space-y-4">
|
||||
<div>
|
||||
<label for="mailwizz_list" class="block text-sm font-medium text-slate-700">{{ __('Mailing list') }}</label>
|
||||
<select
|
||||
id="mailwizz_list"
|
||||
x-model="selectedListUid"
|
||||
@change="syncListNameFromSelection()"
|
||||
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
<span class="tabular-nums">1</span>
|
||||
{{ __('API key') }}
|
||||
</span>
|
||||
<span class="text-slate-300" aria-hidden="true">→</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium"
|
||||
:class="step === 2 ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : (step > 2 ? 'border-emerald-200 bg-emerald-50 text-emerald-900' : 'border-slate-200 bg-slate-50 text-slate-500')"
|
||||
>
|
||||
<option value="">{{ __('Select a list…') }}</option>
|
||||
<template x-for="list in lists" :key="list.list_uid">
|
||||
<option :value="list.list_uid" x-text="list.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="button" class="rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50" @click="step = 1">
|
||||
{{ __('Back') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50"
|
||||
:disabled="loading"
|
||||
@click="loadFieldsAndGoStep3()"
|
||||
<span class="tabular-nums">2</span>
|
||||
{{ __('List') }}
|
||||
</span>
|
||||
<span class="text-slate-300" aria-hidden="true">→</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium"
|
||||
:class="step === 3 ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : (step > 3 ? 'border-emerald-200 bg-emerald-50 text-emerald-900' : 'border-slate-200 bg-slate-50 text-slate-500')"
|
||||
>
|
||||
<span x-show="!loading">{{ __('Load fields') }}</span>
|
||||
<span x-show="loading" x-cloak>{{ __('Loading…') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Step 3 --}}
|
||||
<div x-show="step === 3" x-cloak class="space-y-5">
|
||||
<p class="text-sm text-slate-600">{{ __('Map each local field to the matching Mailwizz custom field (by tag).') }}</p>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Email') }}</label>
|
||||
<select x-model="fieldEmail" class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<option value="">{{ __('Select field…') }}</option>
|
||||
<template x-for="f in emailFieldChoices()" :key="f.tag">
|
||||
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('First name') }}</label>
|
||||
<select x-model="fieldFirstName" class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<option value="">{{ __('Select field…') }}</option>
|
||||
<template x-for="f in textFields()" :key="'fn-' + f.tag">
|
||||
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Last name') }}</label>
|
||||
<select x-model="fieldLastName" class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<option value="">{{ __('Select field…') }}</option>
|
||||
<template x-for="f in textFields()" :key="'ln-' + f.tag">
|
||||
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="phoneEnabled">
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Phone') }} <span class="font-normal text-slate-500">({{ __('optional') }})</span></label>
|
||||
<select x-model="fieldPhone" class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<option value="">{{ __('Select field…') }}</option>
|
||||
<template x-for="f in phoneFields()" :key="'ph-' + f.tag">
|
||||
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Tag / source (checkbox list)') }}</label>
|
||||
<select
|
||||
x-model="tagField"
|
||||
@change="tagValue = ''"
|
||||
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
<span class="tabular-nums">3</span>
|
||||
{{ __('Field mapping') }}
|
||||
</span>
|
||||
<span class="text-slate-300" aria-hidden="true">→</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium"
|
||||
:class="step === 4 ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : 'border-slate-200 bg-slate-50 text-slate-500'"
|
||||
>
|
||||
<option value="">{{ __('Select checkbox list field…') }}</option>
|
||||
<template x-for="f in checkboxFields()" :key="'cb-' + f.tag">
|
||||
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
<span class="tabular-nums">4</span>
|
||||
{{ __('Tag / source') }}
|
||||
</span>
|
||||
</div>
|
||||
<p x-show="checkboxFields().length === 0" class="text-sm text-amber-800">
|
||||
{{ __('No checkbox list fields were returned for this list. Add one in Mailwizz, then run “Load fields” again from step 2.') }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="button" class="rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50" @click="step = 2">
|
||||
{{ __('Back') }}
|
||||
</button>
|
||||
<button type="button" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500" @click="goStep4()">
|
||||
{{ __('Continue') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
|
||||
<div x-show="errorMessage !== ''" x-cloak class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" x-text="errorMessage"></div>
|
||||
|
||||
{{-- Step 4 --}}
|
||||
<div x-show="step === 4" x-cloak class="space-y-5">
|
||||
<p class="text-sm text-slate-600">{{ __('Choose the checkbox option that marks subscribers from this pre-registration page.') }}</p>
|
||||
|
||||
<fieldset class="space-y-2">
|
||||
<legend class="sr-only">{{ __('Tag value') }}</legend>
|
||||
<template x-for="opt in tagOptionsList()" :key="opt.key">
|
||||
<label class="flex cursor-pointer items-start gap-3 rounded-lg border border-slate-200 p-3 hover:bg-slate-50">
|
||||
<input type="radio" name="tag_value_choice" class="mt-1 text-indigo-600" :value="opt.key" x-model="tagValue">
|
||||
<span class="text-sm text-slate-800" x-text="opt.label"></span>
|
||||
</label>
|
||||
</template>
|
||||
</fieldset>
|
||||
<p x-show="tagField && tagOptionsList().length === 0" class="text-sm text-amber-800">
|
||||
{{ __('This field has no options defined in Mailwizz. Add options to the checkbox list field, then reload fields.') }}
|
||||
</p>
|
||||
|
||||
<form x-ref="saveForm" method="post" action="{{ route('admin.pages.mailwizz.update', $page) }}" class="space-y-4">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<input type="hidden" name="api_key" x-bind:value="apiKey">
|
||||
<input type="hidden" name="list_uid" x-bind:value="selectedListUid">
|
||||
<input type="hidden" name="list_name" x-bind:value="selectedListName">
|
||||
<input type="hidden" name="field_email" x-bind:value="fieldEmail">
|
||||
<input type="hidden" name="field_first_name" x-bind:value="fieldFirstName">
|
||||
<input type="hidden" name="field_last_name" x-bind:value="fieldLastName">
|
||||
<input type="hidden" name="field_phone" x-bind:value="phoneEnabled ? fieldPhone : ''">
|
||||
<input type="hidden" name="tag_field" x-bind:value="tagField">
|
||||
<input type="hidden" name="tag_value" x-bind:value="tagValue">
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="button" class="rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50" @click="step = 3">
|
||||
{{ __('Back') }}
|
||||
</button>
|
||||
<button type="button" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500" @click="submitSave()">
|
||||
{{ __('Save configuration') }}
|
||||
{{-- Step 1 --}}
|
||||
<div x-show="step === 1" x-cloak class="space-y-4">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ __('Step 1: API key') }}</h2>
|
||||
<p class="text-sm leading-relaxed text-slate-600">
|
||||
{{ __('First, create a mailing list in Mailwizz with the required custom fields. Add a custom field of type Checkbox List with an option value you will use to track this pre-registration source.') }}
|
||||
</p>
|
||||
@if ($config !== null)
|
||||
<p class="text-sm text-amber-800">
|
||||
{{ __('Enter your API key and connect to load Mailwizz data (the same key as before is fine). If you clear the key field before saving, the previously stored key is kept.') }}
|
||||
</p>
|
||||
@endif
|
||||
<div>
|
||||
<label for="mailwizz_api_key" class="block text-sm font-medium text-slate-700">{{ __('Mailwizz API key') }}</label>
|
||||
<input
|
||||
id="mailwizz_api_key"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
x-model="apiKey"
|
||||
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
placeholder="{{ __('Paste API key') }}"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50"
|
||||
:disabled="loading"
|
||||
@click="connectLists()"
|
||||
>
|
||||
<span x-show="!loading">{{ __('Connect & load lists') }}</span>
|
||||
<span x-show="loading" x-cloak>{{ __('Connecting…') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{-- Step 2 --}}
|
||||
<div x-show="step === 2" x-cloak class="space-y-4">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ __('Step 2: Mailing list') }}</h2>
|
||||
<div>
|
||||
<label for="mailwizz_list" class="block text-sm font-medium text-slate-700">{{ __('Mailing list') }}</label>
|
||||
<select
|
||||
id="mailwizz_list"
|
||||
x-model="selectedListUid"
|
||||
@change="syncListNameFromSelection()"
|
||||
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="">{{ __('Select a list…') }}</option>
|
||||
<template x-for="list in lists" :key="list.list_uid">
|
||||
<option :value="list.list_uid" x-text="list.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="button" class="rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50" @click="step = 1">
|
||||
{{ __('Back') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50"
|
||||
:disabled="loading"
|
||||
@click="loadFieldsAndGoStep3()"
|
||||
>
|
||||
<span x-show="!loading">{{ __('Load fields') }}</span>
|
||||
<span x-show="loading" x-cloak>{{ __('Loading…') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Step 3 --}}
|
||||
<div x-show="step === 3" x-cloak class="space-y-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ __('Step 3: Field mapping') }}</h2>
|
||||
<p class="text-sm text-slate-600">{{ __('Map each local field to the matching Mailwizz custom field (by tag).') }}</p>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Email') }}</label>
|
||||
<select x-model="fieldEmail" class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<option value="">{{ __('Select field…') }}</option>
|
||||
<template x-for="f in emailFieldChoices()" :key="f.tag">
|
||||
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('First name') }}</label>
|
||||
<select x-model="fieldFirstName" class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<option value="">{{ __('Select field…') }}</option>
|
||||
<template x-for="f in textFields()" :key="'fn-' + f.tag">
|
||||
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Last name') }}</label>
|
||||
<select x-model="fieldLastName" class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<option value="">{{ __('Select field…') }}</option>
|
||||
<template x-for="f in textFields()" :key="'ln-' + f.tag">
|
||||
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="phoneEnabled">
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Phone') }} <span class="font-normal text-slate-500">({{ __('optional') }})</span></label>
|
||||
<select x-model="fieldPhone" class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<option value="">{{ __('Select field…') }}</option>
|
||||
<template x-for="f in phoneFields()" :key="'ph-' + f.tag">
|
||||
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="hasWeeztixIntegration">
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Kortingscode (Weeztix)') }} <span class="font-normal text-slate-500">({{ __('optional') }})</span></label>
|
||||
<p class="mt-1 text-xs text-slate-500">{{ __('Koppel aan een tekstveld in Mailwizz om de persoonlijke code in e-mails te tonen.') }}</p>
|
||||
<select x-model="fieldCouponCode" class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<option value="">{{ __('Select field…') }}</option>
|
||||
<template x-for="f in textFields()" :key="'cp-' + f.tag">
|
||||
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Tag / source (checkbox list)') }}</label>
|
||||
<select
|
||||
x-model="tagField"
|
||||
@change="tagValue = ''"
|
||||
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="">{{ __('Select checkbox list field…') }}</option>
|
||||
<template x-for="f in checkboxFields()" :key="'cb-' + f.tag">
|
||||
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<p x-show="checkboxFields().length === 0" class="text-sm text-amber-800">
|
||||
{{ __('No checkbox list fields were returned for this list. Add one in Mailwizz, then run “Load fields” again from step 2.') }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="button" class="rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50" @click="step = 2">
|
||||
{{ __('Back') }}
|
||||
</button>
|
||||
<button type="button" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500" @click="goStep4()">
|
||||
{{ __('Continue') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Step 4 --}}
|
||||
<div x-show="step === 4" x-cloak class="space-y-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ __('Step 4: Tag / source') }}</h2>
|
||||
<p class="text-sm text-slate-600">{{ __('Choose the checkbox option that marks subscribers from this pre-registration page.') }}</p>
|
||||
|
||||
<fieldset class="space-y-2">
|
||||
<legend class="sr-only">{{ __('Tag value') }}</legend>
|
||||
<template x-for="opt in tagOptionsList()" :key="opt.key">
|
||||
<label class="flex cursor-pointer items-start gap-3 rounded-lg border border-slate-200 p-3 hover:bg-slate-50">
|
||||
<input type="radio" name="tag_value_choice" class="mt-1 text-indigo-600" :value="opt.key" x-model="tagValue">
|
||||
<span class="text-sm text-slate-800" x-text="opt.label"></span>
|
||||
</label>
|
||||
</template>
|
||||
</fieldset>
|
||||
<p x-show="tagField && tagOptionsList().length === 0" class="text-sm text-amber-800">
|
||||
{{ __('This field has no options defined in Mailwizz. Add options to the checkbox list field, then reload fields.') }}
|
||||
</p>
|
||||
|
||||
<form x-ref="saveForm" method="post" action="{{ route('admin.pages.mailwizz.update', $page) }}" class="space-y-4">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<input type="hidden" name="api_key" x-bind:value="apiKey">
|
||||
<input type="hidden" name="list_uid" x-bind:value="selectedListUid">
|
||||
<input type="hidden" name="list_name" x-bind:value="selectedListName">
|
||||
<input type="hidden" name="field_email" x-bind:value="fieldEmail">
|
||||
<input type="hidden" name="field_first_name" x-bind:value="fieldFirstName">
|
||||
<input type="hidden" name="field_last_name" x-bind:value="fieldLastName">
|
||||
<input type="hidden" name="field_phone" x-bind:value="phoneEnabled ? fieldPhone : ''">
|
||||
<input type="hidden" name="field_coupon_code" x-bind:value="hasWeeztixIntegration ? fieldCouponCode : ''">
|
||||
<input type="hidden" name="tag_field" x-bind:value="tagField">
|
||||
<input type="hidden" name="tag_value" x-bind:value="tagValue">
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="button" class="rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50" @click="step = 3">
|
||||
{{ __('Back') }}
|
||||
</button>
|
||||
<button type="button" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500" @click="submitSave()">
|
||||
{{ __('Save configuration') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -98,6 +98,19 @@
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label class="inline-flex cursor-pointer items-center gap-2 text-sm text-slate-800">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="background_fixed"
|
||||
value="1"
|
||||
class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
@checked(old('background_fixed', $page?->background_fixed ?? false))
|
||||
/>
|
||||
{{ __('Fix background to viewport') }}
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-slate-600">{{ __('When enabled, the background image and overlay stay fixed while visitors scroll long content.') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 sm:grid-cols-2">
|
||||
|
||||
55
resources/views/admin/pages/_integration_badges.blade.php
Normal file
55
resources/views/admin/pages/_integration_badges.blade.php
Normal 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>
|
||||
@@ -13,9 +13,30 @@
|
||||
{{ __('Public URL') }}: <a href="{{ route('public.page', ['publicPage' => $page]) }}" class="text-indigo-600 hover:underline" target="_blank" rel="noopener">{{ url('/r/'.$page->slug) }}</a>
|
||||
</p>
|
||||
@can('update', $page)
|
||||
<p class="mt-3">
|
||||
<a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">{{ __('Mailwizz integration') }} →</a>
|
||||
</p>
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
<a
|
||||
href="{{ route('admin.pages.mailwizz.edit', $page) }}"
|
||||
class="group flex flex-col rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-indigo-200 hover:shadow-md"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h2 class="text-sm font-semibold text-slate-900">{{ __('Mailwizz') }}</h2>
|
||||
@include('admin.pages._integration_badges', ['page' => $page, 'only' => 'mailwizz', 'integrationBadgeClass' => 'justify-end'])
|
||||
</div>
|
||||
<p class="mt-2 flex-1 text-xs text-slate-600">{{ __('Sync subscribers to your Mailwizz list and map fields.') }}</p>
|
||||
<span class="mt-3 text-sm font-medium text-indigo-600 group-hover:text-indigo-500">{{ __('Open Mailwizz') }} →</span>
|
||||
</a>
|
||||
<a
|
||||
href="{{ route('admin.pages.weeztix.edit', $page) }}"
|
||||
class="group flex flex-col rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-indigo-200 hover:shadow-md"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h2 class="text-sm font-semibold text-slate-900">{{ __('Weeztix') }}</h2>
|
||||
@include('admin.pages._integration_badges', ['page' => $page, 'only' => 'weeztix', 'integrationBadgeClass' => 'justify-end'])
|
||||
</div>
|
||||
<p class="mt-2 flex-1 text-xs text-slate-600">{{ __('Issue unique discount codes via Weeztix when visitors sign up.') }}</p>
|
||||
<span class="mt-3 text-sm font-medium text-indigo-600 group-hover:text-indigo-500">{{ __('Open Weeztix') }} →</span>
|
||||
</a>
|
||||
</div>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('Start') }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('End') }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('Subscribers') }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('Integrations') }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-right font-semibold text-slate-700">{{ __('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -64,6 +65,9 @@
|
||||
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $page->start_date->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $page->end_date->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 tabular-nums text-slate-600">{{ number_format($page->subscribers_count) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
@include('admin.pages._integration_badges', ['page' => $page])
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
@can('update', $page)
|
||||
@@ -75,6 +79,9 @@
|
||||
@can('update', $page)
|
||||
<a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-indigo-600 hover:text-indigo-500">{{ __('Mailwizz') }}</a>
|
||||
@endcan
|
||||
@can('update', $page)
|
||||
<a href="{{ route('admin.pages.weeztix.edit', $page) }}" class="text-indigo-600 hover:text-indigo-500">{{ __('Weeztix') }}</a>
|
||||
@endcan
|
||||
<button
|
||||
type="button"
|
||||
x-data="{ copied: false }"
|
||||
@@ -97,7 +104,7 @@
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="{{ auth()->user()->isSuperadmin() ? 7 : 6 }}" class="px-4 py-12 text-center text-slate-500">
|
||||
<td colspan="{{ auth()->user()->isSuperadmin() ? 8 : 7 }}" class="px-4 py-12 text-center text-slate-500">
|
||||
{{ __('No pages yet.') }}
|
||||
@can('create', \App\Models\PreregistrationPage::class)
|
||||
<a href="{{ route('admin.pages.create') }}" class="font-medium text-indigo-600 hover:text-indigo-500">{{ __('Create one') }}</a>
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
@if ($page->isPhoneFieldEnabledForSubscribers())
|
||||
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Phone') }}</th>
|
||||
@endif
|
||||
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Kortingscode') }}</th>
|
||||
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Registered at') }}</th>
|
||||
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Mailwizz') }}</th>
|
||||
<th class="w-px whitespace-nowrap px-4 py-3 font-semibold text-slate-700">{{ __('Actions') }}</th>
|
||||
@@ -64,8 +65,9 @@
|
||||
<td class="px-4 py-3 text-slate-900">{{ $subscriber->last_name }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ $subscriber->email }}</td>
|
||||
@if ($page->isPhoneFieldEnabledForSubscribers())
|
||||
<td class="px-4 py-3 text-slate-600">{{ $subscriber->phone ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ $subscriber->phoneDisplay() ?? '—' }}</td>
|
||||
@endif
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-700">{{ $subscriber->coupon_code ?? '—' }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $subscriber->created_at->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td>
|
||||
<td class="px-4 py-3">
|
||||
@if ($subscriber->synced_to_mailwizz)
|
||||
@@ -80,27 +82,45 @@
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right">
|
||||
@can('update', $page)
|
||||
<form
|
||||
method="post"
|
||||
action="{{ route('admin.pages.subscribers.destroy', [$page, $subscriber]) }}"
|
||||
class="inline"
|
||||
onsubmit="return confirm(@js(__('Delete this subscriber? This cannot be undone.')));"
|
||||
>
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg border border-red-200 bg-white px-2.5 py-1 text-xs font-semibold text-red-700 hover:bg-red-50"
|
||||
<div class="inline-flex flex-wrap items-center justify-end gap-1.5">
|
||||
@if ($page->mailwizzConfig !== null)
|
||||
<form
|
||||
method="post"
|
||||
action="{{ route('admin.pages.subscribers.sync-mailwizz', [$page, $subscriber]) }}"
|
||||
class="inline"
|
||||
onsubmit="return confirm(@js(__('Queue a Mailwizz sync for this subscriber? The tag and coupon code will be sent when the queue worker runs.')));"
|
||||
>
|
||||
@csrf
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg border border-indigo-200 bg-white px-2.5 py-1 text-xs font-semibold text-indigo-700 hover:bg-indigo-50"
|
||||
>
|
||||
{{ __('Sync Mailwizz') }}
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
<form
|
||||
method="post"
|
||||
action="{{ route('admin.pages.subscribers.destroy', [$page, $subscriber]) }}"
|
||||
class="inline"
|
||||
onsubmit="return confirm(@js(__('Delete this subscriber? This cannot be undone.')));"
|
||||
>
|
||||
{{ __('Remove') }}
|
||||
</button>
|
||||
</form>
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg border border-red-200 bg-white px-2.5 py-1 text-xs font-semibold text-red-700 hover:bg-red-50"
|
||||
>
|
||||
{{ __('Remove') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endcan
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="{{ $page->isPhoneFieldEnabledForSubscribers() ? 7 : 6 }}" class="px-4 py-12 text-center text-slate-500">
|
||||
<td colspan="{{ $page->isPhoneFieldEnabledForSubscribers() ? 8 : 7 }}" class="px-4 py-12 text-center text-slate-500">
|
||||
{{ __('No subscribers match your criteria.') }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
351
resources/views/admin/weeztix/edit.blade.php
Normal file
351
resources/views/admin/weeztix/edit.blade.php
Normal file
@@ -0,0 +1,351 @@
|
||||
@php
|
||||
use Illuminate\Support\Carbon;
|
||||
$wz = $page->weeztixConfig;
|
||||
$existing = $wz !== null
|
||||
? [
|
||||
'coupon_guid' => $wz->coupon_guid,
|
||||
'coupon_name' => $wz->coupon_name,
|
||||
'code_prefix' => $wz->code_prefix,
|
||||
'usage_count' => $wz->usage_count,
|
||||
]
|
||||
: null;
|
||||
$credentialsEdit = ! $hasStoredCredentials || request()->query('credentials') === 'edit';
|
||||
$oauthUrl = route('admin.pages.weeztix.oauth.redirect', ['page' => $page, 'wizard' => 1]);
|
||||
@endphp
|
||||
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', __('Weeztix') . ' — ' . $page->title)
|
||||
|
||||
@section('mobile_title', __('Weeztix'))
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="mb-8">
|
||||
<a href="{{ route('admin.pages.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">← {{ __('Terug naar pagina') }}</a>
|
||||
<h1 class="mt-4 text-2xl font-semibold text-slate-900">{{ __('Weeztix') }}</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">{{ __('Pagina:') }} <span class="font-medium text-slate-800">{{ $page->title }}</span></p>
|
||||
</div>
|
||||
|
||||
@include('admin.pages._save_flash')
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900" role="alert">
|
||||
<p class="font-medium">{{ __('Controleer het volgende:') }}</p>
|
||||
<ul class="mt-2 list-disc space-y-1 pl-5">
|
||||
@foreach ($errors->all() as $message)
|
||||
<li>{{ $message }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (! $showWizard && $wz !== null)
|
||||
{{-- Summary (read-only): change only via wizard --}}
|
||||
@if ($wz->is_connected && ($wz->company_guid === null || $wz->company_guid === ''))
|
||||
<div class="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||
<p class="font-medium">{{ __('Bedrijf nog niet vastgelegd') }}</p>
|
||||
<p class="mt-1 text-amber-900">{{ __('Start de wizard en verbind opnieuw met Weeztix zodat het bedrijf automatisch wordt gekoppeld.') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mb-6 flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]) }}"
|
||||
class="inline-flex rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500"
|
||||
>
|
||||
{{ __('Instellingen wijzigen (wizard)') }}
|
||||
</a>
|
||||
<form action="{{ route('admin.pages.weeztix.destroy', $page) }}" method="post" class="inline"
|
||||
onsubmit="return confirm(@js(__('Weeztix-integratie voor deze pagina verwijderen? Opgeslagen tokens worden gewist.')));">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="rounded-lg border border-red-200 bg-white px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm hover:bg-red-50">
|
||||
{{ __('Weeztix loskoppelen') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ __('Huidige configuratie') }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">{{ __('OAuth-gegevens zijn opgeslagen maar worden om veiligheidsredenen niet getoond.') }}</p>
|
||||
|
||||
<dl class="mt-6 space-y-4 border-t border-slate-100 pt-6 text-sm">
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Verbinding') }}</dt>
|
||||
<dd>
|
||||
@if ($wz->is_connected)
|
||||
<span class="inline-flex rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">{{ __('Verbonden') }}</span>
|
||||
@else
|
||||
<span class="inline-flex rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-medium text-amber-900">{{ __('Niet verbonden') }}</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
@if ($wz->is_connected && $wz->token_expires_at)
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Toegangstoken tot') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ $wz->token_expires_at->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Callback-URL (in Weeztix-dashboard)') }}</dt>
|
||||
<dd class="break-all font-mono text-xs text-slate-600">{{ route('admin.weeztix.callback', absolute: true) }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Coupon') }}</dt>
|
||||
<dd class="text-slate-800">{{ $wz->coupon_name ?: ($wz->coupon_guid ? $wz->coupon_guid : '—') }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Codevoorvoegsel') }}</dt>
|
||||
<dd class="font-mono text-slate-800">{{ $wz->code_prefix ?? '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Gebruik per code') }}</dt>
|
||||
<dd class="text-slate-800">{{ (int) ($wz->usage_count ?? 1) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
@else
|
||||
{{-- Wizard --}}
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
@if ($wz !== null)
|
||||
<a href="{{ route('admin.pages.weeztix.edit', ['page' => $page]) }}" class="text-sm font-medium text-slate-600 hover:text-slate-900">
|
||||
{{ __('Annuleren en terug naar overzicht') }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mb-8 flex flex-wrap items-center gap-2" aria-label="{{ __('Wizardstappen') }}">
|
||||
@foreach ([1 => __('OAuth'), 2 => __('Verbinden'), 3 => __('Coupon')] as $num => $label)
|
||||
@php
|
||||
$active = $wizardStep === $num;
|
||||
$done = $wizardStep > $num;
|
||||
@endphp
|
||||
<span class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium
|
||||
{{ $active ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : '' }}
|
||||
{{ $done ? 'border-emerald-200 bg-emerald-50 text-emerald-900' : '' }}
|
||||
{{ ! $active && ! $done ? 'border-slate-200 bg-slate-50 text-slate-500' : '' }}">
|
||||
<span class="tabular-nums">{{ $num }}</span>
|
||||
{{ $label }}
|
||||
</span>
|
||||
@if ($num < 3)
|
||||
<span class="text-slate-300" aria-hidden="true">→</span>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8 space-y-10">
|
||||
@if ($wizardStep === 1)
|
||||
<section class="space-y-4" aria-labelledby="wz-wizard-step1">
|
||||
<h2 id="wz-wizard-step1" class="text-lg font-semibold text-slate-900">{{ __('Stap 1: OAuth-client') }}</h2>
|
||||
|
||||
@if ($hasStoredCredentials && ! $credentialsEdit)
|
||||
<p class="text-sm text-slate-600">{{ __('Wil je Client ID, client secret of de callback-URI in Weeztix wijzigen? De callback-URL van deze applicatie is hieronder; die moet exact overeenkomen in het Weeztix-dashboard.') }}</p>
|
||||
<p class="rounded-lg bg-slate-50 px-3 py-2 font-mono text-xs text-slate-800 break-all">{{ route('admin.weeztix.callback', absolute: true) }}</p>
|
||||
<div class="flex flex-wrap gap-3 pt-2">
|
||||
<a
|
||||
href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 2]) }}"
|
||||
class="inline-flex rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500"
|
||||
>
|
||||
{{ __('Nee, huidige gegevens behouden') }}
|
||||
</a>
|
||||
<a
|
||||
href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 1, 'credentials' => 'edit']) }}"
|
||||
class="inline-flex rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-800 shadow-sm hover:bg-slate-50"
|
||||
>
|
||||
{{ __('Ja, Client ID en secret wijzigen') }}
|
||||
</a>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-slate-600">{{ __('Vul de OAuth-client uit het Weeztix-dashboard in. Zet de redirect-URI exact op de onderstaande URL.') }}</p>
|
||||
<p class="rounded-lg bg-slate-50 px-3 py-2 font-mono text-xs text-slate-800 break-all">{{ route('admin.weeztix.callback', absolute: true) }}</p>
|
||||
|
||||
<form method="post" action="{{ route('admin.pages.weeztix.update', $page) }}" class="space-y-4 pt-2">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<input type="hidden" name="wizard" value="1">
|
||||
<input type="hidden" name="wizard_credential_save" value="1">
|
||||
<div>
|
||||
<label for="weeztix_client_id" class="block text-sm font-medium text-slate-700">{{ __('Client ID') }}</label>
|
||||
<input
|
||||
id="weeztix_client_id"
|
||||
name="client_id"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
value="{{ old('client_id') }}"
|
||||
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
placeholder="{{ $wz !== null ? __('Laat leeg om ongewijzigd te laten') : '' }}"
|
||||
@if ($wz === null) required @endif
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label for="weeztix_client_secret" class="block text-sm font-medium text-slate-700">{{ __('Client secret') }}</label>
|
||||
<input
|
||||
id="weeztix_client_secret"
|
||||
name="client_secret"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
value=""
|
||||
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
placeholder="{{ $wz !== null ? __('Laat leeg om ongewijzigd te laten') : '' }}"
|
||||
@if ($wz === null) required @endif
|
||||
>
|
||||
</div>
|
||||
@if ($wz !== null)
|
||||
<p class="text-xs text-slate-500">
|
||||
{{ __('Laat velden leeg om opgeslagen waarden te behouden.') }}
|
||||
</p>
|
||||
@endif
|
||||
<button type="submit" class="rounded-lg bg-slate-800 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-slate-700">
|
||||
{{ __('Opslaan en verder naar verbinden') }}
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if ($wizardStep === 2)
|
||||
<section class="space-y-4" aria-labelledby="wz-wizard-step2">
|
||||
<h2 id="wz-wizard-step2" class="text-lg font-semibold text-slate-900">{{ __('Stap 2: Verbinden met Weeztix') }}</h2>
|
||||
<p class="text-sm text-slate-600">{{ __('Log in bij Weeztix en keur de toegang goed. Daarna ga je automatisch verder naar de coupon.') }}</p>
|
||||
<p class="rounded-lg bg-slate-50 px-3 py-2 font-mono text-xs text-slate-800 break-all">{{ route('admin.weeztix.callback', absolute: true) }}</p>
|
||||
|
||||
@if ($wz !== null && $wz->is_connected)
|
||||
<div class="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
|
||||
{{ __('Je bent verbonden. Start opnieuw de Weeztix-login hieronder om een ander bedrijf te kiezen; het gekoppelde bedrijf wordt daarna automatisch bijgewerkt.') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href="{{ $oauthUrl }}"
|
||||
class="inline-flex rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500"
|
||||
>
|
||||
@if ($wz !== null && $wz->is_connected)
|
||||
{{ __('Opnieuw verbinden met Weeztix') }}
|
||||
@else
|
||||
{{ __('Verbind met Weeztix') }}
|
||||
@endif
|
||||
</a>
|
||||
@if ($wz !== null && $wz->is_connected)
|
||||
<a
|
||||
href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 3]) }}"
|
||||
class="inline-flex rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-800 shadow-sm hover:bg-slate-50"
|
||||
>
|
||||
{{ __('Naar stap 3: coupon') }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<p class="pt-4">
|
||||
<a href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]) }}" class="text-sm font-medium text-slate-600 hover:text-slate-900">{{ __('← Terug naar stap 1') }}</a>
|
||||
</p>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if ($wizardStep === 3)
|
||||
<div
|
||||
x-data="weeztixSetup(@js([
|
||||
'pageId' => $page->id,
|
||||
'couponsUrl' => route('admin.weeztix.coupons'),
|
||||
'csrf' => csrf_token(),
|
||||
'isConnected' => $wz?->is_connected ?? false,
|
||||
'callbackUrl' => route('admin.weeztix.callback', absolute: true),
|
||||
'existing' => $existing,
|
||||
'strings' => [
|
||||
'genericError' => __('Er ging iets mis. Probeer het opnieuw.'),
|
||||
'loadCouponsError' => __('Kon kortingsbonnen niet laden.'),
|
||||
'refreshCoupons' => __('Vernieuwen'),
|
||||
'refreshCouponsBusy' => __('Bezig…'),
|
||||
'refreshCouponsTitle' => __('Couponlijst opnieuw ophalen van Weeztix'),
|
||||
],
|
||||
]))"
|
||||
>
|
||||
<div x-show="errorMessage !== ''" x-cloak class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" x-text="errorMessage"></div>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="wz-wizard-step3">
|
||||
<h2 id="wz-wizard-step3" class="text-lg font-semibold text-slate-900">{{ __('Stap 3: Coupon en codes') }}</h2>
|
||||
<p class="text-sm text-slate-600">{{ __('Kies een bestaande coupon in Weeztix en stel het voorvoegsel en aantal gebruiken per code in.') }}</p>
|
||||
|
||||
<form method="post" action="{{ route('admin.pages.weeztix.update', $page) }}" class="space-y-4">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<input type="hidden" name="wizard" value="1">
|
||||
<input type="hidden" name="wizard_coupon_save" value="1">
|
||||
|
||||
<div>
|
||||
<div class="flex flex-wrap items-end justify-between gap-2">
|
||||
<label for="weeztix_coupon" class="block text-sm font-medium text-slate-700">{{ __('Coupon (kortingssjabloon)') }}</label>
|
||||
<button
|
||||
type="button"
|
||||
x-show="isConnected"
|
||||
x-cloak
|
||||
@click="refreshCoupons()"
|
||||
:disabled="couponsRefreshing"
|
||||
:aria-busy="couponsRefreshing"
|
||||
:title="strings.refreshCouponsTitle"
|
||||
class="shrink-0 rounded-md border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!couponsRefreshing" x-text="strings.refreshCoupons"></span>
|
||||
<span x-show="couponsRefreshing" x-cloak x-text="strings.refreshCouponsBusy"></span>
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
id="weeztix_coupon"
|
||||
name="coupon_guid"
|
||||
x-model="couponGuid"
|
||||
@change="syncCouponName()"
|
||||
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="">{{ __('Selecteer een coupon…') }}</option>
|
||||
<template x-for="c in coupons" :key="c.guid">
|
||||
<option :value="c.guid" x-text="c.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<input type="hidden" name="coupon_name" :value="couponName">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="weeztix_code_prefix" class="block text-sm font-medium text-slate-700">{{ __('Codevoorvoegsel') }}</label>
|
||||
<input
|
||||
id="weeztix_code_prefix"
|
||||
name="code_prefix"
|
||||
type="text"
|
||||
maxlength="32"
|
||||
x-model="codePrefix"
|
||||
value="{{ old('code_prefix', $wz->code_prefix ?? 'PREREG') }}"
|
||||
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label for="weeztix_usage_count" class="block text-sm font-medium text-slate-700">{{ __('Gebruik per code') }}</label>
|
||||
<input
|
||||
id="weeztix_usage_count"
|
||||
name="usage_count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="99999"
|
||||
x-model.number="usageCount"
|
||||
value="{{ old('usage_count', $wz->usage_count ?? 1) }}"
|
||||
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3 pt-2">
|
||||
<button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">
|
||||
{{ __('Opslaan en wizard afronden') }}
|
||||
</button>
|
||||
<a href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 2]) }}" class="inline-flex items-center rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50">
|
||||
{{ __('Terug naar stap 2') }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -16,6 +16,8 @@
|
||||
};
|
||||
$eyebrow = data_get($c, 'eyebrow_text');
|
||||
$eyebrowStyle = (string) data_get($c, 'eyebrow_style', 'badge');
|
||||
$subheadlineRaw = data_get($c, 'subheadline');
|
||||
$subheadline = is_string($subheadlineRaw) ? trim($subheadlineRaw) : '';
|
||||
@endphp
|
||||
|
||||
<div class="flex w-full flex-col {{ $alignClass }} space-y-4">
|
||||
@@ -35,9 +37,8 @@
|
||||
</h1>
|
||||
@endif
|
||||
|
||||
@if ($pageState !== 'expired' && filled(data_get($c, 'subheadline')))
|
||||
<div class="w-full max-w-none whitespace-pre-line text-[15px] leading-[1.65] text-white sm:text-base sm:leading-relaxed">
|
||||
{{ trim((string) data_get($c, 'subheadline')) }}
|
||||
</div>
|
||||
{{-- Subheadline must sit on one line inside the div: whitespace-pre-line turns Blade indentation into visible leading space. --}}
|
||||
@if ($pageState !== 'expired' && $subheadline !== '')
|
||||
<div class="w-full max-w-none whitespace-pre-line text-[15px] leading-[1.65] text-white sm:text-base sm:leading-relaxed">{{ $subheadline }}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
$formButtonLabel = (string) (data_get($formContent, 'button_label') ?: __('public.register_button'));
|
||||
$formButtonColor = (string) data_get($formContent, 'button_color', '#F47B20');
|
||||
$formButtonTextColor = (string) data_get($formContent, 'button_text_color', '#FFFFFF');
|
||||
$bgFixed = $page->background_fixed;
|
||||
$bgLayerPosition = $bgFixed ? 'fixed inset-0 pointer-events-none z-0' : 'absolute inset-0';
|
||||
$overlayPosition = $bgFixed ? 'fixed inset-0 pointer-events-none z-[1]' : 'absolute inset-0';
|
||||
@endphp
|
||||
|
||||
@extends('layouts.public')
|
||||
@@ -28,19 +31,19 @@
|
||||
<div class="relative min-h-screen w-full overflow-x-hidden">
|
||||
@if ($bgUrl !== null)
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
class="{{ $bgLayerPosition }} bg-cover bg-center bg-no-repeat"
|
||||
style="background-image: url('{{ e($bgUrl) }}')"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
@else
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950"
|
||||
class="{{ $bgLayerPosition }} bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
@endif
|
||||
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
class="{{ $overlayPosition }}"
|
||||
style="background-color: {{ e($overlayColor) }}; opacity: {{ $overlayOpacity }}"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
@@ -53,13 +56,14 @@
|
||||
'phase' => $alpinePhase,
|
||||
'startAtMs' => $page->start_date->getTimestamp() * 1000,
|
||||
'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(),
|
||||
'phoneRequired' => $page->isPhoneFieldEnabledForSubscribers() && $page->isPhoneFieldRequiredForSubscribers(),
|
||||
'subscribeUrl' => route('public.subscribe', ['publicPage' => $page]),
|
||||
'csrfToken' => csrf_token(),
|
||||
'genericError' => __('Something went wrong. Please try again.'),
|
||||
'labelDay' => __('day'),
|
||||
'labelDays' => __('days'),
|
||||
'invalidEmailMsg' => __('Please enter a valid email address.'),
|
||||
'invalidPhoneMsg' => __('Please enter a valid phone number (8–15 digits).'),
|
||||
'invalidPhoneMsg' => __('Please enter a valid phone number.'),
|
||||
'formButtonLabel' => $formButtonLabel,
|
||||
'formButtonColor' => $formButtonColor,
|
||||
'formButtonTextColor' => $formButtonTextColor,
|
||||
@@ -85,9 +89,8 @@
|
||||
@if ($pageState === 'expired')
|
||||
<div class="space-y-6">
|
||||
@if (filled($page->expired_message))
|
||||
<div class="whitespace-pre-line text-center text-[15px] leading-[1.65] text-white/92 sm:text-base sm:leading-relaxed">
|
||||
{{ $page->expired_message }}
|
||||
</div>
|
||||
{{-- Same as text/hero blocks: no line breaks inside whitespace-pre-line wrapper. --}}
|
||||
<div class="whitespace-pre-line text-center text-[15px] leading-[1.65] text-white/92 sm:text-base sm:leading-relaxed">{{ trim((string) $page->expired_message) }}</div>
|
||||
@else
|
||||
<p class="text-center text-[15px] leading-relaxed text-white/92 sm:text-base">{{ __('This pre-registration period has ended.') }}</p>
|
||||
@endif
|
||||
|
||||
@@ -8,6 +8,9 @@ use App\Http\Controllers\Admin\MailwizzController;
|
||||
use App\Http\Controllers\Admin\PageController;
|
||||
use App\Http\Controllers\Admin\SubscriberController;
|
||||
use App\Http\Controllers\Admin\UserController;
|
||||
use App\Http\Controllers\Admin\WeeztixApiController;
|
||||
use App\Http\Controllers\Admin\WeeztixController;
|
||||
use App\Http\Controllers\Admin\WeeztixOAuthController;
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\PublicPageController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -35,6 +38,7 @@ Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(
|
||||
Route::get('pages/{page}/subscribers/export', [SubscriberController::class, 'export'])->name('pages.subscribers.export');
|
||||
Route::delete('pages/{page}/subscribers/{subscriber}', [SubscriberController::class, 'destroy'])->name('pages.subscribers.destroy');
|
||||
Route::post('pages/{page}/subscribers/queue-mailwizz-sync', [SubscriberController::class, 'queueMailwizzSync'])->name('pages.subscribers.queue-mailwizz-sync');
|
||||
Route::post('pages/{page}/subscribers/{subscriber}/sync-mailwizz', [SubscriberController::class, 'syncSubscriberMailwizz'])->name('pages.subscribers.sync-mailwizz');
|
||||
Route::get('pages/{page}/subscribers', [SubscriberController::class, 'index'])->name('pages.subscribers.index');
|
||||
|
||||
// Mailwizz configuration (nested under pages)
|
||||
@@ -46,6 +50,15 @@ Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(
|
||||
Route::post('mailwizz/lists', [MailwizzApiController::class, 'lists'])->name('mailwizz.lists');
|
||||
Route::post('mailwizz/fields', [MailwizzApiController::class, 'fields'])->name('mailwizz.fields');
|
||||
|
||||
// Weeztix configuration (nested under pages)
|
||||
Route::get('pages/{page}/weeztix', [WeeztixController::class, 'edit'])->name('pages.weeztix.edit');
|
||||
Route::put('pages/{page}/weeztix', [WeeztixController::class, 'update'])->name('pages.weeztix.update');
|
||||
Route::delete('pages/{page}/weeztix', [WeeztixController::class, 'destroy'])->name('pages.weeztix.destroy');
|
||||
Route::get('pages/{page}/weeztix/oauth/redirect', [WeeztixOAuthController::class, 'redirect'])->name('pages.weeztix.oauth.redirect');
|
||||
Route::get('weeztix/callback', [WeeztixOAuthController::class, 'callback'])->name('weeztix.callback');
|
||||
|
||||
Route::post('weeztix/coupons', [WeeztixApiController::class, 'coupons'])->name('weeztix.coupons');
|
||||
|
||||
// User management (superadmin only)
|
||||
Route::middleware('role:superadmin')->group(function () {
|
||||
Route::resource('users', UserController::class)->except(['show']);
|
||||
|
||||
5
run-deploy-from-local.sh
Normal file
5
run-deploy-from-local.sh
Normal file
@@ -0,0 +1,5 @@
|
||||
ssh hausdesign-vps "sudo -u hausdesign bash -c '
|
||||
export PATH=\"\$HOME/.local/share/fnm:\$PATH\"
|
||||
eval \"\$(fnm env)\"
|
||||
cd /home/hausdesign/preregister && ./deploy.sh
|
||||
'"
|
||||
@@ -4,10 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
use App\Models\User;
|
||||
use App\Models\WeeztixConfig;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -128,4 +132,202 @@ class DestroySubscriberTest extends TestCase
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas('subscribers', ['id' => $subscriber->id]);
|
||||
}
|
||||
|
||||
public function test_delete_strips_mailwizz_source_tag_and_clears_coupon_field(): void
|
||||
{
|
||||
Http::fake(function (Request $request) {
|
||||
$url = $request->url();
|
||||
if (str_contains($url, 'search-by-email')) {
|
||||
return Http::response(['status' => 'success', 'data' => ['subscriber_uid' => 'sub-to-clean']]);
|
||||
}
|
||||
if ($request->method() === 'GET' && str_contains($url, '/subscribers/sub-to-clean') && ! str_contains($url, 'search-by-email')) {
|
||||
return Http::response([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'record' => [
|
||||
'TAGS' => 'preregister-source,other-tag',
|
||||
'COUPON' => 'PREREG-OLD',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
if ($request->method() === 'PUT' && str_contains($url, '/subscribers/sub-to-clean')) {
|
||||
$body = $request->body();
|
||||
$this->assertStringContainsString('other-tag', $body);
|
||||
$this->assertStringNotContainsString('preregister-source', $body);
|
||||
$this->assertStringContainsString('COUPON', $body);
|
||||
|
||||
return Http::response(['status' => 'success']);
|
||||
}
|
||||
|
||||
return Http::response(['status' => 'error'], 500);
|
||||
});
|
||||
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$page = $this->makePageForDestroyTest($user);
|
||||
MailwizzConfig::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'api_key' => 'fake-api-key',
|
||||
'list_uid' => 'list-uid-1',
|
||||
'list_name' => 'Main list',
|
||||
'field_email' => 'EMAIL',
|
||||
'field_first_name' => 'FNAME',
|
||||
'field_last_name' => 'LNAME',
|
||||
'field_phone' => null,
|
||||
'field_coupon_code' => 'COUPON',
|
||||
'tag_field' => 'TAGS',
|
||||
'tag_value' => 'preregister-source',
|
||||
]);
|
||||
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'Clean',
|
||||
'last_name' => 'Up',
|
||||
'email' => 'cleanup@example.com',
|
||||
'coupon_code' => 'PREREG-LOCAL',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->delete(route('admin.pages.subscribers.destroy', [$page, $subscriber]));
|
||||
|
||||
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
|
||||
$this->assertDatabaseMissing('subscribers', ['id' => $subscriber->id]);
|
||||
Http::assertSentCount(3);
|
||||
}
|
||||
|
||||
public function test_delete_clears_mailwizz_checkboxlist_when_only_configured_tag_is_present(): void
|
||||
{
|
||||
Http::fake(function (Request $request) {
|
||||
$url = $request->url();
|
||||
if (str_contains($url, 'search-by-email')) {
|
||||
return Http::response(['status' => 'success', 'data' => ['subscriber_uid' => 'sub-single-tag']]);
|
||||
}
|
||||
if ($request->method() === 'GET' && str_contains($url, '/subscribers/sub-single-tag') && ! str_contains($url, 'search-by-email')) {
|
||||
return Http::response([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'record' => [
|
||||
'TAGS' => 'preregister-source',
|
||||
'COUPON' => 'PREREG-OLD',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
if ($request->method() === 'PUT' && str_contains($url, '/subscribers/sub-single-tag')) {
|
||||
$body = $request->body();
|
||||
$this->assertStringContainsString('TAGS', $body);
|
||||
$this->assertStringNotContainsString('preregister-source', $body);
|
||||
$this->assertStringNotContainsString('TAGS[]', $body);
|
||||
$this->assertStringContainsString('COUPON', $body);
|
||||
|
||||
return Http::response(['status' => 'success']);
|
||||
}
|
||||
|
||||
return Http::response(['status' => 'error'], 500);
|
||||
});
|
||||
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$page = $this->makePageForDestroyTest($user);
|
||||
MailwizzConfig::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'api_key' => 'fake-api-key',
|
||||
'list_uid' => 'list-uid-1',
|
||||
'list_name' => 'Main list',
|
||||
'field_email' => 'EMAIL',
|
||||
'field_first_name' => 'FNAME',
|
||||
'field_last_name' => 'LNAME',
|
||||
'field_phone' => null,
|
||||
'field_coupon_code' => 'COUPON',
|
||||
'tag_field' => 'TAGS',
|
||||
'tag_value' => 'preregister-source',
|
||||
]);
|
||||
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'Solo',
|
||||
'last_name' => 'Tag',
|
||||
'email' => 'solo-tag@example.com',
|
||||
'coupon_code' => 'PREREG-LOCAL',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->delete(route('admin.pages.subscribers.destroy', [$page, $subscriber]));
|
||||
|
||||
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
|
||||
$this->assertDatabaseMissing('subscribers', ['id' => $subscriber->id]);
|
||||
Http::assertSentCount(3);
|
||||
}
|
||||
|
||||
public function test_delete_removes_coupon_code_in_weeztix_when_configured(): void
|
||||
{
|
||||
Http::fake(function (Request $request) {
|
||||
$url = $request->url();
|
||||
if ($request->method() === 'GET' && preg_match('#/coupon/coupon-guid-test/codes$#', $url) === 1) {
|
||||
return Http::response([
|
||||
'data' => [
|
||||
['guid' => 'wzx-code-guid', 'code' => 'PREREG-DEL99'],
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
if ($request->method() === 'DELETE' && str_contains($url, '/coupon/coupon-guid-test/codes/wzx-code-guid')) {
|
||||
return Http::response(null, 204);
|
||||
}
|
||||
|
||||
return Http::response(['status' => 'error'], 500);
|
||||
});
|
||||
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$page = $this->makePageForDestroyTest($user);
|
||||
WeeztixConfig::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'client_id' => 'client-id',
|
||||
'client_secret' => 'client-secret',
|
||||
'redirect_uri' => 'https://app.test/callback',
|
||||
'access_token' => 'access-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'token_expires_at' => now()->addHour(),
|
||||
'refresh_token_expires_at' => now()->addMonth(),
|
||||
'company_guid' => 'company-guid-test',
|
||||
'company_name' => 'Test Co',
|
||||
'coupon_guid' => 'coupon-guid-test',
|
||||
'coupon_name' => 'PreReg',
|
||||
'is_connected' => true,
|
||||
]);
|
||||
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'Weez',
|
||||
'last_name' => 'Tix',
|
||||
'email' => 'weez@example.com',
|
||||
'coupon_code' => 'PREREG-DEL99',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->delete(route('admin.pages.subscribers.destroy', [$page, $subscriber]));
|
||||
|
||||
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
|
||||
$this->assertDatabaseMissing('subscribers', ['id' => $subscriber->id]);
|
||||
|
||||
Http::assertSent(function (Request $request): bool {
|
||||
return $request->method() === 'DELETE'
|
||||
&& str_contains($request->url(), '/coupon/coupon-guid-test/codes/wzx-code-guid');
|
||||
});
|
||||
}
|
||||
|
||||
private function makePageForDestroyTest(User $user): PreregistrationPage
|
||||
{
|
||||
return PreregistrationPage::query()->create([
|
||||
'slug' => (string) Str::uuid(),
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Fest',
|
||||
'heading' => 'Fest',
|
||||
'intro_text' => null,
|
||||
'thank_you_message' => null,
|
||||
'expired_message' => null,
|
||||
'ticketshop_url' => null,
|
||||
'start_date' => now()->subDay(),
|
||||
'end_date' => now()->addMonth(),
|
||||
'phone_enabled' => false,
|
||||
'background_image' => null,
|
||||
'logo_image' => null,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -36,6 +37,50 @@ class MailwizzConfigUiTest extends TestCase
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_connected_mailwizz_shows_overview_until_wizard_requested(): void
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$page = $this->makePageForUser($user);
|
||||
MailwizzConfig::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'api_key' => 'test-key',
|
||||
'list_uid' => 'list-uid-1',
|
||||
'list_name' => 'Main list',
|
||||
'field_email' => 'EMAIL',
|
||||
'field_first_name' => 'FNAME',
|
||||
'field_last_name' => 'LNAME',
|
||||
'field_phone' => null,
|
||||
'field_coupon_code' => null,
|
||||
'tag_field' => 'TAGS',
|
||||
'tag_value' => 'preregister-source',
|
||||
]);
|
||||
|
||||
$overview = $this->actingAs($user)->get(route('admin.pages.mailwizz.edit', $page));
|
||||
$overview->assertOk();
|
||||
$overview->assertSee('Current configuration', escape: false);
|
||||
$overview->assertSee('Change settings (wizard)', escape: false);
|
||||
$overview->assertDontSee('Step 1: API key', escape: false);
|
||||
|
||||
$wizard = $this->actingAs($user)->get(route('admin.pages.mailwizz.edit', [
|
||||
'page' => $page,
|
||||
'wizard' => 1,
|
||||
'step' => 1,
|
||||
]));
|
||||
$wizard->assertOk();
|
||||
$wizard->assertSee('Step 1: API key', escape: false);
|
||||
$wizard->assertSee('Cancel and return to overview', escape: false);
|
||||
}
|
||||
|
||||
public function test_mailwizz_wizard_redirects_to_step_one_when_no_config_and_step_gt_one(): void
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$page = $this->makePageForUser($user);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.pages.mailwizz.edit', ['page' => $page, 'wizard' => 1, 'step' => 3]))
|
||||
->assertRedirect(route('admin.pages.mailwizz.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]));
|
||||
}
|
||||
|
||||
private function makePageForUser(User $user): PreregistrationPage
|
||||
{
|
||||
return PreregistrationPage::query()->create([
|
||||
|
||||
@@ -170,7 +170,7 @@ class PublicPageTest extends TestCase
|
||||
$response->assertJsonValidationErrors(['phone']);
|
||||
}
|
||||
|
||||
public function test_subscribe_normalizes_phone_to_digits(): void
|
||||
public function test_subscribe_stores_phone_as_e164(): void
|
||||
{
|
||||
$page = $this->makePage([
|
||||
'start_date' => now()->subHour(),
|
||||
@@ -189,7 +189,7 @@ class PublicPageTest extends TestCase
|
||||
$this->assertDatabaseHas('subscribers', [
|
||||
'preregistration_page_id' => $page->id,
|
||||
'email' => 'phoneuser@example.com',
|
||||
'phone' => '31612345678',
|
||||
'phone' => '+31612345678',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\SyncSubscriberToMailwizz;
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
@@ -12,6 +13,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -80,6 +82,135 @@ class QueueUnsyncedMailwizzSubscribersTest extends TestCase
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_owner_can_queue_single_subscriber_mailwizz_sync(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$page = $this->makePageWithMailwizzForUser($user);
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'One',
|
||||
'last_name' => 'Off',
|
||||
'email' => 'oneoff@example.com',
|
||||
'synced_to_mailwizz' => true,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(
|
||||
route('admin.pages.subscribers.sync-mailwizz', [$page, $subscriber])
|
||||
);
|
||||
|
||||
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
|
||||
$response->assertSessionHas('status');
|
||||
Queue::assertPushed(SyncSubscriberToMailwizz::class, function (SyncSubscriberToMailwizz $job) use ($subscriber): bool {
|
||||
return $job->subscriberId === $subscriber->id;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_other_user_cannot_queue_single_subscriber_mailwizz_sync(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$owner = User::factory()->create(['role' => 'user']);
|
||||
$intruder = User::factory()->create(['role' => 'user']);
|
||||
$page = $this->makePageWithMailwizzForUser($owner);
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'A',
|
||||
'last_name' => 'B',
|
||||
'email' => 'ab@example.com',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($intruder)->post(
|
||||
route('admin.pages.subscribers.sync-mailwizz', [$page, $subscriber])
|
||||
);
|
||||
|
||||
$response->assertForbidden();
|
||||
Queue::assertNothingPushed();
|
||||
}
|
||||
|
||||
public function test_single_subscriber_mailwizz_sync_redirects_with_error_when_page_has_no_mailwizz(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$page = PreregistrationPage::query()->create([
|
||||
'slug' => (string) Str::uuid(),
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Fest',
|
||||
'heading' => 'Join',
|
||||
'intro_text' => null,
|
||||
'thank_you_message' => null,
|
||||
'expired_message' => null,
|
||||
'ticketshop_url' => null,
|
||||
'start_date' => now()->subHour(),
|
||||
'end_date' => now()->addMonth(),
|
||||
'phone_enabled' => false,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'A',
|
||||
'last_name' => 'B',
|
||||
'email' => 'nomw@example.com',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(
|
||||
route('admin.pages.subscribers.sync-mailwizz', [$page, $subscriber])
|
||||
);
|
||||
|
||||
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
|
||||
$response->assertSessionHas('error');
|
||||
Queue::assertNothingPushed();
|
||||
}
|
||||
|
||||
public function test_cannot_queue_single_subscriber_mailwizz_sync_with_mismatched_page(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$pageA = $this->makePageWithMailwizzForUser($user);
|
||||
$pageB = PreregistrationPage::query()->create([
|
||||
'slug' => (string) Str::uuid(),
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Other',
|
||||
'heading' => 'Other',
|
||||
'intro_text' => null,
|
||||
'thank_you_message' => null,
|
||||
'expired_message' => null,
|
||||
'ticketshop_url' => null,
|
||||
'start_date' => now()->subHour(),
|
||||
'end_date' => now()->addMonth(),
|
||||
'phone_enabled' => false,
|
||||
'is_active' => true,
|
||||
]);
|
||||
MailwizzConfig::query()->create([
|
||||
'preregistration_page_id' => $pageB->id,
|
||||
'api_key' => 'fake-api-key',
|
||||
'list_uid' => 'list-uid-2',
|
||||
'list_name' => 'List B',
|
||||
'field_email' => 'EMAIL',
|
||||
'field_first_name' => 'FNAME',
|
||||
'field_last_name' => 'LNAME',
|
||||
'field_phone' => null,
|
||||
'tag_field' => 'TAGS',
|
||||
'tag_value' => 'b-source',
|
||||
]);
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $pageB->id,
|
||||
'first_name' => 'A',
|
||||
'last_name' => 'B',
|
||||
'email' => 'on-b@example.com',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(
|
||||
route('admin.pages.subscribers.sync-mailwizz', [$pageA, $subscriber])
|
||||
);
|
||||
|
||||
$response->assertForbidden();
|
||||
Queue::assertNothingPushed();
|
||||
}
|
||||
|
||||
private function makePageWithMailwizz(): PreregistrationPage
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
@@ -32,9 +32,31 @@ class StorePreregistrationPageTest extends TestCase
|
||||
$page = PreregistrationPage::query()->first();
|
||||
$response->assertRedirect(route('admin.pages.edit', $page));
|
||||
$this->assertSame('Summer Fest', $page?->title);
|
||||
$this->assertFalse($page?->background_fixed);
|
||||
$this->assertGreaterThanOrEqual(4, PageBlock::query()->where('preregistration_page_id', $page?->id)->count());
|
||||
}
|
||||
|
||||
public function test_store_can_enable_fixed_background(): void
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('admin.pages.store'), [
|
||||
'title' => 'Winter Fest',
|
||||
'thank_you_message' => null,
|
||||
'expired_message' => null,
|
||||
'ticketshop_url' => null,
|
||||
'start_date' => '2026-06-01T10:00',
|
||||
'end_date' => '2026-06-30T18:00',
|
||||
'is_active' => true,
|
||||
'background_fixed' => true,
|
||||
]);
|
||||
|
||||
$page = PreregistrationPage::query()->where('title', 'Winter Fest')->first();
|
||||
$response->assertRedirect(route('admin.pages.edit', $page));
|
||||
$this->assertNotNull($page);
|
||||
$this->assertTrue($page->background_fixed);
|
||||
}
|
||||
|
||||
public function test_validation_failure_redirects_back_with_input(): void
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\SyncSubscriberToMailwizz;
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
@@ -18,6 +19,27 @@ class SyncSubscriberToMailwizzTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_subscribe_returns_ok_when_mailwizz_api_fails_under_sync_queue(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*' => Http::response(['status' => 'error', 'message' => 'service unavailable'], 503),
|
||||
]);
|
||||
|
||||
$page = $this->makePageWithMailwizz();
|
||||
|
||||
$this->postJson(route('public.subscribe', ['publicPage' => $page->slug]), [
|
||||
'first_name' => 'Broken',
|
||||
'last_name' => 'Mailwizz',
|
||||
'email' => 'broken-mailwizz@example.com',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJson(['success' => true]);
|
||||
|
||||
$subscriber = Subscriber::query()->where('email', 'broken-mailwizz@example.com')->first();
|
||||
$this->assertNotNull($subscriber);
|
||||
$this->assertFalse($subscriber->synced_to_mailwizz);
|
||||
}
|
||||
|
||||
public function test_subscribe_with_mailwizz_config_runs_sync_create_path_and_marks_synced(): void
|
||||
{
|
||||
Http::fake(function (Request $request) {
|
||||
@@ -91,6 +113,84 @@ class SyncSubscriberToMailwizzTest extends TestCase
|
||||
$this->assertTrue($subscriber->synced_to_mailwizz);
|
||||
}
|
||||
|
||||
public function test_mailwizz_sync_sends_phone_with_e164_plus_prefix(): void
|
||||
{
|
||||
Http::fake(function (Request $request) {
|
||||
$url = $request->url();
|
||||
if (str_contains($url, 'search-by-email')) {
|
||||
return Http::response(['status' => 'error']);
|
||||
}
|
||||
if ($request->method() === 'POST' && preg_match('#/lists/[^/]+/subscribers$#', $url) === 1) {
|
||||
$body = $request->body();
|
||||
$this->assertStringContainsString('PHONE', $body);
|
||||
$this->assertTrue(
|
||||
str_contains($body, '+31612345678') || str_contains($body, '%2B31612345678'),
|
||||
'Expected E.164 phone with + in Mailwizz request body'
|
||||
);
|
||||
|
||||
return Http::response(['status' => 'success']);
|
||||
}
|
||||
|
||||
return Http::response(['status' => 'error'], 500);
|
||||
});
|
||||
|
||||
$page = $this->makePageWithMailwizz([
|
||||
'field_phone' => 'PHONE',
|
||||
]);
|
||||
$page->update(['phone_enabled' => true]);
|
||||
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'Test',
|
||||
'last_name' => 'User',
|
||||
'email' => 'phone-e164@example.com',
|
||||
'phone' => '+31612345678',
|
||||
'synced_to_mailwizz' => false,
|
||||
]);
|
||||
|
||||
SyncSubscriberToMailwizz::dispatchSync($subscriber);
|
||||
|
||||
$subscriber->refresh();
|
||||
$this->assertTrue($subscriber->synced_to_mailwizz);
|
||||
}
|
||||
|
||||
public function test_mailwizz_sync_includes_coupon_code_when_mapped(): void
|
||||
{
|
||||
Http::fake(function (Request $request) {
|
||||
$url = $request->url();
|
||||
if (str_contains($url, 'search-by-email')) {
|
||||
return Http::response(['status' => 'error']);
|
||||
}
|
||||
if ($request->method() === 'POST' && preg_match('#/lists/[^/]+/subscribers$#', $url) === 1) {
|
||||
$body = $request->body();
|
||||
$this->assertStringContainsString('COUPON', $body);
|
||||
$this->assertStringContainsString('PREREG-TEST99', $body);
|
||||
|
||||
return Http::response(['status' => 'success']);
|
||||
}
|
||||
|
||||
return Http::response(['status' => 'error'], 500);
|
||||
});
|
||||
|
||||
$page = $this->makePageWithMailwizz([
|
||||
'field_coupon_code' => 'COUPON',
|
||||
]);
|
||||
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'Coupon',
|
||||
'last_name' => 'User',
|
||||
'email' => 'coupon-map@example.com',
|
||||
'coupon_code' => 'PREREG-TEST99',
|
||||
'synced_to_mailwizz' => false,
|
||||
]);
|
||||
|
||||
SyncSubscriberToMailwizz::dispatchSync($subscriber);
|
||||
|
||||
$subscriber->refresh();
|
||||
$this->assertTrue($subscriber->synced_to_mailwizz);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $configOverrides
|
||||
*/
|
||||
@@ -122,6 +222,7 @@ class SyncSubscriberToMailwizzTest extends TestCase
|
||||
'field_first_name' => 'FNAME',
|
||||
'field_last_name' => 'LNAME',
|
||||
'field_phone' => null,
|
||||
'field_coupon_code' => null,
|
||||
'tag_field' => 'TAGS',
|
||||
'tag_value' => 'preregister-source',
|
||||
], $configOverrides));
|
||||
|
||||
29
tests/Unit/SubscriberPhoneDisplayTest.php
Normal file
29
tests/Unit/SubscriberPhoneDisplayTest.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Subscriber;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SubscriberPhoneDisplayTest extends TestCase
|
||||
{
|
||||
public function test_phone_display_keeps_e164_with_plus(): void
|
||||
{
|
||||
$subscriber = new Subscriber(['phone' => '+31613210095']);
|
||||
$this->assertSame('+31613210095', $subscriber->phoneDisplay());
|
||||
}
|
||||
|
||||
public function test_phone_display_prefixes_plus_for_legacy_digit_only_storage(): void
|
||||
{
|
||||
$subscriber = new Subscriber(['phone' => '31613210095']);
|
||||
$this->assertSame('+31613210095', $subscriber->phoneDisplay());
|
||||
}
|
||||
|
||||
public function test_phone_display_returns_null_when_empty(): void
|
||||
{
|
||||
$subscriber = new Subscriber(['phone' => null]);
|
||||
$this->assertNull($subscriber->phoneDisplay());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user