#26 - Loading animation + success message fix
This commit was merged in pull request #31.
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
export const RsvTimetableReservationClient = {
|
||||
accept(reservation_id) {
|
||||
return this._post(reservation_id, 'accept');
|
||||
},
|
||||
|
||||
refuse(reservation_id) {
|
||||
return this._post(reservation_id, 'refuse');
|
||||
},
|
||||
|
||||
_post(reservation_id, action) {
|
||||
return fetch(`${ReservairServiceAPI.restUrl}/timetable-reservation/${reservation_id}/${action}`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-WP-Nonce': ReservairServiceAPI.nonce },
|
||||
}).then(r => {
|
||||
if (!r.ok) return r.json().then(e => { throw new Error(e.error || 'Request failed'); });
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -64,15 +64,18 @@ class RsvReservationSelector extends HTMLElement {
|
||||
const cal_el = document.createElement('div');
|
||||
cal_el.classList.add('rsv-calendar');
|
||||
|
||||
// Create rsv-timeline with timetable-id set before appending so
|
||||
// connectedCallback sees the correct attribute on first render.
|
||||
// Set up the calendar while cal_el is detached so the initial set_date()
|
||||
// change event fires on a disconnected node and cannot reach any listener.
|
||||
this._calendar = RsvCalendarPicker.create(cal_el, this.inputName);
|
||||
|
||||
// Set timetable-id and the initial date before connecting so connectedCallback
|
||||
// sees both attributes and renders the correct date on first attach.
|
||||
const time_el = document.createElement('rsv-timeline');
|
||||
time_el.setAttribute('timetable-id', this.timetableId);
|
||||
time_el.date = this._calendar.date;
|
||||
|
||||
this.append(cal_el, time_el);
|
||||
|
||||
this._calendar = RsvCalendarPicker.create(cal_el, this.inputName);
|
||||
|
||||
// Date change: clear selection, then push new date to the timeline element.
|
||||
cal_el.addEventListener('change', () => {
|
||||
this.querySelectorAll('.rsv-slots-slot-selected').forEach(s => s.classList.remove('rsv-slots-slot-selected'));
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { render_slot_items } from '../templating/RsvSlotItems.js';
|
||||
|
||||
class RsvReservationSummary extends HTMLElement {
|
||||
|
||||
// ---- Lifecycle ----------------------------------------------------------
|
||||
@@ -25,31 +27,6 @@ class RsvReservationSummary extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Public API ---------------------------------------------------------
|
||||
|
||||
// Detached, static copy of the current selection for the success message.
|
||||
// Mirrors the live layout minus the interactive "clear all" control.
|
||||
snapshot() {
|
||||
const s = ReservairStrings.summary;
|
||||
const list = this.querySelector('.rsv-summary-list');
|
||||
const count = this.querySelector('.rsv-summary-count');
|
||||
const price = this.querySelector('.rsv-summary-price');
|
||||
|
||||
const node = document.createElement('div');
|
||||
node.className = 'rsv-summary rsv-summary-snapshot';
|
||||
node.innerHTML = `
|
||||
<div class="rsv-summary-header">
|
||||
<span class="rsv-summary-title">${s.title}</span>
|
||||
</div>
|
||||
<ul class="rsv-summary-list">${list ? list.innerHTML : ''}</ul>
|
||||
<div class="rsv-summary-footer">
|
||||
<span class="rsv-summary-count">${count ? count.textContent : ''}</span>
|
||||
<div class="rsv-summary-price">${price ? price.textContent : ''}</div>
|
||||
</div>
|
||||
`;
|
||||
return node;
|
||||
}
|
||||
|
||||
// ---- Private ------------------------------------------------------------
|
||||
|
||||
_build() {
|
||||
@@ -74,7 +51,7 @@ class RsvReservationSummary extends HTMLElement {
|
||||
|
||||
_render() {
|
||||
const all_slots = [...this._all_slots.values()].flatMap(({ slots, price_per_block }) =>
|
||||
slots.map(s => ({ ...s, price_per_block }))
|
||||
slots.map(s => ({ ...s, price: price_per_block }))
|
||||
);
|
||||
|
||||
const n = all_slots.length;
|
||||
@@ -92,23 +69,9 @@ class RsvReservationSummary extends HTMLElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const time_opts = { hour: '2-digit', minute: '2-digit' };
|
||||
list.replaceChildren(...all_slots.map(slot => {
|
||||
const start = new Date(slot.start_utc);
|
||||
const end = new Date(slot.end_utc);
|
||||
const li = document.createElement('li');
|
||||
li.className = 'rsv-summary-item';
|
||||
li.innerHTML = `
|
||||
<div class="rsv-summary-item-info">
|
||||
<span class="rsv-summary-item-date">${start.toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long' })}</span>
|
||||
<span class="rsv-summary-item-time">${start.toLocaleTimeString(locale, time_opts)} – ${end.toLocaleTimeString(locale, time_opts)}</span>
|
||||
</div>
|
||||
${slot.price_per_block > 0 ? `<span class="rsv-summary-item-price">${slot.price_per_block} ${s.currency}</span>` : ''}
|
||||
`;
|
||||
return li;
|
||||
}));
|
||||
list.replaceChildren(...render_slot_items(all_slots, locale, s.currency));
|
||||
|
||||
const total = all_slots.reduce((sum, slot) => sum + slot.price_per_block, 0);
|
||||
const total = all_slots.reduce((sum, slot) => sum + slot.price, 0);
|
||||
count_el.textContent = this._fmt_count(n);
|
||||
price_el.textContent = total > 0 ? `${total} ${s.currency}` : '';
|
||||
}
|
||||
|
||||
@@ -53,6 +53,24 @@ class RsvTimeline extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
_skeleton() {
|
||||
const frag = document.createDocumentFragment();
|
||||
const label = document.createElement('div');
|
||||
label.className = 'rsv-slots-label rsv-slots-skeleton rsv-slots-skeleton-label';
|
||||
frag.appendChild(label);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'rsv-slots-slot rsv-slots-skeleton';
|
||||
const time = document.createElement('span');
|
||||
time.className = 'rsv-slots-slot-time rsv-slots-skeleton rsv-slots-skeleton-text';
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'rsv-slots-slot-badge rsv-slots-skeleton rsv-slots-skeleton-badge';
|
||||
row.append(time, badge);
|
||||
frag.appendChild(row);
|
||||
}
|
||||
return frag;
|
||||
}
|
||||
|
||||
async _render() {
|
||||
// Version guard: discard renders that were superseded by a newer call.
|
||||
const v = ++this._version;
|
||||
@@ -63,6 +81,8 @@ class RsvTimeline extends HTMLElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this.replaceChildren(this._skeleton());
|
||||
|
||||
try {
|
||||
const occupancy = await RsvTimetableService.get_availability_for_date(this.timetableId, this.date);
|
||||
if (v !== this._version) return;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { defaultEngine } from '../templating/RsvDefaultEngine.js';
|
||||
|
||||
export const RsvFormSender = {
|
||||
get_form_url(form_id) {
|
||||
return ReservairServiceAPI.restUrl + '/form/' + form_id;
|
||||
@@ -36,7 +38,7 @@ export const RsvFormSender = {
|
||||
form.prepend(summary);
|
||||
},
|
||||
|
||||
show_success(form, _data) {
|
||||
show_success(form, data) {
|
||||
const s = ReservairStrings.form;
|
||||
const wrapper = form.parentElement;
|
||||
const existing = Array.from(wrapper.children);
|
||||
@@ -59,7 +61,7 @@ export const RsvFormSender = {
|
||||
icon.className = 'rsv-success-icon';
|
||||
icon.appendChild(svg);
|
||||
|
||||
const body = this.build_success_body(form, s);
|
||||
const body = this.build_success_body(form, s, data);
|
||||
|
||||
const state = document.createElement('div');
|
||||
state.className = 'rsv-success-state';
|
||||
@@ -86,35 +88,24 @@ export const RsvFormSender = {
|
||||
state.querySelectorAll('[data-rsv-reset]').forEach(btn => btn.addEventListener('click', reset));
|
||||
},
|
||||
|
||||
// Body of the success card. Uses the admin-configured template when the form
|
||||
// ships one, filling the .rsv-success-summary placeholder (expanded server-side
|
||||
// from <reservation-summary>) with a snapshot of the selected slots; otherwise
|
||||
// falls back to the default text.
|
||||
build_success_body(form, strings) {
|
||||
const tpl = form.parentElement?.querySelector('template.rsv-form-success');
|
||||
|
||||
if (!tpl) {
|
||||
const subtitle = document.createElement('p');
|
||||
subtitle.className = 'rsv-success-msg';
|
||||
subtitle.textContent = strings.success_subtitle;
|
||||
return subtitle;
|
||||
build_success_body(form, strings, data = {}) {
|
||||
if (data.template) {
|
||||
const body = document.createElement('div');
|
||||
body.className = 'rsv-success-msg';
|
||||
body.innerHTML = defaultEngine.render(data.template, data.data ?? {});
|
||||
return body;
|
||||
}
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'rsv-success-msg';
|
||||
body.appendChild(tpl.content.cloneNode(true));
|
||||
const subtitle = document.createElement('p');
|
||||
subtitle.className = 'rsv-success-msg';
|
||||
subtitle.textContent = strings.success_subtitle;
|
||||
return subtitle;
|
||||
},
|
||||
|
||||
const placeholder = body.querySelector('.rsv-success-summary');
|
||||
if (placeholder) {
|
||||
const summary = form.querySelector('rsv-reservation-summary');
|
||||
if (summary && typeof summary.snapshot === 'function') {
|
||||
placeholder.replaceWith(summary.snapshot());
|
||||
} else {
|
||||
placeholder.remove();
|
||||
}
|
||||
}
|
||||
|
||||
return body;
|
||||
// Renders a message template (interpolation + custom elements) to HTML the
|
||||
// same way show_success does — used by the admin editor's live preview.
|
||||
render_template(template, data = {}) {
|
||||
return defaultEngine.render(template ?? '', data ?? {});
|
||||
},
|
||||
|
||||
set_loading(form, is_loading) {
|
||||
|
||||
@@ -14,7 +14,8 @@ export const RsvTimetableService = {
|
||||
date: date.toISOString().slice(0, 10),
|
||||
});
|
||||
|
||||
return fetch(get_rest_url(`timetable/${timetable_id}/availability?${params}`), { method: 'GET' })
|
||||
return new Promise(resolve => setTimeout(resolve, 1000))
|
||||
.then(() => fetch(get_rest_url(`timetable/${timetable_id}/availability?${params}`), { method: 'GET' }))
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`fetch availability failed: ${r.status}`);
|
||||
return r.json();
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { RsvTemplateRegistry } from './RsvTemplateRegistry.js';
|
||||
import { RsvTemplateEngine } from './RsvTemplateEngine.js';
|
||||
import { reservation_summary_renderer } from './elements/RsvReservationSummaryElement.js';
|
||||
import { reset_form_button_renderer } from './elements/RsvResetFormButtonElement.js';
|
||||
|
||||
const registry = new RsvTemplateRegistry();
|
||||
registry.register('reservation-summary', reservation_summary_renderer);
|
||||
registry.register('reset-form-button', reset_form_button_renderer);
|
||||
|
||||
export const defaultEngine = new RsvTemplateEngine(registry);
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Renders an array of slot objects into <li> DOM nodes for display in summary lists.
|
||||
* Each slot must have start_utc, end_utc, price fields.
|
||||
*/
|
||||
export function render_slot_items(slots, locale, currency) {
|
||||
const time_opts = { hour: '2-digit', minute: '2-digit' };
|
||||
return slots.map(slot => {
|
||||
const start = new Date(slot.start_utc);
|
||||
const end = new Date(slot.end_utc);
|
||||
const li = document.createElement('li');
|
||||
li.className = 'rsv-summary-item';
|
||||
li.innerHTML = `
|
||||
<div class="rsv-summary-item-info">
|
||||
<span class="rsv-summary-item-date">${start.toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long' })}</span>
|
||||
<span class="rsv-summary-item-time">${start.toLocaleTimeString(locale, time_opts)} – ${end.toLocaleTimeString(locale, time_opts)}</span>
|
||||
</div>
|
||||
${slot.price > 0 ? `<span class="rsv-summary-item-price">${slot.price} ${currency}</span>` : ''}
|
||||
`;
|
||||
return li;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { RsvTemplateRegistry } from './RsvTemplateRegistry.js';
|
||||
|
||||
function esc_html(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export class RsvTemplateEngine {
|
||||
constructor(registry = null) {
|
||||
this.registry = registry ?? new RsvTemplateRegistry();
|
||||
}
|
||||
|
||||
render(source, data = {}) {
|
||||
const interpolated = this.interpolate(source, data);
|
||||
return this.expand_elements(interpolated, data);
|
||||
}
|
||||
|
||||
interpolate(source, data) {
|
||||
return source.replace(/{{\s*([^}]+?)\s*}}/g, (match, path) => {
|
||||
const value = this.resolve(path.trim(), data);
|
||||
if (value === null || value === undefined || typeof value === 'object') {
|
||||
return ''; // null and non-scalar (arrays, objects) render empty
|
||||
}
|
||||
return esc_html(String(value));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} data Submitted & computed data for the form
|
||||
* @param {*} element The element to build the symbol table for
|
||||
* @returns {Object} The symbol table for the element
|
||||
*/
|
||||
build_symbol_table(data, element) {
|
||||
const attrs = {};
|
||||
for (const attr of element.attributes) {
|
||||
attrs[attr.name] = attr.value;
|
||||
}
|
||||
|
||||
return { ...data, ...attrs };
|
||||
}
|
||||
|
||||
wrap(element) {
|
||||
const elWrap = document.createElement('div');
|
||||
elWrap.innerHTML = element;
|
||||
return elWrap;
|
||||
}
|
||||
|
||||
expand_elements(html, data) {
|
||||
html = html.replace(/<([\w-]+)([^>]*?)\/>/g, '<$1$2></$1>');
|
||||
let doc = document.createElement('div');
|
||||
doc.innerHTML = html;
|
||||
|
||||
[...doc.querySelectorAll('*')]
|
||||
.filter(el => this.registry.has(el.tagName.toLowerCase()))
|
||||
.forEach(el => {
|
||||
const renderer = this.registry.get(el.tagName.toLowerCase());
|
||||
el.replaceWith(this.wrap(renderer(this.build_symbol_table(data, el))));
|
||||
});
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.appendChild(doc);
|
||||
|
||||
return wrapper.innerHTML;
|
||||
}
|
||||
|
||||
resolve(path, data) {
|
||||
const tokens = this.tokens(path);
|
||||
let current = data;
|
||||
|
||||
for (const token of tokens) {
|
||||
if (typeof current !== 'object' || current === null || !(token in current)) {
|
||||
return null;
|
||||
}
|
||||
current = current[token];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
tokens(path) {
|
||||
const raw = path.split(/[\.\[\]]+/).filter(Boolean);
|
||||
const result = [];
|
||||
for (const token of raw) {
|
||||
if (token === '$') continue;
|
||||
result.push(token.replace(/^['"]|['"]$/g, ''));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Registry mapping custom element tag names to their renderer functions.
|
||||
*/
|
||||
export class RsvTemplateRegistry {
|
||||
constructor() {
|
||||
this.elements = new Map();
|
||||
}
|
||||
|
||||
register(tag, renderer) {
|
||||
this.elements.set(tag, renderer);
|
||||
}
|
||||
|
||||
get(tag) {
|
||||
return this.elements.get(tag) ?? null;
|
||||
}
|
||||
|
||||
all() {
|
||||
return this.elements;
|
||||
}
|
||||
|
||||
has(tag) {
|
||||
return this.elements.has(tag);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { render_slot_items } from '../RsvSlotItems.js';
|
||||
|
||||
export function reservation_summary_renderer(symbols) {
|
||||
const slots = symbols.slots ?? [];
|
||||
const pricing = symbols.pricing ?? {};
|
||||
|
||||
if (!Array.isArray(slots) || slots.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const s = ReservairStrings.summary;
|
||||
const locale = navigator.language;
|
||||
const currency = pricing.currency ?? s.currency;
|
||||
|
||||
const items = render_slot_items(slots, locale, currency);
|
||||
const itemsHtml = items.map(li => li.outerHTML).join('');
|
||||
|
||||
const footerRows = [];
|
||||
const subtotal = pricing.subtotal ?? 0;
|
||||
footerRows.push(`
|
||||
<div class="rsv-summary-footer-row rsv-summary-subtotal">
|
||||
<span class="rsv-summary-footer-label">${s.subtotal}</span>
|
||||
<span class="rsv-summary-footer-value">${subtotal} ${currency}</span>
|
||||
</div>
|
||||
`);
|
||||
|
||||
if (pricing.discount && pricing.discount.percent > 0) {
|
||||
const amount = pricing.discount.amount ?? 0;
|
||||
const reason = pricing.discount.reason ?? '';
|
||||
footerRows.push(`
|
||||
<div class="rsv-summary-footer-row rsv-summary-discount">
|
||||
<span class="rsv-summary-footer-label">${s.discount}: ${reason}</span>
|
||||
<span class="rsv-summary-footer-value">-${amount} ${currency}</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
const total = pricing.total ?? 0;
|
||||
footerRows.push(`
|
||||
<div class="rsv-summary-footer-row rsv-summary-total">
|
||||
<span class="rsv-summary-footer-label">${s.total}</span>
|
||||
<span class="rsv-summary-footer-value">${total} ${currency}</span>
|
||||
</div>
|
||||
`);
|
||||
|
||||
return `
|
||||
<div class="rsv-summary rsv-summary-snapshot">
|
||||
<div class="rsv-summary-header">
|
||||
<span class="rsv-summary-title">${s.title}</span>
|
||||
</div>
|
||||
<ul class="rsv-summary-list">${itemsHtml}</ul>
|
||||
<div class="rsv-summary-footer rsv-summary-footer--pricing">
|
||||
${footerRows.join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
function esc_html(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function reset_form_button_renderer(symbols) {
|
||||
const label = symbols.label ?? 'Odeslat znova';
|
||||
return `<button type="button" class="rsv-form-btn" data-rsv-reset>${esc_html(label)}</button>`;
|
||||
}
|
||||
Reference in New Issue
Block a user