Implemented a block editor for changing the layout of the page
This commit is contained in:
@@ -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 !== '') {
|
||||
|
||||
Reference in New Issue
Block a user