initial
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
build
|
||||
vendor/
|
||||
dist
|
||||
|
||||
# Editors
|
||||
.claude/
|
||||
.idea/
|
||||
.pytest_cache/
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "/phpactor.schema.json",
|
||||
"language_server_psalm.enabled": true
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"core": "WordPress/WordPress",
|
||||
"plugins": [
|
||||
"."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"languages": {
|
||||
"Python": {
|
||||
"language_servers": ["pyright"],
|
||||
"settings": {
|
||||
"python": {
|
||||
"pythonPath": "tests/rsv-tests/.venv/bin/python"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"label": "Run python file",
|
||||
"command": "python3",
|
||||
"args": ["$ZED_FILE"],
|
||||
"use_new_terminal": false,
|
||||
"env": {
|
||||
"PYTHONPATH": "."
|
||||
}
|
||||
}
|
||||
]
|
||||
+222
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit; // Exit if accessed directly.
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Styles
|
||||
|
||||
All stylesheet files are here. Each must have a `Rsv` prefix and be named in SnakeCase.
|
||||
@@ -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; }
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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 */
|
||||
@@ -0,0 +1,7 @@
|
||||
/*
|
||||
* Utilities for calling the API
|
||||
*/
|
||||
|
||||
function get_rest_url(resource) {
|
||||
return ReservairServiceAPI.restUrl + '/' + resource;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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');
|
||||
@@ -0,0 +1,3 @@
|
||||
# Elements
|
||||
|
||||
Some repeating components of the UI.
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -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&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&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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Executable
+110
@@ -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
|
||||
@@ -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
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
@@ -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>
|
||||
|
|
||||
<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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
@@ -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áš e‑mail.',
|
||||
'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');
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;">✔ 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;">✘ 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;">✔ 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;">✘ 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
Reference in New Issue
Block a user