9 Commits

Author SHA1 Message Date
Martin Slachta c754e18a82 #18 - membership 2026-06-17 11:15:09 +02:00
Martin Slachta df5f9b1df4 #7 - QR Codes & payments 2026-06-16 19:33:55 +02:00
Martin Slachta cfbdca238c #19 - Merge timetable reservations 2026-06-16 10:54:00 +02:00
Martin Slachta 1c2a176d97 #15 - maintainer email template 2026-06-16 10:33:05 +02:00
Martin Slachta 2890a9b993 #12 - form live preview 2026-06-14 14:13:37 +02:00
Martin Slachta 0fc0addf47 #6 - Output elements 2026-06-14 11:07:14 +02:00
Martin Slachta 3225ff1e10 (#8) - Minimum lead time 2026-06-14 10:01:24 +02:00
Martin Slachta 7d7f748f7a (#3) - templating 2026-06-14 07:21:33 +02:00
Martas f4d3972d07 (#2) - forms improvements (#4)
Co-authored-by: Martin Slachta <martin.slachta@outlook.com>
Reviewed-on: #4
2026-06-12 14:05:49 +00:00
73 changed files with 3636 additions and 1352 deletions
+4 -1
View File
@@ -1,4 +1,7 @@
{ {
"$schema": "/phpactor.schema.json", "$schema": "/phpactor.schema.json",
"language_server_psalm.enabled": true "language_server_psalm.enabled": true,
"indexer.stub_paths": [
"%project_root%/vendor/php-stubs/wordpress-stubs"
]
} }
+71
View File
@@ -0,0 +1,71 @@
# Forms
The reservation is mostly created by filling in a form. For that reason this plugin has it's own _form definition_ feature.
The form definition is an array of element it contains. Each element has a *type*, for example: `input-text`, `input-reservation`, etc.
Keep the form structure and content separate -> when working with the form, take time to figure, if you are working with structure, or existence of something within.
## Rendering
The rendering currently happens on the backend, but that might be wrong. It renders out structure, but interactive components gets rendered on the frontend anyway.
## Submitting
When user submits the form, the `RsvFormSubmitter.js` is called. It collects the values, which we describe in more detail, and then sends it using `fetch` as POST to `reservations/forms/{form_id}`.
### Collecting
The *collecting* is a process with `<form>` DOM subtree as an _input_ and valid input JSON for the defined form at `form_id` as _output_.
We decided to only consider *linearized* form, therefore an single-dimension array of fields. We find little to no value to define JSON structure in the form itself, but rather define linear array of inputs, where each input value is a JSON itself. We explaing why that is beneficial.
1. Even atomic values like `"hello"` or `69` are valid JSON format. This means the collector can assume every value is a JSON and use `JSON.parse(input.value)`.
2. We can change the form structure, but the output remains the same. The use-case is for example, grouping fields for First and Last name into one row, but still keep them separate attributes in final JSON. On the other hand, the country code and telephone number are two fields on the same row, but should be one attribute in the final JSON. Both cases reflect only in the DOM structure.
### Contract
For element to be collected, it has to comply to a contract. Namely, it must have a `rsv-form-field` class and have a `value` attribute. The tagging with `rsv-form-field` class allows that by default, no custom defined component is collected. Therefore you can compose new elements from existing ones and only tag the outer-most as `rsv-form-field`. For example:
```
<rsv-reservation-collector class="rsv-form-field">
<rsv-calendar/>
<rsv-time-slot-selector/>
</rsv-reservation-collector>
```
If the collector would collect exact elements, like `<input>`, it would:
a) require a register
b) be unpredictable
c) require some form of filtering
## Element handlers
Element handler is a PHP class extending `RsvFormElementHandler` class. The parent class has two abstract methods: `draw` & `submit`. The `draw` method is called when the form is being rendered on the backend for the user. The caller is the `RsvFormRenderer` that does not try hard to catch all errors, so be careful with putting logic to `draw`.
The other method `submit` is called by the `RsvFormProcessor`. It definitely should validate the value, but it can also do other things. For example, the element for reservation saves the reservation to the database.
You might have noticed in a reservation example one major flaw. What happens, when any of the next elements fail and cause the whole form _unworthy of submission_? The error handling itself and propagating back to the user is described later. For now let's focus on handling the error correctly.
We thought of two approaches: separate validation & submission steps and rollback. The first approach will not actually solve the issue. It might eliminate some cases, but sometimes error slips through and cause exception in the submission step. For example suppose creating reservation. The validation step can decide it is okay and the time block is available. But right after that another request creates the reservation in the same time block.
We could either do same validation in the submission step, but then, what is the point of the validation step. Another solution is to create a token for the time block. The second solution requires one important thing, the element must know it is an element, to implement the safety gates correctly.
The second approach is using a rollback, that does so when any of the next elements fail and cause the whole form _unworthy of submission_. This way only the element handler has to implement the safe gate.
TODO: generalize it using a property the element submission must have
## Register
Register is a string to object mapping, that maps element IDs to instantiated objects of the handlers. This allows to customize the handler by changing it's state. For example, there is a handler for input text. The constructor can have a predicate lambda that does the validation and allow simple implementation of email, telephone number or any other validation. Or it can be wrapped with regular expression.
## Error handling
When an error occurs, the backend should send back an error response, so that user knowns what he did wrong. The response should contain element's ID, that detected the issue and error ID. The reason why not use a message is for internationalization. The form processor does not know and must not know the user's culture. The internationalization is a separate concern and should be done mapping error ID to a particular message.
Last thing inspired by Problem Details RFC are extensions. The response can contain a dictionary mapping strings to strings that contains structured data about the error. For example, only one time slot of multiple can be occupied. The extensions could contain value of the occupied time slot and the response handler could use it to provide a more detailed error message.
## Success handling
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 `<reservation-summary>`.
+181
View File
@@ -0,0 +1,181 @@
# Implementation Brief: Live form preview in the form editor
## Goal
Add a real-time form preview pane beside the elements editor on the form **edit** page. Reuse the existing PHP renderer (`RsvFormHtmlRenderer`) via a new admin REST endpoint — do **not** build a JS renderer.
## Context you need (don't re-derive)
- **Editor**: `includes/Views/RsvFormsPage.php`. `show_edit()` renders the edit page; `elements_table_script()` emits the inline `<script>` that drives the elements table. Elements live in an in-memory JS object `rsv_elements_source`; `rsv_elements_source.get_all()` returns the elements array (without internal `id`). Nothing is persisted until the form is saved (PUT).
- **Renderer**: `includes/Services/Forms/RsvFormHtmlRenderer.php`. `draw(RsvFormDefinition $form): bool` **echoes** the real `<form>` HTML; returns `false` and echoes nothing when the form has no elements. This is the same renderer the frontend uses (`src/render.php`).
- **Definition object**: `new RsvFormDefinition(string $id, array $definition)` where `$definition` has keys `elements` (array), `email_key`, `success_message`.
- **Assets**: admin already enqueues the `rsv-client` bundle (`rsv_enqueue_admin_assets` in `includes/RsvAssetsDefinition.php`). It defines the custom elements (`<rsv-reservation-selector>`, `<rsv-reservation-summary>`) and bundles the form CSS (`RsvFormStyles.css` -> confirmed in `build/client.css`). **So form HTML injected via `innerHTML` into the admin page is styled and auto-upgrades its custom elements — no extra assets needed.**
- `ReservairServiceAPI.nonce` and `ReservairServiceAPI.restUrl` are available globally in admin (localized onto `rsv-client`).
- The registries `$rsv_form_registry` / `$rsv_template_registry` are populated on `plugins_loaded` (`rsv_bootstrap`), which runs for REST requests too.
- REST errors are handled globally by the `rest_dispatch_request` filter in `includes/RsvRestApiDefinition.php` (any `Throwable` -> 500 JSON `{error}`), so the endpoint needs no try/catch.
- `RsvColumnLayout::split('3:2')->column(fn)->column(fn)->output()` (`modules/Layout/RsvColumnLayout.php`) renders side-by-side columns; each callable just echoes. CSS for `.rsv-cols`/`.rsv-col` already exists (used on the list page).
## The plan is JS-inline only — no webpack rebuild
All new editor JS goes inside the existing inline `<script>` in `elements_table_script()`. Do **not** add CSS to `assets/css/*` (that would require a webpack rebuild); use inline `style=""`/a `<style>` block in the PHP if you want the sticky preview.
---
## Task 1 — Preview endpoint
**File:** `includes/Controllers/RsvFormDefinitionController.php`
In `register_routes()`, add a standalone route (the `\d+` id route won't match the non-numeric `preview`, so order is safe):
```php
register_rest_route($this->namespace, '/' . $this->resource_name . '/preview', [
'methods' => 'POST',
'callback' => [$this, 'preview'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
]);
```
Add the method:
```php
/** Renders an unsaved definition to HTML for the editor's live preview. */
function preview(WP_REST_Request $request): WP_REST_Response {
$definition = $request->get_json_params()['definition'] ?? [];
if (!is_array($definition)) {
$definition = [];
}
ob_start();
(new RsvFormHtmlRenderer())->draw(new RsvFormDefinition('preview', $definition));
$html = ob_get_clean();
return new WP_REST_Response(['html' => $html], 200);
}
```
Notes: no-elements case -> `$html` is `''` (handled client-side). A malformed element payload would throw, but the global filter turns it into a 500 the client catch handles gracefully.
---
## Task 2 — Editor layout (two columns + preview pane)
**File:** `includes/Views/RsvFormsPage.php`, `show_edit()`.
Replace the current Form Elements block (the `<h2>Form Elements</h2>` heading, `#form_elements_table`, the add button, and the submit button — currently around lines 133142) with a split layout. **Keep the existing IDs `form_elements_table` and `rsv_add_element_btn`.** `RsvColumnLayout` is already imported at the top of the file.
```php
<hr>
<h2>Form Elements</h2>
<p>Define the fields that will appear in this form.</p>
<?php
RsvColumnLayout::split('3:2')
->column(function (): void { ?>
<div id="form_elements_table"></div>
<p>
<button type="button" class="button" id="rsv_add_element_btn">+ Add Element</button>
</p>
<p class="submit">
<button type="submit" form="edit_form_definition" class="button button-primary">Update Form Definition</button>
</p>
<?php })
->column(function (): void { ?>
<h3>Live preview</h3>
<div id="rsv_form_preview" class="rsv-form-preview" style="position: sticky; top: 40px;"></div>
<?php })
->output();
?>
```
The container is `rsv-form-preview` (a plain wrapper) — **not** `reservair-form`; the injected HTML brings its own `<form class="reservair-form ...">`.
---
## Task 3 — Editor wiring (inline JS in `elements_table_script()`)
### 3a. Shared definition collector + preview functions
Add at the top level of the script (right after the `rsv_elements_source` IIFE). `<?= $form_id ?>` is the meta form's id (`edit_form_definition` on the edit page):
```js
function rsv_collect_definition() {
const form = document.getElementById('<?= $form_id ?>');
const get = (n) => form?.querySelector(`[name="${n}"]`)?.value ?? '';
return {
name: get('name'),
definition: {
email_key: get('definition.email_key'),
success_message: get('definition.success_message'),
elements: rsv_elements_source.get_all(),
},
};
}
const rsv_preview_el = document.getElementById('rsv_form_preview');
let rsv_preview_timer = null;
function rsv_schedule_preview() {
if (!rsv_preview_el) return;
clearTimeout(rsv_preview_timer);
rsv_preview_timer = setTimeout(rsv_render_preview, 300);
}
function rsv_render_preview() {
if (!rsv_preview_el) return;
fetch('<?= get_rest_url(null, 'reservations/v1/form-definition/preview') ?>', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-WP-Nonce': ReservairServiceAPI.nonce,
},
body: JSON.stringify(rsv_collect_definition()),
})
.then(r => r.ok ? r.json() : r.json().then(e => { throw new Error(e.error || 'Preview failed'); }))
.then(data => {
rsv_preview_el.innerHTML = data.html || '<p class="rsv-preview-empty">No fields to preview yet.</p>';
})
.catch(() => { rsv_preview_el.innerHTML = '<p class="rsv-preview-empty">Preview unavailable.</p>'; });
}
// The preview form is inert: block submission (capture so it works after re-render).
rsv_preview_el?.addEventListener('submit', (e) => e.preventDefault(), true);
```
### 3b. Trigger preview on every mutation
- **Inline edit** (`rsv_render_element_inline_form`, the `builder.build({...})` options): change the two callbacks to also schedule a preview:
```js
on_success: () => { elements_dt.refresh(); rsv_schedule_preview(); },
on_cancel: () => { elements_dt.refresh(); rsv_schedule_preview(); },
```
- **Move Up / Move Down / Remove** `func_action`s and the **`rsv_add_element_btn` onclick**: add `rsv_schedule_preview();` right after each `dt.refresh()` / `elements_dt.refresh()`.
- **Meta fields**: after the existing `rsv_email_key_select` block, add:
```js
const rsv_meta_form = document.getElementById('<?= $form_id ?>');
['name', 'definition.email_key', 'definition.success_message'].forEach((n) => {
const el = rsv_meta_form?.querySelector(`[name="${n}"]`);
el?.addEventListener('input', rsv_schedule_preview);
el?.addEventListener('change', rsv_schedule_preview);
});
```
### 3c. Reuse the collector for saving
Simplify the existing `RsvAdminForm.bind(...)` `transform` to reuse the collector (keeps preview == saved shape):
```js
transform: () => rsv_collect_definition(),
```
### 3d. Initial render
At the end of the script add:
```js
rsv_render_preview();
```
(`rsv_render_preview` no-ops when `#rsv_form_preview` is absent, e.g. the create page, so the shared script stays safe there.)
---
## Edge cases / constraints
- A freshly-added element defaults to `type: 'text'`, which has no registered handler -> silently skipped in the preview until a real type is chosen. Expected.
- The `<rsv-reservation-selector>` in the preview will fetch availability read-only by `timetable-id` — fine, makes the preview realistic.
- Use form id `'preview'` (already in the endpoint) so it never collides with a real numeric form id.
- Don't touch `RsvFormHtmlRenderer` — submission is neutralized client-side.
## Verification
- `php -l includes/Controllers/RsvFormDefinitionController.php includes/Views/RsvFormsPage.php`
- `composer lint` (runs phpcs, phpstan, psalm) — must pass.
- No JS build needed (all editor JS is inline).
- Manual: open a form's edit page -> edit/add/reorder elements and change the meta fields -> preview updates within ~300 ms and matches the frontend form.
+3 -3
View File
@@ -1,7 +1,7 @@
/* ─── Two-column admin layout (Forms page) ──────────────────────────────── */ /* ─── Column layouts (RsvColumnLayout) ──────────────────────────────────── */
/*#col-left { width: 30%; } .rsv-cols { display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: flex-start; }
#col-right { width: 70%; }*/ .rsv-cols > .rsv-col { flex: var(--rsv-col-grow, 1) 1 0; min-width: 18rem; }
/* ─── Inline detail expand row (Reservations page) ──────────────────────── */ /* ─── Inline detail expand row (Reservations page) ──────────────────────── */
@@ -74,6 +74,21 @@
overflow: hidden; overflow: hidden;
} }
/* The component renders inside the host page (e.g. wp-admin in the form editor
preview), whose form/table stylesheets restyle bare table cells and reveal
the day radios that only carry the [hidden] attribute. Assert the calendar's
own appearance here, scoped tightly enough to win that cascade. */
.rsv-calendar input[type="radio"] {
display: none;
}
.rsv-calendar table,
.rsv-calendar th,
.rsv-calendar td {
border: 0;
background: none;
}
/*.calendar button { /*.calendar button {
border: none; border: none;
background-color: transparent; background-color: transparent;
@@ -115,6 +130,7 @@
.rsv-calendar td { .rsv-calendar td {
-webkit-user-select:none;user-select:none; -webkit-user-select:none;user-select:none;
padding: 0;
z-index: -1; z-index: -1;
} }
+48 -44
View File
@@ -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) */ /* Primary CTA (submit / confirm) */
.rsv-form-btn-primary { .rsv-form-btn-primary {
background: #2563eb; background: #2563eb;
color: #fff; color: #fff;
border-radius: 1.375rem;
font-size: 1rem;
padding: 0 calc(1.25rem + 4px);
line-height: 140%;
height: 3.5rem;
border: none; border: none;
font-size: 15px;
font-weight: 600;
font-family: inherit;
cursor: pointer; cursor: pointer;
width: 100%; width: 100%;
transition: background .12s; transition: background .12s;
@@ -43,12 +54,6 @@
margin-right: auto; margin-right: auto;
} }
/*.reservair-form button {
padding: var(--s-3) !important;
font-weight: 400 !important;
}*/
/*.reservair-form button,*/
.rsv-form-input { .rsv-form-input {
border: 1px solid var(--color-gray-300); border: 1px solid var(--color-gray-300);
outline: none; outline: none;
@@ -103,10 +108,10 @@
border-color: var(--color-blue-500); border-color: var(--color-blue-500);
} }
.rsv-form-input input:user-invalid { /*.rsv-form-input input:user-invalid {
border-color: var(--color-red-500); border-color: var(--color-red-500);
box-shadow: 0 0 0 4px color-mix(in oklab,var(--color-red-500)25%,transparent); box-shadow: 0 0 0 4px color-mix(in oklab,var(--color-red-500)25%,transparent);
} }*/
.rsv-form-section { .rsv-form-section {
margin-bottom: var(--s-5); margin-bottom: var(--s-5);
@@ -159,55 +164,54 @@
padding-left: 5pt; padding-left: 5pt;
} }
.rsv-form-input:user-invalid,
.rsv-invalid { .rsv-invalid {
border-color: var(--color-red-500) !important; border-color: var(--color-red-500) !important;
box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-red-500) 25%, transparent) !important; 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; text-align: center;
padding: var(--s-5);
color: var(--color-green-700); color: var(--color-green-700);
width: 320px;
} }
.rsv-success-message p { .rsv-success-msg p {
margin-top: 0;
font-size: 1.125rem; font-size: 1.125rem;
font-weight: 500; font-weight: 500;
} }
.rsv-success-msg h1,
.mesg { .rsv-success-msg h2,
width: 100%; .rsv-success-msg h3,
text-align: center; .rsv-success-msg h4,
line-height: 1rem; .rsv-success-msg h5,
margin-top: var(--s-5); .rsv-success-msg h6
margin-bottom: var(--s-5); {
padding: var(--s-4) 0; margin-top: 0;
margin-bottom: 0.5rem;
} }
.success-mesg-icon { .rsv-summary {
margin-bottom: 2rem;
} }
.mesg-icon svg { .rsv-success-icon {
width: 32px; width: 64px;
height: 32px; height: 64px;
padding: var(--s-2);
border-radius: 50%; 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 { .rsv-timetable-selector {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
background-color: white;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
+8 -2
View File
@@ -77,7 +77,7 @@ label.rsv-slots-slot-time>input:checked + .content>.capacity {
margin-top: 0.375rem; margin-top: 0.375rem;
border: 1.5px solid #e8e8e8; border: 1.5px solid #e8e8e8;
border-radius: 10px; border-radius: 10px;
padding: 9px 12px; padding: 0.25rem;
cursor: pointer; cursor: pointer;
transition: border-color .12s, background .12s, color .12s; transition: border-color .12s, background .12s, color .12s;
display: flex; display: flex;
@@ -87,7 +87,7 @@ label.rsv-slots-slot-time>input:checked + .content>.capacity {
font-weight: 600; font-weight: 600;
} }
.rsv-slots-slot:hover:not(.rsv-slots-slot-full):not(.rsv-slots-slot-selected) { .rsv-slots-slot:hover:not(.rsv-slots-slot-full):not(.rsv-slots-slot-too-soon):not(.rsv-slots-slot-selected) {
border-color: #2563eb; border-color: #2563eb;
background: #f5f8ff; background: #f5f8ff;
} }
@@ -115,6 +115,12 @@ label.rsv-slots-slot-time>input:checked + .content>.capacity {
cursor: not-allowed; cursor: not-allowed;
} }
/* Within minimum lead time — available but not yet bookable */
.rsv-slots-slot-too-soon {
opacity: 0.45;
cursor: not-allowed;
}
/* Selected */ /* Selected */
.rsv-slots-slot-selected { .rsv-slots-slot-selected {
background: #2563eb; background: #2563eb;
@@ -0,0 +1,4 @@
import { RsvDataSource } from './RsvDataSource.js';
export const RsvMembershipKeyResource = (program_id) =>
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/membership-program/' + program_id + '/keys');
@@ -0,0 +1,4 @@
import { RsvDataSource } from './RsvDataSource.js';
export const RsvMembershipProgramResource = () =>
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/membership-program');
+8
View File
@@ -179,6 +179,14 @@ export const RsvCalendarPicker = (() => {
new InputEvent('change', { bubbles: true, cancelable: true, composed: true }) 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'); container.classList.add('rsv-calendar');
@@ -37,6 +37,16 @@ class RsvReservationSelector extends HTMLElement {
this.querySelectorAll('.rsv-slots-slot-selected').forEach(s => s.classList.remove('rsv-slots-slot-selected')); this.querySelectorAll('.rsv-slots-slot-selected').forEach(s => s.classList.remove('rsv-slots-slot-selected'));
this._slots = []; this._slots = [];
this._commit(); 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 ------------------------------------------------------------ // ---- Private ------------------------------------------------------------
+25 -1
View File
@@ -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 = `
<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 ------------------------------------------------------------ // ---- Private ------------------------------------------------------------
_build() { _build() {
@@ -51,7 +76,6 @@ class RsvReservationSummary extends HTMLElement {
const all_slots = [...this._all_slots.values()].flatMap(({ slots, price_per_block }) => const all_slots = [...this._all_slots.values()].flatMap(({ slots, price_per_block }) =>
slots.map(s => ({ ...s, price_per_block })) slots.map(s => ({ ...s, price_per_block }))
); );
console.log(all_slots);
const n = all_slots.length; const n = all_slots.length;
const list = this.querySelector('.rsv-summary-list'); const list = this.querySelector('.rsv-summary-list');
+19 -9
View File
@@ -35,11 +35,19 @@ class RsvTimeline extends HTMLElement {
this._render(); 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 ------------------------------------------------------------ // ---- Private ------------------------------------------------------------
_on_click(event) { _on_click(event) {
const slot = event.target.closest('.rsv-slots-slot'); const slot = event.target.closest('.rsv-slots-slot');
if (slot && !slot.classList.contains('rsv-slots-slot-full')) { if (slot && !slot.classList.contains('rsv-slots-slot-full') && !slot.classList.contains('rsv-slots-slot-too-soon')) {
slot.classList.toggle('rsv-slots-slot-selected'); slot.classList.toggle('rsv-slots-slot-selected');
slot.dispatchEvent(new Event('input', { bubbles: true })); slot.dispatchEvent(new Event('input', { bubbles: true }));
} }
@@ -59,10 +67,6 @@ class RsvTimeline extends HTMLElement {
const occupancy = await RsvTimetableService.get_availability_for_date(this.timetableId, this.date); const occupancy = await RsvTimetableService.get_availability_for_date(this.timetableId, this.date);
if (v !== this._version) return; if (v !== this._version) return;
if(occupancy.length === 0) {
this.replaceChildren(this._notice(s.no_blocks));
return;
}
const header = document.createElement('div'); const header = document.createElement('div');
header.classList.add('rsv-slots-label'); header.classList.add('rsv-slots-label');
@@ -70,9 +74,14 @@ class RsvTimeline extends HTMLElement {
weekday: 'long', day: 'numeric', month: 'long', weekday: 'long', day: 'numeric', month: 'long',
}).replace(',', ''); }).replace(',', '');
if(occupancy.length === 0) {
this.replaceChildren(header, this._notice(s.no_blocks));
return;
}
const blocks = []; const blocks = [];
for (const { from_minutes, to_minutes, block_size_in_minutes, occupancy: block_occ } of occupancy) { for (const { from_minutes, to_minutes, block_size_in_minutes, occupancy: block_occ, lead_time_minutes } of occupancy) {
if (from_minutes === to_minutes || block_occ.length === 0) { if (from_minutes === to_minutes || block_occ.length === 0) {
continue; continue;
} }
@@ -80,7 +89,7 @@ class RsvTimeline extends HTMLElement {
const from_block = parseInt(from_minutes) / block_size_in_minutes; const from_block = parseInt(from_minutes) / block_size_in_minutes;
const time_slots = block_occ.map((occ, i) => const time_slots = block_occ.map((occ, i) =>
this._block(this.date, occ, block_size_in_minutes, from_block + i) this._block(this.date, occ, block_size_in_minutes, from_block + i, lead_time_minutes?.[i] ?? 0)
); );
const time_slot_group = document.createElement('div'); const time_slot_group = document.createElement('div');
@@ -96,7 +105,7 @@ class RsvTimeline extends HTMLElement {
} }
} }
_block(date, left, block_size, idx) { _block(date, left, block_size, idx, min_lead_time_minutes = 0) {
const from = new Date(date); const from = new Date(date);
from.setHours(0, idx * block_size, 0, 0); from.setHours(0, idx * block_size, 0, 0);
@@ -107,7 +116,8 @@ class RsvTimeline extends HTMLElement {
cell.classList.add('rsv-slots-slot', 'rsv-slots-slot-available'); cell.classList.add('rsv-slots-slot', 'rsv-slots-slot-available');
cell.dataset.start_utc = from.toISOString(); cell.dataset.start_utc = from.toISOString();
cell.dataset.end_utc = to.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');
else if (from < new Date(Date.now() + min_lead_time_minutes * 60_000)) cell.classList.add('rsv-slots-slot-too-soon');
const time_el = document.createElement('span'); const time_el = document.createElement('span');
time_el.classList.add('rsv-slots-slot-time'); time_el.classList.add('rsv-slots-slot-time');
+44 -16
View File
@@ -56,24 +56,14 @@ export const RsvFormSender = {
svg.appendChild(path); svg.appendChild(path);
const icon = document.createElement('div'); const icon = document.createElement('div');
icon.className = 'success-icon'; icon.className = 'rsv-success-icon';
icon.appendChild(svg); icon.appendChild(svg);
const title = document.createElement('div'); const body = this.build_success_body(form, s);
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 state = document.createElement('div'); const state = document.createElement('div');
state.className = 'success-state'; state.className = 'rsv-success-state';
state.append(icon, title, subtitle, reset_btn); state.append(icon, body);
const msg = document.createElement('div'); const msg = document.createElement('div');
msg.appendChild(state); msg.appendChild(state);
@@ -81,12 +71,50 @@ export const RsvFormSender = {
existing.forEach(child => child.style.display = 'none'); existing.forEach(child => child.style.display = 'none');
wrapper.appendChild(msg); 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(); msg.remove();
form.reset(); 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); this.clear_feedback(form);
existing.forEach(child => child.style.display = ''); 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 <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');
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) { set_loading(form, is_loading) {
+4 -7
View File
@@ -1,6 +1,6 @@
{ {
"require": { "require": {
"chillerlan/php-qrcode": "^5.0" "chillerlan/php-qrcode": "^6.0.1"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@@ -12,17 +12,14 @@
"files": [ "files": [
"includes/RsvAdminMenuDefinition.php", "includes/RsvAdminMenuDefinition.php",
"includes/RsvAssetsDefinition.php", "includes/RsvAssetsDefinition.php",
"includes/RsvRestApiDefinition.php", "includes/RsvRestApiDefinition.php"
"includes/Views/RsvFormsPage.php",
"includes/Views/RsvReservationsPage.php",
"includes/Views/RsvTimetablePage.php",
"includes/Views/RsvGoogleCalendarSettingsPage.php"
] ]
}, },
"require-dev": { "require-dev": {
"johnpbloch/wordpress-core": "^6.9", "johnpbloch/wordpress-core": "^6.9",
"vimeo/psalm": "^6.16", "vimeo/psalm": "^6.16",
"humanmade/psalm-plugin-wordpress": "^3.1" "humanmade/psalm-plugin-wordpress": "^3.1",
"php-stubs/wordpress-stubs": "^6.9"
}, },
"scripts": { "scripts": {
"lint": ["phpcs", "phpstan analyse", "psalm --find-dead-code"] "lint": ["phpcs", "phpstan analyse", "psalm --find-dead-code"]
Generated
+68 -64
View File
@@ -4,40 +4,44 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "c4e0cf49edc636becbde269300f26001", "content-hash": "c898c79e7e1cf9625a3a3f757c17ee0d",
"packages": [ "packages": [
{ {
"name": "chillerlan/php-qrcode", "name": "chillerlan/php-qrcode",
"version": "5.0.3", "version": "6.0.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/chillerlan/php-qrcode.git", "url": "https://github.com/chillerlan/php-qrcode.git",
"reference": "42e215640e9ebdd857570c9e4e52245d1ee51de2" "reference": "49006e34bd5328f163e80329e7312f34dceea59b"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/chillerlan/php-qrcode/zipball/42e215640e9ebdd857570c9e4e52245d1ee51de2", "url": "https://api.github.com/repos/chillerlan/php-qrcode/zipball/49006e34bd5328f163e80329e7312f34dceea59b",
"reference": "42e215640e9ebdd857570c9e4e52245d1ee51de2", "reference": "49006e34bd5328f163e80329e7312f34dceea59b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"chillerlan/php-settings-container": "^2.1.6 || ^3.2.1", "chillerlan/php-settings-container": "^3.2.1",
"ext-mbstring": "*", "ext-mbstring": "*",
"php": "^7.4 || ^8.0" "php": "^8.2"
}, },
"require-dev": { "require-dev": {
"chillerlan/php-authenticator": "^4.3.1 || ^5.2.1", "chillerlan/php-authenticator": "^5.3",
"ext-fileinfo": "*", "ext-fileinfo": "*",
"phan/phan": "^5.4.5", "intervention/image": "^3.11",
"phpcompatibility/php-compatibility": "10.x-dev", "phan/phan": "^6.0.1",
"phpbench/phpbench": "^1.4",
"phpmd/phpmd": "^2.15", "phpmd/phpmd": "^2.15",
"phpunit/phpunit": "^9.6", "phpstan/phpstan": "^2.1.40",
"setasign/fpdf": "^1.8.2", "phpstan/phpstan-deprecation-rules": "^2.0.4",
"slevomat/coding-standard": "^8.15", "phpunit/phpunit": "^11.5",
"squizlabs/php_codesniffer": "^3.11" "setasign/fpdf": "^1.8.6",
"slevomat/coding-standard": "^8.28",
"squizlabs/php_codesniffer": "^4.0"
}, },
"suggest": { "suggest": {
"chillerlan/php-authenticator": "Yet another Google authenticator! Also creates URIs for mobile apps.", "chillerlan/php-authenticator": "Yet another Google authenticator! Also creates URIs for mobile apps.",
"intervention/image": "More advanced GD and ImageMagick output.",
"setasign/fpdf": "Required to use the QR FPDF output.", "setasign/fpdf": "Required to use the QR FPDF output.",
"simple-icons/simple-icons": "SVG icons that you can use to embed as logos in the QR Code" "simple-icons/simple-icons": "SVG icons that you can use to embed as logos in the QR Code"
}, },
@@ -75,7 +79,7 @@
"homepage": "https://github.com/chillerlan/php-qrcode/graphs/contributors" "homepage": "https://github.com/chillerlan/php-qrcode/graphs/contributors"
} }
], ],
"description": "A QR Code generator and reader with a user-friendly API. PHP 7.4+", "description": "A QR Code generator and reader with a user-friendly API. PHP 8.2+",
"homepage": "https://github.com/chillerlan/php-qrcode", "homepage": "https://github.com/chillerlan/php-qrcode",
"keywords": [ "keywords": [
"phpqrcode", "phpqrcode",
@@ -97,20 +101,20 @@
"type": "Ko-Fi" "type": "Ko-Fi"
} }
], ],
"time": "2024-11-21T16:12:34+00:00" "time": "2026-03-18T21:21:07+00:00"
}, },
{ {
"name": "chillerlan/php-settings-container", "name": "chillerlan/php-settings-container",
"version": "3.2.1", "version": "3.3.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/chillerlan/php-settings-container.git", "url": "https://github.com/chillerlan/php-settings-container.git",
"reference": "95ed3e9676a1d47cab2e3174d19b43f5dbf52681" "reference": "a0a487cbf5344f721eb504bf0f59bada40c381b7"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/chillerlan/php-settings-container/zipball/95ed3e9676a1d47cab2e3174d19b43f5dbf52681", "url": "https://api.github.com/repos/chillerlan/php-settings-container/zipball/a0a487cbf5344f721eb504bf0f59bada40c381b7",
"reference": "95ed3e9676a1d47cab2e3174d19b43f5dbf52681", "reference": "a0a487cbf5344f721eb504bf0f59bada40c381b7",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -118,11 +122,13 @@
"php": "^8.1" "php": "^8.1"
}, },
"require-dev": { "require-dev": {
"phan/phan": "^5.5.2",
"phpmd/phpmd": "^2.15", "phpmd/phpmd": "^2.15",
"phpstan/phpstan": "^1.11", "phpstan/phpstan": "^2.1.31",
"phpstan/phpstan-deprecation-rules": "^1.2", "phpstan/phpstan-deprecation-rules": "^2.0.3",
"phpunit/phpunit": "^10.5", "phpunit/phpunit": "^10.5",
"squizlabs/php_codesniffer": "^3.10" "slevomat/coding-standard": "^8.22",
"squizlabs/php_codesniffer": "^4.0"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -147,7 +153,8 @@
"Settings", "Settings",
"configuration", "configuration",
"container", "container",
"helper" "helper",
"property hook"
], ],
"support": { "support": {
"issues": "https://github.com/chillerlan/php-settings-container/issues", "issues": "https://github.com/chillerlan/php-settings-container/issues",
@@ -163,7 +170,7 @@
"type": "ko_fi" "type": "ko_fi"
} }
], ],
"time": "2024-07-16T11:13:48+00:00" "time": "2026-03-20T21:10:52+00:00"
} }
], ],
"packages-dev": [ "packages-dev": [
@@ -692,16 +699,16 @@
}, },
{ {
"name": "amphp/process", "name": "amphp/process",
"version": "v2.0.3", "version": "v2.1.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/amphp/process.git", "url": "https://github.com/amphp/process.git",
"reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d" "reference": "583959df17d00304ad7b0b32285373f985935643"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/amphp/process/zipball/52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", "url": "https://api.github.com/repos/amphp/process/zipball/583959df17d00304ad7b0b32285373f985935643",
"reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", "reference": "583959df17d00304ad7b0b32285373f985935643",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -715,7 +722,7 @@
"amphp/php-cs-fixer-config": "^2", "amphp/php-cs-fixer-config": "^2",
"amphp/phpunit-util": "^3", "amphp/phpunit-util": "^3",
"phpunit/phpunit": "^9", "phpunit/phpunit": "^9",
"psalm/phar": "^5.4" "psalm/phar": "6.16.1"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -748,7 +755,7 @@
"homepage": "https://amphp.org/process", "homepage": "https://amphp.org/process",
"support": { "support": {
"issues": "https://github.com/amphp/process/issues", "issues": "https://github.com/amphp/process/issues",
"source": "https://github.com/amphp/process/tree/v2.0.3" "source": "https://github.com/amphp/process/tree/v2.1.0"
}, },
"funding": [ "funding": [
{ {
@@ -756,7 +763,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2024-04-19T03:13:44+00:00" "time": "2026-05-31T15:11:55+00:00"
}, },
{ {
"name": "amphp/serialization", "name": "amphp/serialization",
@@ -986,28 +993,29 @@
}, },
{ {
"name": "composer/pcre", "name": "composer/pcre",
"version": "3.3.2", "version": "3.4.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/composer/pcre.git", "url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" "reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", "reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.4 || ^8.0" "php": "^7.4 || ^8.0"
}, },
"conflict": { "conflict": {
"phpstan/phpstan": "<1.11.10" "phpstan/phpstan": "<2.2.2"
}, },
"require-dev": { "require-dev": {
"phpstan/phpstan": "^1.12 || ^2", "phpstan/phpstan": "^2",
"phpstan/phpstan-strict-rules": "^1 || ^2", "phpstan/phpstan-deprecation-rules": "^2",
"phpunit/phpunit": "^8 || ^9" "phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "^9"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
@@ -1045,7 +1053,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/composer/pcre/issues", "issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2" "source": "https://github.com/composer/pcre/tree/3.4.0"
}, },
"funding": [ "funding": [
{ {
@@ -1055,13 +1063,9 @@
{ {
"url": "https://github.com/composer", "url": "https://github.com/composer",
"type": "github" "type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
} }
], ],
"time": "2024-11-12T16:29:46+00:00" "time": "2026-06-07T11:47:49+00:00"
}, },
{ {
"name": "composer/semver", "name": "composer/semver",
@@ -1567,16 +1571,16 @@
}, },
{ {
"name": "johnpbloch/wordpress-core", "name": "johnpbloch/wordpress-core",
"version": "6.9.0", "version": "6.9.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/johnpbloch/wordpress-core.git", "url": "https://github.com/johnpbloch/wordpress-core.git",
"reference": "4626d4e896c36ab77a69ce58627bc76243b5dd07" "reference": "13e02e0047ca5c8ec8dc837c2de8a5bd3583b879"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/johnpbloch/wordpress-core/zipball/4626d4e896c36ab77a69ce58627bc76243b5dd07", "url": "https://api.github.com/repos/johnpbloch/wordpress-core/zipball/13e02e0047ca5c8ec8dc837c2de8a5bd3583b879",
"reference": "4626d4e896c36ab77a69ce58627bc76243b5dd07", "reference": "13e02e0047ca5c8ec8dc837c2de8a5bd3583b879",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1584,7 +1588,7 @@
"php": ">=7.2.24" "php": ">=7.2.24"
}, },
"provide": { "provide": {
"wordpress/core-implementation": "6.9.0" "wordpress/core-implementation": "6.9.4"
}, },
"type": "wordpress-core", "type": "wordpress-core",
"notification-url": "https://packagist.org/downloads/", "notification-url": "https://packagist.org/downloads/",
@@ -1611,7 +1615,7 @@
"source": "https://core.trac.wordpress.org/browser", "source": "https://core.trac.wordpress.org/browser",
"wiki": "https://codex.wordpress.org/" "wiki": "https://codex.wordpress.org/"
}, },
"time": "2025-12-02T19:10:58+00:00" "time": "2026-03-11T15:27:36+00:00"
}, },
{ {
"name": "kelunik/certificate", "name": "kelunik/certificate",
@@ -3246,16 +3250,16 @@
}, },
{ {
"name": "symfony/polyfill-mbstring", "name": "symfony/polyfill-mbstring",
"version": "v1.38.1", "version": "v1.38.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git", "url": "https://github.com/symfony/polyfill-mbstring.git",
"reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
"reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3307,7 +3311,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2"
}, },
"funding": [ "funding": [
{ {
@@ -3327,7 +3331,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-26T12:51:13+00:00" "time": "2026-05-27T06:59:30+00:00"
}, },
{ {
"name": "symfony/polyfill-php84", "name": "symfony/polyfill-php84",
@@ -3786,16 +3790,16 @@
}, },
{ {
"name": "webmozart/assert", "name": "webmozart/assert",
"version": "2.4.0", "version": "2.4.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/webmozarts/assert.git", "url": "https://github.com/webmozarts/assert.git",
"reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70",
"reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3846,9 +3850,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/webmozarts/assert/issues", "issues": "https://github.com/webmozarts/assert/issues",
"source": "https://github.com/webmozarts/assert/tree/2.4.0" "source": "https://github.com/webmozarts/assert/tree/2.4.1"
}, },
"time": "2026-05-20T13:07:01+00:00" "time": "2026-06-15T15:31:57+00:00"
}, },
{ {
"name": "wp-hooks/wordpress-core", "name": "wp-hooks/wordpress-core",
@@ -19,6 +19,7 @@ class RsvFormDefinitionController {
'required' => false, 'required' => false,
'properties' => [ 'properties' => [
'email_key' => ['type' => 'string', 'required' => false], 'email_key' => ['type' => 'string', 'required' => false],
'success_message' => ['type' => 'string', 'required' => false],
'elements' => ['type' => 'array', 'default' => []], 'elements' => ['type' => 'array', 'default' => []],
], ],
], ],
@@ -41,6 +42,12 @@ class RsvFormDefinitionController {
], ],
]); ]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/preview', [
'methods' => 'POST',
'callback' => [$this, 'preview'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)', [ register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)', [
[ [
'methods' => 'GET', 'methods' => 'GET',
@@ -78,10 +85,17 @@ class RsvFormDefinitionController {
} }
function create(WP_REST_Request $request): WP_REST_Response { function create(WP_REST_Request $request): WP_REST_Response {
$definition = $request->get_param('definition') ?? [];
$errors = (new RsvFormDefinitionValidator())->validate($definition);
if ($errors !== []) {
return new WP_REST_Response(['error' => implode(' ', $errors)], 422);
}
try { try {
$id = (new RsvFormDefinitionRepository())->add( $id = (new RsvFormDefinitionRepository())->add(
$request->get_param('name'), $request->get_param('name'),
$request->get_param('definition') ?? [] $definition
); );
} catch(Throwable $e) { } catch(Throwable $e) {
Logger::error($e); Logger::error($e);
@@ -104,6 +118,20 @@ class RsvFormDefinitionController {
return new WP_REST_Response(null, 204); return new WP_REST_Response(null, 204);
} }
/** Renders an unsaved definition to HTML for the editor's live preview. */
function preview(WP_REST_Request $request): WP_REST_Response {
$definition = $request->get_json_params()['definition'] ?? [];
if (!is_array($definition)) {
$definition = [];
}
ob_start();
(new RsvFormHtmlRenderer())->draw(new RsvFormDefinition('preview', $definition));
$html = ob_get_clean();
return new WP_REST_Response(['html' => $html], 200);
}
function update(WP_REST_Request $request): WP_REST_Response { function update(WP_REST_Request $request): WP_REST_Response {
$id = (int) $request->get_param('id'); $id = (int) $request->get_param('id');
$repo = new RsvFormDefinitionRepository(); $repo = new RsvFormDefinitionRepository();
@@ -112,7 +140,14 @@ class RsvFormDefinitionController {
return new WP_REST_Response(['error' => 'Not found'], 404); return new WP_REST_Response(['error' => 'Not found'], 404);
} }
$repo->update($id, $request->get_param('name'), $request->get_param('definition')); $definition = $request->get_param('definition') ?? [];
$errors = (new RsvFormDefinitionValidator())->validate($definition);
if ($errors !== []) {
return new WP_REST_Response(['error' => implode(' ', $errors)], 422);
}
$repo->update($id, $request->get_param('name'), $definition);
return new WP_REST_Response(null, 204); return new WP_REST_Response(null, 204);
} }
@@ -0,0 +1,183 @@
<?php
class RsvMembershipProgramController {
use RsvPagedResponseTrait;
private string $namespace = 'reservations/v1';
private string $resource_name = 'membership-program';
private static function schema(): array {
return [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer', 'readonly' => true],
'name' => ['type' => 'string', 'required' => true, 'minLength' => 1],
'active' => ['type' => 'boolean', 'default' => true],
],
];
}
public function register_routes(): void {
register_rest_route($this->namespace, '/' . $this->resource_name, [
[
'methods' => 'GET',
'callback' => [$this, 'index'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
[
'methods' => 'POST',
'callback' => [$this, 'create'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
'args' => self::input_args(self::schema()),
],
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)', [
[
'methods' => 'GET',
'callback' => [$this, 'show'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
[
'methods' => 'PUT',
'callback' => [$this, 'update'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
'args' => self::input_args(self::schema()),
],
[
'methods' => 'DELETE',
'callback' => [$this, 'delete'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)/keys', [
[
'methods' => 'GET',
'callback' => [$this, 'index_keys'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
[
'methods' => 'POST',
'callback' => [$this, 'add_key'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)/keys/(?P<key_id>\d+)', [
[
'methods' => 'DELETE',
'callback' => [$this, 'delete_key'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
]);
}
public function index(WP_REST_Request $request): WP_REST_Response {
[$skip, $limit] = self::paging($request);
$repo = new RsvMembershipProgramRepository();
$programs = array_map(fn($p) => $p->to_array(), $repo->all($limit, $skip));
return $this->paged_response($programs, $repo->count_all());
}
public function create(WP_REST_Request $request): WP_REST_Response {
$params = $request->get_json_params();
$name = $params['name'] ?? '';
$active = $params['active'] ?? true;
if (trim($name) === '') {
throw new InvalidArgumentException('Name is required.');
}
$repo = new RsvMembershipProgramRepository();
$id = $repo->add($name, $active);
return new WP_REST_Response(RsvMembershipProgram::from_array(['id' => $id, 'name' => $name, 'active' => $active])->to_array(), 201);
}
public function show(WP_REST_Request $request): WP_REST_Response {
$id = (int) $request->get_param('id');
$repo = new RsvMembershipProgramRepository();
$program = $repo->get($id);
if ($program === null) {
return new WP_REST_Response(['error' => 'Not found'], 404);
}
return new WP_REST_Response($program, 200);
}
public function update(WP_REST_Request $request): WP_REST_Response {
$id = (int) $request->get_param('id');
$params = $request->get_json_params();
$name = $params['name'] ?? '';
$active = $params['active'] ?? true;
if (trim($name) === '') {
throw new InvalidArgumentException('Name is required.');
}
$repo = new RsvMembershipProgramRepository();
if ($repo->get($id) === null) {
return new WP_REST_Response(['error' => 'Not found'], 404);
}
$repo->update($id, $name, $active);
return new WP_REST_Response(['id' => $id, 'name' => $name, 'active' => $active], 200);
}
public function delete(WP_REST_Request $request): WP_REST_Response {
$id = (int) $request->get_param('id');
$repo = new RsvMembershipProgramRepository();
if ($repo->get($id) === null) {
return new WP_REST_Response(['error' => 'Not found'], 404);
}
$repo->delete($id);
return new WP_REST_Response(['ok' => true], 200);
}
public function index_keys(WP_REST_Request $request): WP_REST_Response {
[$skip, $limit] = self::paging($request);
$program_id = (int) $request->get_param('id');
$repo = new RsvMembershipProgramRepository();
if ($repo->get($program_id) === null) {
return new WP_REST_Response(['error' => 'Program not found'], 404);
}
$keys = array_map(fn($k) => $k->to_array(), $repo->keys($program_id, $limit, $skip));
return $this->paged_response($keys, $repo->count_keys($program_id));
}
public function add_key(WP_REST_Request $request): WP_REST_Response {
$program_id = (int) $request->get_param('id');
$params = $request->get_json_params();
$key_value = $params['key_value'] ?? '';
if (trim($key_value) === '') {
throw new InvalidArgumentException('Key value is required.');
}
$repo = new RsvMembershipProgramRepository();
if ($repo->get($program_id) === null) {
return new WP_REST_Response(['error' => 'Program not found'], 404);
}
$key_id = $repo->add_key($program_id, $key_value);
return new WP_REST_Response(['id' => $key_id, 'program_id' => $program_id, 'key_value' => $key_value], 201);
}
public function delete_key(WP_REST_Request $request): WP_REST_Response {
$program_id = (int) $request->get_param('id');
$key_id = (int) $request->get_param('key_id');
$repo = new RsvMembershipProgramRepository();
if ($repo->get($program_id) === null) {
return new WP_REST_Response(['error' => 'Program not found'], 404);
}
$repo->delete_key($key_id);
return new WP_REST_Response(['ok' => true], 200);
}
}
@@ -2,6 +2,7 @@
class RsvTimetableReservationCreatedEvent { class RsvTimetableReservationCreatedEvent {
public function __construct( public function __construct(
public RsvTimetableReservation $reservation public RsvTimetableReservation $reservation,
public RsvReservation $parent
) {} ) {}
} }
@@ -9,7 +9,7 @@ class RsvTimetableReservationPendingEvent {
public function __construct( public function __construct(
public int $reservation_id, public int $reservation_id,
public RsvTimetableReservation $reservation, public RsvTimetableReservation $reservation,
public string $code, public ?string $code,
public string $maintainer_email public string $maintainer_email
) {} ) {}
} }
+79 -32
View File
@@ -1,16 +1,19 @@
<?php <?php
use Reservair\Logger\Logger; use Reservair\Logger\Logger;
use Reservair\Templating\RsvTemplateEngine;
/** /**
* Handles all outgoing email for the reservation module. * Handles all outgoing email for the reservation module.
* *
* Two responsibilities: * Two responsibilities:
* 1. Maintainer approval request — fires per timetable item that needs * 1. Maintainer approval request — fires per timetable item that needs
* confirmation; template is hardcoded (not admin-configurable). * confirmation, carrying the accept/refuse links the maintainer acts on.
* 2. User notification on form close — fires once per form submission when * 2. User notification on form close — fires once per form submission when
* every reservation item reaches a terminal state. Subject/body come from * every reservation item reaches a terminal state.
* the form definition's reservation element `email_templates` attr. *
* Both subjects/bodies come from the reservation element's `email_templates`
* attr, falling back to the hardcoded defaults when the admin left them blank.
* *
* Exceptions are swallowed so a mail failure never rolls back a transaction. * Exceptions are swallowed so a mail failure never rolls back a transaction.
*/ */
@@ -22,11 +25,7 @@ class RsvEmailListener {
<p>Rezervace č. {{reservation_id}} čeká na vaše schválení.</p> <p>Rezervace č. {{reservation_id}} čeká na vaše schválení.</p>
<p><strong>Datum:</strong> {{date}}</p> <p><strong>Datum:</strong> {{date}}</p>
<p><strong>Čas:</strong> {{start}} {{end}}</p> <p><strong>Čas:</strong> {{start}} {{end}}</p>
<p> <p><reservation-actions></reservation-actions></p>
<a href='{{accept_url}}'>Přijmout</a>
&nbsp;|&nbsp;
<a href='{{refuse_url}}'>Odmítnout</a>
</p>
"; ";
private const string DEFAULT_ACCEPTED_SUBJECT = 'Rezervace přijata'; private const string DEFAULT_ACCEPTED_SUBJECT = 'Rezervace přijata';
@@ -48,39 +47,81 @@ class RsvEmailListener {
public static function on_pending(RsvTimetableReservationPendingEvent $event): void { public static function on_pending(RsvTimetableReservationPendingEvent $event): void {
try { try {
global $rsv_template_registry;
$engine = new RsvTemplateEngine(registry: $rsv_template_registry);
$tz = wp_timezone(); $tz = wp_timezone();
$start_dt = (clone $event->reservation->start_utc)->setTimezone($tz); $start_dt = (clone $event->reservation->start_utc)->setTimezone($tz);
$end_dt = (clone $event->reservation->end_utc)->setTimezone($tz); $end_dt = (clone $event->reservation->end_utc)->setTimezone($tz);
$body = (new RsvEmailTemplater())->render(self::DEFAULT_PENDING_BODY, [ [$tpl, $form_values] = self::resolve_maintainer_template($event);
// Treat blank (admin cleared the field) the same as "use the default".
$subject_tpl = trim((string) ($tpl['subject'] ?? '')) !== '' ? $tpl['subject'] : self::DEFAULT_PENDING_SUBJECT;
$body_tpl = trim((string) ($tpl['body'] ?? '')) !== '' ? $tpl['body'] : self::DEFAULT_PENDING_BODY;
// Reservation context wins over form values on key collisions, so the
// accept/refuse links can never be shadowed by a submitted field.
$symbols = array_merge($form_values, [
'reservation_id' => (string) $event->reservation_id, 'reservation_id' => (string) $event->reservation_id,
'date' => esc_html($start_dt->format('Y-m-d')), 'date' => $start_dt->format('Y-m-d'),
'start' => esc_html($start_dt->format('H:i')), 'start' => $start_dt->format('H:i'),
'end' => esc_html($end_dt->format('H:i')), 'end' => $end_dt->format('H:i'),
'accept_url' => esc_url(get_rest_url(null, 'reservations/v1/timetable-reservation/accept/' . $event->code)), 'accept_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)), 'refuse_url' => get_rest_url(null, 'reservations/v1/timetable-reservation/refuse/' . $event->code),
]); ]);
(new RsvEmailSender())->send($event->maintainer_email, self::DEFAULT_PENDING_SUBJECT, $body); $subject = sanitize_text_field($engine->render_plain($subject_tpl, $symbols));
$body = $engine->render($body_tpl, $symbols);
(new RsvEmailSender())->send($event->maintainer_email, $subject, $body);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Logger::error($e); Logger::error($e);
} }
} }
/** /**
* HTML-escape scalar form values for safe interpolation into an HTML email * Resolves the admin-authored maintainer template for the timetable this
* body. Non-scalar values (e.g. nested arrays) are dropped to an empty * reservation belongs to, together with the submission's field values so the
* string since the templater only substitutes plain placeholders. * template can reference the submitter's answers.
* *
* @param array<string,mixed> $values * @return array{0: array<string, mixed>, 1: array<string, mixed>} The
* @return array<string,string> * maintainer template (empty when none is configured) and form values.
*/ */
private static function escape_values(array $values): array { private static function resolve_maintainer_template(RsvTimetableReservationPendingEvent $event): array {
$escaped = []; $form_submit_id = (new RsvReservationRepository())->get_form_submit_id($event->reservation_id);
foreach ($values as $key => $value) { if ($form_submit_id === null) {
$escaped[$key] = is_scalar($value) ? esc_html((string) $value) : ''; return [[], []];
} }
return $escaped;
$form_submit = (new RsvFormSubmitRepository())->get($form_submit_id);
if ($form_submit === null) {
return [[], []];
}
$form_values = is_array($form_submit['values'] ?? null) ? $form_submit['values'] : [];
$definition = (new RsvFormDefinitionRepository())->get((int) $form_submit['form_id']);
if ($definition === null) {
return [[], $form_values];
}
$elements = $definition['definition']['elements'] ?? [];
$reservation_element = null;
foreach ($elements as $el) {
if (!is_array($el) || ($el['type'] ?? '') !== 'reservation') {
continue;
}
$reservation_element ??= $el; // fall back to the first reservation element
if ((int) ($el['timetable_id'] ?? 0) === $event->reservation->timetable_id) {
$reservation_element = $el;
break;
}
}
$template = $reservation_element['email_templates']['maintainer'] ?? [];
return [is_array($template) ? $template : [], $form_values];
} }
public static function on_form_submit_closed(RsvFormSubmitClosedEvent $event): void { public static function on_form_submit_closed(RsvFormSubmitClosedEvent $event): void {
@@ -136,15 +177,21 @@ class RsvEmailListener {
$subject_tpl = trim((string) ($tpl['subject'] ?? '')) !== '' ? $tpl['subject'] : $default_subject; $subject_tpl = trim((string) ($tpl['subject'] ?? '')) !== '' ? $tpl['subject'] : $default_subject;
$body_tpl = trim((string) ($tpl['body'] ?? '')) !== '' ? $tpl['body'] : $default_body; $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 $form_definition = new RsvFormDefinition((string) $form_submit['form_id'], $definition['definition']);
// or newlines to avoid header issues. $form_data = new RsvFormData($form_values);
$subject = sanitize_text_field($templater->render($subject_tpl, $form_values));
// Body is HTML: escape the user-submitted values before interpolation // Calculated values (e.g. price) win over submitted fields so they can't be shadowed.
// so they can't inject markup into the message. $symbols = array_merge($form_values, (new RsvFormCalculatedValues())->for($form_definition, $form_data));
$body = $templater->render($body_tpl, self::escape_values($form_values));
// Subject is plain text: render without HTML-escaping, then strip tags/newlines.
$subject = sanitize_text_field($engine->render_plain($subject_tpl, $symbols));
// Body is HTML: the engine HTML-escapes all interpolated values.
$body = $engine->render($body_tpl, $symbols);
error_log("Prc");
(new RsvEmailSender())->send($user_email, $subject, $body); (new RsvEmailSender())->send($user_email, $subject, $body);
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -10,23 +10,37 @@ class RsvGoogleCalendarListener {
public static function on_created(RsvTimetableReservationCreatedEvent $event): void { public static function on_created(RsvTimetableReservationCreatedEvent $event): void {
try { try {
$form_submit = (new RsvFormSubmitRepository())->get((int) $event->parent->form_submit_id);
if ($form_submit === null) {
return;
}
$definition = (new RsvFormDefinitionRepository())->get((int) $form_submit['form_id']);
if ($definition === null) {
return;
}
$email_key = $definition['definition']['email_key'] ?? null;
$user_email = is_string($email_key) && $email_key !== '' ? ($form_values[$email_key] ?? null) : null;
$gcal = new RsvGoogleCalendarService(); $gcal = new RsvGoogleCalendarService();
if (!$gcal->is_google_connected()) { if (!$gcal->is_google_connected()) {
return; return;
} }
$calendar_id = self::resolve_calendar_id($gcal, $event->timetable_id); $calendar_id = self::resolve_calendar_id($gcal, $event->reservation->timetable_id);
if (!$calendar_id) { if (!$calendar_id) {
return; return;
} }
$status = $event->reservation->requires_confirmation ? 'tentative' : 'confirmed'; $status = $event->reservation->is_confirmed == null ? 'tentative' : 'confirmed';
$gcal->add_event( $gcal->add_event(
$calendar_id, $calendar_id,
"Reservation #{$event->reservation->id}", "Reservation #{$event->reservation->id}",
$event->reservation->start, // format time as utc
$event->reservation->end, $event->reservation->start_utc->format('Y-m-d H:i:s'),
$event->reservation->user_email, $event->reservation->end_utc->format('Y-m-d H:i:s'),
$user_email,
$event->reservation->id, $event->reservation->id,
$status, $status,
); );
+42
View File
@@ -0,0 +1,42 @@
<?php
class RsvMembershipKey {
public ?int $id;
public int $program_id;
public string $key_value;
public static function schema(): array {
return [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer', 'readonly' => true],
'program_id' => ['type' => 'integer', 'required' => true],
'key_value' => ['type' => 'string', 'required' => true, 'minLength' => 1],
],
];
}
public static function from_array(array $data): self {
return new self(
intval($data['id'] ?? null),
intval($data['program_id'] ?? 0),
$data['key_value'] ?? ''
);
}
public function __construct(?int $id, int $program_id, string $key_value) {
$this->id = $id;
$this->program_id = $program_id;
$this->key_value = $key_value;
}
public function to_array() {
return [
'id' => $this->id,
'program_id' => $this->program_id,
'key_value' => $this->key_value,
];
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
class RsvMembershipProgram {
public ?int $id;
public string $name;
public bool $active;
public static function schema(): array {
return [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer', 'readonly' => true],
'name' => ['type' => 'string', 'required' => true, 'minLength' => 1],
'active' => ['type' => 'boolean', 'default' => true],
],
];
}
public static function from_array(array $data): self {
return new self(
intval($data['id'] ?? null),
$data['name'] ?? '',
boolval($data['active'] ?? true)
);
}
public function __construct(?int $id, string $name, bool $active = true) {
$this->id = $id;
$this->name = $name;
$this->active = $active;
}
public function to_array() {
return [
'id' => $this->id,
'name' => $this->name,
'active' => $this->active,
];
}
}
+5 -2
View File
@@ -6,16 +6,19 @@
class RsvTimetableAvailability { class RsvTimetableAvailability {
/** /**
* @param array<int,int> $occupancy Number of available seats for each time block * @param array<int,int> $occupancy Number of available seats for each time block
* @param array<int,int> $lead_time_minutes Minimum lead time in minutes required for each block
*/ */
public function __construct( public function __construct(
public int $from_minutes, public int $from_minutes,
public int $to_minutes, public int $to_minutes,
public int $block_size_in_minutes, public int $block_size_in_minutes,
public array $occupancy public array $occupancy,
public array $lead_time_minutes = []
) { } ) { }
public function push_block(int $capacity) { public function push_block(int $capacity, int $min_lead_time_minutes = 0) {
$this->occupancy[] = $capacity; $this->occupancy[] = $capacity;
$this->lead_time_minutes[] = $min_lead_time_minutes;
$this->to_minutes += $this->block_size_in_minutes; $this->to_minutes += $this->block_size_in_minutes;
} }
} }
@@ -6,6 +6,8 @@ class RsvTimetableReservation {
public DateTime $start_utc; // UTC, 'Y-m-d H:i:s' public DateTime $start_utc; // UTC, 'Y-m-d H:i:s'
public DateTime $end_utc; // UTC, 'Y-m-d H:i:s' public DateTime $end_utc; // UTC, 'Y-m-d H:i:s'
public ?int $is_confirmed = null;
public static function schema(): array { public static function schema(): array {
return [ return [
'type' => 'object', 'type' => 'object',
@@ -0,0 +1,114 @@
<?php
use Reservair\Database\Db;
class RsvMembershipProgramRepository {
private string $table_program;
private string $table_key;
public function __construct() {
$this->table_program = Db::prefix() . 'rsv_membership_program';
$this->table_key = Db::prefix() . 'rsv_membership_key';
}
public function all(?int $limit = null, int $skip = 0): array {
if ($limit === null) {
$rows = Db::get_results(
"SELECT * FROM {$this->table_program} ORDER BY id DESC",
[],
ARRAY_A
);
} else {
$rows = Db::get_results(
"SELECT * FROM {$this->table_program} ORDER BY id DESC LIMIT %d OFFSET %d",
[$limit, $skip],
ARRAY_A
);
}
return array_map(fn($row) => RsvMembershipProgram::from_array($row), $rows);
}
public function count_all(): int {
return (int) Db::get_var("SELECT COUNT(*) FROM {$this->table_program}");
}
public function get(int $id): ?array {
return Db::get_row(
"SELECT * FROM {$this->table_program} WHERE id = %d",
[$id],
ARRAY_A
);
}
public function add(string $name, bool $active): int {
return Db::insert($this->table_program, [
'name' => $name,
'active' => $active ? 1 : 0,
]);
}
public function update(int $id, string $name, bool $active): int {
return Db::update(
$this->table_program,
[
'name' => $name,
'active' => $active ? 1 : 0,
],
['id' => $id]
);
}
public function delete(int $id): void {
Db::delete($this->table_program, ['id' => $id]);
}
public function keys(int $program_id, ?int $limit = null, int $skip = 0): array {
if ($limit === null) {
$rows = Db::get_results(
"SELECT * FROM {$this->table_key} WHERE program_id = %d ORDER BY id",
[$program_id],
ARRAY_A
);
} else {
$rows = Db::get_results(
"SELECT * FROM {$this->table_key} WHERE program_id = %d ORDER BY id LIMIT %d OFFSET %d",
[$program_id, $limit, $skip],
ARRAY_A
);
}
return array_map(fn($row) => RsvMembershipKey::from_array($row), $rows);
}
public function count_keys(int $program_id): int {
return (int) Db::get_var(
"SELECT COUNT(*) FROM {$this->table_key} WHERE program_id = %d",
[$program_id]
);
}
private function normalize_key(string $key_value): string {
return preg_replace('/\s+/', ' ', strtolower(trim($key_value)));
}
public function add_key(int $program_id, string $key_value): int {
return Db::insert($this->table_key, [
'program_id' => $program_id,
'key_value' => $key_value,
'key_normalized_value' => $this->normalize_key($key_value)
]);
}
public function delete_key(int $key_id): void {
Db::delete($this->table_key, ['id' => $key_id]);
}
public function key_exists(int $program_id, string $key_value): bool {
return Db::get_var(
"SELECT 1 FROM {$this->table_key} k
INNER JOIN {$this->table_program} p ON p.id = k.program_id
WHERE p.active = 1 AND k.program_id = %d AND k.key_normalized_value = %s
LIMIT 1",
[$program_id, $this->normalize_key($key_value)]
) !== null;
}
}
@@ -50,6 +50,7 @@ class RsvTimetableRepository {
'name' => $timetable->name, 'name' => $timetable->name,
'block_size' => $timetable->block_size, 'block_size' => $timetable->block_size,
'maintainer_email' => $timetable->maintainer_email, 'maintainer_email' => $timetable->maintainer_email,
'google_calendar_id' => $timetable->google_calendar_id,
], ],
['id' => $id] ['id' => $id]
); );
@@ -34,7 +34,8 @@ class RsvTimetableReservationRepository {
WHERE timetable_id = %d WHERE timetable_id = %d
AND `start_utc` < %s AND `start_utc` < %s
AND `end_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
) )
); );
} }
+19 -4
View File
@@ -4,12 +4,18 @@
* Contains definitions of the admin menus * Contains definitions of the admin menus
*/ */
function rsv_admin_menu_definition() { function rsv_admin_menu_definition() {
$reservations = new RsvReservationsPage();
$forms = new RsvFormsPage();
$timetable = new RsvTimetablePage();
$google_cal = new RsvGoogleCalendarSettingsPage();
$membership = new RsvMembershipProgramsPage();
add_menu_page( add_menu_page(
'Reservations Settings', // Page title 'Reservations Settings', // Page title
'Reservations', // Menu title 'Reservations', // Menu title
RsvCapabilities::MANAGE, // Capability RsvCapabilities::MANAGE, // Capability
'reservations-settings', // Menu slug 'reservations-settings', // Menu slug
'rsv_reservations_page', // Callback [$reservations, 'render'], // Callback
'dashicons-calendar', // Icon 'dashicons-calendar', // Icon
20 // Position 20 // Position
); );
@@ -20,7 +26,16 @@ function rsv_admin_menu_definition() {
'Forms', 'Forms',
RsvCapabilities::MANAGE, RsvCapabilities::MANAGE,
'forms-settings', 'forms-settings',
'rsv_forms_page' [$forms, 'render']
);
add_submenu_page(
'reservations-settings',
'Membership Programs',
'Membership Programs',
RsvCapabilities::MANAGE,
'membership-programs',
[$membership, 'render']
); );
add_submenu_page( add_submenu_page(
@@ -29,7 +44,7 @@ function rsv_admin_menu_definition() {
'Timetables', 'Timetables',
RsvCapabilities::MANAGE, RsvCapabilities::MANAGE,
'timetable-settings', 'timetable-settings',
'rsv_timetable_page' [$timetable, 'render']
); );
add_submenu_page( add_submenu_page(
@@ -38,6 +53,6 @@ function rsv_admin_menu_definition() {
'Google Calendar', 'Google Calendar',
RsvCapabilities::MANAGE, RsvCapabilities::MANAGE,
'rsv-google-calendar', 'rsv-google-calendar',
'rsv_google_calendar_settings_page' [$google_cal, 'render']
); );
} }
+15
View File
@@ -0,0 +1,15 @@
<?php
abstract class RsvAdminPage {
final public function render(): void {
if (!current_user_can(RsvCapabilities::MANAGE)) {
return;
}
echo '<div class="wrap">';
$this->render_content();
echo '</div>';
}
abstract protected function render_content(): void;
}
+10 -3
View File
@@ -52,8 +52,15 @@ function rsv_enqueue_assets(): void {
} }
function rsv_enqueue_admin_assets(): void { function rsv_enqueue_admin_assets(): void {
wp_enqueue_script('rsv-admin', rsv_build_url('admin.js'), [], filemtime(rsv_build_file('admin.js'))); // The client bundle defines the custom elements shared by both front-end and
wp_enqueue_style('rsv-admin', rsv_build_url('admin.css'), [], filemtime(rsv_build_file('admin.css'))); // 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');
} }
+20
View File
@@ -93,6 +93,26 @@ class RsvInstaller {
ON DELETE CASCADE ON DELETE CASCADE
) $charset_collate;"); ) $charset_collate;");
self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_membership_program (
id bigint unsigned NOT NULL AUTO_INCREMENT,
name TINYTEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
PRIMARY KEY (id)
) $charset_collate;");
self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_membership_key (
id bigint unsigned NOT NULL AUTO_INCREMENT,
program_id bigint unsigned NOT NULL,
key_value VARCHAR(191) NOT NULL,
key_normalized_value VARCHAR(191) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uniq_program_key (program_id, key_normalized_value),
KEY idx_key_value (key_normalized_value),
CONSTRAINT fk_member_key_program
FOREIGN KEY (program_id) REFERENCES {$wpdb->prefix}rsv_membership_program (id)
ON DELETE CASCADE
) $charset_collate;");
// Grant the custom capability that gates the admin REST endpoints. // Grant the custom capability that gates the admin REST endpoints.
RsvCapabilities::ensure(); RsvCapabilities::ensure();
} }
+1
View File
@@ -84,4 +84,5 @@ function rsv_define_rest_api(): void {
(new RsvTimetableReservationController())->register_routes(); (new RsvTimetableReservationController())->register_routes();
(new RsvFormController())->register_routes(); (new RsvFormController())->register_routes();
(new RsvFormDefinitionController())->register_routes(); (new RsvFormDefinitionController())->register_routes();
(new RsvMembershipProgramController())->register_routes();
} }
@@ -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 { public function draw(RsvFormElementDefinition $element): void {
?> ?>
<div class="rsv-form-input-group rsv-form-input-short"> <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> </div>
<?php <?php
} }
@@ -1,6 +1,5 @@
<?php <?php
interface RsvFormElementHandler { interface RsvFormElementHandler {
function draw(RsvFormElementDefinition $def) : void; function draw(RsvFormElementDefinition $def) : void;
@@ -14,7 +14,7 @@ class RsvFormReservationElementHandler implements RsvFormElementHandler {
$name = $element->getName(); $name = $element->getName();
?> ?>
<div class="rsv-form-input-group"> <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 <rsv-reservation-selector
timetable-id="<?= $timetable_id ?>" timetable-id="<?= $timetable_id ?>"
name="<?= esc_attr($name) ?>" name="<?= esc_attr($name) ?>"
@@ -0,0 +1,21 @@
<?php
class RsvOutputTextElementHandler implements RsvFormElementHandler {
private const ALLOWED_TAGS = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
public function draw(RsvFormElementDefinition $def): void {
$raw = $def->getAttr('tag', 'p');
$tag = in_array($raw, self::ALLOWED_TAGS, true) ? $raw : 'p';
echo '<' . $tag . ' class="rsv-form-output-text">' . esc_html($def->getLabel()) . '</' . $tag . '>';
}
public function submit(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): bool {
return true;
}
public function rollback(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): void {
// No side effects to undo.
}
}
@@ -0,0 +1,22 @@
<?php
/** Computes a form's total price as the sum of its elements' prices, reduced by membership discount. */
class RsvFormPriceCalculator {
public function calculate(RsvFormDefinition $definition, RsvFormData $data): float {
global $rsv_form_price_registry;
$total = 0.0;
foreach ($definition->getElements() as $element) {
$calculator = $rsv_form_price_registry->get($element->getType());
if ($calculator === null) {
continue; // Unpriced element type contributes nothing.
}
$total += (float) $calculator($element, $data->getValue($element->getName()));
}
$pct = (new RsvMembershipService())->discount_for($definition, $data);
return $total * (1.0 - max(0.0, min(100.0, $pct)) / 100.0);
}
}
@@ -0,0 +1,21 @@
<?php
class RsvFormPriceCalculatorRegistry {
/** @var array<string, callable(RsvFormElementDefinition, mixed): float> */
private array $calculators = [];
public function register(string $type, callable $calculator): void {
$this->calculators[$type] = $calculator;
}
public function get(string $type): ?callable {
return $this->calculators[$type] ?? null;
}
/** Builds the registry and lets other modules contribute calculators. */
public static function boot(): self {
$registry = new self();
do_action('rsv-register-price-calculator', $registry);
return $registry;
}
}
@@ -0,0 +1,36 @@
<?php
/** Values derived from a submission (not entered by the visitor), exposed to templates. */
final class RsvFormCalculatedValues {
/** @return array<string, mixed> */
public function for(RsvFormDefinition $definition, RsvFormData $data): array {
$calculator = new RsvFormPriceCalculator();
global $rsv_form_price_registry;
$price_before_discount = 0.0;
foreach ($definition->getElements() as $element) {
$element_calculator = $rsv_form_price_registry->get($element->getType());
if ($element_calculator === null) {
continue;
}
$price_before_discount += (float) $element_calculator($element, $data->getValue($element->getName()));
}
$discount_pct = (new RsvMembershipService())->discount_for($definition, $data);
$final_price = $calculator->calculate($definition, $data);
return [
'price' => $final_price,
'price_before_discount' => $price_before_discount,
'discount_percent' => $discount_pct,
];
}
/**
* The names these values expose, so template validation accepts {{ price }}.
* @return list<string>
*/
public static function names(): array {
return ['price', 'price_before_discount', 'discount_percent'];
}
}
+21 -1
View File
@@ -7,8 +7,12 @@ class RsvFormDefinition {
public string $email_key = ""; public string $email_key = "";
public array $membership = [];
public string $success_message = "";
/** /**
* @param array<int,mixed> $definition Full definition array including 'elements' and 'email_key'. * @param array<string,mixed> $definition Full definition array including 'elements', 'email_key' and 'success_message'.
*/ */
public function __construct(string $id, array $definition) { public function __construct(string $id, array $definition) {
$this->_elements = []; $this->_elements = [];
@@ -21,6 +25,8 @@ class RsvFormDefinition {
$this->_id = $id; $this->_id = $id;
$this->email_key = $definition['email_key'] ?? ''; $this->email_key = $definition['email_key'] ?? '';
$this->membership = $definition['membership'] ?? [];
$this->success_message = $definition['success_message'] ?? '';
} }
public function getId(): string { public function getId(): string {
@@ -31,6 +37,20 @@ class RsvFormDefinition {
return $this->email_key; return $this->email_key;
} }
/** @return array<int,array{program_id:int,discount:float,field:string}> */
public function getMembershipBindings(): array {
return $this->membership['bindings'] ?? [];
}
public function getMembershipCombine(): string {
return $this->membership['combine'] ?? 'max';
}
/** Template shown to the visitor after a successful submission. */
public function getSuccessMessage(): string {
return $this->success_message;
}
public function hasElements() : bool { public function hasElements() : bool {
return count($this->_elements) > 0; return count($this->_elements) > 0;
@@ -0,0 +1,152 @@
<?php
use Reservair\Templating\RsvTemplateEngine;
/**
* Validates a form definition before it is persisted.
*
* Template checks (symbols, syntax, custom elements) are delegated to the
* common template validator; on top of that this enforces form-level rules,
* such as requiring a submit button once the form defines any fields.
*/
final class RsvFormDefinitionValidator {
/**
* @param array<string,mixed> $definition The inner definition (elements, email_key, success_message).
* @return list<string> Human-readable problems; empty when the definition is valid.
*/
public function validate(array $definition): array {
$elements = is_array($definition['elements'] ?? null) ? $definition['elements'] : [];
$symbols = $this->symbols($elements);
$engine = $this->engine();
$errors = [];
// Templates reference submitted values by form-element name.
foreach ($this->templates($definition, $elements) as $label => $template) {
foreach ($engine->validate($template, $symbols) as $problem) {
$errors[] = "{$label}: {$problem}";
}
}
// A form that collects fields must give the visitor a way to send them.
if ($elements !== [] && !$this->has_submit($elements)) {
$errors[] = 'Form must contain a submit button.';
}
// Validate membership bindings if present.
$errors = array_merge($errors, $this->validate_membership_bindings($definition));
return $errors;
}
/**
* Names that templates may reference — the form's symbol table.
*
* @param array<int,mixed> $elements
* @return list<string>
*/
private function symbols(array $elements): array {
$names = RsvFormCalculatedValues::names(); // calculated values are referencable too
foreach ($elements as $el) {
$name = is_array($el) ? ($el['name'] ?? '') : '';
if (is_string($name) && $name !== '') {
$names[] = $name;
}
}
return $names;
}
/**
* The definition's admin-authored templates, keyed by a label used to
* prefix any problems found in them.
*
* @param array<string,mixed> $definition
* @param array<int,mixed> $elements
* @return array<string,string>
*/
private function templates(array $definition, array $elements): array {
$templates = [];
$success = $definition['success_message'] ?? '';
if (is_string($success) && trim($success) !== '') {
$templates['Success message'] = $success;
}
foreach ($elements as $el) {
if (!is_array($el) || ($el['type'] ?? '') !== 'reservation') {
continue;
}
$email_templates = $el['email_templates'] ?? [];
if (!is_array($email_templates)) {
continue;
}
foreach (['on_accepted' => 'accepted', 'on_refused' => 'refused'] as $key => $human) {
$tpl = $email_templates[$key] ?? [];
if (!is_array($tpl)) {
continue;
}
foreach (['subject', 'body'] as $part) {
$value = $tpl[$part] ?? '';
if (is_string($value) && trim($value) !== '') {
$templates["Email ({$human} {$part})"] = $value;
}
}
}
}
return $templates;
}
/** @param array<int,mixed> $elements */
private function has_submit(array $elements): bool {
foreach ($elements as $el) {
if (is_array($el) && ($el['type'] ?? '') === 'button') {
return true;
}
}
return false;
}
private function engine(): RsvTemplateEngine {
global $rsv_template_registry;
return new RsvTemplateEngine(registry: $rsv_template_registry);
}
/**
* @param array<string,mixed> $definition
* @return list<string>
*/
private function validate_membership_bindings(array $definition): array {
$errors = [];
$membership = $definition['membership'] ?? [];
if (!is_array($membership)) {
return $errors;
}
$bindings = $membership['bindings'] ?? [];
if (!is_array($bindings)) {
return $errors;
}
foreach ($bindings as $idx => $binding) {
if (!is_array($binding)) {
continue;
}
$program_id = intval($binding['program_id'] ?? 0);
$discount = floatval($binding['discount'] ?? 0.0);
if ($program_id <= 0) {
$errors[] = "Membership binding {$idx}: program_id must be a positive integer.";
}
if ($discount < 0.0 || $discount > 100.0) {
$errors[] = "Membership binding {$idx}: discount must be between 0 and 100.";
}
}
return $errors;
}
}
@@ -1,5 +1,6 @@
<?php <?php
use Reservair\Templating\RsvTemplateEngine;
class RsvFormHtmlRenderer { class RsvFormHtmlRenderer {
public function draw(RsvFormDefinition $form): bool { public function draw(RsvFormDefinition $form): bool {
@@ -20,12 +21,37 @@ class RsvFormHtmlRenderer {
<?php endforeach; ?> <?php endforeach; ?>
</form> </form>
<?php $this->draw_success_template($form); ?>
</div> </div>
<?php <?php
return true; 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 { public function draw_element(RsvFormElementDefinition $data): void {
global $rsv_form_registry; global $rsv_form_registry;
@@ -0,0 +1,9 @@
# Membership
Forms can have a price calculated from it's definition and submitted data. For a discount, the administrator can create a membership program. In the system, that means a collection of strings. In the form definition, the administrator defines, which membership program applies what discount and how to extract the key into the membership program from the from submission.
## Matching the keys
The membership is a collection of keys. But some types of them are hard to match exactly. For example it might be important to ignore case or be culture invariant. For this reason, second column in the membership table is used, which is for the normalized key, that must be matched exactly against another normalized key.
For example, for full names, the process of normalization might remove diacritics or convert to lowercase. The same process must be applied when looking for the key.
@@ -0,0 +1,50 @@
<?php
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 {
$repo = new RsvMembershipProgramRepository();
$matched_discounts = [];
foreach ($def->getMembershipBindings() as $binding) {
$program_id = intval($binding['program_id'] ?? 0);
$discount = floatval($binding['discount'] ?? 0.0);
$field = strval($binding['field'] ?? '');
if ($program_id <= 0 || $field === '') {
continue;
}
$raw = $data->getValue($field, '');
$value = is_scalar($raw) ? trim((string) $raw) : '';
if ($value === '') {
continue;
}
if ($repo->key_exists($program_id, $value)) {
$matched_discounts[] = $discount;
}
}
if (empty($matched_discounts)) {
return 0.0;
}
if ($def->getMembershipCombine() === 'sum') {
return min(100.0, array_sum($matched_discounts));
}
return max($matched_discounts);
}
}
+60 -2
View File
@@ -27,6 +27,9 @@ class RsvReservationService {
} }
public function create(RsvReservation $reservation) { public function create(RsvReservation $reservation) {
$reservation->timetable_reservations =
$this->merge_adjacent($reservation->timetable_reservations);
// Serialise the availability-check + insert per timetable so two // Serialise the availability-check + insert per timetable so two
// concurrent bookings for the last free slot can't both pass the // concurrent bookings for the last free slot can't both pass the
// capacity check and oversell. Locks are taken in a stable order to // capacity check and oversell. Locks are taken in a stable order to
@@ -50,9 +53,10 @@ class RsvReservationService {
try { try {
$reservation_id = $this->repo->insert($reservation->to_array()); $reservation_id = $this->repo->insert($reservation->to_array());
$reservation->id = $reservation_id;
foreach ($reservation->timetable_reservations as $timetable_reservation) { foreach ($reservation->timetable_reservations as $timetable_reservation) {
$timetable_reservation_service->create($reservation_id, $timetable_reservation); $timetable_reservation->id = $timetable_reservation_service->create($reservation_id, $timetable_reservation);
} }
Db::commit(); Db::commit();
@@ -64,7 +68,19 @@ class RsvReservationService {
// Only now that the rows are durably committed do we let listeners // Only now that the rows are durably committed do we let listeners
// (maintainer emails, calendar sync) observe the new reservation. // (maintainer emails, calendar sync) observe the new reservation.
$timetable_reservation_service->flush_deferred_events(); foreach($reservation->timetable_reservations as $timetable_reservation) {
if($timetable_reservation->is_confirmed === null) {
$maintainer_email = (new RsvTimetableRepository())->get_maintainer_email($timetable_reservation->timetable_id);
RsvEventDispatcher::dispatch(new RsvTimetableReservationPendingEvent(
$reservation_id,
$timetable_reservation,
$timetable_reservation_service->get_confirmation_code($timetable_reservation->id),
$maintainer_email
));
}
RsvEventDispatcher::dispatch(new RsvTimetableReservationCreatedEvent($timetable_reservation, $reservation));
}
$this->confirmation_state_changed($reservation_id); $this->confirmation_state_changed($reservation_id);
@@ -80,6 +96,48 @@ class RsvReservationService {
return Db::prefix() . 'rsv_booking_' . $timetable_id; return Db::prefix() . 'rsv_booking_' . $timetable_id;
} }
/**
* Collapse runs of touching reservations into single spans, so a sequence
* of back-to-back blocks is stored and notified as one booking.
*
* @param list<RsvTimetableReservation> $timetable_reservations
* @return list<RsvTimetableReservation>
*/
private function merge_adjacent(array $timetable_reservations): array {
$by_timetable = [];
foreach ($timetable_reservations as $tr) {
$by_timetable[$tr->timetable_id][] = $tr;
}
$merged = [];
foreach ($by_timetable as $group) {
usort($group, fn($a, $b) => $a->start_utc <=> $b->start_utc);
$current = null;
foreach ($group as $tr) {
if ($current !== null && $tr->start_utc <= $current->end_utc) {
if ($tr->end_utc > $current->end_utc) {
$current->end_utc = $tr->end_utc;
}
continue;
}
if ($current !== null) {
$merged[] = $current;
}
$current = new RsvTimetableReservation(
null, $tr->timetable_id, $tr->start_utc, $tr->end_utc
);
}
if ($current !== null) {
$merged[] = $current;
}
}
return $merged;
}
/** Delete a reservation and its dependent timetable reservations and confirmations. */ /** Delete a reservation and its dependent timetable reservations and confirmations. */
public function delete(int $id): void { public function delete(int $id): void {
$this->repo->delete($id); $this->repo->delete($id);
@@ -35,6 +35,15 @@ class RsvTimetableReservationService {
->get_overlapping_capacity($timetable_id, $start_utc, $end_utc); ->get_overlapping_capacity($timetable_id, $start_utc, $end_utc);
if (count($overlapping_capacity) === 0) { 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;
}
$max_lead_time = max(array_map(fn($c) => (int) $c->min_lead_time_minutes, $overlapping_capacity));
$earliest_allowed = new DateTime('now', new DateTimeZone('UTC'));
$earliest_allowed->modify("+{$max_lead_time} minutes");
if ($start_utc < $earliest_allowed) {
Logger::error("Reservation rejected: minimum lead time of {$max_lead_time} minutes not met for timetable_id: $timetable_id");
return false; return false;
} }
@@ -42,6 +51,7 @@ class RsvTimetableReservationService {
$end_min = $this->time_of_day_minutes($end_utc); $end_min = $this->time_of_day_minutes($end_utc);
if ((int) $overlapping_capacity[0]->start_time > $start_min) { 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; return false;
} }
@@ -59,6 +69,7 @@ class RsvTimetableReservationService {
$capacity = (int) $overlapping_capacity[0]->capacity; $capacity = (int) $overlapping_capacity[0]->capacity;
$reservations = $this->repo->get_overlapping($timetable_id, $start_utc, $end_utc); $reservations = $this->repo->get_overlapping($timetable_id, $start_utc, $end_utc);
error_log('reservations: ' . json_encode($reservations));
return count($reservations) < $capacity; return count($reservations) < $capacity;
} }
@@ -104,13 +115,9 @@ class RsvTimetableReservationService {
return $this->repo->has_pending_confirmation($reservation_id); return $this->repo->has_pending_confirmation($reservation_id);
} }
private function get_confirmation_code(int $reservation_id): string { public function get_confirmation_code(int $reservation_id): ?string {
$code = $this->repo->get_confirmation_code($reservation_id); $code = $this->repo->get_confirmation_code($reservation_id);
if ($code === null) {
throw new InvalidArgumentException('No pending confirmation for this reservation.');
}
return $code; return $code;
} }
@@ -146,20 +153,13 @@ class RsvTimetableReservationService {
if ($maintainer_email) { if ($maintainer_email) {
$code = $this->create_confirmation($reservation_id, $id); $code = $this->create_confirmation($reservation_id, $id);
$this->deferred_events[] = new RsvTimetableReservationPendingEvent(
$reservation_id,
$reservation,
$code,
$maintainer_email
);
} }
} else { } else {
$reservation->is_confirmed = 1;
$reservation_service = new RsvReservationService(); $reservation_service = new RsvReservationService();
$reservation_service->confirmation_state_changed($reservation_id); $reservation_service->confirmation_state_changed($reservation_id);
} }
$this->deferred_events[] = new RsvTimetableReservationCreatedEvent($reservation);
return $id; return $id;
} }
+2 -1
View File
@@ -98,7 +98,8 @@ class RsvTimetableService {
$availabilities[] = new RsvTimetableAvailability($i * $block_length, ($i + 1) * $block_length, $block_length, []); $availabilities[] = new RsvTimetableAvailability($i * $block_length, ($i + 1) * $block_length, $block_length, []);
} }
$availabilities[$availability_idx]->push_block($total_capacity - count($reservation_stack)); $max_lead_time = empty($capacity_stack) ? 0 : max(array_map(fn($x) => $x->min_lead_time_minutes, $capacity_stack));
$availabilities[$availability_idx]->push_block($total_capacity - count($reservation_stack), $max_lead_time);
} else if($total_capacity === 0 && count($availabilities) !== $availability_idx) { } else if($total_capacity === 0 && count($availabilities) !== $availability_idx) {
$availability_idx++; $availability_idx++;
} }
+408 -167
View File
@@ -1,24 +1,198 @@
<?php <?php
use Reservair\Forms\RsvFormBuilder; use Reservair\Forms\RsvFormBuilder;
use Reservair\Layout\RsvColumnLayout;
// Shared inline script for the elements data grid. class RsvFormsPage extends RsvAdminPage {
// $elements_with_ids: array of element objects already carrying an 'id' key.
// $next_id: the first integer not yet used as an id. protected function render_content(): void {
function rsv_elements_table_script(array $elements_with_ids, int $next_id, string $form_id, array $element_types, array $timetables = []): void { if (isset($_GET['action']) && $_GET['action'] === 'edit' && isset($_GET['id'])) {
$this->show_edit(intval($_GET['id']));
return;
}
$this->show_list();
}
private function show_list(): void {
global $rsv_form_registry;
$element_types = array_keys($rsv_form_registry->handlers);
$elements_with_ids = [];
$next_id = 1;
$timetables = (new RsvTimetableService())->get_all();
?>
<h1>Formuláře</h1>
<hr>
<?php
RsvColumnLayout::split('1:2')
->column(function () {
echo RsvFormBuilder::create('add_form_definition', get_rest_url(null, 'reservations/v1/form-definition'), 'POST', 'Form definition created.')
->heading('Přidat formulář')
->nonce('my_action', 'my_nonce')
->text('name', 'Název')
->render();
?>
<hr>
<p class="submit">
<button type="submit" form="add_form_definition" class="button button-primary">Add Form Definition</button>
</p>
<?php })
->column(function () { ?>
<div id="forms_table"></div>
<script>
var forms_dt = RsvDataGrid.create_data_grid(forms_table,
RsvFormDefinitionResource(), {
'form_id': RsvDataGrid.column('ID', false, 30),
'name': RsvDataGrid.action_column('Název', false, {
'Edit': RsvDataGrid.link_action((data) =>
`<?= menu_page_url('forms-settings', false) ?>&id=${data.form_id}&action=edit`
),
'Trash': RsvDataGrid.func_action(function(dt, row, data) {
if (!confirm('Delete this form? This cannot be undone.')) return;
dt.resource.delete(data.form_id).then(() => forms_dt.refresh()).catch(err => alert(err.message));
}),
'Clone': RsvDataGrid.func_action(function(dt, row, data) {
const base_url = `<?= get_rest_url(null, 'reservations/v1/form-definition') ?>`;
fetch(`${base_url}/${data.form_id}`, {
headers: { 'Accept': 'application/json' },
})
.then(r => {
if (!r.ok) return r.json().then(err => { throw new Error(err.error || 'Fetch failed'); });
return r.json();
})
.then(form_def => fetch(base_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({
name: 'Copy of ' + form_def.name,
definition: form_def.definition,
}),
}))
.then(r => {
if (!r.ok) return r.json().then(err => { throw new Error(err.error || 'Clone failed'); });
forms_dt.refresh();
})
.catch(err => alert(err.message));
}),
}),
});
forms_dt.refresh();
</script>
<?php })
->output();
?>
<?php $this->elements_table_script($elements_with_ids, $next_id, 'add_form_definition', $element_types, $timetables); ?>
<?php
}
private function show_edit(int $id): void {
global $rsv_form_registry;
$element_types = array_keys($rsv_form_registry->handlers);
$repo = new RsvFormDefinitionRepository();
$form_def = $repo->get($id);
if ($form_def === null) {
echo '<div class="notice notice-error"><p>Form definition not found.</p></div>';
return;
}
$definition = $form_def['definition'] ?? [];
$raw_elements = array_values($definition['elements'] ?? []);
$elements_with_ids = array_map(function (array $el, int $idx): array {
return array_merge($el, ['id' => $idx + 1]);
}, $raw_elements, array_keys($raw_elements));
$next_id = count($elements_with_ids) + 1;
$timetables = (new RsvTimetableService())->get_all();
$programs = array_map(fn($p) => $p->to_array(), (new RsvMembershipProgramRepository())->all());
$email_key_options = ['' => '— select field —'];
foreach ($raw_elements as $el) {
$el_name = $el['name'] ?? '';
if ($el_name === '') {
continue;
}
$el_label = $el['label'] ?? '';
$email_key_options[$el_name] = $el_label !== '' ? "$el_label ($el_name)" : $el_name;
}
?>
<h1>Edit Form: <?= esc_html($form_def['name']) ?></h1>
<a href="<?= menu_page_url('forms-settings', false) ?>">← Back to Forms</a>
<hr>
<?php
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'] ?? '')
->render();
?>
<hr>
<h2>Membership Discounts</h2>
<p>Map a membership program to a discount percentage. The chosen field's value must match a key in that program for the discount to apply.</p>
<?php
$membership = $definition['membership'] ?? [];
$bindings = $membership['bindings'] ?? [];
?>
<div id="rsv_membership_bindings_table"></div>
<p>
<button type="button" class="button" id="rsv_add_binding_btn">+ Add Binding</button>
</p>
<label>
Combine mode:
<select name="definition.membership_combine">
<option value="max" <?= ($membership['combine'] ?? 'max') === 'max' ? 'selected' : '' ?>>Max (best discount wins)</option>
<option value="sum" <?= ($membership['combine'] ?? 'max') === 'sum' ? 'selected' : '' ?>>Sum (capped at 100%)</option>
</select>
</label>
<hr>
<?php
RsvColumnLayout::split('3:2')
->column(function (): void { ?>
<h2>Form Elements</h2>
<p>Define the fields that will appear in this form.</p>
<div id="form_elements_table"></div>
<p>
<button type="button" class="button" id="rsv_add_element_btn">+ Add Element</button>
<button type="submit" form="edit_form_definition" class="button button-primary">Update Form Definition</button>
</p>
<?php })
->column(function (): void { ?>
<h3>Live preview</h3>
<div id="rsv_form_preview" class="rsv-form-preview" style="position: sticky; top: 40px;"></div>
<?php })
->output();
?>
<?php $this->elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables, $programs, $bindings); ?>
<?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 {
$elements_json = json_encode($elements_with_ids); $elements_json = json_encode($elements_with_ids);
$types_json = json_encode(array_values($element_types)); $types_json = json_encode(array_values($element_types));
$timetables_json = json_encode(array_values($timetables)); $timetables_json = json_encode(array_values($timetables));
$programs_json = json_encode(array_values($programs));
$bindings_json = json_encode(array_values($bindings));
$bindings_next = count($bindings) + 1;
?> ?>
<script> <script>
const rsv_element_types = <?= $types_json ?>; const rsv_element_types = <?= $types_json ?>;
const rsv_timetables = <?= $timetables_json ?>; const rsv_timetables = <?= $timetables_json ?>;
const rsv_membership_programs = <?= $programs_json ?>;
const RSV_EMAIL_DEFAULTS = { const RSV_EMAIL_DEFAULTS = {
accepted_subject: <?= json_encode('Rezervace přijata') ?>, accepted_subject: <?= json_encode('Rezervace přijata') ?>,
accepted_body: <?= json_encode("<h1>Vaše rezervace byla přijata</h1>\n<p>Vaše rezervace byla schválena. Těšíme se na vás!</p>") ?>, accepted_body: <?= json_encode("<h1>Vaše rezervace byla přijata</h1>\n<p>Vaše rezervace byla schválena. Těšíme se na vás!</p>") ?>,
refused_subject: <?= json_encode('Rezervace zamítnuta') ?>, refused_subject: <?= json_encode('Rezervace zamítnuta') ?>,
refused_body: <?= json_encode("<h1>Vaše rezervace byla zamítnuta</h1>\n<p>Vaše rezervace bohužel nebyla schválena. Zkuste prosím jiný termín.</p>") ?>, refused_body: <?= json_encode("<h1>Vaše rezervace byla zamítnuta</h1>\n<p>Vaše rezervace bohužel nebyla schválena. Zkuste prosím jiný termín.</p>") ?>,
maintainer_subject: <?= json_encode('Nová rezervace čeká na schválení') ?>,
maintainer_body: <?= json_encode("<h1>Nový požadavek o rezervaci</h1>\n<p>Rezervace č. {{reservation_id}} čeká na vaše schválení.</p>\n<p><strong>Datum:</strong> {{date}}</p>\n<p><strong>Čas:</strong> {{start}} {{end}}</p>\n<p><reservation-actions></reservation-actions></p>") ?>,
}; };
const rsv_elements_source = (function(initial_items, next_id_start) { const rsv_elements_source = (function(initial_items, next_id_start) {
@@ -36,7 +210,7 @@ function rsv_elements_table_script(array $elements_with_ids, int $next_id, strin
if (idx === -1) return Promise.reject(new Error('Element not found')); if (idx === -1) return Promise.reject(new Error('Element not found'));
// Destructure reservation-specific fields so they don't bleed into extra_attrs. // Destructure reservation-specific fields so they don't bleed into extra_attrs.
const { id: _id, name: _n, label: _l, type: _t, desc: _d, required: _r, const { id: _id, name: _n, label: _l, type: _t, desc: _d, required: _r,
price_per_block: _p, email_templates: _et, timetable_id: _ti, ...extra_attrs } = items[idx]; price_per_block: _p, email_templates: _et, timetable_id: _ti, tag: _tag, ...extra_attrs } = items[idx];
items[idx] = { items[idx] = {
...extra_attrs, ...extra_attrs,
id, id,
@@ -50,10 +224,17 @@ function rsv_elements_table_script(array $elements_with_ids, int $next_id, strin
pattern: data.pattern ?? '', pattern: data.pattern ?? '',
pattern_message: data.pattern_message ?? '', pattern_message: data.pattern_message ?? '',
} : {}), } : {}),
...(data.type === 'output-text' ? {
tag: data.tag ?? 'p',
} : {}),
...(data.type === 'reservation' ? { ...(data.type === 'reservation' ? {
timetable_id: data.timetable_id ? parseInt(data.timetable_id) : null, timetable_id: data.timetable_id ? parseInt(data.timetable_id) : null,
price_per_block: parseFloat(data.price_per_block ?? '0') || 0, price_per_block: parseFloat(data.price_per_block ?? '0') || 0,
email_templates: { email_templates: {
maintainer: {
subject: data.email_maintainer_subject ?? RSV_EMAIL_DEFAULTS.maintainer_subject,
body: data.email_maintainer_body ?? RSV_EMAIL_DEFAULTS.maintainer_body,
},
on_accepted: { on_accepted: {
enabled: !!data.email_accepted_enabled, enabled: !!data.email_accepted_enabled,
subject: data.email_accepted_subject ?? RSV_EMAIL_DEFAULTS.accepted_subject, subject: data.email_accepted_subject ?? RSV_EMAIL_DEFAULTS.accepted_subject,
@@ -92,6 +273,98 @@ function rsv_elements_table_script(array $elements_with_ids, int $next_id, strin
}; };
})(<?= $elements_json ?>, <?= $next_id ?>); })(<?= $elements_json ?>, <?= $next_id ?>);
const rsv_bindings_source = (function(initial_items, next_id_start) {
const items = (initial_items || []).map((b, i) => ({
id: i + 1,
program_id: parseInt(b.program_id) || 0,
discount: parseFloat(b.discount) || 0,
field: b.field ?? '',
}));
let next_id = next_id_start;
return {
get_page(skip, limit) {
skip = skip ?? 0;
limit = limit ?? 20;
return Promise.resolve({ total: items.length, data: items.slice(skip, skip + limit) });
},
put(id, data) {
const idx = items.findIndex(e => e.id === id);
if (idx === -1) return Promise.reject(new Error('Binding not found'));
items[idx] = {
id,
program_id: parseInt(data.program_id) || 0,
discount: parseFloat(data.discount) || 0,
field: data.field ?? '',
};
return Promise.resolve(items[idx]);
},
add() {
const item = { id: next_id++, program_id: 0, discount: 0, field: '' };
items.push(item);
return item;
},
remove(id) {
const idx = items.findIndex(e => e.id === id);
if (idx !== -1) items.splice(idx, 1);
},
get_all() {
return items
.filter(b => b.program_id > 0)
.map(({ id, ...rest }) => rest);
},
};
})(<?= $bindings_json ?>, <?= $bindings_next ?>);
function rsv_collect_definition() {
const form = document.getElementById('<?= $form_id ?>');
const get = (n) => form?.querySelector(`[name="${n}"]`)?.value ?? '';
return {
name: get('name'),
definition: {
email_key: get('definition.email_key'),
success_message: get('definition.success_message'),
membership: {
bindings: rsv_bindings_source.get_all(),
combine: get('definition.membership_combine') || 'max',
},
elements: rsv_elements_source.get_all(),
},
};
}
const rsv_preview_el = document.getElementById('rsv_form_preview');
let rsv_preview_timer = null;
function rsv_schedule_preview() {
if (!rsv_preview_el) return;
clearTimeout(rsv_preview_timer);
rsv_preview_timer = setTimeout(rsv_render_preview, 300);
}
function rsv_render_preview() {
if (!rsv_preview_el) return;
fetch('<?= get_rest_url(null, 'reservations/v1/form-definition/preview') ?>', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-WP-Nonce': ReservairServiceAPI.nonce,
},
body: JSON.stringify(rsv_collect_definition()),
})
.then(r => r.ok ? r.json() : r.json().then(e => { throw new Error(e.error || 'Preview failed'); }))
.then(data => {
rsv_preview_el.innerHTML = data.html || '<p class="rsv-preview-empty">No fields to preview yet.</p>';
})
.catch(() => { rsv_preview_el.innerHTML = '<p class="rsv-preview-empty">Preview unavailable.</p>'; });
}
// The preview form is inert: block submission (capture so it works after re-render).
rsv_preview_el?.addEventListener('submit', (e) => e.preventDefault(), true);
function rsv_render_element_inline_form(dt, row, data) { function rsv_render_element_inline_form(dt, row, data) {
const builder = RsvInlineFormBuilder.create(rsv_elements_source) const builder = RsvInlineFormBuilder.create(rsv_elements_source)
.fieldset('Element', '50%') .fieldset('Element', '50%')
@@ -103,6 +376,7 @@ function rsv_elements_table_script(array $elements_with_ids, int $next_id, strin
.input_checkbox('required', 'Required', data?.required ?? false); .input_checkbox('required', 'Required', data?.required ?? false);
if ((data?.type ?? rsv_element_types[0]) === 'reservation') { if ((data?.type ?? rsv_element_types[0]) === 'reservation') {
const maintainer = data?.email_templates?.maintainer ?? {};
const accepted = data?.email_templates?.on_accepted ?? {}; const accepted = data?.email_templates?.on_accepted ?? {};
const refused = data?.email_templates?.on_refused ?? {}; const refused = data?.email_templates?.on_refused ?? {};
const timetable_options = [ const timetable_options = [
@@ -112,6 +386,9 @@ function rsv_elements_table_script(array $elements_with_ids, int $next_id, strin
builder builder
.input_select('timetable_id', 'Timetable', timetable_options, data?.timetable_id ?? '') .input_select('timetable_id', 'Timetable', timetable_options, data?.timetable_id ?? '')
.input_number('price_per_block', 'Price per block', data?.price_per_block ?? 0) .input_number('price_per_block', 'Price per block', data?.price_per_block ?? 0)
.fieldset('Email - for maintainer', '100%')
.input_text('email_maintainer_subject', 'Subject', maintainer.subject ?? RSV_EMAIL_DEFAULTS.maintainer_subject)
.input_textarea('email_maintainer_body', 'Body', maintainer.body ?? RSV_EMAIL_DEFAULTS.maintainer_body)
.fieldset('Email — accepted', '100%') .fieldset('Email — accepted', '100%')
.input_checkbox('email_accepted_enabled', 'Send email when accepted', accepted.enabled ?? true) .input_checkbox('email_accepted_enabled', 'Send email when accepted', accepted.enabled ?? true)
.input_text('email_accepted_subject', 'Subject', accepted.subject ?? RSV_EMAIL_DEFAULTS.accepted_subject) .input_text('email_accepted_subject', 'Subject', accepted.subject ?? RSV_EMAIL_DEFAULTS.accepted_subject)
@@ -122,6 +399,19 @@ function rsv_elements_table_script(array $elements_with_ids, int $next_id, strin
.input_textarea('email_refused_body', 'Body', refused.body ?? RSV_EMAIL_DEFAULTS.refused_body); .input_textarea('email_refused_body', 'Body', refused.body ?? RSV_EMAIL_DEFAULTS.refused_body);
} }
if ((data?.type ?? rsv_element_types[0]) === 'output-text') {
builder
.input_select('tag', 'Tag', [
{ value: 'p', label: 'Paragraph (p)' },
{ value: 'h1', label: 'Heading 1 (h1)' },
{ value: 'h2', label: 'Heading 2 (h2)' },
{ value: 'h3', label: 'Heading 3 (h3)' },
{ value: 'h4', label: 'Heading 4 (h4)' },
{ value: 'h5', label: 'Heading 5 (h5)' },
{ value: 'h6', label: 'Heading 6 (h6)' },
], data?.tag ?? 'p');
}
if ((data?.type ?? rsv_element_types[0]) === 'input-text') { if ((data?.type ?? rsv_element_types[0]) === 'input-text') {
builder builder
.input_select('validation', 'Validation', [ .input_select('validation', 'Validation', [
@@ -139,10 +429,10 @@ function rsv_elements_table_script(array $elements_with_ids, int $next_id, strin
const node = builder.build({ const node = builder.build({
id: data?.id, id: data?.id,
colspan: 5, colspan: 6,
save_label: 'Save', save_label: 'Save',
on_success: () => elements_dt.refresh(), on_success: () => { elements_dt.refresh(); rsv_schedule_preview(); },
on_cancel: () => elements_dt.refresh(), on_cancel: () => { elements_dt.refresh(); rsv_schedule_preview(); },
}); });
// Type swaps whole fieldsets, so re-render the inline form on change. // Type swaps whole fieldsets, so re-render the inline form on change.
@@ -186,14 +476,17 @@ function rsv_elements_table_script(array $elements_with_ids, int $next_id, strin
'Move Up': RsvDataGrid.func_action(function(dt, row, data) { 'Move Up': RsvDataGrid.func_action(function(dt, row, data) {
rsv_elements_source.move_up(data.id); rsv_elements_source.move_up(data.id);
dt.refresh(); dt.refresh();
rsv_schedule_preview();
}), }),
'Move Down': RsvDataGrid.func_action(function(dt, row, data) { 'Move Down': RsvDataGrid.func_action(function(dt, row, data) {
rsv_elements_source.move_down(data.id); rsv_elements_source.move_down(data.id);
dt.refresh(); dt.refresh();
rsv_schedule_preview();
}), }),
'Remove': RsvDataGrid.func_action(function(dt, row, data) { 'Remove': RsvDataGrid.func_action(function(dt, row, data) {
rsv_elements_source.remove(data.id); rsv_elements_source.remove(data.id);
dt.refresh(); dt.refresh();
rsv_schedule_preview();
}), }),
}), }),
'label': RsvDataGrid.column('Label', false), 'label': RsvDataGrid.column('Label', false),
@@ -226,172 +519,120 @@ function rsv_elements_table_script(array $elements_with_ids, int $next_id, strin
document.getElementById('rsv_add_element_btn').onclick = function() { document.getElementById('rsv_add_element_btn').onclick = function() {
rsv_elements_source.add(); rsv_elements_source.add();
elements_dt.refresh(); elements_dt.refresh();
rsv_schedule_preview();
}; };
} }
RsvAdminForm.bind(document.getElementById('<?= $form_id ?>'), { // The Email Key select offers the form's fields. Elements are edited in
transform: (body) => ({ // the grid above and only persisted on save, so refresh the options from
name: body.name, // the live source each time the select is opened.
definition: { const rsv_email_key_select = document.querySelector('#edit_form_definition select[name="definition.email_key"]');
email_key: body.definition?.email_key ?? '', function rsv_sync_email_key_options() {
elements: rsv_elements_source.get_all(), if (!rsv_email_key_select) return;
}, const selected = rsv_email_key_select.value;
const options = [new Option('— select field —', '')];
for (const el of rsv_elements_source.get_all()) {
if (!el.name) continue;
options.push(new Option(el.label ? `${el.label} (${el.name})` : el.name, el.name));
}
rsv_email_key_select.replaceChildren(...options);
rsv_email_key_select.value = selected;
}
rsv_email_key_select?.addEventListener('focus', rsv_sync_email_key_options);
// Membership discount bindings — edited in a data grid backed by the
// in-memory source above; persisted with the rest of the definition.
function rsv_binding_program_options() {
return [
{ value: '', label: '— select program —' },
...rsv_membership_programs.map(p => ({ value: p.id, label: p.name })),
];
}
function rsv_binding_field_options() {
const options = [{ value: '', label: '— select field —' }];
for (const el of rsv_elements_source.get_all()) {
if (!el.name) continue;
options.push({ value: el.name, label: el.label ? `${el.label} (${el.name})` : el.name });
}
return options;
}
function rsv_render_binding_inline_form(dt, row, data) {
return RsvInlineFormBuilder.create(rsv_bindings_source)
.fieldset('Discount binding', '100%')
.input_select('program_id', 'Program', rsv_binding_program_options(), data?.program_id ?? '')
.input_number('discount', 'Discount %', data?.discount ?? 0)
.input_select('field', 'Field', rsv_binding_field_options(), data?.field ?? '')
.build({
id: data?.id,
colspan: 3,
save_label: 'Save',
on_success: () => { rsv_bindings_dt.refresh(); rsv_schedule_preview(); },
on_cancel: () => { rsv_bindings_dt.refresh(); rsv_schedule_preview(); },
});
}
const rsv_bindings_table_el = document.getElementById('rsv_membership_bindings_table');
if (rsv_bindings_table_el) {
var rsv_bindings_dt = RsvDataGrid.create_data_grid(
rsv_bindings_table_el,
rsv_bindings_source,
{
'program_id': RsvDataGrid.action_column('Program', false, {
'Edit': RsvDataGrid.edit_action(function(dt, row, data) {
row.classList.add(
'inline-edit-row', 'inline-edit-row-post',
'quick-edit-row', 'quick-edit-row-post',
'inline-edit-post', 'inline-editor'
);
row.replaceChildren(rsv_render_binding_inline_form(dt, row, data));
}), }),
'Remove': RsvDataGrid.func_action(function(dt, row, data) {
rsv_bindings_source.remove(data.id);
dt.refresh();
rsv_schedule_preview();
}),
}),
'discount': RsvDataGrid.column('Discount %', false),
'field': RsvDataGrid.column('Field', false),
}
);
rsv_bindings_dt.map_column('program_id', (dt, row, data) => {
const td = document.createElement('td');
const p = rsv_membership_programs.find(p => String(p.id) === String(data.program_id));
td.innerText = p ? p.name : (data.program_id ? `#${data.program_id}` : '—');
return td;
});
rsv_bindings_dt.map_column('field', (dt, row, data) => {
const td = document.createElement('td');
const el = rsv_elements_source.get_all().find(e => e.name === data.field);
td.innerText = data.field ? (el && el.label ? `${el.label} (${el.name})` : data.field) : '—';
return td;
});
rsv_bindings_dt.refresh();
document.getElementById('rsv_add_binding_btn')?.addEventListener('click', function() {
rsv_bindings_source.add();
rsv_bindings_dt.refresh();
rsv_schedule_preview();
});
}
const rsv_meta_form = document.getElementById('<?= $form_id ?>');
['name', 'definition.email_key', 'definition.success_message', '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);
});
RsvAdminForm.bind(document.getElementById('<?= $form_id ?>'), {
transform: () => rsv_collect_definition(),
refresh: () => { if (typeof forms_dt !== 'undefined') forms_dt.refresh(); }, refresh: () => { if (typeof forms_dt !== 'undefined') forms_dt.refresh(); },
}); });
rsv_render_preview();
</script> </script>
<?php <?php
}
function rsv_form_info_page(): void {
global $rsv_form_registry;
$element_types = array_keys($rsv_form_registry->handlers);
$elements_with_ids = [];
$next_id = 1;
$timetables = (new RsvTimetableService())->get_all();
?>
<h1>Formuláře</h1>
<hr>
<div id="col-container" class="wp-clearfix">
<div id="col-left">
<div class="col-wrap">
<div class="form-wrap">
<h2>Přidat formulář</h2>
<form id="add_form_definition"
method="post"
data-method="POST"
data-success-msg="Form definition created."
action="<?= get_rest_url(null, 'reservations/v1/form-definition'); ?>">
<?php wp_nonce_field('my_action', 'my_nonce'); ?>
<?php echo RsvFormBuilder::create('add_form_definition')->text('name', 'Název')->render(); ?>
</form>
</div>
<hr>
<p class="submit">
<button type="submit" form="add_form_definition" class="button button-primary">Add Form Definition</button>
</p>
</div>
</div>
<div id="col-right">
<div class="col-wrap">
<div id="forms_table"></div>
<script>
var forms_dt = RsvDataGrid.create_data_grid(forms_table,
RsvFormDefinitionResource(), {
'form_id': RsvDataGrid.column('ID', false, 30),
'name': RsvDataGrid.action_column('Název', false, {
'Edit': RsvDataGrid.link_action((data) =>
`<?= menu_page_url('forms-settings', false) ?>&id=${data.form_id}&action=edit`
),
'Trash': RsvDataGrid.func_action(function(dt, row, data) {
dt.resource.delete(data.form_id).then(() => forms_dt.refresh()).catch(err => alert(err.message));
}),
'Clone': RsvDataGrid.func_action(function(dt, row, data) {
const base_url = `<?= get_rest_url(null, 'reservations/v1/form-definition') ?>`;
fetch(`${base_url}/${data.form_id}`, {
headers: { 'Accept': 'application/json' },
})
.then(r => {
if (!r.ok) return r.json().then(err => { throw new Error(err.error || 'Fetch failed'); });
return r.json();
})
.then(form_def => fetch(base_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({
name: 'Copy of ' + form_def.name,
definition: form_def.definition,
}),
}))
.then(r => {
if (!r.ok) return r.json().then(err => { throw new Error(err.error || 'Clone failed'); });
forms_dt.refresh();
})
.catch(err => alert(err.message));
}),
}),
});
forms_dt.refresh();
</script>
</div>
</div>
</div>
<?php rsv_elements_table_script($elements_with_ids, $next_id, 'add_form_definition', $element_types, $timetables); ?>
<?php
}
function rsv_form_definition_edit_page(int $id): void {
global $rsv_form_registry;
$element_types = array_keys($rsv_form_registry->handlers);
$repo = new RsvFormDefinitionRepository();
$form_def = $repo->get($id);
if ($form_def === null) {
echo '<div class="notice notice-error"><p>Form definition not found.</p></div>';
return;
} }
$definition = $form_def['definition'] ?? [];
$raw_elements = array_values($definition['elements'] ?? []);
$elements_with_ids = array_map(function (array $el, int $idx): array {
return array_merge($el, ['id' => $idx + 1]);
}, $raw_elements, array_keys($raw_elements));
$next_id = count($elements_with_ids) + 1;
$timetables = (new RsvTimetableService())->get_all();
?>
<h1>Edit Form: <?= esc_html($form_def['name']) ?></h1>
<a href="<?= menu_page_url('forms-settings', false) ?>">← Back to Forms</a>
<hr>
<form id="edit_form_definition"
method="post"
data-method="PUT"
data-success-msg="Form definition updated."
action="<?= get_rest_url(null, 'reservations/v1/form-definition/' . $id); ?>">
<?php
echo RsvFormBuilder::create('edit_form_definition')
->text('name', 'Name', '', true, $form_def['name'])
->text('definition.email_key', 'Email Key', "Name of the form field that holds the submitter's email address.", true, $definition['email_key'] ?? '')
->render();
?>
</form>
<hr>
<h2>Form Elements</h2>
<p>Define the fields that will appear in this form.</p>
<div id="form_elements_table"></div>
<p>
<button type="button" class="button" id="rsv_add_element_btn">+ Add Element</button>
</p>
<p class="submit">
<button type="submit" form="edit_form_definition" class="button button-primary">Update Form Definition</button>
</p>
<?php rsv_elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables); ?>
<?php
}
function rsv_forms_page(): void {
if (isset($_GET['action'])) {
if ($_GET['action'] === 'edit' && isset($_GET['id'])) {
rsv_form_definition_edit_page(intval($_GET['id']));
return;
}
}
rsv_form_info_page();
} }
@@ -1,11 +1,9 @@
<?php <?php
function rsv_google_calendar_settings_page(): void { class RsvGoogleCalendarSettingsPage extends RsvAdminPage {
if (!current_user_can(RsvCapabilities::MANAGE)) {
return;
}
protected function render_content(): void {
$service = new RsvGoogleCalendarService(); $service = new RsvGoogleCalendarService();
$notice = null; $notice = null;
@@ -50,7 +48,6 @@ function rsv_google_calendar_settings_page(): void {
$cal_id = esc_attr(get_option('rsv_google_calendar_id', 'primary')); $cal_id = esc_attr(get_option('rsv_google_calendar_id', 'primary'));
$oauth_url = esc_url($service->get_oauth_url()); $oauth_url = esc_url($service->get_oauth_url());
?> ?>
<div class="wrap">
<h1>Google Calendar</h1> <h1>Google Calendar</h1>
<?php if ($notice): ?> <?php if ($notice): ?>
@@ -127,6 +124,6 @@ function rsv_google_calendar_settings_page(): void {
<?php endif; ?> <?php endif; ?>
<?php endif; ?> <?php endif; ?>
</form> </form>
</div>
<?php <?php
}
} }
@@ -0,0 +1,130 @@
<?php
use Reservair\Forms\RsvFormBuilder;
use Reservair\Layout\RsvColumnLayout;
class RsvMembershipProgramsPage extends RsvAdminPage {
protected function render_content(): void {
if (isset($_GET['action']) && $_GET['action'] === 'edit' && isset($_GET['id'])) {
$this->show_edit(intval($_GET['id']));
return;
}
$this->show_list();
}
private function show_list(): void {
?>
<h1>Membership Programs</h1>
<hr>
<?php
RsvColumnLayout::split('1:2')
->column(function () {
echo RsvFormBuilder::create('add_membership_program', get_rest_url(null, 'reservations/v1/membership-program'), 'POST', 'Membership program created.')
->heading('Add Program')
->nonce('my_action', 'add_membership_program_nonce')
->text('name', 'Name')
->render();
?>
<hr>
<p class="submit">
<button type="submit" form="add_membership_program" class="button button-primary">Add Program</button>
</p>
<?php })
->column(function () { ?>
<div id="programs_table"></div>
<script>
var programs_dt = RsvDataGrid.create_data_grid(programs_table,
RsvMembershipProgramResource(), {
'id': RsvDataGrid.column('ID', false, 30),
'name': RsvDataGrid.action_column('Name', false, {
'Edit': RsvDataGrid.link_action((data) =>
`<?= menu_page_url('membership-programs', false) ?>&id=${data.id}&action=edit`
),
'Delete': RsvDataGrid.func_action(function(dt, row, data) {
if (!confirm('Delete this program? This cannot be undone.')) return;
dt.resource.delete(data.id).then(() => programs_dt.refresh()).catch(err => alert(err.message));
}),
}),
'active': RsvDataGrid.column('Active', false),
});
programs_dt.refresh();
</script>
<?php })
->output();
?>
<script>
RsvAdminForm.bind(document.getElementById('add_membership_program'), {
refresh: () => { if (typeof programs_dt !== 'undefined') programs_dt.refresh(); },
});
</script>
<?php
}
private function show_edit(int $id): void {
$repo = new RsvMembershipProgramRepository();
$program = $repo->get($id);
if ($program === null) {
echo '<div class="notice notice-error"><p>Program not found.</p></div>';
return;
}
?>
<h1>Edit Program: <?= esc_html($program['name']) ?></h1>
<a href="<?= menu_page_url('membership-programs', false) ?>">← Back to Programs</a>
<hr>
<?php
echo RsvFormBuilder::create('edit_membership_program', get_rest_url(null, 'reservations/v1/membership-program/' . $id), 'PUT', 'Program updated.')
->text('name', 'Name', '', true, $program['name'])
->checkbox('active', 'Active', '', $program['active'] ?? true)
->render();
?>
<script>
RsvAdminForm.bind(document.getElementById('edit_membership_program'));
</script>
<hr>
<h2>Roster</h2>
<p>Each member is identified by a single key. The key format depends on the active membership strategy.</p>
<?php
RsvColumnLayout::split('1:2')
->column(function () use ($id) {
echo RsvFormBuilder::create('add_membership_key', get_rest_url(null, 'reservations/v1/membership-program/' . $id . '/keys'), 'POST', 'Member added.')
->heading('Add Member')
->text('key_value', 'Key')
->render();
?>
<hr>
<p class="submit">
<button type="submit" form="add_membership_key" class="button button-primary">Add Member</button>
</p>
<?php })
->column(function () use ($id) { ?>
<div id="roster_table"></div>
<script>
var roster_dt = RsvDataGrid.create_data_grid(roster_table,
RsvMembershipKeyResource(<?= (int) $id ?>), {
'id': RsvDataGrid.column('ID', false, 30),
'key_value': RsvDataGrid.action_column('Key', false, {
'Delete': RsvDataGrid.func_action(function(dt, row, data) {
if (!confirm('Delete this member? This cannot be undone.')) return;
dt.resource.delete(data.id).then(() => roster_dt.refresh()).catch(err => alert(err.message));
}),
}),
});
roster_dt.refresh();
</script>
<?php })
->output();
?>
<script>
RsvAdminForm.bind(document.getElementById('add_membership_key'), {
refresh: () => { if (typeof roster_dt !== 'undefined') roster_dt.refresh(); },
onSuccess: () => { document.getElementById('add_membership_key')?.reset(); },
});
</script>
<?php
}
}
+4 -1
View File
@@ -1,6 +1,8 @@
<?php <?php
function rsv_reservations_page(): void { class RsvReservationsPage extends RsvAdminPage {
protected function render_content(): void {
?> ?>
<h1>Form Submissions</h1> <h1>Form Submissions</h1>
@@ -216,4 +218,5 @@ function rsv_reservations_page(): void {
reservations_dt.refresh(); reservations_dt.refresh();
</script> </script>
<?php <?php
}
} }
+134 -194
View File
@@ -1,53 +1,46 @@
<?php <?php
use Reservair\Forms\RsvFormBuilder; use Reservair\Forms\RsvFormBuilder;
use Reservair\Layout\RsvColumnLayout;
class RsvTimetablePage extends RsvAdminPage {
function rsv_timetable_list_page() { protected function render_content(): void {
if (isset($_GET['action'])) {
if ($_GET['action'] === 'view' && isset($_GET['id'])) {
$this->show_view(intval($_GET['id']));
return;
} elseif ($_GET['action'] === 'edit' && isset($_GET['id'])) {
$this->show_capacity(intval($_GET['id']));
return;
}
// Deletion is intentionally not handled here: a state-changing GET is
// CSRF-prone. Timetables are deleted via the nonce-authenticated REST
// DELETE /timetable/{id} (see the "Trash" action in the list grid).
}
$this->show_list();
}
private function show_list(): void {
?> ?>
<style>
/*#col-left {
width: 30%;
}*/
/*#col-right {
width: 70%;
}*/
</style>
<h1>Timetables</h1> <h1>Timetables</h1>
<hr> <hr>
<div id="col-container" class="wp-clearfix">
<div id="col-left">
<div class="col-wrap">
<div class="form-wrap">
<h2>Add timetable</h2>
<?php <?php
$timetable_service = new RsvTimetableService(); $timetable_service = new RsvTimetableService();
$existing_emails = $timetable_service->get_all_maintainer_emails(); $existing_emails = $timetable_service->get_all_maintainer_emails();
$existing_emails_json = json_encode($existing_emails);
?>
<datalist id="maintainer_email_suggestions">
<?php foreach ($existing_emails as $email): ?>
<option value="<?= esc_attr($email) ?>">
<?php endforeach; ?>
</datalist>
<form id="add_timetable_form" RsvColumnLayout::split('1:2')
method="post" ->column(function () use ($existing_emails) {
data-method="POST" echo RsvFormBuilder::create('add_timetable_form', get_rest_url(null, 'reservations/v1/timetable'), 'POST', 'Timetable created.')
data-success-msg="Timetable created." ->heading('Add timetable')
action="<?= get_rest_url(null, 'reservations/v1/timetable'); ?>"> ->datalist('maintainer_email_suggestions', $existing_emails)
<?php
echo RsvFormBuilder::create('add_timetable_form')
->text('name', 'Name', 'Name of the timetable that can be reserved.', true) ->text('name', 'Name', 'Name of the timetable that can be reserved.', true)
->number('block_size', 'Block length (minutes)', 'Duration of one reservable time block in minutes.', true, '', 1) ->number('block_size', 'Block length (minutes)', 'Duration of one reservable time block in minutes.', true, '', 1)
->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, '', 'maintainer_email_suggestions') ->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, '', 'maintainer_email_suggestions')
->submit('Add Timetable') ->submit('Add Timetable')
->render(); ->render();
?> ?>
</form>
<script> <script>
RsvAdminForm.bind(add_timetable_form, { RsvAdminForm.bind(add_timetable_form, {
transform: (body) => ({ transform: (body) => ({
@@ -58,14 +51,9 @@ function rsv_timetable_list_page() {
refresh: () => availability_dt.refresh(), refresh: () => availability_dt.refresh(),
}); });
</script> </script>
</div> <?php })
</div> ->column(function () { ?>
</div> <div id="availability_table"></div>
<div id="col-right">
<div class="col-wrap">
<div id="availability_table">
</div>
<script> <script>
var availability_dt = RsvDataGrid.create_data_grid(availability_table, var availability_dt = RsvDataGrid.create_data_grid(availability_table,
RsvTimetableResource(), { RsvTimetableResource(), {
@@ -83,71 +71,67 @@ function rsv_timetable_list_page() {
}); });
availability_dt.refresh(); availability_dt.refresh();
</script> </script>
</div> <?php })
</div> ->output();
</div>
<?php
}
function rsv_create_capacity_form($timetable_id) {
$form = RsvFormBuilder::create("create_capacity_form", get_rest_url(null, 'reservations/v1/timetable/' . $timetable_id . '/capacity'));
$form->date('date', 'First Date', 'Od kterého datumu platí tato kapacita.', true, new DateTime()->format('Y-m-d'));
$form->group('Availability Range', fn($g) => $g
->time('start_time', 'Start')
->time('end_time', 'End')
);
$form->number('capacity', 'Capacity', 'How many reservations can overlap on the same time.', true, 1, 1);
$form->number('min_lead_time_minutes', 'Minimum lead time (minutes)', 'How many minutes in advance must be the reservation created. This is useful if it takes some time to prepare the reservation.', true);
$form->checkbox('requires_confirmation', 'Requires Confirmation?', 'If checked, all the reservations that overlap this capacity will require confirmation from maintainer. The maintainer will receive an email asking for the confirmation.', true);
$form->custom('Is Repeating Event', function() {
return '
<input id="is_repeating" class="regular-text" type="checkbox" name="is_repeating" checked="true">
<p>If the capacity is available repeatingly. For example: repeat each monday every week.</p>
';
});
$form->number('repeat_period_in_days', 'Repeat Period (days)', 'How many days between each repetition.', true);
$form->custom('Apply to Days', function() {
return '
<table class="option-table">
<tbody>
<tr class="form-day-names">
<td>Monday</td>
<td>Tuesday</td>
<td>Wednesday</td>
<td>Thursday</td>
<td>Friday</td>
<td>Saturday</td>
<td>Sunday</td>
</tr>
<tr class="form-days">
<td><input class="is-repeating-input" type="checkbox" name="monday"></td>
<td><input class="is-repeating-input" type="checkbox" name="tuesday"></td>
<td><input class="is-repeating-input" type="checkbox" name="wednesday"></td>
<td><input class="is-repeating-input" type="checkbox" name="thursday"></td>
<td><input class="is-repeating-input" type="checkbox" name="friday"></td>
<td><input class="is-repeating-input" type="checkbox" name="saturday"></td>
<td><input class="is-repeating-input" type="checkbox" name="sunday"></td>
</tr>
</tbody>
</table>';
});
$form->submit('Create Capacity', 'button-primary', 'submit');
?> ?>
<form
id="create_capacity_form"
data-method="POST"
data-success-msg="Capacity created."
action="<?= get_rest_url(null, 'reservations/v1/timetable/' . $timetable_id . '/capacity'); ?>" >
<?php <?php
$form->output(); }
private function show_view(int $id): void {
$timetable = (new RsvTimetableService())->get($id);
if ($timetable === null) {
echo '<div class="notice notice-error"><p>Timetable not found.</p></div>';
return;
}
?> ?>
</form> <h1><?= esc_html($timetable->name) ?></h1>
<?php <a href="<?= esc_url(menu_page_url('timetable-settings', false)) ?>">← Back to Timetables</a>
} <hr>
function rsv_timetable_capacity_view($id) { <table class="form-table">
<tr><th>Name</th><td><?= esc_html($timetable->name) ?></td></tr>
<tr><th>Block size</th><td><?= esc_html($timetable->block_size) ?> minutes</td></tr>
<?php if ($timetable->maintainer_email): ?>
<tr><th>Maintainer email</th><td><?= esc_html($timetable->maintainer_email) ?></td></tr>
<?php endif; ?>
</table>
<h2>Reservations</h2>
<div id="timetable_reservations_table"></div>
<script>
RsvDataGrid.create_data_grid(
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
),
}),
'start_utc': RsvDataGrid.column('Start', false),
'end_utc': RsvDataGrid.column('End', false),
'is_confirmed': RsvDataGrid.column('Status', false),
}
)
.map_column('is_confirmed', (dt, row, data) => {
const td = document.createElement('td');
td.textContent = data.pending_confirmation_id !== null ? 'Pending'
: data.is_confirmed == 1 ? 'Confirmed'
: 'Refused';
return td;
})
.refresh();
</script>
<?php
}
private function show_capacity(int $id): void {
$timetable_service = new RsvTimetableService(); $timetable_service = new RsvTimetableService();
$timetable = $timetable_service->get($id); $timetable = $timetable_service->get($id);
$gcal_service = new RsvGoogleCalendarService(); $gcal_service = new RsvGoogleCalendarService();
@@ -156,21 +140,10 @@ function rsv_timetable_capacity_view($id) {
$existing_emails = $timetable_service->get_all_maintainer_emails(); $existing_emails = $timetable_service->get_all_maintainer_emails();
?> ?>
<h2>Settings</h2>
<datalist id="maintainer_email_list">
<?php foreach ($existing_emails as $email): ?>
<option value="<?= esc_attr($email) ?>">
<?php endforeach; ?>
</datalist>
<form id="timetable_settings_form"
action="<?= esc_url(get_rest_url(null, 'reservations/v1/timetable/' . $id)) ?>"
data-method="PATCH"
data-success-msg="Settings saved.">
<?php <?php
RsvFormBuilder::create("timetable_settings_form") RsvFormBuilder::create('timetable_settings_form', get_rest_url(null, 'reservations/v1/timetable/' . $id), 'PATCH', 'Settings saved.')
->heading('Settings')
->datalist('maintainer_email_suggestions', $existing_emails)
->text('name', 'Name', 'Name of the timetable that can be reserved.', true, $timetable->name) ->text('name', 'Name', 'Name of the timetable that can be reserved.', true, $timetable->name)
->number('block_size', 'Block length (minutes)', 'Duration of one reservable time block in minutes.', true, $timetable->block_size, 1) ->number('block_size', 'Block length (minutes)', 'Duration of one reservable time block in minutes.', true, $timetable->block_size, 1)
->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, $timetable->maintainer_email ?? '', 'maintainer_email_suggestions') ->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, $timetable->maintainer_email ?? '', 'maintainer_email_suggestions')
@@ -189,7 +162,6 @@ function rsv_timetable_capacity_view($id) {
->submit('Save Settings', 'button-primary', 'submit') ->submit('Save Settings', 'button-primary', 'submit')
->output(); ->output();
?> ?>
</form>
<script> <script>
(function() { (function() {
@@ -225,11 +197,8 @@ function rsv_timetable_capacity_view($id) {
<p>Define capacities for timetable.</p> <p>Define capacities for timetable.</p>
<?php <?php $this->create_capacity_form($id); ?>
rsv_create_capacity_form($id);
?>
<script> <script>
(function() { (function() {
const DAY_DOW = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 0 }; const DAY_DOW = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 0 };
@@ -353,82 +322,53 @@ function rsv_timetable_capacity_view($id) {
capacity_dt.refresh(); capacity_dt.refresh();
</script> </script>
<?php <?php
}
function rsv_timetable_view_page(int $id): void {
$timetable = (new RsvTimetableService())->get($id);
if ($timetable === null) {
echo '<div class="notice notice-error"><p>Timetable not found.</p></div>';
return;
}
?>
<h1><?= esc_html($timetable->name) ?></h1>
<a href="<?= esc_url(menu_page_url('timetable-settings', false)) ?>">← Back to Timetables</a>
<hr>
<table class="form-table">
<tr><th>Name</th><td><?= esc_html($timetable->name) ?></td></tr>
<tr><th>Block size</th><td><?= esc_html($timetable->block_size) ?> minutes</td></tr>
<?php if ($timetable->maintainer_email): ?>
<tr><th>Maintainer email</th><td><?= esc_html($timetable->maintainer_email) ?></td></tr>
<?php endif; ?>
</table>
<h2>Reservations</h2>
<div id="timetable_reservations_table"></div>
<script>
RsvDataGrid.create_data_grid(
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
),
}),
'start_utc': RsvDataGrid.column('Start', false),
'end_utc': RsvDataGrid.column('End', false),
'is_confirmed': RsvDataGrid.column('Status', false),
}
)
.map_column('is_confirmed', (dt, row, data) => {
const td = document.createElement('td');
td.textContent = data.pending_confirmation_id !== null ? 'Pending'
: data.is_confirmed == 1 ? 'Confirmed'
: 'Refused';
return td;
})
.refresh();
</script>
<?php
}
function rsv_timetable_edit_page($id) {
rsv_timetable_capacity_view($id);
}
function rsv_timetable_page() {
if (isset($_GET['action'])) {
if ($_GET['action'] === 'view' && isset($_GET['id'])) {
rsv_timetable_view_page(intval($_GET['id']));
return;
} else if($_GET['action'] === 'edit' && isset($_GET['id'])) {
rsv_timetable_edit_page(intval($_GET['id']));
return;
}
// Deletion is intentionally not handled here: a state-changing GET is
// CSRF-prone. Timetables are deleted via the nonce-authenticated REST
// DELETE /timetable/{id} (see the "Trash" action in the list grid).
} }
rsv_timetable_list_page(); private function create_capacity_form(int $timetable_id): void {
$form = RsvFormBuilder::create('create_capacity_form', get_rest_url(null, 'reservations/v1/timetable/' . $timetable_id . '/capacity'), 'POST', 'Capacity created.');
$form->date('date', 'First Date', 'Od kterého datumu platí tato kapacita.', true, new DateTime()->format('Y-m-d'));
$form->group('Availability Range', fn($g) => $g
->time('start_time', 'Start')
->time('end_time', 'End')
);
$form->number('capacity', 'Capacity', 'How many reservations can overlap on the same time.', true, 1, 1);
$form->number('min_lead_time_minutes', 'Minimum lead time (minutes)', 'How many minutes in advance must be the reservation created. This is useful if it takes some time to prepare the reservation.', true);
$form->checkbox('requires_confirmation', 'Requires Confirmation?', 'If checked, all the reservations that overlap this capacity will require confirmation from maintainer. The maintainer will receive an email asking for the confirmation.', true);
$form->custom('Is Repeating Event', function() {
return '
<input id="is_repeating" class="regular-text" type="checkbox" name="is_repeating" checked="true">
<p>If the capacity is available repeatingly. For example: repeat each monday every week.</p>
';
});
$form->number('repeat_period_in_days', 'Repeat Period (days)', 'How many days between each repetition.', true);
$form->custom('Apply to Days', function() {
return '
<table class="option-table">
<tbody>
<tr class="form-day-names">
<td>Monday</td>
<td>Tuesday</td>
<td>Wednesday</td>
<td>Thursday</td>
<td>Friday</td>
<td>Saturday</td>
<td>Sunday</td>
</tr>
<tr class="form-days">
<td><input class="is-repeating-input" type="checkbox" name="monday"></td>
<td><input class="is-repeating-input" type="checkbox" name="tuesday"></td>
<td><input class="is-repeating-input" type="checkbox" name="wednesday"></td>
<td><input class="is-repeating-input" type="checkbox" name="thursday"></td>
<td><input class="is-repeating-input" type="checkbox" name="friday"></td>
<td><input class="is-repeating-input" type="checkbox" name="saturday"></td>
<td><input class="is-repeating-input" type="checkbox" name="sunday"></td>
</tr>
</tbody>
</table>';
});
$form->submit('Create Capacity', 'button-primary', 'submit');
$form->output();
}
} }
+3 -1
View File
@@ -27,7 +27,9 @@ class Db {
public static function get_results(string $sql, array $params = [], string $output = OBJECT): array { public static function get_results(string $sql, array $params = [], string $output = OBJECT): array {
global $wpdb; 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(); self::throw_if_error();
return $rows ?? []; return $rows ?? [];
} }
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace Reservair\Forms;
/**
* Wraps WordPress' bundled CodeMirror (wp_enqueue_code_editor) as a drop-in
* replacement for a <textarea>.
*
* The textarea stays the source of truth: CodeMirror mirrors its content back
* on every edit, so any form serialization that reads the textarea's value
* keeps working unchanged. When the user has turned syntax highlighting off in
* their profile, this degrades to the plain textarea.
*/
class RsvCodeEditor
{
/**
* Renders a code-editor-backed <textarea> and arranges for it to be
* upgraded to CodeMirror on the page.
*
* @param array{name?:string,value?:string,mode?:string,rows?:int,class?:string} $args
* mode is a MIME type understood by wp_enqueue_code_editor, e.g.
* 'text/html' or 'text/css'.
*/
public static function render(string $id, array $args = []): string
{
$name = $args['name'] ?? $id;
$value = $args['value'] ?? '';
$mode = $args['mode'] ?? 'text/html';
$rows = $args['rows'] ?? 8;
$class = $args['class'] ?? 'large-text code';
$textarea = '<textarea id="' . esc_attr($id) . '" name="' . esc_attr($name) . '"'
. ' rows="' . $rows . '" class="' . esc_attr($class) . '">'
. esc_textarea($value)
. '</textarea>';
$settings = wp_enqueue_code_editor(['type' => $mode]);
// Syntax highlighting disabled in the user's profile — keep it plain.
if ($settings === false) {
return $textarea;
}
self::schedule_init($id, $settings);
return $textarea;
}
/**
* Initializes CodeMirror on $id after the code editor script loads, and
* keeps the underlying textarea in sync — including dispatching `input` so
* listeners (live previews, change tracking) still fire while typing.
*
* @param array<array-key,mixed> $settings As returned by wp_enqueue_code_editor.
*/
private static function schedule_init(string $id, array $settings): void
{
$id_json = wp_json_encode($id);
$settings_json = wp_json_encode($settings);
if ($id_json === false || $settings_json === false) {
return;
}
$script = '(function(){'
. 'var ta=document.getElementById(' . $id_json . ');'
. 'if(!ta||!window.wp||!wp.codeEditor)return;'
. 'var ed=wp.codeEditor.initialize(ta,' . $settings_json . ');'
. 'ed.codemirror.on("change",function(cm){'
. 'cm.save();'
. 'ta.dispatchEvent(new Event("input",{bubbles:true}));'
. '});'
. '})();';
wp_add_inline_script('code-editor', $script);
}
}
+86 -13
View File
@@ -5,24 +5,34 @@ namespace Reservair\Forms;
/** /**
* Fluent builder for WordPress admin settings forms. * Fluent builder for WordPress admin settings forms.
* *
* Renders a <table class="form-table"> with one row per field. * Renders a self-contained "form-wrap" card: an optional heading and a <form>
* Hidden inputs and datalist elements are emitted before the table; * wrapping a <table class="form-table"> with one row per field. Hidden inputs
* notices before, submit button after. * and datalist elements are emitted before the table; notices before the card,
* submit button after the fields.
* *
* Usage: * Usage:
* echo RsvFormBuilder::create() * echo RsvFormBuilder::create('settings', $action, 'PATCH', 'Saved.')
* ->heading('Settings')
* ->text('name', 'Name', required: true) * ->text('name', 'Name', required: true)
* ->email('email', 'Email') * ->email('email', 'Email')
* ->submit('Save'); * ->submit('Save');
*/ */
class RsvFormBuilder class RsvFormBuilder
{ {
private string $form_id = ""; private string $form_id;
/** Where the form submits, and how RsvAdminForm should send it. */
private string $action;
private string $rest_method;
private string $success_msg;
/** Optional heading shown inside the card. */
private string $heading = '';
/** @var string[] Rendered before the table (hidden inputs, datalists). */ /** @var string[] Rendered before the table (hidden inputs, datalists). */
private array $before = []; private array $before = [];
/** @var string[] WP admin notice banners rendered before the table. */ /** @var string[] WP admin notice banners rendered before the card. */
private array $notices = []; private array $notices = [];
/** @var string[] <tr> elements inside the table. */ /** @var string[] <tr> elements inside the table. */
@@ -33,9 +43,29 @@ class RsvFormBuilder
private function __construct() {} private function __construct() {}
public static function create(string $id): static /**
* @param string $rest_method Verb sent via data-method (POST, PUT, PATCH…).
* @param string $success_msg Message RsvAdminForm shows on success.
*/
public static function create(
string $id,
string $action,
string $rest_method = 'POST',
string $success_msg = ''
): static {
$builder = new static();
$builder->form_id = $id;
$builder->action = $action;
$builder->rest_method = $rest_method;
$builder->success_msg = $success_msg;
return $builder;
}
/** Heading shown inside the card, above the fields. */
public function heading(string $text): static
{ {
return new static(); $this->heading = $text;
return $this;
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -164,6 +194,30 @@ class RsvFormBuilder
return $this->row($id, $label, $ctrl, $desc); return $this->row($id, $label, $ctrl, $desc);
} }
/**
* Syntax-highlighted editor backed by WordPress' bundled CodeMirror.
*
* Serializes exactly like {@see textarea()} — the underlying <textarea>
* stays the source of truth.
*
* @param string $mode MIME type for highlighting, e.g. 'text/html'.
*/
public function code(
string $id,
string $label,
string $desc = '',
string $value = '',
string $mode = 'text/html',
int $rows = 8
): static {
$ctrl = RsvCodeEditor::render($id, [
'value' => $value,
'mode' => $mode,
'rows' => $rows,
]);
return $this->row($id, $label, $ctrl, $desc);
}
public function custom(string $label, callable $fn) : static { public function custom(string $label, callable $fn) : static {
$this->rows[] = '<tr>' $this->rows[] = '<tr>'
. '<th>' . esc_html($label) . '</th>' . '<th>' . esc_html($label) . '</th>'
@@ -206,6 +260,13 @@ class RsvFormBuilder
return $this; return $this;
} }
/** WordPress nonce field, emitted inside the form before the table. */
public function nonce(string $action, string $name): static
{
$this->before[] = wp_nonce_field($action, $name, true, false);
return $this;
}
/** /**
* <datalist> element for email/text suggestions — emitted before the table. * <datalist> element for email/text suggestions — emitted before the table.
* *
@@ -257,15 +318,27 @@ class RsvFormBuilder
public function render(): string public function render(): string
{ {
$html = implode('', $this->notices); $inner = implode('', $this->before);
$html .= implode('', $this->before);
if (!empty($this->rows)) { if (!empty($this->rows)) {
$html .= '<table class="form-table"><tbody>' . implode('', $this->rows) . '</tbody></table>'; $inner .= '<table class="form-table"><tbody>' . implode('', $this->rows) . '</tbody></table>';
} }
$html .= implode('', $this->after); $inner .= implode('', $this->after);
return $html;
$success = $this->success_msg !== '' ? ' data-success-msg="' . esc_attr($this->success_msg) . '"' : '';
$form = '<form id="' . esc_attr($this->form_id) . '"'
. ' action="' . esc_url($this->action) . '"'
. ' method="post"'
. ' data-method="' . esc_attr($this->rest_method) . '"'
. $success . '>'
. $inner
. '</form>';
$heading = $this->heading !== '' ? '<h2>' . esc_html($this->heading) . '</h2>' : '';
return implode('', $this->notices)
. '<div class="form-wrap">' . $heading . $form . '</div>';
} }
public function output(): void public function output(): void
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace Reservair\Layout;
/**
* Fluent builder for multi-column admin page layouts.
*
* Lets a page declare the shape it wants (e.g. a two-column split) without
* committing to any markup or styling. Each column's content is supplied as a
* callable that echoes — inline HTML and <script> blocks work as usual.
*
* Usage:
* RsvColumnLayout::split('1:2')
* ->column(function () { ?>
* <h2>Add timetable</h2>
* ...
* <?php })
* ->column(function () { ?>
* <div id="availability_table"></div>
* ...
* <?php })
* ->output();
*/
class RsvColumnLayout
{
/** @var list<callable():void> Column content, in left-to-right order. */
private array $columns = [];
/** @var list<int> Relative column weights, parsed from the ratio. */
private array $weights;
private function __construct(string $ratio)
{
$this->weights = array_map('intval', explode(':', $ratio));
}
/**
* Side-by-side columns that stack on narrow screens.
*
* @param string $ratio Relative column widths, e.g. '1:1', '1:2'.
*/
public static function split(string $ratio = '1:1'): static
{
return new static($ratio);
}
/** Adds the next column. $render echoes the column's content. */
public function column(callable $render): static
{
$this->columns[] = $render;
return $this;
}
public function render(): string
{
$cols = '';
foreach ($this->columns as $i => $render) {
$grow = $this->weights[$i] ?? end($this->weights) ?: 1;
$cols .= '<div class="rsv-col" style="--rsv-col-grow:' . (int) $grow . '">'
. $this->capture($render)
. '</div>';
}
return '<div class="rsv-cols">' . $cols . '</div>';
}
public function output(): void
{
echo $this->render();
}
/** Runs an echoing callable and returns what it printed. */
private function capture(callable $render): string
{
ob_start();
$render();
return ob_get_clean();
}
}
@@ -0,0 +1,90 @@
<?php
namespace Reservair\Templating\Elements;
use Reservair\Templating\RsvTemplateElement;
use Reservair\Templating\RsvTemplateSymbols;
use chillerlan\QRCode\Output\QRGdImagePNG;
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\Data\QRMatrix;
use chillerlan\QRCode\QROptions;
use chillerlan\QRCode\Output\QROutputInterface;
/** Renders a QR payment code for the form's total price into an email body. */
class RsvQrPaymentElement implements RsvTemplateElement {
public function render(RsvTemplateSymbols $symbols): string {
$account = trim((string) $symbols->get('account', ''));
if ($account === '') {
return ''; // No payee account configured — nothing to render.
}
$payload = $this->spayd(
$account,
(float) $symbols->get('price', 0),
(string) $symbols->get('currency', 'CZK'),
(string) $symbols->get('message', ''),
(string) $symbols->get('variable_symbol', '')
);
$src = $this->image_url($payload);
if ($src === '') {
return ''; // Image could not be written.
}
return '<img class="rsv-qr-payment" alt="' . esc_attr__('QR platba', 'reservair')
. '" src="' . esc_url($src) . '" />';
}
public function symbols(): array {
return ['account', 'currency', 'message', 'variable_symbol'];
}
/** Builds a SPAYD string (Czech QR payment). '*' is the field delimiter. */
private function spayd(string $account, float $price, string $currency, string $message, string $vs): string {
$parts = [
'SPD*1.0',
'ACC:' . $account,
'AM:' . number_format($price, 2, '.', ''),
'CC:' . $currency,
];
if ($message !== '') {
$parts[] = 'MSG:' . str_replace('*', ' ', $message);
}
if ($vs !== '') {
$parts[] = 'X-VS:' . preg_replace('/\D/', '', $vs);
}
return implode('*', $parts);
}
/**
* Writes the QR PNG under uploads (deduped by payload hash) and returns its
* public URL, or '' if it could not be written.
*/
private function image_url(string $payload): string {
$uploads = wp_upload_dir();
$dir = $uploads['basedir'] . '/reservair-qr';
$name = hash('sha256', $payload) . '.png';
$path = $dir . '/' . $name;
if (!file_exists($path)) {
wp_mkdir_p($dir);
if (file_put_contents($path, $this->png($payload)) === false) {
return '';
}
}
return $uploads['baseurl'] . '/reservair-qr/' . $name;
}
private function png(string $payload): string {
$options = new QROptions();
$options->version = 7;
$options->outputInterface = QRGdImagePNG::class;
$options->scale = 4;
$options->outputBase64 = false;
$options->bgColor = [200, 150, 200];
return (string) (new QRCode($options))->render($payload);
}
}
@@ -0,0 +1,37 @@
<?php
namespace Reservair\Templating\Elements;
use Reservair\Templating\RsvTemplateElement;
use Reservair\Templating\RsvTemplateSymbols;
/**
* Renders accept / refuse action buttons for a reservation, for the maintainer
* approval email. The links come from the template's accept_url and refuse_url
* symbols; the button labels can be overridden with the accept-label and
* refuse-label attributes.
*/
class RsvReservationActionsElement implements RsvTemplateElement {
/** Inline styles, since email clients ignore stylesheets. */
private const string ACCEPT_STYLE = 'display:inline-block;padding:10px 18px;margin-right:8px;background:#2e7d32;color:#ffffff;text-decoration:none;border-radius:4px;font-weight:bold;';
private const string REFUSE_STYLE = 'display:inline-block;padding:10px 18px;background:#c62828;color:#ffffff;text-decoration:none;border-radius:4px;font-weight:bold;';
public function render(RsvTemplateSymbols $symbols): string {
$accept_url = (string) $symbols->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 '<a href="' . esc_url($accept_url) . '" style="' . self::ACCEPT_STYLE . '">' . esc_html($accept_label) . '</a>'
. '<a href="' . esc_url($refuse_url) . '" style="' . self::REFUSE_STYLE . '">' . esc_html($refuse_label) . '</a>';
}
public function symbols(): array {
return ['accept-label', 'refuse-label'];
}
}
@@ -0,0 +1,22 @@
<?php
namespace Reservair\Templating\Elements;
use Reservair\Templating\RsvTemplateElement;
use Reservair\Templating\RsvTemplateSymbols;
/**
* Emits the client-side summary placeholder. The browser-side RsvFormSender
* locates this div after form submission and fills it with the visitor's
* selected time slots.
*/
class RsvReservationSummaryElement implements RsvTemplateElement {
public function render(RsvTemplateSymbols $symbols): string {
return '<div class="rsv-success-summary"></div>';
}
public function symbols(): array {
return [];
}
}
@@ -0,0 +1,26 @@
<?php
namespace Reservair\Templating\Elements;
use Reservair\Templating\RsvTemplateElement;
use Reservair\Templating\RsvTemplateSymbols;
/**
* Renders a button that asks the form to clear itself. It only carries the
* data-rsv-reset marker; RsvFormSender finds marked buttons in the success card
* and links them to the form's cleanup. The label can be overridden with the
* label attribute.
*/
class RsvResetFormButtonElement implements RsvTemplateElement {
public function render(RsvTemplateSymbols $symbols): string {
$label = (string) $symbols->get('label', 'Odeslat znova');
return '<button type="button" class="rsv-form-btn" data-rsv-reset>'
. esc_html($label)
. '</button>';
}
public function symbols(): array {
return ['label'];
}
}
+23
View File
@@ -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 `<reservation-summary>` 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 `<custom-element attr="test">`, the symbol `attr` with value `test` will also be added to the symbol table.
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace Reservair\Templating;
/** A registered handler for a hyphenated custom element tag. */
interface RsvTemplateElement {
/** Renders the element to a trusted HTML string given the resolved symbol table. */
public function render(RsvTemplateSymbols $symbols): string;
/**
* Returns the symbol names this element understands — used by validation to
* report missing or extra attributes.
*
* @return list<string>
*/
public function symbols(): array;
}
+250
View File
@@ -0,0 +1,250 @@
<?php
namespace Reservair\Templating;
use Reservair\Logger\Logger;
/**
* Renders templates in two phases: first it substitutes {{ path }} interpolations
* with values from the data, then it walks the HTML and expands the registered
* custom elements. The public entry point for the module.
*/
class RsvTemplateEngine {
public readonly RsvTemplateRegistry $registry;
public function __construct(?RsvTemplateRegistry $registry = null) {
$this->registry = $registry ?? new RsvTemplateRegistry();
}
/**
* Renders $source as HTML. Interpolated scalar values are HTML-escaped;
* custom elements emit their own trusted markup.
*
* @param array<string, mixed> $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<string, mixed> $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, references to unknown symbols, unregistered custom
* elements, and attributes an element does not declare.
*
* @param list<string>|null $symbols When given, the roots an interpolation may
* reference; a path rooted outside the set is reported as unknown. Null
* skips the reference check (any path is accepted).
* @return list<string>
*/
public function validate(string $source, ?array $symbols = null): array {
$errors = [];
if (preg_match_all('/{{\s*([^}]*?)\s*}}/', $source, $matches)) {
foreach ($matches[1] as $path) {
$expr = trim($path);
if ($expr === '') {
$errors[] = 'Empty interpolation: {{ }}';
continue;
}
$root = $this->tokens($expr)[0] ?? null;
if ($symbols !== null && $root !== null && !in_array($root, $symbols, true)) {
$errors[] = "Unknown reference: {{ {$expr} }}";
}
}
}
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<string, mixed> $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<string, mixed> $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["<!--{$token}-->"] = $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<string, mixed> $data
*/
private function resolve(string $path, array $data): mixed {
$current = $data;
foreach ($this->tokens($path) as $token) {
if (!is_array($current) || !array_key_exists($token, $current)) {
return null;
}
$current = $current[$token];
}
return $current;
}
/**
* Splits a JSON Path into its key tokens, dropping the root sigil and
* bracket-notation quotes — e.g. "$.items[0]" yields ['items', '0'].
*
* @return list<string>
*/
private function tokens(string $path): array {
$raw = preg_split('/[\.\[\]]+/', $path, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$tokens = [];
foreach ($raw as $token) {
if ($token === '$') {
continue; // root sigil
}
$tokens[] = trim($token, "'\""); // strip bracket-notation quotes
}
return $tokens;
}
// -------------------------------------------------------------------------
// 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 <html>/<body>/doctype wrappers libxml adds.
$dom->loadHTML(
'<?xml encoding="UTF-8">' . $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<string, string> */
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;
}
}
@@ -0,0 +1,5 @@
<?php
namespace Reservair\Templating;
class RsvTemplateException extends \RuntimeException {}
@@ -0,0 +1,41 @@
<?php
namespace Reservair\Templating;
/** Holds the mapping from custom element tag names to their handlers. */
class RsvTemplateRegistry {
/** @var array<string, RsvTemplateElement> */
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<string, RsvTemplateElement> */
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<string, mixed> $base A wp_kses allowed-html map to extend.
* @return array<string, mixed>
*/
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;
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Reservair\Templating;
/** Immutable symbol table passed to custom-element handlers. */
class RsvTemplateSymbols {
/** @param array<string, mixed> $data */
public function __construct(private readonly array $data) {}
/** @return array<string, mixed> */
public function all(): array {
return $this->data;
}
public function get(string $name, mixed $default = null): mixed {
return $this->data[$name] ?? $default;
}
}
-3
View File
@@ -639,9 +639,6 @@
<ClassMustBeFinal> <ClassMustBeFinal>
<code><![CDATA[RsvFormDefinition]]></code> <code><![CDATA[RsvFormDefinition]]></code>
</ClassMustBeFinal> </ClassMustBeFinal>
<InvalidArrayOffset>
<code><![CDATA[$definition['email_key']]]></code>
</InvalidArrayOffset>
<MissingPropertyType> <MissingPropertyType>
<code><![CDATA[$_elements]]></code> <code><![CDATA[$_elements]]></code>
</MissingPropertyType> </MissingPropertyType>
+31 -1
View File
@@ -1,4 +1,9 @@
<?php <?php
use Reservair\Templating\Elements\RsvReservationSummaryElement;
use Reservair\Templating\Elements\RsvReservationActionsElement;
use Reservair\Templating\Elements\RsvResetFormButtonElement;
use Reservair\Templating\Elements\RsvQrPaymentElement;
/** /**
* Plugin Name: Reservair * Plugin Name: Reservair
* Description: A reservation and booking system for WordPress. Site visitors browse available time slots and submit reservation requests via a Gutenberg block; administrators manage timetables, services, forms, and reservations from the WordPress admin panel. * Description: A reservation and booking system for WordPress. Site visitors browse available time slots and submit reservation requests via a Gutenberg block; administrators manage timetables, services, forms, and reservations from the WordPress admin panel.
@@ -29,7 +34,7 @@ register_activation_hook( __FILE__, [ 'RsvInstaller', 'install' ] );
* plugins we might interact with) is fully loaded. * plugins we might interact with) is fully loaded.
*/ */
function rsv_bootstrap(): void { function rsv_bootstrap(): void {
global $rsv_form_registry; global $rsv_form_registry, $rsv_template_registry, $rsv_form_price_registry;
// Re-grant the custom capability after a plugin *update* (the activation hook // Re-grant the custom capability after a plugin *update* (the activation hook
// only runs on activate). No-op once the stored version matches. // only runs on activate). No-op once the stored version matches.
@@ -47,6 +52,31 @@ function rsv_bootstrap(): void {
$rsv_form_registry->register( 'button', new RsvButtonElementHandler() ); $rsv_form_registry->register( 'button', new RsvButtonElementHandler() );
$rsv_form_registry->register( 'reservation', new RsvFormReservationElementHandler() ); $rsv_form_registry->register( 'reservation', new RsvFormReservationElementHandler() );
$rsv_form_registry->register( 'output-reservation-summary', new RsvReservationSummaryElementHandler() ); $rsv_form_registry->register( 'output-reservation-summary', new RsvReservationSummaryElementHandler() );
$rsv_form_registry->register( 'output-text', new RsvOutputTextElementHandler() );
// Price calculators — extensions add per-element calculators via the action.
add_action( 'rsv-register-price-calculator', function ( RsvFormPriceCalculatorRegistry $reg ): void {
$reg->register( 'reservation', function ( RsvFormElementDefinition $def, $value ): float {
if ( ! is_array( $value ) || ! is_array( $value['timetable_reservations'] ?? null ) ) {
return 0.0;
}
$price_per_block = (float) $def->getAttr( 'price_per_block', 0 );
return $price_per_block * count( $value['timetable_reservations'] );
} );
} );
$rsv_form_price_registry = RsvFormPriceCalculatorRegistry::boot();
// 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() );
$reg->register( 'qr-payment', new RsvQrPaymentElement() );
} );
$rsv_template_registry = new \Reservair\Templating\RsvTemplateRegistry();
do_action( 'rsv-template-register-custom-elements', $rsv_template_registry );
} }
add_action( 'plugins_loaded', 'rsv_bootstrap' ); add_action( 'plugins_loaded', 'rsv_bootstrap' );
+9 -2
View File
@@ -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 '../assets/css/RsvAdminStyle.css';
import { RsvDataGrid } from '../assets/js/elements/RsvDatagrid.js'; import { RsvDataGrid } from '../assets/js/elements/RsvDatagrid.js';
@@ -7,16 +8,22 @@ import { RsvInlineFormBuilder } from '../assets/js/forms/RsvInlineFormBuilder.js
import './components/admin.js'; import './components/admin.js';
import { RsvAdminForm } from '../assets/js/forms/RsvAdminForm.js'; import { RsvAdminForm } from '../assets/js/forms/RsvAdminForm.js';
import { RsvReservationResource } from '../assets/js/datasource/RsvReservationResource.js'; import { RsvReservationResource } from '../assets/js/datasource/RsvReservationResource.js';
import { RsvFormDefinitionResource } from '../assets/js/datasource/RsvFormDefinitionResource.js';
import { RsvTimetableResource } from '../assets/js/datasource/RsvTimetableResource.js'; import { RsvTimetableResource } from '../assets/js/datasource/RsvTimetableResource.js';
import { RsvTimetableCapacityResource } from '../assets/js/datasource/RsvTimetableCapacityResource.js'; import { RsvTimetableCapacityResource } from '../assets/js/datasource/RsvTimetableCapacityResource.js';
import { RsvTimetableReservationResource } from '../assets/js/datasource/RsvTimetableReservationResource.js'; import { RsvTimetableReservationResource } from '../assets/js/datasource/RsvTimetableReservationResource.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'; import { RsvReservationClient } from '../assets/js/datasource/RsvReservationClient.js';
window.RsvDataGrid = RsvDataGrid; window.RsvDataGrid = RsvDataGrid;
window.RsvInlineFormBuilder = RsvInlineFormBuilder; window.RsvInlineFormBuilder = RsvInlineFormBuilder;
window.RsvAdminForm = RsvAdminForm; window.RsvAdminForm = RsvAdminForm;
window.RsvReservationResource = RsvReservationResource; window.RsvReservationResource = RsvReservationResource;
window.RsvFormDefinitionResource = RsvFormDefinitionResource;
window.RsvTimetableResource = RsvTimetableResource; window.RsvTimetableResource = RsvTimetableResource;
window.RsvTimetableCapacityResource = RsvTimetableCapacityResource; window.RsvTimetableCapacityResource = RsvTimetableCapacityResource;
window.RsvTimetableReservationResource = RsvTimetableReservationResource; window.RsvTimetableReservationResource = RsvTimetableReservationResource;
window.RsvMembershipProgramResource = RsvMembershipProgramResource;
window.RsvMembershipKeyResource = RsvMembershipKeyResource;
window.RsvReservationClient = RsvReservationClient; window.RsvReservationClient = RsvReservationClient;
-138
View File
@@ -1,99 +1,3 @@
import { get_rest_url } from '../../assets/js/RsvApi.js';
async function fetch_reservations_to_confirm(object_id) {
const url = `/wordpress/wp-json/reservations/v1/object/${object_id}/timetable/reservation/unconfirmed`;
return await fetch(url, {
method: 'GET'
}).then(x => x.json());
}
async function confirm_reservation(confirmation_code) {
const url = `/wordpress/wp-json/reservations/v1/accept/${confirmation_code}`;
const x = await fetch(url, {
method: 'GET'
});
}
async function refuse_reservation(confirmation_code) {
const url = `/wordpress/wp-json/reservations/v1/refuse/${confirmation_code}`;
const x = await fetch(url, {
method: 'GET'
});
}
async function render_unconfirmed_reservations_table(self) {
const reservations = await fetch_reservations_to_confirm(self.object_id);
const rows = reservations.map(reservation => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${reservation.date}</td>
<td>${get_format_time(new Date(`${reservation.date}T${reservation.start}`))}</td>
<td>${get_format_time(add_minutes(new Date(`${reservation.date}T${reservation.start}`), parseInt(reservation.num_minutes)))}</td>
<td>${reservation.num_minutes} min</td>
<td>${reservation.email}</td>
`;
let td = document.createElement('td');
let confirm_button = document.createElement('button');
confirm_button.classList.add('button');
confirm_button.classList.add('button-primary');
confirm_button.onclick = function() {
confirm_reservation(reservation.confirmation_code)
.then(x => self.refresh());
};
confirm_button.innerText = "Confirm";
td.appendChild(confirm_button);
let refuse_button = document.createElement('button');
refuse_button.classList.add('button');
refuse_button.classList.add('button-secondary');
refuse_button.onclick = function() {
confirm_reservation(reservation.confirmation_code)
.then(x => self.refresh());
};
refuse_button.innerText = "Refuse";
td.appendChild(refuse_button);
row.appendChild(td);
return row;
});
self.body.replaceChildren(...rows);
}
function create_unconfirmed_reservations(object_id, container) {
let table = document.createElement('table');
table.classList.add('widefat');
let header = document.createElement('thead');
header.innerHTML = `
<tr>
<th>Date</th>
<th>From</th>
<th>To</th>
<th>Length</th>
<th>Email</th>
<th>Actions</th>
</tr>
`;
table.appendChild(header);
let body = document.createElement('tbody');
table.appendChild(body);
container.appendChild(table);
return {
object_id: object_id,
container: container,
body: body,
refresh() {
render_unconfirmed_reservations_table(this);
}
};
}
function create_notice(id, type, mesg) { function create_notice(id, type, mesg) {
let container = document.createElement('div'); let container = document.createElement('div');
container.id = id; container.id = id;
@@ -107,45 +11,3 @@ export function show_notice(target, type, mesg) {
const notice = create_notice('test', type, mesg); const notice = create_notice('test', type, mesg);
target.prepend(notice); target.prepend(notice);
} }
async function error_handler(error) {
if(error.body != null && error.body.message != null) {
show_notice(error.target, 'error', error.body.message);
}
console.error(error);
}
async function delete_action(id) {
return await fetch(get_rest_url(`action/${id}`), {
method: 'DELETE'
});
}
function collect_selected_actions(target) {
return Array.from(target.querySelectorAll('input[type="checkbox"].action-selector:checked')).map(x =>
x.value
);
}
async function delete_object(id) {
return await fetch(get_rest_url(`object/${id}`), {
method: 'DELETE'
});
}
async function set_object_actions(id, actions) {
return await fetch(get_rest_url(`object/${id}/action`), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(actions)
});
}
function inline_edit_row() {
let row = document.createElement('tr');
row.classList.add('iedit author-self level-0 post-1 type-post status-publish format-standard hentry category-uncategorized');
return row;
}