Compare commits
8 Commits
8e264b8892
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c754e18a82 | |||
| df5f9b1df4 | |||
| cfbdca238c | |||
| 1c2a176d97 | |||
| 2890a9b993 | |||
| 0fc0addf47 | |||
| 3225ff1e10 | |||
| 7d7f748f7a |
@@ -6,6 +6,10 @@ The form definition is an array of element it contains. Each element has a *type
|
||||
|
||||
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}`.
|
||||
|
||||
@@ -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 133–142) 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.
|
||||
@@ -1,209 +0,0 @@
|
||||
# Implementation prompt — Templating module
|
||||
|
||||
> Hand this whole file to a Claude Sonnet model as its task. It is written to be
|
||||
> self-contained but assumes the model can read the repository.
|
||||
|
||||
---
|
||||
|
||||
## Mission
|
||||
|
||||
Implement the templating engine specified in `TEMPLATING.md` as a **new,
|
||||
self-contained module** in this WordPress plugin (Reservair). Today the plugin
|
||||
has two ad-hoc, regex-based templaters; your job is to replace them with one
|
||||
properly-designed module and wire the existing call sites into it.
|
||||
|
||||
Build the smallest thing that satisfies the spec. `TEMPLATING.md` is explicit
|
||||
that the language must stay simple ("Helm being the example of what not to do") —
|
||||
do **not** add conditionals, loops, or expression evaluation. The only language
|
||||
features are `{{ jsonpath }}` interpolation and custom elements.
|
||||
|
||||
## Read these first (in order)
|
||||
|
||||
1. `TEMPLATING.md` — the requirements you are implementing. Treat it as the spec.
|
||||
2. `modules/Database/README.md` and `modules/Logger/README.md` — the **module
|
||||
template** to imitate: a `modules/<Name>/` folder, namespace
|
||||
`Reservair\<Name>`, a `README.md` with *Why / Usage / API reference* sections,
|
||||
`Rsv`-prefixed classes, snake_case method names, a module-specific exception.
|
||||
3. `modules/Database/Db.php` and `modules/Logger/Logger.php` — the coding style
|
||||
to match (PHP 8.0 union types, typed properties, named args, `Logger` for
|
||||
errors, a `\RuntimeException` subclass for failures).
|
||||
4. The two things you are replacing:
|
||||
- `includes/Services/Emails/RsvEmailTemplater.php` — `{{ word }}` → `$data[word]`.
|
||||
- `includes/Services/Forms/RsvFormHtmlRenderer.php::draw_success_template()` —
|
||||
swaps `{{ reservation_summary }}` for a client-filled placeholder.
|
||||
5. `reservair.php::rsv_bootstrap()` — where runtime services and registries are
|
||||
wired on `plugins_loaded`. New registration hooks fire from here.
|
||||
6. `includes/Events/RsvEventDispatcher.php` — the `do_action`/`add_action` hook
|
||||
convention used in this codebase.
|
||||
|
||||
## The module to create
|
||||
|
||||
- Path: `modules/Templating/`
|
||||
- Namespace: `Reservair\Templating` (PSR-4 is already configured in
|
||||
`composer.json`: `"Reservair\\": "modules/"` — no autoload changes needed).
|
||||
- Must include `modules/Templating/README.md` following the Database/Logger
|
||||
README shape.
|
||||
|
||||
## Architecture (a tiny compiler)
|
||||
|
||||
`TEMPLATING.md` describes a compiler with a swappable **frontend**. Honour that:
|
||||
|
||||
```
|
||||
source string ──[ frontend ]──▶ internal node tree ──[ renderer ]──▶ output string
|
||||
RsvHtmlTemplateParser (frontend-agnostic)
|
||||
```
|
||||
|
||||
- **Frontend** `RsvHtmlTemplateParser`: parses HTML into an internal node tree
|
||||
(the "internal structure" from the spec). The renderer must depend only on the
|
||||
node tree, never on HTML — so a future Markdown/other frontend can be dropped in
|
||||
by producing the same tree. Define the parser behind a small interface (e.g.
|
||||
`RsvTemplateParser` with `parse(string $source): RsvTemplateNode`) so the engine
|
||||
takes a frontend by constructor injection and defaults to the HTML one.
|
||||
- **Internal node tree**: a minimal set of node types is enough — e.g. a text
|
||||
node, an interpolation node (holds a JSON Path), and a custom-element node
|
||||
(holds tag name, attribute map, children). Keep it small and explicit.
|
||||
- **Renderer/evaluator**: walks the tree with (a) the submitted-data symbol table
|
||||
and (b) the custom-element registry, and concatenates strings.
|
||||
|
||||
You may use PHP's `DOMDocument` to do the actual HTML tokenizing inside the
|
||||
frontend, but convert its output into your own node tree — do not leak DOM
|
||||
objects into the renderer. Watch the usual `DOMDocument::loadHTML` pitfalls
|
||||
(wrapping `<html>/<body>`, entity handling, UTF-8); strip the synthetic wrappers.
|
||||
|
||||
## Language semantics
|
||||
|
||||
### `{{ jsonpath }}` interpolation
|
||||
- Resolves a JSON Path (RFC 9535) expression against the submitted data array and
|
||||
substitutes the **atomic value** (string/number/bool) as text. Non-scalar
|
||||
results (objects/arrays) resolve to empty string.
|
||||
- Keep the resolver to the minimal subset the use cases need: child access by dot
|
||||
and by bracket, and array index — e.g. `name`, `$.name`, `$.guest.email`,
|
||||
`$.items[0]`. Do **not** implement wildcards, filters, slices, or recursion.
|
||||
- **Use a simple token-walk implementation**, not a real JSON Path parser. Split
|
||||
the path on `.`/`[`/`]` and walk the data array one key at a time:
|
||||
```php
|
||||
function resolve(string $path, array $data): mixed {
|
||||
$tokens = preg_split('/[\.\[\]]+/', $path, -1, PREG_SPLIT_NO_EMPTY);
|
||||
$current = $data;
|
||||
foreach ($tokens as $token) {
|
||||
if (!isset($current[$token])) return null;
|
||||
$current = $current[$token];
|
||||
}
|
||||
return $current;
|
||||
}
|
||||
```
|
||||
This already handles bare keys, dotted access, and `[index]` in one pass. Make
|
||||
two small additions so the `$`-rooted and quoted-bracket forms resolve: drop a
|
||||
leading `$` token, and strip surrounding `'`/`"` from bracket keys (so
|
||||
`$['guest']` works). A `null` return means "no such path" — render it as the
|
||||
empty string.
|
||||
- **Backward compatibility (required):** existing templates use a bare key,
|
||||
`{{ reservation_summary }}` and email bodies like `{{ name }}`. A bare
|
||||
identifier must resolve as a top-level key, identically to today.
|
||||
- **Security:** the output target is HTML, so HTML-escape interpolated scalar
|
||||
values by default (`esc_html`). This lets you delete the manual
|
||||
`RsvEmailListener::escape_values()` pre-escaping once the engine owns escaping —
|
||||
do that. Custom elements (below) are trusted and return their own markup.
|
||||
|
||||
### Custom elements
|
||||
- A custom element is a registered handler keyed by tag name. The renderer, on
|
||||
meeting `<some-element attr="x">…</some-element>`, builds a **symbol table** and
|
||||
asks the handler to render a string.
|
||||
- Per the spec: the symbol table contains the submitted-data symbols **plus** one
|
||||
symbol per attribute (`<el attr="test">` adds `attr` => `"test"`). Decide and
|
||||
document how children/inner content are exposed (a captured inner string is the
|
||||
simplest; do the simplest thing that the reservation-summary case needs).
|
||||
- Define a small interface, e.g.:
|
||||
```php
|
||||
interface RsvTemplateElement {
|
||||
/** Render this element to a string given its symbol table. */
|
||||
public function render(RsvTemplateSymbols $symbols): string;
|
||||
/** Symbols/attributes this element understands — used by validation. */
|
||||
public function symbols(): array;
|
||||
}
|
||||
```
|
||||
(Adjust names to fit, but keep "has a symbol table, outputs a string".)
|
||||
|
||||
### Registration hook
|
||||
- The module fires `do_action('rsv-template-register-custom-elements', $registry)`
|
||||
so the plugin and any extension can register elements in the handler. Register
|
||||
the core element(s) the same way the plugin registers everything else (from a
|
||||
callback wired in `rsv_bootstrap()`), not by hard-coding them inside the module.
|
||||
|
||||
### Validation
|
||||
- Provide a `validate(string $source): array` (or a small result object) that
|
||||
returns the problems in a template without rendering it: unknown/invalid JSON
|
||||
Path symbols and unregistered custom elements. The spec calls this out as a
|
||||
first-class capability ("The language can be easily validated for symbols
|
||||
existence and validity").
|
||||
|
||||
## Public API (the facade)
|
||||
|
||||
Expose one obvious entry point — e.g. `RsvTemplateEngine` (or `RsvTemplate`):
|
||||
|
||||
```php
|
||||
$engine = new RsvTemplateEngine(); // defaults to the HTML frontend + registry
|
||||
$html = $engine->render($source, $data); // data = submitted values array
|
||||
$errors = $engine->validate($source); // [] when valid
|
||||
```
|
||||
|
||||
Keep method names snake_case to match the other modules. Throw a module
|
||||
exception (e.g. `RsvTemplateException extends \RuntimeException`) for malformed
|
||||
templates, and log via `Reservair\Logger\Logger` where the existing modules do.
|
||||
|
||||
## Integration (wire the new module into the live call sites)
|
||||
|
||||
1. **Email bodies** — replace `RsvEmailTemplater` with the engine.
|
||||
`RsvEmailListener` currently calls `(new RsvEmailTemplater())->render(...)` and
|
||||
pre-escapes values; switch it to the engine and drop the manual escaping (the
|
||||
engine escapes interpolations). Keep `RsvEmailTemplater` only if something else
|
||||
needs it; otherwise remove it and update references.
|
||||
2. **Form success message** — replace the regex in
|
||||
`RsvFormHtmlRenderer::draw_success_template()` with the engine.
|
||||
**Critical caveat:** `reservation_summary` in the success message is **not**
|
||||
server data — it is the visitor's live selection, filled in the browser by
|
||||
`assets/js/forms/RsvFormSender.js`, which looks for the
|
||||
`<div class="rsv-success-summary">` placeholder. So model `reservation-summary`
|
||||
as a registered custom element that, here, emits that exact placeholder div, so
|
||||
the existing client JS keeps working unchanged. Do not try to compute the
|
||||
summary server-side. The admin HTML is still sanitized with `wp_kses_post`
|
||||
before/around templating, as today.
|
||||
3. **Bootstrap** — in `rsv_bootstrap()`, after the form registry block, fire the
|
||||
custom-element registration (or instantiate the engine's registry and register
|
||||
core elements) consistent with how the form element registry is built there.
|
||||
|
||||
## Conventions & constraints (do not skip)
|
||||
|
||||
- **Match module style**, not `includes/` style: `Rsv` class prefix, snake_case
|
||||
methods, typed properties, PHP 8.0 features. Files namespaced `Reservair\Templating`.
|
||||
- **Doc comments state intent, not mechanics** — say *what/why*, never narrate the
|
||||
loop. (This is a standing rule in this repo.)
|
||||
- **No new Composer dependencies** unless genuinely unavoidable; the JSON Path
|
||||
subset and HTML parsing are small enough to implement in-module. If you believe
|
||||
a dependency is warranted, stop and justify it instead of adding it silently.
|
||||
- **No database, no migrations** — this module is pure text transformation.
|
||||
- **Security:** never emit unescaped submitted data; escape interpolations,
|
||||
sanitize admin HTML with `wp_kses_post` at the boundary as today, and treat
|
||||
custom-element output as trusted-by-registration.
|
||||
- Ship `modules/Templating/README.md` (Why / Usage / API reference / Notes), and
|
||||
delete or update `TEMPLATING.md`-superseded code so there is exactly one
|
||||
templating path.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- [ ] `modules/Templating/` exists with the engine, HTML frontend, node tree,
|
||||
custom-element registry + interface, JSON Path resolver, validation, a
|
||||
module exception, and `README.md`.
|
||||
- [ ] `rsv-template-register-custom-elements` action is fired by the module and
|
||||
consumed from `rsv_bootstrap()`; `reservation-summary` is registered there.
|
||||
- [ ] Email bodies and form success messages render through the engine; existing
|
||||
templates (`{{ name }}`, `{{ reservation_summary }}`) behave exactly as
|
||||
before, including client-side summary fill.
|
||||
- [ ] `RsvEmailTemplater` and the success-message regex are gone (or justified).
|
||||
- [ ] `composer lint` (phpcs, phpstan, psalm — see `composer.json` scripts) passes
|
||||
clean on the new and changed files.
|
||||
- [ ] README includes at least one worked example for each use case (email,
|
||||
success message) and a custom-element registration example.
|
||||
|
||||
If anything in `TEMPLATING.md` is ambiguous, prefer the **simpler** reading and
|
||||
note the decision in the README rather than expanding the language.
|
||||
@@ -74,6 +74,21 @@
|
||||
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 {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
@@ -115,6 +130,7 @@
|
||||
|
||||
.rsv-calendar td {
|
||||
-webkit-user-select:none;user-select:none;
|
||||
padding: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
|
||||
@@ -211,6 +211,7 @@
|
||||
.rsv-timetable-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -87,7 +87,7 @@ label.rsv-slots-slot-time>input:checked + .content>.capacity {
|
||||
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;
|
||||
background: #f5f8ff;
|
||||
}
|
||||
@@ -115,6 +115,12 @@ label.rsv-slots-slot-time>input:checked + .content>.capacity {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Within minimum lead time — available but not yet bookable */
|
||||
.rsv-slots-slot-too-soon {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Selected */
|
||||
.rsv-slots-slot-selected {
|
||||
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');
|
||||
@@ -47,7 +47,7 @@ class RsvTimeline extends HTMLElement {
|
||||
|
||||
_on_click(event) {
|
||||
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.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
@@ -81,7 +81,7 @@ class RsvTimeline extends HTMLElement {
|
||||
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
@@ -89,7 +89,7 @@ class RsvTimeline extends HTMLElement {
|
||||
const from_block = parseInt(from_minutes) / block_size_in_minutes;
|
||||
|
||||
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');
|
||||
@@ -105,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);
|
||||
from.setHours(0, idx * block_size, 0, 0);
|
||||
|
||||
@@ -117,6 +117,7 @@ class RsvTimeline extends HTMLElement {
|
||||
cell.dataset.start_utc = from.toISOString();
|
||||
cell.dataset.end_utc = to.toISOString();
|
||||
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');
|
||||
time_el.classList.add('rsv-slots-slot-time');
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"require": {
|
||||
"chillerlan/php-qrcode": "^5.0"
|
||||
"chillerlan/php-qrcode": "^6.0.1"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
Generated
+68
-64
@@ -4,40 +4,44 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "b4f5229f78cd0eed0c7166614bf05110",
|
||||
"content-hash": "c898c79e7e1cf9625a3a3f757c17ee0d",
|
||||
"packages": [
|
||||
{
|
||||
"name": "chillerlan/php-qrcode",
|
||||
"version": "5.0.3",
|
||||
"version": "6.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/chillerlan/php-qrcode.git",
|
||||
"reference": "42e215640e9ebdd857570c9e4e52245d1ee51de2"
|
||||
"reference": "49006e34bd5328f163e80329e7312f34dceea59b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/chillerlan/php-qrcode/zipball/42e215640e9ebdd857570c9e4e52245d1ee51de2",
|
||||
"reference": "42e215640e9ebdd857570c9e4e52245d1ee51de2",
|
||||
"url": "https://api.github.com/repos/chillerlan/php-qrcode/zipball/49006e34bd5328f163e80329e7312f34dceea59b",
|
||||
"reference": "49006e34bd5328f163e80329e7312f34dceea59b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"chillerlan/php-settings-container": "^2.1.6 || ^3.2.1",
|
||||
"chillerlan/php-settings-container": "^3.2.1",
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.4 || ^8.0"
|
||||
"php": "^8.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"chillerlan/php-authenticator": "^4.3.1 || ^5.2.1",
|
||||
"chillerlan/php-authenticator": "^5.3",
|
||||
"ext-fileinfo": "*",
|
||||
"phan/phan": "^5.4.5",
|
||||
"phpcompatibility/php-compatibility": "10.x-dev",
|
||||
"intervention/image": "^3.11",
|
||||
"phan/phan": "^6.0.1",
|
||||
"phpbench/phpbench": "^1.4",
|
||||
"phpmd/phpmd": "^2.15",
|
||||
"phpunit/phpunit": "^9.6",
|
||||
"setasign/fpdf": "^1.8.2",
|
||||
"slevomat/coding-standard": "^8.15",
|
||||
"squizlabs/php_codesniffer": "^3.11"
|
||||
"phpstan/phpstan": "^2.1.40",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0.4",
|
||||
"phpunit/phpunit": "^11.5",
|
||||
"setasign/fpdf": "^1.8.6",
|
||||
"slevomat/coding-standard": "^8.28",
|
||||
"squizlabs/php_codesniffer": "^4.0"
|
||||
},
|
||||
"suggest": {
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"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",
|
||||
"keywords": [
|
||||
"phpqrcode",
|
||||
@@ -97,20 +101,20 @@
|
||||
"type": "Ko-Fi"
|
||||
}
|
||||
],
|
||||
"time": "2024-11-21T16:12:34+00:00"
|
||||
"time": "2026-03-18T21:21:07+00:00"
|
||||
},
|
||||
{
|
||||
"name": "chillerlan/php-settings-container",
|
||||
"version": "3.2.1",
|
||||
"version": "3.3.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/chillerlan/php-settings-container.git",
|
||||
"reference": "95ed3e9676a1d47cab2e3174d19b43f5dbf52681"
|
||||
"reference": "a0a487cbf5344f721eb504bf0f59bada40c381b7"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/chillerlan/php-settings-container/zipball/95ed3e9676a1d47cab2e3174d19b43f5dbf52681",
|
||||
"reference": "95ed3e9676a1d47cab2e3174d19b43f5dbf52681",
|
||||
"url": "https://api.github.com/repos/chillerlan/php-settings-container/zipball/a0a487cbf5344f721eb504bf0f59bada40c381b7",
|
||||
"reference": "a0a487cbf5344f721eb504bf0f59bada40c381b7",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -118,11 +122,13 @@
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phan/phan": "^5.5.2",
|
||||
"phpmd/phpmd": "^2.15",
|
||||
"phpstan/phpstan": "^1.11",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.2",
|
||||
"phpstan/phpstan": "^2.1.31",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0.3",
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"squizlabs/php_codesniffer": "^3.10"
|
||||
"slevomat/coding-standard": "^8.22",
|
||||
"squizlabs/php_codesniffer": "^4.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
@@ -147,7 +153,8 @@
|
||||
"Settings",
|
||||
"configuration",
|
||||
"container",
|
||||
"helper"
|
||||
"helper",
|
||||
"property hook"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/chillerlan/php-settings-container/issues",
|
||||
@@ -163,7 +170,7 @@
|
||||
"type": "ko_fi"
|
||||
}
|
||||
],
|
||||
"time": "2024-07-16T11:13:48+00:00"
|
||||
"time": "2026-03-20T21:10:52+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [
|
||||
@@ -692,16 +699,16 @@
|
||||
},
|
||||
{
|
||||
"name": "amphp/process",
|
||||
"version": "v2.0.3",
|
||||
"version": "v2.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/amphp/process.git",
|
||||
"reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d"
|
||||
"reference": "583959df17d00304ad7b0b32285373f985935643"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/amphp/process/zipball/52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d",
|
||||
"reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d",
|
||||
"url": "https://api.github.com/repos/amphp/process/zipball/583959df17d00304ad7b0b32285373f985935643",
|
||||
"reference": "583959df17d00304ad7b0b32285373f985935643",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -715,7 +722,7 @@
|
||||
"amphp/php-cs-fixer-config": "^2",
|
||||
"amphp/phpunit-util": "^3",
|
||||
"phpunit/phpunit": "^9",
|
||||
"psalm/phar": "^5.4"
|
||||
"psalm/phar": "6.16.1"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
@@ -748,7 +755,7 @@
|
||||
"homepage": "https://amphp.org/process",
|
||||
"support": {
|
||||
"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": [
|
||||
{
|
||||
@@ -756,7 +763,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-04-19T03:13:44+00:00"
|
||||
"time": "2026-05-31T15:11:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "amphp/serialization",
|
||||
@@ -986,28 +993,29 @@
|
||||
},
|
||||
{
|
||||
"name": "composer/pcre",
|
||||
"version": "3.3.2",
|
||||
"version": "3.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/composer/pcre.git",
|
||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
|
||||
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||
"url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
|
||||
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpstan/phpstan": "<1.11.10"
|
||||
"phpstan/phpstan": "<2.2.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1.12 || ^2",
|
||||
"phpstan/phpstan-strict-rules": "^1 || ^2",
|
||||
"phpunit/phpunit": "^8 || ^9"
|
||||
"phpstan/phpstan": "^2",
|
||||
"phpstan/phpstan-deprecation-rules": "^2",
|
||||
"phpstan/phpstan-strict-rules": "^2",
|
||||
"phpunit/phpunit": "^9"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
@@ -1045,7 +1053,7 @@
|
||||
],
|
||||
"support": {
|
||||
"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": [
|
||||
{
|
||||
@@ -1055,13 +1063,9 @@
|
||||
{
|
||||
"url": "https://github.com/composer",
|
||||
"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",
|
||||
@@ -1567,16 +1571,16 @@
|
||||
},
|
||||
{
|
||||
"name": "johnpbloch/wordpress-core",
|
||||
"version": "6.9.0",
|
||||
"version": "6.9.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/johnpbloch/wordpress-core.git",
|
||||
"reference": "4626d4e896c36ab77a69ce58627bc76243b5dd07"
|
||||
"reference": "13e02e0047ca5c8ec8dc837c2de8a5bd3583b879"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/johnpbloch/wordpress-core/zipball/4626d4e896c36ab77a69ce58627bc76243b5dd07",
|
||||
"reference": "4626d4e896c36ab77a69ce58627bc76243b5dd07",
|
||||
"url": "https://api.github.com/repos/johnpbloch/wordpress-core/zipball/13e02e0047ca5c8ec8dc837c2de8a5bd3583b879",
|
||||
"reference": "13e02e0047ca5c8ec8dc837c2de8a5bd3583b879",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1584,7 +1588,7 @@
|
||||
"php": ">=7.2.24"
|
||||
},
|
||||
"provide": {
|
||||
"wordpress/core-implementation": "6.9.0"
|
||||
"wordpress/core-implementation": "6.9.4"
|
||||
},
|
||||
"type": "wordpress-core",
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
@@ -1611,7 +1615,7 @@
|
||||
"source": "https://core.trac.wordpress.org/browser",
|
||||
"wiki": "https://codex.wordpress.org/"
|
||||
},
|
||||
"time": "2025-12-02T19:10:58+00:00"
|
||||
"time": "2026-03-11T15:27:36+00:00"
|
||||
},
|
||||
{
|
||||
"name": "kelunik/certificate",
|
||||
@@ -3246,16 +3250,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-mbstring",
|
||||
"version": "v1.38.1",
|
||||
"version": "v1.38.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-mbstring.git",
|
||||
"reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92"
|
||||
"reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92",
|
||||
"reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
|
||||
"reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3307,7 +3311,7 @@
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1"
|
||||
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3327,7 +3331,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-26T12:51:13+00:00"
|
||||
"time": "2026-05-27T06:59:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php84",
|
||||
@@ -3786,16 +3790,16 @@
|
||||
},
|
||||
{
|
||||
"name": "webmozart/assert",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/webmozarts/assert.git",
|
||||
"reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155"
|
||||
"reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155",
|
||||
"reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155",
|
||||
"url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70",
|
||||
"reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3846,9 +3850,9 @@
|
||||
],
|
||||
"support": {
|
||||
"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",
|
||||
|
||||
@@ -42,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+)', [
|
||||
[
|
||||
'methods' => 'GET',
|
||||
@@ -79,10 +85,17 @@ class RsvFormDefinitionController {
|
||||
}
|
||||
|
||||
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 {
|
||||
$id = (new RsvFormDefinitionRepository())->add(
|
||||
$request->get_param('name'),
|
||||
$request->get_param('definition') ?? []
|
||||
$definition
|
||||
);
|
||||
} catch(Throwable $e) {
|
||||
Logger::error($e);
|
||||
@@ -105,6 +118,20 @@ class RsvFormDefinitionController {
|
||||
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 {
|
||||
$id = (int) $request->get_param('id');
|
||||
$repo = new RsvFormDefinitionRepository();
|
||||
@@ -113,7 +140,14 @@ class RsvFormDefinitionController {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
public function __construct(
|
||||
public RsvTimetableReservation $reservation
|
||||
public RsvTimetableReservation $reservation,
|
||||
public RsvReservation $parent
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ class RsvTimetableReservationPendingEvent {
|
||||
public function __construct(
|
||||
public int $reservation_id,
|
||||
public RsvTimetableReservation $reservation,
|
||||
public string $code,
|
||||
public ?string $code,
|
||||
public string $maintainer_email
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ use Reservair\Templating\RsvTemplateEngine;
|
||||
*
|
||||
* Two responsibilities:
|
||||
* 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
|
||||
* every reservation item reaches a terminal state. Subject/body come from
|
||||
* the form definition's reservation element `email_templates` attr.
|
||||
* every reservation item reaches a terminal state.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
@@ -52,7 +54,15 @@ class RsvEmailListener {
|
||||
$start_dt = (clone $event->reservation->start_utc)->setTimezone($tz);
|
||||
$end_dt = (clone $event->reservation->end_utc)->setTimezone($tz);
|
||||
|
||||
$body = $engine->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,
|
||||
'date' => $start_dt->format('Y-m-d'),
|
||||
'start' => $start_dt->format('H:i'),
|
||||
@@ -61,12 +71,59 @@ class RsvEmailListener {
|
||||
'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) {
|
||||
Logger::error($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the admin-authored maintainer template for the timetable this
|
||||
* reservation belongs to, together with the submission's field values so the
|
||||
* template can reference the submitter's answers.
|
||||
*
|
||||
* @return array{0: array<string, mixed>, 1: array<string, mixed>} The
|
||||
* maintainer template (empty when none is configured) and form values.
|
||||
*/
|
||||
private static function resolve_maintainer_template(RsvTimetableReservationPendingEvent $event): array {
|
||||
$form_submit_id = (new RsvReservationRepository())->get_form_submit_id($event->reservation_id);
|
||||
if ($form_submit_id === null) {
|
||||
return [[], []];
|
||||
}
|
||||
|
||||
$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 {
|
||||
try {
|
||||
$form_submit = (new RsvFormSubmitRepository())->get($event->form_submit_id);
|
||||
@@ -123,11 +180,18 @@ class RsvEmailListener {
|
||||
global $rsv_template_registry;
|
||||
$engine = new RsvTemplateEngine(registry: $rsv_template_registry);
|
||||
|
||||
$form_definition = new RsvFormDefinition((string) $form_submit['form_id'], $definition['definition']);
|
||||
$form_data = new RsvFormData($form_values);
|
||||
|
||||
// Calculated values (e.g. price) win over submitted fields so they can't be shadowed.
|
||||
$symbols = array_merge($form_values, (new RsvFormCalculatedValues())->for($form_definition, $form_data));
|
||||
|
||||
// Subject is plain text: render without HTML-escaping, then strip tags/newlines.
|
||||
$subject = sanitize_text_field($engine->render_plain($subject_tpl, $form_values));
|
||||
$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, $form_values);
|
||||
$body = $engine->render($body_tpl, $symbols);
|
||||
error_log("Prc");
|
||||
|
||||
(new RsvEmailSender())->send($user_email, $subject, $body);
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@@ -10,23 +10,37 @@ class RsvGoogleCalendarListener {
|
||||
|
||||
public static function on_created(RsvTimetableReservationCreatedEvent $event): void {
|
||||
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();
|
||||
if (!$gcal->is_google_connected()) {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
$status = $event->reservation->requires_confirmation ? 'tentative' : 'confirmed';
|
||||
$status = $event->reservation->is_confirmed == null ? 'tentative' : 'confirmed';
|
||||
$gcal->add_event(
|
||||
$calendar_id,
|
||||
"Reservation #{$event->reservation->id}",
|
||||
$event->reservation->start,
|
||||
$event->reservation->end,
|
||||
$event->reservation->user_email,
|
||||
// format time as utc
|
||||
$event->reservation->start_utc->format('Y-m-d H:i:s'),
|
||||
$event->reservation->end_utc->format('Y-m-d H:i:s'),
|
||||
$user_email,
|
||||
$event->reservation->id,
|
||||
$status,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,19 @@
|
||||
class RsvTimetableAvailability {
|
||||
/**
|
||||
* @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 int $from_minutes,
|
||||
public int $to_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->lead_time_minutes[] = $min_lead_time_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 $end_utc; // UTC, 'Y-m-d H:i:s'
|
||||
|
||||
public ?int $is_confirmed = null;
|
||||
|
||||
public static function schema(): array {
|
||||
return [
|
||||
'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,
|
||||
'block_size' => $timetable->block_size,
|
||||
'maintainer_email' => $timetable->maintainer_email,
|
||||
'google_calendar_id' => $timetable->google_calendar_id,
|
||||
],
|
||||
['id' => $id]
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ function rsv_admin_menu_definition() {
|
||||
$forms = new RsvFormsPage();
|
||||
$timetable = new RsvTimetablePage();
|
||||
$google_cal = new RsvGoogleCalendarSettingsPage();
|
||||
$membership = new RsvMembershipProgramsPage();
|
||||
|
||||
add_menu_page(
|
||||
'Reservations Settings', // Page title
|
||||
@@ -28,6 +29,15 @@ function rsv_admin_menu_definition() {
|
||||
[$forms, 'render']
|
||||
);
|
||||
|
||||
add_submenu_page(
|
||||
'reservations-settings',
|
||||
'Membership Programs',
|
||||
'Membership Programs',
|
||||
RsvCapabilities::MANAGE,
|
||||
'membership-programs',
|
||||
[$membership, 'render']
|
||||
);
|
||||
|
||||
add_submenu_page(
|
||||
'reservations-settings',
|
||||
'Timetables',
|
||||
|
||||
@@ -93,6 +93,26 @@ class RsvInstaller {
|
||||
ON DELETE CASCADE
|
||||
) $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.
|
||||
RsvCapabilities::ensure();
|
||||
}
|
||||
|
||||
@@ -84,4 +84,5 @@ function rsv_define_rest_api(): void {
|
||||
(new RsvTimetableReservationController())->register_routes();
|
||||
(new RsvFormController())->register_routes();
|
||||
(new RsvFormDefinitionController())->register_routes();
|
||||
(new RsvMembershipProgramController())->register_routes();
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,12 @@ class RsvFormDefinition {
|
||||
|
||||
public string $email_key = "";
|
||||
|
||||
public array $membership = [];
|
||||
|
||||
public string $success_message = "";
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $definition Full definition array including 'elements', 'email_key' and 'success_message'.
|
||||
* @param array<string,mixed> $definition Full definition array including 'elements', 'email_key' and 'success_message'.
|
||||
*/
|
||||
public function __construct(string $id, array $definition) {
|
||||
$this->_elements = [];
|
||||
@@ -23,6 +25,7 @@ class RsvFormDefinition {
|
||||
|
||||
$this->_id = $id;
|
||||
$this->email_key = $definition['email_key'] ?? '';
|
||||
$this->membership = $definition['membership'] ?? [];
|
||||
$this->success_message = $definition['success_message'] ?? '';
|
||||
}
|
||||
|
||||
@@ -34,6 +37,15 @@ class RsvFormDefinition {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,9 @@ class RsvReservationService {
|
||||
}
|
||||
|
||||
public function create(RsvReservation $reservation) {
|
||||
$reservation->timetable_reservations =
|
||||
$this->merge_adjacent($reservation->timetable_reservations);
|
||||
|
||||
// Serialise the availability-check + insert per timetable so two
|
||||
// concurrent bookings for the last free slot can't both pass the
|
||||
// capacity check and oversell. Locks are taken in a stable order to
|
||||
@@ -50,9 +53,10 @@ class RsvReservationService {
|
||||
|
||||
try {
|
||||
$reservation_id = $this->repo->insert($reservation->to_array());
|
||||
$reservation->id = $reservation_id;
|
||||
|
||||
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();
|
||||
@@ -64,7 +68,19 @@ class RsvReservationService {
|
||||
|
||||
// Only now that the rows are durably committed do we let listeners
|
||||
// (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);
|
||||
|
||||
@@ -80,6 +96,48 @@ class RsvReservationService {
|
||||
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. */
|
||||
public function delete(int $id): void {
|
||||
$this->repo->delete($id);
|
||||
|
||||
@@ -39,6 +39,14 @@ class RsvTimetableReservationService {
|
||||
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;
|
||||
}
|
||||
|
||||
$start_min = $this->time_of_day_minutes($start_utc);
|
||||
$end_min = $this->time_of_day_minutes($end_utc);
|
||||
|
||||
@@ -107,13 +115,9 @@ class RsvTimetableReservationService {
|
||||
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);
|
||||
|
||||
if ($code === null) {
|
||||
throw new InvalidArgumentException('No pending confirmation for this reservation.');
|
||||
}
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
@@ -149,20 +153,13 @@ class RsvTimetableReservationService {
|
||||
|
||||
if ($maintainer_email) {
|
||||
$code = $this->create_confirmation($reservation_id, $id);
|
||||
$this->deferred_events[] = new RsvTimetableReservationPendingEvent(
|
||||
$reservation_id,
|
||||
$reservation,
|
||||
$code,
|
||||
$maintainer_email
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$reservation->is_confirmed = 1;
|
||||
$reservation_service = new RsvReservationService();
|
||||
$reservation_service->confirmation_state_changed($reservation_id);
|
||||
}
|
||||
|
||||
$this->deferred_events[] = new RsvTimetableReservationCreatedEvent($reservation);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,8 @@ class RsvTimetableService {
|
||||
$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) {
|
||||
$availability_idx++;
|
||||
}
|
||||
|
||||
+251
-16
@@ -106,6 +106,7 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
|
||||
$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) {
|
||||
@@ -126,39 +127,72 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
echo RsvFormBuilder::create('edit_form_definition', get_rest_url(null, 'reservations/v1/form-definition/' . $id), 'PUT', 'Form definition updated.')
|
||||
->text('name', 'Name', '', true, $form_def['name'])
|
||||
->select('definition.email_key', 'Email Key', $email_key_options, "Form field that holds the submitter's email address.", true, $definition['email_key'] ?? '')
|
||||
->textarea('definition.success_message', 'Success message', 'Shown to the visitor after a successful submission. HTML is allowed. Use <reservation-summary></reservation-summary> to display the selected reservations. Leave blank for the default message.', false, $definition['success_message'] ?? '')
|
||||
->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>
|
||||
</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();
|
||||
?>
|
||||
|
||||
<?php $this->elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables); ?>
|
||||
<?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 = []): void {
|
||||
private function elements_table_script(array $elements_with_ids, int $next_id, string $form_id, array $element_types, array $timetables = [], array $programs = [], array $bindings = []): void {
|
||||
$elements_json = json_encode($elements_with_ids);
|
||||
$types_json = json_encode(array_values($element_types));
|
||||
$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>
|
||||
const rsv_element_types = <?= $types_json ?>;
|
||||
const rsv_timetables = <?= $timetables_json ?>;
|
||||
const rsv_membership_programs = <?= $programs_json ?>;
|
||||
|
||||
const RSV_EMAIL_DEFAULTS = {
|
||||
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>") ?>,
|
||||
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>") ?>,
|
||||
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) {
|
||||
@@ -176,7 +210,7 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
if (idx === -1) return Promise.reject(new Error('Element not found'));
|
||||
// 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,
|
||||
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] = {
|
||||
...extra_attrs,
|
||||
id,
|
||||
@@ -190,10 +224,17 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
pattern: data.pattern ?? '',
|
||||
pattern_message: data.pattern_message ?? '',
|
||||
} : {}),
|
||||
...(data.type === 'output-text' ? {
|
||||
tag: data.tag ?? 'p',
|
||||
} : {}),
|
||||
...(data.type === 'reservation' ? {
|
||||
timetable_id: data.timetable_id ? parseInt(data.timetable_id) : null,
|
||||
price_per_block: parseFloat(data.price_per_block ?? '0') || 0,
|
||||
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: {
|
||||
enabled: !!data.email_accepted_enabled,
|
||||
subject: data.email_accepted_subject ?? RSV_EMAIL_DEFAULTS.accepted_subject,
|
||||
@@ -232,6 +273,98 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
};
|
||||
})(<?= $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) {
|
||||
const builder = RsvInlineFormBuilder.create(rsv_elements_source)
|
||||
.fieldset('Element', '50%')
|
||||
@@ -243,6 +376,7 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
.input_checkbox('required', 'Required', data?.required ?? false);
|
||||
|
||||
if ((data?.type ?? rsv_element_types[0]) === 'reservation') {
|
||||
const maintainer = data?.email_templates?.maintainer ?? {};
|
||||
const accepted = data?.email_templates?.on_accepted ?? {};
|
||||
const refused = data?.email_templates?.on_refused ?? {};
|
||||
const timetable_options = [
|
||||
@@ -252,6 +386,9 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
builder
|
||||
.input_select('timetable_id', 'Timetable', timetable_options, data?.timetable_id ?? '')
|
||||
.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%')
|
||||
.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)
|
||||
@@ -262,6 +399,19 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
.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') {
|
||||
builder
|
||||
.input_select('validation', 'Validation', [
|
||||
@@ -281,8 +431,8 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
id: data?.id,
|
||||
colspan: 6,
|
||||
save_label: 'Save',
|
||||
on_success: () => elements_dt.refresh(),
|
||||
on_cancel: () => elements_dt.refresh(),
|
||||
on_success: () => { elements_dt.refresh(); rsv_schedule_preview(); },
|
||||
on_cancel: () => { elements_dt.refresh(); rsv_schedule_preview(); },
|
||||
});
|
||||
|
||||
// Type swaps whole fieldsets, so re-render the inline form on change.
|
||||
@@ -326,14 +476,17 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
'Move Up': RsvDataGrid.func_action(function(dt, row, data) {
|
||||
rsv_elements_source.move_up(data.id);
|
||||
dt.refresh();
|
||||
rsv_schedule_preview();
|
||||
}),
|
||||
'Move Down': RsvDataGrid.func_action(function(dt, row, data) {
|
||||
rsv_elements_source.move_down(data.id);
|
||||
dt.refresh();
|
||||
rsv_schedule_preview();
|
||||
}),
|
||||
'Remove': RsvDataGrid.func_action(function(dt, row, data) {
|
||||
rsv_elements_source.remove(data.id);
|
||||
dt.refresh();
|
||||
rsv_schedule_preview();
|
||||
}),
|
||||
}),
|
||||
'label': RsvDataGrid.column('Label', false),
|
||||
@@ -366,6 +519,7 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
document.getElementById('rsv_add_element_btn').onclick = function() {
|
||||
rsv_elements_source.add();
|
||||
elements_dt.refresh();
|
||||
rsv_schedule_preview();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -386,17 +540,98 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
}
|
||||
rsv_email_key_select?.addEventListener('focus', rsv_sync_email_key_options);
|
||||
|
||||
RsvAdminForm.bind(document.getElementById('<?= $form_id ?>'), {
|
||||
transform: (body) => ({
|
||||
name: body.name,
|
||||
definition: {
|
||||
email_key: body.definition?.email_key ?? '',
|
||||
success_message: body.definition?.success_message ?? '',
|
||||
elements: rsv_elements_source.get_all(),
|
||||
},
|
||||
// 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(); },
|
||||
});
|
||||
|
||||
rsv_render_preview();
|
||||
</script>
|
||||
<?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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,30 @@ class RsvFormBuilder
|
||||
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 {
|
||||
$this->rows[] = '<tr>'
|
||||
. '<th>' . esc_html($label) . '</th>'
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -49,18 +49,27 @@ class RsvTemplateEngine {
|
||||
|
||||
/**
|
||||
* Lists a template's problems without rendering it (empty = valid): empty
|
||||
* interpolations, unregistered custom elements, and attributes an element
|
||||
* does not declare.
|
||||
* 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 {
|
||||
public function validate(string $source, ?array $symbols = null): array {
|
||||
$errors = [];
|
||||
|
||||
if (preg_match_all('/{{\s*([^}]*?)\s*}}/', $source, $matches)) {
|
||||
foreach ($matches[1] as $path) {
|
||||
if (trim($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} }}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,14 +157,9 @@ class RsvTemplateEngine {
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function resolve(string $path, array $data): mixed {
|
||||
$tokens = preg_split('/[\.\[\]]+/', $path, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
$current = $data;
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if ($token === '$') {
|
||||
continue; // root sigil
|
||||
}
|
||||
$token = trim($token, "'\""); // strip bracket-notation quotes
|
||||
foreach ($this->tokens($path) as $token) {
|
||||
if (!is_array($current) || !array_key_exists($token, $current)) {
|
||||
return null;
|
||||
}
|
||||
@@ -165,6 +169,24 @@ class RsvTemplateEngine {
|
||||
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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -639,9 +639,6 @@
|
||||
<ClassMustBeFinal>
|
||||
<code><![CDATA[RsvFormDefinition]]></code>
|
||||
</ClassMustBeFinal>
|
||||
<InvalidArrayOffset>
|
||||
<code><![CDATA[$definition['email_key']]]></code>
|
||||
</InvalidArrayOffset>
|
||||
<MissingPropertyType>
|
||||
<code><![CDATA[$_elements]]></code>
|
||||
</MissingPropertyType>
|
||||
|
||||
+18
-1
@@ -3,6 +3,7 @@
|
||||
use Reservair\Templating\Elements\RsvReservationSummaryElement;
|
||||
use Reservair\Templating\Elements\RsvReservationActionsElement;
|
||||
use Reservair\Templating\Elements\RsvResetFormButtonElement;
|
||||
use Reservair\Templating\Elements\RsvQrPaymentElement;
|
||||
/**
|
||||
* 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.
|
||||
@@ -33,7 +34,7 @@ register_activation_hook( __FILE__, [ 'RsvInstaller', 'install' ] );
|
||||
* plugins we might interact with) is fully loaded.
|
||||
*/
|
||||
function rsv_bootstrap(): void {
|
||||
global $rsv_form_registry, $rsv_template_registry;
|
||||
global $rsv_form_registry, $rsv_template_registry, $rsv_form_price_registry;
|
||||
|
||||
// Re-grant the custom capability after a plugin *update* (the activation hook
|
||||
// only runs on activate). No-op once the stored version matches.
|
||||
@@ -51,12 +52,28 @@ function rsv_bootstrap(): void {
|
||||
$rsv_form_registry->register( 'button', new RsvButtonElementHandler() );
|
||||
$rsv_form_registry->register( 'reservation', new RsvFormReservationElementHandler() );
|
||||
$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 );
|
||||
|
||||
@@ -12,6 +12,8 @@ import { RsvFormDefinitionResource } from '../assets/js/datasource/RsvFormDefini
|
||||
import { RsvTimetableResource } from '../assets/js/datasource/RsvTimetableResource.js';
|
||||
import { RsvTimetableCapacityResource } from '../assets/js/datasource/RsvTimetableCapacityResource.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';
|
||||
|
||||
window.RsvDataGrid = RsvDataGrid;
|
||||
@@ -22,4 +24,6 @@ window.RsvFormDefinitionResource = RsvFormDefinitionResource;
|
||||
window.RsvTimetableResource = RsvTimetableResource;
|
||||
window.RsvTimetableCapacityResource = RsvTimetableCapacityResource;
|
||||
window.RsvTimetableReservationResource = RsvTimetableReservationResource;
|
||||
window.RsvMembershipProgramResource = RsvMembershipProgramResource;
|
||||
window.RsvMembershipKeyResource = RsvMembershipKeyResource;
|
||||
window.RsvReservationClient = RsvReservationClient;
|
||||
|
||||
Reference in New Issue
Block a user