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
+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();
});
}
}