@jtakeit/kit 0.4.0 → 0.5.1
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 +31 -0
- package/bin/jtkit.mjs +33 -0
- package/booking.css +55 -1
- package/elements/booking-ui.js +371 -0
- package/elements/booking.js +173 -8
- package/elements/writing.js +193 -0
- package/lib/booking/parts.mjs +63 -0
- package/lib/booking/render.mjs +16 -4
- package/lib/booking/words.mjs +200 -0
- package/lib/catalogue.mjs +18 -2
- package/lib/money.mjs +24 -4
- package/lib/phases.mjs +139 -0
- package/package.json +16 -1
- package/types/booking-ui.d.ts +6 -0
- package/types/booking.d.ts +6 -0
- package/types/parts.d.ts +7 -0
- package/types/phases.d.ts +41 -0
package/README.md
CHANGED
|
@@ -58,6 +58,15 @@ A form written by hand is wired too, as long as it keeps the `data-*` hooks
|
|
|
58
58
|
and the input `name`s of the rendered one: `wireAll()` walks the document at
|
|
59
59
|
load and again on the panel's `fl:placed`.
|
|
60
60
|
|
|
61
|
+
**By default the form arrives dressed** — two steps, the day as a week of
|
|
62
|
+
seven that never offers yesterday, the times by the part of the day, a drawn
|
|
63
|
+
choice of whom, a master chosen greying out what they do not do, the time kept
|
|
64
|
+
across changes, the end of the visit, a 24-hour clock, and the success showing
|
|
65
|
+
the booking's link whole with *Copy* (`@jtakeit/kit/elements/booking-ui`,
|
|
66
|
+
switched on by `renderBookingForm`'s `ui`, `false` for the plain form). Pass
|
|
67
|
+
`open` (the days the business opens) and each resource's `does` for the week
|
|
68
|
+
and the greying to know.
|
|
69
|
+
|
|
61
70
|
**The look** is the site's to give. `@jtakeit/kit/booking.css` makes the form
|
|
62
71
|
legible and nothing more; `field`, `field__input`, `booking__slot`,
|
|
63
72
|
`booking__submit` are names to restyle with the site's own fields and
|
|
@@ -163,6 +172,25 @@ build that way yourself (`SITE_URL=https://x.invalid/p/check/`) and pass
|
|
|
163
172
|
`--dist`. `@jtakeit/astro catalogue` is this tool, signing the file as the
|
|
164
173
|
scaffold.
|
|
165
174
|
|
|
175
|
+
## The page says where it is
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
npx jtkit session <session_id> <watch_key> # --api https://api.stg.jtakeit.com on staging
|
|
179
|
+
npx jtkit session --forget
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Under `astro dev` every page carries a small card, bottom right: the seven
|
|
183
|
+
phases of a site being written — the brief, the layout, the writing, read
|
|
184
|
+
off the files; the judge's word, the repository, the build and the preview
|
|
185
|
+
from the platform — and the steps of the panel's setting up the developer
|
|
186
|
+
can take meanwhile, with the way there. The platform's half comes through
|
|
187
|
+
the dev session: `open_dev_session` answers with a `watch_key`, and this
|
|
188
|
+
command keeps it in `jtk/session.json`, out of git, for the dev server to
|
|
189
|
+
present. The key reads that one session's progress and nothing else, and
|
|
190
|
+
dies with the session. The card is `@jtakeit/kit/writing`; the rules it
|
|
191
|
+
draws by are `@jtakeit/kit/phases`, the panel's own; and nothing of it is in
|
|
192
|
+
a build.
|
|
193
|
+
|
|
166
194
|
## Also here
|
|
167
195
|
|
|
168
196
|
- `@jtakeit/kit/progress` — where the site is, read off the files: the brief's
|
|
@@ -170,6 +198,9 @@ scaffold.
|
|
|
170
198
|
scaffold's dev server serves it as `/_jtk/progress.json` for the panel, and
|
|
171
199
|
`jtkit progress` prints the same list for the agent; a rule in one is a
|
|
172
200
|
rule in both, and nothing in it is declared.
|
|
201
|
+
- `@jtakeit/kit/phases` — the seven phases from the two halves, by the panel's
|
|
202
|
+
rules: a later phase counts only once every one before it does; written is
|
|
203
|
+
the still-to-do list being empty.
|
|
173
204
|
- `@jtakeit/kit/money` — a price as the page says it: «2 300 ₴», no kopecks
|
|
174
205
|
on a whole price, no sign where the site has not said its currency.
|
|
175
206
|
- `@jtakeit/kit/booking/words` — the form's words per language, the salon's
|
package/bin/jtkit.mjs
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* the annotation lint the platform's build runs, on this working tree
|
|
12
12
|
* jtkit progress [--root .] [--json]
|
|
13
13
|
* where the site is, read off the files: the brief, the pages, the entries, what is missing
|
|
14
|
+
* jtkit session <session_id> <watch_key> [--api <url>] [--root .] | jtkit session --forget
|
|
15
|
+
* let the page the dev server serves read the platform's half through the dev session
|
|
14
16
|
*/
|
|
15
17
|
import { pathToFileURL } from 'node:url';
|
|
16
18
|
import { join, dirname, resolve } from 'node:path';
|
|
@@ -48,6 +50,37 @@ switch (command) {
|
|
|
48
50
|
}
|
|
49
51
|
break;
|
|
50
52
|
}
|
|
53
|
+
case 'session': {
|
|
54
|
+
// The dev session's watch key, kept beside the content and out of git:
|
|
55
|
+
// the dev server presents it for one read-only answer — where the site
|
|
56
|
+
// is on the platform and what the developer can do in the panel — which
|
|
57
|
+
// the page then shows bottom right (elements/writing.js).
|
|
58
|
+
const { writeFileSync, unlinkSync, mkdirSync, existsSync } = await import('node:fs');
|
|
59
|
+
const root = resolve(flag('root', process.cwd()));
|
|
60
|
+
const file = join(root, 'jtk', 'session.json');
|
|
61
|
+
if (has('forget')) {
|
|
62
|
+
if (existsSync(file)) unlinkSync(file);
|
|
63
|
+
console.log('forgotten: the page no longer reads the platform');
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
const takesValue = new Set(['--api', '--root']);
|
|
67
|
+
const positional = [];
|
|
68
|
+
for (let i = 0; i < rest.length; i++) {
|
|
69
|
+
if (takesValue.has(rest[i])) { i++; continue; }
|
|
70
|
+
if (rest[i].startsWith('--')) continue;
|
|
71
|
+
positional.push(rest[i]);
|
|
72
|
+
}
|
|
73
|
+
const [session, key] = positional;
|
|
74
|
+
if (!session || !key) {
|
|
75
|
+
console.error("jtkit session <session_id> <watch_key> [--api <url>] — both are in open_dev_session's answer");
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
const api = flag('api', process.env.JTK_API_URL || 'https://api.jtakeit.com').replace(/\/$/, '');
|
|
79
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
80
|
+
writeFileSync(file, JSON.stringify({ session, key, api }, null, 2) + '\n');
|
|
81
|
+
console.log(`${relative(process.cwd(), file)} written — the page the dev server serves now shows the platform's half through session ${session} at ${api}. Not in git; jtkit session --forget drops it.`);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
51
84
|
case 'progress': {
|
|
52
85
|
// The same reading the scaffold's dev server serves the panel as
|
|
53
86
|
// /_jtk/progress.json — what the owner sees as missing is what this
|
package/booking.css
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* buttons, the way the scaffold's booking.md asks. Keep the `data-*` hooks and
|
|
7
7
|
* the `[hidden]` rule below whatever you do to the rest.
|
|
8
8
|
*/
|
|
9
|
-
.booking { display: grid; gap: var(--space-4, 1rem); max-width: 34rem; }
|
|
9
|
+
.booking { display: grid; gap: var(--space-4, 1rem); max-width: 34rem; scroll-margin-top: 5rem; }
|
|
10
10
|
|
|
11
11
|
/* The kind's words, until the platform has said them: the space kept, the
|
|
12
12
|
salon's word not shown at a car rental. */
|
|
@@ -30,6 +30,60 @@
|
|
|
30
30
|
.booking__done-line { font-weight: 600; margin: 0; }
|
|
31
31
|
.booking__done-when { margin: 0; }
|
|
32
32
|
|
|
33
|
+
/* The look, by default (booking-ui.js): legible, and the site's to restyle.
|
|
34
|
+
Keep `.is-replaced` and `[data-stage]` whatever you do to the rest. */
|
|
35
|
+
.booking > * { min-width: 0; }
|
|
36
|
+
.booking__ends { margin: 0.35rem 0 0; }
|
|
37
|
+
.booking__service.is-off { opacity: 0.5; cursor: default; }
|
|
38
|
+
.booking__service-off { font-size: 0.8em; opacity: 0.75; }
|
|
39
|
+
/* the native control stays for the form; the drawn one is what is seen */
|
|
40
|
+
.booking .is-replaced { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); opacity: 0; pointer-events: none; }
|
|
41
|
+
/* the week of days */
|
|
42
|
+
.booking__days-head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin: 0 0 0.4rem; }
|
|
43
|
+
.booking__days-row { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 0.3rem; }
|
|
44
|
+
.booking__day, .booking__days-arrow, .booking__part, .booking__next, .booking__back, .booking__dd-trigger, .booking__dd-option, .booking__keep-copy {
|
|
45
|
+
font: inherit; color: inherit; background: transparent; border: 1px solid var(--color-line, currentColor); border-radius: var(--radius-sm, 0.5rem); cursor: pointer;
|
|
46
|
+
}
|
|
47
|
+
.booking__day { display: grid; gap: 0.1rem; padding: 0.4rem 0.1rem; text-align: center; line-height: 1.2; }
|
|
48
|
+
.booking__day-wd { font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; }
|
|
49
|
+
.booking__day:disabled, .booking__days-arrow:disabled { opacity: 0.35; cursor: default; }
|
|
50
|
+
.booking__day.is-chosen { background: var(--color-ink, #111); color: var(--color-bg, #fff); border-color: var(--color-ink, #111); }
|
|
51
|
+
.booking__days-arrow { padding: 0.3rem 0.6rem; }
|
|
52
|
+
/* the times by the part of the day */
|
|
53
|
+
.booking__parts { display: flex; flex-wrap: wrap; gap: 0.25rem; margin: 0 0 0.5rem; }
|
|
54
|
+
.booking__part { padding: 0.3rem 0.7rem; border-radius: 999px; }
|
|
55
|
+
.booking__part.is-current { background: var(--color-ink, #111); color: var(--color-bg, #fff); border-color: var(--color-ink, #111); }
|
|
56
|
+
.booking__part-n { font-size: 0.8em; opacity: 0.75; margin-left: 0.35rem; }
|
|
57
|
+
/* the two steps */
|
|
58
|
+
.booking__stepper { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; margin: 0; padding: 0; list-style: none; opacity: 0.75; }
|
|
59
|
+
.booking__step { display: inline-flex; align-items: center; gap: 0.4rem; }
|
|
60
|
+
.booking__step-n { display: inline-grid; place-items: center; width: 1.4rem; height: 1.4rem; border: 1px solid currentColor; border-radius: 999px; font-size: 0.8em; }
|
|
61
|
+
.booking__step.is-current { opacity: 1; font-weight: 600; }
|
|
62
|
+
.booking__step.is-current .booking__step-n, .booking__step.is-done .booking__step-n { background: var(--color-ink, #111); color: var(--color-bg, #fff); border-color: var(--color-ink, #111); }
|
|
63
|
+
.booking[data-stage='1'] > [data-step='2'], .booking[data-stage='2'] > [data-step='1'] { display: none; }
|
|
64
|
+
.booking[data-stage='done'] > .booking__stepper, .booking[data-stage='done'] > .booking__summary, .booking[data-stage='done'] > .booking__need, .booking[data-stage='done'] > .booking__next { display: none; }
|
|
65
|
+
.booking__next { padding: 0.6rem 1.2rem; }
|
|
66
|
+
.booking__need { margin: 0; color: var(--color-danger, #b3261e); }
|
|
67
|
+
.booking__summary { display: grid; gap: 0.6rem; padding: 0.9rem 1rem; border: 1px solid var(--line, #e5e1d8); border-radius: var(--radius-sm, 0.5rem); }
|
|
68
|
+
.booking__summary-label { margin: 0; font-size: 0.75em; letter-spacing: 0.1em; text-transform: uppercase; opacity: 0.7; }
|
|
69
|
+
.booking__summary-list { display: grid; gap: 0.4rem; margin: 0; }
|
|
70
|
+
.booking__summary-row dt { font-size: 0.8em; opacity: 0.7; }
|
|
71
|
+
.booking__summary-row dd { margin: 0; }
|
|
72
|
+
.booking__back { justify-self: start; padding: 0.3rem 0.8rem; border-radius: 999px; }
|
|
73
|
+
/* the drawn choice of whom */
|
|
74
|
+
.booking__dd { position: relative; }
|
|
75
|
+
.booking__dd-trigger { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; width: 100%; padding: 0.55rem 0.75rem; text-align: left; }
|
|
76
|
+
.booking__dd-list { position: absolute; inset-inline: 0; top: calc(100% + 0.25rem); z-index: 5; display: grid; padding: 0.25rem; background: var(--color-bg, #fff); border: 1px solid var(--line, #e5e1d8); border-radius: var(--radius-sm, 0.5rem); box-shadow: 0 10px 30px -12px rgb(0 0 0 / 0.3); }
|
|
77
|
+
.booking__dd-option { border: 0; padding: 0.5rem 0.6rem; text-align: left; }
|
|
78
|
+
.booking__dd-option:hover, .booking__dd-option:focus-visible { background: var(--line, #e5e1d8); }
|
|
79
|
+
.booking__dd-option.is-chosen { font-weight: 600; }
|
|
80
|
+
.booking__dd-option:disabled { opacity: 0.45; cursor: default; }
|
|
81
|
+
/* the link, kept */
|
|
82
|
+
.booking__keep { display: grid; gap: 0.4rem; }
|
|
83
|
+
.booking__keep-row { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
|
84
|
+
.booking__keep-link { flex: 1; min-width: 12rem; font: inherit; }
|
|
85
|
+
.booking__keep-copy, .booking__keep-open { display: inline-flex; align-items: center; padding: 0.45rem 0.8rem; text-decoration: none; }
|
|
86
|
+
|
|
33
87
|
/* `hidden` means hidden, whatever the element's class says: every state here
|
|
34
88
|
is shown and hidden by the attribute, and a class that sets `display` would
|
|
35
89
|
otherwise win (an agent's rehearsal, 30 September 2026). */
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The booking form's look, by default: what a visitor on a phone between
|
|
3
|
+
* two appointments needs, drawn over the kit's own markup.
|
|
4
|
+
*
|
|
5
|
+
* steps two steps — what and when, then who is coming — with the choice
|
|
6
|
+
* summarised beside the name, and a way back
|
|
7
|
+
* week the day as a row of seven, rolling from today, closed days
|
|
8
|
+
* struck out, never yesterday
|
|
9
|
+
* parts the times by the part of the day — morning, afternoon, evening —
|
|
10
|
+
* one part showing at a time, so thirty pills become a dozen and
|
|
11
|
+
* nothing scrolls; a part of one or two times joins its neighbour
|
|
12
|
+
* select the choice of whom as a drawn list rather than the browser's own
|
|
13
|
+
*
|
|
14
|
+
* `renderBookingForm` turns all four on with `data-ui` on the form; a site
|
|
15
|
+
* passes `ui: false` for the plain form, or names the ones it wants. Every
|
|
16
|
+
* piece reads the element's hooks and leaves them where they are — the
|
|
17
|
+
* native day input is still what is submitted, the kit's pills are still the
|
|
18
|
+
* pills — so the protocol is untouched and `preflight` reads the same form.
|
|
19
|
+
*
|
|
20
|
+
* The look is the kit's reference and the site's to restyle: `booking.css`
|
|
21
|
+
* makes it legible, the scaffold's `global.css` makes it the site's.
|
|
22
|
+
*/
|
|
23
|
+
import { PARTS, partsOf, parseDay, ymd, DAY_NAMES, to24 } from '../lib/booking/parts.mjs';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {HTMLFormElement} form
|
|
27
|
+
* @param {Record<string, string>} words
|
|
28
|
+
*/
|
|
29
|
+
export function dressBooking(form, words) {
|
|
30
|
+
if (form.dataset.dressed === '1') return;
|
|
31
|
+
form.dataset.dressed = '1';
|
|
32
|
+
const wants = new Set((form.dataset.ui ?? '').split(/\s+/).filter(Boolean));
|
|
33
|
+
const lang = document.documentElement.lang || form.dataset.locale || 'en';
|
|
34
|
+
if (wants.has('select')) {
|
|
35
|
+
for (const select of /** @type {NodeListOf<HTMLSelectElement>} */ (form.querySelectorAll('select[name=resource], select[name=service]'))) drawnSelect(select);
|
|
36
|
+
}
|
|
37
|
+
if (wants.has('week')) {
|
|
38
|
+
const day = /** @type {HTMLInputElement | null} */ (form.querySelector('input[name=day]'));
|
|
39
|
+
if (day) weekStrip(day, { open: (form.dataset.open ?? '').split(',').filter(Boolean), lang, words });
|
|
40
|
+
}
|
|
41
|
+
if (wants.has('parts')) partsOfDay(form, words);
|
|
42
|
+
if (wants.has('steps')) twoSteps(form, words, lang);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A week of days in a row: seven chips from the first day that can be
|
|
47
|
+
* booked, the month above them, arrows for the next and the previous week.
|
|
48
|
+
* The native input stays — hidden — and is what the form submits; a press on
|
|
49
|
+
* a day sets it and fires `change`, which is what the element listens to.
|
|
50
|
+
*
|
|
51
|
+
* @param {HTMLInputElement} input
|
|
52
|
+
* @param {{ open: string[]; lang: string; words: Record<string, string> }} options
|
|
53
|
+
*/
|
|
54
|
+
export function weekStrip(input, options) {
|
|
55
|
+
if (input.dataset.week === '1') return;
|
|
56
|
+
input.dataset.week = '1';
|
|
57
|
+
const open = new Set(options.open.length > 0 ? options.open : DAY_NAMES);
|
|
58
|
+
// Today is the browser's, not the build's: `min` was written when the
|
|
59
|
+
// site was built, and a site built on Friday is read on Saturday.
|
|
60
|
+
const now = new Date();
|
|
61
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
62
|
+
const minSaid = parseDay(input.min);
|
|
63
|
+
const floor = minSaid && minSaid > today ? minSaid : today;
|
|
64
|
+
const max = parseDay(input.max) ?? new Date(floor.getFullYear(), floor.getMonth() + 2, floor.getDate());
|
|
65
|
+
const monthName = new Intl.DateTimeFormat(options.lang, { month: 'long', year: 'numeric' });
|
|
66
|
+
const weekday = new Intl.DateTimeFormat(options.lang, { weekday: 'short' });
|
|
67
|
+
const plus = (d, n) => { const out = new Date(d); out.setDate(out.getDate() + n); return out; };
|
|
68
|
+
const choosable = (d) => d >= floor && d <= max && open.has(DAY_NAMES[(d.getDay() + 6) % 7]);
|
|
69
|
+
|
|
70
|
+
const root = document.createElement('div');
|
|
71
|
+
root.className = 'booking__days';
|
|
72
|
+
root.setAttribute('role', 'group');
|
|
73
|
+
root.setAttribute('aria-label', input.labels?.[0]?.textContent?.trim() || options.words.day || 'Day');
|
|
74
|
+
input.classList.add('is-replaced');
|
|
75
|
+
input.setAttribute('aria-hidden', 'true');
|
|
76
|
+
input.tabIndex = -1;
|
|
77
|
+
input.insertAdjacentElement('afterend', root);
|
|
78
|
+
const chosenAtStart = parseDay(input.value);
|
|
79
|
+
let start = chosenAtStart && chosenAtStart >= floor && chosenAtStart > plus(floor, 6) ? chosenAtStart : new Date(floor);
|
|
80
|
+
|
|
81
|
+
function draw() {
|
|
82
|
+
const chosen = input.value;
|
|
83
|
+
const week = Array.from({ length: 7 }, (_, i) => plus(start, i));
|
|
84
|
+
const span = week[0].getMonth() === week[6].getMonth()
|
|
85
|
+
? monthName.format(week[0])
|
|
86
|
+
: `${monthName.format(week[0]).split(' ')[0]} – ${monthName.format(week[6])}`;
|
|
87
|
+
root.innerHTML = `
|
|
88
|
+
<div class="booking__days-head">
|
|
89
|
+
<span class="booking__days-month">${span}</span>
|
|
90
|
+
<span class="booking__days-nav">
|
|
91
|
+
<button type="button" class="booking__days-arrow" data-go="-7" aria-label="${options.words.prevWeek ?? 'Previous week'}"${plus(start, -1) >= floor ? '' : ' disabled'}>‹</button>
|
|
92
|
+
<button type="button" class="booking__days-arrow" data-go="7" aria-label="${options.words.nextWeek ?? 'Next week'}"${plus(start, 7) <= max ? '' : ' disabled'}>›</button>
|
|
93
|
+
</span>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="booking__days-row">
|
|
96
|
+
${week.map((d) => {
|
|
97
|
+
const value = ymd(d);
|
|
98
|
+
const ok = choosable(d);
|
|
99
|
+
return `<button type="button" class="booking__day${value === chosen ? ' is-chosen' : ''}" data-day="${value}"${ok ? '' : ' disabled'} aria-pressed="${value === chosen}"><span class="booking__day-wd">${weekday.format(d)}</span><span class="booking__day-n">${d.getDate()}</span></button>`;
|
|
100
|
+
}).join('')}
|
|
101
|
+
</div>`;
|
|
102
|
+
}
|
|
103
|
+
root.addEventListener('click', (event) => {
|
|
104
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
105
|
+
const arrow = target?.closest('[data-go]');
|
|
106
|
+
if (arrow instanceof HTMLButtonElement) {
|
|
107
|
+
start = plus(start, Number(arrow.dataset.go));
|
|
108
|
+
if (start < floor) start = new Date(floor);
|
|
109
|
+
draw();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const dayButton = target?.closest('[data-day]');
|
|
113
|
+
if (dayButton instanceof HTMLButtonElement && !dayButton.disabled) {
|
|
114
|
+
input.value = dayButton.dataset.day ?? '';
|
|
115
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
116
|
+
draw();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
input.addEventListener('change', draw);
|
|
120
|
+
draw();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The times by the part of the day, one part showing at a time. The element
|
|
125
|
+
* writes the pills; this sorts them as they arrive. A pill the element has
|
|
126
|
+
* chosen decides which part shows.
|
|
127
|
+
*
|
|
128
|
+
* @param {HTMLFormElement} form
|
|
129
|
+
* @param {Record<string, string>} words
|
|
130
|
+
*/
|
|
131
|
+
export function partsOfDay(form, words) {
|
|
132
|
+
const slots = /** @type {HTMLElement | null} */ (form.querySelector('[data-slots]'));
|
|
133
|
+
if (!slots || slots.dataset.parts === '1') return;
|
|
134
|
+
slots.dataset.parts = '1';
|
|
135
|
+
const tabs = document.createElement('div');
|
|
136
|
+
tabs.className = 'booking__parts';
|
|
137
|
+
tabs.setAttribute('role', 'tablist');
|
|
138
|
+
tabs.hidden = true;
|
|
139
|
+
slots.insertAdjacentElement('beforebegin', tabs);
|
|
140
|
+
const timeOf = (pill) => to24((pill.textContent ?? '').trim()).slice(0, 5);
|
|
141
|
+
const hourOf = (pill) => Number(timeOf(pill).slice(0, 2));
|
|
142
|
+
/** @type {WeakMap<HTMLElement, string>} */
|
|
143
|
+
const parts = new WeakMap();
|
|
144
|
+
let current = '';
|
|
145
|
+
|
|
146
|
+
const show = () => {
|
|
147
|
+
for (const pill of /** @type {NodeListOf<HTMLElement>} */ (slots.querySelectorAll('.booking__slot'))) {
|
|
148
|
+
pill.hidden = current !== '' && parts.get(pill) !== current;
|
|
149
|
+
}
|
|
150
|
+
for (const tab of /** @type {NodeListOf<HTMLElement>} */ (tabs.querySelectorAll('.booking__part'))) {
|
|
151
|
+
tab.classList.toggle('is-current', tab.dataset.part === current);
|
|
152
|
+
tab.setAttribute('aria-selected', String(tab.dataset.part === current));
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const sort = () => {
|
|
156
|
+
const pills = /** @type {HTMLElement[]} */ ([...slots.querySelectorAll('.booking__slot')]);
|
|
157
|
+
// A time on the pill is the platform's clock in the site's language;
|
|
158
|
+
// the hour read off it on 24 hours whatever that clock says.
|
|
159
|
+
const sorted = partsOf(pills.map(hourOf));
|
|
160
|
+
pills.forEach((pill, i) => parts.set(pill, sorted[i]));
|
|
161
|
+
const have = new Set(sorted);
|
|
162
|
+
const chosen = pills.find((pill) => pill.classList.contains('is-chosen'));
|
|
163
|
+
tabs.innerHTML = PARTS.filter((part) => have.has(part))
|
|
164
|
+
.map((part) => {
|
|
165
|
+
const own = pills.filter((pill) => parts.get(pill) === part).map(timeOf);
|
|
166
|
+
return `<button type="button" class="booking__part" role="tab" data-part="${part}">${words[part] ?? part}<span class="booking__part-n">${own[0]}–${own[own.length - 1]}</span></button>`;
|
|
167
|
+
})
|
|
168
|
+
.join('');
|
|
169
|
+
tabs.hidden = have.size < 2;
|
|
170
|
+
current = chosen ? parts.get(chosen) ?? '' : have.has(/** @type {never} */ (current)) ? current : [...have][0] ?? '';
|
|
171
|
+
show();
|
|
172
|
+
};
|
|
173
|
+
tabs.addEventListener('click', (event) => {
|
|
174
|
+
const tab = event.target instanceof Element ? event.target.closest('[data-part]') : null;
|
|
175
|
+
if (!(tab instanceof HTMLElement)) return;
|
|
176
|
+
current = tab.dataset.part ?? '';
|
|
177
|
+
show();
|
|
178
|
+
});
|
|
179
|
+
new MutationObserver(sort).observe(slots, { childList: true });
|
|
180
|
+
sort();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The form in two steps: the services, whom, the day and the time first; the
|
|
185
|
+
* name and the rest once those are chosen, with the choice summarised beside
|
|
186
|
+
* them and a way back. One form throughout — every field keeps its place,
|
|
187
|
+
* the element keeps reading them — so with scripts off it is the one form,
|
|
188
|
+
* whole. The success is a step of its own: the element shows its box, this
|
|
189
|
+
* takes the steps and the summary away.
|
|
190
|
+
*
|
|
191
|
+
* @param {HTMLFormElement} form
|
|
192
|
+
* @param {Record<string, string>} words
|
|
193
|
+
* @param {string} lang
|
|
194
|
+
*/
|
|
195
|
+
export function twoSteps(form, words, lang) {
|
|
196
|
+
if (form.dataset.steps === '1') return;
|
|
197
|
+
form.dataset.steps = '1';
|
|
198
|
+
const firstHalf = new Set(['service', 'resource', 'day', 'back', 'hours', 'party', 'quantity', 'class']);
|
|
199
|
+
for (const child of /** @type {HTMLElement[]} */ ([...form.children])) {
|
|
200
|
+
const control = child.querySelector('input, select, textarea');
|
|
201
|
+
const name = control instanceof HTMLInputElement || control instanceof HTMLSelectElement || control instanceof HTMLTextAreaElement ? control.name : '';
|
|
202
|
+
const first = child.matches('[data-times]') || firstHalf.has(name) || child.tagName === 'NOSCRIPT' || child.matches('[data-done]');
|
|
203
|
+
child.dataset.step = first ? '1' : '2';
|
|
204
|
+
}
|
|
205
|
+
for (const el of form.querySelectorAll('[data-done], [data-booking-error]')) /** @type {HTMLElement} */ (el).dataset.step = 'both';
|
|
206
|
+
|
|
207
|
+
const stepper = document.createElement('ol');
|
|
208
|
+
stepper.className = 'booking__stepper';
|
|
209
|
+
stepper.innerHTML = [words.stepWhat ?? 'What and when', words.stepWho ?? 'Who is coming']
|
|
210
|
+
.map((name, i) => `<li class="booking__step" data-at="${i + 1}"><span class="booking__step-n">${i + 1}</span>${name}</li>`)
|
|
211
|
+
.join('');
|
|
212
|
+
form.prepend(stepper);
|
|
213
|
+
|
|
214
|
+
const next = document.createElement('button');
|
|
215
|
+
next.type = 'button';
|
|
216
|
+
next.className = 'booking__next';
|
|
217
|
+
next.dataset.step = '1';
|
|
218
|
+
next.textContent = words.next ?? 'Continue';
|
|
219
|
+
const need = document.createElement('p');
|
|
220
|
+
need.className = 'booking__need';
|
|
221
|
+
need.dataset.step = '1';
|
|
222
|
+
need.hidden = true;
|
|
223
|
+
need.textContent = words.needAll ?? 'Pick a service, a day and a time first.';
|
|
224
|
+
const times = form.querySelector('[data-times]');
|
|
225
|
+
times?.insertAdjacentElement('afterend', need);
|
|
226
|
+
need.insertAdjacentElement('afterend', next);
|
|
227
|
+
|
|
228
|
+
const summary = document.createElement('div');
|
|
229
|
+
summary.className = 'booking__summary';
|
|
230
|
+
summary.dataset.step = '2';
|
|
231
|
+
form.insertBefore(summary, form.querySelector('[data-step="2"]'));
|
|
232
|
+
|
|
233
|
+
const chosen = () => {
|
|
234
|
+
const names = /** @type {HTMLInputElement[]} */ ([...form.querySelectorAll('input[name=service]:checked')])
|
|
235
|
+
.map((box) => box.closest('.booking__service')?.querySelector('.booking__service-name')?.textContent?.trim() ?? box.value);
|
|
236
|
+
const single = /** @type {HTMLSelectElement | null} */ (form.querySelector('select[name=service]'));
|
|
237
|
+
if (single) names.push(single.options[single.selectedIndex]?.textContent?.trim() ?? '');
|
|
238
|
+
const who = /** @type {HTMLSelectElement | null} */ (form.querySelector('select[name=resource]'));
|
|
239
|
+
const dayValue = /** @type {HTMLInputElement | null} */ (form.querySelector('input[name=day]'))?.value ?? '';
|
|
240
|
+
const dayDate = parseDay(dayValue);
|
|
241
|
+
const slot = /** @type {HTMLElement | null} */ (form.querySelector('.booking__slot.is-chosen'));
|
|
242
|
+
const ends = /** @type {HTMLElement | null} */ (form.querySelector('[data-ends]'));
|
|
243
|
+
return {
|
|
244
|
+
services: names.filter(Boolean),
|
|
245
|
+
who: who ? who.options[who.selectedIndex]?.textContent?.trim() ?? '' : '',
|
|
246
|
+
day: dayDate ? new Intl.DateTimeFormat(lang, { weekday: 'short', day: 'numeric', month: 'long' }).format(dayDate) : dayValue,
|
|
247
|
+
time: slot ? to24((slot.textContent ?? '').trim()).split('·')[0].trim() : '',
|
|
248
|
+
until: ends && !ends.hidden ? ends.dataset.clock ?? '' : '',
|
|
249
|
+
takes: ends && !ends.hidden ? ends.dataset.length ?? '' : '',
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const stage = (to) => {
|
|
254
|
+
// A move between the steps brings the form into view; the first stage,
|
|
255
|
+
// set as the page loads, must not — it scrolled every visitor to the
|
|
256
|
+
// form the moment the page opened (10 October 2026).
|
|
257
|
+
const moved = form.dataset.stage !== undefined && form.dataset.stage !== to;
|
|
258
|
+
form.dataset.stage = to;
|
|
259
|
+
for (const step of /** @type {NodeListOf<HTMLElement>} */ (stepper.querySelectorAll('.booking__step'))) {
|
|
260
|
+
step.classList.toggle('is-current', step.dataset.at === to);
|
|
261
|
+
step.classList.toggle('is-done', to === 'done' || Number(step.dataset.at) < Number(to));
|
|
262
|
+
}
|
|
263
|
+
if (to === '2') {
|
|
264
|
+
const c = chosen();
|
|
265
|
+
const row = (label, value) => (value ? `<div class="booking__summary-row"><dt>${label}</dt><dd>${value}</dd></div>` : '');
|
|
266
|
+
summary.innerHTML = `
|
|
267
|
+
<p class="booking__summary-label">${words.summary ?? 'Your booking'}</p>
|
|
268
|
+
<dl class="booking__summary-list">
|
|
269
|
+
${row(words.what ?? 'What', c.services.join(', '))}
|
|
270
|
+
${row(words.resource ?? 'With', c.who)}
|
|
271
|
+
${row(words.when ?? 'When', `${c.day}${c.time ? `, ${c.time}${c.until ? `–${c.until}` : ''}` : ''}`)}
|
|
272
|
+
${row(words.takes ?? 'Takes', c.takes)}
|
|
273
|
+
</dl>
|
|
274
|
+
<button type="button" class="booking__back">${words.change ?? 'Change'}</button>`;
|
|
275
|
+
summary.querySelector('.booking__back')?.addEventListener('click', () => stage('1'));
|
|
276
|
+
/** @type {HTMLInputElement | null} */ (form.querySelector('input[name=name]'))?.focus({ preventScroll: true });
|
|
277
|
+
}
|
|
278
|
+
if (moved && to !== 'done') form.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
next.addEventListener('click', () => {
|
|
282
|
+
const c = chosen();
|
|
283
|
+
const ready = c.services.length > 0 && /** @type {HTMLInputElement | null} */ (form.querySelector('input[name=day]'))?.value && c.time;
|
|
284
|
+
need.hidden = Boolean(ready);
|
|
285
|
+
if (ready) stage('2');
|
|
286
|
+
});
|
|
287
|
+
form.addEventListener('change', () => {
|
|
288
|
+
if (form.dataset.stage === '2') stage('2');
|
|
289
|
+
});
|
|
290
|
+
const done = /** @type {HTMLElement | null} */ (form.querySelector('[data-done]'));
|
|
291
|
+
if (done) {
|
|
292
|
+
const settle = () => {
|
|
293
|
+
if (done.hidden || form.dataset.stage === 'done') return;
|
|
294
|
+
stage('done');
|
|
295
|
+
form.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
|
296
|
+
};
|
|
297
|
+
new MutationObserver(settle).observe(done, { attributes: true, attributeFilter: ['hidden'] });
|
|
298
|
+
settle();
|
|
299
|
+
}
|
|
300
|
+
stage('1');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* A `<select>` as a drawn list: the native one stays, hidden, and is what
|
|
305
|
+
* the form reads; its options are mirrored as they change (the element
|
|
306
|
+
* rewrites whom from every availability answer), and a pick sets the native
|
|
307
|
+
* value and fires `change`.
|
|
308
|
+
*
|
|
309
|
+
* @param {HTMLSelectElement} select
|
|
310
|
+
*/
|
|
311
|
+
export function drawnSelect(select) {
|
|
312
|
+
if (select.dataset.drawn === '1') return;
|
|
313
|
+
select.dataset.drawn = '1';
|
|
314
|
+
const root = document.createElement('div');
|
|
315
|
+
root.className = 'booking__dd';
|
|
316
|
+
const trigger = document.createElement('button');
|
|
317
|
+
trigger.type = 'button';
|
|
318
|
+
trigger.className = 'booking__dd-trigger';
|
|
319
|
+
trigger.setAttribute('aria-haspopup', 'listbox');
|
|
320
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
321
|
+
const list = document.createElement('div');
|
|
322
|
+
list.className = 'booking__dd-list';
|
|
323
|
+
list.setAttribute('role', 'listbox');
|
|
324
|
+
list.hidden = true;
|
|
325
|
+
root.append(trigger, list);
|
|
326
|
+
select.classList.add('is-replaced');
|
|
327
|
+
select.setAttribute('aria-hidden', 'true');
|
|
328
|
+
select.tabIndex = -1;
|
|
329
|
+
select.insertAdjacentElement('afterend', root);
|
|
330
|
+
const label = select.labels?.[0];
|
|
331
|
+
if (label) trigger.setAttribute('aria-label', label.textContent?.trim() ?? '');
|
|
332
|
+
|
|
333
|
+
const draw = () => {
|
|
334
|
+
const current = select.options[select.selectedIndex];
|
|
335
|
+
trigger.innerHTML = `<span class="booking__dd-value">${current?.textContent ?? ''}</span><span class="booking__dd-mark" aria-hidden="true">▾</span>`;
|
|
336
|
+
list.innerHTML = [...select.options]
|
|
337
|
+
.map((o) => `<button type="button" class="booking__dd-option${o.selected ? ' is-chosen' : ''}" role="option" aria-selected="${o.selected}" data-value="${o.value.replace(/"/g, '"')}"${o.disabled ? ' disabled' : ''}>${o.textContent}</button>`)
|
|
338
|
+
.join('');
|
|
339
|
+
};
|
|
340
|
+
const open = (yes) => {
|
|
341
|
+
list.hidden = !yes;
|
|
342
|
+
trigger.setAttribute('aria-expanded', String(yes));
|
|
343
|
+
if (yes) /** @type {HTMLElement | null} */ (list.querySelector('.is-chosen:not(:disabled), .booking__dd-option:not(:disabled)'))?.focus();
|
|
344
|
+
};
|
|
345
|
+
trigger.addEventListener('click', () => open(list.hidden));
|
|
346
|
+
list.addEventListener('click', (event) => {
|
|
347
|
+
const option = event.target instanceof Element ? event.target.closest('[data-value]') : null;
|
|
348
|
+
if (!(option instanceof HTMLButtonElement) || option.disabled) return;
|
|
349
|
+
select.value = option.dataset.value ?? '';
|
|
350
|
+
select.dispatchEvent(new Event('change', { bubbles: true }));
|
|
351
|
+
draw();
|
|
352
|
+
open(false);
|
|
353
|
+
trigger.focus();
|
|
354
|
+
});
|
|
355
|
+
root.addEventListener('keydown', (event) => {
|
|
356
|
+
if (event.key === 'Escape') { open(false); trigger.focus(); }
|
|
357
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
358
|
+
event.preventDefault();
|
|
359
|
+
if (list.hidden) { open(true); return; }
|
|
360
|
+
const options = /** @type {HTMLButtonElement[]} */ ([...list.querySelectorAll('.booking__dd-option:not(:disabled)')]);
|
|
361
|
+
const at = options.indexOf(/** @type {HTMLButtonElement} */ (document.activeElement));
|
|
362
|
+
options[(at + (event.key === 'ArrowDown' ? 1 : options.length - 1)) % options.length]?.focus();
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
document.addEventListener('click', (event) => {
|
|
366
|
+
if (!list.hidden && event.target instanceof Node && !root.contains(event.target)) open(false);
|
|
367
|
+
});
|
|
368
|
+
new MutationObserver(draw).observe(select, { childList: true, subtree: true, attributes: true });
|
|
369
|
+
select.addEventListener('change', draw);
|
|
370
|
+
draw();
|
|
371
|
+
}
|