Implemented a block editor for changing the layout of the page

This commit is contained in:
2026-04-04 01:17:05 +02:00
parent 0800f7664f
commit ff58e82497
41 changed files with 2706 additions and 298 deletions

View File

@@ -1,6 +1,7 @@
import './bootstrap';
import Alpine from 'alpinejs';
import Sortable from 'sortablejs';
/**
* Client-side email check (aligned loosely with RFC-style rules; server is authoritative).
@@ -32,7 +33,359 @@ function isValidEmailFormat(value) {
return Boolean(tld && tld.length >= 2);
}
function newBlockUid() {
return `n_${crypto.randomUUID().replaceAll('-', '')}`;
}
function deepClone(o) {
return JSON.parse(JSON.stringify(o));
}
/** Default `content` payloads for admin block editor (Dutch defaults). */
function pageBlockDefaultContent(type) {
const defaults = {
hero: {
headline: '',
subheadline: '',
eyebrow_text: '',
eyebrow_style: 'badge',
text_alignment: 'center',
},
image: {
image: null,
link_url: '',
alt: '',
max_width_px: 320,
text_alignment: 'center',
},
benefits: {
title: 'Waarom voorregistreren?',
items: [
{ icon: 'ticket', text: 'Exclusieve korting op tickets' },
{ icon: 'clock', text: 'Eerder toegang tot de ticketshop' },
],
layout: 'list',
max_columns: 2,
},
social_proof: {
template: 'Al {count} bezoekers aangemeld!',
min_count: 10,
show_animation: true,
style: 'pill',
},
form: {
title: 'Registreer nu',
description: '',
button_label: 'Registreer nu!',
button_color: '#F47B20',
button_text_color: '#FFFFFF',
fields: {
first_name: {
enabled: true,
required: true,
label: 'Voornaam',
placeholder: 'Je voornaam',
},
last_name: {
enabled: true,
required: true,
label: 'Achternaam',
placeholder: 'Je achternaam',
},
email: {
enabled: true,
required: true,
label: 'E-mailadres',
placeholder: 'je@email.nl',
},
phone: {
enabled: false,
required: false,
label: 'Mobiel',
placeholder: '+31 6 12345678',
},
},
show_field_icons: true,
privacy_text: 'Door je te registreren ga je akkoord met onze privacyverklaring.',
privacy_url: '',
},
countdown: {
target_datetime: new Date(Date.now() + 86400000).toISOString().slice(0, 16),
title: 'De pre-registratie opent over:',
expired_action: 'reload',
expired_message: '',
style: 'large',
show_labels: true,
labels: {
days: 'dagen',
hours: 'uren',
minutes: 'minuten',
seconds: 'seconden',
},
},
text: {
title: '',
body: '',
text_size: 'base',
text_alignment: 'center',
},
cta_banner: {
text: '',
button_label: 'Ga naar de ticketshop',
button_url: 'https://',
button_color: '#F47B20',
style: 'inline',
},
divider: {
style: 'line',
spacing: 'medium',
},
};
return deepClone(defaults[type] || { _: '' });
}
document.addEventListener('alpine:init', () => {
Alpine.data('pageBlockEditor', (config) => ({
blocks: config.initialBlocks || [],
blockTypes: config.blockTypes || [],
storageBase: typeof config.storageBase === 'string' ? config.storageBase.replace(/\/$/, '') : '',
sortable: null,
collapsed: {},
confirmDeleteUid: null,
_verticalDragLock: null,
init() {
this.blocks.forEach((b) => {
this.collapsed[b.uid] = true;
});
this.$nextTick(() => {
const el = this.$refs.sortRoot;
if (!el) {
return;
}
const lockGhostToVerticalAxis = () => {
requestAnimationFrame(() => {
const ghost = Sortable.ghost;
if (!ghost) {
return;
}
const raw = window.getComputedStyle(ghost).transform;
if (!raw || raw === 'none') {
return;
}
try {
const m = new DOMMatrix(raw);
if (m.m41 !== 0) {
m.m41 = 0;
ghost.style.transform = m.toString();
}
} catch {
/* ignore invalid matrix */
}
});
};
this.sortable = Sortable.create(el, {
handle: '.block-drag-handle',
animation: 150,
direction: 'vertical',
forceFallback: true,
fallbackOnBody: true,
onStart: () => {
this._verticalDragLock = lockGhostToVerticalAxis;
document.addEventListener('mousemove', this._verticalDragLock, false);
document.addEventListener('touchmove', this._verticalDragLock, { passive: true });
},
onEnd: () => {
if (this._verticalDragLock) {
document.removeEventListener('mousemove', this._verticalDragLock, false);
document.removeEventListener('touchmove', this._verticalDragLock, { passive: true });
this._verticalDragLock = null;
}
this.syncSortOrderFromDom();
},
});
});
},
syncSortOrderFromDom() {
const root = this.$refs.sortRoot;
if (!root) {
return;
}
const rows = [...root.querySelectorAll('[data-block-uid]')];
rows.forEach((row, i) => {
const uid = row.getAttribute('data-block-uid');
const b = this.blocks.find((x) => x.uid === uid);
if (b) {
b.sort_order = i;
}
});
},
hasFormBlock() {
return this.blocks.some((b) => b.type === 'form');
},
toggleCollapsed(uid) {
this.collapsed[uid] = !this.collapsed[uid];
},
isCollapsed(uid) {
return !!this.collapsed[uid];
},
collapseAll() {
this.blocks.forEach((b) => {
this.collapsed[b.uid] = true;
});
},
expandAll() {
this.blocks.forEach((b) => {
this.collapsed[b.uid] = false;
});
},
blockSummary(block) {
const c = block.content || {};
const pick = (...keys) => {
for (const k of keys) {
const v = c[k];
if (typeof v === 'string' && v.trim() !== '') {
return v.trim().slice(0, 88);
}
}
return '';
};
switch (block.type) {
case 'hero':
return pick('headline');
case 'benefits':
return pick('title');
case 'form':
return pick('title');
case 'countdown':
return pick('title');
case 'text':
return pick('title') || pick('body');
case 'cta_banner':
return pick('text') || pick('button_label');
case 'social_proof':
return pick('template');
case 'image':
return pick('alt');
default:
return '';
}
},
addBlock(type) {
if (type === 'form' && this.hasFormBlock()) {
return;
}
const nextOrder =
this.blocks.length === 0
? 0
: Math.max(...this.blocks.map((b) => Number(b.sort_order) || 0)) + 1;
const uid = newBlockUid();
this.blocks.push({
uid,
type,
sort_order: nextOrder,
is_visible: true,
content: pageBlockDefaultContent(type),
});
this.collapsed[uid] = false;
this.$nextTick(() => this.syncSortOrderFromDom());
},
removeBlock(uid) {
this.blocks = this.blocks.filter((b) => b.uid !== uid);
delete this.collapsed[uid];
this.confirmDeleteUid = null;
this.$nextTick(() => this.syncSortOrderFromDom());
},
requestDelete(uid) {
this.confirmDeleteUid = uid;
},
cancelDelete() {
this.confirmDeleteUid = null;
},
addBenefitItem(uid) {
const b = this.blocks.find((x) => x.uid === uid);
if (!b || b.type !== 'benefits') {
return;
}
if (!Array.isArray(b.content.items)) {
b.content.items = [];
}
b.content.items.push({ icon: 'check', text: '' });
},
removeBenefitItem(uid, idx) {
const b = this.blocks.find((x) => x.uid === uid);
if (!b || !Array.isArray(b.content.items)) {
return;
}
b.content.items.splice(idx, 1);
},
}));
Alpine.data('countdownBlock', (cfg) => ({
targetMs: cfg.targetMs,
expired: false,
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
showLabels: cfg.showLabels !== false,
labels: cfg.labels || {},
expiredAction: cfg.expiredAction || 'hide',
expiredMessage: cfg.expiredMessage || '',
timer: null,
init() {
this.tick();
this.timer = setInterval(() => this.tick(), 1000);
},
destroy() {
if (this.timer !== null) {
clearInterval(this.timer);
}
},
tick() {
const diff = this.targetMs - Date.now();
if (diff <= 0) {
if (this.timer !== null) {
clearInterval(this.timer);
this.timer = null;
}
this.expired = true;
if (this.expiredAction === 'hide' && this.$el) {
this.$el.classList.add('hidden');
}
if (this.expiredAction === 'reload') {
window.location.reload();
}
return;
}
const totalSeconds = Math.floor(diff / 1000);
this.days = Math.floor(totalSeconds / 86400);
this.hours = Math.floor((totalSeconds % 86400) / 3600);
this.minutes = Math.floor((totalSeconds % 3600) / 60);
this.seconds = totalSeconds % 60;
},
pad(n) {
return String(n).padStart(2, '0');
},
}));
Alpine.data('publicPreregisterPage', (config) => ({
phase: config.phase,
startAtMs: config.startAtMs,
@@ -57,6 +410,28 @@ document.addEventListener('alpine:init', () => {
formError: '',
fieldErrors: {},
thankYouMessage: '',
formButtonLabel: config.formButtonLabel || '',
formButtonColor: config.formButtonColor || '#F47B20',
formButtonTextColor: config.formButtonTextColor || '#FFFFFF',
pageShareUrl: config.pageShareUrl || '',
copyFeedback: '',
redirectUrl: config.redirectUrl || '',
redirectSecondsLeft: null,
redirectTimer: null,
strings: config.strings || {},
copyPageLink() {
const url = this.pageShareUrl;
if (!url) {
return;
}
navigator.clipboard.writeText(url).then(() => {
this.copyFeedback = config.strings?.linkCopied || '';
setTimeout(() => {
this.copyFeedback = '';
}, 2500);
});
},
init() {
if (this.phase === 'before') {
@@ -65,6 +440,34 @@ document.addEventListener('alpine:init', () => {
}
},
destroy() {
if (this.countdownTimer !== null) {
clearInterval(this.countdownTimer);
this.countdownTimer = null;
}
if (this.redirectTimer !== null) {
clearInterval(this.redirectTimer);
this.redirectTimer = null;
}
},
startRedirectCountdownIfNeeded() {
if (!this.redirectUrl || String(this.redirectUrl).trim() === '') {
return;
}
this.redirectSecondsLeft = 5;
this.redirectTimer = setInterval(() => {
this.redirectSecondsLeft--;
if (this.redirectSecondsLeft <= 0) {
if (this.redirectTimer !== null) {
clearInterval(this.redirectTimer);
this.redirectTimer = null;
}
window.location.assign(String(this.redirectUrl));
}
}, 1000);
},
tickCountdown() {
const start = this.startAtMs;
const now = Date.now();
@@ -142,6 +545,7 @@ document.addEventListener('alpine:init', () => {
if (res.ok && data.success) {
this.phase = 'thanks';
this.thankYouMessage = data.message ?? '';
this.startRedirectCountdownIfNeeded();
return;
}
if (typeof data.message === 'string' && data.message !== '') {