Compare commits

..

13 Commits

Author SHA1 Message Date
977e09d8ac fix: show real Weeztix company trade names, not GUID-as-name
Merge nested company objects, prefer trade_name over name, skip UUID-like
labels; Alpine falls back to stored company_name when API label is junk.

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

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

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

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

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

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

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

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

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

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

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

Made-with: Cursor
2026-04-04 09:51:54 +02:00
ed85e5c537 feat: delete subscribers from page subscriber list
Adds DELETE route, form request authorization, admin UI with confirm, Dutch strings, and feature tests.

Made-with: Cursor
2026-04-04 01:29:32 +02:00
3c9b1d9810 Add visual improvements 2026-04-04 01:20:01 +02:00
ff58e82497 Implemented a block editor for changing the layout of the page 2026-04-04 01:17:05 +02:00
0800f7664f fix: resolve UserFactory fake() under Database\Factories namespace
Use $this->faker so seeding works on PHP 8.4 where unqualified fake()
resolves to Database\Factories\fake().

Made-with: Cursor
2026-04-04 00:39:31 +02:00
76 changed files with 5944 additions and 335 deletions

View File

@@ -7,6 +7,9 @@ APP_URL=http://localhost
# Optional: max requests/minute per IP for public /r/{slug} and subscribe (default: 1000 when APP_ENV is local|testing, else 60).
# PUBLIC_REQUESTS_PER_MINUTE=120
# Default region for parsing national phone numbers (ISO 3166-1 alpha-2). Used by libphonenumber.
# PREREGISTER_DEFAULT_PHONE_REGION=NL
# Wall-clock times from the admin UI (datetime-local) are interpreted in this zone.
APP_TIMEZONE=Europe/Amsterdam
@@ -70,3 +73,9 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Weeztix OAuth (defaults match https://docs.weeztix.com — only set if you use Open Ticket / another issuer)
# WEEZTIX_OAUTH_AUTHORIZE_URL=https://login.weeztix.com/login
# WEEZTIX_AUTH_BASE_URL=https://auth.weeztix.com
# WEEZTIX_USER_PROFILE_URL=https://auth.weeztix.com/users/me
# WEEZTIX_API_BASE_URL=https://api.weeztix.com

View File

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

View File

@@ -8,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')
->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,61 @@ class PageController extends Controller
public function edit(PreregistrationPage $page): View
{
$page->load(['blocks' => fn ($q) => $q->orderBy('sort_order')]);
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();

View File

@@ -5,9 +5,11 @@ 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\Models\PreregistrationPage;
use App\Models\Subscriber;
use App\Services\DispatchUnsyncedMailwizzSyncJobsService;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
@@ -32,6 +34,15 @@ class SubscriberController extends Controller
return view('admin.subscribers.index', compact('page', 'subscribers', 'unsyncedMailwizzCount'));
}
public function destroy(DestroySubscriberRequest $request, PreregistrationPage $page, Subscriber $subscriber): RedirectResponse
{
$subscriber->delete();
return redirect()
->route('admin.pages.subscribers.index', $page)
->with('status', __('Subscriber removed.'));
}
public function queueMailwizzSync(
QueueMailwizzSyncRequest $request,
PreregistrationPage $page,
@@ -70,7 +81,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 +89,15 @@ class SubscriberController extends Controller
if ($phoneEnabled) {
$headers[] = 'Phone';
}
$headers = array_merge($headers, ['Registered At', 'Synced to Mailwizz', 'Synced At']);
$headers = array_merge($headers, ['Coupon Code', 'Registered At', 'Synced to Mailwizz', 'Synced At']);
fputcsv($handle, $headers);
foreach ($subscribers as $sub) {
$row = [$sub->first_name, $sub->last_name, $sub->email];
if ($phoneEnabled) {
$row[] = $sub->phone ?? '';
$row[] = $sub->phoneDisplay() ?? '';
}
$row[] = $sub->coupon_code ?? '';
$row[] = $sub->created_at?->toDateTimeString() ?? '';
$row[] = $sub->synced_to_mailwizz ? 'Yes' : 'No';
$row[] = $sub->synced_at?->toDateTimeString() ?? '';

View File

@@ -0,0 +1,111 @@
<?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 companies(Request $request): JsonResponse
{
$request->validate([
'page_id' => ['required', 'integer', 'exists:preregistration_pages,id'],
]);
$page = PreregistrationPage::query()->findOrFail($request->integer('page_id'));
$this->authorize('update', $page);
$config = $page->weeztixConfig;
if ($config === null || ! $config->is_connected) {
return response()->json([
'message' => __('Niet verbonden met Weeztix.'),
], 422);
}
try {
$companies = (new WeeztixService($config))->getCompanies();
return response()->json(['companies' => $companies]);
} catch (RuntimeException) {
return response()->json([
'message' => __('Kon bedrijven niet laden. Vernieuw de verbinding indien nodig.'),
], 422);
}
}
public function coupons(Request $request): JsonResponse
{
$request->validate([
'page_id' => ['required', 'integer', 'exists:preregistration_pages,id'],
'company_guid' => ['required', 'string', 'max:255'],
]);
$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 = $request->string('company_guid')->toString();
$previousGuid = $config->company_guid;
$config->setAttribute('company_guid', $companyGuid);
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);
} finally {
$config->setAttribute('company_guid', $previousGuid);
}
}
/**
* @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;
}
$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;
}
}

View File

@@ -0,0 +1,59 @@
<?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\Support\Facades\DB;
use Illuminate\View\View;
class WeeztixController extends Controller
{
public function edit(PreregistrationPage $page): View
{
$this->authorize('update', $page);
$page->load('weeztixConfig');
return view('admin.weeztix.edit', compact('page'));
}
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])
);
});
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('status', __('Weeztix-configuratie opgeslagen.'));
}
public function destroy(PreregistrationPage $page): RedirectResponse
{
$this->authorize('update', $page);
$page->weeztixConfig()?->delete();
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('status', __('Weeztix-integratie verwijderd.'));
}
}

View File

@@ -0,0 +1,141 @@
<?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(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,
]);
$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) {
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
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());
} catch (RuntimeException $e) {
Log::error('Weeztix OAuth callback failed', [
'page_id' => $page->id,
'message' => $e->getMessage(),
]);
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('error', __('Verbinden met Weeztix is mislukt. Controleer je gegevens en probeer opnieuw.'));
}
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('status', __('Succesvol verbonden met Weeztix.'));
}
private function redirectToWeeztixEditWithSessionPage(string $message): RedirectResponse
{
$pageId = session('weeztix_page_id');
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
if (is_int($pageId) || is_numeric($pageId)) {
$page = PreregistrationPage::query()->find((int) $pageId);
if ($page !== null) {
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('error', $message);
}
}
return redirect()
->route('admin.dashboard')
->with('error', $message);
}
}

View File

@@ -4,17 +4,39 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Exceptions\WeeztixCouponCodeConflictException;
use App\Http\Requests\SubscribePublicPageRequest;
use App\Jobs\SyncSubscriberToMailwizz;
use App\Models\PageBlock;
use App\Models\PreregistrationPage;
use App\Models\Subscriber;
use App\Models\WeeztixConfig;
use App\Services\WeeztixService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Throwable;
class PublicPageController extends Controller
{
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
@@ -36,13 +58,101 @@ class PublicPageController extends Controller
$subscriber = $publicPage->subscribers()->create($validated);
$publicPage->loadMissing('weeztixConfig');
$weeztix = $publicPage->weeztixConfig;
if ($this->weeztixCanIssueCodes($weeztix)) {
$this->tryAttachWeeztixCouponCode($subscriber, $weeztix);
}
if ($publicPage->mailwizzConfig !== null) {
SyncSubscriberToMailwizz::dispatch($subscriber);
SyncSubscriberToMailwizz::dispatch($subscriber->fresh());
}
return response()->json([
'success' => true,
'message' => $publicPage->thank_you_message ?? __('Thank you for registering!'),
'coupon_code' => $subscriber->fresh()?->coupon_code,
]);
}
private function weeztixCanIssueCodes(?WeeztixConfig $config): bool
{
if ($config === null || ! $config->is_connected) {
return false;
}
$company = $config->company_guid;
$coupon = $config->coupon_guid;
return is_string($company) && $company !== '' && is_string($coupon) && $coupon !== '';
}
private function tryAttachWeeztixCouponCode(Subscriber $subscriber, WeeztixConfig $config): void
{
$freshConfig = $config->fresh();
if ($freshConfig === null) {
return;
}
$service = new WeeztixService($freshConfig);
$maxAttempts = 5;
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
try {
$code = WeeztixService::generateUniqueCode(
is_string($freshConfig->code_prefix) && $freshConfig->code_prefix !== ''
? $freshConfig->code_prefix
: 'PREREG'
);
$service->createCouponCode($code);
$subscriber->update(['coupon_code' => $code]);
return;
} catch (WeeztixCouponCodeConflictException) {
continue;
} catch (Throwable $e) {
Log::error('Weeztix coupon creation failed', [
'subscriber_id' => $subscriber->id,
'message' => $e->getMessage(),
]);
return;
}
}
Log::warning('Weeztix coupon: exhausted duplicate retries', [
'subscriber_id' => $subscriber->id,
]);
}
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();
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use App\Models\PreregistrationPage;
use App\Models\Subscriber;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class 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 [];
}
}

View 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 => [],
};
}
}

View File

@@ -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();
}
}

View File

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

View File

@@ -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);
});
}
}

View File

@@ -0,0 +1,55 @@
<?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',
],
'company_guid' => ['nullable', 'string', 'max:255'],
'company_name' => ['nullable', 'string', 'max:255'],
'coupon_guid' => ['nullable', 'string', 'max:255'],
'coupon_name' => ['nullable', 'string', 'max:255'],
'code_prefix' => ['nullable', 'string', 'max:32'],
'usage_count' => ['nullable', 'integer', 'min:1', 'max:99999'],
];
}
}

View File

@@ -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();
}
}

View File

@@ -5,7 +5,10 @@ declare(strict_types=1);
namespace App\Http\Requests;
use App\Models\PreregistrationPage;
use App\Rules\ValidPhoneNumber;
use App\Services\PhoneNumberNormalizer;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Email;
class SubscribePublicPageRequest extends FormRequest
@@ -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 (815 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]);
}
}
}

View File

@@ -128,12 +128,17 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
];
if ($phoneEnabled && $config->field_phone !== null && $config->field_phone !== '') {
$phone = $subscriber->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;
}
@@ -144,7 +149,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
string $listUid
): void {
$page = $subscriber->preregistrationPage;
$data = $this->buildBasePayload($subscriber, $config, (bool) $page->phone_enabled);
$data = $this->buildBasePayload($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
$tagField = $config->tag_field;
$tagValue = $config->tag_value;
if ($tagField !== null && $tagField !== '' && $tagValue !== null && $tagValue !== '') {
@@ -162,7 +167,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
string $subscriberUid
): void {
$page = $subscriber->preregistrationPage;
$data = $this->buildBasePayload($subscriber, $config, (bool) $page->phone_enabled);
$data = $this->buildBasePayload($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
$tagField = $config->tag_field;
$tagValue = $config->tag_value;

View File

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

56
app/Models/PageBlock.php Normal file
View 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,
};
}
}

View File

@@ -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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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();
}
}

View File

@@ -0,0 +1,550 @@
<?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;
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);
}
/**
* 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.');
}
public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string
{
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$code = '';
for ($i = 0; $i < $length; $i++) {
$code .= $chars[random_int(0, strlen($chars) - 1)];
}
return strtoupper($prefix).'-'.$code;
}
private function assertCompanyGuid(): void
{
$guid = $this->config->company_guid;
if (! is_string($guid) || $guid === '') {
throw new LogicException('Weeztix company is not configured.');
}
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function apiRequest(string $method, string $url, array $data = []): array
{
$this->assertCompanyGuid();
$token = $this->getValidAccessToken();
$response = $this->sendApiRequest($method, $url, $data, $token);
if ($response->status() === 401) {
$this->refreshAccessToken();
$this->config->refresh();
$response = $this->sendApiRequest($method, $url, $data, (string) $this->config->access_token);
}
if ($response->failed()) {
$this->logFailedResponse('apiRequest', $url, $response);
throw new RuntimeException('Weeztix API request failed: '.$response->status());
}
Log::debug('Weeztix API', [
'action' => 'apiRequest',
'method' => $method,
'url' => $url,
'http_status' => $response->status(),
]);
$json = $response->json();
return is_array($json) ? $json : [];
}
/**
* @param array<string, mixed> $data
*/
private function sendApiRequest(string $method, string $url, array $data, string $token): Response
{
$client = Http::withHeaders([
'Authorization' => 'Bearer '.$token,
'Company' => (string) $this->config->company_guid,
])->acceptJson();
return match (strtolower($method)) {
'get' => $client->get($url, $data),
'post' => $client->asJson()->post($url, $data),
'put' => $client->asJson()->put($url, $data),
'patch' => $client->asJson()->patch($url, $data),
'delete' => $client->delete($url, $data),
default => throw new InvalidArgumentException('Unsupported HTTP method: '.$method),
};
}
private function refreshAccessToken(): void
{
if (! $this->config->refresh_token || $this->config->isRefreshTokenExpired()) {
$this->config->update([
'is_connected' => false,
]);
$this->config->refresh();
throw new RuntimeException('Weeztix refresh token missing or expired; reconnect OAuth.');
}
$tokenUrl = config('weeztix.auth_base_url').'/tokens';
$response = Http::asForm()->post($tokenUrl, [
'grant_type' => 'refresh_token',
'refresh_token' => $this->config->refresh_token,
'client_id' => $this->config->client_id,
'client_secret' => $this->config->client_secret,
]);
if ($response->failed()) {
Log::error('Weeztix token refresh failed', [
'url' => $tokenUrl,
'status' => $response->status(),
'body' => $response->json(),
]);
$this->config->update([
'is_connected' => false,
]);
$this->config->refresh();
throw new RuntimeException('Weeztix token refresh failed: '.$response->status());
}
Log::debug('Weeztix API', [
'action' => 'refresh_token',
'url' => $tokenUrl,
'http_status' => $response->status(),
]);
$json = $response->json();
if (! is_array($json)) {
throw new RuntimeException('Weeztix token refresh returned invalid JSON.');
}
$this->applyTokenResponseToConfig($json);
}
/**
* @param array<string, mixed> $json
*/
private function applyTokenResponseToConfig(array $json): void
{
$access = $json['access_token'] ?? null;
$refresh = $json['refresh_token'] ?? null;
if (! is_string($access) || $access === '') {
throw new RuntimeException('Weeztix token response missing access_token.');
}
$expiresIn = isset($json['expires_in']) ? (int) $json['expires_in'] : 0;
$refreshExpiresIn = isset($json['refresh_token_expires_in']) ? (int) $json['refresh_token_expires_in'] : 0;
$updates = [
'access_token' => $access,
'token_expires_at' => $expiresIn > 0 ? Carbon::now()->addSeconds($expiresIn) : null,
'is_connected' => true,
];
if (is_string($refresh) && $refresh !== '') {
$updates['refresh_token'] = $refresh;
$updates['refresh_token_expires_at'] = $refreshExpiresIn > 0
? Carbon::now()->addSeconds($refreshExpiresIn)
: null;
}
$this->config->update($updates);
$this->config->refresh();
}
/**
* When the token response includes exactly one company, store it to reduce admin steps.
*
* @param array<string, mixed> $json
*/
private function hydrateCompanyFromTokenInfo(array $json): void
{
$companies = data_get($json, 'info.companies');
if (! is_array($companies) || count($companies) !== 1) {
return;
}
$row = $companies[0];
if (! is_array($row)) {
return;
}
$merged = $this->mergeCompanyRowWithNested($row);
$guid = data_get($merged, 'guid') ?? data_get($merged, 'id');
if (! is_string($guid) || $guid === '') {
return;
}
$name = $this->resolveCompanyNameFromRow($merged, $guid);
$this->config->update([
'company_guid' => $guid,
'company_name' => $name,
]);
$this->config->refresh();
}
/**
* @param array<string, mixed> $profile
* @return list<array{guid: string, name: string|null}>
*/
private function normalizeCompaniesFromProfile(array $profile): array
{
$fromInfo = data_get($profile, 'info.companies');
if (is_array($fromInfo) && $fromInfo !== []) {
$normalized = $this->normalizeCompanyRows($fromInfo);
if ($normalized !== []) {
return $normalized;
}
}
$companies = data_get($profile, 'companies');
if (is_array($companies) && $companies !== []) {
return $this->normalizeCompanyRows($companies);
}
$defaultCompany = data_get($profile, 'default_company');
if (is_array($defaultCompany)) {
$merged = $this->mergeCompanyRowWithNested($defaultCompany);
$guid = data_get($merged, 'guid') ?? data_get($merged, 'id');
if (is_string($guid) && $guid !== '') {
return [
[
'guid' => $guid,
'name' => $this->resolveCompanyNameFromRow($merged, $guid),
],
];
}
}
$defaultId = data_get($profile, 'default_company_id');
if (is_string($defaultId) && $defaultId !== '') {
return [
['guid' => $defaultId, 'name' => null],
];
}
return [];
}
/**
* @param array<int, mixed> $rows
* @return list<array{guid: string, name: string|null}>
*/
private function normalizeCompanyRows(array $rows): array
{
$out = [];
foreach ($rows as $row) {
if (! is_array($row)) {
continue;
}
$merged = $this->mergeCompanyRowWithNested($row);
$guid = data_get($merged, 'guid')
?? data_get($merged, 'id')
?? data_get($merged, 'company_id');
if (! is_string($guid) || $guid === '') {
continue;
}
$out[] = [
'guid' => $guid,
'name' => $this->resolveCompanyNameFromRow($merged, $guid),
];
}
return $out;
}
/**
* Flatten `{ "company": { ... } }` style payloads so name fields resolve reliably.
*
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function mergeCompanyRowWithNested(array $row): array
{
$nested = data_get($row, 'company');
if (! is_array($nested)) {
return $row;
}
return array_merge($row, $nested);
}
/**
* Weeztix / Open Ticket payloads use varying keys; `name` is sometimes a duplicate of the GUID.
*
* @param array<string, mixed> $row
*/
private function resolveCompanyNameFromRow(array $row, ?string $companyGuid = null): ?string
{
$candidates = [
data_get($row, 'trade_name'),
data_get($row, 'commercial_name'),
data_get($row, 'business_name'),
data_get($row, 'legal_name'),
data_get($row, 'company_name'),
data_get($row, 'display_name'),
data_get($row, 'title'),
data_get($row, 'label'),
data_get($row, 'general.name'),
data_get($row, 'company.trade_name'),
data_get($row, 'company.legal_name'),
data_get($row, 'company.name'),
data_get($row, 'name'),
];
foreach ($candidates as $value) {
if (! is_string($value)) {
continue;
}
$trimmed = trim($value);
if ($trimmed === '') {
continue;
}
if ($companyGuid !== null && strcasecmp($trimmed, $companyGuid) === 0) {
continue;
}
if ($this->stringLooksLikeUuid($trimmed)) {
continue;
}
return $trimmed;
}
return null;
}
private function stringLooksLikeUuid(string $value): bool
{
return preg_match(
'/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/',
$value
) === 1;
}
private function logFailedResponse(string $action, string $url, Response $response): void
{
Log::error('Weeztix API request failed', [
'action' => $action,
'url' => $url,
'status' => $response->status(),
'body' => $response->json(),
]);
}
}

View File

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

136
composer.lock generated
View File

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

View File

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

57
config/weeztix.php Normal file
View File

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

View File

@@ -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),

View File

@@ -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');
}
};

View File

@@ -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();
}
};

View File

@@ -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');
// });
}
};

View File

@@ -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',
]);
});
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,5 +18,12 @@
"Thank you for registering!": "Bedankt voor je registratie!",
"You are already registered for this event.": "Je bent al geregistreerd voor dit evenement.",
"Please enter a valid email address.": "Voer een geldig e-mailadres in.",
"Please enter a valid phone number (815 digits).": "Voer een geldig telefoonnummer in (8 tot 15 cijfers)."
"Please enter a valid phone number (815 digits).": "Voer een geldig telefoonnummer in (8 tot 15 cijfers).",
"Please enter a valid phone number.": "Voer een geldig telefoonnummer in.",
"Subscriber removed.": "Abonnee verwijderd.",
"Delete this subscriber? This cannot be undone.": "Deze abonnee verwijderen? Dit kan niet ongedaan worden gemaakt.",
"Remove": "Verwijderen",
"Actions": "Acties",
"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
View File

@@ -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",

View File

@@ -17,5 +17,8 @@
"postcss": "^8.4.31",
"tailwindcss": "^3.1.0",
"vite": "^8.0.0"
},
"dependencies": {
"sortablejs": "^1.15.7"
}
}

View File

@@ -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,42 @@ 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 || {},
couponCode: '',
copyPageLink() {
const url = this.pageShareUrl;
if (!url) {
return;
}
navigator.clipboard.writeText(url).then(() => {
this.copyFeedback = config.strings?.linkCopied || '';
setTimeout(() => {
this.copyFeedback = '';
}, 2500);
});
},
copyCouponCode() {
const code = this.couponCode;
if (!code) {
return;
}
navigator.clipboard.writeText(code).then(() => {
this.copyFeedback = this.strings?.couponCopied || '';
setTimeout(() => {
this.copyFeedback = '';
}, 2500);
});
},
init() {
if (this.phase === 'before') {
@@ -65,6 +455,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 +514,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,6 +566,9 @@ document.addEventListener('alpine:init', () => {
if (res.ok && data.success) {
this.phase = 'thanks';
this.thankYouMessage = data.message ?? '';
this.couponCode =
typeof data.coupon_code === 'string' && data.coupon_code !== '' ? data.coupon_code : '';
this.startRedirectCountdownIfNeeded();
return;
}
if (typeof data.message === 'string' && data.message !== '') {
@@ -163,6 +590,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 +603,7 @@ document.addEventListener('alpine:init', () => {
fieldFirstName: '',
fieldLastName: '',
fieldPhone: '',
fieldCouponCode: '',
tagField: '',
tagValue: '',
loading: false,
@@ -186,6 +615,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 +725,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 +766,187 @@ document.addEventListener('alpine:init', () => {
this.$refs.saveForm.requestSubmit();
},
}));
Alpine.data('weeztixSetup', (cfg) => ({
pageId: cfg.pageId,
companiesUrl: cfg.companiesUrl,
couponsUrl: cfg.couponsUrl,
csrf: cfg.csrf,
isConnected: cfg.isConnected === true,
callbackUrl: cfg.callbackUrl,
errorMessage: '',
companies: [],
coupons: [],
companyGuid: '',
companyName: '',
couponGuid: '',
couponName: '',
codePrefix: 'PREREG',
usageCount: 1,
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.companyGuid = cfg.existing.company_guid || '';
this.companyName = cfg.existing.company_name || '';
this.couponGuid = cfg.existing.coupon_guid || '';
this.couponName = cfg.existing.coupon_name || '';
}
if (this.isConnected) {
await this.loadCompanies();
if (this.companyGuid) {
await this.loadCouponsForGuid(this.companyGuid);
}
} else if (cfg.existing && (cfg.existing.company_guid || cfg.existing.coupon_guid)) {
// Show saved choices even when not connected (e.g. expired refresh); lists are from DB only.
this.ensureSelectedCompanyInList();
this.ensureSelectedCouponInList();
}
},
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 };
},
syncCompanyNameFromSelection() {
if (!this.companyGuid) {
this.companyName = '';
return;
}
const c = this.companies.find((x) => x.guid === this.companyGuid);
if (c && typeof c.name === 'string' && c.name.trim() !== '') {
this.companyName = c.name.trim();
}
// If API row has no name or list is still loading, keep companyName from server (DB).
},
syncCouponName() {
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();
}
},
ensureSelectedCompanyInList() {
const guid = this.companyGuid;
if (!guid || this.companies.some((x) => x.guid === guid)) {
return;
}
const label =
typeof this.companyName === 'string' && this.companyName.trim() !== ''
? this.companyName.trim()
: guid;
this.companies = [{ guid, name: label }, ...this.companies];
},
ensureSelectedCouponInList() {
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 loadCompanies() {
this.errorMessage = '';
const { res, data } = await this.postJson(this.companiesUrl, { page_id: this.pageId });
if (!res.ok) {
this.errorMessage = data.message || this.strings.genericError;
this.ensureSelectedCompanyInList();
return;
}
this.companies = Array.isArray(data.companies) ? data.companies : [];
this.ensureSelectedCompanyInList();
this.syncCompanyNameFromSelection();
},
async onCompanyChange() {
this.syncCompanyNameFromSelection();
this.couponGuid = '';
this.couponName = '';
this.coupons = [];
if (!this.companyGuid) {
return;
}
await this.loadCouponsForGuid(this.companyGuid);
},
async loadCouponsForGuid(guid) {
this.errorMessage = '';
const { res, data } = await this.postJson(this.couponsUrl, {
page_id: this.pageId,
company_guid: guid,
});
if (!res.ok) {
this.errorMessage = data.message || this.strings.loadCouponsError;
this.ensureSelectedCouponInList();
return;
}
this.coupons = Array.isArray(data.coupons) ? data.coupons : [];
this.ensureSelectedCouponInList();
this.syncCouponName();
},
/** Prefer human-readable label; skip API "names" that are just the GUID / UUID. */
companyLabel(c) {
if (!c || typeof c.guid !== 'string') {
return '';
}
const g = c.guid;
const isBadLabel = (s) => {
const t = typeof s === 'string' ? s.trim() : '';
return (
t === '' ||
t.toLowerCase() === g.toLowerCase() ||
this.stringLooksLikeUuid(t)
);
};
const fromApi = typeof c.name === 'string' ? c.name : '';
if (!isBadLabel(fromApi)) {
return fromApi.trim();
}
if (this.companyGuid === g && !isBadLabel(this.companyName)) {
return String(this.companyName).trim();
}
return g;
},
stringLooksLikeUuid(s) {
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/.test(
String(s),
);
},
}));
});
window.Alpine = Alpine;

View File

@@ -1,5 +1,7 @@
@php
$config = $page->mailwizzConfig;
$page->loadMissing('weeztixConfig');
$hasWeeztixForCouponMap = $page->weeztixConfig !== null && $page->weeztixConfig->is_connected;
$existing = $config !== null
? [
'list_uid' => $config->list_uid,
@@ -8,6 +10,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,
]
@@ -25,8 +28,9 @@
'listsUrl' => route('admin.mailwizz.lists'),
'fieldsUrl' => route('admin.mailwizz.fields'),
'csrf' => csrf_token(),
'phoneEnabled' => (bool) $page->phone_enabled,
'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(),
'hasExistingConfig' => $config !== null,
'hasWeeztixIntegration' => $hasWeeztixForCouponMap,
'existing' => $existing,
'strings' => [
'apiKeyRequired' => __('Enter your Mailwizz API key to continue.'),
@@ -190,6 +194,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
@@ -244,6 +258,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">

View 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>

View File

@@ -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>

View 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

View File

@@ -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
@include('admin.pages._form', ['page' => null])
<div class="mt-8 flex gap-3">
<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>
</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>

View File

@@ -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,20 @@
{{ __('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">
<p class="mt-3 flex flex-wrap gap-x-4 gap-y-1">
<a href="{{ route('admin.pages.mailwizz.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">{{ __('Mailwizz integration') }} </a>
<a href="{{ route('admin.pages.weeztix.edit', $page) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-500">{{ __('Weeztix integration') }} </a>
</p>
@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 +35,24 @@
</ul>
</div>
@endif
@include('admin.pages._form', ['page' => $page])
<div class="mt-8 flex gap-3">
<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>
</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>

View File

@@ -75,6 +75,12 @@
@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>
@if ($page->weeztixConfig?->is_connected)
<span class="text-xs font-medium text-emerald-600" title="{{ __('Weeztix verbonden') }}"></span>
@endif
@endcan
<button
type="button"
x-data="{ copied: false }"

View File

@@ -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,29 @@
</span>
@endif
</td>
<td class="whitespace-nowrap px-4 py-3 text-right">
@can('update', $page)
<form
method="post"
action="{{ route('admin.pages.subscribers.destroy', [$page, $subscriber]) }}"
class="inline"
onsubmit="return confirm(@js(__('Delete this subscriber? This cannot be undone.')));"
>
@csrf
@method('DELETE')
<button
type="submit"
class="rounded-lg border border-red-200 bg-white px-2.5 py-1 text-xs font-semibold text-red-700 hover:bg-red-50"
>
{{ __('Remove') }}
</button>
</form>
@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>

View File

@@ -0,0 +1,234 @@
@php
use Illuminate\Support\Carbon;
$wz = $page->weeztixConfig;
$existing = $wz !== null
? [
'company_guid' => $wz->company_guid,
'company_name' => $wz->company_name,
'coupon_guid' => $wz->coupon_guid,
'coupon_name' => $wz->coupon_name,
'code_prefix' => $wz->code_prefix,
'usage_count' => $wz->usage_count,
]
: null;
@endphp
@extends('layouts.admin')
@section('title', __('Weeztix') . ' — ' . $page->title)
@section('mobile_title', __('Weeztix'))
@section('content')
<div
class="mx-auto max-w-3xl"
x-data="weeztixSetup(@js([
'pageId' => $page->id,
'companiesUrl' => route('admin.weeztix.companies'),
'couponsUrl' => route('admin.weeztix.coupons'),
'csrf' => csrf_token(),
'isConnected' => $wz?->is_connected ?? false,
'tokenExpiresAt' => $wz?->token_expires_at instanceof Carbon ? $wz->token_expires_at->timezone(config('app.timezone'))->format('Y-m-d H:i') : null,
'callbackUrl' => route('admin.weeztix.callback', absolute: true),
'existing' => $existing,
'strings' => [
'genericError' => __('Er ging iets mis. Probeer het opnieuw.'),
'selectCompany' => __('Selecteer een bedrijf.'),
'loadCouponsError' => __('Kon kortingsbonnen niet laden.'),
],
]))"
>
<div class="mb-8">
<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 ($wz !== null && $wz->is_connected)
<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">{{ __('Verbonden met Weeztix') }}</p>
@if ($wz->token_expires_at)
<p class="mt-1 text-emerald-800">
{{ __('Huidig toegangstoken tot:') }}
<span class="font-mono text-xs">{{ $wz->token_expires_at->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</span>
</p>
<p class="mt-2 text-emerald-800/90">
{{ __('Het toegangstoken wordt automatisch vernieuwd bij API-gebruik (o.a. kortingscodes), zolang de refresh-token geldig is. Je hoeft niet opnieuw te verbinden.') }}
</p>
@endif
</div>
@elseif ($wz !== null && ! $wz->is_connected)
<div class="mb-8 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
<p class="font-medium">{{ __('Niet verbonden') }}</p>
<p class="mt-1 text-amber-900">{{ __('Je moet opnieuw verbinden om kortingscodes aan te maken. Gebruik de knop “Verbind met Weeztix”.') }}</p>
</div>
@endif
@if ($wz !== null)
<form action="{{ route('admin.pages.weeztix.destroy', $page) }}" method="post" class="mb-8"
onsubmit="return confirm(@js(__('Weeztix-integratie voor deze pagina verwijderen? Opgeslagen tokens worden gewist.')));">
@csrf
@method('DELETE')
<button type="submit" class="text-sm font-semibold text-red-700 underline hover:text-red-800">
{{ __('Weeztix loskoppelen') }}
</button>
</form>
@endif
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
<div x-show="errorMessage !== ''" x-cloak class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" x-text="errorMessage"></div>
<section class="space-y-4 border-b border-slate-100 pb-8">
<h2 class="text-lg font-semibold text-slate-900">{{ __('Stap 1: OAuth-gegevens') }}</h2>
<p class="text-sm leading-relaxed text-slate-600">
{{ __('Maak eerst een OAuth-client in het Weeztix-dashboard en stel de redirect-URI exact in op:') }}
</p>
<p class="rounded-lg bg-slate-50 px-3 py-2 font-mono text-xs text-slate-800 break-all" x-text="callbackUrl"></p>
<p class="text-sm text-slate-600">
{{ __('Maak daarna een korting (coupon) in Weeztix; die kies je hierna in stap 2.') }}
</p>
<form method="post" action="{{ route('admin.pages.weeztix.update', $page) }}" class="space-y-4">
@csrf
@method('PUT')
<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">
{{ __('Client ID en secret zijn opgeslagen maar worden niet opnieuw getoond. Laat de velden leeg om ze te behouden; vul ze alleen in als je ze wilt wijzigen.') }}
</p>
@endif
<button type="submit" class="rounded-lg bg-slate-800 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-slate-700">
{{ __('Gegevens opslaan') }}
</button>
</form>
@if ($wz !== null)
<div class="pt-2">
<a
href="{{ route('admin.pages.weeztix.oauth.redirect', $page) }}"
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"
>
{{ __('Verbind met Weeztix') }}
</a>
</div>
@endif
</section>
@if ($wz !== null)
<section class="space-y-4 pt-8">
<h2 class="text-lg font-semibold text-slate-900">{{ __('Stap 2: Bedrijf en kortingsbon') }}</h2>
<p class="text-sm text-slate-600">{{ __('Na een geslaagde verbinding kun je een bedrijf en bestaande coupon uit Weeztix kiezen.') }}</p>
<form method="post" action="{{ route('admin.pages.weeztix.update', $page) }}" class="space-y-4">
@csrf
@method('PUT')
<div>
<label for="weeztix_company" class="block text-sm font-medium text-slate-700">{{ __('Bedrijf') }}</label>
<select
id="weeztix_company"
name="company_guid"
x-model="companyGuid"
@change="onCompanyChange()"
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
<option value="">{{ __('Selecteer een bedrijf…') }}</option>
<template x-for="c in companies" :key="c.guid">
<option :value="c.guid" x-text="companyLabel(c)"></option>
</template>
</select>
<input type="hidden" name="company_name" :value="companyName">
</div>
<div>
<label for="weeztix_coupon" class="block text-sm font-medium text-slate-700">{{ __('Coupon (kortingssjabloon)') }}</label>
<select
id="weeztix_coupon"
name="coupon_guid"
x-model="couponGuid"
@change="syncCouponName()"
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
<option value="">{{ __('Selecteer een coupon…') }}</option>
<template x-for="c in coupons" :key="c.guid">
<option :value="c.guid" x-text="c.name"></option>
</template>
</select>
<input type="hidden" name="coupon_name" :value="couponName">
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="weeztix_code_prefix" class="block text-sm font-medium text-slate-700">{{ __('Codevoorvoegsel') }}</label>
<input
id="weeztix_code_prefix"
name="code_prefix"
type="text"
maxlength="32"
x-model="codePrefix"
value="{{ old('code_prefix', $wz->code_prefix ?? 'PREREG') }}"
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
</div>
<div>
<label for="weeztix_usage_count" class="block text-sm font-medium text-slate-700">{{ __('Gebruik per code') }}</label>
<input
id="weeztix_usage_count"
name="usage_count"
type="number"
min="1"
max="99999"
x-model.number="usageCount"
value="{{ old('usage_count', $wz->usage_count ?? 1) }}"
class="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
</div>
</div>
<button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">
{{ __('Configuratie opslaan') }}
</button>
</form>
</section>
@endif
</div>
</div>
@endsection

View 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>

View 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

View 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>

View 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>

View File

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

View 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>

View 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

View 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

View 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

View 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>

View File

@@ -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

View File

@@ -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">

View File

@@ -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,197 +31,84 @@
<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 (815 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!'),
'couponCopied' => __('Kortingscode 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 ($pageState === 'expired')
<div class="space-y-6">
@if (filled($page->expired_message))
{{-- 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->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 (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>
@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))
<div class="text-center">
<a
href="{{ e($page->ticketshop_url) }}"
class="inline-flex min-h-[52px] items-center justify-center rounded-xl bg-festival px-8 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]"
target="_blank"
rel="noopener noreferrer"
>
{{ __('Visit ticket shop') }}
<span class="ml-1" aria-hidden="true"></span>
</a>
@if (filled($page->ticketshop_url) && ! $hasExpiredCtaBlock)
<div class="text-center">
<a
href="{{ e($page->ticketshop_url) }}"
class="inline-flex min-h-[52px] items-center justify-center rounded-xl bg-festival px-8 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]"
target="_blank"
rel="noopener noreferrer"
>
{{ __('Visit ticket shop') }}
<span class="ml-1" aria-hidden="true"></span>
</a>
</div>
@endif
</div>
@endif
</div>

View File

@@ -8,6 +8,9 @@ use App\Http\Controllers\Admin\MailwizzController;
use App\Http\Controllers\Admin\PageController;
use App\Http\Controllers\Admin\SubscriberController;
use App\Http\Controllers\Admin\UserController;
use App\Http\Controllers\Admin\WeeztixApiController;
use App\Http\Controllers\Admin\WeeztixController;
use App\Http\Controllers\Admin\WeeztixOAuthController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\PublicPageController;
use Illuminate\Support\Facades\Route;
@@ -33,6 +36,7 @@ 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::get('pages/{page}/subscribers', [SubscriberController::class, 'index'])->name('pages.subscribers.index');
@@ -45,6 +49,16 @@ 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/companies', [WeeztixApiController::class, 'companies'])->name('weeztix.companies');
Route::post('weeztix/coupons', [WeeztixApiController::class, 'coupons'])->name('weeztix.coupons');
// User management (superadmin only)
Route::middleware('role:superadmin')->group(function () {
Route::resource('users', UserController::class)->except(['show']);

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

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

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\PreregistrationPage;
use App\Models\Subscriber;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
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]);
}
}

View File

@@ -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']);
}
}

View File

@@ -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');
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Feature;
use App\Jobs\SyncSubscriberToMailwizz;
use App\Models\MailwizzConfig;
use App\Models\PreregistrationPage;
use App\Models\Subscriber;
@@ -91,6 +92,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 +201,7 @@ class SyncSubscriberToMailwizzTest extends TestCase
'field_first_name' => 'FNAME',
'field_last_name' => 'LNAME',
'field_phone' => null,
'field_coupon_code' => null,
'tag_field' => 'TAGS',
'tag_value' => 'preregister-source',
], $configOverrides));

View File

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