This commit is contained in:
Martin Slachta
2026-06-11 19:03:29 +02:00
commit 0d829845c4
150 changed files with 38582 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# Styles
All stylesheet files are here. Each must have a `Rsv` prefix and be named in SnakeCase.
+23
View File
@@ -0,0 +1,23 @@
/* ─── Two-column admin layout (Forms page) ──────────────────────────────── */
/*#col-left { width: 30%; }
#col-right { width: 70%; }*/
/* ─── Inline detail expand row (Reservations page) ──────────────────────── */
.rsv-detail-expand { padding: 1rem 1.5rem; }
.rsv-detail-heading { margin: 0 0 0.5rem; }
.rsv-detail-empty { margin-bottom: 1rem; }
.rsv-detail-table { margin-bottom: 1rem; }
.rsv-detail-actions { display: flex; gap: 0.5rem; margin-top: 1rem; }
/* ─── Action buttons ─────────────────────────────────────────────────────── */
.rsv-btn-refuse { color: #b32d2e; }
/* ─── Form-values key/value table ────────────────────────────────────────── */
/* Depth-based indent: JS sets --rsv-depth on the cell, CSS does the math. */
.rsv-form-key { padding-left: calc(0.5rem + var(--rsv-depth, 0) * 1.5rem); }
.rsv-form-key--group { font-weight: 600; }
.rsv-form-val--null { color: #aaa; }
+187
View File
@@ -0,0 +1,187 @@
:root {
--color-gray-50: oklch(0.985 0.002 247.839); --color-gray-100: oklch(0.967 0.003 264.542); --color-gray-200: oklch(0.928 0.006 264.531); --color-gray-300: oklch(0.872 0.01 258.338); --color-gray-400: oklch(0.707 0.022 261.325); --color-gray-500: oklch(0.551 0.027 264.364); --color-gray-600: oklch(0.446 0.03 256.802); --color-gray-700: oklch(0.373 0.034 259.733); --color-gray-800: oklch(0.278 0.033 256.848); --color-gray-900: oklch(0.21 0.034 264.665); --color-gray-950: oklch(0.13 0.028 261.692);
--container-bg: var(--color-gray-50);
--border: 1px solid var(--color-gray-300);
--hover-bg: var(--color-blue-300);
--dimm-bg: var(--color-gray-200);
--selected-bg: var(--color-blue-400);
--container-border-radius: 1rem;
--s-1: 0.25rem;
--s-2: 0.5rem;
--s-3: 1rem;
--s-4: 1.5rem;
--s-5: 3rem;
--color-blue-50: oklch(97% .014 254.604);
--color-blue-100: oklch(93.2% .032 255.585);
--color-blue-200: oklch(88.2% .059 254.128);
--color-blue-300: oklch(80.9% .105 251.813);
--color-blue-400: oklch(70.7% .165 254.624);
--color-blue-500: oklch(62.3% .214 259.815);
--color-blue-600: oklch(54.6% .245 262.881);
--color-blue-700: oklch(48.8% .243 264.376);
--color-blue-800: oklch(42.4% .199 265.638);
--color-blue-900: oklch(37.9% .146 265.522);
--color-blue-950: oklch(28.2% .091 267.935);
--color-green-50: oklch(98.2% .018 155.826);
--color-green-100: oklch(96.2% .044 156.743);
--color-green-200: oklch(92.5% .084 155.995);
--color-green-300: oklch(87.1% .15 154.449);
--color-green-400: oklch(79.2% .209 151.711);
--color-green-500: oklch(72.3% .219 149.579);
--color-green-600: oklch(62.7% .194 149.214);
--color-green-700: oklch(52.7% .154 150.069);
--color-green-800: oklch(44.8% .119 151.328);
--color-green-900: oklch(39.3% .095 152.535);
--color-green-950: oklch(26.6% .065 152.934);
--color-red-50: oklch(97.1% .013 17.38);
--color-red-100: oklch(93.6% .032 17.717);
--color-red-200: oklch(88.5% .062 18.334);
--color-red-300: oklch(80.8% .114 19.571);
--color-red-400: oklch(70.4% .191 22.216);
--color-red-500: oklch(63.7% .237 25.331);
--color-red-600: oklch(57.7% .245 27.325);
--color-red-700: oklch(50.5% .213 27.518);
--color-red-800: oklch(44.4% .177 26.899);
--color-red-900: oklch(39.6% .141 25.723);
--color-red-950: oklch(25.8% .092 26.042);
}
rsv-reservation-selector {
border: var(--border);
border-radius: var(--container-border-radius);
}
/* ----- Widget shell ----- */
/*.widget {
background: #fff;
border-radius: 20px;
box-shadow: 0 2px 24px rgba(0, 0, 0, .07);
overflow: hidden;
color: #0f0f0f;
}
.widget-header {
padding: 22px 28px 18px;
border-bottom: 1px solid #f0f0f0;
}
.widget-title {
font-size: 22px;
font-weight: 700;
letter-spacing: -.02em;
}
.widget-subtitle {
font-size: 13px;
color: #888;
margin-top: 2px;
}
.widget-body {
display: flex;
}
.widget-calendar {
flex: 1;
padding: 20px 24px;
border-right: 1px solid #f0f0f0;
}
.widget-slots {
width: 240px;
padding: 20px 16px;
}
.widget-form {
padding: 20px 24px;
border-top: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
gap: 16px;
}*/
/* Nav arrows (prev / next month) */
.rsv-cal-btn-nav {
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid #e0e0e0;
background: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background .12s;
color: #555;
}
.rsv-cal-btn-nav {
outline: none;
}
.rsv-cal-btn-nav:hover {
background: #f0f0f0;
}
/* WIDGET START */
.reservation-list {
max-height: 300px;
overflow-y: scroll;
background-color: #f6f7f7;
margin: 0 -12px 6px -12px;
}
.reservation-list li {
margin: 0;
padding: 8px 12px;
color: #2c3338;
box-shadow: inset 0 1px 0 rgba(0,0,0,.06);
}
.reservation-list li:hover .row-actions {
position: static;
}
.reservation-list .row-actions {
margin: 3px 0 0;
padding: 0;
font-size: 13px;
line-height: 1.5;
}
/* WIDGET END */
.rsv-success-state {
display: flex;
flex-direction: column;
align-items: center;
}
.rsv-reset-button {
border: 1.5px solid #e0e0e0;
border-radius: 10px;
padding: 10px 20px;
font-size: 13px;
font-weight: 600;
color: #555;
font-family: var(--wp--preset--font-family--manrope);
}
+187
View File
@@ -0,0 +1,187 @@
.rsv-cal-month {
font-size: 15px;
font-weight: 600;
}
.rsv-cal-dow, .rsv-cal-header th {
text-align: center;
font-size: 11px;
font-weight: 600;
color: #999;
padding: 4px 0 8px;
text-transform: uppercase;
letter-spacing: .04em;
}
.rsv-cal-cell {
aspect-ratio: 1;
/*border-radius: 50%;*/
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background .12s, color .12s;
color: #0f0f0f;
user-select: none;
}
/* Days outside the current month */
.rsv-cal-cell-dimmed {
color: #ccc;
pointer-events: none;
}
/* Today's date — bold, no fill */
.rsv-cal-cell-today {
font-weight: 700;
}
/* Selected date */
.rsv-calendar .rsv-cal-cell input:checked+label {
background: #2563eb;
color: #fff;
}
/* Hover (only meaningful on non-selected, non-dimmed cells) */
.rsv-cal-cell:hover:not(.cell-dimmed) label {
background: #f0f4ff;
color: #2563eb;
}
/* Past dates — visual only, pointer-events handled in JS */
.rsv-cal-cell-past {
color: #ccc;
pointer-events: none;
}
.rsv-cal-month {
font-size: 15px;
font-weight: 600;
}
.rsv-calendar {
padding: 1rem;
}
.rsv-calendar table {
font-size: 0.875rem;
font-weight: 600;
table-layout: fixed;
border-collapse: separate;
/*border-radius: var(--container-border-radius);*/
width: 100%;
border-spacing: 0;
overflow: hidden;
}
/*.calendar button {
border: none;
background-color: transparent;
border-radius: var(--s-2);
}*/
/*.calendar button:focus {
border: none;
outline: none;
}
.calendar button:hover {
background-color: var(--hover-bg);
}*/
.rsv-calendar button svg {
vertical-align: middle;
}
.rsv-calendar tr {
text-align: center;
}
.rsv-calendar th {
padding: var(--s-2);
color: gray;
}
.rsv-calendar .rsv-cal-controls {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.rsv-calendar th.rsv-cal-controls>* {
flex-grow: 1;
}
.rsv-calendar td {
-webkit-user-select:none;user-select:none;
z-index: -1;
}
.rsv-calendar td label {
display: block;
padding: 0.25em;
border-radius: var(--s-4);
transition: background-color 0.3s ease;
}
/*.calendar td:hover label {
background-color: var(--color-gray-100);
}*/
.rsv-calendar td>div {
background-color: white;
padding: 0.5rem;
transition: background-color 0.3s ease;
}
.rsv-calendar td.today {
color: var(--selected);
}
.rsv-calendar td button {
transition: background-color 0.3s ease;
padding: 0.5rem;
}
/*.calendar td button:hover {
cursor: pointer;
background-color: var(--hover-bg);
}*/
.rsv-calendar td.selected>div {
position: relative;
color: white;
background-color: var(--color-blue-500);
}
.rsv-calendar td.dimm {
/*background-color: var(--dimm-bg);*/
}
.rsv-calendar tr:last-child>td {
border-bottom: none;
}
.rsv-calendar tr>td:last-child {
border-right: none;
}
.rsv-calendar td label {
width: 100%;
height: 100%;
padding: 0;
line-height: 2.4rem;
margin: 0;
}
.rsv-calendar input[type="radio"]:checked+label {
background-color: var(--color-blue-500);
color: white;
}
.rsv-cal-cell.dimm {
color: gray;
}
/* CALENDAR END */
+217
View File
@@ -0,0 +1,217 @@
/* Primary CTA (submit / confirm) */
.rsv-form-btn-primary {
background: #2563eb;
color: #fff;
border-radius: 1.375rem;
font-size: 1rem;
padding: 0 calc(1.25rem + 4px);
line-height: 140%;
height: 3.5rem;
border: none;
font-size: 15px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
width: 100%;
transition: background .12s;
letter-spacing: -.01em;
}
.rsv-form-btn-primary:hover {
background: #1d4ed8;
}
.rsv-form-btn-primary:disabled {
background: #e0e0e0;
color: #aaa;
cursor: not-allowed;
}
/* FORM */
.reservair-form {
margin-left: auto;
margin-right: auto;
}
.rsv-form-input-short {
max-width: 320px;
margin-left: auto;
margin-right: auto;
}
/*.reservair-form button {
padding: var(--s-3) !important;
font-weight: 400 !important;
}*/
/*.reservair-form button,*/
.rsv-form-input {
border: 1px solid var(--color-gray-300);
outline: none;
padding: var(--s-2);
border-radius: var(--s-2);
width: 100%;
box-sizing: border-box;
background-color: var(--color-gray-50);
transition: box-shadow 0.2s ease, border-color 0.2s ease;
}
/*.reservair-form button,*/
.rsv-form-input[type="submit"] {
background-color: var(--color-blue-500);
padding: 0.5rem;
color: white;
border: none;
font-size: 0.875rem;
font-weight: 600;
transition: background-color 0.2s ease;
}
.rsv-form-input[type="submit"]:hover {
background-color: var(--color-blue-400);
}
.reservair-form button.rsv-loading {
color: transparent;
pointer-events: none;
position: relative;
}
.reservair-form button.rsv-loading::after {
content: '';
position: absolute;
inset: 0;
margin: auto;
width: 1em;
height: 1em;
border: 2px solid white;
border-top-color: transparent;
border-radius: 50%;
animation: rsv-spin 0.6s linear infinite;
}
@keyframes rsv-spin {
to { transform: rotate(360deg); }
}
.rsv-form-input:focus {
box-shadow: 0 0 0 4px color-mix(in oklab,var(--color-blue-500)25%,transparent);
border-color: var(--color-blue-500);
}
.rsv-form-input input:user-invalid {
border-color: var(--color-red-500);
box-shadow: 0 0 0 4px color-mix(in oklab,var(--color-red-500)25%,transparent);
}
.rsv-form-section {
margin-bottom: var(--s-5);
}
.rsv-form-input-group>* {
margin-bottom: var(--s-1);
}
.rsv-form-input-group {
margin-bottom: var(--s-4);
}
.rsv-form-label,
.rsv-form-small {
padding-left: 5pt;
font-size: 0.875rem;
font-weight: 500;
display: block;
}
.rsv-form-small {
color: gray;
}
/*.confirmation small {
color: var(--color-gray-500);
}*/
.rsv-error-summary {
background-color: color-mix(in oklab, var(--color-red-500) 10%, transparent);
border: 1px solid var(--color-red-400);
border-radius: var(--s-2);
padding: var(--s-2) var(--s-3);
margin-bottom: var(--s-3);
font-size: 0.875rem;
color: var(--color-red-800);
}
.rsv-error-summary ul {
margin: 0;
padding-left: 1.25rem;
}
.rsv-field-error {
display: block;
color: var(--color-red-600);
font-size: 0.8rem;
margin-top: var(--s-1);
padding-left: 5pt;
}
.rsv-invalid {
border-color: var(--color-red-500) !important;
box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-red-500) 25%, transparent) !important;
}
.rsv-success-message {
text-align: center;
padding: var(--s-5);
color: var(--color-green-700);
}
.rsv-success-message p {
font-size: 1.125rem;
font-weight: 500;
}
.mesg {
width: 100%;
text-align: center;
line-height: 1rem;
margin-top: var(--s-5);
margin-bottom: var(--s-5);
padding: var(--s-4) 0;
}
.success-mesg-icon {
}
.mesg-icon svg {
width: 32px;
height: 32px;
padding: var(--s-2);
border-radius: 50%;
color: #00000094;
}
.error-mesg svg {
background-color: var(--color-red-200);
}
.success-mesg svg {
background-color: rgba(0, 201, 80, 0.36);
}
/* FORM END */
.rsv-timetable-selector {
display: grid;
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 768px) {
.rsv-timetable-selector {
grid-template-columns: repeat(1, 1fr);
}
}
@@ -0,0 +1,133 @@
/* ----- Summary (selected slots + price) ----- */
rsv-reservation-summary {
display: block;
margin-bottom: var(--s-4);
}
rsv-reservation-summary {
padding: 14px 20px;
background: #f8faff;
border: 1px solid #e8f0fe;
border-radius: 1.375rem;
box-sizing: border-box;
max-width: 320px;
margin-left: auto;
margin-right: auto;
}
.rsv-summary-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.rsv-summary-title {
font-size: 11px;
font-weight: 700;
color: #2563eb;
text-transform: uppercase;
letter-spacing: .06em;
}
.rsv-summary-clear {
font-size: 11px;
font-weight: 600;
color: #aaa;
background: none;
border: none;
font-family: inherit;
cursor: pointer;
padding: 0;
}
.rsv-summary-clear:hover { color: #e53e3e; }
.rsv-summary-list {
display: flex;
flex-direction: column;
gap: 6px;
list-style: none;
padding: 0;
margin: 0 0 10px;
}
.rsv-summary-item {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border: 1.5px solid #e8f0fe;
border-radius: 10px;
padding: 8px 12px;
}
.rsv-summary-item-info {
display: flex;
flex-direction: column;
gap: 1px;
}
.rsv-summary-item-date {
font-size: 11px;
color: #888;
}
.rsv-summary-item-time {
font-size: 13px;
font-weight: 600;
color: #0f0f0f;
}
.rsv-summary-item-price {
font-size: 1rem;
}
.rsv-summary-item-remove {
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: #f0f0f0;
color: #888;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all .12s;
flex-shrink: 0;
}
.rsv-summary-item-remove:hover {
background: #fee2e2;
color: #e53e3e;
}
.rsv-summary-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 10px;
border-top: 1px solid #e8f0fe;
}
.rsv-summary-count {
font-size: 12px;
color: #888;
}
.rsv-summary-price {
font-size: 16px;
font-weight: 700;
color: #0f0f0f;
letter-spacing: -.02em;
}
.rsv-summary-price span {
font-size: 12px;
font-weight: 500;
color: #888;
margin-left: 2px;
}
@@ -0,0 +1,153 @@
/* TIME SLOTS */
.rsv-time-slots {
padding: 1rem;
border-left: var(--border);
}
.rsv-slots-notice {
display: block;
text-align: center;
grid-column-start: 0;
grid-column-end: 1;
font-weight: 400;
font-size: 0.875rem;
}
.rsv-slots-slot-time {
font-size: 0.875rem;
font-weight: 600;
padding: 0.5rem;
-webkit-user-select:none;user-select:none;
}
.rsv-slots-slot-time>.content {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
align-content: center;
width: 100%;
padding: 0.5rem 1.5rem;
}
.rsv-slots-slot-time .capacity {
font-size: 1rem;
color: var(--color-gray-400);
}
.rsv-slots-slot-time .capacity>* {
display: block;
text-align: center;
width: 100%;
}
label.rsv-slots-slot-time>input:checked + .content>.capacity {
color: rgba(255, 255, 255, 0.7);
}
.reservation-block.blocked>.rsv-slots-slot-time>.content {
opacity: 0.8;
color: var(--color-gray-500);
text-decoration: line-through;
}
.rsv-slots-label {
font-size: 11px;
font-weight: 600;
color: #999;
text-transform: uppercase;
letter-spacing: .06em;
margin-bottom: 12px;
padding-top: 0.5rem;
}
.rsv-slots-list {
display: flex;
flex-direction: column;
gap: 6px;
list-style: none;
padding: 1rem;
margin: 0;
}
/* Base slot */
.rsv-slots-slot {
margin-top: 0.375rem;
border: 1.5px solid #e8e8e8;
border-radius: 10px;
padding: 9px 12px;
cursor: pointer;
transition: border-color .12s, background .12s, color .12s;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
font-weight: 600;
}
.rsv-slots-slot:hover:not(.rsv-slots-slot-full):not(.rsv-slots-slot-selected) {
border-color: #2563eb;
background: #f5f8ff;
}
/* Available — plenty of spots */
.rsv-slots-slot-available {
background: #f0fdf4;
border-color: #86efac;
color: #166534;
}
/* Few spots left */
.rsv-slots-slot-few {
background: #fff8f0;
border-color: #f59e0b;
color: #92400e;
}
/* Fully booked */
.rsv-slots-slot-full {
background: #fafafa;
border-color: #e8e8e8;
color: #bbb;
text-decoration: line-through;
cursor: not-allowed;
}
/* Selected */
.rsv-slots-slot-selected {
background: #2563eb;
border-color: #2563eb;
color: #fff;
}
/* Availability badge (small pill inside slot) */
.rsv-slots-slot-badge {
font-size: 10px;
font-weight: 600;
border-radius: 6px;
padding: 2px 7px;
}
.rsv-slots-slot-badge-available {
background: #dcfce7;
color: #166534;
}
.rsv-slots-slot-badge-few {
background: #fef3c7;
color: #92400e;
}
.rsv-slots-slot-badge-full {
background: #f3f4f6;
color: #9ca3af;
}
.rsv-slots-slot-badge-selected {
background: rgba(255, 255, 255, .2);
color: #fff;
}
/* TIMELINE END */
+7
View File
@@ -0,0 +1,7 @@
/*
* Utilities for calling the API
*/
function get_rest_url(resource) {
return ReservairServiceAPI.restUrl + '/' + resource;
}
View File
+5
View File
@@ -0,0 +1,5 @@
# JS Data Sources
The JavaScript *RSV* Client layer. The frontend uses `RsvDataSource` to work with the Reservair's REST API.
The `RsvDataSource` is an object for a specific resource & contains methods for working with it.
+44
View File
@@ -0,0 +1,44 @@
const RsvDataSource = {
create_rsv_resource(base_url, { nonce } = {}) {
function request(url, method, body) {
const headers = { 'Content-Type': 'application/json' };
if (nonce) headers['X-WP-Nonce'] = nonce;
else headers['X-WP-Nonce'] = ReservairServiceAPI.nonce;
return fetch(url, {
method,
credentials: 'same-origin',
headers,
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
}).then(r => {
if (!r.ok) throw new Error(`${method} ${url} failed: ${r.status}`);
return r.status === 204 ? null : r.json();
});
}
return {
base_url: base_url,
get_page(skip = 0, limit = 20, params = {}) {
const url = new URL(base_url);
url.searchParams.set('skip', skip);
url.searchParams.set('limit', limit);
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, v);
}
return request(url, 'GET');
},
get(id) {
return request(`${base_url}/${id}`, 'GET');
},
post(data) {
return request(base_url, 'POST', data);
},
put(id, data) {
return request(`${base_url}/${id}`, 'PUT', data);
},
delete(id) {
return request(`${base_url}/${id}`, 'DELETE');
},
};
}
};
@@ -0,0 +1,2 @@
const RsvFormDefinitionResource = () =>
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/form-definition');
@@ -0,0 +1,19 @@
const RsvReservationClient = {
accept(reservation_id) {
return this._post(reservation_id, 'accept');
},
refuse(reservation_id) {
return this._post(reservation_id, 'refuse');
},
_post(reservation_id, action) {
return fetch(`${ReservairServiceAPI.restUrl}/reservation/${reservation_id}/${action}`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-WP-Nonce': ReservairServiceAPI.nonce },
}).then(r => {
if (!r.ok) return r.json().then(e => { throw new Error(e.error || 'Request failed'); });
});
},
};
@@ -0,0 +1,2 @@
const RsvReservationResource = () =>
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/reservation');
@@ -0,0 +1,2 @@
const RsvTimetableCapacityResource = (id) =>
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + `/timetable/${id}/capacity`);
@@ -0,0 +1,2 @@
const RsvTimetableReservationResource = (id) =>
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + `/timetable/${id}/reservation`);
@@ -0,0 +1,2 @@
const RsvTimetableResource = () =>
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/timetable');
+3
View File
@@ -0,0 +1,3 @@
# Elements
Some repeating components of the UI.
+196
View File
@@ -0,0 +1,196 @@
const RsvCalendarPicker = (() => {
function get_first_day_of_month(date) {
const day = new Date(date.getFullYear(), date.getMonth(), 1).getDay();
return day === 0 ? 6 : day - 1; // Mon=0 … Sun=6
}
function is_same_day(a, b) {
return a.getUTCFullYear() === b.getUTCFullYear()
&& a.getUTCMonth() === b.getUTCMonth()
&& a.getUTCDate() === b.getUTCDate();
}
function is_same_month(a, b) {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth();
}
function clear_class(root, cls) {
root.querySelectorAll('.' + cls).forEach(el => el.classList.remove(cls));
}
function set_cell(cell, date, outside) {
cell.classList.toggle('dimm', outside);
const iso = date.toISOString();
cell.setAttribute('datetime', iso);
cell.children[0].id = iso;
cell.children[0].setAttribute('datetime', iso);
cell.children[1].textContent = date.getUTCDate();
cell.children[1].setAttribute('for', iso);
}
function render(state, date) {
const year = date.getFullYear();
const month = date.getMonth();
const first = get_first_day_of_month(date);
const in_cur = new Date(year, month + 1, 0).getDate();
const in_prev = new Date(year, month, 0).getDate();
const today = new Date();
const rows = state.body.querySelectorAll('tr');
clear_class(state.body, 'rsv-cal-cell-current');
clear_class(state.body, 'rsv-cal-cell-today');
let idx = 0;
for (let d = in_prev - first + 1; d <= in_prev; d++, idx++) {
const dt = new Date(Date.UTC(year, month - 1, d));
const cell = rows[0].children[idx];
set_cell(cell, dt, true);
if (is_same_day(dt, today)) cell.classList.add('rsv-cal-cell-today');
}
for (let i = 1; i <= in_cur; i++, idx++) {
const dt = new Date(Date.UTC(year, month, i));
const cell = rows[Math.floor(idx / 7)].children[idx % 7];
set_cell(cell, dt, false);
if (is_same_day(dt, date)) cell.querySelector('input').checked = true;
if (is_same_day(dt, today)) cell.classList.add('rsv-cal-cell-today');
}
for (let i = 1; idx < 42; i++, idx++) {
const dt = new Date(Date.UTC(year, month + 1, i));
const cell = rows[Math.floor(idx / 7)].children[idx % 7];
set_cell(cell, dt, true);
if (is_same_day(dt, today)) cell.classList.add('rsv-cal-cell-today');
}
state.month_el.textContent =
new Date(year, month).toLocaleString(navigator.language, { month: 'long' }) + ' ' + year;
}
function day_names() {
// Generate short weekday names starting on Monday using the browser locale.
return Array.from({ length: 7 }, (_, i) =>
new Date(2024, 0, 1 + i) // 2024-01-01 is a Monday
.toLocaleDateString(navigator.language, { weekday: 'short' })
);
}
const ARROW_L = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0"/></svg>`;
const ARROW_R = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708"/></svg>`;
function nav_btn(icon, handler) {
const btn = document.createElement('button');
btn.type = 'button';
btn.innerHTML = icon;
btn.classList.add('rsv-cal-btn-nav');
btn.addEventListener('click', handler);
const wrap = document.createElement('div');
wrap.appendChild(btn);
return wrap;
}
function build_header(state) {
const month_el = document.createElement('span');
month_el.classList.add('rsv-cal-month');
state.month_el = month_el;
const controls = document.createElement('div');
controls.classList.add('rsv-cal-controls');
controls.append(
nav_btn(ARROW_L, () => state.set_date(new Date(state.date.getFullYear(), state.date.getMonth() - 1, state.date.getDate()))),
month_el,
nav_btn(ARROW_R, () => state.set_date(new Date(state.date.getFullYear(), state.date.getMonth() + 1, state.date.getDate())))
);
const ctrl_td = document.createElement('td');
ctrl_td.colSpan = 7;
ctrl_td.appendChild(controls);
const ctrl_row = document.createElement('tr');
ctrl_row.appendChild(ctrl_td);
const names_row = document.createElement('tr');
day_names().forEach(name => {
const th = document.createElement('th');
th.textContent = name;
names_row.appendChild(th);
});
const header = document.createElement('thead');
header.classList.add('rsv-cal-header');
header.append(ctrl_row, names_row);
return header;
}
function build_body(name, on_select) {
const tbody = document.createElement('tbody');
tbody.classList.add('rsv-cal-grid');
for (let y = 0; y < 6; y++) {
const row = document.createElement('tr');
for (let x = 0; x < 7; x++) {
const radio = document.createElement('input');
radio.type = 'radio';
radio.name = name + '.date';
radio.hidden = true;
radio.addEventListener('change', on_select);
const label = document.createElement('label');
label.setAttribute('unselectable', 'true');
const cell = document.createElement('td');
cell.classList.add('rsv-cal-cell');
cell.append(radio, label);
row.appendChild(cell);
}
tbody.appendChild(row);
}
return tbody;
}
return {
create(container, name) {
const state = {
date: null,
month_el: null,
body: null,
container,
set_date(date) {
if (this.date !== null && is_same_day(date, this.date)) return;
const month_changed = this.date === null || !is_same_month(date, this.date);
if (month_changed) {
const prev = this.body.querySelector('input[type="radio"]:checked');
if (prev) prev.checked = false;
render(this, date);
const next = this.body.querySelector(`input[id="${date.toISOString()}"]`);
if (next) next.checked = true;
}
const first = get_first_day_of_month(date);
const cell_idx = first + date.getDate() - 1;
const rows = this.body.querySelectorAll('tr');
rows[Math.floor(cell_idx / 7)].children[cell_idx % 7].querySelector('input').checked = true;
this.date = date;
this.container.value = date;
this.container.dispatchEvent(
new InputEvent('change', { bubbles: true, cancelable: true, composed: true })
);
},
};
container.classList.add('rsv-calendar');
const table = document.createElement('table');
table.appendChild(build_header(state));
table.appendChild(build_body(name, e => state.set_date(new Date(e.target.getAttribute('datetime')))));
state.body = table.querySelector('tbody');
container.appendChild(table);
state.set_date(new Date());
return state;
},
};
})();
+422
View File
@@ -0,0 +1,422 @@
/**
* RSV Dynamic datagrid
* Allows fetching with JS instead of page reload.
*/
window.RsvDataGrid = window.RsvDataGrid || {
create_header(self, columns, has_actions) {
let thead = document.createElement('thead');
thead.replaceChildren(...Object.entries(columns).map(([key, value]) => {
let th = document.createElement('th');
if (value.width) {
th.style.width = value.width + 'px';
}
th.classList.add('manage-columns', 'column-' + key);
if (value.is_sortable) {
th.classList.add('sortable', key);
let button = document.createElement('a');
button.onclick = () => {
self.sort_by(key);
};
button.innerHTML =
`<span>${value.label}</span>
<span class="sorting-indicators">
<span class="sorting-indicator asc" aria-hidden="true"></span>
<span class="sorting-indicator desc" aria-hidden="true"></span>
</span>
<span class="screen-reader-text">Sort ascending.</span>
`;
th.appendChild(button);
} else {
th.innerText = value.label;
}
return th;
}));
if (has_actions) {
let th = document.createElement('th');
th.innerText = 'Actions';
thead.appendChild(th);
}
return thead;
},
create_footer(self, columns, has_actions) {
let tfoot = document.createElement('tfoot');
let trow = document.createElement('tr');
trow.replaceChildren(...Object.entries(columns).map(([key, value]) => {
let th = document.createElement('th');
if (value.width) {
th.style.width = value.width + 'px';
}
th.classList.add('manage-columns', 'column-' + key);
if (value.is_sortable) {
th.classList.add('sortable', key);
let button = document.createElement('a');
button.onclick = () => {
self.sort_by(key);
};
button.innerHTML =
`<span>${value.label}</span>
<span class="sorting-indicators">
<span class="sorting-indicator asc" aria-hidden="true"></span>
<span class="sorting-indicator desc" aria-hidden="true"></span>
</span>
<span class="screen-reader-text">Sort ascending.</span>
`;
th.appendChild(button);
} else {
th.innerText = value.label;
}
return th;
}));
tfoot.appendChild(trow);
return tfoot;
},
create_dg_row(self, data, index = 0) {
let row = document.createElement('tr');
row.classList.add('iedit', 'author-self', 'level-0', 'type-page', 'status-publish', 'hentry');
row.replaceChildren(...Object.entries(self.columns).map(([key, value]) => {
let td = document.createElement('td');
if (self.mappings[key] != null) {
td = self.mappings[key](self, row, data);
} else {
td.innerText = data[key];
}
if(value.actions != null && Object.entries(value.actions).length > 0) {
const visible_actions = Object.entries(value.actions).filter(([, action]) =>
action.condition == null || action.condition(data, index)
);
if (visible_actions.length === 0) return td;
row.classList.add('has-row-actions')
const action_cell = document.createElement('div');
action_cell.classList.add('row-actions', 'visible');
const action_spans = visible_actions.map(([key, value]) => {
if (value.is_link) {
let span = document.createElement('span');
let a = document.createElement('a');
a.innerText = key;
a.href = value.func(data);
span.appendChild(a);
return span;
} else {
let span = document.createElement('span');
let button = document.createElement('a');
button.onclick = function () { value.func(self, row, data) };
button.innerText = key;
span.appendChild(button);
return span;
}
});
const action_nodes = action_spans.flatMap((span, i) =>
i < action_spans.length - 1 ? [span, document.createTextNode(' | ')] : [span]
);
action_cell.replaceChildren(...action_nodes);
td.appendChild(action_cell);
}
return td;
}));
return row;
},
async render_data_grid(self) {
const rows = self.fetch_resource()
.then(x => { self.set_total(x.total); return x; })
.then(x => x.data.map((x, i) => RsvDataGrid.create_dg_row(self, x, i)))
.then(x => self.body.replaceChildren(...x))
.catch(error => {
console.error(error);
return []; // empty the rows
});
},
link_action(func, condition = null) {
return { is_link: true, func, condition };
},
func_action(func, condition = null) {
return { is_link: false, func, condition };
},
edit_action(func, condition = null) {
return { is_link: false, func, condition };
},
action_column(label, is_sortable, actions) {
return {
label: label,
is_sortable: is_sortable,
actions: actions,
};
},
column(label, is_sortable = false, width = 0) {
return {
label: label,
is_sortable: is_sortable,
width: width
};
},
create_paging_button(text) {
let button = document.createElement('a');
button.classList.add('button');
let label = document.createElement('span');
label.innerText = text;
button.appendChild(label);
return button;
},
create_last_page_btn() {
let btn = this.create_paging_button("»");
btn.classList.add('last-page');
return btn;
},
create_next_page_btn() {
let btn = this.create_paging_button("");
btn.classList.add('next-page');
return btn;
},
create_first_page_btn() {
let btn = this.create_paging_button("«");
btn.classList.add('first-page');
return btn;
},
create_prev_page_btn() {
let btn = this.create_paging_button("");
btn.classList.add('prev-page');
return btn;
},
create_paging_text() {
let text = document.createElement('span');
text.classList.add('paging-input');
let paging_text = document.createElement('span');
paging_text.classList.add('tablenav-paging-text');
text.appendChild(paging_text);
const result = {
container: text,
paging_text: paging_text,
};
return result;
},
create_paging_controls(self) {
let nav = document.createElement('div');
nav.classList.add('tablenav-pages');
let displaying_num = document.createElement('span');
displaying_num.classList.add('displaying-num');
nav.appendChild(displaying_num);
let pagination_links = document.createElement('span');
pagination_links.classList.add('pagination-links');
let first_page_btn = this.create_first_page_btn();
first_page_btn.onclick = () => self.goto_first_page();
let prev_page_btn = this.create_prev_page_btn();
prev_page_btn.onclick = () => self.move_page(-1);
let next_page_btn = this.create_next_page_btn();
next_page_btn.onclick = () => self.move_page(1);
let last_page_btn = this.create_last_page_btn();
last_page_btn.onclick = () => self.goto_last_page();
pagination_links.appendChild(first_page_btn);
pagination_links.appendChild(prev_page_btn);
let paging_text = this.create_paging_text();
pagination_links.appendChild(paging_text.container);
pagination_links.appendChild(next_page_btn);
pagination_links.appendChild(last_page_btn);
// pagination_links.innerHTML = `
// <span class="pagination-links">
// <span class="tablenav-pages-navspan button disabled" aria-hidden="true">«</span>
// <span class="tablenav-pages-navspan button disabled" aria-hidden="true"></span>
// <span class="screen-reader-text">Current Page</span>
// <span id="table-paging" class="paging-input">
// <span class="tablenav-paging-text">1 of <span class="total-pages">2</span></span>
// </span>
// <a class="next-page button" href="http://127.0.0.1/wordpress/wp-admin/edit-tags.php?taxonomy=category&amp;paged=2">
// <span class="screen-reader-text">Next page</span>
// <span aria-hidden="true"></span>
// </a>
// <a class="last-page button" href="http://127.0.0.1/wordpress/wp-admin/edit-tags.php?taxonomy=category&amp;paged=2">
// <span class="screen-reader-text">Last page</span>
// <span aria-hidden="true">»</span>
// </a>
// </span>`;
nav.appendChild(pagination_links);
const paging_controls = {
container: nav,
paging_text: paging_text.paging_text,
display_num: displaying_num,
};
return paging_controls;
},
create_data_grid(container, resource, columns, actions) {
let tbody = document.createElement('tbody');
let state = {
columns: columns,
resource: resource,
actions: actions,
body: tbody,
mappings: {},
params: {},
total: 0,
page: 0,
page_size: 20,
container: container,
order_by: null,
order: 0,
fetch_resource() {
const params = { ...this.params };
if (this.order_by) {
params.orderby = this.order_by;
params.order = this.order === 0 ? 'desc' : 'asc';
}
return this.resource.get_page(this.page * this.page_size, this.page_size, params);
},
refresh() {
RsvDataGrid.render_data_grid(this)
},
refresh_row(row, data) {
let row2 = RsvDataGrid.create_dg_row(this, data);
row.replaceWith(row2);
},
map_column(key, func) {
this.mappings[key] = func;
return this;
},
add_action(key, func) {
this.actions[key] = func;
return this;
},
set_param(key, value) {
this.params[key] = value;
},
remove_param(key, do_refresh = true) {
delete this.params[key];
if (do_refresh) {
this.refresh();
}
},
sort_by(key, dir = null, do_refresh = true) {
let ths = this.container.getElementsByClassName("column-" + key);
if (ths.length > 0) {
this.order = dir ?? (this.order_by === key ? 1 - this.order : 0);
this.order_by = key;
let th = ths[0];
if (this.order === 0) {
th.classList.remove('asc');
th.classList.add('desc');
} else {
th.classList.remove('desc');
th.classList.add('asc');
}
// if (th.classList.contains('asc')) {
// th.classList.replace('asc', 'desc');
// this.set_param('order', 'desc');
// } else {
// th.classList.remove('desc');
// th.classList.add('asc');
// this.set_param('order', 'asc');
// }
let sorted = th.parentElement.getElementsByClassName('sorted');
if (sorted.length > 0)
sorted[0].classList.replace('sorted', 'sortable');
th.classList.replace('sortable', 'sorted');
if (do_refresh) {
this.refresh();
}
}
return this;
},
set_total(count) {
this.total = count;
this.paging_controls.display_num.innerHTML = `${this.total} položek`;
this.paging_controls.paging_text.innerHTML = `${this.page + 1} of <span class="total-pages">${Math.ceil(count / this.page_size)}</span>`;
},
get_total() {
return this.total;
},
move_page(relative) {
this.page = Math.max(0, Math.min(this.page + relative, Math.ceil(this.total / this.page_size) - 1));
this.refresh();
},
goto_first_page() {
this.move_page(-this.page);
},
goto_last_page() {
this.move_page(Math.ceil(this.total / this.page_size) - this.page - 1);
},
};
let paging_controls = this.create_paging_controls(state);
let footer = document.createElement('div');
footer.classList.add('tablenav', 'bottom');
footer.appendChild(paging_controls.container);
state.paging_controls = paging_controls;
let table = document.createElement('table');
table.classList.add('datagrid', 'wp-list-table', 'widefat', 'fixed', 'striped', 'table-view-list');
// const has_actions = Object.entries(actions).length > 0;
table.appendChild(this.create_header(state, columns, false));
table.appendChild(tbody);
table.appendChild(this.create_footer(state, columns, false));
container.appendChild(table);
container.appendChild(footer);
return state;
}
}
@@ -0,0 +1,117 @@
class RsvReservationSelector extends HTMLElement {
static get observedAttributes() {
return ['timetable-id', 'name', 'price-per-block'];
}
// ---- Attribute accessors ------------------------------------------------
get timetableId() { return parseInt(this.getAttribute('timetable-id')); }
get inputName() { return this.getAttribute('name') ?? 'reservation'; }
get pricePerBlock() { return parseFloat(this.getAttribute('price-per-block')) || 0; }
// ---- Lifecycle ----------------------------------------------------------
connectedCallback() {
this._slots = [];
this.classList.add('rsv-timetable-selector');
this._build();
}
attributeChangedCallback(_attr, oldVal, newVal) {
if (oldVal === null || oldVal === newVal || !this.isConnected) return;
this._build();
}
// ---- Public API ---------------------------------------------------------
getValue() {
return {
timetable_id: this.timetableId,
timetable_reservations: this._slots.map(s => s.start_utc),
};
}
clear() {
this.querySelectorAll('.rsv-slots-slot-selected').forEach(s => s.classList.remove('rsv-slots-slot-selected'));
this._slots = [];
this._commit();
}
// ---- Private ------------------------------------------------------------
_build() {
this._slots = [];
this.replaceChildren();
const tid = document.createElement('input');
tid.type = 'hidden';
tid.name = `${this.inputName}.timetable_id`;
tid.value = this.timetableId;
this.appendChild(tid);
const cal_el = document.createElement('div');
cal_el.classList.add('rsv-calendar');
// Create rsv-timeline with timetable-id set before appending so
// connectedCallback sees the correct attribute on first render.
const time_el = document.createElement('rsv-timeline');
time_el.setAttribute('timetable-id', this.timetableId);
this.append(cal_el, time_el);
this._calendar = RsvCalendarPicker.create(cal_el, this.inputName);
// Date change: clear selection, then push new date to the timeline element.
cal_el.addEventListener('change', () => {
this.querySelectorAll('.rsv-slots-slot-selected').forEach(s => s.classList.remove('rsv-slots-slot-selected'));
this._slots = [];
this._commit();
time_el.date = this._calendar.date;
});
// Slot toggle: read selected slots from timeline, then commit.
time_el.addEventListener('input', e => {
e.stopPropagation();
this._slots = Array.from(time_el.querySelectorAll('.rsv-slots-slot-selected')).map(s => ({
start_utc: s.dataset.start_utc,
end_utc: s.dataset.end_utc,
}));
this._commit();
});
this._commit();
}
_commit() {
const name = this.inputName;
this.querySelectorAll(`input[name="${name}.timetable_reservations[]"]`).forEach(i => i.remove());
let json = [];
this._slots.forEach(slot => {
const inp = document.createElement('input');
inp.type = 'hidden';
inp.name = `${name}.timetable_reservations[]`;
inp.value = slot.start_utc;
this.appendChild(inp);
json.push(slot.start_utc);
});
this.value = JSON.stringify({
"timetable_id": this.timetableId,
"timetable_reservations": json
});
this.dispatchEvent(new CustomEvent('rsv:slots-changed', {
bubbles: true,
detail: {
name,
slots: this._slots,
price_per_block: this.pricePerBlock,
value: this.getValue(),
},
}));
}
}
customElements.define('rsv-reservation-selector', RsvReservationSelector);
+100
View File
@@ -0,0 +1,100 @@
class RsvReservationSummary extends HTMLElement {
// ---- Lifecycle ----------------------------------------------------------
connectedCallback() {
this._all_slots = new Map(); // name → { slots, price_per_block }
this._form = this.closest('form');
this._build();
if (this._form) {
this._handler = e => {
this._all_slots.set(e.detail.name, {
slots: e.detail.slots,
price_per_block: e.detail.price_per_block,
});
this._render();
};
this._form.addEventListener('rsv:slots-changed', this._handler);
}
}
disconnectedCallback() {
if (this._form && this._handler) {
this._form.removeEventListener('rsv:slots-changed', this._handler);
}
}
// ---- Private ------------------------------------------------------------
_build() {
const s = ReservairStrings.summary;
this.innerHTML = `
<div class="rsv-summary-header">
<span class="rsv-summary-title">${s.title}</span>
<button type="button" class="rsv-summary-clear">${s.clear_all}</button>
</div>
<ul class="rsv-summary-list"></ul>
<div class="rsv-summary-footer">
<span class="rsv-summary-count"></span>
<div class="rsv-summary-price"></div>
</div>
`;
this.hidden = true;
this.querySelector('.rsv-summary-clear').addEventListener('click', () => {
this._form?.querySelectorAll('rsv-reservation-selector').forEach(sel => sel.clear());
});
}
_render() {
const all_slots = [...this._all_slots.values()].flatMap(({ slots, price_per_block }) =>
slots.map(s => ({ ...s, price_per_block }))
);
console.log(all_slots);
const n = all_slots.length;
const list = this.querySelector('.rsv-summary-list');
const count_el = this.querySelector('.rsv-summary-count');
const price_el = this.querySelector('.rsv-summary-price');
const s = ReservairStrings.summary;
const locale = navigator.language;
this.hidden = n === 0;
if (n === 0) {
list.replaceChildren();
count_el.textContent = '';
price_el.textContent = '';
return;
}
const time_opts = { hour: '2-digit', minute: '2-digit' };
list.replaceChildren(...all_slots.map(slot => {
const start = new Date(slot.start_utc);
const end = new Date(slot.end_utc);
const li = document.createElement('li');
li.className = 'rsv-summary-item';
li.innerHTML = `
<div class="rsv-summary-item-info">
<span class="rsv-summary-item-date">${start.toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long' })}</span>
<span class="rsv-summary-item-time">${start.toLocaleTimeString(locale, time_opts)} ${end.toLocaleTimeString(locale, time_opts)}</span>
</div>
${slot.price_per_block > 0 ? `<span class="rsv-summary-item-price">${slot.price_per_block} ${s.currency}</span>` : ''}
`;
return li;
}));
const total = all_slots.reduce((sum, slot) => sum + slot.price_per_block, 0);
count_el.textContent = this._fmt_count(n);
price_el.textContent = total > 0 ? `${total} ${s.currency}` : '';
}
_fmt_count(n) {
const s = ReservairStrings.summary;
if (n === 1) return s.count_one;
if (n < 5) return s.count_few.replace('%d', n);
return s.count_many.replace('%d', n);
}
}
customElements.define('rsv-reservation-summary', RsvReservationSummary);
+141
View File
@@ -0,0 +1,141 @@
class RsvTimeline extends HTMLElement {
static get observedAttributes() {
return ['timetable-id', 'date'];
}
// ---- Attribute accessors ------------------------------------------------
get timetableId() { return parseInt(this.getAttribute('timetable-id')); }
get date() {
const attr = this.getAttribute('date');
// Parse as local midnight so setHours() in block rendering stays in local time.
return attr ? new Date(attr + 'T12:00:00') : new Date();
}
set date(value) {
const d = new Date(value);
const str = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.setAttribute('date', str);
}
// ---- Lifecycle ----------------------------------------------------------
connectedCallback() {
this._version = 0;
this.classList.add('rsv-slots-list');
this.addEventListener('click', this._on_click.bind(this));
this._render();
}
attributeChangedCallback(_attr, oldVal, newVal) {
if (oldVal === newVal || !this.isConnected) return;
this._render();
}
// ---- Private ------------------------------------------------------------
_on_click(event) {
const slot = event.target.closest('.rsv-slots-slot');
if (slot && !slot.classList.contains('rsv-slots-slot-full')) {
slot.classList.toggle('rsv-slots-slot-selected');
slot.dispatchEvent(new Event('input', { bubbles: true }));
}
}
async _render() {
// Version guard: discard renders that were superseded by a newer call.
const v = ++this._version;
const s = ReservairStrings.timeline;
if (this.timetableId === null) {
this.replaceChildren(this._notice(s.not_reservable));
return;
}
try {
const occupancy = await RsvTimetableService.get_availability_for_date(this.timetableId, this.date);
if (v !== this._version) return;
if(occupancy.length === 0) {
this.replaceChildren(this._notice(s.no_blocks));
return;
}
const header = document.createElement('div');
header.classList.add('rsv-slots-label');
header.textContent = this.date.toLocaleDateString(navigator.language, {
weekday: 'long', day: 'numeric', month: 'long',
}).replace(',', '');
const blocks = [];
for (const { from_minutes, to_minutes, block_size_in_minutes, occupancy: block_occ } of occupancy) {
if (from_minutes === to_minutes || block_occ.length === 0) {
continue;
}
const from_block = parseInt(from_minutes) / block_size_in_minutes;
const time_slots = block_occ.map((occ, i) =>
this._block(this.date, occ, block_size_in_minutes, from_block + i)
);
const time_slot_group = document.createElement('div');
time_slot_group.classList.add('rsv-slots-group');
time_slot_group.replaceChildren(...time_slots);
blocks.push(time_slot_group);
}
this.replaceChildren(header, ...blocks);
} catch (_e) {
if (v !== this._version) return;
this.replaceChildren(this._notice(s.no_blocks));
}
}
_block(date, left, block_size, idx) {
const from = new Date(date);
from.setHours(0, idx * block_size, 0, 0);
const to = new Date(from);
to.setMinutes(to.getMinutes() + block_size);
const cell = document.createElement('div');
cell.classList.add('rsv-slots-slot', 'rsv-slots-slot-available');
cell.dataset.start_utc = from.toISOString();
cell.dataset.end_utc = to.toISOString();
if (left === 0) cell.classList.add('rsv-slots-slot-full');
const time_el = document.createElement('span');
time_el.classList.add('rsv-slots-slot-time');
time_el.textContent = `${this._fmt(from)} ${this._fmt(to)}`;
const badge = document.createElement('span');
badge.classList.add('rsv-slots-slot-badge');
const remaining_seats = left;
if (remaining_seats > 0) badge.classList.add('rsv-slots-slot-badge-available');
if (remaining_seats === 1) badge.textContent = `${remaining_seats} místo`;
else if (remaining_seats >= 2 && remaining_seats <= 4) badge.textContent = `${remaining_seats} místa`;
else badge.textContent = `${remaining_seats} míst`;
cell.append(time_el, badge);
return cell;
}
_notice(text) {
const p = document.createElement('p');
p.classList.add('rsv-slots-notice');
p.textContent = text;
return p;
}
_fmt(dt) {
return dt.getHours() + ':' + String(dt.getMinutes()).padStart(2, '0');
}
}
customElements.define('rsv-timeline', RsvTimeline);
+57
View File
@@ -0,0 +1,57 @@
/*
* RsvAdminForm — shared submit handler for wp-admin forms.
*
* Serializes a <form> to JSON (via RsvFormEncoder), sends it to the form's
* `action` using the HTTP verb in `data-method`, always attaches the REST
* nonce, and reports the outcome through show_notice(). The only part that
* legitimately differs between forms — shaping the request body — is handled
* by the optional `transform(body, form)` hook.
*
* Usage:
* RsvAdminForm.bind(my_form, {
* transform: (body, form) => ({ ...body, block_size: parseInt(body.block_size) }),
* refresh: () => my_datagrid.refresh(),
* });
*/
const RsvAdminForm = {
// Attach a submit listener that sends the form as JSON.
bind(form, options = {}) {
if (!form) return;
form.addEventListener('submit', (event) => {
event.preventDefault();
RsvAdminForm.submit(form, options);
});
},
// Send the form now. Returns the fetch promise.
submit(form, { transform, refresh, onSuccess } = {}) {
let body = RsvFormEncoder.encode_form(form);
if (transform) body = transform(body, form);
// `form.method` always returns a string (default 'get'), so default to POST
// explicitly unless the view opted into a verb via data-method.
const method = (form.dataset.method || 'POST').toUpperCase();
return fetch(form.action, {
method,
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-WP-Nonce': ReservairServiceAPI.nonce,
},
body: JSON.stringify(body),
})
.then(async (response) => {
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || data?.message || 'Request failed');
return data;
})
.then((data) => {
show_notice(form, 'success', form.dataset.successMsg ?? 'Saved.');
if (refresh) refresh();
if (onSuccess) onSuccess(data);
})
.catch((error) => show_notice(form, 'error', error.message));
},
};
+41
View File
@@ -0,0 +1,41 @@
const RsvFormEncoder = {
// Serialize form element into a plain JS object supporting arrays.
// - Nested keys supported with dot notation: 'meta.email'
// - Array notation supported with trailing [] (e.g. 'times[]') or multiple inputs with same name
encode_form(form_element) {
const formData = new FormData(form_element);
const body = {};
for (const [rawKey, value] of formData.entries()) {
const isArrayNotation = rawKey.endsWith('[]');
const key = isArrayNotation ? rawKey.slice(0, -2) : rawKey;
const keys = key.split('.');
let current = body;
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i];
if (current[k] === undefined || typeof current[k] !== 'object') {
current[k] = {};
}
current = current[k];
}
const lastKey = keys[keys.length - 1];
if (isArrayNotation) {
if (!Array.isArray(current[lastKey])) current[lastKey] = [];
current[lastKey].push(value);
} else {
if (current[lastKey] === undefined) {
current[lastKey] = value;
} else if (Array.isArray(current[lastKey])) {
current[lastKey].push(value);
} else {
current[lastKey] = [current[lastKey], value];
}
}
}
return body;
}
}
+142
View File
@@ -0,0 +1,142 @@
const RsvFormSender = {
get_form_url(form_id) {
return ReservairServiceAPI.restUrl + '/form/' + form_id;
},
clear_feedback(form) {
form.querySelectorAll('.rsv-field-error').forEach(el => el.remove());
form.querySelectorAll('.rsv-invalid').forEach(el => el.classList.remove('rsv-invalid'));
form.querySelector('.rsv-error-summary')?.remove();
},
show_errors(form, errors) {
this.clear_feedback(form);
const ul = document.createElement('ul');
for (const err of errors) {
const li = document.createElement('li');
li.textContent = err.message;
ul.appendChild(li);
if (err.element) {
const field = form.querySelector(`[name="${err.element}"]`);
if (field) {
field.classList.add('rsv-invalid');
const msg = document.createElement('span');
msg.classList.add('rsv-field-error');
msg.textContent = err.message;
field.insertAdjacentElement('afterend', msg);
}
}
}
const summary = document.createElement('div');
summary.classList.add('rsv-error-summary');
summary.appendChild(ul);
form.prepend(summary);
},
show_success(form, _data) {
const s = ReservairStrings.form;
const wrapper = form.parentElement;
const existing = Array.from(wrapper.children);
const svgNS = 'http://www.w3.org/2000/svg';
const path = document.createElementNS(svgNS, 'path');
path.setAttribute('d', 'M6 14l6 6L22 8');
path.setAttribute('stroke', '#16a34a');
path.setAttribute('stroke-width', '2.5');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('width', '28');
svg.setAttribute('height', '28');
svg.setAttribute('viewBox', '0 0 28 28');
svg.setAttribute('fill', 'none');
svg.appendChild(path);
const icon = document.createElement('div');
icon.className = 'success-icon';
icon.appendChild(svg);
const title = document.createElement('div');
title.className = 'success-title';
title.textContent = s.success_title;
const subtitle = document.createElement('p');
subtitle.className = 'success-msg';
subtitle.textContent = s.success_subtitle;
const reset_btn = document.createElement('button');
reset_btn.className = 'reset-btn';
reset_btn.textContent = s.new_reservation;
const state = document.createElement('div');
state.className = 'success-state';
state.append(icon, title, subtitle, reset_btn);
const msg = document.createElement('div');
msg.appendChild(state);
existing.forEach(child => child.style.display = 'none');
wrapper.appendChild(msg);
reset_btn.addEventListener('click', () => {
msg.remove();
form.reset();
this.clear_feedback(form);
existing.forEach(child => child.style.display = '');
});
},
set_loading(form, is_loading) {
const btn = form.querySelector('button[type="submit"], button:not([type])');
if (!btn) return;
btn.disabled = is_loading;
btn.classList.toggle('rsv-loading', is_loading);
},
encode_to_json(form) {
const fields = form.querySelectorAll('.rsv-form-field');
const body = {};
fields.forEach(field => {
const name = field.name ?? field.getAttribute('name');
try {
body[name] = JSON.parse(field.value);
} catch {
body[name] = field.value;
}
});
return body;
},
send_form(event) {
event.preventDefault();
const form = event.target;
this.clear_feedback(form);
this.set_loading(form, true);
const body = this.encode_to_json(form);
fetch(this.get_form_url(form.id), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
.then(async response => {
const data = await response.json().catch(() => null);
if (!response.ok) throw { status: response.status, body: data };
return data;
})
.then(data => {
this.show_success(form, data);
})
.catch(error => {
const errors = error?.body?.errors
?? [{ element: '', message: ReservairStrings.form.error_generic }];
this.show_errors(form, errors);
})
.finally(() => {
this.set_loading(form, false);
});
},
};
+229
View File
@@ -0,0 +1,229 @@
const RsvInlineFormBuilder = {
match_p(name, value) {
return (form) => String(form[name]) === String(value);
},
create(datasource) {
const fields = [];
const builder = {
datasource: datasource,
fieldset(legend, width = null) {
fields.push({ type: 'fieldset', legend, width });
return this;
},
input_text(name, label, value = '') {
fields.push({ type: 'text', name, label, value });
return this;
},
input_number(name, label, value = '') {
fields.push({ type: 'number', name, label, value });
return this;
},
input_date(name, label, value = '') {
fields.push({ type: 'date', name, label, value });
return this;
},
input_time(name, label, value = '') {
fields.push({ type: 'time', name, label, value });
return this;
},
input_textarea(name, label, value = '') {
fields.push({ type: 'textarea', name, label, value });
return this;
},
input_checkbox(name, label, checked = false) {
fields.push({ type: 'checkbox', name, label, checked });
return this;
},
input_hidden(name, value) {
fields.push({ type: 'hidden', name, value });
return this;
},
input_select(name, label, options, value = '') {
fields.push({ type: 'select', name, label, options, value });
return this;
},
show_if(predicate) {
const last = fields[fields.length - 1];
if (last) last.show_if = predicate;
return this;
},
build({ id, colspan = 1, save_label = 'Update', on_success, on_cancel } = {}) {
const td = document.createElement('td');
td.setAttribute('colspan', colspan);
const form = document.createElement('form');
const wrapper = document.createElement('div');
wrapper.classList.add('inline-edit-wrapper');
const hidden_inputs = [];
let current_fieldset = null;
let current_col = null;
const fieldsets = [];
const conditionals = [];
function ensure_fieldset() {
if (current_fieldset === null) {
current_fieldset = document.createElement('fieldset');
current_col = document.createElement('div');
current_col.classList.add('inline-edit-col');
fieldsets.push(current_fieldset);
}
}
for (const field of fields) {
if (field.type === 'hidden') {
hidden_inputs.push(field);
continue;
}
if (field.type === 'fieldset') {
if (current_fieldset !== null) {
current_fieldset.appendChild(current_col);
}
current_fieldset = document.createElement('fieldset');
if (field.width) current_fieldset.style.width = field.width;
const legend_el = document.createElement('legend');
legend_el.classList.add('inline-edit-legend');
legend_el.innerText = field.legend;
current_fieldset.appendChild(legend_el);
current_col = document.createElement('div');
current_col.classList.add('inline-edit-col');
fieldsets.push(current_fieldset);
continue;
}
ensure_fieldset();
const label_el = document.createElement('label');
const title = document.createElement('span');
title.classList.add('title');
title.innerText = field.label;
const wrap = document.createElement('span');
wrap.classList.add('input-text-wrap');
let input;
if (field.type === 'select') {
input = document.createElement('select');
input.name = field.name;
for (const opt of field.options) {
const option = document.createElement('option');
if (typeof opt === 'object' && opt !== null) {
option.value = opt.value;
option.textContent = opt.label;
option.selected = String(opt.value) === String(field.value);
} else {
option.value = opt;
option.textContent = opt;
option.selected = opt === field.value;
}
input.appendChild(option);
}
} else if (field.type === 'textarea') {
input = document.createElement('textarea');
input.name = field.name;
input.rows = 5;
input.style.width = '100%';
input.value = field.value ?? '';
} else {
input = document.createElement('input');
input.type = field.type;
input.name = field.name;
if (field.type === 'checkbox') {
input.checked = field.checked;
} else {
input.value = field.value ?? '';
}
}
wrap.appendChild(input);
label_el.replaceChildren(title, wrap);
current_col.appendChild(label_el);
if (field.show_if) conditionals.push({ label_el, predicate: field.show_if });
}
if (current_fieldset !== null) {
current_fieldset.appendChild(current_col);
}
const save_row = document.createElement('div');
save_row.classList.add('inline-edit-save', 'submit');
const error = document.createElement('div');
error.classList.add('notice', 'notice-error', 'notice-alt', 'inline', 'hidden');
const error_p = document.createElement('p');
error_p.classList.add('error');
error.appendChild(error_p);
const spinner = document.createElement('span');
spinner.classList.add('spinner');
const save_btn = document.createElement('button');
save_btn.type = 'button';
save_btn.classList.add('save', 'button', 'button-primary');
save_btn.innerText = save_label;
save_btn.onclick = () => {
const form_data = Object.fromEntries(new FormData(form));
for (const field of fields) {
if (field.type === 'checkbox') {
form_data[field.name] = field.name in form_data;
}
}
spinner.classList.add('is-active');
error.classList.add('hidden');
builder.datasource.put(id, form_data)
.then(() => { if (on_success) on_success(); })
.catch(err => {
error_p.innerText = err.message;
error.classList.remove('hidden');
})
.finally(() => spinner.classList.remove('is-active'));
};
const cancel_btn = document.createElement('button');
cancel_btn.type = 'button';
cancel_btn.classList.add('cancel', 'button');
cancel_btn.innerText = 'Cancel';
if (on_cancel) cancel_btn.onclick = on_cancel;
save_row.replaceChildren(save_btn, cancel_btn, spinner, error);
for (const h of hidden_inputs) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = h.name;
input.value = h.value ?? '';
form.appendChild(input);
}
wrapper.replaceChildren(...fieldsets, save_row);
form.appendChild(wrapper);
td.appendChild(form);
if (conditionals.length) {
const snapshot = () => {
const f = Object.fromEntries(new FormData(form));
for (const field of fields) if (field.type === 'checkbox') f[field.name] = field.name in f;
return f;
};
const sync_all = () => {
const f = snapshot();
for (const c of conditionals) c.label_el.classList.toggle('hidden', !c.predicate(f));
};
form.addEventListener('change', sync_all);
form.addEventListener('input', sync_all);
sync_all();
}
return td;
},
};
return builder;
},
};
+21
View File
@@ -0,0 +1,21 @@
const RsvTimetableService = {
get_all() {
return fetch(get_rest_url('timetable'), { method: 'GET' })
.then(r => {
if (!r.ok) throw new Error(`fetch timetables failed: ${r.status}`);
return r.json();
});
},
get_availability_for_date(timetable_id, date) {
const params = new URLSearchParams({
date: date.toISOString().slice(0, 10),
});
return fetch(get_rest_url(`timetable/${timetable_id}/availability?${params}`), { method: 'GET' })
.then(r => {
if (!r.ok) throw new Error(`fetch availability failed: ${r.status}`);
return r.json();
});
}
}