#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
@@ -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(),
+10 -10
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, {
'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),