Compare commits
27 Commits
| 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 | |||
| ed85e5c537 | |||
| 3c9b1d9810 | |||
| ff58e82497 | |||
| 0800f7664f |
13
.env.example
13
.env.example
@@ -7,6 +7,9 @@ APP_URL=http://localhost
|
||||
# Optional: max requests/minute per IP for public /r/{slug} and subscribe (default: 1000 when APP_ENV is local|testing, else 60).
|
||||
# PUBLIC_REQUESTS_PER_MINUTE=120
|
||||
|
||||
# Default region for parsing national phone numbers (ISO 3166-1 alpha-2). Used by libphonenumber.
|
||||
# PREREGISTER_DEFAULT_PHONE_REGION=NL
|
||||
|
||||
# Wall-clock times from the admin UI (datetime-local) are interpreted in this zone.
|
||||
APP_TIMEZONE=Europe/Amsterdam
|
||||
|
||||
@@ -42,6 +45,10 @@ SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
# Use "database" or "redis" in production and run `php artisan queue:work` (see documentation/DEPLOYMENT-STRATEGY.md).
|
||||
# Avoid "sync" in production: Mailwizz (and Weeztix coupon) jobs would run inside the HTTP request; a thrown error
|
||||
# can return 5xx to the visitor even though the subscriber row is already saved — confusing UX.
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
@@ -70,3 +77,9 @@ AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# Weeztix OAuth (defaults match https://docs.weeztix.com — only set if you use Open Ticket / another issuer)
|
||||
# WEEZTIX_OAUTH_AUTHORIZE_URL=https://login.weeztix.com/login
|
||||
# WEEZTIX_AUTH_BASE_URL=https://auth.weeztix.com
|
||||
# WEEZTIX_USER_PROFILE_URL=https://auth.weeztix.com/users/me
|
||||
# WEEZTIX_API_BASE_URL=https://api.weeztix.com
|
||||
|
||||
9
app/Exceptions/WeeztixCouponCodeConflictException.php
Normal file
9
app/Exceptions/WeeztixCouponCodeConflictException.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class WeeztixCouponCodeConflictException extends RuntimeException {}
|
||||
@@ -8,18 +8,29 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\UpdateMailwizzConfigRequest;
|
||||
use App\Models\PreregistrationPage;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MailwizzController extends Controller
|
||||
{
|
||||
public function edit(PreregistrationPage $page): View
|
||||
public function edit(Request $request, PreregistrationPage $page): View|RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $page);
|
||||
|
||||
$page->load('mailwizzConfig');
|
||||
$page->load(['mailwizzConfig', 'weeztixConfig']);
|
||||
|
||||
return view('admin.mailwizz.edit', compact('page'));
|
||||
$config = $page->mailwizzConfig;
|
||||
$showWizard = $config === null || $request->boolean('wizard');
|
||||
if ($showWizard && $config === null) {
|
||||
$requestedStep = min(4, max(1, (int) $request->query('step', 1)));
|
||||
if ($requestedStep !== 1) {
|
||||
return redirect()
|
||||
->route('admin.pages.mailwizz.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]);
|
||||
}
|
||||
}
|
||||
|
||||
return view('admin.mailwizz.edit', compact('page', 'showWizard'));
|
||||
}
|
||||
|
||||
public function update(UpdateMailwizzConfigRequest $request, PreregistrationPage $page): RedirectResponse
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\StorePreregistrationPageRequest;
|
||||
use App\Http\Requests\Admin\UpdatePreregistrationPageRequest;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Services\PreregistrationPageBlockWriter;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -17,8 +18,9 @@ use Illuminate\View\View;
|
||||
|
||||
class PageController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PreregistrationPageBlockWriter $blockWriter
|
||||
) {
|
||||
$this->authorizeResource(PreregistrationPage::class, 'page');
|
||||
}
|
||||
|
||||
@@ -26,6 +28,7 @@ class PageController extends Controller
|
||||
{
|
||||
$query = PreregistrationPage::query()
|
||||
->withCount('subscribers')
|
||||
->with(['weeztixConfig', 'mailwizzConfig'])
|
||||
->orderByDesc('start_date');
|
||||
|
||||
if (! $request->user()?->isSuperadmin()) {
|
||||
@@ -47,31 +50,32 @@ class PageController extends Controller
|
||||
public function store(StorePreregistrationPageRequest $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$background = $request->file('background_image');
|
||||
$logo = $request->file('logo_image');
|
||||
unset($validated['background_image'], $validated['logo_image']);
|
||||
|
||||
$validated['slug'] = (string) Str::uuid();
|
||||
$validated['user_id'] = $request->user()->id;
|
||||
$validated['heading'] = $validated['title'];
|
||||
$validated['intro_text'] = null;
|
||||
$validated['phone_enabled'] = false;
|
||||
$validated['background_image'] = null;
|
||||
$validated['logo_image'] = null;
|
||||
|
||||
$page = DB::transaction(function () use ($validated, $background, $logo): PreregistrationPage {
|
||||
$page = PreregistrationPage::create($validated);
|
||||
$paths = [];
|
||||
if ($background !== null) {
|
||||
$paths['background_image'] = $background->store("preregister/pages/{$page->id}", 'public');
|
||||
}
|
||||
if ($logo !== null) {
|
||||
$paths['logo_image'] = $logo->store("preregister/pages/{$page->id}", 'public');
|
||||
}
|
||||
if ($paths !== []) {
|
||||
$page->update($paths);
|
||||
$page = DB::transaction(function () use ($validated, $request): PreregistrationPage {
|
||||
$page = PreregistrationPage::query()->create($validated);
|
||||
$this->blockWriter->seedDefaultBlocks($page);
|
||||
$page = $page->fresh(['blocks']);
|
||||
|
||||
$bgFile = $request->file('page_background');
|
||||
if ($bgFile !== null && $bgFile->isValid()) {
|
||||
$path = $bgFile->store("preregister/pages/{$page->id}", 'public');
|
||||
$page->update(['background_image' => $path]);
|
||||
}
|
||||
|
||||
return $page->fresh();
|
||||
return $page->fresh(['blocks']);
|
||||
});
|
||||
|
||||
$page->syncLegacyContentColumnsFromBlocks();
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.index')
|
||||
->route('admin.pages.edit', $page)
|
||||
->with('status', __('Page created. Public URL: :url', ['url' => url('/r/'.$page->slug)]));
|
||||
}
|
||||
|
||||
@@ -82,44 +86,65 @@ class PageController extends Controller
|
||||
|
||||
public function edit(PreregistrationPage $page): View
|
||||
{
|
||||
$page->load([
|
||||
'blocks' => fn ($q) => $q->orderBy('sort_order'),
|
||||
'mailwizzConfig',
|
||||
'weeztixConfig',
|
||||
]);
|
||||
|
||||
return view('admin.pages.edit', compact('page'));
|
||||
}
|
||||
|
||||
public function update(UpdatePreregistrationPageRequest $request, PreregistrationPage $page): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$background = $request->file('background_image');
|
||||
$logo = $request->file('logo_image');
|
||||
unset($validated['background_image'], $validated['logo_image']);
|
||||
/** @var array<string|int, array<string, mixed>> $blocks */
|
||||
$blocks = $validated['blocks'];
|
||||
unset($validated['blocks']);
|
||||
|
||||
DB::transaction(function () use ($validated, $background, $logo, $page): void {
|
||||
if ($background !== null) {
|
||||
if ($page->background_image !== null) {
|
||||
Storage::disk('public')->delete($page->background_image);
|
||||
DB::transaction(function () use ($validated, $blocks, $request, $page): void {
|
||||
$disk = Storage::disk('public');
|
||||
if ($request->boolean('remove_page_background')) {
|
||||
if (is_string($page->background_image) && $page->background_image !== '') {
|
||||
$disk->delete($page->background_image);
|
||||
}
|
||||
$validated['background_image'] = $background->store("preregister/pages/{$page->id}", 'public');
|
||||
$validated['background_image'] = null;
|
||||
}
|
||||
if ($logo !== null) {
|
||||
if ($page->logo_image !== null) {
|
||||
Storage::disk('public')->delete($page->logo_image);
|
||||
$bgFile = $request->file('page_background');
|
||||
if ($bgFile !== null && $bgFile->isValid()) {
|
||||
if (is_string($page->background_image) && $page->background_image !== '') {
|
||||
$disk->delete($page->background_image);
|
||||
}
|
||||
$validated['logo_image'] = $logo->store("preregister/pages/{$page->id}", 'public');
|
||||
$validated['background_image'] = $bgFile->store("preregister/pages/{$page->id}", 'public');
|
||||
}
|
||||
$page->update($validated);
|
||||
$this->blockWriter->replaceBlocks($page, $blocks, $request);
|
||||
});
|
||||
|
||||
$page->fresh(['blocks'])->syncLegacyContentColumnsFromBlocks();
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.index')
|
||||
->route('admin.pages.edit', $page)
|
||||
->with('status', __('Page updated.'));
|
||||
}
|
||||
|
||||
public function destroy(PreregistrationPage $page): RedirectResponse
|
||||
{
|
||||
DB::transaction(function () use ($page): void {
|
||||
if ($page->background_image !== null) {
|
||||
$page->load('blocks');
|
||||
foreach ($page->blocks as $block) {
|
||||
if ($block->type !== 'image') {
|
||||
continue;
|
||||
}
|
||||
$path = data_get($block->content, 'image');
|
||||
if (is_string($path) && $path !== '') {
|
||||
Storage::disk('public')->delete($path);
|
||||
}
|
||||
}
|
||||
if ($page->background_image !== null && $page->background_image !== '') {
|
||||
Storage::disk('public')->delete($page->background_image);
|
||||
}
|
||||
if ($page->logo_image !== null) {
|
||||
if ($page->logo_image !== null && $page->logo_image !== '') {
|
||||
Storage::disk('public')->delete($page->logo_image);
|
||||
}
|
||||
$page->delete();
|
||||
|
||||
@@ -5,9 +5,14 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\DestroySubscriberRequest;
|
||||
use App\Http\Requests\Admin\IndexSubscriberRequest;
|
||||
use App\Http\Requests\Admin\QueueMailwizzSyncRequest;
|
||||
use App\Http\Requests\Admin\SyncSubscriberMailwizzRequest;
|
||||
use App\Jobs\SyncSubscriberToMailwizz;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
use App\Services\CleanupSubscriberIntegrationsService;
|
||||
use App\Services\DispatchUnsyncedMailwizzSyncJobsService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
@@ -32,6 +37,20 @@ class SubscriberController extends Controller
|
||||
return view('admin.subscribers.index', compact('page', 'subscribers', 'unsyncedMailwizzCount'));
|
||||
}
|
||||
|
||||
public function destroy(
|
||||
DestroySubscriberRequest $request,
|
||||
PreregistrationPage $page,
|
||||
Subscriber $subscriber,
|
||||
CleanupSubscriberIntegrationsService $cleanupIntegrations
|
||||
): RedirectResponse {
|
||||
$cleanupIntegrations->runBeforeDelete($subscriber);
|
||||
$subscriber->delete();
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.subscribers.index', $page)
|
||||
->with('status', __('Subscriber removed.'));
|
||||
}
|
||||
|
||||
public function queueMailwizzSync(
|
||||
QueueMailwizzSyncRequest $request,
|
||||
PreregistrationPage $page,
|
||||
@@ -62,6 +81,26 @@ class SubscriberController extends Controller
|
||||
));
|
||||
}
|
||||
|
||||
public function syncSubscriberMailwizz(
|
||||
SyncSubscriberMailwizzRequest $request,
|
||||
PreregistrationPage $page,
|
||||
Subscriber $subscriber
|
||||
): RedirectResponse {
|
||||
$page->loadMissing('mailwizzConfig');
|
||||
|
||||
if ($page->mailwizzConfig === null) {
|
||||
return redirect()
|
||||
->route('admin.pages.subscribers.index', $page)
|
||||
->with('error', __('This page has no Mailwizz integration.'));
|
||||
}
|
||||
|
||||
SyncSubscriberToMailwizz::dispatch($subscriber->fresh());
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.subscribers.index', $page)
|
||||
->with('status', __('Mailwizz sync has been queued for this subscriber.'));
|
||||
}
|
||||
|
||||
public function export(IndexSubscriberRequest $request, PreregistrationPage $page): StreamedResponse
|
||||
{
|
||||
$search = $request->validated('search');
|
||||
@@ -70,7 +109,7 @@ class SubscriberController extends Controller
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
$phoneEnabled = $page->phone_enabled;
|
||||
$phoneEnabled = $page->isPhoneFieldEnabledForSubscribers();
|
||||
|
||||
return response()->streamDownload(function () use ($subscribers, $phoneEnabled): void {
|
||||
$handle = fopen('php://output', 'w');
|
||||
@@ -78,14 +117,15 @@ class SubscriberController extends Controller
|
||||
if ($phoneEnabled) {
|
||||
$headers[] = 'Phone';
|
||||
}
|
||||
$headers = array_merge($headers, ['Registered At', 'Synced to Mailwizz', 'Synced At']);
|
||||
$headers = array_merge($headers, ['Coupon Code', 'Registered At', 'Synced to Mailwizz', 'Synced At']);
|
||||
fputcsv($handle, $headers);
|
||||
|
||||
foreach ($subscribers as $sub) {
|
||||
$row = [$sub->first_name, $sub->last_name, $sub->email];
|
||||
if ($phoneEnabled) {
|
||||
$row[] = $sub->phone ?? '';
|
||||
$row[] = $sub->phoneDisplay() ?? '';
|
||||
}
|
||||
$row[] = $sub->coupon_code ?? '';
|
||||
$row[] = $sub->created_at?->toDateTimeString() ?? '';
|
||||
$row[] = $sub->synced_to_mailwizz ? 'Yes' : 'No';
|
||||
$row[] = $sub->synced_at?->toDateTimeString() ?? '';
|
||||
|
||||
99
app/Http/Controllers/Admin/WeeztixApiController.php
Normal file
99
app/Http/Controllers/Admin/WeeztixApiController.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Services\WeeztixService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use RuntimeException;
|
||||
|
||||
class WeeztixApiController extends Controller
|
||||
{
|
||||
public function coupons(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'page_id' => ['required', 'integer', 'exists:preregistration_pages,id'],
|
||||
]);
|
||||
|
||||
$page = PreregistrationPage::query()->findOrFail($request->integer('page_id'));
|
||||
$this->authorize('update', $page);
|
||||
|
||||
$config = $page->weeztixConfig;
|
||||
if ($config === null || ! $config->is_connected) {
|
||||
return response()->json([
|
||||
'message' => __('Niet verbonden met Weeztix.'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$companyGuid = $config->company_guid;
|
||||
if (! is_string($companyGuid) || $companyGuid === '') {
|
||||
return response()->json([
|
||||
'message' => __('Geen Weeztix-bedrijf gekoppeld. Verbind opnieuw met Weeztix.'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$raw = (new WeeztixService($config))->getCoupons();
|
||||
$coupons = $this->normalizeCouponsPayload($raw);
|
||||
|
||||
return response()->json(['coupons' => $coupons]);
|
||||
} catch (RuntimeException) {
|
||||
return response()->json([
|
||||
'message' => __('Kon kortingsbonnen niet laden.'),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $raw
|
||||
* @return list<array{guid: string, name: string}>
|
||||
*/
|
||||
private function normalizeCouponsPayload(array $raw): array
|
||||
{
|
||||
$list = $raw;
|
||||
if (isset($raw['data']) && is_array($raw['data'])) {
|
||||
$list = $raw['data'];
|
||||
}
|
||||
|
||||
if (! is_array($list)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($list as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
if (! $this->couponRowHasEnabledStatus($row)) {
|
||||
continue;
|
||||
}
|
||||
$guid = data_get($row, 'guid') ?? data_get($row, 'id');
|
||||
if (! is_string($guid) || $guid === '') {
|
||||
continue;
|
||||
}
|
||||
$name = data_get($row, 'name') ?? data_get($row, 'title') ?? $guid;
|
||||
$out[] = [
|
||||
'guid' => $guid,
|
||||
'name' => is_string($name) ? $name : $guid,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weeztix coupon list items expose a string status; only "enabled" should appear in the admin picker.
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function couponRowHasEnabledStatus(array $row): bool
|
||||
{
|
||||
$status = data_get($row, 'status');
|
||||
|
||||
return is_string($status) && strcasecmp(trim($status), 'enabled') === 0;
|
||||
}
|
||||
}
|
||||
93
app/Http/Controllers/Admin/WeeztixController.php
Normal file
93
app/Http/Controllers/Admin/WeeztixController.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\UpdateWeeztixConfigRequest;
|
||||
use App\Models\PreregistrationPage;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class WeeztixController extends Controller
|
||||
{
|
||||
public function edit(Request $request, PreregistrationPage $page): View
|
||||
{
|
||||
$this->authorize('update', $page);
|
||||
|
||||
$page->load('weeztixConfig');
|
||||
|
||||
$showWizard = $page->weeztixConfig === null || $request->boolean('wizard');
|
||||
$wizardStep = $showWizard ? min(3, max(1, (int) $request->query('step', 1))) : 1;
|
||||
if ($showWizard && $page->weeztixConfig === null && $wizardStep !== 1) {
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]);
|
||||
}
|
||||
$hasStoredCredentials = $page->weeztixConfig !== null
|
||||
&& is_string($page->weeztixConfig->client_id)
|
||||
&& $page->weeztixConfig->client_id !== '';
|
||||
|
||||
return view('admin.weeztix.edit', compact(
|
||||
'page',
|
||||
'showWizard',
|
||||
'wizardStep',
|
||||
'hasStoredCredentials'
|
||||
));
|
||||
}
|
||||
|
||||
public function update(UpdateWeeztixConfigRequest $request, PreregistrationPage $page): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
foreach (['client_id', 'client_secret'] as $key) {
|
||||
if (array_key_exists($key, $validated) && $validated[$key] === '' && $page->weeztixConfig !== null) {
|
||||
unset($validated[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
$validated['redirect_uri'] = route('admin.weeztix.callback', absolute: true);
|
||||
|
||||
DB::transaction(function () use ($page, $validated): void {
|
||||
$page->weeztixConfig()->updateOrCreate(
|
||||
['preregistration_page_id' => $page->id],
|
||||
array_merge($validated, ['preregistration_page_id' => $page->id])
|
||||
);
|
||||
});
|
||||
|
||||
$page->load('weeztixConfig');
|
||||
|
||||
if ($request->boolean('wizard')) {
|
||||
if ($request->boolean('wizard_coupon_save')) {
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', ['page' => $page])
|
||||
->with('status', __('Weeztix-configuratie opgeslagen.'));
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', [
|
||||
'page' => $page,
|
||||
'wizard' => 1,
|
||||
'step' => 2,
|
||||
])
|
||||
->with('status', __('Weeztix-configuratie opgeslagen.'));
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', ['page' => $page])
|
||||
->with('status', __('Weeztix-configuratie opgeslagen.'));
|
||||
}
|
||||
|
||||
public function destroy(PreregistrationPage $page): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $page);
|
||||
|
||||
$page->weeztixConfig()?->delete();
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', $page)
|
||||
->with('status', __('Weeztix-integratie verwijderd.'));
|
||||
}
|
||||
}
|
||||
168
app/Http/Controllers/Admin/WeeztixOAuthController.php
Normal file
168
app/Http/Controllers/Admin/WeeztixOAuthController.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Services\WeeztixService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class WeeztixOAuthController extends Controller
|
||||
{
|
||||
public function redirect(Request $request, PreregistrationPage $page): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $page);
|
||||
|
||||
$page->load('weeztixConfig');
|
||||
$config = $page->weeztixConfig;
|
||||
if ($config === null) {
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', $page)
|
||||
->with('error', __('Sla eerst je client ID en client secret op.'));
|
||||
}
|
||||
|
||||
$clientId = $config->client_id;
|
||||
if (! is_string($clientId) || $clientId === '') {
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', $page)
|
||||
->with('error', __('Vul een geldige Weeztix client ID in.'));
|
||||
}
|
||||
|
||||
$state = Str::random(40);
|
||||
session([
|
||||
'weeztix_oauth_state' => $state,
|
||||
'weeztix_page_id' => $page->id,
|
||||
'weeztix_oauth_resume_wizard' => $request->boolean('wizard'),
|
||||
]);
|
||||
|
||||
$redirectUri = $config->redirect_uri;
|
||||
if (! is_string($redirectUri) || $redirectUri === '') {
|
||||
$redirectUri = route('admin.weeztix.callback', absolute: true);
|
||||
}
|
||||
|
||||
$query = http_build_query([
|
||||
'client_id' => $clientId,
|
||||
'redirect_uri' => $redirectUri,
|
||||
'response_type' => 'code',
|
||||
'state' => $state,
|
||||
]);
|
||||
|
||||
$authorizeUrl = config('weeztix.oauth_authorize_url');
|
||||
|
||||
return redirect()->away($authorizeUrl.'?'.$query);
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
if ($request->filled('error')) {
|
||||
Log::warning('Weeztix OAuth provider error', [
|
||||
'error' => $request->string('error')->toString(),
|
||||
'description' => $request->string('error_description')->toString(),
|
||||
]);
|
||||
|
||||
return $this->redirectToWeeztixEditWithSessionPage(__('Weeztix heeft de verbinding geweigerd. Probeer opnieuw.'));
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'state' => ['required', 'string'],
|
||||
'code' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$storedState = session('weeztix_oauth_state');
|
||||
$pageId = session('weeztix_page_id');
|
||||
if (! is_string($storedState) || $storedState === '' || ($pageId === null || (! is_int($pageId) && ! is_numeric($pageId)))) {
|
||||
return redirect()
|
||||
->route('admin.dashboard')
|
||||
->with('error', __('Ongeldige OAuth-sessie. Start opnieuw vanaf de Weeztix-pagina.'));
|
||||
}
|
||||
|
||||
if ($request->string('state')->toString() !== $storedState) {
|
||||
abort(403, 'Invalid OAuth state');
|
||||
}
|
||||
|
||||
$page = PreregistrationPage::query()->findOrFail((int) $pageId);
|
||||
$this->authorize('update', $page);
|
||||
|
||||
$config = $page->weeztixConfig;
|
||||
if ($config === null) {
|
||||
$this->forgetOauthSession();
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', $page)
|
||||
->with('error', __('Geen Weeztix-configuratie gevonden voor deze pagina.'));
|
||||
}
|
||||
|
||||
try {
|
||||
$service = new WeeztixService($config);
|
||||
$service->exchangeAuthorizationCode($request->string('code')->toString());
|
||||
$config = $config->fresh();
|
||||
if ($config !== null) {
|
||||
(new WeeztixService($config))->ensureCompanyStoredFromWeeztix();
|
||||
}
|
||||
} catch (RuntimeException $e) {
|
||||
Log::error('Weeztix OAuth callback failed', [
|
||||
'page_id' => $page->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$resumeWizard = $this->forgetOauthSession();
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', $this->weeztixEditParams($page, $resumeWizard, 2))
|
||||
->with('error', __('Verbinden met Weeztix is mislukt. Controleer je gegevens en probeer opnieuw.'));
|
||||
}
|
||||
|
||||
$resumeWizard = $this->forgetOauthSession();
|
||||
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', $this->weeztixEditParams($page, $resumeWizard, 3))
|
||||
->with('status', __('Succesvol verbonden met Weeztix.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{page: PreregistrationPage, wizard?: int, step?: int}
|
||||
*/
|
||||
private function weeztixEditParams(PreregistrationPage $page, bool $resumeWizard, int $step): array
|
||||
{
|
||||
$params = ['page' => $page];
|
||||
if ($resumeWizard) {
|
||||
$params['wizard'] = 1;
|
||||
$params['step'] = $step;
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
private function forgetOauthSession(): bool
|
||||
{
|
||||
$resumeWizard = (bool) session()->pull('weeztix_oauth_resume_wizard', false);
|
||||
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
|
||||
|
||||
return $resumeWizard;
|
||||
}
|
||||
|
||||
private function redirectToWeeztixEditWithSessionPage(string $message): RedirectResponse
|
||||
{
|
||||
$pageId = session('weeztix_page_id');
|
||||
$resumeWizard = $this->forgetOauthSession();
|
||||
|
||||
if (is_int($pageId) || is_numeric($pageId)) {
|
||||
$page = PreregistrationPage::query()->find((int) $pageId);
|
||||
if ($page !== null) {
|
||||
return redirect()
|
||||
->route('admin.pages.weeztix.edit', $this->weeztixEditParams($page, $resumeWizard, 2))
|
||||
->with('error', $message);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.dashboard')
|
||||
->with('error', $message);
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,36 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\SubscribePublicPageRequest;
|
||||
use App\Jobs\SyncSubscriberToMailwizz;
|
||||
use App\Models\PageBlock;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Services\RegisterSubscriberOnPage;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PublicPageController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RegisterSubscriberOnPage $registerSubscriberOnPage
|
||||
) {}
|
||||
|
||||
public function show(PreregistrationPage $publicPage): View
|
||||
{
|
||||
return view('public.page', ['page' => $publicPage]);
|
||||
$publicPage->load(['blocks' => fn ($q) => $q->orderBy('sort_order')]);
|
||||
|
||||
$pageState = $this->resolvePageState($publicPage);
|
||||
$blocksToRender = $this->filterBlocksForPageState($publicPage, $pageState);
|
||||
$subscriberCount = $publicPage->subscribers()->count();
|
||||
|
||||
$title = $publicPage->headlineForMeta();
|
||||
|
||||
return view('public.page', [
|
||||
'page' => $publicPage,
|
||||
'pageState' => $pageState,
|
||||
'blocksToRender' => $blocksToRender,
|
||||
'subscriberCount' => $subscriberCount,
|
||||
'title' => $title,
|
||||
]);
|
||||
}
|
||||
|
||||
public function subscribe(SubscribePublicPageRequest $request, PreregistrationPage $publicPage): JsonResponse
|
||||
@@ -34,15 +54,42 @@ class PublicPageController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$subscriber = $publicPage->subscribers()->create($validated);
|
||||
|
||||
if ($publicPage->mailwizzConfig !== null) {
|
||||
SyncSubscriberToMailwizz::dispatch($subscriber);
|
||||
}
|
||||
$this->registerSubscriberOnPage->storeAndQueueIntegrations($publicPage, $validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $publicPage->thank_you_message ?? __('Thank you for registering!'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolvePageState(PreregistrationPage $page): string
|
||||
{
|
||||
if ($page->isBeforeStart()) {
|
||||
return 'countdown';
|
||||
}
|
||||
|
||||
if ($page->isExpired()) {
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
return 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, PageBlock>
|
||||
*/
|
||||
private function filterBlocksForPageState(PreregistrationPage $page, string $pageState): Collection
|
||||
{
|
||||
return $page->visibleBlocks()
|
||||
->orderBy('sort_order')
|
||||
->get()
|
||||
->filter(function (PageBlock $block) use ($pageState): bool {
|
||||
return match ($pageState) {
|
||||
'countdown' => in_array($block->type, ['hero', 'countdown', 'image'], true),
|
||||
'expired' => in_array($block->type, ['hero', 'image', 'cta_banner'], true),
|
||||
default => true,
|
||||
};
|
||||
})
|
||||
->values();
|
||||
}
|
||||
}
|
||||
|
||||
36
app/Http/Requests/Admin/DestroySubscriberRequest.php
Normal file
36
app/Http/Requests/Admin/DestroySubscriberRequest.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 DestroySubscriberRequest 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 [];
|
||||
}
|
||||
}
|
||||
162
app/Http/Requests/Admin/PageBlockContentValidator.php
Normal file
162
app/Http/Requests/Admin/PageBlockContentValidator.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Contracts\Validation\Validator as ValidatorContract;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
final class PageBlockContentValidator
|
||||
{
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $blocks
|
||||
*/
|
||||
public static function validateBlocksArray(ValidatorContract $validator, array $blocks): void
|
||||
{
|
||||
$formCount = 0;
|
||||
foreach ($blocks as $i => $block) {
|
||||
if (! is_array($block)) {
|
||||
$validator->errors()->add("blocks.$i", __('Ongeldig blok.'));
|
||||
|
||||
continue;
|
||||
}
|
||||
$type = $block['type'] ?? '';
|
||||
if ($type === 'form') {
|
||||
$formCount++;
|
||||
}
|
||||
$content = $block['content'] ?? [];
|
||||
if (! is_array($content)) {
|
||||
$validator->errors()->add("blocks.$i.content", __('Ongeldige blokinhoud.'));
|
||||
|
||||
continue;
|
||||
}
|
||||
$rules = self::rulesForType((string) $type);
|
||||
if ($rules === []) {
|
||||
$validator->errors()->add("blocks.$i.type", __('Onbekend bloktype.'));
|
||||
|
||||
continue;
|
||||
}
|
||||
$inner = Validator::make($content, $rules);
|
||||
if ($inner->fails()) {
|
||||
foreach ($inner->errors()->messages() as $field => $messages) {
|
||||
foreach ($messages as $message) {
|
||||
$validator->errors()->add("blocks.$i.content.$field", $message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'form') {
|
||||
foreach (['first_name', 'last_name', 'email'] as $coreField) {
|
||||
if (! data_get($content, "fields.$coreField.enabled", true)) {
|
||||
$validator->errors()->add(
|
||||
"blocks.$i.content.fields.$coreField.enabled",
|
||||
__('Voornaam, achternaam en e-mail moeten ingeschakeld blijven.')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($formCount > 1) {
|
||||
$validator->errors()->add('blocks', __('Er mag maximaal één registratieformulier-blok zijn.'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string|ValidationRule>>
|
||||
*/
|
||||
private static function rulesForType(string $type): array
|
||||
{
|
||||
$icons = 'ticket,clock,mail,users,star,heart,gift,music,shield,check';
|
||||
|
||||
return match ($type) {
|
||||
'hero' => [
|
||||
'headline' => ['required', 'string', 'max:255'],
|
||||
'subheadline' => ['nullable', 'string', 'max:5000'],
|
||||
'eyebrow_text' => ['nullable', 'string', 'max:255'],
|
||||
'eyebrow_style' => ['nullable', 'string', 'in:badge,text,none'],
|
||||
'text_alignment' => ['nullable', 'string', 'in:center,left,right'],
|
||||
],
|
||||
'image' => [
|
||||
'image' => ['nullable', 'string', 'max:500'],
|
||||
'link_url' => ['nullable', 'string', 'max:500'],
|
||||
'alt' => ['nullable', 'string', 'max:255'],
|
||||
'max_width_px' => ['nullable', 'integer', 'min:48', 'max:800'],
|
||||
'text_alignment' => ['nullable', 'string', 'in:center,left,right'],
|
||||
],
|
||||
'benefits' => [
|
||||
'title' => ['nullable', 'string', 'max:255'],
|
||||
'items' => ['required', 'array', 'min:1'],
|
||||
'items.*.icon' => ['required', 'string', "in:$icons"],
|
||||
'items.*.text' => ['required', 'string', 'max:500'],
|
||||
'layout' => ['nullable', 'string', 'in:list,grid'],
|
||||
'max_columns' => ['nullable', 'integer', 'in:1,2,3'],
|
||||
],
|
||||
'social_proof' => [
|
||||
'template' => ['required', 'string', 'max:255'],
|
||||
'min_count' => ['required', 'integer', 'min:0'],
|
||||
'show_animation' => ['sometimes', 'boolean'],
|
||||
'style' => ['nullable', 'string', 'in:pill,badge,plain'],
|
||||
],
|
||||
'form' => [
|
||||
'title' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
'button_label' => ['required', 'string', 'max:120'],
|
||||
'button_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/'],
|
||||
'button_text_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/'],
|
||||
'show_field_icons' => ['sometimes', 'boolean'],
|
||||
'privacy_text' => ['nullable', 'string', 'max:2000'],
|
||||
'privacy_url' => ['nullable', 'string', 'max:500'],
|
||||
'fields' => ['required', 'array'],
|
||||
'fields.first_name.enabled' => ['sometimes', 'boolean'],
|
||||
'fields.first_name.required' => ['sometimes', 'boolean'],
|
||||
'fields.first_name.label' => ['nullable', 'string', 'max:120'],
|
||||
'fields.first_name.placeholder' => ['nullable', 'string', 'max:255'],
|
||||
'fields.last_name.enabled' => ['sometimes', 'boolean'],
|
||||
'fields.last_name.required' => ['sometimes', 'boolean'],
|
||||
'fields.last_name.label' => ['nullable', 'string', 'max:120'],
|
||||
'fields.last_name.placeholder' => ['nullable', 'string', 'max:255'],
|
||||
'fields.email.enabled' => ['sometimes', 'boolean'],
|
||||
'fields.email.required' => ['sometimes', 'boolean'],
|
||||
'fields.email.label' => ['nullable', 'string', 'max:120'],
|
||||
'fields.email.placeholder' => ['nullable', 'string', 'max:255'],
|
||||
'fields.phone.enabled' => ['sometimes', 'boolean'],
|
||||
'fields.phone.required' => ['sometimes', 'boolean'],
|
||||
'fields.phone.label' => ['nullable', 'string', 'max:120'],
|
||||
'fields.phone.placeholder' => ['nullable', 'string', 'max:255'],
|
||||
],
|
||||
'countdown' => [
|
||||
'target_datetime' => ['required', 'date'],
|
||||
'title' => ['nullable', 'string', 'max:255'],
|
||||
'expired_action' => ['required', 'string', 'in:hide,show_message,reload'],
|
||||
'expired_message' => ['nullable', 'string', 'max:1000'],
|
||||
'style' => ['nullable', 'string', 'in:large,compact'],
|
||||
'show_labels' => ['sometimes', 'boolean'],
|
||||
'labels' => ['nullable', 'array'],
|
||||
'labels.days' => ['nullable', 'string', 'max:32'],
|
||||
'labels.hours' => ['nullable', 'string', 'max:32'],
|
||||
'labels.minutes' => ['nullable', 'string', 'max:32'],
|
||||
'labels.seconds' => ['nullable', 'string', 'max:32'],
|
||||
],
|
||||
'text' => [
|
||||
'title' => ['nullable', 'string', 'max:255'],
|
||||
'body' => ['required', 'string', 'max:20000'],
|
||||
'text_size' => ['nullable', 'string', 'in:sm,base,lg'],
|
||||
'text_alignment' => ['nullable', 'string', 'in:center,left,right'],
|
||||
],
|
||||
'cta_banner' => [
|
||||
'text' => ['required', 'string', 'max:500'],
|
||||
'button_label' => ['required', 'string', 'max:120'],
|
||||
'button_url' => ['required', 'string', 'url:http,https', 'max:500'],
|
||||
'button_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/'],
|
||||
'style' => ['nullable', 'string', 'in:inline,stacked'],
|
||||
],
|
||||
'divider' => [
|
||||
'style' => ['required', 'string', 'in:line,dots,space_only'],
|
||||
'spacing' => ['required', 'string', 'in:small,medium,large'],
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,14 @@ class StorePreregistrationPageRequest extends FormRequest
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->preparePreregistrationPageFields();
|
||||
$this->preparePreregistrationPageSettings();
|
||||
$opacity = $this->input('background_overlay_opacity');
|
||||
if ($opacity === null || $opacity === '') {
|
||||
$this->merge(['background_overlay_opacity' => 50]);
|
||||
}
|
||||
if ($this->input('background_overlay_color') === null || $this->input('background_overlay_color') === '') {
|
||||
$this->merge(['background_overlay_color' => '#000000']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,6 +34,6 @@ class StorePreregistrationPageRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return $this->preregistrationPageRules();
|
||||
return $this->preregistrationPageSettingsRules();
|
||||
}
|
||||
}
|
||||
|
||||
36
app/Http/Requests/Admin/SyncSubscriberMailwizzRequest.php
Normal file
36
app/Http/Requests/Admin/SyncSubscriberMailwizzRequest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SyncSubscriberMailwizzRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$page = $this->route('page');
|
||||
$subscriber = $this->route('subscriber');
|
||||
if (! $page instanceof PreregistrationPage || ! $subscriber instanceof Subscriber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($subscriber->preregistration_page_id !== $page->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->user()?->can('update', $page) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, ValidationRule|string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ class UpdateMailwizzConfigRequest extends FormRequest
|
||||
'field_first_name' => ['required', 'string', 'max:255'],
|
||||
'field_last_name' => ['required', 'string', 'max:255'],
|
||||
'field_phone' => ['nullable', 'string', 'max:255'],
|
||||
'field_coupon_code' => ['nullable', 'string', 'max:255'],
|
||||
'tag_field' => ['required', 'string', 'max:255'],
|
||||
'tag_value' => ['required', 'string', 'max:255'],
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdatePreregistrationPageRequest extends FormRequest
|
||||
@@ -23,7 +24,21 @@ class UpdatePreregistrationPageRequest extends FormRequest
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->preparePreregistrationPageFields();
|
||||
$this->preparePreregistrationPageSettings();
|
||||
$blocks = $this->input('blocks');
|
||||
if (! is_array($blocks)) {
|
||||
return;
|
||||
}
|
||||
$merged = [];
|
||||
foreach ($blocks as $key => $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$row['is_visible'] = filter_var($row['is_visible'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$row['remove_block_image'] = filter_var($row['remove_block_image'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$merged[$key] = $row;
|
||||
}
|
||||
$this->merge(['blocks' => $merged]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,6 +46,20 @@ class UpdatePreregistrationPageRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return $this->preregistrationPageRules();
|
||||
return array_merge(
|
||||
$this->preregistrationPageSettingsRules(),
|
||||
$this->preregistrationPageBlocksRules(),
|
||||
);
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
$blocks = $this->input('blocks');
|
||||
if (! is_array($blocks)) {
|
||||
return;
|
||||
}
|
||||
PageBlockContentValidator::validateBlocksArray($validator, $blocks);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,32 +4,51 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Models\PageBlock;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
trait ValidatesPreregistrationPageInput
|
||||
{
|
||||
/**
|
||||
* @return array<string, array<int, ValidationRule|string>>
|
||||
*/
|
||||
protected function preregistrationPageRules(): array
|
||||
protected function preregistrationPageSettingsRules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'heading' => ['required', 'string', 'max:255'],
|
||||
'intro_text' => ['nullable', 'string'],
|
||||
'thank_you_message' => ['nullable', 'string'],
|
||||
'expired_message' => ['nullable', 'string'],
|
||||
'ticketshop_url' => ['nullable', 'string', 'url:http,https', 'max:255'],
|
||||
'post_submit_redirect_url' => ['nullable', 'string', 'url:http,https', 'max:500'],
|
||||
'background_overlay_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/'],
|
||||
'background_overlay_opacity' => ['nullable', 'integer', 'min:0', 'max:100'],
|
||||
'background_fixed' => ['sometimes', 'boolean'],
|
||||
'page_background' => ['nullable', 'file', 'image', 'mimes:jpeg,png,jpg,webp', 'max:5120'],
|
||||
'remove_page_background' => ['sometimes', 'boolean'],
|
||||
'start_date' => ['required', 'date'],
|
||||
'end_date' => ['required', 'date', 'after:start_date'],
|
||||
'phone_enabled' => ['sometimes', 'boolean'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
'background_image' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp', 'max:5120'],
|
||||
'logo_image' => ['nullable', 'file', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function preparePreregistrationPageFields(): void
|
||||
/**
|
||||
* @return array<string, array<int, ValidationRule|string>>
|
||||
*/
|
||||
protected function preregistrationPageBlocksRules(): array
|
||||
{
|
||||
return [
|
||||
'blocks' => ['required', 'array', 'min:1'],
|
||||
'blocks.*.type' => ['required', 'string', Rule::in(PageBlock::TYPES)],
|
||||
'blocks.*.sort_order' => ['required', 'integer', 'min:0', 'max:9999'],
|
||||
'blocks.*.is_visible' => ['sometimes', 'boolean'],
|
||||
'blocks.*.content' => ['required', 'array'],
|
||||
'blocks.*.remove_block_image' => ['sometimes', 'boolean'],
|
||||
'blocks.*.block_image' => ['nullable', 'file', 'mimes:jpeg,png,jpg,webp,svg', 'max:5120'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function preparePreregistrationPageSettings(): void
|
||||
{
|
||||
$ticketshop = $this->input('ticketshop_url');
|
||||
$ticketshopNormalized = null;
|
||||
@@ -37,10 +56,40 @@ trait ValidatesPreregistrationPageInput
|
||||
$ticketshopNormalized = trim($ticketshop);
|
||||
}
|
||||
|
||||
$redirect = $this->input('post_submit_redirect_url');
|
||||
$redirectNormalized = null;
|
||||
if (is_string($redirect) && trim($redirect) !== '') {
|
||||
$redirectNormalized = trim($redirect);
|
||||
}
|
||||
|
||||
$opacity = $this->input('background_overlay_opacity');
|
||||
$opacityInt = null;
|
||||
if ($opacity !== null && $opacity !== '') {
|
||||
$opacityInt = max(0, min(100, (int) $opacity));
|
||||
}
|
||||
|
||||
$this->merge([
|
||||
'phone_enabled' => $this->boolean('phone_enabled'),
|
||||
'is_active' => $this->boolean('is_active'),
|
||||
'background_fixed' => $this->boolean('background_fixed'),
|
||||
'remove_page_background' => $this->boolean('remove_page_background'),
|
||||
'ticketshop_url' => $ticketshopNormalized,
|
||||
'post_submit_redirect_url' => $redirectNormalized,
|
||||
'background_overlay_opacity' => $opacityInt,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use preregistrationPageSettingsRules
|
||||
*
|
||||
* @return array<string, array<int, ValidationRule|string>>
|
||||
*/
|
||||
protected function preregistrationPageRules(): array
|
||||
{
|
||||
return array_merge($this->preregistrationPageSettingsRules(), $this->preregistrationPageBlocksRules());
|
||||
}
|
||||
|
||||
protected function preparePreregistrationPageFields(): void
|
||||
{
|
||||
$this->preparePreregistrationPageSettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Rules\ValidPhoneNumber;
|
||||
use App\Services\PhoneNumberNormalizer;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Email;
|
||||
|
||||
class SubscribePublicPageRequest extends FormRequest
|
||||
@@ -22,18 +25,29 @@ class SubscribePublicPageRequest extends FormRequest
|
||||
{
|
||||
/** @var PreregistrationPage $page */
|
||||
$page = $this->route('publicPage');
|
||||
$page->loadMissing('blocks');
|
||||
|
||||
$emailRule = (new Email)
|
||||
->rfcCompliant()
|
||||
->preventSpoofing();
|
||||
|
||||
$phoneRules = ['nullable', 'string', 'max:255'];
|
||||
|
||||
if ($page->isPhoneFieldEnabledForSubscribers()) {
|
||||
$phoneRules = [
|
||||
Rule::requiredIf(fn (): bool => $page->isPhoneFieldRequiredForSubscribers()),
|
||||
'nullable',
|
||||
'string',
|
||||
'max:32',
|
||||
new ValidPhoneNumber(app(PhoneNumberNormalizer::class)),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'first_name' => ['required', 'string', 'max:255'],
|
||||
'last_name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'max:255', $emailRule],
|
||||
'phone' => $page->phone_enabled
|
||||
? ['nullable', 'string', 'regex:/^[0-9]{8,15}$/']
|
||||
: ['nullable', 'string', 'max:255'],
|
||||
'phone' => $phoneRules,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -44,7 +58,6 @@ class SubscribePublicPageRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'email' => __('Please enter a valid email address.'),
|
||||
'phone.regex' => __('Please enter a valid phone number (8–15 digits).'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -72,15 +85,22 @@ class SubscribePublicPageRequest extends FormRequest
|
||||
|
||||
/** @var PreregistrationPage $page */
|
||||
$page = $this->route('publicPage');
|
||||
$phone = $this->input('phone');
|
||||
if (! $page->phone_enabled) {
|
||||
if (! $page->isPhoneFieldEnabledForSubscribers()) {
|
||||
$this->merge(['phone' => null]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$phone = $this->input('phone');
|
||||
if ($phone === null || $phone === '') {
|
||||
$this->merge(['phone' => null]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_string($phone)) {
|
||||
$digits = preg_replace('/\D+/', '', $phone);
|
||||
$this->merge(['phone' => $digits === '' ? null : $digits]);
|
||||
$trimmed = trim($phone);
|
||||
$this->merge(['phone' => $trimmed === '' ? null : $trimmed]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
175
app/Jobs/IssueWeeztixCouponForSubscriber.php
Normal file
175
app/Jobs/IssueWeeztixCouponForSubscriber.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Exceptions\WeeztixCouponCodeConflictException;
|
||||
use App\Models\Subscriber;
|
||||
use App\Models\WeeztixConfig;
|
||||
use App\Services\WeeztixService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Creates the Weeztix coupon code after the subscriber row exists, then queues Mailwizz sync
|
||||
* so the external APIs never block the public HTTP response and Mailwizz runs after coupon_code is set when possible.
|
||||
*/
|
||||
final class IssueWeeztixCouponForSubscriber implements ShouldBeUnique, ShouldQueueAfterCommit
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
/**
|
||||
* @var list<int>
|
||||
*/
|
||||
public array $backoff = [5, 15, 45];
|
||||
|
||||
/**
|
||||
* Seconds before the unique lock expires if the worker dies before releasing it.
|
||||
*/
|
||||
public int $uniqueFor = 300;
|
||||
|
||||
public function __construct(public Subscriber $subscriber)
|
||||
{
|
||||
$this->onQueue('weeztix');
|
||||
}
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return 'weeztix-coupon-subscriber-'.$this->subscriber->getKey();
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
$subscriber = Subscriber::query()
|
||||
->with(['preregistrationPage.weeztixConfig', 'preregistrationPage.mailwizzConfig'])
|
||||
->find($this->subscriber->id);
|
||||
|
||||
if ($subscriber === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$page = $subscriber->preregistrationPage;
|
||||
if ($page === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$config = $page->weeztixConfig;
|
||||
$couponMissing = ! is_string($subscriber->coupon_code) || $subscriber->coupon_code === '';
|
||||
|
||||
if ($couponMissing && $this->weeztixCanIssueCodes($config)) {
|
||||
$this->tryAttachWeeztixCouponCode($subscriber, $config);
|
||||
}
|
||||
|
||||
$this->dispatchMailwizzIfNeeded($subscriber);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('IssueWeeztixCouponForSubscriber: handle failed', [
|
||||
'subscriber_id' => $this->subscriber->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$subscriber = Subscriber::query()
|
||||
->with(['preregistrationPage.mailwizzConfig'])
|
||||
->find($this->subscriber->id);
|
||||
|
||||
if ($subscriber !== null) {
|
||||
$this->dispatchMailwizzIfNeeded($subscriber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
Log::error('IssueWeeztixCouponForSubscriber failed', [
|
||||
'subscriber_id' => $this->subscriber->id,
|
||||
'message' => $exception?->getMessage(),
|
||||
]);
|
||||
|
||||
$subscriber = Subscriber::query()
|
||||
->with('preregistrationPage.mailwizzConfig')
|
||||
->find($this->subscriber->id);
|
||||
|
||||
if ($subscriber !== null) {
|
||||
$this->dispatchMailwizzIfNeeded($subscriber);
|
||||
}
|
||||
}
|
||||
|
||||
private function dispatchMailwizzIfNeeded(Subscriber $subscriber): void
|
||||
{
|
||||
$page = $subscriber->preregistrationPage;
|
||||
$page?->loadMissing('mailwizzConfig');
|
||||
|
||||
if ($page?->mailwizzConfig !== null) {
|
||||
try {
|
||||
SyncSubscriberToMailwizz::dispatch($subscriber->fresh());
|
||||
} catch (Throwable $e) {
|
||||
Log::error('IssueWeeztixCouponForSubscriber: could not queue Mailwizz sync', [
|
||||
'subscriber_id' => $subscriber->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function weeztixCanIssueCodes(?WeeztixConfig $config): bool
|
||||
{
|
||||
if ($config === null || ! $config->is_connected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$company = $config->company_guid;
|
||||
$coupon = $config->coupon_guid;
|
||||
|
||||
return is_string($company) && $company !== '' && is_string($coupon) && $coupon !== '';
|
||||
}
|
||||
|
||||
private function tryAttachWeeztixCouponCode(Subscriber $subscriber, WeeztixConfig $config): void
|
||||
{
|
||||
$freshConfig = $config->fresh();
|
||||
if ($freshConfig === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = new WeeztixService($freshConfig);
|
||||
$maxAttempts = 5;
|
||||
|
||||
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
|
||||
try {
|
||||
$code = WeeztixService::generateUniqueCode(
|
||||
is_string($freshConfig->code_prefix) && $freshConfig->code_prefix !== ''
|
||||
? $freshConfig->code_prefix
|
||||
: 'PREREG'
|
||||
);
|
||||
$service->createCouponCode($code);
|
||||
$subscriber->update(['coupon_code' => $code]);
|
||||
|
||||
return;
|
||||
} catch (WeeztixCouponCodeConflictException) {
|
||||
continue;
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Weeztix coupon creation failed', [
|
||||
'subscriber_id' => $subscriber->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Log::warning('Weeztix coupon: exhausted duplicate retries', [
|
||||
'subscriber_id' => $subscriber->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,23 +6,23 @@ namespace App\Jobs;
|
||||
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\Subscriber;
|
||||
use App\Services\MailwizzCheckboxlistTags;
|
||||
use App\Services\MailwizzService;
|
||||
use App\Services\MailwizzSubscriberFormPayload;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueueAfterCommit
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* Seconds before the unique lock expires if the worker dies before releasing it.
|
||||
@@ -36,21 +36,52 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
*/
|
||||
public array $backoff = [10, 30, 60];
|
||||
|
||||
public function __construct(public Subscriber $subscriber)
|
||||
/**
|
||||
* Set in the constructor for new jobs. Remains null when an old queue payload (pre–subscriber-id refactor) is unserialized.
|
||||
*/
|
||||
public ?int $subscriberId = null;
|
||||
|
||||
/**
|
||||
* @param Subscriber|int $subscriber Model is accepted when dispatching; only the id is serialized for the queue.
|
||||
*/
|
||||
public function __construct(Subscriber|int $subscriber)
|
||||
{
|
||||
$this->subscriberId = $subscriber instanceof Subscriber
|
||||
? (int) $subscriber->getKey()
|
||||
: $subscriber;
|
||||
$this->onQueue('mailwizz');
|
||||
}
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return (string) $this->subscriber->getKey();
|
||||
return $this->subscriberId !== null
|
||||
? (string) $this->subscriberId
|
||||
: 'stale-mailwizz-sync-payload';
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
if ($this->subscriberId === null) {
|
||||
Log::notice('SyncSubscriberToMailwizz: skipped job with missing subscriber id (stale queue payload). Clear the queue or re-dispatch sync jobs.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->runSync();
|
||||
} catch (Throwable $e) {
|
||||
Log::error('SyncSubscriberToMailwizz: integration failed; subscriber remains local (use admin resync if needed)', [
|
||||
'subscriber_id' => $this->subscriberId,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function runSync(): void
|
||||
{
|
||||
$subscriber = Subscriber::query()
|
||||
->with(['preregistrationPage.mailwizzConfig'])
|
||||
->find($this->subscriber->id);
|
||||
->find($this->subscriberId);
|
||||
|
||||
if ($subscriber === null) {
|
||||
return;
|
||||
@@ -101,7 +132,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
Log::error('SyncSubscriberToMailwizz failed', [
|
||||
'subscriber_id' => $this->subscriber->id,
|
||||
'subscriber_id' => $this->subscriberId,
|
||||
'message' => $exception?->getMessage(),
|
||||
]);
|
||||
}
|
||||
@@ -119,24 +150,6 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
return true;
|
||||
}
|
||||
|
||||
private function buildBasePayload(Subscriber $subscriber, MailwizzConfig $config, bool $phoneEnabled): array
|
||||
{
|
||||
$data = [
|
||||
$config->field_email => $subscriber->email,
|
||||
$config->field_first_name => $subscriber->first_name,
|
||||
$config->field_last_name => $subscriber->last_name,
|
||||
];
|
||||
|
||||
if ($phoneEnabled && $config->field_phone !== null && $config->field_phone !== '') {
|
||||
$phone = $subscriber->phone;
|
||||
if ($phone !== null && $phone !== '') {
|
||||
$data[$config->field_phone] = $phone;
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function createInMailwizz(
|
||||
MailwizzService $service,
|
||||
Subscriber $subscriber,
|
||||
@@ -144,7 +157,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
string $listUid
|
||||
): void {
|
||||
$page = $subscriber->preregistrationPage;
|
||||
$data = $this->buildBasePayload($subscriber, $config, (bool) $page->phone_enabled);
|
||||
$data = MailwizzSubscriberFormPayload::baseFields($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
|
||||
$tagField = $config->tag_field;
|
||||
$tagValue = $config->tag_value;
|
||||
if ($tagField !== null && $tagField !== '' && $tagValue !== null && $tagValue !== '') {
|
||||
@@ -162,7 +175,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
string $subscriberUid
|
||||
): void {
|
||||
$page = $subscriber->preregistrationPage;
|
||||
$data = $this->buildBasePayload($subscriber, $config, (bool) $page->phone_enabled);
|
||||
$data = MailwizzSubscriberFormPayload::baseFields($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
|
||||
|
||||
$tagField = $config->tag_field;
|
||||
$tagValue = $config->tag_value;
|
||||
@@ -171,46 +184,11 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
||||
if ($full === null) {
|
||||
throw new RuntimeException('Mailwizz getSubscriber returned an empty payload.');
|
||||
}
|
||||
$existingCsv = $this->extractTagCsvFromResponse($full, $tagField);
|
||||
$merged = $this->mergeCheckboxlistTags($existingCsv, $tagValue);
|
||||
$existingCsv = MailwizzCheckboxlistTags::extractCsvFromSubscriberResponse($full, $tagField);
|
||||
$merged = MailwizzCheckboxlistTags::mergeValueIntoCsv($existingCsv, $tagValue);
|
||||
$data[$tagField] = $merged;
|
||||
}
|
||||
|
||||
$service->updateSubscriber($listUid, $subscriberUid, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $apiResponse
|
||||
*/
|
||||
private function extractTagCsvFromResponse(array $apiResponse, string $tagField): string
|
||||
{
|
||||
$record = data_get($apiResponse, 'data.record');
|
||||
if (! is_array($record)) {
|
||||
$record = data_get($apiResponse, 'data');
|
||||
}
|
||||
if (! is_array($record)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$raw = $record[$tagField] ?? data_get($record, "fields.{$tagField}");
|
||||
|
||||
if (is_array($raw)) {
|
||||
return implode(',', array_map(static fn (mixed $v): string => is_scalar($v) ? (string) $v : '', $raw));
|
||||
}
|
||||
|
||||
return is_string($raw) ? $raw : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function mergeCheckboxlistTags(string $existingCsv, string $newValue): array
|
||||
{
|
||||
$parts = array_filter(array_map('trim', explode(',', $existingCsv)), static fn (string $s): bool => $s !== '');
|
||||
if (! in_array($newValue, $parts, true)) {
|
||||
$parts[] = $newValue;
|
||||
}
|
||||
|
||||
return array_values($parts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class MailwizzConfig extends Model
|
||||
'field_first_name',
|
||||
'field_last_name',
|
||||
'field_phone',
|
||||
'field_coupon_code',
|
||||
'tag_field',
|
||||
'tag_value',
|
||||
];
|
||||
|
||||
56
app/Models/PageBlock.php
Normal file
56
app/Models/PageBlock.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PageBlock extends Model
|
||||
{
|
||||
public const TYPES = [
|
||||
'hero',
|
||||
'image',
|
||||
'benefits',
|
||||
'social_proof',
|
||||
'form',
|
||||
'countdown',
|
||||
'text',
|
||||
'cta_banner',
|
||||
'divider',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'preregistration_page_id',
|
||||
'type',
|
||||
'content',
|
||||
'sort_order',
|
||||
'is_visible',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'content' => 'array',
|
||||
'is_visible' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function preregistrationPage(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PreregistrationPage::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blade dynamic component name (kebab-case where needed).
|
||||
*/
|
||||
public function bladeComponentName(): string
|
||||
{
|
||||
return match ($this->type) {
|
||||
'social_proof' => 'blocks.social-proof',
|
||||
'cta_banner' => 'blocks.cta-banner',
|
||||
default => 'blocks.'.$this->type,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,14 @@ class PreregistrationPage extends Model
|
||||
'thank_you_message',
|
||||
'expired_message',
|
||||
'ticketshop_url',
|
||||
'post_submit_redirect_url',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'phone_enabled',
|
||||
'background_image',
|
||||
'background_overlay_color',
|
||||
'background_overlay_opacity',
|
||||
'background_fixed',
|
||||
'logo_image',
|
||||
'is_active',
|
||||
];
|
||||
@@ -39,6 +43,7 @@ class PreregistrationPage extends Model
|
||||
'start_date' => 'datetime',
|
||||
'end_date' => 'datetime',
|
||||
'phone_enabled' => 'boolean',
|
||||
'background_fixed' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
@@ -61,11 +66,107 @@ class PreregistrationPage extends Model
|
||||
return $this->hasMany(Subscriber::class);
|
||||
}
|
||||
|
||||
public function blocks(): HasMany
|
||||
{
|
||||
return $this->hasMany(PageBlock::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function visibleBlocks(): HasMany
|
||||
{
|
||||
return $this->blocks()->where('is_visible', true);
|
||||
}
|
||||
|
||||
public function getBlockByType(string $type): ?PageBlock
|
||||
{
|
||||
if ($this->relationLoaded('blocks')) {
|
||||
return $this->blocks->firstWhere('type', $type);
|
||||
}
|
||||
|
||||
return $this->blocks()->where('type', $type)->first();
|
||||
}
|
||||
|
||||
public function getFormBlock(): ?PageBlock
|
||||
{
|
||||
return $this->getBlockByType('form');
|
||||
}
|
||||
|
||||
public function getHeroBlock(): ?PageBlock
|
||||
{
|
||||
return $this->getBlockByType('hero');
|
||||
}
|
||||
|
||||
/**
|
||||
* Phone field is shown and validated when enabled in the form block (falls back to legacy column).
|
||||
*/
|
||||
public function isPhoneFieldEnabledForSubscribers(): bool
|
||||
{
|
||||
$form = $this->getBlockByType('form');
|
||||
if ($form !== null) {
|
||||
return (bool) data_get($form->content, 'fields.phone.enabled', false);
|
||||
}
|
||||
|
||||
return (bool) $this->phone_enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the form block marks the phone field as required (only applies if phone is enabled).
|
||||
*/
|
||||
public function isPhoneFieldRequiredForSubscribers(): bool
|
||||
{
|
||||
$form = $this->getBlockByType('form');
|
||||
if ($form !== null) {
|
||||
return (bool) data_get($form->content, 'fields.phone.required', false);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function headlineForMeta(): string
|
||||
{
|
||||
$hero = $this->getHeroBlock();
|
||||
if ($hero !== null) {
|
||||
$headline = data_get($hero->content, 'headline');
|
||||
if (is_string($headline) && $headline !== '') {
|
||||
return $headline;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->heading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps legacy DB columns aligned with hero/form blocks until those columns are dropped.
|
||||
*/
|
||||
public function syncLegacyContentColumnsFromBlocks(): void
|
||||
{
|
||||
$this->load('blocks');
|
||||
$hero = $this->getHeroBlock();
|
||||
$form = $this->getFormBlock();
|
||||
$updates = [];
|
||||
if ($hero !== null) {
|
||||
$c = $hero->content ?? [];
|
||||
$updates['heading'] = is_string($c['headline'] ?? null) ? $c['headline'] : $this->heading;
|
||||
$sub = $c['subheadline'] ?? null;
|
||||
$updates['intro_text'] = is_string($sub) ? $sub : null;
|
||||
}
|
||||
if ($form !== null) {
|
||||
$updates['phone_enabled'] = (bool) data_get($form->content, 'fields.phone.enabled', false);
|
||||
}
|
||||
if ($updates !== []) {
|
||||
$this->update($updates);
|
||||
}
|
||||
}
|
||||
|
||||
public function mailwizzConfig(): HasOne
|
||||
{
|
||||
return $this->hasOne(MailwizzConfig::class);
|
||||
}
|
||||
|
||||
public function weeztixConfig(): HasOne
|
||||
{
|
||||
return $this->hasOne(WeeztixConfig::class);
|
||||
}
|
||||
|
||||
public function isBeforeStart(): bool
|
||||
{
|
||||
return Carbon::now()->lt($this->start_date);
|
||||
@@ -103,4 +204,56 @@ class PreregistrationPage extends Model
|
||||
|
||||
return 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mailwizz setup depth for admin UI (API key + list + email field = ready to sync).
|
||||
*
|
||||
* @return 'none'|'partial'|'ready'
|
||||
*/
|
||||
public function mailwizzIntegrationStatus(): string
|
||||
{
|
||||
$c = $this->mailwizzConfig;
|
||||
if ($c === null) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
$key = $c->api_key;
|
||||
if (! is_string($key) || $key === '') {
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
if (! is_string($c->list_uid) || $c->list_uid === '' || ! is_string($c->field_email) || $c->field_email === '') {
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
/**
|
||||
* Weeztix setup depth for admin UI.
|
||||
*
|
||||
* @return 'none'|'credentials'|'connected'|'ready'
|
||||
*/
|
||||
public function weeztixIntegrationStatus(): string
|
||||
{
|
||||
$c = $this->weeztixConfig;
|
||||
if ($c === null) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
$hasClient = is_string($c->client_id) && $c->client_id !== '';
|
||||
if (! $hasClient) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
if (! $c->is_connected) {
|
||||
return 'credentials';
|
||||
}
|
||||
|
||||
if (! is_string($c->coupon_guid) || $c->coupon_guid === '') {
|
||||
return 'connected';
|
||||
}
|
||||
|
||||
return 'ready';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\PhoneNumberNormalizer;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -21,6 +22,7 @@ class Subscriber extends Model
|
||||
'phone',
|
||||
'synced_to_mailwizz',
|
||||
'synced_at',
|
||||
'coupon_code',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -36,6 +38,52 @@ class Subscriber extends Model
|
||||
return $this->belongsTo(PreregistrationPage::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $value
|
||||
*/
|
||||
public function setPhoneAttribute(mixed $value): void
|
||||
{
|
||||
if ($value === null) {
|
||||
$this->attributes['phone'] = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
$this->attributes['phone'] = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$trimmed = trim($value);
|
||||
if ($trimmed === '') {
|
||||
$this->attributes['phone'] = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$normalized = app(PhoneNumberNormalizer::class)->normalizeToE164($trimmed);
|
||||
$this->attributes['phone'] = $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phones are stored as E.164 (e.g. +31612345678). Legacy rows may still be digits-only.
|
||||
*/
|
||||
public function phoneDisplay(): ?string
|
||||
{
|
||||
$phone = $this->phone;
|
||||
if ($phone === null || $phone === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$p = (string) $phone;
|
||||
if (str_starts_with($p, '+')) {
|
||||
return $p;
|
||||
}
|
||||
|
||||
return preg_match('/^\d{8,15}$/', $p) === 1 ? '+'.$p : $p;
|
||||
}
|
||||
|
||||
public function scopeSearch(Builder $query, ?string $term): Builder
|
||||
{
|
||||
if ($term === null || $term === '') {
|
||||
@@ -47,7 +95,9 @@ class Subscriber extends Model
|
||||
return $query->where(function (Builder $q) use ($like): void {
|
||||
$q->where('first_name', 'like', $like)
|
||||
->orWhere('last_name', 'like', $like)
|
||||
->orWhere('email', 'like', $like);
|
||||
->orWhere('email', 'like', $like)
|
||||
->orWhere('phone', 'like', $like)
|
||||
->orWhere('coupon_code', 'like', $like);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
64
app/Models/WeeztixConfig.php
Normal file
64
app/Models/WeeztixConfig.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WeeztixConfig extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'preregistration_page_id',
|
||||
'client_id',
|
||||
'client_secret',
|
||||
'redirect_uri',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'token_expires_at',
|
||||
'refresh_token_expires_at',
|
||||
'company_guid',
|
||||
'company_name',
|
||||
'coupon_guid',
|
||||
'coupon_name',
|
||||
'code_prefix',
|
||||
'usage_count',
|
||||
'is_connected',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'client_id' => 'encrypted',
|
||||
'client_secret' => 'encrypted',
|
||||
'access_token' => 'encrypted',
|
||||
'refresh_token' => 'encrypted',
|
||||
'token_expires_at' => 'datetime',
|
||||
'refresh_token_expires_at' => 'datetime',
|
||||
'is_connected' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function preregistrationPage(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PreregistrationPage::class);
|
||||
}
|
||||
|
||||
public function isTokenExpired(): bool
|
||||
{
|
||||
return ! $this->token_expires_at || $this->token_expires_at->isPast();
|
||||
}
|
||||
|
||||
public function isRefreshTokenExpired(): bool
|
||||
{
|
||||
if ($this->refresh_token_expires_at === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->refresh_token_expires_at->isPast();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Services\PhoneNumberNormalizer;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
@@ -16,7 +17,11 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
$this->app->singleton(PhoneNumberNormalizer::class, function (): PhoneNumberNormalizer {
|
||||
return new PhoneNumberNormalizer(
|
||||
(string) config('preregister.default_phone_region', 'NL')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
33
app/Rules/ValidPhoneNumber.php
Normal file
33
app/Rules/ValidPhoneNumber.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Services\PhoneNumberNormalizer;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
final class ValidPhoneNumber implements ValidationRule
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PhoneNumberNormalizer $normalizer
|
||||
) {}
|
||||
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
$fail(__('Please enter a valid phone number.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->normalizer->normalizeToE164(trim($value)) === null) {
|
||||
$fail(__('Please enter a valid phone number.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
145
app/Services/CleanupSubscriberIntegrationsService.php
Normal file
145
app/Services/CleanupSubscriberIntegrationsService.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
use App\Models\WeeztixConfig;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Before a subscriber row is deleted, best-effort cleanup in Weeztix (coupon code) and Mailwizz (strip source tag, clear coupon field).
|
||||
* Failures are logged only; local delete must still proceed.
|
||||
*/
|
||||
final class CleanupSubscriberIntegrationsService
|
||||
{
|
||||
public function runBeforeDelete(Subscriber $subscriber): void
|
||||
{
|
||||
$subscriber->loadMissing(['preregistrationPage.mailwizzConfig', 'preregistrationPage.weeztixConfig']);
|
||||
$page = $subscriber->preregistrationPage;
|
||||
if ($page === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->cleanupWeeztixIfApplicable($subscriber, $page);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('CleanupSubscriberIntegrations: Weeztix cleanup failed', [
|
||||
'subscriber_id' => $subscriber->id,
|
||||
'preregistration_page_id' => $page->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->cleanupMailwizzIfApplicable($subscriber, $page);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('CleanupSubscriberIntegrations: Mailwizz cleanup failed', [
|
||||
'subscriber_id' => $subscriber->id,
|
||||
'preregistration_page_id' => $page->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanupWeeztixIfApplicable(Subscriber $subscriber, PreregistrationPage $page): void
|
||||
{
|
||||
$config = $page->weeztixConfig;
|
||||
if ($config === null || ! $config->is_connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
$code = $subscriber->coupon_code;
|
||||
if (! is_string($code) || trim($code) === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->weeztixCanManageCodes($config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fresh = $config->fresh();
|
||||
if ($fresh === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
(new WeeztixService($fresh))->deleteCouponCodeByCodeString($code);
|
||||
}
|
||||
|
||||
private function weeztixCanManageCodes(WeeztixConfig $config): bool
|
||||
{
|
||||
$company = $config->company_guid;
|
||||
$coupon = $config->coupon_guid;
|
||||
|
||||
return is_string($company) && $company !== '' && is_string($coupon) && $coupon !== '';
|
||||
}
|
||||
|
||||
private function cleanupMailwizzIfApplicable(Subscriber $subscriber, PreregistrationPage $page): void
|
||||
{
|
||||
$config = $page->mailwizzConfig;
|
||||
if ($config === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->mailwizzConfigAllowsUpdate($config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$apiKey = $config->api_key;
|
||||
if (! is_string($apiKey) || $apiKey === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = new MailwizzService($apiKey);
|
||||
$listUid = $config->list_uid;
|
||||
|
||||
$search = $service->searchSubscriber($listUid, $subscriber->email);
|
||||
if ($search === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscriberUid = $search['subscriber_uid'];
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = [];
|
||||
|
||||
$couponField = $config->field_coupon_code;
|
||||
if (is_string($couponField) && $couponField !== '') {
|
||||
$data[$couponField] = '';
|
||||
}
|
||||
|
||||
$tagField = $config->tag_field;
|
||||
$tagValue = $config->tag_value;
|
||||
if ($tagField !== null && $tagField !== '' && $tagValue !== null && $tagValue !== '') {
|
||||
$full = $service->getSubscriber($listUid, $subscriberUid);
|
||||
if ($full === null) {
|
||||
throw new RuntimeException('Mailwizz getSubscriber returned an empty payload.');
|
||||
}
|
||||
$existingCsv = MailwizzCheckboxlistTags::extractCsvFromSubscriberResponse($full, $tagField);
|
||||
$data[$tagField] = MailwizzCheckboxlistTags::removeValueFromCsv($existingCsv, $tagValue);
|
||||
}
|
||||
|
||||
if ($data === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service->updateSubscriber($listUid, $subscriberUid, $data);
|
||||
}
|
||||
|
||||
private function mailwizzConfigAllowsUpdate(MailwizzConfig $config): bool
|
||||
{
|
||||
if ($config->list_uid === '' || $config->field_email === '' || $config->field_first_name === '' || $config->field_last_name === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hasTagWork = $config->tag_field !== null && $config->tag_field !== ''
|
||||
&& $config->tag_value !== null && $config->tag_value !== '';
|
||||
$hasCouponField = is_string($config->field_coupon_code) && $config->field_coupon_code !== '';
|
||||
|
||||
return $hasTagWork || $hasCouponField;
|
||||
}
|
||||
}
|
||||
64
app/Services/MailwizzCheckboxlistTags.php
Normal file
64
app/Services/MailwizzCheckboxlistTags.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/**
|
||||
* Parses and mutates Mailwizz checkboxlist-style tag fields (comma-separated in API, array on write).
|
||||
*/
|
||||
final class MailwizzCheckboxlistTags
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $apiResponse getSubscriber JSON payload
|
||||
*/
|
||||
public static function extractCsvFromSubscriberResponse(array $apiResponse, string $tagField): string
|
||||
{
|
||||
$record = data_get($apiResponse, 'data.record');
|
||||
if (! is_array($record)) {
|
||||
$record = data_get($apiResponse, 'data');
|
||||
}
|
||||
if (! is_array($record)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$raw = $record[$tagField] ?? data_get($record, "fields.{$tagField}");
|
||||
|
||||
if (is_array($raw)) {
|
||||
return implode(',', array_map(static fn (mixed $v): string => is_scalar($v) ? (string) $v : '', $raw));
|
||||
}
|
||||
|
||||
return is_string($raw) ? $raw : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function mergeValueIntoCsv(string $existingCsv, string $newValue): array
|
||||
{
|
||||
$parts = array_filter(array_map('trim', explode(',', $existingCsv)), static fn (string $s): bool => $s !== '');
|
||||
if (! in_array($newValue, $parts, true)) {
|
||||
$parts[] = $newValue;
|
||||
}
|
||||
|
||||
return array_values($parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function removeValueFromCsv(string $existingCsv, string $valueToRemove): array
|
||||
{
|
||||
$needle = trim($valueToRemove);
|
||||
$parts = array_filter(array_map('trim', explode(',', $existingCsv)), static fn (string $s): bool => $s !== '');
|
||||
|
||||
if ($needle === '') {
|
||||
return array_values($parts);
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$parts,
|
||||
static fn (string $s): bool => $s !== $needle
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -202,7 +202,8 @@ final class MailwizzService
|
||||
$out = [];
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$out[$key] = $value;
|
||||
// Empty arrays are omitted by Laravel's multipart encoder (no KEY[] parts), so Mailwizz never clears checkboxlist fields.
|
||||
$out[$key] = $value === [] ? '' : $value;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
40
app/Services/MailwizzSubscriberFormPayload.php
Normal file
40
app/Services/MailwizzSubscriberFormPayload.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\Subscriber;
|
||||
|
||||
/**
|
||||
* Shared Mailwizz list subscriber field payload for create/update (excluding tag merge logic).
|
||||
*/
|
||||
final class MailwizzSubscriberFormPayload
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function baseFields(Subscriber $subscriber, MailwizzConfig $config, bool $phoneEnabled): array
|
||||
{
|
||||
$data = [
|
||||
$config->field_email => $subscriber->email,
|
||||
$config->field_first_name => $subscriber->first_name,
|
||||
$config->field_last_name => $subscriber->last_name,
|
||||
];
|
||||
|
||||
if ($phoneEnabled && $config->field_phone !== null && $config->field_phone !== '') {
|
||||
$phone = $subscriber->phoneDisplay();
|
||||
if ($phone !== null && $phone !== '') {
|
||||
$data[$config->field_phone] = $phone;
|
||||
}
|
||||
}
|
||||
|
||||
$couponField = $config->field_coupon_code;
|
||||
if (is_string($couponField) && $couponField !== '' && $subscriber->coupon_code !== null && $subscriber->coupon_code !== '') {
|
||||
$data[$couponField] = $subscriber->coupon_code;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
48
app/Services/PhoneNumberNormalizer.php
Normal file
48
app/Services/PhoneNumberNormalizer.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use libphonenumber\NumberParseException;
|
||||
use libphonenumber\PhoneNumberFormat;
|
||||
use libphonenumber\PhoneNumberUtil;
|
||||
|
||||
/**
|
||||
* Parses international and national phone input and normalizes to E.164 for storage (includes leading +).
|
||||
*/
|
||||
final class PhoneNumberNormalizer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $defaultRegion
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns E.164 (e.g. +31612345678) or null if empty/invalid.
|
||||
*/
|
||||
public function normalizeToE164(?string $input): ?string
|
||||
{
|
||||
if ($input === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = trim($input);
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$util = PhoneNumberUtil::getInstance();
|
||||
|
||||
try {
|
||||
$number = $util->parse($trimmed, $this->defaultRegion);
|
||||
} catch (NumberParseException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $util->isValidNumber($number)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $util->format($number, PhoneNumberFormat::E164);
|
||||
}
|
||||
}
|
||||
251
app/Services/PreregistrationPageBlockWriter.php
Normal file
251
app/Services/PreregistrationPageBlockWriter.php
Normal file
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\PageBlock;
|
||||
use App\Models\PreregistrationPage;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PreregistrationPageBlockWriter
|
||||
{
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $blocks
|
||||
*/
|
||||
public function replaceBlocks(PreregistrationPage $page, array $blocks, Request $request): void
|
||||
{
|
||||
DB::transaction(function () use ($page, $blocks, $request): void {
|
||||
$page->load('blocks');
|
||||
$oldPaths = $this->collectBlockImagePaths($page);
|
||||
$page->blocks()->delete();
|
||||
|
||||
$orderedKeys = array_keys($blocks);
|
||||
usort($orderedKeys, function (string|int $a, string|int $b) use ($blocks): int {
|
||||
/** @var array<string, mixed> $rowA */
|
||||
$rowA = $blocks[$a];
|
||||
/** @var array<string, mixed> $rowB */
|
||||
$rowB = $blocks[$b];
|
||||
|
||||
return ((int) ($rowA['sort_order'] ?? 0)) <=> ((int) ($rowB['sort_order'] ?? 0));
|
||||
});
|
||||
|
||||
$allNewPaths = [];
|
||||
foreach ($orderedKeys as $blockKey) {
|
||||
/** @var array<string, mixed> $blockRow */
|
||||
$blockRow = $blocks[$blockKey];
|
||||
$type = (string) $blockRow['type'];
|
||||
/** @var array<string, mixed> $content */
|
||||
$content = is_array($blockRow['content'] ?? null) ? $blockRow['content'] : [];
|
||||
if ($type === 'text' && array_key_exists('body', $content) && is_string($content['body'])) {
|
||||
$content['body'] = ltrim($content['body']);
|
||||
}
|
||||
if ($type === 'image') {
|
||||
$content = $this->mergeImageBlockFiles($page, (string) $blockKey, $content, $request);
|
||||
}
|
||||
$allNewPaths = array_merge($allNewPaths, $this->pathsFromImageTypeContent($type, $content));
|
||||
|
||||
PageBlock::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'type' => $type,
|
||||
'content' => $content,
|
||||
'sort_order' => (int) ($blockRow['sort_order'] ?? 0),
|
||||
'is_visible' => (bool) ($blockRow['is_visible'] ?? true),
|
||||
]);
|
||||
}
|
||||
|
||||
$allNewPaths = array_values(array_unique($allNewPaths));
|
||||
foreach (array_diff($oldPaths, $allNewPaths) as $orphan) {
|
||||
if (is_string($orphan) && $orphan !== '') {
|
||||
Storage::disk('public')->delete($orphan);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function seedDefaultBlocks(PreregistrationPage $page): void
|
||||
{
|
||||
$rows = [
|
||||
[
|
||||
'type' => 'hero',
|
||||
'sort_order' => 0,
|
||||
'is_visible' => true,
|
||||
'content' => [
|
||||
'headline' => $page->title,
|
||||
'subheadline' => '',
|
||||
'eyebrow_text' => '',
|
||||
'eyebrow_style' => 'badge',
|
||||
'text_alignment' => 'center',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$next = 1;
|
||||
if ($page->start_date->isFuture()) {
|
||||
$rows[] = [
|
||||
'type' => 'countdown',
|
||||
'sort_order' => $next,
|
||||
'is_visible' => true,
|
||||
'content' => [
|
||||
'target_datetime' => $page->start_date->toIso8601String(),
|
||||
'title' => 'De pre-registratie opent over:',
|
||||
'expired_action' => 'reload',
|
||||
'style' => 'large',
|
||||
'show_labels' => true,
|
||||
'labels' => [
|
||||
'days' => 'dagen',
|
||||
'hours' => 'uren',
|
||||
'minutes' => 'minuten',
|
||||
'seconds' => 'seconden',
|
||||
],
|
||||
],
|
||||
];
|
||||
$next++;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'type' => 'benefits',
|
||||
'sort_order' => $next,
|
||||
'is_visible' => true,
|
||||
'content' => [
|
||||
'title' => 'Waarom voorregistreren?',
|
||||
'items' => [
|
||||
['icon' => 'ticket', 'text' => 'Exclusieve korting op tickets'],
|
||||
['icon' => 'clock', 'text' => 'Eerder toegang tot de ticketshop'],
|
||||
],
|
||||
'layout' => 'list',
|
||||
'max_columns' => 2,
|
||||
],
|
||||
];
|
||||
$next++;
|
||||
|
||||
$rows[] = [
|
||||
'type' => 'social_proof',
|
||||
'sort_order' => $next,
|
||||
'is_visible' => true,
|
||||
'content' => [
|
||||
'template' => 'Al {count} bezoekers aangemeld!',
|
||||
'min_count' => 10,
|
||||
'show_animation' => true,
|
||||
'style' => 'pill',
|
||||
],
|
||||
];
|
||||
$next++;
|
||||
|
||||
$rows[] = [
|
||||
'type' => 'form',
|
||||
'sort_order' => $next,
|
||||
'is_visible' => true,
|
||||
'content' => [
|
||||
'title' => 'Registreer nu',
|
||||
'description' => '',
|
||||
'button_label' => 'Registreer nu!',
|
||||
'button_color' => '#F47B20',
|
||||
'button_text_color' => '#FFFFFF',
|
||||
'fields' => [
|
||||
'first_name' => [
|
||||
'enabled' => true,
|
||||
'required' => true,
|
||||
'label' => 'Voornaam',
|
||||
'placeholder' => 'Je voornaam',
|
||||
],
|
||||
'last_name' => [
|
||||
'enabled' => true,
|
||||
'required' => true,
|
||||
'label' => 'Achternaam',
|
||||
'placeholder' => 'Je achternaam',
|
||||
],
|
||||
'email' => [
|
||||
'enabled' => true,
|
||||
'required' => true,
|
||||
'label' => 'E-mailadres',
|
||||
'placeholder' => 'je@email.nl',
|
||||
],
|
||||
'phone' => [
|
||||
'enabled' => false,
|
||||
'required' => false,
|
||||
'label' => 'Mobiel',
|
||||
'placeholder' => '+31 6 12345678',
|
||||
],
|
||||
],
|
||||
'show_field_icons' => true,
|
||||
'privacy_text' => 'Door je te registreren ga je akkoord met onze privacyverklaring.',
|
||||
'privacy_url' => '',
|
||||
],
|
||||
];
|
||||
|
||||
$page->blocks()->createMany($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function collectBlockImagePaths(PreregistrationPage $page): array
|
||||
{
|
||||
$paths = [];
|
||||
foreach ($page->blocks as $b) {
|
||||
if ($b->type !== 'image') {
|
||||
continue;
|
||||
}
|
||||
$paths = array_merge($paths, $this->pathsFromImageTypeContent('image', is_array($b->content) ? $b->content : []));
|
||||
}
|
||||
|
||||
return array_values(array_unique($paths));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $content
|
||||
* @return list<string>
|
||||
*/
|
||||
private function pathsFromImageTypeContent(string $type, array $content): array
|
||||
{
|
||||
if ($type !== 'image') {
|
||||
return [];
|
||||
}
|
||||
$p = data_get($content, 'image');
|
||||
if (is_string($p) && $p !== '') {
|
||||
return [$p];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $content
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mergeImageBlockFiles(PreregistrationPage $page, string $blockKey, array $content, Request $request): array
|
||||
{
|
||||
$disk = Storage::disk('public');
|
||||
$dir = "preregister/pages/{$page->id}";
|
||||
|
||||
$link = $content['link_url'] ?? null;
|
||||
$content['link_url'] = is_string($link) && trim($link) !== '' ? trim($link) : null;
|
||||
|
||||
if ($request->boolean("blocks.$blockKey.remove_block_image")) {
|
||||
$prev = $content['image'] ?? null;
|
||||
if (is_string($prev) && $prev !== '') {
|
||||
$disk->delete($prev);
|
||||
}
|
||||
$content['image'] = null;
|
||||
}
|
||||
|
||||
$file = $request->file("blocks.$blockKey.block_image");
|
||||
if ($this->isValidUpload($file)) {
|
||||
if (isset($content['image']) && is_string($content['image']) && $content['image'] !== '') {
|
||||
$disk->delete($content['image']);
|
||||
}
|
||||
$content['image'] = $file->store($dir, 'public');
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function isValidUpload(?UploadedFile $file): bool
|
||||
{
|
||||
return $file !== null && $file->isValid();
|
||||
}
|
||||
}
|
||||
59
app/Services/RegisterSubscriberOnPage.php
Normal file
59
app/Services/RegisterSubscriberOnPage.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\IssueWeeztixCouponForSubscriber;
|
||||
use App\Jobs\SyncSubscriberToMailwizz;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
use App\Models\WeeztixConfig;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Orchestrates public registration: local persist first, then queue external integrations
|
||||
* so Weeztix/Mailwizz failures never prevent a subscriber row from being stored.
|
||||
*/
|
||||
final class RegisterSubscriberOnPage
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
public function storeAndQueueIntegrations(PreregistrationPage $page, array $validated): Subscriber
|
||||
{
|
||||
$subscriber = $page->subscribers()->create($validated);
|
||||
|
||||
$page->loadMissing('weeztixConfig', 'mailwizzConfig');
|
||||
$weeztix = $page->weeztixConfig;
|
||||
|
||||
try {
|
||||
if ($this->weeztixCanIssueCodes($weeztix)) {
|
||||
IssueWeeztixCouponForSubscriber::dispatch($subscriber)->afterResponse();
|
||||
} elseif ($page->mailwizzConfig !== null) {
|
||||
SyncSubscriberToMailwizz::dispatch($subscriber->fresh())->afterResponse();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::error('RegisterSubscriberOnPage: could not queue integration jobs', [
|
||||
'subscriber_id' => $subscriber->id,
|
||||
'preregistration_page_id' => $page->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $subscriber;
|
||||
}
|
||||
|
||||
private function weeztixCanIssueCodes(?WeeztixConfig $config): bool
|
||||
{
|
||||
if ($config === null || ! $config->is_connected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$company = $config->company_guid;
|
||||
$coupon = $config->coupon_guid;
|
||||
|
||||
return is_string($company) && $company !== '' && is_string($coupon) && $coupon !== '';
|
||||
}
|
||||
}
|
||||
737
app/Services/WeeztixService.php
Normal file
737
app/Services/WeeztixService.php
Normal file
@@ -0,0 +1,737 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\WeeztixCouponCodeConflictException;
|
||||
use App\Models\WeeztixConfig;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use InvalidArgumentException;
|
||||
use LogicException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class WeeztixService
|
||||
{
|
||||
public function __construct(
|
||||
private WeeztixConfig $config
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Bearer token for API calls, refreshing from the stored refresh token when needed.
|
||||
*/
|
||||
public function getValidAccessToken(): string
|
||||
{
|
||||
if ($this->config->access_token && ! $this->config->isTokenExpired()) {
|
||||
return (string) $this->config->access_token;
|
||||
}
|
||||
|
||||
$this->refreshAccessToken();
|
||||
$this->config->refresh();
|
||||
|
||||
$token = $this->config->access_token;
|
||||
if (! is_string($token) || $token === '') {
|
||||
throw new RuntimeException('Weeztix access token missing after refresh.');
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{guid: string, name: string|null}>
|
||||
*/
|
||||
public function getCompanies(): array
|
||||
{
|
||||
$token = $this->getValidAccessToken();
|
||||
$url = config('weeztix.user_profile_url');
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])
|
||||
->acceptJson()
|
||||
->get($url);
|
||||
|
||||
if ($response->status() === 401) {
|
||||
$this->refreshAccessToken();
|
||||
$this->config->refresh();
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.(string) $this->config->access_token,
|
||||
])
|
||||
->acceptJson()
|
||||
->get($url);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->logFailedResponse('getCompanies', $url, $response);
|
||||
|
||||
throw new RuntimeException('Weeztix user profile request failed: '.$response->status());
|
||||
}
|
||||
|
||||
Log::debug('Weeztix API', [
|
||||
'action' => 'getCompanies',
|
||||
'url' => $url,
|
||||
'http_status' => $response->status(),
|
||||
]);
|
||||
|
||||
$json = $response->json();
|
||||
if (! is_array($json)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->normalizeCompaniesFromProfile($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync company_guid/name from the Weeztix user profile for the current access token (after OAuth).
|
||||
* Runs on every successful connect or reconnect so a different company chosen in Weeztix is stored here.
|
||||
* Uses the first company returned from the profile when several are present.
|
||||
*/
|
||||
public function ensureCompanyStoredFromWeeztix(): void
|
||||
{
|
||||
$this->config->refresh();
|
||||
|
||||
try {
|
||||
$companies = $this->getCompanies();
|
||||
if ($companies === []) {
|
||||
Log::warning('Weeztix: geen bedrijf uit profiel voor automatische koppeling.', [
|
||||
'weeztix_config_id' => $this->config->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$row = $companies[0];
|
||||
$this->config->update([
|
||||
'company_guid' => $row['guid'],
|
||||
'company_name' => $row['name'],
|
||||
]);
|
||||
$this->config->refresh();
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Weeztix: automatisch bedrijf vastleggen mislukt', [
|
||||
'weeztix_config_id' => $this->config->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange OAuth authorization code for tokens (admin callback).
|
||||
*/
|
||||
public function exchangeAuthorizationCode(string $code): void
|
||||
{
|
||||
$redirectUri = $this->config->redirect_uri;
|
||||
if (! is_string($redirectUri) || $redirectUri === '') {
|
||||
throw new LogicException('Weeztix redirect_uri is not set.');
|
||||
}
|
||||
|
||||
$tokenUrl = config('weeztix.auth_base_url').'/tokens';
|
||||
$response = Http::asForm()->post($tokenUrl, [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => $this->config->client_id,
|
||||
'client_secret' => $this->config->client_secret,
|
||||
'redirect_uri' => $redirectUri,
|
||||
'code' => $code,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Weeztix OAuth code exchange failed', [
|
||||
'url' => $tokenUrl,
|
||||
'status' => $response->status(),
|
||||
'body' => $response->json(),
|
||||
]);
|
||||
|
||||
throw new RuntimeException('Weeztix OAuth code exchange failed: '.$response->status());
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
if (! is_array($json)) {
|
||||
throw new RuntimeException('Weeztix token response was not valid JSON.');
|
||||
}
|
||||
|
||||
$this->applyTokenResponseToConfig($json);
|
||||
$this->hydrateCompanyFromTokenInfo($json);
|
||||
|
||||
Log::debug('Weeztix API', [
|
||||
'action' => 'oauth_authorization_code',
|
||||
'url' => $tokenUrl,
|
||||
'http_status' => $response->status(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getCoupons(): array
|
||||
{
|
||||
$this->assertCompanyGuid();
|
||||
|
||||
$url = config('weeztix.api_base_url').'/coupon';
|
||||
|
||||
return $this->apiRequest('get', $url, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a single coupon code on the coupon selected in config.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @throws WeeztixCouponCodeConflictException When the code already exists (HTTP 409).
|
||||
*/
|
||||
public function createCouponCode(string $code): array
|
||||
{
|
||||
$this->assertCompanyGuid();
|
||||
$couponGuid = $this->config->coupon_guid;
|
||||
if (! is_string($couponGuid) || $couponGuid === '') {
|
||||
throw new LogicException('Weeztix coupon is not configured.');
|
||||
}
|
||||
|
||||
$url = config('weeztix.api_base_url').'/coupon/'.$couponGuid.'/codes';
|
||||
|
||||
$payload = [
|
||||
'usage_count' => $this->config->usage_count,
|
||||
'applies_to_count' => null,
|
||||
'codes' => [
|
||||
['code' => $code],
|
||||
],
|
||||
];
|
||||
|
||||
$rateAttempts = 3;
|
||||
for ($rateAttempt = 0; $rateAttempt < $rateAttempts; $rateAttempt++) {
|
||||
$token = $this->getValidAccessToken();
|
||||
$response = $this->sendApiRequest('put', $url, $payload, $token);
|
||||
|
||||
if ($response->status() === 401) {
|
||||
$this->refreshAccessToken();
|
||||
$this->config->refresh();
|
||||
$response = $this->sendApiRequest('put', $url, $payload, (string) $this->config->access_token);
|
||||
}
|
||||
|
||||
if ($response->status() === 429) {
|
||||
$waitSeconds = min(8, 2 ** $rateAttempt);
|
||||
Log::warning('Weeztix API rate limited', [
|
||||
'url' => $url,
|
||||
'retry_in_seconds' => $waitSeconds,
|
||||
]);
|
||||
sleep($waitSeconds);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($response->status() === 409) {
|
||||
throw new WeeztixCouponCodeConflictException('Weeztix coupon code already exists.');
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->logFailedResponse('createCouponCode', $url, $response);
|
||||
|
||||
throw new RuntimeException('Weeztix API request failed: '.$response->status());
|
||||
}
|
||||
|
||||
Log::debug('Weeztix API', [
|
||||
'action' => 'createCouponCode',
|
||||
'url' => $url,
|
||||
'http_status' => $response->status(),
|
||||
]);
|
||||
|
||||
$json = $response->json();
|
||||
|
||||
return is_array($json) ? $json : [];
|
||||
}
|
||||
|
||||
Log::error('Weeztix API rate limited after retries', ['url' => $url]);
|
||||
|
||||
throw new RuntimeException('Weeztix API rate limited after retries.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists coupon codes for the coupon selected in config (GET /coupon/{guid}/codes).
|
||||
*
|
||||
* @return list<array{guid: string, code: string}>
|
||||
*/
|
||||
public function listCouponCodesForConfiguredCoupon(): array
|
||||
{
|
||||
$this->assertCompanyGuid();
|
||||
$couponGuid = $this->config->coupon_guid;
|
||||
if (! is_string($couponGuid) || $couponGuid === '') {
|
||||
throw new LogicException('Weeztix coupon is not configured.');
|
||||
}
|
||||
|
||||
$url = config('weeztix.api_base_url').'/coupon/'.$couponGuid.'/codes';
|
||||
$json = $this->apiRequest('get', $url, []);
|
||||
|
||||
return $this->normalizeCouponCodeListResponse($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-deletes a coupon code in Weeztix by matching the human-readable code string.
|
||||
*/
|
||||
public function deleteCouponCodeByCodeString(string $code): void
|
||||
{
|
||||
$trimmed = trim($code);
|
||||
if ($trimmed === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->assertCompanyGuid();
|
||||
$couponGuid = $this->config->coupon_guid;
|
||||
if (! is_string($couponGuid) || $couponGuid === '') {
|
||||
throw new LogicException('Weeztix coupon is not configured.');
|
||||
}
|
||||
|
||||
$rows = $this->listCouponCodesForConfiguredCoupon();
|
||||
$codeGuid = null;
|
||||
foreach ($rows as $row) {
|
||||
if (strcasecmp($row['code'], $trimmed) === 0) {
|
||||
$codeGuid = $row['guid'];
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($codeGuid === null) {
|
||||
Log::info('Weeztix: coupon code not found when deleting (already removed or unknown)', [
|
||||
'code' => $trimmed,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$url = config('weeztix.api_base_url').'/coupon/'.$couponGuid.'/codes/'.$codeGuid;
|
||||
$token = $this->getValidAccessToken();
|
||||
$response = $this->sendApiRequest('delete', $url, [], $token);
|
||||
|
||||
if ($response->status() === 401) {
|
||||
$this->refreshAccessToken();
|
||||
$this->config->refresh();
|
||||
$response = $this->sendApiRequest('delete', $url, [], (string) $this->config->access_token);
|
||||
}
|
||||
|
||||
if ($response->status() === 404) {
|
||||
Log::info('Weeztix: coupon code already deleted remotely', [
|
||||
'code' => $trimmed,
|
||||
'code_guid' => $codeGuid,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->logFailedResponse('deleteCouponCodeByCodeString', $url, $response);
|
||||
|
||||
throw new RuntimeException('Weeztix API delete coupon code failed: '.$response->status());
|
||||
}
|
||||
|
||||
Log::debug('Weeztix API', [
|
||||
'action' => 'deleteCouponCodeByCodeString',
|
||||
'url' => $url,
|
||||
'http_status' => $response->status(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $json
|
||||
* @return list<array{guid: string, code: string}>
|
||||
*/
|
||||
private function normalizeCouponCodeListResponse(array $json): array
|
||||
{
|
||||
$candidates = [
|
||||
data_get($json, 'data'),
|
||||
data_get($json, 'data.codes'),
|
||||
data_get($json, 'data.records'),
|
||||
data_get($json, 'codes'),
|
||||
$json,
|
||||
];
|
||||
|
||||
foreach ($candidates as $raw) {
|
||||
if (! is_array($raw)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->isListArray($raw)) {
|
||||
$normalized = $this->normalizeCouponCodeRows($raw);
|
||||
if ($normalized !== []) {
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $rows
|
||||
* @return list<array{guid: string, code: string}>
|
||||
*/
|
||||
private function normalizeCouponCodeRows(array $rows): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$guid = data_get($row, 'guid')
|
||||
?? data_get($row, 'id')
|
||||
?? data_get($row, 'coupon_code_guid');
|
||||
$code = data_get($row, 'code')
|
||||
?? data_get($row, 'coupon_code')
|
||||
?? data_get($row, 'name');
|
||||
if (! is_string($guid) || $guid === '' || ! is_string($code) || $code === '') {
|
||||
continue;
|
||||
}
|
||||
$out[] = ['guid' => $guid, 'code' => $code];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $arr
|
||||
*/
|
||||
private function isListArray(array $arr): bool
|
||||
{
|
||||
if ($arr === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return array_keys($arr) === range(0, count($arr) - 1);
|
||||
}
|
||||
|
||||
public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string
|
||||
{
|
||||
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
$code = '';
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$code .= $chars[random_int(0, strlen($chars) - 1)];
|
||||
}
|
||||
|
||||
return strtoupper($prefix).'-'.$code;
|
||||
}
|
||||
|
||||
private function assertCompanyGuid(): void
|
||||
{
|
||||
$guid = $this->config->company_guid;
|
||||
if (! is_string($guid) || $guid === '') {
|
||||
throw new LogicException('Weeztix company is not configured.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function apiRequest(string $method, string $url, array $data = []): array
|
||||
{
|
||||
$this->assertCompanyGuid();
|
||||
|
||||
$token = $this->getValidAccessToken();
|
||||
$response = $this->sendApiRequest($method, $url, $data, $token);
|
||||
|
||||
if ($response->status() === 401) {
|
||||
$this->refreshAccessToken();
|
||||
$this->config->refresh();
|
||||
$response = $this->sendApiRequest($method, $url, $data, (string) $this->config->access_token);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->logFailedResponse('apiRequest', $url, $response);
|
||||
|
||||
throw new RuntimeException('Weeztix API request failed: '.$response->status());
|
||||
}
|
||||
|
||||
Log::debug('Weeztix API', [
|
||||
'action' => 'apiRequest',
|
||||
'method' => $method,
|
||||
'url' => $url,
|
||||
'http_status' => $response->status(),
|
||||
]);
|
||||
|
||||
$json = $response->json();
|
||||
|
||||
return is_array($json) ? $json : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function sendApiRequest(string $method, string $url, array $data, string $token): Response
|
||||
{
|
||||
$client = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
'Company' => (string) $this->config->company_guid,
|
||||
])->acceptJson();
|
||||
|
||||
return match (strtolower($method)) {
|
||||
'get' => $client->get($url, $data),
|
||||
'post' => $client->asJson()->post($url, $data),
|
||||
'put' => $client->asJson()->put($url, $data),
|
||||
'patch' => $client->asJson()->patch($url, $data),
|
||||
'delete' => $client->delete($url, $data),
|
||||
default => throw new InvalidArgumentException('Unsupported HTTP method: '.$method),
|
||||
};
|
||||
}
|
||||
|
||||
private function refreshAccessToken(): void
|
||||
{
|
||||
if (! $this->config->refresh_token || $this->config->isRefreshTokenExpired()) {
|
||||
$this->config->update([
|
||||
'is_connected' => false,
|
||||
]);
|
||||
$this->config->refresh();
|
||||
|
||||
throw new RuntimeException('Weeztix refresh token missing or expired; reconnect OAuth.');
|
||||
}
|
||||
|
||||
$tokenUrl = config('weeztix.auth_base_url').'/tokens';
|
||||
$response = Http::asForm()->post($tokenUrl, [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $this->config->refresh_token,
|
||||
'client_id' => $this->config->client_id,
|
||||
'client_secret' => $this->config->client_secret,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Weeztix token refresh failed', [
|
||||
'url' => $tokenUrl,
|
||||
'status' => $response->status(),
|
||||
'body' => $response->json(),
|
||||
]);
|
||||
$this->config->update([
|
||||
'is_connected' => false,
|
||||
]);
|
||||
$this->config->refresh();
|
||||
|
||||
throw new RuntimeException('Weeztix token refresh failed: '.$response->status());
|
||||
}
|
||||
|
||||
Log::debug('Weeztix API', [
|
||||
'action' => 'refresh_token',
|
||||
'url' => $tokenUrl,
|
||||
'http_status' => $response->status(),
|
||||
]);
|
||||
|
||||
$json = $response->json();
|
||||
if (! is_array($json)) {
|
||||
throw new RuntimeException('Weeztix token refresh returned invalid JSON.');
|
||||
}
|
||||
|
||||
$this->applyTokenResponseToConfig($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $json
|
||||
*/
|
||||
private function applyTokenResponseToConfig(array $json): void
|
||||
{
|
||||
$access = $json['access_token'] ?? null;
|
||||
$refresh = $json['refresh_token'] ?? null;
|
||||
if (! is_string($access) || $access === '') {
|
||||
throw new RuntimeException('Weeztix token response missing access_token.');
|
||||
}
|
||||
|
||||
$expiresIn = isset($json['expires_in']) ? (int) $json['expires_in'] : 0;
|
||||
$refreshExpiresIn = isset($json['refresh_token_expires_in']) ? (int) $json['refresh_token_expires_in'] : 0;
|
||||
|
||||
$updates = [
|
||||
'access_token' => $access,
|
||||
'token_expires_at' => $expiresIn > 0 ? Carbon::now()->addSeconds($expiresIn) : null,
|
||||
'is_connected' => true,
|
||||
];
|
||||
|
||||
if (is_string($refresh) && $refresh !== '') {
|
||||
$updates['refresh_token'] = $refresh;
|
||||
$updates['refresh_token_expires_at'] = $refreshExpiresIn > 0
|
||||
? Carbon::now()->addSeconds($refreshExpiresIn)
|
||||
: null;
|
||||
}
|
||||
|
||||
$this->config->update($updates);
|
||||
$this->config->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the token response includes exactly one company, store it to reduce admin steps.
|
||||
*
|
||||
* @param array<string, mixed> $json
|
||||
*/
|
||||
private function hydrateCompanyFromTokenInfo(array $json): void
|
||||
{
|
||||
$companies = data_get($json, 'info.companies');
|
||||
if (! is_array($companies) || count($companies) !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$row = $companies[0];
|
||||
if (! is_array($row)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$merged = $this->mergeCompanyRowWithNested($row);
|
||||
$guid = data_get($merged, 'guid') ?? data_get($merged, 'id');
|
||||
if (! is_string($guid) || $guid === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$name = $this->resolveCompanyNameFromRow($merged, $guid);
|
||||
|
||||
$this->config->update([
|
||||
'company_guid' => $guid,
|
||||
'company_name' => $name,
|
||||
]);
|
||||
$this->config->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $profile
|
||||
* @return list<array{guid: string, name: string|null}>
|
||||
*/
|
||||
private function normalizeCompaniesFromProfile(array $profile): array
|
||||
{
|
||||
$fromInfo = data_get($profile, 'info.companies');
|
||||
if (is_array($fromInfo) && $fromInfo !== []) {
|
||||
$normalized = $this->normalizeCompanyRows($fromInfo);
|
||||
if ($normalized !== []) {
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
$companies = data_get($profile, 'companies');
|
||||
if (is_array($companies) && $companies !== []) {
|
||||
return $this->normalizeCompanyRows($companies);
|
||||
}
|
||||
|
||||
$defaultCompany = data_get($profile, 'default_company');
|
||||
if (is_array($defaultCompany)) {
|
||||
$merged = $this->mergeCompanyRowWithNested($defaultCompany);
|
||||
$guid = data_get($merged, 'guid') ?? data_get($merged, 'id');
|
||||
if (is_string($guid) && $guid !== '') {
|
||||
return [
|
||||
[
|
||||
'guid' => $guid,
|
||||
'name' => $this->resolveCompanyNameFromRow($merged, $guid),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$defaultId = data_get($profile, 'default_company_id');
|
||||
if (is_string($defaultId) && $defaultId !== '') {
|
||||
return [
|
||||
['guid' => $defaultId, 'name' => null],
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $rows
|
||||
* @return list<array{guid: string, name: string|null}>
|
||||
*/
|
||||
private function normalizeCompanyRows(array $rows): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$merged = $this->mergeCompanyRowWithNested($row);
|
||||
$guid = data_get($merged, 'guid')
|
||||
?? data_get($merged, 'id')
|
||||
?? data_get($merged, 'company_id');
|
||||
if (! is_string($guid) || $guid === '') {
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
'guid' => $guid,
|
||||
'name' => $this->resolveCompanyNameFromRow($merged, $guid),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten `{ "company": { ... } }` style payloads so name fields resolve reliably.
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mergeCompanyRowWithNested(array $row): array
|
||||
{
|
||||
$nested = data_get($row, 'company');
|
||||
if (! is_array($nested)) {
|
||||
return $row;
|
||||
}
|
||||
|
||||
return array_merge($row, $nested);
|
||||
}
|
||||
|
||||
/**
|
||||
* Weeztix / Open Ticket payloads use varying keys; `name` is sometimes a duplicate of the GUID.
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function resolveCompanyNameFromRow(array $row, ?string $companyGuid = null): ?string
|
||||
{
|
||||
$candidates = [
|
||||
data_get($row, 'trade_name'),
|
||||
data_get($row, 'commercial_name'),
|
||||
data_get($row, 'business_name'),
|
||||
data_get($row, 'legal_name'),
|
||||
data_get($row, 'company_name'),
|
||||
data_get($row, 'display_name'),
|
||||
data_get($row, 'title'),
|
||||
data_get($row, 'label'),
|
||||
data_get($row, 'general.name'),
|
||||
data_get($row, 'company.trade_name'),
|
||||
data_get($row, 'company.legal_name'),
|
||||
data_get($row, 'company.name'),
|
||||
data_get($row, 'name'),
|
||||
];
|
||||
|
||||
foreach ($candidates as $value) {
|
||||
if (! is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
$trimmed = trim($value);
|
||||
if ($trimmed === '') {
|
||||
continue;
|
||||
}
|
||||
if ($companyGuid !== null && strcasecmp($trimmed, $companyGuid) === 0) {
|
||||
continue;
|
||||
}
|
||||
if ($this->stringLooksLikeUuid($trimmed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function stringLooksLikeUuid(string $value): bool
|
||||
{
|
||||
return preg_match(
|
||||
'/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/',
|
||||
$value
|
||||
) === 1;
|
||||
}
|
||||
|
||||
private function logFailedResponse(string $action, string $url, Response $response): void
|
||||
{
|
||||
Log::error('Weeztix API request failed', [
|
||||
'action' => $action,
|
||||
'url' => $url,
|
||||
'status' => $response->status(),
|
||||
'body' => $response->json(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"giggsey/libphonenumber-for-php": "^9.0",
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/tinker": "^3.0"
|
||||
},
|
||||
|
||||
136
composer.lock
generated
136
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "0cbc4dc77a1eeff75f257f6ffae9168b",
|
||||
"content-hash": "505a0bb04eb0eb77eddad8d9e0ef372b",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -579,6 +579,140 @@
|
||||
],
|
||||
"time": "2025-12-03T09:33:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "giggsey/libphonenumber-for-php",
|
||||
"version": "9.0.27",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/giggsey/libphonenumber-for-php.git",
|
||||
"reference": "7973753b3efe38fb57dc949a6014a4d1cfce0ffd"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/giggsey/libphonenumber-for-php/zipball/7973753b3efe38fb57dc949a6014a4d1cfce0ffd",
|
||||
"reference": "7973753b3efe38fb57dc949a6014a4d1cfce0ffd",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"giggsey/locale": "^2.7",
|
||||
"php": "^8.1",
|
||||
"symfony/polyfill-mbstring": "^1.31"
|
||||
},
|
||||
"replace": {
|
||||
"giggsey/libphonenumber-for-php-lite": "self.version"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-dom": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.71",
|
||||
"infection/infection": "^0.29|^0.31.0",
|
||||
"nette/php-generator": "^4.1",
|
||||
"php-coveralls/php-coveralls": "^2.7",
|
||||
"phpstan/extension-installer": "^1.4.3",
|
||||
"phpstan/phpstan": "^2.1.7",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0.1",
|
||||
"phpstan/phpstan-phpunit": "^2.0.4",
|
||||
"phpstan/phpstan-strict-rules": "^2.0.3",
|
||||
"phpunit/phpunit": "^10.5.45",
|
||||
"symfony/console": "^6.4",
|
||||
"symfony/filesystem": "^6.4",
|
||||
"symfony/process": "^6.4"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "9.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"libphonenumber\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Joshua Gigg",
|
||||
"email": "giggsey@gmail.com",
|
||||
"homepage": "https://giggsey.com/"
|
||||
}
|
||||
],
|
||||
"description": "A library for parsing, formatting, storing and validating international phone numbers, a PHP Port of Google's libphonenumber.",
|
||||
"homepage": "https://github.com/giggsey/libphonenumber-for-php",
|
||||
"keywords": [
|
||||
"geocoding",
|
||||
"geolocation",
|
||||
"libphonenumber",
|
||||
"mobile",
|
||||
"phonenumber",
|
||||
"validation"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/giggsey/libphonenumber-for-php/issues",
|
||||
"source": "https://github.com/giggsey/libphonenumber-for-php"
|
||||
},
|
||||
"time": "2026-04-01T12:18:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "giggsey/locale",
|
||||
"version": "2.9.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/giggsey/Locale.git",
|
||||
"reference": "fe741e99ae6ccbe8132f3d63d8ec89924e689778"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/giggsey/Locale/zipball/fe741e99ae6ccbe8132f3d63d8ec89924e689778",
|
||||
"reference": "fe741e99ae6ccbe8132f3d63d8ec89924e689778",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-json": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.66",
|
||||
"infection/infection": "^0.29|^0.32.0",
|
||||
"php-coveralls/php-coveralls": "^2.7",
|
||||
"phpstan/extension-installer": "^1.4.3",
|
||||
"phpstan/phpstan": "^2.1.7",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0.1",
|
||||
"phpstan/phpstan-phpunit": "^2.0.4",
|
||||
"phpstan/phpstan-strict-rules": "^2.0.3",
|
||||
"phpunit/phpunit": "^10.5.45",
|
||||
"symfony/console": "^6.4",
|
||||
"symfony/filesystem": "^6.4",
|
||||
"symfony/finder": "^6.4",
|
||||
"symfony/process": "^6.4",
|
||||
"symfony/var-exporter": "^6.4"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Giggsey\\Locale\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Joshua Gigg",
|
||||
"email": "giggsey@gmail.com",
|
||||
"homepage": "https://giggsey.com/"
|
||||
}
|
||||
],
|
||||
"description": "Locale functions required by libphonenumber-for-php",
|
||||
"support": {
|
||||
"issues": "https://github.com/giggsey/Locale/issues",
|
||||
"source": "https://github.com/giggsey/Locale/tree/2.9.0"
|
||||
},
|
||||
"time": "2026-02-24T15:32:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "graham-campbell/result-type",
|
||||
"version": "v1.1.4",
|
||||
|
||||
@@ -7,6 +7,18 @@ $defaultPerMinute = in_array($env, ['local', 'testing'], true) ? 1000 : 60;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default phone region (ISO 3166-1 alpha-2)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Used when parsing numbers without a country prefix (e.g. national format).
|
||||
| Override with PREREGISTER_DEFAULT_PHONE_REGION in .env.
|
||||
|
|
||||
*/
|
||||
|
||||
'default_phone_region' => strtoupper((string) env('PREREGISTER_DEFAULT_PHONE_REGION', 'NL')),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Public routes rate limit
|
||||
|
||||
57
config/weeztix.php
Normal file
57
config/weeztix.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| OAuth login / authorization (browser redirect)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Official Weeztix: users must be sent to login.weeztix.com with client_id,
|
||||
| redirect_uri, response_type=code, and state. Do NOT use auth.../tokens/authorize
|
||||
| unless your OAuth provider documents that path (e.g. some Open Ticket setups).
|
||||
|
|
||||
| Open Ticket example (if your client was created there):
|
||||
| WEEZTIX_OAUTH_AUTHORIZE_URL=https://auth.openticket.tech/tokens/authorize
|
||||
| WEEZTIX_AUTH_BASE_URL=https://auth.openticket.tech
|
||||
|
|
||||
*/
|
||||
|
||||
'oauth_authorize_url' => rtrim((string) env(
|
||||
'WEEZTIX_OAUTH_AUTHORIZE_URL',
|
||||
'https://login.weeztix.com/login'
|
||||
), '/'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Token endpoint base (authorization code + refresh)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| POST {auth_base_url}/tokens — official Weeztix: https://auth.weeztix.com/tokens
|
||||
|
|
||||
*/
|
||||
|
||||
'auth_base_url' => rtrim((string) env('WEEZTIX_AUTH_BASE_URL', 'https://auth.weeztix.com'), '/'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Weeztix API base URL
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
'api_base_url' => rtrim((string) env('WEEZTIX_API_BASE_URL', 'https://api.weeztix.com'), '/'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Current user profile (token validity + company hints)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Should match the issuer of your access_token (usually same host as auth_base_url).
|
||||
|
|
||||
*/
|
||||
|
||||
'user_profile_url' => (string) env('WEEZTIX_USER_PROFILE_URL', 'https://auth.weeztix.com/users/me'),
|
||||
|
||||
];
|
||||
@@ -25,8 +25,8 @@ class UserFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'name' => $this->faker->name(),
|
||||
'email' => $this->faker->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'remember_token' => Str::random(10),
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?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('page_blocks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('preregistration_page_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('type');
|
||||
$table->json('content');
|
||||
$table->integer('sort_order')->default(0);
|
||||
$table->boolean('is_visible')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['preregistration_page_id', 'sort_order']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('page_blocks');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\PageBlock;
|
||||
use App\Models\PreregistrationPage;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::transaction(function (): void {
|
||||
PreregistrationPage::query()->orderBy('id')->get()->each(function (PreregistrationPage $page): void {
|
||||
if ($page->blocks()->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sort = 0;
|
||||
|
||||
$heroContent = [
|
||||
'headline' => $page->heading,
|
||||
'subheadline' => $page->intro_text ?? '',
|
||||
'eyebrow_text' => '',
|
||||
'eyebrow_style' => 'badge',
|
||||
'logo_max_height' => 80,
|
||||
'background_image' => $page->background_image,
|
||||
'logo_image' => $page->logo_image,
|
||||
'overlay_color' => '#000000',
|
||||
'overlay_opacity' => 50,
|
||||
'text_alignment' => 'center',
|
||||
];
|
||||
|
||||
PageBlock::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'type' => 'hero',
|
||||
'content' => $heroContent,
|
||||
'sort_order' => $sort++,
|
||||
'is_visible' => true,
|
||||
]);
|
||||
|
||||
if ($page->start_date->isFuture()) {
|
||||
PageBlock::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'type' => 'countdown',
|
||||
'content' => [
|
||||
'target_datetime' => $page->start_date->toIso8601String(),
|
||||
'title' => 'De pre-registratie opent over:',
|
||||
'expired_action' => 'reload',
|
||||
'style' => 'large',
|
||||
'show_labels' => true,
|
||||
'labels' => [
|
||||
'days' => 'dagen',
|
||||
'hours' => 'uren',
|
||||
'minutes' => 'minuten',
|
||||
'seconds' => 'seconden',
|
||||
],
|
||||
],
|
||||
'sort_order' => $sort++,
|
||||
'is_visible' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
$formContent = [
|
||||
'title' => 'Registreer nu',
|
||||
'description' => '',
|
||||
'button_label' => 'Registreer nu!',
|
||||
'button_color' => '#F47B20',
|
||||
'button_text_color' => '#FFFFFF',
|
||||
'fields' => [
|
||||
'first_name' => [
|
||||
'enabled' => true,
|
||||
'required' => true,
|
||||
'label' => 'Voornaam',
|
||||
'placeholder' => 'Je voornaam',
|
||||
],
|
||||
'last_name' => [
|
||||
'enabled' => true,
|
||||
'required' => true,
|
||||
'label' => 'Achternaam',
|
||||
'placeholder' => 'Je achternaam',
|
||||
],
|
||||
'email' => [
|
||||
'enabled' => true,
|
||||
'required' => true,
|
||||
'label' => 'E-mailadres',
|
||||
'placeholder' => 'je@email.nl',
|
||||
],
|
||||
'phone' => [
|
||||
'enabled' => (bool) $page->phone_enabled,
|
||||
'required' => false,
|
||||
'label' => 'Mobiel',
|
||||
'placeholder' => '+31 6 12345678',
|
||||
],
|
||||
],
|
||||
'show_field_icons' => true,
|
||||
'privacy_text' => 'Door je te registreren ga je akkoord met onze privacyverklaring.',
|
||||
'privacy_url' => '',
|
||||
];
|
||||
|
||||
PageBlock::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'type' => 'form',
|
||||
'content' => $formContent,
|
||||
'sort_order' => $sort++,
|
||||
'is_visible' => true,
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
PageBlock::query()->delete();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Run only after verifying block-based editor and public pages in production.
|
||||
* Uncomment the Schema::table bodies when ready to drop legacy columns.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Schema::table('preregistration_pages', function (Blueprint $table) {
|
||||
// $table->dropColumn([
|
||||
// 'heading',
|
||||
// 'intro_text',
|
||||
// 'phone_enabled',
|
||||
// 'background_image',
|
||||
// 'logo_image',
|
||||
// ]);
|
||||
// });
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Schema::table('preregistration_pages', function (Blueprint $table) {
|
||||
// $table->string('heading')->after('title');
|
||||
// $table->text('intro_text')->nullable()->after('heading');
|
||||
// $table->boolean('phone_enabled')->default(false)->after('end_date');
|
||||
// $table->string('background_image')->nullable()->after('phone_enabled');
|
||||
// $table->string('logo_image')->nullable()->after('background_image');
|
||||
// });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\PageBlock;
|
||||
use App\Models\PreregistrationPage;
|
||||
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) {
|
||||
$table->string('background_overlay_color', 7)->default('#000000')->after('background_image');
|
||||
$table->unsignedTinyInteger('background_overlay_opacity')->default(50)->after('background_overlay_color');
|
||||
$table->string('post_submit_redirect_url')->nullable()->after('ticketshop_url');
|
||||
});
|
||||
|
||||
PreregistrationPage::query()->orderBy('id')->each(function (PreregistrationPage $page): void {
|
||||
$page->load(['blocks' => fn ($q) => $q->orderBy('sort_order')]);
|
||||
$hero = $page->blocks->firstWhere('type', 'hero');
|
||||
if ($hero === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $c */
|
||||
$c = is_array($hero->content) ? $hero->content : [];
|
||||
$pageUpdates = [];
|
||||
|
||||
if (($page->background_image === null || $page->background_image === '')
|
||||
&& isset($c['background_image']) && is_string($c['background_image']) && $c['background_image'] !== '') {
|
||||
$pageUpdates['background_image'] = $c['background_image'];
|
||||
}
|
||||
|
||||
$oc = $c['overlay_color'] ?? null;
|
||||
if (is_string($oc) && preg_match('/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/', $oc) === 1) {
|
||||
$pageUpdates['background_overlay_color'] = $oc;
|
||||
}
|
||||
|
||||
$oo = $c['overlay_opacity'] ?? null;
|
||||
if (is_numeric($oo)) {
|
||||
$pageUpdates['background_overlay_opacity'] = max(0, min(100, (int) $oo));
|
||||
}
|
||||
|
||||
if ($pageUpdates !== []) {
|
||||
$page->update($pageUpdates);
|
||||
}
|
||||
|
||||
$logoPath = $c['logo_image'] ?? null;
|
||||
unset(
|
||||
$c['logo_image'],
|
||||
$c['logo_max_height'],
|
||||
$c['background_image'],
|
||||
$c['overlay_color'],
|
||||
$c['overlay_opacity'],
|
||||
);
|
||||
|
||||
$hero->update(['content' => $c]);
|
||||
|
||||
if (is_string($logoPath) && $logoPath !== '') {
|
||||
PageBlock::query()
|
||||
->where('preregistration_page_id', $page->id)
|
||||
->where('sort_order', '>=', 1)
|
||||
->increment('sort_order');
|
||||
|
||||
PageBlock::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'type' => 'image',
|
||||
'content' => [
|
||||
'image' => $logoPath,
|
||||
'link_url' => '',
|
||||
'alt' => '',
|
||||
'max_width_px' => 240,
|
||||
'text_alignment' => 'center',
|
||||
],
|
||||
'sort_order' => 1,
|
||||
'is_visible' => true,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('preregistration_pages', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'background_overlay_color',
|
||||
'background_overlay_opacity',
|
||||
'post_submit_redirect_url',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('preregistration_pages', function (Blueprint $table): void {
|
||||
$table->boolean('background_fixed')->default(false)->after('background_overlay_opacity');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('preregistration_pages', function (Blueprint $table): void {
|
||||
$table->dropColumn('background_fixed');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Subscriber;
|
||||
use App\Services\PhoneNumberNormalizer;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('subscribers')) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var PhoneNumberNormalizer $normalizer */
|
||||
$normalizer = app(PhoneNumberNormalizer::class);
|
||||
|
||||
Subscriber::query()
|
||||
->whereNotNull('phone')
|
||||
->where('phone', '!=', '')
|
||||
->orderBy('id')
|
||||
->chunkById(100, function ($subscribers) use ($normalizer): void {
|
||||
foreach ($subscribers as $subscriber) {
|
||||
$p = $subscriber->phone;
|
||||
if (! is_string($p) || $p === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($p, '+')) {
|
||||
$normalized = $normalizer->normalizeToE164($p);
|
||||
if ($normalized !== null && $normalized !== $p) {
|
||||
$subscriber->update(['phone' => $normalized]);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{8,15}$/', $p) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized = $normalizer->normalizeToE164('+'.$p);
|
||||
if ($normalized !== null) {
|
||||
$subscriber->update(['phone' => $normalized]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Irreversible: we cannot recover original user input formatting.
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('weeztix_configs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('preregistration_page_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
$table->text('client_id');
|
||||
$table->text('client_secret');
|
||||
$table->string('redirect_uri');
|
||||
|
||||
$table->text('access_token')->nullable();
|
||||
$table->text('refresh_token')->nullable();
|
||||
$table->timestamp('token_expires_at')->nullable();
|
||||
$table->timestamp('refresh_token_expires_at')->nullable();
|
||||
|
||||
$table->string('company_guid')->nullable();
|
||||
$table->string('company_name')->nullable();
|
||||
|
||||
$table->string('coupon_guid')->nullable();
|
||||
$table->string('coupon_name')->nullable();
|
||||
|
||||
$table->string('code_prefix')->default('PREREG');
|
||||
$table->integer('usage_count')->default(1);
|
||||
|
||||
$table->boolean('is_connected')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('weeztix_configs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('subscribers', function (Blueprint $table) {
|
||||
$table->string('coupon_code')->nullable()->after('synced_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('subscribers', function (Blueprint $table) {
|
||||
$table->dropColumn('coupon_code');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('mailwizz_configs', function (Blueprint $table) {
|
||||
$table->string('field_coupon_code')->nullable()->after('field_phone');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('mailwizz_configs', function (Blueprint $table) {
|
||||
$table->dropColumn('field_coupon_code');
|
||||
});
|
||||
}
|
||||
};
|
||||
29
deploy.sh
29
deploy.sh
@@ -9,10 +9,15 @@ set -e
|
||||
# ./deploy.sh v1.2.0 → deploys specific tag
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
# !! UPDATE THIS PATH TO YOUR VPS DIRECTORY !!
|
||||
APP_DIR="/home/hausdesign/domains/preregister.hausdesign.nl/public_html"
|
||||
APP_DIR="/home/hausdesign/preregister"
|
||||
PHP="/usr/local/php84/bin/php"
|
||||
COMPOSER="/usr/local/bin/composer"
|
||||
TAG="${1:-}"
|
||||
|
||||
# Load fnm (Node version manager)
|
||||
export PATH="$HOME/.local/share/fnm:$PATH"
|
||||
eval "$(fnm env)"
|
||||
|
||||
echo "══════════════════════════════════════"
|
||||
echo " PreRegister — Deploy"
|
||||
echo "══════════════════════════════════════"
|
||||
@@ -21,7 +26,7 @@ cd "$APP_DIR"
|
||||
|
||||
# 1. Maintenance mode
|
||||
echo "→ Enabling maintenance mode..."
|
||||
php artisan down --retry=30 || true
|
||||
$PHP artisan down --retry=30 || true
|
||||
|
||||
# 2. Pull latest code
|
||||
echo "→ Pulling from Gitea..."
|
||||
@@ -38,7 +43,7 @@ fi
|
||||
|
||||
# 3. Install PHP dependencies
|
||||
echo "→ Installing Composer dependencies..."
|
||||
composer install --no-dev --optimize-autoloader --no-interaction
|
||||
$PHP $COMPOSER install --no-dev --optimize-autoloader --no-interaction
|
||||
|
||||
# 4. Install Node dependencies and build
|
||||
echo "→ Installing npm packages..."
|
||||
@@ -49,25 +54,25 @@ npm run build
|
||||
|
||||
# 5. Run migrations
|
||||
echo "→ Running migrations..."
|
||||
php artisan migrate --force
|
||||
$PHP artisan migrate --force
|
||||
|
||||
# 6. Clear and rebuild caches
|
||||
echo "→ Clearing caches..."
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
php artisan event:cache
|
||||
$PHP artisan config:cache
|
||||
$PHP artisan route:cache
|
||||
$PHP artisan view:cache
|
||||
$PHP artisan event:cache
|
||||
|
||||
# 7. Restart queue (process any pending jobs with new code)
|
||||
echo "→ Restarting queue workers..."
|
||||
php artisan queue:restart
|
||||
$PHP artisan queue:restart
|
||||
|
||||
# 8. Storage link (idempotent)
|
||||
php artisan storage:link 2>/dev/null || true
|
||||
$PHP artisan storage:link 2>/dev/null || true
|
||||
|
||||
# 9. Disable maintenance mode
|
||||
echo "→ Going live!"
|
||||
php artisan up
|
||||
$PHP artisan up
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════"
|
||||
|
||||
709
documentation/CURSOR-PROMPT-WEEZTIX.md
Normal file
709
documentation/CURSOR-PROMPT-WEEZTIX.md
Normal file
@@ -0,0 +1,709 @@
|
||||
# Cursor Prompt — Weeztix Coupon Code Integration
|
||||
|
||||
> Paste this into Cursor chat with `@Codebase` and `@PreRegister-Development-Prompt.md` as context.
|
||||
|
||||
---
|
||||
|
||||
## Role & Context
|
||||
|
||||
You are a senior full-stack developer and integration architect. You're working on the PreRegister Laravel 11 application (Blade + Tailwind CSS + Alpine.js). No React, no Vue, no Livewire.
|
||||
|
||||
The application already has a Mailwizz integration that syncs subscribers to an email marketing platform. You are now adding a **second integration: Weeztix** — a ticket sales platform. When a visitor pre-registers on a page, a unique coupon code is generated via the Weeztix API and assigned to the subscriber. This coupon code can then be forwarded to Mailwizz so the subscriber receives a personalized discount email.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Weeztix Platform Concepts
|
||||
|
||||
### Coupon vs CouponCode
|
||||
|
||||
Weeztix separates these into two resources:
|
||||
|
||||
- **Coupon** = a template/definition. Defines the discount type (percentage, fixed amount), value, what it applies to (orders or products). The Coupon is configured by the event organizer in Weeztix dashboard.
|
||||
- **CouponCode** = the actual code a visitor enters in the ticket shop to get the discount. Each CouponCode belongs to a Coupon and inherits its discount settings. CouponCodes are unique strings like `PREREG-A7X9K2`.
|
||||
|
||||
**In our flow:** The user selects an existing Coupon in the backend. When a visitor registers, we create a unique CouponCode under that Coupon via the API.
|
||||
|
||||
### Authentication: OAuth2 Authorization Code Grant
|
||||
|
||||
Weeztix uses the OAuth2 Authorization Code flow. Key details:
|
||||
|
||||
**Endpoints:**
|
||||
| Action | Method | URL |
|
||||
|---|---|---|
|
||||
| Authorize (redirect user) | GET | `https://auth.openticket.tech/tokens/authorize` |
|
||||
| Exchange code for token | POST | `https://auth.openticket.tech/tokens` |
|
||||
| Refresh token | POST | `https://auth.openticket.tech/tokens` |
|
||||
| API requests | Various | `https://api.weeztix.com/...` |
|
||||
|
||||
**Authorization redirect parameters:**
|
||||
```
|
||||
https://auth.openticket.tech/tokens/authorize?
|
||||
client_id={OAUTH_CLIENT_ID}
|
||||
&redirect_uri={OAUTH_CLIENT_REDIRECT}
|
||||
&response_type=code
|
||||
&state={random_state}
|
||||
```
|
||||
|
||||
**Token exchange (POST to `https://auth.openticket.tech/tokens`):**
|
||||
```json
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": "...",
|
||||
"client_secret": "...",
|
||||
"redirect_uri": "...",
|
||||
"code": "..."
|
||||
}
|
||||
```
|
||||
|
||||
**Token response:**
|
||||
```json
|
||||
{
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 259200,
|
||||
"access_token": "THE_ACTUAL_TOKEN",
|
||||
"refresh_token": "REFRESH_TOKEN",
|
||||
"refresh_token_expires_in": 31535999
|
||||
}
|
||||
```
|
||||
- `access_token` expires in ~3 days (259200 seconds)
|
||||
- `refresh_token` expires in ~365 days, can only be used once
|
||||
|
||||
**Refresh token (POST to `https://auth.openticket.tech/tokens`):**
|
||||
```json
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": "...",
|
||||
"client_id": "...",
|
||||
"client_secret": "..."
|
||||
}
|
||||
```
|
||||
Returns a new `access_token` and a new `refresh_token`.
|
||||
|
||||
**API requests require:**
|
||||
- Header: `Authorization: Bearer {access_token}`
|
||||
- Header: `Company: {company_guid}` (to scope requests to a specific company)
|
||||
|
||||
### API Endpoints We Need
|
||||
|
||||
| Action | Method | URL | Notes |
|
||||
|---|---|---|---|
|
||||
| Get coupons | GET | `https://api.weeztix.com/coupon` | Returns coupons for the company (Company header) |
|
||||
| Add coupon codes | POST | `https://api.weeztix.com/coupon/{coupon_guid}/couponCode` | Creates one or more codes under a coupon |
|
||||
|
||||
**Add CouponCodes request body:**
|
||||
```json
|
||||
{
|
||||
"usage_count": 1,
|
||||
"applies_to_count": null,
|
||||
"codes": [
|
||||
{
|
||||
"code": "PREREG-A7X9K2"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
- `usage_count`: how many times the code can be used (1 = single use per subscriber)
|
||||
- `applies_to_count`: null = unlimited items in the order can use the discount
|
||||
- `codes`: array of code objects, each with a unique `code` string
|
||||
|
||||
**Important:** Duplicate CouponCodes cannot be added to a Coupon. Generate unique codes.
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Database Changes
|
||||
|
||||
### New Migration: `weeztix_configs` table
|
||||
|
||||
```php
|
||||
Schema::create('weeztix_configs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('preregistration_page_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
// OAuth credentials (encrypted at rest)
|
||||
$table->text('client_id');
|
||||
$table->text('client_secret');
|
||||
$table->string('redirect_uri');
|
||||
|
||||
// OAuth tokens (encrypted at rest)
|
||||
$table->text('access_token')->nullable();
|
||||
$table->text('refresh_token')->nullable();
|
||||
$table->timestamp('token_expires_at')->nullable();
|
||||
$table->timestamp('refresh_token_expires_at')->nullable();
|
||||
|
||||
// Company context
|
||||
$table->string('company_guid')->nullable();
|
||||
$table->string('company_name')->nullable();
|
||||
|
||||
// Selected coupon
|
||||
$table->string('coupon_guid')->nullable();
|
||||
$table->string('coupon_name')->nullable();
|
||||
|
||||
// CouponCode settings
|
||||
$table->string('code_prefix')->default('PREREG'); // prefix for generated codes
|
||||
$table->integer('usage_count')->default(1); // how many times a code can be used
|
||||
|
||||
$table->boolean('is_connected')->default(false); // OAuth flow completed?
|
||||
$table->timestamps();
|
||||
});
|
||||
```
|
||||
|
||||
### Modify `subscribers` table
|
||||
|
||||
Add a column to store the generated coupon code per subscriber:
|
||||
|
||||
```php
|
||||
Schema::table('subscribers', function (Blueprint $table) {
|
||||
$table->string('coupon_code')->nullable()->after('synced_at');
|
||||
});
|
||||
```
|
||||
|
||||
### Model: `WeeztixConfig`
|
||||
|
||||
```php
|
||||
class WeeztixConfig extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'preregistration_page_id', 'client_id', 'client_secret', 'redirect_uri',
|
||||
'access_token', 'refresh_token', 'token_expires_at', 'refresh_token_expires_at',
|
||||
'company_guid', 'company_name', 'coupon_guid', 'coupon_name',
|
||||
'code_prefix', 'usage_count', 'is_connected',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'client_id' => 'encrypted',
|
||||
'client_secret' => 'encrypted',
|
||||
'access_token' => 'encrypted',
|
||||
'refresh_token' => 'encrypted',
|
||||
'token_expires_at' => 'datetime',
|
||||
'refresh_token_expires_at' => 'datetime',
|
||||
'is_connected' => 'boolean',
|
||||
];
|
||||
|
||||
public function preregistrationPage(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PreregistrationPage::class);
|
||||
}
|
||||
|
||||
public function isTokenExpired(): bool
|
||||
{
|
||||
return !$this->token_expires_at || $this->token_expires_at->isPast();
|
||||
}
|
||||
|
||||
public function isRefreshTokenExpired(): bool
|
||||
{
|
||||
return !$this->refresh_token_expires_at || $this->refresh_token_expires_at->isPast();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Update `PreregistrationPage` model
|
||||
|
||||
Add:
|
||||
```php
|
||||
public function weeztixConfig(): HasOne
|
||||
{
|
||||
return $this->hasOne(WeeztixConfig::class);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 3: WeeztixService
|
||||
|
||||
Create `app/Services/WeeztixService.php` — encapsulates all Weeztix API communication with automatic token refresh.
|
||||
|
||||
```php
|
||||
class WeeztixService
|
||||
{
|
||||
private WeeztixConfig $config;
|
||||
|
||||
public function __construct(WeeztixConfig $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid access token, refreshing if necessary.
|
||||
* Updates the config model with new tokens.
|
||||
*/
|
||||
public function getValidAccessToken(): string
|
||||
|
||||
/**
|
||||
* Refresh the access token using the refresh token.
|
||||
* Stores the new tokens in the config.
|
||||
* Throws exception if refresh token is also expired (re-auth needed).
|
||||
*/
|
||||
private function refreshAccessToken(): void
|
||||
|
||||
/**
|
||||
* Make an authenticated API request.
|
||||
* Automatically refreshes token if expired.
|
||||
*/
|
||||
private function apiRequest(string $method, string $url, array $data = []): array
|
||||
|
||||
/**
|
||||
* Get all companies the token has access to.
|
||||
* GET https://auth.weeztix.com/users/me (or similar endpoint)
|
||||
*/
|
||||
public function getCompanies(): array
|
||||
|
||||
/**
|
||||
* Get all coupons for the configured company.
|
||||
* GET https://api.weeztix.com/coupon
|
||||
* Header: Company: {company_guid}
|
||||
*/
|
||||
public function getCoupons(): array
|
||||
|
||||
/**
|
||||
* Create a unique coupon code under the configured coupon.
|
||||
* POST https://api.weeztix.com/coupon/{coupon_guid}/couponCode
|
||||
* Header: Company: {company_guid}
|
||||
*/
|
||||
public function createCouponCode(string $code): array
|
||||
|
||||
/**
|
||||
* Generate a unique code string.
|
||||
* Format: {prefix}-{random alphanumeric}
|
||||
* Example: PREREG-A7X9K2
|
||||
*/
|
||||
public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string
|
||||
}
|
||||
```
|
||||
|
||||
### Key Implementation Details for WeeztixService
|
||||
|
||||
**Token refresh logic:**
|
||||
```
|
||||
1. Check if access_token exists and is not expired
|
||||
2. If expired, check if refresh_token exists and is not expired
|
||||
3. If refresh_token valid: POST to https://auth.openticket.tech/tokens with grant_type=refresh_token
|
||||
4. Store new access_token, refresh_token, and their expiry timestamps
|
||||
5. If refresh_token also expired: mark config as disconnected, throw exception (user must re-authorize)
|
||||
```
|
||||
|
||||
**API request wrapper:**
|
||||
```php
|
||||
private function apiRequest(string $method, string $url, array $data = []): array
|
||||
{
|
||||
$token = $this->getValidAccessToken();
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => "Bearer {$token}",
|
||||
'Company' => $this->config->company_guid,
|
||||
])->{$method}($url, $data);
|
||||
|
||||
if ($response->status() === 401) {
|
||||
// Token might have been revoked, try refresh once
|
||||
$this->refreshAccessToken();
|
||||
$token = $this->config->access_token;
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => "Bearer {$token}",
|
||||
'Company' => $this->config->company_guid,
|
||||
])->{$method}($url, $data);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Weeztix API request failed', [
|
||||
'url' => $url,
|
||||
'status' => $response->status(),
|
||||
'body' => $response->json(),
|
||||
]);
|
||||
throw new \RuntimeException("Weeztix API request failed: {$response->status()}");
|
||||
}
|
||||
|
||||
return $response->json();
|
||||
}
|
||||
```
|
||||
|
||||
**Unique code generation:**
|
||||
```php
|
||||
public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string
|
||||
{
|
||||
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O/1/I to avoid confusion
|
||||
$code = '';
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$code .= $chars[random_int(0, strlen($chars) - 1)];
|
||||
}
|
||||
return strtoupper($prefix) . '-' . $code;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 4: OAuth Flow — Routes & Controller
|
||||
|
||||
### Routes
|
||||
|
||||
```php
|
||||
// Inside the admin auth middleware group:
|
||||
|
||||
// Weeztix configuration (per page)
|
||||
Route::get('pages/{page}/weeztix', [WeeztixController::class, 'edit'])->name('pages.weeztix.edit');
|
||||
Route::put('pages/{page}/weeztix', [WeeztixController::class, 'update'])->name('pages.weeztix.update');
|
||||
Route::delete('pages/{page}/weeztix', [WeeztixController::class, 'destroy'])->name('pages.weeztix.destroy');
|
||||
|
||||
// OAuth callback (needs to be accessible during OAuth flow)
|
||||
Route::get('weeztix/callback', [WeeztixOAuthController::class, 'callback'])->name('weeztix.callback');
|
||||
|
||||
// AJAX endpoints for dynamic loading
|
||||
Route::post('weeztix/coupons', [WeeztixApiController::class, 'coupons'])->name('weeztix.coupons');
|
||||
```
|
||||
|
||||
### OAuth Flow
|
||||
|
||||
**Step 1: User enters client_id and client_secret in the backend form.**
|
||||
|
||||
**Step 2: User clicks "Connect to Weeztix" button.**
|
||||
|
||||
The controller builds the authorization URL and redirects:
|
||||
```php
|
||||
public function redirect(PreregistrationPage $page)
|
||||
{
|
||||
$config = $page->weeztixConfig;
|
||||
$state = Str::random(40);
|
||||
|
||||
// Store state + page ID in session for the callback
|
||||
session(['weeztix_oauth_state' => $state, 'weeztix_page_id' => $page->id]);
|
||||
|
||||
$query = http_build_query([
|
||||
'client_id' => $config->client_id,
|
||||
'redirect_uri' => route('admin.weeztix.callback'),
|
||||
'response_type' => 'code',
|
||||
'state' => $state,
|
||||
]);
|
||||
|
||||
return redirect("https://auth.openticket.tech/tokens/authorize?{$query}");
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Weeztix redirects back to our callback URL with `code` and `state`.**
|
||||
|
||||
```php
|
||||
public function callback(Request $request)
|
||||
{
|
||||
// Verify state
|
||||
$storedState = session('weeztix_oauth_state');
|
||||
$pageId = session('weeztix_page_id');
|
||||
abort_if($request->state !== $storedState, 403, 'Invalid state');
|
||||
|
||||
// Exchange code for tokens
|
||||
$response = Http::post('https://auth.openticket.tech/tokens', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => $config->client_id,
|
||||
'client_secret' => $config->client_secret,
|
||||
'redirect_uri' => route('admin.weeztix.callback'),
|
||||
'code' => $request->code,
|
||||
]);
|
||||
|
||||
// Store tokens in weeztix_configs
|
||||
$config->update([
|
||||
'access_token' => $data['access_token'],
|
||||
'refresh_token' => $data['refresh_token'],
|
||||
'token_expires_at' => now()->addSeconds($data['expires_in']),
|
||||
'refresh_token_expires_at' => now()->addSeconds($data['refresh_token_expires_in']),
|
||||
'is_connected' => true,
|
||||
]);
|
||||
|
||||
// Redirect back to the Weeztix config page
|
||||
return redirect()->route('admin.pages.weeztix.edit', $pageId)
|
||||
->with('success', 'Successfully connected to Weeztix!');
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: After OAuth, the user selects a Company and Coupon (see Part 5).**
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Backend Configuration UI
|
||||
|
||||
### Weeztix Configuration Page (tab/section within page edit)
|
||||
|
||||
Build a multi-step configuration interface similar to the Mailwizz config:
|
||||
|
||||
```
|
||||
┌─── Weeztix Integration ────────────────────────────────┐
|
||||
│ │
|
||||
│ Step 1: OAuth Credentials │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ Client ID: [________________________] │ │
|
||||
│ │ Client Secret: [________________________] │ │
|
||||
│ │ Redirect URI: https://preregister.crewli.nl │ │
|
||||
│ │ /admin/weeztix/callback │ │
|
||||
│ │ (auto-generated, read-only) │ │
|
||||
│ │ │ │
|
||||
│ │ [Connect to Weeztix] ← OAuth redirect button │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Step 2: Select Coupon (shown after OAuth success) │
|
||||
│ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ Status: ✅ Connected │ │
|
||||
│ │ Token expires: 2026-04-07 14:30 │ │
|
||||
│ │ │ │
|
||||
│ │ Coupon: [▼ Select a coupon ] │ │
|
||||
│ │ - Early Bird 20% discount │ │
|
||||
│ │ - Pre-register €5 korting │ │
|
||||
│ │ │ │
|
||||
│ │ Code Prefix: [PREREG ] │ │
|
||||
│ │ Usage per Code: [1] │ │
|
||||
│ │ │ │
|
||||
│ │ [Save Configuration] │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Disconnect Weeztix] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Instructions to show the user:**
|
||||
> "Before connecting, create an OAuth Client in your Weeztix dashboard and set the redirect URI to: `{route('admin.weeztix.callback')}`. Also create a Coupon with the desired discount settings. You will select this Coupon here after connecting."
|
||||
|
||||
**Loading Coupons (AJAX):**
|
||||
After successful OAuth, use Alpine.js to fetch coupons via:
|
||||
```
|
||||
POST /admin/weeztix/coupons
|
||||
Body: { page_id: ... }
|
||||
```
|
||||
The controller uses `WeeztixService::getCoupons()` and returns the list as JSON.
|
||||
|
||||
---
|
||||
|
||||
## Part 6: Frontend — Coupon Code Generation on Registration
|
||||
|
||||
### Updated Subscription Flow
|
||||
|
||||
When a visitor registers on a public page:
|
||||
|
||||
```
|
||||
1. Validate form input
|
||||
2. Check for duplicate email
|
||||
3. Store subscriber in database
|
||||
4. IF Weeztix is configured AND connected:
|
||||
a. Generate a unique coupon code: PREREG-A7X9K2
|
||||
b. Create the coupon code in Weeztix via API
|
||||
c. Store the coupon code on the subscriber record
|
||||
5. IF Mailwizz is configured:
|
||||
a. Dispatch SyncSubscriberToMailwizz job
|
||||
b. The job should include the coupon_code in the Mailwizz subscriber data
|
||||
(if a Mailwizz field mapping for coupon_code is configured)
|
||||
6. Return success with thank-you message
|
||||
```
|
||||
|
||||
### Job: `CreateWeeztixCouponCode`
|
||||
|
||||
Create a queued job (or execute synchronously if speed is critical — the visitor should see the coupon code in the thank-you message):
|
||||
|
||||
```php
|
||||
class CreateWeeztixCouponCode implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
private Subscriber $subscriber
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$page = $this->subscriber->preregistrationPage;
|
||||
$config = $page->weeztixConfig;
|
||||
|
||||
if (!$config || !$config->is_connected || !$config->coupon_guid) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = new WeeztixService($config);
|
||||
$code = WeeztixService::generateUniqueCode($config->code_prefix);
|
||||
|
||||
try {
|
||||
$service->createCouponCode($code);
|
||||
|
||||
$this->subscriber->update(['coupon_code' => $code]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to create Weeztix coupon code', [
|
||||
'subscriber_id' => $this->subscriber->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e; // Let the job retry
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**IMPORTANT DECISION: Sync vs Async**
|
||||
|
||||
If the coupon code should be shown in the thank-you message immediately after registration, the Weeztix API call must be **synchronous** (not queued). In that case, call the service directly in `PublicPageController@subscribe` instead of dispatching a job:
|
||||
|
||||
```php
|
||||
// In PublicPageController@subscribe, after storing the subscriber:
|
||||
if ($page->weeztixConfig && $page->weeztixConfig->is_connected) {
|
||||
try {
|
||||
$service = new WeeztixService($page->weeztixConfig);
|
||||
$code = WeeztixService::generateUniqueCode($page->weeztixConfig->code_prefix);
|
||||
$service->createCouponCode($code);
|
||||
$subscriber->update(['coupon_code' => $code]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Weeztix coupon creation failed', ['error' => $e->getMessage()]);
|
||||
// Don't fail the registration — the subscriber is already saved
|
||||
}
|
||||
}
|
||||
|
||||
// Then dispatch the Mailwizz sync job (which includes the coupon_code)
|
||||
if ($page->mailwizzConfig) {
|
||||
SyncSubscriberToMailwizz::dispatch($subscriber->fresh());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $page->thank_you_message ?? 'Bedankt voor je registratie!',
|
||||
'coupon_code' => $subscriber->coupon_code, // null if Weeztix not configured
|
||||
]);
|
||||
```
|
||||
|
||||
### Show Coupon Code in Thank-You State
|
||||
|
||||
Update the Alpine.js success state to display the coupon code if present:
|
||||
|
||||
```html
|
||||
<div x-show="submitted" class="text-center text-white">
|
||||
<p x-text="successMessage"></p>
|
||||
|
||||
<template x-if="couponCode">
|
||||
<div class="mt-6 bg-white/10 backdrop-blur rounded-xl p-6 border border-white/20">
|
||||
<p class="text-sm text-white/70 mb-2">Jouw kortingscode:</p>
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<span class="text-2xl font-mono font-bold tracking-wider text-orange-400"
|
||||
x-text="couponCode"></span>
|
||||
<button @click="copyCode()"
|
||||
class="text-white/50 hover:text-white transition">
|
||||
<!-- Copy icon (Heroicon clipboard) -->
|
||||
<svg>...</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 mt-3">
|
||||
Gebruik deze code bij het afrekenen in de ticketshop.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 7: Mailwizz Integration — Forward Coupon Code
|
||||
|
||||
### Add Coupon Code Field Mapping to Mailwizz Config
|
||||
|
||||
Add a new field to `mailwizz_configs`:
|
||||
|
||||
```php
|
||||
// New migration
|
||||
Schema::table('mailwizz_configs', function (Blueprint $table) {
|
||||
$table->string('field_coupon_code')->nullable()->after('field_phone');
|
||||
// This maps to a Mailwizz custom field (e.g., 'COUPON' tag)
|
||||
});
|
||||
```
|
||||
|
||||
### Update Mailwizz Configuration Wizard
|
||||
|
||||
In the field mapping step, add an additional mapping:
|
||||
- **Coupon Code** → show text fields from the Mailwizz list → let user select the field where the coupon code should be stored (e.g., a custom field with tag `COUPON`)
|
||||
- This mapping is optional — only shown if Weeztix is also configured
|
||||
|
||||
### Update SyncSubscriberToMailwizz Job
|
||||
|
||||
When building the Mailwizz subscriber data array, include the coupon code if the mapping exists:
|
||||
|
||||
```php
|
||||
// In the sync job, when building the data array:
|
||||
if ($config->field_coupon_code && $subscriber->coupon_code) {
|
||||
$data[$config->field_coupon_code] = $subscriber->coupon_code;
|
||||
}
|
||||
```
|
||||
|
||||
This allows the Mailwizz email template to include `[COUPON]` as a merge tag, so each subscriber receives their personal coupon code in the email.
|
||||
|
||||
---
|
||||
|
||||
## Part 8: Subscriber Management — Show Coupon Codes
|
||||
|
||||
### Update Subscribers Index
|
||||
|
||||
Add the coupon code column to the subscribers table in the backend:
|
||||
|
||||
| First Name | Last Name | Email | Phone | Coupon Code | Synced | Registered |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Bert | Hausmans | bert@... | +316... | PREREG-A7X9K2 | ✅ | 2026-04-04 |
|
||||
|
||||
### Update CSV Export
|
||||
|
||||
Add the `coupon_code` column to the CSV export.
|
||||
|
||||
---
|
||||
|
||||
## Part 9: Implementation Order
|
||||
|
||||
### Step 1: Database
|
||||
- Create `weeztix_configs` migration
|
||||
- Add `coupon_code` to `subscribers` migration
|
||||
- Add `field_coupon_code` to `mailwizz_configs` migration
|
||||
- Create `WeeztixConfig` model
|
||||
- Update `PreregistrationPage` model with `weeztixConfig` relationship
|
||||
- Run migrations
|
||||
|
||||
### Step 2: WeeztixService
|
||||
- Create `app/Services/WeeztixService.php`
|
||||
- Implement token management (get valid token, refresh, detect expiry)
|
||||
- Implement `getCoupons()` and `createCouponCode()`
|
||||
- Add unique code generation
|
||||
|
||||
### Step 3: OAuth Flow
|
||||
- Create `WeeztixOAuthController` (redirect + callback)
|
||||
- Add routes for OAuth
|
||||
- Handle state validation and token storage
|
||||
|
||||
### Step 4: Backend Configuration UI
|
||||
- Create `WeeztixController` (edit, update, destroy)
|
||||
- Create `WeeztixApiController` (coupons AJAX endpoint)
|
||||
- Create Blade view `admin/weeztix/edit.blade.php`
|
||||
- Build the multi-step form with Alpine.js
|
||||
- Add link/tab in the page edit navigation
|
||||
|
||||
### Step 5: Frontend Coupon Generation
|
||||
- Update `PublicPageController@subscribe` to generate coupon codes
|
||||
- Update Alpine.js success state to show coupon code
|
||||
- Add copy-to-clipboard functionality
|
||||
|
||||
### Step 6: Mailwizz Coupon Forwarding
|
||||
- Update Mailwizz config migration and model
|
||||
- Update Mailwizz configuration wizard (add coupon_code field mapping)
|
||||
- Update `SyncSubscriberToMailwizz` job to include coupon code
|
||||
|
||||
### Step 7: Backend Updates
|
||||
- Update subscribers index to show coupon codes
|
||||
- Update CSV export to include coupon codes
|
||||
- Add Weeztix connection status indicator on the pages index
|
||||
|
||||
### Step 8: Error Handling & Edge Cases
|
||||
- Handle Weeztix API downtime (don't fail registration)
|
||||
- Handle expired refresh tokens (show "Reconnect" button)
|
||||
- Handle duplicate coupon codes (retry with new code)
|
||||
- Handle Weeztix rate limiting
|
||||
- Log all API interactions
|
||||
|
||||
---
|
||||
|
||||
## Important Rules
|
||||
|
||||
- **OAuth credentials must be encrypted** — use Laravel's `encrypted` cast
|
||||
- **Token refresh must be automatic** — the user should never have to manually refresh
|
||||
- **Registration must not fail** if Weeztix is down — coupon code is a bonus, not a requirement
|
||||
- **Coupon codes must be unique** — use the unambiguous character set (no 0/O/1/I)
|
||||
- **Blade + Alpine.js + Tailwind only** — no additional JS frameworks
|
||||
- **All text in Dutch** — labels, messages, instructions
|
||||
- **Don't break existing integrations** — Mailwizz sync must continue working, form blocks must remain functional
|
||||
- **Refer to the Weeztix API documentation** for exact request/response formats:
|
||||
- Authentication: https://docs.weeztix.com/docs/introduction/authentication/
|
||||
- Request token: https://docs.weeztix.com/docs/introduction/authentication/request-token
|
||||
- Refresh token: https://docs.weeztix.com/docs/introduction/authentication/refresh-token
|
||||
- Get Coupons: https://docs.weeztix.com/api/dashboard/get-coupons
|
||||
- Add CouponCodes: https://docs.weeztix.com/api/dashboard/add-coupon-codes
|
||||
- Issuing requests (Company header): https://docs.weeztix.com/docs/introduction/issue-request/
|
||||
@@ -269,6 +269,12 @@ Laravel ships with a `public/.htaccess` that works with Apache. Verify `mod_rewr
|
||||
|
||||
### 4.7 Set up cron for queue worker + scheduler
|
||||
|
||||
Public registration saves the subscriber in the database first, then queues **Weeztix** (coupon code) and **Mailwizz** sync jobs. Those jobs must be processed by a worker.
|
||||
|
||||
- **Visitor-facing behaviour:** the public subscribe endpoint returns **HTTP 200 with `success: true`** as soon as the subscriber row is stored. Failures in Mailwizz or Weeztix are **logged** (and visible via failed jobs when using a real queue); they do **not** change the JSON shown to the visitor. Use logs and admin resync to diagnose integration issues.
|
||||
- **Production:** set `QUEUE_CONNECTION=database` (or `redis`) so retries and `queue:failed` work as intended. `sync` is acceptable for small installs but runs integration work in-process; still, the visitor should not see 5xx from a broken Mailwizz/Weeztix API after subscribe.
|
||||
- **Queues:** coupon jobs use `weeztix`; Mailwizz uses `mailwizz`. The worker should listen to both (order below prioritises `weeztix` so coupon creation tends to run before sync when both are pending).
|
||||
|
||||
In DirectAdmin → Cron Jobs, add:
|
||||
|
||||
```
|
||||
@@ -276,9 +282,11 @@ In DirectAdmin → Cron Jobs, add:
|
||||
* * * * * cd /home/username/preregister && php artisan schedule:run >> /dev/null 2>&1
|
||||
|
||||
# Queue worker - process one job per run (every minute)
|
||||
* * * * * cd /home/username/preregister && php artisan queue:work --once --queue=mailwizz >> /dev/null 2>&1
|
||||
* * * * * cd /home/username/preregister && php artisan queue:work --once --queue=weeztix,mailwizz,default >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
For higher throughput, use a persistent supervisor-managed worker instead of `--once` in cron; keep the same `--queue=weeztix,mailwizz,default` order.
|
||||
|
||||
### 4.8 Directory permissions
|
||||
|
||||
```bash
|
||||
|
||||
13
lang/nl.json
13
lang/nl.json
@@ -14,9 +14,20 @@
|
||||
"Sending…": "Bezig met verzenden…",
|
||||
"Something went wrong. Please try again.": "Er ging iets mis. Probeer het opnieuw.",
|
||||
"This pre-registration period has ended.": "Deze preregistratieperiode is afgelopen.",
|
||||
"You will be redirected in :seconds s…": "Je wordt over :seconds seconden doorgestuurd…",
|
||||
"Visit ticket shop": "Ga naar de ticketshop",
|
||||
"Thank you for registering!": "Bedankt voor je registratie!",
|
||||
"You are already registered for this event.": "Je bent al geregistreerd voor dit evenement.",
|
||||
"Please enter a valid email address.": "Voer een geldig e-mailadres in.",
|
||||
"Please enter a valid phone number (8–15 digits).": "Voer een geldig telefoonnummer in (8 tot 15 cijfers)."
|
||||
"Please enter a valid phone number (8–15 digits).": "Voer een geldig telefoonnummer in (8 tot 15 cijfers).",
|
||||
"Please enter a valid phone number.": "Voer een geldig telefoonnummer in.",
|
||||
"Subscriber removed.": "Abonnee verwijderd.",
|
||||
"Delete this subscriber? This cannot be undone.": "Deze abonnee verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||
"Remove": "Verwijderen",
|
||||
"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."
|
||||
}
|
||||
|
||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -4,6 +4,9 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"sortablejs": "^1.15.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.2",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
@@ -2488,6 +2491,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sortablejs": {
|
||||
"version": "1.15.7",
|
||||
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
|
||||
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
||||
@@ -17,5 +17,8 @@
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.1.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"sortablejs": "^1.15.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import './bootstrap';
|
||||
|
||||
import Alpine from 'alpinejs';
|
||||
import Sortable from 'sortablejs';
|
||||
|
||||
/**
|
||||
* Client-side email check (aligned loosely with RFC-style rules; server is authoritative).
|
||||
@@ -32,11 +33,364 @@ function isValidEmailFormat(value) {
|
||||
return Boolean(tld && tld.length >= 2);
|
||||
}
|
||||
|
||||
function newBlockUid() {
|
||||
return `n_${crypto.randomUUID().replaceAll('-', '')}`;
|
||||
}
|
||||
|
||||
function deepClone(o) {
|
||||
return JSON.parse(JSON.stringify(o));
|
||||
}
|
||||
|
||||
/** Default `content` payloads for admin block editor (Dutch defaults). */
|
||||
function pageBlockDefaultContent(type) {
|
||||
const defaults = {
|
||||
hero: {
|
||||
headline: '',
|
||||
subheadline: '',
|
||||
eyebrow_text: '',
|
||||
eyebrow_style: 'badge',
|
||||
text_alignment: 'center',
|
||||
},
|
||||
image: {
|
||||
image: null,
|
||||
link_url: '',
|
||||
alt: '',
|
||||
max_width_px: 320,
|
||||
text_alignment: 'center',
|
||||
},
|
||||
benefits: {
|
||||
title: 'Waarom voorregistreren?',
|
||||
items: [
|
||||
{ icon: 'ticket', text: 'Exclusieve korting op tickets' },
|
||||
{ icon: 'clock', text: 'Eerder toegang tot de ticketshop' },
|
||||
],
|
||||
layout: 'list',
|
||||
max_columns: 2,
|
||||
},
|
||||
social_proof: {
|
||||
template: 'Al {count} bezoekers aangemeld!',
|
||||
min_count: 10,
|
||||
show_animation: true,
|
||||
style: 'pill',
|
||||
},
|
||||
form: {
|
||||
title: 'Registreer nu',
|
||||
description: '',
|
||||
button_label: 'Registreer nu!',
|
||||
button_color: '#F47B20',
|
||||
button_text_color: '#FFFFFF',
|
||||
fields: {
|
||||
first_name: {
|
||||
enabled: true,
|
||||
required: true,
|
||||
label: 'Voornaam',
|
||||
placeholder: 'Je voornaam',
|
||||
},
|
||||
last_name: {
|
||||
enabled: true,
|
||||
required: true,
|
||||
label: 'Achternaam',
|
||||
placeholder: 'Je achternaam',
|
||||
},
|
||||
email: {
|
||||
enabled: true,
|
||||
required: true,
|
||||
label: 'E-mailadres',
|
||||
placeholder: 'je@email.nl',
|
||||
},
|
||||
phone: {
|
||||
enabled: false,
|
||||
required: false,
|
||||
label: 'Mobiel',
|
||||
placeholder: '+31 6 12345678',
|
||||
},
|
||||
},
|
||||
show_field_icons: true,
|
||||
privacy_text: 'Door je te registreren ga je akkoord met onze privacyverklaring.',
|
||||
privacy_url: '',
|
||||
},
|
||||
countdown: {
|
||||
target_datetime: new Date(Date.now() + 86400000).toISOString().slice(0, 16),
|
||||
title: 'De pre-registratie opent over:',
|
||||
expired_action: 'reload',
|
||||
expired_message: '',
|
||||
style: 'large',
|
||||
show_labels: true,
|
||||
labels: {
|
||||
days: 'dagen',
|
||||
hours: 'uren',
|
||||
minutes: 'minuten',
|
||||
seconds: 'seconden',
|
||||
},
|
||||
},
|
||||
text: {
|
||||
title: '',
|
||||
body: '',
|
||||
text_size: 'base',
|
||||
text_alignment: 'center',
|
||||
},
|
||||
cta_banner: {
|
||||
text: '',
|
||||
button_label: 'Ga naar de ticketshop',
|
||||
button_url: 'https://',
|
||||
button_color: '#F47B20',
|
||||
style: 'inline',
|
||||
},
|
||||
divider: {
|
||||
style: 'line',
|
||||
spacing: 'medium',
|
||||
},
|
||||
};
|
||||
return deepClone(defaults[type] || { _: '' });
|
||||
}
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('pageBlockEditor', (config) => ({
|
||||
blocks: config.initialBlocks || [],
|
||||
blockTypes: config.blockTypes || [],
|
||||
storageBase: typeof config.storageBase === 'string' ? config.storageBase.replace(/\/$/, '') : '',
|
||||
sortable: null,
|
||||
collapsed: {},
|
||||
confirmDeleteUid: null,
|
||||
_verticalDragLock: null,
|
||||
|
||||
init() {
|
||||
this.blocks.forEach((b) => {
|
||||
this.collapsed[b.uid] = true;
|
||||
});
|
||||
this.$nextTick(() => {
|
||||
const el = this.$refs.sortRoot;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const lockGhostToVerticalAxis = () => {
|
||||
requestAnimationFrame(() => {
|
||||
const ghost = Sortable.ghost;
|
||||
if (!ghost) {
|
||||
return;
|
||||
}
|
||||
const raw = window.getComputedStyle(ghost).transform;
|
||||
if (!raw || raw === 'none') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const m = new DOMMatrix(raw);
|
||||
if (m.m41 !== 0) {
|
||||
m.m41 = 0;
|
||||
ghost.style.transform = m.toString();
|
||||
}
|
||||
} catch {
|
||||
/* ignore invalid matrix */
|
||||
}
|
||||
});
|
||||
};
|
||||
this.sortable = Sortable.create(el, {
|
||||
handle: '.block-drag-handle',
|
||||
animation: 150,
|
||||
direction: 'vertical',
|
||||
forceFallback: true,
|
||||
fallbackOnBody: true,
|
||||
onStart: () => {
|
||||
this._verticalDragLock = lockGhostToVerticalAxis;
|
||||
document.addEventListener('mousemove', this._verticalDragLock, false);
|
||||
document.addEventListener('touchmove', this._verticalDragLock, { passive: true });
|
||||
},
|
||||
onEnd: () => {
|
||||
if (this._verticalDragLock) {
|
||||
document.removeEventListener('mousemove', this._verticalDragLock, false);
|
||||
document.removeEventListener('touchmove', this._verticalDragLock, { passive: true });
|
||||
this._verticalDragLock = null;
|
||||
}
|
||||
this.syncSortOrderFromDom();
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
syncSortOrderFromDom() {
|
||||
const root = this.$refs.sortRoot;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
const rows = [...root.querySelectorAll('[data-block-uid]')];
|
||||
rows.forEach((row, i) => {
|
||||
const uid = row.getAttribute('data-block-uid');
|
||||
const b = this.blocks.find((x) => x.uid === uid);
|
||||
if (b) {
|
||||
b.sort_order = i;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
hasFormBlock() {
|
||||
return this.blocks.some((b) => b.type === 'form');
|
||||
},
|
||||
|
||||
toggleCollapsed(uid) {
|
||||
this.collapsed[uid] = !this.collapsed[uid];
|
||||
},
|
||||
|
||||
isCollapsed(uid) {
|
||||
return !!this.collapsed[uid];
|
||||
},
|
||||
|
||||
collapseAll() {
|
||||
this.blocks.forEach((b) => {
|
||||
this.collapsed[b.uid] = true;
|
||||
});
|
||||
},
|
||||
|
||||
expandAll() {
|
||||
this.blocks.forEach((b) => {
|
||||
this.collapsed[b.uid] = false;
|
||||
});
|
||||
},
|
||||
|
||||
blockSummary(block) {
|
||||
const c = block.content || {};
|
||||
const pick = (...keys) => {
|
||||
for (const k of keys) {
|
||||
const v = c[k];
|
||||
if (typeof v === 'string' && v.trim() !== '') {
|
||||
return v.trim().slice(0, 88);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
switch (block.type) {
|
||||
case 'hero':
|
||||
return pick('headline');
|
||||
case 'benefits':
|
||||
return pick('title');
|
||||
case 'form':
|
||||
return pick('title');
|
||||
case 'countdown':
|
||||
return pick('title');
|
||||
case 'text':
|
||||
return pick('title') || pick('body');
|
||||
case 'cta_banner':
|
||||
return pick('text') || pick('button_label');
|
||||
case 'social_proof':
|
||||
return pick('template');
|
||||
case 'image':
|
||||
return pick('alt');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
},
|
||||
|
||||
addBlock(type) {
|
||||
if (type === 'form' && this.hasFormBlock()) {
|
||||
return;
|
||||
}
|
||||
const nextOrder =
|
||||
this.blocks.length === 0
|
||||
? 0
|
||||
: Math.max(...this.blocks.map((b) => Number(b.sort_order) || 0)) + 1;
|
||||
const uid = newBlockUid();
|
||||
this.blocks.push({
|
||||
uid,
|
||||
type,
|
||||
sort_order: nextOrder,
|
||||
is_visible: true,
|
||||
content: pageBlockDefaultContent(type),
|
||||
});
|
||||
this.collapsed[uid] = false;
|
||||
this.$nextTick(() => this.syncSortOrderFromDom());
|
||||
},
|
||||
|
||||
removeBlock(uid) {
|
||||
this.blocks = this.blocks.filter((b) => b.uid !== uid);
|
||||
delete this.collapsed[uid];
|
||||
this.confirmDeleteUid = null;
|
||||
this.$nextTick(() => this.syncSortOrderFromDom());
|
||||
},
|
||||
|
||||
requestDelete(uid) {
|
||||
this.confirmDeleteUid = uid;
|
||||
},
|
||||
|
||||
cancelDelete() {
|
||||
this.confirmDeleteUid = null;
|
||||
},
|
||||
|
||||
addBenefitItem(uid) {
|
||||
const b = this.blocks.find((x) => x.uid === uid);
|
||||
if (!b || b.type !== 'benefits') {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(b.content.items)) {
|
||||
b.content.items = [];
|
||||
}
|
||||
b.content.items.push({ icon: 'check', text: '' });
|
||||
},
|
||||
|
||||
removeBenefitItem(uid, idx) {
|
||||
const b = this.blocks.find((x) => x.uid === uid);
|
||||
if (!b || !Array.isArray(b.content.items)) {
|
||||
return;
|
||||
}
|
||||
b.content.items.splice(idx, 1);
|
||||
},
|
||||
}));
|
||||
|
||||
Alpine.data('countdownBlock', (cfg) => ({
|
||||
targetMs: cfg.targetMs,
|
||||
expired: false,
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
showLabels: cfg.showLabels !== false,
|
||||
labels: cfg.labels || {},
|
||||
expiredAction: cfg.expiredAction || 'hide',
|
||||
expiredMessage: cfg.expiredMessage || '',
|
||||
timer: null,
|
||||
|
||||
init() {
|
||||
this.tick();
|
||||
this.timer = setInterval(() => this.tick(), 1000);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this.timer !== null) {
|
||||
clearInterval(this.timer);
|
||||
}
|
||||
},
|
||||
|
||||
tick() {
|
||||
const diff = this.targetMs - Date.now();
|
||||
if (diff <= 0) {
|
||||
if (this.timer !== null) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.expired = true;
|
||||
if (this.expiredAction === 'hide' && this.$el) {
|
||||
this.$el.classList.add('hidden');
|
||||
}
|
||||
if (this.expiredAction === 'reload') {
|
||||
window.location.reload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const totalSeconds = Math.floor(diff / 1000);
|
||||
this.days = Math.floor(totalSeconds / 86400);
|
||||
this.hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||
this.minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
this.seconds = totalSeconds % 60;
|
||||
},
|
||||
|
||||
pad(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
},
|
||||
}));
|
||||
|
||||
Alpine.data('publicPreregisterPage', (config) => ({
|
||||
phase: config.phase,
|
||||
startAtMs: config.startAtMs,
|
||||
phoneEnabled: config.phoneEnabled,
|
||||
phoneRequired: config.phoneRequired === true,
|
||||
subscribeUrl: config.subscribeUrl,
|
||||
csrfToken: config.csrfToken,
|
||||
genericError: config.genericError,
|
||||
@@ -57,6 +411,28 @@ document.addEventListener('alpine:init', () => {
|
||||
formError: '',
|
||||
fieldErrors: {},
|
||||
thankYouMessage: '',
|
||||
formButtonLabel: config.formButtonLabel || '',
|
||||
formButtonColor: config.formButtonColor || '#F47B20',
|
||||
formButtonTextColor: config.formButtonTextColor || '#FFFFFF',
|
||||
pageShareUrl: config.pageShareUrl || '',
|
||||
copyFeedback: '',
|
||||
redirectUrl: config.redirectUrl || '',
|
||||
redirectSecondsLeft: null,
|
||||
redirectTimer: null,
|
||||
strings: config.strings || {},
|
||||
|
||||
copyPageLink() {
|
||||
const url = this.pageShareUrl;
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
this.copyFeedback = config.strings?.linkCopied || '';
|
||||
setTimeout(() => {
|
||||
this.copyFeedback = '';
|
||||
}, 2500);
|
||||
});
|
||||
},
|
||||
|
||||
init() {
|
||||
if (this.phase === 'before') {
|
||||
@@ -65,6 +441,34 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this.countdownTimer !== null) {
|
||||
clearInterval(this.countdownTimer);
|
||||
this.countdownTimer = null;
|
||||
}
|
||||
if (this.redirectTimer !== null) {
|
||||
clearInterval(this.redirectTimer);
|
||||
this.redirectTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
startRedirectCountdownIfNeeded() {
|
||||
if (!this.redirectUrl || String(this.redirectUrl).trim() === '') {
|
||||
return;
|
||||
}
|
||||
this.redirectSecondsLeft = 5;
|
||||
this.redirectTimer = setInterval(() => {
|
||||
this.redirectSecondsLeft--;
|
||||
if (this.redirectSecondsLeft <= 0) {
|
||||
if (this.redirectTimer !== null) {
|
||||
clearInterval(this.redirectTimer);
|
||||
this.redirectTimer = null;
|
||||
}
|
||||
window.location.assign(String(this.redirectUrl));
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
tickCountdown() {
|
||||
const start = this.startAtMs;
|
||||
const now = Date.now();
|
||||
@@ -96,10 +500,16 @@ document.addEventListener('alpine:init', () => {
|
||||
ok = false;
|
||||
}
|
||||
if (this.phoneEnabled) {
|
||||
const digits = String(this.phone).replace(/\D/g, '');
|
||||
if (digits.length > 0 && (digits.length < 8 || digits.length > 15)) {
|
||||
const trimmed = String(this.phone).trim();
|
||||
if (this.phoneRequired && trimmed === '') {
|
||||
this.fieldErrors.phone = [this.invalidPhoneMsg];
|
||||
ok = false;
|
||||
} else if (trimmed !== '') {
|
||||
const digits = trimmed.replace(/\D/g, '');
|
||||
if (digits.length < 8 || digits.length > 15) {
|
||||
this.fieldErrors.phone = [this.invalidPhoneMsg];
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
@@ -142,14 +552,24 @@ document.addEventListener('alpine:init', () => {
|
||||
if (res.ok && data.success) {
|
||||
this.phase = 'thanks';
|
||||
this.thankYouMessage = data.message ?? '';
|
||||
this.startRedirectCountdownIfNeeded();
|
||||
return;
|
||||
}
|
||||
if (typeof data.message === 'string' && data.message !== '') {
|
||||
const hasServerMessage = typeof data.message === 'string' && data.message !== '';
|
||||
const hasFieldErrors =
|
||||
data.errors !== undefined &&
|
||||
data.errors !== null &&
|
||||
typeof data.errors === 'object' &&
|
||||
Object.keys(data.errors).length > 0;
|
||||
if (hasServerMessage) {
|
||||
this.formError = data.message;
|
||||
}
|
||||
if (data.errors !== undefined && data.errors !== null && typeof data.errors === 'object') {
|
||||
if (hasFieldErrors) {
|
||||
this.fieldErrors = data.errors;
|
||||
}
|
||||
if (!res.ok && !hasServerMessage && !hasFieldErrors) {
|
||||
this.formError = this.genericError;
|
||||
}
|
||||
} catch {
|
||||
this.formError = this.genericError;
|
||||
} finally {
|
||||
@@ -163,6 +583,7 @@ document.addEventListener('alpine:init', () => {
|
||||
fieldsUrl: cfg.fieldsUrl,
|
||||
phoneEnabled: cfg.phoneEnabled,
|
||||
hasExistingConfig: cfg.hasExistingConfig,
|
||||
hasWeeztixIntegration: cfg.hasWeeztixIntegration === true,
|
||||
existing: cfg.existing,
|
||||
csrf: cfg.csrf,
|
||||
step: 1,
|
||||
@@ -175,6 +596,7 @@ document.addEventListener('alpine:init', () => {
|
||||
fieldFirstName: '',
|
||||
fieldLastName: '',
|
||||
fieldPhone: '',
|
||||
fieldCouponCode: '',
|
||||
tagField: '',
|
||||
tagValue: '',
|
||||
loading: false,
|
||||
@@ -186,6 +608,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.fieldFirstName = this.existing.field_first_name ?? '';
|
||||
this.fieldLastName = this.existing.field_last_name ?? '';
|
||||
this.fieldPhone = this.existing.field_phone ?? '';
|
||||
this.fieldCouponCode = this.existing.field_coupon_code ?? '';
|
||||
this.tagField = this.existing.tag_field ?? '';
|
||||
this.tagValue = this.existing.tag_value ?? '';
|
||||
this.selectedListUid = this.existing.list_uid ?? '';
|
||||
@@ -295,6 +718,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.fieldFirstName = this.existing.field_first_name || this.fieldFirstName;
|
||||
this.fieldLastName = this.existing.field_last_name || this.fieldLastName;
|
||||
this.fieldPhone = this.existing.field_phone || this.fieldPhone;
|
||||
this.fieldCouponCode = this.existing.field_coupon_code || this.fieldCouponCode;
|
||||
this.tagField = this.existing.tag_field || this.tagField;
|
||||
this.tagValue = this.existing.tag_value || this.tagValue;
|
||||
}
|
||||
@@ -335,6 +759,107 @@ document.addEventListener('alpine:init', () => {
|
||||
this.$refs.saveForm.requestSubmit();
|
||||
},
|
||||
}));
|
||||
|
||||
Alpine.data('weeztixSetup', (cfg) => ({
|
||||
pageId: cfg.pageId,
|
||||
couponsUrl: cfg.couponsUrl,
|
||||
csrf: cfg.csrf,
|
||||
isConnected: cfg.isConnected === true,
|
||||
callbackUrl: cfg.callbackUrl,
|
||||
errorMessage: '',
|
||||
coupons: [],
|
||||
couponGuid: '',
|
||||
couponName: '',
|
||||
codePrefix: 'PREREG',
|
||||
usageCount: 1,
|
||||
couponsRefreshing: false,
|
||||
strings: cfg.strings || {},
|
||||
|
||||
async init() {
|
||||
if (cfg.existing) {
|
||||
this.codePrefix = cfg.existing.code_prefix || 'PREREG';
|
||||
const uc = cfg.existing.usage_count;
|
||||
if (typeof uc === 'number' && !Number.isNaN(uc)) {
|
||||
this.usageCount = uc;
|
||||
} else if (uc !== null && uc !== undefined && String(uc).trim() !== '') {
|
||||
const parsed = parseInt(String(uc), 10);
|
||||
this.usageCount = Number.isNaN(parsed) ? 1 : parsed;
|
||||
} else {
|
||||
this.usageCount = 1;
|
||||
}
|
||||
this.couponGuid = cfg.existing.coupon_guid || '';
|
||||
this.couponName = cfg.existing.coupon_name || '';
|
||||
}
|
||||
if (this.isConnected) {
|
||||
await this.loadCoupons();
|
||||
} else if (cfg.existing && cfg.existing.coupon_guid) {
|
||||
this.ensureSelectedCouponInList();
|
||||
}
|
||||
},
|
||||
|
||||
async postJson(url, body) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': this.csrf,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { res, data };
|
||||
},
|
||||
|
||||
syncCouponName() {
|
||||
if (!this.couponGuid) {
|
||||
this.couponName = '';
|
||||
return;
|
||||
}
|
||||
const c = this.coupons.find((x) => x.guid === this.couponGuid);
|
||||
if (c && typeof c.name === 'string' && c.name.trim() !== '') {
|
||||
this.couponName = c.name.trim();
|
||||
}
|
||||
},
|
||||
|
||||
ensureSelectedCouponInList() {
|
||||
const guid = this.couponGuid;
|
||||
if (!guid || this.coupons.some((x) => x.guid === guid)) {
|
||||
return;
|
||||
}
|
||||
const label =
|
||||
typeof this.couponName === 'string' && this.couponName.trim() !== ''
|
||||
? this.couponName.trim()
|
||||
: guid;
|
||||
this.coupons = [{ guid, name: label }, ...this.coupons];
|
||||
},
|
||||
|
||||
async loadCoupons() {
|
||||
this.errorMessage = '';
|
||||
const { res, data } = await this.postJson(this.couponsUrl, { page_id: this.pageId });
|
||||
if (!res.ok) {
|
||||
this.errorMessage = data.message || this.strings.loadCouponsError;
|
||||
this.ensureSelectedCouponInList();
|
||||
return;
|
||||
}
|
||||
this.coupons = Array.isArray(data.coupons) ? data.coupons : [];
|
||||
this.ensureSelectedCouponInList();
|
||||
this.syncCouponName();
|
||||
},
|
||||
|
||||
async refreshCoupons() {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
this.couponsRefreshing = true;
|
||||
try {
|
||||
await this.loadCoupons();
|
||||
} finally {
|
||||
this.couponsRefreshing = false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
window.Alpine = Alpine;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
@php
|
||||
$config = $page->mailwizzConfig;
|
||||
$page->loadMissing('weeztixConfig');
|
||||
$hasWeeztixForCouponMap = $page->weeztixConfig !== null && $page->weeztixConfig->is_connected;
|
||||
$mailwizzStatus = $page->mailwizzIntegrationStatus();
|
||||
$existing = $config !== null
|
||||
? [
|
||||
'list_uid' => $config->list_uid,
|
||||
@@ -8,6 +11,7 @@
|
||||
'field_first_name' => $config->field_first_name,
|
||||
'field_last_name' => $config->field_last_name,
|
||||
'field_phone' => $config->field_phone,
|
||||
'field_coupon_code' => $config->field_coupon_code,
|
||||
'tag_field' => $config->tag_field,
|
||||
'tag_value' => $config->tag_value,
|
||||
]
|
||||
@@ -21,29 +25,15 @@
|
||||
@section('mobile_title', __('Mailwizz'))
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-3xl" x-data="mailwizzWizard(@js([
|
||||
'listsUrl' => route('admin.mailwizz.lists'),
|
||||
'fieldsUrl' => route('admin.mailwizz.fields'),
|
||||
'csrf' => csrf_token(),
|
||||
'phoneEnabled' => (bool) $page->phone_enabled,
|
||||
'hasExistingConfig' => $config !== null,
|
||||
'existing' => $existing,
|
||||
'strings' => [
|
||||
'apiKeyRequired' => __('Enter your Mailwizz API key to continue.'),
|
||||
'genericError' => __('Something went wrong. Please try again.'),
|
||||
'noListsError' => __('No mailing lists were returned. Check your API key or create a list in Mailwizz.'),
|
||||
'selectListError' => __('Select a mailing list.'),
|
||||
'mapFieldsError' => __('Map email, first name, and last name to Mailwizz fields.'),
|
||||
'tagFieldError' => __('Select a checkbox list field for source / tag tracking.'),
|
||||
'tagValueError' => __('Select the tag option that identifies this pre-registration.'),
|
||||
],
|
||||
]))">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="mb-8">
|
||||
<a href="{{ route('admin.pages.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">← {{ __('Back to page') }}</a>
|
||||
<h1 class="mt-4 text-2xl font-semibold text-slate-900">{{ __('Mailwizz') }}</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">{{ __('Page:') }} <span class="font-medium text-slate-800">{{ $page->title }}</span></p>
|
||||
</div>
|
||||
|
||||
@include('admin.pages._save_flash')
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900" role="alert">
|
||||
<p class="font-medium">{{ __('Please fix the following:') }}</p>
|
||||
@@ -55,32 +45,154 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($config !== null)
|
||||
<div class="mb-8 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
|
||||
<p class="font-medium">{{ __('Integration active') }}</p>
|
||||
<p class="mt-1 text-emerald-800">
|
||||
{{ __('List:') }}
|
||||
<span class="font-mono text-xs">{{ $config->list_name ?: $config->list_uid }}</span>
|
||||
</p>
|
||||
<form action="{{ route('admin.pages.mailwizz.destroy', $page) }}" method="post" class="mt-3"
|
||||
onsubmit="return confirm(@js(__('Remove Mailwizz integration for this page? Subscribers will stay in the database but will no longer sync.')));">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-sm font-semibold text-red-700 underline hover:text-red-800">
|
||||
{{ __('Remove integration') }}
|
||||
</button>
|
||||
</form>
|
||||
@if (! $showWizard && $config !== null)
|
||||
@if ($mailwizzStatus !== 'ready')
|
||||
<div class="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||
<p class="font-medium">{{ __('Setup incomplete') }}</p>
|
||||
<p class="mt-1 text-amber-900">{{ __('Run the wizard again to finish Mailwizz (API key, list, and field mapping).') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mb-6 flex flex-wrap gap-2 text-xs font-medium text-slate-500">
|
||||
<span :class="step >= 1 ? 'text-indigo-600' : ''">1. {{ __('API key') }}</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
<span :class="step >= 2 ? 'text-indigo-600' : ''">2. {{ __('List') }}</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
<span :class="step >= 3 ? 'text-indigo-600' : ''">3. {{ __('Field mapping') }}</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
<span :class="step >= 4 ? 'text-indigo-600' : ''">4. {{ __('Tag / source') }}</span>
|
||||
<div class="mb-6 flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href="{{ route('admin.pages.mailwizz.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]) }}"
|
||||
class="inline-flex rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500"
|
||||
>
|
||||
{{ __('Change settings (wizard)') }}
|
||||
</a>
|
||||
<form action="{{ route('admin.pages.mailwizz.destroy', $page) }}" method="post" class="inline"
|
||||
onsubmit="return confirm(@js(__('Remove Mailwizz integration for this page? Subscribers will stay in the database but will no longer sync.')));">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="rounded-lg border border-red-200 bg-white px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm hover:bg-red-50">
|
||||
{{ __('Disconnect Mailwizz') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ __('Current configuration') }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">{{ __('The API key is stored encrypted and is not shown here.') }}</p>
|
||||
|
||||
<dl class="mt-6 space-y-4 border-t border-slate-100 pt-6 text-sm">
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Connection') }}</dt>
|
||||
<dd>
|
||||
@if ($mailwizzStatus === 'ready')
|
||||
<span class="inline-flex rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">{{ __('Ready to sync') }}</span>
|
||||
@else
|
||||
<span class="inline-flex rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-medium text-amber-900">{{ __('Incomplete') }}</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Mailing list') }}</dt>
|
||||
<dd class="text-slate-800">{{ $config->list_name ?: '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('List UID') }}</dt>
|
||||
<dd class="break-all font-mono text-xs text-slate-600">{{ $config->list_uid ?: '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<h3 class="mt-8 border-t border-slate-100 pt-6 text-sm font-semibold text-slate-900">{{ __('Field mapping') }}</h3>
|
||||
<p class="mt-1 text-xs text-slate-500">{{ __('Mailwizz custom fields are matched by tag.') }}</p>
|
||||
<dl class="mt-4 space-y-4 text-sm">
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Email') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ $config->field_email ?: '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('First name') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ $config->field_first_name ?: '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Last name') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ $config->field_last_name ?: '—' }}</dd>
|
||||
</div>
|
||||
@if ($page->isPhoneFieldEnabledForSubscribers())
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Phone') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ filled($config->field_phone) ? $config->field_phone : '—' }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
@if ($hasWeeztixForCouponMap)
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Kortingscode (Weeztix)') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ filled($config->field_coupon_code) ? $config->field_coupon_code : '—' }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Tag / source (checkbox list)') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ filled($config->tag_field) ? $config->tag_field : '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Source tag option') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ filled($config->tag_value) ? $config->tag_value : '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
x-data="mailwizzWizard(@js([
|
||||
'listsUrl' => route('admin.mailwizz.lists'),
|
||||
'fieldsUrl' => route('admin.mailwizz.fields'),
|
||||
'csrf' => csrf_token(),
|
||||
'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 class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
|
||||
@@ -88,6 +200,7 @@
|
||||
|
||||
{{-- Step 1 --}}
|
||||
<div x-show="step === 1" x-cloak class="space-y-4">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ __('Step 1: API key') }}</h2>
|
||||
<p class="text-sm leading-relaxed text-slate-600">
|
||||
{{ __('First, create a mailing list in Mailwizz with the required custom fields. Add a custom field of type Checkbox List with an option value you will use to track this pre-registration source.') }}
|
||||
</p>
|
||||
@@ -120,6 +233,7 @@
|
||||
|
||||
{{-- Step 2 --}}
|
||||
<div x-show="step === 2" x-cloak class="space-y-4">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ __('Step 2: Mailing list') }}</h2>
|
||||
<div>
|
||||
<label for="mailwizz_list" class="block text-sm font-medium text-slate-700">{{ __('Mailing list') }}</label>
|
||||
<select
|
||||
@@ -152,6 +266,7 @@
|
||||
|
||||
{{-- Step 3 --}}
|
||||
<div x-show="step === 3" x-cloak class="space-y-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ __('Step 3: Field mapping') }}</h2>
|
||||
<p class="text-sm text-slate-600">{{ __('Map each local field to the matching Mailwizz custom field (by tag).') }}</p>
|
||||
|
||||
<div>
|
||||
@@ -190,6 +305,16 @@
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="hasWeeztixIntegration">
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Kortingscode (Weeztix)') }} <span class="font-normal text-slate-500">({{ __('optional') }})</span></label>
|
||||
<p class="mt-1 text-xs text-slate-500">{{ __('Koppel aan een tekstveld in Mailwizz om de persoonlijke code in e-mails te tonen.') }}</p>
|
||||
<select x-model="fieldCouponCode" class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<option value="">{{ __('Select field…') }}</option>
|
||||
<template x-for="f in textFields()" :key="'cp-' + f.tag">
|
||||
<option :value="f.tag" x-text="f.label + ' (' + f.tag + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Tag / source (checkbox list)') }}</label>
|
||||
<select
|
||||
@@ -219,6 +344,7 @@
|
||||
|
||||
{{-- Step 4 --}}
|
||||
<div x-show="step === 4" x-cloak class="space-y-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ __('Step 4: Tag / source') }}</h2>
|
||||
<p class="text-sm text-slate-600">{{ __('Choose the checkbox option that marks subscribers from this pre-registration page.') }}</p>
|
||||
|
||||
<fieldset class="space-y-2">
|
||||
@@ -244,6 +370,7 @@
|
||||
<input type="hidden" name="field_first_name" x-bind:value="fieldFirstName">
|
||||
<input type="hidden" name="field_last_name" x-bind:value="fieldLastName">
|
||||
<input type="hidden" name="field_phone" x-bind:value="phoneEnabled ? fieldPhone : ''">
|
||||
<input type="hidden" name="field_coupon_code" x-bind:value="hasWeeztixIntegration ? fieldCouponCode : ''">
|
||||
<input type="hidden" name="tag_field" x-bind:value="tagField">
|
||||
<input type="hidden" name="tag_value" x-bind:value="tagValue">
|
||||
|
||||
@@ -259,4 +386,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
485
resources/views/admin/pages/_blocks_editor.blade.php
Normal file
485
resources/views/admin/pages/_blocks_editor.blade.php
Normal file
@@ -0,0 +1,485 @@
|
||||
@php
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
$blockEditorState = $page->blocks->map(function ($b) {
|
||||
$content = $b->content ?? [];
|
||||
if ($b->type === 'countdown' && ! empty($content['target_datetime'])) {
|
||||
try {
|
||||
$content['target_datetime'] = Carbon::parse($content['target_datetime'])
|
||||
->timezone(config('app.timezone'))
|
||||
->format('Y-m-d\TH:i');
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'uid' => 'b'.$b->id,
|
||||
'type' => $b->type,
|
||||
'sort_order' => $b->sort_order,
|
||||
'is_visible' => $b->is_visible,
|
||||
'content' => $content,
|
||||
];
|
||||
})->values()->all();
|
||||
|
||||
$blockTypesMeta = [
|
||||
['type' => 'hero', 'label' => __('Hero-sectie'), 'hint' => __('Kop & subkop')],
|
||||
['type' => 'image', 'label' => __('Afbeelding'), 'hint' => __('Logo of beeld, optionele link')],
|
||||
['type' => 'benefits', 'label' => __('Voordelen'), 'hint' => __('Lijst met USPs')],
|
||||
['type' => 'social_proof', 'label' => __('Social proof'), 'hint' => __('Telleraanmeldingen')],
|
||||
['type' => 'form', 'label' => __('Registratieformulier'), 'hint' => __('Aanmeldformulier')],
|
||||
['type' => 'countdown', 'label' => __('Afteltimer'), 'hint' => __('Aftellen naar datum')],
|
||||
['type' => 'text', 'label' => __('Tekst'), 'hint' => __('Vrije tekst')],
|
||||
['type' => 'cta_banner', 'label' => __('CTA-banner'), 'hint' => __('Knop + link')],
|
||||
['type' => 'divider', 'label' => __('Scheiding'), 'hint' => __('Lijn of ruimte')],
|
||||
];
|
||||
|
||||
$benefitIcons = ['ticket', 'clock', 'mail', 'users', 'star', 'heart', 'gift', 'music', 'shield', 'check'];
|
||||
@endphp
|
||||
|
||||
<div
|
||||
x-data="pageBlockEditor({
|
||||
initialBlocks: @js($blockEditorState),
|
||||
blockTypes: @js($blockTypesMeta),
|
||||
storageBase: @js(asset('storage'))
|
||||
})"
|
||||
>
|
||||
<h2 id="page-blocks-heading" class="text-lg font-semibold text-slate-900">{{ __('Pagina-inhoud (blokken)') }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">{{ __('Sleep blokken om de volgorde te wijzigen. Klik op een blok om het te openen of te sluiten. Het oog-icoon verbergt een blok op de publieke pagina.') }}</p>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||
<div class="relative" x-data="{ open: false }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50"
|
||||
@click="open = !open"
|
||||
>
|
||||
{{ __('+ Blok toevoegen') }}
|
||||
<span class="text-slate-400" x-text="open ? '▲' : '▼'"></span>
|
||||
</button>
|
||||
<div
|
||||
x-show="open"
|
||||
x-cloak
|
||||
@click.outside="open = false"
|
||||
class="absolute left-0 z-20 mt-2 w-full max-w-md rounded-lg border border-slate-200 bg-white py-1 shadow-lg"
|
||||
>
|
||||
<template x-for="bt in blockTypes" :key="bt.type">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full flex-col items-start gap-0.5 px-4 py-3 text-left text-sm hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@click="addBlock(bt.type); open = false"
|
||||
x-bind:disabled="bt.type === 'form' && hasFormBlock()"
|
||||
>
|
||||
<span class="font-medium text-slate-900" x-text="bt.label"></span>
|
||||
<span class="text-xs text-slate-500" x-text="bt.hint"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50" @click="expandAll()">
|
||||
{{ __('Alles uitklappen') }}
|
||||
</button>
|
||||
<button type="button" class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50" @click="collapseAll()">
|
||||
{{ __('Alles inklappen') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-col gap-4" x-ref="sortRoot">
|
||||
<template x-for="block in blocks" :key="block.uid">
|
||||
<div
|
||||
class="overflow-hidden rounded-xl border border-slate-200 bg-slate-50/80 shadow-sm"
|
||||
:class="{ 'opacity-50': !block.is_visible }"
|
||||
:data-block-uid="block.uid"
|
||||
>
|
||||
<input type="hidden" :name="`blocks[${block.uid}][type]`" :value="block.type" />
|
||||
<input type="hidden" :name="`blocks[${block.uid}][sort_order]`" :value="block.sort_order" />
|
||||
<input type="hidden" :name="`blocks[${block.uid}][is_visible]`" :value="block.is_visible ? 1 : 0" />
|
||||
|
||||
<div
|
||||
class="flex items-start gap-2 bg-white px-3 py-2"
|
||||
:class="isCollapsed(block.uid) ? 'rounded-xl' : 'rounded-t-xl border-b border-slate-200'"
|
||||
>
|
||||
<button type="button" class="block-drag-handle mt-2 cursor-grab touch-pan-y px-1 text-slate-400 hover:text-slate-600" title="{{ __('Sleep') }}">≡</button>
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 flex-1 rounded-lg py-0.5 text-left text-sm font-semibold text-slate-800 hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
|
||||
@click="toggleCollapsed(block.uid)"
|
||||
:aria-expanded="!isCollapsed(block.uid)"
|
||||
>
|
||||
<span class="flex items-start gap-2">
|
||||
<span class="mt-0.5 w-4 shrink-0 text-center text-xs text-slate-500" x-text="isCollapsed(block.uid) ? '▶' : '▼'" aria-hidden="true"></span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block" x-text="blockTypes.find(t => t.type === block.type)?.label || block.type"></span>
|
||||
<span
|
||||
x-show="isCollapsed(block.uid) && blockSummary(block) !== ''"
|
||||
x-cloak
|
||||
class="mt-0.5 block max-w-full truncate text-xs font-normal text-slate-500"
|
||||
x-text="blockSummary(block)"
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex shrink-0 items-center gap-0.5 self-center">
|
||||
<button type="button" class="p-1 text-slate-500 hover:text-slate-800" :title="block.is_visible ? '{{ __('Verbergen op publieke pagina') }}' : '{{ __('Tonen') }}'" @click="block.is_visible = !block.is_visible">
|
||||
<span x-text="block.is_visible ? '👁' : '🚫'"></span>
|
||||
</button>
|
||||
<button type="button" class="p-1 text-red-600 hover:text-red-800" @click="requestDelete(block.uid)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="!isCollapsed(block.uid)" class="space-y-4 p-4">
|
||||
{{-- Hero --}}
|
||||
<div x-show="block.type === 'hero'" class="grid gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Kop (headline)') }}</label>
|
||||
<input type="text" :name="`blocks[${block.uid}][content][headline]`" x-model="block.content.headline" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" maxlength="255" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Subkop') }}</label>
|
||||
<textarea :name="`blocks[${block.uid}][content][subheadline]`" x-model="block.content.subheadline" rows="3" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm"></textarea>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Eyebrow / label') }}</label>
|
||||
<input type="text" :name="`blocks[${block.uid}][content][eyebrow_text]`" x-model="block.content.eyebrow_text" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Eyebrow-stijl') }}</label>
|
||||
<select :name="`blocks[${block.uid}][content][eyebrow_style]`" x-model="block.content.eyebrow_style" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||
<option value="badge">badge</option>
|
||||
<option value="text">text</option>
|
||||
<option value="none">none</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Tekstuitlijning') }}</label>
|
||||
<select :name="`blocks[${block.uid}][content][text_alignment]`" x-model="block.content.text_alignment" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||
<option value="center">center</option>
|
||||
<option value="left">left</option>
|
||||
<option value="right">right</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Image --}}
|
||||
<div x-show="block.type === 'image'" class="grid gap-4">
|
||||
<template x-if="block.content.image && block.content.image !== ''">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<img :src="storageBase + '/' + block.content.image" alt="" class="h-16 w-auto max-w-[12rem] rounded border border-slate-200 bg-white object-contain p-1" />
|
||||
<label class="inline-flex items-center gap-2 text-sm text-red-700">
|
||||
<input type="checkbox" :name="`blocks[${block.uid}][remove_block_image]`" value="1" />
|
||||
{{ __('Afbeelding verwijderen') }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<input type="hidden" :name="`blocks[${block.uid}][content][image]`" :value="block.content.image || ''" />
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Afbeelding uploaden') }}</label>
|
||||
<input type="file" :name="`blocks[${block.uid}][block_image]`" accept="image/jpeg,image/png,image/webp,image/svg+xml,.svg" class="mt-1 block w-full text-sm text-slate-600" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Link-URL (optioneel, bij klik)') }}</label>
|
||||
<input type="text" :name="`blocks[${block.uid}][content][link_url]`" x-model="block.content.link_url" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" placeholder="https://…" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Alt-tekst') }}</label>
|
||||
<input type="text" :name="`blocks[${block.uid}][content][alt]`" x-model="block.content.alt" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Max. breedte (px)') }}</label>
|
||||
<input type="number" min="48" max="800" :name="`blocks[${block.uid}][content][max_width_px]`" x-model.number="block.content.max_width_px" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Uitlijning') }}</label>
|
||||
<select :name="`blocks[${block.uid}][content][text_alignment]`" x-model="block.content.text_alignment" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||
<option value="center">center</option>
|
||||
<option value="left">left</option>
|
||||
<option value="right">right</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Benefits --}}
|
||||
<div x-show="block.type === 'benefits'" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Titel') }}</label>
|
||||
<input type="text" :name="`blocks[${block.uid}][content][title]`" x-model="block.content.title" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Layout') }}</label>
|
||||
<select :name="`blocks[${block.uid}][content][layout]`" x-model="block.content.layout" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||
<option value="list">list</option>
|
||||
<option value="grid">grid</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Max. kolommen') }}</label>
|
||||
<select :name="`blocks[${block.uid}][content][max_columns]`" x-model.number="block.content.max_columns" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-slate-700">{{ __('Items') }}</span>
|
||||
<button type="button" class="text-sm font-medium text-indigo-600 hover:text-indigo-500" @click="addBenefitItem(block.uid)">+ {{ __('Voordeel') }}</button>
|
||||
</div>
|
||||
<template x-for="(item, idx) in block.content.items" :key="idx">
|
||||
<div class="flex flex-wrap items-end gap-2 rounded-lg border border-slate-200 bg-white p-3">
|
||||
<div class="min-w-[8rem] flex-1">
|
||||
<label class="text-xs text-slate-500">{{ __('Icoon') }}</label>
|
||||
<select :name="`blocks[${block.uid}][content][items][${idx}][icon]`" x-model="item.icon" class="mt-0.5 block w-full rounded border-slate-300 text-sm">
|
||||
@foreach ($benefitIcons as $ic)
|
||||
<option value="{{ $ic }}">{{ $ic }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="min-w-[12rem] flex-[2]">
|
||||
<label class="text-xs text-slate-500">{{ __('Tekst') }}</label>
|
||||
<input type="text" :name="`blocks[${block.uid}][content][items][${idx}][text]`" x-model="item.text" class="mt-0.5 block w-full rounded border-slate-300 text-sm" />
|
||||
</div>
|
||||
<button type="button" class="mb-0.5 text-sm text-red-600 hover:text-red-800" @click="removeBenefitItem(block.uid, idx)">{{ __('Verwijderen') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- Social proof --}}
|
||||
<div x-show="block.type === 'social_proof'" class="grid gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Sjabloon (gebruik {count})') }}</label>
|
||||
<input type="text" :name="`blocks[${block.uid}][content][template]`" x-model="block.content.template" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Minimum aantal (alleen tonen als ≥)') }}</label>
|
||||
<input type="number" min="0" :name="`blocks[${block.uid}][content][min_count]`" x-model.number="block.content.min_count" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="hidden" :name="`blocks[${block.uid}][content][show_animation]`" :value="block.content.show_animation ? 1 : 0" />
|
||||
<input type="checkbox" @change="block.content.show_animation = $event.target.checked" :checked="block.content.show_animation" />
|
||||
{{ __('Animatie') }}
|
||||
</label>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Stijl') }}</label>
|
||||
<select :name="`blocks[${block.uid}][content][style]`" x-model="block.content.style" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||
<option value="pill">pill</option>
|
||||
<option value="badge">badge</option>
|
||||
<option value="plain">plain</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Form --}}
|
||||
<div x-show="block.type === 'form'" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Titel boven formulier') }}</label>
|
||||
<input type="text" :name="`blocks[${block.uid}][content][title]`" x-model="block.content.title" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Beschrijving') }}</label>
|
||||
<textarea :name="`blocks[${block.uid}][content][description]`" x-model="block.content.description" rows="2" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm"></textarea>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Knoptekst') }}</label>
|
||||
<input type="text" :name="`blocks[${block.uid}][content][button_label]`" x-model="block.content.button_label" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Knopkleur') }}</label>
|
||||
<input type="text" :name="`blocks[${block.uid}][content][button_color]`" x-model="block.content.button_color" class="mt-1 block w-full rounded-lg border-slate-300 font-mono text-sm shadow-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Knoptekstkleur') }}</label>
|
||||
<input type="text" :name="`blocks[${block.uid}][content][button_text_color]`" x-model="block.content.button_text_color" class="mt-1 block w-full rounded-lg border-slate-300 font-mono text-sm shadow-sm" />
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="hidden" :name="`blocks[${block.uid}][content][show_field_icons]`" :value="block.content.show_field_icons ? 1 : 0" />
|
||||
<input type="checkbox" @change="block.content.show_field_icons = $event.target.checked" :checked="block.content.show_field_icons" />
|
||||
{{ __('Iconen bij velden') }}
|
||||
</label>
|
||||
<p class="text-xs text-slate-500">{{ __('Voornaam, achternaam en e-mail blijven verplicht ingeschakeld voor de database.') }}</p>
|
||||
<template x-for="fk in ['first_name','last_name','email','phone']" :key="fk">
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-3">
|
||||
<p class="text-sm font-medium capitalize text-slate-800" x-text="fk.replace('_',' ')"></p>
|
||||
<template x-if="fk === 'phone'">
|
||||
<label class="mt-2 inline-flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="hidden" :name="`blocks[${block.uid}][content][fields][phone][enabled]`" :value="block.content.fields.phone.enabled ? 1 : 0" />
|
||||
<input type="checkbox" @change="block.content.fields.phone.enabled = $event.target.checked" :checked="block.content.fields.phone.enabled" />
|
||||
{{ __('Telefoonveld tonen') }}
|
||||
</label>
|
||||
</template>
|
||||
<template x-if="fk !== 'phone'">
|
||||
<input type="hidden" :name="`blocks[${block.uid}][content][fields][${fk}][enabled]`" value="1" />
|
||||
<input type="hidden" :name="`blocks[${block.uid}][content][fields][${fk}][required]`" value="1" />
|
||||
</template>
|
||||
<div class="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">{{ __('Label') }}</label>
|
||||
<input type="text" :name="`blocks[${block.uid}][content][fields][${fk}][label]`" x-model="block.content.fields[fk].label" class="mt-0.5 block w-full rounded border-slate-300 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">{{ __('Placeholder') }}</label>
|
||||
<input type="text" :name="`blocks[${block.uid}][content][fields][${fk}][placeholder]`" x-model="block.content.fields[fk].placeholder" class="mt-0.5 block w-full rounded border-slate-300 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<template x-if="fk === 'phone'">
|
||||
<label class="mt-2 inline-flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="hidden" :name="`blocks[${block.uid}][content][fields][phone][required]`" :value="block.content.fields.phone.required ? 1 : 0" />
|
||||
<input type="checkbox" @change="block.content.fields.phone.required = $event.target.checked" :checked="block.content.fields.phone.required" />
|
||||
{{ __('Telefoon verplicht') }}
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Privacytekst') }}</label>
|
||||
<textarea :name="`blocks[${block.uid}][content][privacy_text]`" x-model="block.content.privacy_text" rows="2" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Privacy-URL') }}</label>
|
||||
<input type="text" :name="`blocks[${block.uid}][content][privacy_url]`" x-model="block.content.privacy_url" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" placeholder="https://…" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Countdown --}}
|
||||
<div x-show="block.type === 'countdown'" class="grid gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Doeldatum / -tijd') }}</label>
|
||||
<input type="datetime-local" :name="`blocks[${block.uid}][content][target_datetime]`" x-model="block.content.target_datetime" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Titel') }}</label>
|
||||
<input type="text" :name="`blocks[${block.uid}][content][title]`" x-model="block.content.title" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Na afloop') }}</label>
|
||||
<select :name="`blocks[${block.uid}][content][expired_action]`" x-model="block.content.expired_action" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||
<option value="hide">hide</option>
|
||||
<option value="show_message">show_message</option>
|
||||
<option value="reload">reload</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Bericht (bij show_message)') }}</label>
|
||||
<textarea :name="`blocks[${block.uid}][content][expired_message]`" x-model="block.content.expired_message" rows="2" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Stijl') }}</label>
|
||||
<select :name="`blocks[${block.uid}][content][style]`" x-model="block.content.style" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||
<option value="large">large</option>
|
||||
<option value="compact">compact</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="hidden" :name="`blocks[${block.uid}][content][show_labels]`" :value="block.content.show_labels ? 1 : 0" />
|
||||
<input type="checkbox" @change="block.content.show_labels = $event.target.checked" :checked="block.content.show_labels" />
|
||||
{{ __('Labels tonen') }}
|
||||
</label>
|
||||
@foreach (['days' => 'dagen', 'hours' => 'uren', 'minutes' => 'minuten', 'seconds' => 'seconden'] as $lk => $lab)
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Label :key', ['key' => $lk]) }}</label>
|
||||
<input
|
||||
type="text"
|
||||
:name="`blocks[${block.uid}][content][labels][{{ $lk }}]`"
|
||||
x-model="block.content.labels['{{ $lk }}']"
|
||||
class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Text --}}
|
||||
<div x-show="block.type === 'text'" class="grid gap-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Titel') }}</label>
|
||||
<input type="text" :name="`blocks[${block.uid}][content][title]`" x-model="block.content.title" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Tekst') }}</label>
|
||||
<textarea :name="`blocks[${block.uid}][content][body]`" x-model="block.content.body" rows="6" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Tekstgrootte') }}</label>
|
||||
<select :name="`blocks[${block.uid}][content][text_size]`" x-model="block.content.text_size" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||
<option value="sm">sm</option>
|
||||
<option value="base">base</option>
|
||||
<option value="lg">lg</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Uitlijning') }}</label>
|
||||
<select :name="`blocks[${block.uid}][content][text_alignment]`" x-model="block.content.text_alignment" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||
<option value="center">center</option>
|
||||
<option value="left">left</option>
|
||||
<option value="right">right</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- CTA --}}
|
||||
<div x-show="block.type === 'cta_banner'" class="grid gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Tekst') }}</label>
|
||||
<input type="text" :name="`blocks[${block.uid}][content][text]`" x-model="block.content.text" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Knoptekst') }}</label>
|
||||
<input type="text" :name="`blocks[${block.uid}][content][button_label]`" x-model="block.content.button_label" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Knop-URL') }}</label>
|
||||
<input type="text" :name="`blocks[${block.uid}][content][button_url]`" x-model="block.content.button_url" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Knopkleur') }}</label>
|
||||
<input type="text" :name="`blocks[${block.uid}][content][button_color]`" x-model="block.content.button_color" class="mt-1 block w-full rounded-lg border-slate-300 font-mono text-sm shadow-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Stijl') }}</label>
|
||||
<select :name="`blocks[${block.uid}][content][style]`" x-model="block.content.style" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||
<option value="inline">inline</option>
|
||||
<option value="stacked">stacked</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Divider --}}
|
||||
<div x-show="block.type === 'divider'" class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Stijl') }}</label>
|
||||
<select :name="`blocks[${block.uid}][content][style]`" x-model="block.content.style" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||
<option value="line">line</option>
|
||||
<option value="dots">dots</option>
|
||||
<option value="space_only">space_only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{{ __('Ruimte') }}</label>
|
||||
<select :name="`blocks[${block.uid}][content][spacing]`" x-model="block.content.spacing" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||
<option value="small">small</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="large">large</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="confirmDeleteUid === block.uid"
|
||||
x-cloak
|
||||
class="border-t border-red-100 bg-red-50 px-4 py-3 text-sm text-red-900"
|
||||
>
|
||||
<p>{{ __('Dit blok verwijderen?') }}</p>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<button type="button" class="rounded bg-red-600 px-3 py-1.5 font-medium text-white hover:bg-red-500" @click="removeBlock(block.uid)">{{ __('Verwijderen') }}</button>
|
||||
<button type="button" class="rounded border border-slate-300 bg-white px-3 py-1.5 font-medium text-slate-700 hover:bg-slate-50" @click="cancelDelete()">{{ __('Annuleren') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,6 +1,11 @@
|
||||
@php
|
||||
/** @var \App\Models\PreregistrationPage|null $page */
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
$page = $page ?? null;
|
||||
$pageBgUrl = $page !== null && filled($page->background_image)
|
||||
? Storage::disk('public')->url($page->background_image)
|
||||
: null;
|
||||
@endphp
|
||||
|
||||
<div class="grid max-w-3xl gap-6">
|
||||
@@ -13,24 +18,6 @@
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="heading" class="block text-sm font-medium text-slate-700">{{ __('Heading') }}</label>
|
||||
<input type="text" name="heading" id="heading" value="{{ old('heading', $page?->heading) }}" required maxlength="255"
|
||||
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" />
|
||||
@error('heading')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="intro_text" class="block text-sm font-medium text-slate-700">{{ __('Intro text') }}</label>
|
||||
<textarea name="intro_text" id="intro_text" rows="4"
|
||||
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">{{ old('intro_text', $page?->intro_text) }}</textarea>
|
||||
@error('intro_text')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="thank_you_message" class="block text-sm font-medium text-slate-700">{{ __('Thank you message') }}</label>
|
||||
<textarea name="thank_you_message" id="thank_you_message" rows="3"
|
||||
@@ -54,11 +41,78 @@
|
||||
<input type="text" name="ticketshop_url" id="ticketshop_url" inputmode="url" autocomplete="url"
|
||||
value="{{ old('ticketshop_url', $page?->ticketshop_url) }}"
|
||||
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="https://…" />
|
||||
<p class="mt-1 text-xs text-slate-500">{{ __('Shown as the main button when the registration period has ended (and as the CTA link for the CTA banner block in that state).') }}</p>
|
||||
@error('ticketshop_url')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="post_submit_redirect_url" class="block text-sm font-medium text-slate-700">{{ __('Redirect URL (after registration)') }}</label>
|
||||
<input type="text" name="post_submit_redirect_url" id="post_submit_redirect_url" inputmode="url" autocomplete="url"
|
||||
value="{{ old('post_submit_redirect_url', $page?->post_submit_redirect_url) }}"
|
||||
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="https://…" />
|
||||
<p class="mt-1 text-xs text-slate-500">{{ __('Optional. Visitors are sent here 5 seconds after a successful registration.') }}</p>
|
||||
@error('post_submit_redirect_url')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50/80 p-4">
|
||||
<p class="text-sm font-semibold text-slate-800">{{ __('Full-page background') }}</p>
|
||||
<p class="mt-1 text-xs text-slate-600">{{ __('Cover image behind the card on the public page.') }}</p>
|
||||
@if ($pageBgUrl !== null)
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||
<img src="{{ e($pageBgUrl) }}" alt="" class="h-20 w-32 rounded border border-slate-200 object-cover" />
|
||||
<label class="inline-flex items-center gap-2 text-sm text-red-700">
|
||||
<input type="checkbox" name="remove_page_background" value="1" @checked(old('remove_page_background')) />
|
||||
{{ __('Remove background image') }}
|
||||
</label>
|
||||
</div>
|
||||
@endif
|
||||
<div class="mt-3">
|
||||
<label for="page_background" class="block text-sm font-medium text-slate-700">{{ __('Upload background') }}</label>
|
||||
<input type="file" name="page_background" id="page_background" accept="image/jpeg,image/png,image/webp"
|
||||
class="mt-1 block w-full text-sm text-slate-600" />
|
||||
@error('page_background')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="background_overlay_color" class="block text-sm font-medium text-slate-700">{{ __('Overlay colour') }}</label>
|
||||
<input type="text" name="background_overlay_color" id="background_overlay_color"
|
||||
value="{{ old('background_overlay_color', $page?->background_overlay_color ?? '#000000') }}"
|
||||
class="mt-1 block w-full rounded-lg border-slate-300 font-mono text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
|
||||
@error('background_overlay_color')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="background_overlay_opacity" class="block text-sm font-medium text-slate-700">{{ __('Overlay opacity (%)') }}</label>
|
||||
<input type="number" name="background_overlay_opacity" id="background_overlay_opacity" min="0" max="100"
|
||||
value="{{ old('background_overlay_opacity', $page?->background_overlay_opacity ?? 50) }}"
|
||||
class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
|
||||
@error('background_overlay_opacity')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label class="inline-flex cursor-pointer items-center gap-2 text-sm text-slate-800">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="background_fixed"
|
||||
value="1"
|
||||
class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
@checked(old('background_fixed', $page?->background_fixed ?? false))
|
||||
/>
|
||||
{{ __('Fix background to viewport') }}
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-slate-600">{{ __('When enabled, the background image and overlay stay fixed while visitors scroll long content.') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="start_date" class="block text-sm font-medium text-slate-700">{{ __('Start date') }}</label>
|
||||
@@ -80,42 +134,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" name="phone_enabled" value="1" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
@checked(old('phone_enabled', $page?->phone_enabled ?? false)) />
|
||||
<span class="text-sm font-medium text-slate-700">{{ __('Phone enabled') }}</span>
|
||||
</label>
|
||||
<div>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" name="is_active" value="1" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
@checked(old('is_active', $page?->is_active ?? true)) />
|
||||
<span class="text-sm font-medium text-slate-700">{{ __('Active') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="background_image" class="block text-sm font-medium text-slate-700">{{ __('Background image') }}</label>
|
||||
<p class="mt-0.5 text-xs text-slate-500">{{ __('JPG, PNG or WebP. Max 5 MB.') }}</p>
|
||||
<input type="file" name="background_image" id="background_image" accept="image/jpeg,image/png,image/webp"
|
||||
class="mt-1 block w-full text-sm text-slate-600 file:mr-4 file:rounded-lg file:border-0 file:bg-slate-100 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-slate-700 hover:file:bg-slate-200" />
|
||||
@error('background_image')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
@if ($page?->background_image)
|
||||
<p class="mt-2 text-xs text-slate-600">{{ __('Current file:') }} <a href="/storage/{{ $page->background_image }}" class="text-indigo-600 hover:underline" target="_blank" rel="noopener">{{ __('View') }}</a></p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="logo_image" class="block text-sm font-medium text-slate-700">{{ __('Logo image') }}</label>
|
||||
<p class="mt-0.5 text-xs text-slate-500">{{ __('JPG, PNG, WebP or SVG. Max 2 MB.') }}</p>
|
||||
<input type="file" name="logo_image" id="logo_image" accept="image/jpeg,image/png,image/webp,image/svg+xml,.svg"
|
||||
class="mt-1 block w-full text-sm text-slate-600 file:mr-4 file:rounded-lg file:border-0 file:bg-slate-100 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-slate-700 hover:file:bg-slate-200" />
|
||||
@error('logo_image')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
@if ($page?->logo_image)
|
||||
<p class="mt-2 text-xs text-slate-600">{{ __('Current file:') }} <a href="/storage/{{ $page->logo_image }}" class="text-indigo-600 hover:underline" target="_blank" rel="noopener">{{ __('View') }}</a></p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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
resources/views/admin/pages/_save_flash.blade.php
Normal file
13
resources/views/admin/pages/_save_flash.blade.php
Normal file
@@ -0,0 +1,13 @@
|
||||
@if (session('status'))
|
||||
<div
|
||||
class="mb-6 flex items-start gap-3 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 shadow-sm"
|
||||
role="status"
|
||||
>
|
||||
<span class="mt-0.5 shrink-0 text-emerald-600" aria-hidden="true">
|
||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</span>
|
||||
<p class="min-w-0 flex-1 font-medium leading-snug">{{ session('status') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@@ -12,10 +12,12 @@
|
||||
<p class="mt-1 text-sm text-slate-600">{{ __('After saving, use the pages list to copy the public URL.') }}</p>
|
||||
</div>
|
||||
|
||||
<form action="{{ route('admin.pages.store') }}" method="post" enctype="multipart/form-data" class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8" novalidate>
|
||||
@include('admin.pages._save_flash')
|
||||
|
||||
<form action="{{ route('admin.pages.store') }}" method="post" enctype="multipart/form-data" class="space-y-8" novalidate>
|
||||
@csrf
|
||||
@if ($errors->any())
|
||||
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" role="alert">
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" role="alert">
|
||||
<p class="font-medium">{{ __('Please fix the following:') }}</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1">
|
||||
@foreach ($errors->all() as $message)
|
||||
@@ -24,12 +26,20 @@
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<section class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8" aria-labelledby="page-settings-heading">
|
||||
<h2 id="page-settings-heading" class="text-lg font-semibold text-slate-900">{{ __('Page settings') }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">{{ __('Title, dates, messages, and background for this pre-registration page.') }}</p>
|
||||
<div class="mt-6">
|
||||
@include('admin.pages._form', ['page' => null])
|
||||
<div class="mt-8 flex gap-3">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<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">
|
||||
{{ __('Create page') }}
|
||||
</button>
|
||||
<a href="{{ route('admin.pages.index') }}" class="rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50">{{ __('Cancel') }}</a>
|
||||
<a href="{{ route('admin.pages.index') }}" class="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">{{ __('Cancel') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
@section('mobile_title', __('Edit page'))
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="mb-8">
|
||||
<a href="{{ route('admin.pages.index') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">← {{ __('Back to pages') }}</a>
|
||||
<h1 class="mt-4 text-2xl font-semibold text-slate-900">{{ __('Edit page') }}</h1>
|
||||
@@ -13,17 +13,40 @@
|
||||
{{ __('Public URL') }}: <a href="{{ route('public.page', ['publicPage' => $page]) }}" class="text-indigo-600 hover:underline" target="_blank" rel="noopener">{{ url('/r/'.$page->slug) }}</a>
|
||||
</p>
|
||||
@can('update', $page)
|
||||
<p class="mt-3">
|
||||
<a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">{{ __('Mailwizz integration') }} →</a>
|
||||
</p>
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
<a
|
||||
href="{{ route('admin.pages.mailwizz.edit', $page) }}"
|
||||
class="group flex flex-col rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-indigo-200 hover:shadow-md"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h2 class="text-sm font-semibold text-slate-900">{{ __('Mailwizz') }}</h2>
|
||||
@include('admin.pages._integration_badges', ['page' => $page, 'only' => 'mailwizz', 'integrationBadgeClass' => 'justify-end'])
|
||||
</div>
|
||||
<p class="mt-2 flex-1 text-xs text-slate-600">{{ __('Sync subscribers to your Mailwizz list and map fields.') }}</p>
|
||||
<span class="mt-3 text-sm font-medium text-indigo-600 group-hover:text-indigo-500">{{ __('Open Mailwizz') }} →</span>
|
||||
</a>
|
||||
<a
|
||||
href="{{ route('admin.pages.weeztix.edit', $page) }}"
|
||||
class="group flex flex-col rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-indigo-200 hover:shadow-md"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h2 class="text-sm font-semibold text-slate-900">{{ __('Weeztix') }}</h2>
|
||||
@include('admin.pages._integration_badges', ['page' => $page, 'only' => 'weeztix', 'integrationBadgeClass' => 'justify-end'])
|
||||
</div>
|
||||
<p class="mt-2 flex-1 text-xs text-slate-600">{{ __('Issue unique discount codes via Weeztix when visitors sign up.') }}</p>
|
||||
<span class="mt-3 text-sm font-medium text-indigo-600 group-hover:text-indigo-500">{{ __('Open Weeztix') }} →</span>
|
||||
</a>
|
||||
</div>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
<form action="{{ route('admin.pages.update', $page) }}" method="post" enctype="multipart/form-data" class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8" novalidate>
|
||||
@include('admin.pages._save_flash')
|
||||
|
||||
<form action="{{ route('admin.pages.update', $page) }}" method="post" enctype="multipart/form-data" class="space-y-8" novalidate>
|
||||
@csrf
|
||||
@method('PUT')
|
||||
@if ($errors->any())
|
||||
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" role="alert">
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" role="alert">
|
||||
<p class="font-medium">{{ __('Please fix the following:') }}</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1">
|
||||
@foreach ($errors->all() as $message)
|
||||
@@ -32,12 +55,24 @@
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<section class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8" aria-labelledby="page-settings-heading">
|
||||
<h2 id="page-settings-heading" class="text-lg font-semibold text-slate-900">{{ __('Page settings') }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">{{ __('Title, dates, messages, and background for this pre-registration page.') }}</p>
|
||||
<div class="mt-6">
|
||||
@include('admin.pages._form', ['page' => $page])
|
||||
<div class="mt-8 flex gap-3">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8" aria-labelledby="page-blocks-heading">
|
||||
@include('admin.pages._blocks_editor', ['page' => $page])
|
||||
</section>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<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">
|
||||
{{ __('Save changes') }}
|
||||
</button>
|
||||
<a href="{{ route('admin.pages.index') }}" class="rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50">{{ __('Cancel') }}</a>
|
||||
<a href="{{ route('admin.pages.index') }}" class="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">{{ __('Cancel') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('Start') }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('End') }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('Subscribers') }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-semibold text-slate-700">{{ __('Integrations') }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-right font-semibold text-slate-700">{{ __('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -64,6 +65,9 @@
|
||||
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $page->start_date->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $page->end_date->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 tabular-nums text-slate-600">{{ number_format($page->subscribers_count) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
@include('admin.pages._integration_badges', ['page' => $page])
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
@can('update', $page)
|
||||
@@ -75,6 +79,9 @@
|
||||
@can('update', $page)
|
||||
<a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-indigo-600 hover:text-indigo-500">{{ __('Mailwizz') }}</a>
|
||||
@endcan
|
||||
@can('update', $page)
|
||||
<a href="{{ route('admin.pages.weeztix.edit', $page) }}" class="text-indigo-600 hover:text-indigo-500">{{ __('Weeztix') }}</a>
|
||||
@endcan
|
||||
<button
|
||||
type="button"
|
||||
x-data="{ copied: false }"
|
||||
@@ -97,7 +104,7 @@
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="{{ auth()->user()->isSuperadmin() ? 7 : 6 }}" class="px-4 py-12 text-center text-slate-500">
|
||||
<td colspan="{{ auth()->user()->isSuperadmin() ? 8 : 7 }}" class="px-4 py-12 text-center text-slate-500">
|
||||
{{ __('No pages yet.') }}
|
||||
@can('create', \App\Models\PreregistrationPage::class)
|
||||
<a href="{{ route('admin.pages.create') }}" class="font-medium text-indigo-600 hover:text-indigo-500">{{ __('Create one') }}</a>
|
||||
|
||||
@@ -49,11 +49,13 @@
|
||||
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('First name') }}</th>
|
||||
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Last name') }}</th>
|
||||
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Email') }}</th>
|
||||
@if ($page->phone_enabled)
|
||||
@if ($page->isPhoneFieldEnabledForSubscribers())
|
||||
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Phone') }}</th>
|
||||
@endif
|
||||
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Kortingscode') }}</th>
|
||||
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Registered at') }}</th>
|
||||
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Mailwizz') }}</th>
|
||||
<th class="w-px whitespace-nowrap px-4 py-3 font-semibold text-slate-700">{{ __('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
@@ -62,9 +64,10 @@
|
||||
<td class="px-4 py-3 text-slate-900">{{ $subscriber->first_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>
|
||||
@if ($page->phone_enabled)
|
||||
<td class="px-4 py-3 text-slate-600">{{ $subscriber->phone ?? '—' }}</td>
|
||||
@if ($page->isPhoneFieldEnabledForSubscribers())
|
||||
<td class="px-4 py-3 text-slate-600">{{ $subscriber->phoneDisplay() ?? '—' }}</td>
|
||||
@endif
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-700">{{ $subscriber->coupon_code ?? '—' }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $subscriber->created_at->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td>
|
||||
<td class="px-4 py-3">
|
||||
@if ($subscriber->synced_to_mailwizz)
|
||||
@@ -77,10 +80,47 @@
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right">
|
||||
@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
|
||||
method="post"
|
||||
action="{{ route('admin.pages.subscribers.destroy', [$page, $subscriber]) }}"
|
||||
class="inline"
|
||||
onsubmit="return confirm(@js(__('Delete this subscriber? This cannot be undone.')));"
|
||||
>
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg border border-red-200 bg-white px-2.5 py-1 text-xs font-semibold text-red-700 hover:bg-red-50"
|
||||
>
|
||||
{{ __('Remove') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endcan
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="{{ $page->phone_enabled ? 6 : 5 }}" class="px-4 py-12 text-center text-slate-500">
|
||||
<td colspan="{{ $page->isPhoneFieldEnabledForSubscribers() ? 8 : 7 }}" class="px-4 py-12 text-center text-slate-500">
|
||||
{{ __('No subscribers match your criteria.') }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
351
resources/views/admin/weeztix/edit.blade.php
Normal file
351
resources/views/admin/weeztix/edit.blade.php
Normal file
@@ -0,0 +1,351 @@
|
||||
@php
|
||||
use Illuminate\Support\Carbon;
|
||||
$wz = $page->weeztixConfig;
|
||||
$existing = $wz !== null
|
||||
? [
|
||||
'coupon_guid' => $wz->coupon_guid,
|
||||
'coupon_name' => $wz->coupon_name,
|
||||
'code_prefix' => $wz->code_prefix,
|
||||
'usage_count' => $wz->usage_count,
|
||||
]
|
||||
: null;
|
||||
$credentialsEdit = ! $hasStoredCredentials || request()->query('credentials') === 'edit';
|
||||
$oauthUrl = route('admin.pages.weeztix.oauth.redirect', ['page' => $page, 'wizard' => 1]);
|
||||
@endphp
|
||||
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', __('Weeztix') . ' — ' . $page->title)
|
||||
|
||||
@section('mobile_title', __('Weeztix'))
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="mb-8">
|
||||
<a href="{{ route('admin.pages.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">← {{ __('Terug naar pagina') }}</a>
|
||||
<h1 class="mt-4 text-2xl font-semibold text-slate-900">{{ __('Weeztix') }}</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">{{ __('Pagina:') }} <span class="font-medium text-slate-800">{{ $page->title }}</span></p>
|
||||
</div>
|
||||
|
||||
@include('admin.pages._save_flash')
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900" role="alert">
|
||||
<p class="font-medium">{{ __('Controleer het volgende:') }}</p>
|
||||
<ul class="mt-2 list-disc space-y-1 pl-5">
|
||||
@foreach ($errors->all() as $message)
|
||||
<li>{{ $message }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (! $showWizard && $wz !== null)
|
||||
{{-- Summary (read-only): change only via wizard --}}
|
||||
@if ($wz->is_connected && ($wz->company_guid === null || $wz->company_guid === ''))
|
||||
<div class="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||
<p class="font-medium">{{ __('Bedrijf nog niet vastgelegd') }}</p>
|
||||
<p class="mt-1 text-amber-900">{{ __('Start de wizard en verbind opnieuw met Weeztix zodat het bedrijf automatisch wordt gekoppeld.') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mb-6 flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]) }}"
|
||||
class="inline-flex rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500"
|
||||
>
|
||||
{{ __('Instellingen wijzigen (wizard)') }}
|
||||
</a>
|
||||
<form action="{{ route('admin.pages.weeztix.destroy', $page) }}" method="post" class="inline"
|
||||
onsubmit="return confirm(@js(__('Weeztix-integratie voor deze pagina verwijderen? Opgeslagen tokens worden gewist.')));">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="rounded-lg border border-red-200 bg-white px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm hover:bg-red-50">
|
||||
{{ __('Weeztix loskoppelen') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
|
||||
<h2 class="text-lg font-semibold text-slate-900">{{ __('Huidige configuratie') }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">{{ __('OAuth-gegevens zijn opgeslagen maar worden om veiligheidsredenen niet getoond.') }}</p>
|
||||
|
||||
<dl class="mt-6 space-y-4 border-t border-slate-100 pt-6 text-sm">
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Verbinding') }}</dt>
|
||||
<dd>
|
||||
@if ($wz->is_connected)
|
||||
<span class="inline-flex rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">{{ __('Verbonden') }}</span>
|
||||
@else
|
||||
<span class="inline-flex rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-medium text-amber-900">{{ __('Niet verbonden') }}</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
@if ($wz->is_connected && $wz->token_expires_at)
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Toegangstoken tot') }}</dt>
|
||||
<dd class="font-mono text-xs text-slate-800">{{ $wz->token_expires_at->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Callback-URL (in Weeztix-dashboard)') }}</dt>
|
||||
<dd class="break-all font-mono text-xs text-slate-600">{{ route('admin.weeztix.callback', absolute: true) }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Coupon') }}</dt>
|
||||
<dd class="text-slate-800">{{ $wz->coupon_name ?: ($wz->coupon_guid ? $wz->coupon_guid : '—') }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Codevoorvoegsel') }}</dt>
|
||||
<dd class="font-mono text-slate-800">{{ $wz->code_prefix ?? '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
||||
<dt class="font-medium text-slate-700">{{ __('Gebruik per code') }}</dt>
|
||||
<dd class="text-slate-800">{{ (int) ($wz->usage_count ?? 1) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
@else
|
||||
{{-- Wizard --}}
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
@if ($wz !== null)
|
||||
<a href="{{ route('admin.pages.weeztix.edit', ['page' => $page]) }}" class="text-sm font-medium text-slate-600 hover:text-slate-900">
|
||||
{{ __('Annuleren en terug naar overzicht') }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mb-8 flex flex-wrap items-center gap-2" aria-label="{{ __('Wizardstappen') }}">
|
||||
@foreach ([1 => __('OAuth'), 2 => __('Verbinden'), 3 => __('Coupon')] as $num => $label)
|
||||
@php
|
||||
$active = $wizardStep === $num;
|
||||
$done = $wizardStep > $num;
|
||||
@endphp
|
||||
<span class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium
|
||||
{{ $active ? 'border-indigo-500 bg-indigo-50 text-indigo-900' : '' }}
|
||||
{{ $done ? 'border-emerald-200 bg-emerald-50 text-emerald-900' : '' }}
|
||||
{{ ! $active && ! $done ? 'border-slate-200 bg-slate-50 text-slate-500' : '' }}">
|
||||
<span class="tabular-nums">{{ $num }}</span>
|
||||
{{ $label }}
|
||||
</span>
|
||||
@if ($num < 3)
|
||||
<span class="text-slate-300" aria-hidden="true">→</span>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8 space-y-10">
|
||||
@if ($wizardStep === 1)
|
||||
<section class="space-y-4" aria-labelledby="wz-wizard-step1">
|
||||
<h2 id="wz-wizard-step1" class="text-lg font-semibold text-slate-900">{{ __('Stap 1: OAuth-client') }}</h2>
|
||||
|
||||
@if ($hasStoredCredentials && ! $credentialsEdit)
|
||||
<p class="text-sm text-slate-600">{{ __('Wil je Client ID, client secret of de callback-URI in Weeztix wijzigen? De callback-URL van deze applicatie is hieronder; die moet exact overeenkomen in het Weeztix-dashboard.') }}</p>
|
||||
<p class="rounded-lg bg-slate-50 px-3 py-2 font-mono text-xs text-slate-800 break-all">{{ route('admin.weeztix.callback', absolute: true) }}</p>
|
||||
<div class="flex flex-wrap gap-3 pt-2">
|
||||
<a
|
||||
href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 2]) }}"
|
||||
class="inline-flex rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500"
|
||||
>
|
||||
{{ __('Nee, huidige gegevens behouden') }}
|
||||
</a>
|
||||
<a
|
||||
href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 1, 'credentials' => 'edit']) }}"
|
||||
class="inline-flex rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-800 shadow-sm hover:bg-slate-50"
|
||||
>
|
||||
{{ __('Ja, Client ID en secret wijzigen') }}
|
||||
</a>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-slate-600">{{ __('Vul de OAuth-client uit het Weeztix-dashboard in. Zet de redirect-URI exact op de onderstaande URL.') }}</p>
|
||||
<p class="rounded-lg bg-slate-50 px-3 py-2 font-mono text-xs text-slate-800 break-all">{{ route('admin.weeztix.callback', absolute: true) }}</p>
|
||||
|
||||
<form method="post" action="{{ route('admin.pages.weeztix.update', $page) }}" class="space-y-4 pt-2">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<input type="hidden" name="wizard" value="1">
|
||||
<input type="hidden" name="wizard_credential_save" value="1">
|
||||
<div>
|
||||
<label for="weeztix_client_id" class="block text-sm font-medium text-slate-700">{{ __('Client ID') }}</label>
|
||||
<input
|
||||
id="weeztix_client_id"
|
||||
name="client_id"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
value="{{ old('client_id') }}"
|
||||
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
placeholder="{{ $wz !== null ? __('Laat leeg om ongewijzigd te laten') : '' }}"
|
||||
@if ($wz === null) required @endif
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label for="weeztix_client_secret" class="block text-sm font-medium text-slate-700">{{ __('Client secret') }}</label>
|
||||
<input
|
||||
id="weeztix_client_secret"
|
||||
name="client_secret"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
value=""
|
||||
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
placeholder="{{ $wz !== null ? __('Laat leeg om ongewijzigd te laten') : '' }}"
|
||||
@if ($wz === null) required @endif
|
||||
>
|
||||
</div>
|
||||
@if ($wz !== null)
|
||||
<p class="text-xs text-slate-500">
|
||||
{{ __('Laat velden leeg om opgeslagen waarden te behouden.') }}
|
||||
</p>
|
||||
@endif
|
||||
<button type="submit" class="rounded-lg bg-slate-800 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-slate-700">
|
||||
{{ __('Opslaan en verder naar verbinden') }}
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if ($wizardStep === 2)
|
||||
<section class="space-y-4" aria-labelledby="wz-wizard-step2">
|
||||
<h2 id="wz-wizard-step2" class="text-lg font-semibold text-slate-900">{{ __('Stap 2: Verbinden met Weeztix') }}</h2>
|
||||
<p class="text-sm text-slate-600">{{ __('Log in bij Weeztix en keur de toegang goed. Daarna ga je automatisch verder naar de coupon.') }}</p>
|
||||
<p class="rounded-lg bg-slate-50 px-3 py-2 font-mono text-xs text-slate-800 break-all">{{ route('admin.weeztix.callback', absolute: true) }}</p>
|
||||
|
||||
@if ($wz !== null && $wz->is_connected)
|
||||
<div class="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
|
||||
{{ __('Je bent verbonden. Start opnieuw de Weeztix-login hieronder om een ander bedrijf te kiezen; het gekoppelde bedrijf wordt daarna automatisch bijgewerkt.') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href="{{ $oauthUrl }}"
|
||||
class="inline-flex rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500"
|
||||
>
|
||||
@if ($wz !== null && $wz->is_connected)
|
||||
{{ __('Opnieuw verbinden met Weeztix') }}
|
||||
@else
|
||||
{{ __('Verbind met Weeztix') }}
|
||||
@endif
|
||||
</a>
|
||||
@if ($wz !== null && $wz->is_connected)
|
||||
<a
|
||||
href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 3]) }}"
|
||||
class="inline-flex rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-800 shadow-sm hover:bg-slate-50"
|
||||
>
|
||||
{{ __('Naar stap 3: coupon') }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<p class="pt-4">
|
||||
<a href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]) }}" class="text-sm font-medium text-slate-600 hover:text-slate-900">{{ __('← Terug naar stap 1') }}</a>
|
||||
</p>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if ($wizardStep === 3)
|
||||
<div
|
||||
x-data="weeztixSetup(@js([
|
||||
'pageId' => $page->id,
|
||||
'couponsUrl' => route('admin.weeztix.coupons'),
|
||||
'csrf' => csrf_token(),
|
||||
'isConnected' => $wz?->is_connected ?? false,
|
||||
'callbackUrl' => route('admin.weeztix.callback', absolute: true),
|
||||
'existing' => $existing,
|
||||
'strings' => [
|
||||
'genericError' => __('Er ging iets mis. Probeer het opnieuw.'),
|
||||
'loadCouponsError' => __('Kon kortingsbonnen niet laden.'),
|
||||
'refreshCoupons' => __('Vernieuwen'),
|
||||
'refreshCouponsBusy' => __('Bezig…'),
|
||||
'refreshCouponsTitle' => __('Couponlijst opnieuw ophalen van Weeztix'),
|
||||
],
|
||||
]))"
|
||||
>
|
||||
<div x-show="errorMessage !== ''" x-cloak class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" x-text="errorMessage"></div>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="wz-wizard-step3">
|
||||
<h2 id="wz-wizard-step3" class="text-lg font-semibold text-slate-900">{{ __('Stap 3: Coupon en codes') }}</h2>
|
||||
<p class="text-sm text-slate-600">{{ __('Kies een bestaande coupon in Weeztix en stel het voorvoegsel en aantal gebruiken per code in.') }}</p>
|
||||
|
||||
<form method="post" action="{{ route('admin.pages.weeztix.update', $page) }}" class="space-y-4">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<input type="hidden" name="wizard" value="1">
|
||||
<input type="hidden" name="wizard_coupon_save" value="1">
|
||||
|
||||
<div>
|
||||
<div class="flex flex-wrap items-end justify-between gap-2">
|
||||
<label for="weeztix_coupon" class="block text-sm font-medium text-slate-700">{{ __('Coupon (kortingssjabloon)') }}</label>
|
||||
<button
|
||||
type="button"
|
||||
x-show="isConnected"
|
||||
x-cloak
|
||||
@click="refreshCoupons()"
|
||||
:disabled="couponsRefreshing"
|
||||
:aria-busy="couponsRefreshing"
|
||||
:title="strings.refreshCouponsTitle"
|
||||
class="shrink-0 rounded-md border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!couponsRefreshing" x-text="strings.refreshCoupons"></span>
|
||||
<span x-show="couponsRefreshing" x-cloak x-text="strings.refreshCouponsBusy"></span>
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
id="weeztix_coupon"
|
||||
name="coupon_guid"
|
||||
x-model="couponGuid"
|
||||
@change="syncCouponName()"
|
||||
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="">{{ __('Selecteer een coupon…') }}</option>
|
||||
<template x-for="c in coupons" :key="c.guid">
|
||||
<option :value="c.guid" x-text="c.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<input type="hidden" name="coupon_name" :value="couponName">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="weeztix_code_prefix" class="block text-sm font-medium text-slate-700">{{ __('Codevoorvoegsel') }}</label>
|
||||
<input
|
||||
id="weeztix_code_prefix"
|
||||
name="code_prefix"
|
||||
type="text"
|
||||
maxlength="32"
|
||||
x-model="codePrefix"
|
||||
value="{{ old('code_prefix', $wz->code_prefix ?? 'PREREG') }}"
|
||||
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label for="weeztix_usage_count" class="block text-sm font-medium text-slate-700">{{ __('Gebruik per code') }}</label>
|
||||
<input
|
||||
id="weeztix_usage_count"
|
||||
name="usage_count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="99999"
|
||||
x-model.number="usageCount"
|
||||
value="{{ old('usage_count', $wz->usage_count ?? 1) }}"
|
||||
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3 pt-2">
|
||||
<button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">
|
||||
{{ __('Opslaan en wizard afronden') }}
|
||||
</button>
|
||||
<a href="{{ route('admin.pages.weeztix.edit', ['page' => $page, 'wizard' => 1, 'step' => 2]) }}" class="inline-flex items-center rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50">
|
||||
{{ __('Terug naar stap 2') }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
36
resources/views/components/blocks/benefits.blade.php
Normal file
36
resources/views/components/blocks/benefits.blade.php
Normal file
@@ -0,0 +1,36 @@
|
||||
@props(['block'])
|
||||
|
||||
@php
|
||||
/** @var \App\Models\PageBlock $block */
|
||||
$c = $block->content ?? [];
|
||||
$title = data_get($c, 'title');
|
||||
$items = data_get($c, 'items', []);
|
||||
$layout = (string) data_get($c, 'layout', 'list');
|
||||
$cols = (int) data_get($c, 'max_columns', 2);
|
||||
$gridClass = $layout === 'grid'
|
||||
? match ($cols) {
|
||||
3 => 'sm:grid-cols-3',
|
||||
1 => 'sm:grid-cols-1',
|
||||
default => 'sm:grid-cols-2',
|
||||
}
|
||||
: '';
|
||||
@endphp
|
||||
|
||||
<div class="w-full space-y-4" x-show="phase !== 'thanks'" x-cloak>
|
||||
@if (filled($title))
|
||||
<h2 class="text-center text-lg font-semibold text-white sm:text-xl">{{ $title }}</h2>
|
||||
@endif
|
||||
|
||||
@if (is_array($items) && $items !== [])
|
||||
<ul class="{{ $layout === 'grid' ? 'grid grid-cols-1 gap-3 '.$gridClass : 'space-y-3' }} w-full text-left">
|
||||
@foreach ($items as $item)
|
||||
@if (is_array($item))
|
||||
<li class="flex gap-3 rounded-xl border border-white/15 bg-black/25 px-4 py-3">
|
||||
<x-blocks.icon :name="(string) ($item['icon'] ?? 'check')" class="mt-0.5 text-festival" />
|
||||
<span class="text-sm leading-snug text-white/95 sm:text-[15px]">{{ $item['text'] ?? '' }}</span>
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
82
resources/views/components/blocks/countdown.blade.php
Normal file
82
resources/views/components/blocks/countdown.blade.php
Normal file
@@ -0,0 +1,82 @@
|
||||
@props(['block'])
|
||||
|
||||
@php
|
||||
/** @var \App\Models\PageBlock $block */
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
$c = $block->content ?? [];
|
||||
$rawTarget = data_get($c, 'target_datetime');
|
||||
try {
|
||||
$targetMs = $rawTarget ? (int) (Carbon::parse($rawTarget)->getTimestamp() * 1000) : 0;
|
||||
} catch (\Throwable) {
|
||||
$targetMs = 0;
|
||||
}
|
||||
$title = data_get($c, 'title');
|
||||
$expiredAction = (string) data_get($c, 'expired_action', 'hide');
|
||||
$expiredMessage = (string) data_get($c, 'expired_message', '');
|
||||
$style = (string) data_get($c, 'style', 'large');
|
||||
$showLabels = filter_var(data_get($c, 'show_labels', true), FILTER_VALIDATE_BOOLEAN);
|
||||
$labels = data_get($c, 'labels', []);
|
||||
$labelsArr = is_array($labels) ? $labels : [];
|
||||
$gridClass = $style === 'compact' ? 'gap-2 px-2 py-3 sm:gap-3 sm:px-3' : 'gap-3 px-3 py-4 sm:gap-4 sm:px-4 sm:py-5';
|
||||
$numClass = $style === 'compact' ? 'text-xl sm:text-2xl' : 'text-2xl sm:text-3xl';
|
||||
@endphp
|
||||
|
||||
@if ($targetMs > 0)
|
||||
<div
|
||||
class="w-full"
|
||||
x-show="phase !== 'thanks'"
|
||||
x-cloak
|
||||
x-data="countdownBlock(@js([
|
||||
'targetMs' => $targetMs,
|
||||
'showLabels' => $showLabels,
|
||||
'labels' => [
|
||||
'days' => (string) ($labelsArr['days'] ?? __('day')),
|
||||
'hours' => (string) ($labelsArr['hours'] ?? __('hrs')),
|
||||
'minutes' => (string) ($labelsArr['minutes'] ?? __('mins')),
|
||||
'seconds' => (string) ($labelsArr['seconds'] ?? __('secs')),
|
||||
],
|
||||
'expiredAction' => $expiredAction,
|
||||
'expiredMessage' => $expiredMessage,
|
||||
]))"
|
||||
>
|
||||
<div x-show="!expired" x-cloak>
|
||||
@if (filled($title))
|
||||
<p class="mb-3 text-center text-sm font-medium text-white/90 sm:text-base">{{ $title }}</p>
|
||||
@endif
|
||||
<div
|
||||
class="grid grid-cols-4 rounded-2xl border border-white/15 bg-black/35 text-center shadow-inner {{ $gridClass }}"
|
||||
role="timer"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div>
|
||||
<div class="font-mono font-semibold tabular-nums text-white {{ $numClass }}" x-text="pad(days)"></div>
|
||||
@if ($showLabels)
|
||||
<div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs" x-text="labels.days"></div>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-mono font-semibold tabular-nums text-white {{ $numClass }}" x-text="pad(hours)"></div>
|
||||
@if ($showLabels)
|
||||
<div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs" x-text="labels.hours"></div>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-mono font-semibold tabular-nums text-white {{ $numClass }}" x-text="pad(minutes)"></div>
|
||||
@if ($showLabels)
|
||||
<div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs" x-text="labels.minutes"></div>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-mono font-semibold tabular-nums text-white {{ $numClass }}" x-text="pad(seconds)"></div>
|
||||
@if ($showLabels)
|
||||
<div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs" x-text="labels.seconds"></div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="expired && expiredAction === 'show_message'" x-cloak>
|
||||
<p class="text-center text-sm text-white/90" x-text="expiredMessage"></p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
37
resources/views/components/blocks/cta-banner.blade.php
Normal file
37
resources/views/components/blocks/cta-banner.blade.php
Normal file
@@ -0,0 +1,37 @@
|
||||
@props([
|
||||
'block',
|
||||
'page' => null,
|
||||
'pageState' => 'active',
|
||||
])
|
||||
|
||||
@php
|
||||
/** @var \App\Models\PageBlock $block */
|
||||
/** @var \App\Models\PreregistrationPage|null $page */
|
||||
$c = $block->content ?? [];
|
||||
$url = (string) data_get($c, 'button_url', '#');
|
||||
if ($pageState === 'expired' && $page !== null && filled($page->ticketshop_url)) {
|
||||
$url = $page->ticketshop_url;
|
||||
}
|
||||
$btnColor = (string) data_get($c, 'button_color', '#F47B20');
|
||||
$style = (string) data_get($c, 'style', 'inline');
|
||||
$flexClass = $style === 'stacked' ? 'flex-col gap-3' : 'flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4';
|
||||
@endphp
|
||||
|
||||
<div class="w-full rounded-2xl border border-white/15 bg-black/35 px-4 py-4 sm:px-5" x-show="phase !== 'thanks'" x-cloak>
|
||||
<div class="flex {{ $flexClass }}">
|
||||
@if (filled(data_get($c, 'text')))
|
||||
<p class="text-center text-sm font-medium text-white sm:text-left sm:text-base">{{ data_get($c, 'text') }}</p>
|
||||
@endif
|
||||
<div class="flex justify-center sm:justify-end">
|
||||
<a
|
||||
href="{{ e($url) }}"
|
||||
class="inline-flex min-h-[48px] items-center justify-center rounded-xl px-6 py-3 text-sm font-bold text-white shadow-lg transition hover:scale-[1.02] hover:brightness-110 focus:outline-none focus:ring-2 focus:ring-white/40"
|
||||
style="background-color: {{ e($btnColor) }};"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ data_get($c, 'button_label') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
27
resources/views/components/blocks/divider.blade.php
Normal file
27
resources/views/components/blocks/divider.blade.php
Normal file
@@ -0,0 +1,27 @@
|
||||
@props(['block'])
|
||||
|
||||
@php
|
||||
/** @var \App\Models\PageBlock $block */
|
||||
$c = $block->content ?? [];
|
||||
$style = (string) data_get($c, 'style', 'line');
|
||||
$spacing = (string) data_get($c, 'spacing', 'medium');
|
||||
$py = match ($spacing) {
|
||||
'small' => 'py-2',
|
||||
'large' => 'py-6',
|
||||
default => 'py-4',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="w-full {{ $py }}" x-show="phase !== 'thanks'" x-cloak>
|
||||
@if ($style === 'space_only')
|
||||
<div class="h-2" aria-hidden="true"></div>
|
||||
@elseif ($style === 'dots')
|
||||
<div class="flex justify-center gap-2" aria-hidden="true">
|
||||
@foreach (range(1, 5) as $_)
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-white/35"></span>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="h-px w-full bg-gradient-to-r from-transparent via-white/25 to-transparent" aria-hidden="true"></div>
|
||||
@endif
|
||||
</div>
|
||||
177
resources/views/components/blocks/form.blade.php
Normal file
177
resources/views/components/blocks/form.blade.php
Normal file
@@ -0,0 +1,177 @@
|
||||
@props([
|
||||
'block',
|
||||
'page',
|
||||
])
|
||||
|
||||
@php
|
||||
/** @var \App\Models\PageBlock $block */
|
||||
/** @var \App\Models\PreregistrationPage $page */
|
||||
$c = $block->content ?? [];
|
||||
$fields = data_get($c, 'fields', []);
|
||||
$showIcons = filter_var(data_get($c, 'show_field_icons', true), FILTER_VALIDATE_BOOLEAN);
|
||||
$btnColor = (string) data_get($c, 'button_color', '#F47B20');
|
||||
$btnText = (string) data_get($c, 'button_text_color', '#FFFFFF');
|
||||
$privacyText = data_get($c, 'privacy_text');
|
||||
$privacyUrl = data_get($c, 'privacy_url');
|
||||
@endphp
|
||||
|
||||
<div class="w-full space-y-4">
|
||||
@if (filled(data_get($c, 'title')))
|
||||
<h2 class="text-center text-xl font-semibold text-white sm:text-2xl">{{ data_get($c, 'title') }}</h2>
|
||||
@endif
|
||||
|
||||
@if (filled(data_get($c, 'description')))
|
||||
<p class="text-center text-sm leading-relaxed text-white/85 sm:text-[15px]">{{ data_get($c, 'description') }}</p>
|
||||
@endif
|
||||
|
||||
<div x-show="phase === 'active'" x-cloak>
|
||||
<form x-ref="form" class="space-y-4" @submit.prevent="submitForm()">
|
||||
<div x-show="formError !== ''" x-cloak class="rounded-xl border border-red-700 bg-red-600 px-4 py-3 text-sm font-medium leading-snug text-white" x-text="formError"></div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
@foreach (['first_name', 'last_name'] as $fk)
|
||||
@php
|
||||
$fc = is_array($fields[$fk] ?? null) ? $fields[$fk] : [];
|
||||
$enabled = filter_var($fc['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN);
|
||||
$req = filter_var($fc['required'] ?? true, FILTER_VALIDATE_BOOLEAN);
|
||||
@endphp
|
||||
@if ($enabled)
|
||||
<div>
|
||||
<label for="pf-{{ $fk }}" class="sr-only">{{ $fc['label'] ?? $fk }}</label>
|
||||
<div class="relative">
|
||||
@if ($showIcons)
|
||||
<span class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-white/40" aria-hidden="true">
|
||||
@if ($fk === 'first_name')
|
||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" /></svg>
|
||||
@else
|
||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0z" /></svg>
|
||||
@endif
|
||||
</span>
|
||||
@endif
|
||||
<input
|
||||
id="pf-{{ $fk }}"
|
||||
type="text"
|
||||
name="{{ $fk }}"
|
||||
autocomplete="{{ $fk === 'first_name' ? 'given-name' : 'family-name' }}"
|
||||
@if ($req) required @endif
|
||||
maxlength="255"
|
||||
x-model="{{ $fk }}"
|
||||
class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45 {{ $showIcons ? 'pl-11 pr-4' : 'px-4' }}"
|
||||
placeholder="{{ $fc['placeholder'] ?? $fc['label'] ?? '' }}"
|
||||
>
|
||||
</div>
|
||||
<p x-show="fieldErrors.{{ $fk }}" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.{{ $fk }} ? fieldErrors.{{ $fk }}[0] : ''"></p>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@php
|
||||
$emailFc = is_array($fields['email'] ?? null) ? $fields['email'] : [];
|
||||
$emailOn = filter_var($emailFc['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN);
|
||||
@endphp
|
||||
@if ($emailOn)
|
||||
<div>
|
||||
<label for="pf-email" class="sr-only">{{ $emailFc['label'] ?? __('Email') }}</label>
|
||||
<div class="relative">
|
||||
@if ($showIcons)
|
||||
<span class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-white/40" aria-hidden="true">
|
||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" /></svg>
|
||||
</span>
|
||||
@endif
|
||||
<input
|
||||
id="pf-email"
|
||||
type="email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
maxlength="255"
|
||||
x-model="email"
|
||||
class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45 {{ $showIcons ? 'pl-11 pr-4' : 'px-4' }}"
|
||||
placeholder="{{ $emailFc['placeholder'] ?? $emailFc['label'] ?? '' }}"
|
||||
>
|
||||
</div>
|
||||
<p x-show="fieldErrors.email" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.email ? fieldErrors.email[0] : ''"></p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$phoneFc = is_array($fields['phone'] ?? null) ? $fields['phone'] : [];
|
||||
$phoneOn = filter_var($phoneFc['enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$phoneReq = filter_var($phoneFc['required'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
@endphp
|
||||
@if ($phoneOn)
|
||||
<div>
|
||||
<label for="pf-phone" class="sr-only">{{ $phoneFc['label'] ?? __('Phone') }}</label>
|
||||
<div class="relative">
|
||||
@if ($showIcons)
|
||||
<span class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-white/40" aria-hidden="true">
|
||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.608-1.25.608H9.75a2.25 2.25 0 01-2.25-2.25V9.75c0-.481.232-.968.608-1.25l1.293-.97c.362-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z" /></svg>
|
||||
</span>
|
||||
@endif
|
||||
<input
|
||||
id="pf-phone"
|
||||
type="tel"
|
||||
name="phone"
|
||||
autocomplete="tel"
|
||||
maxlength="20"
|
||||
@if ($phoneReq) required @endif
|
||||
x-model="phone"
|
||||
class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45 {{ $showIcons ? 'pl-11 pr-4' : 'px-4' }}"
|
||||
placeholder="{{ $phoneFc['placeholder'] ?? $phoneFc['label'] ?? '' }}"
|
||||
>
|
||||
</div>
|
||||
<p x-show="fieldErrors.phone" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.phone ? fieldErrors.phone[0] : ''"></p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (filled($privacyText))
|
||||
<p class="text-center text-xs leading-relaxed text-white/60">
|
||||
@if (filled($privacyUrl))
|
||||
<a href="{{ e($privacyUrl) }}" class="underline decoration-white/30 underline-offset-2 hover:text-white" target="_blank" rel="noopener noreferrer">{{ $privacyText }}</a>
|
||||
@else
|
||||
{{ $privacyText }}
|
||||
@endif
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="mt-2 min-h-[52px] w-full rounded-xl px-6 py-3.5 text-base font-bold tracking-wide shadow-lg transition duration-200 ease-out hover:scale-[1.02] hover:brightness-110 focus:outline-none focus:ring-2 focus:ring-festival focus:ring-offset-2 focus:ring-offset-black/80 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:scale-100 sm:min-h-[56px] sm:text-lg"
|
||||
style="background-color: {{ e($btnColor) }}; color: {{ e($btnText) }};"
|
||||
:disabled="submitting"
|
||||
>
|
||||
<span x-show="!submitting" x-text="formButtonLabel"></span>
|
||||
<span x-show="submitting" x-cloak>{{ __('Sending…') }}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div x-show="phase === 'thanks'" x-cloak class="space-y-6">
|
||||
<div class="flex justify-center">
|
||||
<div class="animate-preregister-in flex h-16 w-16 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-300 ring-2 ring-emerald-400/50">
|
||||
<svg class="h-9 w-9" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="whitespace-pre-line text-center text-base leading-relaxed text-white/95 sm:text-lg" x-text="thankYouMessage"></p>
|
||||
<p
|
||||
x-show="redirectSecondsLeft !== null && redirectSecondsLeft > 0"
|
||||
x-cloak
|
||||
class="text-center text-sm text-white/75"
|
||||
x-text="(strings.redirectCountdown || '').replace(':seconds', String(redirectSecondsLeft))"
|
||||
></p>
|
||||
<div class="rounded-xl border border-white/15 bg-black/30 px-4 py-4 text-center">
|
||||
<p class="text-sm font-medium text-white/90">{{ __('Deel deze pagina') }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 inline-flex min-h-[44px] w-full items-center justify-center rounded-lg border border-white/25 bg-white/10 px-4 py-2 text-sm font-semibold text-white hover:bg-white/15"
|
||||
@click="copyPageLink()"
|
||||
>
|
||||
{{ __('Link kopiëren') }}
|
||||
</button>
|
||||
<p x-show="copyFeedback !== ''" x-cloak class="mt-2 text-xs text-emerald-300" x-text="copyFeedback"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
44
resources/views/components/blocks/hero.blade.php
Normal file
44
resources/views/components/blocks/hero.blade.php
Normal file
@@ -0,0 +1,44 @@
|
||||
@props([
|
||||
'block',
|
||||
'page',
|
||||
'pageState' => 'active',
|
||||
])
|
||||
|
||||
@php
|
||||
/** @var \App\Models\PageBlock $block */
|
||||
/** @var \App\Models\PreregistrationPage $page */
|
||||
$c = $block->content ?? [];
|
||||
$align = (string) data_get($c, 'text_alignment', 'center');
|
||||
$alignClass = match ($align) {
|
||||
'left' => 'items-start text-left',
|
||||
'right' => 'items-end text-right',
|
||||
default => 'items-center text-center',
|
||||
};
|
||||
$eyebrow = data_get($c, 'eyebrow_text');
|
||||
$eyebrowStyle = (string) data_get($c, 'eyebrow_style', 'badge');
|
||||
$subheadlineRaw = data_get($c, 'subheadline');
|
||||
$subheadline = is_string($subheadlineRaw) ? trim($subheadlineRaw) : '';
|
||||
@endphp
|
||||
|
||||
<div class="flex w-full flex-col {{ $alignClass }} space-y-4">
|
||||
@if (filled($eyebrow) && $eyebrowStyle !== 'none')
|
||||
@if ($eyebrowStyle === 'badge')
|
||||
<span class="inline-flex rounded-full border border-white/25 bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-white/90">
|
||||
{{ $eyebrow }}
|
||||
</span>
|
||||
@else
|
||||
<p class="text-sm font-medium text-white/80">{{ $eyebrow }}</p>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if (filled(data_get($c, 'headline')))
|
||||
<h1 class="w-full max-w-none text-balance text-2xl font-bold leading-snug tracking-tight text-festival sm:text-3xl">
|
||||
{{ data_get($c, 'headline') }}
|
||||
</h1>
|
||||
@endif
|
||||
|
||||
{{-- Subheadline must sit on one line inside the div: whitespace-pre-line turns Blade indentation into visible leading space. --}}
|
||||
@if ($pageState !== 'expired' && $subheadline !== '')
|
||||
<div class="w-full max-w-none whitespace-pre-line text-[15px] leading-[1.65] text-white sm:text-base sm:leading-relaxed">{{ $subheadline }}</div>
|
||||
@endif
|
||||
</div>
|
||||
37
resources/views/components/blocks/icon.blade.php
Normal file
37
resources/views/components/blocks/icon.blade.php
Normal file
@@ -0,0 +1,37 @@
|
||||
@props(['name' => 'check'])
|
||||
|
||||
@php
|
||||
$common = 'h-6 w-6 shrink-0 text-festival';
|
||||
@endphp
|
||||
|
||||
@switch($name)
|
||||
@case('ticket')
|
||||
<svg {{ $attributes->merge(['class' => $common]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 6v.75l1.5 1.5M9 6.75V9m0 0v3.75m0-3.75h3.75m-3.75 0H9m9 3.75V9m0 0V6.75m0 3.75h-3.75m3.75 0H15M4.5 19.5l15-15M4.5 4.5l15 15" /></svg>
|
||||
@break
|
||||
@case('clock')
|
||||
<svg {{ $attributes->merge(['class' => $common]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
@break
|
||||
@case('mail')
|
||||
<svg {{ $attributes->merge(['class' => $common]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" /></svg>
|
||||
@break
|
||||
@case('users')
|
||||
<svg {{ $attributes->merge(['class' => $common]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" /></svg>
|
||||
@break
|
||||
@case('star')
|
||||
<svg {{ $attributes->merge(['class' => $common]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.873a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.385a.562.562 0 00-.182-.557l-4.204-3.602a.562.562 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" /></svg>
|
||||
@break
|
||||
@case('heart')
|
||||
<svg {{ $attributes->merge(['class' => $common]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" /></svg>
|
||||
@break
|
||||
@case('gift')
|
||||
<svg {{ $attributes->merge(['class' => $common]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m-8.25 3.75h15" /></svg>
|
||||
@break
|
||||
@case('music')
|
||||
<svg {{ $attributes->merge(['class' => $common]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z" /></svg>
|
||||
@break
|
||||
@case('shield')
|
||||
<svg {{ $attributes->merge(['class' => $common]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /></svg>
|
||||
@break
|
||||
@default
|
||||
<svg {{ $attributes->merge(['class' => $common]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg>
|
||||
@endswitch
|
||||
49
resources/views/components/blocks/image.blade.php
Normal file
49
resources/views/components/blocks/image.blade.php
Normal file
@@ -0,0 +1,49 @@
|
||||
@props([
|
||||
'block',
|
||||
])
|
||||
|
||||
@php
|
||||
/** @var \App\Models\PageBlock $block */
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
$c = $block->content ?? [];
|
||||
$path = data_get($c, 'image');
|
||||
$src = is_string($path) && $path !== '' ? Storage::disk('public')->url($path) : null;
|
||||
$rawLink = data_get($c, 'link_url');
|
||||
$linkUrl = null;
|
||||
if (is_string($rawLink) && preg_match('/\Ahttps?:\/\//i', trim($rawLink)) === 1) {
|
||||
$linkUrl = trim($rawLink);
|
||||
}
|
||||
$alt = is_string(data_get($c, 'alt')) ? (string) data_get($c, 'alt') : '';
|
||||
$maxW = max(48, min(800, (int) data_get($c, 'max_width_px', 320)));
|
||||
$align = (string) data_get($c, 'text_alignment', 'center');
|
||||
// text-align + inline-block centers reliably; flex + max-w-full often yields full-width flex items.
|
||||
$textAlignClass = match ($align) {
|
||||
'left' => 'text-left',
|
||||
'right' => 'text-right',
|
||||
default => 'text-center',
|
||||
};
|
||||
@endphp
|
||||
|
||||
@if ($src !== null)
|
||||
<div class="w-full {{ $textAlignClass }}">
|
||||
@if ($linkUrl !== null)
|
||||
<a
|
||||
href="{{ e($linkUrl) }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-block max-w-full align-middle"
|
||||
>
|
||||
@endif
|
||||
<img
|
||||
src="{{ e($src) }}"
|
||||
alt="{{ e($alt) }}"
|
||||
class="inline-block h-auto w-auto max-w-full align-middle object-contain drop-shadow-[0_8px_32px_rgba(0,0,0,0.35)]"
|
||||
style="max-width: min(100%, {{ $maxW }}px)"
|
||||
loading="lazy"
|
||||
>
|
||||
@if ($linkUrl !== null)
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
33
resources/views/components/blocks/social-proof.blade.php
Normal file
33
resources/views/components/blocks/social-proof.blade.php
Normal file
@@ -0,0 +1,33 @@
|
||||
@props([
|
||||
'block',
|
||||
'subscriberCount' => 0,
|
||||
])
|
||||
|
||||
@php
|
||||
/** @var \App\Models\PageBlock $block */
|
||||
$c = $block->content ?? [];
|
||||
$min = (int) data_get($c, 'min_count', 0);
|
||||
$template = (string) data_get($c, 'template', '');
|
||||
$style = (string) data_get($c, 'style', 'pill');
|
||||
$showAnim = filter_var(data_get($c, 'show_animation', true), FILTER_VALIDATE_BOOLEAN);
|
||||
@endphp
|
||||
|
||||
@if ($subscriberCount >= $min && str_contains($template, '{count}'))
|
||||
<div
|
||||
class="w-full text-center"
|
||||
x-show="phase !== 'thanks'"
|
||||
x-cloak
|
||||
>
|
||||
@php
|
||||
$text = str_replace('{count}', number_format($subscriberCount, 0, ',', '.'), $template);
|
||||
$wrapClass = match ($style) {
|
||||
'badge' => 'inline-flex rounded-lg border border-white/20 bg-white/10 px-4 py-2 text-sm text-white',
|
||||
'plain' => 'text-sm text-white/90',
|
||||
default => 'inline-flex rounded-full border border-festival/40 bg-festival/15 px-5 py-2 text-sm font-medium text-white shadow-inner',
|
||||
};
|
||||
@endphp
|
||||
<p @class([$wrapClass, 'transition-transform duration-500' => $showAnim]) x-data="{ shown: false }" x-init="setTimeout(() => shown = true, 100)" :class="shown ? 'scale-100 opacity-100' : 'scale-95 opacity-0'">
|
||||
{{ $text }}
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
30
resources/views/components/blocks/text.blade.php
Normal file
30
resources/views/components/blocks/text.blade.php
Normal file
@@ -0,0 +1,30 @@
|
||||
@props(['block'])
|
||||
|
||||
@php
|
||||
/** @var \App\Models\PageBlock $block */
|
||||
$c = $block->content ?? [];
|
||||
$size = (string) data_get($c, 'text_size', 'base');
|
||||
$align = (string) data_get($c, 'text_alignment', 'center');
|
||||
$sizeClass = match ($size) {
|
||||
'sm' => 'text-sm sm:text-[15px]',
|
||||
'lg' => 'text-lg sm:text-xl',
|
||||
default => 'text-[15px] sm:text-base',
|
||||
};
|
||||
$alignClass = match ($align) {
|
||||
'left' => 'text-left',
|
||||
'right' => 'text-right',
|
||||
default => 'text-center',
|
||||
};
|
||||
$bodyRaw = data_get($c, 'body');
|
||||
$body = is_string($bodyRaw) ? trim($bodyRaw) : '';
|
||||
@endphp
|
||||
|
||||
{{-- Body must be on one line inside the div: whitespace-pre-line turns indentation/newlines around {{ $body }} into visible gaps. --}}
|
||||
<div class="w-full space-y-3" x-show="phase !== 'thanks'" x-cloak>
|
||||
@if (filled(data_get($c, 'title')))
|
||||
<h2 class="{{ $alignClass }} text-lg font-semibold text-white sm:text-xl">{{ data_get($c, 'title') }}</h2>
|
||||
@endif
|
||||
@if ($body !== '')
|
||||
<div class="{{ $alignClass }} {{ $sizeClass }} whitespace-pre-line leading-relaxed text-white/90">{{ $body }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -16,14 +16,16 @@
|
||||
@php
|
||||
$adminFlashSuccess = session('status');
|
||||
$adminFlashError = session('error');
|
||||
$inlinePageFlash = request()->routeIs(['admin.pages.edit', 'admin.pages.create']);
|
||||
$showSuccessToast = $adminFlashSuccess !== null && ! $inlinePageFlash;
|
||||
@endphp
|
||||
@if ($adminFlashSuccess !== null || $adminFlashError !== null)
|
||||
@if ($showSuccessToast || $adminFlashError !== null)
|
||||
<div
|
||||
class="pointer-events-none fixed bottom-4 right-4 z-[100] flex w-full max-w-sm flex-col gap-2 px-4 sm:px-0"
|
||||
aria-live="polite"
|
||||
>
|
||||
@foreach (array_filter([
|
||||
$adminFlashSuccess !== null ? ['type' => 'success', 'message' => $adminFlashSuccess] : null,
|
||||
$showSuccessToast ? ['type' => 'success', 'message' => $adminFlashSuccess] : null,
|
||||
$adminFlashError !== null ? ['type' => 'error', 'message' => $adminFlashError] : null,
|
||||
]) as $toast)
|
||||
<div
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<title>{{ $title ?? $page->heading }} — {{ config('app.name', 'PreRegister') }}</title>
|
||||
<title>{{ $title ?? $page->headlineForMeta() }} — {{ config('app.name', 'PreRegister') }}</title>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet">
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
@php
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
$bgUrl = $page->background_image !== null
|
||||
? Storage::disk('public')->url($page->background_image)
|
||||
: null;
|
||||
$logoUrl = $page->logo_image !== null
|
||||
? Storage::disk('public')->url($page->logo_image)
|
||||
: null;
|
||||
$phase = $page->isBeforeStart() ? 'before' : ($page->isExpired() ? 'expired' : 'active');
|
||||
/** @var \Illuminate\Support\Collection<int, \App\Models\PageBlock> $blocksToRender */
|
||||
$bgPath = $page->background_image;
|
||||
$bgUrl = is_string($bgPath) && $bgPath !== '' ? Storage::disk('public')->url($bgPath) : null;
|
||||
$overlayColor = (string) ($page->background_overlay_color ?: '#000000');
|
||||
$overlayOpacity = max(0, min(100, (int) ($page->background_overlay_opacity ?? 50))) / 100;
|
||||
$hasExpiredCtaBlock = $blocksToRender->contains(fn (\App\Models\PageBlock $b): bool => $b->type === 'cta_banner');
|
||||
$redirectAfterSubmit = $page->post_submit_redirect_url;
|
||||
|
||||
$alpinePhase = match ($pageState) {
|
||||
'countdown' => 'before',
|
||||
'expired' => 'expired',
|
||||
default => 'active',
|
||||
};
|
||||
|
||||
$formBlock = $page->getFormBlock();
|
||||
$formContent = $formBlock?->content ?? [];
|
||||
$formButtonLabel = (string) (data_get($formContent, 'button_label') ?: __('public.register_button'));
|
||||
$formButtonColor = (string) data_get($formContent, 'button_color', '#F47B20');
|
||||
$formButtonTextColor = (string) data_get($formContent, 'button_text_color', '#FFFFFF');
|
||||
$bgFixed = $page->background_fixed;
|
||||
$bgLayerPosition = $bgFixed ? 'fixed inset-0 pointer-events-none z-0' : 'absolute inset-0';
|
||||
$overlayPosition = $bgFixed ? 'fixed inset-0 pointer-events-none z-[1]' : 'absolute inset-0';
|
||||
@endphp
|
||||
|
||||
@extends('layouts.public')
|
||||
@@ -16,187 +31,71 @@
|
||||
<div class="relative min-h-screen w-full overflow-x-hidden">
|
||||
@if ($bgUrl !== null)
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
class="{{ $bgLayerPosition }} bg-cover bg-center bg-no-repeat"
|
||||
style="background-image: url('{{ e($bgUrl) }}')"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
@else
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950"
|
||||
class="{{ $bgLayerPosition }} bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
@endif
|
||||
|
||||
<div class="absolute inset-0 bg-black/30" aria-hidden="true"></div>
|
||||
<div
|
||||
class="{{ $overlayPosition }}"
|
||||
style="background-color: {{ e($overlayColor) }}; opacity: {{ $overlayOpacity }}"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<div class="relative z-10 flex min-h-screen items-center justify-center px-4 py-6 sm:px-6 sm:py-10">
|
||||
<div
|
||||
class="animate-preregister-in w-full max-w-3xl rounded-3xl border border-white/20 bg-black/60 px-5 py-8 shadow-[0_25px_60px_-15px_rgba(0,0,0,0.65)] backdrop-blur-[4px] sm:px-10 sm:py-10"
|
||||
class="animate-preregister-in w-full max-w-3xl rounded-3xl border border-white/20 bg-black/60 px-5 py-8 shadow-[0_0_0_1px_rgba(0,0,0,0.2),0_28px_90px_-4px_rgba(0,0,0,0.28),0_48px_140px_2px_rgba(0,0,0,0.18),0_72px_200px_16px_rgba(0,0,0,0.12)] backdrop-blur-[4px] sm:px-10 sm:py-10"
|
||||
x-cloak
|
||||
x-data="publicPreregisterPage(@js([
|
||||
'phase' => $phase,
|
||||
'phase' => $alpinePhase,
|
||||
'startAtMs' => $page->start_date->getTimestamp() * 1000,
|
||||
'phoneEnabled' => (bool) $page->phone_enabled,
|
||||
'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(),
|
||||
'phoneRequired' => $page->isPhoneFieldEnabledForSubscribers() && $page->isPhoneFieldRequiredForSubscribers(),
|
||||
'subscribeUrl' => route('public.subscribe', ['publicPage' => $page]),
|
||||
'csrfToken' => csrf_token(),
|
||||
'genericError' => __('Something went wrong. Please try again.'),
|
||||
'labelDay' => __('day'),
|
||||
'labelDays' => __('days'),
|
||||
'invalidEmailMsg' => __('Please enter a valid email address.'),
|
||||
'invalidPhoneMsg' => __('Please enter a valid phone number (8–15 digits).'),
|
||||
'invalidPhoneMsg' => __('Please enter a valid phone number.'),
|
||||
'formButtonLabel' => $formButtonLabel,
|
||||
'formButtonColor' => $formButtonColor,
|
||||
'formButtonTextColor' => $formButtonTextColor,
|
||||
'pageShareUrl' => url()->current(),
|
||||
'redirectUrl' => filled($redirectAfterSubmit) ? $redirectAfterSubmit : null,
|
||||
'strings' => [
|
||||
'linkCopied' => __('Link gekopieerd!'),
|
||||
'redirectCountdown' => __('You will be redirected in :seconds s…'),
|
||||
],
|
||||
]))"
|
||||
>
|
||||
<div class="flex flex-col items-center text-center">
|
||||
@if ($logoUrl !== null)
|
||||
<div class="mb-4 flex w-full justify-center sm:mb-5">
|
||||
<img
|
||||
src="{{ e($logoUrl) }}"
|
||||
alt=""
|
||||
class="max-h-32 w-auto object-contain object-center drop-shadow-[0_8px_32px_rgba(0,0,0,0.45)] sm:max-h-44 md:max-h-48"
|
||||
width="384"
|
||||
height="192"
|
||||
>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex flex-col items-stretch space-y-4">
|
||||
@foreach ($blocksToRender as $block)
|
||||
<x-dynamic-component
|
||||
:component="$block->bladeComponentName()"
|
||||
:block="$block"
|
||||
:page="$page"
|
||||
:page-state="$pageState"
|
||||
:subscriber-count="$subscriberCount"
|
||||
/>
|
||||
@endforeach
|
||||
|
||||
<h1 class="w-full max-w-none text-balance text-2xl font-bold leading-snug tracking-tight text-festival sm:text-3xl">
|
||||
{{ $page->heading }}
|
||||
</h1>
|
||||
|
||||
@if (filled($page->intro_text))
|
||||
<div
|
||||
x-show="phase === 'before' || phase === 'active'"
|
||||
x-cloak
|
||||
class="mt-0 w-full max-w-none whitespace-pre-line text-[15px] leading-[1.65] text-white sm:text-base sm:leading-relaxed"
|
||||
>
|
||||
{{ trim($page->intro_text) }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Before start: countdown --}}
|
||||
<div x-show="phase === 'before'" x-cloak class="mt-8 space-y-6 sm:mt-10">
|
||||
<div
|
||||
class="grid grid-cols-4 gap-3 rounded-2xl border border-white/15 bg-black/35 px-3 py-4 text-center shadow-inner sm:gap-4 sm:px-4 sm:py-5"
|
||||
role="timer"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div>
|
||||
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(days)"></div>
|
||||
<div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs" x-text="days === 1 ? labelDay : labelDays"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(hours)"></div>
|
||||
<div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs">{{ __('hrs') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(minutes)"></div>
|
||||
<div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs">{{ __('mins') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(seconds)"></div>
|
||||
<div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs">{{ __('secs') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Active: registration form --}}
|
||||
<div x-show="phase === 'active'" x-cloak class="mt-8 sm:mt-10">
|
||||
<form x-ref="form" class="space-y-4" @submit.prevent="submitForm()">
|
||||
<div x-show="formError !== ''" x-cloak class="rounded-xl border border-amber-400/50 bg-amber-500/15 px-4 py-3 text-sm leading-snug text-amber-50" x-text="formError"></div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="first_name" class="sr-only">{{ __('First name') }}</label>
|
||||
<input
|
||||
id="first_name"
|
||||
type="text"
|
||||
name="first_name"
|
||||
autocomplete="given-name"
|
||||
required
|
||||
maxlength="255"
|
||||
x-model="first_name"
|
||||
class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 px-4 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45"
|
||||
placeholder="{{ __('First name') }}"
|
||||
>
|
||||
<p x-show="fieldErrors.first_name" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.first_name ? fieldErrors.first_name[0] : ''"></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="last_name" class="sr-only">{{ __('Last name') }}</label>
|
||||
<input
|
||||
id="last_name"
|
||||
type="text"
|
||||
name="last_name"
|
||||
autocomplete="family-name"
|
||||
required
|
||||
maxlength="255"
|
||||
x-model="last_name"
|
||||
class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 px-4 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45"
|
||||
placeholder="{{ __('Last name') }}"
|
||||
>
|
||||
<p x-show="fieldErrors.last_name" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.last_name ? fieldErrors.last_name[0] : ''"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="sr-only">{{ __('Email') }}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
maxlength="255"
|
||||
x-model="email"
|
||||
class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 px-4 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45"
|
||||
placeholder="{{ __('Email') }}"
|
||||
>
|
||||
<p x-show="fieldErrors.email" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.email ? fieldErrors.email[0] : ''"></p>
|
||||
</div>
|
||||
|
||||
<div x-show="phoneEnabled">
|
||||
<label for="phone" class="sr-only">{{ __('Phone (optional)') }}</label>
|
||||
<input
|
||||
id="phone"
|
||||
type="tel"
|
||||
name="phone"
|
||||
autocomplete="tel"
|
||||
maxlength="20"
|
||||
x-model="phone"
|
||||
class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 px-4 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45"
|
||||
placeholder="{{ __('Phone (optional)') }}"
|
||||
>
|
||||
<p x-show="fieldErrors.phone" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.phone ? fieldErrors.phone[0] : ''"></p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="mt-2 min-h-[52px] w-full rounded-xl bg-festival px-6 py-3.5 text-base font-bold tracking-wide text-white shadow-lg shadow-festival/30 transition duration-200 ease-out hover:scale-[1.02] hover:brightness-110 focus:outline-none focus:ring-2 focus:ring-festival focus:ring-offset-2 focus:ring-offset-black/80 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:scale-100 sm:min-h-[56px] sm:text-lg"
|
||||
:disabled="submitting"
|
||||
>
|
||||
<span x-show="!submitting">{{ __('public.register_button') }}</span>
|
||||
<span x-show="submitting" x-cloak>{{ __('Sending…') }}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Thank you (after successful AJAX) --}}
|
||||
<div x-show="phase === 'thanks'" x-cloak class="mt-8 sm:mt-10">
|
||||
<p class="whitespace-pre-line text-center text-base leading-relaxed text-white/95 sm:text-lg" x-text="thankYouMessage"></p>
|
||||
</div>
|
||||
|
||||
{{-- Expired --}}
|
||||
<div x-show="phase === 'expired'" x-cloak class="mt-8 space-y-6 sm:mt-10">
|
||||
@if ($pageState === 'expired')
|
||||
<div class="space-y-6">
|
||||
@if (filled($page->expired_message))
|
||||
<div class="whitespace-pre-line text-center text-[15px] leading-[1.65] text-white/92 sm:text-base sm:leading-relaxed">
|
||||
{{ $page->expired_message }}
|
||||
</div>
|
||||
{{-- Same as text/hero blocks: no line breaks inside whitespace-pre-line wrapper. --}}
|
||||
<div class="whitespace-pre-line text-center text-[15px] leading-[1.65] text-white/92 sm:text-base sm:leading-relaxed">{{ trim((string) $page->expired_message) }}</div>
|
||||
@else
|
||||
<p class="text-center text-[15px] leading-relaxed text-white/92 sm:text-base">{{ __('This pre-registration period has ended.') }}</p>
|
||||
@endif
|
||||
|
||||
@if (filled($page->ticketshop_url))
|
||||
@if (filled($page->ticketshop_url) && ! $hasExpiredCtaBlock)
|
||||
<div class="text-center">
|
||||
<a
|
||||
href="{{ e($page->ticketshop_url) }}"
|
||||
@@ -210,6 +109,8 @@
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,9 @@ use App\Http\Controllers\Admin\MailwizzController;
|
||||
use App\Http\Controllers\Admin\PageController;
|
||||
use App\Http\Controllers\Admin\SubscriberController;
|
||||
use App\Http\Controllers\Admin\UserController;
|
||||
use App\Http\Controllers\Admin\WeeztixApiController;
|
||||
use App\Http\Controllers\Admin\WeeztixController;
|
||||
use App\Http\Controllers\Admin\WeeztixOAuthController;
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\PublicPageController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -33,7 +36,9 @@ Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(
|
||||
|
||||
// Subscribers (nested under pages) — export before index so the path is unambiguous
|
||||
Route::get('pages/{page}/subscribers/export', [SubscriberController::class, 'export'])->name('pages.subscribers.export');
|
||||
Route::delete('pages/{page}/subscribers/{subscriber}', [SubscriberController::class, 'destroy'])->name('pages.subscribers.destroy');
|
||||
Route::post('pages/{page}/subscribers/queue-mailwizz-sync', [SubscriberController::class, 'queueMailwizzSync'])->name('pages.subscribers.queue-mailwizz-sync');
|
||||
Route::post('pages/{page}/subscribers/{subscriber}/sync-mailwizz', [SubscriberController::class, 'syncSubscriberMailwizz'])->name('pages.subscribers.sync-mailwizz');
|
||||
Route::get('pages/{page}/subscribers', [SubscriberController::class, 'index'])->name('pages.subscribers.index');
|
||||
|
||||
// Mailwizz configuration (nested under pages)
|
||||
@@ -45,6 +50,15 @@ Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(
|
||||
Route::post('mailwizz/lists', [MailwizzApiController::class, 'lists'])->name('mailwizz.lists');
|
||||
Route::post('mailwizz/fields', [MailwizzApiController::class, 'fields'])->name('mailwizz.fields');
|
||||
|
||||
// Weeztix configuration (nested under pages)
|
||||
Route::get('pages/{page}/weeztix', [WeeztixController::class, 'edit'])->name('pages.weeztix.edit');
|
||||
Route::put('pages/{page}/weeztix', [WeeztixController::class, 'update'])->name('pages.weeztix.update');
|
||||
Route::delete('pages/{page}/weeztix', [WeeztixController::class, 'destroy'])->name('pages.weeztix.destroy');
|
||||
Route::get('pages/{page}/weeztix/oauth/redirect', [WeeztixOAuthController::class, 'redirect'])->name('pages.weeztix.oauth.redirect');
|
||||
Route::get('weeztix/callback', [WeeztixOAuthController::class, 'callback'])->name('weeztix.callback');
|
||||
|
||||
Route::post('weeztix/coupons', [WeeztixApiController::class, 'coupons'])->name('weeztix.coupons');
|
||||
|
||||
// User management (superadmin only)
|
||||
Route::middleware('role:superadmin')->group(function () {
|
||||
Route::resource('users', UserController::class)->except(['show']);
|
||||
|
||||
5
run-deploy-from-local.sh
Normal file
5
run-deploy-from-local.sh
Normal file
@@ -0,0 +1,5 @@
|
||||
ssh hausdesign-vps "sudo -u hausdesign bash -c '
|
||||
export PATH=\"\$HOME/.local/share/fnm:\$PATH\"
|
||||
eval \"\$(fnm env)\"
|
||||
cd /home/hausdesign/preregister && ./deploy.sh
|
||||
'"
|
||||
333
tests/Feature/DestroySubscriberTest.php
Normal file
333
tests/Feature/DestroySubscriberTest.php
Normal file
@@ -0,0 +1,333 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
use App\Models\User;
|
||||
use App\Models\WeeztixConfig;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DestroySubscriberTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_page_owner_can_delete_subscriber_on_that_page(): void
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$page = 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,
|
||||
]);
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'Ada',
|
||||
'last_name' => 'Lovelace',
|
||||
'email' => 'ada@example.com',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->delete(route('admin.pages.subscribers.destroy', [$page, $subscriber]));
|
||||
|
||||
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
|
||||
$response->assertSessionHas('status');
|
||||
$this->assertDatabaseMissing('subscribers', ['id' => $subscriber->id]);
|
||||
}
|
||||
|
||||
public function test_other_user_cannot_delete_subscriber(): void
|
||||
{
|
||||
$owner = User::factory()->create(['role' => 'user']);
|
||||
$intruder = User::factory()->create(['role' => 'user']);
|
||||
$page = PreregistrationPage::query()->create([
|
||||
'slug' => (string) Str::uuid(),
|
||||
'user_id' => $owner->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,
|
||||
]);
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'A',
|
||||
'last_name' => 'B',
|
||||
'email' => 'x@example.com',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($intruder)->delete(route('admin.pages.subscribers.destroy', [$page, $subscriber]));
|
||||
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas('subscribers', ['id' => $subscriber->id]);
|
||||
}
|
||||
|
||||
public function test_cannot_delete_subscriber_using_wrong_page_in_url(): void
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$pageA = PreregistrationPage::query()->create([
|
||||
'slug' => (string) Str::uuid(),
|
||||
'user_id' => $user->id,
|
||||
'title' => 'A',
|
||||
'heading' => 'A',
|
||||
'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,
|
||||
]);
|
||||
$pageB = PreregistrationPage::query()->create([
|
||||
'slug' => (string) Str::uuid(),
|
||||
'user_id' => $user->id,
|
||||
'title' => 'B',
|
||||
'heading' => 'B',
|
||||
'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,
|
||||
]);
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $pageB->id,
|
||||
'first_name' => 'A',
|
||||
'last_name' => 'B',
|
||||
'email' => 'y@example.com',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->delete(route('admin.pages.subscribers.destroy', [$pageA, $subscriber]));
|
||||
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas('subscribers', ['id' => $subscriber->id]);
|
||||
}
|
||||
|
||||
public function test_delete_strips_mailwizz_source_tag_and_clears_coupon_field(): void
|
||||
{
|
||||
Http::fake(function (Request $request) {
|
||||
$url = $request->url();
|
||||
if (str_contains($url, 'search-by-email')) {
|
||||
return Http::response(['status' => 'success', 'data' => ['subscriber_uid' => 'sub-to-clean']]);
|
||||
}
|
||||
if ($request->method() === 'GET' && str_contains($url, '/subscribers/sub-to-clean') && ! str_contains($url, 'search-by-email')) {
|
||||
return Http::response([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'record' => [
|
||||
'TAGS' => 'preregister-source,other-tag',
|
||||
'COUPON' => 'PREREG-OLD',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
if ($request->method() === 'PUT' && str_contains($url, '/subscribers/sub-to-clean')) {
|
||||
$body = $request->body();
|
||||
$this->assertStringContainsString('other-tag', $body);
|
||||
$this->assertStringNotContainsString('preregister-source', $body);
|
||||
$this->assertStringContainsString('COUPON', $body);
|
||||
|
||||
return Http::response(['status' => 'success']);
|
||||
}
|
||||
|
||||
return Http::response(['status' => 'error'], 500);
|
||||
});
|
||||
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$page = $this->makePageForDestroyTest($user);
|
||||
MailwizzConfig::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'api_key' => 'fake-api-key',
|
||||
'list_uid' => 'list-uid-1',
|
||||
'list_name' => 'Main list',
|
||||
'field_email' => 'EMAIL',
|
||||
'field_first_name' => 'FNAME',
|
||||
'field_last_name' => 'LNAME',
|
||||
'field_phone' => null,
|
||||
'field_coupon_code' => 'COUPON',
|
||||
'tag_field' => 'TAGS',
|
||||
'tag_value' => 'preregister-source',
|
||||
]);
|
||||
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'Clean',
|
||||
'last_name' => 'Up',
|
||||
'email' => 'cleanup@example.com',
|
||||
'coupon_code' => 'PREREG-LOCAL',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->delete(route('admin.pages.subscribers.destroy', [$page, $subscriber]));
|
||||
|
||||
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
|
||||
$this->assertDatabaseMissing('subscribers', ['id' => $subscriber->id]);
|
||||
Http::assertSentCount(3);
|
||||
}
|
||||
|
||||
public function test_delete_clears_mailwizz_checkboxlist_when_only_configured_tag_is_present(): void
|
||||
{
|
||||
Http::fake(function (Request $request) {
|
||||
$url = $request->url();
|
||||
if (str_contains($url, 'search-by-email')) {
|
||||
return Http::response(['status' => 'success', 'data' => ['subscriber_uid' => 'sub-single-tag']]);
|
||||
}
|
||||
if ($request->method() === 'GET' && str_contains($url, '/subscribers/sub-single-tag') && ! str_contains($url, 'search-by-email')) {
|
||||
return Http::response([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'record' => [
|
||||
'TAGS' => 'preregister-source',
|
||||
'COUPON' => 'PREREG-OLD',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
if ($request->method() === 'PUT' && str_contains($url, '/subscribers/sub-single-tag')) {
|
||||
$body = $request->body();
|
||||
$this->assertStringContainsString('TAGS', $body);
|
||||
$this->assertStringNotContainsString('preregister-source', $body);
|
||||
$this->assertStringNotContainsString('TAGS[]', $body);
|
||||
$this->assertStringContainsString('COUPON', $body);
|
||||
|
||||
return Http::response(['status' => 'success']);
|
||||
}
|
||||
|
||||
return Http::response(['status' => 'error'], 500);
|
||||
});
|
||||
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$page = $this->makePageForDestroyTest($user);
|
||||
MailwizzConfig::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'api_key' => 'fake-api-key',
|
||||
'list_uid' => 'list-uid-1',
|
||||
'list_name' => 'Main list',
|
||||
'field_email' => 'EMAIL',
|
||||
'field_first_name' => 'FNAME',
|
||||
'field_last_name' => 'LNAME',
|
||||
'field_phone' => null,
|
||||
'field_coupon_code' => 'COUPON',
|
||||
'tag_field' => 'TAGS',
|
||||
'tag_value' => 'preregister-source',
|
||||
]);
|
||||
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'Solo',
|
||||
'last_name' => 'Tag',
|
||||
'email' => 'solo-tag@example.com',
|
||||
'coupon_code' => 'PREREG-LOCAL',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->delete(route('admin.pages.subscribers.destroy', [$page, $subscriber]));
|
||||
|
||||
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
|
||||
$this->assertDatabaseMissing('subscribers', ['id' => $subscriber->id]);
|
||||
Http::assertSentCount(3);
|
||||
}
|
||||
|
||||
public function test_delete_removes_coupon_code_in_weeztix_when_configured(): void
|
||||
{
|
||||
Http::fake(function (Request $request) {
|
||||
$url = $request->url();
|
||||
if ($request->method() === 'GET' && preg_match('#/coupon/coupon-guid-test/codes$#', $url) === 1) {
|
||||
return Http::response([
|
||||
'data' => [
|
||||
['guid' => 'wzx-code-guid', 'code' => 'PREREG-DEL99'],
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
if ($request->method() === 'DELETE' && str_contains($url, '/coupon/coupon-guid-test/codes/wzx-code-guid')) {
|
||||
return Http::response(null, 204);
|
||||
}
|
||||
|
||||
return Http::response(['status' => 'error'], 500);
|
||||
});
|
||||
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$page = $this->makePageForDestroyTest($user);
|
||||
WeeztixConfig::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'client_id' => 'client-id',
|
||||
'client_secret' => 'client-secret',
|
||||
'redirect_uri' => 'https://app.test/callback',
|
||||
'access_token' => 'access-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'token_expires_at' => now()->addHour(),
|
||||
'refresh_token_expires_at' => now()->addMonth(),
|
||||
'company_guid' => 'company-guid-test',
|
||||
'company_name' => 'Test Co',
|
||||
'coupon_guid' => 'coupon-guid-test',
|
||||
'coupon_name' => 'PreReg',
|
||||
'is_connected' => true,
|
||||
]);
|
||||
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'Weez',
|
||||
'last_name' => 'Tix',
|
||||
'email' => 'weez@example.com',
|
||||
'coupon_code' => 'PREREG-DEL99',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->delete(route('admin.pages.subscribers.destroy', [$page, $subscriber]));
|
||||
|
||||
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
|
||||
$this->assertDatabaseMissing('subscribers', ['id' => $subscriber->id]);
|
||||
|
||||
Http::assertSent(function (Request $request): bool {
|
||||
return $request->method() === 'DELETE'
|
||||
&& str_contains($request->url(), '/coupon/coupon-guid-test/codes/wzx-code-guid');
|
||||
});
|
||||
}
|
||||
|
||||
private function makePageForDestroyTest(User $user): PreregistrationPage
|
||||
{
|
||||
return PreregistrationPage::query()->create([
|
||||
'slug' => (string) Str::uuid(),
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Fest',
|
||||
'heading' => 'Fest',
|
||||
'intro_text' => null,
|
||||
'thank_you_message' => null,
|
||||
'expired_message' => null,
|
||||
'ticketshop_url' => null,
|
||||
'start_date' => now()->subDay(),
|
||||
'end_date' => now()->addMonth(),
|
||||
'phone_enabled' => false,
|
||||
'background_image' => null,
|
||||
'logo_image' => null,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -36,6 +37,50 @@ class MailwizzConfigUiTest extends TestCase
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_connected_mailwizz_shows_overview_until_wizard_requested(): void
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$page = $this->makePageForUser($user);
|
||||
MailwizzConfig::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'api_key' => 'test-key',
|
||||
'list_uid' => 'list-uid-1',
|
||||
'list_name' => 'Main list',
|
||||
'field_email' => 'EMAIL',
|
||||
'field_first_name' => 'FNAME',
|
||||
'field_last_name' => 'LNAME',
|
||||
'field_phone' => null,
|
||||
'field_coupon_code' => null,
|
||||
'tag_field' => 'TAGS',
|
||||
'tag_value' => 'preregister-source',
|
||||
]);
|
||||
|
||||
$overview = $this->actingAs($user)->get(route('admin.pages.mailwizz.edit', $page));
|
||||
$overview->assertOk();
|
||||
$overview->assertSee('Current configuration', escape: false);
|
||||
$overview->assertSee('Change settings (wizard)', escape: false);
|
||||
$overview->assertDontSee('Step 1: API key', escape: false);
|
||||
|
||||
$wizard = $this->actingAs($user)->get(route('admin.pages.mailwizz.edit', [
|
||||
'page' => $page,
|
||||
'wizard' => 1,
|
||||
'step' => 1,
|
||||
]));
|
||||
$wizard->assertOk();
|
||||
$wizard->assertSee('Step 1: API key', escape: false);
|
||||
$wizard->assertSee('Cancel and return to overview', escape: false);
|
||||
}
|
||||
|
||||
public function test_mailwizz_wizard_redirects_to_step_one_when_no_config_and_step_gt_one(): void
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$page = $this->makePageForUser($user);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.pages.mailwizz.edit', ['page' => $page, 'wizard' => 1, 'step' => 3]))
|
||||
->assertRedirect(route('admin.pages.mailwizz.edit', ['page' => $page, 'wizard' => 1, 'step' => 1]));
|
||||
}
|
||||
|
||||
private function makePageForUser(User $user): PreregistrationPage
|
||||
{
|
||||
return PreregistrationPage::query()->create([
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Tests\Feature;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
use App\Models\User;
|
||||
use App\Services\PreregistrationPageBlockWriter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
@@ -169,7 +170,7 @@ class PublicPageTest extends TestCase
|
||||
$response->assertJsonValidationErrors(['phone']);
|
||||
}
|
||||
|
||||
public function test_subscribe_normalizes_phone_to_digits(): void
|
||||
public function test_subscribe_stores_phone_as_e164(): void
|
||||
{
|
||||
$page = $this->makePage([
|
||||
'start_date' => now()->subHour(),
|
||||
@@ -188,7 +189,7 @@ class PublicPageTest extends TestCase
|
||||
$this->assertDatabaseHas('subscribers', [
|
||||
'preregistration_page_id' => $page->id,
|
||||
'email' => 'phoneuser@example.com',
|
||||
'phone' => '31612345678',
|
||||
'phone' => '+31612345678',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -199,7 +200,7 @@ class PublicPageTest extends TestCase
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
return PreregistrationPage::query()->create(array_merge([
|
||||
$page = PreregistrationPage::query()->create(array_merge([
|
||||
'slug' => (string) Str::uuid(),
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Test page',
|
||||
@@ -211,7 +212,36 @@ class PublicPageTest extends TestCase
|
||||
'start_date' => now()->subHour(),
|
||||
'end_date' => now()->addMonth(),
|
||||
'phone_enabled' => false,
|
||||
'background_image' => null,
|
||||
'logo_image' => null,
|
||||
'is_active' => true,
|
||||
], $overrides));
|
||||
|
||||
$writer = app(PreregistrationPageBlockWriter::class);
|
||||
if (! $page->blocks()->exists()) {
|
||||
$writer->seedDefaultBlocks($page);
|
||||
}
|
||||
|
||||
$page->refresh();
|
||||
$hero = $page->getHeroBlock();
|
||||
if ($hero !== null) {
|
||||
$c = $hero->content;
|
||||
$c['headline'] = $page->heading;
|
||||
$hero->update(['content' => $c]);
|
||||
}
|
||||
|
||||
if (($overrides['phone_enabled'] ?? false) === true) {
|
||||
$page->load('blocks');
|
||||
$form = $page->getFormBlock();
|
||||
if ($form !== null) {
|
||||
$c = $form->content;
|
||||
data_set($c, 'fields.phone.enabled', true);
|
||||
$form->update(['content' => $c]);
|
||||
}
|
||||
}
|
||||
|
||||
$page->syncLegacyContentColumnsFromBlocks();
|
||||
|
||||
return $page->fresh(['blocks']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\SyncSubscriberToMailwizz;
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
@@ -12,6 +13,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -80,6 +82,135 @@ class QueueUnsyncedMailwizzSubscribersTest extends TestCase
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_owner_can_queue_single_subscriber_mailwizz_sync(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$page = $this->makePageWithMailwizzForUser($user);
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'One',
|
||||
'last_name' => 'Off',
|
||||
'email' => 'oneoff@example.com',
|
||||
'synced_to_mailwizz' => true,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(
|
||||
route('admin.pages.subscribers.sync-mailwizz', [$page, $subscriber])
|
||||
);
|
||||
|
||||
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
|
||||
$response->assertSessionHas('status');
|
||||
Queue::assertPushed(SyncSubscriberToMailwizz::class, function (SyncSubscriberToMailwizz $job) use ($subscriber): bool {
|
||||
return $job->subscriberId === $subscriber->id;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_other_user_cannot_queue_single_subscriber_mailwizz_sync(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$owner = User::factory()->create(['role' => 'user']);
|
||||
$intruder = User::factory()->create(['role' => 'user']);
|
||||
$page = $this->makePageWithMailwizzForUser($owner);
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'A',
|
||||
'last_name' => 'B',
|
||||
'email' => 'ab@example.com',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($intruder)->post(
|
||||
route('admin.pages.subscribers.sync-mailwizz', [$page, $subscriber])
|
||||
);
|
||||
|
||||
$response->assertForbidden();
|
||||
Queue::assertNothingPushed();
|
||||
}
|
||||
|
||||
public function test_single_subscriber_mailwizz_sync_redirects_with_error_when_page_has_no_mailwizz(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$page = PreregistrationPage::query()->create([
|
||||
'slug' => (string) Str::uuid(),
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Fest',
|
||||
'heading' => 'Join',
|
||||
'intro_text' => null,
|
||||
'thank_you_message' => null,
|
||||
'expired_message' => null,
|
||||
'ticketshop_url' => null,
|
||||
'start_date' => now()->subHour(),
|
||||
'end_date' => now()->addMonth(),
|
||||
'phone_enabled' => false,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'A',
|
||||
'last_name' => 'B',
|
||||
'email' => 'nomw@example.com',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(
|
||||
route('admin.pages.subscribers.sync-mailwizz', [$page, $subscriber])
|
||||
);
|
||||
|
||||
$response->assertRedirect(route('admin.pages.subscribers.index', $page));
|
||||
$response->assertSessionHas('error');
|
||||
Queue::assertNothingPushed();
|
||||
}
|
||||
|
||||
public function test_cannot_queue_single_subscriber_mailwizz_sync_with_mismatched_page(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
$pageA = $this->makePageWithMailwizzForUser($user);
|
||||
$pageB = PreregistrationPage::query()->create([
|
||||
'slug' => (string) Str::uuid(),
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Other',
|
||||
'heading' => 'Other',
|
||||
'intro_text' => null,
|
||||
'thank_you_message' => null,
|
||||
'expired_message' => null,
|
||||
'ticketshop_url' => null,
|
||||
'start_date' => now()->subHour(),
|
||||
'end_date' => now()->addMonth(),
|
||||
'phone_enabled' => false,
|
||||
'is_active' => true,
|
||||
]);
|
||||
MailwizzConfig::query()->create([
|
||||
'preregistration_page_id' => $pageB->id,
|
||||
'api_key' => 'fake-api-key',
|
||||
'list_uid' => 'list-uid-2',
|
||||
'list_name' => 'List B',
|
||||
'field_email' => 'EMAIL',
|
||||
'field_first_name' => 'FNAME',
|
||||
'field_last_name' => 'LNAME',
|
||||
'field_phone' => null,
|
||||
'tag_field' => 'TAGS',
|
||||
'tag_value' => 'b-source',
|
||||
]);
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $pageB->id,
|
||||
'first_name' => 'A',
|
||||
'last_name' => 'B',
|
||||
'email' => 'on-b@example.com',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(
|
||||
route('admin.pages.subscribers.sync-mailwizz', [$pageA, $subscriber])
|
||||
);
|
||||
|
||||
$response->assertForbidden();
|
||||
Queue::assertNothingPushed();
|
||||
}
|
||||
|
||||
private function makePageWithMailwizz(): PreregistrationPage
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\PageBlock;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -19,20 +20,41 @@ class StorePreregistrationPageTest extends TestCase
|
||||
|
||||
$response = $this->actingAs($user)->post(route('admin.pages.store'), [
|
||||
'title' => 'Summer Fest',
|
||||
'heading' => 'Register',
|
||||
'intro_text' => null,
|
||||
'thank_you_message' => null,
|
||||
'expired_message' => null,
|
||||
'ticketshop_url' => null,
|
||||
'start_date' => '2026-06-01T10:00',
|
||||
'end_date' => '2026-06-30T18:00',
|
||||
'phone_enabled' => false,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('admin.pages.index'));
|
||||
$this->assertDatabaseCount('preregistration_pages', 1);
|
||||
$this->assertSame('Summer Fest', PreregistrationPage::query()->first()?->title);
|
||||
$page = PreregistrationPage::query()->first();
|
||||
$response->assertRedirect(route('admin.pages.edit', $page));
|
||||
$this->assertSame('Summer Fest', $page?->title);
|
||||
$this->assertFalse($page?->background_fixed);
|
||||
$this->assertGreaterThanOrEqual(4, PageBlock::query()->where('preregistration_page_id', $page?->id)->count());
|
||||
}
|
||||
|
||||
public function test_store_can_enable_fixed_background(): void
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('admin.pages.store'), [
|
||||
'title' => 'Winter Fest',
|
||||
'thank_you_message' => null,
|
||||
'expired_message' => null,
|
||||
'ticketshop_url' => null,
|
||||
'start_date' => '2026-06-01T10:00',
|
||||
'end_date' => '2026-06-30T18:00',
|
||||
'is_active' => true,
|
||||
'background_fixed' => true,
|
||||
]);
|
||||
|
||||
$page = PreregistrationPage::query()->where('title', 'Winter Fest')->first();
|
||||
$response->assertRedirect(route('admin.pages.edit', $page));
|
||||
$this->assertNotNull($page);
|
||||
$this->assertTrue($page->background_fixed);
|
||||
}
|
||||
|
||||
public function test_validation_failure_redirects_back_with_input(): void
|
||||
@@ -41,13 +63,11 @@ class StorePreregistrationPageTest extends TestCase
|
||||
|
||||
$response = $this->actingAs($user)->from(route('admin.pages.create'))->post(route('admin.pages.store'), [
|
||||
'title' => '',
|
||||
'heading' => 'H',
|
||||
'start_date' => '2026-06-30T10:00',
|
||||
'end_date' => '2026-06-01T10:00',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('admin.pages.create'));
|
||||
$response->assertSessionHasErrors('title');
|
||||
$response->assertSessionHasInput('heading', 'H');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\SyncSubscriberToMailwizz;
|
||||
use App\Models\MailwizzConfig;
|
||||
use App\Models\PreregistrationPage;
|
||||
use App\Models\Subscriber;
|
||||
@@ -18,6 +19,27 @@ class SyncSubscriberToMailwizzTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_subscribe_returns_ok_when_mailwizz_api_fails_under_sync_queue(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*' => Http::response(['status' => 'error', 'message' => 'service unavailable'], 503),
|
||||
]);
|
||||
|
||||
$page = $this->makePageWithMailwizz();
|
||||
|
||||
$this->postJson(route('public.subscribe', ['publicPage' => $page->slug]), [
|
||||
'first_name' => 'Broken',
|
||||
'last_name' => 'Mailwizz',
|
||||
'email' => 'broken-mailwizz@example.com',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJson(['success' => true]);
|
||||
|
||||
$subscriber = Subscriber::query()->where('email', 'broken-mailwizz@example.com')->first();
|
||||
$this->assertNotNull($subscriber);
|
||||
$this->assertFalse($subscriber->synced_to_mailwizz);
|
||||
}
|
||||
|
||||
public function test_subscribe_with_mailwizz_config_runs_sync_create_path_and_marks_synced(): void
|
||||
{
|
||||
Http::fake(function (Request $request) {
|
||||
@@ -91,6 +113,84 @@ class SyncSubscriberToMailwizzTest extends TestCase
|
||||
$this->assertTrue($subscriber->synced_to_mailwizz);
|
||||
}
|
||||
|
||||
public function test_mailwizz_sync_sends_phone_with_e164_plus_prefix(): void
|
||||
{
|
||||
Http::fake(function (Request $request) {
|
||||
$url = $request->url();
|
||||
if (str_contains($url, 'search-by-email')) {
|
||||
return Http::response(['status' => 'error']);
|
||||
}
|
||||
if ($request->method() === 'POST' && preg_match('#/lists/[^/]+/subscribers$#', $url) === 1) {
|
||||
$body = $request->body();
|
||||
$this->assertStringContainsString('PHONE', $body);
|
||||
$this->assertTrue(
|
||||
str_contains($body, '+31612345678') || str_contains($body, '%2B31612345678'),
|
||||
'Expected E.164 phone with + in Mailwizz request body'
|
||||
);
|
||||
|
||||
return Http::response(['status' => 'success']);
|
||||
}
|
||||
|
||||
return Http::response(['status' => 'error'], 500);
|
||||
});
|
||||
|
||||
$page = $this->makePageWithMailwizz([
|
||||
'field_phone' => 'PHONE',
|
||||
]);
|
||||
$page->update(['phone_enabled' => true]);
|
||||
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'Test',
|
||||
'last_name' => 'User',
|
||||
'email' => 'phone-e164@example.com',
|
||||
'phone' => '+31612345678',
|
||||
'synced_to_mailwizz' => false,
|
||||
]);
|
||||
|
||||
SyncSubscriberToMailwizz::dispatchSync($subscriber);
|
||||
|
||||
$subscriber->refresh();
|
||||
$this->assertTrue($subscriber->synced_to_mailwizz);
|
||||
}
|
||||
|
||||
public function test_mailwizz_sync_includes_coupon_code_when_mapped(): void
|
||||
{
|
||||
Http::fake(function (Request $request) {
|
||||
$url = $request->url();
|
||||
if (str_contains($url, 'search-by-email')) {
|
||||
return Http::response(['status' => 'error']);
|
||||
}
|
||||
if ($request->method() === 'POST' && preg_match('#/lists/[^/]+/subscribers$#', $url) === 1) {
|
||||
$body = $request->body();
|
||||
$this->assertStringContainsString('COUPON', $body);
|
||||
$this->assertStringContainsString('PREREG-TEST99', $body);
|
||||
|
||||
return Http::response(['status' => 'success']);
|
||||
}
|
||||
|
||||
return Http::response(['status' => 'error'], 500);
|
||||
});
|
||||
|
||||
$page = $this->makePageWithMailwizz([
|
||||
'field_coupon_code' => 'COUPON',
|
||||
]);
|
||||
|
||||
$subscriber = Subscriber::query()->create([
|
||||
'preregistration_page_id' => $page->id,
|
||||
'first_name' => 'Coupon',
|
||||
'last_name' => 'User',
|
||||
'email' => 'coupon-map@example.com',
|
||||
'coupon_code' => 'PREREG-TEST99',
|
||||
'synced_to_mailwizz' => false,
|
||||
]);
|
||||
|
||||
SyncSubscriberToMailwizz::dispatchSync($subscriber);
|
||||
|
||||
$subscriber->refresh();
|
||||
$this->assertTrue($subscriber->synced_to_mailwizz);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $configOverrides
|
||||
*/
|
||||
@@ -122,6 +222,7 @@ class SyncSubscriberToMailwizzTest extends TestCase
|
||||
'field_first_name' => 'FNAME',
|
||||
'field_last_name' => 'LNAME',
|
||||
'field_phone' => null,
|
||||
'field_coupon_code' => null,
|
||||
'tag_field' => 'TAGS',
|
||||
'tag_value' => 'preregister-source',
|
||||
], $configOverrides));
|
||||
|
||||
29
tests/Unit/SubscriberPhoneDisplayTest.php
Normal file
29
tests/Unit/SubscriberPhoneDisplayTest.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Subscriber;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SubscriberPhoneDisplayTest extends TestCase
|
||||
{
|
||||
public function test_phone_display_keeps_e164_with_plus(): void
|
||||
{
|
||||
$subscriber = new Subscriber(['phone' => '+31613210095']);
|
||||
$this->assertSame('+31613210095', $subscriber->phoneDisplay());
|
||||
}
|
||||
|
||||
public function test_phone_display_prefixes_plus_for_legacy_digit_only_storage(): void
|
||||
{
|
||||
$subscriber = new Subscriber(['phone' => '31613210095']);
|
||||
$this->assertSame('+31613210095', $subscriber->phoneDisplay());
|
||||
}
|
||||
|
||||
public function test_phone_display_returns_null_when_empty(): void
|
||||
{
|
||||
$subscriber = new Subscriber(['phone' => null]);
|
||||
$this->assertNull($subscriber->phoneDisplay());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user