@jtakeit/kit 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,11 +8,14 @@
8
8
  * what they answer. Nothing about availability is computed on the page, and
9
9
  * nothing about the visitor's choice is trusted by the server.
10
10
  *
11
- * This is the markup `@jtakeit/astro`'s `BookingForm.astro` used to carry
12
- * itself; it is a string now so a site on any generator can put the same form
13
- * on a page: Eleventy in a shortcode, Next in `dangerouslySetInnerHTML`, a
14
- * Node script in a template literal. Keep the `data-*` hooks and the input
15
- * `name`s whatever you do to its classes: the element reads those, and the
11
+ * Two ways to the same form. `renderBookingForm` writes it as a string, for a
12
+ * site on any generator: Eleventy in a shortcode, Next in
13
+ * `dangerouslySetInnerHTML`, a Node script in a template literal.
14
+ * `bookingForm` gives what that string is made of — the words, the services
15
+ * in their runs, the form's attributes, the days — for a page that writes the
16
+ * markup itself, the way `@jtakeit/astro`'s `BookingForm.astro` does, so the
17
+ * markup is the site's and only the facts are the kit's. Either way, keep the
18
+ * hooks (`./hooks.mjs`): the element reads those and nothing else, and the
16
19
  * platform reads what the element sends.
17
20
  *
18
21
  * ── what the page passes in ─────────────────────────────────────────────────
@@ -87,31 +90,45 @@ function esc(value) {
87
90
  /**
88
91
  * A service's price as the list says it: the price, or «from …» for one
89
92
  * priced by the hour of the week — whose price is the slot's and is printed
90
- * beside the time once one is chosen (wiki/68).
93
+ * beside the time once one is chosen (wiki/68). Plain text, not escaped.
91
94
  * @param {Named} one
92
95
  * @param {string} currency
93
96
  * @param {string} lang
97
+ * @param {Record<string, string>} words
94
98
  */
95
- function priceSaid(one, currency, lang) {
96
- if (one.costs) return ` · ${esc(money(one.costs, currency, lang))}`;
99
+ function priceText(one, currency, lang, words) {
100
+ if (one.costs) return money(one.costs, currency, lang);
97
101
  const least = priceFrom(one);
98
- return least > 0 ? ` · ${esc(wordsFor(lang).from)} ${esc(money(least, currency, lang))}` : '';
102
+ return least > 0 ? `${words.from} ${money(least, currency, lang)}` : '';
99
103
  }
100
104
 
101
105
  /**
102
- * The booking form as HTML.
106
+ * @typedef {object} BookingForm
107
+ * @property {Record<string, string>} words the form's words, in the page's language; the platform's answer replaces the kind's
108
+ * @property {string} lang the language tag prices and days are written in
109
+ * @property {boolean} combine a checklist, or one `<select>`
110
+ * @property {{ group: string; items: (Named & { meta: string })[] }[]} runs the checklist's runs, headings in order, the ones with none after them, the add-ons last under their own; `meta` is «45 min · CHF 95»
111
+ * @property {Named[]} services for one `<select>`, as they came
112
+ * @property {(Named & { does: string })[]} resources the choice of whom — empty for none; `does` is the slugs, joined, for `data-does`
113
+ * @property {string} today the day input's `min`
114
+ * @property {string} last its `max`
115
+ * @property {Record<string, string>} attrs every attribute of the `<form>`, `data-booking` and `novalidate` among them — spread them on it
116
+ */
117
+
118
+ /**
119
+ * What the booking form is made of, for a page that writes the markup.
103
120
  *
104
121
  * @param {BookingFormProps} props
105
- * @returns {string}
122
+ * @returns {BookingForm}
106
123
  */
107
- export function renderBookingForm(props) {
124
+ export function bookingForm(props) {
108
125
  const {
109
126
  services, resources = [], locale, daysAhead = 30, combine = false, currency = '', scale = '', kind = '',
110
- api = '/api/', tag = 'jtk-booking', now = new Date(), open = [], ui = true,
127
+ api = '/api/', now = new Date(), open = [], ui = true,
111
128
  } = props;
112
- if (!Array.isArray(services)) throw new TypeError('renderBookingForm: services is a list of { slug, title }');
129
+ if (!Array.isArray(services)) throw new TypeError('bookingForm: services is a list of { slug, title }');
113
130
  const unsaid = kind !== '' && kind !== 'salon';
114
- const WORDS = wordsFor(locale, scale);
131
+ const words = wordsFor(locale, scale);
115
132
  const lang = { de: 'de-CH', uk: 'uk-UA', ru: 'ru-RU', es: 'es-ES', it: 'it-IT', pt: 'pt-PT', fr: 'fr-FR' }[locale] ?? 'en';
116
133
 
117
134
  // The checklist's runs, in the order the services came, each heading once.
@@ -139,15 +156,62 @@ export function renderBookingForm(props) {
139
156
  // its own heading, after the services, and rides on the visit (wiki/70).
140
157
  const addOns = runs.flatMap((run) => run.items.filter((one) => !one.takes && (one.costs || priceFrom(one))));
141
158
  const timedRuns = runs.map((run) => ({ ...run, items: run.items.filter((one) => !addOns.includes(one)) })).filter((run) => run.items.length > 0);
159
+ /** @param {Named} one */
160
+ const meta = (one) => [one.takes ? `${one.takes} min` : '', priceText(one, currency, lang, words), one.per === 'person' ? words.perPerson : ''].filter(Boolean).join(' · ');
161
+
162
+ // Fixed, the whole form, as the lead form's labels are: every word in it is
163
+ // the platform's — spoken in the business's kind from the availability
164
+ // answer, «Забронювати» over the build's «Записатися» — or an entry's own
165
+ // title, which the owner edits on the entry. None is the owner's to edit
166
+ // here, and `jtk catalogue` asked whose all seventeen were on a fresh
167
+ // restaurant (30 September 2026).
168
+ /** @type {Record<string, string>} */
169
+ const attrs = {
170
+ 'data-booking': '',
171
+ 'data-locale': locale,
172
+ 'data-currency': currency,
173
+ 'data-words': JSON.stringify(words),
174
+ 'data-scale': scale,
175
+ 'data-api': api,
176
+ 'data-sends': SENDS.join(','),
177
+ 'data-open': open.join(','),
178
+ ...(ui ? { 'data-ui': ui === true ? 'steps week parts select' : String(ui) } : {}),
179
+ ...(unsaid ? { 'data-unsaid': '' } : {}),
180
+ // Its own name as its value: Astro drops a boolean attribute spread as
181
+ // '', and the browser's own check stopped every booking without a name
182
+ // before the form could say so (12 October 2026).
183
+ novalidate: 'novalidate',
184
+ 'data-jtk-fixed': '',
185
+ };
186
+
187
+ return {
188
+ words, lang, combine, today, last, attrs, services,
189
+ runs: [...timedRuns, ...(addOns.length ? [{ group: words.addOns, items: addOns }] : [])].map((run) => ({ ...run, items: run.items.map((one) => ({ ...one, meta: meta(one) })) })),
190
+ resources: resources.length > 1 ? resources.map((one) => ({ ...one, does: Array.isArray(one.does) ? one.does.join(',') : '' })) : [],
191
+ };
192
+ }
193
+
194
+ /**
195
+ * The booking form as HTML — `bookingForm`'s facts in the kit's own markup.
196
+ *
197
+ * @param {BookingFormProps} props
198
+ * @returns {string}
199
+ */
200
+ export function renderBookingForm(props) {
201
+ const { tag = 'jtk-booking', kind = '' } = props;
202
+ if (!Array.isArray(props.services)) throw new TypeError('renderBookingForm: services is a list of { slug, title }');
203
+ const { words: WORDS, combine, runs, services, resources, today, last, attrs } = bookingForm(props);
204
+ const unsaid = kind !== '' && kind !== 'salon';
205
+
142
206
  const servicesField = combine
143
207
  ? `<fieldset class="booking__services" data-services>
144
208
  <legend class="field__label" data-say="services">${esc(WORDS.services)}</legend>
145
- ${[...timedRuns, ...(addOns.length ? [{ group: WORDS.addOns, items: addOns }] : [])].map((run) => `<div class="booking__run">
146
- ${run.group !== '' ? `<p class="booking__run-title">${esc(run.group)}</p>` : ''}
147
- ${run.items.map((one) => `<label class="booking__service">
209
+ ${runs.map((run, i) => `<div class="booking__run"${run.group !== '' ? ` role="group" aria-labelledby="booking-run-${i}"` : ''}>
210
+ ${run.group !== '' ? `<p class="booking__run-title" id="booking-run-${i}">${esc(run.group)}</p>` : ''}
211
+ ${run.items.map((one) => `<label class="booking__service" data-service-row>
148
212
  <input type="checkbox" name="service" value="${esc(one.slug)}" data-takes="${esc(one.takes ?? 0)}" data-costs="${esc(one.costs ?? 0)}"${one.per === 'person' ? ' data-per="person"' : ''}>
149
- <span class="booking__service-name">${esc(one.title)}</span>
150
- <span class="booking__service-meta">${one.takes ? `${esc(one.takes)} min` : ''}${priceSaid(one, currency, lang)}${one.per === 'person' ? ` · ${esc(WORDS.perPerson)}` : ''}</span>
213
+ <span class="booking__service-name" data-service-name>${esc(one.title)}</span>
214
+ <span class="booking__service-meta">${esc(one.meta)}</span>
151
215
  </label>`).join('\n')}
152
216
  </div>`).join('\n')}
153
217
  <p class="booking__total" data-total hidden></p>
@@ -157,53 +221,48 @@ export function renderBookingForm(props) {
157
221
  ${services.map((one) => `<option value="${esc(one.slug)}">${esc(one.title)}</option>`).join('\n')}
158
222
  </select>`;
159
223
 
160
- const resourcesField = resources.length > 1
161
- ? `<div class="field">
224
+ const resourcesField = resources.length > 0
225
+ ? `<div class="field" data-field>
162
226
  <label class="field__label" for="booking-resource" data-say="resource">${esc(WORDS.resource)}</label>
163
227
  <select class="field__input" id="booking-resource" name="resource" data-say-wait>
164
228
  <option value="" data-say="any">${esc(WORDS.any)}</option>
165
- ${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')}
229
+ ${resources.map((one) => `<option value="${esc(one.slug)}"${one.does ? ` data-does="${esc(one.does)}"` : ''}>${esc(one.title)}</option>`).join('\n')}
166
230
  </select>
167
231
  </div>`
168
232
  : '';
169
233
 
170
- // Fixed, the whole form, as the lead form's labels are: every word in it is
171
- // the platform's — spoken in the business's kind from the availability
172
- // answer, «Забронювати» over the build's «Записатися» — or an entry's own
173
- // title, which the owner edits on the entry. None is the owner's to edit
174
- // here, and `jtk catalogue` asked whose all seventeen were on a fresh
175
- // restaurant (30 September 2026).
176
- 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>
234
+ const said = Object.entries(attrs).map(([key, value]) => (value === '' || value === key ? ` ${key}` : ` ${key}="${esc(value)}"`)).join('');
235
+ const form = `<form class="booking${unsaid ? ' booking--unsaid' : ''}"${said}>
177
236
  <noscript><p class="booking__note">${esc(WORDS.noScript)}</p></noscript>
178
237
 
179
- <div class="field">
238
+ <div class="field" data-field>
180
239
  ${servicesField}
181
240
  </div>
182
241
 
183
242
  ${resourcesField}
184
243
 
185
- <div class="field">
244
+ <div class="field" data-field>
186
245
  <label class="field__label" for="booking-day">${esc(WORDS.day)}</label>
187
246
  <input class="field__input" id="booking-day" name="day" type="date" min="${today}" max="${last}" required>
188
247
  </div>
189
248
 
190
- <div class="field" data-back-field hidden>
249
+ <div class="field" data-field data-back-field hidden>
191
250
  <label class="field__label" for="booking-back">${esc(WORDS.back)}</label>
192
251
  <input class="field__input" id="booking-back" name="back" type="date" min="${today}">
193
252
  <p class="booking__hint" data-length hidden></p>
194
253
  </div>
195
254
 
196
- <div class="field" data-hours-field hidden>
255
+ <div class="field" data-field data-hours-field hidden>
197
256
  <label class="field__label" for="booking-hours">${esc(WORDS.hours)}</label>
198
257
  <select class="field__input" id="booking-hours" name="hours"></select>
199
258
  </div>
200
259
 
201
- <div class="field" data-party-field hidden>
260
+ <div class="field" data-field data-party-field hidden>
202
261
  <label class="field__label" for="booking-party">${esc(WORDS.party)}</label>
203
262
  <select class="field__input" id="booking-party" name="party"></select>
204
263
  </div>
205
264
 
206
- <div class="field" data-quantity-field hidden>
265
+ <div class="field" data-field data-quantity-field hidden>
207
266
  <label class="field__label" for="booking-quantity">${esc(WORDS.quantity)}</label>
208
267
  <select class="field__input" id="booking-quantity" name="quantity"></select>
209
268
  </div>
@@ -216,26 +275,26 @@ export function renderBookingForm(props) {
216
275
  <p class="booking__total" data-stay-total hidden></p>
217
276
  </fieldset>
218
277
 
219
- <div class="field">
278
+ <div class="field" data-field>
220
279
  <label class="field__label" for="booking-name">${esc(WORDS.name)} <span aria-hidden="true">*</span></label>
221
280
  <input class="field__input" id="booking-name" name="name" type="text" autocomplete="name" required maxlength="120">
222
281
  </div>
223
- <div class="field">
282
+ <div class="field" data-field>
224
283
  <label class="field__label" for="booking-phone">${esc(WORDS.phone)}</label>
225
284
  <input class="field__input" id="booking-phone" name="phone" type="tel" inputmode="tel" autocomplete="tel" maxlength="40">
226
285
  </div>
227
- <div class="field">
286
+ <div class="field" data-field>
228
287
  <label class="field__label" for="booking-email">${esc(WORDS.email)}</label>
229
288
  <input class="field__input" id="booking-email" name="email" type="email" inputmode="email" autocomplete="email" maxlength="120">
230
289
  </div>
231
- <div class="field">
290
+ <div class="field" data-field>
232
291
  <label class="field__label" for="booking-note">${esc(WORDS.note)}</label>
233
292
  <textarea class="field__input field__input--area" id="booking-note" name="note" rows="3" maxlength="2000"></textarea>
234
293
  </div>
235
294
 
236
295
  <div class="booking__challenge" data-challenge hidden></div>
237
296
 
238
- <p class="field__error" data-booking-error role="alert" hidden></p>
297
+ <p class="field__error" id="booking-error" data-booking-error role="alert" hidden></p>
239
298
 
240
299
  <button class="booking__submit" type="submit" data-submit data-say="book">${esc(WORDS.book)}</button>
241
300
 
@@ -250,6 +309,7 @@ export function renderBookingForm(props) {
250
309
  <button type="button" class="booking__keep-copy" data-keep-copy></button>
251
310
  <a class="booking__keep-open" data-keep-open></a>
252
311
  </div>
312
+ <span class="booking__sr" role="status" data-keep-said></span>
253
313
  </div>
254
314
  <p class="booking__note" data-pay-note hidden></p>
255
315
  <p class="booking__links">
@@ -49,6 +49,7 @@ const BASE = {
49
49
  back: 'До', length: 'Від {min} до {max} діб.', anyOf: 'Будь-яке вільне — {class}', noneOf: '{class} — немає вільних', notFor: '{who} — не для обраного', notForChosen: '{who} — не для обраного. Оберіть іншого або «{any}».', took: 'Ви берете: {what}',
50
50
  party: 'Скільки вас', perPerson: 'за особу', addOns: 'Додатково', quantity: 'Скільки — доріжок, столів, кімнат', together: '{n} разом', hours: 'Скільки часу', hourShort: 'год', minuteShort: 'хв', challenge: 'Підтвердіть, що ви не робот, і спробуйте ще раз.', tooMany: 'У вас уже є кілька відкритих записів. Скасуйте один із них або зателефонуйте нам.',
51
51
  from: 'від',
52
+ needName: "Вкажіть імʼя, щоб ми знали, кого чекати.",
52
53
  needContact: 'Вкажіть телефон або пошту, щоб ми могли з вами звʼязатися.', noScript: 'Онлайн-форма працює з увімкненим JavaScript — увімкніть його або зателефонуйте нам.',
53
54
  },
54
55
  de: {
@@ -87,6 +88,7 @@ const BASE = {
87
88
  back: 'Bis', length: 'Von {min} bis {max} Nächte.', anyOf: 'Beliebig frei — {class}', noneOf: '{class} — nichts verfügbar', notFor: '{who} — nicht für diese Auswahl', notForChosen: '{who} — nicht für diese Auswahl. Bitte jemand anderen oder «{any}» wählen.', took: 'Sie nehmen: {what}',
88
89
  party: 'Wie viele Personen', perPerson: 'pro Person', addOns: 'Zusätzlich', quantity: 'Wie viele — Bahnen, Tische, Zimmer', together: '{n} zusammen', hours: 'Wie lange', hourShort: 'Std.', minuteShort: 'Min.', 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.',
89
90
  from: 'ab',
91
+ needName: "Bitte geben Sie Ihren Namen an.",
90
92
  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.',
91
93
  },
92
94
  en: {
@@ -125,6 +127,7 @@ const BASE = {
125
127
  back: 'Until', length: 'From {min} to {max} nights.', anyOf: 'Any free — {class}', noneOf: '{class} — none available', notFor: '{who} — not for this choice', notForChosen: '{who} — not for this choice. Pick another, or «{any}».', took: 'You are taking: {what}',
126
128
  party: 'How many of you', perPerson: 'per person', addOns: 'Add-ons', quantity: 'How many — lanes, tables, rooms', together: '{n} together', hours: 'How long', hourShort: 'h', minuteShort: 'min', challenge: 'Please confirm you are not a robot and try again.', tooMany: 'You already have several open bookings. Cancel one, or ring us.',
127
129
  from: 'from',
130
+ needName: "Please give your name.",
128
131
  needContact: 'Leave a phone number or an email so we can reach you.', noScript: 'Booking online needs JavaScript — or ring us.',
129
132
  },
130
133
  ru: {
@@ -192,6 +195,7 @@ const BASE = {
192
195
  challenge: "Подтвердите, что вы не робот, и попробуйте еще раз.",
193
196
  tooMany: "У вас уже есть несколько открытых записей. Отмените одну из них или позвоните нам.",
194
197
  from: 'от',
198
+ needName: "Укажите имя, чтобы мы знали, кого ждать.",
195
199
  needContact: "Укажите телефон или почту, чтобы мы могли с вами связаться.",
196
200
  noScript: "Онлайн-форма работает с включенным JavaScript — включите его или позвоните нам.",
197
201
  },
@@ -260,6 +264,7 @@ const BASE = {
260
264
  challenge: "Confirme que no es un robot e inténtelo de nuevo.",
261
265
  tooMany: "Ya tiene varias reservas abiertas. Cancele una o llámenos.",
262
266
  from: 'desde',
267
+ needName: "Indique su nombre, por favor.",
263
268
  needContact: "Deje un teléfono o un correo electrónico para que podamos contactar con usted.",
264
269
  noScript: "Para reservar en línea hace falta JavaScript — o llámenos.",
265
270
  },
@@ -328,6 +333,7 @@ const BASE = {
328
333
  challenge: "Confermi di non essere un robot e riprovi.",
329
334
  tooMany: "Ha già diverse prenotazioni aperte. Ne annulli una, o ci chiami.",
330
335
  from: 'da',
336
+ needName: "Indichi il Suo nome, per favore.",
331
337
  needContact: "Lasci un numero di telefono o un'email per poterLa contattare.",
332
338
  noScript: "Per prenotare online serve JavaScript: oppure ci chiami.",
333
339
  },
@@ -396,6 +402,7 @@ const BASE = {
396
402
  challenge: "Confirme que não é um robô e tente de novo.",
397
403
  tooMany: "Já tem várias reservas em aberto. Cancele uma ou ligue-nos.",
398
404
  from: 'desde',
405
+ needName: "Indique o seu nome, por favor.",
399
406
  needContact: "Deixe um telefone ou um e-mail para podermos contactá-lo.",
400
407
  noScript: "Reservar online precisa de JavaScript — ou ligue-nos.",
401
408
  },
@@ -464,6 +471,7 @@ const BASE = {
464
471
  challenge: "Veuillez confirmer que vous n’êtes pas un robot et réessayer.",
465
472
  tooMany: "Vous avez déjà plusieurs réservations en cours. Annulez-en une, ou appelez-nous.",
466
473
  from: 'à partir de',
474
+ needName: "Indiquez votre nom, s'il vous plaît.",
467
475
  needContact: "Laissez un numéro de téléphone ou un e-mail pour que nous puissions vous joindre.",
468
476
  noScript: "Réserver en ligne nécessite JavaScript — ou appelez-nous.",
469
477
  },
package/lib/lint.mjs CHANGED
@@ -363,8 +363,45 @@ export async function lint(options = {}) {
363
363
  * The command: print the findings the way the build does, and exit 1 on any.
364
364
  * @param {{ root?: string, dist?: string, siteURL?: string }} options
365
365
  */
366
+ /**
367
+ * The files under a built directory the platform never publishes: a path with
368
+ * a segment that starts with a dot, `.well-known/` at the root aside — the
369
+ * rule the platform's upload and edge keep (its wiki/81). A directory is said
370
+ * once, with a slash.
371
+ * @param {string} dir
372
+ * @returns {Promise<string[]>}
373
+ */
374
+ export async function withheldIn(dir) {
375
+ /** @type {string[]} */
376
+ const out = [];
377
+ /** @param {string} at @param {string} rel */
378
+ async function walk(at, rel) {
379
+ let entries;
380
+ try {
381
+ entries = await readdir(at, { withFileTypes: true });
382
+ } catch {
383
+ return;
384
+ }
385
+ for (const entry of entries) {
386
+ const path = rel === '' ? entry.name : `${rel}/${entry.name}`;
387
+ const dotted = entry.name.startsWith('.') && !(rel === '' && entry.name === '.well-known');
388
+ if (dotted) out.push(entry.isDirectory() ? `${path}/` : path);
389
+ else if (entry.isDirectory()) await walk(join(at, entry.name), path);
390
+ }
391
+ }
392
+ await walk(dir, '');
393
+ return out.sort();
394
+ }
395
+
366
396
  export async function lintCommand(options) {
367
397
  const out = await lint(options);
398
+ // Said, and not a failure: the platform leaves these out of the upload and
399
+ // its preflight fails the site until a build without them is served. Here
400
+ // they are caught before the push, which is when they are cheapest.
401
+ const withheld = await withheldIn(options.dist ?? join(options.root ?? process.cwd(), 'dist'));
402
+ if (withheld.length > 0) {
403
+ console.error(` not published: ${withheld.join(', ')} — a path starting with a dot is never served (.well-known/ aside). It comes from the repository (usually public/): remove it there, and change any secret it held — it is in the history.`);
404
+ }
368
405
  for (const one of out.findings) console.error(' ' + said(one.code, one.says));
369
406
  if (out.line !== '') (out.ok ? console.log : console.error)(' ' + out.line);
370
407
  return out.ok ? 0 : 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jtakeit/kit",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "The framework-free half of a site on jtakeit: the booking form as an element and as markup, for Astro through @jtakeit/astro and for any other generator directly",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,6 +24,10 @@
24
24
  "types": "./types/parts.d.ts",
25
25
  "default": "./lib/booking/parts.mjs"
26
26
  },
27
+ "./booking/hooks": {
28
+ "types": "./types/hooks.d.ts",
29
+ "default": "./lib/booking/hooks.mjs"
30
+ },
27
31
  "./lead": {
28
32
  "types": "./types/lead.d.ts",
29
33
  "default": "./lib/lead/render.mjs"
@@ -35,5 +35,26 @@ export interface BookingFormProps {
35
35
 
36
36
  /** What the form sends to /api/book, as `data-sends` says it. */
37
37
  export const SENDS: readonly string[];
38
+ /** What the booking form is made of, for a page that writes the markup itself (the scaffold's BookingForm.astro). */
39
+ export interface BookingForm {
40
+ /** The form's words in the page's language; the platform's answer replaces the kind's. */
41
+ words: Record<string, string>;
42
+ /** The language tag prices and days are written in. */
43
+ lang: string;
44
+ /** A checklist, or one `<select>`. */
45
+ combine: boolean;
46
+ /** The checklist's runs: headings in order, the ones with none after them, add-ons last. `meta` is «45 min · CHF 95». */
47
+ runs: { group: string; items: (Named & { meta: string })[] }[];
48
+ /** For one `<select>`, as they came. */
49
+ services: Named[];
50
+ /** The choice of whom — empty for none; `does` is the slugs joined for `data-does`. */
51
+ resources: (Omit<Named, 'does'> & { does: string })[];
52
+ /** The day input's `min` and `max`. */
53
+ today: string;
54
+ last: string;
55
+ /** Every attribute of the `<form>` — `data-booking`, `data-api`, `data-sends`, `novalidate`… — to spread on it. */
56
+ attrs: Record<string, string>;
57
+ }
58
+ export function bookingForm(props: BookingFormProps): BookingForm;
38
59
  /** The booking form as HTML, wrapped in `<jtk-booking>`. */
39
60
  export function renderBookingForm(props: BookingFormProps): string;
@@ -7,3 +7,4 @@ export class JtkBooking extends HTMLElement {
7
7
  connectedCallback(): void;
8
8
  }
9
9
  export function defineBooking(name?: string): void;
10
+ export const BOOKING_PROTOCOL: number;
@@ -0,0 +1,12 @@
1
+ export interface Hook {
2
+ hook: string;
3
+ says: string;
4
+ }
5
+ /** Every form has these, or it cannot book. */
6
+ export const ALWAYS: readonly Hook[];
7
+ /** Shown only when the platform's answer asks; preflight fails a form without one where the business asks. */
8
+ export const ASKED: readonly (Hook & { when: string })[];
9
+ /** Where the element writes what happened; left out, that line is not said. */
10
+ export const SAID: readonly Hook[];
11
+ export const HOOKS: readonly string[];
12
+ export function lacking(form: ParentNode): string[];
package/types/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
- export { renderBookingForm, SENDS } from './booking';
2
- export type { BookingFormProps, Named } from './booking';
1
+ export { renderBookingForm, bookingForm, SENDS } from './booking';
2
+ export type { BookingFormProps, BookingForm, Named } from './booking';
3
+ export { ALWAYS, ASKED, SAID, HOOKS } from './hooks';
4
+ export type { Hook } from './hooks';
3
5
  export { renderLeadForm, inputType } from './lead';
4
6
  export type { Ask, LeadCopy, LeadPaths, LeadFormProps } from './lead';
5
7
  export { wordsFor, LOCALES } from './words';