diff --git a/app/Http/Controllers/Admin/PageController.php b/app/Http/Controllers/Admin/PageController.php index eea531d..25b17cf 100644 --- a/app/Http/Controllers/Admin/PageController.php +++ b/app/Http/Controllers/Admin/PageController.php @@ -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'); } @@ -47,31 +49,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 +85,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> $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(); diff --git a/app/Http/Controllers/Admin/SubscriberController.php b/app/Http/Controllers/Admin/SubscriberController.php index f429142..6d7a663 100644 --- a/app/Http/Controllers/Admin/SubscriberController.php +++ b/app/Http/Controllers/Admin/SubscriberController.php @@ -70,7 +70,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'); diff --git a/app/Http/Controllers/PublicPageController.php b/app/Http/Controllers/PublicPageController.php index 27c9d23..fc16f1b 100644 --- a/app/Http/Controllers/PublicPageController.php +++ b/app/Http/Controllers/PublicPageController.php @@ -6,15 +6,31 @@ namespace App\Http\Controllers; use App\Http\Requests\SubscribePublicPageRequest; use App\Jobs\SyncSubscriberToMailwizz; +use App\Models\PageBlock; use App\Models\PreregistrationPage; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Collection; use Illuminate\View\View; 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 @@ -45,4 +61,35 @@ class PublicPageController extends Controller 'message' => $publicPage->thank_you_message ?? __('Thank you for registering!'), ]); } + + private function resolvePageState(PreregistrationPage $page): string + { + if ($page->isBeforeStart()) { + return 'countdown'; + } + + if ($page->isExpired()) { + return 'expired'; + } + + return 'active'; + } + + /** + * @return Collection + */ + 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(); + } } diff --git a/app/Http/Requests/Admin/PageBlockContentValidator.php b/app/Http/Requests/Admin/PageBlockContentValidator.php new file mode 100644 index 0000000..ddf9c0f --- /dev/null +++ b/app/Http/Requests/Admin/PageBlockContentValidator.php @@ -0,0 +1,162 @@ +> $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> + */ + 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 => [], + }; + } +} diff --git a/app/Http/Requests/Admin/StorePreregistrationPageRequest.php b/app/Http/Requests/Admin/StorePreregistrationPageRequest.php index 34c0ad7..99715f9 100644 --- a/app/Http/Requests/Admin/StorePreregistrationPageRequest.php +++ b/app/Http/Requests/Admin/StorePreregistrationPageRequest.php @@ -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(); } } diff --git a/app/Http/Requests/Admin/UpdatePreregistrationPageRequest.php b/app/Http/Requests/Admin/UpdatePreregistrationPageRequest.php index 0b90862..49c1ab9 100644 --- a/app/Http/Requests/Admin/UpdatePreregistrationPageRequest.php +++ b/app/Http/Requests/Admin/UpdatePreregistrationPageRequest.php @@ -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); + }); } } diff --git a/app/Http/Requests/Admin/ValidatesPreregistrationPageInput.php b/app/Http/Requests/Admin/ValidatesPreregistrationPageInput.php index 8d9cb2d..6dceb65 100644 --- a/app/Http/Requests/Admin/ValidatesPreregistrationPageInput.php +++ b/app/Http/Requests/Admin/ValidatesPreregistrationPageInput.php @@ -4,32 +4,50 @@ 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> */ - 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'], + '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> + */ + 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 +55,39 @@ 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'), + '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> + */ + protected function preregistrationPageRules(): array + { + return array_merge($this->preregistrationPageSettingsRules(), $this->preregistrationPageBlocksRules()); + } + + protected function preparePreregistrationPageFields(): void + { + $this->preparePreregistrationPageSettings(); + } } diff --git a/app/Http/Requests/SubscribePublicPageRequest.php b/app/Http/Requests/SubscribePublicPageRequest.php index 745db43..f89ed09 100644 --- a/app/Http/Requests/SubscribePublicPageRequest.php +++ b/app/Http/Requests/SubscribePublicPageRequest.php @@ -22,6 +22,7 @@ class SubscribePublicPageRequest extends FormRequest { /** @var PreregistrationPage $page */ $page = $this->route('publicPage'); + $page->loadMissing('blocks'); $emailRule = (new Email) ->rfcCompliant() @@ -31,7 +32,7 @@ class SubscribePublicPageRequest extends FormRequest 'first_name' => ['required', 'string', 'max:255'], 'last_name' => ['required', 'string', 'max:255'], 'email' => ['required', 'string', 'max:255', $emailRule], - 'phone' => $page->phone_enabled + 'phone' => $page->isPhoneFieldEnabledForSubscribers() ? ['nullable', 'string', 'regex:/^[0-9]{8,15}$/'] : ['nullable', 'string', 'max:255'], ]; @@ -73,7 +74,7 @@ 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; diff --git a/app/Jobs/SyncSubscriberToMailwizz.php b/app/Jobs/SyncSubscriberToMailwizz.php index d3b34ac..64d2675 100644 --- a/app/Jobs/SyncSubscriberToMailwizz.php +++ b/app/Jobs/SyncSubscriberToMailwizz.php @@ -144,7 +144,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 +162,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; diff --git a/app/Models/PageBlock.php b/app/Models/PageBlock.php new file mode 100644 index 0000000..5a2aa99 --- /dev/null +++ b/app/Models/PageBlock.php @@ -0,0 +1,56 @@ + '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, + }; + } +} diff --git a/app/Models/PreregistrationPage.php b/app/Models/PreregistrationPage.php index 7c617a8..f307e96 100644 --- a/app/Models/PreregistrationPage.php +++ b/app/Models/PreregistrationPage.php @@ -25,10 +25,13 @@ 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', 'logo_image', 'is_active', ]; @@ -61,6 +64,84 @@ 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; + } + + 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); diff --git a/app/Services/PreregistrationPageBlockWriter.php b/app/Services/PreregistrationPageBlockWriter.php new file mode 100644 index 0000000..ed8e8e5 --- /dev/null +++ b/app/Services/PreregistrationPageBlockWriter.php @@ -0,0 +1,251 @@ +> $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 $rowA */ + $rowA = $blocks[$a]; + /** @var array $rowB */ + $rowB = $blocks[$b]; + + return ((int) ($rowA['sort_order'] ?? 0)) <=> ((int) ($rowB['sort_order'] ?? 0)); + }); + + $allNewPaths = []; + foreach ($orderedKeys as $blockKey) { + /** @var array $blockRow */ + $blockRow = $blocks[$blockKey]; + $type = (string) $blockRow['type']; + /** @var array $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 + */ + 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 $content + * @return list + */ + 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 $content + * @return array + */ + 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(); + } +} diff --git a/database/migrations/2026_04_03_100000_create_page_blocks_table.php b/database/migrations/2026_04_03_100000_create_page_blocks_table.php new file mode 100644 index 0000000..8e4f2ea --- /dev/null +++ b/database/migrations/2026_04_03_100000_create_page_blocks_table.php @@ -0,0 +1,30 @@ +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'); + } +}; diff --git a/database/migrations/2026_04_03_100001_migrate_preregistration_pages_to_page_blocks.php b/database/migrations/2026_04_03_100001_migrate_preregistration_pages_to_page_blocks.php new file mode 100644 index 0000000..c29efac --- /dev/null +++ b/database/migrations/2026_04_03_100001_migrate_preregistration_pages_to_page_blocks.php @@ -0,0 +1,117 @@ +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(); + } +}; diff --git a/database/migrations/2026_04_03_100002_drop_legacy_preregistration_page_content_columns.php b/database/migrations/2026_04_03_100002_drop_legacy_preregistration_page_content_columns.php new file mode 100644 index 0000000..7452f81 --- /dev/null +++ b/database/migrations/2026_04_03_100002_drop_legacy_preregistration_page_content_columns.php @@ -0,0 +1,38 @@ +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'); + // }); + } +}; diff --git a/database/migrations/2026_04_04_120000_add_page_overlay_and_redirect_and_migrate_hero_assets.php b/database/migrations/2026_04_04_120000_add_page_overlay_and_redirect_and_migrate_hero_assets.php new file mode 100644 index 0000000..f74b6bd --- /dev/null +++ b/database/migrations/2026_04_04_120000_add_page_overlay_and_redirect_and_migrate_hero_assets.php @@ -0,0 +1,95 @@ +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 $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', + ]); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index ca3a0cd..9239fe1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2eeb856..c1943f4 100644 --- a/package.json +++ b/package.json @@ -17,5 +17,8 @@ "postcss": "^8.4.31", "tailwindcss": "^3.1.0", "vite": "^8.0.0" + }, + "dependencies": { + "sortablejs": "^1.15.7" } } diff --git a/resources/js/app.js b/resources/js/app.js index 8d6e8f8..366c7ca 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -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,7 +33,359 @@ 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, @@ -57,6 +410,28 @@ document.addEventListener('alpine:init', () => { formError: '', fieldErrors: {}, thankYouMessage: '', + formButtonLabel: config.formButtonLabel || '', + formButtonColor: config.formButtonColor || '#F47B20', + formButtonTextColor: config.formButtonTextColor || '#FFFFFF', + pageShareUrl: config.pageShareUrl || '', + copyFeedback: '', + redirectUrl: config.redirectUrl || '', + redirectSecondsLeft: null, + redirectTimer: null, + strings: config.strings || {}, + + copyPageLink() { + const url = this.pageShareUrl; + if (!url) { + return; + } + navigator.clipboard.writeText(url).then(() => { + this.copyFeedback = config.strings?.linkCopied || ''; + setTimeout(() => { + this.copyFeedback = ''; + }, 2500); + }); + }, init() { if (this.phase === 'before') { @@ -65,6 +440,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(); @@ -142,6 +545,7 @@ document.addEventListener('alpine:init', () => { if (res.ok && data.success) { this.phase = 'thanks'; this.thankYouMessage = data.message ?? ''; + this.startRedirectCountdownIfNeeded(); return; } if (typeof data.message === 'string' && data.message !== '') { diff --git a/resources/views/admin/mailwizz/edit.blade.php b/resources/views/admin/mailwizz/edit.blade.php index 6a55207..0568137 100644 --- a/resources/views/admin/mailwizz/edit.blade.php +++ b/resources/views/admin/mailwizz/edit.blade.php @@ -25,7 +25,7 @@ 'listsUrl' => route('admin.mailwizz.lists'), 'fieldsUrl' => route('admin.mailwizz.fields'), 'csrf' => csrf_token(), - 'phoneEnabled' => (bool) $page->phone_enabled, + 'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(), 'hasExistingConfig' => $config !== null, 'existing' => $existing, 'strings' => [ diff --git a/resources/views/admin/pages/_blocks_editor.blade.php b/resources/views/admin/pages/_blocks_editor.blade.php new file mode 100644 index 0000000..c8a5e8a --- /dev/null +++ b/resources/views/admin/pages/_blocks_editor.blade.php @@ -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 + +
+

{{ __('Pagina-inhoud (blokken)') }}

+

{{ __('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.') }}

+ +
+
+ +
+ +
+
+ + +
+ +
+ +
+
diff --git a/resources/views/admin/pages/_form.blade.php b/resources/views/admin/pages/_form.blade.php index 5a10b25..004dab1 100644 --- a/resources/views/admin/pages/_form.blade.php +++ b/resources/views/admin/pages/_form.blade.php @@ -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
@@ -13,24 +18,6 @@ @enderror
-
- - - @error('heading') -

{{ $message }}

- @enderror -
- -
- - - @error('intro_text') -

{{ $message }}

- @enderror -
-