#26 - Loading animation + success message fix

This commit was merged in pull request #31.
This commit is contained in:
Martin Slachta
2026-06-22 11:20:28 +02:00
parent c754e18a82
commit 97ee8fc991
32 changed files with 597 additions and 175 deletions
+8 -4
View File
@@ -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;
}
@@ -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;
}
@@ -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 */
@@ -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'); });
});
},
};
+7 -4
View File
@@ -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'));
+5 -42
View File
@@ -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}` : '';
}
+20
View File
@@ -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;
+16 -25
View File
@@ -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');
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;
}
if (!tpl) {
const subtitle = document.createElement('p');
subtitle.className = 'rsv-success-msg';
subtitle.textContent = strings.success_subtitle;
return subtitle;
}
},
const body = document.createElement('div');
body.className = 'rsv-success-msg';
body.appendChild(tpl.content.cloneNode(true));
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) {
+2 -1
View File
@@ -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();
+10
View File
@@ -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);
+21
View File
@@ -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;
});
}
+94
View File
@@ -0,0 +1,94 @@
import { RsvTemplateRegistry } from './RsvTemplateRegistry.js';
function esc_html(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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>`;
}
@@ -48,6 +48,12 @@ class RsvFormDefinitionController {
'permission_callback' => [RsvRestPolicy::class, 'admin'],
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)/submission/latest', [
'methods' => 'GET',
'callback' => [$this, 'latest_submit'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\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();
@@ -30,18 +30,6 @@ class RsvReservationController {
'callback' => [$this, 'create'],
'permission_callback' => [RsvRestPolicy::class, 'admin']
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)/accept', [
'methods' => 'POST',
'callback' => [$this, 'accept_by_id'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\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);
}
}
}
@@ -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<id>\d+)/accept', [
'methods' => 'POST',
'callback' => [$this, 'accept_by_id'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
]);
register_rest_route($this->namespace, '/timetable-reservation/(?P<id>\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);
}
}
}
@@ -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<string,mixed>|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]);
}
@@ -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]
);
+3
View File
@@ -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!',
+1
View File
@@ -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)
@@ -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;
}
@@ -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<string>
*/
public static function names(): array {
return ['price', 'price_before_discount', 'discount_percent'];
return ['price', 'price_before_discount', 'discount_percent', 'pricing'];
}
}
@@ -1,7 +1,5 @@
<?php
use Reservair\Templating\RsvTemplateEngine;
class RsvFormHtmlRenderer {
public function draw(RsvFormDefinition $form): bool {
if (!$form->hasElements()) {
@@ -21,37 +19,12 @@ class RsvFormHtmlRenderer {
<?php endforeach; ?>
</form>
<?php $this->draw_success_template($form); ?>
</div>
<?php
return true;
}
/**
* Emits the admin-configured success message as an inert <template> that the
* client clones once the form is submitted. A <reservation-summary> 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));
?>
<template class="rsv-form-success"><?= $html ?></template>
<?php
}
public function draw_element(RsvFormElementDefinition $data): void {
global $rsv_form_registry;
+18 -1
View File
@@ -35,7 +35,24 @@ class RsvFormSubmission {
return ['success' => 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. */
@@ -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'];
}
}
@@ -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,
@@ -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
+70 -4
View File
@@ -1,6 +1,7 @@
<?php
use Reservair\Forms\RsvFormBuilder;
use Reservair\Forms\RsvCodeEditor;
use Reservair\Layout\RsvColumnLayout;
class RsvFormsPage extends RsvAdminPage {
@@ -127,7 +128,20 @@ class RsvFormsPage extends RsvAdminPage {
echo RsvFormBuilder::create('edit_form_definition', get_rest_url(null, 'reservations/v1/form-definition/' . $id), 'PUT', 'Form definition updated.')
->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 <reservation-summary></reservation-summary> 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 <reservation-summary></reservation-summary> to display the selected reservations. Leave blank for the default message.');
return '<div style="display:flex;gap:24px;align-items:flex-start;flex-wrap:wrap;">'
. '<div style="flex:1 1 320px;min-width:0;">' . $editor . '<p class="description">' . $hint . '</p></div>'
. '<div style="flex:1 1 320px;min-width:0;">'
. '<span style="display:block;font-weight:600;margin-bottom:8px;">Live preview</span>'
. '<div id="rsv_success_preview" class="rsv-form-preview rsv-success-msg"></div>'
. '</div></div>';
})
->render();
?>
@@ -169,11 +183,11 @@ class RsvFormsPage extends RsvAdminPage {
->output();
?>
<?php $this->elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables, $programs, $bindings); ?>
<?php $this->elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables, $programs, $bindings, $id); ?>
<?php
}
private function elements_table_script(array $elements_with_ids, int $next_id, string $form_id, array $element_types, array $timetables = [], array $programs = [], array $bindings = []): void {
private function elements_table_script(array $elements_with_ids, int $next_id, string $form_id, array $element_types, array $timetables = [], array $programs = [], array $bindings = [], int $definition_id = 0): void {
$elements_json = json_encode($elements_with_ids);
$types_json = json_encode(array_values($element_types));
$timetables_json = json_encode(array_values($timetables));
@@ -365,6 +379,54 @@ class RsvFormsPage extends RsvAdminPage {
// The preview form is inert: block submission (capture so it works after re-render).
rsv_preview_el?.addEventListener('submit', (e) => 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 <reservation-summary> 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('<?= $form_id ?>');
const tpl = (form?.querySelector('[name="definition.success_message"]')?.value ?? '').trim();
if (tpl === '') {
rsv_success_preview_el.innerHTML = '<p class="rsv-preview-empty">Leave blank to show the default confirmation message.</p>';
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 = '<p class="rsv-preview-empty">Preview unavailable.</p>';
}
}
// Buttons rendered into the preview (e.g. <reset-form-button>) 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('<?= get_rest_url(null, 'reservations/v1/form-definition/' . $definition_id . '/submission/latest') ?>', {
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('<?= $form_id ?>');
['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('<?= $form_id ?>'), {
transform: () => rsv_collect_definition(),
+4 -4
View File
@@ -103,17 +103,17 @@ class RsvTimetablePage extends RsvAdminPage {
timetable_reservations_table,
RsvTimetableReservationResource(<?= $id ?>),
{
'id': RsvDataGrid.column('ID', false),
'reservation_id': RsvDataGrid.action_column('Reservation', false, {
'id': RsvDataGrid.action_column('ID', false, {
'Accept': RsvDataGrid.func_action(
(dt, row, data) => RsvReservationClient.accept(data.reservation_id).then(() => dt.refresh()),
(dt, row, data) => RsvTimetableReservationClient.accept(data.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()),
(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),
+2
View File
@@ -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;