@jtakeit/kit 0.4.0 → 0.5.2
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 +181 -10
- 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/elements/booking.js
CHANGED
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
* says which was taken.
|
|
46
46
|
*/
|
|
47
47
|
import { money } from '../lib/money.mjs';
|
|
48
|
+
import { wordsFor } from '../lib/booking/words.mjs';
|
|
49
|
+
import { dressBooking } from './booking-ui.js';
|
|
48
50
|
|
|
49
51
|
// The words that depend on the kind of business, as the platform says them:
|
|
50
52
|
// its availability answer carries the form's, in every language, the site's
|
|
@@ -93,7 +95,9 @@ export function wireBooking(form) {
|
|
|
93
95
|
form.dataset.jtkWired = '1';
|
|
94
96
|
|
|
95
97
|
/** @type {Record<string, string>} */
|
|
96
|
-
|
|
98
|
+
// The form's own words under the page's: a form rendered by an older kit
|
|
99
|
+
// carries none of the newer ones.
|
|
100
|
+
const words = { ...wordsFor(form.dataset.locale ?? 'en', form.dataset.scale ?? ''), ...JSON.parse(form.dataset.words ?? '{}') };
|
|
97
101
|
// Days or hours. The page's own setting first; the platform's answer,
|
|
98
102
|
// once there is one, wins — an owner may switch without a rebuild.
|
|
99
103
|
let daily = form.dataset.scale === 'daily';
|
|
@@ -129,6 +133,24 @@ export function wireBooking(form) {
|
|
|
129
133
|
/** @type {HTMLElement} */ const hoursField = q('[data-hours-field]');
|
|
130
134
|
/** @type {HTMLElement} */ const partyField = q('[data-party-field]');
|
|
131
135
|
/** @type {HTMLInputElement} */ const day = q('[name=day]');
|
|
136
|
+
/** @type {HTMLElement | null} */ const ends = q('[data-ends]');
|
|
137
|
+
// The day's floor is the browser's today, not the build's: `min` was
|
|
138
|
+
// written when the site was built, and a site built on Friday is read on
|
|
139
|
+
// Saturday (10 October 2026).
|
|
140
|
+
{
|
|
141
|
+
const n = new Date();
|
|
142
|
+
const today = `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, '0')}-${String(n.getDate()).padStart(2, '0')}`;
|
|
143
|
+
if (!day.min || day.min < today) day.min = today;
|
|
144
|
+
}
|
|
145
|
+
// What each resource does, read off the options before the element
|
|
146
|
+
// rewrites them from the platform's answers.
|
|
147
|
+
/** @type {Map<string, Set<string>>} */
|
|
148
|
+
const does = new Map();
|
|
149
|
+
for (const option of resource?.options ?? []) {
|
|
150
|
+
if (option.dataset.does) does.set(option.value, new Set(option.dataset.does.split(',').filter(Boolean)));
|
|
151
|
+
}
|
|
152
|
+
/** @type {Set<HTMLInputElement | HTMLOptionElement>} */
|
|
153
|
+
const offByMe = new Set();
|
|
132
154
|
/** @type {HTMLElement} */ const slots = q('[data-slots]');
|
|
133
155
|
/** @type {HTMLElement} */ const hint = q('[data-times-hint]');
|
|
134
156
|
/** @type {HTMLElement} */ const error = q('[data-booking-error]');
|
|
@@ -181,12 +203,24 @@ export function wireBooking(form) {
|
|
|
181
203
|
const offered = new Map();
|
|
182
204
|
/** @type {Record<string, number>} */ let prices = {};
|
|
183
205
|
/** @type {Record<string, string>} */ let classOf = {};
|
|
184
|
-
|
|
206
|
+
// The site's, from the page, and then the platform's own answer below: no
|
|
207
|
+
// sign made up while it is asked (11 October 2026).
|
|
208
|
+
let currency = form.dataset.currency || '';
|
|
185
209
|
|
|
186
210
|
/** @type {string | null} */ let chosen = null;
|
|
187
211
|
let zone = '';
|
|
188
212
|
let turnstileToken = '';
|
|
189
213
|
|
|
214
|
+
/** The page's own scheme — `color-scheme` on the root — or the browser's. */
|
|
215
|
+
function turnstileTheme() {
|
|
216
|
+
const said = form.dataset.turnstileTheme;
|
|
217
|
+
if (said === 'light' || said === 'dark' || said === 'auto') return said;
|
|
218
|
+
const scheme = getComputedStyle(document.documentElement).colorScheme || '';
|
|
219
|
+
if (/\blight\b/.test(scheme) && !/\bdark\b/.test(scheme)) return 'light';
|
|
220
|
+
if (/\bdark\b/.test(scheme) && !/\blight\b/.test(scheme)) return 'dark';
|
|
221
|
+
return 'auto';
|
|
222
|
+
}
|
|
223
|
+
|
|
190
224
|
// The widget, if this host has one. Asked at runtime so the site carries
|
|
191
225
|
// no key: which widget covers a host is the platform's to decide.
|
|
192
226
|
void fetch(api + 'turnstile', { cache: 'no-store' })
|
|
@@ -200,6 +234,12 @@ export function wireBooking(form) {
|
|
|
200
234
|
script.onload = () => {
|
|
201
235
|
window.turnstile?.render(challenge, {
|
|
202
236
|
sitekey: data.sitekey,
|
|
237
|
+
// The widget in the page's colours and language, not the
|
|
238
|
+
// browser's: a light site showed a dark box saying «Успешно» to
|
|
239
|
+
// a visitor whose browser was dark and Russian (10 October 2026).
|
|
240
|
+
// `data-turnstile-theme` on the form says otherwise.
|
|
241
|
+
theme: turnstileTheme(),
|
|
242
|
+
language: pageLang(),
|
|
203
243
|
callback: (token) => { turnstileToken = token; },
|
|
204
244
|
'expired-callback': () => { turnstileToken = ''; },
|
|
205
245
|
});
|
|
@@ -302,11 +342,13 @@ export function wireBooking(form) {
|
|
|
302
342
|
return midnight ? dayOf(iso, long) : `${dayOf(iso, long)}, ${clock(iso)}`;
|
|
303
343
|
}
|
|
304
344
|
|
|
345
|
+
// On a 24-hour clock in every language: the sites are Swiss, German and
|
|
346
|
+
// Ukrainian, and «11:15 AM» on a Zürich salon's page reads as foreign.
|
|
305
347
|
/** @param {string} iso */
|
|
306
348
|
function clock(iso) {
|
|
307
349
|
try {
|
|
308
350
|
return new Intl.DateTimeFormat(pageLang(), {
|
|
309
|
-
hour: '2-digit', minute: '2-digit', timeZone: zone || undefined,
|
|
351
|
+
hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: zone || undefined,
|
|
310
352
|
}).format(new Date(iso));
|
|
311
353
|
} catch {
|
|
312
354
|
return iso.slice(11, 16);
|
|
@@ -430,6 +472,55 @@ export function wireBooking(form) {
|
|
|
430
472
|
if ([...resource.options].some((o) => o.value === was)) resource.value = was;
|
|
431
473
|
}
|
|
432
474
|
|
|
475
|
+
/**
|
|
476
|
+
* A resource chosen: the services they do not do go grey and come
|
|
477
|
+
* unticked, with their name on them («not with Noa»); «anyone» brings
|
|
478
|
+
* them all back. The other direction — a service chosen, the resources
|
|
479
|
+
* who do not do it — the platform's answer says (`performs`).
|
|
480
|
+
*/
|
|
481
|
+
function greyByResource() {
|
|
482
|
+
const who = resource?.value ?? '';
|
|
483
|
+
const option = resource?.options[resource.selectedIndex];
|
|
484
|
+
const name = (option?.textContent ?? '').split(' · ')[0].trim();
|
|
485
|
+
const can = who && !who.startsWith('class:') && !who.startsWith('empty:') && does.has(who) ? does.get(who) : null;
|
|
486
|
+
let moved = false;
|
|
487
|
+
for (const box of boxes) {
|
|
488
|
+
const row = box.closest('.booking__service');
|
|
489
|
+
const off = can !== null && !can.has(box.value);
|
|
490
|
+
if (off && !box.disabled) {
|
|
491
|
+
box.disabled = true;
|
|
492
|
+
offByMe.add(box);
|
|
493
|
+
if (box.checked) { box.checked = false; moved = true; }
|
|
494
|
+
} else if (!off && offByMe.has(box)) {
|
|
495
|
+
box.disabled = false;
|
|
496
|
+
offByMe.delete(box);
|
|
497
|
+
}
|
|
498
|
+
row?.classList.toggle('is-off', offByMe.has(box));
|
|
499
|
+
let note = row?.querySelector('.booking__service-off') ?? null;
|
|
500
|
+
if (offByMe.has(box) && row) {
|
|
501
|
+
const nameEl = row.querySelector('.booking__service-name');
|
|
502
|
+
if (!note && nameEl) {
|
|
503
|
+
note = document.createElement('span');
|
|
504
|
+
note.className = 'booking__service-off';
|
|
505
|
+
nameEl.insertAdjacentElement('afterend', note);
|
|
506
|
+
}
|
|
507
|
+
if (note) note.textContent = words.notWith.replace('{who}', name.split(' ')[0] || name);
|
|
508
|
+
} else {
|
|
509
|
+
note?.remove();
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
for (const o of select?.options ?? []) {
|
|
513
|
+
const off = can !== null && !can.has(o.value);
|
|
514
|
+
if (off && !o.disabled) { o.disabled = true; offByMe.add(o); }
|
|
515
|
+
else if (!off && offByMe.has(o)) { o.disabled = false; offByMe.delete(o); }
|
|
516
|
+
}
|
|
517
|
+
if (select && select.options[select.selectedIndex]?.disabled) {
|
|
518
|
+
const next = [...select.options].find((o) => !o.disabled);
|
|
519
|
+
if (next) { select.value = next.value; moved = true; }
|
|
520
|
+
}
|
|
521
|
+
if (moved) showTotal();
|
|
522
|
+
}
|
|
523
|
+
|
|
433
524
|
// The resource chosen, if it does not do what is asked: its name, for
|
|
434
525
|
// saying so in place of «nothing free on this day».
|
|
435
526
|
/** @param {{ slug: string; title: string; performs?: boolean }[]} resources */
|
|
@@ -467,11 +558,26 @@ export function wireBooking(form) {
|
|
|
467
558
|
return moved;
|
|
468
559
|
}
|
|
469
560
|
|
|
561
|
+
// The last answer's choices, so that a form with nothing picked any more
|
|
562
|
+
// can offer every master again: «not for this choice» is about a choice,
|
|
563
|
+
// and it went on reading that way after the choice was taken back
|
|
564
|
+
// (9 October 2026).
|
|
565
|
+
let lastClasses = [];
|
|
566
|
+
let lastResources = [];
|
|
567
|
+
|
|
470
568
|
async function load() {
|
|
471
569
|
const mine = ++asking;
|
|
570
|
+
// The start picked before this reload: picked again if it is still
|
|
571
|
+
// offered — a service added, the same time — and said gone if not.
|
|
572
|
+
const wanted = chosen;
|
|
472
573
|
chosen = null;
|
|
473
574
|
slots.textContent = '';
|
|
474
|
-
if (
|
|
575
|
+
if (ends) ends.hidden = true;
|
|
576
|
+
if (!day.value || picked().length === 0) {
|
|
577
|
+
hint.textContent = picked().length === 0 ? words.pickService : words.pick;
|
|
578
|
+
if (lastResources.length > 0) offerChoices(lastClasses, lastResources.map((one) => ({ ...one, performs: true })), []);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
475
581
|
hint.textContent = '…';
|
|
476
582
|
|
|
477
583
|
const from = new Date(day.value + 'T00:00:00');
|
|
@@ -495,7 +601,9 @@ export function wireBooking(form) {
|
|
|
495
601
|
if (mine !== asking) return;
|
|
496
602
|
const data = await answer.json();
|
|
497
603
|
hear(data.words);
|
|
498
|
-
|
|
604
|
+
lastClasses = data.classes ?? [];
|
|
605
|
+
lastResources = data.resources ?? [];
|
|
606
|
+
offerChoices(lastClasses, lastResources, data.empty_classes ?? []);
|
|
499
607
|
if (sayEmptyServices(data.empty_services ?? [])) { void load(); return; }
|
|
500
608
|
zone = data.zone;
|
|
501
609
|
if (typeof data.scale === 'string') daily = data.scale === 'daily';
|
|
@@ -528,6 +636,12 @@ export function wireBooking(form) {
|
|
|
528
636
|
return;
|
|
529
637
|
}
|
|
530
638
|
hint.textContent = '';
|
|
639
|
+
// A price beside each time only where the times differ in price — a
|
|
640
|
+
// rate by the hour (wiki/68). Where every time costs the same, the
|
|
641
|
+
// price is the services' and is said beside them: «09:00 · CHF 95» on
|
|
642
|
+
// thirty pills is one number read thirty times (11 October 2026).
|
|
643
|
+
const slotPrices = new Set(data.slots.map((one) => one.costs_minor ?? 0));
|
|
644
|
+
const pricedByTime = slotPrices.size > 1;
|
|
531
645
|
for (const slot of data.slots) {
|
|
532
646
|
const button = document.createElement('button');
|
|
533
647
|
button.type = 'button';
|
|
@@ -538,10 +652,13 @@ export function wireBooking(form) {
|
|
|
538
652
|
? slot.ends ? `${dayAndHour(slot.start, false)} — ${dayAndHour(slot.ends, false)}` : dayAndHour(slot.start, false)
|
|
539
653
|
: clock(slot.start);
|
|
540
654
|
// What the booking starting here costs — the platform's number, every
|
|
541
|
-
// hour by its own row (wiki/68) — beside the time,
|
|
542
|
-
if (slot.costs_minor > 0) button.textContent += ` · ${money(slot.costs_minor, currency, pageLang())}`;
|
|
655
|
+
// hour by its own row (wiki/68) — beside the time, where it varies.
|
|
656
|
+
if (pricedByTime && slot.costs_minor > 0) button.textContent += ` · ${money(slot.costs_minor, currency, pageLang())}`;
|
|
657
|
+
button.dataset.start = slot.start;
|
|
543
658
|
button.addEventListener('click', () => {
|
|
544
659
|
chosen = slot.start;
|
|
660
|
+
if (hint.textContent === words.retime) hint.textContent = '';
|
|
661
|
+
sayEnds(slot);
|
|
545
662
|
offerQuantity(data.quantity_max ?? 1, (slot.free ?? []).length);
|
|
546
663
|
for (const other of slots.querySelectorAll('.booking__slot')) {
|
|
547
664
|
other.classList.remove('is-chosen');
|
|
@@ -554,19 +671,48 @@ export function wireBooking(form) {
|
|
|
554
671
|
slots.appendChild(button);
|
|
555
672
|
}
|
|
556
673
|
if (stay() && data.slots.length === 1) /** @type {HTMLButtonElement | null} */ (slots.firstElementChild)?.click();
|
|
674
|
+
else if (wanted) {
|
|
675
|
+
const same = /** @type {HTMLButtonElement | null} */ (slots.querySelector(`[data-start="${CSS.escape(wanted)}"]`));
|
|
676
|
+
if (same) same.click();
|
|
677
|
+
else hint.textContent = words.retime;
|
|
678
|
+
}
|
|
557
679
|
} catch (err) {
|
|
558
|
-
|
|
680
|
+
// The platform's sentence, where it sent one; never a whole 404 page
|
|
681
|
+
// (the preview's slug missing from the address, 10 October 2026).
|
|
682
|
+
const said = err instanceof Error ? err.message : '';
|
|
683
|
+
hint.textContent = said && said.length <= 200 && !/<[a-z!]/i.test(said) ? said : words.failed;
|
|
559
684
|
}
|
|
560
685
|
}
|
|
561
686
|
|
|
687
|
+
/**
|
|
688
|
+
* When the visit ends — the platform's own `ends`, said under the times:
|
|
689
|
+
* «Until about 10:45 · 1 h 15 min». Nothing by the day, which says its
|
|
690
|
+
* days on the pill.
|
|
691
|
+
* @param {{ start: string; ends?: string }} slot
|
|
692
|
+
*/
|
|
693
|
+
function sayEnds(slot) {
|
|
694
|
+
if (!ends || daily || !slot.ends) return;
|
|
695
|
+
const minutes = Math.round((new Date(slot.ends).getTime() - new Date(slot.start).getTime()) / 60000);
|
|
696
|
+
if (!(minutes > 0)) return;
|
|
697
|
+
ends.dataset.clock = clock(slot.ends);
|
|
698
|
+
ends.dataset.length = lasting(minutes, words);
|
|
699
|
+
ends.textContent = `${words.until.replace('{time}', ends.dataset.clock)} · ${ends.dataset.length}`;
|
|
700
|
+
ends.hidden = false;
|
|
701
|
+
}
|
|
702
|
+
|
|
562
703
|
select?.addEventListener('change', () => { fitHours(); void load(); });
|
|
563
704
|
hours.addEventListener('change', () => void load());
|
|
705
|
+
// The look — the steps, the week, the parts of the day, the drawn choice
|
|
706
|
+
// — over the form, where the form asks for it.
|
|
707
|
+
if (form.dataset.ui) dressBooking(form, words);
|
|
564
708
|
for (const box of boxes) box.addEventListener('change', () => { showTotal(); void load(); });
|
|
565
|
-
resource?.addEventListener('change', () => void load());
|
|
709
|
+
resource?.addEventListener('change', () => { greyByResource(); void load(); });
|
|
710
|
+
greyByResource();
|
|
566
711
|
// A new first date keeps the stay's length: five nights from the 10th
|
|
567
712
|
// become five nights from the 12th, not whatever the old «until» leaves.
|
|
568
713
|
let lastDay = day.value;
|
|
569
714
|
day.addEventListener('change', () => {
|
|
715
|
+
chosen = null; // a new day starts clean
|
|
570
716
|
if (lastDay && back.value && day.value) back.value = plusDays(day.value, daysBetween(lastDay, back.value));
|
|
571
717
|
lastDay = day.value;
|
|
572
718
|
fitBack();
|
|
@@ -657,12 +803,37 @@ export function wireBooking(form) {
|
|
|
657
803
|
}
|
|
658
804
|
if (what) {
|
|
659
805
|
/** @type {HTMLElement} */ const line = q('[data-done-what]');
|
|
660
|
-
|
|
806
|
+
// A person by name is «With Noa»; a class or a group is what was taken.
|
|
807
|
+
const byName = !made.class && !(made.group && made.group.quantity > 1) && resourceChosen();
|
|
808
|
+
line.textContent = byName ? words.withNamed.replace('{who}', what) : words.took.replace('{what}', what);
|
|
661
809
|
line.hidden = false;
|
|
662
810
|
}
|
|
663
811
|
|
|
664
812
|
/** @type {HTMLAnchorElement} */ const manage = q('[data-manage]');
|
|
665
813
|
if (made.manage_path) { manage.href = made.manage_path; manage.textContent = words.manage; manage.hidden = false; }
|
|
814
|
+
// The link is the booking's only key: shown whole, with a way to copy
|
|
815
|
+
// it and a sentence saying so, where the form has the box for it.
|
|
816
|
+
/** @type {HTMLElement | null} */ const keep = q('[data-keep]');
|
|
817
|
+
if (keep && made.manage_path) {
|
|
818
|
+
const href = new URL(made.manage_path, window.location.href).href;
|
|
819
|
+
/** @type {HTMLInputElement} */ const link = q('[data-keep-link]');
|
|
820
|
+
/** @type {HTMLButtonElement} */ const copy = q('[data-keep-copy]');
|
|
821
|
+
/** @type {HTMLAnchorElement} */ const open = q('[data-keep-open]');
|
|
822
|
+
q('[data-keep-note]').textContent = words.keep;
|
|
823
|
+
link.value = href;
|
|
824
|
+
link.setAttribute('aria-label', words.manage);
|
|
825
|
+
link.addEventListener('focus', () => link.select());
|
|
826
|
+
copy.textContent = words.copy;
|
|
827
|
+
copy.addEventListener('click', async () => {
|
|
828
|
+
try { await navigator.clipboard.writeText(href); } catch { link.select(); document.execCommand('copy'); }
|
|
829
|
+
copy.textContent = words.copied;
|
|
830
|
+
setTimeout(() => { copy.textContent = words.copy; }, 1600);
|
|
831
|
+
});
|
|
832
|
+
open.href = href;
|
|
833
|
+
open.textContent = words.open;
|
|
834
|
+
keep.hidden = false;
|
|
835
|
+
manage.hidden = true;
|
|
836
|
+
}
|
|
666
837
|
/** @type {HTMLAnchorElement} */ const tg = q('[data-telegram]');
|
|
667
838
|
if (made.telegram_url) { tg.href = made.telegram_url; tg.textContent = words.telegram; tg.hidden = false; }
|
|
668
839
|
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's own card: where the site is, bottom right, on the dev server
|
|
3
|
+
* only (jtakeit-core, wiki/73 · §7).
|
|
4
|
+
*
|
|
5
|
+
* A scaffold just made is an empty page, and a developer looking at
|
|
6
|
+
* localhost while the agent writes sees nothing of what is happening. This
|
|
7
|
+
* card says it on the page itself: the seven phases the panel's «What the
|
|
8
|
+
* agent is doing» shows, by the same rules (lib/phases.mjs), and — through
|
|
9
|
+
* the dev session — the steps of the panel's setting up the developer can
|
|
10
|
+
* take meanwhile. It reads `/_jtk/state.json` from the dev server, its own
|
|
11
|
+
* origin, every three seconds; nothing crosses an origin and nothing is
|
|
12
|
+
* declared. The scaffold's integration puts it on every page under `astro
|
|
13
|
+
* dev` and on none of a build.
|
|
14
|
+
*
|
|
15
|
+
* Collapsed, it is a pill with the phase; a press opens it; the choice is
|
|
16
|
+
* remembered in this browser.
|
|
17
|
+
*/
|
|
18
|
+
import { PHASES, PHASE_NAMES, phasesOf, sayPhase, meanwhile } from '../lib/phases.mjs';
|
|
19
|
+
|
|
20
|
+
const STORE = 'jtk-writing:collapsed';
|
|
21
|
+
|
|
22
|
+
const CSS = `
|
|
23
|
+
:host{position:fixed;right:16px;bottom:16px;z-index:2147483000;font:13px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased;
|
|
24
|
+
--bg:#fbfaf7;--ink:#1d1b17;--ink2:#3f3b33;--muted:#6b665c;--faint:#9b958a;--line:#e6e1d6;--line2:#d8d2c4;--ok:#2f7d5a;--attn:#9a6b1c;--shadow:0 12px 36px rgba(30,25,15,.16),0 2px 6px rgba(30,25,15,.08)}
|
|
25
|
+
@media (prefers-color-scheme:dark){:host{--bg:#1c1b18;--ink:#f1ede5;--ink2:#d9d3c7;--muted:#a8a295;--faint:#716c62;--line:#33302a;--line2:#443f36;--ok:#5bb38a;--attn:#d4a24a;--shadow:0 12px 36px rgba(0,0,0,.5)}}
|
|
26
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
27
|
+
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
|
28
|
+
a{color:var(--ink);text-decoration:underline;text-decoration-color:var(--line2);text-underline-offset:3px}
|
|
29
|
+
a:hover{text-decoration-color:var(--ink)}
|
|
30
|
+
.pill{display:flex;align-items:center;gap:10px;background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:999px;padding:9px 14px 9px 12px;box-shadow:var(--shadow);transition:transform .18s ease}
|
|
31
|
+
.pill:hover{transform:translateY(-1px)}
|
|
32
|
+
.mini{display:flex;gap:3px}.mini i{display:block;width:7px;height:7px;border-radius:999px;background:var(--line2)}
|
|
33
|
+
.pill .dev{font-size:10px;color:var(--faint);margin-left:2px}
|
|
34
|
+
.devnote{margin-top:8px;color:var(--faint);font-size:10.5px}
|
|
35
|
+
.card{width:min(372px,calc(100vw - 32px));background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:16px;box-shadow:var(--shadow);padding:14px 16px 12px}
|
|
36
|
+
.card.enter{animation:in .22s cubic-bezier(.22,.61,.36,1)}
|
|
37
|
+
@keyframes in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
|
38
|
+
.head{display:flex;align-items:baseline;gap:10px}.title{font-weight:600;font-size:14px}.count{margin-left:auto;color:var(--muted);font-variant-numeric:tabular-nums;font-size:12px}
|
|
39
|
+
.hide{color:var(--faint);line-height:1;padding:2px 4px;border-radius:6px;margin-right:-4px}.hide:hover{color:var(--ink);background:var(--line)}
|
|
40
|
+
.strip{display:flex;gap:4px;margin-top:12px;list-style:none}.seg{flex:1;height:5px;border-radius:999px;background:var(--line2)}
|
|
41
|
+
.seg.done,.mini i.done{background:var(--ok)}.seg.current,.mini i.current{background:var(--attn)}.seg.current{animation:pulse 2.4s ease-in-out infinite}
|
|
42
|
+
.seg.unseen{background:transparent;border:1px solid var(--line2)}
|
|
43
|
+
@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){.seg.current,.card.enter{animation:none}}
|
|
44
|
+
.names{display:flex;gap:4px;margin-top:6px}.names span{flex:1;min-width:0;font-size:10px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.names .current{color:var(--ink);font-weight:600}.names .done{color:var(--muted)}
|
|
45
|
+
.say{display:flex;gap:9px;align-items:flex-start;margin-top:12px;font-weight:600;font-size:13.5px;line-height:1.35}.say i{flex:none;width:8px;height:8px;border-radius:999px;background:var(--attn);margin-top:5px}
|
|
46
|
+
.todo{list-style:none;margin:6px 0 0 17px;color:var(--muted);font-size:12.5px}.todo li+li{margin-top:2px}
|
|
47
|
+
.rule{height:1px;background:var(--line);margin:12px 0 10px}
|
|
48
|
+
.label{font-size:10.5px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--faint)}
|
|
49
|
+
.steps{list-style:none;margin-top:6px}.steps li{display:flex;gap:8px;align-items:baseline;font-size:12.5px;color:var(--ink2)}.steps li+li{margin-top:3px}
|
|
50
|
+
.steps .o{flex:none;width:12px;height:12px;border-radius:999px;border:1.5px solid var(--line2);transform:translateY(1.5px)}
|
|
51
|
+
.steps .done{color:var(--faint);text-decoration:line-through}.steps .done .o{border-color:var(--ok);background:var(--ok)}
|
|
52
|
+
.note{margin-top:6px;color:var(--muted);font-size:12.5px}code{font:11.5px ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--line);padding:1px 5px;border-radius:5px}
|
|
53
|
+
.panel{display:inline-block;margin-top:8px;font-size:12.5px}
|
|
54
|
+
.foot{margin-top:10px;color:var(--faint);font-size:11px}
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
58
|
+
|
|
59
|
+
function ago(iso) {
|
|
60
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
61
|
+
if (!Number.isFinite(ms) || ms < 0) return 'just now';
|
|
62
|
+
const m = Math.round(ms / 60000);
|
|
63
|
+
if (m < 1) return 'just now';
|
|
64
|
+
if (m < 60) return `${m} min ago`;
|
|
65
|
+
const h = Math.round(m / 60);
|
|
66
|
+
return h < 24 ? `${h} h ago` : `${Math.round(h / 24)} d ago`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function until(iso) {
|
|
70
|
+
const at = new Date(iso);
|
|
71
|
+
return Number.isNaN(at.getTime()) ? '' : at.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class JtkWriting extends HTMLElement {
|
|
75
|
+
constructor() {
|
|
76
|
+
super();
|
|
77
|
+
this.state = null;
|
|
78
|
+
this.failed = false;
|
|
79
|
+
this.timer = 0;
|
|
80
|
+
// What is on the page now: a poll that changes nothing redraws nothing,
|
|
81
|
+
// so the card does not blink every three seconds — and the entrance
|
|
82
|
+
// animation plays on a press, not on every reading.
|
|
83
|
+
this.shown = '';
|
|
84
|
+
this.enter = false;
|
|
85
|
+
let collapsed = false;
|
|
86
|
+
try { collapsed = localStorage.getItem(STORE) === '1'; } catch { /* a browser without storage shows it open */ }
|
|
87
|
+
this.collapsed = collapsed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
connectedCallback() {
|
|
91
|
+
const root = this.attachShadow({ mode: 'open' });
|
|
92
|
+
const style = document.createElement('style');
|
|
93
|
+
style.textContent = CSS;
|
|
94
|
+
this.box = document.createElement('div');
|
|
95
|
+
root.append(style, this.box);
|
|
96
|
+
root.addEventListener('click', (event) => {
|
|
97
|
+
const target = event.target instanceof Element ? event.target.closest('[data-toggle]') : null;
|
|
98
|
+
if (!target) return;
|
|
99
|
+
this.collapsed = !this.collapsed;
|
|
100
|
+
this.enter = !this.collapsed;
|
|
101
|
+
try { localStorage.setItem(STORE, this.collapsed ? '1' : '0'); } catch { /* fine */ }
|
|
102
|
+
this.render();
|
|
103
|
+
});
|
|
104
|
+
this.render();
|
|
105
|
+
this.poll();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
disconnectedCallback() { clearTimeout(this.timer); }
|
|
109
|
+
|
|
110
|
+
async poll() {
|
|
111
|
+
try {
|
|
112
|
+
const answer = await fetch('/_jtk/state.json', { cache: 'no-store' });
|
|
113
|
+
if (!answer.ok) throw new Error(`the dev server answered ${answer.status}`);
|
|
114
|
+
this.state = await answer.json();
|
|
115
|
+
this.failed = false;
|
|
116
|
+
} catch {
|
|
117
|
+
this.failed = true;
|
|
118
|
+
}
|
|
119
|
+
this.render();
|
|
120
|
+
this.timer = setTimeout(() => this.poll(), this.failed ? 30000 : 3000);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
render() {
|
|
124
|
+
const progress = this.state?.progress ?? null;
|
|
125
|
+
const platform = this.state?.platform ?? null;
|
|
126
|
+
const connected = platform !== null && platform.connected === true;
|
|
127
|
+
const half = connected && platform.error === undefined ? platform : null;
|
|
128
|
+
const where = phasesOf(progress, half);
|
|
129
|
+
const n = where.index + 1;
|
|
130
|
+
|
|
131
|
+
if (this.collapsed) {
|
|
132
|
+
this.show(`<button class="pill" data-toggle aria-expanded="false" aria-label="Where the site is: phase ${n} of 7">
|
|
133
|
+
<span class="mini">${where.phases.map((p) => `<i class="${p.state}"></i>`).join('')}</span>
|
|
134
|
+
<span>Being written · ${n} of 7</span><span class="dev">dev only</span></button>`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const say = sayPhase(where, progress, half);
|
|
139
|
+
const todo = progress?.missing ?? [];
|
|
140
|
+
const steps = meanwhile(half);
|
|
141
|
+
const stepsHTML = half
|
|
142
|
+
? (steps.open.length + steps.done.length === 0
|
|
143
|
+
? `<p class="note">Nothing left in the panel.</p>`
|
|
144
|
+
: `<ul class="steps">${[...steps.open, ...steps.done].map((s) =>
|
|
145
|
+
`<li class="${s.done ? 'done' : ''}"><i class="o"></i><span>${esc(s.name)}</span></li>`).join('')}</ul>`)
|
|
146
|
+
+ (half.panel ? `<a class="panel" href="${esc(half.panel)}" target="_blank" rel="noopener">Open the panel ↗</a>` : '')
|
|
147
|
+
: connected
|
|
148
|
+
? `<p class="note">The platform could not be read: ${esc(platform.error)}</p>`
|
|
149
|
+
: `<p class="note">Not connected to the platform yet — in the working tree: <code>npx jtkit session <session_id> <watch_key></code>, both from the session's answer.</p>`;
|
|
150
|
+
|
|
151
|
+
const foot = [
|
|
152
|
+
this.failed ? 'the dev server did not answer' : progress?.changedAt ? `read from the files, changed ${ago(progress.changedAt)}` : 'read from the files',
|
|
153
|
+
half?.until ? `platform through the session until ${until(half.until)}` : '',
|
|
154
|
+
].filter(Boolean).join(' · ');
|
|
155
|
+
|
|
156
|
+
this.show(`<section class="card${this.enter ? ' enter' : ''}" aria-label="Where the site is">
|
|
157
|
+
<div class="head"><span class="title">What the agent is doing</span><span class="count">phase ${n} of 7</span><button class="hide" data-toggle aria-label="Collapse">–</button></div>
|
|
158
|
+
<ol class="strip" aria-label="The seven phases">${where.phases.map((p, i) => `<li class="seg ${p.state}" title="${i + 1}. ${PHASE_NAMES[p.key]}"></li>`).join('')}</ol>
|
|
159
|
+
<div class="names" aria-hidden="true">${where.phases.map((p, i) => `<span class="${p.state}">${i + 1}. ${PHASE_NAMES[p.key]}</span>`).join('')}</div>
|
|
160
|
+
<p class="say"><i></i><span>${esc(say)}</span></p>
|
|
161
|
+
${todo.length > 0 && where.current !== 'preview'
|
|
162
|
+
? `<ul class="todo">${todo.slice(0, 4).map((line) => `<li>${esc(line)}</li>`).join('')}${todo.length > 4 ? `<li>+ ${todo.length - 4} more — <code>jtkit progress</code></li>` : ''}</ul>`
|
|
163
|
+
: ''}
|
|
164
|
+
<div class="rule"></div>
|
|
165
|
+
<p class="label">Meanwhile, in the panel</p>
|
|
166
|
+
${stepsHTML}
|
|
167
|
+
<p class="foot">${esc(foot)}</p>
|
|
168
|
+
<p class="devnote">Shown on the dev server only, while the site is being written — never in a build.</p>
|
|
169
|
+
</section>`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Put this on the page, unless it is what is there already. */
|
|
173
|
+
show(html) {
|
|
174
|
+
const key = html.replace(' enter', '');
|
|
175
|
+
if (key === this.shown) return;
|
|
176
|
+
this.shown = key;
|
|
177
|
+
this.box.innerHTML = html;
|
|
178
|
+
this.enter = false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!customElements.get('jtk-writing')) customElements.define('jtk-writing', JtkWriting);
|
|
183
|
+
|
|
184
|
+
function mount() {
|
|
185
|
+
if (document.querySelector('jtk-writing')) return;
|
|
186
|
+
document.body.append(document.createElement('jtk-writing'));
|
|
187
|
+
}
|
|
188
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount, { once: true });
|
|
189
|
+
else mount();
|
|
190
|
+
// Astro's view transitions swap the body: put it back.
|
|
191
|
+
document.addEventListener('astro:page-load', mount);
|
|
192
|
+
|
|
193
|
+
export { JtkWriting, PHASES };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The arithmetic behind the form's look, kept pure so it can be tested
|
|
3
|
+
* without a document: which part of the day an hour is, which parts a set
|
|
4
|
+
* of times make, and a clock read on 24 hours.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** «09:15 AM» as «09:15»; «21:00» or anything else as it was. */
|
|
8
|
+
export function to24(text) {
|
|
9
|
+
return String(text ?? '').replace(/\b(\d{1,2}):(\d{2})\s*([AP]M)\b/gi, (_, h, mm, half) => {
|
|
10
|
+
let hour = Number(h);
|
|
11
|
+
if (half.toUpperCase() === 'PM' && hour < 12) hour += 12;
|
|
12
|
+
if (half.toUpperCase() === 'AM' && hour === 12) hour = 0;
|
|
13
|
+
return `${String(hour).padStart(2, '0')}:${mm}`;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The three parts of a day, in order. */
|
|
18
|
+
export const PARTS = Object.freeze(['morning', 'afternoon', 'evening']);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Which part of the day an hour is in: before noon the morning, before four
|
|
22
|
+
* the afternoon, the evening after.
|
|
23
|
+
* @param {number} hour
|
|
24
|
+
* @returns {'morning' | 'afternoon' | 'evening'}
|
|
25
|
+
*/
|
|
26
|
+
export function partOfHour(hour) {
|
|
27
|
+
return hour < 12 ? 'morning' : hour < 16 ? 'afternoon' : 'evening';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The part each of a day's times goes under — by its hour, except that a
|
|
32
|
+
* part of one or two times joins the part next to it: «Evening 16:00–16:00»
|
|
33
|
+
* is a tab nobody needs. Fewer than `least` times is small.
|
|
34
|
+
*
|
|
35
|
+
* @param {number[]} hours the hour of each time, in order
|
|
36
|
+
* @param {number} [least] how many times make a part of their own
|
|
37
|
+
* @returns {('morning' | 'afternoon' | 'evening')[]} a part per time
|
|
38
|
+
*/
|
|
39
|
+
export function partsOf(hours, least = 3) {
|
|
40
|
+
const count = { morning: 0, afternoon: 0, evening: 0 };
|
|
41
|
+
for (const hour of hours) count[partOfHour(hour)] += 1;
|
|
42
|
+
const small = (part) => count[part] > 0 && count[part] < least;
|
|
43
|
+
/** @type {Partial<Record<string, string>>} */
|
|
44
|
+
const join = {};
|
|
45
|
+
if (small('evening') && count.afternoon > 0) join.evening = 'afternoon';
|
|
46
|
+
if (small('morning') && count.afternoon > 0) join.morning = 'afternoon';
|
|
47
|
+
if (small('afternoon') && !join.evening && !join.morning) join.afternoon = count.morning >= count.evening ? 'morning' : 'evening';
|
|
48
|
+
return hours.map((hour) => /** @type {'morning' | 'afternoon' | 'evening'} */ (join[partOfHour(hour)] ?? partOfHour(hour)));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A day's name in the catalogue's spelling, from a date: `monday`. */
|
|
52
|
+
export const DAY_NAMES = Object.freeze(['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']);
|
|
53
|
+
|
|
54
|
+
/** `YYYY-MM-DD` of a date, in the browser's own day — never `toISOString`, which is UTC's. */
|
|
55
|
+
export function ymd(d) {
|
|
56
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The date `YYYY-MM-DD` names, at local midnight; null for anything else. */
|
|
60
|
+
export function parseDay(text) {
|
|
61
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text ?? '');
|
|
62
|
+
return m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : null;
|
|
63
|
+
}
|
package/lib/booking/render.mjs
CHANGED
|
@@ -49,6 +49,7 @@ export const SENDS = Object.freeze(['service', 'services', 'resource', 'class',
|
|
|
49
49
|
* @property {{ day: string, from: number, until: number, costs: number }[]} [rates] a price by the hour of the week instead of `costs`: the list says «from …», and the price is the slot's, printed beside the time
|
|
50
50
|
* @property {string} [per] `'person'` for a price times the party — a class per place, shoes; the list says so and the running total multiplies
|
|
51
51
|
* @property {string} [group] a heading the services are listed under
|
|
52
|
+
* @property {string[]} [does] for a resource: the services they perform, by slug — the element greys the others out once they are chosen; empty or absent is everything
|
|
52
53
|
*/
|
|
53
54
|
|
|
54
55
|
/**
|
|
@@ -73,6 +74,8 @@ export function priceFrom(one) {
|
|
|
73
74
|
* @property {string} [api] where `/api/` is from this page — under the preview it carries a path, so pass the address as your generator writes a root-relative one (Astro's `under('/api/')`)
|
|
74
75
|
* @property {string} [tag] the element's name around the form, `jtk-booking`; `''` for none
|
|
75
76
|
* @property {Date} [now] the day the picker starts from; the build's, by default
|
|
77
|
+
* @property {string[]} [open] the days the business opens, by name (`monday`…) — the week of days strikes the others out; empty is every day
|
|
78
|
+
* @property {boolean | string} [ui] the form's look — two steps, the week of days, the times by the part of the day, the drawn choice of whom — on by default; `false` for the plain form, or the ones wanted by name: `'week parts'`
|
|
76
79
|
*/
|
|
77
80
|
|
|
78
81
|
const ESCAPE = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
@@ -103,8 +106,8 @@ function priceSaid(one, currency, lang) {
|
|
|
103
106
|
*/
|
|
104
107
|
export function renderBookingForm(props) {
|
|
105
108
|
const {
|
|
106
|
-
services, resources = [], locale, daysAhead = 30, combine = false, currency = '
|
|
107
|
-
api = '/api/', tag = 'jtk-booking', now = new Date(),
|
|
109
|
+
services, resources = [], locale, daysAhead = 30, combine = false, currency = '', scale = '', kind = '',
|
|
110
|
+
api = '/api/', tag = 'jtk-booking', now = new Date(), open = [], ui = true,
|
|
108
111
|
} = props;
|
|
109
112
|
if (!Array.isArray(services)) throw new TypeError('renderBookingForm: services is a list of { slug, title }');
|
|
110
113
|
const unsaid = kind !== '' && kind !== 'salon';
|
|
@@ -151,7 +154,7 @@ export function renderBookingForm(props) {
|
|
|
151
154
|
<label class="field__label" for="booking-resource" data-say="resource">${esc(WORDS.resource)}</label>
|
|
152
155
|
<select class="field__input" id="booking-resource" name="resource" data-say-wait>
|
|
153
156
|
<option value="" data-say="any">${esc(WORDS.any)}</option>
|
|
154
|
-
${resources.map((one) => `<option value="${esc(one.slug)}">${esc(one.title)}</option>`).join('\n')}
|
|
157
|
+
${resources.map((one) => `<option value="${esc(one.slug)}"${Array.isArray(one.does) && one.does.length > 0 ? ` data-does="${esc(one.does.join(','))}"` : ''}>${esc(one.title)}</option>`).join('\n')}
|
|
155
158
|
</select>
|
|
156
159
|
</div>`
|
|
157
160
|
: '';
|
|
@@ -162,7 +165,7 @@ export function renderBookingForm(props) {
|
|
|
162
165
|
// title, which the owner edits on the entry. None is the owner's to edit
|
|
163
166
|
// here, and `jtk catalogue` asked whose all seventeen were on a fresh
|
|
164
167
|
// restaurant (30 September 2026).
|
|
165
|
-
const form = `<form class="booking${unsaid ? ' booking--unsaid' : ''}" data-booking data-locale="${esc(locale)}" data-currency="${esc(currency)}" data-words="${esc(JSON.stringify(WORDS))}" data-scale="${esc(scale)}" data-api="${esc(api)}" data-sends="${SENDS.join(',')}" novalidate data-jtk-fixed>
|
|
168
|
+
const form = `<form class="booking${unsaid ? ' booking--unsaid' : ''}" data-booking data-locale="${esc(locale)}" data-currency="${esc(currency)}" data-words="${esc(JSON.stringify(WORDS))}" data-scale="${esc(scale)}" data-api="${esc(api)}" data-sends="${SENDS.join(',')}" data-open="${esc(open.join(','))}"${ui ? ` data-ui="${ui === true ? 'steps week parts select' : esc(ui)}"` : ''} novalidate data-jtk-fixed>
|
|
166
169
|
<noscript><p class="booking__note">${esc(WORDS.noScript)}</p></noscript>
|
|
167
170
|
|
|
168
171
|
<div class="field">
|
|
@@ -201,6 +204,7 @@ export function renderBookingForm(props) {
|
|
|
201
204
|
<legend class="field__label">${esc(WORDS.time)}</legend>
|
|
202
205
|
<p class="booking__hint" data-times-hint aria-live="polite">${esc(WORDS.pick)}</p>
|
|
203
206
|
<div class="booking__slots" data-slots></div>
|
|
207
|
+
<p class="booking__ends" data-ends hidden></p>
|
|
204
208
|
<p class="booking__total" data-stay-total hidden></p>
|
|
205
209
|
</fieldset>
|
|
206
210
|
|
|
@@ -231,6 +235,14 @@ export function renderBookingForm(props) {
|
|
|
231
235
|
<p class="booking__done-line" data-done-line></p>
|
|
232
236
|
<p class="booking__done-when" data-done-when></p>
|
|
233
237
|
<p class="booking__done-when" data-done-what hidden></p>
|
|
238
|
+
<div class="booking__keep" data-keep hidden>
|
|
239
|
+
<p class="booking__note" data-keep-note></p>
|
|
240
|
+
<div class="booking__keep-row">
|
|
241
|
+
<input class="field__input booking__keep-link" type="text" readonly data-keep-link>
|
|
242
|
+
<button type="button" class="booking__keep-copy" data-keep-copy></button>
|
|
243
|
+
<a class="booking__keep-open" data-keep-open></a>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
234
246
|
<p class="booking__note" data-pay-note hidden></p>
|
|
235
247
|
<p class="booking__links">
|
|
236
248
|
<a data-pay hidden></a>
|