@jtakeit/kit 0.0.0-stage → 0.2.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.
- package/README.md +174 -2
- package/bin/jtkit.mjs +62 -0
- package/booking.css +36 -0
- package/elements/alive.js +52 -0
- package/elements/booking.js +737 -0
- package/elements/lead.js +134 -0
- package/index.mjs +21 -0
- package/lead.css +23 -0
- package/lib/booking/render.mjs +244 -0
- package/lib/booking/words.mjs +346 -0
- package/lib/catalogue.mjs +1819 -0
- package/lib/codes.mjs +171 -0
- package/lib/kits.mjs +208 -0
- package/lib/lead/render.mjs +166 -0
- package/lib/meta.mjs +150 -0
- package/lib/money.mjs +30 -0
- package/package.json +81 -4
- package/types/alive.d.ts +2 -0
- package/types/booking.d.ts +32 -0
- package/types/elements-booking.d.ts +9 -0
- package/types/elements-lead.d.ts +6 -0
- package/types/index.d.ts +6 -0
- package/types/kits.d.ts +24 -0
- package/types/lead.d.ts +49 -0
- package/types/meta.d.ts +26 -0
- package/types/money.d.ts +2 -0
- package/types/words.d.ts +3 -0
package/elements/lead.js
ADDED
|
@@ -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,244 @@
|
|
|
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', 'quantity', '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 {{ day: string, from: number, until: number, costs: number }[]} [rates] a price by the hour of the week instead of `costs`: the list says «from …», and the price is the slot's, printed beside the time
|
|
50
|
+
* @property {string} [per] `'person'` for a price times the party — a class per place, shoes; the list says so and the running total multiplies
|
|
51
|
+
* @property {string} [group] a heading the services are listed under
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The least a service priced by the hour of the week may cost, or nothing.
|
|
56
|
+
* @param {Named} one
|
|
57
|
+
*/
|
|
58
|
+
export function priceFrom(one) {
|
|
59
|
+
const rows = Array.isArray(one.rates) ? one.rates : [];
|
|
60
|
+
return rows.reduce((least, row) => (row.costs > 0 && (least === 0 || row.costs < least) ? row.costs : least), 0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @typedef {object} BookingFormProps
|
|
65
|
+
* @property {Named[]} services
|
|
66
|
+
* @property {Named[]} [resources] empty for a solo business: the platform assigns
|
|
67
|
+
* @property {string} locale `uk`, `de` or `en` — decides the words drawn before the platform has spoken
|
|
68
|
+
* @property {number} [daysAhead] how many days ahead the picker offers; the platform clamps to its own horizon
|
|
69
|
+
* @property {boolean} [combine] the module's `combine` setting: several services for one visit, as a checklist
|
|
70
|
+
* @property {string} [currency] the site's currency, for the running total
|
|
71
|
+
* @property {string} [scale] the module's `scale`: `'daily'` for a business that lets things by the day
|
|
72
|
+
* @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
|
|
73
|
+
* @property {string} [api] where `/api/` is from this page — under the preview it carries a path, so pass the address as your generator writes a root-relative one (Astro's `under('/api/')`)
|
|
74
|
+
* @property {string} [tag] the element's name around the form, `jtk-booking`; `''` for none
|
|
75
|
+
* @property {Date} [now] the day the picker starts from; the build's, by default
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
const ESCAPE = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
79
|
+
/** @param {unknown} value */
|
|
80
|
+
function esc(value) {
|
|
81
|
+
return String(value ?? '').replace(/[&<>"']/g, (c) => ESCAPE[c]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A service's price as the list says it: the price, or «from …» for one
|
|
86
|
+
* priced by the hour of the week — whose price is the slot's and is printed
|
|
87
|
+
* beside the time once one is chosen (wiki/68).
|
|
88
|
+
* @param {Named} one
|
|
89
|
+
* @param {string} currency
|
|
90
|
+
* @param {string} lang
|
|
91
|
+
*/
|
|
92
|
+
function priceSaid(one, currency, lang) {
|
|
93
|
+
if (one.costs) return ` · ${esc(money(one.costs, currency, lang))}`;
|
|
94
|
+
const least = priceFrom(one);
|
|
95
|
+
return least > 0 ? ` · ${esc(wordsFor(lang).from)} ${esc(money(least, currency, lang))}` : '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The booking form as HTML.
|
|
100
|
+
*
|
|
101
|
+
* @param {BookingFormProps} props
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
export function renderBookingForm(props) {
|
|
105
|
+
const {
|
|
106
|
+
services, resources = [], locale, daysAhead = 30, combine = false, currency = 'CHF', scale = '', kind = '',
|
|
107
|
+
api = '/api/', tag = 'jtk-booking', now = new Date(),
|
|
108
|
+
} = props;
|
|
109
|
+
if (!Array.isArray(services)) throw new TypeError('renderBookingForm: services is a list of { slug, title }');
|
|
110
|
+
const unsaid = kind !== '' && kind !== 'salon';
|
|
111
|
+
const WORDS = wordsFor(locale, scale);
|
|
112
|
+
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';
|
|
113
|
+
|
|
114
|
+
// The checklist's runs, in the order the services came, each heading once.
|
|
115
|
+
/** @type {{ group: string; items: Named[] }[]} */
|
|
116
|
+
const runs = [];
|
|
117
|
+
for (const one of services) {
|
|
118
|
+
const group = one.group ?? '';
|
|
119
|
+
const run = runs.find((r) => r.group === group);
|
|
120
|
+
if (run) run.items.push(one);
|
|
121
|
+
else runs.push({ group, items: [one] });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const today = now.toISOString().slice(0, 10);
|
|
125
|
+
const last = new Date(now.getTime() + daysAhead * 86_400_000).toISOString().slice(0, 10);
|
|
126
|
+
|
|
127
|
+
// An add-on — a price and no length: shoes, a racket — is listed under
|
|
128
|
+
// its own heading, after the services, and rides on the visit (wiki/70).
|
|
129
|
+
const addOns = runs.flatMap((run) => run.items.filter((one) => !one.takes && (one.costs || priceFrom(one))));
|
|
130
|
+
const timedRuns = runs.map((run) => ({ ...run, items: run.items.filter((one) => !addOns.includes(one)) })).filter((run) => run.items.length > 0);
|
|
131
|
+
const servicesField = combine
|
|
132
|
+
? `<fieldset class="booking__services" data-services>
|
|
133
|
+
<legend class="field__label" data-say="services">${esc(WORDS.services)}</legend>
|
|
134
|
+
${[...timedRuns, ...(addOns.length ? [{ group: WORDS.addOns, items: addOns }] : [])].map((run) => `<div class="booking__run">
|
|
135
|
+
${run.group !== '' ? `<p class="booking__run-title">${esc(run.group)}</p>` : ''}
|
|
136
|
+
${run.items.map((one) => `<label class="booking__service">
|
|
137
|
+
<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"' : ''}>
|
|
138
|
+
<span class="booking__service-name">${esc(one.title)}</span>
|
|
139
|
+
<span class="booking__service-meta">${one.takes ? `${esc(one.takes)} min` : ''}${priceSaid(one, currency, lang)}${one.per === 'person' ? ` · ${esc(WORDS.perPerson)}` : ''}</span>
|
|
140
|
+
</label>`).join('\n')}
|
|
141
|
+
</div>`).join('\n')}
|
|
142
|
+
<p class="booking__total" data-total hidden></p>
|
|
143
|
+
</fieldset>`
|
|
144
|
+
: `<label class="field__label" for="booking-service" data-say="service">${esc(WORDS.service)}</label>
|
|
145
|
+
<select class="field__input" id="booking-service" name="service" required>
|
|
146
|
+
${services.map((one) => `<option value="${esc(one.slug)}">${esc(one.title)}</option>`).join('\n')}
|
|
147
|
+
</select>`;
|
|
148
|
+
|
|
149
|
+
const resourcesField = resources.length > 1
|
|
150
|
+
? `<div class="field">
|
|
151
|
+
<label class="field__label" for="booking-resource" data-say="resource">${esc(WORDS.resource)}</label>
|
|
152
|
+
<select class="field__input" id="booking-resource" name="resource" data-say-wait>
|
|
153
|
+
<option value="" data-say="any">${esc(WORDS.any)}</option>
|
|
154
|
+
${resources.map((one) => `<option value="${esc(one.slug)}">${esc(one.title)}</option>`).join('\n')}
|
|
155
|
+
</select>
|
|
156
|
+
</div>`
|
|
157
|
+
: '';
|
|
158
|
+
|
|
159
|
+
// Fixed, the whole form, as the lead form's labels are: every word in it is
|
|
160
|
+
// the platform's — spoken in the business's kind from the availability
|
|
161
|
+
// answer, «Забронювати» over the build's «Записатися» — or an entry's own
|
|
162
|
+
// title, which the owner edits on the entry. None is the owner's to edit
|
|
163
|
+
// here, and `jtk catalogue` asked whose all seventeen were on a fresh
|
|
164
|
+
// restaurant (30 September 2026).
|
|
165
|
+
const form = `<form class="booking${unsaid ? ' booking--unsaid' : ''}" data-booking data-locale="${esc(locale)}" data-currency="${esc(currency)}" data-words="${esc(JSON.stringify(WORDS))}" data-scale="${esc(scale)}" data-api="${esc(api)}" data-sends="${SENDS.join(',')}" novalidate data-jtk-fixed>
|
|
166
|
+
<noscript><p class="booking__note">${esc(WORDS.noScript)}</p></noscript>
|
|
167
|
+
|
|
168
|
+
<div class="field">
|
|
169
|
+
${servicesField}
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
${resourcesField}
|
|
173
|
+
|
|
174
|
+
<div class="field">
|
|
175
|
+
<label class="field__label" for="booking-day">${esc(WORDS.day)}</label>
|
|
176
|
+
<input class="field__input" id="booking-day" name="day" type="date" min="${today}" max="${last}" required>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<div class="field" data-back-field hidden>
|
|
180
|
+
<label class="field__label" for="booking-back">${esc(WORDS.back)}</label>
|
|
181
|
+
<input class="field__input" id="booking-back" name="back" type="date" min="${today}">
|
|
182
|
+
<p class="booking__hint" data-length hidden></p>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<div class="field" data-hours-field hidden>
|
|
186
|
+
<label class="field__label" for="booking-hours">${esc(WORDS.hours)}</label>
|
|
187
|
+
<select class="field__input" id="booking-hours" name="hours"></select>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<div class="field" data-party-field hidden>
|
|
191
|
+
<label class="field__label" for="booking-party">${esc(WORDS.party)}</label>
|
|
192
|
+
<select class="field__input" id="booking-party" name="party"></select>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div class="field" data-quantity-field hidden>
|
|
196
|
+
<label class="field__label" for="booking-quantity">${esc(WORDS.quantity)}</label>
|
|
197
|
+
<select class="field__input" id="booking-quantity" name="quantity"></select>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<fieldset class="field booking__times" data-times>
|
|
201
|
+
<legend class="field__label">${esc(WORDS.time)}</legend>
|
|
202
|
+
<p class="booking__hint" data-times-hint aria-live="polite">${esc(WORDS.pick)}</p>
|
|
203
|
+
<div class="booking__slots" data-slots></div>
|
|
204
|
+
<p class="booking__total" data-stay-total hidden></p>
|
|
205
|
+
</fieldset>
|
|
206
|
+
|
|
207
|
+
<div class="field">
|
|
208
|
+
<label class="field__label" for="booking-name">${esc(WORDS.name)} <span aria-hidden="true">*</span></label>
|
|
209
|
+
<input class="field__input" id="booking-name" name="name" type="text" autocomplete="name" required maxlength="120">
|
|
210
|
+
</div>
|
|
211
|
+
<div class="field">
|
|
212
|
+
<label class="field__label" for="booking-phone">${esc(WORDS.phone)}</label>
|
|
213
|
+
<input class="field__input" id="booking-phone" name="phone" type="tel" inputmode="tel" autocomplete="tel" maxlength="40">
|
|
214
|
+
</div>
|
|
215
|
+
<div class="field">
|
|
216
|
+
<label class="field__label" for="booking-email">${esc(WORDS.email)}</label>
|
|
217
|
+
<input class="field__input" id="booking-email" name="email" type="email" inputmode="email" autocomplete="email" maxlength="120">
|
|
218
|
+
</div>
|
|
219
|
+
<div class="field">
|
|
220
|
+
<label class="field__label" for="booking-note">${esc(WORDS.note)}</label>
|
|
221
|
+
<textarea class="field__input field__input--area" id="booking-note" name="note" rows="3" maxlength="2000"></textarea>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<div class="booking__challenge" data-challenge hidden></div>
|
|
225
|
+
|
|
226
|
+
<p class="field__error" data-booking-error role="alert" hidden></p>
|
|
227
|
+
|
|
228
|
+
<button class="booking__submit" type="submit" data-submit data-say="book">${esc(WORDS.book)}</button>
|
|
229
|
+
|
|
230
|
+
<div class="booking__done" data-done role="status" tabindex="-1" hidden>
|
|
231
|
+
<p class="booking__done-line" data-done-line></p>
|
|
232
|
+
<p class="booking__done-when" data-done-when></p>
|
|
233
|
+
<p class="booking__done-when" data-done-what hidden></p>
|
|
234
|
+
<p class="booking__note" data-pay-note hidden></p>
|
|
235
|
+
<p class="booking__links">
|
|
236
|
+
<a data-pay hidden></a>
|
|
237
|
+
<a data-manage hidden></a>
|
|
238
|
+
<a data-telegram hidden target="_blank" rel="noreferrer noopener"></a>
|
|
239
|
+
</p>
|
|
240
|
+
</div>
|
|
241
|
+
</form>`;
|
|
242
|
+
|
|
243
|
+
return tag ? `<${tag}>${form}</${tag}>` : form;
|
|
244
|
+
}
|