@jtakeit/kit 0.1.0 → 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 CHANGED
@@ -31,7 +31,7 @@ shape, so the form says what it sends and the element sends nothing else.
31
31
  import { renderBookingForm } from '@jtakeit/kit/booking';
32
32
 
33
33
  const html = renderBookingForm({
34
- services, // [{ slug, title, takes, costs, group }], the entries with `takes`
34
+ services, // [{ slug, title, takes, costs, rates, group }], the entries with `takes`; `rates` instead of `costs` says «from …»
35
35
  resources, // [{ slug, title }] — leave empty for a solo business
36
36
  locale: 'uk', // uk, de or en: the words before the platform has spoken
37
37
  combine: settings.combine === true, // jtk/bookings.json
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * GET /api/turnstile the widget's key, or "" for none
11
11
  * GET /api/availability?service=…&from=…&to=…[&resource=…][&party=…][&count=…]
12
- * → { zone, slots[{start, free, ends}], party_max, scale, services[{slug,
12
+ * → { zone, slots[{start, free, ends, costs_minor}], party_max, quantity_max, scale, services[{slug,
13
13
  * scale, takes, fewest, most}], prices{class: minor}, currency, … }
14
14
  * POST /api/book → { id, state, amount_minor, currency, token, manage_path, telegram_url }
15
15
  * POST /api/pay → { url } — only when amount_minor > 0
@@ -113,13 +113,18 @@ export function wireBooking(form) {
113
113
  const chosenBoxes = boxes.filter((b) => b.checked);
114
114
  if (chosenBoxes.length < 2) { total.hidden = true; return; }
115
115
  const minutes = chosenBoxes.reduce((sum, b) => sum + Number(b.dataset.takes ?? 0), 0);
116
- const minor = chosenBoxes.reduce((sum, b) => sum + Number(b.dataset.costs ?? 0), 0);
116
+ // A price per person is times the party (wiki/70); the slot's price is
117
+ // the platform's, this is the list's own running sum.
118
+ const people = partyField.hidden ? 1 : Number(party.value) || 1;
119
+ const minor = chosenBoxes.reduce((sum, b) => sum + Number(b.dataset.costs ?? 0) * (b.dataset.per === 'person' ? people : 1), 0);
117
120
  const price = minor > 0 ? ' · ' + money(minor, form.dataset.currency, pageLang()) : '';
118
121
  total.textContent = `${words.total}: ${minutes} min${price}`;
119
122
  total.hidden = false;
120
123
  }
121
124
  const resource = /** @type {HTMLSelectElement | null} */ (form.querySelector('[name=resource]'));
122
125
  /** @type {HTMLSelectElement} */ const party = q('[name=party]');
126
+ /** @type {HTMLSelectElement} */ const quantity = q('[name=quantity]');
127
+ /** @type {HTMLElement} */ const quantityField = q('[data-quantity-field]');
123
128
  /** @type {HTMLSelectElement} */ const hours = q('[name=hours]');
124
129
  /** @type {HTMLElement} */ const hoursField = q('[data-hours-field]');
125
130
  /** @type {HTMLElement} */ const partyField = q('[data-party-field]');
@@ -221,10 +226,34 @@ export function wireBooking(form) {
221
226
  }
222
227
  if (partyField.hidden) {
223
228
  partyField.hidden = false;
224
- party.addEventListener('change', () => void load());
229
+ party.addEventListener('change', () => { showTotal(); void load(); });
225
230
  }
226
231
  }
227
232
 
233
+ /**
234
+ * How many of the class one request may take (wiki/69): the settings'
235
+ * most, and no more than are free at the chosen time. Hidden at one, and
236
+ * when a resource is named — a name is one thing.
237
+ * @param {number} most
238
+ * @param {number} free
239
+ */
240
+ function offerQuantity(most, free) {
241
+ if (most <= 1 || resourceChosen()) { quantityField.hidden = true; return; }
242
+ const cap = Math.max(1, Math.min(most, free || most));
243
+ const was = quantity.value;
244
+ quantity.textContent = '';
245
+ for (let n = 1; n <= cap; n++) {
246
+ const option = document.createElement('option');
247
+ option.value = String(n);
248
+ option.textContent = String(n);
249
+ quantity.appendChild(option);
250
+ }
251
+ quantity.value = was && Number(was) <= cap ? was : '1';
252
+ quantityField.hidden = false;
253
+ }
254
+ function quantityChosen() {
255
+ return quantityField.hidden ? 1 : Number(quantity.value) || 1;
256
+ }
228
257
  /** @param {string} text */
229
258
  function say(text) {
230
259
  error.textContent = text;
@@ -471,6 +500,7 @@ export function wireBooking(form) {
471
500
  zone = data.zone;
472
501
  if (typeof data.scale === 'string') daily = data.scale === 'daily';
473
502
  offerParty(data.party_max ?? 0);
503
+ offerQuantity(data.quantity_max ?? 1, Math.max(0, ...(data.slots ?? []).map((/** @type {{free?: string[]}} */ s) => (s.free ?? []).length)));
474
504
  // A platform that says what each service may be taken as: the second
475
505
  // date follows it. The first answer may change what was asked — the
476
506
  // shortest stay filled in — and is asked once more with it.
@@ -507,8 +537,12 @@ export function wireBooking(form) {
507
537
  button.textContent = daily
508
538
  ? slot.ends ? `${dayAndHour(slot.start, false)} — ${dayAndHour(slot.ends, false)}` : dayAndHour(slot.start, false)
509
539
  : clock(slot.start);
540
+ // What the booking starting here costs — the platform's number, every
541
+ // hour by its own row (wiki/68) — beside the time, whenever one came.
542
+ if (slot.costs_minor > 0) button.textContent += ` · ${money(slot.costs_minor, currency, pageLang())}`;
510
543
  button.addEventListener('click', () => {
511
544
  chosen = slot.start;
545
+ offerQuantity(data.quantity_max ?? 1, (slot.free ?? []).length);
512
546
  for (const other of slots.querySelectorAll('.booking__slot')) {
513
547
  other.classList.remove('is-chosen');
514
548
  other.setAttribute('aria-pressed', 'false');
@@ -565,6 +599,7 @@ export function wireBooking(form) {
565
599
  // How many of the service — never an end: the platform computes
566
600
  // the end and the price from it.
567
601
  ...(count() ? { count: count() } : {}),
602
+ ...(quantityChosen() > 1 ? { quantity: quantityChosen() } : {}),
568
603
  start: chosen,
569
604
  name, phone, email,
570
605
  note: String(data.get('note') ?? ''),
@@ -599,7 +634,7 @@ export function wireBooking(form) {
599
634
  });
600
635
 
601
636
  /**
602
- * @param {{ state: string; start: string; ends: string; amount_minor: number; currency: string; id: string; manage_path: string; telegram_url: string; resource?: string; class?: string }} made
637
+ * @param {{ state: string; start: string; ends: string; amount_minor: number; currency: string; id: string; manage_path: string; telegram_url: string; resource?: string; class?: string; group?: { quantity: number; amount_minor: number; bookings: { resource: string; resource_name?: string }[] } }} made
603
638
  */
604
639
  function done(made) {
605
640
  for (const el of /** @type {NodeListOf<HTMLElement>} */ (form.querySelectorAll('.field, .booking__times, .booking__services, [data-submit], [data-challenge]'))) el.hidden = true;
@@ -615,7 +650,11 @@ export function wireBooking(form) {
615
650
  // What was taken: the class promised, where «any free» was chosen; the
616
651
  // one by name and marks, where it was chosen — «VW Combi · білий ·
617
652
  // AA1234BB». Nothing where the business has one of everything.
618
- const what = made.class ? words.anyOf.replace('{class}', made.class) : resourceChosen() ? made.resource ?? '' : '';
653
+ let what = made.class ? words.anyOf.replace('{class}', made.class) : resourceChosen() ? made.resource ?? '' : '';
654
+ // A group: the resources it took, and how many together (wiki/69).
655
+ if (made.group && made.group.quantity > 1) {
656
+ what = `${made.group.bookings.map((/** @type {{resource_name?: string, resource: string}} */ b) => b.resource_name || b.resource).join(', ')} · ${words.together.replace('{n}', String(made.group.quantity))}`;
657
+ }
619
658
  if (what) {
620
659
  /** @type {HTMLElement} */ const line = q('[data-done-what]');
621
660
  line.textContent = words.took.replace('{what}', what);
@@ -627,12 +666,14 @@ export function wireBooking(form) {
627
666
  /** @type {HTMLAnchorElement} */ const tg = q('[data-telegram]');
628
667
  if (made.telegram_url) { tg.href = made.telegram_url; tg.textContent = words.telegram; tg.hidden = false; }
629
668
 
630
- if (made.amount_minor > 0) {
669
+ // The group is paid for together: its sum on the button.
670
+ const owed = made.group && made.group.amount_minor > 0 ? made.group.amount_minor : made.amount_minor;
671
+ if (owed > 0) {
631
672
  /** @type {HTMLElement} */ const note = q('[data-pay-note]');
632
673
  note.textContent = words.payNote;
633
674
  note.hidden = false;
634
675
  /** @type {HTMLAnchorElement} */ const pay = q('[data-pay]');
635
- pay.textContent = `${words.pay} · ${money(made.amount_minor, made.currency, pageLang())}`;
676
+ pay.textContent = `${words.pay} · ${money(owed, made.currency, pageLang())}`;
636
677
  pay.href = '#';
637
678
  pay.hidden = false;
638
679
  pay.addEventListener('click', async (event) => {
@@ -38,7 +38,7 @@ import { wordsFor } from './words.mjs';
38
38
  * sends no key that is not here — the list cannot drift from the request,
39
39
  * because the element imports this one.
40
40
  */
41
- export const SENDS = Object.freeze(['service', 'services', 'resource', 'class', 'party', 'count', 'start', 'name', 'phone', 'email', 'note', 'turnstile']);
41
+ export const SENDS = Object.freeze(['service', 'services', 'resource', 'class', 'party', 'count', 'quantity', 'start', 'name', 'phone', 'email', 'note', 'turnstile']);
42
42
 
43
43
  /**
44
44
  * @typedef {object} Named
@@ -46,9 +46,20 @@ export const SENDS = Object.freeze(['service', 'services', 'resource', 'class',
46
46
  * @property {string} title what the visitor reads
47
47
  * @property {number} [takes] minutes, for the running total when several are picked
48
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
49
51
  * @property {string} [group] a heading the services are listed under
50
52
  */
51
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
+
52
63
  /**
53
64
  * @typedef {object} BookingFormProps
54
65
  * @property {Named[]} services
@@ -70,6 +81,20 @@ function esc(value) {
70
81
  return String(value ?? '').replace(/[&<>"']/g, (c) => ESCAPE[c]);
71
82
  }
72
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
+
73
98
  /**
74
99
  * The booking form as HTML.
75
100
  *
@@ -84,7 +109,7 @@ export function renderBookingForm(props) {
84
109
  if (!Array.isArray(services)) throw new TypeError('renderBookingForm: services is a list of { slug, title }');
85
110
  const unsaid = kind !== '' && kind !== 'salon';
86
111
  const WORDS = wordsFor(locale, scale);
87
- const lang = locale === 'de' ? 'de-CH' : locale === 'uk' ? 'uk-UA' : 'en';
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';
88
113
 
89
114
  // The checklist's runs, in the order the services came, each heading once.
90
115
  /** @type {{ group: string; items: Named[] }[]} */
@@ -99,15 +124,19 @@ export function renderBookingForm(props) {
99
124
  const today = now.toISOString().slice(0, 10);
100
125
  const last = new Date(now.getTime() + daysAhead * 86_400_000).toISOString().slice(0, 10);
101
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);
102
131
  const servicesField = combine
103
132
  ? `<fieldset class="booking__services" data-services>
104
133
  <legend class="field__label" data-say="services">${esc(WORDS.services)}</legend>
105
- ${runs.map((run) => `<div class="booking__run">
134
+ ${[...timedRuns, ...(addOns.length ? [{ group: WORDS.addOns, items: addOns }] : [])].map((run) => `<div class="booking__run">
106
135
  ${run.group !== '' ? `<p class="booking__run-title">${esc(run.group)}</p>` : ''}
107
136
  ${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)}">
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"' : ''}>
109
138
  <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>
139
+ <span class="booking__service-meta">${one.takes ? `${esc(one.takes)} min` : ''}${priceSaid(one, currency, lang)}${one.per === 'person' ? ` · ${esc(WORDS.perPerson)}` : ''}</span>
111
140
  </label>`).join('\n')}
112
141
  </div>`).join('\n')}
113
142
  <p class="booking__total" data-total hidden></p>
@@ -163,6 +192,11 @@ export function renderBookingForm(props) {
163
192
  <select class="field__input" id="booking-party" name="party"></select>
164
193
  </div>
165
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
+
166
200
  <fieldset class="field booking__times" data-times>
167
201
  <legend class="field__label">${esc(WORDS.time)}</legend>
168
202
  <p class="booking__hint" data-times-hint aria-live="polite">${esc(WORDS.pick)}</p>
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  /** The languages the form carries words in. */
13
- export const LOCALES = Object.freeze(['uk', 'de', 'en']);
13
+ export const LOCALES = Object.freeze(['uk', 'de', 'en', 'ru', 'es', 'it', 'pt', 'fr']);
14
14
 
15
15
  const BASE = {
16
16
  uk: {
@@ -22,7 +22,8 @@ const BASE = {
22
22
  manage: 'Скасувати або перенести запис', telegram: 'Нагадування в Telegram',
23
23
  taken: 'Цей час щойно зайняли — оберіть інший.', failed: 'Не вдалося записатися. Спробуйте ще раз або зателефонуйте.',
24
24
  back: 'До', length: 'Від {min} до {max} діб.', anyOf: 'Будь-яке вільне — {class}', noneOf: '{class} — немає вільних', notFor: '{who} — не для обраного', notForChosen: '{who} — не для обраного. Оберіть іншого або «{any}».', took: 'Ви берете: {what}',
25
- party: 'Скільки вас', hours: 'Скільки часу', hourShort: 'год', minuteShort: 'хв', challenge: 'Підтвердіть, що ви не робот, і спробуйте ще раз.', tooMany: 'У вас уже є кілька відкритих записів. Скасуйте один із них або зателефонуйте нам.',
25
+ party: 'Скільки вас', perPerson: 'за особу', addOns: 'Додатково', quantity: 'Скільки — доріжок, столів, кімнат', together: '{n} разом', hours: 'Скільки часу', hourShort: 'год', minuteShort: 'хв', challenge: 'Підтвердіть, що ви не робот, і спробуйте ще раз.', tooMany: 'У вас уже є кілька відкритих записів. Скасуйте один із них або зателефонуйте нам.',
26
+ from: 'від',
26
27
  needContact: 'Вкажіть телефон або пошту, щоб ми могли з вами звʼязатися.', noScript: 'Онлайн-форма працює з увімкненим JavaScript — увімкніть його або зателефонуйте нам.',
27
28
  },
28
29
  de: {
@@ -34,7 +35,8 @@ const BASE = {
34
35
  manage: 'Termin absagen oder verschieben', telegram: 'Erinnerungen per Telegram',
35
36
  taken: 'Diese Zeit wurde gerade vergeben — bitte wählen Sie eine andere.', failed: 'Die Buchung hat nicht geklappt. Bitte erneut versuchen oder anrufen.',
36
37
  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
+ 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.',
39
+ from: 'ab',
38
40
  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
41
  },
40
42
  en: {
@@ -46,9 +48,225 @@ const BASE = {
46
48
  manage: 'Cancel or move the booking', telegram: 'Reminders in Telegram',
47
49
  taken: 'That time has just been taken — please choose another.', failed: 'That did not work. Please try again or ring us.',
48
50
  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.',
51
+ 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.',
52
+ from: 'from',
50
53
  needContact: 'Leave a phone number or an email so we can reach you.', noScript: 'Booking online needs JavaScript — or ring us.',
51
54
  },
55
+ ru: {
56
+ service: "Услуга",
57
+ services: "Услуги",
58
+ total: "Итого",
59
+ pickService: "Выберите хотя бы одну услугу.",
60
+ resource: "Мастер",
61
+ any: "Любой",
62
+ day: "День",
63
+ time: "Время",
64
+ name: "Имя",
65
+ phone: "Телефон",
66
+ email: "Электронная почта",
67
+ note: "Комментарий",
68
+ book: "Записаться",
69
+ sending: "Записываем…",
70
+ none: "В этот день свободного времени нет.",
71
+ noParty: "Для такой компании свободных мест нет.",
72
+ pick: "Выберите день",
73
+ confirmed: "Вы записаны.",
74
+ pending: "Запись получена — мы подтвердим ее в ближайшее время.",
75
+ pay: "Оплатить",
76
+ payNote: "Время зарезервировано на 30 минут. Чтобы закрепить его, завершите оплату.",
77
+ manage: "Отменить или перенести запись",
78
+ telegram: "Напоминания в Telegram",
79
+ taken: "Это время только что заняли — выберите другое.",
80
+ failed: "Не удалось записаться. Попробуйте еще раз или позвоните.",
81
+ back: "До",
82
+ length: "От {min} до {max} суток.",
83
+ anyOf: "Любой свободный — {class}",
84
+ noneOf: "{class} — свободных нет",
85
+ notFor: "{who} — не для выбранного",
86
+ notForChosen: "{who} — не для выбранного. Выберите другого или «{any}».",
87
+ took: "Вы берете: {what}",
88
+ party: "Сколько вас", perPerson: 'за человека', addOns: 'Дополнительно', quantity: 'Сколько — дорожек, столов, комнат', together: '{n} вместе',
89
+ hours: "На сколько времени",
90
+ hourShort: "ч",
91
+ minuteShort: "мин",
92
+ challenge: "Подтвердите, что вы не робот, и попробуйте еще раз.",
93
+ tooMany: "У вас уже есть несколько открытых записей. Отмените одну из них или позвоните нам.",
94
+ from: 'от',
95
+ needContact: "Укажите телефон или почту, чтобы мы могли с вами связаться.",
96
+ noScript: "Онлайн-форма работает с включенным JavaScript — включите его или позвоните нам.",
97
+ },
98
+ es: {
99
+ service: "Servicio",
100
+ services: "Servicios",
101
+ total: "Total",
102
+ pickService: "Elija al menos un servicio.",
103
+ resource: "Con",
104
+ any: "Cualquiera",
105
+ day: "Día",
106
+ time: "Hora",
107
+ name: "Nombre",
108
+ phone: "Teléfono",
109
+ email: "Correo electrónico",
110
+ note: "Comentario",
111
+ book: "Reservar",
112
+ sending: "Reservando…",
113
+ none: "Ese día no queda nada libre.",
114
+ noParty: "No hay sitio para un grupo tan grande.",
115
+ pick: "Elija un día",
116
+ confirmed: "Su reserva está hecha.",
117
+ pending: "Recibida: la confirmaremos en breve.",
118
+ pay: "Pagar ahora",
119
+ payNote: "La hora queda retenida 30 minutos. Complete el pago para conservarla.",
120
+ manage: "Cancelar o mover la reserva",
121
+ telegram: "Recordatorios en Telegram",
122
+ taken: "Esa hora acaba de ocuparse; elija otra, por favor.",
123
+ failed: "No ha funcionado. Inténtelo de nuevo o llámenos.",
124
+ back: "Hasta",
125
+ length: "De {min} a {max} noches.",
126
+ anyOf: "Cualquiera libre — {class}",
127
+ noneOf: "{class} — ninguno disponible",
128
+ notFor: "{who} — no para esta elección",
129
+ notForChosen: "{who} — no para esta elección. Elija otro, o «{any}».",
130
+ took: "Se lleva: {what}",
131
+ party: "Cuántos son", perPerson: 'por persona', addOns: 'Extras', quantity: 'Cuántas — pistas, mesas, habitaciones', together: '{n} juntas',
132
+ hours: "Cuánto tiempo",
133
+ hourShort: "h",
134
+ minuteShort: "min",
135
+ challenge: "Confirme que no es un robot e inténtelo de nuevo.",
136
+ tooMany: "Ya tiene varias reservas abiertas. Cancele una o llámenos.",
137
+ from: 'desde',
138
+ needContact: "Deje un teléfono o un correo electrónico para que podamos contactar con usted.",
139
+ noScript: "Para reservar en línea hace falta JavaScript — o llámenos.",
140
+ },
141
+ it: {
142
+ service: "Servizio",
143
+ services: "Servizi",
144
+ total: "Totale",
145
+ pickService: "Scelga almeno un servizio.",
146
+ resource: "Con",
147
+ any: "Chiunque",
148
+ day: "Giorno",
149
+ time: "Orario",
150
+ name: "Nome",
151
+ phone: "Telefono",
152
+ email: "Email",
153
+ note: "Nota",
154
+ book: "Prenota",
155
+ sending: "Prenotazione…",
156
+ none: "Niente di libero in quel giorno.",
157
+ noParty: "Non c'è posto per un gruppo così numeroso.",
158
+ pick: "Scelga un giorno",
159
+ confirmed: "Prenotazione effettuata.",
160
+ pending: "Ricevuta: confermeremo a breve.",
161
+ pay: "Paga ora",
162
+ payNote: "L'orario è tenuto per 30 minuti. Completi il pagamento per mantenerlo.",
163
+ manage: "Annulla o sposta la prenotazione",
164
+ telegram: "Promemoria su Telegram",
165
+ taken: "Questo orario è appena stato preso: ne scelga un altro.",
166
+ failed: "Non ha funzionato. Riprovi o ci chiami.",
167
+ back: "Fino a",
168
+ length: "Da {min} a {max} notti.",
169
+ anyOf: "Uno qualsiasi libero — {class}",
170
+ noneOf: "{class} — nessuno disponibile",
171
+ notFor: "{who} — non per questa scelta",
172
+ notForChosen: "{who} — non per questa scelta. Ne scelga un altro, o «{any}».",
173
+ took: "Sta prendendo: {what}",
174
+ party: "Quante persone", perPerson: 'a persona', addOns: 'Extra', quantity: 'Quante — piste, tavoli, camere', together: '{n} insieme',
175
+ hours: "Per quanto tempo",
176
+ hourShort: "h",
177
+ minuteShort: "min",
178
+ challenge: "Confermi di non essere un robot e riprovi.",
179
+ tooMany: "Ha già diverse prenotazioni aperte. Ne annulli una, o ci chiami.",
180
+ from: 'da',
181
+ needContact: "Lasci un numero di telefono o un'email per poterLa contattare.",
182
+ noScript: "Per prenotare online serve JavaScript: oppure ci chiami.",
183
+ },
184
+ pt: {
185
+ service: "Serviço",
186
+ services: "Serviços",
187
+ total: "Total",
188
+ pickService: "Escolha pelo menos um serviço.",
189
+ resource: "Com",
190
+ any: "Qualquer um",
191
+ day: "Dia",
192
+ time: "Hora",
193
+ name: "Nome",
194
+ phone: "Telefone",
195
+ email: "E-mail",
196
+ note: "Observações",
197
+ book: "Reservar",
198
+ sending: "A reservar…",
199
+ none: "Não há horários livres nesse dia.",
200
+ noParty: "Não há lugar para um grupo tão grande.",
201
+ pick: "Escolha um dia",
202
+ confirmed: "A sua reserva está feita.",
203
+ pending: "Pedido recebido — confirmamos em breve.",
204
+ pay: "Pagar agora",
205
+ payNote: "O horário fica reservado durante 30 minutos. Conclua o pagamento para o manter.",
206
+ manage: "Cancelar ou remarcar a reserva",
207
+ telegram: "Lembretes no Telegram",
208
+ taken: "Esse horário acabou de ser ocupado — escolha outro.",
209
+ failed: "Não foi possível reservar. Tente de novo ou ligue-nos.",
210
+ back: "Até",
211
+ length: "De {min} a {max} noites.",
212
+ anyOf: "Qualquer livre — {class}",
213
+ noneOf: "{class} — nenhum disponível",
214
+ notFor: "{who} — não para esta escolha",
215
+ notForChosen: "{who} — não para esta escolha. Escolha outro ou «{any}».",
216
+ took: "Leva: {what}",
217
+ party: "Quantos são", perPerson: 'por pessoa', addOns: 'Extras', quantity: 'Quantas — pistas, mesas, quartos', together: '{n} em conjunto',
218
+ hours: "Quanto tempo",
219
+ hourShort: "h",
220
+ minuteShort: "min",
221
+ challenge: "Confirme que não é um robô e tente de novo.",
222
+ tooMany: "Já tem várias reservas em aberto. Cancele uma ou ligue-nos.",
223
+ from: 'desde',
224
+ needContact: "Deixe um telefone ou um e-mail para podermos contactá-lo.",
225
+ noScript: "Reservar online precisa de JavaScript — ou ligue-nos.",
226
+ },
227
+ fr: {
228
+ service: "Prestation",
229
+ services: "Prestations",
230
+ total: "Total",
231
+ pickService: "Choisissez au moins une prestation.",
232
+ resource: "Avec",
233
+ any: "Sans préférence",
234
+ day: "Jour",
235
+ time: "Heure",
236
+ name: "Nom",
237
+ phone: "Téléphone",
238
+ email: "E-mail",
239
+ note: "Remarque",
240
+ book: "Réserver",
241
+ sending: "Réservation en cours…",
242
+ none: "Rien n’est libre ce jour-là.",
243
+ noParty: "Pas de place pour un groupe de cette taille.",
244
+ pick: "Choisissez un jour",
245
+ confirmed: "Votre réservation est confirmée.",
246
+ pending: "Demande reçue — nous confirmons sous peu.",
247
+ pay: "Payer maintenant",
248
+ payNote: "Le créneau est réservé pendant 30 minutes. Terminez le paiement pour le conserver.",
249
+ manage: "Annuler ou déplacer la réservation",
250
+ telegram: "Rappels dans Telegram",
251
+ taken: "Ce créneau vient d’être pris — veuillez en choisir un autre.",
252
+ failed: "Cela n’a pas fonctionné. Réessayez ou appelez-nous.",
253
+ back: "Jusqu’au",
254
+ length: "De {min} à {max} nuits.",
255
+ anyOf: "N’importe lequel de libre — {class}",
256
+ noneOf: "{class} — aucun disponible",
257
+ notFor: "{who} — pas pour ce choix",
258
+ notForChosen: "{who} — pas pour ce choix. Choisissez-en un autre, ou « {any} ».",
259
+ took: "Vous prenez : {what}",
260
+ party: "Combien êtes-vous", perPerson: 'par personne', addOns: 'Options', quantity: 'Combien — pistes, tables, chambres', together: '{n} ensemble',
261
+ hours: "Combien de temps",
262
+ hourShort: "h",
263
+ minuteShort: "min",
264
+ challenge: "Veuillez confirmer que vous n’êtes pas un robot et réessayer.",
265
+ tooMany: "Vous avez déjà plusieurs réservations en cours. Annulez-en une, ou appelez-nous.",
266
+ from: 'à partir de',
267
+ needContact: "Laissez un numéro de téléphone ou un e-mail pour que nous puissions vous joindre.",
268
+ noScript: "Réserver en ligne nécessite JavaScript — ou appelez-nous.",
269
+ },
52
270
  };
53
271
 
54
272
  // A letting is had for days, not at a time: «Час», «вільного часу немає» and
@@ -72,6 +290,46 @@ const DAYS = {
72
290
  payNote: 'The days are held for 30 minutes. Complete the payment to keep them.',
73
291
  taken: 'Those days have just been taken — please choose others.',
74
292
  },
293
+ ru: {
294
+ day: "С",
295
+ pick: "Выберите даты",
296
+ time: "Дни",
297
+ none: "С этого дня ничего свободного нет.",
298
+ payNote: "Эти дни держим за вами 30 минут. Чтобы закрепить их, завершите оплату.",
299
+ taken: "Эти дни только что заняли — выберите другие.",
300
+ },
301
+ es: {
302
+ day: "Desde",
303
+ pick: "Elija las fechas",
304
+ time: "Días",
305
+ none: "A partir de ese día no queda nada libre.",
306
+ payNote: "Los días quedan retenidos 30 minutos. Complete el pago para conservarlos.",
307
+ taken: "Esos días acaban de ocuparse; elija otros, por favor.",
308
+ },
309
+ it: {
310
+ day: "Dal",
311
+ pick: "Scelga le date",
312
+ time: "Giorni",
313
+ none: "Niente di libero da quel giorno.",
314
+ payNote: "I giorni sono tenuti per 30 minuti. Completi il pagamento per mantenerli.",
315
+ taken: "Questi giorni sono appena stati presi: ne scelga altri.",
316
+ },
317
+ pt: {
318
+ day: "De",
319
+ pick: "Escolha as datas",
320
+ time: "Dias",
321
+ none: "Não há nada livre a partir desse dia.",
322
+ payNote: "Os dias ficam reservados durante 30 minutos. Conclua o pagamento para os manter.",
323
+ taken: "Esses dias acabaram de ser ocupados — escolha outros.",
324
+ },
325
+ fr: {
326
+ day: "Du",
327
+ pick: "Choisissez les dates",
328
+ time: "Jours",
329
+ none: "Rien n’est libre à partir de ce jour.",
330
+ payNote: "Les jours sont réservés pendant 30 minutes. Terminez le paiement pour les conserver.",
331
+ taken: "Ces jours viennent d’être pris — veuillez en choisir d’autres.",
332
+ },
75
333
  };
76
334
 
77
335
  /**
package/lib/kits.mjs CHANGED
@@ -108,7 +108,7 @@ export function checkKit(kit, from) {
108
108
  /** The language a kit's words are taken in: the site's, or English where the kit has not got it. */
109
109
  export function kitLanguage(locale) {
110
110
  const lang = String(locale ?? '').slice(0, 2);
111
- return ['uk', 'en', 'de'].includes(lang) ? lang : 'en';
111
+ return ['uk', 'en', 'de', 'ru', 'es', 'it', 'pt', 'fr'].includes(lang) ? lang : 'en';
112
112
  }
113
113
 
114
114
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jtakeit/kit",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
@@ -8,18 +8,48 @@
8
8
  "jtkit": "bin/jtkit.mjs"
9
9
  },
10
10
  "exports": {
11
- ".": { "types": "./types/index.d.ts", "default": "./index.mjs" },
12
- "./booking": { "types": "./types/booking.d.ts", "default": "./lib/booking/render.mjs" },
13
- "./booking/words": { "types": "./types/words.d.ts", "default": "./lib/booking/words.mjs" },
14
- "./lead": { "types": "./types/lead.d.ts", "default": "./lib/lead/render.mjs" },
15
- "./money": { "types": "./types/money.d.ts", "default": "./lib/money.mjs" },
16
- "./meta": { "types": "./types/meta.d.ts", "default": "./lib/meta.mjs" },
17
- "./kits": { "types": "./types/kits.d.ts", "default": "./lib/kits.mjs" },
11
+ ".": {
12
+ "types": "./types/index.d.ts",
13
+ "default": "./index.mjs"
14
+ },
15
+ "./booking": {
16
+ "types": "./types/booking.d.ts",
17
+ "default": "./lib/booking/render.mjs"
18
+ },
19
+ "./booking/words": {
20
+ "types": "./types/words.d.ts",
21
+ "default": "./lib/booking/words.mjs"
22
+ },
23
+ "./lead": {
24
+ "types": "./types/lead.d.ts",
25
+ "default": "./lib/lead/render.mjs"
26
+ },
27
+ "./money": {
28
+ "types": "./types/money.d.ts",
29
+ "default": "./lib/money.mjs"
30
+ },
31
+ "./meta": {
32
+ "types": "./types/meta.d.ts",
33
+ "default": "./lib/meta.mjs"
34
+ },
35
+ "./kits": {
36
+ "types": "./types/kits.d.ts",
37
+ "default": "./lib/kits.mjs"
38
+ },
18
39
  "./catalogue": "./lib/catalogue.mjs",
19
40
  "./codes": "./lib/codes.mjs",
20
- "./elements/booking": { "types": "./types/elements-booking.d.ts", "default": "./elements/booking.js" },
21
- "./elements/lead": { "types": "./types/elements-lead.d.ts", "default": "./elements/lead.js" },
22
- "./elements/alive": { "types": "./types/alive.d.ts", "default": "./elements/alive.js" },
41
+ "./elements/booking": {
42
+ "types": "./types/elements-booking.d.ts",
43
+ "default": "./elements/booking.js"
44
+ },
45
+ "./elements/lead": {
46
+ "types": "./types/elements-lead.d.ts",
47
+ "default": "./elements/lead.js"
48
+ },
49
+ "./elements/alive": {
50
+ "types": "./types/alive.d.ts",
51
+ "default": "./elements/alive.js"
52
+ },
23
53
  "./booking.css": "./booking.css",
24
54
  "./lead.css": "./lead.css"
25
55
  },
@@ -38,7 +68,7 @@
38
68
  },
39
69
  "repository": {
40
70
  "type": "git",
41
- "url": "https://github.com/jtakeit/astro.git",
71
+ "url": "https://github.com/jtakeit/jtakeit-kit.git",
42
72
  "directory": "kit"
43
73
  },
44
74
  "keywords": [
@@ -3,6 +3,10 @@ export interface Named {
3
3
  title: string;
4
4
  takes?: number;
5
5
  costs?: number;
6
+ /** A price by the hour of the week instead of `costs`: rows of a day, a span and a price. The list says «from …»; the price is the slot's. */
7
+ rates?: { day: string; from: number; until: number; costs: number }[];
8
+ /** `'person'` for a price times the party — a class per place, shoes; the list says so and the running total multiplies. */
9
+ per?: 'booking' | 'person';
6
10
  group?: string;
7
11
  }
8
12