This commit is contained in:
Martin Slachta
2026-06-11 19:03:29 +02:00
commit 0d829845c4
150 changed files with 38582 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules
build
vendor/
dist
# Editors
.claude/
.idea/
.pytest_cache/
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "/phpactor.schema.json",
"language_server_psalm.enabled": true
}
+6
View File
@@ -0,0 +1,6 @@
{
"core": "WordPress/WordPress",
"plugins": [
"."
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"languages": {
"Python": {
"language_servers": ["pyright"],
"settings": {
"python": {
"pythonPath": "tests/rsv-tests/.venv/bin/python"
}
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
[
{
"label": "Run python file",
"command": "python3",
"args": ["$ZED_FILE"],
"use_new_terminal": false,
"env": {
"PYTHONPATH": "."
}
}
]
+222
View File
@@ -0,0 +1,222 @@
# Architecture
This file contains description of the software architecture. Before starting any development, consult this file.
The plugin is made out of a Gutenberg widget that communicates with the backend using endpoints handled by PHP. The endpoints are defined in the *controllers*, that handle everything between the request and creating a application specific models to pass down to the application. It hides all the details about network communication.
## Forms
The main component of the plugin are the *forms*. The administrator defines information that the user submits to them. There are two objects, the *definition* and *submission*. The administrator makes the definition: list of fields & elements the form is made out of. The user fills in the information and submits the form.
### Submitting forms
Use POST endpoint `forms/{form_id}` to submit form. The `RsvFormProcessor` will validate the form and call handler for each element in the definition.
## JavaScript
The JavaScript is mostly used for frontend components and slightly for the administration editor of the Wordpress widget. Each JS file and symbol in global scope must have the prefix `Rsv` or `rsv`. Each component should be encapsulated within it's own `const` object in separate file. The root directory is `assets/js/components/`.
The other JavaScript files are split between user-space and admin-space. Example of admin-space component is Wordpress-looking datagrid. Example of user-space component is calendar.
The JavaScript code should always use a resource object for communicating with the backend REST API. See **Data Layer** below.
### Error handling
## Data Layer
All REST API communication goes through the data layer in `assets/js/datasource/`.
### RsvDataSource
`RsvDataSource` is a factory object with a single method:
```js
RsvDataSource.create_rsv_resource(base_url, { nonce })
```
It returns a resource object that wraps `fetch` and provides a consistent CRUD interface:
| Method | Signature | HTTP |
|---|---|---|
| `get_page` | `(skip = 0, limit = 20, params = {})` | `GET base_url?skip=…&limit=…` |
| `get` | `(id)` | `GET base_url/{id}` |
| `post` | `(data)` | `POST base_url` |
| `put` | `(id, data)` | `PUT base_url/{id}` |
| `delete` | `(id)` | `DELETE base_url/{id}` |
Every method returns a `Promise`. Non-2xx responses reject with an `Error`. A `204 No Content` response resolves to `null`.
When a `nonce` is supplied, it is sent as the `X-WP-Nonce` header, which satisfies WordPress REST API authentication.
### Resource files
Each REST endpoint has its own factory function in `assets/js/datasource/`. The factory calls `RsvDataSource.create_rsv_resource` and fills in the endpoint path using the `ReservairServiceAPI.restUrl` global (injected by PHP via `wp_localize_script`).
| File | Factory | Endpoint |
|---|---|---|
| `RsvReservationResource.js` | `RsvReservationResource()` | `/reservation` |
| `RsvFormDefinitionResource.js` | `RsvFormDefinitionResource()` | `/form-definition` |
| `RsvTimetableResource.js` | `RsvTimetableResource()` | `/timetable` |
| `RsvTimetableCapacityResource.js` | `RsvTimetableCapacityResource(id)` | `/timetable/{id}/capacity` |
To add a new endpoint, create a new file following the same pattern and register it in `includes/RsvAssetsDefinition.php`.
## Admin REST Forms
Admin pages that write to the REST API follow a standard pattern to keep the code consistent and easy to extend.
### HTML
Use a `<form>` element with three data attributes:
```html
<form
id="my_form"
action="<?= esc_url(get_rest_url(null, 'reservations/v1/some-resource')) ?>"
data-method="POST"
data-success-msg="Record created.">
<!-- inputs with name= attributes -->
<button type="submit" class="button button-primary">Save</button>
</form>
```
- `action` — the full REST endpoint URL (use `get_rest_url` + `esc_url`).
- `data-method` — HTTP verb (`POST`, `PATCH`, `PUT`, `DELETE`). Standard HTML forms only support GET/POST; this attribute carries the real verb for the JS layer.
- `data-success-msg` — human-readable message passed to `show_notice` on success.
Fields that should not appear as literal HTML inputs (e.g. a `<select>` populated asynchronously) still use `name=` so `FormData` picks them up automatically at submit time.
### JavaScript
Attach a `submit` listener that reads everything it needs from the form element itself:
```javascript
document.getElementById('my_form').addEventListener('submit', function(event) {
event.preventDefault();
const form = event.currentTarget;
const fd = new FormData(form);
fetch(form.action, {
method: form.dataset.method,
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': ReservairServiceAPI.nonce,
},
body: JSON.stringify({
some_field: fd.get('some_field') || null,
// ...
}),
})
.then(response => {
if (!response.ok) return response.json().then(err => { throw new Error(err.error || 'Request failed'); });
show_notice(form, 'success', form.dataset.successMsg);
})
.catch(err => show_notice(form, 'error', err.message));
});
```
Rules:
- Always call `event.preventDefault()` first.
- Read the endpoint from `form.action` and the verb from `form.dataset.method` — never hard-code them in the handler.
- Pass `X-WP-Nonce: ReservairServiceAPI.nonce` on every authenticated request.
- Use `show_notice(form, 'success'|'error', message)` for user feedback — do not roll custom status spans.
- Any async pre-population (e.g. fetching a list of calendars to fill a `<select>`) runs independently of the submit listener and writes its results into named `<select>` / hidden `<input>` elements so `FormData` picks them up transparently.
---
## Forms
The plugin allows administrators to define & display forms on pages as a widget. The definitions is an ordered list of element definitions in a JSON format. The required attributes for each element are `name`, `label` and `is_required`. The optional are `description`.
To define new element, create a PHP class in `includes/Services/Forms/` like `RsvFormHelloElementHandler` that derives from `RsvFormElementHandler` and implement all the abstract methods. The `RsvFormHandler` validates and commits the forms.
The form values are saved as JSON with keys for the input elements.
## PHP
### Controllers
Builds the REST API. Should not assume anything about the application and mainly just pass built domain object to the application layer. Each controller manages a single resource and defines operations that the user can do with the object.
---
## Permissions & Capabilities
All administrative functionality — both the REST API and the admin pages — is gated by a **single custom capability**, and every REST route declares its intended audience through a small policy class. There are two source-of-truth symbols; nothing else should appear in authorization code:
| Symbol | File | Purpose |
|---|---|---|
| `RsvCapabilities::MANAGE` (`'manage_reservations'`) | `includes/RsvCapabilities.php` | The capability that authorises managing reservation data. |
| `RsvRestPolicy::admin()` / `RsvRestPolicy::open()` | `includes/Controllers/RsvRestPolicy.php` | The `permission_callback` tiers every route uses. |
> **Rule:** never write `'manage_options'` or `'__return_true'` in this plugin. Routes go through `RsvRestPolicy`; menus and page-level checks use `RsvCapabilities::MANAGE`. This keeps the whole authorization surface greppable and consistent.
### The capability
`manage_reservations` is a **custom** capability (not WordPress's built-in `manage_options`) so that reservation management can later be granted to a non-admin role without also handing over the whole site. By default it is granted only to the `administrator` role.
### Lifecycle
WordPress only runs the activation hook on *activate*, never on a plugin *update* — so granting the cap solely on activation would silently lock admins out after an update. `RsvCapabilities` handles all three moments:
| Moment | Call | Effect |
|---|---|---|
| Activation | `RsvInstaller::install()``RsvCapabilities::ensure()` | Grants the cap to the default roles. |
| Every request | `rsv_bootstrap()``RsvCapabilities::ensure()` | Self-heals after an update. No-op once the stored version matches. |
| Uninstall | `uninstall.php``RsvCapabilities::revoke()` | Removes the cap from every role and clears the marker. |
`ensure()` is idempotent and guarded by the `rsv_caps_version` option compared against `RsvCapabilities::VERSION`. **Bump `VERSION` whenever the capability set changes** so existing installs re-grant on their next request. Because `ensure()` runs on every load, admins never need to manually reactivate the plugin.
### Authorization tiers
Each route's `permission_callback` is one of:
| Tier | Callback | Means |
|---|---|---|
| **admin** | `[RsvRestPolicy::class, 'admin']` | Caller must have `manage_reservations`. Returns a `WP_Error` (401 logged-out / 403 under-privileged) otherwise. |
| **open** | `[RsvRestPolicy::class, 'open']` | Either genuinely public (form submission, availability lookups) **or** a *capability URL* whose secret is validated inside the handler. |
A **capability URL** is a public endpoint authorised by an unguessable secret in the request rather than by session auth — e.g. the maintainer accept/refuse links (`/timetable-reservation/accept|refuse/{code}`), the Google webhook (secret channel id in headers), and the OAuth callback. Each is marked `open()` with a comment naming where its secret is checked.
> **Rule:** any `open()` route that is not fully public **must** authorise its caller from the request inside the handler. `open()` performs no check itself.
### Using a capability
**In a REST route** (`register_rest_route`):
```php
register_rest_route($this->namespace, '/' . $this->resource_name, [
'methods' => 'POST',
'callback' => [$this, 'create'],
'permission_callback' => [RsvRestPolicy::class, 'admin'], // or 'open'
]);
```
**In the admin menu** (`add_menu_page` / `add_submenu_page`) — pass the constant as the capability argument:
```php
add_menu_page('Reservations', 'Reservations', RsvCapabilities::MANAGE, 'reservations-settings', 'rsv_reservations_page');
```
**In a page-level / inline check:**
```php
if (!current_user_can(RsvCapabilities::MANAGE)) {
return;
}
```
### Adding a new capability
The current design hardcodes the single `MANAGE` capability for the `administrator` role. To introduce a second (e.g. a read-only `view_reservations` for an `editor`):
1. **Declare it** as a constant on `RsvCapabilities` (`public const VIEW = 'view_reservations';`).
2. **Grant it** in `RsvCapabilities::ensure()` and **remove it** in `revoke()`. Once there is more than one capability, replace the single-cap logic with a `capability => [roles]` map and iterate it in both methods, so the two stay in sync.
3. **Bump `RsvCapabilities::VERSION`** so `ensure()` re-applies the new grant on existing installs at their next request.
4. **Expose a tier** for it: add a method to `RsvRestPolicy` (e.g. `viewer()` checking `current_user_can(RsvCapabilities::VIEW)`), following the `admin()` pattern (return `true` or a `WP_Error` with `rest_authorization_required_code()`).
5. **Reference it** from the relevant routes, menu entries, and page checks — never the literal string.
+13
View File
@@ -0,0 +1,13 @@
SHELL := /bin/bash
.PHONY: help build clean
help: ## Show available targets
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
build: ## Build the installable plugin ZIP into dist/
@./bin/build-zip.sh
clean: ## Remove build artifacts (dist/)
@rm -rf dist
+75
View File
@@ -0,0 +1,75 @@
# Reservair
A WordPress plugin for managing reservations and bookings. Visitors submit reservation requests through a Gutenberg block; administrators manage everything through a dedicated admin panel.
## Requirements
- WordPress 6.7+
- PHP 7.4+
- Composer
- Node.js / npm
## Admin Menu
| Page | Slug | Description |
|---|---|---|
| Reservations (root) | `reservations-settings` | Services — the reservable resources |
| Timetables | `timetable-settings` | Time-block schedules with capacity and repeating windows |
| Forms | `forms-settings` | Custom reservation form definitions |
| Reservations | `reservations-list` | All submissions with accept / reject actions |
| Google Calendar | `rsv-google-calendar` | OAuth2 connect and webhook configuration |
## REST API
All routes are registered under the `reservations/v1` namespace.
| Resource | Controller |
|---|---|
| `/service` | `RsvServicesController` |
| `/service-type` | `RsvServiceTypeController` |
| `/reservation` | `RsvReservationController` |
| `/timetable` | `RsvTimetableDefinitionController` |
| `/timetable/availability` | `RsvTimetableAvailabilityController` |
| `/timetable/capacity` | `RsvTimetableCapacityController` |
| `/timetable/reservation` | `RsvTimetableReservationController` |
| `/form` | `RsvFormController` |
| `/form-definition` | `RsvFormDefinitionController` |
| `/google-callback` | OAuth2 redirect handler |
| `/google-calendar-hook` | Google Calendar push notification webhook |
## Linting
Static analysis is handled by [Psalm](https://psalm.dev/) and covers both `src/` (frontend PHP) and `includes/` (plugin PHP).
```bash
vendor/bin/psalm
```
Pre-existing issues are suppressed in `psalm-baseline.xml`. New code must introduce no new errors — any issue not in the baseline will cause a non-zero exit.
**Updating the baseline** — after intentionally fixing pre-existing issues, shrink the baseline so they don't regress:
```bash
vendor/bin/psalm --update-baseline
```
**Psalm is part of the `lint` Composer script**, alongside `phpcs` and `phpstan` (those require separate installation):
```bash
composer run lint
```
## Running Tests
See [`tests/README.md`](tests/README.md) for setup and usage.
## Build
```bash
npm install
npm run build
```
```bash
composer install
```
+5
View File
@@ -0,0 +1,5 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
+3
View File
@@ -0,0 +1,3 @@
# Styles
All stylesheet files are here. Each must have a `Rsv` prefix and be named in SnakeCase.
+23
View File
@@ -0,0 +1,23 @@
/* ─── Two-column admin layout (Forms page) ──────────────────────────────── */
/*#col-left { width: 30%; }
#col-right { width: 70%; }*/
/* ─── Inline detail expand row (Reservations page) ──────────────────────── */
.rsv-detail-expand { padding: 1rem 1.5rem; }
.rsv-detail-heading { margin: 0 0 0.5rem; }
.rsv-detail-empty { margin-bottom: 1rem; }
.rsv-detail-table { margin-bottom: 1rem; }
.rsv-detail-actions { display: flex; gap: 0.5rem; margin-top: 1rem; }
/* ─── Action buttons ─────────────────────────────────────────────────────── */
.rsv-btn-refuse { color: #b32d2e; }
/* ─── Form-values key/value table ────────────────────────────────────────── */
/* Depth-based indent: JS sets --rsv-depth on the cell, CSS does the math. */
.rsv-form-key { padding-left: calc(0.5rem + var(--rsv-depth, 0) * 1.5rem); }
.rsv-form-key--group { font-weight: 600; }
.rsv-form-val--null { color: #aaa; }
+187
View File
@@ -0,0 +1,187 @@
:root {
--color-gray-50: oklch(0.985 0.002 247.839); --color-gray-100: oklch(0.967 0.003 264.542); --color-gray-200: oklch(0.928 0.006 264.531); --color-gray-300: oklch(0.872 0.01 258.338); --color-gray-400: oklch(0.707 0.022 261.325); --color-gray-500: oklch(0.551 0.027 264.364); --color-gray-600: oklch(0.446 0.03 256.802); --color-gray-700: oklch(0.373 0.034 259.733); --color-gray-800: oklch(0.278 0.033 256.848); --color-gray-900: oklch(0.21 0.034 264.665); --color-gray-950: oklch(0.13 0.028 261.692);
--container-bg: var(--color-gray-50);
--border: 1px solid var(--color-gray-300);
--hover-bg: var(--color-blue-300);
--dimm-bg: var(--color-gray-200);
--selected-bg: var(--color-blue-400);
--container-border-radius: 1rem;
--s-1: 0.25rem;
--s-2: 0.5rem;
--s-3: 1rem;
--s-4: 1.5rem;
--s-5: 3rem;
--color-blue-50: oklch(97% .014 254.604);
--color-blue-100: oklch(93.2% .032 255.585);
--color-blue-200: oklch(88.2% .059 254.128);
--color-blue-300: oklch(80.9% .105 251.813);
--color-blue-400: oklch(70.7% .165 254.624);
--color-blue-500: oklch(62.3% .214 259.815);
--color-blue-600: oklch(54.6% .245 262.881);
--color-blue-700: oklch(48.8% .243 264.376);
--color-blue-800: oklch(42.4% .199 265.638);
--color-blue-900: oklch(37.9% .146 265.522);
--color-blue-950: oklch(28.2% .091 267.935);
--color-green-50: oklch(98.2% .018 155.826);
--color-green-100: oklch(96.2% .044 156.743);
--color-green-200: oklch(92.5% .084 155.995);
--color-green-300: oklch(87.1% .15 154.449);
--color-green-400: oklch(79.2% .209 151.711);
--color-green-500: oklch(72.3% .219 149.579);
--color-green-600: oklch(62.7% .194 149.214);
--color-green-700: oklch(52.7% .154 150.069);
--color-green-800: oklch(44.8% .119 151.328);
--color-green-900: oklch(39.3% .095 152.535);
--color-green-950: oklch(26.6% .065 152.934);
--color-red-50: oklch(97.1% .013 17.38);
--color-red-100: oklch(93.6% .032 17.717);
--color-red-200: oklch(88.5% .062 18.334);
--color-red-300: oklch(80.8% .114 19.571);
--color-red-400: oklch(70.4% .191 22.216);
--color-red-500: oklch(63.7% .237 25.331);
--color-red-600: oklch(57.7% .245 27.325);
--color-red-700: oklch(50.5% .213 27.518);
--color-red-800: oklch(44.4% .177 26.899);
--color-red-900: oklch(39.6% .141 25.723);
--color-red-950: oklch(25.8% .092 26.042);
}
rsv-reservation-selector {
border: var(--border);
border-radius: var(--container-border-radius);
}
/* ----- Widget shell ----- */
/*.widget {
background: #fff;
border-radius: 20px;
box-shadow: 0 2px 24px rgba(0, 0, 0, .07);
overflow: hidden;
color: #0f0f0f;
}
.widget-header {
padding: 22px 28px 18px;
border-bottom: 1px solid #f0f0f0;
}
.widget-title {
font-size: 22px;
font-weight: 700;
letter-spacing: -.02em;
}
.widget-subtitle {
font-size: 13px;
color: #888;
margin-top: 2px;
}
.widget-body {
display: flex;
}
.widget-calendar {
flex: 1;
padding: 20px 24px;
border-right: 1px solid #f0f0f0;
}
.widget-slots {
width: 240px;
padding: 20px 16px;
}
.widget-form {
padding: 20px 24px;
border-top: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
gap: 16px;
}*/
/* Nav arrows (prev / next month) */
.rsv-cal-btn-nav {
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid #e0e0e0;
background: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background .12s;
color: #555;
}
.rsv-cal-btn-nav {
outline: none;
}
.rsv-cal-btn-nav:hover {
background: #f0f0f0;
}
/* WIDGET START */
.reservation-list {
max-height: 300px;
overflow-y: scroll;
background-color: #f6f7f7;
margin: 0 -12px 6px -12px;
}
.reservation-list li {
margin: 0;
padding: 8px 12px;
color: #2c3338;
box-shadow: inset 0 1px 0 rgba(0,0,0,.06);
}
.reservation-list li:hover .row-actions {
position: static;
}
.reservation-list .row-actions {
margin: 3px 0 0;
padding: 0;
font-size: 13px;
line-height: 1.5;
}
/* WIDGET END */
.rsv-success-state {
display: flex;
flex-direction: column;
align-items: center;
}
.rsv-reset-button {
border: 1.5px solid #e0e0e0;
border-radius: 10px;
padding: 10px 20px;
font-size: 13px;
font-weight: 600;
color: #555;
font-family: var(--wp--preset--font-family--manrope);
}
+187
View File
@@ -0,0 +1,187 @@
.rsv-cal-month {
font-size: 15px;
font-weight: 600;
}
.rsv-cal-dow, .rsv-cal-header th {
text-align: center;
font-size: 11px;
font-weight: 600;
color: #999;
padding: 4px 0 8px;
text-transform: uppercase;
letter-spacing: .04em;
}
.rsv-cal-cell {
aspect-ratio: 1;
/*border-radius: 50%;*/
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background .12s, color .12s;
color: #0f0f0f;
user-select: none;
}
/* Days outside the current month */
.rsv-cal-cell-dimmed {
color: #ccc;
pointer-events: none;
}
/* Today's date — bold, no fill */
.rsv-cal-cell-today {
font-weight: 700;
}
/* Selected date */
.rsv-calendar .rsv-cal-cell input:checked+label {
background: #2563eb;
color: #fff;
}
/* Hover (only meaningful on non-selected, non-dimmed cells) */
.rsv-cal-cell:hover:not(.cell-dimmed) label {
background: #f0f4ff;
color: #2563eb;
}
/* Past dates — visual only, pointer-events handled in JS */
.rsv-cal-cell-past {
color: #ccc;
pointer-events: none;
}
.rsv-cal-month {
font-size: 15px;
font-weight: 600;
}
.rsv-calendar {
padding: 1rem;
}
.rsv-calendar table {
font-size: 0.875rem;
font-weight: 600;
table-layout: fixed;
border-collapse: separate;
/*border-radius: var(--container-border-radius);*/
width: 100%;
border-spacing: 0;
overflow: hidden;
}
/*.calendar button {
border: none;
background-color: transparent;
border-radius: var(--s-2);
}*/
/*.calendar button:focus {
border: none;
outline: none;
}
.calendar button:hover {
background-color: var(--hover-bg);
}*/
.rsv-calendar button svg {
vertical-align: middle;
}
.rsv-calendar tr {
text-align: center;
}
.rsv-calendar th {
padding: var(--s-2);
color: gray;
}
.rsv-calendar .rsv-cal-controls {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.rsv-calendar th.rsv-cal-controls>* {
flex-grow: 1;
}
.rsv-calendar td {
-webkit-user-select:none;user-select:none;
z-index: -1;
}
.rsv-calendar td label {
display: block;
padding: 0.25em;
border-radius: var(--s-4);
transition: background-color 0.3s ease;
}
/*.calendar td:hover label {
background-color: var(--color-gray-100);
}*/
.rsv-calendar td>div {
background-color: white;
padding: 0.5rem;
transition: background-color 0.3s ease;
}
.rsv-calendar td.today {
color: var(--selected);
}
.rsv-calendar td button {
transition: background-color 0.3s ease;
padding: 0.5rem;
}
/*.calendar td button:hover {
cursor: pointer;
background-color: var(--hover-bg);
}*/
.rsv-calendar td.selected>div {
position: relative;
color: white;
background-color: var(--color-blue-500);
}
.rsv-calendar td.dimm {
/*background-color: var(--dimm-bg);*/
}
.rsv-calendar tr:last-child>td {
border-bottom: none;
}
.rsv-calendar tr>td:last-child {
border-right: none;
}
.rsv-calendar td label {
width: 100%;
height: 100%;
padding: 0;
line-height: 2.4rem;
margin: 0;
}
.rsv-calendar input[type="radio"]:checked+label {
background-color: var(--color-blue-500);
color: white;
}
.rsv-cal-cell.dimm {
color: gray;
}
/* CALENDAR END */
+217
View File
@@ -0,0 +1,217 @@
/* Primary CTA (submit / confirm) */
.rsv-form-btn-primary {
background: #2563eb;
color: #fff;
border-radius: 1.375rem;
font-size: 1rem;
padding: 0 calc(1.25rem + 4px);
line-height: 140%;
height: 3.5rem;
border: none;
font-size: 15px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
width: 100%;
transition: background .12s;
letter-spacing: -.01em;
}
.rsv-form-btn-primary:hover {
background: #1d4ed8;
}
.rsv-form-btn-primary:disabled {
background: #e0e0e0;
color: #aaa;
cursor: not-allowed;
}
/* FORM */
.reservair-form {
margin-left: auto;
margin-right: auto;
}
.rsv-form-input-short {
max-width: 320px;
margin-left: auto;
margin-right: auto;
}
/*.reservair-form button {
padding: var(--s-3) !important;
font-weight: 400 !important;
}*/
/*.reservair-form button,*/
.rsv-form-input {
border: 1px solid var(--color-gray-300);
outline: none;
padding: var(--s-2);
border-radius: var(--s-2);
width: 100%;
box-sizing: border-box;
background-color: var(--color-gray-50);
transition: box-shadow 0.2s ease, border-color 0.2s ease;
}
/*.reservair-form button,*/
.rsv-form-input[type="submit"] {
background-color: var(--color-blue-500);
padding: 0.5rem;
color: white;
border: none;
font-size: 0.875rem;
font-weight: 600;
transition: background-color 0.2s ease;
}
.rsv-form-input[type="submit"]:hover {
background-color: var(--color-blue-400);
}
.reservair-form button.rsv-loading {
color: transparent;
pointer-events: none;
position: relative;
}
.reservair-form button.rsv-loading::after {
content: '';
position: absolute;
inset: 0;
margin: auto;
width: 1em;
height: 1em;
border: 2px solid white;
border-top-color: transparent;
border-radius: 50%;
animation: rsv-spin 0.6s linear infinite;
}
@keyframes rsv-spin {
to { transform: rotate(360deg); }
}
.rsv-form-input:focus {
box-shadow: 0 0 0 4px color-mix(in oklab,var(--color-blue-500)25%,transparent);
border-color: var(--color-blue-500);
}
.rsv-form-input input:user-invalid {
border-color: var(--color-red-500);
box-shadow: 0 0 0 4px color-mix(in oklab,var(--color-red-500)25%,transparent);
}
.rsv-form-section {
margin-bottom: var(--s-5);
}
.rsv-form-input-group>* {
margin-bottom: var(--s-1);
}
.rsv-form-input-group {
margin-bottom: var(--s-4);
}
.rsv-form-label,
.rsv-form-small {
padding-left: 5pt;
font-size: 0.875rem;
font-weight: 500;
display: block;
}
.rsv-form-small {
color: gray;
}
/*.confirmation small {
color: var(--color-gray-500);
}*/
.rsv-error-summary {
background-color: color-mix(in oklab, var(--color-red-500) 10%, transparent);
border: 1px solid var(--color-red-400);
border-radius: var(--s-2);
padding: var(--s-2) var(--s-3);
margin-bottom: var(--s-3);
font-size: 0.875rem;
color: var(--color-red-800);
}
.rsv-error-summary ul {
margin: 0;
padding-left: 1.25rem;
}
.rsv-field-error {
display: block;
color: var(--color-red-600);
font-size: 0.8rem;
margin-top: var(--s-1);
padding-left: 5pt;
}
.rsv-invalid {
border-color: var(--color-red-500) !important;
box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-red-500) 25%, transparent) !important;
}
.rsv-success-message {
text-align: center;
padding: var(--s-5);
color: var(--color-green-700);
}
.rsv-success-message p {
font-size: 1.125rem;
font-weight: 500;
}
.mesg {
width: 100%;
text-align: center;
line-height: 1rem;
margin-top: var(--s-5);
margin-bottom: var(--s-5);
padding: var(--s-4) 0;
}
.success-mesg-icon {
}
.mesg-icon svg {
width: 32px;
height: 32px;
padding: var(--s-2);
border-radius: 50%;
color: #00000094;
}
.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 {
display: grid;
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 768px) {
.rsv-timetable-selector {
grid-template-columns: repeat(1, 1fr);
}
}
@@ -0,0 +1,133 @@
/* ----- Summary (selected slots + price) ----- */
rsv-reservation-summary {
display: block;
margin-bottom: var(--s-4);
}
rsv-reservation-summary {
padding: 14px 20px;
background: #f8faff;
border: 1px solid #e8f0fe;
border-radius: 1.375rem;
box-sizing: border-box;
max-width: 320px;
margin-left: auto;
margin-right: auto;
}
.rsv-summary-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.rsv-summary-title {
font-size: 11px;
font-weight: 700;
color: #2563eb;
text-transform: uppercase;
letter-spacing: .06em;
}
.rsv-summary-clear {
font-size: 11px;
font-weight: 600;
color: #aaa;
background: none;
border: none;
font-family: inherit;
cursor: pointer;
padding: 0;
}
.rsv-summary-clear:hover { color: #e53e3e; }
.rsv-summary-list {
display: flex;
flex-direction: column;
gap: 6px;
list-style: none;
padding: 0;
margin: 0 0 10px;
}
.rsv-summary-item {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border: 1.5px solid #e8f0fe;
border-radius: 10px;
padding: 8px 12px;
}
.rsv-summary-item-info {
display: flex;
flex-direction: column;
gap: 1px;
}
.rsv-summary-item-date {
font-size: 11px;
color: #888;
}
.rsv-summary-item-time {
font-size: 13px;
font-weight: 600;
color: #0f0f0f;
}
.rsv-summary-item-price {
font-size: 1rem;
}
.rsv-summary-item-remove {
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: #f0f0f0;
color: #888;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all .12s;
flex-shrink: 0;
}
.rsv-summary-item-remove:hover {
background: #fee2e2;
color: #e53e3e;
}
.rsv-summary-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 10px;
border-top: 1px solid #e8f0fe;
}
.rsv-summary-count {
font-size: 12px;
color: #888;
}
.rsv-summary-price {
font-size: 16px;
font-weight: 700;
color: #0f0f0f;
letter-spacing: -.02em;
}
.rsv-summary-price span {
font-size: 12px;
font-weight: 500;
color: #888;
margin-left: 2px;
}
@@ -0,0 +1,153 @@
/* TIME SLOTS */
.rsv-time-slots {
padding: 1rem;
border-left: var(--border);
}
.rsv-slots-notice {
display: block;
text-align: center;
grid-column-start: 0;
grid-column-end: 1;
font-weight: 400;
font-size: 0.875rem;
}
.rsv-slots-slot-time {
font-size: 0.875rem;
font-weight: 600;
padding: 0.5rem;
-webkit-user-select:none;user-select:none;
}
.rsv-slots-slot-time>.content {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
align-content: center;
width: 100%;
padding: 0.5rem 1.5rem;
}
.rsv-slots-slot-time .capacity {
font-size: 1rem;
color: var(--color-gray-400);
}
.rsv-slots-slot-time .capacity>* {
display: block;
text-align: center;
width: 100%;
}
label.rsv-slots-slot-time>input:checked + .content>.capacity {
color: rgba(255, 255, 255, 0.7);
}
.reservation-block.blocked>.rsv-slots-slot-time>.content {
opacity: 0.8;
color: var(--color-gray-500);
text-decoration: line-through;
}
.rsv-slots-label {
font-size: 11px;
font-weight: 600;
color: #999;
text-transform: uppercase;
letter-spacing: .06em;
margin-bottom: 12px;
padding-top: 0.5rem;
}
.rsv-slots-list {
display: flex;
flex-direction: column;
gap: 6px;
list-style: none;
padding: 1rem;
margin: 0;
}
/* Base slot */
.rsv-slots-slot {
margin-top: 0.375rem;
border: 1.5px solid #e8e8e8;
border-radius: 10px;
padding: 9px 12px;
cursor: pointer;
transition: border-color .12s, background .12s, color .12s;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
font-weight: 600;
}
.rsv-slots-slot:hover:not(.rsv-slots-slot-full):not(.rsv-slots-slot-selected) {
border-color: #2563eb;
background: #f5f8ff;
}
/* Available — plenty of spots */
.rsv-slots-slot-available {
background: #f0fdf4;
border-color: #86efac;
color: #166534;
}
/* Few spots left */
.rsv-slots-slot-few {
background: #fff8f0;
border-color: #f59e0b;
color: #92400e;
}
/* Fully booked */
.rsv-slots-slot-full {
background: #fafafa;
border-color: #e8e8e8;
color: #bbb;
text-decoration: line-through;
cursor: not-allowed;
}
/* Selected */
.rsv-slots-slot-selected {
background: #2563eb;
border-color: #2563eb;
color: #fff;
}
/* Availability badge (small pill inside slot) */
.rsv-slots-slot-badge {
font-size: 10px;
font-weight: 600;
border-radius: 6px;
padding: 2px 7px;
}
.rsv-slots-slot-badge-available {
background: #dcfce7;
color: #166534;
}
.rsv-slots-slot-badge-few {
background: #fef3c7;
color: #92400e;
}
.rsv-slots-slot-badge-full {
background: #f3f4f6;
color: #9ca3af;
}
.rsv-slots-slot-badge-selected {
background: rgba(255, 255, 255, .2);
color: #fff;
}
/* TIMELINE END */
+7
View File
@@ -0,0 +1,7 @@
/*
* Utilities for calling the API
*/
function get_rest_url(resource) {
return ReservairServiceAPI.restUrl + '/' + resource;
}
View File
+5
View File
@@ -0,0 +1,5 @@
# JS Data Sources
The JavaScript *RSV* Client layer. The frontend uses `RsvDataSource` to work with the Reservair's REST API.
The `RsvDataSource` is an object for a specific resource & contains methods for working with it.
+44
View File
@@ -0,0 +1,44 @@
const RsvDataSource = {
create_rsv_resource(base_url, { nonce } = {}) {
function request(url, method, body) {
const headers = { 'Content-Type': 'application/json' };
if (nonce) headers['X-WP-Nonce'] = nonce;
else headers['X-WP-Nonce'] = ReservairServiceAPI.nonce;
return fetch(url, {
method,
credentials: 'same-origin',
headers,
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
}).then(r => {
if (!r.ok) throw new Error(`${method} ${url} failed: ${r.status}`);
return r.status === 204 ? null : r.json();
});
}
return {
base_url: base_url,
get_page(skip = 0, limit = 20, params = {}) {
const url = new URL(base_url);
url.searchParams.set('skip', skip);
url.searchParams.set('limit', limit);
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, v);
}
return request(url, 'GET');
},
get(id) {
return request(`${base_url}/${id}`, 'GET');
},
post(data) {
return request(base_url, 'POST', data);
},
put(id, data) {
return request(`${base_url}/${id}`, 'PUT', data);
},
delete(id) {
return request(`${base_url}/${id}`, 'DELETE');
},
};
}
};
@@ -0,0 +1,2 @@
const RsvFormDefinitionResource = () =>
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/form-definition');
@@ -0,0 +1,19 @@
const RsvReservationClient = {
accept(reservation_id) {
return this._post(reservation_id, 'accept');
},
refuse(reservation_id) {
return this._post(reservation_id, 'refuse');
},
_post(reservation_id, action) {
return fetch(`${ReservairServiceAPI.restUrl}/reservation/${reservation_id}/${action}`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-WP-Nonce': ReservairServiceAPI.nonce },
}).then(r => {
if (!r.ok) return r.json().then(e => { throw new Error(e.error || 'Request failed'); });
});
},
};
@@ -0,0 +1,2 @@
const RsvReservationResource = () =>
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/reservation');
@@ -0,0 +1,2 @@
const RsvTimetableCapacityResource = (id) =>
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + `/timetable/${id}/capacity`);
@@ -0,0 +1,2 @@
const RsvTimetableReservationResource = (id) =>
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + `/timetable/${id}/reservation`);
@@ -0,0 +1,2 @@
const RsvTimetableResource = () =>
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/timetable');
+3
View File
@@ -0,0 +1,3 @@
# Elements
Some repeating components of the UI.
+196
View File
@@ -0,0 +1,196 @@
const RsvCalendarPicker = (() => {
function get_first_day_of_month(date) {
const day = new Date(date.getFullYear(), date.getMonth(), 1).getDay();
return day === 0 ? 6 : day - 1; // Mon=0 … Sun=6
}
function is_same_day(a, b) {
return a.getUTCFullYear() === b.getUTCFullYear()
&& a.getUTCMonth() === b.getUTCMonth()
&& a.getUTCDate() === b.getUTCDate();
}
function is_same_month(a, b) {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth();
}
function clear_class(root, cls) {
root.querySelectorAll('.' + cls).forEach(el => el.classList.remove(cls));
}
function set_cell(cell, date, outside) {
cell.classList.toggle('dimm', outside);
const iso = date.toISOString();
cell.setAttribute('datetime', iso);
cell.children[0].id = iso;
cell.children[0].setAttribute('datetime', iso);
cell.children[1].textContent = date.getUTCDate();
cell.children[1].setAttribute('for', iso);
}
function render(state, date) {
const year = date.getFullYear();
const month = date.getMonth();
const first = get_first_day_of_month(date);
const in_cur = new Date(year, month + 1, 0).getDate();
const in_prev = new Date(year, month, 0).getDate();
const today = new Date();
const rows = state.body.querySelectorAll('tr');
clear_class(state.body, 'rsv-cal-cell-current');
clear_class(state.body, 'rsv-cal-cell-today');
let idx = 0;
for (let d = in_prev - first + 1; d <= in_prev; d++, idx++) {
const dt = new Date(Date.UTC(year, month - 1, d));
const cell = rows[0].children[idx];
set_cell(cell, dt, true);
if (is_same_day(dt, today)) cell.classList.add('rsv-cal-cell-today');
}
for (let i = 1; i <= in_cur; i++, idx++) {
const dt = new Date(Date.UTC(year, month, i));
const cell = rows[Math.floor(idx / 7)].children[idx % 7];
set_cell(cell, dt, false);
if (is_same_day(dt, date)) cell.querySelector('input').checked = true;
if (is_same_day(dt, today)) cell.classList.add('rsv-cal-cell-today');
}
for (let i = 1; idx < 42; i++, idx++) {
const dt = new Date(Date.UTC(year, month + 1, i));
const cell = rows[Math.floor(idx / 7)].children[idx % 7];
set_cell(cell, dt, true);
if (is_same_day(dt, today)) cell.classList.add('rsv-cal-cell-today');
}
state.month_el.textContent =
new Date(year, month).toLocaleString(navigator.language, { month: 'long' }) + ' ' + year;
}
function day_names() {
// Generate short weekday names starting on Monday using the browser locale.
return Array.from({ length: 7 }, (_, i) =>
new Date(2024, 0, 1 + i) // 2024-01-01 is a Monday
.toLocaleDateString(navigator.language, { weekday: 'short' })
);
}
const ARROW_L = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0"/></svg>`;
const ARROW_R = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708"/></svg>`;
function nav_btn(icon, handler) {
const btn = document.createElement('button');
btn.type = 'button';
btn.innerHTML = icon;
btn.classList.add('rsv-cal-btn-nav');
btn.addEventListener('click', handler);
const wrap = document.createElement('div');
wrap.appendChild(btn);
return wrap;
}
function build_header(state) {
const month_el = document.createElement('span');
month_el.classList.add('rsv-cal-month');
state.month_el = month_el;
const controls = document.createElement('div');
controls.classList.add('rsv-cal-controls');
controls.append(
nav_btn(ARROW_L, () => state.set_date(new Date(state.date.getFullYear(), state.date.getMonth() - 1, state.date.getDate()))),
month_el,
nav_btn(ARROW_R, () => state.set_date(new Date(state.date.getFullYear(), state.date.getMonth() + 1, state.date.getDate())))
);
const ctrl_td = document.createElement('td');
ctrl_td.colSpan = 7;
ctrl_td.appendChild(controls);
const ctrl_row = document.createElement('tr');
ctrl_row.appendChild(ctrl_td);
const names_row = document.createElement('tr');
day_names().forEach(name => {
const th = document.createElement('th');
th.textContent = name;
names_row.appendChild(th);
});
const header = document.createElement('thead');
header.classList.add('rsv-cal-header');
header.append(ctrl_row, names_row);
return header;
}
function build_body(name, on_select) {
const tbody = document.createElement('tbody');
tbody.classList.add('rsv-cal-grid');
for (let y = 0; y < 6; y++) {
const row = document.createElement('tr');
for (let x = 0; x < 7; x++) {
const radio = document.createElement('input');
radio.type = 'radio';
radio.name = name + '.date';
radio.hidden = true;
radio.addEventListener('change', on_select);
const label = document.createElement('label');
label.setAttribute('unselectable', 'true');
const cell = document.createElement('td');
cell.classList.add('rsv-cal-cell');
cell.append(radio, label);
row.appendChild(cell);
}
tbody.appendChild(row);
}
return tbody;
}
return {
create(container, name) {
const state = {
date: null,
month_el: null,
body: null,
container,
set_date(date) {
if (this.date !== null && is_same_day(date, this.date)) return;
const month_changed = this.date === null || !is_same_month(date, this.date);
if (month_changed) {
const prev = this.body.querySelector('input[type="radio"]:checked');
if (prev) prev.checked = false;
render(this, date);
const next = this.body.querySelector(`input[id="${date.toISOString()}"]`);
if (next) next.checked = true;
}
const first = get_first_day_of_month(date);
const cell_idx = first + date.getDate() - 1;
const rows = this.body.querySelectorAll('tr');
rows[Math.floor(cell_idx / 7)].children[cell_idx % 7].querySelector('input').checked = true;
this.date = date;
this.container.value = date;
this.container.dispatchEvent(
new InputEvent('change', { bubbles: true, cancelable: true, composed: true })
);
},
};
container.classList.add('rsv-calendar');
const table = document.createElement('table');
table.appendChild(build_header(state));
table.appendChild(build_body(name, e => state.set_date(new Date(e.target.getAttribute('datetime')))));
state.body = table.querySelector('tbody');
container.appendChild(table);
state.set_date(new Date());
return state;
},
};
})();
+422
View File
@@ -0,0 +1,422 @@
/**
* RSV Dynamic datagrid
* Allows fetching with JS instead of page reload.
*/
window.RsvDataGrid = window.RsvDataGrid || {
create_header(self, columns, has_actions) {
let thead = document.createElement('thead');
thead.replaceChildren(...Object.entries(columns).map(([key, value]) => {
let th = document.createElement('th');
if (value.width) {
th.style.width = value.width + 'px';
}
th.classList.add('manage-columns', 'column-' + key);
if (value.is_sortable) {
th.classList.add('sortable', key);
let button = document.createElement('a');
button.onclick = () => {
self.sort_by(key);
};
button.innerHTML =
`<span>${value.label}</span>
<span class="sorting-indicators">
<span class="sorting-indicator asc" aria-hidden="true"></span>
<span class="sorting-indicator desc" aria-hidden="true"></span>
</span>
<span class="screen-reader-text">Sort ascending.</span>
`;
th.appendChild(button);
} else {
th.innerText = value.label;
}
return th;
}));
if (has_actions) {
let th = document.createElement('th');
th.innerText = 'Actions';
thead.appendChild(th);
}
return thead;
},
create_footer(self, columns, has_actions) {
let tfoot = document.createElement('tfoot');
let trow = document.createElement('tr');
trow.replaceChildren(...Object.entries(columns).map(([key, value]) => {
let th = document.createElement('th');
if (value.width) {
th.style.width = value.width + 'px';
}
th.classList.add('manage-columns', 'column-' + key);
if (value.is_sortable) {
th.classList.add('sortable', key);
let button = document.createElement('a');
button.onclick = () => {
self.sort_by(key);
};
button.innerHTML =
`<span>${value.label}</span>
<span class="sorting-indicators">
<span class="sorting-indicator asc" aria-hidden="true"></span>
<span class="sorting-indicator desc" aria-hidden="true"></span>
</span>
<span class="screen-reader-text">Sort ascending.</span>
`;
th.appendChild(button);
} else {
th.innerText = value.label;
}
return th;
}));
tfoot.appendChild(trow);
return tfoot;
},
create_dg_row(self, data, index = 0) {
let row = document.createElement('tr');
row.classList.add('iedit', 'author-self', 'level-0', 'type-page', 'status-publish', 'hentry');
row.replaceChildren(...Object.entries(self.columns).map(([key, value]) => {
let td = document.createElement('td');
if (self.mappings[key] != null) {
td = self.mappings[key](self, row, data);
} else {
td.innerText = data[key];
}
if(value.actions != null && Object.entries(value.actions).length > 0) {
const visible_actions = Object.entries(value.actions).filter(([, action]) =>
action.condition == null || action.condition(data, index)
);
if (visible_actions.length === 0) return td;
row.classList.add('has-row-actions')
const action_cell = document.createElement('div');
action_cell.classList.add('row-actions', 'visible');
const action_spans = visible_actions.map(([key, value]) => {
if (value.is_link) {
let span = document.createElement('span');
let a = document.createElement('a');
a.innerText = key;
a.href = value.func(data);
span.appendChild(a);
return span;
} else {
let span = document.createElement('span');
let button = document.createElement('a');
button.onclick = function () { value.func(self, row, data) };
button.innerText = key;
span.appendChild(button);
return span;
}
});
const action_nodes = action_spans.flatMap((span, i) =>
i < action_spans.length - 1 ? [span, document.createTextNode(' | ')] : [span]
);
action_cell.replaceChildren(...action_nodes);
td.appendChild(action_cell);
}
return td;
}));
return row;
},
async render_data_grid(self) {
const rows = self.fetch_resource()
.then(x => { self.set_total(x.total); return x; })
.then(x => x.data.map((x, i) => RsvDataGrid.create_dg_row(self, x, i)))
.then(x => self.body.replaceChildren(...x))
.catch(error => {
console.error(error);
return []; // empty the rows
});
},
link_action(func, condition = null) {
return { is_link: true, func, condition };
},
func_action(func, condition = null) {
return { is_link: false, func, condition };
},
edit_action(func, condition = null) {
return { is_link: false, func, condition };
},
action_column(label, is_sortable, actions) {
return {
label: label,
is_sortable: is_sortable,
actions: actions,
};
},
column(label, is_sortable = false, width = 0) {
return {
label: label,
is_sortable: is_sortable,
width: width
};
},
create_paging_button(text) {
let button = document.createElement('a');
button.classList.add('button');
let label = document.createElement('span');
label.innerText = text;
button.appendChild(label);
return button;
},
create_last_page_btn() {
let btn = this.create_paging_button("»");
btn.classList.add('last-page');
return btn;
},
create_next_page_btn() {
let btn = this.create_paging_button("");
btn.classList.add('next-page');
return btn;
},
create_first_page_btn() {
let btn = this.create_paging_button("«");
btn.classList.add('first-page');
return btn;
},
create_prev_page_btn() {
let btn = this.create_paging_button("");
btn.classList.add('prev-page');
return btn;
},
create_paging_text() {
let text = document.createElement('span');
text.classList.add('paging-input');
let paging_text = document.createElement('span');
paging_text.classList.add('tablenav-paging-text');
text.appendChild(paging_text);
const result = {
container: text,
paging_text: paging_text,
};
return result;
},
create_paging_controls(self) {
let nav = document.createElement('div');
nav.classList.add('tablenav-pages');
let displaying_num = document.createElement('span');
displaying_num.classList.add('displaying-num');
nav.appendChild(displaying_num);
let pagination_links = document.createElement('span');
pagination_links.classList.add('pagination-links');
let first_page_btn = this.create_first_page_btn();
first_page_btn.onclick = () => self.goto_first_page();
let prev_page_btn = this.create_prev_page_btn();
prev_page_btn.onclick = () => self.move_page(-1);
let next_page_btn = this.create_next_page_btn();
next_page_btn.onclick = () => self.move_page(1);
let last_page_btn = this.create_last_page_btn();
last_page_btn.onclick = () => self.goto_last_page();
pagination_links.appendChild(first_page_btn);
pagination_links.appendChild(prev_page_btn);
let paging_text = this.create_paging_text();
pagination_links.appendChild(paging_text.container);
pagination_links.appendChild(next_page_btn);
pagination_links.appendChild(last_page_btn);
// pagination_links.innerHTML = `
// <span class="pagination-links">
// <span class="tablenav-pages-navspan button disabled" aria-hidden="true">«</span>
// <span class="tablenav-pages-navspan button disabled" aria-hidden="true"></span>
// <span class="screen-reader-text">Current Page</span>
// <span id="table-paging" class="paging-input">
// <span class="tablenav-paging-text">1 of <span class="total-pages">2</span></span>
// </span>
// <a class="next-page button" href="http://127.0.0.1/wordpress/wp-admin/edit-tags.php?taxonomy=category&amp;paged=2">
// <span class="screen-reader-text">Next page</span>
// <span aria-hidden="true"></span>
// </a>
// <a class="last-page button" href="http://127.0.0.1/wordpress/wp-admin/edit-tags.php?taxonomy=category&amp;paged=2">
// <span class="screen-reader-text">Last page</span>
// <span aria-hidden="true">»</span>
// </a>
// </span>`;
nav.appendChild(pagination_links);
const paging_controls = {
container: nav,
paging_text: paging_text.paging_text,
display_num: displaying_num,
};
return paging_controls;
},
create_data_grid(container, resource, columns, actions) {
let tbody = document.createElement('tbody');
let state = {
columns: columns,
resource: resource,
actions: actions,
body: tbody,
mappings: {},
params: {},
total: 0,
page: 0,
page_size: 20,
container: container,
order_by: null,
order: 0,
fetch_resource() {
const params = { ...this.params };
if (this.order_by) {
params.orderby = this.order_by;
params.order = this.order === 0 ? 'desc' : 'asc';
}
return this.resource.get_page(this.page * this.page_size, this.page_size, params);
},
refresh() {
RsvDataGrid.render_data_grid(this)
},
refresh_row(row, data) {
let row2 = RsvDataGrid.create_dg_row(this, data);
row.replaceWith(row2);
},
map_column(key, func) {
this.mappings[key] = func;
return this;
},
add_action(key, func) {
this.actions[key] = func;
return this;
},
set_param(key, value) {
this.params[key] = value;
},
remove_param(key, do_refresh = true) {
delete this.params[key];
if (do_refresh) {
this.refresh();
}
},
sort_by(key, dir = null, do_refresh = true) {
let ths = this.container.getElementsByClassName("column-" + key);
if (ths.length > 0) {
this.order = dir ?? (this.order_by === key ? 1 - this.order : 0);
this.order_by = key;
let th = ths[0];
if (this.order === 0) {
th.classList.remove('asc');
th.classList.add('desc');
} else {
th.classList.remove('desc');
th.classList.add('asc');
}
// if (th.classList.contains('asc')) {
// th.classList.replace('asc', 'desc');
// this.set_param('order', 'desc');
// } else {
// th.classList.remove('desc');
// th.classList.add('asc');
// this.set_param('order', 'asc');
// }
let sorted = th.parentElement.getElementsByClassName('sorted');
if (sorted.length > 0)
sorted[0].classList.replace('sorted', 'sortable');
th.classList.replace('sortable', 'sorted');
if (do_refresh) {
this.refresh();
}
}
return this;
},
set_total(count) {
this.total = count;
this.paging_controls.display_num.innerHTML = `${this.total} položek`;
this.paging_controls.paging_text.innerHTML = `${this.page + 1} of <span class="total-pages">${Math.ceil(count / this.page_size)}</span>`;
},
get_total() {
return this.total;
},
move_page(relative) {
this.page = Math.max(0, Math.min(this.page + relative, Math.ceil(this.total / this.page_size) - 1));
this.refresh();
},
goto_first_page() {
this.move_page(-this.page);
},
goto_last_page() {
this.move_page(Math.ceil(this.total / this.page_size) - this.page - 1);
},
};
let paging_controls = this.create_paging_controls(state);
let footer = document.createElement('div');
footer.classList.add('tablenav', 'bottom');
footer.appendChild(paging_controls.container);
state.paging_controls = paging_controls;
let table = document.createElement('table');
table.classList.add('datagrid', 'wp-list-table', 'widefat', 'fixed', 'striped', 'table-view-list');
// const has_actions = Object.entries(actions).length > 0;
table.appendChild(this.create_header(state, columns, false));
table.appendChild(tbody);
table.appendChild(this.create_footer(state, columns, false));
container.appendChild(table);
container.appendChild(footer);
return state;
}
}
@@ -0,0 +1,117 @@
class RsvReservationSelector extends HTMLElement {
static get observedAttributes() {
return ['timetable-id', 'name', 'price-per-block'];
}
// ---- Attribute accessors ------------------------------------------------
get timetableId() { return parseInt(this.getAttribute('timetable-id')); }
get inputName() { return this.getAttribute('name') ?? 'reservation'; }
get pricePerBlock() { return parseFloat(this.getAttribute('price-per-block')) || 0; }
// ---- Lifecycle ----------------------------------------------------------
connectedCallback() {
this._slots = [];
this.classList.add('rsv-timetable-selector');
this._build();
}
attributeChangedCallback(_attr, oldVal, newVal) {
if (oldVal === null || oldVal === newVal || !this.isConnected) return;
this._build();
}
// ---- Public API ---------------------------------------------------------
getValue() {
return {
timetable_id: this.timetableId,
timetable_reservations: this._slots.map(s => s.start_utc),
};
}
clear() {
this.querySelectorAll('.rsv-slots-slot-selected').forEach(s => s.classList.remove('rsv-slots-slot-selected'));
this._slots = [];
this._commit();
}
// ---- Private ------------------------------------------------------------
_build() {
this._slots = [];
this.replaceChildren();
const tid = document.createElement('input');
tid.type = 'hidden';
tid.name = `${this.inputName}.timetable_id`;
tid.value = this.timetableId;
this.appendChild(tid);
const cal_el = document.createElement('div');
cal_el.classList.add('rsv-calendar');
// Create rsv-timeline with timetable-id set before appending so
// connectedCallback sees the correct attribute on first render.
const time_el = document.createElement('rsv-timeline');
time_el.setAttribute('timetable-id', this.timetableId);
this.append(cal_el, time_el);
this._calendar = RsvCalendarPicker.create(cal_el, this.inputName);
// Date change: clear selection, then push new date to the timeline element.
cal_el.addEventListener('change', () => {
this.querySelectorAll('.rsv-slots-slot-selected').forEach(s => s.classList.remove('rsv-slots-slot-selected'));
this._slots = [];
this._commit();
time_el.date = this._calendar.date;
});
// Slot toggle: read selected slots from timeline, then commit.
time_el.addEventListener('input', e => {
e.stopPropagation();
this._slots = Array.from(time_el.querySelectorAll('.rsv-slots-slot-selected')).map(s => ({
start_utc: s.dataset.start_utc,
end_utc: s.dataset.end_utc,
}));
this._commit();
});
this._commit();
}
_commit() {
const name = this.inputName;
this.querySelectorAll(`input[name="${name}.timetable_reservations[]"]`).forEach(i => i.remove());
let json = [];
this._slots.forEach(slot => {
const inp = document.createElement('input');
inp.type = 'hidden';
inp.name = `${name}.timetable_reservations[]`;
inp.value = slot.start_utc;
this.appendChild(inp);
json.push(slot.start_utc);
});
this.value = JSON.stringify({
"timetable_id": this.timetableId,
"timetable_reservations": json
});
this.dispatchEvent(new CustomEvent('rsv:slots-changed', {
bubbles: true,
detail: {
name,
slots: this._slots,
price_per_block: this.pricePerBlock,
value: this.getValue(),
},
}));
}
}
customElements.define('rsv-reservation-selector', RsvReservationSelector);
+100
View File
@@ -0,0 +1,100 @@
class RsvReservationSummary extends HTMLElement {
// ---- Lifecycle ----------------------------------------------------------
connectedCallback() {
this._all_slots = new Map(); // name → { slots, price_per_block }
this._form = this.closest('form');
this._build();
if (this._form) {
this._handler = e => {
this._all_slots.set(e.detail.name, {
slots: e.detail.slots,
price_per_block: e.detail.price_per_block,
});
this._render();
};
this._form.addEventListener('rsv:slots-changed', this._handler);
}
}
disconnectedCallback() {
if (this._form && this._handler) {
this._form.removeEventListener('rsv:slots-changed', this._handler);
}
}
// ---- Private ------------------------------------------------------------
_build() {
const s = ReservairStrings.summary;
this.innerHTML = `
<div class="rsv-summary-header">
<span class="rsv-summary-title">${s.title}</span>
<button type="button" class="rsv-summary-clear">${s.clear_all}</button>
</div>
<ul class="rsv-summary-list"></ul>
<div class="rsv-summary-footer">
<span class="rsv-summary-count"></span>
<div class="rsv-summary-price"></div>
</div>
`;
this.hidden = true;
this.querySelector('.rsv-summary-clear').addEventListener('click', () => {
this._form?.querySelectorAll('rsv-reservation-selector').forEach(sel => sel.clear());
});
}
_render() {
const all_slots = [...this._all_slots.values()].flatMap(({ slots, price_per_block }) =>
slots.map(s => ({ ...s, price_per_block }))
);
console.log(all_slots);
const n = all_slots.length;
const list = this.querySelector('.rsv-summary-list');
const count_el = this.querySelector('.rsv-summary-count');
const price_el = this.querySelector('.rsv-summary-price');
const s = ReservairStrings.summary;
const locale = navigator.language;
this.hidden = n === 0;
if (n === 0) {
list.replaceChildren();
count_el.textContent = '';
price_el.textContent = '';
return;
}
const time_opts = { hour: '2-digit', minute: '2-digit' };
list.replaceChildren(...all_slots.map(slot => {
const start = new Date(slot.start_utc);
const end = new Date(slot.end_utc);
const li = document.createElement('li');
li.className = 'rsv-summary-item';
li.innerHTML = `
<div class="rsv-summary-item-info">
<span class="rsv-summary-item-date">${start.toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long' })}</span>
<span class="rsv-summary-item-time">${start.toLocaleTimeString(locale, time_opts)} ${end.toLocaleTimeString(locale, time_opts)}</span>
</div>
${slot.price_per_block > 0 ? `<span class="rsv-summary-item-price">${slot.price_per_block} ${s.currency}</span>` : ''}
`;
return li;
}));
const total = all_slots.reduce((sum, slot) => sum + slot.price_per_block, 0);
count_el.textContent = this._fmt_count(n);
price_el.textContent = total > 0 ? `${total} ${s.currency}` : '';
}
_fmt_count(n) {
const s = ReservairStrings.summary;
if (n === 1) return s.count_one;
if (n < 5) return s.count_few.replace('%d', n);
return s.count_many.replace('%d', n);
}
}
customElements.define('rsv-reservation-summary', RsvReservationSummary);
+141
View File
@@ -0,0 +1,141 @@
class RsvTimeline extends HTMLElement {
static get observedAttributes() {
return ['timetable-id', 'date'];
}
// ---- Attribute accessors ------------------------------------------------
get timetableId() { return parseInt(this.getAttribute('timetable-id')); }
get date() {
const attr = this.getAttribute('date');
// Parse as local midnight so setHours() in block rendering stays in local time.
return attr ? new Date(attr + 'T12:00:00') : new Date();
}
set date(value) {
const d = new Date(value);
const str = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.setAttribute('date', str);
}
// ---- Lifecycle ----------------------------------------------------------
connectedCallback() {
this._version = 0;
this.classList.add('rsv-slots-list');
this.addEventListener('click', this._on_click.bind(this));
this._render();
}
attributeChangedCallback(_attr, oldVal, newVal) {
if (oldVal === newVal || !this.isConnected) return;
this._render();
}
// ---- Private ------------------------------------------------------------
_on_click(event) {
const slot = event.target.closest('.rsv-slots-slot');
if (slot && !slot.classList.contains('rsv-slots-slot-full')) {
slot.classList.toggle('rsv-slots-slot-selected');
slot.dispatchEvent(new Event('input', { bubbles: true }));
}
}
async _render() {
// Version guard: discard renders that were superseded by a newer call.
const v = ++this._version;
const s = ReservairStrings.timeline;
if (this.timetableId === null) {
this.replaceChildren(this._notice(s.not_reservable));
return;
}
try {
const occupancy = await RsvTimetableService.get_availability_for_date(this.timetableId, this.date);
if (v !== this._version) return;
if(occupancy.length === 0) {
this.replaceChildren(this._notice(s.no_blocks));
return;
}
const header = document.createElement('div');
header.classList.add('rsv-slots-label');
header.textContent = this.date.toLocaleDateString(navigator.language, {
weekday: 'long', day: 'numeric', month: 'long',
}).replace(',', '');
const blocks = [];
for (const { from_minutes, to_minutes, block_size_in_minutes, occupancy: block_occ } of occupancy) {
if (from_minutes === to_minutes || block_occ.length === 0) {
continue;
}
const from_block = parseInt(from_minutes) / block_size_in_minutes;
const time_slots = block_occ.map((occ, i) =>
this._block(this.date, occ, block_size_in_minutes, from_block + i)
);
const time_slot_group = document.createElement('div');
time_slot_group.classList.add('rsv-slots-group');
time_slot_group.replaceChildren(...time_slots);
blocks.push(time_slot_group);
}
this.replaceChildren(header, ...blocks);
} catch (_e) {
if (v !== this._version) return;
this.replaceChildren(this._notice(s.no_blocks));
}
}
_block(date, left, block_size, idx) {
const from = new Date(date);
from.setHours(0, idx * block_size, 0, 0);
const to = new Date(from);
to.setMinutes(to.getMinutes() + block_size);
const cell = document.createElement('div');
cell.classList.add('rsv-slots-slot', 'rsv-slots-slot-available');
cell.dataset.start_utc = from.toISOString();
cell.dataset.end_utc = to.toISOString();
if (left === 0) cell.classList.add('rsv-slots-slot-full');
const time_el = document.createElement('span');
time_el.classList.add('rsv-slots-slot-time');
time_el.textContent = `${this._fmt(from)} ${this._fmt(to)}`;
const badge = document.createElement('span');
badge.classList.add('rsv-slots-slot-badge');
const remaining_seats = left;
if (remaining_seats > 0) badge.classList.add('rsv-slots-slot-badge-available');
if (remaining_seats === 1) badge.textContent = `${remaining_seats} místo`;
else if (remaining_seats >= 2 && remaining_seats <= 4) badge.textContent = `${remaining_seats} místa`;
else badge.textContent = `${remaining_seats} míst`;
cell.append(time_el, badge);
return cell;
}
_notice(text) {
const p = document.createElement('p');
p.classList.add('rsv-slots-notice');
p.textContent = text;
return p;
}
_fmt(dt) {
return dt.getHours() + ':' + String(dt.getMinutes()).padStart(2, '0');
}
}
customElements.define('rsv-timeline', RsvTimeline);
+57
View File
@@ -0,0 +1,57 @@
/*
* RsvAdminForm — shared submit handler for wp-admin forms.
*
* Serializes a <form> to JSON (via RsvFormEncoder), sends it to the form's
* `action` using the HTTP verb in `data-method`, always attaches the REST
* nonce, and reports the outcome through show_notice(). The only part that
* legitimately differs between forms — shaping the request body — is handled
* by the optional `transform(body, form)` hook.
*
* Usage:
* RsvAdminForm.bind(my_form, {
* transform: (body, form) => ({ ...body, block_size: parseInt(body.block_size) }),
* refresh: () => my_datagrid.refresh(),
* });
*/
const RsvAdminForm = {
// Attach a submit listener that sends the form as JSON.
bind(form, options = {}) {
if (!form) return;
form.addEventListener('submit', (event) => {
event.preventDefault();
RsvAdminForm.submit(form, options);
});
},
// Send the form now. Returns the fetch promise.
submit(form, { transform, refresh, onSuccess } = {}) {
let body = RsvFormEncoder.encode_form(form);
if (transform) body = transform(body, form);
// `form.method` always returns a string (default 'get'), so default to POST
// explicitly unless the view opted into a verb via data-method.
const method = (form.dataset.method || 'POST').toUpperCase();
return fetch(form.action, {
method,
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-WP-Nonce': ReservairServiceAPI.nonce,
},
body: JSON.stringify(body),
})
.then(async (response) => {
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || data?.message || 'Request failed');
return data;
})
.then((data) => {
show_notice(form, 'success', form.dataset.successMsg ?? 'Saved.');
if (refresh) refresh();
if (onSuccess) onSuccess(data);
})
.catch((error) => show_notice(form, 'error', error.message));
},
};
+41
View File
@@ -0,0 +1,41 @@
const RsvFormEncoder = {
// Serialize form element into a plain JS object supporting arrays.
// - Nested keys supported with dot notation: 'meta.email'
// - Array notation supported with trailing [] (e.g. 'times[]') or multiple inputs with same name
encode_form(form_element) {
const formData = new FormData(form_element);
const body = {};
for (const [rawKey, value] of formData.entries()) {
const isArrayNotation = rawKey.endsWith('[]');
const key = isArrayNotation ? rawKey.slice(0, -2) : rawKey;
const keys = key.split('.');
let current = body;
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i];
if (current[k] === undefined || typeof current[k] !== 'object') {
current[k] = {};
}
current = current[k];
}
const lastKey = keys[keys.length - 1];
if (isArrayNotation) {
if (!Array.isArray(current[lastKey])) current[lastKey] = [];
current[lastKey].push(value);
} else {
if (current[lastKey] === undefined) {
current[lastKey] = value;
} else if (Array.isArray(current[lastKey])) {
current[lastKey].push(value);
} else {
current[lastKey] = [current[lastKey], value];
}
}
}
return body;
}
}
+142
View File
@@ -0,0 +1,142 @@
const RsvFormSender = {
get_form_url(form_id) {
return ReservairServiceAPI.restUrl + '/form/' + form_id;
},
clear_feedback(form) {
form.querySelectorAll('.rsv-field-error').forEach(el => el.remove());
form.querySelectorAll('.rsv-invalid').forEach(el => el.classList.remove('rsv-invalid'));
form.querySelector('.rsv-error-summary')?.remove();
},
show_errors(form, errors) {
this.clear_feedback(form);
const ul = document.createElement('ul');
for (const err of errors) {
const li = document.createElement('li');
li.textContent = err.message;
ul.appendChild(li);
if (err.element) {
const field = form.querySelector(`[name="${err.element}"]`);
if (field) {
field.classList.add('rsv-invalid');
const msg = document.createElement('span');
msg.classList.add('rsv-field-error');
msg.textContent = err.message;
field.insertAdjacentElement('afterend', msg);
}
}
}
const summary = document.createElement('div');
summary.classList.add('rsv-error-summary');
summary.appendChild(ul);
form.prepend(summary);
},
show_success(form, _data) {
const s = ReservairStrings.form;
const wrapper = form.parentElement;
const existing = Array.from(wrapper.children);
const svgNS = 'http://www.w3.org/2000/svg';
const path = document.createElementNS(svgNS, 'path');
path.setAttribute('d', 'M6 14l6 6L22 8');
path.setAttribute('stroke', '#16a34a');
path.setAttribute('stroke-width', '2.5');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('width', '28');
svg.setAttribute('height', '28');
svg.setAttribute('viewBox', '0 0 28 28');
svg.setAttribute('fill', 'none');
svg.appendChild(path);
const icon = document.createElement('div');
icon.className = 'success-icon';
icon.appendChild(svg);
const title = document.createElement('div');
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');
state.className = 'success-state';
state.append(icon, title, subtitle, reset_btn);
const msg = document.createElement('div');
msg.appendChild(state);
existing.forEach(child => child.style.display = 'none');
wrapper.appendChild(msg);
reset_btn.addEventListener('click', () => {
msg.remove();
form.reset();
this.clear_feedback(form);
existing.forEach(child => child.style.display = '');
});
},
set_loading(form, is_loading) {
const btn = form.querySelector('button[type="submit"], button:not([type])');
if (!btn) return;
btn.disabled = is_loading;
btn.classList.toggle('rsv-loading', is_loading);
},
encode_to_json(form) {
const fields = form.querySelectorAll('.rsv-form-field');
const body = {};
fields.forEach(field => {
const name = field.name ?? field.getAttribute('name');
try {
body[name] = JSON.parse(field.value);
} catch {
body[name] = field.value;
}
});
return body;
},
send_form(event) {
event.preventDefault();
const form = event.target;
this.clear_feedback(form);
this.set_loading(form, true);
const body = this.encode_to_json(form);
fetch(this.get_form_url(form.id), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
.then(async response => {
const data = await response.json().catch(() => null);
if (!response.ok) throw { status: response.status, body: data };
return data;
})
.then(data => {
this.show_success(form, data);
})
.catch(error => {
const errors = error?.body?.errors
?? [{ element: '', message: ReservairStrings.form.error_generic }];
this.show_errors(form, errors);
})
.finally(() => {
this.set_loading(form, false);
});
},
};
+229
View File
@@ -0,0 +1,229 @@
const RsvInlineFormBuilder = {
match_p(name, value) {
return (form) => String(form[name]) === String(value);
},
create(datasource) {
const fields = [];
const builder = {
datasource: datasource,
fieldset(legend, width = null) {
fields.push({ type: 'fieldset', legend, width });
return this;
},
input_text(name, label, value = '') {
fields.push({ type: 'text', name, label, value });
return this;
},
input_number(name, label, value = '') {
fields.push({ type: 'number', name, label, value });
return this;
},
input_date(name, label, value = '') {
fields.push({ type: 'date', name, label, value });
return this;
},
input_time(name, label, value = '') {
fields.push({ type: 'time', name, label, value });
return this;
},
input_textarea(name, label, value = '') {
fields.push({ type: 'textarea', name, label, value });
return this;
},
input_checkbox(name, label, checked = false) {
fields.push({ type: 'checkbox', name, label, checked });
return this;
},
input_hidden(name, value) {
fields.push({ type: 'hidden', name, value });
return this;
},
input_select(name, label, options, value = '') {
fields.push({ type: 'select', name, label, options, value });
return this;
},
show_if(predicate) {
const last = fields[fields.length - 1];
if (last) last.show_if = predicate;
return this;
},
build({ id, colspan = 1, save_label = 'Update', on_success, on_cancel } = {}) {
const td = document.createElement('td');
td.setAttribute('colspan', colspan);
const form = document.createElement('form');
const wrapper = document.createElement('div');
wrapper.classList.add('inline-edit-wrapper');
const hidden_inputs = [];
let current_fieldset = null;
let current_col = null;
const fieldsets = [];
const conditionals = [];
function ensure_fieldset() {
if (current_fieldset === null) {
current_fieldset = document.createElement('fieldset');
current_col = document.createElement('div');
current_col.classList.add('inline-edit-col');
fieldsets.push(current_fieldset);
}
}
for (const field of fields) {
if (field.type === 'hidden') {
hidden_inputs.push(field);
continue;
}
if (field.type === 'fieldset') {
if (current_fieldset !== null) {
current_fieldset.appendChild(current_col);
}
current_fieldset = document.createElement('fieldset');
if (field.width) current_fieldset.style.width = field.width;
const legend_el = document.createElement('legend');
legend_el.classList.add('inline-edit-legend');
legend_el.innerText = field.legend;
current_fieldset.appendChild(legend_el);
current_col = document.createElement('div');
current_col.classList.add('inline-edit-col');
fieldsets.push(current_fieldset);
continue;
}
ensure_fieldset();
const label_el = document.createElement('label');
const title = document.createElement('span');
title.classList.add('title');
title.innerText = field.label;
const wrap = document.createElement('span');
wrap.classList.add('input-text-wrap');
let input;
if (field.type === 'select') {
input = document.createElement('select');
input.name = field.name;
for (const opt of field.options) {
const option = document.createElement('option');
if (typeof opt === 'object' && opt !== null) {
option.value = opt.value;
option.textContent = opt.label;
option.selected = String(opt.value) === String(field.value);
} else {
option.value = opt;
option.textContent = opt;
option.selected = opt === field.value;
}
input.appendChild(option);
}
} else if (field.type === 'textarea') {
input = document.createElement('textarea');
input.name = field.name;
input.rows = 5;
input.style.width = '100%';
input.value = field.value ?? '';
} else {
input = document.createElement('input');
input.type = field.type;
input.name = field.name;
if (field.type === 'checkbox') {
input.checked = field.checked;
} else {
input.value = field.value ?? '';
}
}
wrap.appendChild(input);
label_el.replaceChildren(title, wrap);
current_col.appendChild(label_el);
if (field.show_if) conditionals.push({ label_el, predicate: field.show_if });
}
if (current_fieldset !== null) {
current_fieldset.appendChild(current_col);
}
const save_row = document.createElement('div');
save_row.classList.add('inline-edit-save', 'submit');
const error = document.createElement('div');
error.classList.add('notice', 'notice-error', 'notice-alt', 'inline', 'hidden');
const error_p = document.createElement('p');
error_p.classList.add('error');
error.appendChild(error_p);
const spinner = document.createElement('span');
spinner.classList.add('spinner');
const save_btn = document.createElement('button');
save_btn.type = 'button';
save_btn.classList.add('save', 'button', 'button-primary');
save_btn.innerText = save_label;
save_btn.onclick = () => {
const form_data = Object.fromEntries(new FormData(form));
for (const field of fields) {
if (field.type === 'checkbox') {
form_data[field.name] = field.name in form_data;
}
}
spinner.classList.add('is-active');
error.classList.add('hidden');
builder.datasource.put(id, form_data)
.then(() => { if (on_success) on_success(); })
.catch(err => {
error_p.innerText = err.message;
error.classList.remove('hidden');
})
.finally(() => spinner.classList.remove('is-active'));
};
const cancel_btn = document.createElement('button');
cancel_btn.type = 'button';
cancel_btn.classList.add('cancel', 'button');
cancel_btn.innerText = 'Cancel';
if (on_cancel) cancel_btn.onclick = on_cancel;
save_row.replaceChildren(save_btn, cancel_btn, spinner, error);
for (const h of hidden_inputs) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = h.name;
input.value = h.value ?? '';
form.appendChild(input);
}
wrapper.replaceChildren(...fieldsets, save_row);
form.appendChild(wrapper);
td.appendChild(form);
if (conditionals.length) {
const snapshot = () => {
const f = Object.fromEntries(new FormData(form));
for (const field of fields) if (field.type === 'checkbox') f[field.name] = field.name in f;
return f;
};
const sync_all = () => {
const f = snapshot();
for (const c of conditionals) c.label_el.classList.toggle('hidden', !c.predicate(f));
};
form.addEventListener('change', sync_all);
form.addEventListener('input', sync_all);
sync_all();
}
return td;
},
};
return builder;
},
};
+21
View File
@@ -0,0 +1,21 @@
const RsvTimetableService = {
get_all() {
return fetch(get_rest_url('timetable'), { method: 'GET' })
.then(r => {
if (!r.ok) throw new Error(`fetch timetables failed: ${r.status}`);
return r.json();
});
},
get_availability_for_date(timetable_id, date) {
const params = new URLSearchParams({
date: date.toISOString().slice(0, 10),
});
return fetch(get_rest_url(`timetable/${timetable_id}/availability?${params}`), { method: 'GET' })
.then(r => {
if (!r.ok) throw new Error(`fetch availability failed: ${r.status}`);
return r.json();
});
}
}
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
#
# Build an installable WordPress plugin ZIP for Reservair.
#
# Produces dist/reservair-<version>.zip containing a single top-level
# `reservair/` directory with ONLY the runtime files: the compiled Gutenberg
# block assets and production-only Composer dependencies.
#
# The build runs in an isolated temp directory, so it never touches your
# working-tree vendor/ or node_modules/ — safe to run locally or in CI.
#
# Usage:
# ./bin/build-zip.sh
#
# Environment overrides:
# OUTPUT_DIR directory the final zip is written to (default: <repo>/dist)
# SKIP_JS "1" to reuse the existing build/ dir (default: build fresh)
# KEEP_TMP "1" to keep the temp build dir for debug (default: removed)
#
set -euo pipefail
# --- locate repo root (this script lives in <root>/bin) ----------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$ROOT_DIR"
SLUG="reservair"
OUTPUT_DIR="${OUTPUT_DIR:-$ROOT_DIR/dist}"
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
# --- prerequisites -----------------------------------------------------------
need() { command -v "$1" >/dev/null 2>&1 || die "'$1' is required but not installed"; }
need composer
need zip
need rsync
[ "${SKIP_JS:-0}" = "1" ] || need npm
# --- read version from the plugin header (single source of truth) ------------
# `read` consumes only the first match and avoids a `| head` pipe (which would
# SIGPIPE the upstream sed under `pipefail` and abort the script with code 141).
VERSION=""
read -r VERSION < <(sed -n 's/^[[:space:]]*\*\{0,1\}[[:space:]]*Version:[[:space:]]*\([^[:space:]]*\).*/\1/p' "$ROOT_DIR/$SLUG.php") || true
[ -n "$VERSION" ] || die "could not read 'Version:' header from $SLUG.php"
log "Packaging $SLUG version $VERSION"
# --- isolated build dir (never touches your working vendor/ or node_modules) -
TMP_DIR="$(mktemp -d)"
BUILD_DIR="$TMP_DIR/src"
STAGE_DIR="$TMP_DIR/stage/$SLUG"
cleanup() { [ "${KEEP_TMP:-0}" = "1" ] || rm -rf "$TMP_DIR"; }
trap cleanup EXIT
mkdir -p "$BUILD_DIR" "$STAGE_DIR"
# --- copy the source tree into the isolated build dir ------------------------
log "Copying source into isolated build dir"
rsync -a \
--exclude='.git/' \
--exclude='node_modules/' \
--exclude='vendor/' \
--exclude='build/' \
--exclude='dist/' \
--exclude='.wp-env.json' \
./ "$BUILD_DIR/"
# --- compile the Gutenberg block (src/ -> build/) ----------------------------
if [ "${SKIP_JS:-0}" = "1" ]; then
[ -d "$ROOT_DIR/build" ] || die "SKIP_JS=1 but no build/ exists — run 'npm run build' first"
log "SKIP_JS=1 — reusing existing build/"
cp -R "$ROOT_DIR/build" "$BUILD_DIR/build"
else
log "Installing JS deps and building block assets"
( cd "$BUILD_DIR" && npm ci && npm run build )
fi
# --- install production-only PHP deps + regenerate autoloader ----------------
# This strips psalm/phpstan/wordpress-core etc. and rebuilds the classmap that
# loads the plugin's own includes/ classes — vendor/ is required at runtime.
log "Installing production Composer dependencies"
( cd "$BUILD_DIR" && composer install \
--no-dev --optimize-autoloader --classmap-authoritative \
--no-interaction --no-progress )
# --- stage only the runtime files -------------------------------------------
log "Staging runtime files"
RUNTIME=( "$SLUG.php" admin.php uninstall.php readme.txt includes modules build assets vendor )
for item in "${RUNTIME[@]}"; do
if [ -e "$BUILD_DIR/$item" ]; then
cp -R "$BUILD_DIR/$item" "$STAGE_DIR/"
else
log " (skipping missing $item)"
fi
done
# --- fail loudly if anything essential is missing ----------------------------
for required in "$SLUG.php" vendor/autoload.php build/block.json; do
[ -e "$STAGE_DIR/$required" ] || die "packaging incomplete: missing $required"
done
# --- zip it ------------------------------------------------------------------
mkdir -p "$OUTPUT_DIR"
ZIP_PATH="$OUTPUT_DIR/${SLUG}-${VERSION}.zip"
rm -f "$ZIP_PATH"
log "Creating $ZIP_PATH"
( cd "$TMP_DIR/stage" && zip -rq "$ZIP_PATH" "$SLUG" -x '*.DS_Store' )
log "Done → $ZIP_PATH"
# `|| true` keeps an early-closing `head` (SIGPIPE) from failing the script.
unzip -l "$ZIP_PATH" | tail -n +2 | head -n 12 || true
+30
View File
@@ -0,0 +1,30 @@
{
"require": {
"chillerlan/php-qrcode": "^5.0"
},
"autoload": {
"psr-4": {
"Reservair\\": "modules/"
},
"classmap": [
"includes/"
],
"files": [
"includes/RsvAdminMenuDefinition.php",
"includes/RsvAssetsDefinition.php",
"includes/RsvRestApiDefinition.php",
"includes/Views/RsvFormsPage.php",
"includes/Views/RsvReservationsPage.php",
"includes/Views/RsvTimetablePage.php",
"includes/Views/RsvGoogleCalendarSettingsPage.php"
]
},
"require-dev": {
"johnpbloch/wordpress-core": "^6.9",
"vimeo/psalm": "^6.16",
"humanmade/psalm-plugin-wordpress": "^3.1"
},
"scripts": {
"lint": ["phpcs", "phpstan analyse", "psalm --find-dead-code"]
}
}
Generated
+3945
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
<?php
const RSV_REST_API_BASE = 'reservations/';
@@ -0,0 +1,32 @@
<?php
class RsvFormController {
private $namespace;
private $resource_name;
public function __construct() {
$this->namespace = 'reservations/v1';
$this->resource_name = 'form';
}
public function register_routes(): void {
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>[^/]+)', [
'methods' => 'POST',
'callback' => [$this, 'handle'],
// Public: site visitors submit reservation forms. The handler validates
// the form definition and payload before persisting anything.
'permission_callback' => [RsvRestPolicy::class, 'open']
]);
}
function handle(WP_REST_Request $request) {
$submitter = new RsvFormSubmission();
$submit_result = $submitter->submit($request->get_param("id"), $request->get_json_params());
if(isset($submit_result['success']) && $submit_result['success'] === true) {
return new WP_REST_Response($submit_result, 200);
}
return new WP_REST_Response($submit_result, 400);
}
}
@@ -0,0 +1,119 @@
<?php
use Reservair\Logger\Logger;
class RsvFormDefinitionController {
use RsvPagedResponseTrait;
private string $namespace = 'reservations/v1';
private string $resource_name = 'form-definition';
private static function schema(): array {
return [
'type' => 'object',
'properties' => [
'form_id' => ['type' => 'integer', 'readonly' => true],
'name' => ['type' => 'string', 'required' => true, 'minLength' => 1],
'definition' => [
'type' => 'object',
'required' => false,
'properties' => [
'email_key' => ['type' => 'string', 'required' => false],
'elements' => ['type' => 'array', 'default' => []],
],
],
],
];
}
public function register_routes(): void {
register_rest_route($this->namespace, '/' . $this->resource_name, [
[
'methods' => 'GET',
'callback' => [$this, 'index'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
[
'methods' => 'POST',
'callback' => [$this, 'create'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
'args' => self::input_args(self::schema()),
],
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)', [
[
'methods' => 'GET',
'callback' => [$this, 'show'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
[
'methods' => 'PUT',
'callback' => [$this, 'update'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
'args' => self::input_args(self::schema()),
],
[
'methods' => 'DELETE',
'callback' => [$this, 'destroy'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
]);
}
function index(WP_REST_Request $request): WP_REST_Response {
[$skip, $limit] = self::paging($request);
$repo = new RsvFormDefinitionRepository();
return $this->paged_response($repo->get_all($limit, $skip), $repo->count_all());
}
function show(WP_REST_Request $request): WP_REST_Response {
$row = (new RsvFormDefinitionRepository())->get((int) $request->get_param('id'));
if ($row === null) {
return new WP_REST_Response(['error' => 'Not found'], 404);
}
return new WP_REST_Response($row, 200);
}
function create(WP_REST_Request $request): WP_REST_Response {
try {
$id = (new RsvFormDefinitionRepository())->add(
$request->get_param('name'),
$request->get_param('definition') ?? []
);
} catch(Throwable $e) {
Logger::error($e);
return new WP_REST_Response(['error' => 'An error occurred.'], 500);
}
return new WP_REST_Response(['id' => $id], 201);
}
function destroy(WP_REST_Request $request): WP_REST_Response {
$id = (int) $request->get_param('id');
$repo = new RsvFormDefinitionRepository();
if ($repo->get($id) === null) {
return new WP_REST_Response(['error' => 'Not found'], 404);
}
$repo->delete($id);
return new WP_REST_Response(null, 204);
}
function update(WP_REST_Request $request): WP_REST_Response {
$id = (int) $request->get_param('id');
$repo = new RsvFormDefinitionRepository();
if ($repo->get($id) === null) {
return new WP_REST_Response(['error' => 'Not found'], 404);
}
$repo->update($id, $request->get_param('name'), $request->get_param('definition'));
return new WP_REST_Response(null, 204);
}
}
@@ -0,0 +1,31 @@
<?php
trait RsvPagedResponseTrait {
private function paged_response(array $data, ?int $total = null): WP_REST_Response {
return new WP_REST_Response([
'total' => $total ?? count($data),
'data' => $data,
], 200);
}
/**
* Read pagination from the request as [skip, limit]. limit is clamped to
* 1..100 and defaults to 20; skip defaults to 0.
*
* @return array{0:int,1:int}
*/
private static function paging(WP_REST_Request $request): array {
$skip = max(0, (int) $request->get_param('skip'));
$limit = (int) $request->get_param('limit');
$limit = $limit > 0 ? min($limit, 100) : 20;
return [$skip, $limit];
}
/** Extract writable (non-readonly) properties from a schema for use as route args. */
private static function input_args(array $schema): array {
return array_filter(
$schema['properties'],
fn(array $prop): bool => empty($prop['readonly'])
);
}
}
@@ -0,0 +1,87 @@
<?php
class RsvReservationController {
use RsvPagedResponseTrait;
private $namespace;
private $resource_name;
public function __construct() {
$this->namespace = 'reservations/v1';
$this->resource_name = 'reservation';
}
public function register_routes(): void {
register_rest_route($this->namespace, '/' . $this->resource_name, [
'methods' => 'GET',
'callback' => [$this, 'get_all'],
'permission_callback' => [RsvRestPolicy::class, 'admin']
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)', [
'methods' => 'GET',
'callback' => [$this, 'get'],
'permission_callback' => [RsvRestPolicy::class, 'admin']
]);
register_rest_route($this->namespace, '/' . $this->resource_name, [
'methods' => 'POST',
'callback' => [$this, 'create'],
'permission_callback' => [RsvRestPolicy::class, 'admin']
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)/accept', [
'methods' => 'POST',
'callback' => [$this, 'accept_by_id'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)/refuse', [
'methods' => 'POST',
'callback' => [$this, 'refuse_by_id'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
]);
}
function get_all(WP_REST_Request $request) {
[$skip, $limit] = self::paging($request);
$service = new RsvReservationService();
return $this->paged_response((array) $service->get_all($limit, $skip), $service->count_all());
}
function get(WP_REST_Request $request): WP_REST_Response {
$service = new RsvReservationService();
$detail = $service->get_detail((int) $request->get_param('id'));
if ($detail === null) {
return new WP_REST_Response(['error' => 'Not found'], 404);
}
return new WP_REST_Response($detail, 200);
}
function create(WP_REST_Request $request) {
$service = new RsvReservationService();
$body = $request->get_json_params();
return $service->create(RsvReservation::from_array($body));
}
function accept_by_id(WP_REST_Request $request): WP_REST_Response {
try {
(new RsvTimetableReservationService())->accept_by_reservation_id((int) $request->get_param('id'));
return new WP_REST_Response(['status' => 'accepted'], 200);
} catch (InvalidArgumentException $e) {
return new WP_REST_Response(['error' => $e->getMessage()], 404);
}
}
function refuse_by_id(WP_REST_Request $request): WP_REST_Response {
try {
(new RsvTimetableReservationService())->refuse_by_reservation_id((int) $request->get_param('id'));
return new WP_REST_Response(['status' => 'refused'], 200);
} catch (InvalidArgumentException $e) {
return new WP_REST_Response(['error' => $e->getMessage()], 404);
}
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Authorization policy for the reservations/v1 REST API.
*
* Every route's `permission_callback` references one of these tiers so the
* intended audience is visible at the route definition:
*
* - admin(): requires the manage_reservations capability (see RsvCapabilities).
* - open(): genuinely public, OR a capability URL whose secret is validated
* inside the handler itself (confirmation codes, the Google webhook,
* the OAuth callback). Any `open()` route that is not fully public
* MUST authorise its caller from the request.
*/
final class RsvRestPolicy {
/** Administrative endpoints: managing timetables, capacities, forms, reservations. */
public static function admin(): bool|WP_Error {
if ( current_user_can( RsvCapabilities::MANAGE ) ) {
return true;
}
return new WP_Error(
'rsv_forbidden',
__( 'Sorry, you are not allowed to do that.', 'reservair' ),
// 401 when logged out, 403 when logged in but under-privileged.
[ 'status' => rest_authorization_required_code() ]
);
}
/** Public endpoints, and capability URLs validated inside the handler. */
public static function open(): bool {
return true;
}
}
@@ -0,0 +1,38 @@
<?php
use Reservair\Logger\Logger;
class RsvTimetableAvailabilityController {
private string $namespace = 'reservations/v1';
public function register_routes(): void {
register_rest_route($this->namespace, '/timetable/(?P<id>\d+)/availability', [
'methods' => 'GET',
'callback' => [$this, 'show'],
// Public: the booking widget reads availability for anonymous visitors.
'permission_callback' => [RsvRestPolicy::class, 'open'],
'args' => [
'date' => ['type' => 'string', 'required' => true, 'format' => 'date'],
],
]);
}
public function show(WP_REST_Request $request): WP_REST_Response {
$id = (int) $request->get_param('id');
$service = new RsvTimetableService();
$timetable = $service->get($id);
if ($timetable === null || $timetable->id === null) {
return new WP_REST_Response(['error' => 'Timetable not found'], 404);
}
try {
$availability = $service->get_availability_on_date($id, $timetable->block_size, new DateTime($request->get_param('date')));
} catch (Throwable $e) {
Logger::error($e);
return new WP_REST_Response(['error' => $e->getMessage()], 400);
}
return new WP_REST_Response($availability, 200);
}
}
@@ -0,0 +1,114 @@
<?php
class RsvTimetableCapacityController {
use RsvPagedResponseTrait;
private string $namespace = 'reservations/v1';
private string $resource_name = 'timetable/(?P<id>\d+)/capacity';
public function register_routes(): void {
register_rest_route($this->namespace, '/' . $this->resource_name, [
[
'methods' => 'GET',
'callback' => [$this, 'get_all'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
[
'methods' => 'POST',
'callback' => [$this, 'create'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
// 'args' => self::input_args(RsvTimetableCapacity::schema()),
],
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<capacity_id>\d+)', [
[
'methods' => 'GET',
'callback' => [$this, 'get'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
[
'methods' => 'PUT',
'callback' => [$this, 'update'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
'args' => self::input_args(RsvTimetableCapacity::schema()),
],
[
'methods' => 'DELETE',
'callback' => [$this, 'delete'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
]);
}
public function get_all(WP_REST_Request $request): WP_REST_Response {
[$skip, $limit] = self::paging($request);
$timetable_id = (int) $request->get_param('id');
$service = new RsvTimetableCapacityRepository();
return $this->paged_response(
$service->get_all($timetable_id, $limit, $skip),
$service->count_all($timetable_id)
);
}
public function get(WP_REST_Request $request): WP_REST_Response {
return new WP_REST_Response(
(new RsvTimetableCapacityRepository())->get((int) $request->get_param('capacity_id')),
200
);
}
public function create(WP_REST_Request $request): WP_REST_Response {
$items = $request->get_json_params();
$timetable_id = (int) $request->get_param('id');
$ids = [];
foreach($items as $item) {
$capacity = new RsvTimetableCapacity(
null,
$timetable_id,
(int) $item['capacity'],
(int) $item['min_lead_time_minutes'],
new DateTime($item['date']),
(int) $item['start_time'],
(int) $item['end_time'],
(int) $item['repeat_period_in_days'],
(int) $item['repeat_times'],
(bool) $item['requires_confirmation'],
);
$ids[] = (new RsvTimetableCapacityRepository())->create($capacity);
}
return new WP_REST_Response(
['ids' => $ids],
201
);
}
public function update(WP_REST_Request $request): WP_REST_Response {
$capacity = new RsvTimetableCapacity(
(int) $request->get_param('capacity_id'),
(int) $request->get_param('id'),
(int) $request->get_param('capacity'),
(int) $request->get_param('min_lead_time_minutes'),
new DateTime($request->get_param('date')),
(int)$request->get_param('start_time'),
(int)$request->get_param('end_time'),
(int) $request->get_param('repeat_period_in_days'),
(int) $request->get_param('repeat_times'),
(bool) $request->get_param('requires_confirmation'),
);
$capacity_id = (int) $request->get_param('capacity_id');
(new RsvTimetableCapacityRepository())->update($capacity_id, $capacity);
return new WP_REST_Response(['id' => $capacity_id], 200);
}
public function delete(WP_REST_Request $request): WP_REST_Response {
(new RsvTimetableCapacityRepository())->delete((int) $request->get_param('capacity_id'));
return new WP_REST_Response(null, 204);
}
}
@@ -0,0 +1,88 @@
<?php
class RsvTimetableDefinitionController {
use RsvPagedResponseTrait;
private string $namespace = 'reservations/v1';
private string $resource_name = 'timetable';
public function register_routes(): void {
register_rest_route($this->namespace, '/' . $this->resource_name, [
[
'methods' => 'GET',
'callback' => [$this, 'index'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
[
'methods' => 'POST',
'callback' => [$this, 'create'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
'args' => self::input_args(RsvTimetable::schema()),
],
]);
register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P<id>\d+)', [
[
'methods' => 'PATCH',
'callback' => [$this, 'update'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
'args' => self::input_args(RsvTimetable::schema()),
],
[
'methods' => 'DELETE',
'callback' => [$this, 'destroy'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
],
]);
}
public function index(WP_REST_Request $request): WP_REST_Response {
[$skip, $limit] = self::paging($request);
$service = new RsvTimetableService();
return $this->paged_response($service->get_all($limit, $skip), $service->count_all());
}
public function create(WP_REST_Request $request): WP_REST_Response {
$service = new RsvTimetableService();
$id = $service->create(new RsvTimetable([
'name' => $request->get_param('name'),
'block_size' => (int) $request->get_param('block_size'),
'maintainer_email' => $request->get_param('maintainer_email'),
]));
return new WP_REST_Response(['id' => $id], 201);
}
public function update(WP_REST_Request $request): WP_REST_Response {
$id = (int) $request->get_param('id');
$service = new RsvTimetableService();
$body = $request->get_json_params();
$timetable = $service->get($id);
if ($timetable === null) {
return new WP_REST_Response(['error' => 'Not found'], 404);
}
if (array_key_exists('name', $body)) $timetable->name = $body['name'];
if (array_key_exists('block_size', $body)) $timetable->block_size = (int) $body['block_size'];
if (array_key_exists('maintainer_email', $body)) $timetable->maintainer_email = $body['maintainer_email'] ?: null;
if (array_key_exists('google_calendar_id', $body)) $timetable->google_calendar_id = $body['google_calendar_id'] ?: null;
$service->update($id, $timetable);
return new WP_REST_Response(['id' => $id], 200);
}
public function destroy(WP_REST_Request $request): WP_REST_Response {
$id = (int) $request->get_param('id');
$service = new RsvTimetableService();
if ($service->get($id) === null) {
return new WP_REST_Response(['error' => 'Not found'], 404);
}
$service->delete($id);
return new WP_REST_Response(null, 204);
}
}
@@ -0,0 +1,62 @@
<?php
class RsvTimetableReservationController {
use RsvPagedResponseTrait;
private $namespace = 'reservations/v1';
private $resource_name = '/timetable/(?P<id>\d+)/reservation';
public function register_routes(): void {
register_rest_route($this->namespace, $this->resource_name, [
'methods' => 'GET',
'callback' => [$this, 'by_timetable'],
'permission_callback' => [RsvRestPolicy::class, 'admin'],
]);
register_rest_route($this->namespace, '/timetable-reservation/accept/(?P<code>[a-zA-Z0-9]+)', [
'methods' => 'GET',
'callback' => [$this, 'accept'],
// Capability URL: authorised by the secret confirmation code, which
// accept() validates against the database before changing state.
'permission_callback' => [RsvRestPolicy::class, 'open'],
]);
register_rest_route($this->namespace, '/timetable-reservation/refuse/(?P<code>[a-zA-Z0-9]+)', [
'methods' => 'GET',
'callback' => [$this, 'refuse'],
// Capability URL: authorised by the secret confirmation code, which
// refuse() validates against the database before changing state.
'permission_callback' => [RsvRestPolicy::class, 'open'],
]);
}
public function by_timetable(WP_REST_Request $request): WP_REST_Response {
[$skip, $limit] = self::paging($request);
$timetable_id = (int) $request->get_param('id');
$service = new RsvTimetableReservationService();
return $this->paged_response(
$service->get_by_timetable($timetable_id, $limit, $skip),
$service->count_by_timetable($timetable_id)
);
}
function accept(WP_REST_Request $request) {
try {
$service = new RsvTimetableReservationService();
$service->accept($request->get_param('code'));
return new WP_REST_Response(['status' => 'accepted'], 200);
} catch (InvalidArgumentException $e) {
return new WP_REST_Response(['error' => 'Invalid or expired confirmation code.'], 404);
}
}
function refuse(WP_REST_Request $request) {
try {
$service = new RsvTimetableReservationService();
$service->refuse($request->get_param('code'));
return new WP_REST_Response(['status' => 'refused'], 200);
} catch (InvalidArgumentException $e) {
return new WP_REST_Response(['error' => 'Invalid or expired confirmation code.'], 404);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
interface RsvEventBusInterface {
public function dispatch(object $event): void;
public function listen(string $event_class, callable $listener): void;
}
class RsvWordPressEventBus implements RsvEventBusInterface {
public function dispatch(object $event): void {
do_action(get_class($event), $event);
}
public function listen(string $event_class, callable $listener): void {
add_action($event_class, $listener);
}
}
class RsvEventDispatcher {
private static ?RsvEventBusInterface $bus = null;
public static function init(RsvEventBusInterface $bus): void {
self::$bus = $bus;
}
private static function bus(): RsvEventBusInterface {
// Fall back to the WordPress bus if init() was never called, so a stray
// dispatch/listen during early bootstrap can't fatal on an uninitialised
// typed property.
return self::$bus ??= new RsvWordPressEventBus();
}
public static function dispatch(object $event): void {
self::bus()->dispatch($event);
}
public static function listen(string $event_class, callable $listener): void {
self::bus()->listen($event_class, $listener);
}
}
@@ -0,0 +1,17 @@
<?php
/**
* Dispatched when every element of a form submission reaches its terminal
* state — i.e. the form transitions from open to closed.
*
* $accepted is true when every timetable item was confirmed, false when at
* least one was refused. It is resolved at dispatch time so listeners never
* need a second database round-trip.
*/
final class RsvFormSubmitClosedEvent {
public function __construct(
public int $form_submit_id,
public int $reservation_id,
public bool $accepted
) {}
}
@@ -0,0 +1,11 @@
<?php
/**
* Dispatched when every timetable item of a reservation is confirmed, i.e. the
* whole reservation has been accepted.
*/
class RsvReservationConfirmedEvent {
public function __construct(
public int $reservation_id
) {}
}
@@ -0,0 +1,11 @@
<?php
/**
* Dispatched when a reservation is refused (at least one of its timetable items
* was rejected), so the whole reservation can no longer be confirmed.
*/
class RsvReservationRefusedEvent {
public function __construct(
public int $reservation_id
) {}
}
@@ -0,0 +1,7 @@
<?php
class RsvTimetableReservationAcceptedEvent {
public function __construct(
public int $reservation_id
) {}
}
@@ -0,0 +1,7 @@
<?php
class RsvTimetableReservationCreatedEvent {
public function __construct(
public RsvTimetableReservation $reservation
) {}
}
@@ -0,0 +1,15 @@
<?php
/**
* Dispatched when a single timetable reservation item requires maintainer
* confirmation. Carries everything the email module needs so it never has to
* reach back into the reservation services.
*/
class RsvTimetableReservationPendingEvent {
public function __construct(
public int $reservation_id,
public RsvTimetableReservation $reservation,
public string $code,
public string $maintainer_email
) {}
}
@@ -0,0 +1,7 @@
<?php
class RsvTimetableReservationRefusedEvent {
public function __construct(
public int $reservation_id
) {}
}
+154
View File
@@ -0,0 +1,154 @@
<?php
use Reservair\Logger\Logger;
/**
* Handles all outgoing email for the reservation module.
*
* Two responsibilities:
* 1. Maintainer approval request — fires per timetable item that needs
* confirmation; template is hardcoded (not admin-configurable).
* 2. User notification on form close — fires once per form submission when
* every reservation item reaches a terminal state. Subject/body come from
* the form definition's reservation element `email_templates` attr.
*
* Exceptions are swallowed so a mail failure never rolls back a transaction.
*/
class RsvEmailListener {
private const string DEFAULT_PENDING_SUBJECT = 'Nová rezervace čeká na schválení';
private const string DEFAULT_PENDING_BODY = "
<h1>Nový požadavek o rezervaci</h1>
<p>Rezervace č. {{reservation_id}} čeká na vaše schválení.</p>
<p><strong>Datum:</strong> {{date}}</p>
<p><strong>Čas:</strong> {{start}} {{end}}</p>
<p>
<a href='{{accept_url}}'>Přijmout</a>
&nbsp;|&nbsp;
<a href='{{refuse_url}}'>Odmítnout</a>
</p>
";
private const string DEFAULT_ACCEPTED_SUBJECT = 'Rezervace přijata';
private const string DEFAULT_ACCEPTED_BODY = "
<h1>Vaše rezervace byla přijata</h1>
<p>Vaše rezervace byla schválena. Těšíme se na vás!</p>
";
private const string DEFAULT_REFUSED_SUBJECT = 'Rezervace zamítnuta';
private const string DEFAULT_REFUSED_BODY = "
<h1>Vaše rezervace byla zamítnuta</h1>
<p>Vaše rezervace bohužel nebyla schválena. Zkuste prosím jiný termín.</p>
";
public static function register(): void {
RsvEventDispatcher::listen(RsvTimetableReservationPendingEvent::class, [self::class, 'on_pending']);
RsvEventDispatcher::listen(RsvFormSubmitClosedEvent::class, [self::class, 'on_form_submit_closed']);
}
public static function on_pending(RsvTimetableReservationPendingEvent $event): void {
try {
$tz = wp_timezone();
$start_dt = (clone $event->reservation->start_utc)->setTimezone($tz);
$end_dt = (clone $event->reservation->end_utc)->setTimezone($tz);
$body = (new RsvEmailTemplater())->render(self::DEFAULT_PENDING_BODY, [
'reservation_id' => (string) $event->reservation_id,
'date' => esc_html($start_dt->format('Y-m-d')),
'start' => esc_html($start_dt->format('H:i')),
'end' => esc_html($end_dt->format('H:i')),
'accept_url' => esc_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)),
]);
(new RsvEmailSender())->send($event->maintainer_email, self::DEFAULT_PENDING_SUBJECT, $body);
} catch (\Throwable $e) {
Logger::error($e);
}
}
/**
* 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 {
try {
$form_submit = (new RsvFormSubmitRepository())->get($event->form_submit_id);
if ($form_submit === null) {
Logger::warning("on_form_submit_closed: form_submit {$event->form_submit_id} not found");
return;
}
$definition = (new RsvFormDefinitionRepository())->get((int) $form_submit['form_id']);
if ($definition === null) {
Logger::warning("on_form_submit_closed: form definition for submit {$event->form_submit_id} not found");
return;
}
$elements = $definition['definition']['elements'] ?? [];
$reservation_element = null;
foreach ($elements as $el) {
if (($el['type'] ?? '') === 'reservation') {
$reservation_element = $el;
break;
}
}
if ($reservation_element === null) {
return;
}
$template_key = $event->accepted ? 'on_accepted' : 'on_refused';
$templates = $reservation_element['email_templates'] ?? [];
$tpl = $templates[$template_key] ?? [];
$tpl_enabled = $tpl['enabled'] ?? null;
$enabled = is_bool($tpl_enabled) ? $tpl_enabled : ($template_key === 'on_accepted');
if (!$enabled) {
return;
}
$email_key = $definition['definition']['email_key'] ?? null;
$form_values = $form_submit['values'] ?? [];
$user_email = is_string($email_key) && $email_key !== '' ? ($form_values[$email_key] ?? null) : null;
if ($user_email === null || $user_email === '') {
Logger::warning("on_form_submit_closed: no user email for submit {$event->form_submit_id}");
return;
}
$default_subject = $event->accepted ? self::DEFAULT_ACCEPTED_SUBJECT : self::DEFAULT_REFUSED_SUBJECT;
$default_body = $event->accepted ? self::DEFAULT_ACCEPTED_BODY : self::DEFAULT_REFUSED_BODY;
// Treat blank (admin cleared the field) the same as "use the default".
$subject_tpl = trim((string) ($tpl['subject'] ?? '')) !== '' ? $tpl['subject'] : $default_subject;
$body_tpl = trim((string) ($tpl['body'] ?? '')) !== '' ? $tpl['body'] : $default_body;
$templater = new RsvEmailTemplater();
// Subject is plain text: render with raw values, then strip any tags
// or newlines to avoid header issues.
$subject = sanitize_text_field($templater->render($subject_tpl, $form_values));
// Body is HTML: escape the user-submitted values before interpolation
// so they can't inject markup into the message.
$body = $templater->render($body_tpl, self::escape_values($form_values));
(new RsvEmailSender())->send($user_email, $subject, $body);
} catch (\Throwable $e) {
Logger::error($e);
}
}
}
@@ -0,0 +1,59 @@
<?php
class RsvGoogleCalendarListener {
public static function register(): void {
RsvEventDispatcher::listen(RsvTimetableReservationCreatedEvent::class, [self::class, 'on_created']);
RsvEventDispatcher::listen(RsvTimetableReservationAcceptedEvent::class, [self::class, 'on_accepted']);
RsvEventDispatcher::listen(RsvTimetableReservationRefusedEvent::class, [self::class, 'on_refused']);
}
public static function on_created(RsvTimetableReservationCreatedEvent $event): void {
try {
$gcal = new RsvGoogleCalendarService();
if (!$gcal->is_google_connected()) {
return;
}
$calendar_id = self::resolve_calendar_id($gcal, $event->timetable_id);
if (!$calendar_id) {
return;
}
$status = $event->reservation->requires_confirmation ? 'tentative' : 'confirmed';
$gcal->add_event(
$calendar_id,
"Reservation #{$event->reservation->id}",
$event->reservation->start,
$event->reservation->end,
$event->reservation->user_email,
$event->reservation->id,
$status,
);
} catch (\Throwable $e) {
error_log('RsvGoogleCalendarListener::on_created: ' . $e->getMessage());
}
}
public static function on_accepted(RsvTimetableReservationAcceptedEvent $event): void {
// Future: update the calendar event status to 'confirmed'.
}
public static function on_refused(RsvTimetableReservationRefusedEvent $event): void {
// Future: cancel the calendar event.
}
// -------------------------------------------------------------------------
private static function resolve_calendar_id(RsvGoogleCalendarService $gcal, int $timetable_id): ?string {
$timetable_service = new RsvTimetableService();
$timetable = $timetable_service->get($timetable_id);
if ($timetable && !empty($timetable->google_calendar_id)) {
return $timetable->google_calendar_id;
}
$global = $gcal->get_calendar_id();
return $global ?: null;
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
class RsvReservation {
public ?int $id;
public int $form_submit_id;
public bool|null $is_confirmed;
public array $timetable_reservations;
public static function schema(): array {
return [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer', 'readonly' => true],
'form_submit_id' => ['type' => 'integer', 'readonly' => true],
'is_confirmed' => ['type' => 'boolean', 'readonly' => true],
'timetable_reservations' => [
'type' => 'array',
'required' => true,
'items' => RsvTimetableReservation::schema(),
],
],
];
}
public static function from_array(array $data) : self {
return new self(
intval($data['id'] ?? null),
intval($data['form_submit_id'] ?? null),
$data['is_confirmed'] ?? null,
array_map(fn($t) =>
new RsvTimetableReservation(null, $data['timetable_id'], $t['start'], $t['end']),
$data['timetable_reservations'] ?? [])
);
}
public function __construct(?int $id, int $form_submit_id, bool|null $is_confirmed, array $timetable_reservations)
{
$this->id = $id;
$this->form_submit_id = $form_submit_id;
$this->is_confirmed = $is_confirmed;
$this->timetable_reservations = $timetable_reservations;
}
public function to_array() {
return [
'id' => $this->id,
'form_submit_id' => $this->form_submit_id,
'is_confirmed' => $this->is_confirmed,
// 'timetable_reservations' => $this->timetable_reservations,
];
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
class RsvReservationTypeConfigurationStep {
public int $index;
public string $type;
public array|null $configuration;
public function __construct(array $data) {
$this->index = $data['index'];
$this->type = $data['type'];
$this->configuration = $data['configuration'] ?? null;
}
}
class RsvReservationTypeConfiguration {
public array $steps = [];
public function __construct(array $data) {
$this->steps = [];
foreach( $data['steps'] as $step ) {
array_push( $this->steps, new RsvReservationTypeConfigurationStep($step) );
}
}
}
class RsvReservationType {
public int $id;
public string $name;
public string $description;
public $configuration;
public function __construct(array $data) {
$this->id = $data['id'];
$this->name = $data['name'];
$this->description = $data['description'];
$this->configuration = json_decode($data['configuration'], true);
}
public function to_array() {
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'configuration' => json_encode($this->configuration)
];
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
class RsvTimetable {
public int|null $id;
public string $name;
public int $block_size;
public ?string $google_calendar_id;
public ?string $maintainer_email;
public static function schema(): array {
return [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer', 'readonly' => true],
'name' => ['type' => 'string', 'required' => true, 'minLength' => 1],
'block_size' => ['type' => 'integer', 'required' => true, 'minimum' => 1],
'maintainer_email' => ['type' => ['string', 'null'], 'format' => 'email'],
'google_calendar_id' => ['type' => ['string', 'null']],
],
];
}
public function __construct(array $data) {
$this->id = $data['id'] ?? null;
$this->name = $data['name'];
$this->block_size = $data['block_size'] ?? 0;
$this->google_calendar_id = $data['google_calendar_id'] ?? null;
$this->maintainer_email = $data['maintainer_email'] ?? null;
}
}
@@ -0,0 +1,21 @@
<?php
/**
* Number of available seats for each time block
*/
class RsvTimetableAvailability {
/**
* @param array<int,int> $occupancy Number of available seats for each time block
*/
public function __construct(
public int $from_minutes,
public int $to_minutes,
public int $block_size_in_minutes,
public array $occupancy
) { }
public function push_block(int $capacity) {
$this->occupancy[] = $capacity;
$this->to_minutes += $this->block_size_in_minutes;
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
class RsvTimetableCapacity {
public int|null $id;
public int $timetable_id;
public int $capacity;
public int $min_lead_time_minutes;
public DateTime $date;
public int $start_time;
public int $end_time;
public int $repeat_period_in_days;
public int $repeat_times;
public bool $requires_confirmation;
public static function schema(): array {
return [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer', 'readonly' => true],
'timetable_id' => ['type' => 'integer', 'readonly' => true],
'capacity' => ['type' => 'integer', 'required' => true, 'minimum' => 1],
'min_lead_time_minutes' => ['type' => 'integer', 'required' => true, 'minimum' => 0],
'date' => ['type' => 'string', 'required' => true, 'format' => 'date'],
'start_time' => ['type' => 'integer', 'required' => true, 'minimum' => 0],
'end_time' => ['type' => 'integer', 'required' => true, 'minimum' => 0],
'repeat_period_in_days' => ['type' => 'integer', 'required' => true, 'minimum' => 0],
'repeat_times' => ['type' => 'integer', 'required' => true, 'minimum' => 0],
'requires_confirmation' => ['type' => 'boolean'],
],
];
}
public static function from_array(array $data): self {
return new self(
$data['id'],
$data['timetable_id'],
$data['capacity'],
$data['min_lead_time_minutes'],
new DateTime($data['date']),
$data['start_time'],
$data['end_time'],
$data['repeat_period_in_days'],
$data['repeat_times'],
$data['requires_confirmation'] ?? false
);
}
public function __construct(
int|null $id,
int $timetable_id,
int $capacity,
int $min_lead_time_minutes,
DateTime $date,
int $start_time,
int $end_time,
int|null $repeat_period_in_days,
int|null $repeat_times,
bool $requires_confirmation
) {
$this->id = $id;
$this->timetable_id = $timetable_id;
$this->capacity = $capacity;
$this->min_lead_time_minutes = $min_lead_time_minutes;
$this->date = $date;
$this->start_time = $start_time;
$this->end_time = $end_time;
$this->repeat_period_in_days = $repeat_period_in_days;
$this->repeat_times = $repeat_times;
$this->requires_confirmation = $requires_confirmation;
// $this->repeat_times = $data['repeat_times'];
// $this->requires_confirmation = $data['requires_confirmation'] ?? false;
}
public function to_array(): array {
return [
'id' => $this->id,
'timetable_id' => $this->timetable_id,
'capacity' => $this->capacity,
'min_lead_time_minutes' => $this->min_lead_time_minutes,
'date' => $this->date->format('Y-m-d'),
'start_time' => $this->start_time,
'end_time' => $this->end_time,
'repeat_period_in_days' => $this->repeat_period_in_days,
'repeat_times' => $this->repeat_times,
'requires_confirmation' => $this->requires_confirmation,
];
}
}
@@ -0,0 +1,46 @@
<?php
class RsvTimetableReservation {
public int|null $id;
public int $timetable_id;
public DateTime $start_utc; // UTC, 'Y-m-d H:i:s'
public DateTime $end_utc; // UTC, 'Y-m-d H:i:s'
public static function schema(): array {
return [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer', 'readonly' => true],
'timetable_id' => ['type' => 'integer', 'required' => true],
'start_utc' => ['type' => 'string', 'required' => true, 'format' => 'date-time'],
'end_utc' => ['type' => 'string', 'required' => true, 'format' => 'date-time'],
],
];
}
public function __construct(int|null $id, int $timetable_id, DateTime $start_utc, DateTime $end_utc) {
$this->id = $id;
$this->timetable_id = $timetable_id;
$this->start_utc = $start_utc;
$this->end_utc = $end_utc;
}
public static function from_array(array $data): self {
$utc = new DateTimeZone('UTC');
return new self(
$data['id'] ?? null,
(int) $data['timetable_id'],
new DateTime($data['start_utc'], $utc),
new DateTime($data['end_utc'], $utc),
);
}
public function to_array(): array {
return [
'id' => $this->id,
'timetable_id' => $this->timetable_id,
'start_utc' => $this->start_utc->format('Y-m-d H:i:s'),
'end_utc' => $this->end_utc->format('Y-m-d H:i:s'),
];
}
}
+15
View File
@@ -0,0 +1,15 @@
# PHP Code Structure
The PHP side combines processing and drawing forms.
## Views
Output side of the web-interface layer to the application.
## Services
Application-layer classes.
## Controllers
API classes.
@@ -0,0 +1,65 @@
<?php
use Reservair\Database\Db;
use Reservair\Logger\Logger;
class RsvFormDefinitionRepository {
private string $table;
public function __construct() {
$this->table = Db::prefix() . 'rsv_form_definition';
}
public function add(string $name, array $definition): int {
return Db::insert($this->table, [
'name' => $name,
'definition' => json_encode($definition),
]);
}
public function get_all(?int $limit = null, int $skip = 0): array {
if ($limit === null) {
return Db::get_results(
"SELECT form_id, name FROM {$this->table} ORDER BY form_id ASC",
[],
ARRAY_A
);
}
return Db::get_results(
"SELECT form_id, name FROM {$this->table} ORDER BY form_id ASC LIMIT %d OFFSET %d",
[$limit, $skip],
ARRAY_A
);
}
public function count_all(): int {
return (int) Db::get_var("SELECT COUNT(*) FROM {$this->table}");
}
public function update(int $id, string $name, array $definition): void {
Db::update(
$this->table,
['name' => $name, 'definition' => json_encode($definition)],
['form_id' => $id]
);
}
public function delete(int $id): void {
Db::delete($this->table, ['form_id' => $id]);
}
public function get(int $id): ?array {
$row = Db::get_row(
"SELECT * FROM {$this->table} WHERE form_id = %d",
[$id],
ARRAY_A
);
if ($row === null) {
return null;
}
$row['definition'] = json_decode($row['definition'], true);
return $row;
}
}
@@ -0,0 +1,37 @@
<?php
use Reservair\Database\Db;
class RsvFormSubmitRepository {
private string $table;
public function __construct() {
$this->table = Db::prefix() . 'rsv_form_submit';
}
public function add(int $form_id, array $values): int {
return Db::insert($this->table, [
'form_id' => $form_id,
'values' => json_encode($values),
]);
}
public function delete(int $id): void {
Db::delete($this->table, ['form_submit_id' => $id]);
}
public function get(int $id): ?array {
$row = Db::get_row(
"SELECT * FROM {$this->table} WHERE form_submit_id = %d",
[$id],
ARRAY_A
);
if ($row === null) {
return null;
}
$row['values'] = json_decode($row['values'], true);
return $row;
}
}
@@ -0,0 +1,125 @@
<?php
use Reservair\Database\Db;
class RsvReservationRepository {
private string $table;
private string $timetable_reservations_table;
public function __construct() {
$this->table = Db::prefix() . 'rsv_reservation';
$this->timetable_reservations_table = Db::prefix() . 'rsv_timetable_reservation';
}
public function get_all(?int $limit = null, int $skip = 0) {
if ($limit === null) {
return Db::get_results("SELECT * FROM {$this->table} ORDER BY id DESC");
}
return Db::get_results(
"SELECT * FROM {$this->table} ORDER BY id DESC LIMIT %d OFFSET %d",
[$limit, $skip]
);
}
public function count_all(): int {
return (int) Db::get_var("SELECT COUNT(*) FROM {$this->table}");
}
public function get(int $id) {
return Db::get_row(
"SELECT * FROM {$this->table} WHERE id = %d",
[$id]
);
}
public function get_detail(int $id): ?array {
$reservation = Db::get_row(
"SELECT * FROM {$this->table} WHERE id = %d",
[$id],
ARRAY_A
);
if ($reservation === null) {
return null;
}
$form_submit_table = Db::prefix() . 'rsv_form_submit';
$form_submit = Db::get_row(
"SELECT `values` FROM {$form_submit_table} WHERE form_submit_id = %d",
[(int) $reservation['form_submit_id']],
ARRAY_A
);
$reservation['form_values'] = $form_submit ? json_decode($form_submit['values'], true) : null;
$reservation['timetable_reservations'] = Db::get_results(
"SELECT id, timetable_id, `start_utc`, `end_utc` FROM {$this->timetable_reservations_table} WHERE reservation_id = %d",
[$id],
ARRAY_A
);
$confirmation_table = Db::prefix() . 'rsv_timetable_reservation_confirmation';
$reservation['pending_confirmation'] = (int) Db::get_var(
"SELECT COUNT(*) FROM {$confirmation_table} c
JOIN {$this->timetable_reservations_table} tr ON tr.id = c.timetable_reservation_id
WHERE tr.reservation_id = %d",
[$id]
) > 0;
return $reservation;
}
public function insert(array $data): int {
return Db::insert($this->table, $data);
}
public function delete(int $id): void {
Db::delete($this->table, ['id' => $id]);
}
public function count_subitems(int $reservation_id): int {
return (int) Db::get_var(
"SELECT COUNT(*) FROM {$this->timetable_reservations_table} WHERE reservation_id = %d",
[$reservation_id]
);
}
public function are_subitems_confirmed(int $reservation_id): bool {
$result = Db::get_row(
"SELECT MIN(rtr.is_confirmed) AS is_confirmed
FROM {$this->table} AS rr
LEFT JOIN {$this->timetable_reservations_table} AS rtr ON rr.id = rtr.reservation_id
WHERE rr.id = %d",
[$reservation_id]
);
return $result !== null && $result->is_confirmed == 1;
}
/**
* Whether the reservation has reached a terminal state. `is_confirmed` is
* NULL while pending, 1 once confirmed and 0 once refused, so any non-NULL
* value means the outcome is settled.
*/
public function is_resolved(int $reservation_id): bool {
$result = Db::get_row(
"SELECT is_confirmed FROM {$this->table} WHERE id = %d",
[$reservation_id]
);
return $result !== null && $result->is_confirmed !== null && $result->is_confirmed;
}
public function set_resolved_state(int $reservation_id, bool $confirmed): void {
Db::update(
$this->table,
['is_confirmed' => $confirmed ? 1 : 0],
['id' => $reservation_id]
);
}
public function get_form_submit_id(int $reservation_id): ?int {
$result = Db::get_var(
"SELECT form_submit_id FROM {$this->table} WHERE id = %d",
[$reservation_id]
);
return $result !== null ? (int) $result : null;
}
}
@@ -0,0 +1,104 @@
<?php
use Reservair\Database\Db;
class RsvTimetableCapacityRepository {
private string $table;
public function __construct() {
$this->table = Db::prefix() . 'rsv_timetable_capacity';
}
public function get_all($timetable_id, ?int $limit = null, int $skip = 0): array {
if ($limit === null) {
return Db::get_results(
"SELECT * FROM {$this->table} WHERE timetable_id = %d ORDER BY id",
[$timetable_id]
);
}
return Db::get_results(
"SELECT * FROM {$this->table} WHERE timetable_id = %d ORDER BY id LIMIT %d OFFSET %d",
[$timetable_id, $limit, $skip]
);
}
public function count_all($timetable_id): int {
return (int) Db::get_var(
"SELECT COUNT(*) FROM {$this->table} WHERE timetable_id = %d",
[$timetable_id]
);
}
public function get(int $id): ?RsvTimetableCapacity {
$row = Db::get_row(
"SELECT * FROM {$this->table} WHERE id = %d",
[$id],
ARRAY_A
);
return $row === null ? null : RsvTimetableCapacity::from_array($row);
}
public function create(RsvTimetableCapacity $capacity): int {
return Db::insert($this->table, $capacity->to_array());
}
public function delete(int $id): void {
Db::delete($this->table, ['id' => $id]);
}
public function update(int $id, RsvTimetableCapacity $capacity): int {
$capacity->id = $id;
return Db::update($this->table, $capacity->to_array(), ['id' => $id]);
}
public function get_overlapping_capacity(int $timetable_id, DateTime $start, DateTime $end): array {
$start_str = (clone $start)->setTimezone(wp_timezone())->format('Y-m-d H:i:s');
$end_str = (clone $end)->setTimezone(wp_timezone())->format('Y-m-d H:i:s');
return Db::get_results(
"SELECT * FROM {$this->table}
WHERE timetable_id = %d
AND (
date = DATE(%s)
OR (
repeat_period_in_days > 0
AND DATEDIFF(DATE(%s), date) >= 0
AND MOD(DATEDIFF(DATE(%s), date), repeat_period_in_days) = 0
)
)
AND DATE_ADD(DATE(%s), INTERVAL start_time MINUTE) < %s
AND DATE_ADD(DATE(%s), INTERVAL end_time MINUTE) > %s
ORDER BY start_time",
[$timetable_id, $start_str, $start_str, $start_str, $start_str, $end_str, $start_str, $start_str]
);
}
public function get_capacities_for_date(int $timetable_id, DateTime $date) : array {
$row = Db::get_results(
"SELECT *
FROM {$this->table}
WHERE timetable_id = %d
AND (date = DATE(%s) OR (repeat_period_in_days > 0 AND MOD(DATEDIFF(date, %s), repeat_period_in_days) = 0))
ORDER BY start_time ASC",
[$timetable_id, $date->format('Y-m-d'), $date->format('Y-m-d')],
ARRAY_A
);
return array_map(fn($x) => RsvTimetableCapacity::from_array($x), $row);
}
public function get_available_range_for_date(int $timetable_id, DateTime $date) {
if ($date === null) {
throw new InvalidArgumentException('Invalid date');
}
return Db::get_row(
"SELECT MAX(start_time) AS `from`, MIN(end_time) AS `to`
FROM {$this->table}
WHERE timetable_id = %d
AND (date = DATE(%s) OR (repeat_period_in_days > 0 AND MOD(DATEDIFF(date, %s), repeat_period_in_days) = 0))",
[$timetable_id, $date->format('Y-m-d'), $date->format('Y-m-d')]
);
}
}
@@ -0,0 +1,83 @@
<?php
use Reservair\Database\Db;
class RsvTimetableRepository {
private string $table;
public function __construct() {
$this->table = Db::prefix() . 'rsv_timetable';
}
public function get_all(?int $limit = null, int $skip = 0): array {
if ($limit === null) {
return Db::get_results("SELECT * FROM {$this->table} ORDER BY id");
}
return Db::get_results(
"SELECT * FROM {$this->table} ORDER BY id LIMIT %d OFFSET %d",
[$limit, $skip]
);
}
public function count_all(): int {
return (int) Db::get_var("SELECT COUNT(*) FROM {$this->table}");
}
public function get(int $id): ?RsvTimetable {
$row = Db::get_row(
"SELECT * FROM {$this->table} WHERE id = %d",
[$id],
ARRAY_A
);
if ($row === null) {
return null;
}
return new RsvTimetable($row);
}
public function create(RsvTimetable $timetable): int {
return Db::insert($this->table, [
'name' => $timetable->name,
'block_size' => $timetable->block_size,
'maintainer_email' => $timetable->maintainer_email,
]);
}
public function update(int $id, RsvTimetable $timetable): int {
return Db::update(
$this->table,
[
'name' => $timetable->name,
'block_size' => $timetable->block_size,
'maintainer_email' => $timetable->maintainer_email,
],
['id' => $id]
);
}
public function get_all_maintainer_emails(): array {
return Db::get_col(
"SELECT DISTINCT maintainer_email FROM {$this->table}
WHERE maintainer_email IS NOT NULL AND maintainer_email != ''"
);
}
public function get_maintainer_email(int $id): ?string {
return Db::get_var(
"SELECT maintainer_email FROM {$this->table} WHERE id = %d",
[$id]
) ?: null;
}
public function set_google_calendar_id(int $id, ?string $calendar_id): void {
Db::update(
$this->table,
['google_calendar_id' => $calendar_id],
['id' => $id]
);
}
public function delete(int $id): int {
return Db::delete($this->table, ['id' => $id]);
}
}
@@ -0,0 +1,129 @@
<?php
use Reservair\Database\Db;
class RsvTimetableReservationRepository {
private string $table;
private string $confirmation_table;
public function __construct() {
$this->table = Db::prefix() . 'rsv_timetable_reservation';
$this->confirmation_table = Db::prefix() . 'rsv_timetable_reservation_confirmation';
}
public function get_all(): array {
return array_map(
'RsvTimetableReservation::from_array',
Db::get_results("SELECT * FROM {$this->table}")
);
}
public function get(int $id): RsvTimetableReservation {
return RsvTimetableReservation::from_array(Db::get_row(
"SELECT * FROM {$this->table} WHERE id = %d",
[$id],
ARRAY_A
));
}
public function get_overlapping(int $timetable_id, DateTime $start_utc, DateTime $end_utc): array {
return array_map(
'RsvTimetableReservation::from_array',
Db::get_results(
"SELECT * FROM {$this->table}
WHERE timetable_id = %d
AND `start_utc` < %s
AND `end_utc` > %s",
[$timetable_id, $end_utc, $start_utc]
)
);
}
public function insert(array $data): int {
return Db::insert($this->table, $data);
}
public function insert_confirmation(int $reservation_id, int $timetable_reservation_id, string $code): void {
Db::insert($this->confirmation_table, [
'reservation_id' => $reservation_id,
'timetable_reservation_id' => $timetable_reservation_id,
'code' => $code,
]);
}
public function get_confirmation(string $code): ?array {
return Db::get_row(
"SELECT c.*
FROM {$this->confirmation_table} c
JOIN {$this->table} tr ON tr.id = c.timetable_reservation_id
WHERE c.code = %s
LIMIT 1",
[$code],
ARRAY_A
);
}
public function set_confirmed(int $timetable_reservation_id, bool $state): void {
Db::update(
$this->table,
['is_confirmed' => $state ? 1 : 0],
['id' => $timetable_reservation_id]
);
}
public function delete_confirmation(string $code): void {
Db::delete($this->confirmation_table, ['code' => $code]);
}
public function has_pending_confirmation(int $reservation_id): bool {
return (int) Db::get_var(
"SELECT COUNT(*) FROM {$this->confirmation_table} c
JOIN {$this->table} tr ON tr.id = c.timetable_reservation_id
WHERE tr.reservation_id = %d",
[$reservation_id]
) > 0;
}
public function get_confirmation_code(int $reservation_id): ?string {
return Db::get_var(
"SELECT c.code FROM {$this->confirmation_table} c
JOIN {$this->table} tr ON tr.id = c.timetable_reservation_id
WHERE tr.reservation_id = %d
LIMIT 1",
[$reservation_id]
);
}
public function get_by_timetable(int $timetable_id, ?int $limit = null, int $skip = 0): array {
$sql = "SELECT tr.*, c.timetable_reservation_id AS pending_confirmation_id
FROM {$this->table} tr
LEFT JOIN {$this->confirmation_table} c ON c.timetable_reservation_id = tr.id
WHERE tr.timetable_id = %d
ORDER BY tr.start_utc DESC";
if ($limit === null) {
return Db::get_results($sql, [$timetable_id], ARRAY_A);
}
return Db::get_results($sql . " LIMIT %d OFFSET %d", [$timetable_id, $limit, $skip], ARRAY_A);
}
public function count_by_timetable(int $timetable_id): int {
return (int) Db::get_var(
"SELECT COUNT(*) FROM {$this->table} WHERE timetable_id = %d",
[$timetable_id]
);
}
public function get_reservations_on_date(int $timetable_id, DateTime $date_utc): array {
return array_map(
'RsvTimetableReservation::from_array',
Db::get_results(
"SELECT * FROM {$this->table}
WHERE timetable_id = %d AND DATE(`start_utc`) = DATE(%s)
ORDER BY `start_utc`",
[$timetable_id, $date_utc->format('Y-m-d')],
ARRAY_A
)
);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
/**
* Contains definitions of the admin menus
*/
function rsv_admin_menu_definition() {
add_menu_page(
'Reservations Settings', // Page title
'Reservations', // Menu title
RsvCapabilities::MANAGE, // Capability
'reservations-settings', // Menu slug
'rsv_reservations_page', // Callback
'dashicons-calendar', // Icon
20 // Position
);
add_submenu_page(
'reservations-settings',
'Forms',
'Forms',
RsvCapabilities::MANAGE,
'forms-settings',
'rsv_forms_page'
);
add_submenu_page(
'reservations-settings',
'Timetables',
'Timetables',
RsvCapabilities::MANAGE,
'timetable-settings',
'rsv_timetable_page'
);
add_submenu_page(
'reservations-settings',
'Google Calendar',
'Google Calendar',
RsvCapabilities::MANAGE,
'rsv-google-calendar',
'rsv_google_calendar_settings_page'
);
}
+97
View File
@@ -0,0 +1,97 @@
<?php
/**
* Defines assets that must be included to the client. Separated into assets for
* admin and the default user.
*/
function rsv_asset_url(string $relative): string {
return plugin_dir_url(__FILE__) . '../assets/' . $relative;
}
function rsv_asset_file(string $relative): string {
return plugin_dir_path(__FILE__) . '../assets/' . $relative;
}
function rsv_js(string $handle, string $relative, array $deps = []): void {
wp_enqueue_script($handle, rsv_asset_url($relative), $deps, filemtime(rsv_asset_file($relative)));
}
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'),
'nonce' => wp_create_nonce('wp_rest'),
]);
wp_localize_script('rsv_api', 'ReservairStrings', [
'timeline' => [
'not_reservable' => 'Tento objekt nelze rezervovat.',
'no_blocks' => 'Tento den není dostupný žádný blok. Vyberte jiné datum.',
'seats' => 'míst',
],
'summary' => [
'title' => 'Vybrané termíny',
'clear_all' => 'Smazat vše',
'count_one' => '1 termín',
'count_few' => '%d termíny',
'count_many' => '%d termínů',
'currency' => 'Kč',
],
'form' => [
'success_title' => 'Rezervace potvrzena!',
'success_subtitle' => 'Potvrzení jsme zaslali na váš email.',
'new_reservation' => 'Nová rezervace',
'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 ---
function rsv_enqueue_assets(): void {
rsv_enqueue_shared_assets();
rsv_js('rsv_timetable_service', 'js/services/RsvTimetableService.js');
rsv_js('rsv_form_sender', 'js/forms/RsvFormSender.js');
rsv_js('rsv_form_encoder', 'js/forms/RsvFormEncoder.js');
}
function rsv_enqueue_admin_assets(): void {
rsv_enqueue_shared_assets();
$admin_js = plugin_dir_path(__FILE__) . '../src/components/admin.js';
wp_enqueue_script('admin', plugin_dir_url(__FILE__) . '../src/components/admin.js', [], filemtime($admin_js));
rsv_js('rsv_inline_form_builder', 'js/forms/RsvInlineFormBuilder.js');
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');
}
+60
View File
@@ -0,0 +1,60 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Central definition and lifecycle for the plugin's custom capability.
*
* `manage_reservations` gates every administrative REST endpoint. It is granted to the
* roles in DEFAULT_ROLES on activation.
*
* Because WordPress only runs the activation hook on *activate* (never on a
* plugin update), ensure() re-grants the capability when the stored version
* lags behind, so an update can never silently lock admins out of the API.
*/
final class RsvCapabilities {
/** The capability that authorises managing reservation data. */
public const MANAGE = 'manage_reservations';
/** Bumped whenever the capability set changes, to drive re-grants on update. */
public const VERSION = '1';
/** Option that records which capability VERSION has been applied. */
private const VERSION_OPTION = 'rsv_caps_version';
/** Roles that receive the capability by default. */
private const DEFAULT_ROLES = [ 'administrator' ];
/**
* Grant the capability to the default roles, then record the version.
* Idempotent and safe to call on activation and on every bootstrap.
*/
public static function ensure(): void {
if ( get_option( self::VERSION_OPTION ) === self::VERSION ) {
return;
}
foreach ( self::DEFAULT_ROLES as $role_name ) {
$role = get_role( $role_name );
if ( $role && ! $role->has_cap( self::MANAGE ) ) {
$role->add_cap( self::MANAGE );
}
}
update_option( self::VERSION_OPTION, self::VERSION );
}
/** Remove the capability from every role and clear the version marker. */
public static function revoke(): void {
foreach ( array_keys( wp_roles()->roles ) as $role_name ) {
$role = get_role( $role_name );
if ( $role ) {
$role->remove_cap( self::MANAGE );
}
}
delete_option( self::VERSION_OPTION );
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Symmetric encryption for secrets kept in wp_options (Google OAuth access /
* refresh tokens and the client secret).
*
* Uses libsodium's secretbox with a 32-byte key derived from the site's
* AUTH_KEY / AUTH_SALT. Ciphertext is prefixed with "rsvenc:" so decrypt() can
* transparently pass through values that were written as plaintext before this
* was introduced existing installs migrate to encrypted storage on the next
* write (e.g. the next token refresh).
*/
final class RsvCrypto {
private const PREFIX = 'rsvenc:';
public static function encrypt(string $plaintext): string {
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$cipher = sodium_crypto_secretbox($plaintext, $nonce, self::key());
return self::PREFIX . base64_encode($nonce . $cipher);
}
/** Returns the plaintext, or null if a ciphertext can't be decrypted. */
public static function decrypt(string $stored): ?string {
if (!str_starts_with($stored, self::PREFIX)) {
return $stored; // legacy plaintext — pass through for migration
}
$raw = base64_decode(substr($stored, strlen(self::PREFIX)), true);
if ($raw === false || strlen($raw) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) {
return null;
}
$nonce = substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$cipher = substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$plain = sodium_crypto_secretbox_open($cipher, $nonce, self::key());
return $plain === false ? null : $plain;
}
/** 32-byte key derived from the site's auth salts. */
private static function key(): string {
$material = (defined('AUTH_KEY') ? AUTH_KEY : '') . '|' . (defined('AUTH_SALT') ? AUTH_SALT : '');
return hash('sha256', $material, true);
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
use Reservair\Database\Db;
use Reservair\Logger\Logger;
class RsvInstaller {
public static function install() : void {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_form_definition (
form_id bigint unsigned NOT NULL AUTO_INCREMENT,
name TINYTEXT NOT NULL,
definition JSON NOT NULL,
PRIMARY KEY (form_id)
) $charset_collate;");
self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_form_submit (
form_submit_id bigint unsigned NOT NULL AUTO_INCREMENT,
form_id bigint unsigned NOT NULL,
submitted_on_utc TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`values` JSON NOT NULL,
PRIMARY KEY (form_submit_id),
CONSTRAINT fk_form_submit_definition
FOREIGN KEY (form_id) REFERENCES {$wpdb->prefix}rsv_form_definition (form_id)
ON DELETE CASCADE
) $charset_collate;");
self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_timetable (
id bigint unsigned NOT NULL AUTO_INCREMENT,
name TINYTEXT NOT NULL,
block_size int unsigned NOT NULL DEFAULT 0,
maintainer_email TINYTEXT NULL DEFAULT NULL,
google_calendar_id TINYTEXT NULL DEFAULT NULL,
PRIMARY KEY (id)
) $charset_collate;");
self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_reservation (
id bigint unsigned NOT NULL AUTO_INCREMENT,
form_submit_id bigint unsigned NOT NULL,
is_confirmed tinyint(1) NULL DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
CONSTRAINT fk_reservation_form_submit
FOREIGN KEY (form_submit_id) REFERENCES {$wpdb->prefix}rsv_form_submit (form_submit_id)
ON DELETE CASCADE
) $charset_collate;");
self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_timetable_capacity (
id bigint unsigned NOT NULL AUTO_INCREMENT,
timetable_id bigint unsigned NOT NULL,
capacity int unsigned NOT NULL DEFAULT 1,
min_lead_time_minutes int unsigned NOT NULL DEFAULT 0,
date DATE NOT NULL,
start_time smallint unsigned NOT NULL,
end_time smallint unsigned NOT NULL,
repeat_period_in_days int unsigned NOT NULL DEFAULT 0,
repeat_times int unsigned NOT NULL DEFAULT 0,
requires_confirmation tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY idx_cap_timetable_date (timetable_id, date),
CONSTRAINT fk_capacity_timetable
FOREIGN KEY (timetable_id) REFERENCES {$wpdb->prefix}rsv_timetable (id)
) $charset_collate;");
self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_timetable_reservation (
id bigint unsigned NOT NULL AUTO_INCREMENT,
timetable_id bigint unsigned NOT NULL,
reservation_id bigint unsigned NOT NULL,
start_utc DATETIME NOT NULL,
end_utc DATETIME NOT NULL,
is_confirmed tinyint(1) NULL DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_ttr_timetable_time (timetable_id, start_utc, end_utc),
CONSTRAINT fk_timetable_reservation_timetable
FOREIGN KEY (timetable_id) REFERENCES {$wpdb->prefix}rsv_timetable (id),
CONSTRAINT fk_timetable_reservation_reservation
FOREIGN KEY (reservation_id) REFERENCES {$wpdb->prefix}rsv_reservation (id)
ON DELETE CASCADE
) $charset_collate;");
self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_timetable_reservation_confirmation (
reservation_id bigint unsigned NOT NULL,
timetable_reservation_id bigint unsigned NOT NULL,
code VARCHAR(32) NOT NULL,
PRIMARY KEY (timetable_reservation_id),
CONSTRAINT fk_trc_timetable_reservation
FOREIGN KEY (timetable_reservation_id) REFERENCES {$wpdb->prefix}rsv_timetable_reservation (id)
ON DELETE CASCADE,
CONSTRAINT fk_trc_reservation
FOREIGN KEY (reservation_id) REFERENCES {$wpdb->prefix}rsv_reservation (id)
ON DELETE CASCADE
) $charset_collate;");
// Grant the custom capability that gates the admin REST endpoints.
RsvCapabilities::ensure();
}
private static function run(string $sql) : void {
if (Db::query($sql) === false) {
Logger::error('RsvInstaller error');
}
}
public static function uninstall() : void {
RsvCapabilities::revoke();
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
use Reservair\Database\DbException;
use Reservair\Logger\Logger;
function rsv_define_rest_api(): void {
// Exception handler for all reservations/v1 endpoints.
// Runs before WordPress calls the callback, so no try/catch is needed
// in individual controller methods.
add_filter('rest_dispatch_request', function ($result, WP_REST_Request $request, string $route, array $handler) {
if ($result !== null) {
return $result;
}
if (!str_starts_with($route, '/reservations/v1/') && $route !== '/reservations/v1') {
return $result;
}
try {
return call_user_func($handler['callback'], $request);
} catch (DbException $e) {
Logger::error($e);
return new WP_REST_Response(['error' => 'A database error occurred.'], 500);
} catch (InvalidArgumentException $e) {
return new WP_REST_Response(['error' => $e->getMessage()], 400);
} catch (\Throwable $e) {
Logger::error($e);
return new WP_REST_Response(['error' => 'An unexpected error occurred.'], 500);
}
}, 10, 4);
// -------------------------------------------------------------------------
// Routes
// -------------------------------------------------------------------------
register_rest_route('reservations/v1', '/google-callback', [
'methods' => 'GET',
'callback' => 'rsv_google_oauth_callback',
// Public: OAuth redirect from Google; the handler validates the returned code.
'permission_callback' => [RsvRestPolicy::class, 'open'],
]);
register_rest_route('reservations/v1', '/google-calendar-hook', [
'methods' => 'POST',
'callback' => 'rsv_google_calendar_webhook',
// Public webhook: authorised by matching the secret channel id carried in
// the request headers (validated inside rsv_google_calendar_webhook).
'permission_callback' => [RsvRestPolicy::class, 'open'],
]);
register_rest_route('reservations/v1', '/google-calendars', [
'methods' => 'GET',
'callback' => function () {
$service = new RsvGoogleCalendarService();
if (!$service->is_google_connected()) {
return new WP_REST_Response(['error' => 'Not connected to Google Calendar'], 403);
}
return new WP_REST_Response($service->list_calendars(), 200);
},
'permission_callback' => [RsvRestPolicy::class, 'admin'],
]);
register_rest_route('reservations/v1', '/timetable/(?P<id>\d+)/google-calendar', [
'methods' => 'PUT',
'callback' => function (WP_REST_Request $request) {
$id = (int) $request->get_param('id');
$calendar_id = $request->get_json_params()['calendar_id'] ?? null;
$service = new RsvTimetableService();
if (!$service->get($id)) {
return new WP_REST_Response(['error' => 'Timetable not found'], 404);
}
$service->set_google_calendar_id($id, $calendar_id ?: null);
return new WP_REST_Response(['ok' => true], 200);
},
'permission_callback' => [RsvRestPolicy::class, 'admin'],
]);
(new RsvReservationController())->register_routes();
(new RsvTimetableDefinitionController())->register_routes();
(new RsvTimetableAvailabilityController())->register_routes();
(new RsvTimetableCapacityController())->register_routes();
(new RsvTimetableReservationController())->register_routes();
(new RsvFormController())->register_routes();
(new RsvFormDefinitionController())->register_routes();
}
@@ -0,0 +1,22 @@
<?php
class RsvEmailSender {
private function headers(): array {
return [
'From: ' . get_bloginfo('name') . ' <' . get_option('admin_email') . '>',
'Content-Type: text/html; charset=UTF-8',
];
}
private function do_send(string $to, string $subject, string $body): void {
$success = wp_mail($to, $subject, $body, $this->headers());
if (!$success) {
throw new \RuntimeException('wp_mail failed for ' . $to);
}
}
public function send(string $to, string $subject, string $body): void {
$this->do_send($to, $subject, $body);
}
}
@@ -0,0 +1,9 @@
<?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);
}
}
@@ -0,0 +1,20 @@
<?php
class RsvButtonElementHandler implements RsvFormElementHandler {
public function draw(RsvFormElementDefinition $element): void {
?>
<div class="rsv-form-input-group rsv-form-input-short">
<button class="rsv-form-btn-primary"><?= $element->getLabel() ?></button>
</div>
<?php
}
public function submit(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): bool {
return true;
}
public function rollback(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): void {
// No side effects to undo.
}
}
@@ -0,0 +1,21 @@
<?php
interface RsvFormElementHandler {
function draw(RsvFormElementDefinition $def) : void;
/**
* Validate and execute the element. Records errors on $result and returns
* false if it cannot complete.
*
* @param array<int,mixed> $data
*/
function submit(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result) : bool;
/**
* Undo a successful submit(); a no-op for elements without side effects.
*
* @param array<int,mixed> $data
*/
function rollback(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result) : void;
}
@@ -0,0 +1,87 @@
<?php
use Reservair\Logger\Logger;
class RsvFormReservationElementHandler implements RsvFormElementHandler {
private function end_from_start(DateTime $start, int $block_size_minutes): DateTime {
return ($start)->modify("+{$block_size_minutes} minutes");
}
public function draw(RsvFormElementDefinition $element): void {
$timetable_id = (int) $element->getAttr('timetable_id');
$price_per_block = (float) $element->getAttr('price_per_block', 0);
$name = $element->getName();
?>
<div class="rsv-form-input-group">
<label><?= esc_html($element->getLabel()) ?></label>
<rsv-reservation-selector
timetable-id="<?= $timetable_id ?>"
name="<?= esc_attr($name) ?>"
price-per-block="<?= $price_per_block ?>"
class="rsv-form-field"
></rsv-reservation-selector>
</div>
<?php
}
/** Validate the payload and create the reservation. */
public function submit(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): bool {
$name = $def->getName();
$payload = $data[$name] ?? null;
if ($def->isRequired() && is_null($payload)) {
$result->addError($name, 'required', 'Reservation payload is required');
return false;
}
if (!is_array($payload) || !array_key_exists('timetable_reservations', $payload)) {
$result->addError($name, 'invalid', 'Reservation payload must be an object with timetable_reservations');
return false;
}
$timetable_id = intval($payload['timetable_id'] ?? null);
$timetable = (new RsvTimetableService())->get($timetable_id);
if (!$timetable) {
$result->addError($name, 'invalid', 'Invalid timetable ID');
return false;
}
$reservation = new RsvReservation(
0,
$submit_id,
false,
array_map(fn($t) => new RsvTimetableReservation(
0,
$timetable_id,
new DateTime($t),
$this->end_from_start(new DateTime($t), $timetable->block_size)
), $payload['timetable_reservations'])
);
try {
$reservation_id = (new RsvReservationService())->create($reservation);
} catch (\Throwable $e) {
// The slot may have been taken since the user selected it.
Logger::error($e);
$result->addError($name, 'creation_failed', 'Could not create the reservation. The slot may no longer be available.');
return false;
}
$result->setValue($name . '_reservation_id', $reservation_id);
$price_per_block = (float) $def->getAttr('price_per_block', 0);
$result->setValue($name . '_price', $price_per_block * count($payload['timetable_reservations']));
return true;
}
/** Delete the reservation created by submit(). */
public function rollback(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): void {
$reservation_id = $result->getValue($def->getName() . '_reservation_id');
if ($reservation_id) {
(new RsvReservationService())->delete((int) $reservation_id);
}
}
}
@@ -0,0 +1,18 @@
<?php
class RsvReservationSummaryElementHandler implements RsvFormElementHandler {
public function draw(RsvFormElementDefinition $def): void {
?>
<rsv-reservation-summary></rsv-reservation-summary>
<?php
}
public function submit(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): bool {
return true;
}
public function rollback(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): void {
// No side effects to undo.
}
}
@@ -0,0 +1,82 @@
<?php
class RsvTextFieldElementHandler implements RsvFormElementHandler {
/** @return array<string, array{0: callable, 1: string}> */
private function rules(): array {
return [
'email' => ['is_email', 'Please enter a valid email address.'],
'phone' => [$this->regexPredicate('\+?[0-9 ()\-]{6,20}'), 'Please enter a valid phone number.'],
'digits' => [$this->regexPredicate('[0-9]+'), 'Please enter digits only.'],
];
}
private function regexPredicate(string $pattern): callable {
return fn($value): bool => (bool) @preg_match('~^(?:' . $pattern . ')$~u', (string) $value);
}
/** @return array{0:string,1:callable,2:string}|null */
private function resolveRule(RsvFormElementDefinition $def): ?array {
$name = $def->getAttr('validation', '');
if ($name === '') {
return null;
}
if ($name === 'pattern') {
$pattern = $def->getAttr('pattern', '');
if ($pattern === '') {
return null;
}
return ['pattern', $this->regexPredicate($pattern), $def->getAttr('pattern_message', '') ?: 'Invalid format.'];
}
$rules = $this->rules();
if (isset($rules[$name])) {
return [$name, $rules[$name][0], $rules[$name][1]];
}
return null;
}
public function draw(RsvFormElementDefinition $def): void {
$validation = $def->getAttr('validation', '');
$type = match ($validation) {
'email' => 'email',
'phone' => 'tel',
'digits' => 'number',
default => 'text',
};
$pattern = $validation === 'pattern' ? $def->getAttr('pattern', '') : '';
?>
<div class="rsv-form-input-group rsv-form-input-short">
<label class="rsv-form-label"><?= $def->getLabel() ?>:</label>
<input class="rsv-form-input rsv-form-field" type="<?= $type ?>" name="<?= $def->getName() ?>"<?= $pattern !== '' ? ' pattern="' . esc_attr($pattern) . '"' : '' ?> <?= $def->isRequired() ? "required" : "" ?>/>
<small class="rsv-form-small"><?= $def->getDesc() ?></small>
</div>
<?php
}
public function submit(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): bool {
$name = $def->getName();
$value = $data[$name] ?? null;
if ($def->isRequired() && (is_null($value) || $value === "")) {
$result->addError($name, 'required', 'Field is required');
return false;
}
$rule = $this->resolveRule($def);
if ($rule !== null && !is_null($value) && $value !== "") {
[$code, $predicate, $message] = $rule;
if (!$predicate($value)) {
$result->addError($name, $code, $message);
return false;
}
}
$result->setValue($name, sanitize_text_field($value));
return true;
}
public function rollback(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): void {
// No side effects to undo.
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
class RsvFormData {
private array $elements = [];
/**
* @param array<int,mixed> $data
*/
public function __construct(array $data) {
// Expecting raw post values as associative array
$this->elements = $data['values'] ?? $data;
}
public function getElements(): array {
return $this->elements;
}
public function getValue(string $name, $default = null) {
return $this->elements[$name] ?? $default;
}
}
@@ -0,0 +1,45 @@
<?php
class RsvFormDefinition {
public $_elements = [];
public string $_id = "";
public string $email_key = "";
/**
* @param array<int,mixed> $definition Full definition array including 'elements' and 'email_key'.
*/
public function __construct(string $id, array $definition) {
$this->_elements = [];
if (array_key_exists('elements', $definition)) {
foreach ($definition['elements'] as $element) {
array_push($this->_elements, RsvFormElementDefinition::fromArray($element));
}
}
$this->_id = $id;
$this->email_key = $definition['email_key'] ?? '';
}
public function getId(): string {
return $this->_id;
}
public function getEmailKey(): string {
return $this->email_key;
}
public function hasElements() : bool {
return count($this->_elements) > 0;
}
/**
* @return array<int, RsvFormElementDefinition>
*/
public function getElements() : array {
return $this->_elements;
}
}
@@ -0,0 +1,65 @@
<?php
/**
* Dynamic definition of the element to be rendered or handled.
*/
class RsvFormElementDefinition {
public string $type;
public string $name;
public string $label;
public string $desc;
public bool $required;
public array $attrs = [];
public function getType(): string {
return $this->type;
}
public function getName(): string {
return $this->name;
}
public function getLabel(): string {
return $this->label;
}
public function getDesc(): string {
return $this->desc;
}
public function isRequired(): bool {
return $this->required;
}
public function getAttr(string $key, $default = null) {
return $this->attrs[$key] ?? $default;
}
/**
* @param array<int,mixed> $array Array for initializing the form element definition
* @return RsvFormElementDefinition Definition of the form element for rendering and handling.
*/
public static function fromArray(array $array): RsvFormElementDefinition {
$known = ['type','name','label','desc','required'];
$attrs = array_diff_key($array, array_flip($known));
$def = new self(
$array['type'],
$array['name'],
$array['label'],
$array['desc'] ?? "",
$array['required'] ?? false
);
$def->attrs = $attrs;
return $def;
}
public function __construct(string $type, string $name, string $label, string $desc = "", bool $required = false) {
$this->type = $type;
$this->name = $name;
$this->label = $label;
$this->desc = $desc;
$this->required = $required;
}
}
@@ -0,0 +1,15 @@
<?php
class RsvFormElementRegistry {
/** @var array<string, RsvFormElementHandler> */
public array $handlers = [];
public function register(string $type, RsvFormElementHandler $handler): void {
$this->handlers[$type] = $handler;
}
public function get(string $type): ?RsvFormElementHandler {
return $this->handlers[$type] ?? null;
}
}
@@ -0,0 +1,38 @@
<?php
class RsvFormHtmlRenderer {
public function draw(RsvFormDefinition $form): bool {
if (!$form->hasElements()) {
return false;
}
$form_id = esc_attr($form->getId());
?>
<div>
<form class="reservair-form confirmation"
id="<?= $form_id ?>"
onsubmit="RsvFormSender.send_form(event)"
method="POST">
<?php foreach ($form->getElements() as $element): ?>
<?php $this->draw_element($element); ?>
<?php endforeach; ?>
</form>
</div>
<?php
return true;
}
public function draw_element(RsvFormElementDefinition $data): void {
global $rsv_form_registry;
$handler = $rsv_form_registry->get($data->getType());
if ($handler === null) {
return;
}
$handler->draw($data);
}
}
@@ -0,0 +1,44 @@
<?php
use Reservair\Logger\Logger;
class RsvFormProcessor {
/** Submit every element. If one fails, undo the ones that already succeeded. */
public function submit(RsvFormDefinition $definition, int $submit_id, RsvFormData $data): RsvFormSubmitResult {
global $rsv_form_registry;
$result = new RsvFormSubmitResult();
$committed = [];
foreach ($definition->getElements() as $element) {
$handler = $rsv_form_registry->get($element->getType());
if ($handler === null) {
$result->addError($element->getName(), 'handler_missing', 'No handler registered for element type ' . $element->getType());
$this->rollback_all($committed, $submit_id, $data, $result);
return $result;
}
if (!$handler->submit($element, $submit_id, $data->getElements(), $result)) {
$this->rollback_all($committed, $submit_id, $data, $result);
return $result;
}
$committed[] = [$handler, $element];
}
return $result;
}
/**
* @param array<int,array{0:RsvFormElementHandler,1:RsvFormElementDefinition}> $committed
*/
private function rollback_all(array $committed, int $submit_id, RsvFormData $data, RsvFormSubmitResult $result): void {
foreach (array_reverse($committed) as [$handler, $element]) {
try {
$handler->rollback($element, $submit_id, $data->getElements(), $result);
} catch (\Throwable $e) {
Logger::error($e);
}
}
}
}
@@ -0,0 +1,49 @@
<?php
use Reservair\Logger\Logger;
/**
* Handles submitting a form. If any element fails, nothing is persisted.
*/
class RsvFormSubmission {
public function submit(string $formId, array $data) : array {
$repo = new RsvFormDefinitionRepository();
$row = $repo->get((int) $formId);
if ($row === null) {
return ['success' => false, 'errors' => [['element' => '', 'code' => 'not_found', 'message' => 'Form not found']]];
}
$definition = new RsvFormDefinition($formId, $row['definition']);
$form_data = new RsvFormData($data);
$processor = new RsvFormProcessor();
// Persist the submission first so element handlers can link to it.
$submit_repo = new RsvFormSubmitRepository();
$submit_id = $submit_repo->add((int) $definition->getId(), $data);
try {
$result = $processor->submit($definition, $submit_id, $form_data);
} catch (\Throwable $e) {
Logger::error($e);
$this->discard_submission($submit_repo, $submit_id);
return ['success' => false, 'errors' => [['element' => '', 'code' => 'internal_error', 'message' => 'An unexpected error occurred.']]];
}
if ($result->hasErrors()) {
$this->discard_submission($submit_repo, $submit_id);
return ['success' => false, 'errors' => $result->getErrors()];
}
return ['success' => true, 'submit_id' => $submit_id, 'values' => $result->getValues()];
}
/** Remove a submission whose run failed. */
private function discard_submission(RsvFormSubmitRepository $repo, int $submit_id): void {
try {
$repo->delete($submit_id);
} catch (\Throwable $e) {
Logger::error($e);
}
}
}
@@ -0,0 +1,38 @@
<?php
class RsvFormSubmitResult {
private array $errors = [];
private array $values = [];
public function addError(string $elementName, string $code, string $message): void {
$this->errors[] = [
'element' => $elementName,
'code' => $code,
'message' => $message
];
}
public function hasErrors(): bool {
return count($this->errors) > 0;
}
public function getErrors(): array {
return $this->errors;
}
public function setValue(string $name, $value): void {
$this->values[$name] = $value;
}
public function getValue(string $name) {
return $this->values[$name] ?? null;
}
public function getValues(): array {
return $this->values;
}
public function toDto(): array {
}
}
@@ -0,0 +1,371 @@
<?php
use Reservair\Logger\Logger;
class RsvGoogleCalendarService {
public function is_google_connected(): bool {
return (bool) get_option('rsv_google_access_token');
}
private function get_client_id(): string {
return (string) get_option('rsv_google_client_id', '');
}
private function get_client_secret(): string {
return (string) ($this->get_secret('rsv_google_client_secret') ?? '');
}
public function get_calendar_id(): string {
return (string) get_option('rsv_google_calendar_id', 'primary');
}
public function is_webhook_registered(): bool {
return (bool) get_option('rsv_google_webhook_channel_id');
}
// -------------------------------------------------------------------------
// Encrypted option storage for secrets (tokens, client secret)
// -------------------------------------------------------------------------
/** Decrypt a secret stored in wp_options; null if unset or undecryptable. */
private function get_secret(string $option): ?string {
$stored = get_option($option);
if (!is_string($stored) || $stored === '') {
return null;
}
return RsvCrypto::decrypt($stored);
}
/** Store a secret in wp_options, encrypted at rest. */
private function set_secret(string $option, string $value): void {
update_option($option, RsvCrypto::encrypt($value));
}
/** Persist the OAuth client secret (encrypted). */
public function set_client_secret(string $secret): void {
$this->set_secret('rsv_google_client_secret', $secret);
}
// -------------------------------------------------------------------------
// OAuth
// -------------------------------------------------------------------------
public function get_oauth_url(): string {
return 'https://accounts.google.com/o/oauth2/v2/auth?' . http_build_query([
'client_id' => $this->get_client_id(),
'redirect_uri' => site_url('/wp-json/reservations/v1/google-callback'),
'response_type' => 'code',
'scope' => 'https://www.googleapis.com/auth/calendar',
'access_type' => 'offline',
'prompt' => 'consent',
]);
}
public function exchange_code(string $code): bool {
$response = wp_remote_post('https://oauth2.googleapis.com/token', [
'body' => [
'code' => $code,
'client_id' => $this->get_client_id(),
'client_secret' => $this->get_client_secret(),
'redirect_uri' => site_url('/wp-json/reservations/v1/google-callback'),
'grant_type' => 'authorization_code',
],
]);
$data = json_decode(wp_remote_retrieve_body($response), true);
if (!isset($data['access_token'])) {
Logger::error('Google Calendar token exchange failed: ' . json_encode($data));
return false;
}
$this->set_secret('rsv_google_access_token', $data['access_token']);
$this->set_secret('rsv_google_refresh_token', $data['refresh_token']);
update_option('rsv_google_token_expires', time() + $data['expires_in']);
return true;
}
public function disconnect(): void {
$this->stop_webhook();
delete_option('rsv_google_access_token');
delete_option('rsv_google_refresh_token');
delete_option('rsv_google_token_expires');
delete_option('rsv_google_sync_token');
}
public function get_access_token(): ?string {
$access_token = $this->get_secret('rsv_google_access_token');
$refresh_token = $this->get_secret('rsv_google_refresh_token');
$expires_at = (int) get_option('rsv_google_token_expires', 0);
if (!$access_token) {
return null;
}
if (time() > $expires_at - 60) {
$response = wp_remote_post('https://oauth2.googleapis.com/token', [
'body' => [
'client_id' => $this->get_client_id(),
'client_secret' => $this->get_client_secret(),
'refresh_token' => $refresh_token,
'grant_type' => 'refresh_token',
],
]);
$data = json_decode(wp_remote_retrieve_body($response), true);
if (isset($data['access_token'])) {
$this->set_secret('rsv_google_access_token', $data['access_token']);
update_option('rsv_google_token_expires', time() + $data['expires_in']);
$access_token = $data['access_token'];
} else {
Logger::error('Google Calendar token refresh failed: ' . json_encode($data));
// Refresh token is permanently invalid — clear credentials so the
// admin UI shows "reconnect needed" instead of silently failing.
delete_option('rsv_google_access_token');
delete_option('rsv_google_refresh_token');
delete_option('rsv_google_token_expires');
return null;
}
}
return $access_token;
}
// -------------------------------------------------------------------------
// Calendar list
// -------------------------------------------------------------------------
public function list_calendars(): array {
$access_token = $this->get_access_token();
if (!$access_token) {
return [];
}
$response = wp_remote_get(
'https://www.googleapis.com/calendar/v3/users/me/calendarList',
['headers' => ['Authorization' => 'Bearer ' . $access_token]]
);
if (is_wp_error($response)) {
return [];
}
$data = json_decode(wp_remote_retrieve_body($response), true);
return array_map(fn($c) => ['id' => $c['id'], 'summary' => $c['summary']], $data['items'] ?? []);
}
// -------------------------------------------------------------------------
// Calendar events
// -------------------------------------------------------------------------
public function add_event(
string $calendar_id,
string $summary,
string $start_utc,
string $end_utc,
?string $attendee_email = null,
?int $reservation_id = null,
string $status = 'confirmed'
): void {
$access_token = $this->get_access_token();
if (!$access_token) {
throw new \RuntimeException('Not connected to Google Calendar.');
}
$tz = wp_timezone()->getName();
$start = (new \DateTime($start_utc, new \DateTimeZone('UTC')))->setTimezone(wp_timezone());
$end = (new \DateTime($end_utc, new \DateTimeZone('UTC')))->setTimezone(wp_timezone());
$event = [
'summary' => $summary,
'status' => $status,
'start' => ['dateTime' => $start->format(\DateTimeInterface::RFC3339), 'timeZone' => $tz],
'end' => ['dateTime' => $end->format(\DateTimeInterface::RFC3339), 'timeZone' => $tz],
];
if ($attendee_email) {
$event['attendees'] = [['email' => $attendee_email]];
}
if ($reservation_id !== null) {
$event['extendedProperties'] = [
'private' => ['rsv_reservation_id' => (string) $reservation_id],
];
}
$response = wp_remote_post(
"https://www.googleapis.com/calendar/v3/calendars/{$calendar_id}/events?sendUpdates=all",
[
'headers' => [
'Authorization' => 'Bearer ' . $access_token,
'Content-Type' => 'application/json',
],
'body' => json_encode($event),
]
);
if (is_wp_error($response)) {
throw new \RuntimeException('Google Calendar request failed: ' . $response->get_error_message());
}
$body = json_decode(wp_remote_retrieve_body($response), true);
if (!isset($body['id'])) {
throw new \RuntimeException('Google Calendar event creation failed: ' . json_encode($body));
}
}
// -------------------------------------------------------------------------
// Push notification webhook
// -------------------------------------------------------------------------
public function register_webhook(): array {
$this->stop_webhook();
$access_token = $this->get_access_token();
if (!$access_token) {
return ['error' => 'Not connected to Google Calendar.'];
}
// Get an initial sync token so the first process_changes() call works.
$this->initialize_sync_token();
$channel_id = uniqid('rsv_gcal_', true);
$response = wp_remote_post(
"https://www.googleapis.com/calendar/v3/calendars/{$this->get_calendar_id()}/events/watch",
[
'headers' => [
'Authorization' => 'Bearer ' . $access_token,
'Content-Type' => 'application/json',
],
'body' => json_encode([
'id' => $channel_id,
'type' => 'web_hook',
'address' => site_url('/wp-json/reservations/v1/google-calendar-hook'),
]),
]
);
$data = json_decode(wp_remote_retrieve_body($response), true);
if (isset($data['id'])) {
update_option('rsv_google_webhook_channel_id', $data['id']);
update_option('rsv_google_webhook_resource_id', $data['resourceId']);
update_option('rsv_google_webhook_expiration', $data['expiration'] / 1000); // ms → s
} else {
Logger::error('Google Calendar webhook registration failed: ' . json_encode($data));
}
return $data;
}
public function stop_webhook(): void {
$channel_id = get_option('rsv_google_webhook_channel_id');
$resource_id = get_option('rsv_google_webhook_resource_id');
if (!$channel_id || !$resource_id) {
return;
}
$access_token = $this->get_access_token();
if ($access_token) {
wp_remote_post('https://www.googleapis.com/calendar/v3/channels/stop', [
'headers' => [
'Authorization' => 'Bearer ' . $access_token,
'Content-Type' => 'application/json',
],
'body' => json_encode([
'id' => $channel_id,
'resourceId' => $resource_id,
]),
]);
}
delete_option('rsv_google_webhook_channel_id');
delete_option('rsv_google_webhook_resource_id');
delete_option('rsv_google_webhook_expiration');
}
// -------------------------------------------------------------------------
// Change sync
// -------------------------------------------------------------------------
public function initialize_sync_token(): void {
$access_token = $this->get_access_token();
if (!$access_token) {
return;
}
$response = wp_remote_get(
'https://www.googleapis.com/calendar/v3/calendars/' . rawurlencode($this->get_calendar_id()) . '/events?' . http_build_query([
'fields' => 'nextSyncToken',
'showDeleted' => 'true',
'singleEvents' => 'true',
]),
['headers' => ['Authorization' => 'Bearer ' . $access_token]]
);
$data = json_decode(wp_remote_retrieve_body($response), true);
if (isset($data['nextSyncToken'])) {
update_option('rsv_google_sync_token', $data['nextSyncToken']);
}
}
/**
* Fetch events changed since the last sync and return actions to take.
*
* @return array<array{reservation_id: int, action: 'accept'|'refuse'}>
*/
public function process_changes(): array {
$access_token = $this->get_access_token();
if (!$access_token) {
return [];
}
$sync_token = get_option('rsv_google_sync_token');
if (!$sync_token) {
$this->initialize_sync_token();
return [];
}
$response = wp_remote_get(
'https://www.googleapis.com/calendar/v3/calendars/' . rawurlencode($this->get_calendar_id()) . '/events?' . http_build_query([
'syncToken' => $sync_token,
'showDeleted' => 'true',
'fields' => 'nextSyncToken,items(id,status,extendedProperties)',
]),
['headers' => ['Authorization' => 'Bearer ' . $access_token]]
);
if (wp_remote_retrieve_response_code($response) === 410) {
// Sync token expired — reinitialise and process next time.
delete_option('rsv_google_sync_token');
$this->initialize_sync_token();
return [];
}
$data = json_decode(wp_remote_retrieve_body($response), true);
if (isset($data['nextSyncToken'])) {
update_option('rsv_google_sync_token', $data['nextSyncToken']);
}
$actions = [];
foreach ($data['items'] ?? [] as $event) {
$reservation_id = (int) ($event['extendedProperties']['private']['rsv_reservation_id'] ?? 0);
if (!$reservation_id) {
continue;
}
$status = $event['status'] ?? '';
if ($status === 'confirmed') {
$actions[] = ['reservation_id' => $reservation_id, 'action' => 'accept'];
} elseif ($status === 'cancelled') {
$actions[] = ['reservation_id' => $reservation_id, 'action' => 'refuse'];
}
}
return $actions;
}
}
+131
View File
@@ -0,0 +1,131 @@
<?php
use Reservair\Database\Db;
use Reservair\Logger\Logger;
class RsvReservationService {
private RsvReservationRepository $repo;
public function __construct() {
$this->repo = new RsvReservationRepository();
}
public function get_all(?int $limit = null, int $skip = 0) {
return $this->repo->get_all($limit, $skip);
}
public function count_all(): int {
return $this->repo->count_all();
}
public function get(int $id) {
return $this->repo->get($id);
}
public function get_detail(int $id): ?array {
return $this->repo->get_detail($id);
}
public function create(RsvReservation $reservation) {
// Serialise the availability-check + insert per timetable so two
// concurrent bookings for the last free slot can't both pass the
// capacity check and oversell. Locks are taken in a stable order to
// avoid lock-ordering deadlocks and released only after the commit.
$timetable_ids = array_values(array_unique(array_map(
fn($tr) => $tr->timetable_id,
$reservation->timetable_reservations
)));
sort($timetable_ids);
foreach ($timetable_ids as $tid) {
if (!Db::acquire_lock($this->booking_lock_name($tid))) {
throw new \RuntimeException('Could not acquire booking lock for timetable ' . $tid);
}
}
$timetable_reservation_service = new RsvTimetableReservationService();
try {
Db::begin_transaction();
try {
$reservation_id = $this->repo->insert($reservation->to_array());
foreach ($reservation->timetable_reservations as $timetable_reservation) {
$timetable_reservation_service->create($reservation_id, $timetable_reservation);
}
Db::commit();
} catch (Exception $e) {
Db::rollback();
Logger::error($e);
throw new \Exception($e);
}
// Only now that the rows are durably committed do we let listeners
// (maintainer emails, calendar sync) observe the new reservation.
$timetable_reservation_service->flush_deferred_events();
$this->confirmation_state_changed($reservation_id);
return $reservation_id;
} finally {
foreach (array_reverse($timetable_ids) as $tid) {
Db::release_lock($this->booking_lock_name($tid));
}
}
}
private function booking_lock_name(int $timetable_id): string {
return Db::prefix() . 'rsv_booking_' . $timetable_id;
}
/** Delete a reservation and its dependent timetable reservations and confirmations. */
public function delete(int $id): void {
$this->repo->delete($id);
}
public function confirmation_state_changed(int $reservation_id): void {
// Terminal state is persisted, so this is idempotent: once a reservation
// is confirmed or refused, repeat calls (e.g. a Google Calendar re-sync)
// short-circuit and never re-dispatch the closing events.
if ($this->repo->is_resolved($reservation_id)) {
Logger::info('Reservation already resolved: ' . $reservation_id);
return;
}
// A reservation with no timetable items has nothing to confirm or refuse;
// bailing out avoids mislabelling it as refused.
if ($this->repo->count_subitems($reservation_id) === 0) {
Logger::info('Reservation has not subitems: ' . $reservation_id);
return;
}
$timetable_service = new RsvTimetableReservationService();
$all_confirmed = $this->repo->are_subitems_confirmed($reservation_id);
$any_pending = $timetable_service->has_pending_confirmation($reservation_id);
if ($all_confirmed) {
$this->repo->set_resolved_state($reservation_id, true);
RsvEventDispatcher::dispatch(new RsvReservationConfirmedEvent($reservation_id));
$form_submit_id = $this->repo->get_form_submit_id($reservation_id);
if ($form_submit_id !== null) {
RsvEventDispatcher::dispatch(new RsvFormSubmitClosedEvent($form_submit_id, $reservation_id, true));
}
return;
}
if (!$any_pending) {
// All items resolved but not all confirmed — at least one refused.
$this->repo->set_resolved_state($reservation_id, false);
RsvEventDispatcher::dispatch(new RsvReservationRefusedEvent($reservation_id));
$form_submit_id = $this->repo->get_form_submit_id($reservation_id);
if ($form_submit_id !== null) {
RsvEventDispatcher::dispatch(new RsvFormSubmitClosedEvent($form_submit_id, $reservation_id, false));
}
return;
}
Logger::info('Waiting for pending confirmations on reservation: ' . $reservation_id);
}
}
@@ -0,0 +1,189 @@
<?php
class RsvTimetableReservationService {
private RsvTimetableReservationRepository $repo;
/**
* Events produced by create() that must not be dispatched until the
* surrounding transaction has committed otherwise a later rollback would
* leave listeners (e.g. the maintainer notification email) acting on rows
* that no longer exist.
*
* @var list<object>
*/
private array $deferred_events = [];
public function __construct() {
$this->repo = new RsvTimetableReservationRepository();
}
public function get_all(): array {
return $this->repo->get_all();
}
public function get(int $id) : RsvTimetableReservation {
return $this->repo->get($id);
}
private function time_of_day_minutes(DateTime $utc_dt): int {
$dt = (clone $utc_dt)->setTimezone(wp_timezone());
return (int) $dt->format('G') * 60 + (int) $dt->format('i');
}
public function check_availability(int $timetable_id, DateTime $start_utc, DateTime $end_utc): bool {
$overlapping_capacity = (new RsvTimetableCapacityRepository())
->get_overlapping_capacity($timetable_id, $start_utc, $end_utc);
if (count($overlapping_capacity) === 0) {
return false;
}
$start_min = $this->time_of_day_minutes($start_utc);
$end_min = $this->time_of_day_minutes($end_utc);
if ((int) $overlapping_capacity[0]->start_time > $start_min) {
return false;
}
$max = (int) $overlapping_capacity[0]->end_time;
foreach ($overlapping_capacity as $cap) {
if ((int) $cap->start_time > $max) {
return false;
}
$max = max($max, (int) $cap->end_time);
}
if ($max < $end_min) {
return false;
}
$capacity = (int) $overlapping_capacity[0]->capacity;
$reservations = $this->repo->get_overlapping($timetable_id, $start_utc, $end_utc);
return count($reservations) < $capacity;
}
public function is_reservation_valid(RsvTimetableReservation $reservation): bool {
return $this->check_availability(
$reservation->timetable_id,
$reservation->start_utc,
$reservation->end_utc,
);
}
private function create_confirmation(int $reservation_id, int $timetable_reservation_id): string {
$code = wp_generate_password(32, false);
$this->repo->insert_confirmation($reservation_id, $timetable_reservation_id, $code);
return $code;
}
private function set_confirmed_state(string $code, bool $state): int {
$confirmation = $this->repo->get_confirmation($code);
if ($confirmation === null) {
throw new InvalidArgumentException('Confirmation code not found.');
}
$this->repo->set_confirmed((int) $confirmation['timetable_reservation_id'], $state);
$this->repo->delete_confirmation($code);
$reservation_service = new RsvReservationService();
$reservation_service->confirmation_state_changed($confirmation['reservation_id']);
return $confirmation['timetable_reservation_id'];
}
public function accept(string $code): int {
return $this->set_confirmed_state($code, true);
}
public function refuse(string $code): int {
return $this->set_confirmed_state($code, false);
}
public function has_pending_confirmation(int $reservation_id): bool {
return $this->repo->has_pending_confirmation($reservation_id);
}
private function get_confirmation_code(int $reservation_id): string {
$code = $this->repo->get_confirmation_code($reservation_id);
if ($code === null) {
throw new InvalidArgumentException('No pending confirmation for this reservation.');
}
return $code;
}
public function accept_by_reservation_id(int $reservation_id): void {
$this->set_confirmed_state($this->get_confirmation_code($reservation_id), true);
}
public function refuse_by_reservation_id(int $reservation_id): void {
$this->set_confirmed_state($this->get_confirmation_code($reservation_id), false);
}
// TODO: Add requires_confirmation parameter
public function create(int $reservation_id, RsvTimetableReservation $reservation): int {
if (!$this->is_reservation_valid($reservation)) {
throw new InvalidArgumentException();
}
$capacity = (new RsvTimetableCapacityRepository())->get_overlapping_capacity(
$reservation->timetable_id,
$reservation->start_utc,
$reservation->end_utc,
);
$needs_confirmation = array_filter($capacity, fn($c) => (bool) $c->requires_confirmation);
$insert_array = $reservation->to_array();
$insert_array['reservation_id'] = $reservation_id;
$insert_array['is_confirmed'] = empty($needs_confirmation);
$id = $this->repo->insert($insert_array);
if (!empty($needs_confirmation)) {
$maintainer_email = (new RsvTimetableRepository())->get_maintainer_email($reservation->timetable_id);
if ($maintainer_email) {
$code = $this->create_confirmation($reservation_id, $id);
$this->deferred_events[] = new RsvTimetableReservationPendingEvent(
$reservation_id,
$reservation,
$code,
$maintainer_email
);
}
} else {
$reservation_service = new RsvReservationService();
$reservation_service->confirmation_state_changed($reservation_id);
}
$this->deferred_events[] = new RsvTimetableReservationCreatedEvent($reservation);
return $id;
}
/**
* Dispatch (and clear) the events buffered by create(). Callers must invoke
* this *after* committing the transaction that wraps create().
*/
public function flush_deferred_events(): void {
$events = $this->deferred_events;
$this->deferred_events = [];
foreach ($events as $event) {
RsvEventDispatcher::dispatch($event);
}
}
public function get_by_timetable(int $timetable_id, ?int $limit = null, int $skip = 0): array {
return $this->repo->get_by_timetable($timetable_id, $limit, $skip);
}
public function count_by_timetable(int $timetable_id): int {
return $this->repo->count_by_timetable($timetable_id);
}
public function get_reservations_on_date(int $timetable_id, DateTime $date_utc): array {
return $this->repo->get_reservations_on_date($timetable_id, $date_utc);
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
use Reservair\Logger\Logger;
class RsvTimetableService {
private RsvTimetableRepository $repo;
public function __construct() {
$this->repo = new RsvTimetableRepository();
}
public function get_all(?int $limit = null, int $skip = 0): array {
return $this->repo->get_all($limit, $skip);
}
public function count_all(): int {
return $this->repo->count_all();
}
public function get(int $id): ?RsvTimetable {
return $this->repo->get($id);
}
public function create(RsvTimetable $timetable): int {
return $this->repo->create($timetable);
}
public function update(int $id, RsvTimetable $timetable): int {
return $this->repo->update($id, $timetable);
}
public function get_all_maintainer_emails(): array {
return $this->repo->get_all_maintainer_emails();
}
public function set_google_calendar_id(int $id, ?string $calendar_id): void {
$this->repo->set_google_calendar_id($id, $calendar_id);
}
public function delete(int $id): int {
Logger::info('Deleting timetable: ' . $id);
return $this->repo->delete($id);
}
private function datetime_to_minutes(DateTime $dt): int {
$d = $dt->setTimezone(wp_timezone());
return (int) $d->format('G') * 60 + (int) $d->format('i');
}
/**
* @return array<int,RsvTimetableAvailability> Availability on date
*/
public function get_availability_on_date(int $timetable_id, int $block_length, DateTime $date) : array {
/**
* Get available seats for the given date
* Keeps two stacks: capacities & reservations
*
* Goes from left to right in reservations and pushes to stack
*/
$capacities = (new RsvTimetableCapacityRepository())->get_capacities_for_date($timetable_id, $date);
$reservations = (new RsvTimetableReservationService())->get_reservations_on_date($timetable_id, $date);
$capacity_stack = [];
$reservation_stack = [];
$capacity_index = 0;
$reservation_index = 0;
$blocks_count = 24 * 60 / $block_length;
$blocks = array_fill(0, $blocks_count, 0);
$availabilities = [];
$availability_idx = 0;
for($i = 0; $i < $blocks_count; $i++) {
$reservation_stack = array_filter($reservation_stack, fn($reservation) =>
$this->datetime_to_minutes($reservation->end_utc) > $i * $block_length
);
$capacity_stack = array_filter($capacity_stack, fn($capacity) =>
$capacity->end_time > $i * $block_length
);
while($reservation_index < count($reservations) && $this->datetime_to_minutes($reservations[$reservation_index]->start_utc) <= $i * $block_length) {
$reservation_stack[] = $reservations[$reservation_index];
$reservation_index++;
}
while($capacity_index < count($capacities) && $capacities[$capacity_index]->start_time <= $i * $block_length) {
$capacity_stack[] = $capacities[$capacity_index];
$capacity_index++;
}
$total_capacity = array_sum(array_map(fn($x) => $x->capacity, $capacity_stack));
if ($total_capacity > 0) {
if(count($availabilities) === $availability_idx) {
$availabilities[] = new RsvTimetableAvailability($i * $block_length, ($i + 1) * $block_length, $block_length, []);
}
$availabilities[$availability_idx]->push_block($total_capacity - count($reservation_stack));
} else if($total_capacity === 0 && count($availabilities) !== $availability_idx) {
$availability_idx++;
}
}
return $availabilities;
}
}
+397
View File
@@ -0,0 +1,397 @@
<?php
use Reservair\Forms\RsvFormBuilder;
// Shared inline script for the elements data grid.
// $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 = {
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: 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
// 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();
};
}
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>
<p class="submit">
<button type="submit" form="add_form_definition" class="button button-primary">Add Form Definition</button>
</p>
</div>
</div>
<div id="col-right">
<div class="col-wrap">
<div id="forms_table"></div>
<script>
var forms_dt = RsvDataGrid.create_data_grid(forms_table,
RsvFormDefinitionResource(), {
'form_id': RsvDataGrid.column('ID', false, 30),
'name': RsvDataGrid.action_column('Název', false, {
'Edit': RsvDataGrid.link_action((data) =>
`<?= menu_page_url('forms-settings', false) ?>&id=${data.form_id}&action=edit`
),
'Trash': RsvDataGrid.func_action(function(dt, row, data) {
dt.resource.delete(data.form_id).then(() => forms_dt.refresh()).catch(err => alert(err.message));
}),
'Clone': RsvDataGrid.func_action(function(dt, row, data) {
const base_url = `<?= get_rest_url(null, 'reservations/v1/form-definition') ?>`;
fetch(`${base_url}/${data.form_id}`, {
headers: { 'Accept': 'application/json' },
})
.then(r => {
if (!r.ok) return r.json().then(err => { throw new Error(err.error || 'Fetch failed'); });
return r.json();
})
.then(form_def => fetch(base_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({
name: 'Copy of ' + form_def.name,
definition: form_def.definition,
}),
}))
.then(r => {
if (!r.ok) return r.json().then(err => { throw new Error(err.error || 'Clone failed'); });
forms_dt.refresh();
})
.catch(err => alert(err.message));
}),
}),
});
forms_dt.refresh();
</script>
</div>
</div>
</div>
<?php rsv_elements_table_script($elements_with_ids, $next_id, 'add_form_definition', $element_types, $timetables); ?>
<?php
}
function rsv_form_definition_edit_page(int $id): void {
global $rsv_form_registry;
$element_types = array_keys($rsv_form_registry->handlers);
$repo = new RsvFormDefinitionRepository();
$form_def = $repo->get($id);
if ($form_def === null) {
echo '<div class="notice notice-error"><p>Form definition not found.</p></div>';
return;
}
$definition = $form_def['definition'] ?? [];
$raw_elements = array_values($definition['elements'] ?? []);
$elements_with_ids = array_map(function (array $el, int $idx): array {
return array_merge($el, ['id' => $idx + 1]);
}, $raw_elements, array_keys($raw_elements));
$next_id = count($elements_with_ids) + 1;
$timetables = (new RsvTimetableService())->get_all();
?>
<h1>Edit Form: <?= esc_html($form_def['name']) ?></h1>
<a href="<?= menu_page_url('forms-settings', false) ?>"> Back to Forms</a>
<hr>
<form id="edit_form_definition"
method="post"
data-method="PUT"
data-success-msg="Form definition updated."
action="<?= get_rest_url(null, 'reservations/v1/form-definition/' . $id); ?>">
<?php
echo RsvFormBuilder::create('edit_form_definition')
->text('name', 'Name', '', true, $form_def['name'])
->text('definition.email_key', 'Email Key', "Name of the form field that holds the submitter's email address.", true, $definition['email_key'] ?? '')
->render();
?>
</form>
<hr>
<h2>Form Elements</h2>
<p>Define the fields that will appear in this form.</p>
<div id="form_elements_table"></div>
<p>
<button type="button" class="button" id="rsv_add_element_btn">+ Add Element</button>
</p>
<p class="submit">
<button type="submit" form="edit_form_definition" class="button button-primary">Update Form Definition</button>
</p>
<?php rsv_elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables); ?>
<?php
}
function rsv_forms_page(): void {
if (isset($_GET['action'])) {
if ($_GET['action'] === 'edit' && isset($_GET['id'])) {
rsv_form_definition_edit_page(intval($_GET['id']));
return;
}
}
rsv_form_info_page();
}
@@ -0,0 +1,132 @@
<?php
function rsv_google_calendar_settings_page(): void {
if (!current_user_can(RsvCapabilities::MANAGE)) {
return;
}
$service = new RsvGoogleCalendarService();
$notice = null;
if (isset($_GET['connected'])) {
$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'])) {
$service->disconnect();
$notice = ['type' => 'success', 'message' => 'Disconnected from Google Calendar.'];
} 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();
$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());
?>
<div class="wrap">
<h1>Google Calendar</h1>
<?php if ($notice): ?>
<div class="notice notice-<?= esc_attr($notice['type']) ?> is-dismissible">
<p><?= esc_html($notice['message']) ?></p>
</div>
<?php endif; ?>
<form method="post">
<?php wp_nonce_field('rsv_google_settings', 'rsv_google_settings_nonce'); ?>
<h2>OAuth Credentials</h2>
<p>Create a project in <a href="https://console.cloud.google.com/" target="_blank">Google Cloud Console</a>, enable the <strong>Google Calendar API</strong>, and create OAuth 2.0 credentials. Set the authorised redirect URI to <code><?= esc_html(site_url('/wp-json/reservations/v1/google-callback')) ?></code>.</p>
<table class="form-table">
<tr>
<th><label for="rsv_google_client_id">Client ID</label></th>
<td><input class="regular-text" type="text" id="rsv_google_client_id" name="rsv_google_client_id" value="<?= $client_id ?>"></td>
</tr>
<tr>
<th><label for="rsv_google_client_secret">Client Secret</label></th>
<td><input class="regular-text" type="password" id="rsv_google_client_secret" name="rsv_google_client_secret" placeholder="<?= $connected ? '(saved)' : '' ?>"></td>
</tr>
<tr>
<th><label for="rsv_google_calendar_id">Calendar ID</label></th>
<td>
<input class="regular-text" type="text" id="rsv_google_calendar_id" name="rsv_google_calendar_id" value="<?= $cal_id ?>">
<p class="description">Use <code>primary</code> for the account's main calendar, or paste a specific calendar ID from Google Calendar settings.</p>
</td>
</tr>
</table>
<?php submit_button('Save Settings'); ?>
<hr>
<h2>Connection</h2>
<?php if ($connected): ?>
<p><span style="color:#46b450; font-weight:600;">&#10004; Connected to Google Calendar.</span></p>
<button type="submit" name="rsv_disconnect" value="1" class="button button-secondary" style="color:#b32d2e;">
Disconnect
</button>
<?php else: ?>
<p><span style="color:#dc3232; font-weight:600;">&#10008; Not connected.</span></p>
<a href="<?= $oauth_url ?>" class="button button-primary">Connect with Google</a>
<p class="description" style="margin-top:.5rem;">You must save the Client ID and Secret first.</p>
<?php endif; ?>
<?php if ($connected): ?>
<hr>
<h2>Webhook</h2>
<p>The webhook lets Google Calendar notify this site when you confirm or cancel a reservation event, so the reservation state is updated automatically.</p>
<?php $webhook_url = site_url('/wp-json/reservations/v1/google-calendar-hook'); ?>
<p>Webhook URL: <code><?= esc_html($webhook_url) ?></code></p>
<?php if (!str_starts_with($webhook_url, 'https://')): ?>
<div class="notice notice-warning inline">
<p><strong>HTTPS required.</strong> Google Calendar only accepts webhook URLs served over HTTPS. This site is currently on HTTP, so webhook registration will be rejected by Google. Use a publicly accessible HTTPS address in production.</p>
</div>
<?php endif; ?>
<?php if ($webhook_registered): ?>
<p><span style="color:#46b450; font-weight:600;">&#10004; Webhook active.</span>
<?php if ($webhook_expiry): ?>
Expires <?= esc_html(date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $webhook_expiry)) ?>.
<?php endif; ?>
</p>
<button type="submit" name="rsv_register_webhook" value="1" class="button">Re-register</button>
<button type="submit" name="rsv_stop_webhook" value="1" class="button button-secondary" style="color:#b32d2e;">Stop</button>
<?php else: ?>
<p><span style="color:#dc3232; font-weight:600;">&#10008; Webhook not registered.</span></p>
<button type="submit" name="rsv_register_webhook" value="1" class="button button-primary">Register Webhook</button>
<?php endif; ?>
<?php endif; ?>
</form>
</div>
<?php
}

Some files were not shown because too many files have changed in this diff Show More