@jtakeit/kit 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -8
- package/booking.css +23 -10
- package/elements/booking-ui.js +91 -21
- package/elements/booking.js +170 -29
- package/elements/lead.js +26 -4
- package/index.mjs +2 -1
- package/lib/booking/hooks.mjs +91 -0
- package/lib/booking/render.mjs +110 -42
- package/lib/booking/words.mjs +8 -0
- package/lib/catalogue.mjs +96 -1
- package/lib/content.mjs +7 -2
- package/lib/kits.mjs +5 -1
- package/lib/lint.mjs +39 -0
- package/lib/meta.mjs +11 -3
- package/lib/schedule.mjs +46 -0
- package/package.json +5 -1
- package/types/booking.d.ts +22 -0
- package/types/content.d.ts +1 -1
- package/types/elements-booking.d.ts +1 -0
- package/types/hooks.d.ts +12 -0
- package/types/index.d.ts +4 -2
- package/types/meta.d.ts +1 -1
package/elements/booking.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* POST /api/book → { id, state, amount_minor, currency, token, manage_path, telegram_url }
|
|
15
15
|
* POST /api/pay → { url } — only when amount_minor > 0
|
|
16
16
|
*
|
|
17
|
+
* Every request names the protocol version: `X-Jtk-Booking: 1` (BOOKING_PROTOCOL).
|
|
18
|
+
*
|
|
17
19
|
* The visitor's own page — cancel, move, add to calendar — is served by the
|
|
18
20
|
* platform at `manage_path`; this form only has to link to it.
|
|
19
21
|
*
|
|
@@ -22,9 +24,14 @@
|
|
|
22
24
|
* Import this module once on the page and every `<form data-booking>` is
|
|
23
25
|
* wired: the ones inside a `<jtk-booking>` by the element's own lifecycle,
|
|
24
26
|
* which also covers markup the panel places later (`fl:placed`); the ones
|
|
25
|
-
* written bare by a walk of the document at load. The markup is
|
|
26
|
-
*
|
|
27
|
-
*
|
|
27
|
+
* written bare by a walk of the document at load. The markup is the site's —
|
|
28
|
+
* the scaffold's `BookingForm.astro` written out, or `renderBookingForm`'s
|
|
29
|
+
* (`@jtakeit/kit/booking`) — and the element finds everything in it by a
|
|
30
|
+
* `data-*` hook or an input's `name`, never by a class: the list is
|
|
31
|
+
* `@jtakeit/kit/booking/hooks`. A hook left out is said in the console and
|
|
32
|
+
* its field is never shown; the CSS the behaviour needs (`[hidden]`, the
|
|
33
|
+
* steps, the native control under a drawn one) and the ARIA it depends on are
|
|
34
|
+
* put on the page by the element, so a form restyled from nothing keeps them.
|
|
28
35
|
*
|
|
29
36
|
* ── a stay of several nights ────────────────────────────────────────────────
|
|
30
37
|
*
|
|
@@ -47,6 +54,40 @@
|
|
|
47
54
|
import { money } from '../lib/money.mjs';
|
|
48
55
|
import { wordsFor } from '../lib/booking/words.mjs';
|
|
49
56
|
import { dressBooking } from './booking-ui.js';
|
|
57
|
+
import { lacking } from '../lib/booking/hooks.mjs';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The rules the behaviour cannot work without, whatever the site's own CSS
|
|
61
|
+
* says — put on the page once, by the element, at the lowest specificity
|
|
62
|
+
* that still wins where it must. Not a look: every state here is shown and
|
|
63
|
+
* hidden by the `hidden` attribute, the two steps by `data-stage`, a drawn
|
|
64
|
+
* control over a native one by `data-replaced`; a form an agent dressed from
|
|
65
|
+
* nothing lost all three with `booking.css` (12 October 2026). The first two
|
|
66
|
+
* are `!important`, as `hidden` is: a site's `display: grid` on a piece of
|
|
67
|
+
* the second step drew it on the first.
|
|
68
|
+
*/
|
|
69
|
+
const BASE = `
|
|
70
|
+
[data-booking] [hidden] { display: none !important; }
|
|
71
|
+
[data-booking][data-unsaid] :is([data-say], [data-say-wait]) { visibility: hidden; }
|
|
72
|
+
:where([data-booking]) :where([data-replaced], .is-replaced, [data-sr], .booking__sr) {
|
|
73
|
+
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
|
|
74
|
+
clip-path: inset(50%); white-space: nowrap; border: 0;
|
|
75
|
+
}
|
|
76
|
+
:where([data-booking]) :where([data-replaced], .is-replaced) { opacity: 0; pointer-events: none; }
|
|
77
|
+
[data-booking][data-stage='1'] [data-step='2'], [data-booking][data-stage='2'] [data-step='1'],
|
|
78
|
+
[data-booking][data-stage='done'] [data-step-ui] { display: none !important; }
|
|
79
|
+
:where([data-booking] :focus-visible) { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
function putBase() {
|
|
83
|
+
if (typeof document === 'undefined' || document.querySelector('style[data-jtk-booking]')) return;
|
|
84
|
+
const style = document.createElement('style');
|
|
85
|
+
style.dataset.jtkBooking = '';
|
|
86
|
+
style.textContent = BASE;
|
|
87
|
+
// First in <head>, so a site's own rule of the same weight wins.
|
|
88
|
+
document.head.prepend(style);
|
|
89
|
+
}
|
|
90
|
+
let wiredCount = 0;
|
|
50
91
|
|
|
51
92
|
// The words that depend on the kind of business, as the platform says them:
|
|
52
93
|
// its availability answer carries the form's, in every language, the site's
|
|
@@ -90,10 +131,25 @@ export function lasting(minutes, words) {
|
|
|
90
131
|
* Wires one form. Idempotent: a form already wired is left alone.
|
|
91
132
|
* @param {HTMLFormElement} form
|
|
92
133
|
*/
|
|
134
|
+
/**
|
|
135
|
+
* The booking protocol's version this element speaks, named on every request
|
|
136
|
+
* (the platform's wiki/79). A page is a file in a build: when the platform
|
|
137
|
+
* changes, a site keeps the kit it was built with, and the platform answers it
|
|
138
|
+
* by this number for as long as a live site speaks it — so it moves only with
|
|
139
|
+
* a change the platform cannot make by adding, and the platform says which
|
|
140
|
+
* versions it answers when it refuses one.
|
|
141
|
+
*/
|
|
142
|
+
export const BOOKING_PROTOCOL = 1;
|
|
143
|
+
const SPOKEN = { 'x-jtk-booking': String(BOOKING_PROTOCOL) };
|
|
144
|
+
|
|
93
145
|
export function wireBooking(form) {
|
|
94
146
|
if (form.dataset.jtkWired === '1') return;
|
|
95
147
|
form.dataset.jtkWired = '1';
|
|
96
148
|
|
|
149
|
+
putBase();
|
|
150
|
+
const missing = lacking(form);
|
|
151
|
+
if (missing.length > 0) console.error(`booking form: it has no ${missing.join(', ')}, and cannot book without them — the hooks are listed in @jtakeit/kit/booking/hooks`);
|
|
152
|
+
|
|
97
153
|
/** @type {Record<string, string>} */
|
|
98
154
|
// The form's own words under the page's: a form rendered by an older kit
|
|
99
155
|
// carries none of the newer ones.
|
|
@@ -102,8 +158,10 @@ export function wireBooking(form) {
|
|
|
102
158
|
// once there is one, wins — an owner may switch without a rebuild.
|
|
103
159
|
let daily = form.dataset.scale === 'daily';
|
|
104
160
|
const api = form.dataset.api ?? '/api/';
|
|
105
|
-
|
|
106
|
-
|
|
161
|
+
// A hook the form left out is an element of its kind that is never on the
|
|
162
|
+
// page: the field it stands for is never shown, and nothing here throws.
|
|
163
|
+
/** @template {HTMLElement} T @param {string} sel @param {string} [tag] @returns {T} */
|
|
164
|
+
const q = (sel, tag = 'div') => /** @type {any} */ (form.querySelector(sel) ?? document.createElement(tag));
|
|
107
165
|
const lang = form.dataset.locale ?? 'en';
|
|
108
166
|
const pageLang = () => document.documentElement.lang || 'en';
|
|
109
167
|
|
|
@@ -111,6 +169,12 @@ export function wireBooking(form) {
|
|
|
111
169
|
const select = /** @type {HTMLSelectElement | null} */ (form.querySelector('select[name=service]'));
|
|
112
170
|
const boxes = /** @type {HTMLInputElement[]} */ ([...form.querySelectorAll('input[type=checkbox][name=service]')]);
|
|
113
171
|
const total = /** @type {HTMLElement | null} */ (form.querySelector('[data-total]'));
|
|
172
|
+
// A checkbox's row and the service's name in it, by their hooks; the
|
|
173
|
+
// classes of an older kit's markup where a form has no hooks.
|
|
174
|
+
/** @param {HTMLInputElement} box */
|
|
175
|
+
const rowOf = (box) => /** @type {HTMLElement | null} */ (box.closest('[data-service-row], .booking__service, label'));
|
|
176
|
+
/** @param {Element} row */
|
|
177
|
+
const nameIn = (row) => /** @type {HTMLElement | null} */ (row.querySelector('[data-service-name], .booking__service-name'));
|
|
114
178
|
const picked = () => (select ? (select.value ? [select.value] : []) : boxes.filter((b) => b.checked).map((b) => b.value));
|
|
115
179
|
function showTotal() {
|
|
116
180
|
if (!total) return;
|
|
@@ -126,14 +190,14 @@ export function wireBooking(form) {
|
|
|
126
190
|
total.hidden = false;
|
|
127
191
|
}
|
|
128
192
|
const resource = /** @type {HTMLSelectElement | null} */ (form.querySelector('[name=resource]'));
|
|
129
|
-
/** @type {HTMLSelectElement} */ const party = q('[name=party]');
|
|
130
|
-
/** @type {HTMLSelectElement} */ const quantity = q('[name=quantity]');
|
|
193
|
+
/** @type {HTMLSelectElement} */ const party = q('[name=party]', 'select');
|
|
194
|
+
/** @type {HTMLSelectElement} */ const quantity = q('[name=quantity]', 'select');
|
|
131
195
|
/** @type {HTMLElement} */ const quantityField = q('[data-quantity-field]');
|
|
132
|
-
/** @type {HTMLSelectElement} */ const hours = q('[name=hours]');
|
|
196
|
+
/** @type {HTMLSelectElement} */ const hours = q('[name=hours]', 'select');
|
|
133
197
|
/** @type {HTMLElement} */ const hoursField = q('[data-hours-field]');
|
|
134
198
|
/** @type {HTMLElement} */ const partyField = q('[data-party-field]');
|
|
135
|
-
/** @type {HTMLInputElement} */ const day = q('[name=day]');
|
|
136
|
-
/** @type {HTMLElement | null} */ const ends =
|
|
199
|
+
/** @type {HTMLInputElement} */ const day = q('[name=day]', 'input');
|
|
200
|
+
/** @type {HTMLElement | null} */ const ends = form.querySelector('[data-ends]');
|
|
137
201
|
// The day's floor is the browser's today, not the build's: `min` was
|
|
138
202
|
// written when the site was built, and a site built on Friday is read on
|
|
139
203
|
// Saturday (10 October 2026).
|
|
@@ -156,11 +220,25 @@ export function wireBooking(form) {
|
|
|
156
220
|
/** @type {HTMLElement} */ const error = q('[data-booking-error]');
|
|
157
221
|
/** @type {HTMLButtonElement} */ const submit = q('[data-submit]');
|
|
158
222
|
/** @type {HTMLElement} */ const challenge = q('[data-challenge]');
|
|
159
|
-
/** @type {HTMLInputElement} */ const back = q('[name=back]');
|
|
223
|
+
/** @type {HTMLInputElement} */ const back = q('[name=back]', 'input');
|
|
160
224
|
/** @type {HTMLElement} */ const backField = q('[data-back-field]');
|
|
161
225
|
/** @type {HTMLElement} */ const lengthHint = q('[data-length]');
|
|
162
226
|
/** @type {HTMLElement} */ const stayTotal = q('[data-stay-total]');
|
|
163
227
|
|
|
228
|
+
// The ARIA the behaviour depends on, whatever the copy kept: the hint is
|
|
229
|
+
// read as it changes, the refusal as it appears, the answer takes the focus
|
|
230
|
+
// and is read, and a refusal is tied to its fields by the error's id.
|
|
231
|
+
if (!hint.hasAttribute('aria-live')) hint.setAttribute('aria-live', 'polite');
|
|
232
|
+
if (!error.getAttribute('role')) error.setAttribute('role', 'alert');
|
|
233
|
+
if (!error.id) error.id = `jtk-booking-error-${++wiredCount}`;
|
|
234
|
+
{
|
|
235
|
+
/** @type {HTMLElement} */ const answerBox = q('[data-done]');
|
|
236
|
+
if (!answerBox.getAttribute('role')) answerBox.setAttribute('role', 'status');
|
|
237
|
+
if (!answerBox.hasAttribute('tabindex')) answerBox.tabIndex = -1;
|
|
238
|
+
const keepSaid = form.querySelector('[data-keep-said]');
|
|
239
|
+
if (keepSaid && !keepSaid.getAttribute('role')) keepSaid.setAttribute('role', 'status');
|
|
240
|
+
}
|
|
241
|
+
|
|
164
242
|
/** @param {Record<string, Record<string, string>>} [all] */
|
|
165
243
|
function hear(all) {
|
|
166
244
|
const mine = all?.[lang] ?? all?.en;
|
|
@@ -172,11 +250,15 @@ export function wireBooking(form) {
|
|
|
172
250
|
el.textContent = words[el.dataset.say ?? ''] ?? el.textContent;
|
|
173
251
|
}
|
|
174
252
|
}
|
|
253
|
+
unsay();
|
|
254
|
+
}
|
|
255
|
+
function unsay() {
|
|
175
256
|
form.classList.remove('booking--unsaid');
|
|
257
|
+
delete form.dataset.unsaid;
|
|
176
258
|
}
|
|
177
259
|
// Held back at most this long: a platform that is slow to answer is not
|
|
178
260
|
// a reason for a form without a button.
|
|
179
|
-
setTimeout(
|
|
261
|
+
setTimeout(unsay, 3000);
|
|
180
262
|
{
|
|
181
263
|
const first = select?.options[0]?.value ?? boxes[0]?.value ?? '';
|
|
182
264
|
const from = new Date();
|
|
@@ -185,10 +267,11 @@ export function wireBooking(form) {
|
|
|
185
267
|
to.setDate(to.getDate() + 1);
|
|
186
268
|
if (first) {
|
|
187
269
|
const params = new URLSearchParams({ service: first, from: from.toISOString(), to: to.toISOString() });
|
|
188
|
-
void fetch(api + 'availability?' + params.toString(), { cache: 'no-store' })
|
|
270
|
+
void fetch(api + 'availability?' + params.toString(), { cache: 'no-store', headers: SPOKEN })
|
|
189
271
|
.then((r) => (r.ok ? r.json() : {}))
|
|
190
272
|
.then((data) => {
|
|
191
273
|
hear(data.words);
|
|
274
|
+
hearCombine(data.combine);
|
|
192
275
|
sayEmptyServices(data.empty_services ?? []);
|
|
193
276
|
})
|
|
194
277
|
.catch(() => hear());
|
|
@@ -207,6 +290,23 @@ export function wireBooking(form) {
|
|
|
207
290
|
// sign made up while it is asked (11 October 2026).
|
|
208
291
|
let currency = form.dataset.currency || '';
|
|
209
292
|
|
|
293
|
+
// One service or several, as the platform answers it now: the owner flips
|
|
294
|
+
// `combine` in the panel and a checklist built while it was on keeps to one
|
|
295
|
+
// choice once it is off, rather than offering two the booking refuses.
|
|
296
|
+
let single = false;
|
|
297
|
+
/** @param {unknown} said */
|
|
298
|
+
function hearCombine(said) {
|
|
299
|
+
if (typeof said !== 'boolean' || boxes.length === 0) return;
|
|
300
|
+
single = !said;
|
|
301
|
+
if (!single) return;
|
|
302
|
+
let kept = false;
|
|
303
|
+
for (const box of boxes) {
|
|
304
|
+
if (box.checked && kept) box.checked = false;
|
|
305
|
+
if (box.checked) kept = true;
|
|
306
|
+
}
|
|
307
|
+
showTotal();
|
|
308
|
+
}
|
|
309
|
+
|
|
210
310
|
/** @type {string | null} */ let chosen = null;
|
|
211
311
|
let zone = '';
|
|
212
312
|
let turnstileToken = '';
|
|
@@ -223,7 +323,7 @@ export function wireBooking(form) {
|
|
|
223
323
|
|
|
224
324
|
// The widget, if this host has one. Asked at runtime so the site carries
|
|
225
325
|
// no key: which widget covers a host is the platform's to decide.
|
|
226
|
-
void fetch(api + 'turnstile', { cache: 'no-store' })
|
|
326
|
+
void fetch(api + 'turnstile', { cache: 'no-store', headers: SPOKEN })
|
|
227
327
|
.then((r) => r.json())
|
|
228
328
|
.then((data) => {
|
|
229
329
|
if (!data.sitekey) return;
|
|
@@ -294,10 +394,25 @@ export function wireBooking(form) {
|
|
|
294
394
|
function quantityChosen() {
|
|
295
395
|
return quantityField.hidden ? 1 : Number(quantity.value) || 1;
|
|
296
396
|
}
|
|
297
|
-
|
|
298
|
-
|
|
397
|
+
// The fields the last refusal was about: marked invalid and described by
|
|
398
|
+
// the sentence, so a screen reader reads it on the field itself and not
|
|
399
|
+
// only as it appears (WCAG 3.3.1, 12 October 2026).
|
|
400
|
+
/** @type {HTMLElement[]} */ let blamed = [];
|
|
401
|
+
/** @param {string} text @param {HTMLElement[]} [fields] the fields it is about */
|
|
402
|
+
function say(text, fields = []) {
|
|
299
403
|
error.textContent = text;
|
|
300
404
|
error.hidden = text === '';
|
|
405
|
+
for (const field of blamed) {
|
|
406
|
+
field.removeAttribute('aria-invalid');
|
|
407
|
+
const rest = (field.getAttribute('aria-describedby') ?? '').split(/\s+/).filter((id) => id && id !== error.id);
|
|
408
|
+
if (rest.length) field.setAttribute('aria-describedby', rest.join(' '));
|
|
409
|
+
else field.removeAttribute('aria-describedby');
|
|
410
|
+
}
|
|
411
|
+
blamed = text === '' || !error.id ? [] : fields;
|
|
412
|
+
for (const field of blamed) {
|
|
413
|
+
field.setAttribute('aria-invalid', 'true');
|
|
414
|
+
field.setAttribute('aria-describedby', [field.getAttribute('aria-describedby'), error.id].filter(Boolean).join(' '));
|
|
415
|
+
}
|
|
301
416
|
}
|
|
302
417
|
|
|
303
418
|
// A refusal as a sentence in the visitor's language. The platform answers
|
|
@@ -485,7 +600,7 @@ export function wireBooking(form) {
|
|
|
485
600
|
const can = who && !who.startsWith('class:') && !who.startsWith('empty:') && does.has(who) ? does.get(who) : null;
|
|
486
601
|
let moved = false;
|
|
487
602
|
for (const box of boxes) {
|
|
488
|
-
const row = box
|
|
603
|
+
const row = rowOf(box);
|
|
489
604
|
const off = can !== null && !can.has(box.value);
|
|
490
605
|
if (off && !box.disabled) {
|
|
491
606
|
box.disabled = true;
|
|
@@ -496,12 +611,14 @@ export function wireBooking(form) {
|
|
|
496
611
|
offByMe.delete(box);
|
|
497
612
|
}
|
|
498
613
|
row?.classList.toggle('is-off', offByMe.has(box));
|
|
499
|
-
|
|
614
|
+
if (row) row.toggleAttribute('data-off', offByMe.has(box));
|
|
615
|
+
let note = row?.querySelector('[data-service-off], .booking__service-off') ?? null;
|
|
500
616
|
if (offByMe.has(box) && row) {
|
|
501
|
-
const nameEl = row
|
|
617
|
+
const nameEl = nameIn(row);
|
|
502
618
|
if (!note && nameEl) {
|
|
503
619
|
note = document.createElement('span');
|
|
504
620
|
note.className = 'booking__service-off';
|
|
621
|
+
note.setAttribute('data-service-off', '');
|
|
505
622
|
nameEl.insertAdjacentElement('afterend', note);
|
|
506
623
|
}
|
|
507
624
|
if (note) note.textContent = words.notWith.replace('{who}', name.split(' ')[0] || name);
|
|
@@ -550,7 +667,8 @@ export function wireBooking(form) {
|
|
|
550
667
|
const box = boxes.find((b) => b.value === one.slug);
|
|
551
668
|
if (box && !box.disabled) {
|
|
552
669
|
box.disabled = true;
|
|
553
|
-
const
|
|
670
|
+
const row = rowOf(box);
|
|
671
|
+
const name = row ? nameIn(row) : null;
|
|
554
672
|
if (name) name.textContent = said;
|
|
555
673
|
if (box.checked) { box.checked = false; showTotal(); moved = true; }
|
|
556
674
|
}
|
|
@@ -579,6 +697,7 @@ export function wireBooking(form) {
|
|
|
579
697
|
return;
|
|
580
698
|
}
|
|
581
699
|
hint.textContent = '…';
|
|
700
|
+
slots.setAttribute('aria-busy', 'true');
|
|
582
701
|
|
|
583
702
|
const from = new Date(day.value + 'T00:00:00');
|
|
584
703
|
const to = new Date(from);
|
|
@@ -596,11 +715,12 @@ export function wireBooking(form) {
|
|
|
596
715
|
if (n !== null) params.set('count', String(n));
|
|
597
716
|
|
|
598
717
|
try {
|
|
599
|
-
const answer = await fetch(api + 'availability?' + params.toString(), { cache: 'no-store' });
|
|
718
|
+
const answer = await fetch(api + 'availability?' + params.toString(), { cache: 'no-store', headers: SPOKEN }).finally(() => slots.removeAttribute('aria-busy'));
|
|
600
719
|
if (!answer.ok) throw new Error(await answer.text());
|
|
601
720
|
if (mine !== asking) return;
|
|
602
721
|
const data = await answer.json();
|
|
603
722
|
hear(data.words);
|
|
723
|
+
hearCombine(data.combine);
|
|
604
724
|
lastClasses = data.classes ?? [];
|
|
605
725
|
lastResources = data.resources ?? [];
|
|
606
726
|
offerChoices(lastClasses, lastResources, data.empty_classes ?? []);
|
|
@@ -646,6 +766,7 @@ export function wireBooking(form) {
|
|
|
646
766
|
const button = document.createElement('button');
|
|
647
767
|
button.type = 'button';
|
|
648
768
|
button.className = 'booking__slot';
|
|
769
|
+
button.dataset.slot = '';
|
|
649
770
|
// Which one is chosen, said as well as drawn.
|
|
650
771
|
button.setAttribute('aria-pressed', 'false');
|
|
651
772
|
button.textContent = daily
|
|
@@ -660,7 +781,7 @@ export function wireBooking(form) {
|
|
|
660
781
|
if (hint.textContent === words.retime) hint.textContent = '';
|
|
661
782
|
sayEnds(slot);
|
|
662
783
|
offerQuantity(data.quantity_max ?? 1, (slot.free ?? []).length);
|
|
663
|
-
for (const other of slots.querySelectorAll('
|
|
784
|
+
for (const other of slots.querySelectorAll('[data-slot]')) {
|
|
664
785
|
other.classList.remove('is-chosen');
|
|
665
786
|
other.setAttribute('aria-pressed', 'false');
|
|
666
787
|
}
|
|
@@ -705,7 +826,13 @@ export function wireBooking(form) {
|
|
|
705
826
|
// The look — the steps, the week, the parts of the day, the drawn choice
|
|
706
827
|
// — over the form, where the form asks for it.
|
|
707
828
|
if (form.dataset.ui) dressBooking(form, words);
|
|
708
|
-
for (const box of boxes)
|
|
829
|
+
for (const box of boxes) {
|
|
830
|
+
box.addEventListener('change', () => {
|
|
831
|
+
if (single && box.checked) for (const other of boxes) if (other !== box) other.checked = false;
|
|
832
|
+
showTotal();
|
|
833
|
+
void load();
|
|
834
|
+
});
|
|
835
|
+
}
|
|
709
836
|
resource?.addEventListener('change', () => { greyByResource(); void load(); });
|
|
710
837
|
greyByResource();
|
|
711
838
|
// A new first date keeps the stay's length: five nights from the 10th
|
|
@@ -729,8 +856,14 @@ export function wireBooking(form) {
|
|
|
729
856
|
const email = String(data.get('email') ?? '').trim();
|
|
730
857
|
if (picked().length === 0) { say(words.pickService); return; }
|
|
731
858
|
if (!chosen) { say(words.pick); return; }
|
|
732
|
-
|
|
733
|
-
if (
|
|
859
|
+
/** @type {HTMLInputElement} */ const nameInput = q('[name=name]');
|
|
860
|
+
if (name === '') { say(words.needName, [nameInput]); nameInput.focus(); return; }
|
|
861
|
+
if (phone === '' && email === '') {
|
|
862
|
+
/** @type {HTMLInputElement} */ const phoneInput = q('[name=phone]');
|
|
863
|
+
say(words.needContact, [phoneInput, q('[name=email]')]);
|
|
864
|
+
phoneInput.focus();
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
734
867
|
|
|
735
868
|
submit.disabled = true;
|
|
736
869
|
submit.textContent = words.sending;
|
|
@@ -763,7 +896,7 @@ export function wireBooking(form) {
|
|
|
763
896
|
}
|
|
764
897
|
const answer = await fetch(api + 'book', {
|
|
765
898
|
method: 'POST',
|
|
766
|
-
headers: { 'content-type': 'application/json' },
|
|
899
|
+
headers: { 'content-type': 'application/json', ...SPOKEN },
|
|
767
900
|
body: JSON.stringify(body),
|
|
768
901
|
});
|
|
769
902
|
if (answer.status === 409) { say(words.taken); await load(); return; }
|
|
@@ -783,7 +916,11 @@ export function wireBooking(form) {
|
|
|
783
916
|
* @param {{ state: string; start: string; ends: string; amount_minor: number; currency: string; id: string; manage_path: string; telegram_url: string; resource?: string; class?: string; group?: { quantity: number; amount_minor: number; bookings: { resource: string; resource_name?: string }[] } }} made
|
|
784
917
|
*/
|
|
785
918
|
function done(made) {
|
|
786
|
-
|
|
919
|
+
// Everything that asks, gone; the answer stays. By the hooks — the
|
|
920
|
+
// classes are an older kit's markup.
|
|
921
|
+
for (const el of /** @type {NodeListOf<HTMLElement>} */ (form.querySelectorAll('[data-field], [data-times], [data-services], [data-submit], [data-challenge], .field, .booking__times, .booking__services'))) {
|
|
922
|
+
if (!el.contains(form.querySelector('[data-done]'))) el.hidden = true;
|
|
923
|
+
}
|
|
787
924
|
/** @type {HTMLElement} */ const box = q('[data-done]');
|
|
788
925
|
box.hidden = false;
|
|
789
926
|
// After the lines are written below — see the end of this function.
|
|
@@ -824,10 +961,14 @@ export function wireBooking(form) {
|
|
|
824
961
|
link.setAttribute('aria-label', words.manage);
|
|
825
962
|
link.addEventListener('focus', () => link.select());
|
|
826
963
|
copy.textContent = words.copy;
|
|
964
|
+
/** @type {HTMLElement | null} */ const told = q('[data-keep-said]');
|
|
827
965
|
copy.addEventListener('click', async () => {
|
|
828
966
|
try { await navigator.clipboard.writeText(href); } catch { link.select(); document.execCommand('copy'); }
|
|
829
967
|
copy.textContent = words.copied;
|
|
830
|
-
|
|
968
|
+
// Said as well as drawn: a button's own words changing under the
|
|
969
|
+
// focus is read by some screen readers and not by others.
|
|
970
|
+
if (told) told.textContent = words.copied;
|
|
971
|
+
setTimeout(() => { copy.textContent = words.copy; if (told) told.textContent = ''; }, 1600);
|
|
831
972
|
});
|
|
832
973
|
open.href = href;
|
|
833
974
|
open.textContent = words.open;
|
|
@@ -851,7 +992,7 @@ export function wireBooking(form) {
|
|
|
851
992
|
event.preventDefault();
|
|
852
993
|
const answer = await fetch(api + 'pay', {
|
|
853
994
|
method: 'POST',
|
|
854
|
-
headers: { 'content-type': 'application/json' },
|
|
995
|
+
headers: { 'content-type': 'application/json', ...SPOKEN },
|
|
855
996
|
body: JSON.stringify({ booking: made.id, return: made.manage_path || '/' }),
|
|
856
997
|
});
|
|
857
998
|
if (!answer.ok) { say(await reason(answer)); return; }
|
package/elements/lead.js
CHANGED
|
@@ -27,18 +27,39 @@ export function wireLead(form) {
|
|
|
27
27
|
if (!status || !button) return;
|
|
28
28
|
const original = label?.textContent ?? '';
|
|
29
29
|
|
|
30
|
+
/*
|
|
31
|
+
* A sentence about a field is said on the field (WCAG 3.3.1): the input is
|
|
32
|
+
* marked invalid and described by the sentence, so a screen reader reads
|
|
33
|
+
* it when the field is reached and not only when it appears. It was drawn
|
|
34
|
+
* under the field and tied to nothing (12 October 2026).
|
|
35
|
+
*/
|
|
36
|
+
/** @param {string} field */
|
|
37
|
+
const inputOf = (field) => /** @type {HTMLInputElement | null} */ (form.elements.namedItem(field));
|
|
38
|
+
|
|
30
39
|
/** @param {string} field @param {string} message */
|
|
31
40
|
const showError = (field, message) => {
|
|
32
41
|
/** @type {HTMLElement | null} */ const el = form.querySelector(`[data-error-for="${field}"]`);
|
|
33
42
|
if (!el) return;
|
|
34
43
|
el.textContent = message;
|
|
35
44
|
el.hidden = !message;
|
|
45
|
+
const input = inputOf(field);
|
|
46
|
+
if (!input || !message) return;
|
|
47
|
+
if (!el.id) el.id = `${form.id || 'lead'}-${field}-error`;
|
|
48
|
+
input.setAttribute('aria-invalid', 'true');
|
|
49
|
+
const described = (input.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean);
|
|
50
|
+
if (!described.includes(el.id)) input.setAttribute('aria-describedby', [...described, el.id].join(' '));
|
|
36
51
|
};
|
|
37
52
|
|
|
38
53
|
const clearErrors = () => {
|
|
39
54
|
for (const el of /** @type {NodeListOf<HTMLElement>} */ (form.querySelectorAll('[data-error-for]'))) {
|
|
40
55
|
el.textContent = '';
|
|
41
56
|
el.hidden = true;
|
|
57
|
+
const input = inputOf(el.dataset.errorFor ?? '');
|
|
58
|
+
if (!input) continue;
|
|
59
|
+
input.removeAttribute('aria-invalid');
|
|
60
|
+
const rest = (input.getAttribute('aria-describedby') ?? '').split(/\s+/).filter((id) => id && id !== el.id);
|
|
61
|
+
if (rest.length) input.setAttribute('aria-describedby', rest.join(' '));
|
|
62
|
+
else input.removeAttribute('aria-describedby');
|
|
42
63
|
}
|
|
43
64
|
};
|
|
44
65
|
|
|
@@ -49,15 +70,16 @@ export function wireLead(form) {
|
|
|
49
70
|
form.removeAttribute('data-state');
|
|
50
71
|
|
|
51
72
|
const data = new FormData(form);
|
|
52
|
-
let
|
|
73
|
+
/** @type {HTMLInputElement | null} */ let first = null;
|
|
53
74
|
for (const field of ['name', 'contact']) {
|
|
54
|
-
const input =
|
|
75
|
+
const input = inputOf(field);
|
|
55
76
|
if (!input?.value.trim()) {
|
|
56
77
|
showError(field, input?.dataset.message ?? '');
|
|
57
|
-
|
|
78
|
+
first ??= input;
|
|
58
79
|
}
|
|
59
80
|
}
|
|
60
|
-
|
|
81
|
+
// And the visitor is taken to the first thing to fix (3.3.1).
|
|
82
|
+
if (first) { first.focus(); return; }
|
|
61
83
|
|
|
62
84
|
button.disabled = true;
|
|
63
85
|
if (label) label.textContent = form.dataset.sending ?? original;
|
package/index.mjs
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* and the enquiry form the same way: `@jtakeit/kit/lead`,
|
|
16
16
|
* `@jtakeit/kit/elements/lead`, `@jtakeit/kit/lead.css`.
|
|
17
17
|
*/
|
|
18
|
-
export { renderBookingForm, SENDS } from './lib/booking/render.mjs';
|
|
18
|
+
export { renderBookingForm, bookingForm, SENDS } from './lib/booking/render.mjs';
|
|
19
|
+
export { ALWAYS, ASKED, SAID, HOOKS } from './lib/booking/hooks.mjs';
|
|
19
20
|
export { renderLeadForm, inputType } from './lib/lead/render.mjs';
|
|
20
21
|
export { wordsFor, LOCALES } from './lib/booking/words.mjs';
|
|
21
22
|
export { money } from './lib/money.mjs';
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `<jtk-booking>` reads off a booking form — the whole of the contract
|
|
3
|
+
* between a form's markup and the element that makes it work.
|
|
4
|
+
*
|
|
5
|
+
* The element finds everything by these: a `data-*` attribute or an input's
|
|
6
|
+
* `name`, never a class. Classes, wrappers, the order of the fields, the
|
|
7
|
+
* words and the look are the site's; a form an agent copies and dresses in
|
|
8
|
+
* its own design keeps working as long as these are on it.
|
|
9
|
+
*
|
|
10
|
+
* Three kinds:
|
|
11
|
+
*
|
|
12
|
+
* ALWAYS every form has them, or it cannot book — the element says which
|
|
13
|
+
* is missing in the console, and the platform's preflight fails a
|
|
14
|
+
* booking page without them;
|
|
15
|
+
* ASKED a field the element shows only when the platform's answer asks
|
|
16
|
+
* for it — a party, how many, how long, a second date. A form for
|
|
17
|
+
* a business that never asks may leave one out; preflight fails it
|
|
18
|
+
* where the business does ask;
|
|
19
|
+
* SAID where the element writes what happened — the running total, when
|
|
20
|
+
* the visit ends, what was taken, the link to keep. Left out, that
|
|
21
|
+
* line is not said and nothing else changes.
|
|
22
|
+
*
|
|
23
|
+
* The test (kit/test/hooks.test.mjs) holds `renderBookingForm` and the
|
|
24
|
+
* scaffold's `BookingForm.astro` to ALWAYS, ASKED and SAID; the platform's
|
|
25
|
+
* preflight keeps its own copy of ALWAYS and ASKED, written from this one.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** @typedef {{ hook: string; says: string }} Hook */
|
|
29
|
+
|
|
30
|
+
/** @type {readonly Hook[]} */
|
|
31
|
+
export const ALWAYS = Object.freeze([
|
|
32
|
+
{ hook: 'form[data-booking]', says: 'the form; `data-api`, `data-sends` and `data-locale` on it' },
|
|
33
|
+
{ hook: '[name=service]', says: 'a `<select>` for one service, or checkboxes for several (`combine`)' },
|
|
34
|
+
{ hook: '[name=day]', says: 'an `<input type="date">`; the week of days is drawn over it' },
|
|
35
|
+
{ hook: '[data-times]', says: 'around the times: the hint, the times and when the visit ends' },
|
|
36
|
+
{ hook: '[data-slots]', says: 'empty; the element writes a button per free time into it' },
|
|
37
|
+
{ hook: '[data-times-hint]', says: '«pick a day», «nothing free», «…» while it asks' },
|
|
38
|
+
{ hook: '[name=name]', says: 'the visitor\'s name' },
|
|
39
|
+
{ hook: '[name=phone]', says: 'a phone or an email, one of the two is needed' },
|
|
40
|
+
{ hook: '[name=email]', says: 'a phone or an email, one of the two is needed' },
|
|
41
|
+
{ hook: '[data-challenge]', says: 'empty and hidden; the anti-bot widget goes here where the host has one' },
|
|
42
|
+
{ hook: '[data-booking-error]', says: 'the refusal, said; tied to the fields it is about' },
|
|
43
|
+
{ hook: '[data-submit]', says: 'the submit button' },
|
|
44
|
+
{ hook: '[data-done]', says: 'hidden; shown once booked, with the lines below inside it' },
|
|
45
|
+
{ hook: '[data-done-line]', says: '«you are booked» or «waiting for the business»' },
|
|
46
|
+
{ hook: '[data-done-when]', says: 'the day and the time booked' },
|
|
47
|
+
{ hook: '[data-manage]', says: 'a link to the visitor\'s own page: cancel, move' },
|
|
48
|
+
{ hook: '[data-pay-note]', says: 'the sentence before paying, where the booking is paid online' },
|
|
49
|
+
{ hook: '[data-pay]', says: 'the link that pays, where the booking is paid online' },
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/** @type {readonly (Hook & { when: string })[]} */
|
|
53
|
+
export const ASKED = Object.freeze([
|
|
54
|
+
{ hook: '[data-party-field]', when: 'party_max > 1', says: 'hidden; around `[name=party]`, a `<select>` the element fills' },
|
|
55
|
+
{ hook: '[name=party]', when: 'party_max > 1', says: 'how many people' },
|
|
56
|
+
{ hook: '[data-quantity-field]', when: 'quantity_max > 1', says: 'hidden; around `[name=quantity]`' },
|
|
57
|
+
{ hook: '[name=quantity]', when: 'quantity_max > 1', says: 'how many of a class at once' },
|
|
58
|
+
{ hook: '[data-hours-field]', when: 'an hourly service with most > fewest', says: 'hidden; around `[name=hours]`' },
|
|
59
|
+
{ hook: '[name=hours]', when: 'an hourly service with most > fewest', says: 'how long, in the service\'s units' },
|
|
60
|
+
{ hook: '[data-back-field]', when: 'a daily service with most > fewest', says: 'hidden; around `[name=back]` and `[data-length]`' },
|
|
61
|
+
{ hook: '[name=back]', when: 'a daily service with most > fewest', says: 'the second date: until when' },
|
|
62
|
+
{ hook: '[data-length]', when: 'a daily service with most > fewest', says: 'how many nights may be taken' },
|
|
63
|
+
{ hook: '[name=resource]', when: 'the visitor chooses whom', says: 'a `<select>`: anyone, a class, or one by name' },
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/** @type {readonly Hook[]} */
|
|
67
|
+
export const SAID = Object.freeze([
|
|
68
|
+
{ hook: '[data-field]', says: 'on each field\'s wrapper: what the two steps sort and what is hidden once booked' },
|
|
69
|
+
{ hook: '[data-services]', says: 'the checklist\'s fieldset' },
|
|
70
|
+
{ hook: '[data-service-row]', says: 'the `<label>` around each checkbox: greyed with `data-off` when the one chosen does not do it' },
|
|
71
|
+
{ hook: '[data-service-name]', says: 'the service\'s name inside the row' },
|
|
72
|
+
{ hook: '[data-total]', says: 'the running total of a checklist' },
|
|
73
|
+
{ hook: '[data-ends]', says: '«until about 10:45 · 1 h 15 min»' },
|
|
74
|
+
{ hook: '[data-stay-total]', says: 'the price of a stay' },
|
|
75
|
+
{ hook: '[data-done-what]', says: 'what was taken: «with Noa», «any of Economy»' },
|
|
76
|
+
{ hook: '[data-keep]', says: 'hidden; the link to keep, with `[data-keep-note]`, `[data-keep-link]`, `[data-keep-copy]`, `[data-keep-open]`, `[data-keep-said]`' },
|
|
77
|
+
{ hook: '[data-telegram]', says: 'a link to the business\'s Telegram bot, where it has one' },
|
|
78
|
+
{ hook: '[name=note]', says: 'a note to the business' },
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
/** Every hook, for a check that reads them all. */
|
|
82
|
+
export const HOOKS = Object.freeze([...ALWAYS, ...ASKED, ...SAID].map((one) => one.hook));
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The hooks in `ALWAYS` a form lacks — what the element says in the console
|
|
86
|
+
* when it wires one.
|
|
87
|
+
* @param {ParentNode & { matches?: (s: string) => boolean }} form
|
|
88
|
+
*/
|
|
89
|
+
export function lacking(form) {
|
|
90
|
+
return ALWAYS.filter(({ hook }) => hook !== 'form[data-booking]' && !form.querySelector(hook)).map(({ hook }) => hook);
|
|
91
|
+
}
|