@jtakeit/kit 0.0.0-stage → 0.1.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.
@@ -0,0 +1,696 @@
1
+ /**
2
+ * `<jtk-booking>` — the booking form, made to work.
3
+ *
4
+ * The whole client. No framework, one form, one fetch per question. Every
5
+ * rule enforced here is enforced again by the platform — the browser's checks
6
+ * are a convenience, never a gate — so the script's only jobs are to fetch
7
+ * the free times, to render the widget it is told to, and to say plainly
8
+ * what the platform answered.
9
+ *
10
+ * GET /api/turnstile the widget's key, or "" for none
11
+ * GET /api/availability?service=…&from=…&to=…[&resource=…][&party=…][&count=…]
12
+ * → { zone, slots[{start, free, ends}], party_max, scale, services[{slug,
13
+ * scale, takes, fewest, most}], prices{class: minor}, currency, … }
14
+ * POST /api/book → { id, state, amount_minor, currency, token, manage_path, telegram_url }
15
+ * POST /api/pay → { url } — only when amount_minor > 0
16
+ *
17
+ * The visitor's own page — cancel, move, add to calendar — is served by the
18
+ * platform at `manage_path`; this form only has to link to it.
19
+ *
20
+ * ── how a page gets it ──────────────────────────────────────────────────────
21
+ *
22
+ * Import this module once on the page and every `<form data-booking>` is
23
+ * wired: the ones inside a `<jtk-booking>` by the element's own lifecycle,
24
+ * 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
+ * `renderBookingForm`'s (`@jtakeit/kit/booking`); a hand-written form keeps
27
+ * its `data-*` hooks and input `name`s.
28
+ *
29
+ * ── a stay of several nights ────────────────────────────────────────────────
30
+ *
31
+ * By the day a visitor gives two dates, from and until, and the form sends how
32
+ * many of the service that is — `count`, nights of «a day» — and **never an
33
+ * end**: the platform computes the end and the price from the service. The
34
+ * answer to the first question says, per service, what may be taken (`fewest`,
35
+ * `most`); a service taken a fixed number of times — «a week» — asks for one
36
+ * date only. What the form draws comes from that answer rather than from the
37
+ * build, so a service changed in the panel needs no rebuild.
38
+ *
39
+ * ── any of a class, or this one ─────────────────────────────────────────────
40
+ *
41
+ * Where the business keeps classes, the choice of what to take is «any free
42
+ * Економ» — the platform gives the first free one — or one car by name, with
43
+ * what tells it from its twins: «VW Combi · білий · AA1234BB». Both from the
44
+ * platform's answer (`resources`, `classes`), and the page that just booked
45
+ * says which was taken.
46
+ */
47
+ import { money } from '../lib/money.mjs';
48
+
49
+ // The words that depend on the kind of business, as the platform says them:
50
+ // its availability answer carries the form's, in every language, the site's
51
+ // own over the kind's. Heard from every answer, and asked for once at the
52
+ // start, so they are there before anybody has picked a day.
53
+ const SAID = {
54
+ 'form.service': 'service', 'form.services': 'services', 'form.resource': 'resource', 'form.any': 'any',
55
+ 'form.book': 'book', 'form.sending': 'sending', 'form.confirmed': 'confirmed', 'form.pending': 'pending',
56
+ 'form.manage': 'manage', 'form.failed': 'failed', 'form.tooMany': 'tooMany', 'form.length': 'length',
57
+ };
58
+
59
+ /**
60
+ * Days between two dates of the picker, by the calendar rather than by the
61
+ * clock: a night the clocks move is still one night.
62
+ * @param {string} from YYYY-MM-DD
63
+ * @param {string} until YYYY-MM-DD
64
+ */
65
+ export function daysBetween(from, until) {
66
+ const [a, b] = [from, until].map((iso) => { const [y, m, d] = iso.split('-').map(Number); return Date.UTC(y, m - 1, d); });
67
+ return Math.round((b - a) / 86_400_000);
68
+ }
69
+
70
+ /** @param {string} iso YYYY-MM-DD @param {number} n */
71
+ export function plusDays(iso, n) {
72
+ const [y, m, d] = iso.split('-').map(Number);
73
+ return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10);
74
+ }
75
+
76
+ /**
77
+ * «2 год», «1 год 30 хв»: n of a rate, said as the time it is.
78
+ * @param {number} minutes
79
+ * @param {Record<string, string>} words
80
+ */
81
+ export function lasting(minutes, words) {
82
+ const h = Math.floor(minutes / 60);
83
+ const m = minutes % 60;
84
+ return [h > 0 ? `${h} ${words.hourShort}` : '', m > 0 ? `${m} ${words.minuteShort}` : ''].filter(Boolean).join(' ');
85
+ }
86
+
87
+ /**
88
+ * Wires one form. Idempotent: a form already wired is left alone.
89
+ * @param {HTMLFormElement} form
90
+ */
91
+ export function wireBooking(form) {
92
+ if (form.dataset.jtkWired === '1') return;
93
+ form.dataset.jtkWired = '1';
94
+
95
+ /** @type {Record<string, string>} */
96
+ const words = JSON.parse(form.dataset.words ?? '{}');
97
+ // Days or hours. The page's own setting first; the platform's answer,
98
+ // once there is one, wins — an owner may switch without a rebuild.
99
+ let daily = form.dataset.scale === 'daily';
100
+ const api = form.dataset.api ?? '/api/';
101
+ /** @template {HTMLElement} T @param {string} sel @returns {T} */
102
+ const q = (sel) => /** @type {any} */ (form.querySelector(sel));
103
+ const lang = form.dataset.locale ?? 'en';
104
+ const pageLang = () => document.documentElement.lang || 'en';
105
+
106
+ // One select, or a checklist: what is chosen is a list either way.
107
+ const select = /** @type {HTMLSelectElement | null} */ (form.querySelector('select[name=service]'));
108
+ const boxes = /** @type {HTMLInputElement[]} */ ([...form.querySelectorAll('input[type=checkbox][name=service]')]);
109
+ const total = /** @type {HTMLElement | null} */ (form.querySelector('[data-total]'));
110
+ const picked = () => (select ? (select.value ? [select.value] : []) : boxes.filter((b) => b.checked).map((b) => b.value));
111
+ function showTotal() {
112
+ if (!total) return;
113
+ const chosenBoxes = boxes.filter((b) => b.checked);
114
+ if (chosenBoxes.length < 2) { total.hidden = true; return; }
115
+ const minutes = chosenBoxes.reduce((sum, b) => sum + Number(b.dataset.takes ?? 0), 0);
116
+ const minor = chosenBoxes.reduce((sum, b) => sum + Number(b.dataset.costs ?? 0), 0);
117
+ const price = minor > 0 ? ' · ' + money(minor, form.dataset.currency, pageLang()) : '';
118
+ total.textContent = `${words.total}: ${minutes} min${price}`;
119
+ total.hidden = false;
120
+ }
121
+ const resource = /** @type {HTMLSelectElement | null} */ (form.querySelector('[name=resource]'));
122
+ /** @type {HTMLSelectElement} */ const party = q('[name=party]');
123
+ /** @type {HTMLSelectElement} */ const hours = q('[name=hours]');
124
+ /** @type {HTMLElement} */ const hoursField = q('[data-hours-field]');
125
+ /** @type {HTMLElement} */ const partyField = q('[data-party-field]');
126
+ /** @type {HTMLInputElement} */ const day = q('[name=day]');
127
+ /** @type {HTMLElement} */ const slots = q('[data-slots]');
128
+ /** @type {HTMLElement} */ const hint = q('[data-times-hint]');
129
+ /** @type {HTMLElement} */ const error = q('[data-booking-error]');
130
+ /** @type {HTMLButtonElement} */ const submit = q('[data-submit]');
131
+ /** @type {HTMLElement} */ const challenge = q('[data-challenge]');
132
+ /** @type {HTMLInputElement} */ const back = q('[name=back]');
133
+ /** @type {HTMLElement} */ const backField = q('[data-back-field]');
134
+ /** @type {HTMLElement} */ const lengthHint = q('[data-length]');
135
+ /** @type {HTMLElement} */ const stayTotal = q('[data-stay-total]');
136
+
137
+ /** @param {Record<string, Record<string, string>>} [all] */
138
+ function hear(all) {
139
+ const mine = all?.[lang] ?? all?.en;
140
+ if (mine) {
141
+ for (const [key, name] of Object.entries(SAID)) if (mine[key]) words[name] = mine[key];
142
+ for (const el of /** @type {NodeListOf<HTMLElement>} */ (form.querySelectorAll('[data-say]'))) {
143
+ // A button mid-send says «sending»; it says «book» again when done.
144
+ if (el instanceof HTMLButtonElement && el.disabled) continue;
145
+ el.textContent = words[el.dataset.say ?? ''] ?? el.textContent;
146
+ }
147
+ }
148
+ form.classList.remove('booking--unsaid');
149
+ }
150
+ // Held back at most this long: a platform that is slow to answer is not
151
+ // a reason for a form without a button.
152
+ setTimeout(() => form.classList.remove('booking--unsaid'), 3000);
153
+ {
154
+ const first = select?.options[0]?.value ?? boxes[0]?.value ?? '';
155
+ const from = new Date();
156
+ from.setHours(0, 0, 0, 0);
157
+ const to = new Date(from);
158
+ to.setDate(to.getDate() + 1);
159
+ if (first) {
160
+ const params = new URLSearchParams({ service: first, from: from.toISOString(), to: to.toISOString() });
161
+ void fetch(api + 'availability?' + params.toString(), { cache: 'no-store' })
162
+ .then((r) => (r.ok ? r.json() : {}))
163
+ .then((data) => {
164
+ hear(data.words);
165
+ sayEmptyServices(data.empty_services ?? []);
166
+ })
167
+ .catch(() => hear());
168
+ } else {
169
+ hear();
170
+ }
171
+ }
172
+
173
+ // What the platform said each service may be taken as, and the prices of
174
+ // what was last asked — from the answer, never from the build.
175
+ /** @type {Map<string, { slug: string; scale: string; takes: number; fewest: number; most: number }>} */
176
+ const offered = new Map();
177
+ /** @type {Record<string, number>} */ let prices = {};
178
+ /** @type {Record<string, string>} */ let classOf = {};
179
+ let currency = form.dataset.currency || 'CHF';
180
+
181
+ /** @type {string | null} */ let chosen = null;
182
+ let zone = '';
183
+ let turnstileToken = '';
184
+
185
+ // The widget, if this host has one. Asked at runtime so the site carries
186
+ // no key: which widget covers a host is the platform's to decide.
187
+ void fetch(api + 'turnstile', { cache: 'no-store' })
188
+ .then((r) => r.json())
189
+ .then((data) => {
190
+ if (!data.sitekey) return;
191
+ challenge.hidden = false;
192
+ const script = document.createElement('script');
193
+ script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
194
+ script.async = true;
195
+ script.onload = () => {
196
+ window.turnstile?.render(challenge, {
197
+ sitekey: data.sitekey,
198
+ callback: (token) => { turnstileToken = token; },
199
+ 'expired-callback': () => { turnstileToken = ''; },
200
+ });
201
+ };
202
+ document.head.appendChild(script);
203
+ })
204
+ .catch(() => undefined);
205
+
206
+ // The party control appears the first time the platform says how large a
207
+ // party may be, and asks again when it changes.
208
+ /** @param {number} most */
209
+ function offerParty(most) {
210
+ if (most <= 1) { partyField.hidden = true; return; }
211
+ if (party.options.length !== most) {
212
+ const was = party.value;
213
+ party.textContent = '';
214
+ for (let n = 1; n <= most; n++) {
215
+ const option = document.createElement('option');
216
+ option.value = String(n);
217
+ option.textContent = String(n);
218
+ party.appendChild(option);
219
+ }
220
+ party.value = was && Number(was) <= most ? was : '1';
221
+ }
222
+ if (partyField.hidden) {
223
+ partyField.hidden = false;
224
+ party.addEventListener('change', () => void load());
225
+ }
226
+ }
227
+
228
+ /** @param {string} text */
229
+ function say(text) {
230
+ error.textContent = text;
231
+ error.hidden = text === '';
232
+ }
233
+
234
+ // A refusal as a sentence in the visitor's language. The platform answers
235
+ // with a JSON body — `{"error":"challenge"}` — or with a line of prose;
236
+ // neither is for a visitor to read as it came, and a sweep once found the
237
+ // JSON printed between the form and its button.
238
+ /** @param {Response} answer */
239
+ async function reason(answer) {
240
+ let body = '';
241
+ try { body = await answer.text(); } catch { /* nothing to read */ }
242
+ let code = '';
243
+ try { code = String(JSON.parse(body).error ?? ''); } catch { /* prose */ }
244
+ if (answer.status === 403 || code === 'challenge') return words.challenge;
245
+ if (answer.status === 429) return words.tooMany;
246
+ if (answer.status === 409) return words.taken;
247
+ return words.failed;
248
+ }
249
+
250
+ // A letting's day, «сб, 26 вер.» on a button, «субота, 26 вересня» in a
251
+ // sentence. Out to back, both drawn: the day it comes back is the day to
252
+ // bring it.
253
+ /** @param {string} iso @param {boolean} long */
254
+ function dayOf(iso, long) {
255
+ try {
256
+ return new Intl.DateTimeFormat(pageLang(), long
257
+ ? { weekday: 'long', day: 'numeric', month: 'long', timeZone: zone || undefined }
258
+ : { weekday: 'short', day: 'numeric', month: 'short', timeZone: zone || undefined },
259
+ ).format(new Date(iso));
260
+ } catch {
261
+ return iso.slice(0, 10);
262
+ }
263
+ }
264
+
265
+ // A letting handed over at an hour says it beside the day — «сб, 10 жовт.
266
+ // 15:00»; one from midnight is its day (25 September 2026).
267
+ /** @param {string} iso @param {boolean} long */
268
+ function dayAndHour(iso, long) {
269
+ let midnight = true;
270
+ try {
271
+ midnight = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: zone || undefined }).format(new Date(iso)) === '00:00';
272
+ } catch { /* the day alone */ }
273
+ return midnight ? dayOf(iso, long) : `${dayOf(iso, long)}, ${clock(iso)}`;
274
+ }
275
+
276
+ /** @param {string} iso */
277
+ function clock(iso) {
278
+ try {
279
+ return new Intl.DateTimeFormat(pageLang(), {
280
+ hour: '2-digit', minute: '2-digit', timeZone: zone || undefined,
281
+ }).format(new Date(iso));
282
+ } catch {
283
+ return iso.slice(11, 16);
284
+ }
285
+ }
286
+
287
+ // The one service a stay is of, and what it may be taken as — null for a
288
+ // visit of several, or an hourly one, or before the platform has said.
289
+ function stay() {
290
+ const slugs = picked();
291
+ if (slugs.length !== 1) return null;
292
+ const one = offered.get(slugs[0]);
293
+ if (!one || one.scale !== 'daily') return null;
294
+ return { unit: Math.max(1, Math.round(one.takes / 1440)), fewest: one.fewest || 1, most: one.most || 1 };
295
+ }
296
+
297
+ // The one rate by the hour that may be taken several times, and how —
298
+ // null for anything else, or before the platform has said.
299
+ function byHours() {
300
+ const slugs = picked();
301
+ if (slugs.length !== 1) return null;
302
+ const one = offered.get(slugs[0]);
303
+ if (!one || one.scale === 'daily' || (one.most || 1) <= (one.fewest || 1)) return null;
304
+ return { takes: one.takes, fewest: one.fewest || 1, most: one.most };
305
+ }
306
+
307
+ // The choice of how long, as the chosen rate allows it: shown, filled,
308
+ // and keeping what was chosen where it still may be.
309
+ function fitHours() {
310
+ const h = byHours();
311
+ if (!h) { hoursField.hidden = true; return; }
312
+ const was = hours.value;
313
+ hours.textContent = '';
314
+ for (let n = h.fewest; n <= h.most; n++) {
315
+ const option = document.createElement('option');
316
+ option.value = String(n);
317
+ option.textContent = lasting(n * h.takes, words);
318
+ hours.appendChild(option);
319
+ }
320
+ hours.value = [...hours.options].some((o) => o.value === was) ? was : String(h.fewest);
321
+ hoursField.hidden = false;
322
+ }
323
+
324
+ // How many of the service the two dates are, or null where there is no
325
+ // choice to send; NaN for dates the service does not allow. By the hour,
326
+ // the hours chosen.
327
+ function count() {
328
+ const h = byHours();
329
+ if (h) return Number(hours.value) || h.fewest;
330
+ const s = stay();
331
+ if (!s || s.fewest === s.most || !back.value || !day.value) return s ? s.fewest : null;
332
+ const nights = daysBetween(day.value, back.value);
333
+ const n = nights / s.unit;
334
+ return Number.isInteger(n) && n >= s.fewest && n <= s.most ? n : NaN;
335
+ }
336
+
337
+ // The second date as the service allows it: shown, bounded, and filled
338
+ // with the shortest stay when it is empty or out of bounds.
339
+ function fitBack() {
340
+ const s = stay();
341
+ daily = s !== null || (offered.size === 0 && daily);
342
+ if (!s || s.fewest === s.most || !day.value) { backField.hidden = true; lengthHint.hidden = true; return; }
343
+ backField.hidden = false;
344
+ back.min = plusDays(day.value, s.fewest * s.unit);
345
+ back.max = plusDays(day.value, s.most * s.unit);
346
+ if (!back.value || back.value < back.min || back.value > back.max) back.value = back.min;
347
+ lengthHint.textContent = words.length.replace('{min}', String(s.fewest * s.unit)).replace('{max}', String(s.most * s.unit));
348
+ lengthHint.hidden = false;
349
+ }
350
+
351
+ function showStayTotal() {
352
+ const s = stay();
353
+ const cls = classChosen() || (resourceChosen() ? classOf[resourceChosen()] ?? '' : '');
354
+ const minor = prices[cls] ?? prices[''] ?? 0;
355
+ if (!s || minor <= 0) { stayTotal.hidden = true; return; }
356
+ stayTotal.textContent = `${words.total}: ` + money(minor, currency, pageLang());
357
+ stayTotal.hidden = false;
358
+ }
359
+
360
+ // Which question is the latest. Two dates changed quickly are two
361
+ // questions in flight, and both answers drew their buttons — the same
362
+ // stay offered twice. Only the last one asked is drawn.
363
+ let asking = 0;
364
+
365
+ // «class:<slug>» is «any free one of the class»; anything else a resource.
366
+ const classChosen = () => (resource?.value.startsWith('class:') ? resource.value.slice(6) : '');
367
+ const resourceChosen = () => (resource && !resource.value.startsWith('class:') ? resource.value : '');
368
+
369
+ // The choice rebuilt from every answer: every class as «any free», then
370
+ // every resource by its name and its marks. A class nothing is in is said
371
+ // and cannot be chosen: offered as «any free», it answered «nothing free»
372
+ // on every day, as if another might be different. So is a master who
373
+ // does not do what is asked — and the answer changes with the service,
374
+ // which is why it is rebuilt each time (2 October 2026). One already
375
+ // chosen stays chosen, said, rather than swapped for somebody else.
376
+ /**
377
+ * @param {{ slug: string; title: string }[]} classes
378
+ * @param {{ slug: string; title: string; marks: string; class: string; performs?: boolean }[]} resources
379
+ * @param {{ slug: string; title: string }[]} [empty]
380
+ */
381
+ function offerChoices(classes, resources, empty = []) {
382
+ if (!resource || resources.length === 0) return;
383
+ const was = resource.value;
384
+ resource.textContent = '';
385
+ /** @param {string} value @param {string} label @param {boolean} [disabled] */
386
+ const add = (value, label, disabled = false) => {
387
+ const option = document.createElement('option');
388
+ option.value = value;
389
+ option.textContent = label;
390
+ option.disabled = disabled;
391
+ if (value === '') option.dataset.say = 'any';
392
+ resource.appendChild(option);
393
+ };
394
+ add('', words.any);
395
+ for (const one of classes) add('class:' + one.slug, words.anyOf.replace('{class}', one.title));
396
+ for (const one of empty) add('empty:' + one.slug, words.noneOf.replace('{class}', one.title), true);
397
+ for (const one of resources) {
398
+ const name = one.marks ? `${one.title} · ${one.marks}` : one.title;
399
+ add(one.slug, one.performs === false ? words.notFor.replace('{who}', name) : name, one.performs === false);
400
+ }
401
+ if ([...resource.options].some((o) => o.value === was)) resource.value = was;
402
+ }
403
+
404
+ // The resource chosen, if it does not do what is asked: its name, for
405
+ // saying so in place of «nothing free on this day».
406
+ /** @param {{ slug: string; title: string; performs?: boolean }[]} resources */
407
+ function chosenNotFor(resources) {
408
+ const one = resources.find((r) => r.slug === resourceChosen());
409
+ return one && one.performs === false ? one.title : '';
410
+ }
411
+
412
+ // A service nobody performs — a salon's service no master does — is said
413
+ // and cannot be chosen, as a class nothing is in: offered, it answered
414
+ // «nothing free» on every day. True when the choice had to move off it,
415
+ // so what is now chosen is asked for.
416
+ /** @param {{ slug: string; title: string }[]} empty */
417
+ function sayEmptyServices(empty) {
418
+ let moved = false;
419
+ for (const one of empty) {
420
+ const said = words.noneOf.replace('{class}', one.title);
421
+ const option = select ? [...select.options].find((o) => o.value === one.slug) : undefined;
422
+ if (select && option && !option.disabled) {
423
+ option.disabled = true;
424
+ option.textContent = said;
425
+ if (select.value === one.slug) {
426
+ const next = [...select.options].find((o) => !o.disabled);
427
+ if (next) { select.value = next.value; moved = true; }
428
+ }
429
+ }
430
+ const box = boxes.find((b) => b.value === one.slug);
431
+ if (box && !box.disabled) {
432
+ box.disabled = true;
433
+ const name = box.closest('.booking__service')?.querySelector('.booking__service-name');
434
+ if (name) name.textContent = said;
435
+ if (box.checked) { box.checked = false; showTotal(); moved = true; }
436
+ }
437
+ }
438
+ return moved;
439
+ }
440
+
441
+ async function load() {
442
+ const mine = ++asking;
443
+ chosen = null;
444
+ slots.textContent = '';
445
+ if (!day.value || picked().length === 0) return;
446
+ hint.textContent = '…';
447
+
448
+ const from = new Date(day.value + 'T00:00:00');
449
+ const to = new Date(from);
450
+ to.setDate(to.getDate() + 1);
451
+ const params = new URLSearchParams({
452
+ from: from.toISOString(),
453
+ to: to.toISOString(),
454
+ });
455
+ for (const slug of picked()) params.append('service', slug);
456
+ if (resourceChosen()) params.set('resource', resourceChosen());
457
+ if (classChosen()) params.set('class', classChosen());
458
+ if (!partyField.hidden && party.value) params.set('party', party.value);
459
+ const n = count();
460
+ if (Number.isNaN(n)) { hint.textContent = lengthHint.textContent || words.failed; stayTotal.hidden = true; return; }
461
+ if (n !== null) params.set('count', String(n));
462
+
463
+ try {
464
+ const answer = await fetch(api + 'availability?' + params.toString(), { cache: 'no-store' });
465
+ if (!answer.ok) throw new Error(await answer.text());
466
+ if (mine !== asking) return;
467
+ const data = await answer.json();
468
+ hear(data.words);
469
+ offerChoices(data.classes ?? [], data.resources ?? [], data.empty_classes ?? []);
470
+ if (sayEmptyServices(data.empty_services ?? [])) { void load(); return; }
471
+ zone = data.zone;
472
+ if (typeof data.scale === 'string') daily = data.scale === 'daily';
473
+ offerParty(data.party_max ?? 0);
474
+ // A platform that says what each service may be taken as: the second
475
+ // date follows it. The first answer may change what was asked — the
476
+ // shortest stay filled in — and is asked once more with it.
477
+ if (Array.isArray(data.services)) {
478
+ const knewBefore = offered.size > 0;
479
+ for (const one of data.services) offered.set(one.slug, one);
480
+ prices = data.prices ?? {};
481
+ classOf = data.class_of ?? {};
482
+ if (data.currency) currency = data.currency;
483
+ const was = back.value;
484
+ fitBack();
485
+ fitHours();
486
+ if (!knewBefore && stay() && back.value !== was && !backField.hidden) { void load(); return; }
487
+ showStayTotal();
488
+ }
489
+ if (data.slots.length === 0) {
490
+ // A party larger than any table is not a busy day: no other day has
491
+ // a place for it either (the night run of 2 October 2026).
492
+ const seats = data.party_seats ?? 0;
493
+ const asked = partyField.hidden ? 1 : Number(party.value);
494
+ // Nor is a master who does not do what was asked (2 October 2026).
495
+ const notFor = chosenNotFor(data.resources ?? []);
496
+ hint.textContent = notFor ? words.notForChosen.replace('{who}', notFor).replace('{any}', words.any)
497
+ : seats > 0 && asked > seats ? words.noParty : words.none;
498
+ return;
499
+ }
500
+ hint.textContent = '';
501
+ for (const slot of data.slots) {
502
+ const button = document.createElement('button');
503
+ button.type = 'button';
504
+ button.className = 'booking__slot';
505
+ // Which one is chosen, said as well as drawn.
506
+ button.setAttribute('aria-pressed', 'false');
507
+ button.textContent = daily
508
+ ? slot.ends ? `${dayAndHour(slot.start, false)} — ${dayAndHour(slot.ends, false)}` : dayAndHour(slot.start, false)
509
+ : clock(slot.start);
510
+ button.addEventListener('click', () => {
511
+ chosen = slot.start;
512
+ for (const other of slots.querySelectorAll('.booking__slot')) {
513
+ other.classList.remove('is-chosen');
514
+ other.setAttribute('aria-pressed', 'false');
515
+ }
516
+ button.classList.add('is-chosen');
517
+ button.setAttribute('aria-pressed', 'true');
518
+ say('');
519
+ });
520
+ slots.appendChild(button);
521
+ }
522
+ if (stay() && data.slots.length === 1) /** @type {HTMLButtonElement | null} */ (slots.firstElementChild)?.click();
523
+ } catch (err) {
524
+ hint.textContent = err instanceof Error && err.message ? err.message : words.failed;
525
+ }
526
+ }
527
+
528
+ select?.addEventListener('change', () => { fitHours(); void load(); });
529
+ hours.addEventListener('change', () => void load());
530
+ for (const box of boxes) box.addEventListener('change', () => { showTotal(); void load(); });
531
+ resource?.addEventListener('change', () => void load());
532
+ // A new first date keeps the stay's length: five nights from the 10th
533
+ // become five nights from the 12th, not whatever the old «until» leaves.
534
+ let lastDay = day.value;
535
+ day.addEventListener('change', () => {
536
+ if (lastDay && back.value && day.value) back.value = plusDays(day.value, daysBetween(lastDay, back.value));
537
+ lastDay = day.value;
538
+ fitBack();
539
+ void load();
540
+ });
541
+ back.addEventListener('change', () => void load());
542
+
543
+ form.addEventListener('submit', async (event) => {
544
+ event.preventDefault();
545
+ say('');
546
+ const data = new FormData(form);
547
+ const name = String(data.get('name') ?? '').trim();
548
+ const phone = String(data.get('phone') ?? '').trim();
549
+ const email = String(data.get('email') ?? '').trim();
550
+ if (picked().length === 0) { say(words.pickService); return; }
551
+ if (!chosen) { say(words.pick); return; }
552
+ if (name === '') { /** @type {HTMLInputElement} */ (q('[name=name]')).focus(); return; }
553
+ if (phone === '' && email === '') { say(words.needContact); return; }
554
+
555
+ submit.disabled = true;
556
+ submit.textContent = words.sending;
557
+ try {
558
+ /** @type {Record<string, unknown>} */
559
+ const body = {
560
+ service: picked()[0],
561
+ services: picked(),
562
+ resource: resourceChosen(),
563
+ class: classChosen(),
564
+ party: partyField.hidden ? 1 : Number(party.value) || 1,
565
+ // How many of the service — never an end: the platform computes
566
+ // the end and the price from it.
567
+ ...(count() ? { count: count() } : {}),
568
+ start: chosen,
569
+ name, phone, email,
570
+ note: String(data.get('note') ?? ''),
571
+ turnstile: turnstileToken,
572
+ };
573
+ // Only what the form says it sends (`data-sends`), which is what
574
+ // preflight books with: a key added here and not there is dropped,
575
+ // and said, rather than sent unchecked.
576
+ const sends = new Set((form.dataset.sends ?? '').split(','));
577
+ for (const key of Object.keys(body)) {
578
+ if (!sends.has(key)) {
579
+ console.error(`booking form: "${key}" is not in data-sends, so it is not sent`);
580
+ delete body[key];
581
+ }
582
+ }
583
+ const answer = await fetch(api + 'book', {
584
+ method: 'POST',
585
+ headers: { 'content-type': 'application/json' },
586
+ body: JSON.stringify(body),
587
+ });
588
+ if (answer.status === 409) { say(words.taken); await load(); return; }
589
+ if (!answer.ok) { say(await reason(answer)); return; }
590
+ done(await answer.json());
591
+ } catch {
592
+ say(words.failed);
593
+ } finally {
594
+ submit.disabled = false;
595
+ submit.textContent = words.book;
596
+ window.turnstile?.reset();
597
+ turnstileToken = '';
598
+ }
599
+ });
600
+
601
+ /**
602
+ * @param {{ state: string; start: string; ends: string; amount_minor: number; currency: string; id: string; manage_path: string; telegram_url: string; resource?: string; class?: string }} made
603
+ */
604
+ function done(made) {
605
+ for (const el of /** @type {NodeListOf<HTMLElement>} */ (form.querySelectorAll('.field, .booking__times, .booking__services, [data-submit], [data-challenge]'))) el.hidden = true;
606
+ /** @type {HTMLElement} */ const box = q('[data-done]');
607
+ box.hidden = false;
608
+ // After the lines are written below — see the end of this function.
609
+ queueMicrotask(() => box.focus());
610
+ q('[data-done-line]').textContent = made.state === 'confirmed' ? words.confirmed : words.pending;
611
+ q('[data-done-when]').textContent = daily
612
+ ? `${dayAndHour(made.start, true)} — ${dayAndHour(made.ends, true)}`
613
+ : `${new Intl.DateTimeFormat(pageLang(), { weekday: 'long', day: 'numeric', month: 'long', timeZone: zone || undefined }).format(new Date(made.start))}, ${clock(made.start)}–${clock(made.ends)}`;
614
+
615
+ // What was taken: the class promised, where «any free» was chosen; the
616
+ // one by name and marks, where it was chosen — «VW Combi · білий ·
617
+ // AA1234BB». Nothing where the business has one of everything.
618
+ const what = made.class ? words.anyOf.replace('{class}', made.class) : resourceChosen() ? made.resource ?? '' : '';
619
+ if (what) {
620
+ /** @type {HTMLElement} */ const line = q('[data-done-what]');
621
+ line.textContent = words.took.replace('{what}', what);
622
+ line.hidden = false;
623
+ }
624
+
625
+ /** @type {HTMLAnchorElement} */ const manage = q('[data-manage]');
626
+ if (made.manage_path) { manage.href = made.manage_path; manage.textContent = words.manage; manage.hidden = false; }
627
+ /** @type {HTMLAnchorElement} */ const tg = q('[data-telegram]');
628
+ if (made.telegram_url) { tg.href = made.telegram_url; tg.textContent = words.telegram; tg.hidden = false; }
629
+
630
+ if (made.amount_minor > 0) {
631
+ /** @type {HTMLElement} */ const note = q('[data-pay-note]');
632
+ note.textContent = words.payNote;
633
+ note.hidden = false;
634
+ /** @type {HTMLAnchorElement} */ const pay = q('[data-pay]');
635
+ pay.textContent = `${words.pay} · ${money(made.amount_minor, made.currency, pageLang())}`;
636
+ pay.href = '#';
637
+ pay.hidden = false;
638
+ pay.addEventListener('click', async (event) => {
639
+ event.preventDefault();
640
+ const answer = await fetch(api + 'pay', {
641
+ method: 'POST',
642
+ headers: { 'content-type': 'application/json' },
643
+ body: JSON.stringify({ booking: made.id, return: made.manage_path || '/' }),
644
+ });
645
+ if (!answer.ok) { say(await reason(answer)); return; }
646
+ const { url } = await answer.json();
647
+ window.location.href = url;
648
+ });
649
+ }
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Wires every booking form under a root that is not wired yet.
655
+ * @param {ParentNode} [root]
656
+ */
657
+ export function wireAll(root = document) {
658
+ for (const form of /** @type {NodeListOf<HTMLFormElement>} */ (root.querySelectorAll('form[data-booking]'))) wireBooking(form);
659
+ if (root instanceof HTMLFormElement && root.matches('[data-booking]')) wireBooking(root);
660
+ }
661
+
662
+ // The element, where there is a document to define it in. Under Node — the
663
+ // tests, a build that imports the module for its helpers — there is none, and
664
+ // nothing here needs one.
665
+ const BaseElement = typeof HTMLElement === 'undefined' ? class {} : HTMLElement;
666
+
667
+ /**
668
+ * `<jtk-booking>` wraps the form `renderBookingForm` wrote and wires it when
669
+ * it reaches the document — on load, and when the panel places the markup
670
+ * later, which is what a custom element's lifecycle is for.
671
+ */
672
+ export class JtkBooking extends BaseElement {
673
+ connectedCallback() {
674
+ wireAll(/** @type {any} */ (this));
675
+ }
676
+ }
677
+
678
+ /** @param {string} [name] */
679
+ export function defineBooking(name = 'jtk-booking') {
680
+ if (typeof customElements === 'undefined' || customElements.get(name)) return;
681
+ customElements.define(name, JtkBooking);
682
+ }
683
+
684
+ if (typeof document !== 'undefined') {
685
+ defineBooking();
686
+ // A form written bare, outside the element: wired at load, and again on
687
+ // markup the panel places (`fl:placed`, the way the scaffold's `onAlive`
688
+ // listens).
689
+ const start = () => wireAll(document);
690
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });
691
+ else start();
692
+ document.addEventListener('fl:placed', (event) => {
693
+ const at = event.target;
694
+ if (at instanceof Element) wireAll(at);
695
+ });
696
+ }