@@ -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' => []],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Reservair\Logger\Logger;
|
||||
use Reservair\Templating\RsvTemplateEngine;
|
||||
|
||||
/**
|
||||
* Handles all outgoing email for the reservation module.
|
||||
@@ -22,11 +23,7 @@ class RsvEmailListener {
|
||||
<p>Rezervace č. {{reservation_id}} čeká na vaše schválení.</p>
|
||||
<p><strong>Datum:</strong> {{date}}</p>
|
||||
<p><strong>Čas:</strong> {{start}} – {{end}}</p>
|
||||
<p>
|
||||
<a href='{{accept_url}}'>Přijmout</a>
|
||||
|
|
||||
<a href='{{refuse_url}}'>Odmítnout</a>
|
||||
</p>
|
||||
<p><reservation-actions></reservation-actions></p>
|
||||
";
|
||||
|
||||
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<string,mixed> $values
|
||||
* @return array<string,string>
|
||||
*/
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
class RsvEmailTemplater {
|
||||
public function render(string $template, array $data) : string {
|
||||
return preg_replace_callback('/{{\s*(\w+)\s*}}/', function($matches) use ($data) {
|
||||
return $data[$matches[1]] ?? '';
|
||||
}, $template);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ class RsvButtonElementHandler implements RsvFormElementHandler {
|
||||
public function draw(RsvFormElementDefinition $element): void {
|
||||
?>
|
||||
<div class="rsv-form-input-group rsv-form-input-short">
|
||||
<button class="rsv-form-btn-primary"><?= $element->getLabel() ?></button>
|
||||
<button class="rsv-form-btn rsv-form-btn-primary"><?= $element->getLabel() ?></button>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<?php
|
||||
|
||||
|
||||
interface RsvFormElementHandler {
|
||||
function draw(RsvFormElementDefinition $def) : void;
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ class RsvFormReservationElementHandler implements RsvFormElementHandler {
|
||||
$name = $element->getName();
|
||||
?>
|
||||
<div class="rsv-form-input-group">
|
||||
<label><?= esc_html($element->getLabel()) ?></label>
|
||||
<label class="rsv-form-label"><?= esc_html($element->getLabel()) ?></label>
|
||||
<rsv-reservation-selector
|
||||
timetable-id="<?= $timetable_id ?>"
|
||||
name="<?= esc_attr($name) ?>"
|
||||
|
||||
@@ -7,8 +7,10 @@ class RsvFormDefinition {
|
||||
|
||||
public string $email_key = "";
|
||||
|
||||
public string $success_message = "";
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $definition Full definition array including 'elements' and 'email_key'.
|
||||
* @param array<int,mixed> $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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use Reservair\Templating\RsvTemplateEngine;
|
||||
|
||||
class RsvFormHtmlRenderer {
|
||||
public function draw(RsvFormDefinition $form): bool {
|
||||
@@ -20,12 +21,37 @@ 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;
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ class RsvTimetableReservationService {
|
||||
->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;
|
||||
}
|
||||
|
||||
@@ -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 <reservation-summary></reservation-summary> 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(); },
|
||||
|
||||
Reference in New Issue
Block a user