@jtakeit/kit 0.0.0-stage → 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.
@@ -0,0 +1,134 @@
1
+ /**
2
+ * `<jtk-lead>` — the enquiry form, spared the navigation.
3
+ *
4
+ * Progressive enhancement only. Every branch here has a working no-JS
5
+ * equivalent: without this module the browser posts the form and the
6
+ * platform answers with a page. What this adds is the answer in place — the
7
+ * sentence the form carries in `data-success` or `data-error`, and the
8
+ * platform's two refusals by name put beside the field they are about.
9
+ *
10
+ * Import once on the page and every `<form data-lead-form>` is wired: the
11
+ * ones inside a `<jtk-lead>` by the element's lifecycle, which also covers
12
+ * markup the panel places later (`fl:placed`); the ones written bare by a
13
+ * walk of the document at load.
14
+ */
15
+
16
+ /**
17
+ * Wires one form. Idempotent: a form already wired is left alone.
18
+ * @param {HTMLFormElement} form
19
+ */
20
+ export function wireLead(form) {
21
+ if (form.dataset.jtkWired === '1') return;
22
+ form.dataset.jtkWired = '1';
23
+
24
+ /** @type {HTMLElement | null} */ const status = form.querySelector('[data-status]');
25
+ /** @type {HTMLButtonElement | null} */ const button = form.querySelector('[data-submit]');
26
+ /** @type {HTMLElement | null} */ const label = form.querySelector('[data-submit-label]');
27
+ if (!status || !button) return;
28
+ const original = label?.textContent ?? '';
29
+
30
+ /** @param {string} field @param {string} message */
31
+ const showError = (field, message) => {
32
+ /** @type {HTMLElement | null} */ const el = form.querySelector(`[data-error-for="${field}"]`);
33
+ if (!el) return;
34
+ el.textContent = message;
35
+ el.hidden = !message;
36
+ };
37
+
38
+ const clearErrors = () => {
39
+ for (const el of /** @type {NodeListOf<HTMLElement>} */ (form.querySelectorAll('[data-error-for]'))) {
40
+ el.textContent = '';
41
+ el.hidden = true;
42
+ }
43
+ };
44
+
45
+ form.addEventListener('submit', async (event) => {
46
+ event.preventDefault();
47
+ clearErrors();
48
+ status.textContent = '';
49
+ form.removeAttribute('data-state');
50
+
51
+ const data = new FormData(form);
52
+ let ok = true;
53
+ for (const field of ['name', 'contact']) {
54
+ const input = /** @type {HTMLInputElement | null} */ (form.elements.namedItem(field));
55
+ if (!input?.value.trim()) {
56
+ showError(field, input?.dataset.message ?? '');
57
+ ok = false;
58
+ }
59
+ }
60
+ if (!ok) return;
61
+
62
+ button.disabled = true;
63
+ if (label) label.textContent = form.dataset.sending ?? original;
64
+
65
+ try {
66
+ const response = await fetch(form.action, {
67
+ method: 'POST',
68
+ headers: { accept: 'application/json' },
69
+ body: data,
70
+ });
71
+ const body = await response.json();
72
+
73
+ if (response.ok && body.ok) {
74
+ form.reset();
75
+ status.textContent = form.dataset.success ?? '';
76
+ // Preview only: the destination is not connected, so nothing was
77
+ // delivered. Visible to the studio, meaningless to a visitor. It
78
+ // disappears in production.
79
+ if (body.demo) status.textContent += ' ⚠︎';
80
+ } else if (body.error === 'anonymous' || body.error === 'unreachable') {
81
+ // The platform refuses by name — no name, no way back to the sender —
82
+ // and the sentence for each is the one the field already carries.
83
+ const field = body.error === 'anonymous' ? 'name' : 'contact';
84
+ const input = /** @type {HTMLInputElement | null} */ (form.elements.namedItem(field));
85
+ showError(field, input?.dataset.message ?? '');
86
+ input?.focus();
87
+ } else {
88
+ form.setAttribute('data-state', 'error');
89
+ status.textContent = body.message ?? form.dataset.error ?? '';
90
+ }
91
+ } catch {
92
+ form.setAttribute('data-state', 'error');
93
+ status.textContent = form.dataset.error ?? '';
94
+ } finally {
95
+ button.disabled = false;
96
+ if (label) label.textContent = original;
97
+ }
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Wires every enquiry form under a root that is not wired yet.
103
+ * @param {ParentNode} [root]
104
+ */
105
+ export function wireAll(root = document) {
106
+ for (const form of /** @type {NodeListOf<HTMLFormElement>} */ (root.querySelectorAll('form[data-lead-form]'))) wireLead(form);
107
+ if (root instanceof HTMLFormElement && root.matches('[data-lead-form]')) wireLead(root);
108
+ }
109
+
110
+ const BaseElement = typeof HTMLElement === 'undefined' ? class {} : HTMLElement;
111
+
112
+ /** `<jtk-lead>` wraps the form `renderLeadForm` wrote and wires it when it reaches the document. */
113
+ export class JtkLead extends BaseElement {
114
+ connectedCallback() {
115
+ wireAll(/** @type {any} */ (this));
116
+ }
117
+ }
118
+
119
+ /** @param {string} [name] */
120
+ export function defineLead(name = 'jtk-lead') {
121
+ if (typeof customElements === 'undefined' || customElements.get(name)) return;
122
+ customElements.define(name, JtkLead);
123
+ }
124
+
125
+ if (typeof document !== 'undefined') {
126
+ defineLead();
127
+ const start = () => wireAll(document);
128
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });
129
+ else start();
130
+ document.addEventListener('fl:placed', (event) => {
131
+ const at = event.target;
132
+ if (at instanceof Element) wireAll(at);
133
+ });
134
+ }
package/index.mjs ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @jtakeit/kit — the half of a site on jtakeit that does not depend on how
3
+ * the site is built.
4
+ *
5
+ * `@jtakeit/astro` is the scaffold: an Astro project with the platform's
6
+ * contract already on it. Everything in it that is not Astro's lives here,
7
+ * so a site on any other generator can take the same pieces (wiki/66,
8
+ * layer B): the booking form, as markup to put on a page at build time and
9
+ * as an element that makes it work in the browser.
10
+ *
11
+ * import { renderBookingForm } from '@jtakeit/kit/booking'; // build time
12
+ * import '@jtakeit/kit/elements/booking'; // the page
13
+ * @import '@jtakeit/kit/booking.css'; // optional
14
+ *
15
+ * and the enquiry form the same way: `@jtakeit/kit/lead`,
16
+ * `@jtakeit/kit/elements/lead`, `@jtakeit/kit/lead.css`.
17
+ */
18
+ export { renderBookingForm, SENDS } from './lib/booking/render.mjs';
19
+ export { renderLeadForm, inputType } from './lib/lead/render.mjs';
20
+ export { wordsFor, LOCALES } from './lib/booking/words.mjs';
21
+ export { money } from './lib/money.mjs';
package/lead.css ADDED
@@ -0,0 +1,23 @@
1
+ /*
2
+ * The enquiry form, legible and nothing more — a reference, not a design.
3
+ * `field`, `field__input`, `btn` are names for the site to restyle with its
4
+ * own. Keep `.trap` and the `[hidden]` rule whatever you do to the rest.
5
+ */
6
+ .lead { display: grid; gap: 1.25rem; max-inline-size: 34rem; }
7
+ .lead .field { display: grid; gap: 0.375rem; }
8
+ .lead .field__label { font-size: 0.875rem; }
9
+ .lead .field__input {
10
+ inline-size: 100%; padding: 0.75rem 0.875rem; font: inherit; color: inherit;
11
+ background: var(--paper-deep, #f2efea); border: 1px solid var(--line, #d9d4cc); border-radius: var(--r-2, 8px);
12
+ }
13
+ .lead .field__input--area { resize: vertical; }
14
+ .lead .field__error { margin: 0; font-size: 0.8125rem; color: var(--danger, #b3261e); }
15
+
16
+ /* Bots fill everything; people never see this. Off-screen rather than
17
+ display:none, which some bots detect. */
18
+ .lead .trap { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
19
+
20
+ .lead__foot { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; }
21
+ .lead__status { margin: 0; font-size: 0.875rem; }
22
+ .lead[data-state='error'] .lead__status { color: var(--danger, #b3261e); }
23
+ .lead [hidden] { display: none !important; }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * The booking form, as markup — made at build time by whatever builds the
3
+ * site, and made to work in the browser by `@jtakeit/kit/elements/booking`.
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 the element draws
8
+ * what they answer. Nothing about availability is computed on the page, and
9
+ * nothing about the visitor's choice is trusted by the server.
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
16
+ * platform reads what the element sends.
17
+ *
18
+ * ── what the page passes in ─────────────────────────────────────────────────
19
+ *
20
+ * The services and the resources are collection entries the owner edits, so
21
+ * they arrive from the page that reads the collections. Slugs are what the
22
+ * platform books against; titles are what the visitor reads.
23
+ *
24
+ * ── with JavaScript off ─────────────────────────────────────────────────────
25
+ *
26
+ * A booking cannot be made without a script — the free times are fetched — so
27
+ * with scripts off this shows the sentence in `noScript`, and nothing
28
+ * pretends otherwise.
29
+ */
30
+ import { money } from '../money.mjs';
31
+ import { wordsFor } from './words.mjs';
32
+
33
+ /**
34
+ * What this form sends to /api/book, said on the page. Preflight reads it off
35
+ * the built page and books in exactly this shape, so a form that sends
36
+ * `units` for nights, or never sends `count`, fails the check rather than
37
+ * passing on a request the platform made up (2 October 2026). The element
38
+ * sends no key that is not here — the list cannot drift from the request,
39
+ * because the element imports this one.
40
+ */
41
+ export const SENDS = Object.freeze(['service', 'services', 'resource', 'class', 'party', 'count', 'start', 'name', 'phone', 'email', 'note', 'turnstile']);
42
+
43
+ /**
44
+ * @typedef {object} Named
45
+ * @property {string} slug what the platform books against
46
+ * @property {string} title what the visitor reads
47
+ * @property {number} [takes] minutes, for the running total when several are picked
48
+ * @property {number} [costs] minor units, for the same
49
+ * @property {string} [group] a heading the services are listed under
50
+ */
51
+
52
+ /**
53
+ * @typedef {object} BookingFormProps
54
+ * @property {Named[]} services
55
+ * @property {Named[]} [resources] empty for a solo business: the platform assigns
56
+ * @property {string} locale `uk`, `de` or `en` — decides the words drawn before the platform has spoken
57
+ * @property {number} [daysAhead] how many days ahead the picker offers; the platform clamps to its own horizon
58
+ * @property {boolean} [combine] the module's `combine` setting: several services for one visit, as a checklist
59
+ * @property {string} [currency] the site's currency, for the running total
60
+ * @property {string} [scale] the module's `scale`: `'daily'` for a business that lets things by the day
61
+ * @property {string} [kind] the module's `kind`: for any kind but a salon's the kind's words are held back until the platform has said them
62
+ * @property {string} [api] where `/api/` is from this page — under the preview it carries a path, so pass the address as your generator writes a root-relative one (Astro's `under('/api/')`)
63
+ * @property {string} [tag] the element's name around the form, `jtk-booking`; `''` for none
64
+ * @property {Date} [now] the day the picker starts from; the build's, by default
65
+ */
66
+
67
+ const ESCAPE = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
68
+ /** @param {unknown} value */
69
+ function esc(value) {
70
+ return String(value ?? '').replace(/[&<>"']/g, (c) => ESCAPE[c]);
71
+ }
72
+
73
+ /**
74
+ * The booking form as HTML.
75
+ *
76
+ * @param {BookingFormProps} props
77
+ * @returns {string}
78
+ */
79
+ export function renderBookingForm(props) {
80
+ const {
81
+ services, resources = [], locale, daysAhead = 30, combine = false, currency = 'CHF', scale = '', kind = '',
82
+ api = '/api/', tag = 'jtk-booking', now = new Date(),
83
+ } = props;
84
+ if (!Array.isArray(services)) throw new TypeError('renderBookingForm: services is a list of { slug, title }');
85
+ const unsaid = kind !== '' && kind !== 'salon';
86
+ const WORDS = wordsFor(locale, scale);
87
+ const lang = locale === 'de' ? 'de-CH' : locale === 'uk' ? 'uk-UA' : 'en';
88
+
89
+ // The checklist's runs, in the order the services came, each heading once.
90
+ /** @type {{ group: string; items: Named[] }[]} */
91
+ const runs = [];
92
+ for (const one of services) {
93
+ const group = one.group ?? '';
94
+ const run = runs.find((r) => r.group === group);
95
+ if (run) run.items.push(one);
96
+ else runs.push({ group, items: [one] });
97
+ }
98
+
99
+ const today = now.toISOString().slice(0, 10);
100
+ const last = new Date(now.getTime() + daysAhead * 86_400_000).toISOString().slice(0, 10);
101
+
102
+ const servicesField = combine
103
+ ? `<fieldset class="booking__services" data-services>
104
+ <legend class="field__label" data-say="services">${esc(WORDS.services)}</legend>
105
+ ${runs.map((run) => `<div class="booking__run">
106
+ ${run.group !== '' ? `<p class="booking__run-title">${esc(run.group)}</p>` : ''}
107
+ ${run.items.map((one) => `<label class="booking__service">
108
+ <input type="checkbox" name="service" value="${esc(one.slug)}" data-takes="${esc(one.takes ?? 0)}" data-costs="${esc(one.costs ?? 0)}">
109
+ <span class="booking__service-name">${esc(one.title)}</span>
110
+ <span class="booking__service-meta">${one.takes ? `${esc(one.takes)} min` : ''}${one.costs ? ` · ${esc(money(one.costs, currency, lang))}` : ''}</span>
111
+ </label>`).join('\n')}
112
+ </div>`).join('\n')}
113
+ <p class="booking__total" data-total hidden></p>
114
+ </fieldset>`
115
+ : `<label class="field__label" for="booking-service" data-say="service">${esc(WORDS.service)}</label>
116
+ <select class="field__input" id="booking-service" name="service" required>
117
+ ${services.map((one) => `<option value="${esc(one.slug)}">${esc(one.title)}</option>`).join('\n')}
118
+ </select>`;
119
+
120
+ const resourcesField = resources.length > 1
121
+ ? `<div class="field">
122
+ <label class="field__label" for="booking-resource" data-say="resource">${esc(WORDS.resource)}</label>
123
+ <select class="field__input" id="booking-resource" name="resource" data-say-wait>
124
+ <option value="" data-say="any">${esc(WORDS.any)}</option>
125
+ ${resources.map((one) => `<option value="${esc(one.slug)}">${esc(one.title)}</option>`).join('\n')}
126
+ </select>
127
+ </div>`
128
+ : '';
129
+
130
+ // Fixed, the whole form, as the lead form's labels are: every word in it is
131
+ // the platform's — spoken in the business's kind from the availability
132
+ // answer, «Забронювати» over the build's «Записатися» — or an entry's own
133
+ // title, which the owner edits on the entry. None is the owner's to edit
134
+ // here, and `jtk catalogue` asked whose all seventeen were on a fresh
135
+ // restaurant (30 September 2026).
136
+ const form = `<form class="booking${unsaid ? ' booking--unsaid' : ''}" data-booking data-locale="${esc(locale)}" data-currency="${esc(currency)}" data-words="${esc(JSON.stringify(WORDS))}" data-scale="${esc(scale)}" data-api="${esc(api)}" data-sends="${SENDS.join(',')}" novalidate data-jtk-fixed>
137
+ <noscript><p class="booking__note">${esc(WORDS.noScript)}</p></noscript>
138
+
139
+ <div class="field">
140
+ ${servicesField}
141
+ </div>
142
+
143
+ ${resourcesField}
144
+
145
+ <div class="field">
146
+ <label class="field__label" for="booking-day">${esc(WORDS.day)}</label>
147
+ <input class="field__input" id="booking-day" name="day" type="date" min="${today}" max="${last}" required>
148
+ </div>
149
+
150
+ <div class="field" data-back-field hidden>
151
+ <label class="field__label" for="booking-back">${esc(WORDS.back)}</label>
152
+ <input class="field__input" id="booking-back" name="back" type="date" min="${today}">
153
+ <p class="booking__hint" data-length hidden></p>
154
+ </div>
155
+
156
+ <div class="field" data-hours-field hidden>
157
+ <label class="field__label" for="booking-hours">${esc(WORDS.hours)}</label>
158
+ <select class="field__input" id="booking-hours" name="hours"></select>
159
+ </div>
160
+
161
+ <div class="field" data-party-field hidden>
162
+ <label class="field__label" for="booking-party">${esc(WORDS.party)}</label>
163
+ <select class="field__input" id="booking-party" name="party"></select>
164
+ </div>
165
+
166
+ <fieldset class="field booking__times" data-times>
167
+ <legend class="field__label">${esc(WORDS.time)}</legend>
168
+ <p class="booking__hint" data-times-hint aria-live="polite">${esc(WORDS.pick)}</p>
169
+ <div class="booking__slots" data-slots></div>
170
+ <p class="booking__total" data-stay-total hidden></p>
171
+ </fieldset>
172
+
173
+ <div class="field">
174
+ <label class="field__label" for="booking-name">${esc(WORDS.name)} <span aria-hidden="true">*</span></label>
175
+ <input class="field__input" id="booking-name" name="name" type="text" autocomplete="name" required maxlength="120">
176
+ </div>
177
+ <div class="field">
178
+ <label class="field__label" for="booking-phone">${esc(WORDS.phone)}</label>
179
+ <input class="field__input" id="booking-phone" name="phone" type="tel" inputmode="tel" autocomplete="tel" maxlength="40">
180
+ </div>
181
+ <div class="field">
182
+ <label class="field__label" for="booking-email">${esc(WORDS.email)}</label>
183
+ <input class="field__input" id="booking-email" name="email" type="email" inputmode="email" autocomplete="email" maxlength="120">
184
+ </div>
185
+ <div class="field">
186
+ <label class="field__label" for="booking-note">${esc(WORDS.note)}</label>
187
+ <textarea class="field__input field__input--area" id="booking-note" name="note" rows="3" maxlength="2000"></textarea>
188
+ </div>
189
+
190
+ <div class="booking__challenge" data-challenge hidden></div>
191
+
192
+ <p class="field__error" data-booking-error role="alert" hidden></p>
193
+
194
+ <button class="booking__submit" type="submit" data-submit data-say="book">${esc(WORDS.book)}</button>
195
+
196
+ <div class="booking__done" data-done role="status" tabindex="-1" hidden>
197
+ <p class="booking__done-line" data-done-line></p>
198
+ <p class="booking__done-when" data-done-when></p>
199
+ <p class="booking__done-when" data-done-what hidden></p>
200
+ <p class="booking__note" data-pay-note hidden></p>
201
+ <p class="booking__links">
202
+ <a data-pay hidden></a>
203
+ <a data-manage hidden></a>
204
+ <a data-telegram hidden target="_blank" rel="noreferrer noopener"></a>
205
+ </p>
206
+ </div>
207
+ </form>`;
208
+
209
+ return tag ? `<${tag}>${form}</${tag}>` : form;
210
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The booking form's words, per language, before the platform has spoken.
3
+ *
4
+ * These are a salon's. The words that depend on the kind of business —
5
+ * «Записатися» or «Орендувати», «Майстер» or «Авто» — are the platform's, in
6
+ * its availability answer (`words`), the kind's dictionary with the site's
7
+ * own over it; the element hears them and replaces these. A site in a
8
+ * language not here gets English, which the platform's answer then corrects
9
+ * for the words it has.
10
+ */
11
+
12
+ /** The languages the form carries words in. */
13
+ export const LOCALES = Object.freeze(['uk', 'de', 'en']);
14
+
15
+ const BASE = {
16
+ uk: {
17
+ service: 'Послуга', services: 'Послуги', total: 'Разом', pickService: 'Оберіть хоча б одну послугу.', resource: 'Майстер', any: 'Будь-хто', day: 'День', time: 'Час',
18
+ name: "Ім'я", phone: 'Телефон', email: 'Електронна пошта', note: 'Коментар',
19
+ book: 'Записатися', sending: 'Записуємо…', none: 'У цей день вільного часу немає.', noParty: 'Немає вільних місць для такої компанії.',
20
+ pick: 'Оберіть день', confirmed: 'Ви записані.', pending: 'Запис отримано — ми підтвердимо його найближчим часом.',
21
+ pay: 'Оплатити', payNote: 'Час зарезервовано на 30 хвилин. Щоб закріпити його, завершіть оплату.',
22
+ manage: 'Скасувати або перенести запис', telegram: 'Нагадування в Telegram',
23
+ taken: 'Цей час щойно зайняли — оберіть інший.', failed: 'Не вдалося записатися. Спробуйте ще раз або зателефонуйте.',
24
+ back: 'До', length: 'Від {min} до {max} діб.', anyOf: 'Будь-яке вільне — {class}', noneOf: '{class} — немає вільних', notFor: '{who} — не для обраного', notForChosen: '{who} — не для обраного. Оберіть іншого або «{any}».', took: 'Ви берете: {what}',
25
+ party: 'Скільки вас', hours: 'Скільки часу', hourShort: 'год', minuteShort: 'хв', challenge: 'Підтвердіть, що ви не робот, і спробуйте ще раз.', tooMany: 'У вас уже є кілька відкритих записів. Скасуйте один із них або зателефонуйте нам.',
26
+ needContact: 'Вкажіть телефон або пошту, щоб ми могли з вами звʼязатися.', noScript: 'Онлайн-форма працює з увімкненим JavaScript — увімкніть його або зателефонуйте нам.',
27
+ },
28
+ de: {
29
+ service: 'Leistung', services: 'Leistungen', total: 'Zusammen', pickService: 'Bitte mindestens eine Leistung wählen.', resource: 'Bei', any: 'Egal wer', day: 'Tag', time: 'Uhrzeit',
30
+ name: 'Name', phone: 'Telefon', email: 'E-Mail', note: 'Bemerkung',
31
+ book: 'Termin buchen', sending: 'Wird gebucht…', none: 'An diesem Tag ist nichts frei.', noParty: 'Für so viele Personen gibt es keinen freien Platz.',
32
+ pick: 'Wählen Sie einen Tag', confirmed: 'Ihr Termin ist gebucht.', pending: 'Anfrage erhalten — wir bestätigen den Termin in Kürze.',
33
+ pay: 'Jetzt bezahlen', payNote: 'Der Termin ist 30 Minuten reserviert. Schliessen Sie die Zahlung ab, um ihn zu behalten.',
34
+ manage: 'Termin absagen oder verschieben', telegram: 'Erinnerungen per Telegram',
35
+ taken: 'Diese Zeit wurde gerade vergeben — bitte wählen Sie eine andere.', failed: 'Die Buchung hat nicht geklappt. Bitte erneut versuchen oder anrufen.',
36
+ 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}',
37
+ party: 'Wie viele Personen', 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.',
38
+ 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.',
39
+ },
40
+ en: {
41
+ service: 'Service', services: 'Services', total: 'Total', pickService: 'Pick at least one service.', resource: 'With', any: 'Anyone', day: 'Day', time: 'Time',
42
+ name: 'Name', phone: 'Phone', email: 'Email', note: 'Note',
43
+ book: 'Book', sending: 'Booking…', none: 'Nothing is free on that day.', noParty: 'There is no place for a party this large.',
44
+ pick: 'Choose a day', confirmed: 'You are booked.', pending: 'Received — we will confirm shortly.',
45
+ pay: 'Pay now', payNote: 'The time is held for 30 minutes. Complete the payment to keep it.',
46
+ manage: 'Cancel or move the booking', telegram: 'Reminders in Telegram',
47
+ taken: 'That time has just been taken — please choose another.', failed: 'That did not work. Please try again or ring us.',
48
+ 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}',
49
+ party: 'How many of you', 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.',
50
+ needContact: 'Leave a phone number or an email so we can reach you.', noScript: 'Booking online needs JavaScript — or ring us.',
51
+ },
52
+ };
53
+
54
+ // A letting is had for days, not at a time: «Час», «вільного часу немає» and
55
+ // «Час зарезервовано» are a salon's words.
56
+ const DAYS = {
57
+ uk: {
58
+ day: 'Від', pick: 'Оберіть дати',
59
+ time: 'Дні', none: 'Цього дня нічого вільного немає.',
60
+ payNote: 'Ці дні тримаємо за вами 30 хвилин. Щоб закріпити їх, завершіть оплату.',
61
+ taken: 'Ці дні щойно зайняли — оберіть інші.',
62
+ },
63
+ de: {
64
+ day: 'Von', pick: 'Wählen Sie die Tage',
65
+ time: 'Tage', none: 'Ab diesem Tag ist nichts frei.',
66
+ payNote: 'Die Tage sind 30 Minuten für Sie reserviert. Schliessen Sie die Zahlung ab, um sie zu behalten.',
67
+ taken: 'Diese Tage wurden gerade vergeben — bitte wählen Sie andere.',
68
+ },
69
+ en: {
70
+ day: 'From', pick: 'Choose the dates',
71
+ time: 'Days', none: 'Nothing is free from that day.',
72
+ payNote: 'The days are held for 30 minutes. Complete the payment to keep them.',
73
+ taken: 'Those days have just been taken — please choose others.',
74
+ },
75
+ };
76
+
77
+ /**
78
+ * The form's words for a language and a scale — the salon's, or a letting
79
+ * business's by the day — as a fresh object the caller may write over.
80
+ *
81
+ * @param {string} locale `uk`, `de` or `en`; anything else reads as `en`
82
+ * @param {string} [scale] `'daily'` for a business that lets things by the day
83
+ * @returns {Record<string, string>}
84
+ */
85
+ export function wordsFor(locale, scale = '') {
86
+ const lang = LOCALES.includes(locale) ? locale : 'en';
87
+ return scale === 'daily' ? { ...BASE[lang], ...DAYS[lang] } : { ...BASE[lang] };
88
+ }