@jtakeit/kit 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -7
- package/booking.css +23 -10
- package/elements/booking-ui.js +91 -21
- package/elements/booking.js +170 -29
- package/elements/lead.js +26 -4
- package/elements/writing.js +109 -7
- package/index.mjs +2 -1
- package/lib/booking/hooks.mjs +91 -0
- package/lib/booking/render.mjs +101 -41
- package/lib/booking/words.mjs +8 -0
- package/lib/lint.mjs +37 -0
- package/lib/phases.mjs +22 -1
- package/package.json +5 -1
- package/types/booking.d.ts +21 -0
- package/types/elements-booking.d.ts +1 -0
- package/types/hooks.d.ts +12 -0
- package/types/index.d.ts +4 -2
- package/types/phases.d.ts +1 -0
package/index.mjs
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* and the enquiry form the same way: `@jtakeit/kit/lead`,
|
|
16
16
|
* `@jtakeit/kit/elements/lead`, `@jtakeit/kit/lead.css`.
|
|
17
17
|
*/
|
|
18
|
-
export { renderBookingForm, SENDS } from './lib/booking/render.mjs';
|
|
18
|
+
export { renderBookingForm, bookingForm, SENDS } from './lib/booking/render.mjs';
|
|
19
|
+
export { ALWAYS, ASKED, SAID, HOOKS } from './lib/booking/hooks.mjs';
|
|
19
20
|
export { renderLeadForm, inputType } from './lib/lead/render.mjs';
|
|
20
21
|
export { wordsFor, LOCALES } from './lib/booking/words.mjs';
|
|
21
22
|
export { money } from './lib/money.mjs';
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `<jtk-booking>` reads off a booking form — the whole of the contract
|
|
3
|
+
* between a form's markup and the element that makes it work.
|
|
4
|
+
*
|
|
5
|
+
* The element finds everything by these: a `data-*` attribute or an input's
|
|
6
|
+
* `name`, never a class. Classes, wrappers, the order of the fields, the
|
|
7
|
+
* words and the look are the site's; a form an agent copies and dresses in
|
|
8
|
+
* its own design keeps working as long as these are on it.
|
|
9
|
+
*
|
|
10
|
+
* Three kinds:
|
|
11
|
+
*
|
|
12
|
+
* ALWAYS every form has them, or it cannot book — the element says which
|
|
13
|
+
* is missing in the console, and the platform's preflight fails a
|
|
14
|
+
* booking page without them;
|
|
15
|
+
* ASKED a field the element shows only when the platform's answer asks
|
|
16
|
+
* for it — a party, how many, how long, a second date. A form for
|
|
17
|
+
* a business that never asks may leave one out; preflight fails it
|
|
18
|
+
* where the business does ask;
|
|
19
|
+
* SAID where the element writes what happened — the running total, when
|
|
20
|
+
* the visit ends, what was taken, the link to keep. Left out, that
|
|
21
|
+
* line is not said and nothing else changes.
|
|
22
|
+
*
|
|
23
|
+
* The test (kit/test/hooks.test.mjs) holds `renderBookingForm` and the
|
|
24
|
+
* scaffold's `BookingForm.astro` to ALWAYS, ASKED and SAID; the platform's
|
|
25
|
+
* preflight keeps its own copy of ALWAYS and ASKED, written from this one.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** @typedef {{ hook: string; says: string }} Hook */
|
|
29
|
+
|
|
30
|
+
/** @type {readonly Hook[]} */
|
|
31
|
+
export const ALWAYS = Object.freeze([
|
|
32
|
+
{ hook: 'form[data-booking]', says: 'the form; `data-api`, `data-sends` and `data-locale` on it' },
|
|
33
|
+
{ hook: '[name=service]', says: 'a `<select>` for one service, or checkboxes for several (`combine`)' },
|
|
34
|
+
{ hook: '[name=day]', says: 'an `<input type="date">`; the week of days is drawn over it' },
|
|
35
|
+
{ hook: '[data-times]', says: 'around the times: the hint, the times and when the visit ends' },
|
|
36
|
+
{ hook: '[data-slots]', says: 'empty; the element writes a button per free time into it' },
|
|
37
|
+
{ hook: '[data-times-hint]', says: '«pick a day», «nothing free», «…» while it asks' },
|
|
38
|
+
{ hook: '[name=name]', says: 'the visitor\'s name' },
|
|
39
|
+
{ hook: '[name=phone]', says: 'a phone or an email, one of the two is needed' },
|
|
40
|
+
{ hook: '[name=email]', says: 'a phone or an email, one of the two is needed' },
|
|
41
|
+
{ hook: '[data-challenge]', says: 'empty and hidden; the anti-bot widget goes here where the host has one' },
|
|
42
|
+
{ hook: '[data-booking-error]', says: 'the refusal, said; tied to the fields it is about' },
|
|
43
|
+
{ hook: '[data-submit]', says: 'the submit button' },
|
|
44
|
+
{ hook: '[data-done]', says: 'hidden; shown once booked, with the lines below inside it' },
|
|
45
|
+
{ hook: '[data-done-line]', says: '«you are booked» or «waiting for the business»' },
|
|
46
|
+
{ hook: '[data-done-when]', says: 'the day and the time booked' },
|
|
47
|
+
{ hook: '[data-manage]', says: 'a link to the visitor\'s own page: cancel, move' },
|
|
48
|
+
{ hook: '[data-pay-note]', says: 'the sentence before paying, where the booking is paid online' },
|
|
49
|
+
{ hook: '[data-pay]', says: 'the link that pays, where the booking is paid online' },
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/** @type {readonly (Hook & { when: string })[]} */
|
|
53
|
+
export const ASKED = Object.freeze([
|
|
54
|
+
{ hook: '[data-party-field]', when: 'party_max > 1', says: 'hidden; around `[name=party]`, a `<select>` the element fills' },
|
|
55
|
+
{ hook: '[name=party]', when: 'party_max > 1', says: 'how many people' },
|
|
56
|
+
{ hook: '[data-quantity-field]', when: 'quantity_max > 1', says: 'hidden; around `[name=quantity]`' },
|
|
57
|
+
{ hook: '[name=quantity]', when: 'quantity_max > 1', says: 'how many of a class at once' },
|
|
58
|
+
{ hook: '[data-hours-field]', when: 'an hourly service with most > fewest', says: 'hidden; around `[name=hours]`' },
|
|
59
|
+
{ hook: '[name=hours]', when: 'an hourly service with most > fewest', says: 'how long, in the service\'s units' },
|
|
60
|
+
{ hook: '[data-back-field]', when: 'a daily service with most > fewest', says: 'hidden; around `[name=back]` and `[data-length]`' },
|
|
61
|
+
{ hook: '[name=back]', when: 'a daily service with most > fewest', says: 'the second date: until when' },
|
|
62
|
+
{ hook: '[data-length]', when: 'a daily service with most > fewest', says: 'how many nights may be taken' },
|
|
63
|
+
{ hook: '[name=resource]', when: 'the visitor chooses whom', says: 'a `<select>`: anyone, a class, or one by name' },
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/** @type {readonly Hook[]} */
|
|
67
|
+
export const SAID = Object.freeze([
|
|
68
|
+
{ hook: '[data-field]', says: 'on each field\'s wrapper: what the two steps sort and what is hidden once booked' },
|
|
69
|
+
{ hook: '[data-services]', says: 'the checklist\'s fieldset' },
|
|
70
|
+
{ hook: '[data-service-row]', says: 'the `<label>` around each checkbox: greyed with `data-off` when the one chosen does not do it' },
|
|
71
|
+
{ hook: '[data-service-name]', says: 'the service\'s name inside the row' },
|
|
72
|
+
{ hook: '[data-total]', says: 'the running total of a checklist' },
|
|
73
|
+
{ hook: '[data-ends]', says: '«until about 10:45 · 1 h 15 min»' },
|
|
74
|
+
{ hook: '[data-stay-total]', says: 'the price of a stay' },
|
|
75
|
+
{ hook: '[data-done-what]', says: 'what was taken: «with Noa», «any of Economy»' },
|
|
76
|
+
{ hook: '[data-keep]', says: 'hidden; the link to keep, with `[data-keep-note]`, `[data-keep-link]`, `[data-keep-copy]`, `[data-keep-open]`, `[data-keep-said]`' },
|
|
77
|
+
{ hook: '[data-telegram]', says: 'a link to the business\'s Telegram bot, where it has one' },
|
|
78
|
+
{ hook: '[name=note]', says: 'a note to the business' },
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
/** Every hook, for a check that reads them all. */
|
|
82
|
+
export const HOOKS = Object.freeze([...ALWAYS, ...ASKED, ...SAID].map((one) => one.hook));
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The hooks in `ALWAYS` a form lacks — what the element says in the console
|
|
86
|
+
* when it wires one.
|
|
87
|
+
* @param {ParentNode & { matches?: (s: string) => boolean }} form
|
|
88
|
+
*/
|
|
89
|
+
export function lacking(form) {
|
|
90
|
+
return ALWAYS.filter(({ hook }) => hook !== 'form[data-booking]' && !form.querySelector(hook)).map(({ hook }) => hook);
|
|
91
|
+
}
|
package/lib/booking/render.mjs
CHANGED
|
@@ -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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
96
|
-
if (one.costs) return
|
|
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 ?
|
|
102
|
+
return least > 0 ? `${words.from} ${money(least, currency, lang)}` : '';
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
/**
|
|
102
|
-
*
|
|
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 {
|
|
122
|
+
* @returns {BookingForm}
|
|
106
123
|
*/
|
|
107
|
-
export function
|
|
124
|
+
export function bookingForm(props) {
|
|
108
125
|
const {
|
|
109
126
|
services, resources = [], locale, daysAhead = 30, combine = false, currency = '', scale = '', kind = '',
|
|
110
|
-
api = '/api/',
|
|
127
|
+
api = '/api/', now = new Date(), open = [], ui = true,
|
|
111
128
|
} = props;
|
|
112
|
-
if (!Array.isArray(services)) throw new TypeError('
|
|
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
|
|
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
|
-
${
|
|
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">${
|
|
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 >
|
|
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)}"${
|
|
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
|
-
|
|
171
|
-
|
|
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">
|
package/lib/booking/words.mjs
CHANGED
|
@@ -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/lib/phases.mjs
CHANGED
|
@@ -103,9 +103,15 @@ export function sayPhase(where, progress, platform) {
|
|
|
103
103
|
case 'laidOut':
|
|
104
104
|
return 'Laying the site out';
|
|
105
105
|
case 'writing': {
|
|
106
|
+
// In the order the work goes, not always «the pages»: an agent declaring
|
|
107
|
+
// the blocks and setting the diary up for five minutes read as writing
|
|
108
|
+
// a page it had not reached (13 October 2026).
|
|
109
|
+
if (progress && progress.catalogue !== true) return 'Declaring the blocks — what the site is made of';
|
|
110
|
+
if (progress && progress.kit !== null && progress.settings === null) return 'Setting up the bookings — hours, prices, what is booked';
|
|
106
111
|
const pages = progress?.pages ?? [];
|
|
107
112
|
const written = pages.filter((one) => one.written).length;
|
|
108
|
-
return `Writing the pages — ${written} of ${pages.length} with their text
|
|
113
|
+
if (pages.length > 0 && written < pages.length) return `Writing the pages — ${written} of ${pages.length} with their text`;
|
|
114
|
+
return `Finishing — still to do: ${progress?.missing.length ?? 0}`;
|
|
109
115
|
}
|
|
110
116
|
case 'judged':
|
|
111
117
|
if (!known || !platform.judged) return 'To be checked before the push';
|
|
@@ -137,3 +143,18 @@ export function meanwhile(platform) {
|
|
|
137
143
|
done: theirs.filter((step) => step.done).map(named),
|
|
138
144
|
};
|
|
139
145
|
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Whether the page being looked at has no words yet: a page the files know,
|
|
149
|
+
* with not one text field filled. A page they do not know — a listing, an
|
|
150
|
+
* entry, a 404 — is never blank by this; nor is any page before the files
|
|
151
|
+
* could be read.
|
|
152
|
+
* @param {any} progress
|
|
153
|
+
*/
|
|
154
|
+
export function blankHere(progress, path = typeof location === 'undefined' ? '/' : location.pathname) {
|
|
155
|
+
if (!progress || !Array.isArray(progress.pages)) return false;
|
|
156
|
+
const norm = (p) => (p.length > 1 && p.endsWith('/') ? p.slice(0, -1) : p);
|
|
157
|
+
const here = norm(path);
|
|
158
|
+
const page = progress.pages.find((one) => norm(one.path) === here);
|
|
159
|
+
return page !== undefined && page.filled === 0;
|
|
160
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jtakeit/kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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"
|
package/types/booking.d.ts
CHANGED
|
@@ -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;
|
package/types/hooks.d.ts
ADDED
|
@@ -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';
|
package/types/phases.d.ts
CHANGED