From 97ee8fc991f067de218850cfe04fbd19d99985d9 Mon Sep 17 00:00:00 2001 From: Martin Slachta Date: Mon, 22 Jun 2026 11:20:28 +0200 Subject: [PATCH] #26 - Loading animation + success message fix --- assets/css/components/RsvCalendarStyles.css | 12 ++- .../css/components/RsvFormSummaryStyles.css | 38 ++++++++ assets/css/components/RsvTimeSlotsStyles.css | 35 +++++++ .../RsvTimetableReservationClient.js | 19 ++++ assets/js/elements/RsvReservationSelector.js | 11 ++- assets/js/elements/RsvReservationSummary.js | 47 +--------- assets/js/elements/RsvTimeline.js | 20 ++++ assets/js/forms/RsvFormSender.js | 47 ++++------ assets/js/services/RsvTimetableService.js | 3 +- assets/js/templating/RsvDefaultEngine.js | 10 ++ assets/js/templating/RsvSlotItems.js | 21 +++++ assets/js/templating/RsvTemplateEngine.js | 94 +++++++++++++++++++ assets/js/templating/RsvTemplateRegistry.js | 24 +++++ .../elements/RsvReservationSummaryElement.js | 57 +++++++++++ .../elements/RsvResetFormButtonElement.js | 13 +++ .../RsvFormDefinitionController.php | 12 +++ .../Controllers/RsvReservationController.php | 30 ------ .../RsvTimetableReservationController.php | 34 +++++++ .../Repository/RsvFormSubmitRepository.php | 21 +++++ .../RsvTimetableReservationRepository.php | 3 +- includes/RsvAssetsDefinition.php | 3 + includes/RsvInstaller.php | 1 + .../RsvFormReservationElementHandler.php | 7 ++ .../{ => Pricing}/RsvFormCalculatedValues.php | 17 +++- .../Services/Forms/RsvFormHtmlRenderer.php | 27 ------ includes/Services/Forms/RsvFormSubmission.php | 19 +++- .../Membership/RsvMembershipService.php | 38 +++++--- includes/Services/RsvReservationService.php | 1 + .../RsvTimetableReservationService.php | 12 +-- includes/Views/RsvFormsPage.php | 74 ++++++++++++++- includes/Views/RsvTimetablePage.php | 20 ++-- src/admin.js | 2 + 32 files changed, 597 insertions(+), 175 deletions(-) create mode 100644 assets/js/datasource/RsvTimetableReservationClient.js create mode 100644 assets/js/templating/RsvDefaultEngine.js create mode 100644 assets/js/templating/RsvSlotItems.js create mode 100644 assets/js/templating/RsvTemplateEngine.js create mode 100644 assets/js/templating/RsvTemplateRegistry.js create mode 100644 assets/js/templating/elements/RsvReservationSummaryElement.js create mode 100644 assets/js/templating/elements/RsvResetFormButtonElement.js rename includes/Services/Forms/{ => Pricing}/RsvFormCalculatedValues.php (64%) diff --git a/assets/css/components/RsvCalendarStyles.css b/assets/css/components/RsvCalendarStyles.css index b1599c3..3fec6c9 100644 --- a/assets/css/components/RsvCalendarStyles.css +++ b/assets/css/components/RsvCalendarStyles.css @@ -128,16 +128,20 @@ flex-grow: 1; } -.rsv-calendar td { +.rsv-calendar td.rsv-cal-cell { -webkit-user-select:none;user-select:none; padding: 0; z-index: -1; } -.rsv-calendar td label { +.rsv-calendar td.rsv-cal-cell label { + margin-left:auto; + margin-right:auto; + width: 40px; + height: 40px; + line-height: 40px; display: block; - padding: 0.25em; - border-radius: var(--s-4); + border-radius: 50%; transition: background-color 0.3s ease; } diff --git a/assets/css/components/RsvFormSummaryStyles.css b/assets/css/components/RsvFormSummaryStyles.css index 16650b1..626ad5e 100644 --- a/assets/css/components/RsvFormSummaryStyles.css +++ b/assets/css/components/RsvFormSummaryStyles.css @@ -131,3 +131,41 @@ rsv-reservation-summary { color: #888; margin-left: 2px; } + +/* ----- Success summary footer (pricing breakdown) ----- */ +.rsv-summary-footer--pricing { + display: flex; + flex-direction: column; + gap: 4px; + padding-top: 10px; + border-top: 1px solid #e8f0fe; +} + +.rsv-summary-footer-row { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; +} + +.rsv-summary-footer-label { + font-size: 12px; + color: #888; +} + +.rsv-summary-footer-value { + font-size: 14px; + font-weight: 600; + color: #0f0f0f; +} + +.rsv-summary-total .rsv-summary-footer-label, +.rsv-summary-total .rsv-summary-footer-value { + font-weight: 700; + color: #0f0f0f; + font-size: 16px; +} + +.rsv-summary-discount .rsv-summary-footer-value { + color: #16a34a; +} diff --git a/assets/css/components/RsvTimeSlotsStyles.css b/assets/css/components/RsvTimeSlotsStyles.css index 04b1242..ad35cb5 100644 --- a/assets/css/components/RsvTimeSlotsStyles.css +++ b/assets/css/components/RsvTimeSlotsStyles.css @@ -156,4 +156,39 @@ label.rsv-slots-slot-time>input:checked + .content>.capacity { color: #fff; } +/* Skeleton loading */ +@keyframes rsv-shimmer { + 0% { background-position: -400px 0; } + 100% { background-position: 400px 0; } +} + +.rsv-slots-skeleton { + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 800px 100%; + animation: rsv-shimmer 1.4s infinite linear; + border-color: transparent !important; + color: transparent !important; + pointer-events: none; +} + +.rsv-slots-skeleton-label { + width: 120px; + height: 14px; + border-radius: 4px; +} + +.rsv-slots-skeleton-text { + width: 90px; + height: 13px; + border-radius: 4px; + display: inline-block; +} + +.rsv-slots-skeleton-badge { + width: 46px; + height: 18px; + border-radius: 6px; + display: inline-block; +} + /* TIMELINE END */ diff --git a/assets/js/datasource/RsvTimetableReservationClient.js b/assets/js/datasource/RsvTimetableReservationClient.js new file mode 100644 index 0000000..865c552 --- /dev/null +++ b/assets/js/datasource/RsvTimetableReservationClient.js @@ -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'); }); + }); + }, +}; diff --git a/assets/js/elements/RsvReservationSelector.js b/assets/js/elements/RsvReservationSelector.js index ae54556..aad6ea4 100644 --- a/assets/js/elements/RsvReservationSelector.js +++ b/assets/js/elements/RsvReservationSelector.js @@ -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')); diff --git a/assets/js/elements/RsvReservationSummary.js b/assets/js/elements/RsvReservationSummary.js index ada557e..bccfb1f 100644 --- a/assets/js/elements/RsvReservationSummary.js +++ b/assets/js/elements/RsvReservationSummary.js @@ -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 = ` -
- ${s.title} -
- - - `; - 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 = ` -
- ${start.toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long' })} - ${start.toLocaleTimeString(locale, time_opts)} – ${end.toLocaleTimeString(locale, time_opts)} -
- ${slot.price_per_block > 0 ? `${slot.price_per_block} ${s.currency}` : ''} - `; - 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}` : ''; } diff --git a/assets/js/elements/RsvTimeline.js b/assets/js/elements/RsvTimeline.js index 0a47774..f1da68d 100644 --- a/assets/js/elements/RsvTimeline.js +++ b/assets/js/elements/RsvTimeline.js @@ -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; diff --git a/assets/js/forms/RsvFormSender.js b/assets/js/forms/RsvFormSender.js index ef43164..9428764 100644 --- a/assets/js/forms/RsvFormSender.js +++ b/assets/js/forms/RsvFormSender.js @@ -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 ) 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) { diff --git a/assets/js/services/RsvTimetableService.js b/assets/js/services/RsvTimetableService.js index c0348df..8f14350 100644 --- a/assets/js/services/RsvTimetableService.js +++ b/assets/js/services/RsvTimetableService.js @@ -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(); diff --git a/assets/js/templating/RsvDefaultEngine.js b/assets/js/templating/RsvDefaultEngine.js new file mode 100644 index 0000000..153b43c --- /dev/null +++ b/assets/js/templating/RsvDefaultEngine.js @@ -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); diff --git a/assets/js/templating/RsvSlotItems.js b/assets/js/templating/RsvSlotItems.js new file mode 100644 index 0000000..a1fe7be --- /dev/null +++ b/assets/js/templating/RsvSlotItems.js @@ -0,0 +1,21 @@ +/** + * Renders an array of slot objects into
  • 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 = ` +
    + ${start.toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long' })} + ${start.toLocaleTimeString(locale, time_opts)} – ${end.toLocaleTimeString(locale, time_opts)} +
    + ${slot.price > 0 ? `${slot.price} ${currency}` : ''} + `; + return li; + }); +} diff --git a/assets/js/templating/RsvTemplateEngine.js b/assets/js/templating/RsvTemplateEngine.js new file mode 100644 index 0000000..d53c9b5 --- /dev/null +++ b/assets/js/templating/RsvTemplateEngine.js @@ -0,0 +1,94 @@ +import { RsvTemplateRegistry } from './RsvTemplateRegistry.js'; + +function esc_html(str) { + return String(str) + .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>'); + 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; + } +} diff --git a/assets/js/templating/RsvTemplateRegistry.js b/assets/js/templating/RsvTemplateRegistry.js new file mode 100644 index 0000000..83fe83c --- /dev/null +++ b/assets/js/templating/RsvTemplateRegistry.js @@ -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); + } +} diff --git a/assets/js/templating/elements/RsvReservationSummaryElement.js b/assets/js/templating/elements/RsvReservationSummaryElement.js new file mode 100644 index 0000000..c399f7f --- /dev/null +++ b/assets/js/templating/elements/RsvReservationSummaryElement.js @@ -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(` + + `); + + if (pricing.discount && pricing.discount.percent > 0) { + const amount = pricing.discount.amount ?? 0; + const reason = pricing.discount.reason ?? ''; + footerRows.push(` + + `); + } + + const total = pricing.total ?? 0; + footerRows.push(` + + `); + + return ` +
    +
    + ${s.title} +
    +
      ${itemsHtml}
    + +
    + `; +} diff --git a/assets/js/templating/elements/RsvResetFormButtonElement.js b/assets/js/templating/elements/RsvResetFormButtonElement.js new file mode 100644 index 0000000..e1da65f --- /dev/null +++ b/assets/js/templating/elements/RsvResetFormButtonElement.js @@ -0,0 +1,13 @@ +function esc_html(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function reset_form_button_renderer(symbols) { + const label = symbols.label ?? 'Odeslat znova'; + return ``; +} diff --git a/includes/Controllers/RsvFormDefinitionController.php b/includes/Controllers/RsvFormDefinitionController.php index e2cdd88..ba5246a 100644 --- a/includes/Controllers/RsvFormDefinitionController.php +++ b/includes/Controllers/RsvFormDefinitionController.php @@ -48,6 +48,12 @@ class RsvFormDefinitionController { 'permission_callback' => [RsvRestPolicy::class, 'admin'], ]); + register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P\d+)/submission/latest', [ + 'methods' => 'GET', + 'callback' => [$this, 'latest_submit'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ]); + register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P\d+)', [ [ 'methods' => 'GET', @@ -132,6 +138,12 @@ class RsvFormDefinitionController { return new WP_REST_Response(['html' => $html], 200); } + /** Most recent submission's rendered context, for the success-message preview. */ + function latest_submit(WP_REST_Request $request): WP_REST_Response { + $data = (new RsvFormSubmitRepository())->latest_computed((int) $request->get_param('id')); + return new WP_REST_Response(['data' => $data], 200); + } + function update(WP_REST_Request $request): WP_REST_Response { $id = (int) $request->get_param('id'); $repo = new RsvFormDefinitionRepository(); diff --git a/includes/Controllers/RsvReservationController.php b/includes/Controllers/RsvReservationController.php index 640cdbc..555d67b 100644 --- a/includes/Controllers/RsvReservationController.php +++ b/includes/Controllers/RsvReservationController.php @@ -30,18 +30,6 @@ class RsvReservationController { 'callback' => [$this, 'create'], 'permission_callback' => [RsvRestPolicy::class, 'admin'] ]); - - register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P\d+)/accept', [ - 'methods' => 'POST', - 'callback' => [$this, 'accept_by_id'], - 'permission_callback' => [RsvRestPolicy::class, 'admin'], - ]); - - register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P\d+)/refuse', [ - 'methods' => 'POST', - 'callback' => [$this, 'refuse_by_id'], - 'permission_callback' => [RsvRestPolicy::class, 'admin'], - ]); } function get_all(WP_REST_Request $request) { @@ -66,22 +54,4 @@ class RsvReservationController { $body = $request->get_json_params(); return $service->create(RsvReservation::from_array($body)); } - - function accept_by_id(WP_REST_Request $request): WP_REST_Response { - try { - (new RsvTimetableReservationService())->accept_by_reservation_id((int) $request->get_param('id')); - return new WP_REST_Response(['status' => 'accepted'], 200); - } catch (InvalidArgumentException $e) { - return new WP_REST_Response(['error' => $e->getMessage()], 404); - } - } - - function refuse_by_id(WP_REST_Request $request): WP_REST_Response { - try { - (new RsvTimetableReservationService())->refuse_by_reservation_id((int) $request->get_param('id')); - return new WP_REST_Response(['status' => 'refused'], 200); - } catch (InvalidArgumentException $e) { - return new WP_REST_Response(['error' => $e->getMessage()], 404); - } - } } diff --git a/includes/Controllers/RsvTimetableReservationController.php b/includes/Controllers/RsvTimetableReservationController.php index abec8b1..1245c4d 100644 --- a/includes/Controllers/RsvTimetableReservationController.php +++ b/includes/Controllers/RsvTimetableReservationController.php @@ -28,6 +28,18 @@ class RsvTimetableReservationController { // refuse() validates against the database before changing state. 'permission_callback' => [RsvRestPolicy::class, 'open'], ]); + + register_rest_route($this->namespace, '/timetable-reservation/(?P\d+)/accept', [ + 'methods' => 'POST', + 'callback' => [$this, 'accept_by_id'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ]); + + register_rest_route($this->namespace, '/timetable-reservation/(?P\d+)/refuse', [ + 'methods' => 'POST', + 'callback' => [$this, 'refuse_by_id'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ]); } public function by_timetable(WP_REST_Request $request): WP_REST_Response { @@ -59,4 +71,26 @@ class RsvTimetableReservationController { return new WP_REST_Response(['error' => 'Invalid or expired confirmation code.'], 404); } } + + function accept_by_id(WP_REST_Request $request) { + try { + $service = new RsvTimetableReservationService(); + $service->accept_by_id(intval($request->get_param('id'))); + + return new WP_REST_Response(['status' => 'accepted'], 200); + } catch (InvalidArgumentException $e) { + return new WP_REST_Response(['error' => 'Invalid or expired confirmation code.'], 404); + } + } + + function refuse_by_id(WP_REST_Request $request) { + try { + $service = new RsvTimetableReservationService(); + $service->refuse_by_id(intval($request->get_param('id'))); + + return new WP_REST_Response(['status' => 'refused'], 200); + } catch (InvalidArgumentException $e) { + return new WP_REST_Response(['error' => 'Invalid or expired confirmation code.'], 404); + } + } } diff --git a/includes/Repository/RsvFormSubmitRepository.php b/includes/Repository/RsvFormSubmitRepository.php index 97320cb..41783d3 100644 --- a/includes/Repository/RsvFormSubmitRepository.php +++ b/includes/Repository/RsvFormSubmitRepository.php @@ -16,6 +16,27 @@ class RsvFormSubmitRepository { ]); } + /** Store the derived template context (field values, slots, pricing) for a submission. */ + public function set_computed(int $id, array $computed): void { + Db::update($this->table, ['computed' => json_encode($computed)], ['form_submit_id' => $id]); + } + + /** + * The derived template context of the most recent submission for a form, + * or null when the form has no submission carrying computed data. + * + * @return array|null + */ + public function latest_computed(int $form_id): ?array { + $value = Db::get_var( + "SELECT computed FROM {$this->table} + WHERE form_id = %d AND computed IS NOT NULL + ORDER BY form_submit_id DESC LIMIT 1", + [$form_id] + ); + return $value === null ? null : json_decode($value, true); + } + public function delete(int $id): void { Db::delete($this->table, ['form_submit_id' => $id]); } diff --git a/includes/Repository/RsvTimetableReservationRepository.php b/includes/Repository/RsvTimetableReservationRepository.php index e20eb7b..0754812 100644 --- a/includes/Repository/RsvTimetableReservationRepository.php +++ b/includes/Repository/RsvTimetableReservationRepository.php @@ -88,8 +88,7 @@ class RsvTimetableReservationRepository { public function get_confirmation_code(int $reservation_id): ?string { return Db::get_var( "SELECT c.code FROM {$this->confirmation_table} c - JOIN {$this->table} tr ON tr.id = c.timetable_reservation_id - WHERE tr.reservation_id = %d + WHERE c.timetable_reservation_id = %d LIMIT 1", [$reservation_id] ); diff --git a/includes/RsvAssetsDefinition.php b/includes/RsvAssetsDefinition.php index 9b960ce..f76b0ad 100644 --- a/includes/RsvAssetsDefinition.php +++ b/includes/RsvAssetsDefinition.php @@ -32,6 +32,9 @@ function rsv_localize_api(string $handle): void { 'count_few' => '%d termíny', 'count_many' => '%d termínů', 'currency' => 'Kč', + 'subtotal' => 'Mezisoučet', + 'discount' => 'Sleva', + 'total' => 'Celkem', ], 'form' => [ 'success_title' => 'Rezervace potvrzena!', diff --git a/includes/RsvInstaller.php b/includes/RsvInstaller.php index 688d8a9..b53ca92 100644 --- a/includes/RsvInstaller.php +++ b/includes/RsvInstaller.php @@ -21,6 +21,7 @@ class RsvInstaller { form_id bigint unsigned NOT NULL, submitted_on_utc TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `values` JSON NOT NULL, + computed JSON NULL, PRIMARY KEY (form_submit_id), CONSTRAINT fk_form_submit_definition FOREIGN KEY (form_id) REFERENCES {$wpdb->prefix}rsv_form_definition (form_id) diff --git a/includes/Services/Forms/Handlers/RsvFormReservationElementHandler.php b/includes/Services/Forms/Handlers/RsvFormReservationElementHandler.php index af0f408..aff466d 100644 --- a/includes/Services/Forms/Handlers/RsvFormReservationElementHandler.php +++ b/includes/Services/Forms/Handlers/RsvFormReservationElementHandler.php @@ -74,6 +74,13 @@ class RsvFormReservationElementHandler implements RsvFormElementHandler { $price_per_block = (float) $def->getAttr('price_per_block', 0); $result->setValue($name . '_price', $price_per_block * count($payload['timetable_reservations'])); + $slots = array_map(fn($t) => [ + 'start_utc' => (new DateTime($t))->format(DateTime::ATOM), + 'end_utc' => $this->end_from_start(new DateTime($t), $timetable->block_size)->format(DateTime::ATOM), + 'price' => $price_per_block, + ], $payload['timetable_reservations']); + $result->setValue('slots', array_merge($result->getValue('slots') ?? [], $slots)); + return true; } diff --git a/includes/Services/Forms/RsvFormCalculatedValues.php b/includes/Services/Forms/Pricing/RsvFormCalculatedValues.php similarity index 64% rename from includes/Services/Forms/RsvFormCalculatedValues.php rename to includes/Services/Forms/Pricing/RsvFormCalculatedValues.php index 4d7ab4a..b47a996 100644 --- a/includes/Services/Forms/RsvFormCalculatedValues.php +++ b/includes/Services/Forms/Pricing/RsvFormCalculatedValues.php @@ -16,13 +16,26 @@ final class RsvFormCalculatedValues { $price_before_discount += (float) $element_calculator($element, $data->getValue($element->getName())); } - $discount_pct = (new RsvMembershipService())->discount_for($definition, $data); + $discount_detail = (new RsvMembershipService())->discount_detail_for($definition, $data); + $discount_pct = $discount_detail['percent']; $final_price = $calculator->calculate($definition, $data); + $subtotal = $price_before_discount; + $discount_amount = $subtotal - $final_price; return [ 'price' => $final_price, 'price_before_discount' => $price_before_discount, 'discount_percent' => $discount_pct, + 'pricing' => [ + 'currency' => 'CZK', + 'subtotal' => $subtotal, + 'discount' => $discount_pct > 0.0 ? [ + 'percent' => $discount_pct, + 'amount' => round($discount_amount, 2), + 'reason' => $discount_detail['reason'], + ] : null, + 'total' => $final_price, + ], ]; } @@ -31,6 +44,6 @@ final class RsvFormCalculatedValues { * @return list */ public static function names(): array { - return ['price', 'price_before_discount', 'discount_percent']; + return ['price', 'price_before_discount', 'discount_percent', 'pricing']; } } diff --git a/includes/Services/Forms/RsvFormHtmlRenderer.php b/includes/Services/Forms/RsvFormHtmlRenderer.php index 1dbd71f..9f20aa2 100644 --- a/includes/Services/Forms/RsvFormHtmlRenderer.php +++ b/includes/Services/Forms/RsvFormHtmlRenderer.php @@ -1,7 +1,5 @@ hasElements()) { @@ -21,37 +19,12 @@ class RsvFormHtmlRenderer { - draw_success_template($form); ?> that the - * client clones once the form is submitted. A element - * expands to a placeholder div that RsvFormSender fills with the visitor's - * selected slots. - */ - private function draw_success_template(RsvFormDefinition $form): void { - $message = trim($form->getSuccessMessage()); - if ($message === '') { - return; - } - - global $rsv_template_registry; - $engine = new RsvTemplateEngine(registry: $rsv_template_registry); - - // Sanitize admin HTML before rendering, allowing the registered template - // custom elements through so the engine can expand them. - $allowed = $rsv_template_registry->kses_allowed(wp_kses_allowed_html('post')); - $html = $engine->render(wp_kses($message, $allowed)); - ?> - - false, 'errors' => $result->getErrors()]; } - return ['success' => true, 'submit_id' => $submit_id, 'values' => $result->getValues()]; + global $rsv_template_registry; + $message = trim($definition->getSuccessMessage()); + if ($message !== '') { + $allowed = $rsv_template_registry->kses_allowed(wp_kses_allowed_html('post')); + $template = wp_kses($message, $allowed); + } else { + $template = ''; + } + + $data = array_merge($result->getValues(), (new RsvFormCalculatedValues())->for($definition, $form_data)); + + try { + $submit_repo->set_computed($submit_id, $data); + } catch (\Throwable $e) { + Logger::error($e); + } + + return ['success' => true, 'submit_id' => $submit_id, 'template' => $template, 'data' => $data]; } /** Remove a submission whose run failed. */ diff --git a/includes/Services/Membership/RsvMembershipService.php b/includes/Services/Membership/RsvMembershipService.php index ec585d3..dcf4cfe 100644 --- a/includes/Services/Membership/RsvMembershipService.php +++ b/includes/Services/Membership/RsvMembershipService.php @@ -2,18 +2,9 @@ class RsvMembershipService { - - - /** - * Total membership discount for a submission. - * - * Each binding names a form field whose submitted value must match a key - * in the bound program. Matching bindings' discounts are combined per the - * definition's combine mode: the best single discount, or all summed and - * capped at 100%. - */ - public function discount_for(RsvFormDefinition $def, RsvFormData $data): float { + public function discount_detail_for(RsvFormDefinition $def, RsvFormData $data): array { $repo = new RsvMembershipProgramRepository(); + $matched_programs = []; $matched_discounts = []; foreach ($def->getMembershipBindings() as $binding) { @@ -33,18 +24,37 @@ class RsvMembershipService { } if ($repo->key_exists($program_id, $value)) { + $program = $repo->get($program_id); + if ($program) { + $matched_programs[] = $program['name']; + } $matched_discounts[] = $discount; } } if (empty($matched_discounts)) { - return 0.0; + return ['percent' => 0.0, 'reason' => '']; } if ($def->getMembershipCombine() === 'sum') { - return min(100.0, array_sum($matched_discounts)); + $reason = implode(', ', $matched_programs); + return ['percent' => min(100.0, array_sum($matched_discounts)), 'reason' => $reason]; } - return max($matched_discounts); + $max_idx = array_search(max($matched_discounts), $matched_discounts, true); + $reason = $matched_programs[$max_idx] ?? ''; + return ['percent' => max($matched_discounts), 'reason' => $reason]; + } + + /** + * Total membership discount for a submission. + * + * Each binding names a form field whose submitted value must match a key + * in the bound program. Matching bindings' discounts are combined per the + * definition's combine mode: the best single discount, or all summed and + * capped at 100%. + */ + public function discount_for(RsvFormDefinition $def, RsvFormData $data): float { + return $this->discount_detail_for($def, $data)['percent']; } } diff --git a/includes/Services/RsvReservationService.php b/includes/Services/RsvReservationService.php index 006bca8..e643839 100644 --- a/includes/Services/RsvReservationService.php +++ b/includes/Services/RsvReservationService.php @@ -70,6 +70,7 @@ class RsvReservationService { // (maintainer emails, calendar sync) observe the new reservation. foreach($reservation->timetable_reservations as $timetable_reservation) { if($timetable_reservation->is_confirmed === null) { + error_log('timetable_reservation->is_confirmed is null: ' . $timetable_reservation->id); $maintainer_email = (new RsvTimetableRepository())->get_maintainer_email($timetable_reservation->timetable_id); RsvEventDispatcher::dispatch(new RsvTimetableReservationPendingEvent( $reservation_id, diff --git a/includes/Services/RsvTimetableReservationService.php b/includes/Services/RsvTimetableReservationService.php index abac6e1..6dfe6c9 100644 --- a/includes/Services/RsvTimetableReservationService.php +++ b/includes/Services/RsvTimetableReservationService.php @@ -115,18 +115,18 @@ class RsvTimetableReservationService { return $this->repo->has_pending_confirmation($reservation_id); } - public function get_confirmation_code(int $reservation_id): ?string { - $code = $this->repo->get_confirmation_code($reservation_id); + public function get_confirmation_code(int $timetable_reservation_id): ?string { + $code = $this->repo->get_confirmation_code($timetable_reservation_id); return $code; } - public function accept_by_reservation_id(int $reservation_id): void { - $this->set_confirmed_state($this->get_confirmation_code($reservation_id), true); + public function accept_by_id(int $timetable_reservation_id): void { + $this->set_confirmed_state($this->get_confirmation_code($timetable_reservation_id), true); } - public function refuse_by_reservation_id(int $reservation_id): void { - $this->set_confirmed_state($this->get_confirmation_code($reservation_id), false); + public function refuse_by_id(int $timetable_reservation_id): void { + $this->set_confirmed_state($this->get_confirmation_code($timetable_reservation_id), false); } // TODO: Add requires_confirmation parameter diff --git a/includes/Views/RsvFormsPage.php b/includes/Views/RsvFormsPage.php index 6c779ed..9a22702 100644 --- a/includes/Views/RsvFormsPage.php +++ b/includes/Views/RsvFormsPage.php @@ -1,6 +1,7 @@ text('name', 'Name', '', true, $form_def['name']) ->select('definition.email_key', 'Email Key', $email_key_options, "Form field that holds the submitter's email address.", true, $definition['email_key'] ?? '') - ->code('definition.success_message', 'Success message', 'Shown to the visitor after a successful submission. HTML is allowed. Use to display the selected reservations. Leave blank for the default message.', $definition['success_message'] ?? '') + ->custom('Success message', function () use ($definition) { + $editor = RsvCodeEditor::render('definition.success_message', [ + 'value' => $definition['success_message'] ?? '', + 'mode' => 'text/html', + 'rows' => 8, + ]); + $hint = esc_html('Shown to the visitor after a successful submission. HTML is allowed. Use to display the selected reservations. Leave blank for the default message.'); + return '
    ' + . '
    ' . $editor . '

    ' . $hint . '

    ' + . '
    ' + . 'Live preview' + . '
    ' + . '
    '; + }) ->render(); ?> @@ -169,11 +183,11 @@ class RsvFormsPage extends RsvAdminPage { ->output(); ?> - elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables, $programs, $bindings); ?> + elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables, $programs, $bindings, $id); ?> e.preventDefault(), true); + // --- Success message live preview ---------------------------------- + // Rendered with the same template engine the front-end uses after a real + // submission. The data comes from this form's most recent submission, so + // {{ tokens }} and mirror a genuine confirmation; + // the message text itself updates live as it is edited. + const rsv_success_preview_el = document.getElementById('rsv_success_preview'); + let rsv_success_preview_timer = null; + let rsv_success_preview_data = {}; + + function rsv_schedule_success_preview() { + if (!rsv_success_preview_el) return; + clearTimeout(rsv_success_preview_timer); + rsv_success_preview_timer = setTimeout(rsv_render_success_preview, 300); + } + + function rsv_render_success_preview() { + if (!rsv_success_preview_el) return; + const form = document.getElementById(''); + const tpl = (form?.querySelector('[name="definition.success_message"]')?.value ?? '').trim(); + if (tpl === '') { + rsv_success_preview_el.innerHTML = '

    Leave blank to show the default confirmation message.

    '; + return; + } + try { + rsv_success_preview_el.innerHTML = RsvFormSender.render_template(tpl, rsv_success_preview_data); + } catch (e) { + console.log(e); + rsv_success_preview_el.innerHTML = '

    Preview unavailable.

    '; + } + } + + // Buttons rendered into the preview (e.g. ) live inside + // the edit form — keep them from submitting it. + rsv_success_preview_el?.addEventListener('click', (e) => { + if (e.target.closest('button, input[type="submit"], input[type="image"]')) e.preventDefault(); + }, true); + + if (rsv_success_preview_el) { + rsv_render_success_preview(); + fetch('', { + credentials: 'same-origin', + headers: { 'Accept': 'application/json', 'X-WP-Nonce': ReservairServiceAPI.nonce }, + }) + .then(r => r.ok ? r.json() : null) + .then(res => { rsv_success_preview_data = res?.data ?? {}; rsv_render_success_preview(); }) + .catch(() => {}); + } + function rsv_render_element_inline_form(dt, row, data) { const builder = RsvInlineFormBuilder.create(rsv_elements_source) .fieldset('Element', '50%') @@ -620,11 +682,15 @@ class RsvFormsPage extends RsvAdminPage { } const rsv_meta_form = document.getElementById(''); - ['name', 'definition.email_key', 'definition.success_message', 'definition.membership_combine'].forEach((n) => { + ['name', 'definition.email_key', 'definition.membership_combine'].forEach((n) => { const el = rsv_meta_form?.querySelector(`[name="${n}"]`); el?.addEventListener('input', rsv_schedule_preview); el?.addEventListener('change', rsv_schedule_preview); }); + // The success message drives its own preview only. + const rsv_success_input = rsv_meta_form?.querySelector('[name="definition.success_message"]'); + rsv_success_input?.addEventListener('input', rsv_schedule_success_preview); + rsv_success_input?.addEventListener('change', rsv_schedule_success_preview); RsvAdminForm.bind(document.getElementById(''), { transform: () => rsv_collect_definition(), diff --git a/includes/Views/RsvTimetablePage.php b/includes/Views/RsvTimetablePage.php index 4773d81..81436c8 100644 --- a/includes/Views/RsvTimetablePage.php +++ b/includes/Views/RsvTimetablePage.php @@ -103,17 +103,17 @@ class RsvTimetablePage extends RsvAdminPage { timetable_reservations_table, RsvTimetableReservationResource(), { - 'id': RsvDataGrid.column('ID', false), - 'reservation_id': RsvDataGrid.action_column('Reservation', false, { - 'Accept': RsvDataGrid.func_action( - (dt, row, data) => RsvReservationClient.accept(data.reservation_id).then(() => dt.refresh()), - (item) => item.pending_confirmation_id !== null - ), - 'Refuse': RsvDataGrid.func_action( - (dt, row, data) => RsvReservationClient.refuse(data.reservation_id).then(() => dt.refresh()), - (item) => item.pending_confirmation_id !== null - ), + 'id': RsvDataGrid.action_column('ID', false, { + 'Accept': RsvDataGrid.func_action( + (dt, row, data) => RsvTimetableReservationClient.accept(data.id).then(() => dt.refresh()), + (item) => item.pending_confirmation_id !== null + ), + 'Refuse': RsvDataGrid.func_action( + (dt, row, data) => RsvTimetableReservationClient.refuse(data.id).then(() => dt.refresh()), + (item) => item.pending_confirmation_id !== null + ), }), + 'reservation_id': RsvDataGrid.action_column('Reservation', false), 'start_utc': RsvDataGrid.column('Start', false), 'end_utc': RsvDataGrid.column('End', false), 'is_confirmed': RsvDataGrid.column('Status', false), diff --git a/src/admin.js b/src/admin.js index 589fd0f..a3ee239 100644 --- a/src/admin.js +++ b/src/admin.js @@ -12,6 +12,7 @@ import { RsvFormDefinitionResource } from '../assets/js/datasource/RsvFormDefini import { RsvTimetableResource } from '../assets/js/datasource/RsvTimetableResource.js'; import { RsvTimetableCapacityResource } from '../assets/js/datasource/RsvTimetableCapacityResource.js'; import { RsvTimetableReservationResource } from '../assets/js/datasource/RsvTimetableReservationResource.js'; +import { RsvTimetableReservationClient } from '../assets/js/datasource/RsvTimetableReservationClient.js'; import { RsvMembershipProgramResource } from '../assets/js/datasource/RsvMembershipProgramResource.js'; import { RsvMembershipKeyResource } from '../assets/js/datasource/RsvMembershipKeyResource.js'; import { RsvReservationClient } from '../assets/js/datasource/RsvReservationClient.js'; @@ -24,6 +25,7 @@ window.RsvFormDefinitionResource = RsvFormDefinitionResource; window.RsvTimetableResource = RsvTimetableResource; window.RsvTimetableCapacityResource = RsvTimetableCapacityResource; window.RsvTimetableReservationResource = RsvTimetableReservationResource; +window.RsvTimetableReservationClient = RsvTimetableReservationClient; window.RsvMembershipProgramResource = RsvMembershipProgramResource; window.RsvMembershipKeyResource = RsvMembershipKeyResource; window.RsvReservationClient = RsvReservationClient; -- 2.52.0