+ `;
+}
diff --git a/assets/js/templating/elements/RsvResetFormButtonElement.js b/assets/js/templating/elements/RsvResetFormButtonElement.js
new file mode 100644
index 0000000..e1da65f
--- /dev/null
+++ b/assets/js/templating/elements/RsvResetFormButtonElement.js
@@ -0,0 +1,13 @@
+function esc_html(str) {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+export function reset_form_button_renderer(symbols) {
+ const label = symbols.label ?? 'Odeslat znova';
+ return ``;
+}
diff --git a/includes/Controllers/RsvFormDefinitionController.php b/includes/Controllers/RsvFormDefinitionController.php
index e2cdd88..ba5246a 100644
--- a/includes/Controllers/RsvFormDefinitionController.php
+++ b/includes/Controllers/RsvFormDefinitionController.php
@@ -48,6 +48,12 @@ class RsvFormDefinitionController {
'permission_callback' => [RsvRestPolicy::class, 'admin'],
]);
+ register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P\d+)/submission/latest', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'latest_submit'],
+ 'permission_callback' => [RsvRestPolicy::class, 'admin'],
+ ]);
+
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P\d+)', [
[
'methods' => 'GET',
@@ -132,6 +138,12 @@ class RsvFormDefinitionController {
return new WP_REST_Response(['html' => $html], 200);
}
+ /** Most recent submission's rendered context, for the success-message preview. */
+ function latest_submit(WP_REST_Request $request): WP_REST_Response {
+ $data = (new RsvFormSubmitRepository())->latest_computed((int) $request->get_param('id'));
+ return new WP_REST_Response(['data' => $data], 200);
+ }
+
function update(WP_REST_Request $request): WP_REST_Response {
$id = (int) $request->get_param('id');
$repo = new RsvFormDefinitionRepository();
diff --git a/includes/Controllers/RsvReservationController.php b/includes/Controllers/RsvReservationController.php
index 640cdbc..555d67b 100644
--- a/includes/Controllers/RsvReservationController.php
+++ b/includes/Controllers/RsvReservationController.php
@@ -30,18 +30,6 @@ class RsvReservationController {
'callback' => [$this, 'create'],
'permission_callback' => [RsvRestPolicy::class, 'admin']
]);
-
- register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P\d+)/accept', [
- 'methods' => 'POST',
- 'callback' => [$this, 'accept_by_id'],
- 'permission_callback' => [RsvRestPolicy::class, 'admin'],
- ]);
-
- register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P\d+)/refuse', [
- 'methods' => 'POST',
- 'callback' => [$this, 'refuse_by_id'],
- 'permission_callback' => [RsvRestPolicy::class, 'admin'],
- ]);
}
function get_all(WP_REST_Request $request) {
@@ -66,22 +54,4 @@ class RsvReservationController {
$body = $request->get_json_params();
return $service->create(RsvReservation::from_array($body));
}
-
- function accept_by_id(WP_REST_Request $request): WP_REST_Response {
- try {
- (new RsvTimetableReservationService())->accept_by_reservation_id((int) $request->get_param('id'));
- return new WP_REST_Response(['status' => 'accepted'], 200);
- } catch (InvalidArgumentException $e) {
- return new WP_REST_Response(['error' => $e->getMessage()], 404);
- }
- }
-
- function refuse_by_id(WP_REST_Request $request): WP_REST_Response {
- try {
- (new RsvTimetableReservationService())->refuse_by_reservation_id((int) $request->get_param('id'));
- return new WP_REST_Response(['status' => 'refused'], 200);
- } catch (InvalidArgumentException $e) {
- return new WP_REST_Response(['error' => $e->getMessage()], 404);
- }
- }
}
diff --git a/includes/Controllers/RsvTimetableReservationController.php b/includes/Controllers/RsvTimetableReservationController.php
index abec8b1..1245c4d 100644
--- a/includes/Controllers/RsvTimetableReservationController.php
+++ b/includes/Controllers/RsvTimetableReservationController.php
@@ -28,6 +28,18 @@ class RsvTimetableReservationController {
// refuse() validates against the database before changing state.
'permission_callback' => [RsvRestPolicy::class, 'open'],
]);
+
+ register_rest_route($this->namespace, '/timetable-reservation/(?P\d+)/accept', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'accept_by_id'],
+ 'permission_callback' => [RsvRestPolicy::class, 'admin'],
+ ]);
+
+ register_rest_route($this->namespace, '/timetable-reservation/(?P\d+)/refuse', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'refuse_by_id'],
+ 'permission_callback' => [RsvRestPolicy::class, 'admin'],
+ ]);
}
public function by_timetable(WP_REST_Request $request): WP_REST_Response {
@@ -59,4 +71,26 @@ class RsvTimetableReservationController {
return new WP_REST_Response(['error' => 'Invalid or expired confirmation code.'], 404);
}
}
+
+ function accept_by_id(WP_REST_Request $request) {
+ try {
+ $service = new RsvTimetableReservationService();
+ $service->accept_by_id(intval($request->get_param('id')));
+
+ return new WP_REST_Response(['status' => 'accepted'], 200);
+ } catch (InvalidArgumentException $e) {
+ return new WP_REST_Response(['error' => 'Invalid or expired confirmation code.'], 404);
+ }
+ }
+
+ function refuse_by_id(WP_REST_Request $request) {
+ try {
+ $service = new RsvTimetableReservationService();
+ $service->refuse_by_id(intval($request->get_param('id')));
+
+ return new WP_REST_Response(['status' => 'refused'], 200);
+ } catch (InvalidArgumentException $e) {
+ return new WP_REST_Response(['error' => 'Invalid or expired confirmation code.'], 404);
+ }
+ }
}
diff --git a/includes/Repository/RsvFormSubmitRepository.php b/includes/Repository/RsvFormSubmitRepository.php
index 97320cb..41783d3 100644
--- a/includes/Repository/RsvFormSubmitRepository.php
+++ b/includes/Repository/RsvFormSubmitRepository.php
@@ -16,6 +16,27 @@ class RsvFormSubmitRepository {
]);
}
+ /** Store the derived template context (field values, slots, pricing) for a submission. */
+ public function set_computed(int $id, array $computed): void {
+ Db::update($this->table, ['computed' => json_encode($computed)], ['form_submit_id' => $id]);
+ }
+
+ /**
+ * The derived template context of the most recent submission for a form,
+ * or null when the form has no submission carrying computed data.
+ *
+ * @return array|null
+ */
+ public function latest_computed(int $form_id): ?array {
+ $value = Db::get_var(
+ "SELECT computed FROM {$this->table}
+ WHERE form_id = %d AND computed IS NOT NULL
+ ORDER BY form_submit_id DESC LIMIT 1",
+ [$form_id]
+ );
+ return $value === null ? null : json_decode($value, true);
+ }
+
public function delete(int $id): void {
Db::delete($this->table, ['form_submit_id' => $id]);
}
diff --git a/includes/Repository/RsvTimetableReservationRepository.php b/includes/Repository/RsvTimetableReservationRepository.php
index e20eb7b..0754812 100644
--- a/includes/Repository/RsvTimetableReservationRepository.php
+++ b/includes/Repository/RsvTimetableReservationRepository.php
@@ -88,8 +88,7 @@ class RsvTimetableReservationRepository {
public function get_confirmation_code(int $reservation_id): ?string {
return Db::get_var(
"SELECT c.code FROM {$this->confirmation_table} c
- JOIN {$this->table} tr ON tr.id = c.timetable_reservation_id
- WHERE tr.reservation_id = %d
+ WHERE c.timetable_reservation_id = %d
LIMIT 1",
[$reservation_id]
);
diff --git a/includes/RsvAssetsDefinition.php b/includes/RsvAssetsDefinition.php
index 9b960ce..f76b0ad 100644
--- a/includes/RsvAssetsDefinition.php
+++ b/includes/RsvAssetsDefinition.php
@@ -32,6 +32,9 @@ function rsv_localize_api(string $handle): void {
'count_few' => '%d termíny',
'count_many' => '%d termínů',
'currency' => 'Kč',
+ 'subtotal' => 'Mezisoučet',
+ 'discount' => 'Sleva',
+ 'total' => 'Celkem',
],
'form' => [
'success_title' => 'Rezervace potvrzena!',
diff --git a/includes/RsvInstaller.php b/includes/RsvInstaller.php
index 688d8a9..b53ca92 100644
--- a/includes/RsvInstaller.php
+++ b/includes/RsvInstaller.php
@@ -21,6 +21,7 @@ class RsvInstaller {
form_id bigint unsigned NOT NULL,
submitted_on_utc TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`values` JSON NOT NULL,
+ computed JSON NULL,
PRIMARY KEY (form_submit_id),
CONSTRAINT fk_form_submit_definition
FOREIGN KEY (form_id) REFERENCES {$wpdb->prefix}rsv_form_definition (form_id)
diff --git a/includes/Services/Forms/Handlers/RsvFormReservationElementHandler.php b/includes/Services/Forms/Handlers/RsvFormReservationElementHandler.php
index af0f408..aff466d 100644
--- a/includes/Services/Forms/Handlers/RsvFormReservationElementHandler.php
+++ b/includes/Services/Forms/Handlers/RsvFormReservationElementHandler.php
@@ -74,6 +74,13 @@ class RsvFormReservationElementHandler implements RsvFormElementHandler {
$price_per_block = (float) $def->getAttr('price_per_block', 0);
$result->setValue($name . '_price', $price_per_block * count($payload['timetable_reservations']));
+ $slots = array_map(fn($t) => [
+ 'start_utc' => (new DateTime($t))->format(DateTime::ATOM),
+ 'end_utc' => $this->end_from_start(new DateTime($t), $timetable->block_size)->format(DateTime::ATOM),
+ 'price' => $price_per_block,
+ ], $payload['timetable_reservations']);
+ $result->setValue('slots', array_merge($result->getValue('slots') ?? [], $slots));
+
return true;
}
diff --git a/includes/Services/Forms/RsvFormCalculatedValues.php b/includes/Services/Forms/Pricing/RsvFormCalculatedValues.php
similarity index 64%
rename from includes/Services/Forms/RsvFormCalculatedValues.php
rename to includes/Services/Forms/Pricing/RsvFormCalculatedValues.php
index 4d7ab4a..b47a996 100644
--- a/includes/Services/Forms/RsvFormCalculatedValues.php
+++ b/includes/Services/Forms/Pricing/RsvFormCalculatedValues.php
@@ -16,13 +16,26 @@ final class RsvFormCalculatedValues {
$price_before_discount += (float) $element_calculator($element, $data->getValue($element->getName()));
}
- $discount_pct = (new RsvMembershipService())->discount_for($definition, $data);
+ $discount_detail = (new RsvMembershipService())->discount_detail_for($definition, $data);
+ $discount_pct = $discount_detail['percent'];
$final_price = $calculator->calculate($definition, $data);
+ $subtotal = $price_before_discount;
+ $discount_amount = $subtotal - $final_price;
return [
'price' => $final_price,
'price_before_discount' => $price_before_discount,
'discount_percent' => $discount_pct,
+ 'pricing' => [
+ 'currency' => 'CZK',
+ 'subtotal' => $subtotal,
+ 'discount' => $discount_pct > 0.0 ? [
+ 'percent' => $discount_pct,
+ 'amount' => round($discount_amount, 2),
+ 'reason' => $discount_detail['reason'],
+ ] : null,
+ 'total' => $final_price,
+ ],
];
}
@@ -31,6 +44,6 @@ final class RsvFormCalculatedValues {
* @return list
*/
public static function names(): array {
- return ['price', 'price_before_discount', 'discount_percent'];
+ return ['price', 'price_before_discount', 'discount_percent', 'pricing'];
}
}
diff --git a/includes/Services/Forms/RsvFormHtmlRenderer.php b/includes/Services/Forms/RsvFormHtmlRenderer.php
index 1dbd71f..9f20aa2 100644
--- a/includes/Services/Forms/RsvFormHtmlRenderer.php
+++ b/includes/Services/Forms/RsvFormHtmlRenderer.php
@@ -1,7 +1,5 @@
hasElements()) {
@@ -21,37 +19,12 @@ class RsvFormHtmlRenderer {
- draw_success_template($form); ?>
that the
- * client clones once the form is submitted. A element
- * expands to a placeholder div that RsvFormSender fills with the visitor's
- * selected slots.
- */
- private function draw_success_template(RsvFormDefinition $form): void {
- $message = trim($form->getSuccessMessage());
- if ($message === '') {
- return;
- }
-
- global $rsv_template_registry;
- $engine = new RsvTemplateEngine(registry: $rsv_template_registry);
-
- // Sanitize admin HTML before rendering, allowing the registered template
- // custom elements through so the engine can expand them.
- $allowed = $rsv_template_registry->kses_allowed(wp_kses_allowed_html('post'));
- $html = $engine->render(wp_kses($message, $allowed));
- ?>
- = $html ?>
- false, 'errors' => $result->getErrors()];
}
- return ['success' => true, 'submit_id' => $submit_id, 'values' => $result->getValues()];
+ global $rsv_template_registry;
+ $message = trim($definition->getSuccessMessage());
+ if ($message !== '') {
+ $allowed = $rsv_template_registry->kses_allowed(wp_kses_allowed_html('post'));
+ $template = wp_kses($message, $allowed);
+ } else {
+ $template = '';
+ }
+
+ $data = array_merge($result->getValues(), (new RsvFormCalculatedValues())->for($definition, $form_data));
+
+ try {
+ $submit_repo->set_computed($submit_id, $data);
+ } catch (\Throwable $e) {
+ Logger::error($e);
+ }
+
+ return ['success' => true, 'submit_id' => $submit_id, 'template' => $template, 'data' => $data];
}
/** Remove a submission whose run failed. */
diff --git a/includes/Services/Membership/RsvMembershipService.php b/includes/Services/Membership/RsvMembershipService.php
index ec585d3..dcf4cfe 100644
--- a/includes/Services/Membership/RsvMembershipService.php
+++ b/includes/Services/Membership/RsvMembershipService.php
@@ -2,18 +2,9 @@
class RsvMembershipService {
-
-
- /**
- * Total membership discount for a submission.
- *
- * Each binding names a form field whose submitted value must match a key
- * in the bound program. Matching bindings' discounts are combined per the
- * definition's combine mode: the best single discount, or all summed and
- * capped at 100%.
- */
- public function discount_for(RsvFormDefinition $def, RsvFormData $data): float {
+ public function discount_detail_for(RsvFormDefinition $def, RsvFormData $data): array {
$repo = new RsvMembershipProgramRepository();
+ $matched_programs = [];
$matched_discounts = [];
foreach ($def->getMembershipBindings() as $binding) {
@@ -33,18 +24,37 @@ class RsvMembershipService {
}
if ($repo->key_exists($program_id, $value)) {
+ $program = $repo->get($program_id);
+ if ($program) {
+ $matched_programs[] = $program['name'];
+ }
$matched_discounts[] = $discount;
}
}
if (empty($matched_discounts)) {
- return 0.0;
+ return ['percent' => 0.0, 'reason' => ''];
}
if ($def->getMembershipCombine() === 'sum') {
- return min(100.0, array_sum($matched_discounts));
+ $reason = implode(', ', $matched_programs);
+ return ['percent' => min(100.0, array_sum($matched_discounts)), 'reason' => $reason];
}
- return max($matched_discounts);
+ $max_idx = array_search(max($matched_discounts), $matched_discounts, true);
+ $reason = $matched_programs[$max_idx] ?? '';
+ return ['percent' => max($matched_discounts), 'reason' => $reason];
+ }
+
+ /**
+ * Total membership discount for a submission.
+ *
+ * Each binding names a form field whose submitted value must match a key
+ * in the bound program. Matching bindings' discounts are combined per the
+ * definition's combine mode: the best single discount, or all summed and
+ * capped at 100%.
+ */
+ public function discount_for(RsvFormDefinition $def, RsvFormData $data): float {
+ return $this->discount_detail_for($def, $data)['percent'];
}
}
diff --git a/includes/Services/RsvReservationService.php b/includes/Services/RsvReservationService.php
index 006bca8..e643839 100644
--- a/includes/Services/RsvReservationService.php
+++ b/includes/Services/RsvReservationService.php
@@ -70,6 +70,7 @@ class RsvReservationService {
// (maintainer emails, calendar sync) observe the new reservation.
foreach($reservation->timetable_reservations as $timetable_reservation) {
if($timetable_reservation->is_confirmed === null) {
+ error_log('timetable_reservation->is_confirmed is null: ' . $timetable_reservation->id);
$maintainer_email = (new RsvTimetableRepository())->get_maintainer_email($timetable_reservation->timetable_id);
RsvEventDispatcher::dispatch(new RsvTimetableReservationPendingEvent(
$reservation_id,
diff --git a/includes/Services/RsvTimetableReservationService.php b/includes/Services/RsvTimetableReservationService.php
index abac6e1..6dfe6c9 100644
--- a/includes/Services/RsvTimetableReservationService.php
+++ b/includes/Services/RsvTimetableReservationService.php
@@ -115,18 +115,18 @@ class RsvTimetableReservationService {
return $this->repo->has_pending_confirmation($reservation_id);
}
- public function get_confirmation_code(int $reservation_id): ?string {
- $code = $this->repo->get_confirmation_code($reservation_id);
+ public function get_confirmation_code(int $timetable_reservation_id): ?string {
+ $code = $this->repo->get_confirmation_code($timetable_reservation_id);
return $code;
}
- public function accept_by_reservation_id(int $reservation_id): void {
- $this->set_confirmed_state($this->get_confirmation_code($reservation_id), true);
+ public function accept_by_id(int $timetable_reservation_id): void {
+ $this->set_confirmed_state($this->get_confirmation_code($timetable_reservation_id), true);
}
- public function refuse_by_reservation_id(int $reservation_id): void {
- $this->set_confirmed_state($this->get_confirmation_code($reservation_id), false);
+ public function refuse_by_id(int $timetable_reservation_id): void {
+ $this->set_confirmed_state($this->get_confirmation_code($timetable_reservation_id), false);
}
// TODO: Add requires_confirmation parameter
diff --git a/includes/Views/RsvFormsPage.php b/includes/Views/RsvFormsPage.php
index 6c779ed..9a22702 100644
--- a/includes/Views/RsvFormsPage.php
+++ b/includes/Views/RsvFormsPage.php
@@ -1,6 +1,7 @@
text('name', 'Name', '', true, $form_def['name'])
->select('definition.email_key', 'Email Key', $email_key_options, "Form field that holds the submitter's email address.", true, $definition['email_key'] ?? '')
- ->code('definition.success_message', 'Success message', 'Shown to the visitor after a successful submission. HTML is allowed. Use to display the selected reservations. Leave blank for the default message.', $definition['success_message'] ?? '')
+ ->custom('Success message', function () use ($definition) {
+ $editor = RsvCodeEditor::render('definition.success_message', [
+ 'value' => $definition['success_message'] ?? '',
+ 'mode' => 'text/html',
+ 'rows' => 8,
+ ]);
+ $hint = esc_html('Shown to the visitor after a successful submission. HTML is allowed. Use to display the selected reservations. Leave blank for the default message.');
+ return '
'
+ . '
' . $editor . '
' . $hint . '
'
+ . '
'
+ . 'Live preview'
+ . ''
+ . '
';
+ })
->render();
?>
@@ -169,11 +183,11 @@ class RsvFormsPage extends RsvAdminPage {
->output();
?>
- elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables, $programs, $bindings); ?>
+ elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables, $programs, $bindings, $id); ?>
e.preventDefault(), true);
+ // --- Success message live preview ----------------------------------
+ // Rendered with the same template engine the front-end uses after a real
+ // submission. The data comes from this form's most recent submission, so
+ // {{ tokens }} and mirror a genuine confirmation;
+ // the message text itself updates live as it is edited.
+ const rsv_success_preview_el = document.getElementById('rsv_success_preview');
+ let rsv_success_preview_timer = null;
+ let rsv_success_preview_data = {};
+
+ function rsv_schedule_success_preview() {
+ if (!rsv_success_preview_el) return;
+ clearTimeout(rsv_success_preview_timer);
+ rsv_success_preview_timer = setTimeout(rsv_render_success_preview, 300);
+ }
+
+ function rsv_render_success_preview() {
+ if (!rsv_success_preview_el) return;
+ const form = document.getElementById('= $form_id ?>');
+ const tpl = (form?.querySelector('[name="definition.success_message"]')?.value ?? '').trim();
+ if (tpl === '') {
+ rsv_success_preview_el.innerHTML = '
Leave blank to show the default confirmation message.