@jtakeit/astro 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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -0
  3. package/bin/jtk.mjs +41 -0
  4. package/docs/booking.md +164 -0
  5. package/docs/catalogue.md +459 -0
  6. package/docs/collections.md +249 -0
  7. package/docs/css.md +86 -0
  8. package/docs/gallery.md +127 -0
  9. package/docs/hero-motion.md +189 -0
  10. package/docs/kit.md +454 -0
  11. package/docs/languages.md +182 -0
  12. package/docs/lead-form.md +109 -0
  13. package/docs/pages.md +193 -0
  14. package/docs/photos.md +314 -0
  15. package/docs/scaffold.md +75 -0
  16. package/docs/shapes.md +140 -0
  17. package/docs/surface.md +187 -0
  18. package/lib/catalogue.mjs +1678 -0
  19. package/lib/codes.mjs +171 -0
  20. package/lib/create.mjs +282 -0
  21. package/package.json +16 -0
  22. package/template/astro.config.mjs +84 -0
  23. package/template/figures.mjs +122 -0
  24. package/template/gitignore +16 -0
  25. package/template/jtakeit-meta.mjs +112 -0
  26. package/template/jtk/content/index.json +38 -0
  27. package/template/jtk/design.json +24 -0
  28. package/template/markdown.mjs +36 -0
  29. package/template/package-lock.json +5320 -0
  30. package/template/package.json +26 -0
  31. package/template/specimens.mjs +46 -0
  32. package/template/src/components/Blocks.astro +151 -0
  33. package/template/src/components/BookingForm.astro +506 -0
  34. package/template/src/components/Clip.astro +155 -0
  35. package/template/src/components/Hero.astro +66 -0
  36. package/template/src/components/LeadForm.astro +347 -0
  37. package/template/src/components/OpeningHours.astro +69 -0
  38. package/template/src/components/Pile.astro +185 -0
  39. package/template/src/components/Shot.astro +472 -0
  40. package/template/src/components/gallery/Gallery.astro +381 -0
  41. package/template/src/components/gallery/galleries.ts +139 -0
  42. package/template/src/components/motion/HeroField.astro +520 -0
  43. package/template/src/components/motion/fields.ts +430 -0
  44. package/template/src/components/surface/Pattern.astro +278 -0
  45. package/template/src/components/surface/patterns.ts +187 -0
  46. package/template/src/content/blocks.ts +758 -0
  47. package/template/src/content.config.ts +19 -0
  48. package/template/src/copy/LOCALE.ts +324 -0
  49. package/template/src/data/site.ts +137 -0
  50. package/template/src/layouts/Layout.astro +282 -0
  51. package/template/src/lib/alive.ts +49 -0
  52. package/template/src/lib/entries.ts +106 -0
  53. package/template/src/lib/entryLoader.ts +315 -0
  54. package/template/src/lib/noise.ts +26 -0
  55. package/template/src/lib/page.ts +287 -0
  56. package/template/src/lib/photos.ts +168 -0
  57. package/template/src/lib/under.ts +32 -0
  58. package/template/src/lib/uploads.ts +85 -0
  59. package/template/src/pages/[...entry].astro +207 -0
  60. package/template/src/pages/[...feed].xml.ts +64 -0
  61. package/template/src/pages/index.astro +90 -0
  62. package/template/src/pages/llms.txt.ts +50 -0
  63. package/template/src/pages/privacy.astro +59 -0
  64. package/template/src/pages/robots.txt.ts +21 -0
  65. package/template/src/pages/sitemap.xml.ts +50 -0
  66. package/template/src/styles/global.css +411 -0
  67. package/template/src/styles/surface.css +375 -0
  68. package/template/tsconfig.json +5 -0
@@ -0,0 +1,506 @@
1
+ ---
2
+ /**
3
+ * The booking form.
4
+ *
5
+ * The rules — when the business is open, how long a slot is, whether a time
6
+ * is still free, what it costs — live in one place, and it is not here: the
7
+ * platform serves six addresses on the site's own host and this component
8
+ * draws what they answer. Nothing about availability is computed on the page,
9
+ * and nothing about the visitor's choice is trusted by the server.
10
+ *
11
+ * GET /api/turnstile the widget's key, or "" for none
12
+ * GET /api/availability?service=…&from=…&to=…[&resource=…]
13
+ * POST /api/book → { id, state, amount_minor, currency, token, manage_path, telegram_url }
14
+ * POST /api/pay → { url } — only when amount_minor > 0
15
+ *
16
+ * The visitor's own page — cancel, move, add to calendar — is served by the
17
+ * platform at `manage_path`; this form only has to link to it.
18
+ *
19
+ * ── what the page passes in ─────────────────────────────────────────────────
20
+ *
21
+ * The services and the resources are collection entries the owner edits, so
22
+ * they arrive as props from the page that reads the collections. Slugs are
23
+ * what the platform books against; titles are what the visitor reads.
24
+ *
25
+ * ── with JavaScript off ─────────────────────────────────────────────────────
26
+ *
27
+ * A booking cannot be made without a script — the free times are fetched — so
28
+ * with scripts off this shows the contact details and the sentence in
29
+ * `copy.noScript`, and nothing pretends otherwise.
30
+ */
31
+ import { under } from '../lib/under';
32
+
33
+ interface Named {
34
+ slug: string;
35
+ title: string;
36
+ /** Minutes and minor units, for the running total when several are picked. */
37
+ takes?: number;
38
+ costs?: number;
39
+ /** A heading the services are listed under, where the catalogue groups them. */
40
+ group?: string;
41
+ }
42
+
43
+ interface Props {
44
+ services: Named[];
45
+ /** Empty for a solo business: the platform assigns. */
46
+ resources?: Named[];
47
+ /** The site's locale — decides the words. */
48
+ locale: 'uk' | 'de' | 'en';
49
+ /** How many days ahead the picker offers. The platform clamps to its own horizon. */
50
+ daysAhead?: number;
51
+ /**
52
+ * Whether a visitor may pick several services for one visit — the module's
53
+ * `combine` setting, read from `jtk/bookings.json`. On, the services are a
54
+ * checklist with a running total; off, one choice. The platform holds the
55
+ * same rule, so a form that lies here is refused there.
56
+ */
57
+ combine?: boolean;
58
+ /** The site's currency, for the running total. */
59
+ currency?: string;
60
+ }
61
+
62
+ const { services, resources = [], locale, daysAhead = 30, combine = false, currency = 'CHF' } = Astro.props;
63
+
64
+ // The checklist's runs, in the order the services came, each heading once.
65
+ const runs: { group: string; items: Named[] }[] = [];
66
+ for (const one of services) {
67
+ const group = one.group ?? '';
68
+ const run = runs.find((r) => r.group === group);
69
+ if (run) run.items.push(one);
70
+ else runs.push({ group, items: [one] });
71
+ }
72
+
73
+ const WORDS = {
74
+ uk: {
75
+ service: 'Послуга', services: 'Послуги', total: 'Разом', pickService: 'Оберіть хоча б одну послугу.', resource: 'Майстер', any: 'Будь-хто', day: 'День', time: 'Час',
76
+ name: "Ім'я", phone: 'Телефон', email: 'Електронна пошта', note: 'Коментар',
77
+ book: 'Записатися', sending: 'Записуємо…', none: 'У цей день вільного часу немає.',
78
+ pick: 'Оберіть день', confirmed: 'Ви записані.', pending: 'Запис отримано — ми підтвердимо його найближчим часом.',
79
+ pay: 'Оплатити', payNote: 'Час зарезервовано на 30 хвилин. Щоб закріпити його, завершіть оплату.',
80
+ manage: 'Скасувати або перенести запис', telegram: 'Нагадування в Telegram',
81
+ taken: 'Цей час щойно зайняли — оберіть інший.', failed: 'Не вдалося записатися. Спробуйте ще раз або зателефонуйте.',
82
+ party: 'Скільки вас', challenge: 'Підтвердіть, що ви не робот, і спробуйте ще раз.', tooMany: 'У вас уже є кілька відкритих записів. Скасуйте один із них або зателефонуйте нам.',
83
+ needContact: 'Вкажіть телефон або пошту, щоб ми могли з вами звʼязатися.', noScript: 'Щоб записатися онлайн, увімкніть JavaScript — або зателефонуйте нам.',
84
+ },
85
+ de: {
86
+ service: 'Leistung', services: 'Leistungen', total: 'Zusammen', pickService: 'Bitte mindestens eine Leistung wählen.', resource: 'Bei', any: 'Egal wer', day: 'Tag', time: 'Uhrzeit',
87
+ name: 'Name', phone: 'Telefon', email: 'E-Mail', note: 'Bemerkung',
88
+ book: 'Termin buchen', sending: 'Wird gebucht…', none: 'An diesem Tag ist nichts frei.',
89
+ pick: 'Wählen Sie einen Tag', confirmed: 'Ihr Termin ist gebucht.', pending: 'Anfrage erhalten — wir bestätigen den Termin in Kürze.',
90
+ pay: 'Jetzt bezahlen', payNote: 'Der Termin ist 30 Minuten reserviert. Schliessen Sie die Zahlung ab, um ihn zu behalten.',
91
+ manage: 'Termin absagen oder verschieben', telegram: 'Erinnerungen per Telegram',
92
+ taken: 'Diese Zeit wurde gerade vergeben — bitte wählen Sie eine andere.', failed: 'Die Buchung hat nicht geklappt. Bitte erneut versuchen oder anrufen.',
93
+ party: 'Wie viele Personen', challenge: 'Bitte bestätigen Sie, dass Sie kein Roboter sind, und versuchen Sie es erneut.', tooMany: 'Sie haben bereits mehrere offene Termine. Sagen Sie einen ab oder rufen Sie uns an.',
94
+ needContact: 'Bitte Telefon oder E-Mail angeben, damit wir Sie erreichen können.', noScript: 'Für die Online-Buchung ist JavaScript nötig — oder rufen Sie uns an.',
95
+ },
96
+ en: {
97
+ service: 'Service', services: 'Services', total: 'Total', pickService: 'Pick at least one service.', resource: 'With', any: 'Anyone', day: 'Day', time: 'Time',
98
+ name: 'Name', phone: 'Phone', email: 'Email', note: 'Note',
99
+ book: 'Book', sending: 'Booking…', none: 'Nothing is free on that day.',
100
+ pick: 'Choose a day', confirmed: 'You are booked.', pending: 'Received — we will confirm shortly.',
101
+ pay: 'Pay now', payNote: 'The time is held for 30 minutes. Complete the payment to keep it.',
102
+ manage: 'Cancel or move the booking', telegram: 'Reminders in Telegram',
103
+ taken: 'That time has just been taken — please choose another.', failed: 'That did not work. Please try again or ring us.',
104
+ party: 'How many of you', challenge: 'Please confirm you are not a robot and try again.', tooMany: 'You already have several open bookings. Cancel one, or ring us.',
105
+ needContact: 'Leave a phone number or an email so we can reach you.', noScript: 'Booking online needs JavaScript — or ring us.',
106
+ },
107
+ }[locale];
108
+
109
+ const today = new Date().toISOString().slice(0, 10);
110
+ const last = new Date(Date.now() + daysAhead * 86_400_000).toISOString().slice(0, 10);
111
+ ---
112
+
113
+ <form
114
+ class="booking"
115
+ data-booking
116
+ data-currency={currency}
117
+ data-words={JSON.stringify(WORDS)}
118
+ data-api={under('/api/')}
119
+ novalidate
120
+ >
121
+ <noscript><p class="booking__note">{WORDS.noScript}</p></noscript>
122
+
123
+ <div class="field">
124
+ {combine ? (
125
+ <fieldset class="booking__services" data-services>
126
+ <legend class="field__label">{WORDS.services}</legend>
127
+ {runs.map((run) => (
128
+ <div class="booking__run">
129
+ {run.group !== '' && <p class="booking__run-title">{run.group}</p>}
130
+ {run.items.map((one) => (
131
+ <label class="booking__service">
132
+ <input type="checkbox" name="service" value={one.slug} data-takes={one.takes ?? 0} data-costs={one.costs ?? 0} />
133
+ <span class="booking__service-name">{one.title}</span>
134
+ <span class="booking__service-meta">
135
+ {one.takes ? `${one.takes} min` : ''}
136
+ {one.costs ? ` · ${new Intl.NumberFormat(locale === 'de' ? 'de-CH' : locale === 'uk' ? 'uk-UA' : 'en', { style: 'currency', currency }).format(one.costs / 100)}` : ''}
137
+ </span>
138
+ </label>
139
+ ))}
140
+ </div>
141
+ ))}
142
+ <p class="booking__total" data-total hidden></p>
143
+ </fieldset>
144
+ ) : (
145
+ <>
146
+ <label class="field__label" for="booking-service">{WORDS.service}</label>
147
+ <select class="field__input" id="booking-service" name="service" required>
148
+ {services.map((one) => <option value={one.slug}>{one.title}</option>)}
149
+ </select>
150
+ </>
151
+ )}
152
+ </div>
153
+
154
+ {resources.length > 1 && (
155
+ <div class="field">
156
+ <label class="field__label" for="booking-resource">{WORDS.resource}</label>
157
+ <select class="field__input" id="booking-resource" name="resource">
158
+ <option value="">{WORDS.any}</option>
159
+ {resources.map((one) => <option value={one.slug}>{one.title}</option>)}
160
+ </select>
161
+ </div>
162
+ )}
163
+
164
+ <div class="field">
165
+ <label class="field__label" for="booking-day">{WORDS.day}</label>
166
+ <input class="field__input" id="booking-day" name="day" type="date" min={today} max={last} required />
167
+ </div>
168
+
169
+ {/* How many, shown only once availability has said the site takes a party
170
+ (party_max > 0): a salon never asks, a restaurant and a class do. The
171
+ options are filled from that answer rather than from the build, because
172
+ the largest party is the owner's setting and not the template's. */}
173
+ <div class="field" data-party-field hidden>
174
+ <label class="field__label" for="booking-party">{WORDS.party}</label>
175
+ <select class="field__input" id="booking-party" name="party"></select>
176
+ </div>
177
+
178
+ <fieldset class="field booking__times" data-times>
179
+ <legend class="field__label">{WORDS.time}</legend>
180
+ <p class="booking__hint" data-times-hint>{WORDS.pick}</p>
181
+ <div class="booking__slots" data-slots></div>
182
+ </fieldset>
183
+
184
+ <div class="field">
185
+ <label class="field__label" for="booking-name">{WORDS.name} <span aria-hidden="true">*</span></label>
186
+ <input class="field__input" id="booking-name" name="name" type="text" autocomplete="name" required maxlength="120" />
187
+ </div>
188
+ <div class="field">
189
+ <label class="field__label" for="booking-phone">{WORDS.phone}</label>
190
+ <input class="field__input" id="booking-phone" name="phone" type="tel" inputmode="tel" autocomplete="tel" maxlength="40" />
191
+ </div>
192
+ <div class="field">
193
+ <label class="field__label" for="booking-email">{WORDS.email}</label>
194
+ <input class="field__input" id="booking-email" name="email" type="email" inputmode="email" autocomplete="email" maxlength="120" />
195
+ </div>
196
+ <div class="field">
197
+ <label class="field__label" for="booking-note">{WORDS.note}</label>
198
+ <textarea class="field__input field__input--area" id="booking-note" name="note" rows="3" maxlength="2000"></textarea>
199
+ </div>
200
+
201
+ {/* Turnstile renders here when /api/turnstile hands the page a key. */}
202
+ <div class="booking__challenge" data-challenge hidden></div>
203
+
204
+ <p class="field__error" data-booking-error hidden></p>
205
+
206
+ <button class="booking__submit" type="submit" data-submit>{WORDS.book}</button>
207
+
208
+ <div class="booking__done" data-done hidden>
209
+ <p class="booking__done-line" data-done-line></p>
210
+ <p class="booking__done-when" data-done-when></p>
211
+ <p class="booking__note" data-pay-note hidden></p>
212
+ <p class="booking__links">
213
+ <a data-pay hidden></a>
214
+ <a data-manage hidden></a>
215
+ <a data-telegram hidden target="_blank" rel="noreferrer noopener"></a>
216
+ </p>
217
+ </div>
218
+ </form>
219
+
220
+ <script>
221
+ /**
222
+ * The whole client. No framework, one form, one fetch per question.
223
+ *
224
+ * Every rule enforced here is enforced again by the platform — the browser's
225
+ * checks are a convenience, never a gate — so the script's only jobs are to
226
+ * fetch the free times, to render the widget it is told to, and to say
227
+ * plainly what the platform answered.
228
+ */
229
+ type Words = Record<string, string>;
230
+ type Slot = { start: string; free: string[] };
231
+
232
+ declare global {
233
+ interface Window {
234
+ turnstile?: {
235
+ render: (el: HTMLElement, opts: { sitekey: string; callback: (token: string) => void; 'expired-callback'?: () => void }) => string;
236
+ reset: (id?: string) => void;
237
+ };
238
+ }
239
+ }
240
+
241
+ for (const form of document.querySelectorAll<HTMLFormElement>('[data-booking]')) {
242
+ wire(form);
243
+ }
244
+
245
+ function wire(form: HTMLFormElement): void {
246
+ const words = JSON.parse(form.dataset.words ?? '{}') as Words;
247
+ const api = form.dataset.api ?? '/api/';
248
+ const q = <T extends HTMLElement>(sel: string) => form.querySelector<T>(sel)!;
249
+
250
+ // One select, or a checklist: what is chosen is a list either way.
251
+ const select = form.querySelector<HTMLSelectElement>('select[name=service]');
252
+ const boxes = [...form.querySelectorAll<HTMLInputElement>('input[type=checkbox][name=service]')];
253
+ const total = form.querySelector<HTMLElement>('[data-total]');
254
+ const picked = (): string[] =>
255
+ select ? (select.value ? [select.value] : []) : boxes.filter((b) => b.checked).map((b) => b.value);
256
+ function showTotal(): void {
257
+ if (!total) return;
258
+ const chosenBoxes = boxes.filter((b) => b.checked);
259
+ if (chosenBoxes.length < 2) { total.hidden = true; return; }
260
+ const minutes = chosenBoxes.reduce((sum, b) => sum + Number(b.dataset.takes ?? 0), 0);
261
+ const minor = chosenBoxes.reduce((sum, b) => sum + Number(b.dataset.costs ?? 0), 0);
262
+ const money = minor > 0
263
+ ? ' · ' + new Intl.NumberFormat(document.documentElement.lang || 'en', { style: 'currency', currency: form.dataset.currency || 'CHF' }).format(minor / 100)
264
+ : '';
265
+ total.textContent = `${words.total}: ${minutes} min${money}`;
266
+ total.hidden = false;
267
+ }
268
+ const resource = form.querySelector<HTMLSelectElement>('[name=resource]');
269
+ const party = q<HTMLSelectElement>('[name=party]');
270
+ const partyField = q<HTMLElement>('[data-party-field]');
271
+ const day = q<HTMLInputElement>('[name=day]');
272
+ const slots = q<HTMLElement>('[data-slots]');
273
+ const hint = q<HTMLElement>('[data-times-hint]');
274
+ const error = q<HTMLElement>('[data-booking-error]');
275
+ const submit = q<HTMLButtonElement>('[data-submit]');
276
+ const challenge = q<HTMLElement>('[data-challenge]');
277
+
278
+ let chosen: string | null = null;
279
+ let zone = '';
280
+ let turnstileToken = '';
281
+
282
+ // The widget, if this host has one. Asked at runtime so the site carries
283
+ // no key: which widget covers a host is the platform's to decide.
284
+ void fetch(api + 'turnstile', { cache: 'no-store' })
285
+ .then((r) => r.json())
286
+ .then((data: { sitekey?: string }) => {
287
+ if (!data.sitekey) return;
288
+ challenge.hidden = false;
289
+ const script = document.createElement('script');
290
+ script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
291
+ script.async = true;
292
+ script.onload = () => {
293
+ window.turnstile?.render(challenge, {
294
+ sitekey: data.sitekey!,
295
+ callback: (token) => { turnstileToken = token; },
296
+ 'expired-callback': () => { turnstileToken = ''; },
297
+ });
298
+ };
299
+ document.head.appendChild(script);
300
+ })
301
+ .catch(() => undefined);
302
+
303
+ // The party control appears the first time the platform says how large a
304
+ // party may be, and asks again when it changes.
305
+ function offerParty(most: number): void {
306
+ if (most <= 1) { partyField.hidden = true; return; }
307
+ if (party.options.length !== most) {
308
+ const was = party.value;
309
+ party.textContent = '';
310
+ for (let n = 1; n <= most; n++) {
311
+ const option = document.createElement('option');
312
+ option.value = String(n);
313
+ option.textContent = String(n);
314
+ party.appendChild(option);
315
+ }
316
+ party.value = was && Number(was) <= most ? was : '1';
317
+ }
318
+ if (partyField.hidden) {
319
+ partyField.hidden = false;
320
+ party.addEventListener('change', () => void load());
321
+ }
322
+ }
323
+
324
+ function say(text: string): void {
325
+ error.textContent = text;
326
+ error.hidden = text === '';
327
+ }
328
+
329
+ // A refusal as a sentence in the visitor's language. The platform answers
330
+ // with a JSON body — `{"error":"challenge"}` — or with a line of prose;
331
+ // neither is for a visitor to read as it came, and a sweep once found the
332
+ // JSON printed between the form and its button.
333
+ async function reason(answer: Response): Promise<string> {
334
+ let body = '';
335
+ try { body = await answer.text(); } catch { /* nothing to read */ }
336
+ let code = '';
337
+ try { code = String((JSON.parse(body) as { error?: string }).error ?? ''); } catch { /* prose */ }
338
+ if (answer.status === 403 || code === 'challenge') return words.challenge;
339
+ if (answer.status === 429) return words.tooMany;
340
+ if (answer.status === 409) return words.taken;
341
+ return words.failed;
342
+ }
343
+
344
+ function clock(iso: string): string {
345
+ try {
346
+ return new Intl.DateTimeFormat(document.documentElement.lang || 'en', {
347
+ hour: '2-digit', minute: '2-digit', timeZone: zone || undefined,
348
+ }).format(new Date(iso));
349
+ } catch {
350
+ return iso.slice(11, 16);
351
+ }
352
+ }
353
+
354
+ async function load(): Promise<void> {
355
+ chosen = null;
356
+ slots.textContent = '';
357
+ if (!day.value || picked().length === 0) return;
358
+ hint.textContent = '…';
359
+
360
+ const from = new Date(day.value + 'T00:00:00');
361
+ const to = new Date(from);
362
+ to.setDate(to.getDate() + 1);
363
+ const params = new URLSearchParams({
364
+ from: from.toISOString(),
365
+ to: to.toISOString(),
366
+ });
367
+ for (const slug of picked()) params.append('service', slug);
368
+ if (resource?.value) params.set('resource', resource.value);
369
+ if (!partyField.hidden && party.value) params.set('party', party.value);
370
+
371
+ try {
372
+ const answer = await fetch(api + 'availability?' + params.toString(), { cache: 'no-store' });
373
+ if (!answer.ok) throw new Error(await answer.text());
374
+ const data = (await answer.json()) as { zone: string; slots: Slot[]; party_max?: number };
375
+ zone = data.zone;
376
+ offerParty(data.party_max ?? 0);
377
+ if (data.slots.length === 0) {
378
+ hint.textContent = words.none;
379
+ return;
380
+ }
381
+ hint.textContent = '';
382
+ for (const slot of data.slots) {
383
+ const button = document.createElement('button');
384
+ button.type = 'button';
385
+ button.className = 'booking__slot';
386
+ button.textContent = clock(slot.start);
387
+ button.addEventListener('click', () => {
388
+ chosen = slot.start;
389
+ for (const other of slots.querySelectorAll('.booking__slot')) other.classList.remove('is-chosen');
390
+ button.classList.add('is-chosen');
391
+ say('');
392
+ });
393
+ slots.appendChild(button);
394
+ }
395
+ } catch (err) {
396
+ hint.textContent = err instanceof Error && err.message ? err.message : words.failed;
397
+ }
398
+ }
399
+
400
+ select?.addEventListener('change', () => void load());
401
+ for (const box of boxes) box.addEventListener('change', () => { showTotal(); void load(); });
402
+ resource?.addEventListener('change', () => void load());
403
+ day.addEventListener('change', () => void load());
404
+
405
+ form.addEventListener('submit', async (event) => {
406
+ event.preventDefault();
407
+ say('');
408
+ const data = new FormData(form);
409
+ const name = String(data.get('name') ?? '').trim();
410
+ const phone = String(data.get('phone') ?? '').trim();
411
+ const email = String(data.get('email') ?? '').trim();
412
+ if (picked().length === 0) { say(words.pickService); return; }
413
+ if (!chosen) { say(words.pick); return; }
414
+ if (name === '') { q<HTMLInputElement>('[name=name]').focus(); return; }
415
+ if (phone === '' && email === '') { say(words.needContact); return; }
416
+
417
+ submit.disabled = true;
418
+ submit.textContent = words.sending;
419
+ try {
420
+ const answer = await fetch(api + 'book', {
421
+ method: 'POST',
422
+ headers: { 'content-type': 'application/json' },
423
+ body: JSON.stringify({
424
+ service: picked()[0],
425
+ services: picked(),
426
+ resource: resource?.value ?? '',
427
+ party: partyField.hidden ? 1 : Number(party.value) || 1,
428
+ start: chosen,
429
+ name, phone, email,
430
+ note: String(data.get('note') ?? ''),
431
+ turnstile: turnstileToken,
432
+ }),
433
+ });
434
+ if (answer.status === 409) { say(words.taken); await load(); return; }
435
+ if (!answer.ok) { say(await reason(answer)); return; }
436
+ const made = (await answer.json()) as {
437
+ state: string; start: string; ends: string; amount_minor: number; currency: string;
438
+ id: string; manage_path: string; telegram_url: string;
439
+ };
440
+ done(made);
441
+ } catch {
442
+ say(words.failed);
443
+ } finally {
444
+ submit.disabled = false;
445
+ submit.textContent = words.book;
446
+ window.turnstile?.reset();
447
+ turnstileToken = '';
448
+ }
449
+ });
450
+
451
+ function done(made: { state: string; start: string; ends: string; amount_minor: number; currency: string; id: string; manage_path: string; telegram_url: string }): void {
452
+ for (const el of form.querySelectorAll<HTMLElement>('.field, .booking__times, .booking__services, [data-submit], [data-challenge]')) el.hidden = true;
453
+ const box = q<HTMLElement>('[data-done]');
454
+ box.hidden = false;
455
+ q<HTMLElement>('[data-done-line]').textContent = made.state === 'confirmed' ? words.confirmed : words.pending;
456
+ q<HTMLElement>('[data-done-when]').textContent = `${new Intl.DateTimeFormat(document.documentElement.lang || 'en', { weekday: 'long', day: 'numeric', month: 'long', timeZone: zone || undefined }).format(new Date(made.start))}, ${clock(made.start)}–${clock(made.ends)}`;
457
+
458
+ const manage = q<HTMLAnchorElement>('[data-manage]');
459
+ if (made.manage_path) { manage.href = made.manage_path; manage.textContent = words.manage; manage.hidden = false; }
460
+ const tg = q<HTMLAnchorElement>('[data-telegram]');
461
+ if (made.telegram_url) { tg.href = made.telegram_url; tg.textContent = words.telegram; tg.hidden = false; }
462
+
463
+ if (made.amount_minor > 0) {
464
+ const note = q<HTMLElement>('[data-pay-note]');
465
+ note.textContent = words.payNote;
466
+ note.hidden = false;
467
+ const pay = q<HTMLAnchorElement>('[data-pay]');
468
+ pay.textContent = `${words.pay} · ${new Intl.NumberFormat(document.documentElement.lang || 'en', { style: 'currency', currency: made.currency.toUpperCase() }).format(made.amount_minor / 100)}`;
469
+ pay.href = '#';
470
+ pay.hidden = false;
471
+ pay.addEventListener('click', async (event) => {
472
+ event.preventDefault();
473
+ const answer = await fetch(api + 'pay', {
474
+ method: 'POST',
475
+ headers: { 'content-type': 'application/json' },
476
+ body: JSON.stringify({ booking: made.id, return: made.manage_path || '/' }),
477
+ });
478
+ if (!answer.ok) { say(await reason(answer)); return; }
479
+ const { url } = (await answer.json()) as { url: string };
480
+ window.location.href = url;
481
+ });
482
+ }
483
+ }
484
+ }
485
+ </script>
486
+
487
+ <style>
488
+ .booking { display: grid; gap: var(--space-4, 1rem); max-width: 34rem; }
489
+ .booking__times { border: 0; padding: 0; margin: 0; }
490
+ .booking__services { border: 0; padding: 0; margin: 0; display: grid; gap: 0.75rem; }
491
+ .booking__run { display: grid; gap: 0.35rem; }
492
+ .booking__run-title { margin: 0.5rem 0 0.15rem; font-size: 0.75rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; opacity: 0.7; }
493
+ .booking__service { display: grid; grid-template-columns: auto 1fr auto; gap: 0.6rem; align-items: baseline; padding: 0.45rem 0; border-block-end: 1px solid var(--line, #e5e1d8); cursor: pointer; }
494
+ .booking__service-meta { font-size: 0.85em; opacity: 0.7; white-space: nowrap; }
495
+ .booking__total { margin: 0.25rem 0 0; font-weight: 600; }
496
+ .booking__slots { display: flex; flex-wrap: wrap; gap: 0.4rem; }
497
+ .booking__slot {
498
+ font: inherit; padding: 0.45rem 0.75rem; border-radius: var(--radius-sm, 0.5rem);
499
+ border: 1px solid var(--color-line, currentColor); background: transparent; color: inherit; cursor: pointer;
500
+ }
501
+ .booking__slot.is-chosen { background: var(--color-ink, #111); color: var(--color-bg, #fff); border-color: var(--color-ink, #111); }
502
+ .booking__hint, .booking__note { margin: 0; opacity: 0.75; }
503
+ .booking__links { display: flex; flex-wrap: wrap; gap: 0.75rem 1.25rem; margin: 0; }
504
+ .booking__done-line { font-weight: 600; margin: 0; }
505
+ .booking__done-when { margin: 0; }
506
+ </style>
@@ -0,0 +1,155 @@
1
+ ---
2
+ /**
3
+ * One clip: a few seconds of the work being done, looping, silent.
4
+ *
5
+ * ── why a component and not a <video> ───────────────────────────────────────
6
+ *
7
+ * Everything below is a way for a clip to fail, and all of them are ordinary:
8
+ * autoplay refused, Low Power Mode, Data Saver, a codec the browser does not
9
+ * have, reduced motion, JavaScript off, a battery under twenty percent on an
10
+ * iPhone. Written by hand each time, a page gets three of the seven right and
11
+ * the other four show a black rectangle in the middle of somebody's site.
12
+ *
13
+ * So the rules live here:
14
+ *
15
+ * · **the poster is the clip's own first frame.** Every failure above lands
16
+ * on that picture, so it cannot be a frame from the middle — it has to be
17
+ * the frame the loop starts from, or the switch from poster to video is a
18
+ * visible jump that reads as a fault. `fl-clips` writes exactly that frame,
19
+ * and the admin takes the same one when an owner uploads a clip;
20
+ * · **autoplay, muted, looping, inline, and nothing to press.** These are
21
+ * moving photographs. A play button in a hero is an invitation to do
22
+ * something other than the one action the page is asking for;
23
+ * · **no audio track at all**, which is `fl-clips`' business rather than
24
+ * this component's: a muted track is bytes nobody hears, and a browser that
25
+ * unmutes on a gesture plays a stranger's room out loud;
26
+ * · **reduced motion stops it on frame one**, which is the poster. There is
27
+ * no CSS that pauses a video, so it is a few lines of script — and with
28
+ * JavaScript off the markup's own autoplay runs, which is the right way
29
+ * round.
30
+ *
31
+ * ── where the clip comes from ───────────────────────────────────────────────
32
+ *
33
+ * A key from the content document (`media/<site>/<hash>.mp4`), which the build
34
+ * downloaded, or a file the repository ships under `public/clips/`. Passing the
35
+ * content's key is what makes the clip replaceable in the admin — the same
36
+ * decision as a photograph, with the same consequence for the person who would
37
+ * otherwise be sending us a message about it.
38
+ *
39
+ * <Clip src={item.name} poster={item.poster} path={item.path} label="Стенсіл" />
40
+ *
41
+ * **One label for a set, not one per clip.** Five loops in a pile are visually
42
+ * one thing, and five labels read out in a row are worse than one sentence:
43
+ * label the container with `role="img"` and leave these unlabelled.
44
+ */
45
+ import { uploadedClip, isUpload } from '../lib/uploads';
46
+ import { photo } from '../lib/photos';
47
+
48
+ interface Props {
49
+ /** A key from the content document, or a path under `public/`. */
50
+ src: string;
51
+ /** The still. A key, or a path under `public/`. */
52
+ poster?: string;
53
+ /** `data-jtk-path` for this clip, where it comes from the content document. */
54
+ path?: string;
55
+ /** What the set shows, where this clip is the whole of it. */
56
+ label?: string;
57
+ /** CSS aspect-ratio for the frame. */
58
+ ratio?: string;
59
+ class?: string;
60
+ }
61
+
62
+ const { src, poster, path, label, ratio = '9 / 16', class: className } = Astro.props;
63
+
64
+ /*
65
+ * A key is resolved to the file the build emitted; anything else is taken as
66
+ * given, which is what a path under `public/` is. `base` matters here: a
67
+ * preview is served under /p/<slug>/, and a clip addressed from the root is a
68
+ * clip that 404s there and nowhere else.
69
+ */
70
+ const base = import.meta.env.BASE_URL.replace(/\/?$/, '/');
71
+ const address = (value: string): string =>
72
+ isUpload(value) ? (uploadedClip(value) ?? '') : base + value.replace(/^\//, '');
73
+
74
+ const clip = address(src);
75
+
76
+ /*
77
+ * The poster is the element's own `poster` attribute and not a picture layered
78
+ * under it. A browser shows it for exactly as long as it is not painting video
79
+ * frames — which is every one of the failures above, including the ones no
80
+ * script can detect — and it needs no second element to get right.
81
+ *
82
+ * It is not run through `astro:assets`: `fl-clips` writes a poster at the
83
+ * clip's own size, a few tens of kilobytes, and there is nothing to gain by
84
+ * making variants of a picture at most visitors see for a quarter of a second.
85
+ */
86
+ const stillFile = poster && isUpload(poster) ? photo(poster) : undefined;
87
+ const stillURL = poster ? (stillFile ? stillFile.src : address(poster)) : undefined;
88
+ ---
89
+
90
+ <div class:list={['clip', className]} style={`aspect-ratio:${ratio}`} role={label ? 'img' : undefined} aria-label={label}>
91
+ {
92
+ clip && (
93
+ <video
94
+ class="clip__video"
95
+ data-jtk-path={path}
96
+ data-clip
97
+ poster={stillURL}
98
+ autoplay
99
+ muted
100
+ loop
101
+ playsinline
102
+ preload="metadata"
103
+ aria-hidden={label ? 'true' : undefined}
104
+ >
105
+ <source src={clip} type={clip.endsWith('.webm') ? 'video/webm' : 'video/mp4'} />
106
+ </video>
107
+ )
108
+ }
109
+ </div>
110
+
111
+ <script>
112
+ /*
113
+ * Reduced motion: stopped on frame one, which is what the poster shows.
114
+ *
115
+ * The markup autoplays, so a browser with JavaScript off plays the loop —
116
+ * which is the right way round: the setting is a preference somebody
117
+ * expressed, and it is honoured where it can be read.
118
+ */
119
+ const still = window.matchMedia('(prefers-reduced-motion: reduce)');
120
+ const clips = document.querySelectorAll<HTMLVideoElement>('video[data-clip]');
121
+
122
+ const apply = (): void => {
123
+ for (const clip of clips) {
124
+ if (still.matches) {
125
+ clip.pause();
126
+ clip.currentTime = 0;
127
+ } else {
128
+ clip.play().catch(() => {
129
+ /* Refused. The poster is the design, not a fallback. */
130
+ });
131
+ }
132
+ }
133
+ };
134
+
135
+ apply();
136
+ still.addEventListener('change', apply);
137
+ </script>
138
+
139
+ <style>
140
+ .clip {
141
+ position: relative;
142
+ overflow: hidden;
143
+ border-radius: var(--r-2, 8px);
144
+ background: var(--paper-deep, #eceae5);
145
+ }
146
+
147
+ .clip__video {
148
+ position: absolute;
149
+ inset: 0;
150
+ inline-size: 100%;
151
+ block-size: 100%;
152
+ object-fit: cover;
153
+ }
154
+
155
+ </style>