From 7d7f748f7abc916860157b695d4dd6212f9529b8 Mon Sep 17 00:00:00 2001 From: Martin Slachta Date: Sun, 14 Jun 2026 07:16:13 +0200 Subject: [PATCH] (#3) - templating --- FORMS.md | 2 +- assets/css/components/RsvFormStyles.css | 86 +++---- assets/js/elements/RsvCalendar.js | 8 + assets/js/elements/RsvReservationSelector.js | 10 + assets/js/elements/RsvReservationSummary.js | 26 +- assets/js/elements/RsvTimeline.js | 19 +- assets/js/forms/RsvFormSender.js | 60 +++-- .../RsvFormDefinitionController.php | 5 +- includes/Listeners/RsvEmailListener.php | 51 ++-- .../RsvTimetableReservationRepository.php | 3 +- includes/RsvAssetsDefinition.php | 13 +- .../Services/Emails/RsvEmailTemplater.php | 9 - .../Handlers/RsvButtonElementHandler.php | 2 +- .../Forms/Handlers/RsvFormElementHandler.php | 1 - .../RsvFormReservationElementHandler.php | 2 +- includes/Services/Forms/RsvFormDefinition.php | 14 +- .../Services/Forms/RsvFormHtmlRenderer.php | 26 ++ .../RsvTimetableReservationService.php | 3 + includes/Views/RsvFormsPage.php | 6 +- modules/Database/Db.php | 4 +- .../Elements/RsvReservationActionsElement.php | 37 +++ .../Elements/RsvReservationSummaryElement.php | 22 ++ .../Elements/RsvResetFormButtonElement.php | 26 ++ modules/Templating/README.md | 23 ++ modules/Templating/RsvTemplateElement.php | 17 ++ modules/Templating/RsvTemplateEngine.php | 228 ++++++++++++++++++ modules/Templating/RsvTemplateException.php | 5 + modules/Templating/RsvTemplateRegistry.php | 41 ++++ modules/Templating/RsvTemplateSymbols.php | 18 ++ reservair.php | 15 +- src/admin.js | 5 +- 31 files changed, 661 insertions(+), 126 deletions(-) delete mode 100644 includes/Services/Emails/RsvEmailTemplater.php create mode 100644 modules/Templating/Elements/RsvReservationActionsElement.php create mode 100644 modules/Templating/Elements/RsvReservationSummaryElement.php create mode 100644 modules/Templating/Elements/RsvResetFormButtonElement.php create mode 100644 modules/Templating/README.md create mode 100644 modules/Templating/RsvTemplateElement.php create mode 100644 modules/Templating/RsvTemplateEngine.php create mode 100644 modules/Templating/RsvTemplateException.php create mode 100644 modules/Templating/RsvTemplateRegistry.php create mode 100644 modules/Templating/RsvTemplateSymbols.php diff --git a/FORMS.md b/FORMS.md index f8ed7dd..95b0897 100644 --- a/FORMS.md +++ b/FORMS.md @@ -64,4 +64,4 @@ Last thing inspired by Problem Details RFC are extensions. The response can cont ## Success handling -Even success must be handled. The user must know that the submission is successfully finished. The form definition can contain a success message as HTML. +Even success must be handled. The user must know that the submission is successfully finished. What is shown is a templated HTML code that is defined by user. It can contain custom elements, like ``. diff --git a/assets/css/components/RsvFormStyles.css b/assets/css/components/RsvFormStyles.css index e777136..781e937 100644 --- a/assets/css/components/RsvFormStyles.css +++ b/assets/css/components/RsvFormStyles.css @@ -1,19 +1,30 @@ +.rsv-form-btn { + border-radius: 1.375rem; + border: 1.5px solid #e0e0e0; + color: #555; + padding: 0 calc(1.25rem + 4px); + height: 3.5rem; + background-color: white; + + line-height: 140%; + font-size: 1rem; + font-size: 15px; + font-weight: 600; + font-family: inherit; + + outline: none; +} + +.rsv-form-btn:hover { + background-color: #f5f5f5; +} + /* Primary CTA (submit / confirm) */ .rsv-form-btn-primary { background: #2563eb; color: #fff; - border-radius: 1.375rem; - font-size: 1rem; - padding: 0 calc(1.25rem + 4px); - line-height: 140%; - height: 3.5rem; - - border: none; - font-size: 15px; - font-weight: 600; - font-family: inherit; cursor: pointer; width: 100%; transition: background .12s; @@ -43,12 +54,6 @@ margin-right: auto; } -/*.reservair-form button { - padding: var(--s-3) !important; - font-weight: 400 !important; -}*/ - -/*.reservair-form button,*/ .rsv-form-input { border: 1px solid var(--color-gray-300); outline: none; @@ -165,47 +170,44 @@ box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-red-500) 25%, transparent) !important; } -.rsv-success-message { +.rsv-success-msg { text-align: center; - padding: var(--s-5); color: var(--color-green-700); + width: 320px; } -.rsv-success-message p { +.rsv-success-msg p { + margin-top: 0; font-size: 1.125rem; font-weight: 500; } - -.mesg { - width: 100%; - text-align: center; - line-height: 1rem; - margin-top: var(--s-5); - margin-bottom: var(--s-5); - padding: var(--s-4) 0; +.rsv-success-msg h1, +.rsv-success-msg h2, +.rsv-success-msg h3, +.rsv-success-msg h4, +.rsv-success-msg h5, +.rsv-success-msg h6 +{ + margin-top: 0; + margin-bottom: 0.5rem; } -.success-mesg-icon { +.rsv-summary { + margin-bottom: 2rem; } -.mesg-icon svg { - width: 32px; - height: 32px; - padding: var(--s-2); +.rsv-success-icon { + width: 64px; + height: 64px; border-radius: 50%; - color: #00000094; + background: #dcfce7; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 20px; } -.error-mesg svg { - background-color: var(--color-red-200); -} - -.success-mesg svg { - background-color: rgba(0, 201, 80, 0.36); -} -/* FORM END */ - .rsv-timetable-selector { display: grid; grid-template-columns: repeat(2, 1fr); diff --git a/assets/js/elements/RsvCalendar.js b/assets/js/elements/RsvCalendar.js index 1bf16c1..7a35781 100644 --- a/assets/js/elements/RsvCalendar.js +++ b/assets/js/elements/RsvCalendar.js @@ -179,6 +179,14 @@ export const RsvCalendarPicker = (() => { new InputEvent('change', { bubbles: true, cancelable: true, composed: true }) ); }, + + // Re-check the radio for the current date. The date lives in JS state, so + // this restores the visual selection after a native form.reset() clears it. + reselect() { + if (this.date === null) return; + const radio = this.body.querySelector(`input[id="${this.date.toISOString()}"]`); + if (radio) radio.checked = true; + }, }; container.classList.add('rsv-calendar'); diff --git a/assets/js/elements/RsvReservationSelector.js b/assets/js/elements/RsvReservationSelector.js index de6baf4..ae54556 100644 --- a/assets/js/elements/RsvReservationSelector.js +++ b/assets/js/elements/RsvReservationSelector.js @@ -37,6 +37,16 @@ class RsvReservationSelector extends HTMLElement { this.querySelectorAll('.rsv-slots-slot-selected').forEach(s => s.classList.remove('rsv-slots-slot-selected')); this._slots = []; this._commit(); + // _commit clears only the slots; keep the picked date selected, re-asserting + // it in case a surrounding form.reset() just unchecked the calendar radio. + this._calendar?.reselect(); + } + + // Reset for a new reservation: drop the local selection (keeping the date) and + // reload availability so slots booked by the previous submission show as full. + reset() { + this.clear(); + this.querySelector('rsv-timeline')?.refresh(); } // ---- Private ------------------------------------------------------------ diff --git a/assets/js/elements/RsvReservationSummary.js b/assets/js/elements/RsvReservationSummary.js index f93373c..ada557e 100644 --- a/assets/js/elements/RsvReservationSummary.js +++ b/assets/js/elements/RsvReservationSummary.js @@ -25,6 +25,31 @@ 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() { @@ -51,7 +76,6 @@ class RsvReservationSummary extends HTMLElement { const all_slots = [...this._all_slots.values()].flatMap(({ slots, price_per_block }) => slots.map(s => ({ ...s, price_per_block })) ); - console.log(all_slots); const n = all_slots.length; const list = this.querySelector('.rsv-summary-list'); diff --git a/assets/js/elements/RsvTimeline.js b/assets/js/elements/RsvTimeline.js index 499e5ef..8b7189e 100644 --- a/assets/js/elements/RsvTimeline.js +++ b/assets/js/elements/RsvTimeline.js @@ -35,6 +35,14 @@ class RsvTimeline extends HTMLElement { this._render(); } + // ---- Public API --------------------------------------------------------- + + // Re-fetch availability for the current date, e.g. after a booking occupied + // some slots. The date is unchanged, so attributeChangedCallback won't fire. + refresh() { + this._render(); + } + // ---- Private ------------------------------------------------------------ _on_click(event) { @@ -59,10 +67,6 @@ class RsvTimeline extends HTMLElement { const occupancy = await RsvTimetableService.get_availability_for_date(this.timetableId, this.date); if (v !== this._version) return; - if(occupancy.length === 0) { - this.replaceChildren(this._notice(s.no_blocks)); - return; - } const header = document.createElement('div'); header.classList.add('rsv-slots-label'); @@ -70,6 +74,11 @@ class RsvTimeline extends HTMLElement { weekday: 'long', day: 'numeric', month: 'long', }).replace(',', ''); + if(occupancy.length === 0) { + this.replaceChildren(header, this._notice(s.no_blocks)); + return; + } + const blocks = []; for (const { from_minutes, to_minutes, block_size_in_minutes, occupancy: block_occ } of occupancy) { @@ -107,7 +116,7 @@ class RsvTimeline extends HTMLElement { cell.classList.add('rsv-slots-slot', 'rsv-slots-slot-available'); cell.dataset.start_utc = from.toISOString(); cell.dataset.end_utc = to.toISOString(); - if (left === 0) cell.classList.add('rsv-slots-slot-full'); + if (left <= 0) cell.classList.add('rsv-slots-slot-full'); const time_el = document.createElement('span'); time_el.classList.add('rsv-slots-slot-time'); diff --git a/assets/js/forms/RsvFormSender.js b/assets/js/forms/RsvFormSender.js index e8465fd..ef43164 100644 --- a/assets/js/forms/RsvFormSender.js +++ b/assets/js/forms/RsvFormSender.js @@ -56,24 +56,14 @@ export const RsvFormSender = { svg.appendChild(path); const icon = document.createElement('div'); - icon.className = 'success-icon'; + icon.className = 'rsv-success-icon'; icon.appendChild(svg); - const title = document.createElement('div'); - title.className = 'success-title'; - title.textContent = s.success_title; - - const subtitle = document.createElement('p'); - subtitle.className = 'success-msg'; - subtitle.textContent = s.success_subtitle; - - const reset_btn = document.createElement('button'); - reset_btn.className = 'reset-btn'; - reset_btn.textContent = s.new_reservation; + const body = this.build_success_body(form, s); const state = document.createElement('div'); - state.className = 'success-state'; - state.append(icon, title, subtitle, reset_btn); + state.className = 'rsv-success-state'; + state.append(icon, body); const msg = document.createElement('div'); msg.appendChild(state); @@ -81,12 +71,50 @@ export const RsvFormSender = { existing.forEach(child => child.style.display = 'none'); wrapper.appendChild(msg); - reset_btn.addEventListener('click', () => { + // The form catches every data-rsv-reset button in the card and links it to + // its cleanup — so new buttons just need the marker, no wiring here. + const reset = () => { msg.remove(); form.reset(); + // Native reset leaves custom controls untouched; reset the reservation + // selectors so their slots, hidden inputs and the summary clear, the date + // stays selected and availability reloads with the just-booked slots. + form.querySelectorAll('rsv-reservation-selector').forEach(sel => sel.reset()); this.clear_feedback(form); existing.forEach(child => child.style.display = ''); - }); + }; + 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; + } + + 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; }, set_loading(form, is_loading) { diff --git a/includes/Controllers/RsvFormDefinitionController.php b/includes/Controllers/RsvFormDefinitionController.php index 4bfd319..cca9d5a 100644 --- a/includes/Controllers/RsvFormDefinitionController.php +++ b/includes/Controllers/RsvFormDefinitionController.php @@ -18,8 +18,9 @@ class RsvFormDefinitionController { 'type' => 'object', 'required' => false, 'properties' => [ - 'email_key' => ['type' => 'string', 'required' => false], - 'elements' => ['type' => 'array', 'default' => []], + 'email_key' => ['type' => 'string', 'required' => false], + 'success_message' => ['type' => 'string', 'required' => false], + 'elements' => ['type' => 'array', 'default' => []], ], ], ], diff --git a/includes/Listeners/RsvEmailListener.php b/includes/Listeners/RsvEmailListener.php index f705fc1..4d6ce89 100644 --- a/includes/Listeners/RsvEmailListener.php +++ b/includes/Listeners/RsvEmailListener.php @@ -1,6 +1,7 @@ Rezervace č. {{reservation_id}} čeká na vaše schválení.

Datum: {{date}}

Čas: {{start}} – {{end}}

-

- Přijmout -  |  - Odmítnout -

+

"; private const string DEFAULT_ACCEPTED_SUBJECT = 'Rezervace přijata'; @@ -48,17 +45,20 @@ class RsvEmailListener { public static function on_pending(RsvTimetableReservationPendingEvent $event): void { try { + global $rsv_template_registry; + $engine = new RsvTemplateEngine(registry: $rsv_template_registry); + $tz = wp_timezone(); $start_dt = (clone $event->reservation->start_utc)->setTimezone($tz); $end_dt = (clone $event->reservation->end_utc)->setTimezone($tz); - $body = (new RsvEmailTemplater())->render(self::DEFAULT_PENDING_BODY, [ + $body = $engine->render(self::DEFAULT_PENDING_BODY, [ 'reservation_id' => (string) $event->reservation_id, - 'date' => esc_html($start_dt->format('Y-m-d')), - 'start' => esc_html($start_dt->format('H:i')), - 'end' => esc_html($end_dt->format('H:i')), - 'accept_url' => esc_url(get_rest_url(null, 'reservations/v1/timetable-reservation/accept/' . $event->code)), - 'refuse_url' => esc_url(get_rest_url(null, 'reservations/v1/timetable-reservation/refuse/' . $event->code)), + 'date' => $start_dt->format('Y-m-d'), + 'start' => $start_dt->format('H:i'), + 'end' => $end_dt->format('H:i'), + 'accept_url' => get_rest_url(null, 'reservations/v1/timetable-reservation/accept/' . $event->code), + 'refuse_url' => get_rest_url(null, 'reservations/v1/timetable-reservation/refuse/' . $event->code), ]); (new RsvEmailSender())->send($event->maintainer_email, self::DEFAULT_PENDING_SUBJECT, $body); @@ -67,22 +67,6 @@ class RsvEmailListener { } } - /** - * HTML-escape scalar form values for safe interpolation into an HTML email - * body. Non-scalar values (e.g. nested arrays) are dropped to an empty - * string since the templater only substitutes plain placeholders. - * - * @param array $values - * @return array - */ - private static function escape_values(array $values): array { - $escaped = []; - foreach ($values as $key => $value) { - $escaped[$key] = is_scalar($value) ? esc_html((string) $value) : ''; - } - return $escaped; - } - public static function on_form_submit_closed(RsvFormSubmitClosedEvent $event): void { try { $form_submit = (new RsvFormSubmitRepository())->get($event->form_submit_id); @@ -136,15 +120,14 @@ class RsvEmailListener { $subject_tpl = trim((string) ($tpl['subject'] ?? '')) !== '' ? $tpl['subject'] : $default_subject; $body_tpl = trim((string) ($tpl['body'] ?? '')) !== '' ? $tpl['body'] : $default_body; - $templater = new RsvEmailTemplater(); + global $rsv_template_registry; + $engine = new RsvTemplateEngine(registry: $rsv_template_registry); - // Subject is plain text: render with raw values, then strip any tags - // or newlines to avoid header issues. - $subject = sanitize_text_field($templater->render($subject_tpl, $form_values)); + // Subject is plain text: render without HTML-escaping, then strip tags/newlines. + $subject = sanitize_text_field($engine->render_plain($subject_tpl, $form_values)); - // Body is HTML: escape the user-submitted values before interpolation - // so they can't inject markup into the message. - $body = $templater->render($body_tpl, self::escape_values($form_values)); + // Body is HTML: the engine HTML-escapes all interpolated values. + $body = $engine->render($body_tpl, $form_values); (new RsvEmailSender())->send($user_email, $subject, $body); } catch (\Throwable $e) { diff --git a/includes/Repository/RsvTimetableReservationRepository.php b/includes/Repository/RsvTimetableReservationRepository.php index d9987b9..e20eb7b 100644 --- a/includes/Repository/RsvTimetableReservationRepository.php +++ b/includes/Repository/RsvTimetableReservationRepository.php @@ -34,7 +34,8 @@ class RsvTimetableReservationRepository { WHERE timetable_id = %d AND `start_utc` < %s AND `end_utc` > %s", - [$timetable_id, $end_utc, $start_utc] + [$timetable_id, $end_utc->format('Y-m-d H:i:s'), $start_utc->format('Y-m-d H:i:s')], + ARRAY_A ) ); } diff --git a/includes/RsvAssetsDefinition.php b/includes/RsvAssetsDefinition.php index 7cebf39..9b960ce 100644 --- a/includes/RsvAssetsDefinition.php +++ b/includes/RsvAssetsDefinition.php @@ -52,8 +52,15 @@ function rsv_enqueue_assets(): void { } function rsv_enqueue_admin_assets(): void { - wp_enqueue_script('rsv-admin', rsv_build_url('admin.js'), [], filemtime(rsv_build_file('admin.js'))); - wp_enqueue_style('rsv-admin', rsv_build_url('admin.css'), [], filemtime(rsv_build_file('admin.css'))); + // The client bundle defines the custom elements shared by both front-end and + // admin. enqueue_block_assets already enqueues `rsv-client` in the editor, so + // re-using the same handle here keeps it loaded exactly once (WP dedupes by + // handle) instead of bundling a second copy into admin.js. + wp_enqueue_script('rsv-client', rsv_build_url('client.js'), [], filemtime(rsv_build_file('client.js'))); + wp_enqueue_style('rsv-client', rsv_build_url('client.css'), [], filemtime(rsv_build_file('client.css'))); - rsv_localize_api('rsv-admin'); + wp_enqueue_script('rsv-admin', rsv_build_url('admin.js'), ['rsv-client'], filemtime(rsv_build_file('admin.js'))); + wp_enqueue_style('rsv-admin', rsv_build_url('admin.css'), ['rsv-client'], filemtime(rsv_build_file('admin.css'))); + + rsv_localize_api('rsv-client'); } diff --git a/includes/Services/Emails/RsvEmailTemplater.php b/includes/Services/Emails/RsvEmailTemplater.php deleted file mode 100644 index 13d1c17..0000000 --- a/includes/Services/Emails/RsvEmailTemplater.php +++ /dev/null @@ -1,9 +0,0 @@ -
- +
getName(); ?>
- + $definition Full definition array including 'elements' and 'email_key'. + * @param array $definition Full definition array including 'elements', 'email_key' and 'success_message'. */ public function __construct(string $id, array $definition) { $this->_elements = []; @@ -19,8 +21,9 @@ class RsvFormDefinition { } } - $this->_id = $id; - $this->email_key = $definition['email_key'] ?? ''; + $this->_id = $id; + $this->email_key = $definition['email_key'] ?? ''; + $this->success_message = $definition['success_message'] ?? ''; } public function getId(): string { @@ -31,6 +34,11 @@ class RsvFormDefinition { return $this->email_key; } + /** Template shown to the visitor after a successful submission. */ + public function getSuccessMessage(): string { + return $this->success_message; + } + public function hasElements() : bool { return count($this->_elements) > 0; diff --git a/includes/Services/Forms/RsvFormHtmlRenderer.php b/includes/Services/Forms/RsvFormHtmlRenderer.php index f176ee7..1dbd71f 100644 --- a/includes/Services/Forms/RsvFormHtmlRenderer.php +++ b/includes/Services/Forms/RsvFormHtmlRenderer.php @@ -1,5 +1,6 @@ + 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)); + ?> + + get_overlapping_capacity($timetable_id, $start_utc, $end_utc); if (count($overlapping_capacity) === 0) { + Logger::error("No available capacity for timetable_id: $timetable_id, start_utc: " . $start_utc->format('Y-m-d H:i:s') . ", end_utc: " . $end_utc->format('Y-m-d H:i:s')); return false; } @@ -42,6 +43,7 @@ class RsvTimetableReservationService { $end_min = $this->time_of_day_minutes($end_utc); if ((int) $overlapping_capacity[0]->start_time > $start_min) { + Logger::error("Overlapping capacity start_time: " . (int) $overlapping_capacity[0]->start_time . " is after the reservation start_time: $start_min"); return false; } @@ -59,6 +61,7 @@ class RsvTimetableReservationService { $capacity = (int) $overlapping_capacity[0]->capacity; $reservations = $this->repo->get_overlapping($timetable_id, $start_utc, $end_utc); + error_log('reservations: ' . json_encode($reservations)); return count($reservations) < $capacity; } diff --git a/includes/Views/RsvFormsPage.php b/includes/Views/RsvFormsPage.php index dba1cb0..82fd820 100644 --- a/includes/Views/RsvFormsPage.php +++ b/includes/Views/RsvFormsPage.php @@ -126,6 +126,7 @@ 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'] ?? '') + ->textarea('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.', false, $definition['success_message'] ?? '') ->render(); ?> @@ -389,8 +390,9 @@ class RsvFormsPage extends RsvAdminPage { transform: (body) => ({ name: body.name, definition: { - email_key: body.definition?.email_key ?? '', - elements: rsv_elements_source.get_all(), + email_key: body.definition?.email_key ?? '', + success_message: body.definition?.success_message ?? '', + elements: rsv_elements_source.get_all(), }, }), refresh: () => { if (typeof forms_dt !== 'undefined') forms_dt.refresh(); }, diff --git a/modules/Database/Db.php b/modules/Database/Db.php index a223089..b8141a8 100644 --- a/modules/Database/Db.php +++ b/modules/Database/Db.php @@ -27,7 +27,9 @@ class Db { public static function get_results(string $sql, array $params = [], string $output = OBJECT): array { global $wpdb; - $rows = $wpdb->get_results(empty($params) ? $sql : $wpdb->prepare($sql, $params), $output); + $query = empty($params) ? $sql : $wpdb->prepare($sql, $params); + error_log($query); + $rows = $wpdb->get_results($query, $output); self::throw_if_error(); return $rows ?? []; } diff --git a/modules/Templating/Elements/RsvReservationActionsElement.php b/modules/Templating/Elements/RsvReservationActionsElement.php new file mode 100644 index 0000000..9db9191 --- /dev/null +++ b/modules/Templating/Elements/RsvReservationActionsElement.php @@ -0,0 +1,37 @@ +get('accept_url', ''); + $refuse_url = (string) $symbols->get('refuse_url', ''); + if ($accept_url === '' && $refuse_url === '') { + return ''; + } + + $accept_label = (string) $symbols->get('accept-label', 'Přijmout'); + $refuse_label = (string) $symbols->get('refuse-label', 'Odmítnout'); + + return '' . esc_html($accept_label) . '' + . '' . esc_html($refuse_label) . ''; + } + + public function symbols(): array { + return ['accept-label', 'refuse-label']; + } +} diff --git a/modules/Templating/Elements/RsvReservationSummaryElement.php b/modules/Templating/Elements/RsvReservationSummaryElement.php new file mode 100644 index 0000000..5dd1a8a --- /dev/null +++ b/modules/Templating/Elements/RsvReservationSummaryElement.php @@ -0,0 +1,22 @@ +'; + } + + public function symbols(): array { + return []; + } +} diff --git a/modules/Templating/Elements/RsvResetFormButtonElement.php b/modules/Templating/Elements/RsvResetFormButtonElement.php new file mode 100644 index 0000000..c9a34e8 --- /dev/null +++ b/modules/Templating/Elements/RsvResetFormButtonElement.php @@ -0,0 +1,26 @@ +get('label', 'Odeslat znova'); + return ''; + } + + public function symbols(): array { + return ['label']; + } +} diff --git a/modules/Templating/README.md b/modules/Templating/README.md new file mode 100644 index 0000000..179c864 --- /dev/null +++ b/modules/Templating/README.md @@ -0,0 +1,23 @@ +# Templating + +Templates allow to replace place values of symbols in text written in one language. Templating itself requires a language. Therefore the process combines two languages. + +This plugin uses templates for success messages of forms & emails. The usage might expand in the future. That is one of the quality attributes of this requirement. + +## Language + +As both use cases are using HTML (one is an email and another a webpage), the templates in this plugin also uses HTML. The frontend for HTML is `RsvHtmlTemplateParser`, that transforms it into an internal structure. The input language can be changed by writing a new frontend for the compiler in the future. + +The main advantage of HTML is that it can be easily extended with custom elements. But we do note that traditional custom elements using JS cannot be used, because JS would not work like that in emails for example. + +Extensions and the plugin itself can register custom elements to templates, for example `` that auto-expands onto a nice summary of the selected time slots. + +Atomic values and strings can be retrieved from submitted data using JSON Path RFC using this syntax: `{{ path }}`. The reason for JSON Path is the simplicity of implementation. We are careful not to overcomplicate the templating language, for a price of being less powerful. Helm language being the example of what not to do. + +The language can be easily validated for symbols existence and validity. + +## Custom elements + +The Templating module calls `rsv-template-register-custom-elements` to register custom elements to templates. Plugins can subscribe to it and in the handler register their own custom elements. + +Custom element has a symbol table with values in the input and outputs string. If the custom element has attributes, like ``, the symbol `attr` with value `test` will also be added to the symbol table. diff --git a/modules/Templating/RsvTemplateElement.php b/modules/Templating/RsvTemplateElement.php new file mode 100644 index 0000000..49d9957 --- /dev/null +++ b/modules/Templating/RsvTemplateElement.php @@ -0,0 +1,17 @@ + + */ + public function symbols(): array; +} diff --git a/modules/Templating/RsvTemplateEngine.php b/modules/Templating/RsvTemplateEngine.php new file mode 100644 index 0000000..ff80c77 --- /dev/null +++ b/modules/Templating/RsvTemplateEngine.php @@ -0,0 +1,228 @@ +registry = $registry ?? new RsvTemplateRegistry(); + } + + /** + * Renders $source as HTML. Interpolated scalar values are HTML-escaped; + * custom elements emit their own trusted markup. + * + * @param array $data + */ + public function render(string $source, array $data = []): string { + try { + return $this->expand_elements( + $this->interpolate($source, $data, escape: true), + $data, + ); + } catch (RsvTemplateException $e) { + throw $e; + } catch (\Throwable $e) { + Logger::error($e); + throw new RsvTemplateException('Template render failed: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Renders $source for a plain-text context such as an email subject: values + * are substituted without HTML-escaping and all tags are stripped. + * + * @param array $data + */ + public function render_plain(string $source, array $data = []): string { + return trim(wp_strip_all_tags($this->interpolate($source, $data, escape: false))); + } + + /** + * Lists a template's problems without rendering it (empty = valid): empty + * interpolations, unregistered custom elements, and attributes an element + * does not declare. + * + * @return list + */ + public function validate(string $source): array { + $errors = []; + + if (preg_match_all('/{{\s*([^}]*?)\s*}}/', $source, $matches)) { + foreach ($matches[1] as $path) { + if (trim($path) === '') { + $errors[] = 'Empty interpolation: {{ }}'; + } + } + } + + foreach ($this->custom_elements($this->load($source)) as $element) { + $handler = $this->registry->get($element->tagName); + if ($handler === null) { + $errors[] = "Unregistered custom element: <{$element->tagName}>"; + continue; + } + $allowed = $handler->symbols(); + foreach ($this->attributes($element) as $name => $value) { + if (!in_array($name, $allowed, true)) { + $errors[] = "Unknown attribute \"{$name}\" on <{$element->tagName}>"; + } + } + } + + return $errors; + } + + // ------------------------------------------------------------------------- + // Phase 1 — interpolation + // ------------------------------------------------------------------------- + + /** @param array $data */ + private function interpolate(string $source, array $data, bool $escape): string { + return preg_replace_callback('/{{\s*([^}]+?)\s*}}/', function (array $match) use ($data, $escape): string { + $value = $this->resolve($match[1], $data); + if (!is_scalar($value)) { + return ''; // null and non-scalar (arrays/objects) render empty + } + $string = (string) $value; + return $escape ? esc_html($string) : $string; + }, $source) ?? $source; + } + + // ------------------------------------------------------------------------- + // Phase 2 — custom element expansion + // ------------------------------------------------------------------------- + + /** + * Replaces each registered custom element with its handler's output. The + * element is swapped for a unique comment marker, the document is serialised, + * and the markers are substituted for the (trusted) handler strings — so the + * output is never re-escaped by the serialiser. + * + * @param array $data + */ + private function expand_elements(string $html, array $data): string { + // Skip the DOM round-trip unless a hyphenated tag could match a handler. + if ($this->registry->all() === [] || !preg_match('/<[a-z][a-z0-9]*-/i', $html)) { + return $html; + } + + $dom = $this->load($html); + $nonce = 'rsv-' . bin2hex(random_bytes(6)); + $replacements = []; + + foreach ($this->custom_elements($dom) as $i => $element) { + $handler = $this->registry->get($element->tagName); + if ($handler === null) { + continue; // leave unknown custom elements in place + } + $token = "{$nonce}-{$i}"; + $replacements[""] = $handler->render( + new RsvTemplateSymbols(array_merge($data, $this->attributes($element))) + ); + $element->parentNode?->replaceChild($dom->createComment($token), $element); + } + + return strtr($this->serialize($dom), $replacements); + } + + // ------------------------------------------------------------------------- + // JSON Path resolver + // ------------------------------------------------------------------------- + + /** + * Resolves a minimal JSON Path expression against $data. + * + * Supported forms: bare key, $.key, $.a.b, $.items[0], $['key']. + * Returns null for a missing path; the renderer emits an empty string for null. + * + * @param array $data + */ + private function resolve(string $path, array $data): mixed { + $tokens = preg_split('/[\.\[\]]+/', $path, -1, PREG_SPLIT_NO_EMPTY) ?: []; + $current = $data; + + foreach ($tokens as $token) { + if ($token === '$') { + continue; // root sigil + } + $token = trim($token, "'\""); // strip bracket-notation quotes + if (!is_array($current) || !array_key_exists($token, $current)) { + return null; + } + $current = $current[$token]; + } + + return $current; + } + + // ------------------------------------------------------------------------- + // DOM helpers + // ------------------------------------------------------------------------- + + private function load(string $html): \DOMDocument { + $dom = new \DOMDocument(); + $prev = libxml_use_internal_errors(true); + // The XML encoding hint forces UTF-8; NOIMPLIED/NODEFDTD keep the fragment + // free of the synthetic //doctype wrappers libxml adds. + $dom->loadHTML( + '' . $html, + LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD, + ); + libxml_clear_errors(); + libxml_use_internal_errors($prev); + + // Drop the encoding hint, which libxml leaves behind as a stray node. + foreach (iterator_to_array($dom->childNodes) as $node) { + if ($node instanceof \DOMProcessingInstruction + || ($node instanceof \DOMComment && str_contains((string) $node->nodeValue, 'xml encoding'))) { + $dom->removeChild($node); + } + } + + return $dom; + } + + /** + * Every hyphenated element in document order — registered or not. + * + * @return list<\DOMElement> + */ + private function custom_elements(\DOMDocument $dom): array { + $elements = []; + foreach ((new \DOMXPath($dom))->query('//*[contains(local-name(), "-")]') as $node) { + if ($node instanceof \DOMElement) { + $elements[] = $node; + } + } + return $elements; + } + + /** @return array */ + private function attributes(\DOMElement $element): array { + $attributes = []; + if ($element->hasAttributes()) { + foreach ($element->attributes as $attribute) { + $attributes[$attribute->name] = $attribute->value; + } + } + return $attributes; + } + + private function serialize(\DOMDocument $dom): string { + $html = ''; + foreach ($dom->childNodes as $child) { + $html .= $dom->saveHTML($child); + } + return $html; + } +} diff --git a/modules/Templating/RsvTemplateException.php b/modules/Templating/RsvTemplateException.php new file mode 100644 index 0000000..4c21569 --- /dev/null +++ b/modules/Templating/RsvTemplateException.php @@ -0,0 +1,5 @@ + */ + private array $elements = []; + + public function register(string $tag, RsvTemplateElement $element): void { + $this->elements[$tag] = $element; + } + + public function get(string $tag): ?RsvTemplateElement { + return $this->elements[$tag] ?? null; + } + + /** @return array */ + public function all(): array { + return $this->elements; + } + + /** + * Extends a wp_kses allowlist so the registered custom elements survive + * sanitization of admin HTML. Each element contributes its tag and the + * attributes it declares via symbols(). + * + * @param array $base A wp_kses allowed-html map to extend. + * @return array + */ + public function kses_allowed(array $base = []): array { + foreach ($this->elements as $tag => $element) { + $attributes = []; + foreach ($element->symbols() as $name) { + $attributes[$name] = true; + } + $base[$tag] = $attributes; + } + return $base; + } +} diff --git a/modules/Templating/RsvTemplateSymbols.php b/modules/Templating/RsvTemplateSymbols.php new file mode 100644 index 0000000..96cabb5 --- /dev/null +++ b/modules/Templating/RsvTemplateSymbols.php @@ -0,0 +1,18 @@ + $data */ + public function __construct(private readonly array $data) {} + + /** @return array */ + public function all(): array { + return $this->data; + } + + public function get(string $name, mixed $default = null): mixed { + return $this->data[$name] ?? $default; + } +} diff --git a/reservair.php b/reservair.php index 1d9cd61..8124d11 100644 --- a/reservair.php +++ b/reservair.php @@ -1,4 +1,8 @@ register( 'button', new RsvButtonElementHandler() ); $rsv_form_registry->register( 'reservation', new RsvFormReservationElementHandler() ); $rsv_form_registry->register( 'output-reservation-summary', new RsvReservationSummaryElementHandler() ); + + // Template custom-element registry. Extensions register via the action. + add_action( 'rsv-template-register-custom-elements', function ( \Reservair\Templating\RsvTemplateRegistry $reg ): void { + $reg->register( 'reservation-summary', new RsvReservationSummaryElement() ); + $reg->register( 'reservation-actions', new RsvReservationActionsElement() ); + $reg->register( 'reset-form-button', new RsvResetFormButtonElement() ); + } ); + $rsv_template_registry = new \Reservair\Templating\RsvTemplateRegistry(); + do_action( 'rsv-template-register-custom-elements', $rsv_template_registry ); } add_action( 'plugins_loaded', 'rsv_bootstrap' ); diff --git a/src/admin.js b/src/admin.js index 311cba9..4dd9e24 100644 --- a/src/admin.js +++ b/src/admin.js @@ -1,5 +1,6 @@ -import './client.js'; - +// The client bundle (custom elements, form sender, client styles) is loaded +// separately under the shared `rsv-client` handle — see rsv_enqueue_admin_assets. +// Bundling it here too would run customElements.define() twice in the editor. import '../assets/css/RsvAdminStyle.css'; import { RsvDataGrid } from '../assets/js/elements/RsvDatagrid.js';