Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e264b8892 | |||
| f4d3972d07 | |||
| 1294a177ae |
+3
-2
@@ -1,9 +1,10 @@
|
|||||||
node_modules
|
node_modules
|
||||||
build
|
build/
|
||||||
vendor/
|
vendor/
|
||||||
dist
|
dist/
|
||||||
|
|
||||||
# Editors
|
# Editors
|
||||||
.claude/
|
.claude/
|
||||||
.idea/
|
.idea/
|
||||||
|
.zed/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
|||||||
+4
-1
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "/phpactor.schema.json",
|
"$schema": "/phpactor.schema.json",
|
||||||
"language_server_psalm.enabled": true
|
"language_server_psalm.enabled": true,
|
||||||
|
"indexer.stub_paths": [
|
||||||
|
"%project_root%/vendor/php-stubs/wordpress-stubs"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Forms
|
||||||
|
|
||||||
|
The reservation is mostly created by filling in a form. For that reason this plugin has it's own _form definition_ feature.
|
||||||
|
|
||||||
|
The form definition is an array of element it contains. Each element has a *type*, for example: `input-text`, `input-reservation`, etc.
|
||||||
|
|
||||||
|
Keep the form structure and content separate -> when working with the form, take time to figure, if you are working with structure, or existence of something within.
|
||||||
|
|
||||||
|
## Submitting
|
||||||
|
|
||||||
|
When user submits the form, the `RsvFormSubmitter.js` is called. It collects the values, which we describe in more detail, and then sends it using `fetch` as POST to `reservations/forms/{form_id}`.
|
||||||
|
|
||||||
|
### Collecting
|
||||||
|
|
||||||
|
The *collecting* is a process with `<form>` DOM subtree as an _input_ and valid input JSON for the defined form at `form_id` as _output_.
|
||||||
|
|
||||||
|
We decided to only consider *linearized* form, therefore an single-dimension array of fields. We find little to no value to define JSON structure in the form itself, but rather define linear array of inputs, where each input value is a JSON itself. We explaing why that is beneficial.
|
||||||
|
|
||||||
|
1. Even atomic values like `"hello"` or `69` are valid JSON format. This means the collector can assume every value is a JSON and use `JSON.parse(input.value)`.
|
||||||
|
2. We can change the form structure, but the output remains the same. The use-case is for example, grouping fields for First and Last name into one row, but still keep them separate attributes in final JSON. On the other hand, the country code and telephone number are two fields on the same row, but should be one attribute in the final JSON. Both cases reflect only in the DOM structure.
|
||||||
|
|
||||||
|
### Contract
|
||||||
|
|
||||||
|
For element to be collected, it has to comply to a contract. Namely, it must have a `rsv-form-field` class and have a `value` attribute. The tagging with `rsv-form-field` class allows that by default, no custom defined component is collected. Therefore you can compose new elements from existing ones and only tag the outer-most as `rsv-form-field`. For example:
|
||||||
|
|
||||||
|
```
|
||||||
|
<rsv-reservation-collector class="rsv-form-field">
|
||||||
|
<rsv-calendar/>
|
||||||
|
|
||||||
|
<rsv-time-slot-selector/>
|
||||||
|
</rsv-reservation-collector>
|
||||||
|
```
|
||||||
|
|
||||||
|
If the collector would collect exact elements, like `<input>`, it would:
|
||||||
|
a) require a register
|
||||||
|
b) be unpredictable
|
||||||
|
c) require some form of filtering
|
||||||
|
|
||||||
|
## Element handlers
|
||||||
|
|
||||||
|
Element handler is a PHP class extending `RsvFormElementHandler` class. The parent class has two abstract methods: `draw` & `submit`. The `draw` method is called when the form is being rendered on the backend for the user. The caller is the `RsvFormRenderer` that does not try hard to catch all errors, so be careful with putting logic to `draw`.
|
||||||
|
|
||||||
|
The other method `submit` is called by the `RsvFormProcessor`. It definitely should validate the value, but it can also do other things. For example, the element for reservation saves the reservation to the database.
|
||||||
|
|
||||||
|
You might have noticed in a reservation example one major flaw. What happens, when any of the next elements fail and cause the whole form _unworthy of submission_? The error handling itself and propagating back to the user is described later. For now let's focus on handling the error correctly.
|
||||||
|
|
||||||
|
We thought of two approaches: separate validation & submission steps and rollback. The first approach will not actually solve the issue. It might eliminate some cases, but sometimes error slips through and cause exception in the submission step. For example suppose creating reservation. The validation step can decide it is okay and the time block is available. But right after that another request creates the reservation in the same time block.
|
||||||
|
|
||||||
|
We could either do same validation in the submission step, but then, what is the point of the validation step. Another solution is to create a token for the time block. The second solution requires one important thing, the element must know it is an element, to implement the safety gates correctly.
|
||||||
|
|
||||||
|
The second approach is using a rollback, that does so when any of the next elements fail and cause the whole form _unworthy of submission_. This way only the element handler has to implement the safe gate.
|
||||||
|
|
||||||
|
TODO: generalize it using a property the element submission must have
|
||||||
|
|
||||||
|
## Register
|
||||||
|
|
||||||
|
Register is a string to object mapping, that maps element IDs to instantiated objects of the handlers. This allows to customize the handler by changing it's state. For example, there is a handler for input text. The constructor can have a predicate lambda that does the validation and allow simple implementation of email, telephone number or any other validation. Or it can be wrapped with regular expression.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
When an error occurs, the backend should send back an error response, so that user knowns what he did wrong. The response should contain element's ID, that detected the issue and error ID. The reason why not use a message is for internationalization. The form processor does not know and must not know the user's culture. The internationalization is a separate concern and should be done mapping error ID to a particular message.
|
||||||
|
|
||||||
|
Last thing inspired by Problem Details RFC are extensions. The response can contain a dictionary mapping strings to strings that contains structured data about the error. For example, only one time slot of multiple can be occupied. The extensions could contain value of the occupied time slot and the response handler could use it to provide a more detailed error message.
|
||||||
|
|
||||||
|
## Success handling
|
||||||
|
|
||||||
|
Even success must be handled. The user must know that the submission is successfully finished. What is shown is a templated HTML code that is defined by user. It can contain custom elements, like `<reservation-summary>`.
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# 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.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/* ─── Two-column admin layout (Forms page) ──────────────────────────────── */
|
/* ─── Column layouts (RsvColumnLayout) ──────────────────────────────────── */
|
||||||
|
|
||||||
/*#col-left { width: 30%; }
|
.rsv-cols { display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: flex-start; }
|
||||||
#col-right { width: 70%; }*/
|
.rsv-cols > .rsv-col { flex: var(--rsv-col-grow, 1) 1 0; min-width: 18rem; }
|
||||||
|
|
||||||
/* ─── Inline detail expand row (Reservations page) ──────────────────────── */
|
/* ─── Inline detail expand row (Reservations page) ──────────────────────── */
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
|
.rsv-form-btn {
|
||||||
|
border-radius: 1.375rem;
|
||||||
|
border: 1.5px solid #e0e0e0;
|
||||||
|
color: #555;
|
||||||
|
padding: 0 calc(1.25rem + 4px);
|
||||||
|
height: 3.5rem;
|
||||||
|
background-color: white;
|
||||||
|
|
||||||
|
line-height: 140%;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsv-form-btn:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
/* Primary CTA (submit / confirm) */
|
/* Primary CTA (submit / confirm) */
|
||||||
.rsv-form-btn-primary {
|
.rsv-form-btn-primary {
|
||||||
background: #2563eb;
|
background: #2563eb;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
|
||||||
border-radius: 1.375rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
padding: 0 calc(1.25rem + 4px);
|
|
||||||
line-height: 140%;
|
|
||||||
height: 3.5rem;
|
|
||||||
|
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
transition: background .12s;
|
transition: background .12s;
|
||||||
@@ -43,12 +54,6 @@
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*.reservair-form button {
|
|
||||||
padding: var(--s-3) !important;
|
|
||||||
font-weight: 400 !important;
|
|
||||||
}*/
|
|
||||||
|
|
||||||
/*.reservair-form button,*/
|
|
||||||
.rsv-form-input {
|
.rsv-form-input {
|
||||||
border: 1px solid var(--color-gray-300);
|
border: 1px solid var(--color-gray-300);
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -103,10 +108,10 @@
|
|||||||
border-color: var(--color-blue-500);
|
border-color: var(--color-blue-500);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rsv-form-input input:user-invalid {
|
/*.rsv-form-input input:user-invalid {
|
||||||
border-color: var(--color-red-500);
|
border-color: var(--color-red-500);
|
||||||
box-shadow: 0 0 0 4px color-mix(in oklab,var(--color-red-500)25%,transparent);
|
box-shadow: 0 0 0 4px color-mix(in oklab,var(--color-red-500)25%,transparent);
|
||||||
}
|
}*/
|
||||||
|
|
||||||
.rsv-form-section {
|
.rsv-form-section {
|
||||||
margin-bottom: var(--s-5);
|
margin-bottom: var(--s-5);
|
||||||
@@ -159,52 +164,50 @@
|
|||||||
padding-left: 5pt;
|
padding-left: 5pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rsv-form-input:user-invalid,
|
||||||
.rsv-invalid {
|
.rsv-invalid {
|
||||||
border-color: var(--color-red-500) !important;
|
border-color: var(--color-red-500) !important;
|
||||||
box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-red-500) 25%, transparent) !important;
|
box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-red-500) 25%, transparent) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rsv-success-message {
|
.rsv-success-msg {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: var(--s-5);
|
|
||||||
color: var(--color-green-700);
|
color: var(--color-green-700);
|
||||||
|
width: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rsv-success-message p {
|
.rsv-success-msg p {
|
||||||
|
margin-top: 0;
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rsv-success-msg h1,
|
||||||
.mesg {
|
.rsv-success-msg h2,
|
||||||
width: 100%;
|
.rsv-success-msg h3,
|
||||||
text-align: center;
|
.rsv-success-msg h4,
|
||||||
line-height: 1rem;
|
.rsv-success-msg h5,
|
||||||
margin-top: var(--s-5);
|
.rsv-success-msg h6
|
||||||
margin-bottom: var(--s-5);
|
{
|
||||||
padding: var(--s-4) 0;
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.success-mesg-icon {
|
.rsv-summary {
|
||||||
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mesg-icon svg {
|
.rsv-success-icon {
|
||||||
width: 32px;
|
width: 64px;
|
||||||
height: 32px;
|
height: 64px;
|
||||||
padding: var(--s-2);
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
color: #00000094;
|
background: #dcfce7;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-mesg svg {
|
|
||||||
background-color: var(--color-red-200);
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-mesg svg {
|
|
||||||
background-color: rgba(0, 201, 80, 0.36);
|
|
||||||
}
|
|
||||||
/* FORM END */
|
|
||||||
|
|
||||||
.rsv-timetable-selector {
|
.rsv-timetable-selector {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ label.rsv-slots-slot-time>input:checked + .content>.capacity {
|
|||||||
margin-top: 0.375rem;
|
margin-top: 0.375rem;
|
||||||
border: 1.5px solid #e8e8e8;
|
border: 1.5px solid #e8e8e8;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 9px 12px;
|
padding: 0.25rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color .12s, background .12s, color .12s;
|
transition: border-color .12s, background .12s, color .12s;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+1
-1
@@ -2,6 +2,6 @@
|
|||||||
* Utilities for calling the API
|
* Utilities for calling the API
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function get_rest_url(resource) {
|
export function get_rest_url(resource) {
|
||||||
return ReservairServiceAPI.restUrl + '/' + resource;
|
return ReservairServiceAPI.restUrl + '/' + resource;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const RsvDataSource = {
|
export const RsvDataSource = {
|
||||||
create_rsv_resource(base_url, { nonce } = {}) {
|
create_rsv_resource(base_url, { nonce } = {}) {
|
||||||
function request(url, method, body) {
|
function request(url, method, body) {
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
const RsvFormDefinitionResource = () =>
|
import { RsvDataSource } from './RsvDataSource.js';
|
||||||
|
|
||||||
|
export const RsvFormDefinitionResource = () =>
|
||||||
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/form-definition');
|
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/form-definition');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const RsvReservationClient = {
|
export const RsvReservationClient = {
|
||||||
accept(reservation_id) {
|
accept(reservation_id) {
|
||||||
return this._post(reservation_id, 'accept');
|
return this._post(reservation_id, 'accept');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
const RsvReservationResource = () =>
|
import { RsvDataSource } from './RsvDataSource.js';
|
||||||
|
|
||||||
|
export const RsvReservationResource = () =>
|
||||||
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/reservation');
|
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/reservation');
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
const RsvTimetableCapacityResource = (id) =>
|
import { RsvDataSource } from './RsvDataSource.js';
|
||||||
|
|
||||||
|
export const RsvTimetableCapacityResource = (id) =>
|
||||||
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + `/timetable/${id}/capacity`);
|
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + `/timetable/${id}/capacity`);
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
const RsvTimetableReservationResource = (id) =>
|
import { RsvDataSource } from './RsvDataSource.js';
|
||||||
|
|
||||||
|
export const RsvTimetableReservationResource = (id) =>
|
||||||
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + `/timetable/${id}/reservation`);
|
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + `/timetable/${id}/reservation`);
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
const RsvTimetableResource = () =>
|
import { RsvDataSource } from './RsvDataSource.js';
|
||||||
|
|
||||||
|
export const RsvTimetableResource = () =>
|
||||||
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/timetable');
|
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/timetable');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const RsvCalendarPicker = (() => {
|
export const RsvCalendarPicker = (() => {
|
||||||
|
|
||||||
function get_first_day_of_month(date) {
|
function get_first_day_of_month(date) {
|
||||||
const day = new Date(date.getFullYear(), date.getMonth(), 1).getDay();
|
const day = new Date(date.getFullYear(), date.getMonth(), 1).getDay();
|
||||||
@@ -179,6 +179,14 @@ const RsvCalendarPicker = (() => {
|
|||||||
new InputEvent('change', { bubbles: true, cancelable: true, composed: true })
|
new InputEvent('change', { bubbles: true, cancelable: true, composed: true })
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Re-check the radio for the current date. The date lives in JS state, so
|
||||||
|
// this restores the visual selection after a native form.reset() clears it.
|
||||||
|
reselect() {
|
||||||
|
if (this.date === null) return;
|
||||||
|
const radio = this.body.querySelector(`input[id="${this.date.toISOString()}"]`);
|
||||||
|
if (radio) radio.checked = true;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
container.classList.add('rsv-calendar');
|
container.classList.add('rsv-calendar');
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* RSV Dynamic datagrid
|
* RSV Dynamic datagrid
|
||||||
* Allows fetching with JS instead of page reload.
|
* Allows fetching with JS instead of page reload.
|
||||||
*/
|
*/
|
||||||
window.RsvDataGrid = window.RsvDataGrid || {
|
const RsvDataGrid = {
|
||||||
create_header(self, columns, has_actions) {
|
create_header(self, columns, has_actions) {
|
||||||
let thead = document.createElement('thead');
|
let thead = document.createElement('thead');
|
||||||
|
|
||||||
@@ -419,4 +419,6 @@ window.RsvDataGrid = window.RsvDataGrid || {
|
|||||||
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { RsvDataGrid };
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { RsvCalendarPicker } from './RsvCalendar.js';
|
||||||
|
|
||||||
class RsvReservationSelector extends HTMLElement {
|
class RsvReservationSelector extends HTMLElement {
|
||||||
static get observedAttributes() {
|
static get observedAttributes() {
|
||||||
return ['timetable-id', 'name', 'price-per-block'];
|
return ['timetable-id', 'name', 'price-per-block'];
|
||||||
@@ -35,6 +37,16 @@ class RsvReservationSelector extends HTMLElement {
|
|||||||
this.querySelectorAll('.rsv-slots-slot-selected').forEach(s => s.classList.remove('rsv-slots-slot-selected'));
|
this.querySelectorAll('.rsv-slots-slot-selected').forEach(s => s.classList.remove('rsv-slots-slot-selected'));
|
||||||
this._slots = [];
|
this._slots = [];
|
||||||
this._commit();
|
this._commit();
|
||||||
|
// _commit clears only the slots; keep the picked date selected, re-asserting
|
||||||
|
// it in case a surrounding form.reset() just unchecked the calendar radio.
|
||||||
|
this._calendar?.reselect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset for a new reservation: drop the local selection (keeping the date) and
|
||||||
|
// reload availability so slots booked by the previous submission show as full.
|
||||||
|
reset() {
|
||||||
|
this.clear();
|
||||||
|
this.querySelector('rsv-timeline')?.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Private ------------------------------------------------------------
|
// ---- Private ------------------------------------------------------------
|
||||||
|
|||||||
@@ -25,6 +25,31 @@ class RsvReservationSummary extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Public API ---------------------------------------------------------
|
||||||
|
|
||||||
|
// Detached, static copy of the current selection for the success message.
|
||||||
|
// Mirrors the live layout minus the interactive "clear all" control.
|
||||||
|
snapshot() {
|
||||||
|
const s = ReservairStrings.summary;
|
||||||
|
const list = this.querySelector('.rsv-summary-list');
|
||||||
|
const count = this.querySelector('.rsv-summary-count');
|
||||||
|
const price = this.querySelector('.rsv-summary-price');
|
||||||
|
|
||||||
|
const node = document.createElement('div');
|
||||||
|
node.className = 'rsv-summary rsv-summary-snapshot';
|
||||||
|
node.innerHTML = `
|
||||||
|
<div class="rsv-summary-header">
|
||||||
|
<span class="rsv-summary-title">${s.title}</span>
|
||||||
|
</div>
|
||||||
|
<ul class="rsv-summary-list">${list ? list.innerHTML : ''}</ul>
|
||||||
|
<div class="rsv-summary-footer">
|
||||||
|
<span class="rsv-summary-count">${count ? count.textContent : ''}</span>
|
||||||
|
<div class="rsv-summary-price">${price ? price.textContent : ''}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Private ------------------------------------------------------------
|
// ---- Private ------------------------------------------------------------
|
||||||
|
|
||||||
_build() {
|
_build() {
|
||||||
@@ -51,7 +76,6 @@ class RsvReservationSummary extends HTMLElement {
|
|||||||
const all_slots = [...this._all_slots.values()].flatMap(({ slots, price_per_block }) =>
|
const all_slots = [...this._all_slots.values()].flatMap(({ slots, price_per_block }) =>
|
||||||
slots.map(s => ({ ...s, price_per_block }))
|
slots.map(s => ({ ...s, price_per_block }))
|
||||||
);
|
);
|
||||||
console.log(all_slots);
|
|
||||||
|
|
||||||
const n = all_slots.length;
|
const n = all_slots.length;
|
||||||
const list = this.querySelector('.rsv-summary-list');
|
const list = this.querySelector('.rsv-summary-list');
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { RsvTimetableService } from '../services/RsvTimetableService.js';
|
||||||
|
|
||||||
class RsvTimeline extends HTMLElement {
|
class RsvTimeline extends HTMLElement {
|
||||||
static get observedAttributes() {
|
static get observedAttributes() {
|
||||||
return ['timetable-id', 'date'];
|
return ['timetable-id', 'date'];
|
||||||
@@ -33,6 +35,14 @@ class RsvTimeline extends HTMLElement {
|
|||||||
this._render();
|
this._render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Public API ---------------------------------------------------------
|
||||||
|
|
||||||
|
// Re-fetch availability for the current date, e.g. after a booking occupied
|
||||||
|
// some slots. The date is unchanged, so attributeChangedCallback won't fire.
|
||||||
|
refresh() {
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Private ------------------------------------------------------------
|
// ---- Private ------------------------------------------------------------
|
||||||
|
|
||||||
_on_click(event) {
|
_on_click(event) {
|
||||||
@@ -57,10 +67,6 @@ class RsvTimeline extends HTMLElement {
|
|||||||
const occupancy = await RsvTimetableService.get_availability_for_date(this.timetableId, this.date);
|
const occupancy = await RsvTimetableService.get_availability_for_date(this.timetableId, this.date);
|
||||||
if (v !== this._version) return;
|
if (v !== this._version) return;
|
||||||
|
|
||||||
if(occupancy.length === 0) {
|
|
||||||
this.replaceChildren(this._notice(s.no_blocks));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
header.classList.add('rsv-slots-label');
|
header.classList.add('rsv-slots-label');
|
||||||
@@ -68,6 +74,11 @@ class RsvTimeline extends HTMLElement {
|
|||||||
weekday: 'long', day: 'numeric', month: 'long',
|
weekday: 'long', day: 'numeric', month: 'long',
|
||||||
}).replace(',', '');
|
}).replace(',', '');
|
||||||
|
|
||||||
|
if(occupancy.length === 0) {
|
||||||
|
this.replaceChildren(header, this._notice(s.no_blocks));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const blocks = [];
|
const blocks = [];
|
||||||
|
|
||||||
for (const { from_minutes, to_minutes, block_size_in_minutes, occupancy: block_occ } of occupancy) {
|
for (const { from_minutes, to_minutes, block_size_in_minutes, occupancy: block_occ } of occupancy) {
|
||||||
@@ -105,7 +116,7 @@ class RsvTimeline extends HTMLElement {
|
|||||||
cell.classList.add('rsv-slots-slot', 'rsv-slots-slot-available');
|
cell.classList.add('rsv-slots-slot', 'rsv-slots-slot-available');
|
||||||
cell.dataset.start_utc = from.toISOString();
|
cell.dataset.start_utc = from.toISOString();
|
||||||
cell.dataset.end_utc = to.toISOString();
|
cell.dataset.end_utc = to.toISOString();
|
||||||
if (left === 0) cell.classList.add('rsv-slots-slot-full');
|
if (left <= 0) cell.classList.add('rsv-slots-slot-full');
|
||||||
|
|
||||||
const time_el = document.createElement('span');
|
const time_el = document.createElement('span');
|
||||||
time_el.classList.add('rsv-slots-slot-time');
|
time_el.classList.add('rsv-slots-slot-time');
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { RsvFormEncoder } from './RsvFormEncoder.js';
|
||||||
|
import { show_notice } from '../../../src/components/admin.js';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* RsvAdminForm — shared submit handler for wp-admin forms.
|
* RsvAdminForm — shared submit handler for wp-admin forms.
|
||||||
*
|
*
|
||||||
@@ -13,7 +16,7 @@
|
|||||||
* refresh: () => my_datagrid.refresh(),
|
* refresh: () => my_datagrid.refresh(),
|
||||||
* });
|
* });
|
||||||
*/
|
*/
|
||||||
const RsvAdminForm = {
|
export const RsvAdminForm = {
|
||||||
// Attach a submit listener that sends the form as JSON.
|
// Attach a submit listener that sends the form as JSON.
|
||||||
bind(form, options = {}) {
|
bind(form, options = {}) {
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const RsvFormEncoder = {
|
export const RsvFormEncoder = {
|
||||||
// Serialize form element into a plain JS object supporting arrays.
|
// Serialize form element into a plain JS object supporting arrays.
|
||||||
// - Nested keys supported with dot notation: 'meta.email'
|
// - Nested keys supported with dot notation: 'meta.email'
|
||||||
// - Array notation supported with trailing [] (e.g. 'times[]') or multiple inputs with same name
|
// - Array notation supported with trailing [] (e.g. 'times[]') or multiple inputs with same name
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const RsvFormSender = {
|
export const RsvFormSender = {
|
||||||
get_form_url(form_id) {
|
get_form_url(form_id) {
|
||||||
return ReservairServiceAPI.restUrl + '/form/' + form_id;
|
return ReservairServiceAPI.restUrl + '/form/' + form_id;
|
||||||
},
|
},
|
||||||
@@ -56,24 +56,14 @@ const RsvFormSender = {
|
|||||||
svg.appendChild(path);
|
svg.appendChild(path);
|
||||||
|
|
||||||
const icon = document.createElement('div');
|
const icon = document.createElement('div');
|
||||||
icon.className = 'success-icon';
|
icon.className = 'rsv-success-icon';
|
||||||
icon.appendChild(svg);
|
icon.appendChild(svg);
|
||||||
|
|
||||||
const title = document.createElement('div');
|
const body = this.build_success_body(form, s);
|
||||||
title.className = 'success-title';
|
|
||||||
title.textContent = s.success_title;
|
|
||||||
|
|
||||||
const subtitle = document.createElement('p');
|
|
||||||
subtitle.className = 'success-msg';
|
|
||||||
subtitle.textContent = s.success_subtitle;
|
|
||||||
|
|
||||||
const reset_btn = document.createElement('button');
|
|
||||||
reset_btn.className = 'reset-btn';
|
|
||||||
reset_btn.textContent = s.new_reservation;
|
|
||||||
|
|
||||||
const state = document.createElement('div');
|
const state = document.createElement('div');
|
||||||
state.className = 'success-state';
|
state.className = 'rsv-success-state';
|
||||||
state.append(icon, title, subtitle, reset_btn);
|
state.append(icon, body);
|
||||||
|
|
||||||
const msg = document.createElement('div');
|
const msg = document.createElement('div');
|
||||||
msg.appendChild(state);
|
msg.appendChild(state);
|
||||||
@@ -81,12 +71,50 @@ const RsvFormSender = {
|
|||||||
existing.forEach(child => child.style.display = 'none');
|
existing.forEach(child => child.style.display = 'none');
|
||||||
wrapper.appendChild(msg);
|
wrapper.appendChild(msg);
|
||||||
|
|
||||||
reset_btn.addEventListener('click', () => {
|
// The form catches every data-rsv-reset button in the card and links it to
|
||||||
|
// its cleanup — so new buttons just need the marker, no wiring here.
|
||||||
|
const reset = () => {
|
||||||
msg.remove();
|
msg.remove();
|
||||||
form.reset();
|
form.reset();
|
||||||
|
// Native reset leaves custom controls untouched; reset the reservation
|
||||||
|
// selectors so their slots, hidden inputs and the summary clear, the date
|
||||||
|
// stays selected and availability reloads with the just-booked slots.
|
||||||
|
form.querySelectorAll('rsv-reservation-selector').forEach(sel => sel.reset());
|
||||||
this.clear_feedback(form);
|
this.clear_feedback(form);
|
||||||
existing.forEach(child => child.style.display = '');
|
existing.forEach(child => child.style.display = '');
|
||||||
});
|
};
|
||||||
|
state.querySelectorAll('[data-rsv-reset]').forEach(btn => btn.addEventListener('click', reset));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Body of the success card. Uses the admin-configured template when the form
|
||||||
|
// ships one, filling the .rsv-success-summary placeholder (expanded server-side
|
||||||
|
// from <reservation-summary>) with a snapshot of the selected slots; otherwise
|
||||||
|
// falls back to the default text.
|
||||||
|
build_success_body(form, strings) {
|
||||||
|
const tpl = form.parentElement?.querySelector('template.rsv-form-success');
|
||||||
|
|
||||||
|
if (!tpl) {
|
||||||
|
const subtitle = document.createElement('p');
|
||||||
|
subtitle.className = 'rsv-success-msg';
|
||||||
|
subtitle.textContent = strings.success_subtitle;
|
||||||
|
return subtitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'rsv-success-msg';
|
||||||
|
body.appendChild(tpl.content.cloneNode(true));
|
||||||
|
|
||||||
|
const placeholder = body.querySelector('.rsv-success-summary');
|
||||||
|
if (placeholder) {
|
||||||
|
const summary = form.querySelector('rsv-reservation-summary');
|
||||||
|
if (summary && typeof summary.snapshot === 'function') {
|
||||||
|
placeholder.replaceWith(summary.snapshot());
|
||||||
|
} else {
|
||||||
|
placeholder.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
},
|
},
|
||||||
|
|
||||||
set_loading(form, is_loading) {
|
set_loading(form, is_loading) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const RsvInlineFormBuilder = {
|
export const RsvInlineFormBuilder = {
|
||||||
match_p(name, value) {
|
match_p(name, value) {
|
||||||
return (form) => String(form[name]) === String(value);
|
return (form) => String(form[name]) === String(value);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
const RsvTimetableService = {
|
import { get_rest_url } from '../RsvApi.js';
|
||||||
|
|
||||||
|
export const RsvTimetableService = {
|
||||||
get_all() {
|
get_all() {
|
||||||
return fetch(get_rest_url('timetable'), { method: 'GET' })
|
return fetch(get_rest_url('timetable'), { method: 'GET' })
|
||||||
.then(r => {
|
.then(r => {
|
||||||
|
|||||||
+3
-6
@@ -12,17 +12,14 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"includes/RsvAdminMenuDefinition.php",
|
"includes/RsvAdminMenuDefinition.php",
|
||||||
"includes/RsvAssetsDefinition.php",
|
"includes/RsvAssetsDefinition.php",
|
||||||
"includes/RsvRestApiDefinition.php",
|
"includes/RsvRestApiDefinition.php"
|
||||||
"includes/Views/RsvFormsPage.php",
|
|
||||||
"includes/Views/RsvReservationsPage.php",
|
|
||||||
"includes/Views/RsvTimetablePage.php",
|
|
||||||
"includes/Views/RsvGoogleCalendarSettingsPage.php"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"johnpbloch/wordpress-core": "^6.9",
|
"johnpbloch/wordpress-core": "^6.9",
|
||||||
"vimeo/psalm": "^6.16",
|
"vimeo/psalm": "^6.16",
|
||||||
"humanmade/psalm-plugin-wordpress": "^3.1"
|
"humanmade/psalm-plugin-wordpress": "^3.1",
|
||||||
|
"php-stubs/wordpress-stubs": "^6.9"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": ["phpcs", "phpstan analyse", "psalm --find-dead-code"]
|
"lint": ["phpcs", "phpstan analyse", "psalm --find-dead-code"]
|
||||||
|
|||||||
Generated
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "c4e0cf49edc636becbde269300f26001",
|
"content-hash": "b4f5229f78cd0eed0c7166614bf05110",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "chillerlan/php-qrcode",
|
"name": "chillerlan/php-qrcode",
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ class RsvFormDefinitionController {
|
|||||||
'type' => 'object',
|
'type' => 'object',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'properties' => [
|
'properties' => [
|
||||||
'email_key' => ['type' => 'string', 'required' => false],
|
'email_key' => ['type' => 'string', 'required' => false],
|
||||||
'elements' => ['type' => 'array', 'default' => []],
|
'success_message' => ['type' => 'string', 'required' => false],
|
||||||
|
'elements' => ['type' => 'array', 'default' => []],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Reservair\Logger\Logger;
|
use Reservair\Logger\Logger;
|
||||||
|
use Reservair\Templating\RsvTemplateEngine;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles all outgoing email for the reservation module.
|
* Handles all outgoing email for the reservation module.
|
||||||
@@ -22,11 +23,7 @@ class RsvEmailListener {
|
|||||||
<p>Rezervace č. {{reservation_id}} čeká na vaše schválení.</p>
|
<p>Rezervace č. {{reservation_id}} čeká na vaše schválení.</p>
|
||||||
<p><strong>Datum:</strong> {{date}}</p>
|
<p><strong>Datum:</strong> {{date}}</p>
|
||||||
<p><strong>Čas:</strong> {{start}} – {{end}}</p>
|
<p><strong>Čas:</strong> {{start}} – {{end}}</p>
|
||||||
<p>
|
<p><reservation-actions></reservation-actions></p>
|
||||||
<a href='{{accept_url}}'>Přijmout</a>
|
|
||||||
|
|
|
||||||
<a href='{{refuse_url}}'>Odmítnout</a>
|
|
||||||
</p>
|
|
||||||
";
|
";
|
||||||
|
|
||||||
private const string DEFAULT_ACCEPTED_SUBJECT = 'Rezervace přijata';
|
private const string DEFAULT_ACCEPTED_SUBJECT = 'Rezervace přijata';
|
||||||
@@ -48,17 +45,20 @@ class RsvEmailListener {
|
|||||||
|
|
||||||
public static function on_pending(RsvTimetableReservationPendingEvent $event): void {
|
public static function on_pending(RsvTimetableReservationPendingEvent $event): void {
|
||||||
try {
|
try {
|
||||||
|
global $rsv_template_registry;
|
||||||
|
$engine = new RsvTemplateEngine(registry: $rsv_template_registry);
|
||||||
|
|
||||||
$tz = wp_timezone();
|
$tz = wp_timezone();
|
||||||
$start_dt = (clone $event->reservation->start_utc)->setTimezone($tz);
|
$start_dt = (clone $event->reservation->start_utc)->setTimezone($tz);
|
||||||
$end_dt = (clone $event->reservation->end_utc)->setTimezone($tz);
|
$end_dt = (clone $event->reservation->end_utc)->setTimezone($tz);
|
||||||
|
|
||||||
$body = (new RsvEmailTemplater())->render(self::DEFAULT_PENDING_BODY, [
|
$body = $engine->render(self::DEFAULT_PENDING_BODY, [
|
||||||
'reservation_id' => (string) $event->reservation_id,
|
'reservation_id' => (string) $event->reservation_id,
|
||||||
'date' => esc_html($start_dt->format('Y-m-d')),
|
'date' => $start_dt->format('Y-m-d'),
|
||||||
'start' => esc_html($start_dt->format('H:i')),
|
'start' => $start_dt->format('H:i'),
|
||||||
'end' => esc_html($end_dt->format('H:i')),
|
'end' => $end_dt->format('H:i'),
|
||||||
'accept_url' => esc_url(get_rest_url(null, 'reservations/v1/timetable-reservation/accept/' . $event->code)),
|
'accept_url' => get_rest_url(null, 'reservations/v1/timetable-reservation/accept/' . $event->code),
|
||||||
'refuse_url' => esc_url(get_rest_url(null, 'reservations/v1/timetable-reservation/refuse/' . $event->code)),
|
'refuse_url' => get_rest_url(null, 'reservations/v1/timetable-reservation/refuse/' . $event->code),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
(new RsvEmailSender())->send($event->maintainer_email, self::DEFAULT_PENDING_SUBJECT, $body);
|
(new RsvEmailSender())->send($event->maintainer_email, self::DEFAULT_PENDING_SUBJECT, $body);
|
||||||
@@ -67,22 +67,6 @@ class RsvEmailListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* HTML-escape scalar form values for safe interpolation into an HTML email
|
|
||||||
* body. Non-scalar values (e.g. nested arrays) are dropped to an empty
|
|
||||||
* string since the templater only substitutes plain placeholders.
|
|
||||||
*
|
|
||||||
* @param array<string,mixed> $values
|
|
||||||
* @return array<string,string>
|
|
||||||
*/
|
|
||||||
private static function escape_values(array $values): array {
|
|
||||||
$escaped = [];
|
|
||||||
foreach ($values as $key => $value) {
|
|
||||||
$escaped[$key] = is_scalar($value) ? esc_html((string) $value) : '';
|
|
||||||
}
|
|
||||||
return $escaped;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function on_form_submit_closed(RsvFormSubmitClosedEvent $event): void {
|
public static function on_form_submit_closed(RsvFormSubmitClosedEvent $event): void {
|
||||||
try {
|
try {
|
||||||
$form_submit = (new RsvFormSubmitRepository())->get($event->form_submit_id);
|
$form_submit = (new RsvFormSubmitRepository())->get($event->form_submit_id);
|
||||||
@@ -136,15 +120,14 @@ class RsvEmailListener {
|
|||||||
$subject_tpl = trim((string) ($tpl['subject'] ?? '')) !== '' ? $tpl['subject'] : $default_subject;
|
$subject_tpl = trim((string) ($tpl['subject'] ?? '')) !== '' ? $tpl['subject'] : $default_subject;
|
||||||
$body_tpl = trim((string) ($tpl['body'] ?? '')) !== '' ? $tpl['body'] : $default_body;
|
$body_tpl = trim((string) ($tpl['body'] ?? '')) !== '' ? $tpl['body'] : $default_body;
|
||||||
|
|
||||||
$templater = new RsvEmailTemplater();
|
global $rsv_template_registry;
|
||||||
|
$engine = new RsvTemplateEngine(registry: $rsv_template_registry);
|
||||||
|
|
||||||
// Subject is plain text: render with raw values, then strip any tags
|
// Subject is plain text: render without HTML-escaping, then strip tags/newlines.
|
||||||
// or newlines to avoid header issues.
|
$subject = sanitize_text_field($engine->render_plain($subject_tpl, $form_values));
|
||||||
$subject = sanitize_text_field($templater->render($subject_tpl, $form_values));
|
|
||||||
|
|
||||||
// Body is HTML: escape the user-submitted values before interpolation
|
// Body is HTML: the engine HTML-escapes all interpolated values.
|
||||||
// so they can't inject markup into the message.
|
$body = $engine->render($body_tpl, $form_values);
|
||||||
$body = $templater->render($body_tpl, self::escape_values($form_values));
|
|
||||||
|
|
||||||
(new RsvEmailSender())->send($user_email, $subject, $body);
|
(new RsvEmailSender())->send($user_email, $subject, $body);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ class RsvTimetableReservationRepository {
|
|||||||
WHERE timetable_id = %d
|
WHERE timetable_id = %d
|
||||||
AND `start_utc` < %s
|
AND `start_utc` < %s
|
||||||
AND `end_utc` > %s",
|
AND `end_utc` > %s",
|
||||||
[$timetable_id, $end_utc, $start_utc]
|
[$timetable_id, $end_utc->format('Y-m-d H:i:s'), $start_utc->format('Y-m-d H:i:s')],
|
||||||
|
ARRAY_A
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,17 @@
|
|||||||
* Contains definitions of the admin menus
|
* Contains definitions of the admin menus
|
||||||
*/
|
*/
|
||||||
function rsv_admin_menu_definition() {
|
function rsv_admin_menu_definition() {
|
||||||
|
$reservations = new RsvReservationsPage();
|
||||||
|
$forms = new RsvFormsPage();
|
||||||
|
$timetable = new RsvTimetablePage();
|
||||||
|
$google_cal = new RsvGoogleCalendarSettingsPage();
|
||||||
|
|
||||||
add_menu_page(
|
add_menu_page(
|
||||||
'Reservations Settings', // Page title
|
'Reservations Settings', // Page title
|
||||||
'Reservations', // Menu title
|
'Reservations', // Menu title
|
||||||
RsvCapabilities::MANAGE, // Capability
|
RsvCapabilities::MANAGE, // Capability
|
||||||
'reservations-settings', // Menu slug
|
'reservations-settings', // Menu slug
|
||||||
'rsv_reservations_page', // Callback
|
[$reservations, 'render'], // Callback
|
||||||
'dashicons-calendar', // Icon
|
'dashicons-calendar', // Icon
|
||||||
20 // Position
|
20 // Position
|
||||||
);
|
);
|
||||||
@@ -20,7 +25,7 @@ function rsv_admin_menu_definition() {
|
|||||||
'Forms',
|
'Forms',
|
||||||
RsvCapabilities::MANAGE,
|
RsvCapabilities::MANAGE,
|
||||||
'forms-settings',
|
'forms-settings',
|
||||||
'rsv_forms_page'
|
[$forms, 'render']
|
||||||
);
|
);
|
||||||
|
|
||||||
add_submenu_page(
|
add_submenu_page(
|
||||||
@@ -29,7 +34,7 @@ function rsv_admin_menu_definition() {
|
|||||||
'Timetables',
|
'Timetables',
|
||||||
RsvCapabilities::MANAGE,
|
RsvCapabilities::MANAGE,
|
||||||
'timetable-settings',
|
'timetable-settings',
|
||||||
'rsv_timetable_page'
|
[$timetable, 'render']
|
||||||
);
|
);
|
||||||
|
|
||||||
add_submenu_page(
|
add_submenu_page(
|
||||||
@@ -38,6 +43,6 @@ function rsv_admin_menu_definition() {
|
|||||||
'Google Calendar',
|
'Google Calendar',
|
||||||
RsvCapabilities::MANAGE,
|
RsvCapabilities::MANAGE,
|
||||||
'rsv-google-calendar',
|
'rsv-google-calendar',
|
||||||
'rsv_google_calendar_settings_page'
|
[$google_cal, 'render']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
abstract class RsvAdminPage {
|
||||||
|
|
||||||
|
final public function render(): void {
|
||||||
|
if (!current_user_can(RsvCapabilities::MANAGE)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
echo '<div class="wrap">';
|
||||||
|
$this->render_content();
|
||||||
|
echo '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract protected function render_content(): void;
|
||||||
|
}
|
||||||
@@ -5,44 +5,21 @@
|
|||||||
* admin and the default user.
|
* admin and the default user.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function rsv_asset_url(string $relative): string {
|
function rsv_build_url(string $file): string {
|
||||||
return plugin_dir_url(__FILE__) . '../assets/' . $relative;
|
return plugin_dir_url(__FILE__) . '../build/' . $file;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rsv_asset_file(string $relative): string {
|
function rsv_build_file(string $file): string {
|
||||||
return plugin_dir_path(__FILE__) . '../assets/' . $relative;
|
return plugin_dir_path(__FILE__) . '../build/' . $file;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rsv_js(string $handle, string $relative, array $deps = []): void {
|
function rsv_localize_api(string $handle): void {
|
||||||
wp_enqueue_script($handle, rsv_asset_url($relative), $deps, filemtime(rsv_asset_file($relative)));
|
wp_localize_script($handle, 'ReservairServiceAPI', [
|
||||||
}
|
|
||||||
|
|
||||||
function rsv_css(string $handle, string $relative): void {
|
|
||||||
wp_enqueue_style($handle, rsv_asset_url($relative), [], filemtime(rsv_asset_file($relative)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Shared between frontend and admin ---
|
|
||||||
|
|
||||||
function rsv_enqueue_shared_assets(): void {
|
|
||||||
rsv_js('rsv_calendar', 'js/elements/RsvCalendar.js');
|
|
||||||
rsv_js('rsv_timeline', 'js/elements/RsvTimeline.js');
|
|
||||||
rsv_js('rsv_api', 'js/RsvApi.js');
|
|
||||||
rsv_js('reservation_selector', 'js/elements/RsvReservationSelector.js');
|
|
||||||
rsv_js('rsv_reservation_summary', 'js/elements/RsvReservationSummary.js');
|
|
||||||
rsv_js('rsv_data_source', 'js/datasource/RsvDataSource.js');
|
|
||||||
rsv_js('rsv_reservation_resource', 'js/datasource/RsvReservationResource.js');
|
|
||||||
rsv_js('rsv_form_definition_resource', 'js/datasource/RsvFormDefinitionResource.js');
|
|
||||||
rsv_js('rsv_timetable_resource', 'js/datasource/RsvTimetableResource.js');
|
|
||||||
rsv_js('rsv_timetable_capacity_resource', 'js/datasource/RsvTimetableCapacityResource.js');
|
|
||||||
rsv_js('rsv_timetable_reservation_resource', 'js/datasource/RsvTimetableReservationResource.js');
|
|
||||||
rsv_js('rsv_reservation_client', 'js/datasource/RsvReservationClient.js');
|
|
||||||
|
|
||||||
wp_localize_script('rsv_api', 'ReservairServiceAPI', [
|
|
||||||
'restUrl' => rest_url('reservations/v1'),
|
'restUrl' => rest_url('reservations/v1'),
|
||||||
'nonce' => wp_create_nonce('wp_rest'),
|
'nonce' => wp_create_nonce('wp_rest'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
wp_localize_script('rsv_api', 'ReservairStrings', [
|
wp_localize_script($handle, 'ReservairStrings', [
|
||||||
'timeline' => [
|
'timeline' => [
|
||||||
'not_reservable' => 'Tento objekt nelze rezervovat.',
|
'not_reservable' => 'Tento objekt nelze rezervovat.',
|
||||||
'no_blocks' => 'Tento den není dostupný žádný blok. Vyberte jiné datum.',
|
'no_blocks' => 'Tento den není dostupný žádný blok. Vyberte jiné datum.',
|
||||||
@@ -63,35 +40,27 @@ function rsv_enqueue_shared_assets(): void {
|
|||||||
'error_generic' => 'Něco se pokazilo. Zkuste to prosím znovu.',
|
'error_generic' => 'Něco se pokazilo. Zkuste to prosím znovu.',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
rsv_css('reservations-styles', 'css/RsvMainStyle.css');
|
|
||||||
rsv_css('rsv-form-summary-styles', 'css/components/RsvFormSummaryStyles.css');
|
|
||||||
rsv_css('rsv-calendar-styles', 'css/components/RsvCalendarStyles.css');
|
|
||||||
rsv_css('rsv-form-styles', 'css/components/RsvFormStyles.css');
|
|
||||||
rsv_css('rsv-time-slot-styles', 'css/components/RsvTimeSlotsStyles.css');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Public hooks ---
|
// --- Public hooks ---
|
||||||
|
|
||||||
function rsv_enqueue_assets(): void {
|
function rsv_enqueue_assets(): void {
|
||||||
rsv_enqueue_shared_assets();
|
wp_enqueue_script('rsv-client', rsv_build_url('client.js'), [], filemtime(rsv_build_file('client.js')));
|
||||||
|
wp_enqueue_style('rsv-client', rsv_build_url('client.css'), [], filemtime(rsv_build_file('client.css')));
|
||||||
|
|
||||||
rsv_js('rsv_timetable_service', 'js/services/RsvTimetableService.js');
|
rsv_localize_api('rsv-client');
|
||||||
rsv_js('rsv_form_sender', 'js/forms/RsvFormSender.js');
|
|
||||||
rsv_js('rsv_form_encoder', 'js/forms/RsvFormEncoder.js');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rsv_enqueue_admin_assets(): void {
|
function rsv_enqueue_admin_assets(): void {
|
||||||
rsv_enqueue_shared_assets();
|
// The client bundle defines the custom elements shared by both front-end and
|
||||||
|
// admin. enqueue_block_assets already enqueues `rsv-client` in the editor, so
|
||||||
|
// re-using the same handle here keeps it loaded exactly once (WP dedupes by
|
||||||
|
// handle) instead of bundling a second copy into admin.js.
|
||||||
|
wp_enqueue_script('rsv-client', rsv_build_url('client.js'), [], filemtime(rsv_build_file('client.js')));
|
||||||
|
wp_enqueue_style('rsv-client', rsv_build_url('client.css'), [], filemtime(rsv_build_file('client.css')));
|
||||||
|
|
||||||
$admin_js = plugin_dir_path(__FILE__) . '../src/components/admin.js';
|
wp_enqueue_script('rsv-admin', rsv_build_url('admin.js'), ['rsv-client'], filemtime(rsv_build_file('admin.js')));
|
||||||
wp_enqueue_script('admin', plugin_dir_url(__FILE__) . '../src/components/admin.js', [], filemtime($admin_js));
|
wp_enqueue_style('rsv-admin', rsv_build_url('admin.css'), ['rsv-client'], filemtime(rsv_build_file('admin.css')));
|
||||||
|
|
||||||
rsv_js('rsv_inline_form_builder', 'js/forms/RsvInlineFormBuilder.js');
|
rsv_localize_api('rsv-client');
|
||||||
rsv_js('datagrid', 'js/elements/RsvDatagrid.js');
|
|
||||||
rsv_js('rsv_form_encoder', 'js/forms/RsvFormEncoder.js');
|
|
||||||
// RsvAdminForm needs the encoder, the localized nonce (rsv_api), and
|
|
||||||
// show_notice() (admin) — declare them as deps so load order is correct.
|
|
||||||
rsv_js('rsv_admin_form', 'js/forms/RsvAdminForm.js', ['rsv_form_encoder', 'rsv_api', 'admin']);
|
|
||||||
rsv_css('rsv-admin-style', 'css/RsvAdminStyle.css');
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
class RsvEmailTemplater {
|
|
||||||
public function render(string $template, array $data) : string {
|
|
||||||
return preg_replace_callback('/{{\s*(\w+)\s*}}/', function($matches) use ($data) {
|
|
||||||
return $data[$matches[1]] ?? '';
|
|
||||||
}, $template);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ class RsvButtonElementHandler implements RsvFormElementHandler {
|
|||||||
public function draw(RsvFormElementDefinition $element): void {
|
public function draw(RsvFormElementDefinition $element): void {
|
||||||
?>
|
?>
|
||||||
<div class="rsv-form-input-group rsv-form-input-short">
|
<div class="rsv-form-input-group rsv-form-input-short">
|
||||||
<button class="rsv-form-btn-primary"><?= $element->getLabel() ?></button>
|
<button class="rsv-form-btn rsv-form-btn-primary"><?= $element->getLabel() ?></button>
|
||||||
</div>
|
</div>
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
|
||||||
interface RsvFormElementHandler {
|
interface RsvFormElementHandler {
|
||||||
function draw(RsvFormElementDefinition $def) : void;
|
function draw(RsvFormElementDefinition $def) : void;
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class RsvFormReservationElementHandler implements RsvFormElementHandler {
|
|||||||
$name = $element->getName();
|
$name = $element->getName();
|
||||||
?>
|
?>
|
||||||
<div class="rsv-form-input-group">
|
<div class="rsv-form-input-group">
|
||||||
<label><?= esc_html($element->getLabel()) ?></label>
|
<label class="rsv-form-label"><?= esc_html($element->getLabel()) ?></label>
|
||||||
<rsv-reservation-selector
|
<rsv-reservation-selector
|
||||||
timetable-id="<?= $timetable_id ?>"
|
timetable-id="<?= $timetable_id ?>"
|
||||||
name="<?= esc_attr($name) ?>"
|
name="<?= esc_attr($name) ?>"
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ class RsvFormDefinition {
|
|||||||
|
|
||||||
public string $email_key = "";
|
public string $email_key = "";
|
||||||
|
|
||||||
|
public string $success_message = "";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int,mixed> $definition Full definition array including 'elements' and 'email_key'.
|
* @param array<int,mixed> $definition Full definition array including 'elements', 'email_key' and 'success_message'.
|
||||||
*/
|
*/
|
||||||
public function __construct(string $id, array $definition) {
|
public function __construct(string $id, array $definition) {
|
||||||
$this->_elements = [];
|
$this->_elements = [];
|
||||||
@@ -19,8 +21,9 @@ class RsvFormDefinition {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->_id = $id;
|
$this->_id = $id;
|
||||||
$this->email_key = $definition['email_key'] ?? '';
|
$this->email_key = $definition['email_key'] ?? '';
|
||||||
|
$this->success_message = $definition['success_message'] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getId(): string {
|
public function getId(): string {
|
||||||
@@ -31,6 +34,11 @@ class RsvFormDefinition {
|
|||||||
return $this->email_key;
|
return $this->email_key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Template shown to the visitor after a successful submission. */
|
||||||
|
public function getSuccessMessage(): string {
|
||||||
|
return $this->success_message;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public function hasElements() : bool {
|
public function hasElements() : bool {
|
||||||
return count($this->_elements) > 0;
|
return count($this->_elements) > 0;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Reservair\Templating\RsvTemplateEngine;
|
||||||
|
|
||||||
class RsvFormHtmlRenderer {
|
class RsvFormHtmlRenderer {
|
||||||
public function draw(RsvFormDefinition $form): bool {
|
public function draw(RsvFormDefinition $form): bool {
|
||||||
@@ -20,12 +21,37 @@ class RsvFormHtmlRenderer {
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
<?php $this->draw_success_template($form); ?>
|
||||||
</div>
|
</div>
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits the admin-configured success message as an inert <template> that the
|
||||||
|
* client clones once the form is submitted. A <reservation-summary> element
|
||||||
|
* expands to a placeholder div that RsvFormSender fills with the visitor's
|
||||||
|
* selected slots.
|
||||||
|
*/
|
||||||
|
private function draw_success_template(RsvFormDefinition $form): void {
|
||||||
|
$message = trim($form->getSuccessMessage());
|
||||||
|
if ($message === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $rsv_template_registry;
|
||||||
|
$engine = new RsvTemplateEngine(registry: $rsv_template_registry);
|
||||||
|
|
||||||
|
// Sanitize admin HTML before rendering, allowing the registered template
|
||||||
|
// custom elements through so the engine can expand them.
|
||||||
|
$allowed = $rsv_template_registry->kses_allowed(wp_kses_allowed_html('post'));
|
||||||
|
$html = $engine->render(wp_kses($message, $allowed));
|
||||||
|
?>
|
||||||
|
<template class="rsv-form-success"><?= $html ?></template>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
public function draw_element(RsvFormElementDefinition $data): void {
|
public function draw_element(RsvFormElementDefinition $data): void {
|
||||||
global $rsv_form_registry;
|
global $rsv_form_registry;
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class RsvTimetableReservationService {
|
|||||||
->get_overlapping_capacity($timetable_id, $start_utc, $end_utc);
|
->get_overlapping_capacity($timetable_id, $start_utc, $end_utc);
|
||||||
|
|
||||||
if (count($overlapping_capacity) === 0) {
|
if (count($overlapping_capacity) === 0) {
|
||||||
|
Logger::error("No available capacity for timetable_id: $timetable_id, start_utc: " . $start_utc->format('Y-m-d H:i:s') . ", end_utc: " . $end_utc->format('Y-m-d H:i:s'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ class RsvTimetableReservationService {
|
|||||||
$end_min = $this->time_of_day_minutes($end_utc);
|
$end_min = $this->time_of_day_minutes($end_utc);
|
||||||
|
|
||||||
if ((int) $overlapping_capacity[0]->start_time > $start_min) {
|
if ((int) $overlapping_capacity[0]->start_time > $start_min) {
|
||||||
|
Logger::error("Overlapping capacity start_time: " . (int) $overlapping_capacity[0]->start_time . " is after the reservation start_time: $start_min");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +61,7 @@ class RsvTimetableReservationService {
|
|||||||
|
|
||||||
$capacity = (int) $overlapping_capacity[0]->capacity;
|
$capacity = (int) $overlapping_capacity[0]->capacity;
|
||||||
$reservations = $this->repo->get_overlapping($timetable_id, $start_utc, $end_utc);
|
$reservations = $this->repo->get_overlapping($timetable_id, $start_utc, $end_utc);
|
||||||
|
error_log('reservations: ' . json_encode($reservations));
|
||||||
|
|
||||||
return count($reservations) < $capacity;
|
return count($reservations) < $capacity;
|
||||||
}
|
}
|
||||||
|
|||||||
+342
-336
@@ -1,284 +1,42 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Reservair\Forms\RsvFormBuilder;
|
use Reservair\Forms\RsvFormBuilder;
|
||||||
|
use Reservair\Layout\RsvColumnLayout;
|
||||||
|
|
||||||
// Shared inline script for the elements data grid.
|
class RsvFormsPage extends RsvAdminPage {
|
||||||
// $elements_with_ids: array of element objects already carrying an 'id' key.
|
|
||||||
// $next_id: the first integer not yet used as an id.
|
|
||||||
function rsv_elements_table_script(array $elements_with_ids, int $next_id, string $form_id, array $element_types, array $timetables = []): void {
|
|
||||||
$elements_json = json_encode($elements_with_ids);
|
|
||||||
$types_json = json_encode(array_values($element_types));
|
|
||||||
$timetables_json = json_encode(array_values($timetables));
|
|
||||||
?>
|
|
||||||
<script>
|
|
||||||
const rsv_element_types = <?= $types_json ?>;
|
|
||||||
const rsv_timetables = <?= $timetables_json ?>;
|
|
||||||
|
|
||||||
const RSV_EMAIL_DEFAULTS = {
|
protected function render_content(): void {
|
||||||
accepted_subject: <?= json_encode('Rezervace přijata') ?>,
|
if (isset($_GET['action']) && $_GET['action'] === 'edit' && isset($_GET['id'])) {
|
||||||
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>") ?>,
|
$this->show_edit(intval($_GET['id']));
|
||||||
refused_subject: <?= json_encode('Rezervace zamítnuta') ?>,
|
return;
|
||||||
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>") ?>,
|
|
||||||
};
|
|
||||||
|
|
||||||
const rsv_elements_source = (function(initial_items, next_id_start) {
|
|
||||||
const items = initial_items;
|
|
||||||
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('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];
|
|
||||||
items[idx] = {
|
|
||||||
...extra_attrs,
|
|
||||||
id,
|
|
||||||
name: data.name ?? '',
|
|
||||||
label: data.label ?? '',
|
|
||||||
type: data.type ?? 'text',
|
|
||||||
desc: data.desc ?? '',
|
|
||||||
required: data.required === 'on',
|
|
||||||
...(data.type === 'input-text' ? {
|
|
||||||
validation: data.validation ?? '',
|
|
||||||
pattern: data.pattern ?? '',
|
|
||||||
pattern_message: data.pattern_message ?? '',
|
|
||||||
} : {}),
|
|
||||||
...(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: {
|
|
||||||
on_accepted: {
|
|
||||||
enabled: !!data.email_accepted_enabled,
|
|
||||||
subject: data.email_accepted_subject ?? RSV_EMAIL_DEFAULTS.accepted_subject,
|
|
||||||
body: data.email_accepted_body ?? RSV_EMAIL_DEFAULTS.accepted_body,
|
|
||||||
},
|
|
||||||
on_refused: {
|
|
||||||
enabled: !!data.email_refused_enabled,
|
|
||||||
subject: data.email_refused_subject ?? RSV_EMAIL_DEFAULTS.refused_subject,
|
|
||||||
body: data.email_refused_body ?? RSV_EMAIL_DEFAULTS.refused_body,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} : {}),
|
|
||||||
};
|
|
||||||
return Promise.resolve(items[idx]);
|
|
||||||
},
|
|
||||||
add() {
|
|
||||||
const item = { id: next_id++, name: '', label: '', type: 'text', desc: '', required: false };
|
|
||||||
items.push(item);
|
|
||||||
return item;
|
|
||||||
},
|
|
||||||
move_up(id) {
|
|
||||||
const idx = items.findIndex(e => e.id === id);
|
|
||||||
if (idx > 0) [items[idx - 1], items[idx]] = [items[idx], items[idx - 1]];
|
|
||||||
},
|
|
||||||
move_down(id) {
|
|
||||||
const idx = items.findIndex(e => e.id === id);
|
|
||||||
if (idx !== -1 && idx < items.length - 1) [items[idx], items[idx + 1]] = [items[idx + 1], items[idx]];
|
|
||||||
},
|
|
||||||
remove(id) {
|
|
||||||
const idx = items.findIndex(e => e.id === id);
|
|
||||||
if (idx !== -1) items.splice(idx, 1);
|
|
||||||
},
|
|
||||||
get_all() {
|
|
||||||
return items.map(({ id, ...rest }) => rest);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
})(<?= $elements_json ?>, <?= $next_id ?>);
|
|
||||||
|
|
||||||
function rsv_render_element_inline_form(dt, row, data) {
|
|
||||||
const builder = RsvInlineFormBuilder.create(rsv_elements_source)
|
|
||||||
.fieldset('Element', '50%')
|
|
||||||
.input_text('name', 'Slug', data?.name ?? '')
|
|
||||||
.input_text('label', 'Label', data?.label ?? '')
|
|
||||||
.input_select('type', 'Type', rsv_element_types, data?.type ?? rsv_element_types[0])
|
|
||||||
.fieldset('Options', '50%')
|
|
||||||
.input_text('desc', 'Description', data?.desc ?? '')
|
|
||||||
.input_checkbox('required', 'Required', data?.required ?? false);
|
|
||||||
|
|
||||||
if ((data?.type ?? rsv_element_types[0]) === 'reservation') {
|
|
||||||
const accepted = data?.email_templates?.on_accepted ?? {};
|
|
||||||
const refused = data?.email_templates?.on_refused ?? {};
|
|
||||||
const timetable_options = [
|
|
||||||
{ value: '', label: '— none —' },
|
|
||||||
...rsv_timetables.map(t => ({ value: t.id, label: t.name })),
|
|
||||||
];
|
|
||||||
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 — 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)
|
|
||||||
.input_textarea('email_accepted_body', 'Body', accepted.body ?? RSV_EMAIL_DEFAULTS.accepted_body)
|
|
||||||
.fieldset('Email — refused', '100%')
|
|
||||||
.input_checkbox('email_refused_enabled', 'Send email when refused', refused.enabled ?? true)
|
|
||||||
.input_text('email_refused_subject', 'Subject', refused.subject ?? RSV_EMAIL_DEFAULTS.refused_subject)
|
|
||||||
.input_textarea('email_refused_body', 'Body', refused.body ?? RSV_EMAIL_DEFAULTS.refused_body);
|
|
||||||
}
|
}
|
||||||
|
$this->show_list();
|
||||||
if ((data?.type ?? rsv_element_types[0]) === 'input-text') {
|
|
||||||
builder
|
|
||||||
.input_select('validation', 'Validation', [
|
|
||||||
{ value: '', label: '— none —' },
|
|
||||||
{ value: 'email', label: 'Email' },
|
|
||||||
{ value: 'phone', label: 'Phone' },
|
|
||||||
{ value: 'digits', label: 'Digits only' },
|
|
||||||
{ value: 'pattern', label: 'Custom pattern' },
|
|
||||||
], data?.validation ?? '')
|
|
||||||
.input_text('pattern', 'Custom pattern (regex)', data?.pattern ?? '')
|
|
||||||
.show_if(RsvInlineFormBuilder.match_p('validation', 'pattern'))
|
|
||||||
.input_text('pattern_message', 'Pattern error message', data?.pattern_message ?? '')
|
|
||||||
.show_if(RsvInlineFormBuilder.match_p('validation', 'pattern'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = builder.build({
|
|
||||||
id: data?.id,
|
|
||||||
colspan: 5,
|
|
||||||
save_label: 'Save',
|
|
||||||
on_success: () => elements_dt.refresh(),
|
|
||||||
on_cancel: () => elements_dt.refresh(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Type swaps whole fieldsets, so re-render the inline form on change.
|
|
||||||
// Common fields are carried over; type-specific fields reset to stored data.
|
|
||||||
const type_select = node.querySelector('select[name="type"]');
|
|
||||||
if (type_select) {
|
|
||||||
type_select.addEventListener('change', () => {
|
|
||||||
const current = Object.fromEntries(new FormData(node.querySelector('form')));
|
|
||||||
const merged = {
|
|
||||||
...data,
|
|
||||||
name: current.name ?? data?.name,
|
|
||||||
label: current.label ?? data?.label,
|
|
||||||
desc: current.desc ?? data?.desc,
|
|
||||||
required: 'required' in current,
|
|
||||||
type: type_select.value,
|
|
||||||
};
|
|
||||||
row.replaceChildren(rsv_render_element_inline_form(dt, row, merged));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The elements data grid only exists on the edit page. Guard it so a missing
|
private function show_list(): void {
|
||||||
// container can't abort the script and strip the form's submit handler.
|
global $rsv_form_registry;
|
||||||
const elements_table_el = document.getElementById('form_elements_table');
|
$element_types = array_keys($rsv_form_registry->handlers);
|
||||||
if (elements_table_el) {
|
$elements_with_ids = [];
|
||||||
var elements_dt = RsvDataGrid.create_data_grid(
|
$next_id = 1;
|
||||||
elements_table_el,
|
$timetables = (new RsvTimetableService())->get_all();
|
||||||
rsv_elements_source,
|
?>
|
||||||
{
|
<h1>Formuláře</h1>
|
||||||
'name': RsvDataGrid.action_column('Name', false, {
|
<hr>
|
||||||
'Edit': RsvDataGrid.edit_action(function(dt, row, data) {
|
<?php
|
||||||
row.classList.add(
|
RsvColumnLayout::split('1:2')
|
||||||
'inline-edit-row', 'inline-edit-row-post',
|
->column(function () {
|
||||||
'quick-edit-row', 'quick-edit-row-post',
|
echo RsvFormBuilder::create('add_form_definition', get_rest_url(null, 'reservations/v1/form-definition'), 'POST', 'Form definition created.')
|
||||||
'inline-edit-post', 'inline-editor'
|
->heading('Přidat formulář')
|
||||||
);
|
->nonce('my_action', 'my_nonce')
|
||||||
row.replaceChildren(rsv_render_element_inline_form(dt, row, data));
|
->text('name', 'Název')
|
||||||
}),
|
->render();
|
||||||
'Move Up': RsvDataGrid.func_action(function(dt, row, data) {
|
?>
|
||||||
rsv_elements_source.move_up(data.id);
|
|
||||||
dt.refresh();
|
|
||||||
}),
|
|
||||||
'Move Down': RsvDataGrid.func_action(function(dt, row, data) {
|
|
||||||
rsv_elements_source.move_down(data.id);
|
|
||||||
dt.refresh();
|
|
||||||
}),
|
|
||||||
'Remove': RsvDataGrid.func_action(function(dt, row, data) {
|
|
||||||
rsv_elements_source.remove(data.id);
|
|
||||||
dt.refresh();
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
'label': RsvDataGrid.column('Label', false),
|
|
||||||
'type': RsvDataGrid.column('Type', false),
|
|
||||||
'desc': RsvDataGrid.column('Description', false),
|
|
||||||
'required': RsvDataGrid.column('Required', false),
|
|
||||||
'details': RsvDataGrid.column('Details', false),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
elements_dt.map_column('details', (dt, row, data) => {
|
|
||||||
const td = document.createElement('td');
|
|
||||||
if (data.type === 'reservation') {
|
|
||||||
const parts = [];
|
|
||||||
if (data.timetable_id) {
|
|
||||||
const t = rsv_timetables.find(t => t.id === data.timetable_id);
|
|
||||||
parts.push(`Timetable: ${t ? t.name : data.timetable_id}`);
|
|
||||||
}
|
|
||||||
if (data.price_per_block != null) parts.push(`Price/block: ${data.price_per_block}`);
|
|
||||||
const et = data.email_templates ?? {};
|
|
||||||
const emails = [];
|
|
||||||
if (et.on_accepted?.enabled) emails.push('accepted');
|
|
||||||
if (et.on_refused?.enabled) emails.push('refused');
|
|
||||||
if (emails.length) parts.push(`Emails: ${emails.join(', ')}`);
|
|
||||||
td.innerText = parts.join(' · ');
|
|
||||||
}
|
|
||||||
return td;
|
|
||||||
});
|
|
||||||
elements_dt.refresh();
|
|
||||||
|
|
||||||
document.getElementById('rsv_add_element_btn').onclick = function() {
|
|
||||||
rsv_elements_source.add();
|
|
||||||
elements_dt.refresh();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
RsvAdminForm.bind(document.getElementById('<?= $form_id ?>'), {
|
|
||||||
transform: (body) => ({
|
|
||||||
name: body.name,
|
|
||||||
definition: {
|
|
||||||
email_key: body.definition?.email_key ?? '',
|
|
||||||
elements: rsv_elements_source.get_all(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
refresh: () => { if (typeof forms_dt !== 'undefined') forms_dt.refresh(); },
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<?php
|
|
||||||
}
|
|
||||||
|
|
||||||
function rsv_form_info_page(): void {
|
|
||||||
global $rsv_form_registry;
|
|
||||||
$element_types = array_keys($rsv_form_registry->handlers);
|
|
||||||
$elements_with_ids = [];
|
|
||||||
$next_id = 1;
|
|
||||||
$timetables = (new RsvTimetableService())->get_all();
|
|
||||||
?>
|
|
||||||
|
|
||||||
<h1>Formuláře</h1>
|
|
||||||
<hr>
|
|
||||||
<div id="col-container" class="wp-clearfix">
|
|
||||||
<div id="col-left">
|
|
||||||
<div class="col-wrap">
|
|
||||||
<div class="form-wrap">
|
|
||||||
<h2>Přidat formulář</h2>
|
|
||||||
|
|
||||||
<form id="add_form_definition"
|
|
||||||
method="post"
|
|
||||||
data-method="POST"
|
|
||||||
data-success-msg="Form definition created."
|
|
||||||
action="<?= get_rest_url(null, 'reservations/v1/form-definition'); ?>">
|
|
||||||
<?php wp_nonce_field('my_action', 'my_nonce'); ?>
|
|
||||||
|
|
||||||
<?php echo RsvFormBuilder::create('add_form_definition')->text('name', 'Název')->render(); ?>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
<p class="submit">
|
<p class="submit">
|
||||||
<button type="submit" form="add_form_definition" class="button button-primary">Add Form Definition</button>
|
<button type="submit" form="add_form_definition" class="button button-primary">Add Form Definition</button>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<?php })
|
||||||
</div>
|
->column(function () { ?>
|
||||||
|
|
||||||
<div id="col-right">
|
|
||||||
<div class="col-wrap">
|
|
||||||
<div id="forms_table"></div>
|
<div id="forms_table"></div>
|
||||||
<script>
|
<script>
|
||||||
var forms_dt = RsvDataGrid.create_data_grid(forms_table,
|
var forms_dt = RsvDataGrid.create_data_grid(forms_table,
|
||||||
@@ -289,6 +47,7 @@ function rsv_form_info_page(): void {
|
|||||||
`<?= menu_page_url('forms-settings', false) ?>&id=${data.form_id}&action=edit`
|
`<?= menu_page_url('forms-settings', false) ?>&id=${data.form_id}&action=edit`
|
||||||
),
|
),
|
||||||
'Trash': RsvDataGrid.func_action(function(dt, row, data) {
|
'Trash': RsvDataGrid.func_action(function(dt, row, data) {
|
||||||
|
if (!confirm('Delete this form? This cannot be undone.')) return;
|
||||||
dt.resource.delete(data.form_id).then(() => forms_dt.refresh()).catch(err => alert(err.message));
|
dt.resource.delete(data.form_id).then(() => forms_dt.refresh()).catch(err => alert(err.message));
|
||||||
}),
|
}),
|
||||||
'Clone': RsvDataGrid.func_action(function(dt, row, data) {
|
'Clone': RsvDataGrid.func_action(function(dt, row, data) {
|
||||||
@@ -318,80 +77,327 @@ function rsv_form_info_page(): void {
|
|||||||
});
|
});
|
||||||
forms_dt.refresh();
|
forms_dt.refresh();
|
||||||
</script>
|
</script>
|
||||||
</div>
|
<?php })
|
||||||
</div>
|
->output();
|
||||||
</div>
|
?>
|
||||||
|
|
||||||
|
<?php $this->elements_table_script($elements_with_ids, $next_id, 'add_form_definition', $element_types, $timetables); ?>
|
||||||
<?php rsv_elements_table_script($elements_with_ids, $next_id, 'add_form_definition', $element_types, $timetables); ?>
|
<?php
|
||||||
|
|
||||||
<?php
|
|
||||||
}
|
|
||||||
|
|
||||||
function rsv_form_definition_edit_page(int $id): void {
|
|
||||||
global $rsv_form_registry;
|
|
||||||
$element_types = array_keys($rsv_form_registry->handlers);
|
|
||||||
|
|
||||||
$repo = new RsvFormDefinitionRepository();
|
|
||||||
$form_def = $repo->get($id);
|
|
||||||
|
|
||||||
if ($form_def === null) {
|
|
||||||
echo '<div class="notice notice-error"><p>Form definition not found.</p></div>';
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$definition = $form_def['definition'] ?? [];
|
private function show_edit(int $id): void {
|
||||||
$raw_elements = array_values($definition['elements'] ?? []);
|
global $rsv_form_registry;
|
||||||
|
$element_types = array_keys($rsv_form_registry->handlers);
|
||||||
|
|
||||||
$elements_with_ids = array_map(function (array $el, int $idx): array {
|
$repo = new RsvFormDefinitionRepository();
|
||||||
return array_merge($el, ['id' => $idx + 1]);
|
$form_def = $repo->get($id);
|
||||||
}, $raw_elements, array_keys($raw_elements));
|
|
||||||
|
|
||||||
$next_id = count($elements_with_ids) + 1;
|
if ($form_def === null) {
|
||||||
$timetables = (new RsvTimetableService())->get_all();
|
echo '<div class="notice notice-error"><p>Form definition not found.</p></div>';
|
||||||
|
|
||||||
?>
|
|
||||||
<h1>Edit Form: <?= esc_html($form_def['name']) ?></h1>
|
|
||||||
<a href="<?= menu_page_url('forms-settings', false) ?>">← Back to Forms</a>
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<form id="edit_form_definition"
|
|
||||||
method="post"
|
|
||||||
data-method="PUT"
|
|
||||||
data-success-msg="Form definition updated."
|
|
||||||
action="<?= get_rest_url(null, 'reservations/v1/form-definition/' . $id); ?>">
|
|
||||||
|
|
||||||
<?php
|
|
||||||
echo RsvFormBuilder::create('edit_form_definition')
|
|
||||||
->text('name', 'Name', '', true, $form_def['name'])
|
|
||||||
->text('definition.email_key', 'Email Key', "Name of the form field that holds the submitter's email address.", true, $definition['email_key'] ?? '')
|
|
||||||
->render();
|
|
||||||
?>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
<h2>Form Elements</h2>
|
|
||||||
<p>Define the fields that will appear in this form.</p>
|
|
||||||
<div id="form_elements_table"></div>
|
|
||||||
<p>
|
|
||||||
<button type="button" class="button" id="rsv_add_element_btn">+ Add Element</button>
|
|
||||||
</p>
|
|
||||||
<p class="submit">
|
|
||||||
<button type="submit" form="edit_form_definition" class="button button-primary">Update Form Definition</button>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<?php rsv_elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables); ?>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
}
|
|
||||||
|
|
||||||
function rsv_forms_page(): void {
|
|
||||||
if (isset($_GET['action'])) {
|
|
||||||
if ($_GET['action'] === 'edit' && isset($_GET['id'])) {
|
|
||||||
rsv_form_definition_edit_page(intval($_GET['id']));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$definition = $form_def['definition'] ?? [];
|
||||||
|
$raw_elements = array_values($definition['elements'] ?? []);
|
||||||
|
|
||||||
|
$elements_with_ids = array_map(function (array $el, int $idx): array {
|
||||||
|
return array_merge($el, ['id' => $idx + 1]);
|
||||||
|
}, $raw_elements, array_keys($raw_elements));
|
||||||
|
|
||||||
|
$next_id = count($elements_with_ids) + 1;
|
||||||
|
$timetables = (new RsvTimetableService())->get_all();
|
||||||
|
|
||||||
|
$email_key_options = ['' => '— select field —'];
|
||||||
|
foreach ($raw_elements as $el) {
|
||||||
|
$el_name = $el['name'] ?? '';
|
||||||
|
if ($el_name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$el_label = $el['label'] ?? '';
|
||||||
|
$email_key_options[$el_name] = $el_label !== '' ? "$el_label ($el_name)" : $el_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
?>
|
||||||
|
<h1>Edit Form: <?= esc_html($form_def['name']) ?></h1>
|
||||||
|
<a href="<?= menu_page_url('forms-settings', false) ?>">← Back to Forms</a>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
echo RsvFormBuilder::create('edit_form_definition', get_rest_url(null, 'reservations/v1/form-definition/' . $id), 'PUT', 'Form definition updated.')
|
||||||
|
->text('name', 'Name', '', true, $form_def['name'])
|
||||||
|
->select('definition.email_key', 'Email Key', $email_key_options, "Form field that holds the submitter's email address.", true, $definition['email_key'] ?? '')
|
||||||
|
->textarea('definition.success_message', 'Success message', 'Shown to the visitor after a successful submission. HTML is allowed. Use <reservation-summary></reservation-summary> to display the selected reservations. Leave blank for the default message.', false, $definition['success_message'] ?? '')
|
||||||
|
->render();
|
||||||
|
?>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<h2>Form Elements</h2>
|
||||||
|
<p>Define the fields that will appear in this form.</p>
|
||||||
|
<div id="form_elements_table"></div>
|
||||||
|
<p>
|
||||||
|
<button type="button" class="button" id="rsv_add_element_btn">+ Add Element</button>
|
||||||
|
</p>
|
||||||
|
<p class="submit">
|
||||||
|
<button type="submit" form="edit_form_definition" class="button button-primary">Update Form Definition</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<?php $this->elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables); ?>
|
||||||
|
<?php
|
||||||
}
|
}
|
||||||
|
|
||||||
rsv_form_info_page();
|
private function elements_table_script(array $elements_with_ids, int $next_id, string $form_id, array $element_types, array $timetables = []): void {
|
||||||
|
$elements_json = json_encode($elements_with_ids);
|
||||||
|
$types_json = json_encode(array_values($element_types));
|
||||||
|
$timetables_json = json_encode(array_values($timetables));
|
||||||
|
?>
|
||||||
|
<script>
|
||||||
|
const rsv_element_types = <?= $types_json ?>;
|
||||||
|
const rsv_timetables = <?= $timetables_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>") ?>,
|
||||||
|
};
|
||||||
|
|
||||||
|
const rsv_elements_source = (function(initial_items, next_id_start) {
|
||||||
|
const items = initial_items;
|
||||||
|
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('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];
|
||||||
|
items[idx] = {
|
||||||
|
...extra_attrs,
|
||||||
|
id,
|
||||||
|
name: data.name ?? '',
|
||||||
|
label: data.label ?? '',
|
||||||
|
type: data.type ?? 'text',
|
||||||
|
desc: data.desc ?? '',
|
||||||
|
required: data.required === 'on',
|
||||||
|
...(data.type === 'input-text' ? {
|
||||||
|
validation: data.validation ?? '',
|
||||||
|
pattern: data.pattern ?? '',
|
||||||
|
pattern_message: data.pattern_message ?? '',
|
||||||
|
} : {}),
|
||||||
|
...(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: {
|
||||||
|
on_accepted: {
|
||||||
|
enabled: !!data.email_accepted_enabled,
|
||||||
|
subject: data.email_accepted_subject ?? RSV_EMAIL_DEFAULTS.accepted_subject,
|
||||||
|
body: data.email_accepted_body ?? RSV_EMAIL_DEFAULTS.accepted_body,
|
||||||
|
},
|
||||||
|
on_refused: {
|
||||||
|
enabled: !!data.email_refused_enabled,
|
||||||
|
subject: data.email_refused_subject ?? RSV_EMAIL_DEFAULTS.refused_subject,
|
||||||
|
body: data.email_refused_body ?? RSV_EMAIL_DEFAULTS.refused_body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
|
};
|
||||||
|
return Promise.resolve(items[idx]);
|
||||||
|
},
|
||||||
|
add() {
|
||||||
|
const item = { id: next_id++, name: '', label: '', type: 'text', desc: '', required: false };
|
||||||
|
items.push(item);
|
||||||
|
return item;
|
||||||
|
},
|
||||||
|
move_up(id) {
|
||||||
|
const idx = items.findIndex(e => e.id === id);
|
||||||
|
if (idx > 0) [items[idx - 1], items[idx]] = [items[idx], items[idx - 1]];
|
||||||
|
},
|
||||||
|
move_down(id) {
|
||||||
|
const idx = items.findIndex(e => e.id === id);
|
||||||
|
if (idx !== -1 && idx < items.length - 1) [items[idx], items[idx + 1]] = [items[idx + 1], items[idx]];
|
||||||
|
},
|
||||||
|
remove(id) {
|
||||||
|
const idx = items.findIndex(e => e.id === id);
|
||||||
|
if (idx !== -1) items.splice(idx, 1);
|
||||||
|
},
|
||||||
|
get_all() {
|
||||||
|
return items.map(({ id, ...rest }) => rest);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})(<?= $elements_json ?>, <?= $next_id ?>);
|
||||||
|
|
||||||
|
function rsv_render_element_inline_form(dt, row, data) {
|
||||||
|
const builder = RsvInlineFormBuilder.create(rsv_elements_source)
|
||||||
|
.fieldset('Element', '50%')
|
||||||
|
.input_text('name', 'Slug', data?.name ?? '')
|
||||||
|
.input_text('label', 'Label', data?.label ?? '')
|
||||||
|
.input_select('type', 'Type', rsv_element_types, data?.type ?? rsv_element_types[0])
|
||||||
|
.fieldset('Options', '50%')
|
||||||
|
.input_text('desc', 'Description', data?.desc ?? '')
|
||||||
|
.input_checkbox('required', 'Required', data?.required ?? false);
|
||||||
|
|
||||||
|
if ((data?.type ?? rsv_element_types[0]) === 'reservation') {
|
||||||
|
const accepted = data?.email_templates?.on_accepted ?? {};
|
||||||
|
const refused = data?.email_templates?.on_refused ?? {};
|
||||||
|
const timetable_options = [
|
||||||
|
{ value: '', label: '— none —' },
|
||||||
|
...rsv_timetables.map(t => ({ value: t.id, label: t.name })),
|
||||||
|
];
|
||||||
|
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 — 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)
|
||||||
|
.input_textarea('email_accepted_body', 'Body', accepted.body ?? RSV_EMAIL_DEFAULTS.accepted_body)
|
||||||
|
.fieldset('Email — refused', '100%')
|
||||||
|
.input_checkbox('email_refused_enabled', 'Send email when refused', refused.enabled ?? true)
|
||||||
|
.input_text('email_refused_subject', 'Subject', refused.subject ?? RSV_EMAIL_DEFAULTS.refused_subject)
|
||||||
|
.input_textarea('email_refused_body', 'Body', refused.body ?? RSV_EMAIL_DEFAULTS.refused_body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((data?.type ?? rsv_element_types[0]) === 'input-text') {
|
||||||
|
builder
|
||||||
|
.input_select('validation', 'Validation', [
|
||||||
|
{ value: '', label: '— none —' },
|
||||||
|
{ value: 'email', label: 'Email' },
|
||||||
|
{ value: 'phone', label: 'Phone' },
|
||||||
|
{ value: 'digits', label: 'Digits only' },
|
||||||
|
{ value: 'pattern', label: 'Custom pattern' },
|
||||||
|
], data?.validation ?? '')
|
||||||
|
.input_text('pattern', 'Custom pattern (regex)', data?.pattern ?? '')
|
||||||
|
.show_if(RsvInlineFormBuilder.match_p('validation', 'pattern'))
|
||||||
|
.input_text('pattern_message', 'Pattern error message', data?.pattern_message ?? '')
|
||||||
|
.show_if(RsvInlineFormBuilder.match_p('validation', 'pattern'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = builder.build({
|
||||||
|
id: data?.id,
|
||||||
|
colspan: 6,
|
||||||
|
save_label: 'Save',
|
||||||
|
on_success: () => elements_dt.refresh(),
|
||||||
|
on_cancel: () => elements_dt.refresh(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type swaps whole fieldsets, so re-render the inline form on change.
|
||||||
|
// Common fields are carried over; type-specific fields reset to stored data.
|
||||||
|
const type_select = node.querySelector('select[name="type"]');
|
||||||
|
if (type_select) {
|
||||||
|
type_select.addEventListener('change', () => {
|
||||||
|
const current = Object.fromEntries(new FormData(node.querySelector('form')));
|
||||||
|
const merged = {
|
||||||
|
...data,
|
||||||
|
name: current.name ?? data?.name,
|
||||||
|
label: current.label ?? data?.label,
|
||||||
|
desc: current.desc ?? data?.desc,
|
||||||
|
required: 'required' in current,
|
||||||
|
type: type_select.value,
|
||||||
|
};
|
||||||
|
row.replaceChildren(rsv_render_element_inline_form(dt, row, merged));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The elements data grid only exists on the edit page. Guard it so a missing
|
||||||
|
// container can't abort the script and strip the form's submit handler.
|
||||||
|
const elements_table_el = document.getElementById('form_elements_table');
|
||||||
|
if (elements_table_el) {
|
||||||
|
var elements_dt = RsvDataGrid.create_data_grid(
|
||||||
|
elements_table_el,
|
||||||
|
rsv_elements_source,
|
||||||
|
{
|
||||||
|
'name': RsvDataGrid.action_column('Name', 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_element_inline_form(dt, row, data));
|
||||||
|
}),
|
||||||
|
'Move Up': RsvDataGrid.func_action(function(dt, row, data) {
|
||||||
|
rsv_elements_source.move_up(data.id);
|
||||||
|
dt.refresh();
|
||||||
|
}),
|
||||||
|
'Move Down': RsvDataGrid.func_action(function(dt, row, data) {
|
||||||
|
rsv_elements_source.move_down(data.id);
|
||||||
|
dt.refresh();
|
||||||
|
}),
|
||||||
|
'Remove': RsvDataGrid.func_action(function(dt, row, data) {
|
||||||
|
rsv_elements_source.remove(data.id);
|
||||||
|
dt.refresh();
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
'label': RsvDataGrid.column('Label', false),
|
||||||
|
'type': RsvDataGrid.column('Type', false),
|
||||||
|
'desc': RsvDataGrid.column('Description', false),
|
||||||
|
'required': RsvDataGrid.column('Required', false),
|
||||||
|
'details': RsvDataGrid.column('Details', false),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
elements_dt.map_column('details', (dt, row, data) => {
|
||||||
|
const td = document.createElement('td');
|
||||||
|
if (data.type === 'reservation') {
|
||||||
|
const parts = [];
|
||||||
|
if (data.timetable_id) {
|
||||||
|
const t = rsv_timetables.find(t => t.id === data.timetable_id);
|
||||||
|
parts.push(`Timetable: ${t ? t.name : data.timetable_id}`);
|
||||||
|
}
|
||||||
|
if (data.price_per_block != null) parts.push(`Price/block: ${data.price_per_block}`);
|
||||||
|
const et = data.email_templates ?? {};
|
||||||
|
const emails = [];
|
||||||
|
if (et.on_accepted?.enabled) emails.push('accepted');
|
||||||
|
if (et.on_refused?.enabled) emails.push('refused');
|
||||||
|
if (emails.length) parts.push(`Emails: ${emails.join(', ')}`);
|
||||||
|
td.innerText = parts.join(' · ');
|
||||||
|
}
|
||||||
|
return td;
|
||||||
|
});
|
||||||
|
elements_dt.refresh();
|
||||||
|
|
||||||
|
document.getElementById('rsv_add_element_btn').onclick = function() {
|
||||||
|
rsv_elements_source.add();
|
||||||
|
elements_dt.refresh();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Email Key select offers the form's fields. Elements are edited in
|
||||||
|
// the grid above and only persisted on save, so refresh the options from
|
||||||
|
// the live source each time the select is opened.
|
||||||
|
const rsv_email_key_select = document.querySelector('#edit_form_definition select[name="definition.email_key"]');
|
||||||
|
function rsv_sync_email_key_options() {
|
||||||
|
if (!rsv_email_key_select) return;
|
||||||
|
const selected = rsv_email_key_select.value;
|
||||||
|
const options = [new Option('— select field —', '')];
|
||||||
|
for (const el of rsv_elements_source.get_all()) {
|
||||||
|
if (!el.name) continue;
|
||||||
|
options.push(new Option(el.label ? `${el.label} (${el.name})` : el.name, el.name));
|
||||||
|
}
|
||||||
|
rsv_email_key_select.replaceChildren(...options);
|
||||||
|
rsv_email_key_select.value = selected;
|
||||||
|
}
|
||||||
|
rsv_email_key_select?.addEventListener('focus', rsv_sync_email_key_options);
|
||||||
|
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
refresh: () => { if (typeof forms_dt !== 'undefined') forms_dt.refresh(); },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,53 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
|
||||||
function rsv_google_calendar_settings_page(): void {
|
class RsvGoogleCalendarSettingsPage extends RsvAdminPage {
|
||||||
if (!current_user_can(RsvCapabilities::MANAGE)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$service = new RsvGoogleCalendarService();
|
protected function render_content(): void {
|
||||||
$notice = null;
|
$service = new RsvGoogleCalendarService();
|
||||||
|
$notice = null;
|
||||||
|
|
||||||
if (isset($_GET['connected'])) {
|
if (isset($_GET['connected'])) {
|
||||||
$notice = ['type' => 'success', 'message' => 'Google Calendar connected successfully.'];
|
$notice = ['type' => 'success', 'message' => 'Google Calendar connected successfully.'];
|
||||||
}
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['rsv_google_settings_nonce'])) {
|
|
||||||
if (!wp_verify_nonce($_POST['rsv_google_settings_nonce'], 'rsv_google_settings')) {
|
|
||||||
wp_die('Security check failed.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($_POST['rsv_disconnect'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['rsv_google_settings_nonce'])) {
|
||||||
$service->disconnect();
|
if (!wp_verify_nonce($_POST['rsv_google_settings_nonce'], 'rsv_google_settings')) {
|
||||||
$notice = ['type' => 'success', 'message' => 'Disconnected from Google Calendar.'];
|
wp_die('Security check failed.');
|
||||||
} elseif (isset($_POST['rsv_register_webhook'])) {
|
|
||||||
$result = $service->register_webhook();
|
|
||||||
$notice = isset($result['id'])
|
|
||||||
? ['type' => 'success', 'message' => 'Webhook registered.']
|
|
||||||
: ['type' => 'error', 'message' => 'Webhook registration failed: ' . ($result['error'] ?? json_encode($result))];
|
|
||||||
} elseif (isset($_POST['rsv_stop_webhook'])) {
|
|
||||||
$service->stop_webhook();
|
|
||||||
$notice = ['type' => 'success', 'message' => 'Webhook stopped.'];
|
|
||||||
} else {
|
|
||||||
update_option('rsv_google_client_id', sanitize_text_field($_POST['rsv_google_client_id'] ?? ''));
|
|
||||||
update_option('rsv_google_calendar_id', sanitize_text_field($_POST['rsv_google_calendar_id'] ?? 'primary'));
|
|
||||||
// Only overwrite the (encrypted) client secret when a new value is
|
|
||||||
// supplied — the field renders blank, so otherwise saving any other
|
|
||||||
// setting would wipe the stored secret.
|
|
||||||
$client_secret = sanitize_text_field($_POST['rsv_google_client_secret'] ?? '');
|
|
||||||
if ($client_secret !== '') {
|
|
||||||
$service->set_client_secret($client_secret);
|
|
||||||
}
|
}
|
||||||
$notice = ['type' => 'success', 'message' => 'Settings saved.'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$connected = $service->is_google_connected();
|
if (isset($_POST['rsv_disconnect'])) {
|
||||||
$webhook_registered = $service->is_webhook_registered();
|
$service->disconnect();
|
||||||
$webhook_expiry = (int) get_option('rsv_google_webhook_expiration', 0);
|
$notice = ['type' => 'success', 'message' => 'Disconnected from Google Calendar.'];
|
||||||
$client_id = esc_attr(get_option('rsv_google_client_id', ''));
|
} elseif (isset($_POST['rsv_register_webhook'])) {
|
||||||
$cal_id = esc_attr(get_option('rsv_google_calendar_id', 'primary'));
|
$result = $service->register_webhook();
|
||||||
$oauth_url = esc_url($service->get_oauth_url());
|
$notice = isset($result['id'])
|
||||||
?>
|
? ['type' => 'success', 'message' => 'Webhook registered.']
|
||||||
<div class="wrap">
|
: ['type' => 'error', 'message' => 'Webhook registration failed: ' . ($result['error'] ?? json_encode($result))];
|
||||||
|
} elseif (isset($_POST['rsv_stop_webhook'])) {
|
||||||
|
$service->stop_webhook();
|
||||||
|
$notice = ['type' => 'success', 'message' => 'Webhook stopped.'];
|
||||||
|
} else {
|
||||||
|
update_option('rsv_google_client_id', sanitize_text_field($_POST['rsv_google_client_id'] ?? ''));
|
||||||
|
update_option('rsv_google_calendar_id', sanitize_text_field($_POST['rsv_google_calendar_id'] ?? 'primary'));
|
||||||
|
// Only overwrite the (encrypted) client secret when a new value is
|
||||||
|
// supplied — the field renders blank, so otherwise saving any other
|
||||||
|
// setting would wipe the stored secret.
|
||||||
|
$client_secret = sanitize_text_field($_POST['rsv_google_client_secret'] ?? '');
|
||||||
|
if ($client_secret !== '') {
|
||||||
|
$service->set_client_secret($client_secret);
|
||||||
|
}
|
||||||
|
$notice = ['type' => 'success', 'message' => 'Settings saved.'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$connected = $service->is_google_connected();
|
||||||
|
$webhook_registered = $service->is_webhook_registered();
|
||||||
|
$webhook_expiry = (int) get_option('rsv_google_webhook_expiration', 0);
|
||||||
|
$client_id = esc_attr(get_option('rsv_google_client_id', ''));
|
||||||
|
$cal_id = esc_attr(get_option('rsv_google_calendar_id', 'primary'));
|
||||||
|
$oauth_url = esc_url($service->get_oauth_url());
|
||||||
|
?>
|
||||||
<h1>Google Calendar</h1>
|
<h1>Google Calendar</h1>
|
||||||
|
|
||||||
<?php if ($notice): ?>
|
<?php if ($notice): ?>
|
||||||
@@ -127,6 +124,6 @@ function rsv_google_calendar_settings_page(): void {
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
<?php
|
||||||
<?php
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,219 +1,222 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
function rsv_reservations_page(): void {
|
class RsvReservationsPage extends RsvAdminPage {
|
||||||
?>
|
|
||||||
<h1>Form Submissions</h1>
|
|
||||||
|
|
||||||
<hr>
|
protected function render_content(): void {
|
||||||
<div id="reservations_table"></div>
|
?>
|
||||||
|
<h1>Form Submissions</h1>
|
||||||
|
|
||||||
<script>
|
<hr>
|
||||||
function rsv_fmt_utc(utc_str) {
|
<div id="reservations_table"></div>
|
||||||
if (!utc_str) return '';
|
|
||||||
return new Date(utc_str.replace(' ', 'T') + 'Z').toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function rsv_cell_value(value) {
|
<script>
|
||||||
if (value === null || value === undefined) return '';
|
function rsv_fmt_utc(utc_str) {
|
||||||
if (typeof value === 'object') return JSON.stringify(value);
|
if (!utc_str) return '';
|
||||||
return String(value);
|
return new Date(utc_str.replace(' ', 'T') + 'Z').toLocaleString();
|
||||||
}
|
|
||||||
|
|
||||||
function rsv_make_table(head_labels, rows_data, cell_fn) {
|
|
||||||
const table = document.createElement('table');
|
|
||||||
table.classList.add('wp-list-table', 'widefat', 'fixed', 'striped', 'rsv-detail-table');
|
|
||||||
|
|
||||||
const thead = document.createElement('thead');
|
|
||||||
const header_row = document.createElement('tr');
|
|
||||||
for (const label of head_labels) {
|
|
||||||
const th = document.createElement('th');
|
|
||||||
th.textContent = label;
|
|
||||||
header_row.appendChild(th);
|
|
||||||
}
|
}
|
||||||
thead.appendChild(header_row);
|
|
||||||
table.appendChild(thead);
|
|
||||||
|
|
||||||
const tbody = document.createElement('tbody');
|
function rsv_cell_value(value) {
|
||||||
for (const row_data of rows_data) {
|
if (value === null || value === undefined) return '';
|
||||||
const tr = document.createElement('tr');
|
if (typeof value === 'object') return JSON.stringify(value);
|
||||||
for (const cell of cell_fn(row_data)) {
|
return String(value);
|
||||||
const td = document.createElement('td');
|
}
|
||||||
td.textContent = cell;
|
|
||||||
tr.appendChild(td);
|
function rsv_make_table(head_labels, rows_data, cell_fn) {
|
||||||
|
const table = document.createElement('table');
|
||||||
|
table.classList.add('wp-list-table', 'widefat', 'fixed', 'striped', 'rsv-detail-table');
|
||||||
|
|
||||||
|
const thead = document.createElement('thead');
|
||||||
|
const header_row = document.createElement('tr');
|
||||||
|
for (const label of head_labels) {
|
||||||
|
const th = document.createElement('th');
|
||||||
|
th.textContent = label;
|
||||||
|
header_row.appendChild(th);
|
||||||
}
|
}
|
||||||
tbody.appendChild(tr);
|
thead.appendChild(header_row);
|
||||||
}
|
table.appendChild(thead);
|
||||||
table.appendChild(tbody);
|
|
||||||
|
|
||||||
return table;
|
const tbody = document.createElement('tbody');
|
||||||
}
|
for (const row_data of rows_data) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
function rsv_flatten_form_entries(obj, depth) {
|
for (const cell of cell_fn(row_data)) {
|
||||||
const rows = [];
|
const td = document.createElement('td');
|
||||||
for (const [key, val] of Object.entries(obj)) {
|
td.textContent = cell;
|
||||||
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
|
tr.appendChild(td);
|
||||||
rows.push({ key, value: null, depth });
|
}
|
||||||
for (const child of rsv_flatten_form_entries(val, depth + 1)) rows.push(child);
|
tbody.appendChild(tr);
|
||||||
} else if (Array.isArray(val)) {
|
|
||||||
rows.push({ key, value: null, depth });
|
|
||||||
val.forEach((item, i) => {
|
|
||||||
if (item !== null && typeof item === 'object') {
|
|
||||||
rows.push({ key: `[${i}]`, value: null, depth: depth + 1 });
|
|
||||||
for (const child of rsv_flatten_form_entries(item, depth + 2)) rows.push(child);
|
|
||||||
} else {
|
|
||||||
rows.push({ key: `[${i}]`, value: rsv_cell_value(item), depth: depth + 1 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
rows.push({ key, value: rsv_cell_value(val), depth });
|
|
||||||
}
|
}
|
||||||
}
|
table.appendChild(tbody);
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rsv_make_form_table(form_values) {
|
return table;
|
||||||
const table = document.createElement('table');
|
|
||||||
table.classList.add('wp-list-table', 'widefat', 'fixed', 'striped', 'rsv-detail-table');
|
|
||||||
|
|
||||||
const thead = document.createElement('thead');
|
|
||||||
const header_row = document.createElement('tr');
|
|
||||||
for (const label of ['Field', 'Value']) {
|
|
||||||
const th = document.createElement('th');
|
|
||||||
th.textContent = label;
|
|
||||||
header_row.appendChild(th);
|
|
||||||
}
|
|
||||||
thead.appendChild(header_row);
|
|
||||||
table.appendChild(thead);
|
|
||||||
|
|
||||||
const tbody = document.createElement('tbody');
|
|
||||||
for (const { key, value, depth } of rsv_flatten_form_entries(form_values, 0)) {
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
|
|
||||||
const td_key = document.createElement('td');
|
|
||||||
td_key.textContent = key;
|
|
||||||
td_key.classList.add('rsv-form-key');
|
|
||||||
td_key.style.setProperty('--rsv-depth', depth);
|
|
||||||
if (value === null) td_key.classList.add('rsv-form-key--group');
|
|
||||||
|
|
||||||
const td_val = document.createElement('td');
|
|
||||||
td_val.textContent = value ?? '';
|
|
||||||
if (value === null) td_val.classList.add('rsv-form-val--null');
|
|
||||||
|
|
||||||
tr.appendChild(td_key);
|
|
||||||
tr.appendChild(td_val);
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
}
|
|
||||||
table.appendChild(tbody);
|
|
||||||
|
|
||||||
return table;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rsv_render_reservation_detail(dt, row, data, detail) {
|
|
||||||
const td = document.createElement('td');
|
|
||||||
td.setAttribute('colspan', 3);
|
|
||||||
td.classList.add('rsv-detail-expand');
|
|
||||||
|
|
||||||
const form_heading = document.createElement('h4');
|
|
||||||
form_heading.textContent = 'Form Submission';
|
|
||||||
form_heading.classList.add('rsv-detail-heading');
|
|
||||||
|
|
||||||
const form_values = detail.form_values ?? {};
|
|
||||||
let form_content;
|
|
||||||
if (Object.keys(form_values).length === 0) {
|
|
||||||
form_content = document.createElement('p');
|
|
||||||
form_content.textContent = 'No form values recorded.';
|
|
||||||
form_content.classList.add('rsv-detail-empty');
|
|
||||||
} else {
|
|
||||||
form_content = rsv_make_form_table(form_values);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const timetable_heading = document.createElement('h4');
|
function rsv_flatten_form_entries(obj, depth) {
|
||||||
timetable_heading.textContent = 'Timetable Reservations';
|
const rows = [];
|
||||||
timetable_heading.classList.add('rsv-detail-heading');
|
for (const [key, val] of Object.entries(obj)) {
|
||||||
|
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
|
||||||
const timetable_rows = detail.timetable_reservations ?? [];
|
rows.push({ key, value: null, depth });
|
||||||
let timetable_content;
|
for (const child of rsv_flatten_form_entries(val, depth + 1)) rows.push(child);
|
||||||
if (timetable_rows.length === 0) {
|
} else if (Array.isArray(val)) {
|
||||||
timetable_content = document.createElement('p');
|
rows.push({ key, value: null, depth });
|
||||||
timetable_content.textContent = 'No timetable reservations.';
|
val.forEach((item, i) => {
|
||||||
timetable_content.classList.add('rsv-detail-empty');
|
if (item !== null && typeof item === 'object') {
|
||||||
} else {
|
rows.push({ key: `[${i}]`, value: null, depth: depth + 1 });
|
||||||
timetable_content = rsv_make_table(
|
for (const child of rsv_flatten_form_entries(item, depth + 2)) rows.push(child);
|
||||||
['ID', 'Timetable', 'Start', 'End'],
|
} else {
|
||||||
timetable_rows,
|
rows.push({ key: `[${i}]`, value: rsv_cell_value(item), depth: depth + 1 });
|
||||||
r => [r.id, r.timetable_id, rsv_fmt_utc(r.start), rsv_fmt_utc(r.end)]
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
|
||||||
actions.classList.add('rsv-detail-actions');
|
|
||||||
|
|
||||||
const close_btn = document.createElement('button');
|
|
||||||
close_btn.classList.add('button');
|
|
||||||
close_btn.textContent = 'Close';
|
|
||||||
close_btn.onclick = () => dt.refresh_row(row, data);
|
|
||||||
actions.appendChild(close_btn);
|
|
||||||
|
|
||||||
if (detail.pending_confirmation) {
|
|
||||||
const base_url = `<?= get_rest_url(null, 'reservations/v1/reservation'); ?>/${data.id}`;
|
|
||||||
|
|
||||||
const accept_btn = document.createElement('button');
|
|
||||||
accept_btn.classList.add('button', 'button-primary');
|
|
||||||
accept_btn.textContent = 'Accept';
|
|
||||||
accept_btn.onclick = () => {
|
|
||||||
accept_btn.disabled = true;
|
|
||||||
refuse_btn.disabled = true;
|
|
||||||
fetch(base_url + '/accept', { method: 'POST', credentials: 'same-origin' })
|
|
||||||
.then(() => dt.refresh())
|
|
||||||
.catch(() => { accept_btn.disabled = false; refuse_btn.disabled = false; });
|
|
||||||
};
|
|
||||||
|
|
||||||
const refuse_btn = document.createElement('button');
|
|
||||||
refuse_btn.classList.add('button', 'button-secondary', 'rsv-btn-refuse');
|
|
||||||
refuse_btn.textContent = 'Refuse';
|
|
||||||
refuse_btn.onclick = () => {
|
|
||||||
accept_btn.disabled = true;
|
|
||||||
refuse_btn.disabled = true;
|
|
||||||
fetch(base_url + '/refuse', { method: 'POST', credentials: 'same-origin' })
|
|
||||||
.then(() => dt.refresh())
|
|
||||||
.catch(() => { accept_btn.disabled = false; refuse_btn.disabled = false; });
|
|
||||||
};
|
|
||||||
|
|
||||||
actions.appendChild(accept_btn);
|
|
||||||
actions.appendChild(refuse_btn);
|
|
||||||
}
|
|
||||||
|
|
||||||
td.replaceChildren(form_heading, form_content, timetable_heading, timetable_content, actions);
|
|
||||||
return td;
|
|
||||||
}
|
|
||||||
|
|
||||||
var reservations_dt = RsvDataGrid.create_data_grid(
|
|
||||||
document.getElementById('reservations_table'),
|
|
||||||
RsvReservationResource(),
|
|
||||||
{
|
|
||||||
'id': RsvDataGrid.action_column('ID', false, {
|
|
||||||
'View': RsvDataGrid.func_action(function(dt, row, data) {
|
|
||||||
const url = `<?= get_rest_url(null, 'reservations/v1/reservation'); ?>/${data.id}`;
|
|
||||||
fetch(url, {
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: { 'Accept': 'application/json' },
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(detail => {
|
|
||||||
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_reservation_detail(dt, row, data, detail));
|
|
||||||
});
|
});
|
||||||
}),
|
} else {
|
||||||
}),
|
rows.push({ key, value: rsv_cell_value(val), depth });
|
||||||
'form_submit_id': RsvDataGrid.column('Form Submit', false),
|
}
|
||||||
'is_confirmed': RsvDataGrid.column('Confirmed', false),
|
}
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
reservations_dt.refresh();
|
function rsv_make_form_table(form_values) {
|
||||||
</script>
|
const table = document.createElement('table');
|
||||||
<?php
|
table.classList.add('wp-list-table', 'widefat', 'fixed', 'striped', 'rsv-detail-table');
|
||||||
|
|
||||||
|
const thead = document.createElement('thead');
|
||||||
|
const header_row = document.createElement('tr');
|
||||||
|
for (const label of ['Field', 'Value']) {
|
||||||
|
const th = document.createElement('th');
|
||||||
|
th.textContent = label;
|
||||||
|
header_row.appendChild(th);
|
||||||
|
}
|
||||||
|
thead.appendChild(header_row);
|
||||||
|
table.appendChild(thead);
|
||||||
|
|
||||||
|
const tbody = document.createElement('tbody');
|
||||||
|
for (const { key, value, depth } of rsv_flatten_form_entries(form_values, 0)) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
|
||||||
|
const td_key = document.createElement('td');
|
||||||
|
td_key.textContent = key;
|
||||||
|
td_key.classList.add('rsv-form-key');
|
||||||
|
td_key.style.setProperty('--rsv-depth', depth);
|
||||||
|
if (value === null) td_key.classList.add('rsv-form-key--group');
|
||||||
|
|
||||||
|
const td_val = document.createElement('td');
|
||||||
|
td_val.textContent = value ?? '';
|
||||||
|
if (value === null) td_val.classList.add('rsv-form-val--null');
|
||||||
|
|
||||||
|
tr.appendChild(td_key);
|
||||||
|
tr.appendChild(td_val);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
table.appendChild(tbody);
|
||||||
|
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rsv_render_reservation_detail(dt, row, data, detail) {
|
||||||
|
const td = document.createElement('td');
|
||||||
|
td.setAttribute('colspan', 3);
|
||||||
|
td.classList.add('rsv-detail-expand');
|
||||||
|
|
||||||
|
const form_heading = document.createElement('h4');
|
||||||
|
form_heading.textContent = 'Form Submission';
|
||||||
|
form_heading.classList.add('rsv-detail-heading');
|
||||||
|
|
||||||
|
const form_values = detail.form_values ?? {};
|
||||||
|
let form_content;
|
||||||
|
if (Object.keys(form_values).length === 0) {
|
||||||
|
form_content = document.createElement('p');
|
||||||
|
form_content.textContent = 'No form values recorded.';
|
||||||
|
form_content.classList.add('rsv-detail-empty');
|
||||||
|
} else {
|
||||||
|
form_content = rsv_make_form_table(form_values);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timetable_heading = document.createElement('h4');
|
||||||
|
timetable_heading.textContent = 'Timetable Reservations';
|
||||||
|
timetable_heading.classList.add('rsv-detail-heading');
|
||||||
|
|
||||||
|
const timetable_rows = detail.timetable_reservations ?? [];
|
||||||
|
let timetable_content;
|
||||||
|
if (timetable_rows.length === 0) {
|
||||||
|
timetable_content = document.createElement('p');
|
||||||
|
timetable_content.textContent = 'No timetable reservations.';
|
||||||
|
timetable_content.classList.add('rsv-detail-empty');
|
||||||
|
} else {
|
||||||
|
timetable_content = rsv_make_table(
|
||||||
|
['ID', 'Timetable', 'Start', 'End'],
|
||||||
|
timetable_rows,
|
||||||
|
r => [r.id, r.timetable_id, rsv_fmt_utc(r.start), rsv_fmt_utc(r.end)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.classList.add('rsv-detail-actions');
|
||||||
|
|
||||||
|
const close_btn = document.createElement('button');
|
||||||
|
close_btn.classList.add('button');
|
||||||
|
close_btn.textContent = 'Close';
|
||||||
|
close_btn.onclick = () => dt.refresh_row(row, data);
|
||||||
|
actions.appendChild(close_btn);
|
||||||
|
|
||||||
|
if (detail.pending_confirmation) {
|
||||||
|
const base_url = `<?= get_rest_url(null, 'reservations/v1/reservation'); ?>/${data.id}`;
|
||||||
|
|
||||||
|
const accept_btn = document.createElement('button');
|
||||||
|
accept_btn.classList.add('button', 'button-primary');
|
||||||
|
accept_btn.textContent = 'Accept';
|
||||||
|
accept_btn.onclick = () => {
|
||||||
|
accept_btn.disabled = true;
|
||||||
|
refuse_btn.disabled = true;
|
||||||
|
fetch(base_url + '/accept', { method: 'POST', credentials: 'same-origin' })
|
||||||
|
.then(() => dt.refresh())
|
||||||
|
.catch(() => { accept_btn.disabled = false; refuse_btn.disabled = false; });
|
||||||
|
};
|
||||||
|
|
||||||
|
const refuse_btn = document.createElement('button');
|
||||||
|
refuse_btn.classList.add('button', 'button-secondary', 'rsv-btn-refuse');
|
||||||
|
refuse_btn.textContent = 'Refuse';
|
||||||
|
refuse_btn.onclick = () => {
|
||||||
|
accept_btn.disabled = true;
|
||||||
|
refuse_btn.disabled = true;
|
||||||
|
fetch(base_url + '/refuse', { method: 'POST', credentials: 'same-origin' })
|
||||||
|
.then(() => dt.refresh())
|
||||||
|
.catch(() => { accept_btn.disabled = false; refuse_btn.disabled = false; });
|
||||||
|
};
|
||||||
|
|
||||||
|
actions.appendChild(accept_btn);
|
||||||
|
actions.appendChild(refuse_btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
td.replaceChildren(form_heading, form_content, timetable_heading, timetable_content, actions);
|
||||||
|
return td;
|
||||||
|
}
|
||||||
|
|
||||||
|
var reservations_dt = RsvDataGrid.create_data_grid(
|
||||||
|
document.getElementById('reservations_table'),
|
||||||
|
RsvReservationResource(),
|
||||||
|
{
|
||||||
|
'id': RsvDataGrid.action_column('ID', false, {
|
||||||
|
'View': RsvDataGrid.func_action(function(dt, row, data) {
|
||||||
|
const url = `<?= get_rest_url(null, 'reservations/v1/reservation'); ?>/${data.id}`;
|
||||||
|
fetch(url, {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(detail => {
|
||||||
|
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_reservation_detail(dt, row, data, detail));
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
'form_submit_id': RsvDataGrid.column('Form Submit', false),
|
||||||
|
'is_confirmed': RsvDataGrid.column('Confirmed', false),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
reservations_dt.refresh();
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+315
-375
@@ -1,71 +1,59 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Reservair\Forms\RsvFormBuilder;
|
use Reservair\Forms\RsvFormBuilder;
|
||||||
|
use Reservair\Layout\RsvColumnLayout;
|
||||||
|
|
||||||
|
class RsvTimetablePage extends RsvAdminPage {
|
||||||
|
|
||||||
function rsv_timetable_list_page() {
|
protected function render_content(): void {
|
||||||
?>
|
if (isset($_GET['action'])) {
|
||||||
<style>
|
if ($_GET['action'] === 'view' && isset($_GET['id'])) {
|
||||||
/*#col-left {
|
$this->show_view(intval($_GET['id']));
|
||||||
width: 30%;
|
return;
|
||||||
}*/
|
} elseif ($_GET['action'] === 'edit' && isset($_GET['id'])) {
|
||||||
|
$this->show_capacity(intval($_GET['id']));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Deletion is intentionally not handled here: a state-changing GET is
|
||||||
|
// CSRF-prone. Timetables are deleted via the nonce-authenticated REST
|
||||||
|
// DELETE /timetable/{id} (see the "Trash" action in the list grid).
|
||||||
|
}
|
||||||
|
|
||||||
/*#col-right {
|
$this->show_list();
|
||||||
width: 70%;
|
}
|
||||||
}*/
|
|
||||||
</style>
|
|
||||||
<h1>Timetables</h1>
|
|
||||||
<hr>
|
|
||||||
<div id="col-container" class="wp-clearfix">
|
|
||||||
<div id="col-left">
|
|
||||||
<div class="col-wrap">
|
|
||||||
<div class="form-wrap">
|
|
||||||
<h2>Add timetable</h2>
|
|
||||||
|
|
||||||
<?php
|
private function show_list(): void {
|
||||||
$timetable_service = new RsvTimetableService();
|
?>
|
||||||
$existing_emails = $timetable_service->get_all_maintainer_emails();
|
<h1>Timetables</h1>
|
||||||
$existing_emails_json = json_encode($existing_emails);
|
<hr>
|
||||||
?>
|
<?php
|
||||||
<datalist id="maintainer_email_suggestions">
|
$timetable_service = new RsvTimetableService();
|
||||||
<?php foreach ($existing_emails as $email): ?>
|
$existing_emails = $timetable_service->get_all_maintainer_emails();
|
||||||
<option value="<?= esc_attr($email) ?>">
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</datalist>
|
|
||||||
|
|
||||||
<form id="add_timetable_form"
|
RsvColumnLayout::split('1:2')
|
||||||
method="post"
|
->column(function () use ($existing_emails) {
|
||||||
data-method="POST"
|
echo RsvFormBuilder::create('add_timetable_form', get_rest_url(null, 'reservations/v1/timetable'), 'POST', 'Timetable created.')
|
||||||
data-success-msg="Timetable created."
|
->heading('Add timetable')
|
||||||
action="<?= get_rest_url(null, 'reservations/v1/timetable'); ?>">
|
->datalist('maintainer_email_suggestions', $existing_emails)
|
||||||
|
->text('name', 'Name', 'Name of the timetable that can be reserved.', true)
|
||||||
<?php
|
->number('block_size', 'Block length (minutes)', 'Duration of one reservable time block in minutes.', true, '', 1)
|
||||||
echo RsvFormBuilder::create('add_timetable_form')
|
->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, '', 'maintainer_email_suggestions')
|
||||||
->text('name', 'Name', 'Name of the timetable that can be reserved.', true)
|
->submit('Add Timetable')
|
||||||
->number('block_size', 'Block length (minutes)', 'Duration of one reservable time block in minutes.', true, '', 1)
|
->render();
|
||||||
->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, '', 'maintainer_email_suggestions')
|
?>
|
||||||
->submit('Add Timetable')
|
<script>
|
||||||
->render();
|
RsvAdminForm.bind(add_timetable_form, {
|
||||||
?>
|
transform: (body) => ({
|
||||||
</form>
|
name: body.name,
|
||||||
<script>
|
block_size: parseInt(body.block_size),
|
||||||
RsvAdminForm.bind(add_timetable_form, {
|
maintainer_email: body.maintainer_email || null,
|
||||||
transform: (body) => ({
|
}),
|
||||||
name: body.name,
|
refresh: () => availability_dt.refresh(),
|
||||||
block_size: parseInt(body.block_size),
|
});
|
||||||
maintainer_email: body.maintainer_email || null,
|
</script>
|
||||||
}),
|
<?php })
|
||||||
refresh: () => availability_dt.refresh(),
|
->column(function () { ?>
|
||||||
});
|
<div id="availability_table"></div>
|
||||||
</script>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="col-right">
|
|
||||||
<div class="col-wrap">
|
|
||||||
<div id="availability_table">
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<script>
|
<script>
|
||||||
var availability_dt = RsvDataGrid.create_data_grid(availability_table,
|
var availability_dt = RsvDataGrid.create_data_grid(availability_table,
|
||||||
RsvTimetableResource(), {
|
RsvTimetableResource(), {
|
||||||
@@ -83,94 +71,79 @@ function rsv_timetable_list_page() {
|
|||||||
});
|
});
|
||||||
availability_dt.refresh();
|
availability_dt.refresh();
|
||||||
</script>
|
</script>
|
||||||
</div>
|
<?php })
|
||||||
</div>
|
->output();
|
||||||
</div>
|
?>
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
|
|
||||||
function rsv_create_capacity_form($timetable_id) {
|
private function show_view(int $id): void {
|
||||||
$form = RsvFormBuilder::create("create_capacity_form", get_rest_url(null, 'reservations/v1/timetable/' . $timetable_id . '/capacity'));
|
$timetable = (new RsvTimetableService())->get($id);
|
||||||
$form->date('date', 'First Date', 'Od kterého datumu platí tato kapacita.', true, new DateTime()->format('Y-m-d'));
|
if ($timetable === null) {
|
||||||
$form->group('Availability Range', fn($g) => $g
|
echo '<div class="notice notice-error"><p>Timetable not found.</p></div>';
|
||||||
->time('start_time', 'Start')
|
return;
|
||||||
->time('end_time', 'End')
|
}
|
||||||
);
|
?>
|
||||||
$form->number('capacity', 'Capacity', 'How many reservations can overlap on the same time.', true, 1, 1);
|
<h1><?= esc_html($timetable->name) ?></h1>
|
||||||
$form->number('min_lead_time_minutes', 'Minimum lead time (minutes)', 'How many minutes in advance must be the reservation created. This is useful if it takes some time to prepare the reservation.', true);
|
<a href="<?= esc_url(menu_page_url('timetable-settings', false)) ?>">← Back to Timetables</a>
|
||||||
$form->checkbox('requires_confirmation', 'Requires Confirmation?', 'If checked, all the reservations that overlap this capacity will require confirmation from maintainer. The maintainer will receive an email asking for the confirmation.', true);
|
<hr>
|
||||||
$form->custom('Is Repeating Event', function() {
|
|
||||||
return '
|
|
||||||
<input id="is_repeating" class="regular-text" type="checkbox" name="is_repeating" checked="true">
|
|
||||||
<p>If the capacity is available repeatingly. For example: repeat each monday every week.</p>
|
|
||||||
';
|
|
||||||
});
|
|
||||||
$form->number('repeat_period_in_days', 'Repeat Period (days)', 'How many days between each repetition.', true);
|
|
||||||
$form->custom('Apply to Days', function() {
|
|
||||||
return '
|
|
||||||
<table class="option-table">
|
|
||||||
<tbody>
|
|
||||||
<tr class="form-day-names">
|
|
||||||
<td>Monday</td>
|
|
||||||
<td>Tuesday</td>
|
|
||||||
<td>Wednesday</td>
|
|
||||||
<td>Thursday</td>
|
|
||||||
<td>Friday</td>
|
|
||||||
<td>Saturday</td>
|
|
||||||
<td>Sunday</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="form-days">
|
|
||||||
<td><input class="is-repeating-input" type="checkbox" name="monday"></td>
|
|
||||||
<td><input class="is-repeating-input" type="checkbox" name="tuesday"></td>
|
|
||||||
<td><input class="is-repeating-input" type="checkbox" name="wednesday"></td>
|
|
||||||
<td><input class="is-repeating-input" type="checkbox" name="thursday"></td>
|
|
||||||
<td><input class="is-repeating-input" type="checkbox" name="friday"></td>
|
|
||||||
<td><input class="is-repeating-input" type="checkbox" name="saturday"></td>
|
|
||||||
<td><input class="is-repeating-input" type="checkbox" name="sunday"></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>';
|
|
||||||
});
|
|
||||||
$form->submit('Create Capacity', 'button-primary', 'submit');
|
|
||||||
|
|
||||||
?>
|
<table class="form-table">
|
||||||
<form
|
<tr><th>Name</th><td><?= esc_html($timetable->name) ?></td></tr>
|
||||||
id="create_capacity_form"
|
<tr><th>Block size</th><td><?= esc_html($timetable->block_size) ?> minutes</td></tr>
|
||||||
data-method="POST"
|
<?php if ($timetable->maintainer_email): ?>
|
||||||
data-success-msg="Capacity created."
|
<tr><th>Maintainer email</th><td><?= esc_html($timetable->maintainer_email) ?></td></tr>
|
||||||
action="<?= get_rest_url(null, 'reservations/v1/timetable/' . $timetable_id . '/capacity'); ?>" >
|
<?php endif; ?>
|
||||||
<?php
|
</table>
|
||||||
$form->output();
|
|
||||||
|
|
||||||
?>
|
<h2>Reservations</h2>
|
||||||
</form>
|
<div id="timetable_reservations_table"></div>
|
||||||
<?php
|
<script>
|
||||||
}
|
RsvDataGrid.create_data_grid(
|
||||||
|
timetable_reservations_table,
|
||||||
|
RsvTimetableReservationResource(<?= $id ?>),
|
||||||
|
{
|
||||||
|
'id': RsvDataGrid.column('ID', false),
|
||||||
|
'reservation_id': RsvDataGrid.action_column('Reservation', false, {
|
||||||
|
'Accept': RsvDataGrid.func_action(
|
||||||
|
(dt, row, data) => RsvReservationClient.accept(data.reservation_id).then(() => dt.refresh()),
|
||||||
|
(item) => item.pending_confirmation_id !== null
|
||||||
|
),
|
||||||
|
'Refuse': RsvDataGrid.func_action(
|
||||||
|
(dt, row, data) => RsvReservationClient.refuse(data.reservation_id).then(() => dt.refresh()),
|
||||||
|
(item) => item.pending_confirmation_id !== null
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
'start_utc': RsvDataGrid.column('Start', false),
|
||||||
|
'end_utc': RsvDataGrid.column('End', false),
|
||||||
|
'is_confirmed': RsvDataGrid.column('Status', false),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.map_column('is_confirmed', (dt, row, data) => {
|
||||||
|
const td = document.createElement('td');
|
||||||
|
td.textContent = data.pending_confirmation_id !== null ? 'Pending'
|
||||||
|
: data.is_confirmed == 1 ? 'Confirmed'
|
||||||
|
: 'Refused';
|
||||||
|
return td;
|
||||||
|
})
|
||||||
|
.refresh();
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
function rsv_timetable_capacity_view($id) {
|
private function show_capacity(int $id): void {
|
||||||
$timetable_service = new RsvTimetableService();
|
$timetable_service = new RsvTimetableService();
|
||||||
$timetable = $timetable_service->get($id);
|
$timetable = $timetable_service->get($id);
|
||||||
$gcal_service = new RsvGoogleCalendarService();
|
$gcal_service = new RsvGoogleCalendarService();
|
||||||
$gcal_connected = $gcal_service->is_google_connected();
|
$gcal_connected = $gcal_service->is_google_connected();
|
||||||
$current_calendar_id = $timetable->google_calendar_id ?? null;
|
$current_calendar_id = $timetable->google_calendar_id ?? null;
|
||||||
$existing_emails = $timetable_service->get_all_maintainer_emails();
|
$existing_emails = $timetable_service->get_all_maintainer_emails();
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<h2>Settings</h2>
|
|
||||||
|
|
||||||
<datalist id="maintainer_email_list">
|
|
||||||
<?php foreach ($existing_emails as $email): ?>
|
|
||||||
<option value="<?= esc_attr($email) ?>">
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</datalist>
|
|
||||||
|
|
||||||
<form id="timetable_settings_form"
|
|
||||||
action="<?= esc_url(get_rest_url(null, 'reservations/v1/timetable/' . $id)) ?>"
|
|
||||||
data-method="PATCH"
|
|
||||||
data-success-msg="Settings saved.">
|
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
RsvFormBuilder::create("timetable_settings_form")
|
RsvFormBuilder::create('timetable_settings_form', get_rest_url(null, 'reservations/v1/timetable/' . $id), 'PATCH', 'Settings saved.')
|
||||||
|
->heading('Settings')
|
||||||
|
->datalist('maintainer_email_suggestions', $existing_emails)
|
||||||
->text('name', 'Name', 'Name of the timetable that can be reserved.', true, $timetable->name)
|
->text('name', 'Name', 'Name of the timetable that can be reserved.', true, $timetable->name)
|
||||||
->number('block_size', 'Block length (minutes)', 'Duration of one reservable time block in minutes.', true, $timetable->block_size, 1)
|
->number('block_size', 'Block length (minutes)', 'Duration of one reservable time block in minutes.', true, $timetable->block_size, 1)
|
||||||
->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, $timetable->maintainer_email ?? '', 'maintainer_email_suggestions')
|
->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, $timetable->maintainer_email ?? '', 'maintainer_email_suggestions')
|
||||||
@@ -189,246 +162,213 @@ function rsv_timetable_capacity_view($id) {
|
|||||||
->submit('Save Settings', 'button-primary', 'submit')
|
->submit('Save Settings', 'button-primary', 'submit')
|
||||||
->output();
|
->output();
|
||||||
?>
|
?>
|
||||||
</form>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
const gcalSelect = document.getElementById('gcal_select');
|
const gcalSelect = document.getElementById('gcal_select');
|
||||||
const currentCalId = <?= json_encode($current_calendar_id) ?>;
|
const currentCalId = <?= json_encode($current_calendar_id) ?>;
|
||||||
|
|
||||||
if (gcalSelect) {
|
if (gcalSelect) {
|
||||||
fetch('<?= esc_js(get_rest_url(null, 'reservations/v1/google-calendars')) ?>', {
|
fetch('<?= esc_js(get_rest_url(null, 'reservations/v1/google-calendars')) ?>', {
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { 'X-WP-Nonce': ReservairServiceAPI.nonce },
|
headers: { 'X-WP-Nonce': ReservairServiceAPI.nonce },
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(calendars => {
|
.then(calendars => {
|
||||||
for (const cal of calendars) {
|
for (const cal of calendars) {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = cal.id;
|
opt.value = cal.id;
|
||||||
opt.textContent = cal.summary;
|
opt.textContent = cal.summary;
|
||||||
if (cal.id === currentCalId) opt.selected = true;
|
if (cal.id === currentCalId) opt.selected = true;
|
||||||
gcalSelect.appendChild(opt);
|
gcalSelect.appendChild(opt);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
gcalSelect.disabled = true;
|
gcalSelect.disabled = true;
|
||||||
gcalSelect.options[0].textContent = 'Failed to load calendars';
|
gcalSelect.options[0].textContent = 'Failed to load calendars';
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
RsvAdminForm.bind(timetable_settings_form);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h2>Capacity</h2>
|
|
||||||
|
|
||||||
<p>Define capacities for timetable.</p>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
|
|
||||||
rsv_create_capacity_form($id);
|
|
||||||
|
|
||||||
?>
|
|
||||||
<script>
|
|
||||||
(function() {
|
|
||||||
const DAY_DOW = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 0 };
|
|
||||||
|
|
||||||
function nearest_weekday(base_str, dow) {
|
|
||||||
const d = new Date(base_str + 'T00:00:00');
|
|
||||||
const diff = (dow - d.getDay() + 7) % 7;
|
|
||||||
d.setDate(d.getDate() + diff);
|
|
||||||
const y = d.getFullYear();
|
|
||||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(d.getDate()).padStart(2, '0');
|
|
||||||
return `${y}-${m}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
RsvAdminForm.bind(document.getElementById('create_capacity_form'), {
|
|
||||||
// Expand one form submission into one capacity row per selected weekday.
|
|
||||||
transform: (body, form) => {
|
|
||||||
const fd = new FormData(form);
|
|
||||||
|
|
||||||
const is_repeating = fd.get('is_repeating') !== null;
|
|
||||||
const repeat_period_in_days = is_repeating
|
|
||||||
? parseInt(fd.get('repeat_period_in_days')) * parseInt(fd.get('repeat_period_multiplier'))
|
|
||||||
: 1;
|
|
||||||
const repeat_times = is_repeating ? parseInt(fd.get('repeat_times')) : 0;
|
|
||||||
|
|
||||||
const common = {
|
|
||||||
start_time: time_to_minutes(fd.get('start_time')),
|
|
||||||
end_time: time_to_minutes(fd.get('end_time')),
|
|
||||||
capacity: parseInt(fd.get('capacity')),
|
|
||||||
min_lead_time_minutes: parseInt(fd.get('min_lead_time_minutes')),
|
|
||||||
requires_confirmation: fd.get('requires_confirmation') !== null,
|
|
||||||
repeat_period_in_days,
|
|
||||||
repeat_times,
|
|
||||||
};
|
|
||||||
|
|
||||||
const base_date = fd.get('date');
|
|
||||||
const selected_days = Object.keys(DAY_DOW).filter(day => fd.get(day) !== null);
|
|
||||||
return selected_days.length > 0
|
|
||||||
? selected_days.map(day => ({ ...common, date: nearest_weekday(base_date, DAY_DOW[day]) }))
|
|
||||||
: [{ ...common, date: base_date }];
|
|
||||||
},
|
|
||||||
refresh: () => capacity_dt.refresh(),
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div id="capacity_table"></div>
|
|
||||||
<script>
|
|
||||||
function minutes_to_time(minutes) {
|
|
||||||
const h = Math.floor(minutes / 60).toString().padStart(2, '0');
|
|
||||||
const m = (minutes % 60).toString().padStart(2, '0');
|
|
||||||
return `${h}:${m}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function time_to_minutes(time_str) {
|
|
||||||
const [h, m] = time_str.split(':').map(Number);
|
|
||||||
return h * 60 + m;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rsv_render_capacity_inline_form(dt, row, data) {
|
|
||||||
const resource_with_time_conversion = {
|
|
||||||
...dt.resource,
|
|
||||||
put(id, form_data) {
|
|
||||||
return dt.resource.put(id, {
|
|
||||||
...form_data,
|
|
||||||
start_time: time_to_minutes(form_data.start_time),
|
|
||||||
end_time: time_to_minutes(form_data.end_time),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return RsvInlineFormBuilder.create(resource_with_time_conversion)
|
|
||||||
.input_hidden('min_lead_time_minutes', 0)
|
|
||||||
.fieldset('Datum a čas', '50%')
|
|
||||||
.input_date('date', 'Datum', data?.date ?? '')
|
|
||||||
.input_time('start_time', 'Začátek', minutes_to_time(data?.start_time ?? 0))
|
|
||||||
.input_time('end_time', 'Konec', minutes_to_time(data?.end_time ?? 0))
|
|
||||||
.fieldset('Kapacita a opakování', '50%')
|
|
||||||
.input_number('capacity', 'Kapacita', data?.capacity ?? '')
|
|
||||||
.input_number('repeat_period_in_days', 'Dnů mezi opakováním', data?.repeat_period_in_days ?? '')
|
|
||||||
.input_number('repeat_times', 'Počet opakování', data?.repeat_times ?? '')
|
|
||||||
.input_checkbox('requires_confirmation', 'Vyžaduje potvrzení', data?.requires_confirmation == 1)
|
|
||||||
.build({
|
|
||||||
id: data?.id,
|
|
||||||
colspan: 8,
|
|
||||||
save_label: 'Aktualizovat',
|
|
||||||
on_success: () => capacity_dt.refresh(),
|
|
||||||
on_cancel: () => capacity_dt.refresh(),
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
var capacity_dt = RsvDataGrid.create_data_grid(capacity_table,
|
|
||||||
RsvTimetableCapacityResource(<?= $id ?>),
|
|
||||||
{
|
|
||||||
'id': RsvDataGrid.column('ID', false),
|
|
||||||
'date': RsvDataGrid.action_column('Datum', false, {
|
|
||||||
'Edit': RsvDataGrid.edit_action((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_capacity_inline_form(dt, row, data));
|
|
||||||
}),
|
|
||||||
'Trash': RsvDataGrid.func_action((dt, row, data) => {
|
|
||||||
dt.resource.delete(data.id).then(() => dt.refresh());
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
'start_time': RsvDataGrid.column('Začátek', false),
|
|
||||||
'end_time': RsvDataGrid.column('Konec', false),
|
|
||||||
'capacity': RsvDataGrid.column('Kapacita', false),
|
|
||||||
'repeat_period_in_days': RsvDataGrid.column('Dnů mezi opakováním', false),
|
|
||||||
'repeat_times': RsvDataGrid.column('Počet opakování', false),
|
|
||||||
'requires_confirmation': RsvDataGrid.column('Vyžaduje potvrzení', false),
|
|
||||||
}, );
|
|
||||||
capacity_dt.map_column('start_time', (dt, row, data) => {
|
|
||||||
const td = document.createElement('td');
|
|
||||||
td.innerText = minutes_to_time(data.start_time);
|
|
||||||
return td;
|
|
||||||
});
|
|
||||||
capacity_dt.map_column('end_time', (dt, row, data) => {
|
|
||||||
const td = document.createElement('td');
|
|
||||||
td.innerText = minutes_to_time(data.end_time);
|
|
||||||
return td;
|
|
||||||
});
|
|
||||||
|
|
||||||
capacity_dt.refresh();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
}
|
|
||||||
|
|
||||||
function rsv_timetable_view_page(int $id): void {
|
|
||||||
$timetable = (new RsvTimetableService())->get($id);
|
|
||||||
if ($timetable === null) {
|
|
||||||
echo '<div class="notice notice-error"><p>Timetable not found.</p></div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<h1><?= esc_html($timetable->name) ?></h1>
|
|
||||||
<a href="<?= esc_url(menu_page_url('timetable-settings', false)) ?>">← Back to Timetables</a>
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<table class="form-table">
|
|
||||||
<tr><th>Name</th><td><?= esc_html($timetable->name) ?></td></tr>
|
|
||||||
<tr><th>Block size</th><td><?= esc_html($timetable->block_size) ?> minutes</td></tr>
|
|
||||||
<?php if ($timetable->maintainer_email): ?>
|
|
||||||
<tr><th>Maintainer email</th><td><?= esc_html($timetable->maintainer_email) ?></td></tr>
|
|
||||||
<?php endif; ?>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2>Reservations</h2>
|
|
||||||
<div id="timetable_reservations_table"></div>
|
|
||||||
<script>
|
|
||||||
RsvDataGrid.create_data_grid(
|
|
||||||
timetable_reservations_table,
|
|
||||||
RsvTimetableReservationResource(<?= $id ?>),
|
|
||||||
{
|
|
||||||
'id': RsvDataGrid.column('ID', false),
|
|
||||||
'reservation_id': RsvDataGrid.action_column('Reservation', false, {
|
|
||||||
'Accept': RsvDataGrid.func_action(
|
|
||||||
(dt, row, data) => RsvReservationClient.accept(data.reservation_id).then(() => dt.refresh()),
|
|
||||||
(item) => item.pending_confirmation_id !== null
|
|
||||||
),
|
|
||||||
'Refuse': RsvDataGrid.func_action(
|
|
||||||
(dt, row, data) => RsvReservationClient.refuse(data.reservation_id).then(() => dt.refresh()),
|
|
||||||
(item) => item.pending_confirmation_id !== null
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
'start_utc': RsvDataGrid.column('Start', false),
|
|
||||||
'end_utc': RsvDataGrid.column('End', false),
|
|
||||||
'is_confirmed': RsvDataGrid.column('Status', false),
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
.map_column('is_confirmed', (dt, row, data) => {
|
|
||||||
const td = document.createElement('td');
|
|
||||||
td.textContent = data.pending_confirmation_id !== null ? 'Pending'
|
|
||||||
: data.is_confirmed == 1 ? 'Confirmed'
|
|
||||||
: 'Refused';
|
|
||||||
return td;
|
|
||||||
})
|
|
||||||
.refresh();
|
|
||||||
</script>
|
|
||||||
<?php
|
|
||||||
}
|
|
||||||
|
|
||||||
function rsv_timetable_edit_page($id) {
|
RsvAdminForm.bind(timetable_settings_form);
|
||||||
rsv_timetable_capacity_view($id);
|
})();
|
||||||
}
|
</script>
|
||||||
|
|
||||||
|
<h2>Capacity</h2>
|
||||||
|
|
||||||
function rsv_timetable_page() {
|
<p>Define capacities for timetable.</p>
|
||||||
if (isset($_GET['action'])) {
|
|
||||||
if ($_GET['action'] === 'view' && isset($_GET['id'])) {
|
<?php $this->create_capacity_form($id); ?>
|
||||||
rsv_timetable_view_page(intval($_GET['id']));
|
|
||||||
return;
|
<script>
|
||||||
} else if($_GET['action'] === 'edit' && isset($_GET['id'])) {
|
(function() {
|
||||||
rsv_timetable_edit_page(intval($_GET['id']));
|
const DAY_DOW = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 0 };
|
||||||
return;
|
|
||||||
}
|
function nearest_weekday(base_str, dow) {
|
||||||
// Deletion is intentionally not handled here: a state-changing GET is
|
const d = new Date(base_str + 'T00:00:00');
|
||||||
// CSRF-prone. Timetables are deleted via the nonce-authenticated REST
|
const diff = (dow - d.getDay() + 7) % 7;
|
||||||
// DELETE /timetable/{id} (see the "Trash" action in the list grid).
|
d.setDate(d.getDate() + diff);
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
RsvAdminForm.bind(document.getElementById('create_capacity_form'), {
|
||||||
|
// Expand one form submission into one capacity row per selected weekday.
|
||||||
|
transform: (body, form) => {
|
||||||
|
const fd = new FormData(form);
|
||||||
|
|
||||||
|
const is_repeating = fd.get('is_repeating') !== null;
|
||||||
|
const repeat_period_in_days = is_repeating
|
||||||
|
? parseInt(fd.get('repeat_period_in_days')) * parseInt(fd.get('repeat_period_multiplier'))
|
||||||
|
: 1;
|
||||||
|
const repeat_times = is_repeating ? parseInt(fd.get('repeat_times')) : 0;
|
||||||
|
|
||||||
|
const common = {
|
||||||
|
start_time: time_to_minutes(fd.get('start_time')),
|
||||||
|
end_time: time_to_minutes(fd.get('end_time')),
|
||||||
|
capacity: parseInt(fd.get('capacity')),
|
||||||
|
min_lead_time_minutes: parseInt(fd.get('min_lead_time_minutes')),
|
||||||
|
requires_confirmation: fd.get('requires_confirmation') !== null,
|
||||||
|
repeat_period_in_days,
|
||||||
|
repeat_times,
|
||||||
|
};
|
||||||
|
|
||||||
|
const base_date = fd.get('date');
|
||||||
|
const selected_days = Object.keys(DAY_DOW).filter(day => fd.get(day) !== null);
|
||||||
|
return selected_days.length > 0
|
||||||
|
? selected_days.map(day => ({ ...common, date: nearest_weekday(base_date, DAY_DOW[day]) }))
|
||||||
|
: [{ ...common, date: base_date }];
|
||||||
|
},
|
||||||
|
refresh: () => capacity_dt.refresh(),
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id="capacity_table"></div>
|
||||||
|
<script>
|
||||||
|
function minutes_to_time(minutes) {
|
||||||
|
const h = Math.floor(minutes / 60).toString().padStart(2, '0');
|
||||||
|
const m = (minutes % 60).toString().padStart(2, '0');
|
||||||
|
return `${h}:${m}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function time_to_minutes(time_str) {
|
||||||
|
const [h, m] = time_str.split(':').map(Number);
|
||||||
|
return h * 60 + m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rsv_render_capacity_inline_form(dt, row, data) {
|
||||||
|
const resource_with_time_conversion = {
|
||||||
|
...dt.resource,
|
||||||
|
put(id, form_data) {
|
||||||
|
return dt.resource.put(id, {
|
||||||
|
...form_data,
|
||||||
|
start_time: time_to_minutes(form_data.start_time),
|
||||||
|
end_time: time_to_minutes(form_data.end_time),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return RsvInlineFormBuilder.create(resource_with_time_conversion)
|
||||||
|
.input_hidden('min_lead_time_minutes', 0)
|
||||||
|
.fieldset('Datum a čas', '50%')
|
||||||
|
.input_date('date', 'Datum', data?.date ?? '')
|
||||||
|
.input_time('start_time', 'Začátek', minutes_to_time(data?.start_time ?? 0))
|
||||||
|
.input_time('end_time', 'Konec', minutes_to_time(data?.end_time ?? 0))
|
||||||
|
.fieldset('Kapacita a opakování', '50%')
|
||||||
|
.input_number('capacity', 'Kapacita', data?.capacity ?? '')
|
||||||
|
.input_number('repeat_period_in_days', 'Dnů mezi opakováním', data?.repeat_period_in_days ?? '')
|
||||||
|
.input_number('repeat_times', 'Počet opakování', data?.repeat_times ?? '')
|
||||||
|
.input_checkbox('requires_confirmation', 'Vyžaduje potvrzení', data?.requires_confirmation == 1)
|
||||||
|
.build({
|
||||||
|
id: data?.id,
|
||||||
|
colspan: 8,
|
||||||
|
save_label: 'Aktualizovat',
|
||||||
|
on_success: () => capacity_dt.refresh(),
|
||||||
|
on_cancel: () => capacity_dt.refresh(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var capacity_dt = RsvDataGrid.create_data_grid(capacity_table,
|
||||||
|
RsvTimetableCapacityResource(<?= $id ?>),
|
||||||
|
{
|
||||||
|
'id': RsvDataGrid.column('ID', false),
|
||||||
|
'date': RsvDataGrid.action_column('Datum', false, {
|
||||||
|
'Edit': RsvDataGrid.edit_action((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_capacity_inline_form(dt, row, data));
|
||||||
|
}),
|
||||||
|
'Trash': RsvDataGrid.func_action((dt, row, data) => {
|
||||||
|
dt.resource.delete(data.id).then(() => dt.refresh());
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
'start_time': RsvDataGrid.column('Začátek', false),
|
||||||
|
'end_time': RsvDataGrid.column('Konec', false),
|
||||||
|
'capacity': RsvDataGrid.column('Kapacita', false),
|
||||||
|
'repeat_period_in_days': RsvDataGrid.column('Dnů mezi opakováním', false),
|
||||||
|
'repeat_times': RsvDataGrid.column('Počet opakování', false),
|
||||||
|
'requires_confirmation': RsvDataGrid.column('Vyžaduje potvrzení', false),
|
||||||
|
}, );
|
||||||
|
capacity_dt.map_column('start_time', (dt, row, data) => {
|
||||||
|
const td = document.createElement('td');
|
||||||
|
td.innerText = minutes_to_time(data.start_time);
|
||||||
|
return td;
|
||||||
|
});
|
||||||
|
capacity_dt.map_column('end_time', (dt, row, data) => {
|
||||||
|
const td = document.createElement('td');
|
||||||
|
td.innerText = minutes_to_time(data.end_time);
|
||||||
|
return td;
|
||||||
|
});
|
||||||
|
|
||||||
|
capacity_dt.refresh();
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
}
|
}
|
||||||
|
|
||||||
rsv_timetable_list_page();
|
private function create_capacity_form(int $timetable_id): void {
|
||||||
|
$form = RsvFormBuilder::create('create_capacity_form', get_rest_url(null, 'reservations/v1/timetable/' . $timetable_id . '/capacity'), 'POST', 'Capacity created.');
|
||||||
|
$form->date('date', 'First Date', 'Od kterého datumu platí tato kapacita.', true, new DateTime()->format('Y-m-d'));
|
||||||
|
$form->group('Availability Range', fn($g) => $g
|
||||||
|
->time('start_time', 'Start')
|
||||||
|
->time('end_time', 'End')
|
||||||
|
);
|
||||||
|
$form->number('capacity', 'Capacity', 'How many reservations can overlap on the same time.', true, 1, 1);
|
||||||
|
$form->number('min_lead_time_minutes', 'Minimum lead time (minutes)', 'How many minutes in advance must be the reservation created. This is useful if it takes some time to prepare the reservation.', true);
|
||||||
|
$form->checkbox('requires_confirmation', 'Requires Confirmation?', 'If checked, all the reservations that overlap this capacity will require confirmation from maintainer. The maintainer will receive an email asking for the confirmation.', true);
|
||||||
|
$form->custom('Is Repeating Event', function() {
|
||||||
|
return '
|
||||||
|
<input id="is_repeating" class="regular-text" type="checkbox" name="is_repeating" checked="true">
|
||||||
|
<p>If the capacity is available repeatingly. For example: repeat each monday every week.</p>
|
||||||
|
';
|
||||||
|
});
|
||||||
|
$form->number('repeat_period_in_days', 'Repeat Period (days)', 'How many days between each repetition.', true);
|
||||||
|
$form->custom('Apply to Days', function() {
|
||||||
|
return '
|
||||||
|
<table class="option-table">
|
||||||
|
<tbody>
|
||||||
|
<tr class="form-day-names">
|
||||||
|
<td>Monday</td>
|
||||||
|
<td>Tuesday</td>
|
||||||
|
<td>Wednesday</td>
|
||||||
|
<td>Thursday</td>
|
||||||
|
<td>Friday</td>
|
||||||
|
<td>Saturday</td>
|
||||||
|
<td>Sunday</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="form-days">
|
||||||
|
<td><input class="is-repeating-input" type="checkbox" name="monday"></td>
|
||||||
|
<td><input class="is-repeating-input" type="checkbox" name="tuesday"></td>
|
||||||
|
<td><input class="is-repeating-input" type="checkbox" name="wednesday"></td>
|
||||||
|
<td><input class="is-repeating-input" type="checkbox" name="thursday"></td>
|
||||||
|
<td><input class="is-repeating-input" type="checkbox" name="friday"></td>
|
||||||
|
<td><input class="is-repeating-input" type="checkbox" name="saturday"></td>
|
||||||
|
<td><input class="is-repeating-input" type="checkbox" name="sunday"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>';
|
||||||
|
});
|
||||||
|
$form->submit('Create Capacity', 'button-primary', 'submit');
|
||||||
|
|
||||||
|
$form->output();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ class Db {
|
|||||||
|
|
||||||
public static function get_results(string $sql, array $params = [], string $output = OBJECT): array {
|
public static function get_results(string $sql, array $params = [], string $output = OBJECT): array {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$rows = $wpdb->get_results(empty($params) ? $sql : $wpdb->prepare($sql, $params), $output);
|
$query = empty($params) ? $sql : $wpdb->prepare($sql, $params);
|
||||||
|
error_log($query);
|
||||||
|
$rows = $wpdb->get_results($query, $output);
|
||||||
self::throw_if_error();
|
self::throw_if_error();
|
||||||
return $rows ?? [];
|
return $rows ?? [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,24 +5,34 @@ namespace Reservair\Forms;
|
|||||||
/**
|
/**
|
||||||
* Fluent builder for WordPress admin settings forms.
|
* Fluent builder for WordPress admin settings forms.
|
||||||
*
|
*
|
||||||
* Renders a <table class="form-table"> with one row per field.
|
* Renders a self-contained "form-wrap" card: an optional heading and a <form>
|
||||||
* Hidden inputs and datalist elements are emitted before the table;
|
* wrapping a <table class="form-table"> with one row per field. Hidden inputs
|
||||||
* notices before, submit button after.
|
* and datalist elements are emitted before the table; notices before the card,
|
||||||
|
* submit button after the fields.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* echo RsvFormBuilder::create()
|
* echo RsvFormBuilder::create('settings', $action, 'PATCH', 'Saved.')
|
||||||
|
* ->heading('Settings')
|
||||||
* ->text('name', 'Name', required: true)
|
* ->text('name', 'Name', required: true)
|
||||||
* ->email('email', 'Email')
|
* ->email('email', 'Email')
|
||||||
* ->submit('Save');
|
* ->submit('Save');
|
||||||
*/
|
*/
|
||||||
class RsvFormBuilder
|
class RsvFormBuilder
|
||||||
{
|
{
|
||||||
private string $form_id = "";
|
private string $form_id;
|
||||||
|
|
||||||
|
/** Where the form submits, and how RsvAdminForm should send it. */
|
||||||
|
private string $action;
|
||||||
|
private string $rest_method;
|
||||||
|
private string $success_msg;
|
||||||
|
|
||||||
|
/** Optional heading shown inside the card. */
|
||||||
|
private string $heading = '';
|
||||||
|
|
||||||
/** @var string[] Rendered before the table (hidden inputs, datalists). */
|
/** @var string[] Rendered before the table (hidden inputs, datalists). */
|
||||||
private array $before = [];
|
private array $before = [];
|
||||||
|
|
||||||
/** @var string[] WP admin notice banners rendered before the table. */
|
/** @var string[] WP admin notice banners rendered before the card. */
|
||||||
private array $notices = [];
|
private array $notices = [];
|
||||||
|
|
||||||
/** @var string[] <tr> elements inside the table. */
|
/** @var string[] <tr> elements inside the table. */
|
||||||
@@ -33,9 +43,29 @@ class RsvFormBuilder
|
|||||||
|
|
||||||
private function __construct() {}
|
private function __construct() {}
|
||||||
|
|
||||||
public static function create(string $id): static
|
/**
|
||||||
|
* @param string $rest_method Verb sent via data-method (POST, PUT, PATCH…).
|
||||||
|
* @param string $success_msg Message RsvAdminForm shows on success.
|
||||||
|
*/
|
||||||
|
public static function create(
|
||||||
|
string $id,
|
||||||
|
string $action,
|
||||||
|
string $rest_method = 'POST',
|
||||||
|
string $success_msg = ''
|
||||||
|
): static {
|
||||||
|
$builder = new static();
|
||||||
|
$builder->form_id = $id;
|
||||||
|
$builder->action = $action;
|
||||||
|
$builder->rest_method = $rest_method;
|
||||||
|
$builder->success_msg = $success_msg;
|
||||||
|
return $builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Heading shown inside the card, above the fields. */
|
||||||
|
public function heading(string $text): static
|
||||||
{
|
{
|
||||||
return new static();
|
$this->heading = $text;
|
||||||
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -206,6 +236,13 @@ class RsvFormBuilder
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** WordPress nonce field, emitted inside the form before the table. */
|
||||||
|
public function nonce(string $action, string $name): static
|
||||||
|
{
|
||||||
|
$this->before[] = wp_nonce_field($action, $name, true, false);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <datalist> element for email/text suggestions — emitted before the table.
|
* <datalist> element for email/text suggestions — emitted before the table.
|
||||||
*
|
*
|
||||||
@@ -257,15 +294,27 @@ class RsvFormBuilder
|
|||||||
|
|
||||||
public function render(): string
|
public function render(): string
|
||||||
{
|
{
|
||||||
$html = implode('', $this->notices);
|
$inner = implode('', $this->before);
|
||||||
$html .= implode('', $this->before);
|
|
||||||
|
|
||||||
if (!empty($this->rows)) {
|
if (!empty($this->rows)) {
|
||||||
$html .= '<table class="form-table"><tbody>' . implode('', $this->rows) . '</tbody></table>';
|
$inner .= '<table class="form-table"><tbody>' . implode('', $this->rows) . '</tbody></table>';
|
||||||
}
|
}
|
||||||
|
|
||||||
$html .= implode('', $this->after);
|
$inner .= implode('', $this->after);
|
||||||
return $html;
|
|
||||||
|
$success = $this->success_msg !== '' ? ' data-success-msg="' . esc_attr($this->success_msg) . '"' : '';
|
||||||
|
$form = '<form id="' . esc_attr($this->form_id) . '"'
|
||||||
|
. ' action="' . esc_url($this->action) . '"'
|
||||||
|
. ' method="post"'
|
||||||
|
. ' data-method="' . esc_attr($this->rest_method) . '"'
|
||||||
|
. $success . '>'
|
||||||
|
. $inner
|
||||||
|
. '</form>';
|
||||||
|
|
||||||
|
$heading = $this->heading !== '' ? '<h2>' . esc_html($this->heading) . '</h2>' : '';
|
||||||
|
|
||||||
|
return implode('', $this->notices)
|
||||||
|
. '<div class="form-wrap">' . $heading . $form . '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function output(): void
|
public function output(): void
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Reservair\Layout;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fluent builder for multi-column admin page layouts.
|
||||||
|
*
|
||||||
|
* Lets a page declare the shape it wants (e.g. a two-column split) without
|
||||||
|
* committing to any markup or styling. Each column's content is supplied as a
|
||||||
|
* callable that echoes — inline HTML and <script> blocks work as usual.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* RsvColumnLayout::split('1:2')
|
||||||
|
* ->column(function () { ?>
|
||||||
|
* <h2>Add timetable</h2>
|
||||||
|
* ...
|
||||||
|
* <?php })
|
||||||
|
* ->column(function () { ?>
|
||||||
|
* <div id="availability_table"></div>
|
||||||
|
* ...
|
||||||
|
* <?php })
|
||||||
|
* ->output();
|
||||||
|
*/
|
||||||
|
class RsvColumnLayout
|
||||||
|
{
|
||||||
|
/** @var list<callable():void> Column content, in left-to-right order. */
|
||||||
|
private array $columns = [];
|
||||||
|
|
||||||
|
/** @var list<int> Relative column weights, parsed from the ratio. */
|
||||||
|
private array $weights;
|
||||||
|
|
||||||
|
private function __construct(string $ratio)
|
||||||
|
{
|
||||||
|
$this->weights = array_map('intval', explode(':', $ratio));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Side-by-side columns that stack on narrow screens.
|
||||||
|
*
|
||||||
|
* @param string $ratio Relative column widths, e.g. '1:1', '1:2'.
|
||||||
|
*/
|
||||||
|
public static function split(string $ratio = '1:1'): static
|
||||||
|
{
|
||||||
|
return new static($ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds the next column. $render echoes the column's content. */
|
||||||
|
public function column(callable $render): static
|
||||||
|
{
|
||||||
|
$this->columns[] = $render;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): string
|
||||||
|
{
|
||||||
|
$cols = '';
|
||||||
|
foreach ($this->columns as $i => $render) {
|
||||||
|
$grow = $this->weights[$i] ?? end($this->weights) ?: 1;
|
||||||
|
$cols .= '<div class="rsv-col" style="--rsv-col-grow:' . (int) $grow . '">'
|
||||||
|
. $this->capture($render)
|
||||||
|
. '</div>';
|
||||||
|
}
|
||||||
|
return '<div class="rsv-cols">' . $cols . '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function output(): void
|
||||||
|
{
|
||||||
|
echo $this->render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs an echoing callable and returns what it printed. */
|
||||||
|
private function capture(callable $render): string
|
||||||
|
{
|
||||||
|
ob_start();
|
||||||
|
$render();
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Reservair\Templating\Elements;
|
||||||
|
|
||||||
|
use Reservair\Templating\RsvTemplateElement;
|
||||||
|
use Reservair\Templating\RsvTemplateSymbols;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders accept / refuse action buttons for a reservation, for the maintainer
|
||||||
|
* approval email. The links come from the template's accept_url and refuse_url
|
||||||
|
* symbols; the button labels can be overridden with the accept-label and
|
||||||
|
* refuse-label attributes.
|
||||||
|
*/
|
||||||
|
class RsvReservationActionsElement implements RsvTemplateElement {
|
||||||
|
|
||||||
|
/** Inline styles, since email clients ignore stylesheets. */
|
||||||
|
private const string ACCEPT_STYLE = 'display:inline-block;padding:10px 18px;margin-right:8px;background:#2e7d32;color:#ffffff;text-decoration:none;border-radius:4px;font-weight:bold;';
|
||||||
|
private const string REFUSE_STYLE = 'display:inline-block;padding:10px 18px;background:#c62828;color:#ffffff;text-decoration:none;border-radius:4px;font-weight:bold;';
|
||||||
|
|
||||||
|
public function render(RsvTemplateSymbols $symbols): string {
|
||||||
|
$accept_url = (string) $symbols->get('accept_url', '');
|
||||||
|
$refuse_url = (string) $symbols->get('refuse_url', '');
|
||||||
|
if ($accept_url === '' && $refuse_url === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$accept_label = (string) $symbols->get('accept-label', 'Přijmout');
|
||||||
|
$refuse_label = (string) $symbols->get('refuse-label', 'Odmítnout');
|
||||||
|
|
||||||
|
return '<a href="' . esc_url($accept_url) . '" style="' . self::ACCEPT_STYLE . '">' . esc_html($accept_label) . '</a>'
|
||||||
|
. '<a href="' . esc_url($refuse_url) . '" style="' . self::REFUSE_STYLE . '">' . esc_html($refuse_label) . '</a>';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function symbols(): array {
|
||||||
|
return ['accept-label', 'refuse-label'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Reservair\Templating\Elements;
|
||||||
|
|
||||||
|
use Reservair\Templating\RsvTemplateElement;
|
||||||
|
use Reservair\Templating\RsvTemplateSymbols;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits the client-side summary placeholder. The browser-side RsvFormSender
|
||||||
|
* locates this div after form submission and fills it with the visitor's
|
||||||
|
* selected time slots.
|
||||||
|
*/
|
||||||
|
class RsvReservationSummaryElement implements RsvTemplateElement {
|
||||||
|
|
||||||
|
public function render(RsvTemplateSymbols $symbols): string {
|
||||||
|
return '<div class="rsv-success-summary"></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function symbols(): array {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Reservair\Templating\Elements;
|
||||||
|
|
||||||
|
use Reservair\Templating\RsvTemplateElement;
|
||||||
|
use Reservair\Templating\RsvTemplateSymbols;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a button that asks the form to clear itself. It only carries the
|
||||||
|
* data-rsv-reset marker; RsvFormSender finds marked buttons in the success card
|
||||||
|
* and links them to the form's cleanup. The label can be overridden with the
|
||||||
|
* label attribute.
|
||||||
|
*/
|
||||||
|
class RsvResetFormButtonElement implements RsvTemplateElement {
|
||||||
|
|
||||||
|
public function render(RsvTemplateSymbols $symbols): string {
|
||||||
|
$label = (string) $symbols->get('label', 'Odeslat znova');
|
||||||
|
return '<button type="button" class="rsv-form-btn" data-rsv-reset>'
|
||||||
|
. esc_html($label)
|
||||||
|
. '</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function symbols(): array {
|
||||||
|
return ['label'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Templating
|
||||||
|
|
||||||
|
Templates allow to replace place values of symbols in text written in one language. Templating itself requires a language. Therefore the process combines two languages.
|
||||||
|
|
||||||
|
This plugin uses templates for success messages of forms & emails. The usage might expand in the future. That is one of the quality attributes of this requirement.
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
As both use cases are using HTML (one is an email and another a webpage), the templates in this plugin also uses HTML. The frontend for HTML is `RsvHtmlTemplateParser`, that transforms it into an internal structure. The input language can be changed by writing a new frontend for the compiler in the future.
|
||||||
|
|
||||||
|
The main advantage of HTML is that it can be easily extended with custom elements. But we do note that traditional custom elements using JS cannot be used, because JS would not work like that in emails for example.
|
||||||
|
|
||||||
|
Extensions and the plugin itself can register custom elements to templates, for example `<reservation-summary>` that auto-expands onto a nice summary of the selected time slots.
|
||||||
|
|
||||||
|
Atomic values and strings can be retrieved from submitted data using JSON Path RFC using this syntax: `{{ path }}`. The reason for JSON Path is the simplicity of implementation. We are careful not to overcomplicate the templating language, for a price of being less powerful. Helm language being the example of what not to do.
|
||||||
|
|
||||||
|
The language can be easily validated for symbols existence and validity.
|
||||||
|
|
||||||
|
## Custom elements
|
||||||
|
|
||||||
|
The Templating module calls `rsv-template-register-custom-elements` to register custom elements to templates. Plugins can subscribe to it and in the handler register their own custom elements.
|
||||||
|
|
||||||
|
Custom element has a symbol table with values in the input and outputs string. If the custom element has attributes, like `<custom-element attr="test">`, the symbol `attr` with value `test` will also be added to the symbol table.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Reservair\Templating;
|
||||||
|
|
||||||
|
/** A registered handler for a hyphenated custom element tag. */
|
||||||
|
interface RsvTemplateElement {
|
||||||
|
/** Renders the element to a trusted HTML string given the resolved symbol table. */
|
||||||
|
public function render(RsvTemplateSymbols $symbols): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the symbol names this element understands — used by validation to
|
||||||
|
* report missing or extra attributes.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function symbols(): array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Reservair\Templating;
|
||||||
|
|
||||||
|
use Reservair\Logger\Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders templates in two phases: first it substitutes {{ path }} interpolations
|
||||||
|
* with values from the data, then it walks the HTML and expands the registered
|
||||||
|
* custom elements. The public entry point for the module.
|
||||||
|
*/
|
||||||
|
class RsvTemplateEngine {
|
||||||
|
|
||||||
|
public readonly RsvTemplateRegistry $registry;
|
||||||
|
|
||||||
|
public function __construct(?RsvTemplateRegistry $registry = null) {
|
||||||
|
$this->registry = $registry ?? new RsvTemplateRegistry();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders $source as HTML. Interpolated scalar values are HTML-escaped;
|
||||||
|
* custom elements emit their own trusted markup.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function render(string $source, array $data = []): string {
|
||||||
|
try {
|
||||||
|
return $this->expand_elements(
|
||||||
|
$this->interpolate($source, $data, escape: true),
|
||||||
|
$data,
|
||||||
|
);
|
||||||
|
} catch (RsvTemplateException $e) {
|
||||||
|
throw $e;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Logger::error($e);
|
||||||
|
throw new RsvTemplateException('Template render failed: ' . $e->getMessage(), 0, $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders $source for a plain-text context such as an email subject: values
|
||||||
|
* are substituted without HTML-escaping and all tags are stripped.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function render_plain(string $source, array $data = []): string {
|
||||||
|
return trim(wp_strip_all_tags($this->interpolate($source, $data, escape: false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists a template's problems without rendering it (empty = valid): empty
|
||||||
|
* interpolations, unregistered custom elements, and attributes an element
|
||||||
|
* does not declare.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function validate(string $source): array {
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
if (preg_match_all('/{{\s*([^}]*?)\s*}}/', $source, $matches)) {
|
||||||
|
foreach ($matches[1] as $path) {
|
||||||
|
if (trim($path) === '') {
|
||||||
|
$errors[] = 'Empty interpolation: {{ }}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->custom_elements($this->load($source)) as $element) {
|
||||||
|
$handler = $this->registry->get($element->tagName);
|
||||||
|
if ($handler === null) {
|
||||||
|
$errors[] = "Unregistered custom element: <{$element->tagName}>";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$allowed = $handler->symbols();
|
||||||
|
foreach ($this->attributes($element) as $name => $value) {
|
||||||
|
if (!in_array($name, $allowed, true)) {
|
||||||
|
$errors[] = "Unknown attribute \"{$name}\" on <{$element->tagName}>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Phase 1 — interpolation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
private function interpolate(string $source, array $data, bool $escape): string {
|
||||||
|
return preg_replace_callback('/{{\s*([^}]+?)\s*}}/', function (array $match) use ($data, $escape): string {
|
||||||
|
$value = $this->resolve($match[1], $data);
|
||||||
|
if (!is_scalar($value)) {
|
||||||
|
return ''; // null and non-scalar (arrays/objects) render empty
|
||||||
|
}
|
||||||
|
$string = (string) $value;
|
||||||
|
return $escape ? esc_html($string) : $string;
|
||||||
|
}, $source) ?? $source;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Phase 2 — custom element expansion
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces each registered custom element with its handler's output. The
|
||||||
|
* element is swapped for a unique comment marker, the document is serialised,
|
||||||
|
* and the markers are substituted for the (trusted) handler strings — so the
|
||||||
|
* output is never re-escaped by the serialiser.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
private function expand_elements(string $html, array $data): string {
|
||||||
|
// Skip the DOM round-trip unless a hyphenated tag could match a handler.
|
||||||
|
if ($this->registry->all() === [] || !preg_match('/<[a-z][a-z0-9]*-/i', $html)) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dom = $this->load($html);
|
||||||
|
$nonce = 'rsv-' . bin2hex(random_bytes(6));
|
||||||
|
$replacements = [];
|
||||||
|
|
||||||
|
foreach ($this->custom_elements($dom) as $i => $element) {
|
||||||
|
$handler = $this->registry->get($element->tagName);
|
||||||
|
if ($handler === null) {
|
||||||
|
continue; // leave unknown custom elements in place
|
||||||
|
}
|
||||||
|
$token = "{$nonce}-{$i}";
|
||||||
|
$replacements["<!--{$token}-->"] = $handler->render(
|
||||||
|
new RsvTemplateSymbols(array_merge($data, $this->attributes($element)))
|
||||||
|
);
|
||||||
|
$element->parentNode?->replaceChild($dom->createComment($token), $element);
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtr($this->serialize($dom), $replacements);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// JSON Path resolver
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a minimal JSON Path expression against $data.
|
||||||
|
*
|
||||||
|
* Supported forms: bare key, $.key, $.a.b, $.items[0], $['key'].
|
||||||
|
* Returns null for a missing path; the renderer emits an empty string for null.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
private function resolve(string $path, array $data): mixed {
|
||||||
|
$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
|
||||||
|
if (!is_array($current) || !array_key_exists($token, $current)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$current = $current[$token];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// DOM helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private function load(string $html): \DOMDocument {
|
||||||
|
$dom = new \DOMDocument();
|
||||||
|
$prev = libxml_use_internal_errors(true);
|
||||||
|
// The XML encoding hint forces UTF-8; NOIMPLIED/NODEFDTD keep the fragment
|
||||||
|
// free of the synthetic <html>/<body>/doctype wrappers libxml adds.
|
||||||
|
$dom->loadHTML(
|
||||||
|
'<?xml encoding="UTF-8">' . $html,
|
||||||
|
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD,
|
||||||
|
);
|
||||||
|
libxml_clear_errors();
|
||||||
|
libxml_use_internal_errors($prev);
|
||||||
|
|
||||||
|
// Drop the encoding hint, which libxml leaves behind as a stray node.
|
||||||
|
foreach (iterator_to_array($dom->childNodes) as $node) {
|
||||||
|
if ($node instanceof \DOMProcessingInstruction
|
||||||
|
|| ($node instanceof \DOMComment && str_contains((string) $node->nodeValue, 'xml encoding'))) {
|
||||||
|
$dom->removeChild($node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every hyphenated element in document order — registered or not.
|
||||||
|
*
|
||||||
|
* @return list<\DOMElement>
|
||||||
|
*/
|
||||||
|
private function custom_elements(\DOMDocument $dom): array {
|
||||||
|
$elements = [];
|
||||||
|
foreach ((new \DOMXPath($dom))->query('//*[contains(local-name(), "-")]') as $node) {
|
||||||
|
if ($node instanceof \DOMElement) {
|
||||||
|
$elements[] = $node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
|
private function attributes(\DOMElement $element): array {
|
||||||
|
$attributes = [];
|
||||||
|
if ($element->hasAttributes()) {
|
||||||
|
foreach ($element->attributes as $attribute) {
|
||||||
|
$attributes[$attribute->name] = $attribute->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serialize(\DOMDocument $dom): string {
|
||||||
|
$html = '';
|
||||||
|
foreach ($dom->childNodes as $child) {
|
||||||
|
$html .= $dom->saveHTML($child);
|
||||||
|
}
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Reservair\Templating;
|
||||||
|
|
||||||
|
class RsvTemplateException extends \RuntimeException {}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Reservair\Templating;
|
||||||
|
|
||||||
|
/** Holds the mapping from custom element tag names to their handlers. */
|
||||||
|
class RsvTemplateRegistry {
|
||||||
|
/** @var array<string, RsvTemplateElement> */
|
||||||
|
private array $elements = [];
|
||||||
|
|
||||||
|
public function register(string $tag, RsvTemplateElement $element): void {
|
||||||
|
$this->elements[$tag] = $element;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $tag): ?RsvTemplateElement {
|
||||||
|
return $this->elements[$tag] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, RsvTemplateElement> */
|
||||||
|
public function all(): array {
|
||||||
|
return $this->elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends a wp_kses allowlist so the registered custom elements survive
|
||||||
|
* sanitization of admin HTML. Each element contributes its tag and the
|
||||||
|
* attributes it declares via symbols().
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $base A wp_kses allowed-html map to extend.
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function kses_allowed(array $base = []): array {
|
||||||
|
foreach ($this->elements as $tag => $element) {
|
||||||
|
$attributes = [];
|
||||||
|
foreach ($element->symbols() as $name) {
|
||||||
|
$attributes[$name] = true;
|
||||||
|
}
|
||||||
|
$base[$tag] = $attributes;
|
||||||
|
}
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Reservair\Templating;
|
||||||
|
|
||||||
|
/** Immutable symbol table passed to custom-element handlers. */
|
||||||
|
class RsvTemplateSymbols {
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
public function __construct(private readonly array $data) {}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function all(): array {
|
||||||
|
return $this->data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $name, mixed $default = null): mixed {
|
||||||
|
return $this->data[$name] ?? $default;
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-1
@@ -1,4 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Reservair\Templating\Elements\RsvReservationSummaryElement;
|
||||||
|
use Reservair\Templating\Elements\RsvReservationActionsElement;
|
||||||
|
use Reservair\Templating\Elements\RsvResetFormButtonElement;
|
||||||
/**
|
/**
|
||||||
* Plugin Name: Reservair
|
* Plugin Name: Reservair
|
||||||
* Description: A reservation and booking system for WordPress. Site visitors browse available time slots and submit reservation requests via a Gutenberg block; administrators manage timetables, services, forms, and reservations from the WordPress admin panel.
|
* Description: A reservation and booking system for WordPress. Site visitors browse available time slots and submit reservation requests via a Gutenberg block; administrators manage timetables, services, forms, and reservations from the WordPress admin panel.
|
||||||
@@ -29,7 +33,7 @@ register_activation_hook( __FILE__, [ 'RsvInstaller', 'install' ] );
|
|||||||
* plugins we might interact with) is fully loaded.
|
* plugins we might interact with) is fully loaded.
|
||||||
*/
|
*/
|
||||||
function rsv_bootstrap(): void {
|
function rsv_bootstrap(): void {
|
||||||
global $rsv_form_registry;
|
global $rsv_form_registry, $rsv_template_registry;
|
||||||
|
|
||||||
// Re-grant the custom capability after a plugin *update* (the activation hook
|
// Re-grant the custom capability after a plugin *update* (the activation hook
|
||||||
// only runs on activate). No-op once the stored version matches.
|
// only runs on activate). No-op once the stored version matches.
|
||||||
@@ -47,6 +51,15 @@ function rsv_bootstrap(): void {
|
|||||||
$rsv_form_registry->register( 'button', new RsvButtonElementHandler() );
|
$rsv_form_registry->register( 'button', new RsvButtonElementHandler() );
|
||||||
$rsv_form_registry->register( 'reservation', new RsvFormReservationElementHandler() );
|
$rsv_form_registry->register( 'reservation', new RsvFormReservationElementHandler() );
|
||||||
$rsv_form_registry->register( 'output-reservation-summary', new RsvReservationSummaryElementHandler() );
|
$rsv_form_registry->register( 'output-reservation-summary', new RsvReservationSummaryElementHandler() );
|
||||||
|
|
||||||
|
// 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() );
|
||||||
|
} );
|
||||||
|
$rsv_template_registry = new \Reservair\Templating\RsvTemplateRegistry();
|
||||||
|
do_action( 'rsv-template-register-custom-elements', $rsv_template_registry );
|
||||||
}
|
}
|
||||||
|
|
||||||
add_action( 'plugins_loaded', 'rsv_bootstrap' );
|
add_action( 'plugins_loaded', 'rsv_bootstrap' );
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// The client bundle (custom elements, form sender, client styles) is loaded
|
||||||
|
// separately under the shared `rsv-client` handle — see rsv_enqueue_admin_assets.
|
||||||
|
// Bundling it here too would run customElements.define() twice in the editor.
|
||||||
|
import '../assets/css/RsvAdminStyle.css';
|
||||||
|
|
||||||
|
import { RsvDataGrid } from '../assets/js/elements/RsvDatagrid.js';
|
||||||
|
import { RsvInlineFormBuilder } from '../assets/js/forms/RsvInlineFormBuilder.js';
|
||||||
|
import './components/admin.js';
|
||||||
|
import { RsvAdminForm } from '../assets/js/forms/RsvAdminForm.js';
|
||||||
|
import { RsvReservationResource } from '../assets/js/datasource/RsvReservationResource.js';
|
||||||
|
import { RsvFormDefinitionResource } from '../assets/js/datasource/RsvFormDefinitionResource.js';
|
||||||
|
import { RsvTimetableResource } from '../assets/js/datasource/RsvTimetableResource.js';
|
||||||
|
import { RsvTimetableCapacityResource } from '../assets/js/datasource/RsvTimetableCapacityResource.js';
|
||||||
|
import { RsvTimetableReservationResource } from '../assets/js/datasource/RsvTimetableReservationResource.js';
|
||||||
|
import { RsvReservationClient } from '../assets/js/datasource/RsvReservationClient.js';
|
||||||
|
|
||||||
|
window.RsvDataGrid = RsvDataGrid;
|
||||||
|
window.RsvInlineFormBuilder = RsvInlineFormBuilder;
|
||||||
|
window.RsvAdminForm = RsvAdminForm;
|
||||||
|
window.RsvReservationResource = RsvReservationResource;
|
||||||
|
window.RsvFormDefinitionResource = RsvFormDefinitionResource;
|
||||||
|
window.RsvTimetableResource = RsvTimetableResource;
|
||||||
|
window.RsvTimetableCapacityResource = RsvTimetableCapacityResource;
|
||||||
|
window.RsvTimetableReservationResource = RsvTimetableReservationResource;
|
||||||
|
window.RsvReservationClient = RsvReservationClient;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import '../assets/css/RsvMainStyle.css';
|
||||||
|
import '../assets/css/components/RsvCalendarStyles.css';
|
||||||
|
import '../assets/css/components/RsvFormStyles.css';
|
||||||
|
import '../assets/css/components/RsvFormSummaryStyles.css';
|
||||||
|
import '../assets/css/components/RsvTimeSlotsStyles.css';
|
||||||
|
|
||||||
|
import '../assets/js/elements/RsvTimeline.js';
|
||||||
|
import '../assets/js/elements/RsvReservationSelector.js';
|
||||||
|
import '../assets/js/elements/RsvReservationSummary.js';
|
||||||
|
|
||||||
|
import { RsvReservationResource } from '../assets/js/datasource/RsvReservationResource.js';
|
||||||
|
import { RsvFormDefinitionResource } from '../assets/js/datasource/RsvFormDefinitionResource.js';
|
||||||
|
import { RsvTimetableResource } from '../assets/js/datasource/RsvTimetableResource.js';
|
||||||
|
import { RsvTimetableCapacityResource } from '../assets/js/datasource/RsvTimetableCapacityResource.js';
|
||||||
|
import { RsvTimetableReservationResource } from '../assets/js/datasource/RsvTimetableReservationResource.js';
|
||||||
|
import { RsvReservationClient } from '../assets/js/datasource/RsvReservationClient.js';
|
||||||
|
import { RsvFormEncoder } from '../assets/js/forms/RsvFormEncoder.js';
|
||||||
|
import { RsvFormSender } from '../assets/js/forms/RsvFormSender.js';
|
||||||
|
|
||||||
|
window.RsvFormSender = RsvFormSender;
|
||||||
+1
-137
@@ -1,97 +1,3 @@
|
|||||||
async function fetch_reservations_to_confirm(object_id) {
|
|
||||||
const url = `/wordpress/wp-json/reservations/v1/object/${object_id}/timetable/reservation/unconfirmed`;
|
|
||||||
return await fetch(url, {
|
|
||||||
method: 'GET'
|
|
||||||
}).then(x => x.json());
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirm_reservation(confirmation_code) {
|
|
||||||
const url = `/wordpress/wp-json/reservations/v1/accept/${confirmation_code}`;
|
|
||||||
const x = await fetch(url, {
|
|
||||||
method: 'GET'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refuse_reservation(confirmation_code) {
|
|
||||||
const url = `/wordpress/wp-json/reservations/v1/refuse/${confirmation_code}`;
|
|
||||||
const x = await fetch(url, {
|
|
||||||
method: 'GET'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function render_unconfirmed_reservations_table(self) {
|
|
||||||
const reservations = await fetch_reservations_to_confirm(self.object_id);
|
|
||||||
const rows = reservations.map(reservation => {
|
|
||||||
const row = document.createElement('tr');
|
|
||||||
row.innerHTML = `
|
|
||||||
<td>${reservation.date}</td>
|
|
||||||
<td>${get_format_time(new Date(`${reservation.date}T${reservation.start}`))}</td>
|
|
||||||
<td>${get_format_time(add_minutes(new Date(`${reservation.date}T${reservation.start}`), parseInt(reservation.num_minutes)))}</td>
|
|
||||||
<td>${reservation.num_minutes} min</td>
|
|
||||||
<td>${reservation.email}</td>
|
|
||||||
`;
|
|
||||||
|
|
||||||
let td = document.createElement('td');
|
|
||||||
let confirm_button = document.createElement('button');
|
|
||||||
confirm_button.classList.add('button');
|
|
||||||
confirm_button.classList.add('button-primary');
|
|
||||||
confirm_button.onclick = function() {
|
|
||||||
confirm_reservation(reservation.confirmation_code)
|
|
||||||
.then(x => self.refresh());
|
|
||||||
};
|
|
||||||
confirm_button.innerText = "Confirm";
|
|
||||||
td.appendChild(confirm_button);
|
|
||||||
|
|
||||||
let refuse_button = document.createElement('button');
|
|
||||||
refuse_button.classList.add('button');
|
|
||||||
refuse_button.classList.add('button-secondary');
|
|
||||||
refuse_button.onclick = function() {
|
|
||||||
confirm_reservation(reservation.confirmation_code)
|
|
||||||
.then(x => self.refresh());
|
|
||||||
};
|
|
||||||
refuse_button.innerText = "Refuse";
|
|
||||||
td.appendChild(refuse_button);
|
|
||||||
row.appendChild(td);
|
|
||||||
|
|
||||||
return row;
|
|
||||||
});
|
|
||||||
self.body.replaceChildren(...rows);
|
|
||||||
}
|
|
||||||
|
|
||||||
function create_unconfirmed_reservations(object_id, container) {
|
|
||||||
let table = document.createElement('table');
|
|
||||||
table.classList.add('widefat');
|
|
||||||
|
|
||||||
let header = document.createElement('thead');
|
|
||||||
header.innerHTML = `
|
|
||||||
<tr>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>From</th>
|
|
||||||
<th>To</th>
|
|
||||||
<th>Length</th>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
|
|
||||||
table.appendChild(header);
|
|
||||||
let body = document.createElement('tbody');
|
|
||||||
|
|
||||||
table.appendChild(body);
|
|
||||||
|
|
||||||
container.appendChild(table);
|
|
||||||
|
|
||||||
return {
|
|
||||||
object_id: object_id,
|
|
||||||
container: container,
|
|
||||||
body: body,
|
|
||||||
refresh() {
|
|
||||||
render_unconfirmed_reservations_table(this);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function create_notice(id, type, mesg) {
|
function create_notice(id, type, mesg) {
|
||||||
let container = document.createElement('div');
|
let container = document.createElement('div');
|
||||||
container.id = id;
|
container.id = id;
|
||||||
@@ -100,50 +6,8 @@ function create_notice(id, type, mesg) {
|
|||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
function show_notice(target, type, mesg) {
|
export function show_notice(target, type, mesg) {
|
||||||
target.querySelectorAll('.notice').forEach(x => x.remove());
|
target.querySelectorAll('.notice').forEach(x => x.remove());
|
||||||
const notice = create_notice('test', type, mesg);
|
const notice = create_notice('test', type, mesg);
|
||||||
target.prepend(notice);
|
target.prepend(notice);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function error_handler(error) {
|
|
||||||
if(error.body != null && error.body.message != null) {
|
|
||||||
show_notice(error.target, 'error', error.body.message);
|
|
||||||
}
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function delete_action(id) {
|
|
||||||
return await fetch(get_rest_url(`action/${id}`), {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function collect_selected_actions(target) {
|
|
||||||
return Array.from(target.querySelectorAll('input[type="checkbox"].action-selector:checked')).map(x =>
|
|
||||||
x.value
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function delete_object(id) {
|
|
||||||
return await fetch(get_rest_url(`object/${id}`), {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function set_object_actions(id, actions) {
|
|
||||||
return await fetch(get_rest_url(`object/${id}/action`), {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(actions)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function inline_edit_row() {
|
|
||||||
let row = document.createElement('tr');
|
|
||||||
row.classList.add('iedit author-self level-0 post-1 type-post status-publish format-standard hentry category-uncategorized');
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
const defaultConfig = require('@wordpress/scripts/config/webpack.config');
|
||||||
|
|
||||||
|
const defaultEntry = defaultConfig.entry;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
...defaultConfig,
|
||||||
|
entry: async () => {
|
||||||
|
const entries = typeof defaultEntry === 'function'
|
||||||
|
? await defaultEntry()
|
||||||
|
: defaultEntry;
|
||||||
|
return {
|
||||||
|
...entries,
|
||||||
|
client: './src/client.js',
|
||||||
|
admin: './src/admin.js',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user