Compare commits
4 Commits
v1.0.0
...
ed85e5c537
| Author | SHA1 | Date | |
|---|---|---|---|
| ed85e5c537 | |||
| 3c9b1d9810 | |||
| ff58e82497 | |||
| 0800f7664f |
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Http\Requests\Admin\StorePreregistrationPageRequest;
|
use App\Http\Requests\Admin\StorePreregistrationPageRequest;
|
||||||
use App\Http\Requests\Admin\UpdatePreregistrationPageRequest;
|
use App\Http\Requests\Admin\UpdatePreregistrationPageRequest;
|
||||||
use App\Models\PreregistrationPage;
|
use App\Models\PreregistrationPage;
|
||||||
|
use App\Services\PreregistrationPageBlockWriter;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -17,8 +18,9 @@ use Illuminate\View\View;
|
|||||||
|
|
||||||
class PageController extends Controller
|
class PageController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct()
|
public function __construct(
|
||||||
{
|
private readonly PreregistrationPageBlockWriter $blockWriter
|
||||||
|
) {
|
||||||
$this->authorizeResource(PreregistrationPage::class, 'page');
|
$this->authorizeResource(PreregistrationPage::class, 'page');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,31 +49,32 @@ class PageController extends Controller
|
|||||||
public function store(StorePreregistrationPageRequest $request): RedirectResponse
|
public function store(StorePreregistrationPageRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$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['slug'] = (string) Str::uuid();
|
||||||
$validated['user_id'] = $request->user()->id;
|
$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 = DB::transaction(function () use ($validated, $request): PreregistrationPage {
|
||||||
$page = PreregistrationPage::create($validated);
|
$page = PreregistrationPage::query()->create($validated);
|
||||||
$paths = [];
|
$this->blockWriter->seedDefaultBlocks($page);
|
||||||
if ($background !== null) {
|
$page = $page->fresh(['blocks']);
|
||||||
$paths['background_image'] = $background->store("preregister/pages/{$page->id}", 'public');
|
|
||||||
}
|
$bgFile = $request->file('page_background');
|
||||||
if ($logo !== null) {
|
if ($bgFile !== null && $bgFile->isValid()) {
|
||||||
$paths['logo_image'] = $logo->store("preregister/pages/{$page->id}", 'public');
|
$path = $bgFile->store("preregister/pages/{$page->id}", 'public');
|
||||||
}
|
$page->update(['background_image' => $path]);
|
||||||
if ($paths !== []) {
|
|
||||||
$page->update($paths);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $page->fresh();
|
return $page->fresh(['blocks']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$page->syncLegacyContentColumnsFromBlocks();
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('admin.pages.index')
|
->route('admin.pages.edit', $page)
|
||||||
->with('status', __('Page created. Public URL: :url', ['url' => url('/r/'.$page->slug)]));
|
->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
|
public function edit(PreregistrationPage $page): View
|
||||||
{
|
{
|
||||||
|
$page->load(['blocks' => fn ($q) => $q->orderBy('sort_order')]);
|
||||||
|
|
||||||
return view('admin.pages.edit', compact('page'));
|
return view('admin.pages.edit', compact('page'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(UpdatePreregistrationPageRequest $request, PreregistrationPage $page): RedirectResponse
|
public function update(UpdatePreregistrationPageRequest $request, PreregistrationPage $page): RedirectResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
$background = $request->file('background_image');
|
/** @var array<string|int, array<string, mixed>> $blocks */
|
||||||
$logo = $request->file('logo_image');
|
$blocks = $validated['blocks'];
|
||||||
unset($validated['background_image'], $validated['logo_image']);
|
unset($validated['blocks']);
|
||||||
|
|
||||||
DB::transaction(function () use ($validated, $background, $logo, $page): void {
|
DB::transaction(function () use ($validated, $blocks, $request, $page): void {
|
||||||
if ($background !== null) {
|
$disk = Storage::disk('public');
|
||||||
if ($page->background_image !== null) {
|
if ($request->boolean('remove_page_background')) {
|
||||||
Storage::disk('public')->delete($page->background_image);
|
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) {
|
$bgFile = $request->file('page_background');
|
||||||
if ($page->logo_image !== null) {
|
if ($bgFile !== null && $bgFile->isValid()) {
|
||||||
Storage::disk('public')->delete($page->logo_image);
|
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);
|
$page->update($validated);
|
||||||
|
$this->blockWriter->replaceBlocks($page, $blocks, $request);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$page->fresh(['blocks'])->syncLegacyContentColumnsFromBlocks();
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('admin.pages.index')
|
->route('admin.pages.edit', $page)
|
||||||
->with('status', __('Page updated.'));
|
->with('status', __('Page updated.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(PreregistrationPage $page): RedirectResponse
|
public function destroy(PreregistrationPage $page): RedirectResponse
|
||||||
{
|
{
|
||||||
DB::transaction(function () use ($page): void {
|
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);
|
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);
|
Storage::disk('public')->delete($page->logo_image);
|
||||||
}
|
}
|
||||||
$page->delete();
|
$page->delete();
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Admin\DestroySubscriberRequest;
|
||||||
use App\Http\Requests\Admin\IndexSubscriberRequest;
|
use App\Http\Requests\Admin\IndexSubscriberRequest;
|
||||||
use App\Http\Requests\Admin\QueueMailwizzSyncRequest;
|
use App\Http\Requests\Admin\QueueMailwizzSyncRequest;
|
||||||
use App\Models\PreregistrationPage;
|
use App\Models\PreregistrationPage;
|
||||||
|
use App\Models\Subscriber;
|
||||||
use App\Services\DispatchUnsyncedMailwizzSyncJobsService;
|
use App\Services\DispatchUnsyncedMailwizzSyncJobsService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
@@ -32,6 +34,15 @@ class SubscriberController extends Controller
|
|||||||
return view('admin.subscribers.index', compact('page', 'subscribers', 'unsyncedMailwizzCount'));
|
return view('admin.subscribers.index', compact('page', 'subscribers', 'unsyncedMailwizzCount'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function destroy(DestroySubscriberRequest $request, PreregistrationPage $page, Subscriber $subscriber): RedirectResponse
|
||||||
|
{
|
||||||
|
$subscriber->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.pages.subscribers.index', $page)
|
||||||
|
->with('status', __('Subscriber removed.'));
|
||||||
|
}
|
||||||
|
|
||||||
public function queueMailwizzSync(
|
public function queueMailwizzSync(
|
||||||
QueueMailwizzSyncRequest $request,
|
QueueMailwizzSyncRequest $request,
|
||||||
PreregistrationPage $page,
|
PreregistrationPage $page,
|
||||||
@@ -70,7 +81,7 @@ class SubscriberController extends Controller
|
|||||||
->orderBy('created_at')
|
->orderBy('created_at')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$phoneEnabled = $page->phone_enabled;
|
$phoneEnabled = $page->isPhoneFieldEnabledForSubscribers();
|
||||||
|
|
||||||
return response()->streamDownload(function () use ($subscribers, $phoneEnabled): void {
|
return response()->streamDownload(function () use ($subscribers, $phoneEnabled): void {
|
||||||
$handle = fopen('php://output', 'w');
|
$handle = fopen('php://output', 'w');
|
||||||
|
|||||||
@@ -6,15 +6,31 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Http\Requests\SubscribePublicPageRequest;
|
use App\Http\Requests\SubscribePublicPageRequest;
|
||||||
use App\Jobs\SyncSubscriberToMailwizz;
|
use App\Jobs\SyncSubscriberToMailwizz;
|
||||||
|
use App\Models\PageBlock;
|
||||||
use App\Models\PreregistrationPage;
|
use App\Models\PreregistrationPage;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class PublicPageController extends Controller
|
class PublicPageController extends Controller
|
||||||
{
|
{
|
||||||
public function show(PreregistrationPage $publicPage): View
|
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
|
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!'),
|
'message' => $publicPage->thank_you_message ?? __('Thank you for registering!'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolvePageState(PreregistrationPage $page): string
|
||||||
|
{
|
||||||
|
if ($page->isBeforeStart()) {
|
||||||
|
return 'countdown';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($page->isExpired()) {
|
||||||
|
return 'expired';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, PageBlock>
|
||||||
|
*/
|
||||||
|
private function filterBlocksForPageState(PreregistrationPage $page, string $pageState): Collection
|
||||||
|
{
|
||||||
|
return $page->visibleBlocks()
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->get()
|
||||||
|
->filter(function (PageBlock $block) use ($pageState): bool {
|
||||||
|
return match ($pageState) {
|
||||||
|
'countdown' => in_array($block->type, ['hero', 'countdown', 'image'], true),
|
||||||
|
'expired' => in_array($block->type, ['hero', 'image', 'cta_banner'], true),
|
||||||
|
default => true,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
->values();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
app/Http/Requests/Admin/DestroySubscriberRequest.php
Normal file
36
app/Http/Requests/Admin/DestroySubscriberRequest.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use App\Models\PreregistrationPage;
|
||||||
|
use App\Models\Subscriber;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class DestroySubscriberRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
$page = $this->route('page');
|
||||||
|
$subscriber = $this->route('subscriber');
|
||||||
|
if (! $page instanceof PreregistrationPage || ! $subscriber instanceof Subscriber) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($subscriber->preregistration_page_id !== $page->id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->user()?->can('update', $page) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, ValidationRule|string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
162
app/Http/Requests/Admin/PageBlockContentValidator.php
Normal file
162
app/Http/Requests/Admin/PageBlockContentValidator.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Contracts\Validation\Validator as ValidatorContract;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
|
final class PageBlockContentValidator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $blocks
|
||||||
|
*/
|
||||||
|
public static function validateBlocksArray(ValidatorContract $validator, array $blocks): void
|
||||||
|
{
|
||||||
|
$formCount = 0;
|
||||||
|
foreach ($blocks as $i => $block) {
|
||||||
|
if (! is_array($block)) {
|
||||||
|
$validator->errors()->add("blocks.$i", __('Ongeldig blok.'));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$type = $block['type'] ?? '';
|
||||||
|
if ($type === 'form') {
|
||||||
|
$formCount++;
|
||||||
|
}
|
||||||
|
$content = $block['content'] ?? [];
|
||||||
|
if (! is_array($content)) {
|
||||||
|
$validator->errors()->add("blocks.$i.content", __('Ongeldige blokinhoud.'));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rules = self::rulesForType((string) $type);
|
||||||
|
if ($rules === []) {
|
||||||
|
$validator->errors()->add("blocks.$i.type", __('Onbekend bloktype.'));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$inner = Validator::make($content, $rules);
|
||||||
|
if ($inner->fails()) {
|
||||||
|
foreach ($inner->errors()->messages() as $field => $messages) {
|
||||||
|
foreach ($messages as $message) {
|
||||||
|
$validator->errors()->add("blocks.$i.content.$field", $message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === 'form') {
|
||||||
|
foreach (['first_name', 'last_name', 'email'] as $coreField) {
|
||||||
|
if (! data_get($content, "fields.$coreField.enabled", true)) {
|
||||||
|
$validator->errors()->add(
|
||||||
|
"blocks.$i.content.fields.$coreField.enabled",
|
||||||
|
__('Voornaam, achternaam en e-mail moeten ingeschakeld blijven.')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($formCount > 1) {
|
||||||
|
$validator->errors()->add('blocks', __('Er mag maximaal één registratieformulier-blok zijn.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, string|ValidationRule>>
|
||||||
|
*/
|
||||||
|
private static function rulesForType(string $type): array
|
||||||
|
{
|
||||||
|
$icons = 'ticket,clock,mail,users,star,heart,gift,music,shield,check';
|
||||||
|
|
||||||
|
return match ($type) {
|
||||||
|
'hero' => [
|
||||||
|
'headline' => ['required', 'string', 'max:255'],
|
||||||
|
'subheadline' => ['nullable', 'string', 'max:5000'],
|
||||||
|
'eyebrow_text' => ['nullable', 'string', 'max:255'],
|
||||||
|
'eyebrow_style' => ['nullable', 'string', 'in:badge,text,none'],
|
||||||
|
'text_alignment' => ['nullable', 'string', 'in:center,left,right'],
|
||||||
|
],
|
||||||
|
'image' => [
|
||||||
|
'image' => ['nullable', 'string', 'max:500'],
|
||||||
|
'link_url' => ['nullable', 'string', 'max:500'],
|
||||||
|
'alt' => ['nullable', 'string', 'max:255'],
|
||||||
|
'max_width_px' => ['nullable', 'integer', 'min:48', 'max:800'],
|
||||||
|
'text_alignment' => ['nullable', 'string', 'in:center,left,right'],
|
||||||
|
],
|
||||||
|
'benefits' => [
|
||||||
|
'title' => ['nullable', 'string', 'max:255'],
|
||||||
|
'items' => ['required', 'array', 'min:1'],
|
||||||
|
'items.*.icon' => ['required', 'string', "in:$icons"],
|
||||||
|
'items.*.text' => ['required', 'string', 'max:500'],
|
||||||
|
'layout' => ['nullable', 'string', 'in:list,grid'],
|
||||||
|
'max_columns' => ['nullable', 'integer', 'in:1,2,3'],
|
||||||
|
],
|
||||||
|
'social_proof' => [
|
||||||
|
'template' => ['required', 'string', 'max:255'],
|
||||||
|
'min_count' => ['required', 'integer', 'min:0'],
|
||||||
|
'show_animation' => ['sometimes', 'boolean'],
|
||||||
|
'style' => ['nullable', 'string', 'in:pill,badge,plain'],
|
||||||
|
],
|
||||||
|
'form' => [
|
||||||
|
'title' => ['nullable', 'string', 'max:255'],
|
||||||
|
'description' => ['nullable', 'string', 'max:2000'],
|
||||||
|
'button_label' => ['required', 'string', 'max:120'],
|
||||||
|
'button_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/'],
|
||||||
|
'button_text_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/'],
|
||||||
|
'show_field_icons' => ['sometimes', 'boolean'],
|
||||||
|
'privacy_text' => ['nullable', 'string', 'max:2000'],
|
||||||
|
'privacy_url' => ['nullable', 'string', 'max:500'],
|
||||||
|
'fields' => ['required', 'array'],
|
||||||
|
'fields.first_name.enabled' => ['sometimes', 'boolean'],
|
||||||
|
'fields.first_name.required' => ['sometimes', 'boolean'],
|
||||||
|
'fields.first_name.label' => ['nullable', 'string', 'max:120'],
|
||||||
|
'fields.first_name.placeholder' => ['nullable', 'string', 'max:255'],
|
||||||
|
'fields.last_name.enabled' => ['sometimes', 'boolean'],
|
||||||
|
'fields.last_name.required' => ['sometimes', 'boolean'],
|
||||||
|
'fields.last_name.label' => ['nullable', 'string', 'max:120'],
|
||||||
|
'fields.last_name.placeholder' => ['nullable', 'string', 'max:255'],
|
||||||
|
'fields.email.enabled' => ['sometimes', 'boolean'],
|
||||||
|
'fields.email.required' => ['sometimes', 'boolean'],
|
||||||
|
'fields.email.label' => ['nullable', 'string', 'max:120'],
|
||||||
|
'fields.email.placeholder' => ['nullable', 'string', 'max:255'],
|
||||||
|
'fields.phone.enabled' => ['sometimes', 'boolean'],
|
||||||
|
'fields.phone.required' => ['sometimes', 'boolean'],
|
||||||
|
'fields.phone.label' => ['nullable', 'string', 'max:120'],
|
||||||
|
'fields.phone.placeholder' => ['nullable', 'string', 'max:255'],
|
||||||
|
],
|
||||||
|
'countdown' => [
|
||||||
|
'target_datetime' => ['required', 'date'],
|
||||||
|
'title' => ['nullable', 'string', 'max:255'],
|
||||||
|
'expired_action' => ['required', 'string', 'in:hide,show_message,reload'],
|
||||||
|
'expired_message' => ['nullable', 'string', 'max:1000'],
|
||||||
|
'style' => ['nullable', 'string', 'in:large,compact'],
|
||||||
|
'show_labels' => ['sometimes', 'boolean'],
|
||||||
|
'labels' => ['nullable', 'array'],
|
||||||
|
'labels.days' => ['nullable', 'string', 'max:32'],
|
||||||
|
'labels.hours' => ['nullable', 'string', 'max:32'],
|
||||||
|
'labels.minutes' => ['nullable', 'string', 'max:32'],
|
||||||
|
'labels.seconds' => ['nullable', 'string', 'max:32'],
|
||||||
|
],
|
||||||
|
'text' => [
|
||||||
|
'title' => ['nullable', 'string', 'max:255'],
|
||||||
|
'body' => ['required', 'string', 'max:20000'],
|
||||||
|
'text_size' => ['nullable', 'string', 'in:sm,base,lg'],
|
||||||
|
'text_alignment' => ['nullable', 'string', 'in:center,left,right'],
|
||||||
|
],
|
||||||
|
'cta_banner' => [
|
||||||
|
'text' => ['required', 'string', 'max:500'],
|
||||||
|
'button_label' => ['required', 'string', 'max:120'],
|
||||||
|
'button_url' => ['required', 'string', 'url:http,https', 'max:500'],
|
||||||
|
'button_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/'],
|
||||||
|
'style' => ['nullable', 'string', 'in:inline,stacked'],
|
||||||
|
],
|
||||||
|
'divider' => [
|
||||||
|
'style' => ['required', 'string', 'in:line,dots,space_only'],
|
||||||
|
'spacing' => ['required', 'string', 'in:small,medium,large'],
|
||||||
|
],
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,14 @@ class StorePreregistrationPageRequest extends FormRequest
|
|||||||
|
|
||||||
protected function prepareForValidation(): void
|
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
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return $this->preregistrationPageRules();
|
return $this->preregistrationPageSettingsRules();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Requests\Admin;
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Contracts\Validation\Validator;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class UpdatePreregistrationPageRequest extends FormRequest
|
class UpdatePreregistrationPageRequest extends FormRequest
|
||||||
@@ -23,7 +24,21 @@ class UpdatePreregistrationPageRequest extends FormRequest
|
|||||||
|
|
||||||
protected function prepareForValidation(): void
|
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
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,32 +4,50 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Requests\Admin;
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use App\Models\PageBlock;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
trait ValidatesPreregistrationPageInput
|
trait ValidatesPreregistrationPageInput
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @return array<string, array<int, ValidationRule|string>>
|
* @return array<string, array<int, ValidationRule|string>>
|
||||||
*/
|
*/
|
||||||
protected function preregistrationPageRules(): array
|
protected function preregistrationPageSettingsRules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'title' => ['required', 'string', 'max:255'],
|
'title' => ['required', 'string', 'max:255'],
|
||||||
'heading' => ['required', 'string', 'max:255'],
|
|
||||||
'intro_text' => ['nullable', 'string'],
|
|
||||||
'thank_you_message' => ['nullable', 'string'],
|
'thank_you_message' => ['nullable', 'string'],
|
||||||
'expired_message' => ['nullable', 'string'],
|
'expired_message' => ['nullable', 'string'],
|
||||||
'ticketshop_url' => ['nullable', 'string', 'url:http,https', 'max:255'],
|
'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'],
|
'start_date' => ['required', 'date'],
|
||||||
'end_date' => ['required', 'date', 'after:start_date'],
|
'end_date' => ['required', 'date', 'after:start_date'],
|
||||||
'phone_enabled' => ['sometimes', 'boolean'],
|
|
||||||
'is_active' => ['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');
|
$ticketshop = $this->input('ticketshop_url');
|
||||||
$ticketshopNormalized = null;
|
$ticketshopNormalized = null;
|
||||||
@@ -37,10 +55,39 @@ trait ValidatesPreregistrationPageInput
|
|||||||
$ticketshopNormalized = trim($ticketshop);
|
$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([
|
$this->merge([
|
||||||
'phone_enabled' => $this->boolean('phone_enabled'),
|
|
||||||
'is_active' => $this->boolean('is_active'),
|
'is_active' => $this->boolean('is_active'),
|
||||||
|
'remove_page_background' => $this->boolean('remove_page_background'),
|
||||||
'ticketshop_url' => $ticketshopNormalized,
|
'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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class SubscribePublicPageRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
/** @var PreregistrationPage $page */
|
/** @var PreregistrationPage $page */
|
||||||
$page = $this->route('publicPage');
|
$page = $this->route('publicPage');
|
||||||
|
$page->loadMissing('blocks');
|
||||||
|
|
||||||
$emailRule = (new Email)
|
$emailRule = (new Email)
|
||||||
->rfcCompliant()
|
->rfcCompliant()
|
||||||
@@ -31,7 +32,7 @@ class SubscribePublicPageRequest extends FormRequest
|
|||||||
'first_name' => ['required', 'string', 'max:255'],
|
'first_name' => ['required', 'string', 'max:255'],
|
||||||
'last_name' => ['required', 'string', 'max:255'],
|
'last_name' => ['required', 'string', 'max:255'],
|
||||||
'email' => ['required', 'string', 'max:255', $emailRule],
|
'email' => ['required', 'string', 'max:255', $emailRule],
|
||||||
'phone' => $page->phone_enabled
|
'phone' => $page->isPhoneFieldEnabledForSubscribers()
|
||||||
? ['nullable', 'string', 'regex:/^[0-9]{8,15}$/']
|
? ['nullable', 'string', 'regex:/^[0-9]{8,15}$/']
|
||||||
: ['nullable', 'string', 'max:255'],
|
: ['nullable', 'string', 'max:255'],
|
||||||
];
|
];
|
||||||
@@ -73,7 +74,7 @@ class SubscribePublicPageRequest extends FormRequest
|
|||||||
/** @var PreregistrationPage $page */
|
/** @var PreregistrationPage $page */
|
||||||
$page = $this->route('publicPage');
|
$page = $this->route('publicPage');
|
||||||
$phone = $this->input('phone');
|
$phone = $this->input('phone');
|
||||||
if (! $page->phone_enabled) {
|
if (! $page->isPhoneFieldEnabledForSubscribers()) {
|
||||||
$this->merge(['phone' => null]);
|
$this->merge(['phone' => null]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
|||||||
string $listUid
|
string $listUid
|
||||||
): void {
|
): void {
|
||||||
$page = $subscriber->preregistrationPage;
|
$page = $subscriber->preregistrationPage;
|
||||||
$data = $this->buildBasePayload($subscriber, $config, (bool) $page->phone_enabled);
|
$data = $this->buildBasePayload($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
|
||||||
$tagField = $config->tag_field;
|
$tagField = $config->tag_field;
|
||||||
$tagValue = $config->tag_value;
|
$tagValue = $config->tag_value;
|
||||||
if ($tagField !== null && $tagField !== '' && $tagValue !== null && $tagValue !== '') {
|
if ($tagField !== null && $tagField !== '' && $tagValue !== null && $tagValue !== '') {
|
||||||
@@ -162,7 +162,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
|
|||||||
string $subscriberUid
|
string $subscriberUid
|
||||||
): void {
|
): void {
|
||||||
$page = $subscriber->preregistrationPage;
|
$page = $subscriber->preregistrationPage;
|
||||||
$data = $this->buildBasePayload($subscriber, $config, (bool) $page->phone_enabled);
|
$data = $this->buildBasePayload($subscriber, $config, $page->isPhoneFieldEnabledForSubscribers());
|
||||||
|
|
||||||
$tagField = $config->tag_field;
|
$tagField = $config->tag_field;
|
||||||
$tagValue = $config->tag_value;
|
$tagValue = $config->tag_value;
|
||||||
|
|||||||
56
app/Models/PageBlock.php
Normal file
56
app/Models/PageBlock.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PageBlock extends Model
|
||||||
|
{
|
||||||
|
public const TYPES = [
|
||||||
|
'hero',
|
||||||
|
'image',
|
||||||
|
'benefits',
|
||||||
|
'social_proof',
|
||||||
|
'form',
|
||||||
|
'countdown',
|
||||||
|
'text',
|
||||||
|
'cta_banner',
|
||||||
|
'divider',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'preregistration_page_id',
|
||||||
|
'type',
|
||||||
|
'content',
|
||||||
|
'sort_order',
|
||||||
|
'is_visible',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'content' => 'array',
|
||||||
|
'is_visible' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function preregistrationPage(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PreregistrationPage::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blade dynamic component name (kebab-case where needed).
|
||||||
|
*/
|
||||||
|
public function bladeComponentName(): string
|
||||||
|
{
|
||||||
|
return match ($this->type) {
|
||||||
|
'social_proof' => 'blocks.social-proof',
|
||||||
|
'cta_banner' => 'blocks.cta-banner',
|
||||||
|
default => 'blocks.'.$this->type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,10 +25,13 @@ class PreregistrationPage extends Model
|
|||||||
'thank_you_message',
|
'thank_you_message',
|
||||||
'expired_message',
|
'expired_message',
|
||||||
'ticketshop_url',
|
'ticketshop_url',
|
||||||
|
'post_submit_redirect_url',
|
||||||
'start_date',
|
'start_date',
|
||||||
'end_date',
|
'end_date',
|
||||||
'phone_enabled',
|
'phone_enabled',
|
||||||
'background_image',
|
'background_image',
|
||||||
|
'background_overlay_color',
|
||||||
|
'background_overlay_opacity',
|
||||||
'logo_image',
|
'logo_image',
|
||||||
'is_active',
|
'is_active',
|
||||||
];
|
];
|
||||||
@@ -61,6 +64,84 @@ class PreregistrationPage extends Model
|
|||||||
return $this->hasMany(Subscriber::class);
|
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
|
public function mailwizzConfig(): HasOne
|
||||||
{
|
{
|
||||||
return $this->hasOne(MailwizzConfig::class);
|
return $this->hasOne(MailwizzConfig::class);
|
||||||
|
|||||||
251
app/Services/PreregistrationPageBlockWriter.php
Normal file
251
app/Services/PreregistrationPageBlockWriter.php
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\PageBlock;
|
||||||
|
use App\Models\PreregistrationPage;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class PreregistrationPageBlockWriter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $blocks
|
||||||
|
*/
|
||||||
|
public function replaceBlocks(PreregistrationPage $page, array $blocks, Request $request): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($page, $blocks, $request): void {
|
||||||
|
$page->load('blocks');
|
||||||
|
$oldPaths = $this->collectBlockImagePaths($page);
|
||||||
|
$page->blocks()->delete();
|
||||||
|
|
||||||
|
$orderedKeys = array_keys($blocks);
|
||||||
|
usort($orderedKeys, function (string|int $a, string|int $b) use ($blocks): int {
|
||||||
|
/** @var array<string, mixed> $rowA */
|
||||||
|
$rowA = $blocks[$a];
|
||||||
|
/** @var array<string, mixed> $rowB */
|
||||||
|
$rowB = $blocks[$b];
|
||||||
|
|
||||||
|
return ((int) ($rowA['sort_order'] ?? 0)) <=> ((int) ($rowB['sort_order'] ?? 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
$allNewPaths = [];
|
||||||
|
foreach ($orderedKeys as $blockKey) {
|
||||||
|
/** @var array<string, mixed> $blockRow */
|
||||||
|
$blockRow = $blocks[$blockKey];
|
||||||
|
$type = (string) $blockRow['type'];
|
||||||
|
/** @var array<string, mixed> $content */
|
||||||
|
$content = is_array($blockRow['content'] ?? null) ? $blockRow['content'] : [];
|
||||||
|
if ($type === 'text' && array_key_exists('body', $content) && is_string($content['body'])) {
|
||||||
|
$content['body'] = ltrim($content['body']);
|
||||||
|
}
|
||||||
|
if ($type === 'image') {
|
||||||
|
$content = $this->mergeImageBlockFiles($page, (string) $blockKey, $content, $request);
|
||||||
|
}
|
||||||
|
$allNewPaths = array_merge($allNewPaths, $this->pathsFromImageTypeContent($type, $content));
|
||||||
|
|
||||||
|
PageBlock::query()->create([
|
||||||
|
'preregistration_page_id' => $page->id,
|
||||||
|
'type' => $type,
|
||||||
|
'content' => $content,
|
||||||
|
'sort_order' => (int) ($blockRow['sort_order'] ?? 0),
|
||||||
|
'is_visible' => (bool) ($blockRow['is_visible'] ?? true),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$allNewPaths = array_values(array_unique($allNewPaths));
|
||||||
|
foreach (array_diff($oldPaths, $allNewPaths) as $orphan) {
|
||||||
|
if (is_string($orphan) && $orphan !== '') {
|
||||||
|
Storage::disk('public')->delete($orphan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function seedDefaultBlocks(PreregistrationPage $page): void
|
||||||
|
{
|
||||||
|
$rows = [
|
||||||
|
[
|
||||||
|
'type' => 'hero',
|
||||||
|
'sort_order' => 0,
|
||||||
|
'is_visible' => true,
|
||||||
|
'content' => [
|
||||||
|
'headline' => $page->title,
|
||||||
|
'subheadline' => '',
|
||||||
|
'eyebrow_text' => '',
|
||||||
|
'eyebrow_style' => 'badge',
|
||||||
|
'text_alignment' => 'center',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$next = 1;
|
||||||
|
if ($page->start_date->isFuture()) {
|
||||||
|
$rows[] = [
|
||||||
|
'type' => 'countdown',
|
||||||
|
'sort_order' => $next,
|
||||||
|
'is_visible' => true,
|
||||||
|
'content' => [
|
||||||
|
'target_datetime' => $page->start_date->toIso8601String(),
|
||||||
|
'title' => 'De pre-registratie opent over:',
|
||||||
|
'expired_action' => 'reload',
|
||||||
|
'style' => 'large',
|
||||||
|
'show_labels' => true,
|
||||||
|
'labels' => [
|
||||||
|
'days' => 'dagen',
|
||||||
|
'hours' => 'uren',
|
||||||
|
'minutes' => 'minuten',
|
||||||
|
'seconds' => 'seconden',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$next++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'type' => 'benefits',
|
||||||
|
'sort_order' => $next,
|
||||||
|
'is_visible' => true,
|
||||||
|
'content' => [
|
||||||
|
'title' => 'Waarom voorregistreren?',
|
||||||
|
'items' => [
|
||||||
|
['icon' => 'ticket', 'text' => 'Exclusieve korting op tickets'],
|
||||||
|
['icon' => 'clock', 'text' => 'Eerder toegang tot de ticketshop'],
|
||||||
|
],
|
||||||
|
'layout' => 'list',
|
||||||
|
'max_columns' => 2,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$next++;
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'type' => 'social_proof',
|
||||||
|
'sort_order' => $next,
|
||||||
|
'is_visible' => true,
|
||||||
|
'content' => [
|
||||||
|
'template' => 'Al {count} bezoekers aangemeld!',
|
||||||
|
'min_count' => 10,
|
||||||
|
'show_animation' => true,
|
||||||
|
'style' => 'pill',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$next++;
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'type' => 'form',
|
||||||
|
'sort_order' => $next,
|
||||||
|
'is_visible' => true,
|
||||||
|
'content' => [
|
||||||
|
'title' => 'Registreer nu',
|
||||||
|
'description' => '',
|
||||||
|
'button_label' => 'Registreer nu!',
|
||||||
|
'button_color' => '#F47B20',
|
||||||
|
'button_text_color' => '#FFFFFF',
|
||||||
|
'fields' => [
|
||||||
|
'first_name' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'required' => true,
|
||||||
|
'label' => 'Voornaam',
|
||||||
|
'placeholder' => 'Je voornaam',
|
||||||
|
],
|
||||||
|
'last_name' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'required' => true,
|
||||||
|
'label' => 'Achternaam',
|
||||||
|
'placeholder' => 'Je achternaam',
|
||||||
|
],
|
||||||
|
'email' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'required' => true,
|
||||||
|
'label' => 'E-mailadres',
|
||||||
|
'placeholder' => 'je@email.nl',
|
||||||
|
],
|
||||||
|
'phone' => [
|
||||||
|
'enabled' => false,
|
||||||
|
'required' => false,
|
||||||
|
'label' => 'Mobiel',
|
||||||
|
'placeholder' => '+31 6 12345678',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'show_field_icons' => true,
|
||||||
|
'privacy_text' => 'Door je te registreren ga je akkoord met onze privacyverklaring.',
|
||||||
|
'privacy_url' => '',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$page->blocks()->createMany($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function collectBlockImagePaths(PreregistrationPage $page): array
|
||||||
|
{
|
||||||
|
$paths = [];
|
||||||
|
foreach ($page->blocks as $b) {
|
||||||
|
if ($b->type !== 'image') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$paths = array_merge($paths, $this->pathsFromImageTypeContent('image', is_array($b->content) ? $b->content : []));
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($paths));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $content
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function pathsFromImageTypeContent(string $type, array $content): array
|
||||||
|
{
|
||||||
|
if ($type !== 'image') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$p = data_get($content, 'image');
|
||||||
|
if (is_string($p) && $p !== '') {
|
||||||
|
return [$p];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $content
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function mergeImageBlockFiles(PreregistrationPage $page, string $blockKey, array $content, Request $request): array
|
||||||
|
{
|
||||||
|
$disk = Storage::disk('public');
|
||||||
|
$dir = "preregister/pages/{$page->id}";
|
||||||
|
|
||||||
|
$link = $content['link_url'] ?? null;
|
||||||
|
$content['link_url'] = is_string($link) && trim($link) !== '' ? trim($link) : null;
|
||||||
|
|
||||||
|
if ($request->boolean("blocks.$blockKey.remove_block_image")) {
|
||||||
|
$prev = $content['image'] ?? null;
|
||||||
|
if (is_string($prev) && $prev !== '') {
|
||||||
|
$disk->delete($prev);
|
||||||
|
}
|
||||||
|
$content['image'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $request->file("blocks.$blockKey.block_image");
|
||||||
|
if ($this->isValidUpload($file)) {
|
||||||
|
if (isset($content['image']) && is_string($content['image']) && $content['image'] !== '') {
|
||||||
|
$disk->delete($content['image']);
|
||||||
|
}
|
||||||
|
$content['image'] = $file->store($dir, 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isValidUpload(?UploadedFile $file): bool
|
||||||
|
{
|
||||||
|
return $file !== null && $file->isValid();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,8 +25,8 @@ class UserFactory extends Factory
|
|||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => fake()->name(),
|
'name' => $this->faker->name(),
|
||||||
'email' => fake()->unique()->safeEmail(),
|
'email' => $this->faker->unique()->safeEmail(),
|
||||||
'email_verified_at' => now(),
|
'email_verified_at' => now(),
|
||||||
'password' => static::$password ??= Hash::make('password'),
|
'password' => static::$password ??= Hash::make('password'),
|
||||||
'remember_token' => Str::random(10),
|
'remember_token' => Str::random(10),
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('page_blocks', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('preregistration_page_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('type');
|
||||||
|
$table->json('content');
|
||||||
|
$table->integer('sort_order')->default(0);
|
||||||
|
$table->boolean('is_visible')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['preregistration_page_id', 'sort_order']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('page_blocks');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\PageBlock;
|
||||||
|
use App\Models\PreregistrationPage;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::transaction(function (): void {
|
||||||
|
PreregistrationPage::query()->orderBy('id')->get()->each(function (PreregistrationPage $page): void {
|
||||||
|
if ($page->blocks()->exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sort = 0;
|
||||||
|
|
||||||
|
$heroContent = [
|
||||||
|
'headline' => $page->heading,
|
||||||
|
'subheadline' => $page->intro_text ?? '',
|
||||||
|
'eyebrow_text' => '',
|
||||||
|
'eyebrow_style' => 'badge',
|
||||||
|
'logo_max_height' => 80,
|
||||||
|
'background_image' => $page->background_image,
|
||||||
|
'logo_image' => $page->logo_image,
|
||||||
|
'overlay_color' => '#000000',
|
||||||
|
'overlay_opacity' => 50,
|
||||||
|
'text_alignment' => 'center',
|
||||||
|
];
|
||||||
|
|
||||||
|
PageBlock::query()->create([
|
||||||
|
'preregistration_page_id' => $page->id,
|
||||||
|
'type' => 'hero',
|
||||||
|
'content' => $heroContent,
|
||||||
|
'sort_order' => $sort++,
|
||||||
|
'is_visible' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($page->start_date->isFuture()) {
|
||||||
|
PageBlock::query()->create([
|
||||||
|
'preregistration_page_id' => $page->id,
|
||||||
|
'type' => 'countdown',
|
||||||
|
'content' => [
|
||||||
|
'target_datetime' => $page->start_date->toIso8601String(),
|
||||||
|
'title' => 'De pre-registratie opent over:',
|
||||||
|
'expired_action' => 'reload',
|
||||||
|
'style' => 'large',
|
||||||
|
'show_labels' => true,
|
||||||
|
'labels' => [
|
||||||
|
'days' => 'dagen',
|
||||||
|
'hours' => 'uren',
|
||||||
|
'minutes' => 'minuten',
|
||||||
|
'seconds' => 'seconden',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'sort_order' => $sort++,
|
||||||
|
'is_visible' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$formContent = [
|
||||||
|
'title' => 'Registreer nu',
|
||||||
|
'description' => '',
|
||||||
|
'button_label' => 'Registreer nu!',
|
||||||
|
'button_color' => '#F47B20',
|
||||||
|
'button_text_color' => '#FFFFFF',
|
||||||
|
'fields' => [
|
||||||
|
'first_name' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'required' => true,
|
||||||
|
'label' => 'Voornaam',
|
||||||
|
'placeholder' => 'Je voornaam',
|
||||||
|
],
|
||||||
|
'last_name' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'required' => true,
|
||||||
|
'label' => 'Achternaam',
|
||||||
|
'placeholder' => 'Je achternaam',
|
||||||
|
],
|
||||||
|
'email' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'required' => true,
|
||||||
|
'label' => 'E-mailadres',
|
||||||
|
'placeholder' => 'je@email.nl',
|
||||||
|
],
|
||||||
|
'phone' => [
|
||||||
|
'enabled' => (bool) $page->phone_enabled,
|
||||||
|
'required' => false,
|
||||||
|
'label' => 'Mobiel',
|
||||||
|
'placeholder' => '+31 6 12345678',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'show_field_icons' => true,
|
||||||
|
'privacy_text' => 'Door je te registreren ga je akkoord met onze privacyverklaring.',
|
||||||
|
'privacy_url' => '',
|
||||||
|
];
|
||||||
|
|
||||||
|
PageBlock::query()->create([
|
||||||
|
'preregistration_page_id' => $page->id,
|
||||||
|
'type' => 'form',
|
||||||
|
'content' => $formContent,
|
||||||
|
'sort_order' => $sort++,
|
||||||
|
'is_visible' => true,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
PageBlock::query()->delete();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run only after verifying block-based editor and public pages in production.
|
||||||
|
* Uncomment the Schema::table bodies when ready to drop legacy columns.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// Schema::table('preregistration_pages', function (Blueprint $table) {
|
||||||
|
// $table->dropColumn([
|
||||||
|
// 'heading',
|
||||||
|
// 'intro_text',
|
||||||
|
// 'phone_enabled',
|
||||||
|
// 'background_image',
|
||||||
|
// 'logo_image',
|
||||||
|
// ]);
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Schema::table('preregistration_pages', function (Blueprint $table) {
|
||||||
|
// $table->string('heading')->after('title');
|
||||||
|
// $table->text('intro_text')->nullable()->after('heading');
|
||||||
|
// $table->boolean('phone_enabled')->default(false)->after('end_date');
|
||||||
|
// $table->string('background_image')->nullable()->after('phone_enabled');
|
||||||
|
// $table->string('logo_image')->nullable()->after('background_image');
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\PageBlock;
|
||||||
|
use App\Models\PreregistrationPage;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('preregistration_pages', function (Blueprint $table) {
|
||||||
|
$table->string('background_overlay_color', 7)->default('#000000')->after('background_image');
|
||||||
|
$table->unsignedTinyInteger('background_overlay_opacity')->default(50)->after('background_overlay_color');
|
||||||
|
$table->string('post_submit_redirect_url')->nullable()->after('ticketshop_url');
|
||||||
|
});
|
||||||
|
|
||||||
|
PreregistrationPage::query()->orderBy('id')->each(function (PreregistrationPage $page): void {
|
||||||
|
$page->load(['blocks' => fn ($q) => $q->orderBy('sort_order')]);
|
||||||
|
$hero = $page->blocks->firstWhere('type', 'hero');
|
||||||
|
if ($hero === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<string, mixed> $c */
|
||||||
|
$c = is_array($hero->content) ? $hero->content : [];
|
||||||
|
$pageUpdates = [];
|
||||||
|
|
||||||
|
if (($page->background_image === null || $page->background_image === '')
|
||||||
|
&& isset($c['background_image']) && is_string($c['background_image']) && $c['background_image'] !== '') {
|
||||||
|
$pageUpdates['background_image'] = $c['background_image'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$oc = $c['overlay_color'] ?? null;
|
||||||
|
if (is_string($oc) && preg_match('/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/', $oc) === 1) {
|
||||||
|
$pageUpdates['background_overlay_color'] = $oc;
|
||||||
|
}
|
||||||
|
|
||||||
|
$oo = $c['overlay_opacity'] ?? null;
|
||||||
|
if (is_numeric($oo)) {
|
||||||
|
$pageUpdates['background_overlay_opacity'] = max(0, min(100, (int) $oo));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pageUpdates !== []) {
|
||||||
|
$page->update($pageUpdates);
|
||||||
|
}
|
||||||
|
|
||||||
|
$logoPath = $c['logo_image'] ?? null;
|
||||||
|
unset(
|
||||||
|
$c['logo_image'],
|
||||||
|
$c['logo_max_height'],
|
||||||
|
$c['background_image'],
|
||||||
|
$c['overlay_color'],
|
||||||
|
$c['overlay_opacity'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$hero->update(['content' => $c]);
|
||||||
|
|
||||||
|
if (is_string($logoPath) && $logoPath !== '') {
|
||||||
|
PageBlock::query()
|
||||||
|
->where('preregistration_page_id', $page->id)
|
||||||
|
->where('sort_order', '>=', 1)
|
||||||
|
->increment('sort_order');
|
||||||
|
|
||||||
|
PageBlock::query()->create([
|
||||||
|
'preregistration_page_id' => $page->id,
|
||||||
|
'type' => 'image',
|
||||||
|
'content' => [
|
||||||
|
'image' => $logoPath,
|
||||||
|
'link_url' => '',
|
||||||
|
'alt' => '',
|
||||||
|
'max_width_px' => 240,
|
||||||
|
'text_alignment' => 'center',
|
||||||
|
],
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_visible' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('preregistration_pages', function (Blueprint $table) {
|
||||||
|
$table->dropColumn([
|
||||||
|
'background_overlay_color',
|
||||||
|
'background_overlay_opacity',
|
||||||
|
'post_submit_redirect_url',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -18,5 +18,9 @@
|
|||||||
"Thank you for registering!": "Bedankt voor je registratie!",
|
"Thank you for registering!": "Bedankt voor je registratie!",
|
||||||
"You are already registered for this event.": "Je bent al geregistreerd voor dit evenement.",
|
"You are already registered for this event.": "Je bent al geregistreerd voor dit evenement.",
|
||||||
"Please enter a valid email address.": "Voer een geldig e-mailadres in.",
|
"Please enter a valid email address.": "Voer een geldig e-mailadres in.",
|
||||||
"Please enter a valid phone number (8–15 digits).": "Voer een geldig telefoonnummer in (8 tot 15 cijfers)."
|
"Please enter a valid phone number (8–15 digits).": "Voer een geldig telefoonnummer in (8 tot 15 cijfers).",
|
||||||
|
"Subscriber removed.": "Abonnee verwijderd.",
|
||||||
|
"Delete this subscriber? This cannot be undone.": "Deze abonnee verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||||
|
"Remove": "Verwijderen",
|
||||||
|
"Actions": "Acties"
|
||||||
}
|
}
|
||||||
|
|||||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -4,6 +4,9 @@
|
|||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"sortablejs": "^1.15.7"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.2",
|
"@tailwindcss/forms": "^0.5.2",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
@@ -2488,6 +2491,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|||||||
@@ -17,5 +17,8 @@
|
|||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
"tailwindcss": "^3.1.0",
|
"tailwindcss": "^3.1.0",
|
||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"sortablejs": "^1.15.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import './bootstrap';
|
import './bootstrap';
|
||||||
|
|
||||||
import Alpine from 'alpinejs';
|
import Alpine from 'alpinejs';
|
||||||
|
import Sortable from 'sortablejs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client-side email check (aligned loosely with RFC-style rules; server is authoritative).
|
* 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);
|
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', () => {
|
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) => ({
|
Alpine.data('publicPreregisterPage', (config) => ({
|
||||||
phase: config.phase,
|
phase: config.phase,
|
||||||
startAtMs: config.startAtMs,
|
startAtMs: config.startAtMs,
|
||||||
@@ -57,6 +410,28 @@ document.addEventListener('alpine:init', () => {
|
|||||||
formError: '',
|
formError: '',
|
||||||
fieldErrors: {},
|
fieldErrors: {},
|
||||||
thankYouMessage: '',
|
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() {
|
init() {
|
||||||
if (this.phase === 'before') {
|
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() {
|
tickCountdown() {
|
||||||
const start = this.startAtMs;
|
const start = this.startAtMs;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -142,6 +545,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
if (res.ok && data.success) {
|
if (res.ok && data.success) {
|
||||||
this.phase = 'thanks';
|
this.phase = 'thanks';
|
||||||
this.thankYouMessage = data.message ?? '';
|
this.thankYouMessage = data.message ?? '';
|
||||||
|
this.startRedirectCountdownIfNeeded();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof data.message === 'string' && data.message !== '') {
|
if (typeof data.message === 'string' && data.message !== '') {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
'listsUrl' => route('admin.mailwizz.lists'),
|
'listsUrl' => route('admin.mailwizz.lists'),
|
||||||
'fieldsUrl' => route('admin.mailwizz.fields'),
|
'fieldsUrl' => route('admin.mailwizz.fields'),
|
||||||
'csrf' => csrf_token(),
|
'csrf' => csrf_token(),
|
||||||
'phoneEnabled' => (bool) $page->phone_enabled,
|
'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(),
|
||||||
'hasExistingConfig' => $config !== null,
|
'hasExistingConfig' => $config !== null,
|
||||||
'existing' => $existing,
|
'existing' => $existing,
|
||||||
'strings' => [
|
'strings' => [
|
||||||
|
|||||||
485
resources/views/admin/pages/_blocks_editor.blade.php
Normal file
485
resources/views/admin/pages/_blocks_editor.blade.php
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
@php
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
$blockEditorState = $page->blocks->map(function ($b) {
|
||||||
|
$content = $b->content ?? [];
|
||||||
|
if ($b->type === 'countdown' && ! empty($content['target_datetime'])) {
|
||||||
|
try {
|
||||||
|
$content['target_datetime'] = Carbon::parse($content['target_datetime'])
|
||||||
|
->timezone(config('app.timezone'))
|
||||||
|
->format('Y-m-d\TH:i');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'uid' => 'b'.$b->id,
|
||||||
|
'type' => $b->type,
|
||||||
|
'sort_order' => $b->sort_order,
|
||||||
|
'is_visible' => $b->is_visible,
|
||||||
|
'content' => $content,
|
||||||
|
];
|
||||||
|
})->values()->all();
|
||||||
|
|
||||||
|
$blockTypesMeta = [
|
||||||
|
['type' => 'hero', 'label' => __('Hero-sectie'), 'hint' => __('Kop & subkop')],
|
||||||
|
['type' => 'image', 'label' => __('Afbeelding'), 'hint' => __('Logo of beeld, optionele link')],
|
||||||
|
['type' => 'benefits', 'label' => __('Voordelen'), 'hint' => __('Lijst met USPs')],
|
||||||
|
['type' => 'social_proof', 'label' => __('Social proof'), 'hint' => __('Telleraanmeldingen')],
|
||||||
|
['type' => 'form', 'label' => __('Registratieformulier'), 'hint' => __('Aanmeldformulier')],
|
||||||
|
['type' => 'countdown', 'label' => __('Afteltimer'), 'hint' => __('Aftellen naar datum')],
|
||||||
|
['type' => 'text', 'label' => __('Tekst'), 'hint' => __('Vrije tekst')],
|
||||||
|
['type' => 'cta_banner', 'label' => __('CTA-banner'), 'hint' => __('Knop + link')],
|
||||||
|
['type' => 'divider', 'label' => __('Scheiding'), 'hint' => __('Lijn of ruimte')],
|
||||||
|
];
|
||||||
|
|
||||||
|
$benefitIcons = ['ticket', 'clock', 'mail', 'users', 'star', 'heart', 'gift', 'music', 'shield', 'check'];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div
|
||||||
|
x-data="pageBlockEditor({
|
||||||
|
initialBlocks: @js($blockEditorState),
|
||||||
|
blockTypes: @js($blockTypesMeta),
|
||||||
|
storageBase: @js(asset('storage'))
|
||||||
|
})"
|
||||||
|
>
|
||||||
|
<h2 id="page-blocks-heading" class="text-lg font-semibold text-slate-900">{{ __('Pagina-inhoud (blokken)') }}</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-600">{{ __('Sleep blokken om de volgorde te wijzigen. Klik op een blok om het te openen of te sluiten. Het oog-icoon verbergt een blok op de publieke pagina.') }}</p>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||||
|
<div class="relative" x-data="{ open: false }">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50"
|
||||||
|
@click="open = !open"
|
||||||
|
>
|
||||||
|
{{ __('+ Blok toevoegen') }}
|
||||||
|
<span class="text-slate-400" x-text="open ? '▲' : '▼'"></span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
x-show="open"
|
||||||
|
x-cloak
|
||||||
|
@click.outside="open = false"
|
||||||
|
class="absolute left-0 z-20 mt-2 w-full max-w-md rounded-lg border border-slate-200 bg-white py-1 shadow-lg"
|
||||||
|
>
|
||||||
|
<template x-for="bt in blockTypes" :key="bt.type">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full flex-col items-start gap-0.5 px-4 py-3 text-left text-sm hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
@click="addBlock(bt.type); open = false"
|
||||||
|
x-bind:disabled="bt.type === 'form' && hasFormBlock()"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-slate-900" x-text="bt.label"></span>
|
||||||
|
<span class="text-xs text-slate-500" x-text="bt.hint"></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50" @click="expandAll()">
|
||||||
|
{{ __('Alles uitklappen') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50" @click="collapseAll()">
|
||||||
|
{{ __('Alles inklappen') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-col gap-4" x-ref="sortRoot">
|
||||||
|
<template x-for="block in blocks" :key="block.uid">
|
||||||
|
<div
|
||||||
|
class="overflow-hidden rounded-xl border border-slate-200 bg-slate-50/80 shadow-sm"
|
||||||
|
:class="{ 'opacity-50': !block.is_visible }"
|
||||||
|
:data-block-uid="block.uid"
|
||||||
|
>
|
||||||
|
<input type="hidden" :name="`blocks[${block.uid}][type]`" :value="block.type" />
|
||||||
|
<input type="hidden" :name="`blocks[${block.uid}][sort_order]`" :value="block.sort_order" />
|
||||||
|
<input type="hidden" :name="`blocks[${block.uid}][is_visible]`" :value="block.is_visible ? 1 : 0" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-start gap-2 bg-white px-3 py-2"
|
||||||
|
:class="isCollapsed(block.uid) ? 'rounded-xl' : 'rounded-t-xl border-b border-slate-200'"
|
||||||
|
>
|
||||||
|
<button type="button" class="block-drag-handle mt-2 cursor-grab touch-pan-y px-1 text-slate-400 hover:text-slate-600" title="{{ __('Sleep') }}">≡</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="min-w-0 flex-1 rounded-lg py-0.5 text-left text-sm font-semibold text-slate-800 hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
|
||||||
|
@click="toggleCollapsed(block.uid)"
|
||||||
|
:aria-expanded="!isCollapsed(block.uid)"
|
||||||
|
>
|
||||||
|
<span class="flex items-start gap-2">
|
||||||
|
<span class="mt-0.5 w-4 shrink-0 text-center text-xs text-slate-500" x-text="isCollapsed(block.uid) ? '▶' : '▼'" aria-hidden="true"></span>
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block" x-text="blockTypes.find(t => t.type === block.type)?.label || block.type"></span>
|
||||||
|
<span
|
||||||
|
x-show="isCollapsed(block.uid) && blockSummary(block) !== ''"
|
||||||
|
x-cloak
|
||||||
|
class="mt-0.5 block max-w-full truncate text-xs font-normal text-slate-500"
|
||||||
|
x-text="blockSummary(block)"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div class="flex shrink-0 items-center gap-0.5 self-center">
|
||||||
|
<button type="button" class="p-1 text-slate-500 hover:text-slate-800" :title="block.is_visible ? '{{ __('Verbergen op publieke pagina') }}' : '{{ __('Tonen') }}'" @click="block.is_visible = !block.is_visible">
|
||||||
|
<span x-text="block.is_visible ? '👁' : '🚫'"></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="p-1 text-red-600 hover:text-red-800" @click="requestDelete(block.uid)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="!isCollapsed(block.uid)" class="space-y-4 p-4">
|
||||||
|
{{-- Hero --}}
|
||||||
|
<div x-show="block.type === 'hero'" class="grid gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Kop (headline)') }}</label>
|
||||||
|
<input type="text" :name="`blocks[${block.uid}][content][headline]`" x-model="block.content.headline" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" maxlength="255" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Subkop') }}</label>
|
||||||
|
<textarea :name="`blocks[${block.uid}][content][subheadline]`" x-model="block.content.subheadline" rows="3" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Eyebrow / label') }}</label>
|
||||||
|
<input type="text" :name="`blocks[${block.uid}][content][eyebrow_text]`" x-model="block.content.eyebrow_text" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Eyebrow-stijl') }}</label>
|
||||||
|
<select :name="`blocks[${block.uid}][content][eyebrow_style]`" x-model="block.content.eyebrow_style" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||||
|
<option value="badge">badge</option>
|
||||||
|
<option value="text">text</option>
|
||||||
|
<option value="none">none</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Tekstuitlijning') }}</label>
|
||||||
|
<select :name="`blocks[${block.uid}][content][text_alignment]`" x-model="block.content.text_alignment" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||||
|
<option value="center">center</option>
|
||||||
|
<option value="left">left</option>
|
||||||
|
<option value="right">right</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Image --}}
|
||||||
|
<div x-show="block.type === 'image'" class="grid gap-4">
|
||||||
|
<template x-if="block.content.image && block.content.image !== ''">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<img :src="storageBase + '/' + block.content.image" alt="" class="h-16 w-auto max-w-[12rem] rounded border border-slate-200 bg-white object-contain p-1" />
|
||||||
|
<label class="inline-flex items-center gap-2 text-sm text-red-700">
|
||||||
|
<input type="checkbox" :name="`blocks[${block.uid}][remove_block_image]`" value="1" />
|
||||||
|
{{ __('Afbeelding verwijderen') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<input type="hidden" :name="`blocks[${block.uid}][content][image]`" :value="block.content.image || ''" />
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Afbeelding uploaden') }}</label>
|
||||||
|
<input type="file" :name="`blocks[${block.uid}][block_image]`" accept="image/jpeg,image/png,image/webp,image/svg+xml,.svg" class="mt-1 block w-full text-sm text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Link-URL (optioneel, bij klik)') }}</label>
|
||||||
|
<input type="text" :name="`blocks[${block.uid}][content][link_url]`" x-model="block.content.link_url" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" placeholder="https://…" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Alt-tekst') }}</label>
|
||||||
|
<input type="text" :name="`blocks[${block.uid}][content][alt]`" x-model="block.content.alt" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Max. breedte (px)') }}</label>
|
||||||
|
<input type="number" min="48" max="800" :name="`blocks[${block.uid}][content][max_width_px]`" x-model.number="block.content.max_width_px" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Uitlijning') }}</label>
|
||||||
|
<select :name="`blocks[${block.uid}][content][text_alignment]`" x-model="block.content.text_alignment" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||||
|
<option value="center">center</option>
|
||||||
|
<option value="left">left</option>
|
||||||
|
<option value="right">right</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Benefits --}}
|
||||||
|
<div x-show="block.type === 'benefits'" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Titel') }}</label>
|
||||||
|
<input type="text" :name="`blocks[${block.uid}][content][title]`" x-model="block.content.title" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Layout') }}</label>
|
||||||
|
<select :name="`blocks[${block.uid}][content][layout]`" x-model="block.content.layout" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||||
|
<option value="list">list</option>
|
||||||
|
<option value="grid">grid</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Max. kolommen') }}</label>
|
||||||
|
<select :name="`blocks[${block.uid}][content][max_columns]`" x-model.number="block.content.max_columns" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2">2</option>
|
||||||
|
<option value="3">3</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm font-medium text-slate-700">{{ __('Items') }}</span>
|
||||||
|
<button type="button" class="text-sm font-medium text-indigo-600 hover:text-indigo-500" @click="addBenefitItem(block.uid)">+ {{ __('Voordeel') }}</button>
|
||||||
|
</div>
|
||||||
|
<template x-for="(item, idx) in block.content.items" :key="idx">
|
||||||
|
<div class="flex flex-wrap items-end gap-2 rounded-lg border border-slate-200 bg-white p-3">
|
||||||
|
<div class="min-w-[8rem] flex-1">
|
||||||
|
<label class="text-xs text-slate-500">{{ __('Icoon') }}</label>
|
||||||
|
<select :name="`blocks[${block.uid}][content][items][${idx}][icon]`" x-model="item.icon" class="mt-0.5 block w-full rounded border-slate-300 text-sm">
|
||||||
|
@foreach ($benefitIcons as $ic)
|
||||||
|
<option value="{{ $ic }}">{{ $ic }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-[12rem] flex-[2]">
|
||||||
|
<label class="text-xs text-slate-500">{{ __('Tekst') }}</label>
|
||||||
|
<input type="text" :name="`blocks[${block.uid}][content][items][${idx}][text]`" x-model="item.text" class="mt-0.5 block w-full rounded border-slate-300 text-sm" />
|
||||||
|
</div>
|
||||||
|
<button type="button" class="mb-0.5 text-sm text-red-600 hover:text-red-800" @click="removeBenefitItem(block.uid, idx)">{{ __('Verwijderen') }}</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Social proof --}}
|
||||||
|
<div x-show="block.type === 'social_proof'" class="grid gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Sjabloon (gebruik {count})') }}</label>
|
||||||
|
<input type="text" :name="`blocks[${block.uid}][content][template]`" x-model="block.content.template" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Minimum aantal (alleen tonen als ≥)') }}</label>
|
||||||
|
<input type="number" min="0" :name="`blocks[${block.uid}][content][min_count]`" x-model.number="block.content.min_count" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||||
|
</div>
|
||||||
|
<label class="inline-flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="hidden" :name="`blocks[${block.uid}][content][show_animation]`" :value="block.content.show_animation ? 1 : 0" />
|
||||||
|
<input type="checkbox" @change="block.content.show_animation = $event.target.checked" :checked="block.content.show_animation" />
|
||||||
|
{{ __('Animatie') }}
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Stijl') }}</label>
|
||||||
|
<select :name="`blocks[${block.uid}][content][style]`" x-model="block.content.style" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||||
|
<option value="pill">pill</option>
|
||||||
|
<option value="badge">badge</option>
|
||||||
|
<option value="plain">plain</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Form --}}
|
||||||
|
<div x-show="block.type === 'form'" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Titel boven formulier') }}</label>
|
||||||
|
<input type="text" :name="`blocks[${block.uid}][content][title]`" x-model="block.content.title" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Beschrijving') }}</label>
|
||||||
|
<textarea :name="`blocks[${block.uid}][content][description]`" x-model="block.content.description" rows="2" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Knoptekst') }}</label>
|
||||||
|
<input type="text" :name="`blocks[${block.uid}][content][button_label]`" x-model="block.content.button_label" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Knopkleur') }}</label>
|
||||||
|
<input type="text" :name="`blocks[${block.uid}][content][button_color]`" x-model="block.content.button_color" class="mt-1 block w-full rounded-lg border-slate-300 font-mono text-sm shadow-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Knoptekstkleur') }}</label>
|
||||||
|
<input type="text" :name="`blocks[${block.uid}][content][button_text_color]`" x-model="block.content.button_text_color" class="mt-1 block w-full rounded-lg border-slate-300 font-mono text-sm shadow-sm" />
|
||||||
|
</div>
|
||||||
|
<label class="inline-flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="hidden" :name="`blocks[${block.uid}][content][show_field_icons]`" :value="block.content.show_field_icons ? 1 : 0" />
|
||||||
|
<input type="checkbox" @change="block.content.show_field_icons = $event.target.checked" :checked="block.content.show_field_icons" />
|
||||||
|
{{ __('Iconen bij velden') }}
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-slate-500">{{ __('Voornaam, achternaam en e-mail blijven verplicht ingeschakeld voor de database.') }}</p>
|
||||||
|
<template x-for="fk in ['first_name','last_name','email','phone']" :key="fk">
|
||||||
|
<div class="rounded-lg border border-slate-200 bg-white p-3">
|
||||||
|
<p class="text-sm font-medium capitalize text-slate-800" x-text="fk.replace('_',' ')"></p>
|
||||||
|
<template x-if="fk === 'phone'">
|
||||||
|
<label class="mt-2 inline-flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="hidden" :name="`blocks[${block.uid}][content][fields][phone][enabled]`" :value="block.content.fields.phone.enabled ? 1 : 0" />
|
||||||
|
<input type="checkbox" @change="block.content.fields.phone.enabled = $event.target.checked" :checked="block.content.fields.phone.enabled" />
|
||||||
|
{{ __('Telefoonveld tonen') }}
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
<template x-if="fk !== 'phone'">
|
||||||
|
<input type="hidden" :name="`blocks[${block.uid}][content][fields][${fk}][enabled]`" value="1" />
|
||||||
|
<input type="hidden" :name="`blocks[${block.uid}][content][fields][${fk}][required]`" value="1" />
|
||||||
|
</template>
|
||||||
|
<div class="mt-2 grid gap-2 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-slate-500">{{ __('Label') }}</label>
|
||||||
|
<input type="text" :name="`blocks[${block.uid}][content][fields][${fk}][label]`" x-model="block.content.fields[fk].label" class="mt-0.5 block w-full rounded border-slate-300 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-slate-500">{{ __('Placeholder') }}</label>
|
||||||
|
<input type="text" :name="`blocks[${block.uid}][content][fields][${fk}][placeholder]`" x-model="block.content.fields[fk].placeholder" class="mt-0.5 block w-full rounded border-slate-300 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template x-if="fk === 'phone'">
|
||||||
|
<label class="mt-2 inline-flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="hidden" :name="`blocks[${block.uid}][content][fields][phone][required]`" :value="block.content.fields.phone.required ? 1 : 0" />
|
||||||
|
<input type="checkbox" @change="block.content.fields.phone.required = $event.target.checked" :checked="block.content.fields.phone.required" />
|
||||||
|
{{ __('Telefoon verplicht') }}
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Privacytekst') }}</label>
|
||||||
|
<textarea :name="`blocks[${block.uid}][content][privacy_text]`" x-model="block.content.privacy_text" rows="2" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Privacy-URL') }}</label>
|
||||||
|
<input type="text" :name="`blocks[${block.uid}][content][privacy_url]`" x-model="block.content.privacy_url" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" placeholder="https://…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Countdown --}}
|
||||||
|
<div x-show="block.type === 'countdown'" class="grid gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Doeldatum / -tijd') }}</label>
|
||||||
|
<input type="datetime-local" :name="`blocks[${block.uid}][content][target_datetime]`" x-model="block.content.target_datetime" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Titel') }}</label>
|
||||||
|
<input type="text" :name="`blocks[${block.uid}][content][title]`" x-model="block.content.title" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Na afloop') }}</label>
|
||||||
|
<select :name="`blocks[${block.uid}][content][expired_action]`" x-model="block.content.expired_action" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||||
|
<option value="hide">hide</option>
|
||||||
|
<option value="show_message">show_message</option>
|
||||||
|
<option value="reload">reload</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Bericht (bij show_message)') }}</label>
|
||||||
|
<textarea :name="`blocks[${block.uid}][content][expired_message]`" x-model="block.content.expired_message" rows="2" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Stijl') }}</label>
|
||||||
|
<select :name="`blocks[${block.uid}][content][style]`" x-model="block.content.style" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||||
|
<option value="large">large</option>
|
||||||
|
<option value="compact">compact</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label class="inline-flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="hidden" :name="`blocks[${block.uid}][content][show_labels]`" :value="block.content.show_labels ? 1 : 0" />
|
||||||
|
<input type="checkbox" @change="block.content.show_labels = $event.target.checked" :checked="block.content.show_labels" />
|
||||||
|
{{ __('Labels tonen') }}
|
||||||
|
</label>
|
||||||
|
@foreach (['days' => 'dagen', 'hours' => 'uren', 'minutes' => 'minuten', 'seconds' => 'seconden'] as $lk => $lab)
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Label :key', ['key' => $lk]) }}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:name="`blocks[${block.uid}][content][labels][{{ $lk }}]`"
|
||||||
|
x-model="block.content.labels['{{ $lk }}']"
|
||||||
|
class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Text --}}
|
||||||
|
<div x-show="block.type === 'text'" class="grid gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Titel') }}</label>
|
||||||
|
<input type="text" :name="`blocks[${block.uid}][content][title]`" x-model="block.content.title" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Tekst') }}</label>
|
||||||
|
<textarea :name="`blocks[${block.uid}][content][body]`" x-model="block.content.body" rows="6" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Tekstgrootte') }}</label>
|
||||||
|
<select :name="`blocks[${block.uid}][content][text_size]`" x-model="block.content.text_size" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||||
|
<option value="sm">sm</option>
|
||||||
|
<option value="base">base</option>
|
||||||
|
<option value="lg">lg</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Uitlijning') }}</label>
|
||||||
|
<select :name="`blocks[${block.uid}][content][text_alignment]`" x-model="block.content.text_alignment" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||||
|
<option value="center">center</option>
|
||||||
|
<option value="left">left</option>
|
||||||
|
<option value="right">right</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- CTA --}}
|
||||||
|
<div x-show="block.type === 'cta_banner'" class="grid gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Tekst') }}</label>
|
||||||
|
<input type="text" :name="`blocks[${block.uid}][content][text]`" x-model="block.content.text" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Knoptekst') }}</label>
|
||||||
|
<input type="text" :name="`blocks[${block.uid}][content][button_label]`" x-model="block.content.button_label" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Knop-URL') }}</label>
|
||||||
|
<input type="text" :name="`blocks[${block.uid}][content][button_url]`" x-model="block.content.button_url" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Knopkleur') }}</label>
|
||||||
|
<input type="text" :name="`blocks[${block.uid}][content][button_color]`" x-model="block.content.button_color" class="mt-1 block w-full rounded-lg border-slate-300 font-mono text-sm shadow-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Stijl') }}</label>
|
||||||
|
<select :name="`blocks[${block.uid}][content][style]`" x-model="block.content.style" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||||
|
<option value="inline">inline</option>
|
||||||
|
<option value="stacked">stacked</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Divider --}}
|
||||||
|
<div x-show="block.type === 'divider'" class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Stijl') }}</label>
|
||||||
|
<select :name="`blocks[${block.uid}][content][style]`" x-model="block.content.style" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||||
|
<option value="line">line</option>
|
||||||
|
<option value="dots">dots</option>
|
||||||
|
<option value="space_only">space_only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">{{ __('Ruimte') }}</label>
|
||||||
|
<select :name="`blocks[${block.uid}][content][spacing]`" x-model="block.content.spacing" class="mt-1 block w-full rounded-lg border-slate-300 text-sm shadow-sm">
|
||||||
|
<option value="small">small</option>
|
||||||
|
<option value="medium">medium</option>
|
||||||
|
<option value="large">large</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
x-show="confirmDeleteUid === block.uid"
|
||||||
|
x-cloak
|
||||||
|
class="border-t border-red-100 bg-red-50 px-4 py-3 text-sm text-red-900"
|
||||||
|
>
|
||||||
|
<p>{{ __('Dit blok verwijderen?') }}</p>
|
||||||
|
<div class="mt-2 flex gap-2">
|
||||||
|
<button type="button" class="rounded bg-red-600 px-3 py-1.5 font-medium text-white hover:bg-red-500" @click="removeBlock(block.uid)">{{ __('Verwijderen') }}</button>
|
||||||
|
<button type="button" class="rounded border border-slate-300 bg-white px-3 py-1.5 font-medium text-slate-700 hover:bg-slate-50" @click="cancelDelete()">{{ __('Annuleren') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
@php
|
@php
|
||||||
/** @var \App\Models\PreregistrationPage|null $page */
|
/** @var \App\Models\PreregistrationPage|null $page */
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
$page = $page ?? null;
|
$page = $page ?? null;
|
||||||
|
$pageBgUrl = $page !== null && filled($page->background_image)
|
||||||
|
? Storage::disk('public')->url($page->background_image)
|
||||||
|
: null;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="grid max-w-3xl gap-6">
|
<div class="grid max-w-3xl gap-6">
|
||||||
@@ -13,24 +18,6 @@
|
|||||||
@enderror
|
@enderror
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label for="thank_you_message" class="block text-sm font-medium text-slate-700">{{ __('Thank you message') }}</label>
|
<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"
|
<textarea name="thank_you_message" id="thank_you_message" rows="3"
|
||||||
@@ -54,11 +41,65 @@
|
|||||||
<input type="text" name="ticketshop_url" id="ticketshop_url" inputmode="url" autocomplete="url"
|
<input type="text" name="ticketshop_url" id="ticketshop_url" inputmode="url" autocomplete="url"
|
||||||
value="{{ old('ticketshop_url', $page?->ticketshop_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://…" />
|
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')
|
@error('ticketshop_url')
|
||||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||||
@enderror
|
@enderror
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="grid gap-6 sm:grid-cols-2">
|
<div class="grid gap-6 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label for="start_date" class="block text-sm font-medium text-slate-700">{{ __('Start date') }}</label>
|
<label for="start_date" class="block text-sm font-medium text-slate-700">{{ __('Start date') }}</label>
|
||||||
@@ -80,42 +121,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-6">
|
<div>
|
||||||
<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>
|
|
||||||
<label class="inline-flex items-center gap-2">
|
<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"
|
<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)) />
|
@checked(old('is_active', $page?->is_active ?? true)) />
|
||||||
<span class="text-sm font-medium text-slate-700">{{ __('Active') }}</span>
|
<span class="text-sm font-medium text-slate-700">{{ __('Active') }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|||||||
13
resources/views/admin/pages/_save_flash.blade.php
Normal file
13
resources/views/admin/pages/_save_flash.blade.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
@if (session('status'))
|
||||||
|
<div
|
||||||
|
class="mb-6 flex items-start gap-3 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 shadow-sm"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<span class="mt-0.5 shrink-0 text-emerald-600" aria-hidden="true">
|
||||||
|
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<p class="min-w-0 flex-1 font-medium leading-snug">{{ session('status') }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@@ -12,10 +12,12 @@
|
|||||||
<p class="mt-1 text-sm text-slate-600">{{ __('After saving, use the pages list to copy the public URL.') }}</p>
|
<p class="mt-1 text-sm text-slate-600">{{ __('After saving, use the pages list to copy the public URL.') }}</p>
|
||||||
</div>
|
</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
|
@csrf
|
||||||
@if ($errors->any())
|
@if ($errors->any())
|
||||||
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-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>
|
<p class="font-medium">{{ __('Please fix the following:') }}</p>
|
||||||
<ul class="mt-2 list-inside list-disc space-y-1">
|
<ul class="mt-2 list-inside list-disc space-y-1">
|
||||||
@foreach ($errors->all() as $message)
|
@foreach ($errors->all() as $message)
|
||||||
@@ -24,12 +26,20 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
<section class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8" aria-labelledby="page-settings-heading">
|
||||||
|
<h2 id="page-settings-heading" class="text-lg font-semibold text-slate-900">{{ __('Page settings') }}</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-600">{{ __('Title, dates, messages, and background for this pre-registration page.') }}</p>
|
||||||
|
<div class="mt-6">
|
||||||
@include('admin.pages._form', ['page' => null])
|
@include('admin.pages._form', ['page' => null])
|
||||||
<div class="mt-8 flex gap-3">
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
<button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">
|
<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') }}
|
{{ __('Create page') }}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
@section('mobile_title', __('Edit page'))
|
@section('mobile_title', __('Edit page'))
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="mx-auto max-w-3xl">
|
<div class="mx-auto max-w-4xl">
|
||||||
<div class="mb-8">
|
<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>
|
<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>
|
<h1 class="mt-4 text-2xl font-semibold text-slate-900">{{ __('Edit page') }}</h1>
|
||||||
@@ -19,11 +19,13 @@
|
|||||||
@endcan
|
@endcan
|
||||||
</div>
|
</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
|
@csrf
|
||||||
@method('PUT')
|
@method('PUT')
|
||||||
@if ($errors->any())
|
@if ($errors->any())
|
||||||
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-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>
|
<p class="font-medium">{{ __('Please fix the following:') }}</p>
|
||||||
<ul class="mt-2 list-inside list-disc space-y-1">
|
<ul class="mt-2 list-inside list-disc space-y-1">
|
||||||
@foreach ($errors->all() as $message)
|
@foreach ($errors->all() as $message)
|
||||||
@@ -32,12 +34,24 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
<section class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8" aria-labelledby="page-settings-heading">
|
||||||
|
<h2 id="page-settings-heading" class="text-lg font-semibold text-slate-900">{{ __('Page settings') }}</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-600">{{ __('Title, dates, messages, and background for this pre-registration page.') }}</p>
|
||||||
|
<div class="mt-6">
|
||||||
@include('admin.pages._form', ['page' => $page])
|
@include('admin.pages._form', ['page' => $page])
|
||||||
<div class="mt-8 flex gap-3">
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8" aria-labelledby="page-blocks-heading">
|
||||||
|
@include('admin.pages._blocks_editor', ['page' => $page])
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
<button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">
|
<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') }}
|
{{ __('Save changes') }}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,11 +49,12 @@
|
|||||||
<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">{{ __('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">{{ __('Last name') }}</th>
|
||||||
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Email') }}</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>
|
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Phone') }}</th>
|
||||||
@endif
|
@endif
|
||||||
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Registered at') }}</th>
|
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Registered at') }}</th>
|
||||||
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Mailwizz') }}</th>
|
<th class="px-4 py-3 font-semibold text-slate-700">{{ __('Mailwizz') }}</th>
|
||||||
|
<th class="w-px whitespace-nowrap px-4 py-3 font-semibold text-slate-700">{{ __('Actions') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-100">
|
<tbody class="divide-y divide-slate-100">
|
||||||
@@ -62,7 +63,7 @@
|
|||||||
<td class="px-4 py-3 text-slate-900">{{ $subscriber->first_name }}</td>
|
<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-900">{{ $subscriber->last_name }}</td>
|
||||||
<td class="px-4 py-3 text-slate-600">{{ $subscriber->email }}</td>
|
<td class="px-4 py-3 text-slate-600">{{ $subscriber->email }}</td>
|
||||||
@if ($page->phone_enabled)
|
@if ($page->isPhoneFieldEnabledForSubscribers())
|
||||||
<td class="px-4 py-3 text-slate-600">{{ $subscriber->phone ?? '—' }}</td>
|
<td class="px-4 py-3 text-slate-600">{{ $subscriber->phone ?? '—' }}</td>
|
||||||
@endif
|
@endif
|
||||||
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $subscriber->created_at->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td>
|
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $subscriber->created_at->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td>
|
||||||
@@ -77,10 +78,29 @@
|
|||||||
</span>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="{{ $page->phone_enabled ? 6 : 5 }}" class="px-4 py-12 text-center text-slate-500">
|
<td colspan="{{ $page->isPhoneFieldEnabledForSubscribers() ? 7 : 6 }}" class="px-4 py-12 text-center text-slate-500">
|
||||||
{{ __('No subscribers match your criteria.') }}
|
{{ __('No subscribers match your criteria.') }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
36
resources/views/components/blocks/benefits.blade.php
Normal file
36
resources/views/components/blocks/benefits.blade.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
@props(['block'])
|
||||||
|
|
||||||
|
@php
|
||||||
|
/** @var \App\Models\PageBlock $block */
|
||||||
|
$c = $block->content ?? [];
|
||||||
|
$title = data_get($c, 'title');
|
||||||
|
$items = data_get($c, 'items', []);
|
||||||
|
$layout = (string) data_get($c, 'layout', 'list');
|
||||||
|
$cols = (int) data_get($c, 'max_columns', 2);
|
||||||
|
$gridClass = $layout === 'grid'
|
||||||
|
? match ($cols) {
|
||||||
|
3 => 'sm:grid-cols-3',
|
||||||
|
1 => 'sm:grid-cols-1',
|
||||||
|
default => 'sm:grid-cols-2',
|
||||||
|
}
|
||||||
|
: '';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="w-full space-y-4" x-show="phase !== 'thanks'" x-cloak>
|
||||||
|
@if (filled($title))
|
||||||
|
<h2 class="text-center text-lg font-semibold text-white sm:text-xl">{{ $title }}</h2>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (is_array($items) && $items !== [])
|
||||||
|
<ul class="{{ $layout === 'grid' ? 'grid grid-cols-1 gap-3 '.$gridClass : 'space-y-3' }} w-full text-left">
|
||||||
|
@foreach ($items as $item)
|
||||||
|
@if (is_array($item))
|
||||||
|
<li class="flex gap-3 rounded-xl border border-white/15 bg-black/25 px-4 py-3">
|
||||||
|
<x-blocks.icon :name="(string) ($item['icon'] ?? 'check')" class="mt-0.5 text-festival" />
|
||||||
|
<span class="text-sm leading-snug text-white/95 sm:text-[15px]">{{ $item['text'] ?? '' }}</span>
|
||||||
|
</li>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
82
resources/views/components/blocks/countdown.blade.php
Normal file
82
resources/views/components/blocks/countdown.blade.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
@props(['block'])
|
||||||
|
|
||||||
|
@php
|
||||||
|
/** @var \App\Models\PageBlock $block */
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
$c = $block->content ?? [];
|
||||||
|
$rawTarget = data_get($c, 'target_datetime');
|
||||||
|
try {
|
||||||
|
$targetMs = $rawTarget ? (int) (Carbon::parse($rawTarget)->getTimestamp() * 1000) : 0;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$targetMs = 0;
|
||||||
|
}
|
||||||
|
$title = data_get($c, 'title');
|
||||||
|
$expiredAction = (string) data_get($c, 'expired_action', 'hide');
|
||||||
|
$expiredMessage = (string) data_get($c, 'expired_message', '');
|
||||||
|
$style = (string) data_get($c, 'style', 'large');
|
||||||
|
$showLabels = filter_var(data_get($c, 'show_labels', true), FILTER_VALIDATE_BOOLEAN);
|
||||||
|
$labels = data_get($c, 'labels', []);
|
||||||
|
$labelsArr = is_array($labels) ? $labels : [];
|
||||||
|
$gridClass = $style === 'compact' ? 'gap-2 px-2 py-3 sm:gap-3 sm:px-3' : 'gap-3 px-3 py-4 sm:gap-4 sm:px-4 sm:py-5';
|
||||||
|
$numClass = $style === 'compact' ? 'text-xl sm:text-2xl' : 'text-2xl sm:text-3xl';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if ($targetMs > 0)
|
||||||
|
<div
|
||||||
|
class="w-full"
|
||||||
|
x-show="phase !== 'thanks'"
|
||||||
|
x-cloak
|
||||||
|
x-data="countdownBlock(@js([
|
||||||
|
'targetMs' => $targetMs,
|
||||||
|
'showLabels' => $showLabels,
|
||||||
|
'labels' => [
|
||||||
|
'days' => (string) ($labelsArr['days'] ?? __('day')),
|
||||||
|
'hours' => (string) ($labelsArr['hours'] ?? __('hrs')),
|
||||||
|
'minutes' => (string) ($labelsArr['minutes'] ?? __('mins')),
|
||||||
|
'seconds' => (string) ($labelsArr['seconds'] ?? __('secs')),
|
||||||
|
],
|
||||||
|
'expiredAction' => $expiredAction,
|
||||||
|
'expiredMessage' => $expiredMessage,
|
||||||
|
]))"
|
||||||
|
>
|
||||||
|
<div x-show="!expired" x-cloak>
|
||||||
|
@if (filled($title))
|
||||||
|
<p class="mb-3 text-center text-sm font-medium text-white/90 sm:text-base">{{ $title }}</p>
|
||||||
|
@endif
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-4 rounded-2xl border border-white/15 bg-black/35 text-center shadow-inner {{ $gridClass }}"
|
||||||
|
role="timer"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="font-mono font-semibold tabular-nums text-white {{ $numClass }}" x-text="pad(days)"></div>
|
||||||
|
@if ($showLabels)
|
||||||
|
<div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs" x-text="labels.days"></div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-mono font-semibold tabular-nums text-white {{ $numClass }}" x-text="pad(hours)"></div>
|
||||||
|
@if ($showLabels)
|
||||||
|
<div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs" x-text="labels.hours"></div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-mono font-semibold tabular-nums text-white {{ $numClass }}" x-text="pad(minutes)"></div>
|
||||||
|
@if ($showLabels)
|
||||||
|
<div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs" x-text="labels.minutes"></div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-mono font-semibold tabular-nums text-white {{ $numClass }}" x-text="pad(seconds)"></div>
|
||||||
|
@if ($showLabels)
|
||||||
|
<div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs" x-text="labels.seconds"></div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div x-show="expired && expiredAction === 'show_message'" x-cloak>
|
||||||
|
<p class="text-center text-sm text-white/90" x-text="expiredMessage"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
37
resources/views/components/blocks/cta-banner.blade.php
Normal file
37
resources/views/components/blocks/cta-banner.blade.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
@props([
|
||||||
|
'block',
|
||||||
|
'page' => null,
|
||||||
|
'pageState' => 'active',
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
/** @var \App\Models\PageBlock $block */
|
||||||
|
/** @var \App\Models\PreregistrationPage|null $page */
|
||||||
|
$c = $block->content ?? [];
|
||||||
|
$url = (string) data_get($c, 'button_url', '#');
|
||||||
|
if ($pageState === 'expired' && $page !== null && filled($page->ticketshop_url)) {
|
||||||
|
$url = $page->ticketshop_url;
|
||||||
|
}
|
||||||
|
$btnColor = (string) data_get($c, 'button_color', '#F47B20');
|
||||||
|
$style = (string) data_get($c, 'style', 'inline');
|
||||||
|
$flexClass = $style === 'stacked' ? 'flex-col gap-3' : 'flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="w-full rounded-2xl border border-white/15 bg-black/35 px-4 py-4 sm:px-5" x-show="phase !== 'thanks'" x-cloak>
|
||||||
|
<div class="flex {{ $flexClass }}">
|
||||||
|
@if (filled(data_get($c, 'text')))
|
||||||
|
<p class="text-center text-sm font-medium text-white sm:text-left sm:text-base">{{ data_get($c, 'text') }}</p>
|
||||||
|
@endif
|
||||||
|
<div class="flex justify-center sm:justify-end">
|
||||||
|
<a
|
||||||
|
href="{{ e($url) }}"
|
||||||
|
class="inline-flex min-h-[48px] items-center justify-center rounded-xl px-6 py-3 text-sm font-bold text-white shadow-lg transition hover:scale-[1.02] hover:brightness-110 focus:outline-none focus:ring-2 focus:ring-white/40"
|
||||||
|
style="background-color: {{ e($btnColor) }};"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{{ data_get($c, 'button_label') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
27
resources/views/components/blocks/divider.blade.php
Normal file
27
resources/views/components/blocks/divider.blade.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
@props(['block'])
|
||||||
|
|
||||||
|
@php
|
||||||
|
/** @var \App\Models\PageBlock $block */
|
||||||
|
$c = $block->content ?? [];
|
||||||
|
$style = (string) data_get($c, 'style', 'line');
|
||||||
|
$spacing = (string) data_get($c, 'spacing', 'medium');
|
||||||
|
$py = match ($spacing) {
|
||||||
|
'small' => 'py-2',
|
||||||
|
'large' => 'py-6',
|
||||||
|
default => 'py-4',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="w-full {{ $py }}" x-show="phase !== 'thanks'" x-cloak>
|
||||||
|
@if ($style === 'space_only')
|
||||||
|
<div class="h-2" aria-hidden="true"></div>
|
||||||
|
@elseif ($style === 'dots')
|
||||||
|
<div class="flex justify-center gap-2" aria-hidden="true">
|
||||||
|
@foreach (range(1, 5) as $_)
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-white/35"></span>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="h-px w-full bg-gradient-to-r from-transparent via-white/25 to-transparent" aria-hidden="true"></div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
177
resources/views/components/blocks/form.blade.php
Normal file
177
resources/views/components/blocks/form.blade.php
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
@props([
|
||||||
|
'block',
|
||||||
|
'page',
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
/** @var \App\Models\PageBlock $block */
|
||||||
|
/** @var \App\Models\PreregistrationPage $page */
|
||||||
|
$c = $block->content ?? [];
|
||||||
|
$fields = data_get($c, 'fields', []);
|
||||||
|
$showIcons = filter_var(data_get($c, 'show_field_icons', true), FILTER_VALIDATE_BOOLEAN);
|
||||||
|
$btnColor = (string) data_get($c, 'button_color', '#F47B20');
|
||||||
|
$btnText = (string) data_get($c, 'button_text_color', '#FFFFFF');
|
||||||
|
$privacyText = data_get($c, 'privacy_text');
|
||||||
|
$privacyUrl = data_get($c, 'privacy_url');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="w-full space-y-4">
|
||||||
|
@if (filled(data_get($c, 'title')))
|
||||||
|
<h2 class="text-center text-xl font-semibold text-white sm:text-2xl">{{ data_get($c, 'title') }}</h2>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (filled(data_get($c, 'description')))
|
||||||
|
<p class="text-center text-sm leading-relaxed text-white/85 sm:text-[15px]">{{ data_get($c, 'description') }}</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div x-show="phase === 'active'" x-cloak>
|
||||||
|
<form x-ref="form" class="space-y-4" @submit.prevent="submitForm()">
|
||||||
|
<div x-show="formError !== ''" x-cloak class="rounded-xl border border-red-700 bg-red-600 px-4 py-3 text-sm font-medium leading-snug text-white" x-text="formError"></div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
@foreach (['first_name', 'last_name'] as $fk)
|
||||||
|
@php
|
||||||
|
$fc = is_array($fields[$fk] ?? null) ? $fields[$fk] : [];
|
||||||
|
$enabled = filter_var($fc['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
$req = filter_var($fc['required'] ?? true, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
@endphp
|
||||||
|
@if ($enabled)
|
||||||
|
<div>
|
||||||
|
<label for="pf-{{ $fk }}" class="sr-only">{{ $fc['label'] ?? $fk }}</label>
|
||||||
|
<div class="relative">
|
||||||
|
@if ($showIcons)
|
||||||
|
<span class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-white/40" aria-hidden="true">
|
||||||
|
@if ($fk === 'first_name')
|
||||||
|
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" /></svg>
|
||||||
|
@else
|
||||||
|
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0z" /></svg>
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
<input
|
||||||
|
id="pf-{{ $fk }}"
|
||||||
|
type="text"
|
||||||
|
name="{{ $fk }}"
|
||||||
|
autocomplete="{{ $fk === 'first_name' ? 'given-name' : 'family-name' }}"
|
||||||
|
@if ($req) required @endif
|
||||||
|
maxlength="255"
|
||||||
|
x-model="{{ $fk }}"
|
||||||
|
class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45 {{ $showIcons ? 'pl-11 pr-4' : 'px-4' }}"
|
||||||
|
placeholder="{{ $fc['placeholder'] ?? $fc['label'] ?? '' }}"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p x-show="fieldErrors.{{ $fk }}" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.{{ $fk }} ? fieldErrors.{{ $fk }}[0] : ''"></p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@php
|
||||||
|
$emailFc = is_array($fields['email'] ?? null) ? $fields['email'] : [];
|
||||||
|
$emailOn = filter_var($emailFc['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
@endphp
|
||||||
|
@if ($emailOn)
|
||||||
|
<div>
|
||||||
|
<label for="pf-email" class="sr-only">{{ $emailFc['label'] ?? __('Email') }}</label>
|
||||||
|
<div class="relative">
|
||||||
|
@if ($showIcons)
|
||||||
|
<span class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-white/40" aria-hidden="true">
|
||||||
|
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" /></svg>
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
<input
|
||||||
|
id="pf-email"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
autocomplete="email"
|
||||||
|
required
|
||||||
|
maxlength="255"
|
||||||
|
x-model="email"
|
||||||
|
class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45 {{ $showIcons ? 'pl-11 pr-4' : 'px-4' }}"
|
||||||
|
placeholder="{{ $emailFc['placeholder'] ?? $emailFc['label'] ?? '' }}"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p x-show="fieldErrors.email" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.email ? fieldErrors.email[0] : ''"></p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@php
|
||||||
|
$phoneFc = is_array($fields['phone'] ?? null) ? $fields['phone'] : [];
|
||||||
|
$phoneOn = filter_var($phoneFc['enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
$phoneReq = filter_var($phoneFc['required'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
@endphp
|
||||||
|
@if ($phoneOn)
|
||||||
|
<div>
|
||||||
|
<label for="pf-phone" class="sr-only">{{ $phoneFc['label'] ?? __('Phone') }}</label>
|
||||||
|
<div class="relative">
|
||||||
|
@if ($showIcons)
|
||||||
|
<span class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-white/40" aria-hidden="true">
|
||||||
|
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.608-1.25.608H9.75a2.25 2.25 0 01-2.25-2.25V9.75c0-.481.232-.968.608-1.25l1.293-.97c.362-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z" /></svg>
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
<input
|
||||||
|
id="pf-phone"
|
||||||
|
type="tel"
|
||||||
|
name="phone"
|
||||||
|
autocomplete="tel"
|
||||||
|
maxlength="20"
|
||||||
|
@if ($phoneReq) required @endif
|
||||||
|
x-model="phone"
|
||||||
|
class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45 {{ $showIcons ? 'pl-11 pr-4' : 'px-4' }}"
|
||||||
|
placeholder="{{ $phoneFc['placeholder'] ?? $phoneFc['label'] ?? '' }}"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p x-show="fieldErrors.phone" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.phone ? fieldErrors.phone[0] : ''"></p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (filled($privacyText))
|
||||||
|
<p class="text-center text-xs leading-relaxed text-white/60">
|
||||||
|
@if (filled($privacyUrl))
|
||||||
|
<a href="{{ e($privacyUrl) }}" class="underline decoration-white/30 underline-offset-2 hover:text-white" target="_blank" rel="noopener noreferrer">{{ $privacyText }}</a>
|
||||||
|
@else
|
||||||
|
{{ $privacyText }}
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="mt-2 min-h-[52px] w-full rounded-xl px-6 py-3.5 text-base font-bold tracking-wide shadow-lg transition duration-200 ease-out hover:scale-[1.02] hover:brightness-110 focus:outline-none focus:ring-2 focus:ring-festival focus:ring-offset-2 focus:ring-offset-black/80 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:scale-100 sm:min-h-[56px] sm:text-lg"
|
||||||
|
style="background-color: {{ e($btnColor) }}; color: {{ e($btnText) }};"
|
||||||
|
:disabled="submitting"
|
||||||
|
>
|
||||||
|
<span x-show="!submitting" x-text="formButtonLabel"></span>
|
||||||
|
<span x-show="submitting" x-cloak>{{ __('Sending…') }}</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="phase === 'thanks'" x-cloak class="space-y-6">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<div class="animate-preregister-in flex h-16 w-16 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-300 ring-2 ring-emerald-400/50">
|
||||||
|
<svg class="h-9 w-9" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="whitespace-pre-line text-center text-base leading-relaxed text-white/95 sm:text-lg" x-text="thankYouMessage"></p>
|
||||||
|
<p
|
||||||
|
x-show="redirectSecondsLeft !== null && redirectSecondsLeft > 0"
|
||||||
|
x-cloak
|
||||||
|
class="text-center text-sm text-white/75"
|
||||||
|
x-text="(strings.redirectCountdown || '').replace(':seconds', String(redirectSecondsLeft))"
|
||||||
|
></p>
|
||||||
|
<div class="rounded-xl border border-white/15 bg-black/30 px-4 py-4 text-center">
|
||||||
|
<p class="text-sm font-medium text-white/90">{{ __('Deel deze pagina') }}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-3 inline-flex min-h-[44px] w-full items-center justify-center rounded-lg border border-white/25 bg-white/10 px-4 py-2 text-sm font-semibold text-white hover:bg-white/15"
|
||||||
|
@click="copyPageLink()"
|
||||||
|
>
|
||||||
|
{{ __('Link kopiëren') }}
|
||||||
|
</button>
|
||||||
|
<p x-show="copyFeedback !== ''" x-cloak class="mt-2 text-xs text-emerald-300" x-text="copyFeedback"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
43
resources/views/components/blocks/hero.blade.php
Normal file
43
resources/views/components/blocks/hero.blade.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
@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');
|
||||||
|
@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
|
||||||
|
|
||||||
|
@if ($pageState !== 'expired' && filled(data_get($c, 'subheadline')))
|
||||||
|
<div class="w-full max-w-none whitespace-pre-line text-[15px] leading-[1.65] text-white sm:text-base sm:leading-relaxed">
|
||||||
|
{{ trim((string) data_get($c, 'subheadline')) }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
37
resources/views/components/blocks/icon.blade.php
Normal file
37
resources/views/components/blocks/icon.blade.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
@props(['name' => 'check'])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$common = 'h-6 w-6 shrink-0 text-festival';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@switch($name)
|
||||||
|
@case('ticket')
|
||||||
|
<svg {{ $attributes->merge(['class' => $common]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 6v.75l1.5 1.5M9 6.75V9m0 0v3.75m0-3.75h3.75m-3.75 0H9m9 3.75V9m0 0V6.75m0 3.75h-3.75m3.75 0H15M4.5 19.5l15-15M4.5 4.5l15 15" /></svg>
|
||||||
|
@break
|
||||||
|
@case('clock')
|
||||||
|
<svg {{ $attributes->merge(['class' => $common]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||||
|
@break
|
||||||
|
@case('mail')
|
||||||
|
<svg {{ $attributes->merge(['class' => $common]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" /></svg>
|
||||||
|
@break
|
||||||
|
@case('users')
|
||||||
|
<svg {{ $attributes->merge(['class' => $common]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" /></svg>
|
||||||
|
@break
|
||||||
|
@case('star')
|
||||||
|
<svg {{ $attributes->merge(['class' => $common]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.873a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.385a.562.562 0 00-.182-.557l-4.204-3.602a.562.562 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" /></svg>
|
||||||
|
@break
|
||||||
|
@case('heart')
|
||||||
|
<svg {{ $attributes->merge(['class' => $common]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" /></svg>
|
||||||
|
@break
|
||||||
|
@case('gift')
|
||||||
|
<svg {{ $attributes->merge(['class' => $common]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m-8.25 3.75h15" /></svg>
|
||||||
|
@break
|
||||||
|
@case('music')
|
||||||
|
<svg {{ $attributes->merge(['class' => $common]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z" /></svg>
|
||||||
|
@break
|
||||||
|
@case('shield')
|
||||||
|
<svg {{ $attributes->merge(['class' => $common]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /></svg>
|
||||||
|
@break
|
||||||
|
@default
|
||||||
|
<svg {{ $attributes->merge(['class' => $common]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg>
|
||||||
|
@endswitch
|
||||||
49
resources/views/components/blocks/image.blade.php
Normal file
49
resources/views/components/blocks/image.blade.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
@props([
|
||||||
|
'block',
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
/** @var \App\Models\PageBlock $block */
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
$c = $block->content ?? [];
|
||||||
|
$path = data_get($c, 'image');
|
||||||
|
$src = is_string($path) && $path !== '' ? Storage::disk('public')->url($path) : null;
|
||||||
|
$rawLink = data_get($c, 'link_url');
|
||||||
|
$linkUrl = null;
|
||||||
|
if (is_string($rawLink) && preg_match('/\Ahttps?:\/\//i', trim($rawLink)) === 1) {
|
||||||
|
$linkUrl = trim($rawLink);
|
||||||
|
}
|
||||||
|
$alt = is_string(data_get($c, 'alt')) ? (string) data_get($c, 'alt') : '';
|
||||||
|
$maxW = max(48, min(800, (int) data_get($c, 'max_width_px', 320)));
|
||||||
|
$align = (string) data_get($c, 'text_alignment', 'center');
|
||||||
|
// text-align + inline-block centers reliably; flex + max-w-full often yields full-width flex items.
|
||||||
|
$textAlignClass = match ($align) {
|
||||||
|
'left' => 'text-left',
|
||||||
|
'right' => 'text-right',
|
||||||
|
default => 'text-center',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if ($src !== null)
|
||||||
|
<div class="w-full {{ $textAlignClass }}">
|
||||||
|
@if ($linkUrl !== null)
|
||||||
|
<a
|
||||||
|
href="{{ e($linkUrl) }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-block max-w-full align-middle"
|
||||||
|
>
|
||||||
|
@endif
|
||||||
|
<img
|
||||||
|
src="{{ e($src) }}"
|
||||||
|
alt="{{ e($alt) }}"
|
||||||
|
class="inline-block h-auto w-auto max-w-full align-middle object-contain drop-shadow-[0_8px_32px_rgba(0,0,0,0.35)]"
|
||||||
|
style="max-width: min(100%, {{ $maxW }}px)"
|
||||||
|
loading="lazy"
|
||||||
|
>
|
||||||
|
@if ($linkUrl !== null)
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
33
resources/views/components/blocks/social-proof.blade.php
Normal file
33
resources/views/components/blocks/social-proof.blade.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
@props([
|
||||||
|
'block',
|
||||||
|
'subscriberCount' => 0,
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
/** @var \App\Models\PageBlock $block */
|
||||||
|
$c = $block->content ?? [];
|
||||||
|
$min = (int) data_get($c, 'min_count', 0);
|
||||||
|
$template = (string) data_get($c, 'template', '');
|
||||||
|
$style = (string) data_get($c, 'style', 'pill');
|
||||||
|
$showAnim = filter_var(data_get($c, 'show_animation', true), FILTER_VALIDATE_BOOLEAN);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if ($subscriberCount >= $min && str_contains($template, '{count}'))
|
||||||
|
<div
|
||||||
|
class="w-full text-center"
|
||||||
|
x-show="phase !== 'thanks'"
|
||||||
|
x-cloak
|
||||||
|
>
|
||||||
|
@php
|
||||||
|
$text = str_replace('{count}', number_format($subscriberCount, 0, ',', '.'), $template);
|
||||||
|
$wrapClass = match ($style) {
|
||||||
|
'badge' => 'inline-flex rounded-lg border border-white/20 bg-white/10 px-4 py-2 text-sm text-white',
|
||||||
|
'plain' => 'text-sm text-white/90',
|
||||||
|
default => 'inline-flex rounded-full border border-festival/40 bg-festival/15 px-5 py-2 text-sm font-medium text-white shadow-inner',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
<p @class([$wrapClass, 'transition-transform duration-500' => $showAnim]) x-data="{ shown: false }" x-init="setTimeout(() => shown = true, 100)" :class="shown ? 'scale-100 opacity-100' : 'scale-95 opacity-0'">
|
||||||
|
{{ $text }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
30
resources/views/components/blocks/text.blade.php
Normal file
30
resources/views/components/blocks/text.blade.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
@props(['block'])
|
||||||
|
|
||||||
|
@php
|
||||||
|
/** @var \App\Models\PageBlock $block */
|
||||||
|
$c = $block->content ?? [];
|
||||||
|
$size = (string) data_get($c, 'text_size', 'base');
|
||||||
|
$align = (string) data_get($c, 'text_alignment', 'center');
|
||||||
|
$sizeClass = match ($size) {
|
||||||
|
'sm' => 'text-sm sm:text-[15px]',
|
||||||
|
'lg' => 'text-lg sm:text-xl',
|
||||||
|
default => 'text-[15px] sm:text-base',
|
||||||
|
};
|
||||||
|
$alignClass = match ($align) {
|
||||||
|
'left' => 'text-left',
|
||||||
|
'right' => 'text-right',
|
||||||
|
default => 'text-center',
|
||||||
|
};
|
||||||
|
$bodyRaw = data_get($c, 'body');
|
||||||
|
$body = is_string($bodyRaw) ? trim($bodyRaw) : '';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
{{-- Body must be on one line inside the div: whitespace-pre-line turns indentation/newlines around {{ $body }} into visible gaps. --}}
|
||||||
|
<div class="w-full space-y-3" x-show="phase !== 'thanks'" x-cloak>
|
||||||
|
@if (filled(data_get($c, 'title')))
|
||||||
|
<h2 class="{{ $alignClass }} text-lg font-semibold text-white sm:text-xl">{{ data_get($c, 'title') }}</h2>
|
||||||
|
@endif
|
||||||
|
@if ($body !== '')
|
||||||
|
<div class="{{ $alignClass }} {{ $sizeClass }} whitespace-pre-line leading-relaxed text-white/90">{{ $body }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@@ -16,14 +16,16 @@
|
|||||||
@php
|
@php
|
||||||
$adminFlashSuccess = session('status');
|
$adminFlashSuccess = session('status');
|
||||||
$adminFlashError = session('error');
|
$adminFlashError = session('error');
|
||||||
|
$inlinePageFlash = request()->routeIs(['admin.pages.edit', 'admin.pages.create']);
|
||||||
|
$showSuccessToast = $adminFlashSuccess !== null && ! $inlinePageFlash;
|
||||||
@endphp
|
@endphp
|
||||||
@if ($adminFlashSuccess !== null || $adminFlashError !== null)
|
@if ($showSuccessToast || $adminFlashError !== null)
|
||||||
<div
|
<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"
|
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"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
@foreach (array_filter([
|
@foreach (array_filter([
|
||||||
$adminFlashSuccess !== null ? ['type' => 'success', 'message' => $adminFlashSuccess] : null,
|
$showSuccessToast ? ['type' => 'success', 'message' => $adminFlashSuccess] : null,
|
||||||
$adminFlashError !== null ? ['type' => 'error', 'message' => $adminFlashError] : null,
|
$adminFlashError !== null ? ['type' => 'error', 'message' => $adminFlashError] : null,
|
||||||
]) as $toast)
|
]) as $toast)
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
<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 rel="preconnect" href="https://fonts.bunny.net">
|
||||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet">
|
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet">
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
@php
|
@php
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
$bgUrl = $page->background_image !== null
|
/** @var \Illuminate\Support\Collection<int, \App\Models\PageBlock> $blocksToRender */
|
||||||
? Storage::disk('public')->url($page->background_image)
|
$bgPath = $page->background_image;
|
||||||
: null;
|
$bgUrl = is_string($bgPath) && $bgPath !== '' ? Storage::disk('public')->url($bgPath) : null;
|
||||||
$logoUrl = $page->logo_image !== null
|
$overlayColor = (string) ($page->background_overlay_color ?: '#000000');
|
||||||
? Storage::disk('public')->url($page->logo_image)
|
$overlayOpacity = max(0, min(100, (int) ($page->background_overlay_opacity ?? 50))) / 100;
|
||||||
: null;
|
$hasExpiredCtaBlock = $blocksToRender->contains(fn (\App\Models\PageBlock $b): bool => $b->type === 'cta_banner');
|
||||||
$phase = $page->isBeforeStart() ? 'before' : ($page->isExpired() ? 'expired' : 'active');
|
$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');
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
@@ -27,16 +39,20 @@
|
|||||||
></div>
|
></div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="absolute inset-0 bg-black/30" aria-hidden="true"></div>
|
<div
|
||||||
|
class="absolute inset-0"
|
||||||
|
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="relative z-10 flex min-h-screen items-center justify-center px-4 py-6 sm:px-6 sm:py-10">
|
||||||
<div
|
<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-cloak
|
||||||
x-data="publicPreregisterPage(@js([
|
x-data="publicPreregisterPage(@js([
|
||||||
'phase' => $phase,
|
'phase' => $alpinePhase,
|
||||||
'startAtMs' => $page->start_date->getTimestamp() * 1000,
|
'startAtMs' => $page->start_date->getTimestamp() * 1000,
|
||||||
'phoneEnabled' => (bool) $page->phone_enabled,
|
'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(),
|
||||||
'subscribeUrl' => route('public.subscribe', ['publicPage' => $page]),
|
'subscribeUrl' => route('public.subscribe', ['publicPage' => $page]),
|
||||||
'csrfToken' => csrf_token(),
|
'csrfToken' => csrf_token(),
|
||||||
'genericError' => __('Something went wrong. Please try again.'),
|
'genericError' => __('Something went wrong. Please try again.'),
|
||||||
@@ -44,150 +60,30 @@
|
|||||||
'labelDays' => __('days'),
|
'labelDays' => __('days'),
|
||||||
'invalidEmailMsg' => __('Please enter a valid email address.'),
|
'invalidEmailMsg' => __('Please enter a valid email address.'),
|
||||||
'invalidPhoneMsg' => __('Please enter a valid phone number (8–15 digits).'),
|
'invalidPhoneMsg' => __('Please enter a valid phone number (8–15 digits).'),
|
||||||
|
'formButtonLabel' => $formButtonLabel,
|
||||||
|
'formButtonColor' => $formButtonColor,
|
||||||
|
'formButtonTextColor' => $formButtonTextColor,
|
||||||
|
'pageShareUrl' => url()->current(),
|
||||||
|
'redirectUrl' => filled($redirectAfterSubmit) ? $redirectAfterSubmit : null,
|
||||||
|
'strings' => [
|
||||||
|
'linkCopied' => __('Link gekopieerd!'),
|
||||||
|
'redirectCountdown' => __('You will be redirected in :seconds s…'),
|
||||||
|
],
|
||||||
]))"
|
]))"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col items-center text-center">
|
<div class="flex flex-col items-stretch space-y-4">
|
||||||
@if ($logoUrl !== null)
|
@foreach ($blocksToRender as $block)
|
||||||
<div class="mb-4 flex w-full justify-center sm:mb-5">
|
<x-dynamic-component
|
||||||
<img
|
:component="$block->bladeComponentName()"
|
||||||
src="{{ e($logoUrl) }}"
|
:block="$block"
|
||||||
alt=""
|
:page="$page"
|
||||||
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"
|
:page-state="$pageState"
|
||||||
width="384"
|
:subscriber-count="$subscriberCount"
|
||||||
height="192"
|
/>
|
||||||
>
|
@endforeach
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<h1 class="w-full max-w-none text-balance text-2xl font-bold leading-snug tracking-tight text-festival sm:text-3xl">
|
@if ($pageState === 'expired')
|
||||||
{{ $page->heading }}
|
<div class="space-y-6">
|
||||||
</h1>
|
|
||||||
|
|
||||||
@if (filled($page->intro_text))
|
|
||||||
<div
|
|
||||||
x-show="phase === 'before' || phase === 'active'"
|
|
||||||
x-cloak
|
|
||||||
class="mt-0 w-full max-w-none whitespace-pre-line text-[15px] leading-[1.65] text-white sm:text-base sm:leading-relaxed"
|
|
||||||
>
|
|
||||||
{{ trim($page->intro_text) }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Before start: countdown --}}
|
|
||||||
<div x-show="phase === 'before'" x-cloak class="mt-8 space-y-6 sm:mt-10">
|
|
||||||
<div
|
|
||||||
class="grid grid-cols-4 gap-3 rounded-2xl border border-white/15 bg-black/35 px-3 py-4 text-center shadow-inner sm:gap-4 sm:px-4 sm:py-5"
|
|
||||||
role="timer"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(days)"></div>
|
|
||||||
<div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs" x-text="days === 1 ? labelDay : labelDays"></div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(hours)"></div>
|
|
||||||
<div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs">{{ __('hrs') }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(minutes)"></div>
|
|
||||||
<div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs">{{ __('mins') }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="font-mono text-2xl font-semibold tabular-nums text-white sm:text-3xl" x-text="pad(seconds)"></div>
|
|
||||||
<div class="mt-2 text-[10px] uppercase tracking-wider text-white/65 sm:text-xs">{{ __('secs') }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Active: registration form --}}
|
|
||||||
<div x-show="phase === 'active'" x-cloak class="mt-8 sm:mt-10">
|
|
||||||
<form x-ref="form" class="space-y-4" @submit.prevent="submitForm()">
|
|
||||||
<div x-show="formError !== ''" x-cloak class="rounded-xl border border-amber-400/50 bg-amber-500/15 px-4 py-3 text-sm leading-snug text-amber-50" x-text="formError"></div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label for="first_name" class="sr-only">{{ __('First name') }}</label>
|
|
||||||
<input
|
|
||||||
id="first_name"
|
|
||||||
type="text"
|
|
||||||
name="first_name"
|
|
||||||
autocomplete="given-name"
|
|
||||||
required
|
|
||||||
maxlength="255"
|
|
||||||
x-model="first_name"
|
|
||||||
class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 px-4 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45"
|
|
||||||
placeholder="{{ __('First name') }}"
|
|
||||||
>
|
|
||||||
<p x-show="fieldErrors.first_name" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.first_name ? fieldErrors.first_name[0] : ''"></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="last_name" class="sr-only">{{ __('Last name') }}</label>
|
|
||||||
<input
|
|
||||||
id="last_name"
|
|
||||||
type="text"
|
|
||||||
name="last_name"
|
|
||||||
autocomplete="family-name"
|
|
||||||
required
|
|
||||||
maxlength="255"
|
|
||||||
x-model="last_name"
|
|
||||||
class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 px-4 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45"
|
|
||||||
placeholder="{{ __('Last name') }}"
|
|
||||||
>
|
|
||||||
<p x-show="fieldErrors.last_name" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.last_name ? fieldErrors.last_name[0] : ''"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="email" class="sr-only">{{ __('Email') }}</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
autocomplete="email"
|
|
||||||
required
|
|
||||||
maxlength="255"
|
|
||||||
x-model="email"
|
|
||||||
class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 px-4 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45"
|
|
||||||
placeholder="{{ __('Email') }}"
|
|
||||||
>
|
|
||||||
<p x-show="fieldErrors.email" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.email ? fieldErrors.email[0] : ''"></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div x-show="phoneEnabled">
|
|
||||||
<label for="phone" class="sr-only">{{ __('Phone (optional)') }}</label>
|
|
||||||
<input
|
|
||||||
id="phone"
|
|
||||||
type="tel"
|
|
||||||
name="phone"
|
|
||||||
autocomplete="tel"
|
|
||||||
maxlength="20"
|
|
||||||
x-model="phone"
|
|
||||||
class="min-h-[48px] w-full rounded-xl border border-white/35 bg-white/15 px-4 py-3 text-[15px] text-white placeholder-white/55 shadow-sm transition duration-200 ease-out focus:border-festival focus:outline-none focus:ring-2 focus:ring-festival/45"
|
|
||||||
placeholder="{{ __('Phone (optional)') }}"
|
|
||||||
>
|
|
||||||
<p x-show="fieldErrors.phone" x-cloak class="mt-2 text-sm text-red-200" x-text="fieldErrors.phone ? fieldErrors.phone[0] : ''"></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="mt-2 min-h-[52px] w-full rounded-xl bg-festival px-6 py-3.5 text-base font-bold tracking-wide text-white shadow-lg shadow-festival/30 transition duration-200 ease-out hover:scale-[1.02] hover:brightness-110 focus:outline-none focus:ring-2 focus:ring-festival focus:ring-offset-2 focus:ring-offset-black/80 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:scale-100 sm:min-h-[56px] sm:text-lg"
|
|
||||||
:disabled="submitting"
|
|
||||||
>
|
|
||||||
<span x-show="!submitting">{{ __('public.register_button') }}</span>
|
|
||||||
<span x-show="submitting" x-cloak>{{ __('Sending…') }}</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Thank you (after successful AJAX) --}}
|
|
||||||
<div x-show="phase === 'thanks'" x-cloak class="mt-8 sm:mt-10">
|
|
||||||
<p class="whitespace-pre-line text-center text-base leading-relaxed text-white/95 sm:text-lg" x-text="thankYouMessage"></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Expired --}}
|
|
||||||
<div x-show="phase === 'expired'" x-cloak class="mt-8 space-y-6 sm:mt-10">
|
|
||||||
@if (filled($page->expired_message))
|
@if (filled($page->expired_message))
|
||||||
<div class="whitespace-pre-line text-center text-[15px] leading-[1.65] text-white/92 sm:text-base sm:leading-relaxed">
|
<div class="whitespace-pre-line text-center text-[15px] leading-[1.65] text-white/92 sm:text-base sm:leading-relaxed">
|
||||||
{{ $page->expired_message }}
|
{{ $page->expired_message }}
|
||||||
@@ -196,7 +92,7 @@
|
|||||||
<p class="text-center text-[15px] leading-relaxed text-white/92 sm:text-base">{{ __('This pre-registration period has ended.') }}</p>
|
<p class="text-center text-[15px] leading-relaxed text-white/92 sm:text-base">{{ __('This pre-registration period has ended.') }}</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if (filled($page->ticketshop_url))
|
@if (filled($page->ticketshop_url) && ! $hasExpiredCtaBlock)
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<a
|
<a
|
||||||
href="{{ e($page->ticketshop_url) }}"
|
href="{{ e($page->ticketshop_url) }}"
|
||||||
@@ -210,6 +106,8 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(
|
|||||||
|
|
||||||
// Subscribers (nested under pages) — export before index so the path is unambiguous
|
// 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::get('pages/{page}/subscribers/export', [SubscriberController::class, 'export'])->name('pages.subscribers.export');
|
||||||
|
Route::delete('pages/{page}/subscribers/{subscriber}', [SubscriberController::class, 'destroy'])->name('pages.subscribers.destroy');
|
||||||
Route::post('pages/{page}/subscribers/queue-mailwizz-sync', [SubscriberController::class, 'queueMailwizzSync'])->name('pages.subscribers.queue-mailwizz-sync');
|
Route::post('pages/{page}/subscribers/queue-mailwizz-sync', [SubscriberController::class, 'queueMailwizzSync'])->name('pages.subscribers.queue-mailwizz-sync');
|
||||||
Route::get('pages/{page}/subscribers', [SubscriberController::class, 'index'])->name('pages.subscribers.index');
|
Route::get('pages/{page}/subscribers', [SubscriberController::class, 'index'])->name('pages.subscribers.index');
|
||||||
|
|
||||||
|
|||||||
131
tests/Feature/DestroySubscriberTest.php
Normal file
131
tests/Feature/DestroySubscriberTest.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ namespace Tests\Feature;
|
|||||||
use App\Models\PreregistrationPage;
|
use App\Models\PreregistrationPage;
|
||||||
use App\Models\Subscriber;
|
use App\Models\Subscriber;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\PreregistrationPageBlockWriter;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -199,7 +200,7 @@ class PublicPageTest extends TestCase
|
|||||||
{
|
{
|
||||||
$user = User::factory()->create(['role' => 'user']);
|
$user = User::factory()->create(['role' => 'user']);
|
||||||
|
|
||||||
return PreregistrationPage::query()->create(array_merge([
|
$page = PreregistrationPage::query()->create(array_merge([
|
||||||
'slug' => (string) Str::uuid(),
|
'slug' => (string) Str::uuid(),
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'title' => 'Test page',
|
'title' => 'Test page',
|
||||||
@@ -211,7 +212,36 @@ class PublicPageTest extends TestCase
|
|||||||
'start_date' => now()->subHour(),
|
'start_date' => now()->subHour(),
|
||||||
'end_date' => now()->addMonth(),
|
'end_date' => now()->addMonth(),
|
||||||
'phone_enabled' => false,
|
'phone_enabled' => false,
|
||||||
|
'background_image' => null,
|
||||||
|
'logo_image' => null,
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
], $overrides));
|
], $overrides));
|
||||||
|
|
||||||
|
$writer = app(PreregistrationPageBlockWriter::class);
|
||||||
|
if (! $page->blocks()->exists()) {
|
||||||
|
$writer->seedDefaultBlocks($page);
|
||||||
|
}
|
||||||
|
|
||||||
|
$page->refresh();
|
||||||
|
$hero = $page->getHeroBlock();
|
||||||
|
if ($hero !== null) {
|
||||||
|
$c = $hero->content;
|
||||||
|
$c['headline'] = $page->heading;
|
||||||
|
$hero->update(['content' => $c]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($overrides['phone_enabled'] ?? false) === true) {
|
||||||
|
$page->load('blocks');
|
||||||
|
$form = $page->getFormBlock();
|
||||||
|
if ($form !== null) {
|
||||||
|
$c = $form->content;
|
||||||
|
data_set($c, 'fields.phone.enabled', true);
|
||||||
|
$form->update(['content' => $c]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$page->syncLegacyContentColumnsFromBlocks();
|
||||||
|
|
||||||
|
return $page->fresh(['blocks']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\PageBlock;
|
||||||
use App\Models\PreregistrationPage;
|
use App\Models\PreregistrationPage;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@@ -19,20 +20,19 @@ class StorePreregistrationPageTest extends TestCase
|
|||||||
|
|
||||||
$response = $this->actingAs($user)->post(route('admin.pages.store'), [
|
$response = $this->actingAs($user)->post(route('admin.pages.store'), [
|
||||||
'title' => 'Summer Fest',
|
'title' => 'Summer Fest',
|
||||||
'heading' => 'Register',
|
|
||||||
'intro_text' => null,
|
|
||||||
'thank_you_message' => null,
|
'thank_you_message' => null,
|
||||||
'expired_message' => null,
|
'expired_message' => null,
|
||||||
'ticketshop_url' => null,
|
'ticketshop_url' => null,
|
||||||
'start_date' => '2026-06-01T10:00',
|
'start_date' => '2026-06-01T10:00',
|
||||||
'end_date' => '2026-06-30T18:00',
|
'end_date' => '2026-06-30T18:00',
|
||||||
'phone_enabled' => false,
|
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertRedirect(route('admin.pages.index'));
|
|
||||||
$this->assertDatabaseCount('preregistration_pages', 1);
|
$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->assertGreaterThanOrEqual(4, PageBlock::query()->where('preregistration_page_id', $page?->id)->count());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_validation_failure_redirects_back_with_input(): void
|
public function test_validation_failure_redirects_back_with_input(): void
|
||||||
@@ -41,13 +41,11 @@ class StorePreregistrationPageTest extends TestCase
|
|||||||
|
|
||||||
$response = $this->actingAs($user)->from(route('admin.pages.create'))->post(route('admin.pages.store'), [
|
$response = $this->actingAs($user)->from(route('admin.pages.create'))->post(route('admin.pages.store'), [
|
||||||
'title' => '',
|
'title' => '',
|
||||||
'heading' => 'H',
|
|
||||||
'start_date' => '2026-06-30T10:00',
|
'start_date' => '2026-06-30T10:00',
|
||||||
'end_date' => '2026-06-01T10:00',
|
'end_date' => '2026-06-01T10:00',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertRedirect(route('admin.pages.create'));
|
$response->assertRedirect(route('admin.pages.create'));
|
||||||
$response->assertSessionHasErrors('title');
|
$response->assertSessionHasErrors('title');
|
||||||
$response->assertSessionHasInput('heading', 'H');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user