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).
|
# 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
|
# 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.
|
# Wall-clock times from the admin UI (datetime-local) are interpreted in this zone.
|
||||||
APP_TIMEZONE=Europe/Amsterdam
|
APP_TIMEZONE=Europe/Amsterdam
|
||||||
|
|
||||||
@@ -42,6 +45,10 @@ SESSION_DOMAIN=null
|
|||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
|
|
||||||
|
# Use "database" or "redis" in production and run `php artisan queue:work` (see documentation/DEPLOYMENT-STRATEGY.md).
|
||||||
|
# Avoid "sync" in production: Mailwizz (and Weeztix coupon) jobs would run inside the HTTP request; a thrown error
|
||||||
|
# can return 5xx to the visitor even though the subscriber row is already saved — confusing UX.
|
||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
CACHE_STORE=database
|
CACHE_STORE=database
|
||||||
@@ -70,3 +77,9 @@ AWS_BUCKET=
|
|||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
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\Http\Requests\Admin\UpdateMailwizzConfigRequest;
|
||||||
use App\Models\PreregistrationPage;
|
use App\Models\PreregistrationPage;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class MailwizzController extends Controller
|
class MailwizzController extends Controller
|
||||||
{
|
{
|
||||||
public function edit(PreregistrationPage $page): View
|
public function edit(Request $request, PreregistrationPage $page): View|RedirectResponse
|
||||||
{
|
{
|
||||||
$this->authorize('update', $page);
|
$this->authorize('update', $page);
|
||||||
|
|
||||||
$page->load('mailwizzConfig');
|
$page->load(['mailwizzConfig', 'weeztixConfig']);
|
||||||
|
|
||||||
return view('admin.mailwizz.edit', compact('page'));
|
$config = $page->mailwizzConfig;
|
||||||
|
$showWizard = $config === null || $request->boolean('wizard');
|
||||||
|
if ($showWizard && $config === null) {
|
||||||
|
$requestedStep = min(4, max(1, (int) $request->query('step', 1)));
|
||||||
|
if ($requestedStep !== 1) {
|
||||||
|
return redirect()
|
||||||
|
->route('admin.pages.mailwizz.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('admin.mailwizz.edit', compact('page', 'showWizard'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(UpdateMailwizzConfigRequest $request, PreregistrationPage $page): RedirectResponse
|
public function update(UpdateMailwizzConfigRequest $request, PreregistrationPage $page): RedirectResponse
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class PageController extends Controller
|
|||||||
{
|
{
|
||||||
$query = PreregistrationPage::query()
|
$query = PreregistrationPage::query()
|
||||||
->withCount('subscribers')
|
->withCount('subscribers')
|
||||||
|
->with(['weeztixConfig', 'mailwizzConfig'])
|
||||||
->orderByDesc('start_date');
|
->orderByDesc('start_date');
|
||||||
|
|
||||||
if (! $request->user()?->isSuperadmin()) {
|
if (! $request->user()?->isSuperadmin()) {
|
||||||
@@ -85,7 +86,11 @@ class PageController extends Controller
|
|||||||
|
|
||||||
public function edit(PreregistrationPage $page): View
|
public function edit(PreregistrationPage $page): View
|
||||||
{
|
{
|
||||||
$page->load(['blocks' => fn ($q) => $q->orderBy('sort_order')]);
|
$page->load([
|
||||||
|
'blocks' => fn ($q) => $q->orderBy('sort_order'),
|
||||||
|
'mailwizzConfig',
|
||||||
|
'weeztixConfig',
|
||||||
|
]);
|
||||||
|
|
||||||
return view('admin.pages.edit', compact('page'));
|
return view('admin.pages.edit', compact('page'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Http\Requests\Admin\DestroySubscriberRequest;
|
use App\Http\Requests\Admin\DestroySubscriberRequest;
|
||||||
use App\Http\Requests\Admin\IndexSubscriberRequest;
|
use App\Http\Requests\Admin\IndexSubscriberRequest;
|
||||||
use App\Http\Requests\Admin\QueueMailwizzSyncRequest;
|
use App\Http\Requests\Admin\QueueMailwizzSyncRequest;
|
||||||
|
use App\Http\Requests\Admin\SyncSubscriberMailwizzRequest;
|
||||||
|
use App\Jobs\SyncSubscriberToMailwizz;
|
||||||
use App\Models\PreregistrationPage;
|
use App\Models\PreregistrationPage;
|
||||||
use App\Models\Subscriber;
|
use App\Models\Subscriber;
|
||||||
|
use App\Services\CleanupSubscriberIntegrationsService;
|
||||||
use App\Services\DispatchUnsyncedMailwizzSyncJobsService;
|
use App\Services\DispatchUnsyncedMailwizzSyncJobsService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
@@ -34,8 +37,13 @@ class SubscriberController extends Controller
|
|||||||
return view('admin.subscribers.index', compact('page', 'subscribers', 'unsyncedMailwizzCount'));
|
return view('admin.subscribers.index', compact('page', 'subscribers', 'unsyncedMailwizzCount'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(DestroySubscriberRequest $request, PreregistrationPage $page, Subscriber $subscriber): RedirectResponse
|
public function destroy(
|
||||||
{
|
DestroySubscriberRequest $request,
|
||||||
|
PreregistrationPage $page,
|
||||||
|
Subscriber $subscriber,
|
||||||
|
CleanupSubscriberIntegrationsService $cleanupIntegrations
|
||||||
|
): RedirectResponse {
|
||||||
|
$cleanupIntegrations->runBeforeDelete($subscriber);
|
||||||
$subscriber->delete();
|
$subscriber->delete();
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
@@ -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
|
public function export(IndexSubscriberRequest $request, PreregistrationPage $page): StreamedResponse
|
||||||
{
|
{
|
||||||
$search = $request->validated('search');
|
$search = $request->validated('search');
|
||||||
@@ -89,14 +117,15 @@ class SubscriberController extends Controller
|
|||||||
if ($phoneEnabled) {
|
if ($phoneEnabled) {
|
||||||
$headers[] = 'Phone';
|
$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);
|
fputcsv($handle, $headers);
|
||||||
|
|
||||||
foreach ($subscribers as $sub) {
|
foreach ($subscribers as $sub) {
|
||||||
$row = [$sub->first_name, $sub->last_name, $sub->email];
|
$row = [$sub->first_name, $sub->last_name, $sub->email];
|
||||||
if ($phoneEnabled) {
|
if ($phoneEnabled) {
|
||||||
$row[] = $sub->phone ?? '';
|
$row[] = $sub->phoneDisplay() ?? '';
|
||||||
}
|
}
|
||||||
|
$row[] = $sub->coupon_code ?? '';
|
||||||
$row[] = $sub->created_at?->toDateTimeString() ?? '';
|
$row[] = $sub->created_at?->toDateTimeString() ?? '';
|
||||||
$row[] = $sub->synced_to_mailwizz ? 'Yes' : 'No';
|
$row[] = $sub->synced_to_mailwizz ? 'Yes' : 'No';
|
||||||
$row[] = $sub->synced_at?->toDateTimeString() ?? '';
|
$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;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Requests\SubscribePublicPageRequest;
|
use App\Http\Requests\SubscribePublicPageRequest;
|
||||||
use App\Jobs\SyncSubscriberToMailwizz;
|
|
||||||
use App\Models\PageBlock;
|
use App\Models\PageBlock;
|
||||||
use App\Models\PreregistrationPage;
|
use App\Models\PreregistrationPage;
|
||||||
|
use App\Services\RegisterSubscriberOnPage;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class PublicPageController extends Controller
|
class PublicPageController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly RegisterSubscriberOnPage $registerSubscriberOnPage
|
||||||
|
) {}
|
||||||
|
|
||||||
public function show(PreregistrationPage $publicPage): View
|
public function show(PreregistrationPage $publicPage): View
|
||||||
{
|
{
|
||||||
$publicPage->load(['blocks' => fn ($q) => $q->orderBy('sort_order')]);
|
$publicPage->load(['blocks' => fn ($q) => $q->orderBy('sort_order')]);
|
||||||
@@ -50,11 +54,7 @@ class PublicPageController extends Controller
|
|||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
$subscriber = $publicPage->subscribers()->create($validated);
|
$this->registerSubscriberOnPage->storeAndQueueIntegrations($publicPage, $validated);
|
||||||
|
|
||||||
if ($publicPage->mailwizzConfig !== null) {
|
|
||||||
SyncSubscriberToMailwizz::dispatch($subscriber);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'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_first_name' => ['required', 'string', 'max:255'],
|
||||||
'field_last_name' => ['required', 'string', 'max:255'],
|
'field_last_name' => ['required', 'string', 'max:255'],
|
||||||
'field_phone' => ['nullable', 'string', 'max:255'],
|
'field_phone' => ['nullable', 'string', 'max:255'],
|
||||||
|
'field_coupon_code' => ['nullable', 'string', 'max:255'],
|
||||||
'tag_field' => ['required', 'string', 'max:255'],
|
'tag_field' => ['required', 'string', 'max:255'],
|
||||||
'tag_value' => ['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'],
|
'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_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/'],
|
||||||
'background_overlay_opacity' => ['nullable', 'integer', 'min:0', 'max:100'],
|
'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'],
|
'page_background' => ['nullable', 'file', 'image', 'mimes:jpeg,png,jpg,webp', 'max:5120'],
|
||||||
'remove_page_background' => ['sometimes', 'boolean'],
|
'remove_page_background' => ['sometimes', 'boolean'],
|
||||||
'start_date' => ['required', 'date'],
|
'start_date' => ['required', 'date'],
|
||||||
@@ -69,6 +70,7 @@ trait ValidatesPreregistrationPageInput
|
|||||||
|
|
||||||
$this->merge([
|
$this->merge([
|
||||||
'is_active' => $this->boolean('is_active'),
|
'is_active' => $this->boolean('is_active'),
|
||||||
|
'background_fixed' => $this->boolean('background_fixed'),
|
||||||
'remove_page_background' => $this->boolean('remove_page_background'),
|
'remove_page_background' => $this->boolean('remove_page_background'),
|
||||||
'ticketshop_url' => $ticketshopNormalized,
|
'ticketshop_url' => $ticketshopNormalized,
|
||||||
'post_submit_redirect_url' => $redirectNormalized,
|
'post_submit_redirect_url' => $redirectNormalized,
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
use App\Models\PreregistrationPage;
|
use App\Models\PreregistrationPage;
|
||||||
|
use App\Rules\ValidPhoneNumber;
|
||||||
|
use App\Services\PhoneNumberNormalizer;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\Rules\Email;
|
use Illuminate\Validation\Rules\Email;
|
||||||
|
|
||||||
class SubscribePublicPageRequest extends FormRequest
|
class SubscribePublicPageRequest extends FormRequest
|
||||||
@@ -28,13 +31,23 @@ class SubscribePublicPageRequest extends FormRequest
|
|||||||
->rfcCompliant()
|
->rfcCompliant()
|
||||||
->preventSpoofing();
|
->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 [
|
return [
|
||||||
'first_name' => ['required', 'string', 'max:255'],
|
'first_name' => ['required', 'string', 'max:255'],
|
||||||
'last_name' => ['required', 'string', 'max:255'],
|
'last_name' => ['required', 'string', 'max:255'],
|
||||||
'email' => ['required', 'string', 'max:255', $emailRule],
|
'email' => ['required', 'string', 'max:255', $emailRule],
|
||||||
'phone' => $page->isPhoneFieldEnabledForSubscribers()
|
'phone' => $phoneRules,
|
||||||
? ['nullable', 'string', 'regex:/^[0-9]{8,15}$/']
|
|
||||||
: ['nullable', 'string', 'max:255'],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +58,6 @@ class SubscribePublicPageRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'email' => __('Please enter a valid email address.'),
|
'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 */
|
/** @var PreregistrationPage $page */
|
||||||
$page = $this->route('publicPage');
|
$page = $this->route('publicPage');
|
||||||
$phone = $this->input('phone');
|
|
||||||
if (! $page->isPhoneFieldEnabledForSubscribers()) {
|
if (! $page->isPhoneFieldEnabledForSubscribers()) {
|
||||||
$this->merge(['phone' => null]);
|
$this->merge(['phone' => null]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$phone = $this->input('phone');
|
||||||
|
if ($phone === null || $phone === '') {
|
||||||
|
$this->merge(['phone' => null]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (is_string($phone)) {
|
if (is_string($phone)) {
|
||||||
$digits = preg_replace('/\D+/', '', $phone);
|
$trimmed = trim($phone);
|
||||||
$this->merge(['phone' => $digits === '' ? null : $digits]);
|
$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\MailwizzConfig;
|
||||||
use App\Models\Subscriber;
|
use App\Models\Subscriber;
|
||||||
|
use App\Services\MailwizzCheckboxlistTags;
|
||||||
use App\Services\MailwizzService;
|
use App\Services\MailwizzService;
|
||||||
|
use App\Services\MailwizzSubscriberFormPayload;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueueAfterCommit
|
||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
use InteractsWithQueue;
|
use InteractsWithQueue;
|
||||||
use Queueable;
|
use Queueable;
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seconds before the unique lock expires if the worker dies before releasing it.
|
* Seconds before the unique lock expires if the worker dies before releasing it.
|
||||||
@@ -36,21 +36,52 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
|||||||
*/
|
*/
|
||||||
public array $backoff = [10, 30, 60];
|
public array $backoff = [10, 30, 60];
|
||||||
|
|
||||||
public function __construct(public Subscriber $subscriber)
|
/**
|
||||||
|
* Set in the constructor for new jobs. Remains null when an old queue payload (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');
|
$this->onQueue('mailwizz');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function uniqueId(): string
|
public function uniqueId(): string
|
||||||
{
|
{
|
||||||
return (string) $this->subscriber->getKey();
|
return $this->subscriberId !== null
|
||||||
|
? (string) $this->subscriberId
|
||||||
|
: 'stale-mailwizz-sync-payload';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
|
{
|
||||||
|
if ($this->subscriberId === null) {
|
||||||
|
Log::notice('SyncSubscriberToMailwizz: skipped job with missing subscriber id (stale queue payload). Clear the queue or re-dispatch sync jobs.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->runSync();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error('SyncSubscriberToMailwizz: integration failed; subscriber remains local (use admin resync if needed)', [
|
||||||
|
'subscriber_id' => $this->subscriberId,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runSync(): void
|
||||||
{
|
{
|
||||||
$subscriber = Subscriber::query()
|
$subscriber = Subscriber::query()
|
||||||
->with(['preregistrationPage.mailwizzConfig'])
|
->with(['preregistrationPage.mailwizzConfig'])
|
||||||
->find($this->subscriber->id);
|
->find($this->subscriberId);
|
||||||
|
|
||||||
if ($subscriber === null) {
|
if ($subscriber === null) {
|
||||||
return;
|
return;
|
||||||
@@ -101,7 +132,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
|||||||
public function failed(?Throwable $exception): void
|
public function failed(?Throwable $exception): void
|
||||||
{
|
{
|
||||||
Log::error('SyncSubscriberToMailwizz failed', [
|
Log::error('SyncSubscriberToMailwizz failed', [
|
||||||
'subscriber_id' => $this->subscriber->id,
|
'subscriber_id' => $this->subscriberId,
|
||||||
'message' => $exception?->getMessage(),
|
'message' => $exception?->getMessage(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -119,24 +150,6 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildBasePayload(Subscriber $subscriber, MailwizzConfig $config, bool $phoneEnabled): array
|
|
||||||
{
|
|
||||||
$data = [
|
|
||||||
$config->field_email => $subscriber->email,
|
|
||||||
$config->field_first_name => $subscriber->first_name,
|
|
||||||
$config->field_last_name => $subscriber->last_name,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($phoneEnabled && $config->field_phone !== null && $config->field_phone !== '') {
|
|
||||||
$phone = $subscriber->phone;
|
|
||||||
if ($phone !== null && $phone !== '') {
|
|
||||||
$data[$config->field_phone] = $phone;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createInMailwizz(
|
private function createInMailwizz(
|
||||||
MailwizzService $service,
|
MailwizzService $service,
|
||||||
Subscriber $subscriber,
|
Subscriber $subscriber,
|
||||||
@@ -144,7 +157,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
|||||||
string $listUid
|
string $listUid
|
||||||
): void {
|
): void {
|
||||||
$page = $subscriber->preregistrationPage;
|
$page = $subscriber->preregistrationPage;
|
||||||
$data = $this->buildBasePayload($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
|
$data = MailwizzSubscriberFormPayload::baseFields($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
|
||||||
$tagField = $config->tag_field;
|
$tagField = $config->tag_field;
|
||||||
$tagValue = $config->tag_value;
|
$tagValue = $config->tag_value;
|
||||||
if ($tagField !== null && $tagField !== '' && $tagValue !== null && $tagValue !== '') {
|
if ($tagField !== null && $tagField !== '' && $tagValue !== null && $tagValue !== '') {
|
||||||
@@ -162,7 +175,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
|||||||
string $subscriberUid
|
string $subscriberUid
|
||||||
): void {
|
): void {
|
||||||
$page = $subscriber->preregistrationPage;
|
$page = $subscriber->preregistrationPage;
|
||||||
$data = $this->buildBasePayload($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
|
$data = MailwizzSubscriberFormPayload::baseFields($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
|
||||||
|
|
||||||
$tagField = $config->tag_field;
|
$tagField = $config->tag_field;
|
||||||
$tagValue = $config->tag_value;
|
$tagValue = $config->tag_value;
|
||||||
@@ -171,46 +184,11 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
|||||||
if ($full === null) {
|
if ($full === null) {
|
||||||
throw new RuntimeException('Mailwizz getSubscriber returned an empty payload.');
|
throw new RuntimeException('Mailwizz getSubscriber returned an empty payload.');
|
||||||
}
|
}
|
||||||
$existingCsv = $this->extractTagCsvFromResponse($full, $tagField);
|
$existingCsv = MailwizzCheckboxlistTags::extractCsvFromSubscriberResponse($full, $tagField);
|
||||||
$merged = $this->mergeCheckboxlistTags($existingCsv, $tagValue);
|
$merged = MailwizzCheckboxlistTags::mergeValueIntoCsv($existingCsv, $tagValue);
|
||||||
$data[$tagField] = $merged;
|
$data[$tagField] = $merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
$service->updateSubscriber($listUid, $subscriberUid, $data);
|
$service->updateSubscriber($listUid, $subscriberUid, $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $apiResponse
|
|
||||||
*/
|
|
||||||
private function extractTagCsvFromResponse(array $apiResponse, string $tagField): string
|
|
||||||
{
|
|
||||||
$record = data_get($apiResponse, 'data.record');
|
|
||||||
if (! is_array($record)) {
|
|
||||||
$record = data_get($apiResponse, 'data');
|
|
||||||
}
|
|
||||||
if (! is_array($record)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$raw = $record[$tagField] ?? data_get($record, "fields.{$tagField}");
|
|
||||||
|
|
||||||
if (is_array($raw)) {
|
|
||||||
return implode(',', array_map(static fn (mixed $v): string => is_scalar($v) ? (string) $v : '', $raw));
|
|
||||||
}
|
|
||||||
|
|
||||||
return is_string($raw) ? $raw : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private function mergeCheckboxlistTags(string $existingCsv, string $newValue): array
|
|
||||||
{
|
|
||||||
$parts = array_filter(array_map('trim', explode(',', $existingCsv)), static fn (string $s): bool => $s !== '');
|
|
||||||
if (! in_array($newValue, $parts, true)) {
|
|
||||||
$parts[] = $newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values($parts);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class MailwizzConfig extends Model
|
|||||||
'field_first_name',
|
'field_first_name',
|
||||||
'field_last_name',
|
'field_last_name',
|
||||||
'field_phone',
|
'field_phone',
|
||||||
|
'field_coupon_code',
|
||||||
'tag_field',
|
'tag_field',
|
||||||
'tag_value',
|
'tag_value',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class PreregistrationPage extends Model
|
|||||||
'background_image',
|
'background_image',
|
||||||
'background_overlay_color',
|
'background_overlay_color',
|
||||||
'background_overlay_opacity',
|
'background_overlay_opacity',
|
||||||
|
'background_fixed',
|
||||||
'logo_image',
|
'logo_image',
|
||||||
'is_active',
|
'is_active',
|
||||||
];
|
];
|
||||||
@@ -42,6 +43,7 @@ class PreregistrationPage extends Model
|
|||||||
'start_date' => 'datetime',
|
'start_date' => 'datetime',
|
||||||
'end_date' => 'datetime',
|
'end_date' => 'datetime',
|
||||||
'phone_enabled' => 'boolean',
|
'phone_enabled' => 'boolean',
|
||||||
|
'background_fixed' => 'boolean',
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -106,6 +108,19 @@ class PreregistrationPage extends Model
|
|||||||
return (bool) $this->phone_enabled;
|
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
|
public function headlineForMeta(): string
|
||||||
{
|
{
|
||||||
$hero = $this->getHeroBlock();
|
$hero = $this->getHeroBlock();
|
||||||
@@ -147,6 +162,11 @@ class PreregistrationPage extends Model
|
|||||||
return $this->hasOne(MailwizzConfig::class);
|
return $this->hasOne(MailwizzConfig::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function weeztixConfig(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(WeeztixConfig::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function isBeforeStart(): bool
|
public function isBeforeStart(): bool
|
||||||
{
|
{
|
||||||
return Carbon::now()->lt($this->start_date);
|
return Carbon::now()->lt($this->start_date);
|
||||||
@@ -184,4 +204,56 @@ class PreregistrationPage extends Model
|
|||||||
|
|
||||||
return 'active';
|
return 'active';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mailwizz setup depth for admin UI (API key + list + email field = ready to sync).
|
||||||
|
*
|
||||||
|
* @return 'none'|'partial'|'ready'
|
||||||
|
*/
|
||||||
|
public function mailwizzIntegrationStatus(): string
|
||||||
|
{
|
||||||
|
$c = $this->mailwizzConfig;
|
||||||
|
if ($c === null) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = $c->api_key;
|
||||||
|
if (! is_string($key) || $key === '') {
|
||||||
|
return 'partial';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($c->list_uid) || $c->list_uid === '' || ! is_string($c->field_email) || $c->field_email === '') {
|
||||||
|
return 'partial';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ready';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weeztix setup depth for admin UI.
|
||||||
|
*
|
||||||
|
* @return 'none'|'credentials'|'connected'|'ready'
|
||||||
|
*/
|
||||||
|
public function weeztixIntegrationStatus(): string
|
||||||
|
{
|
||||||
|
$c = $this->weeztixConfig;
|
||||||
|
if ($c === null) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasClient = is_string($c->client_id) && $c->client_id !== '';
|
||||||
|
if (! $hasClient) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $c->is_connected) {
|
||||||
|
return 'credentials';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($c->coupon_guid) || $c->coupon_guid === '') {
|
||||||
|
return 'connected';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ready';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Services\PhoneNumberNormalizer;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -21,6 +22,7 @@ class Subscriber extends Model
|
|||||||
'phone',
|
'phone',
|
||||||
'synced_to_mailwizz',
|
'synced_to_mailwizz',
|
||||||
'synced_at',
|
'synced_at',
|
||||||
|
'coupon_code',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
@@ -36,6 +38,52 @@ class Subscriber extends Model
|
|||||||
return $this->belongsTo(PreregistrationPage::class);
|
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
|
public function scopeSearch(Builder $query, ?string $term): Builder
|
||||||
{
|
{
|
||||||
if ($term === null || $term === '') {
|
if ($term === null || $term === '') {
|
||||||
@@ -47,7 +95,9 @@ class Subscriber extends Model
|
|||||||
return $query->where(function (Builder $q) use ($like): void {
|
return $query->where(function (Builder $q) use ($like): void {
|
||||||
$q->where('first_name', 'like', $like)
|
$q->where('first_name', 'like', $like)
|
||||||
->orWhere('last_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;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Models\PreregistrationPage;
|
use App\Models\PreregistrationPage;
|
||||||
|
use App\Services\PhoneNumberNormalizer;
|
||||||
use Illuminate\Pagination\Paginator;
|
use Illuminate\Pagination\Paginator;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
@@ -16,7 +17,11 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function register(): void
|
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 = [];
|
$out = [];
|
||||||
foreach ($data as $key => $value) {
|
foreach ($data as $key => $value) {
|
||||||
if (is_array($value)) {
|
if (is_array($value)) {
|
||||||
$out[$key] = $value;
|
// Empty arrays are omitted by Laravel's multipart encoder (no KEY[] parts), so Mailwizz never clears checkboxlist fields.
|
||||||
|
$out[$key] = $value === [] ? '' : $value;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
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",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
|
"giggsey/libphonenumber-for-php": "^9.0",
|
||||||
"laravel/framework": "^13.0",
|
"laravel/framework": "^13.0",
|
||||||
"laravel/tinker": "^3.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",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "0cbc4dc77a1eeff75f257f6ffae9168b",
|
"content-hash": "505a0bb04eb0eb77eddad8d9e0ef372b",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -579,6 +579,140 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-12-03T09:33:47+00:00"
|
"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",
|
"name": "graham-campbell/result-type",
|
||||||
"version": "v1.1.4",
|
"version": "v1.1.4",
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ $defaultPerMinute = in_array($env, ['local', 'testing'], true) ? 1000 : 60;
|
|||||||
|
|
||||||
return [
|
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
|
| 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
|
# ./deploy.sh v1.2.0 → deploys specific tag
|
||||||
# ──────────────────────────────────────────
|
# ──────────────────────────────────────────
|
||||||
|
|
||||||
# !! UPDATE THIS PATH TO YOUR VPS DIRECTORY !!
|
APP_DIR="/home/hausdesign/preregister"
|
||||||
APP_DIR="/home/hausdesign/domains/preregister.hausdesign.nl/public_html"
|
PHP="/usr/local/php84/bin/php"
|
||||||
|
COMPOSER="/usr/local/bin/composer"
|
||||||
TAG="${1:-}"
|
TAG="${1:-}"
|
||||||
|
|
||||||
|
# Load fnm (Node version manager)
|
||||||
|
export PATH="$HOME/.local/share/fnm:$PATH"
|
||||||
|
eval "$(fnm env)"
|
||||||
|
|
||||||
echo "══════════════════════════════════════"
|
echo "══════════════════════════════════════"
|
||||||
echo " PreRegister — Deploy"
|
echo " PreRegister — Deploy"
|
||||||
echo "══════════════════════════════════════"
|
echo "══════════════════════════════════════"
|
||||||
@@ -21,7 +26,7 @@ cd "$APP_DIR"
|
|||||||
|
|
||||||
# 1. Maintenance mode
|
# 1. Maintenance mode
|
||||||
echo "→ Enabling maintenance mode..."
|
echo "→ Enabling maintenance mode..."
|
||||||
php artisan down --retry=30 || true
|
$PHP artisan down --retry=30 || true
|
||||||
|
|
||||||
# 2. Pull latest code
|
# 2. Pull latest code
|
||||||
echo "→ Pulling from Gitea..."
|
echo "→ Pulling from Gitea..."
|
||||||
@@ -38,7 +43,7 @@ fi
|
|||||||
|
|
||||||
# 3. Install PHP dependencies
|
# 3. Install PHP dependencies
|
||||||
echo "→ Installing Composer 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
|
# 4. Install Node dependencies and build
|
||||||
echo "→ Installing npm packages..."
|
echo "→ Installing npm packages..."
|
||||||
@@ -49,25 +54,25 @@ npm run build
|
|||||||
|
|
||||||
# 5. Run migrations
|
# 5. Run migrations
|
||||||
echo "→ Running migrations..."
|
echo "→ Running migrations..."
|
||||||
php artisan migrate --force
|
$PHP artisan migrate --force
|
||||||
|
|
||||||
# 6. Clear and rebuild caches
|
# 6. Clear and rebuild caches
|
||||||
echo "→ Clearing caches..."
|
echo "→ Clearing caches..."
|
||||||
php artisan config:cache
|
$PHP artisan config:cache
|
||||||
php artisan route:cache
|
$PHP artisan route:cache
|
||||||
php artisan view:cache
|
$PHP artisan view:cache
|
||||||
php artisan event:cache
|
$PHP artisan event:cache
|
||||||
|
|
||||||
# 7. Restart queue (process any pending jobs with new code)
|
# 7. Restart queue (process any pending jobs with new code)
|
||||||
echo "→ Restarting queue workers..."
|
echo "→ Restarting queue workers..."
|
||||||
php artisan queue:restart
|
$PHP artisan queue:restart
|
||||||
|
|
||||||
# 8. Storage link (idempotent)
|
# 8. Storage link (idempotent)
|
||||||
php artisan storage:link 2>/dev/null || true
|
$PHP artisan storage:link 2>/dev/null || true
|
||||||
|
|
||||||
# 9. Disable maintenance mode
|
# 9. Disable maintenance mode
|
||||||
echo "→ Going live!"
|
echo "→ Going live!"
|
||||||
php artisan up
|
$PHP artisan up
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
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
|
### 4.7 Set up cron for queue worker + scheduler
|
||||||
|
|
||||||
|
Public registration saves the subscriber in the database first, then queues **Weeztix** (coupon code) and **Mailwizz** sync jobs. Those jobs must be processed by a worker.
|
||||||
|
|
||||||
|
- **Visitor-facing behaviour:** the public subscribe endpoint returns **HTTP 200 with `success: true`** as soon as the subscriber row is stored. Failures in Mailwizz or Weeztix are **logged** (and visible via failed jobs when using a real queue); they do **not** change the JSON shown to the visitor. Use logs and admin resync to diagnose integration issues.
|
||||||
|
- **Production:** set `QUEUE_CONNECTION=database` (or `redis`) so retries and `queue:failed` work as intended. `sync` is acceptable for small installs but runs integration work in-process; still, the visitor should not see 5xx from a broken Mailwizz/Weeztix API after subscribe.
|
||||||
|
- **Queues:** coupon jobs use `weeztix`; Mailwizz uses `mailwizz`. The worker should listen to both (order below prioritises `weeztix` so coupon creation tends to run before sync when both are pending).
|
||||||
|
|
||||||
In DirectAdmin → Cron Jobs, add:
|
In DirectAdmin → Cron Jobs, add:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -276,9 +282,11 @@ In DirectAdmin → Cron Jobs, add:
|
|||||||
* * * * * cd /home/username/preregister && php artisan schedule:run >> /dev/null 2>&1
|
* * * * * cd /home/username/preregister && php artisan schedule:run >> /dev/null 2>&1
|
||||||
|
|
||||||
# Queue worker - process one job per run (every minute)
|
# Queue worker - process one job per run (every minute)
|
||||||
* * * * * cd /home/username/preregister && php artisan queue:work --once --queue=mailwizz >> /dev/null 2>&1
|
* * * * * cd /home/username/preregister && php artisan queue:work --once --queue=weeztix,mailwizz,default >> /dev/null 2>&1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For higher throughput, use a persistent supervisor-managed worker instead of `--once` in cron; keep the same `--queue=weeztix,mailwizz,default` order.
|
||||||
|
|
||||||
### 4.8 Directory permissions
|
### 4.8 Directory permissions
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -14,13 +14,20 @@
|
|||||||
"Sending…": "Bezig met verzenden…",
|
"Sending…": "Bezig met verzenden…",
|
||||||
"Something went wrong. Please try again.": "Er ging iets mis. Probeer het opnieuw.",
|
"Something went wrong. Please try again.": "Er ging iets mis. Probeer het opnieuw.",
|
||||||
"This pre-registration period has ended.": "Deze preregistratieperiode is afgelopen.",
|
"This pre-registration period has ended.": "Deze preregistratieperiode is afgelopen.",
|
||||||
|
"You will be redirected in :seconds s…": "Je wordt over :seconds seconden doorgestuurd…",
|
||||||
"Visit ticket shop": "Ga naar de ticketshop",
|
"Visit ticket shop": "Ga naar de ticketshop",
|
||||||
"Thank you for registering!": "Bedankt voor je registratie!",
|
"Thank you for registering!": "Bedankt voor je registratie!",
|
||||||
"You are already registered for this event.": "Je bent al geregistreerd voor dit evenement.",
|
"You are already registered for this event.": "Je bent al geregistreerd voor dit evenement.",
|
||||||
"Please enter a valid email address.": "Voer een geldig e-mailadres in.",
|
"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 (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.",
|
"Subscriber removed.": "Abonnee verwijderd.",
|
||||||
"Delete this subscriber? This cannot be undone.": "Deze abonnee verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
"Delete this subscriber? This cannot be undone.": "Deze abonnee verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||||
"Remove": "Verwijderen",
|
"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,
|
phase: config.phase,
|
||||||
startAtMs: config.startAtMs,
|
startAtMs: config.startAtMs,
|
||||||
phoneEnabled: config.phoneEnabled,
|
phoneEnabled: config.phoneEnabled,
|
||||||
|
phoneRequired: config.phoneRequired === true,
|
||||||
subscribeUrl: config.subscribeUrl,
|
subscribeUrl: config.subscribeUrl,
|
||||||
csrfToken: config.csrfToken,
|
csrfToken: config.csrfToken,
|
||||||
genericError: config.genericError,
|
genericError: config.genericError,
|
||||||
@@ -499,10 +500,16 @@ document.addEventListener('alpine:init', () => {
|
|||||||
ok = false;
|
ok = false;
|
||||||
}
|
}
|
||||||
if (this.phoneEnabled) {
|
if (this.phoneEnabled) {
|
||||||
const digits = String(this.phone).replace(/\D/g, '');
|
const trimmed = String(this.phone).trim();
|
||||||
if (digits.length > 0 && (digits.length < 8 || digits.length > 15)) {
|
if (this.phoneRequired && trimmed === '') {
|
||||||
this.fieldErrors.phone = [this.invalidPhoneMsg];
|
this.fieldErrors.phone = [this.invalidPhoneMsg];
|
||||||
ok = false;
|
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;
|
return ok;
|
||||||
@@ -548,12 +555,21 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.startRedirectCountdownIfNeeded();
|
this.startRedirectCountdownIfNeeded();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof data.message === 'string' && data.message !== '') {
|
const hasServerMessage = typeof data.message === 'string' && data.message !== '';
|
||||||
|
const hasFieldErrors =
|
||||||
|
data.errors !== undefined &&
|
||||||
|
data.errors !== null &&
|
||||||
|
typeof data.errors === 'object' &&
|
||||||
|
Object.keys(data.errors).length > 0;
|
||||||
|
if (hasServerMessage) {
|
||||||
this.formError = data.message;
|
this.formError = data.message;
|
||||||
}
|
}
|
||||||
if (data.errors !== undefined && data.errors !== null && typeof data.errors === 'object') {
|
if (hasFieldErrors) {
|
||||||
this.fieldErrors = data.errors;
|
this.fieldErrors = data.errors;
|
||||||
}
|
}
|
||||||
|
if (!res.ok && !hasServerMessage && !hasFieldErrors) {
|
||||||
|
this.formError = this.genericError;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
this.formError = this.genericError;
|
this.formError = this.genericError;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -567,6 +583,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
fieldsUrl: cfg.fieldsUrl,
|
fieldsUrl: cfg.fieldsUrl,
|
||||||
phoneEnabled: cfg.phoneEnabled,
|
phoneEnabled: cfg.phoneEnabled,
|
||||||
hasExistingConfig: cfg.hasExistingConfig,
|
hasExistingConfig: cfg.hasExistingConfig,
|
||||||
|
hasWeeztixIntegration: cfg.hasWeeztixIntegration === true,
|
||||||
existing: cfg.existing,
|
existing: cfg.existing,
|
||||||
csrf: cfg.csrf,
|
csrf: cfg.csrf,
|
||||||
step: 1,
|
step: 1,
|
||||||
@@ -579,6 +596,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
fieldFirstName: '',
|
fieldFirstName: '',
|
||||||
fieldLastName: '',
|
fieldLastName: '',
|
||||||
fieldPhone: '',
|
fieldPhone: '',
|
||||||
|
fieldCouponCode: '',
|
||||||
tagField: '',
|
tagField: '',
|
||||||
tagValue: '',
|
tagValue: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -590,6 +608,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.fieldFirstName = this.existing.field_first_name ?? '';
|
this.fieldFirstName = this.existing.field_first_name ?? '';
|
||||||
this.fieldLastName = this.existing.field_last_name ?? '';
|
this.fieldLastName = this.existing.field_last_name ?? '';
|
||||||
this.fieldPhone = this.existing.field_phone ?? '';
|
this.fieldPhone = this.existing.field_phone ?? '';
|
||||||
|
this.fieldCouponCode = this.existing.field_coupon_code ?? '';
|
||||||
this.tagField = this.existing.tag_field ?? '';
|
this.tagField = this.existing.tag_field ?? '';
|
||||||
this.tagValue = this.existing.tag_value ?? '';
|
this.tagValue = this.existing.tag_value ?? '';
|
||||||
this.selectedListUid = this.existing.list_uid ?? '';
|
this.selectedListUid = this.existing.list_uid ?? '';
|
||||||
@@ -699,6 +718,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.fieldFirstName = this.existing.field_first_name || this.fieldFirstName;
|
this.fieldFirstName = this.existing.field_first_name || this.fieldFirstName;
|
||||||
this.fieldLastName = this.existing.field_last_name || this.fieldLastName;
|
this.fieldLastName = this.existing.field_last_name || this.fieldLastName;
|
||||||
this.fieldPhone = this.existing.field_phone || this.fieldPhone;
|
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.tagField = this.existing.tag_field || this.tagField;
|
||||||
this.tagValue = this.existing.tag_value || this.tagValue;
|
this.tagValue = this.existing.tag_value || this.tagValue;
|
||||||
}
|
}
|
||||||
@@ -739,6 +759,107 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.$refs.saveForm.requestSubmit();
|
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;
|
window.Alpine = Alpine;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
@php
|
@php
|
||||||
$config = $page->mailwizzConfig;
|
$config = $page->mailwizzConfig;
|
||||||
|
$page->loadMissing('weeztixConfig');
|
||||||
|
$hasWeeztixForCouponMap = $page->weeztixConfig !== null && $page->weeztixConfig->is_connected;
|
||||||
|
$mailwizzStatus = $page->mailwizzIntegrationStatus();
|
||||||
$existing = $config !== null
|
$existing = $config !== null
|
||||||
? [
|
? [
|
||||||
'list_uid' => $config->list_uid,
|
'list_uid' => $config->list_uid,
|
||||||
@@ -8,6 +11,7 @@
|
|||||||
'field_first_name' => $config->field_first_name,
|
'field_first_name' => $config->field_first_name,
|
||||||
'field_last_name' => $config->field_last_name,
|
'field_last_name' => $config->field_last_name,
|
||||||
'field_phone' => $config->field_phone,
|
'field_phone' => $config->field_phone,
|
||||||
|
'field_coupon_code' => $config->field_coupon_code,
|
||||||
'tag_field' => $config->tag_field,
|
'tag_field' => $config->tag_field,
|
||||||
'tag_value' => $config->tag_value,
|
'tag_value' => $config->tag_value,
|
||||||
]
|
]
|
||||||
@@ -21,29 +25,15 @@
|
|||||||
@section('mobile_title', __('Mailwizz'))
|
@section('mobile_title', __('Mailwizz'))
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="mx-auto max-w-3xl" x-data="mailwizzWizard(@js([
|
<div class="mx-auto max-w-3xl">
|
||||||
'listsUrl' => route('admin.mailwizz.lists'),
|
|
||||||
'fieldsUrl' => route('admin.mailwizz.fields'),
|
|
||||||
'csrf' => csrf_token(),
|
|
||||||
'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(),
|
|
||||||
'hasExistingConfig' => $config !== null,
|
|
||||||
'existing' => $existing,
|
|
||||||
'strings' => [
|
|
||||||
'apiKeyRequired' => __('Enter your Mailwizz API key to continue.'),
|
|
||||||
'genericError' => __('Something went wrong. Please try again.'),
|
|
||||||
'noListsError' => __('No mailing lists were returned. Check your API key or create a list in Mailwizz.'),
|
|
||||||
'selectListError' => __('Select a mailing list.'),
|
|
||||||
'mapFieldsError' => __('Map email, first name, and last name to Mailwizz fields.'),
|
|
||||||
'tagFieldError' => __('Select a checkbox list field for source / tag tracking.'),
|
|
||||||
'tagValueError' => __('Select the tag option that identifies this pre-registration.'),
|
|
||||||
],
|
|
||||||
]))">
|
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<a href="{{ route('admin.pages.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">← {{ __('Back to page') }}</a>
|
<a href="{{ route('admin.pages.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">← {{ __('Back to page') }}</a>
|
||||||
<h1 class="mt-4 text-2xl font-semibold text-slate-900">{{ __('Mailwizz') }}</h1>
|
<h1 class="mt-4 text-2xl font-semibold text-slate-900">{{ __('Mailwizz') }}</h1>
|
||||||
<p class="mt-2 text-sm text-slate-600">{{ __('Page:') }} <span class="font-medium text-slate-800">{{ $page->title }}</span></p>
|
<p class="mt-2 text-sm text-slate-600">{{ __('Page:') }} <span class="font-medium text-slate-800">{{ $page->title }}</span></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@include('admin.pages._save_flash')
|
||||||
|
|
||||||
@if ($errors->any())
|
@if ($errors->any())
|
||||||
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900" role="alert">
|
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900" role="alert">
|
||||||
<p class="font-medium">{{ __('Please fix the following:') }}</p>
|
<p class="font-medium">{{ __('Please fix the following:') }}</p>
|
||||||
@@ -55,32 +45,154 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($config !== null)
|
@if (! $showWizard && $config !== null)
|
||||||
<div class="mb-8 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
|
@if ($mailwizzStatus !== 'ready')
|
||||||
<p class="font-medium">{{ __('Integration active') }}</p>
|
<div class="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||||
<p class="mt-1 text-emerald-800">
|
<p class="font-medium">{{ __('Setup incomplete') }}</p>
|
||||||
{{ __('List:') }}
|
<p class="mt-1 text-amber-900">{{ __('Run the wizard again to finish Mailwizz (API key, list, and field mapping).') }}</p>
|
||||||
<span class="font-mono text-xs">{{ $config->list_name ?: $config->list_uid }}</span>
|
|
||||||
</p>
|
|
||||||
<form action="{{ route('admin.pages.mailwizz.destroy', $page) }}" method="post" class="mt-3"
|
|
||||||
onsubmit="return confirm(@js(__('Remove Mailwizz integration for this page? Subscribers will stay in the database but will no longer sync.')));">
|
|
||||||
@csrf
|
|
||||||
@method('DELETE')
|
|
||||||
<button type="submit" class="text-sm font-semibold text-red-700 underline hover:text-red-800">
|
|
||||||
{{ __('Remove integration') }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="mb-6 flex flex-wrap gap-2 text-xs font-medium text-slate-500">
|
<div class="mb-6 flex flex-wrap items-center gap-3">
|
||||||
<span :class="step >= 1 ? 'text-indigo-600' : ''">1. {{ __('API key') }}</span>
|
<a
|
||||||
<span aria-hidden="true">→</span>
|
href="{{ route('admin.pages.mailwizz.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]) }}"
|
||||||
<span :class="step >= 2 ? 'text-indigo-600' : ''">2. {{ __('List') }}</span>
|
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"
|
||||||
<span aria-hidden="true">→</span>
|
>
|
||||||
<span :class="step >= 3 ? 'text-indigo-600' : ''">3. {{ __('Field mapping') }}</span>
|
{{ __('Change settings (wizard)') }}
|
||||||
<span aria-hidden="true">→</span>
|
</a>
|
||||||
<span :class="step >= 4 ? 'text-indigo-600' : ''">4. {{ __('Tag / source') }}</span>
|
<form action="{{ route('admin.pages.mailwizz.destroy', $page) }}" method="post" class="inline"
|
||||||
|
onsubmit="return confirm(@js(__('Remove Mailwizz integration for this page? Subscribers will stay in the database but will no longer sync.')));">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="rounded-lg border border-red-200 bg-white px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm hover:bg-red-50">
|
||||||
|
{{ __('Disconnect Mailwizz') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900">{{ __('Current configuration') }}</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-600">{{ __('The API key is stored encrypted and is not shown here.') }}</p>
|
||||||
|
|
||||||
|
<dl class="mt-6 space-y-4 border-t border-slate-100 pt-6 text-sm">
|
||||||
|
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||||
|
<dt class="font-medium text-slate-700">{{ __('Connection') }}</dt>
|
||||||
|
<dd>
|
||||||
|
@if ($mailwizzStatus === 'ready')
|
||||||
|
<span class="inline-flex rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">{{ __('Ready to sync') }}</span>
|
||||||
|
@else
|
||||||
|
<span class="inline-flex rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-medium text-amber-900">{{ __('Incomplete') }}</span>
|
||||||
|
@endif
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||||
|
<dt class="font-medium text-slate-700">{{ __('Mailing list') }}</dt>
|
||||||
|
<dd class="text-slate-800">{{ $config->list_name ?: '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||||
|
<dt class="font-medium text-slate-700">{{ __('List UID') }}</dt>
|
||||||
|
<dd class="break-all font-mono text-xs text-slate-600">{{ $config->list_uid ?: '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h3 class="mt-8 border-t border-slate-100 pt-6 text-sm font-semibold text-slate-900">{{ __('Field mapping') }}</h3>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">{{ __('Mailwizz custom fields are matched by tag.') }}</p>
|
||||||
|
<dl class="mt-4 space-y-4 text-sm">
|
||||||
|
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||||
|
<dt class="font-medium text-slate-700">{{ __('Email') }}</dt>
|
||||||
|
<dd class="font-mono text-xs text-slate-800">{{ $config->field_email ?: '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||||
|
<dt class="font-medium text-slate-700">{{ __('First name') }}</dt>
|
||||||
|
<dd class="font-mono text-xs text-slate-800">{{ $config->field_first_name ?: '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||||
|
<dt class="font-medium text-slate-700">{{ __('Last name') }}</dt>
|
||||||
|
<dd class="font-mono text-xs text-slate-800">{{ $config->field_last_name ?: '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
@if ($page->isPhoneFieldEnabledForSubscribers())
|
||||||
|
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||||
|
<dt class="font-medium text-slate-700">{{ __('Phone') }}</dt>
|
||||||
|
<dd class="font-mono text-xs text-slate-800">{{ filled($config->field_phone) ? $config->field_phone : '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if ($hasWeeztixForCouponMap)
|
||||||
|
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||||
|
<dt class="font-medium text-slate-700">{{ __('Kortingscode (Weeztix)') }}</dt>
|
||||||
|
<dd class="font-mono text-xs text-slate-800">{{ filled($config->field_coupon_code) ? $config->field_coupon_code : '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||||
|
<dt class="font-medium text-slate-700">{{ __('Tag / source (checkbox list)') }}</dt>
|
||||||
|
<dd class="font-mono text-xs text-slate-800">{{ filled($config->tag_field) ? $config->tag_field : '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||||
|
<dt class="font-medium text-slate-700">{{ __('Source tag option') }}</dt>
|
||||||
|
<dd class="font-mono text-xs text-slate-800">{{ filled($config->tag_value) ? $config->tag_value : '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div
|
||||||
|
x-data="mailwizzWizard(@js([
|
||||||
|
'listsUrl' => route('admin.mailwizz.lists'),
|
||||||
|
'fieldsUrl' => route('admin.mailwizz.fields'),
|
||||||
|
'csrf' => csrf_token(),
|
||||||
|
'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)
|
||||||
|
<div class="mb-6 flex flex-wrap items-center gap-3">
|
||||||
|
<a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-sm font-medium text-slate-600 hover:text-slate-900">
|
||||||
|
{{ __('Cancel and return to overview') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="mb-8 flex flex-wrap items-center gap-2" aria-label="{{ __('Wizard steps') }}">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium"
|
||||||
|
:class="step === 1 ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : (step > 1 ? 'border-emerald-200 bg-emerald-50 text-emerald-900' : 'border-slate-200 bg-slate-50 text-slate-500')"
|
||||||
|
>
|
||||||
|
<span class="tabular-nums">1</span>
|
||||||
|
{{ __('API key') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-slate-300" aria-hidden="true">→</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium"
|
||||||
|
:class="step === 2 ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : (step > 2 ? 'border-emerald-200 bg-emerald-50 text-emerald-900' : 'border-slate-200 bg-slate-50 text-slate-500')"
|
||||||
|
>
|
||||||
|
<span class="tabular-nums">2</span>
|
||||||
|
{{ __('List') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-slate-300" aria-hidden="true">→</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium"
|
||||||
|
:class="step === 3 ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : (step > 3 ? 'border-emerald-200 bg-emerald-50 text-emerald-900' : 'border-slate-200 bg-slate-50 text-slate-500')"
|
||||||
|
>
|
||||||
|
<span class="tabular-nums">3</span>
|
||||||
|
{{ __('Field mapping') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-slate-300" aria-hidden="true">→</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium"
|
||||||
|
:class="step === 4 ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : 'border-slate-200 bg-slate-50 text-slate-500'"
|
||||||
|
>
|
||||||
|
<span class="tabular-nums">4</span>
|
||||||
|
{{ __('Tag / source') }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
|
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
|
||||||
@@ -88,6 +200,7 @@
|
|||||||
|
|
||||||
{{-- Step 1 --}}
|
{{-- Step 1 --}}
|
||||||
<div x-show="step === 1" x-cloak class="space-y-4">
|
<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">
|
<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.') }}
|
{{ __('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>
|
</p>
|
||||||
@@ -120,6 +233,7 @@
|
|||||||
|
|
||||||
{{-- Step 2 --}}
|
{{-- Step 2 --}}
|
||||||
<div x-show="step === 2" x-cloak class="space-y-4">
|
<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>
|
<div>
|
||||||
<label for="mailwizz_list" class="block text-sm font-medium text-slate-700">{{ __('Mailing list') }}</label>
|
<label for="mailwizz_list" class="block text-sm font-medium text-slate-700">{{ __('Mailing list') }}</label>
|
||||||
<select
|
<select
|
||||||
@@ -152,6 +266,7 @@
|
|||||||
|
|
||||||
{{-- Step 3 --}}
|
{{-- Step 3 --}}
|
||||||
<div x-show="step === 3" x-cloak class="space-y-5">
|
<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>
|
<p class="text-sm text-slate-600">{{ __('Map each local field to the matching Mailwizz custom field (by tag).') }}</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -190,6 +305,16 @@
|
|||||||
</template>
|
</template>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700">{{ __('Tag / source (checkbox list)') }}</label>
|
<label class="block text-sm font-medium text-slate-700">{{ __('Tag / source (checkbox list)') }}</label>
|
||||||
<select
|
<select
|
||||||
@@ -219,6 +344,7 @@
|
|||||||
|
|
||||||
{{-- Step 4 --}}
|
{{-- Step 4 --}}
|
||||||
<div x-show="step === 4" x-cloak class="space-y-5">
|
<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>
|
<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">
|
<fieldset class="space-y-2">
|
||||||
@@ -244,6 +370,7 @@
|
|||||||
<input type="hidden" name="field_first_name" x-bind:value="fieldFirstName">
|
<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_last_name" x-bind:value="fieldLastName">
|
||||||
<input type="hidden" name="field_phone" x-bind:value="phoneEnabled ? fieldPhone : ''">
|
<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_field" x-bind:value="tagField">
|
||||||
<input type="hidden" name="tag_value" x-bind:value="tagValue">
|
<input type="hidden" name="tag_value" x-bind:value="tagValue">
|
||||||
|
|
||||||
@@ -259,4 +386,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -98,6 +98,19 @@
|
|||||||
@enderror
|
@enderror
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div class="grid gap-6 sm:grid-cols-2">
|
<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>
|
{{ __('Public URL') }}: <a href="{{ route('public.page', ['publicPage' => $page]) }}" class="text-indigo-600 hover:underline" target="_blank" rel="noopener">{{ url('/r/'.$page->slug) }}</a>
|
||||||
</p>
|
</p>
|
||||||
@can('update', $page)
|
@can('update', $page)
|
||||||
<p class="mt-3">
|
<div class="mt-6 grid gap-4 sm:grid-cols-2">
|
||||||
<a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">{{ __('Mailwizz integration') }} →</a>
|
<a
|
||||||
</p>
|
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
|
@endcan
|
||||||
</div>
|
</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">{{ __('Start') }}</th>
|
||||||
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('End') }}</th>
|
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('End') }}</th>
|
||||||
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('Subscribers') }}</th>
|
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('Subscribers') }}</th>
|
||||||
|
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('Integrations') }}</th>
|
||||||
<th scope="col" class="px-4 py-3 text-right font-semibold text-slate-700">{{ __('Actions') }}</th>
|
<th scope="col" class="px-4 py-3 text-right font-semibold text-slate-700">{{ __('Actions') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -64,6 +65,9 @@
|
|||||||
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $page->start_date->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td>
|
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $page->start_date->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td>
|
||||||
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $page->end_date->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td>
|
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $page->end_date->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td>
|
||||||
<td class="whitespace-nowrap px-4 py-3 tabular-nums text-slate-600">{{ number_format($page->subscribers_count) }}</td>
|
<td class="whitespace-nowrap px-4 py-3 tabular-nums text-slate-600">{{ number_format($page->subscribers_count) }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
@include('admin.pages._integration_badges', ['page' => $page])
|
||||||
|
</td>
|
||||||
<td class="whitespace-nowrap px-4 py-3 text-right">
|
<td class="whitespace-nowrap px-4 py-3 text-right">
|
||||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||||
@can('update', $page)
|
@can('update', $page)
|
||||||
@@ -75,6 +79,9 @@
|
|||||||
@can('update', $page)
|
@can('update', $page)
|
||||||
<a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-indigo-600 hover:text-indigo-500">{{ __('Mailwizz') }}</a>
|
<a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-indigo-600 hover:text-indigo-500">{{ __('Mailwizz') }}</a>
|
||||||
@endcan
|
@endcan
|
||||||
|
@can('update', $page)
|
||||||
|
<a href="{{ route('admin.pages.weeztix.edit', $page) }}" class="text-indigo-600 hover:text-indigo-500">{{ __('Weeztix') }}</a>
|
||||||
|
@endcan
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
x-data="{ copied: false }"
|
x-data="{ copied: false }"
|
||||||
@@ -97,7 +104,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="{{ auth()->user()->isSuperadmin() ? 7 : 6 }}" class="px-4 py-12 text-center text-slate-500">
|
<td colspan="{{ auth()->user()->isSuperadmin() ? 8 : 7 }}" class="px-4 py-12 text-center text-slate-500">
|
||||||
{{ __('No pages yet.') }}
|
{{ __('No pages yet.') }}
|
||||||
@can('create', \App\Models\PreregistrationPage::class)
|
@can('create', \App\Models\PreregistrationPage::class)
|
||||||
<a href="{{ route('admin.pages.create') }}" class="font-medium text-indigo-600 hover:text-indigo-500">{{ __('Create one') }}</a>
|
<a href="{{ route('admin.pages.create') }}" class="font-medium text-indigo-600 hover:text-indigo-500">{{ __('Create one') }}</a>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
@if ($page->isPhoneFieldEnabledForSubscribers())
|
@if ($page->isPhoneFieldEnabledForSubscribers())
|
||||||
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Phone') }}</th>
|
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Phone') }}</th>
|
||||||
@endif
|
@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">{{ __('Registered at') }}</th>
|
||||||
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Mailwizz') }}</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>
|
<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-900">{{ $subscriber->last_name }}</td>
|
||||||
<td class="px-4 py-3 text-slate-600">{{ $subscriber->email }}</td>
|
<td class="px-4 py-3 text-slate-600">{{ $subscriber->email }}</td>
|
||||||
@if ($page->isPhoneFieldEnabledForSubscribers())
|
@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
|
@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="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">
|
<td class="px-4 py-3">
|
||||||
@if ($subscriber->synced_to_mailwizz)
|
@if ($subscriber->synced_to_mailwizz)
|
||||||
@@ -80,6 +82,23 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-4 py-3 text-right">
|
<td class="whitespace-nowrap px-4 py-3 text-right">
|
||||||
@can('update', $page)
|
@can('update', $page)
|
||||||
|
<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
|
<form
|
||||||
method="post"
|
method="post"
|
||||||
action="{{ route('admin.pages.subscribers.destroy', [$page, $subscriber]) }}"
|
action="{{ route('admin.pages.subscribers.destroy', [$page, $subscriber]) }}"
|
||||||
@@ -95,12 +114,13 @@
|
|||||||
{{ __('Remove') }}
|
{{ __('Remove') }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
@endcan
|
@endcan
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr>
|
<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.') }}
|
{{ __('No subscribers match your criteria.') }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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');
|
$eyebrow = data_get($c, 'eyebrow_text');
|
||||||
$eyebrowStyle = (string) data_get($c, 'eyebrow_style', 'badge');
|
$eyebrowStyle = (string) data_get($c, 'eyebrow_style', 'badge');
|
||||||
|
$subheadlineRaw = data_get($c, 'subheadline');
|
||||||
|
$subheadline = is_string($subheadlineRaw) ? trim($subheadlineRaw) : '';
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="flex w-full flex-col {{ $alignClass }} space-y-4">
|
<div class="flex w-full flex-col {{ $alignClass }} space-y-4">
|
||||||
@@ -35,9 +37,8 @@
|
|||||||
</h1>
|
</h1>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($pageState !== 'expired' && filled(data_get($c, 'subheadline')))
|
{{-- Subheadline must sit on one line inside the div: whitespace-pre-line turns Blade indentation into visible leading space. --}}
|
||||||
<div class="w-full max-w-none whitespace-pre-line text-[15px] leading-[1.65] text-white sm:text-base sm:leading-relaxed">
|
@if ($pageState !== 'expired' && $subheadline !== '')
|
||||||
{{ trim((string) 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">{{ $subheadline }}</div>
|
||||||
</div>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
$formButtonLabel = (string) (data_get($formContent, 'button_label') ?: __('public.register_button'));
|
$formButtonLabel = (string) (data_get($formContent, 'button_label') ?: __('public.register_button'));
|
||||||
$formButtonColor = (string) data_get($formContent, 'button_color', '#F47B20');
|
$formButtonColor = (string) data_get($formContent, 'button_color', '#F47B20');
|
||||||
$formButtonTextColor = (string) data_get($formContent, 'button_text_color', '#FFFFFF');
|
$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
|
@endphp
|
||||||
|
|
||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
@@ -28,19 +31,19 @@
|
|||||||
<div class="relative min-h-screen w-full overflow-x-hidden">
|
<div class="relative min-h-screen w-full overflow-x-hidden">
|
||||||
@if ($bgUrl !== null)
|
@if ($bgUrl !== null)
|
||||||
<div
|
<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) }}')"
|
style="background-image: url('{{ e($bgUrl) }}')"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></div>
|
></div>
|
||||||
@else
|
@else
|
||||||
<div
|
<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"
|
aria-hidden="true"
|
||||||
></div>
|
></div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0"
|
class="{{ $overlayPosition }}"
|
||||||
style="background-color: {{ e($overlayColor) }}; opacity: {{ $overlayOpacity }}"
|
style="background-color: {{ e($overlayColor) }}; opacity: {{ $overlayOpacity }}"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></div>
|
></div>
|
||||||
@@ -53,13 +56,14 @@
|
|||||||
'phase' => $alpinePhase,
|
'phase' => $alpinePhase,
|
||||||
'startAtMs' => $page->start_date->getTimestamp() * 1000,
|
'startAtMs' => $page->start_date->getTimestamp() * 1000,
|
||||||
'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(),
|
'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(),
|
||||||
|
'phoneRequired' => $page->isPhoneFieldEnabledForSubscribers() && $page->isPhoneFieldRequiredForSubscribers(),
|
||||||
'subscribeUrl' => route('public.subscribe', ['publicPage' => $page]),
|
'subscribeUrl' => route('public.subscribe', ['publicPage' => $page]),
|
||||||
'csrfToken' => csrf_token(),
|
'csrfToken' => csrf_token(),
|
||||||
'genericError' => __('Something went wrong. Please try again.'),
|
'genericError' => __('Something went wrong. Please try again.'),
|
||||||
'labelDay' => __('day'),
|
'labelDay' => __('day'),
|
||||||
'labelDays' => __('days'),
|
'labelDays' => __('days'),
|
||||||
'invalidEmailMsg' => __('Please enter a valid email address.'),
|
'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,
|
'formButtonLabel' => $formButtonLabel,
|
||||||
'formButtonColor' => $formButtonColor,
|
'formButtonColor' => $formButtonColor,
|
||||||
'formButtonTextColor' => $formButtonTextColor,
|
'formButtonTextColor' => $formButtonTextColor,
|
||||||
@@ -85,9 +89,8 @@
|
|||||||
@if ($pageState === 'expired')
|
@if ($pageState === 'expired')
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
@if (filled($page->expired_message))
|
@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">
|
{{-- Same as text/hero blocks: no line breaks inside whitespace-pre-line wrapper. --}}
|
||||||
{{ $page->expired_message }}
|
<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>
|
||||||
</div>
|
|
||||||
@else
|
@else
|
||||||
<p class="text-center text-[15px] leading-relaxed text-white/92 sm:text-base">{{ __('This pre-registration period has ended.') }}</p>
|
<p class="text-center text-[15px] leading-relaxed text-white/92 sm:text-base">{{ __('This pre-registration period has ended.') }}</p>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ use App\Http\Controllers\Admin\MailwizzController;
|
|||||||
use App\Http\Controllers\Admin\PageController;
|
use App\Http\Controllers\Admin\PageController;
|
||||||
use App\Http\Controllers\Admin\SubscriberController;
|
use App\Http\Controllers\Admin\SubscriberController;
|
||||||
use App\Http\Controllers\Admin\UserController;
|
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\ProfileController;
|
||||||
use App\Http\Controllers\PublicPageController;
|
use App\Http\Controllers\PublicPageController;
|
||||||
use Illuminate\Support\Facades\Route;
|
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::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::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/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');
|
Route::get('pages/{page}/subscribers', [SubscriberController::class, 'index'])->name('pages.subscribers.index');
|
||||||
|
|
||||||
// Mailwizz configuration (nested under pages)
|
// 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/lists', [MailwizzApiController::class, 'lists'])->name('mailwizz.lists');
|
||||||
Route::post('mailwizz/fields', [MailwizzApiController::class, 'fields'])->name('mailwizz.fields');
|
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)
|
// User management (superadmin only)
|
||||||
Route::middleware('role:superadmin')->group(function () {
|
Route::middleware('role:superadmin')->group(function () {
|
||||||
Route::resource('users', UserController::class)->except(['show']);
|
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;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\MailwizzConfig;
|
||||||
use App\Models\PreregistrationPage;
|
use App\Models\PreregistrationPage;
|
||||||
use App\Models\Subscriber;
|
use App\Models\Subscriber;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\WeeztixConfig;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\Client\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -128,4 +132,202 @@ class DestroySubscriberTest extends TestCase
|
|||||||
$response->assertForbidden();
|
$response->assertForbidden();
|
||||||
$this->assertDatabaseHas('subscribers', ['id' => $subscriber->id]);
|
$this->assertDatabaseHas('subscribers', ['id' => $subscriber->id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_delete_strips_mailwizz_source_tag_and_clears_coupon_field(): void
|
||||||
|
{
|
||||||
|
Http::fake(function (Request $request) {
|
||||||
|
$url = $request->url();
|
||||||
|
if (str_contains($url, 'search-by-email')) {
|
||||||
|
return Http::response(['status' => 'success', 'data' => ['subscriber_uid' => 'sub-to-clean']]);
|
||||||
|
}
|
||||||
|
if ($request->method() === 'GET' && str_contains($url, '/subscribers/sub-to-clean') && ! str_contains($url, 'search-by-email')) {
|
||||||
|
return Http::response([
|
||||||
|
'status' => 'success',
|
||||||
|
'data' => [
|
||||||
|
'record' => [
|
||||||
|
'TAGS' => 'preregister-source,other-tag',
|
||||||
|
'COUPON' => 'PREREG-OLD',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if ($request->method() === 'PUT' && str_contains($url, '/subscribers/sub-to-clean')) {
|
||||||
|
$body = $request->body();
|
||||||
|
$this->assertStringContainsString('other-tag', $body);
|
||||||
|
$this->assertStringNotContainsString('preregister-source', $body);
|
||||||
|
$this->assertStringContainsString('COUPON', $body);
|
||||||
|
|
||||||
|
return Http::response(['status' => 'success']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Http::response(['status' => 'error'], 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
$user = User::factory()->create(['role' => 'user']);
|
||||||
|
$page = $this->makePageForDestroyTest($user);
|
||||||
|
MailwizzConfig::query()->create([
|
||||||
|
'preregistration_page_id' => $page->id,
|
||||||
|
'api_key' => 'fake-api-key',
|
||||||
|
'list_uid' => 'list-uid-1',
|
||||||
|
'list_name' => 'Main list',
|
||||||
|
'field_email' => 'EMAIL',
|
||||||
|
'field_first_name' => 'FNAME',
|
||||||
|
'field_last_name' => 'LNAME',
|
||||||
|
'field_phone' => null,
|
||||||
|
'field_coupon_code' => 'COUPON',
|
||||||
|
'tag_field' => 'TAGS',
|
||||||
|
'tag_value' => 'preregister-source',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$subscriber = Subscriber::query()->create([
|
||||||
|
'preregistration_page_id' => $page->id,
|
||||||
|
'first_name' => 'Clean',
|
||||||
|
'last_name' => 'Up',
|
||||||
|
'email' => 'cleanup@example.com',
|
||||||
|
'coupon_code' => 'PREREG-LOCAL',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->delete(route('admin.pages.subscribers.destroy', [$page, $subscriber]));
|
||||||
|
|
||||||
|
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
|
||||||
|
$this->assertDatabaseMissing('subscribers', ['id' => $subscriber->id]);
|
||||||
|
Http::assertSentCount(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_clears_mailwizz_checkboxlist_when_only_configured_tag_is_present(): void
|
||||||
|
{
|
||||||
|
Http::fake(function (Request $request) {
|
||||||
|
$url = $request->url();
|
||||||
|
if (str_contains($url, 'search-by-email')) {
|
||||||
|
return Http::response(['status' => 'success', 'data' => ['subscriber_uid' => 'sub-single-tag']]);
|
||||||
|
}
|
||||||
|
if ($request->method() === 'GET' && str_contains($url, '/subscribers/sub-single-tag') && ! str_contains($url, 'search-by-email')) {
|
||||||
|
return Http::response([
|
||||||
|
'status' => 'success',
|
||||||
|
'data' => [
|
||||||
|
'record' => [
|
||||||
|
'TAGS' => 'preregister-source',
|
||||||
|
'COUPON' => 'PREREG-OLD',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if ($request->method() === 'PUT' && str_contains($url, '/subscribers/sub-single-tag')) {
|
||||||
|
$body = $request->body();
|
||||||
|
$this->assertStringContainsString('TAGS', $body);
|
||||||
|
$this->assertStringNotContainsString('preregister-source', $body);
|
||||||
|
$this->assertStringNotContainsString('TAGS[]', $body);
|
||||||
|
$this->assertStringContainsString('COUPON', $body);
|
||||||
|
|
||||||
|
return Http::response(['status' => 'success']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Http::response(['status' => 'error'], 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
$user = User::factory()->create(['role' => 'user']);
|
||||||
|
$page = $this->makePageForDestroyTest($user);
|
||||||
|
MailwizzConfig::query()->create([
|
||||||
|
'preregistration_page_id' => $page->id,
|
||||||
|
'api_key' => 'fake-api-key',
|
||||||
|
'list_uid' => 'list-uid-1',
|
||||||
|
'list_name' => 'Main list',
|
||||||
|
'field_email' => 'EMAIL',
|
||||||
|
'field_first_name' => 'FNAME',
|
||||||
|
'field_last_name' => 'LNAME',
|
||||||
|
'field_phone' => null,
|
||||||
|
'field_coupon_code' => 'COUPON',
|
||||||
|
'tag_field' => 'TAGS',
|
||||||
|
'tag_value' => 'preregister-source',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$subscriber = Subscriber::query()->create([
|
||||||
|
'preregistration_page_id' => $page->id,
|
||||||
|
'first_name' => 'Solo',
|
||||||
|
'last_name' => 'Tag',
|
||||||
|
'email' => 'solo-tag@example.com',
|
||||||
|
'coupon_code' => 'PREREG-LOCAL',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->delete(route('admin.pages.subscribers.destroy', [$page, $subscriber]));
|
||||||
|
|
||||||
|
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
|
||||||
|
$this->assertDatabaseMissing('subscribers', ['id' => $subscriber->id]);
|
||||||
|
Http::assertSentCount(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_removes_coupon_code_in_weeztix_when_configured(): void
|
||||||
|
{
|
||||||
|
Http::fake(function (Request $request) {
|
||||||
|
$url = $request->url();
|
||||||
|
if ($request->method() === 'GET' && preg_match('#/coupon/coupon-guid-test/codes$#', $url) === 1) {
|
||||||
|
return Http::response([
|
||||||
|
'data' => [
|
||||||
|
['guid' => 'wzx-code-guid', 'code' => 'PREREG-DEL99'],
|
||||||
|
],
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
if ($request->method() === 'DELETE' && str_contains($url, '/coupon/coupon-guid-test/codes/wzx-code-guid')) {
|
||||||
|
return Http::response(null, 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Http::response(['status' => 'error'], 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
$user = User::factory()->create(['role' => 'user']);
|
||||||
|
$page = $this->makePageForDestroyTest($user);
|
||||||
|
WeeztixConfig::query()->create([
|
||||||
|
'preregistration_page_id' => $page->id,
|
||||||
|
'client_id' => 'client-id',
|
||||||
|
'client_secret' => 'client-secret',
|
||||||
|
'redirect_uri' => 'https://app.test/callback',
|
||||||
|
'access_token' => 'access-token',
|
||||||
|
'refresh_token' => 'refresh-token',
|
||||||
|
'token_expires_at' => now()->addHour(),
|
||||||
|
'refresh_token_expires_at' => now()->addMonth(),
|
||||||
|
'company_guid' => 'company-guid-test',
|
||||||
|
'company_name' => 'Test Co',
|
||||||
|
'coupon_guid' => 'coupon-guid-test',
|
||||||
|
'coupon_name' => 'PreReg',
|
||||||
|
'is_connected' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$subscriber = Subscriber::query()->create([
|
||||||
|
'preregistration_page_id' => $page->id,
|
||||||
|
'first_name' => 'Weez',
|
||||||
|
'last_name' => 'Tix',
|
||||||
|
'email' => 'weez@example.com',
|
||||||
|
'coupon_code' => 'PREREG-DEL99',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->delete(route('admin.pages.subscribers.destroy', [$page, $subscriber]));
|
||||||
|
|
||||||
|
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
|
||||||
|
$this->assertDatabaseMissing('subscribers', ['id' => $subscriber->id]);
|
||||||
|
|
||||||
|
Http::assertSent(function (Request $request): bool {
|
||||||
|
return $request->method() === 'DELETE'
|
||||||
|
&& str_contains($request->url(), '/coupon/coupon-guid-test/codes/wzx-code-guid');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makePageForDestroyTest(User $user): PreregistrationPage
|
||||||
|
{
|
||||||
|
return PreregistrationPage::query()->create([
|
||||||
|
'slug' => (string) Str::uuid(),
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'title' => 'Fest',
|
||||||
|
'heading' => 'Fest',
|
||||||
|
'intro_text' => null,
|
||||||
|
'thank_you_message' => null,
|
||||||
|
'expired_message' => null,
|
||||||
|
'ticketshop_url' => null,
|
||||||
|
'start_date' => now()->subDay(),
|
||||||
|
'end_date' => now()->addMonth(),
|
||||||
|
'phone_enabled' => false,
|
||||||
|
'background_image' => null,
|
||||||
|
'logo_image' => null,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\MailwizzConfig;
|
||||||
use App\Models\PreregistrationPage;
|
use App\Models\PreregistrationPage;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@@ -36,6 +37,50 @@ class MailwizzConfigUiTest extends TestCase
|
|||||||
$response->assertForbidden();
|
$response->assertForbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_connected_mailwizz_shows_overview_until_wizard_requested(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['role' => 'user']);
|
||||||
|
$page = $this->makePageForUser($user);
|
||||||
|
MailwizzConfig::query()->create([
|
||||||
|
'preregistration_page_id' => $page->id,
|
||||||
|
'api_key' => 'test-key',
|
||||||
|
'list_uid' => 'list-uid-1',
|
||||||
|
'list_name' => 'Main list',
|
||||||
|
'field_email' => 'EMAIL',
|
||||||
|
'field_first_name' => 'FNAME',
|
||||||
|
'field_last_name' => 'LNAME',
|
||||||
|
'field_phone' => null,
|
||||||
|
'field_coupon_code' => null,
|
||||||
|
'tag_field' => 'TAGS',
|
||||||
|
'tag_value' => 'preregister-source',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$overview = $this->actingAs($user)->get(route('admin.pages.mailwizz.edit', $page));
|
||||||
|
$overview->assertOk();
|
||||||
|
$overview->assertSee('Current configuration', escape: false);
|
||||||
|
$overview->assertSee('Change settings (wizard)', escape: false);
|
||||||
|
$overview->assertDontSee('Step 1: API key', escape: false);
|
||||||
|
|
||||||
|
$wizard = $this->actingAs($user)->get(route('admin.pages.mailwizz.edit', [
|
||||||
|
'page' => $page,
|
||||||
|
'wizard' => 1,
|
||||||
|
'step' => 1,
|
||||||
|
]));
|
||||||
|
$wizard->assertOk();
|
||||||
|
$wizard->assertSee('Step 1: API key', escape: false);
|
||||||
|
$wizard->assertSee('Cancel and return to overview', escape: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_mailwizz_wizard_redirects_to_step_one_when_no_config_and_step_gt_one(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['role' => 'user']);
|
||||||
|
$page = $this->makePageForUser($user);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('admin.pages.mailwizz.edit', ['page' => $page, 'wizard' => 1, 'step' => 3]))
|
||||||
|
->assertRedirect(route('admin.pages.mailwizz.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]));
|
||||||
|
}
|
||||||
|
|
||||||
private function makePageForUser(User $user): PreregistrationPage
|
private function makePageForUser(User $user): PreregistrationPage
|
||||||
{
|
{
|
||||||
return PreregistrationPage::query()->create([
|
return PreregistrationPage::query()->create([
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ class PublicPageTest extends TestCase
|
|||||||
$response->assertJsonValidationErrors(['phone']);
|
$response->assertJsonValidationErrors(['phone']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_subscribe_normalizes_phone_to_digits(): void
|
public function test_subscribe_stores_phone_as_e164(): void
|
||||||
{
|
{
|
||||||
$page = $this->makePage([
|
$page = $this->makePage([
|
||||||
'start_date' => now()->subHour(),
|
'start_date' => now()->subHour(),
|
||||||
@@ -189,7 +189,7 @@ class PublicPageTest extends TestCase
|
|||||||
$this->assertDatabaseHas('subscribers', [
|
$this->assertDatabaseHas('subscribers', [
|
||||||
'preregistration_page_id' => $page->id,
|
'preregistration_page_id' => $page->id,
|
||||||
'email' => 'phoneuser@example.com',
|
'email' => 'phoneuser@example.com',
|
||||||
'phone' => '31612345678',
|
'phone' => '+31612345678',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Jobs\SyncSubscriberToMailwizz;
|
||||||
use App\Models\MailwizzConfig;
|
use App\Models\MailwizzConfig;
|
||||||
use App\Models\PreregistrationPage;
|
use App\Models\PreregistrationPage;
|
||||||
use App\Models\Subscriber;
|
use App\Models\Subscriber;
|
||||||
@@ -12,6 +13,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
|||||||
use Illuminate\Http\Client\Request;
|
use Illuminate\Http\Client\Request;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -80,6 +82,135 @@ class QueueUnsyncedMailwizzSubscribersTest extends TestCase
|
|||||||
$response->assertForbidden();
|
$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
|
private function makePageWithMailwizz(): PreregistrationPage
|
||||||
{
|
{
|
||||||
$user = User::factory()->create(['role' => 'user']);
|
$user = User::factory()->create(['role' => 'user']);
|
||||||
|
|||||||
@@ -32,9 +32,31 @@ class StorePreregistrationPageTest extends TestCase
|
|||||||
$page = PreregistrationPage::query()->first();
|
$page = PreregistrationPage::query()->first();
|
||||||
$response->assertRedirect(route('admin.pages.edit', $page));
|
$response->assertRedirect(route('admin.pages.edit', $page));
|
||||||
$this->assertSame('Summer Fest', $page?->title);
|
$this->assertSame('Summer Fest', $page?->title);
|
||||||
|
$this->assertFalse($page?->background_fixed);
|
||||||
$this->assertGreaterThanOrEqual(4, PageBlock::query()->where('preregistration_page_id', $page?->id)->count());
|
$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
|
public function test_validation_failure_redirects_back_with_input(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create(['role' => 'user']);
|
$user = User::factory()->create(['role' => 'user']);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Jobs\SyncSubscriberToMailwizz;
|
||||||
use App\Models\MailwizzConfig;
|
use App\Models\MailwizzConfig;
|
||||||
use App\Models\PreregistrationPage;
|
use App\Models\PreregistrationPage;
|
||||||
use App\Models\Subscriber;
|
use App\Models\Subscriber;
|
||||||
@@ -18,6 +19,27 @@ class SyncSubscriberToMailwizzTest extends TestCase
|
|||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_subscribe_returns_ok_when_mailwizz_api_fails_under_sync_queue(): void
|
||||||
|
{
|
||||||
|
Http::fake([
|
||||||
|
'*' => Http::response(['status' => 'error', 'message' => 'service unavailable'], 503),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$page = $this->makePageWithMailwizz();
|
||||||
|
|
||||||
|
$this->postJson(route('public.subscribe', ['publicPage' => $page->slug]), [
|
||||||
|
'first_name' => 'Broken',
|
||||||
|
'last_name' => 'Mailwizz',
|
||||||
|
'email' => 'broken-mailwizz@example.com',
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJson(['success' => true]);
|
||||||
|
|
||||||
|
$subscriber = Subscriber::query()->where('email', 'broken-mailwizz@example.com')->first();
|
||||||
|
$this->assertNotNull($subscriber);
|
||||||
|
$this->assertFalse($subscriber->synced_to_mailwizz);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_subscribe_with_mailwizz_config_runs_sync_create_path_and_marks_synced(): void
|
public function test_subscribe_with_mailwizz_config_runs_sync_create_path_and_marks_synced(): void
|
||||||
{
|
{
|
||||||
Http::fake(function (Request $request) {
|
Http::fake(function (Request $request) {
|
||||||
@@ -91,6 +113,84 @@ class SyncSubscriberToMailwizzTest extends TestCase
|
|||||||
$this->assertTrue($subscriber->synced_to_mailwizz);
|
$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
|
* @param array<string, mixed> $configOverrides
|
||||||
*/
|
*/
|
||||||
@@ -122,6 +222,7 @@ class SyncSubscriberToMailwizzTest extends TestCase
|
|||||||
'field_first_name' => 'FNAME',
|
'field_first_name' => 'FNAME',
|
||||||
'field_last_name' => 'LNAME',
|
'field_last_name' => 'LNAME',
|
||||||
'field_phone' => null,
|
'field_phone' => null,
|
||||||
|
'field_coupon_code' => null,
|
||||||
'tag_field' => 'TAGS',
|
'tag_field' => 'TAGS',
|
||||||
'tag_value' => 'preregister-source',
|
'tag_value' => 'preregister-source',
|
||||||
], $configOverrides));
|
], $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