@medalsocial/booking 0.0.0-placeholder.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/CHANGELOG.md +27 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +220 -1
- package/dist/core/age.d.mts +34 -0
- package/dist/core/age.mjs +95 -0
- package/dist/core/age.mjs.map +1 -0
- package/dist/core/attempt-store.d.mts +111 -0
- package/dist/core/attempt-store.mjs +159 -0
- package/dist/core/attempt-store.mjs.map +1 -0
- package/dist/core/categories.d.mts +27 -0
- package/dist/core/categories.mjs +42 -0
- package/dist/core/categories.mjs.map +1 -0
- package/dist/core/clock.d.mts +33 -0
- package/dist/core/clock.mjs +251 -0
- package/dist/core/clock.mjs.map +1 -0
- package/dist/core/config.d.mts +178 -0
- package/dist/core/config.mjs +394 -0
- package/dist/core/config.mjs.map +1 -0
- package/dist/core/consent.d.mts +14 -0
- package/dist/core/consent.mjs +17 -0
- package/dist/core/consent.mjs.map +1 -0
- package/dist/core/deep-link.d.mts +40 -0
- package/dist/core/deep-link.mjs +160 -0
- package/dist/core/deep-link.mjs.map +1 -0
- package/dist/core/display-name.d.mts +18 -0
- package/dist/core/display-name.mjs +30 -0
- package/dist/core/display-name.mjs.map +1 -0
- package/dist/core/draft-store.d.mts +89 -0
- package/dist/core/draft-store.mjs +131 -0
- package/dist/core/draft-store.mjs.map +1 -0
- package/dist/core/dto.d.mts +46 -0
- package/dist/core/dto.mjs +157 -0
- package/dist/core/dto.mjs.map +1 -0
- package/dist/core/ics.d.mts +63 -0
- package/dist/core/ics.mjs +130 -0
- package/dist/core/ics.mjs.map +1 -0
- package/dist/core/index.d.mts +35 -0
- package/dist/core/index.mjs +32 -0
- package/dist/core/labels.d.mts +88 -0
- package/dist/core/labels.mjs +93 -0
- package/dist/core/labels.mjs.map +1 -0
- package/dist/core/machine.d.mts +645 -0
- package/dist/core/machine.mjs +988 -0
- package/dist/core/machine.mjs.map +1 -0
- package/dist/core/money.d.mts +12 -0
- package/dist/core/money.mjs +51 -0
- package/dist/core/money.mjs.map +1 -0
- package/dist/core/next-available.d.mts +20 -0
- package/dist/core/next-available.mjs +36 -0
- package/dist/core/next-available.mjs.map +1 -0
- package/dist/core/next-free.d.mts +25 -0
- package/dist/core/next-free.mjs +44 -0
- package/dist/core/next-free.mjs.map +1 -0
- package/dist/core/party-slots.d.mts +79 -0
- package/dist/core/party-slots.mjs +229 -0
- package/dist/core/party-slots.mjs.map +1 -0
- package/dist/core/paths.d.mts +11 -0
- package/dist/core/paths.mjs +56 -0
- package/dist/core/paths.mjs.map +1 -0
- package/dist/core/phone.d.mts +17 -0
- package/dist/core/phone.mjs +58 -0
- package/dist/core/phone.mjs.map +1 -0
- package/dist/core/portal/cookies.d.mts +29 -0
- package/dist/core/portal/dto.d.mts +140 -0
- package/dist/core/portal/dto.mjs +142 -0
- package/dist/core/portal/dto.mjs.map +1 -0
- package/dist/core/portal/family.d.mts +39 -0
- package/dist/core/portal/family.mjs +39 -0
- package/dist/core/portal/family.mjs.map +1 -0
- package/dist/core/portal/login-input.d.mts +29 -0
- package/dist/core/portal/login-input.mjs +29 -0
- package/dist/core/portal/login-input.mjs.map +1 -0
- package/dist/core/portal/next-path.d.mts +12 -0
- package/dist/core/portal/next-path.mjs +98 -0
- package/dist/core/portal/next-path.mjs.map +1 -0
- package/dist/core/portal/return-path.d.mts +7 -0
- package/dist/core/portal/return-path.mjs +49 -0
- package/dist/core/portal/return-path.mjs.map +1 -0
- package/dist/core/portal/session-cookie.d.mts +27 -0
- package/dist/core/portal/session-cookie.mjs +30 -0
- package/dist/core/portal/session-cookie.mjs.map +1 -0
- package/dist/core/portal/vipps-link.d.mts +56 -0
- package/dist/core/portal/vipps-link.mjs +136 -0
- package/dist/core/portal/vipps-link.mjs.map +1 -0
- package/dist/core/portal/vipps-return.d.mts +39 -0
- package/dist/core/portal/vipps-return.mjs +69 -0
- package/dist/core/portal/vipps-return.mjs.map +1 -0
- package/dist/core/rebook-store.d.mts +28 -0
- package/dist/core/rebook-store.mjs +60 -0
- package/dist/core/rebook-store.mjs.map +1 -0
- package/dist/core/restore-gate.d.mts +40 -0
- package/dist/core/restore-gate.mjs +96 -0
- package/dist/core/restore-gate.mjs.map +1 -0
- package/dist/core/stores.d.mts +8 -0
- package/dist/core/stores.mjs +23 -0
- package/dist/core/stores.mjs.map +1 -0
- package/dist/core/types.d.mts +245 -0
- package/dist/core/visit.d.mts +26 -0
- package/dist/core/visit.mjs +40 -0
- package/dist/core/visit.mjs.map +1 -0
- package/dist/core/wire.d.mts +37 -0
- package/dist/next/cache/memory.d.mts +14 -0
- package/dist/next/cache/memory.mjs +51 -0
- package/dist/next/cache/memory.mjs.map +1 -0
- package/dist/next/cache/next-data.d.mts +6 -0
- package/dist/next/cache/next-data.mjs +35 -0
- package/dist/next/cache/next-data.mjs.map +1 -0
- package/dist/next/cache/noop.d.mts +6 -0
- package/dist/next/cache/noop.mjs +15 -0
- package/dist/next/cache/noop.mjs.map +1 -0
- package/dist/next/cache/workers.d.mts +29 -0
- package/dist/next/cache/workers.mjs +74 -0
- package/dist/next/cache/workers.mjs.map +1 -0
- package/dist/next/catalogue.d.mts +38 -0
- package/dist/next/catalogue.mjs +215 -0
- package/dist/next/catalogue.mjs.map +1 -0
- package/dist/next/handler.d.mts +11 -0
- package/dist/next/handler.mjs +150 -0
- package/dist/next/handler.mjs.map +1 -0
- package/dist/next/index.d.mts +17 -0
- package/dist/next/index.mjs +14 -0
- package/dist/next/loaders.d.mts +79 -0
- package/dist/next/loaders.mjs +195 -0
- package/dist/next/loaders.mjs.map +1 -0
- package/dist/next/manage-dto.d.mts +6 -0
- package/dist/next/manage-dto.mjs +28 -0
- package/dist/next/manage-dto.mjs.map +1 -0
- package/dist/next/medal.d.mts +105 -0
- package/dist/next/medal.mjs +155 -0
- package/dist/next/medal.mjs.map +1 -0
- package/dist/next/options.d.mts +158 -0
- package/dist/next/options.mjs +65 -0
- package/dist/next/options.mjs.map +1 -0
- package/dist/next/portal/guardian.d.mts +42 -0
- package/dist/next/portal/guardian.mjs +76 -0
- package/dist/next/portal/guardian.mjs.map +1 -0
- package/dist/next/portal/medal-portal.d.mts +161 -0
- package/dist/next/portal/medal-portal.mjs +825 -0
- package/dist/next/portal/medal-portal.mjs.map +1 -0
- package/dist/next/portal/session.d.mts +38 -0
- package/dist/next/portal/session.mjs +40 -0
- package/dist/next/portal/session.mjs.map +1 -0
- package/dist/next/portal/vipps-flash.d.mts +14 -0
- package/dist/next/portal/vipps-flash.mjs +41 -0
- package/dist/next/portal/vipps-flash.mjs.map +1 -0
- package/dist/next/portal.d.mts +205 -0
- package/dist/next/portal.mjs +469 -0
- package/dist/next/portal.mjs.map +1 -0
- package/dist/next/request.d.mts +51 -0
- package/dist/next/request.mjs +123 -0
- package/dist/next/request.mjs.map +1 -0
- package/dist/next/routes/catalogue-routes.mjs +344 -0
- package/dist/next/routes/catalogue-routes.mjs.map +1 -0
- package/dist/next/routes/create.mjs +460 -0
- package/dist/next/routes/create.mjs.map +1 -0
- package/dist/next/routes/manage.mjs +191 -0
- package/dist/next/routes/manage.mjs.map +1 -0
- package/dist/next/routes/portal-routes.mjs +312 -0
- package/dist/next/routes/portal-routes.mjs.map +1 -0
- package/dist/next/routes/shared.mjs +162 -0
- package/dist/next/routes/shared.mjs.map +1 -0
- package/dist/next/routes/vipps-return.mjs +166 -0
- package/dist/next/routes/vipps-return.mjs.map +1 -0
- package/dist/next/runtime.d.mts +49 -0
- package/dist/next/runtime.mjs +94 -0
- package/dist/next/runtime.mjs.map +1 -0
- package/dist/next/seed.d.mts +71 -0
- package/dist/next/seed.mjs +495 -0
- package/dist/next/seed.mjs.map +1 -0
- package/dist/next/server.d.mts +26 -0
- package/dist/next/server.mjs +63 -0
- package/dist/next/server.mjs.map +1 -0
- package/dist/react/BookingLink.d.mts +61 -0
- package/dist/react/BookingLink.mjs +102 -0
- package/dist/react/BookingLink.mjs.map +1 -0
- package/dist/react/BookingWizard.d.mts +49 -0
- package/dist/react/BookingWizard.mjs +598 -0
- package/dist/react/BookingWizard.mjs.map +1 -0
- package/dist/react/LoginPageQuery.d.mts +37 -0
- package/dist/react/LoginPageQuery.mjs +106 -0
- package/dist/react/LoginPageQuery.mjs.map +1 -0
- package/dist/react/LoginSheet.d.mts +49 -0
- package/dist/react/LoginSheet.mjs +98 -0
- package/dist/react/LoginSheet.mjs.map +1 -0
- package/dist/react/ManageBooking.d.mts +50 -0
- package/dist/react/ManageBooking.mjs +197 -0
- package/dist/react/ManageBooking.mjs.map +1 -0
- package/dist/react/PortalDashboard.d.mts +10 -0
- package/dist/react/Provider.d.mts +82 -0
- package/dist/react/Provider.mjs +132 -0
- package/dist/react/Provider.mjs.map +1 -0
- package/dist/react/actions.d.mts +133 -0
- package/dist/react/actions.mjs +90 -0
- package/dist/react/actions.mjs.map +1 -0
- package/dist/react/index.d.mts +28 -0
- package/dist/react/index.mjs +20 -0
- package/dist/react/kit.d.mts +54 -0
- package/dist/react/kit.mjs +127 -0
- package/dist/react/kit.mjs.map +1 -0
- package/dist/react/labels/index.d.mts +23 -0
- package/dist/react/labels/index.mjs +44 -0
- package/dist/react/labels/index.mjs.map +1 -0
- package/dist/react/labels/login.d.mts +10 -0
- package/dist/react/labels/login.mjs +13 -0
- package/dist/react/labels/login.mjs.map +1 -0
- package/dist/react/labels/manage.d.mts +15 -0
- package/dist/react/labels/manage.mjs +19 -0
- package/dist/react/labels/manage.mjs.map +1 -0
- package/dist/react/labels/portal.d.mts +25 -0
- package/dist/react/labels/portal.mjs +41 -0
- package/dist/react/labels/portal.mjs.map +1 -0
- package/dist/react/labels/screens.d.mts +11 -0
- package/dist/react/labels/screens.mjs +721 -0
- package/dist/react/labels/screens.mjs.map +1 -0
- package/dist/react/labels/wizard.d.mts +76 -0
- package/dist/react/labels/wizard.mjs +97 -0
- package/dist/react/labels/wizard.mjs.map +1 -0
- package/dist/react/link/index.d.mts +5 -0
- package/dist/react/link/index.mjs +5 -0
- package/dist/react/portal/SessionTouch.d.mts +19 -0
- package/dist/react/portal/SessionTouch.mjs +40 -0
- package/dist/react/portal/SessionTouch.mjs.map +1 -0
- package/dist/react/portal/age-prompt.d.mts +10 -0
- package/dist/react/portal/age-prompt.mjs +21 -0
- package/dist/react/portal/age-prompt.mjs.map +1 -0
- package/dist/react/portal/book-again.d.mts +18 -0
- package/dist/react/portal/book-again.mjs +21 -0
- package/dist/react/portal/book-again.mjs.map +1 -0
- package/dist/react/portal/child-summary.d.mts +17 -0
- package/dist/react/portal/child-summary.mjs +45 -0
- package/dist/react/portal/child-summary.mjs.map +1 -0
- package/dist/react/portal/dashboard.d.mts +74 -0
- package/dist/react/portal/dashboard.mjs +204 -0
- package/dist/react/portal/dashboard.mjs.map +1 -0
- package/dist/react/portal/leave.d.mts +17 -0
- package/dist/react/portal/leave.mjs +21 -0
- package/dist/react/portal/leave.mjs.map +1 -0
- package/dist/react/portal/sections.d.mts +12 -0
- package/dist/react/portal/sections.mjs +48 -0
- package/dist/react/portal/sections.mjs.map +1 -0
- package/dist/react/portal/tabs.d.mts +23 -0
- package/dist/react/portal/tabs.mjs +36 -0
- package/dist/react/portal/tabs.mjs.map +1 -0
- package/dist/react/portal/vipps-flash.d.mts +11 -0
- package/dist/react/portal/vipps-flash.mjs +18 -0
- package/dist/react/portal/vipps-flash.mjs.map +1 -0
- package/dist/react/portal/wired.mjs +202 -0
- package/dist/react/portal/wired.mjs.map +1 -0
- package/dist/react/screen-labels.mjs +14 -0
- package/dist/react/screen-labels.mjs.map +1 -0
- package/dist/react/shared.d.mts +13 -0
- package/dist/react/shared.mjs +7 -0
- package/dist/react/use-next-free.d.mts +19 -0
- package/dist/react/use-next-free.mjs +56 -0
- package/dist/react/use-next-free.mjs.map +1 -0
- package/dist/react/useBooking.d.mts +179 -0
- package/dist/react/useBooking.mjs +1331 -0
- package/dist/react/useBooking.mjs.map +1 -0
- package/dist/react/wizard/adapters.mjs +189 -0
- package/dist/react/wizard/adapters.mjs.map +1 -0
- package/dist/react/wizard/index.d.mts +11 -0
- package/dist/react/wizard/index.mjs +6 -0
- package/dist/react/wizard-error.mjs +29 -0
- package/dist/react/wizard-error.mjs.map +1 -0
- package/package.json +126 -5
|
@@ -0,0 +1,1331 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { fill, labelText } from "../core/labels.mjs";
|
|
3
|
+
import "../core/attempt-store.mjs";
|
|
4
|
+
import { stylistDisplayName } from "../core/display-name.mjs";
|
|
5
|
+
import { serviceMatches } from "../core/deep-link.mjs";
|
|
6
|
+
import "../core/draft-store.mjs";
|
|
7
|
+
import { visitKey, visitServicesOf } from "../core/visit.mjs";
|
|
8
|
+
import { firstOpeningPerResource } from "../core/next-available.mjs";
|
|
9
|
+
import { slotKeysFor } from "../core/party-slots.mjs";
|
|
10
|
+
import { stripVippsReturn, vippsConfirmFrom } from "../core/portal/vipps-return.mjs";
|
|
11
|
+
import { useBookingKit } from "./Provider.mjs";
|
|
12
|
+
import { useSearchParams } from "next/navigation";
|
|
13
|
+
import { useEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from "react";
|
|
14
|
+
//#region src/react/useBooking.ts
|
|
15
|
+
/**
|
|
16
|
+
* `useBooking()` — the booking wizard without its markup.
|
|
17
|
+
*
|
|
18
|
+
* Everything `<BookingWizard>` knows lives here, and the wizard is built on
|
|
19
|
+
* it, so the headless path is never second-class: the machine (through
|
|
20
|
+
* `useReducer`), the fetches to the site's own routes under `config.paths.api`
|
|
21
|
+
* (availability, stylists, hours, the submission — the Medal key never leaves
|
|
22
|
+
* the server), the draft / attempt / rebook stores, the restore of a booking
|
|
23
|
+
* a reload or a Vipps login interrupted, the deep-link and «book again»
|
|
24
|
+
* prefill, the 409 slot-taken refresh and the idempotency nonce.
|
|
25
|
+
*
|
|
26
|
+
* What it does NOT own is the page: focus, scrolling, which screen is drawn.
|
|
27
|
+
* Those are the caller's (see `BookingWizard.tsx`).
|
|
28
|
+
*
|
|
29
|
+
* Moved from the source app's wizard shell with its behaviour unchanged; the
|
|
30
|
+
* long-form reasoning for each rule is kept beside the rule.
|
|
31
|
+
*/
|
|
32
|
+
/** How long a replayed submission may take before the restore gate gives up. */
|
|
33
|
+
const REPLAY_TIMEOUT_MS = 15e3;
|
|
34
|
+
/** «hydrated» only ever flips once; `useSyncExternalStore` wants a subscription. */
|
|
35
|
+
function noSubscription() {
|
|
36
|
+
return () => {};
|
|
37
|
+
}
|
|
38
|
+
/** The `freshSlots` of a `slotTaken` answer, narrowed to the lost visits' lists. */
|
|
39
|
+
function freshSlotsOf(raw, lost) {
|
|
40
|
+
if (typeof raw !== "object" || raw === null) return {};
|
|
41
|
+
return Object.fromEntries(Object.entries(raw).filter((entry) => lost.has(entry[0]) && Array.isArray(entry[1])));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The window query for ONE visit, named by its key (`a` or `a+b`): the first
|
|
45
|
+
* service as `service_id`, the rest as `extra_service_ids`. The extras are
|
|
46
|
+
* added only when there are some, so a one-service visit asks with exactly the
|
|
47
|
+
* URL it always did — the slot cache and every seeded answer are keyed by it.
|
|
48
|
+
*/
|
|
49
|
+
function windowQuery(key, fromTs, toTs) {
|
|
50
|
+
const [serviceId, ...extras] = key.split("+");
|
|
51
|
+
return new URLSearchParams({
|
|
52
|
+
service_id: serviceId,
|
|
53
|
+
...extras.length === 0 ? {} : { extra_service_ids: extras.join(",") },
|
|
54
|
+
from_ts: String(fromTs),
|
|
55
|
+
to_ts: String(toTs)
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The submission with each line's extras on it. meda's details screen builds
|
|
60
|
+
* the body from its own types, which have one service per line, so the shell
|
|
61
|
+
* is the only place that knows the rest of a person's visit. Index-aligned with
|
|
62
|
+
* the basket the screen was drawn from; a body of another length is not one
|
|
63
|
+
* this basket produced, and is sent as it came rather than with extras pinned
|
|
64
|
+
* to the wrong people.
|
|
65
|
+
*/
|
|
66
|
+
function withExtraServices(submission, items) {
|
|
67
|
+
if (!items.some((item) => (item.extraServices ?? []).length > 0)) return submission;
|
|
68
|
+
if (submission.items.length !== items.length) {
|
|
69
|
+
console.warn("[@medalsocial/booking] The submission has %d lines for a basket of %d; not sent.", submission.items.length, items.length);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
...submission,
|
|
74
|
+
items: submission.items.map((line, index) => {
|
|
75
|
+
const extras = items[index].extraServices ?? [];
|
|
76
|
+
return extras.length === 0 ? line : {
|
|
77
|
+
...line,
|
|
78
|
+
extraServiceIds: extras.map((service) => service.id)
|
|
79
|
+
};
|
|
80
|
+
})
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** What the create route said, narrowed to what the machine can hold. */
|
|
84
|
+
function asSubmitFailure(code) {
|
|
85
|
+
switch (code) {
|
|
86
|
+
case "conflict":
|
|
87
|
+
case "invalidInput":
|
|
88
|
+
case "unconfigured":
|
|
89
|
+
case "inProgress": return code;
|
|
90
|
+
default: return "upstreamError";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** What the named stylist can do; `undefined` for «nobody named» or «not loaded». */
|
|
94
|
+
function serviceIdsOf(resources, resourceId) {
|
|
95
|
+
if (resourceId === null) return void 0;
|
|
96
|
+
return resources.find((resource) => resource.id === resourceId)?.serviceIds;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Whether a create answer is a whole one: exactly one booking, with its own
|
|
100
|
+
* distinct id, per submitted line. Anything else — none, fewer, more, an id
|
|
101
|
+
* missing or repeated — is not a
|
|
102
|
+
* confirmation; the wizard shows it as the generic create failure
|
|
103
|
+
* (`upstreamError`, «we still do not know»), whose attempt survives for the
|
|
104
|
+
* replay under the same idempotency key.
|
|
105
|
+
*/
|
|
106
|
+
function isCompleteConfirmation(bookings, itemCount) {
|
|
107
|
+
if (!Array.isArray(bookings) || itemCount <= 0 || bookings.length !== itemCount) return false;
|
|
108
|
+
const ids = bookings.map((booking) => booking?.id);
|
|
109
|
+
return ids.every((id) => typeof id === "string" && id.length > 0) && new Set(ids).size === ids.length;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The basket and the create response, zipped by INDEX — each line its OWN
|
|
113
|
+
* booking. Only a whole answer (`isCompleteConfirmation`) gets this far; a line
|
|
114
|
+
* the answer has no booking for is left out rather than handed a sibling's id,
|
|
115
|
+
* which would give two calendar entries one UID.
|
|
116
|
+
*/
|
|
117
|
+
function confirmationLines(confirmation) {
|
|
118
|
+
const { submitted, bookings } = confirmation;
|
|
119
|
+
return submitted.items.flatMap((item, index) => {
|
|
120
|
+
const booking = bookings[index];
|
|
121
|
+
if (booking === void 0) return [];
|
|
122
|
+
return [{
|
|
123
|
+
item,
|
|
124
|
+
bookingId: booking.id,
|
|
125
|
+
stylistName: submitted.stylistNames[index] ?? null,
|
|
126
|
+
manageHref: booking.manageHref
|
|
127
|
+
}];
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/** Only the stylist the visitor asked for. */
|
|
131
|
+
function onlyResource(slots, resourceId) {
|
|
132
|
+
return slots.filter((slot) => slot.resourceId === resourceId);
|
|
133
|
+
}
|
|
134
|
+
/** The catalogue a prefill is checked against: what step 2 lets the visitor TAP. */
|
|
135
|
+
function prefillCatalogue(services, resources) {
|
|
136
|
+
return {
|
|
137
|
+
services: services.filter((service) => service.bookableOnline),
|
|
138
|
+
resources
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** A stylist without a service is nothing to finish. */
|
|
142
|
+
function stylistLeftToFill(prefill) {
|
|
143
|
+
return prefill?.serviceId && prefill.resourceId ? prefill : null;
|
|
144
|
+
}
|
|
145
|
+
/** Whether the stylist list can now vouch for the pending link. */
|
|
146
|
+
function canFinishStylist(pending, resources, primaryVisit) {
|
|
147
|
+
return pending !== null && resources.length > 0 && primaryVisit === pending.serviceId;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* A saved child as the person the machine seats: by `personId` when Medal has
|
|
151
|
+
* one, else by their PLACE in the profile with name and year beside it, so two
|
|
152
|
+
* «Emma 2018» are two seats and a reordered list matches nobody.
|
|
153
|
+
*/
|
|
154
|
+
function personForChild(child, index) {
|
|
155
|
+
return {
|
|
156
|
+
key: child.personId ? `p:${child.personId}` : `n:${index}:${child.name}:${child.birthYear}`,
|
|
157
|
+
name: child.name,
|
|
158
|
+
birthYear: child.birthYear,
|
|
159
|
+
...child.personId ? { personId: child.personId } : {},
|
|
160
|
+
...child.birthMonth ? { birthMonth: child.birthMonth } : {}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** The pure parts of the wizard's setup, bound to one kit. */
|
|
164
|
+
function setupFor(kit) {
|
|
165
|
+
const { config, wizard, deepLinks } = kit;
|
|
166
|
+
const q = config.query;
|
|
167
|
+
/** «Bestill igjen» (`?service=&stylist=`) or a marketing `?tjeneste=` link, or `null`. */
|
|
168
|
+
function prefillFromQuery(query, services) {
|
|
169
|
+
if (query === null) return null;
|
|
170
|
+
const linked = query.get(q.service);
|
|
171
|
+
const linkedService = linked ? services.find((service) => service.bookableOnline && serviceMatches(service, linked)) : void 0;
|
|
172
|
+
const serviceId = query.get(q.rebookService) || linkedService?.id || null;
|
|
173
|
+
const resourceId = query.get(q.rebookStylist) || (serviceId ? query.get(q.stylist) : null);
|
|
174
|
+
if (!serviceId && !resourceId) return null;
|
|
175
|
+
return {
|
|
176
|
+
serviceId,
|
|
177
|
+
resourceId,
|
|
178
|
+
party: serviceId ? deepLinks.parseParty(query.get(q.party)) : null
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function deepLinkFromQuery(query) {
|
|
182
|
+
return {
|
|
183
|
+
category: deepLinks.parseCategory(query?.get(q.category)),
|
|
184
|
+
stylist: query?.get(q.stylist)?.trim() || null,
|
|
185
|
+
party: deepLinks.parseParty(query?.get(q.party)),
|
|
186
|
+
seat: deepLinks.impliedParty(query)
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/** One grown-up seated, as the adult chip does. */
|
|
190
|
+
function withAdult() {
|
|
191
|
+
return wizard.reduce(wizard.initialState(), {
|
|
192
|
+
type: "choosePeople",
|
|
193
|
+
people: [{
|
|
194
|
+
key: "adult",
|
|
195
|
+
adult: true
|
|
196
|
+
}],
|
|
197
|
+
advance: true
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/** `n` guest children seated, clamped to the chips there are. */
|
|
201
|
+
function withGuests(party) {
|
|
202
|
+
const count = Math.min(Math.max(1, party), wizard.maxPeople);
|
|
203
|
+
const people = Array.from({ length: count }, (_, index) => wizard.guestChild(index + 1));
|
|
204
|
+
return wizard.reduce(wizard.initialState(), {
|
|
205
|
+
type: "choosePeople",
|
|
206
|
+
people,
|
|
207
|
+
advance: true
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
/** `state` with the link's service held for step 1, when the catalogue has it. */
|
|
211
|
+
function heldLink(state, prefill, services, resources) {
|
|
212
|
+
const service = prefill?.serviceId ? prefillCatalogue(services, resources).services.find((entry) => entry.id === prefill.serviceId) : void 0;
|
|
213
|
+
return service === void 0 ? state : wizard.reduce(state, {
|
|
214
|
+
type: "holdService",
|
|
215
|
+
service
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Where the wizard starts: empty, or with the link folded in — in the
|
|
220
|
+
* reducer's initialiser, so the server render already shows that step.
|
|
221
|
+
*/
|
|
222
|
+
function initialWizardState(prefill, services, resources, seat = null, familyKnown = false) {
|
|
223
|
+
if (familyKnown) return heldLink(wizard.initialState(), prefill, services, resources);
|
|
224
|
+
const unasked = config.party.askWhoFirst ? null : kit.childCategory !== null ? { children: 1 } : { adult: true };
|
|
225
|
+
const seated = seat ?? unasked;
|
|
226
|
+
const base = seated !== null && "adult" in seated ? withAdult() : wizard.initialState();
|
|
227
|
+
if (prefill === null) {
|
|
228
|
+
if (seated === null) return wizard.initialState();
|
|
229
|
+
return "adult" in seated ? withAdult() : withGuests(seated.children);
|
|
230
|
+
}
|
|
231
|
+
return wizard.applyPrefill(base, prefill, prefillCatalogue(services, resources));
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
prefillFromQuery,
|
|
235
|
+
deepLinkFromQuery,
|
|
236
|
+
heldLink,
|
|
237
|
+
initialWizardState
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function useBooking(options) {
|
|
241
|
+
const { kit } = useBookingKit(options, options.contact);
|
|
242
|
+
const { config, labels, wizard, clock, age, phone, deepLinks } = kit;
|
|
243
|
+
const services = options.seed.services;
|
|
244
|
+
const arrivedAs = options.guardian ?? null;
|
|
245
|
+
const rangeDays = options.rangeDays ?? config.window.rangeDays;
|
|
246
|
+
const setup = useMemo(() => setupFor(kit), [kit]);
|
|
247
|
+
/**
|
|
248
|
+
* Who logged in from the login sheet — and whether anybody did, which is
|
|
249
|
+
* not the same question: a good code whose profile read failed is still a
|
|
250
|
+
* logged-in parent, who must not be offered the login again.
|
|
251
|
+
*/
|
|
252
|
+
const [signedIn, setSignedIn] = useState(null);
|
|
253
|
+
const guardian = signedIn?.guardian ?? arrivedAs;
|
|
254
|
+
const query = useSearchParams();
|
|
255
|
+
const [prefill] = useState(() => setup.prefillFromQuery(query, services));
|
|
256
|
+
const [deepLink] = useState(() => setup.deepLinkFromQuery(query));
|
|
257
|
+
const deepLinkPending = useRef(prefill === null || prefill.serviceId == null);
|
|
258
|
+
const [linked] = useState(() => prefill !== null || deepLinks.hasDeepLink(query));
|
|
259
|
+
const [vippsResumePath] = useState(() => deepLinks.resumePath(query));
|
|
260
|
+
const [resuming] = useState(() => query?.get(config.query.resume) === "1");
|
|
261
|
+
const [vippsConfirm] = useState(() => vippsConfirmFrom(query) ?? null);
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
if (vippsConfirm !== null) stripVippsReturn();
|
|
264
|
+
}, [vippsConfirm]);
|
|
265
|
+
/** `false` on the server and through hydration, `true` from the first render after it. */
|
|
266
|
+
const hydrated = useSyncExternalStore(noSubscription, () => true, () => false);
|
|
267
|
+
const [restoreExpected] = useState(() => typeof window !== "undefined" && kit.restoreGate.restorePendingInStorage(kit.attempts.ATTEMPT_STORAGE_KEY, 36e5, kit.drafts.DRAFT_STORAGE_KEY, 18e5, linked, resuming));
|
|
268
|
+
const [restoreSettled, setRestoreSettled] = useState(false);
|
|
269
|
+
const restoring = restoreExpected && !restoreSettled;
|
|
270
|
+
const [state, dispatch] = useReducer(wizard.reduce, prefill, (initial) => setup.initialWizardState(initial, services, options.seed.resources ?? [], deepLink.seat, arrivedAs !== null));
|
|
271
|
+
/** Children a logged-in parent added from step 1 during this visit. */
|
|
272
|
+
const [addedChildren, setAddedChildren] = useState([]);
|
|
273
|
+
const family = useMemo(() => guardian === null ? null : [...guardian.family, ...addedChildren], [guardian, addedChildren]);
|
|
274
|
+
/** Guest seats go when a family appears over them (`seatFamily`). */
|
|
275
|
+
const guestSeated = family !== null && state.people.some(wizard.isGuestSeat);
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
if (guestSeated) dispatch({ type: "seatFamily" });
|
|
278
|
+
}, [guestSeated, state.step]);
|
|
279
|
+
/** A stylist a party link named, applied once the family reaches step 3. */
|
|
280
|
+
const partyStylist = useRef(arrivedAs !== null ? prefill?.resourceId ?? deepLink.stylist : null);
|
|
281
|
+
const childByKey = useMemo(() => new Map((family ?? []).map((child, index) => [personForChild(child, index).key, child])), [family]);
|
|
282
|
+
const [fromTs] = useState(() => options.seed.fromTs ?? Date.now());
|
|
283
|
+
const { toTs, days } = useMemo(() => clock.window(fromTs, rangeDays), [
|
|
284
|
+
clock,
|
|
285
|
+
fromTs,
|
|
286
|
+
rangeDays
|
|
287
|
+
]);
|
|
288
|
+
/** The business date the ages on steps 1 and 2 are read on: the chosen day, else today. */
|
|
289
|
+
const ageDayKey = clock.dayKey(state.startTs ?? fromTs);
|
|
290
|
+
/** A child's age on the business day `dayKey`; `null` for a grown-up or an unknown year. */
|
|
291
|
+
function personAge(person, dayKey) {
|
|
292
|
+
if (person.adult || person.birthYear === void 0) return null;
|
|
293
|
+
return age.ageOnDay(person.birthYear, person.birthMonth, dayKey);
|
|
294
|
+
}
|
|
295
|
+
/** «Samme som sist»: the child's last visit, where it is still taken online. */
|
|
296
|
+
const suggestionFor = (person) => {
|
|
297
|
+
const serviceId = childByKey.get(person.key)?.lastVisit?.serviceId;
|
|
298
|
+
if (!serviceId) return null;
|
|
299
|
+
const bookable = services.filter((entry) => entry.bookableOnline);
|
|
300
|
+
const last = bookable.find((entry) => entry.id === serviceId);
|
|
301
|
+
if (!last) return null;
|
|
302
|
+
const ageNow = personAge(person, ageDayKey);
|
|
303
|
+
if (age.fitsAge(last, ageNow)) return { service: last };
|
|
304
|
+
const grownUp = age.grownUpEquivalent(last, bookable, ageNow);
|
|
305
|
+
if (!grownUp) return null;
|
|
306
|
+
return {
|
|
307
|
+
service: grownUp,
|
|
308
|
+
note: fill(labels["wizard.suggestion.outgrown"], {
|
|
309
|
+
last: last.name,
|
|
310
|
+
name: person.name ?? labelText(labels["wizard.suggestion.someone"]),
|
|
311
|
+
next: grownUp.name
|
|
312
|
+
})
|
|
313
|
+
};
|
|
314
|
+
};
|
|
315
|
+
const pendingStylist = useRef(stylistLeftToFill(prefill));
|
|
316
|
+
/**
|
|
317
|
+
* Unfiltered openings, keyed by VISIT (`visitKey`): a service id for a
|
|
318
|
+
* one-service visit — which is all the server seed ever holds — and `a+b`
|
|
319
|
+
* for a person having both, whose list is the server's answer for the whole
|
|
320
|
+
* visit rather than either service's own.
|
|
321
|
+
*/
|
|
322
|
+
const [slots, setSlots] = useState(() => options.seed.slots ?? {});
|
|
323
|
+
const [slotsFailed, setSlotsFailed] = useState(false);
|
|
324
|
+
const [resources, setResources] = useState(() => options.seed.resources ?? []);
|
|
325
|
+
/** Whether the stylist list has been answered at all — seeded, fetched or failed. */
|
|
326
|
+
const [resourcesKnown, setResourcesKnown] = useState(() => options.seed.resources != null);
|
|
327
|
+
const [seededNextAvailable, setSeededNextAvailable] = useState(() => options.seed.resources ? options.seed.nextAvailable ?? {} : {});
|
|
328
|
+
const [seededSchedule] = useState(() => options.seed.schedules ?? {});
|
|
329
|
+
/** The last «next available» answer, and for WHICH visit (its key). */
|
|
330
|
+
const [fetchedNextAvailable, setFetchedNextAvailable] = useState(null);
|
|
331
|
+
const [submitting, setSubmitting] = useState(false);
|
|
332
|
+
/**
|
|
333
|
+
* The attempt (and its nonce), null until the browser has it: storage does
|
|
334
|
+
* not exist during the server render, and reading it in an initialiser
|
|
335
|
+
* would give the two sides different trees.
|
|
336
|
+
*/
|
|
337
|
+
const [attempt, setAttempt] = useState(null);
|
|
338
|
+
/** The submission whose answer is still unknown; a retry resends THAT body. */
|
|
339
|
+
const [pendingAttempt, setPendingAttempt] = useState(null);
|
|
340
|
+
const submissionNonce = attempt?.nonce;
|
|
341
|
+
/** The instant that was taken while the visitor was filling in the form. */
|
|
342
|
+
const [takenSlotTs, setTakenSlotTs] = useState(null);
|
|
343
|
+
/** Set once, by a successful submission — one entry per line item. */
|
|
344
|
+
const [confirmed, setConfirmed] = useState(null);
|
|
345
|
+
const resumed = useRef(false);
|
|
346
|
+
/** The whole «book again» link, kept to apply AGAIN after a refused replay. */
|
|
347
|
+
const rebookLink = useRef(prefill);
|
|
348
|
+
const latestResources = useRef(resources);
|
|
349
|
+
useEffect(() => {
|
|
350
|
+
latestResources.current = resources;
|
|
351
|
+
}, [resources]);
|
|
352
|
+
const applyRebookWho = () => {
|
|
353
|
+
const who = kit.rebook.takeRebookWho();
|
|
354
|
+
if (who === null) return;
|
|
355
|
+
rebookLink.current = {
|
|
356
|
+
...rebookLink.current ?? {},
|
|
357
|
+
who
|
|
358
|
+
};
|
|
359
|
+
const known = guardian?.family ?? [];
|
|
360
|
+
const named = known.filter((child) => child.name === who);
|
|
361
|
+
if (named.length === 1) {
|
|
362
|
+
const service = state.items[0]?.service ?? state.pendingService;
|
|
363
|
+
dispatch({
|
|
364
|
+
type: "choosePeople",
|
|
365
|
+
people: [personForChild(named[0], known.indexOf(named[0]))],
|
|
366
|
+
services: [service]
|
|
367
|
+
});
|
|
368
|
+
if (service !== null) dispatch({
|
|
369
|
+
type: "pickServiceFor",
|
|
370
|
+
index: 0,
|
|
371
|
+
service
|
|
372
|
+
});
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
dispatch({
|
|
376
|
+
type: "prefill",
|
|
377
|
+
prefill: { who },
|
|
378
|
+
catalogue: prefillCatalogue(services, [])
|
|
379
|
+
});
|
|
380
|
+
};
|
|
381
|
+
/** Put the «book again» link back onto a wizard that has just started over, once. */
|
|
382
|
+
const restartFromLink = () => {
|
|
383
|
+
const link = rebookLink.current;
|
|
384
|
+
if (link === null) return;
|
|
385
|
+
rebookLink.current = null;
|
|
386
|
+
const current = latestResources.current;
|
|
387
|
+
if (guardian !== null) {
|
|
388
|
+
const held = setup.heldLink(wizard.initialState(), link, services, current);
|
|
389
|
+
dispatch({
|
|
390
|
+
type: "holdService",
|
|
391
|
+
service: held.pendingService
|
|
392
|
+
});
|
|
393
|
+
partyStylist.current = link.resourceId ?? null;
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
dispatch({
|
|
397
|
+
type: "prefill",
|
|
398
|
+
prefill: link,
|
|
399
|
+
catalogue: prefillCatalogue(services, current)
|
|
400
|
+
});
|
|
401
|
+
if (current.length === 0) pendingStylist.current = stylistLeftToFill(link);
|
|
402
|
+
};
|
|
403
|
+
/** The stylists a restored slot was booked with, until the catalogue can vouch for them. */
|
|
404
|
+
const restoredStylists = useRef(null);
|
|
405
|
+
/**
|
|
406
|
+
* A logged-in parent's saved children back in their chairs: each draft line
|
|
407
|
+
* whose stored `personId` is a child of THIS guardian's family is reseated as
|
|
408
|
+
* that child (`choosePeople`, then its service again), so it is submitted
|
|
409
|
+
* with `booked_for_person_id` as it would have been without the round trip.
|
|
410
|
+
* An id the family does not have — another parent's, a child removed since,
|
|
411
|
+
* a hand-edited jar — is ignored, and its line restores as a name and a year
|
|
412
|
+
* exactly as a draft without ids does. Returns the reseated line indexes.
|
|
413
|
+
*/
|
|
414
|
+
const reseatFamily = (draft, lines) => {
|
|
415
|
+
const reseated = /* @__PURE__ */ new Set();
|
|
416
|
+
if (family === null) return reseated;
|
|
417
|
+
const keys = /* @__PURE__ */ new Set();
|
|
418
|
+
const people = draft.items.map((item, index) => {
|
|
419
|
+
const built = wizard.guestChild(index + 1);
|
|
420
|
+
const at = item.personId ? family.findIndex((child) => child.personId === item.personId) : -1;
|
|
421
|
+
const child = at === -1 ? null : personForChild(family[at], at);
|
|
422
|
+
if (child === null || keys.has(child.key)) {
|
|
423
|
+
keys.add(built.key);
|
|
424
|
+
return built;
|
|
425
|
+
}
|
|
426
|
+
keys.add(child.key);
|
|
427
|
+
reseated.add(index);
|
|
428
|
+
return child;
|
|
429
|
+
});
|
|
430
|
+
if (reseated.size === 0) return reseated;
|
|
431
|
+
dispatch({
|
|
432
|
+
type: "choosePeople",
|
|
433
|
+
people
|
|
434
|
+
});
|
|
435
|
+
for (const index of reseated) dispatch({
|
|
436
|
+
type: "pickServiceFor",
|
|
437
|
+
index,
|
|
438
|
+
service: lines[index]
|
|
439
|
+
});
|
|
440
|
+
return reseated;
|
|
441
|
+
};
|
|
442
|
+
/**
|
|
443
|
+
* Each draft line's extras back on it, through `toggleServiceFor` like a tap.
|
|
444
|
+
* Returns whether EVERY extra came back. The machine's refusals are checked
|
|
445
|
+
* here first rather than read off its state, which a dispatch does not hand
|
|
446
|
+
* back: an id the catalogue no longer books, one already on the line (a
|
|
447
|
+
* toggle would take it OFF again), one past `maxServicesPerPerson`, or one
|
|
448
|
+
* more taker than the service's `maxPerBooking` — each is skipped, as an
|
|
449
|
+
* unknown main service is, and makes the answer `false`.
|
|
450
|
+
*/
|
|
451
|
+
const restoreExtras = (draft, lines, bookable) => {
|
|
452
|
+
let whole = true;
|
|
453
|
+
const lists = lines.map((service) => [service]);
|
|
454
|
+
draft.items.forEach((item, index) => {
|
|
455
|
+
for (const id of item.extraServiceIds ?? []) {
|
|
456
|
+
const service = bookable.get(id);
|
|
457
|
+
const list = lists[index];
|
|
458
|
+
const takers = lists.filter((other) => other.some((entry) => entry.id === id)).length;
|
|
459
|
+
if (service === void 0 || list.some((entry) => entry.id === id) || list.length >= config.party.maxServicesPerPerson || takers + 1 > service.maxPerBooking) {
|
|
460
|
+
whole = false;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
list.push(service);
|
|
464
|
+
dispatch({
|
|
465
|
+
type: "toggleServiceFor",
|
|
466
|
+
index,
|
|
467
|
+
service
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
return whole;
|
|
472
|
+
};
|
|
473
|
+
/**
|
|
474
|
+
* The booking a Vipps login interrupted, rebuilt through the machine's own
|
|
475
|
+
* actions — so it obeys every rule a tapped one does — and trusting nothing
|
|
476
|
+
* beyond the catalogue.
|
|
477
|
+
*/
|
|
478
|
+
const restoreDraft = (draft) => {
|
|
479
|
+
const bookable = new Map(services.filter((service) => service.bookableOnline).map((service) => [service.id, service]));
|
|
480
|
+
const resolved = draft.items.map((item) => bookable.get(item.serviceId));
|
|
481
|
+
const [first, ...rest] = resolved;
|
|
482
|
+
if (first === void 0 || resolved.some((service) => service === void 0)) return;
|
|
483
|
+
if (family === null && draft.items.every((item) => typeof item.adult === "boolean")) dispatch({
|
|
484
|
+
type: "choosePeople",
|
|
485
|
+
people: draft.items.map((item, index) => item.adult ? {
|
|
486
|
+
key: "adult",
|
|
487
|
+
adult: true
|
|
488
|
+
} : wizard.guestChild(index + 1)),
|
|
489
|
+
advance: true
|
|
490
|
+
});
|
|
491
|
+
dispatch({
|
|
492
|
+
type: "pickService",
|
|
493
|
+
service: first
|
|
494
|
+
});
|
|
495
|
+
for (const service of rest) dispatch({
|
|
496
|
+
type: "addService",
|
|
497
|
+
service
|
|
498
|
+
});
|
|
499
|
+
const reseated = reseatFamily(draft, resolved);
|
|
500
|
+
const wholeVisits = restoreExtras(draft, resolved, bookable);
|
|
501
|
+
if (draft.partyMode === "parallel") dispatch({
|
|
502
|
+
type: "setPartyMode",
|
|
503
|
+
mode: "parallel"
|
|
504
|
+
});
|
|
505
|
+
dispatch({
|
|
506
|
+
type: "pickResource",
|
|
507
|
+
resourceId: draft.resourceId
|
|
508
|
+
});
|
|
509
|
+
draft.items.forEach((item, index) => {
|
|
510
|
+
if (reseated.has(index)) return;
|
|
511
|
+
if (item.bookedForName !== null) dispatch({
|
|
512
|
+
type: "setItemField",
|
|
513
|
+
index,
|
|
514
|
+
field: "bookedForName",
|
|
515
|
+
value: item.bookedForName
|
|
516
|
+
});
|
|
517
|
+
if (item.bookedForBirthYear !== null) dispatch({
|
|
518
|
+
type: "setItemField",
|
|
519
|
+
index,
|
|
520
|
+
field: "bookedForBirthYear",
|
|
521
|
+
value: item.bookedForBirthYear
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
if (draft.startTs === null || !wholeVisits) return;
|
|
525
|
+
if (draft.partyResourceIds !== null && draft.partyResourceIds.length === resolved.length) {
|
|
526
|
+
restoredStylists.current = draft.partyResourceIds;
|
|
527
|
+
dispatch({
|
|
528
|
+
type: "pickPartySlot",
|
|
529
|
+
startTs: draft.startTs,
|
|
530
|
+
resourceIds: draft.partyResourceIds,
|
|
531
|
+
mode: draft.partyMode
|
|
532
|
+
});
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
restoredStylists.current = draft.resolvedResourceId === null ? null : [draft.resolvedResourceId];
|
|
536
|
+
dispatch({
|
|
537
|
+
type: "pickSlot",
|
|
538
|
+
startTs: draft.startTs,
|
|
539
|
+
resourceId: draft.resolvedResourceId
|
|
540
|
+
});
|
|
541
|
+
};
|
|
542
|
+
useEffect(() => {
|
|
543
|
+
if (resumed.current) return;
|
|
544
|
+
resumed.current = true;
|
|
545
|
+
const replay = restoreFromStorage();
|
|
546
|
+
if (replay === null) setRestoreSettled(true);
|
|
547
|
+
else replay.finally(() => setRestoreSettled(true));
|
|
548
|
+
}, []);
|
|
549
|
+
/** What the mount effect restores, in order; the replay when it started one. */
|
|
550
|
+
function restoreFromStorage() {
|
|
551
|
+
let stored = kit.attempts.readAttempt();
|
|
552
|
+
if (linked && stored.confirmed !== void 0 && stored.pending === void 0) {
|
|
553
|
+
kit.attempts.clearAttempt();
|
|
554
|
+
stored = kit.attempts.readAttempt();
|
|
555
|
+
}
|
|
556
|
+
if (stored.confirmed !== void 0 && !isCompleteConfirmation(stored.confirmed.bookings, stored.confirmed.submitted.items.length)) {
|
|
557
|
+
kit.attempts.clearAttempt();
|
|
558
|
+
stored = kit.attempts.readAttempt();
|
|
559
|
+
}
|
|
560
|
+
setAttempt(stored);
|
|
561
|
+
applyRebookWho();
|
|
562
|
+
if (stored.confirmed !== void 0) {
|
|
563
|
+
setConfirmed(stored.confirmed);
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
if (stored.pending === void 0) {
|
|
567
|
+
if (resuming) {
|
|
568
|
+
const draft = kit.drafts.takeDraft();
|
|
569
|
+
if (draft !== null) restoreDraft(draft);
|
|
570
|
+
}
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
if (rebookLink.current !== null) {
|
|
574
|
+
pendingStylist.current = null;
|
|
575
|
+
dispatch({ type: "startOver" });
|
|
576
|
+
}
|
|
577
|
+
setPendingAttempt(stored.pending);
|
|
578
|
+
return send(stored.pending.submission, stored.pending.submitted, { replay: true });
|
|
579
|
+
}
|
|
580
|
+
/** The draft, kept current on every answer rather than written on the Vipps tap. */
|
|
581
|
+
useEffect(() => {
|
|
582
|
+
if (confirmed !== null || state.items.length === 0) {
|
|
583
|
+
kit.drafts.clearDraft();
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
kit.drafts.stashDraft({
|
|
587
|
+
items: state.items.map((item) => ({
|
|
588
|
+
serviceId: item.service.id,
|
|
589
|
+
...item.extraServices?.length ? { extraServiceIds: item.extraServices.map((service) => service.id) } : {},
|
|
590
|
+
bookedForName: item.bookedForName ?? null,
|
|
591
|
+
bookedForBirthYear: item.bookedForBirthYear ?? null,
|
|
592
|
+
adult: item.adult === true,
|
|
593
|
+
personId: item.bookedForPersonId ?? null
|
|
594
|
+
})),
|
|
595
|
+
resourceId: state.resourceId,
|
|
596
|
+
partyMode: state.partyMode,
|
|
597
|
+
startTs: state.startTs,
|
|
598
|
+
resolvedResourceId: state.resolvedResourceId,
|
|
599
|
+
partyResourceIds: state.partyResourceIds
|
|
600
|
+
});
|
|
601
|
+
}, [
|
|
602
|
+
kit,
|
|
603
|
+
confirmed,
|
|
604
|
+
state.items,
|
|
605
|
+
state.resourceId,
|
|
606
|
+
state.partyMode,
|
|
607
|
+
state.startTs,
|
|
608
|
+
state.resolvedResourceId,
|
|
609
|
+
state.partyResourceIds
|
|
610
|
+
]);
|
|
611
|
+
/** A live mirror of the contact fields, for the prefill below. */
|
|
612
|
+
const latestContact = useRef(state.contact);
|
|
613
|
+
useEffect(() => {
|
|
614
|
+
latestContact.current = state.contact;
|
|
615
|
+
});
|
|
616
|
+
/**
|
|
617
|
+
* The logged-in parent's details into the fields — once per parent, into
|
|
618
|
+
* fields still BLANK or still holding exactly what the PREVIOUS parent's
|
|
619
|
+
* profile put there (a shared device), never over what the visitor typed.
|
|
620
|
+
*/
|
|
621
|
+
const guardianFilled = useRef(null);
|
|
622
|
+
useEffect(() => {
|
|
623
|
+
if (guardian === null || guardianFilled.current?.email === guardian.email) return;
|
|
624
|
+
const previous = guardianFilled.current?.wrote;
|
|
625
|
+
const contact = latestContact.current;
|
|
626
|
+
const wrote = {
|
|
627
|
+
name: [guardian.firstName, guardian.lastName].filter(Boolean).join(" ").trim(),
|
|
628
|
+
phone: guardian.phone === null ? "" : phone.nationalDigits(guardian.phone),
|
|
629
|
+
email: guardian.email
|
|
630
|
+
};
|
|
631
|
+
guardianFilled.current = {
|
|
632
|
+
email: guardian.email,
|
|
633
|
+
wrote
|
|
634
|
+
};
|
|
635
|
+
for (const field of [
|
|
636
|
+
"name",
|
|
637
|
+
"phone",
|
|
638
|
+
"email"
|
|
639
|
+
]) {
|
|
640
|
+
const value = wrote[field];
|
|
641
|
+
if (value === "") continue;
|
|
642
|
+
const held = contact[field].trim();
|
|
643
|
+
if (held !== "" && held !== previous?.[field]) continue;
|
|
644
|
+
dispatch({
|
|
645
|
+
type: "setContact",
|
|
646
|
+
field,
|
|
647
|
+
value
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
}, [guardian, phone]);
|
|
651
|
+
const basketVisits = useMemo(() => slotKeysFor(state.items), [state.items]);
|
|
652
|
+
const basketKey = basketVisits.join("|");
|
|
653
|
+
const primaryServiceId = state.items[0]?.service.id ?? null;
|
|
654
|
+
/**
|
|
655
|
+
* The first person's whole visit, by key — what the stylists' «neste ledige»
|
|
656
|
+
* and the business's hours are asked for. Equal to `primaryServiceId` for a
|
|
657
|
+
* one-service visit, so the seed (keyed by service id, and only ever for one
|
|
658
|
+
* service) answers exactly the visits it can, and a visit with extras is
|
|
659
|
+
* fetched instead of being read off its first service's openings.
|
|
660
|
+
*/
|
|
661
|
+
const primaryVisit = state.items[0] ? visitKey(state.items[0]) : null;
|
|
662
|
+
const nextAvailableTs = useMemo(() => {
|
|
663
|
+
if (primaryVisit === null) return {};
|
|
664
|
+
const seeded = seededNextAvailable[primaryVisit];
|
|
665
|
+
if (seeded) return seeded;
|
|
666
|
+
return fetchedNextAvailable?.key === primaryVisit ? fetchedNextAvailable.times : {};
|
|
667
|
+
}, [
|
|
668
|
+
primaryVisit,
|
|
669
|
+
seededNextAvailable,
|
|
670
|
+
fetchedNextAvailable
|
|
671
|
+
]);
|
|
672
|
+
const missingKey = basketVisits.filter((key) => !(key in slots)).join("|");
|
|
673
|
+
/** Bumped to ask for the openings again after a failure. */
|
|
674
|
+
const [slotsAttempt, setSlotsAttempt] = useState(0);
|
|
675
|
+
const api = config.paths.api;
|
|
676
|
+
useEffect(() => {
|
|
677
|
+
if (missingKey === "") return;
|
|
678
|
+
const wanted = missingKey.split("|");
|
|
679
|
+
let cancelled = false;
|
|
680
|
+
setSlotsFailed(false);
|
|
681
|
+
(async () => {
|
|
682
|
+
try {
|
|
683
|
+
const fetched = await Promise.all(wanted.map(async (key) => {
|
|
684
|
+
const response = await fetch(`${api}/availability?${windowQuery(key, fromTs, toTs)}`);
|
|
685
|
+
if (!response.ok) throw new Error(`availability ${response.status}`);
|
|
686
|
+
return [key, (await response.json()).slots ?? []];
|
|
687
|
+
}));
|
|
688
|
+
if (cancelled) return;
|
|
689
|
+
setSlots((previous) => ({
|
|
690
|
+
...previous,
|
|
691
|
+
...Object.fromEntries(fetched)
|
|
692
|
+
}));
|
|
693
|
+
} catch {
|
|
694
|
+
if (!cancelled) setSlotsFailed(true);
|
|
695
|
+
}
|
|
696
|
+
})();
|
|
697
|
+
return () => {
|
|
698
|
+
cancelled = true;
|
|
699
|
+
};
|
|
700
|
+
}, [
|
|
701
|
+
api,
|
|
702
|
+
missingKey,
|
|
703
|
+
fromTs,
|
|
704
|
+
toTs,
|
|
705
|
+
slotsAttempt
|
|
706
|
+
]);
|
|
707
|
+
useEffect(() => {
|
|
708
|
+
if (primaryVisit === null) return;
|
|
709
|
+
if (primaryVisit in seededNextAvailable) return;
|
|
710
|
+
let cancelled = false;
|
|
711
|
+
(async () => {
|
|
712
|
+
try {
|
|
713
|
+
const response = await fetch(`${api}/resources?${windowQuery(primaryVisit, fromTs, toTs)}`);
|
|
714
|
+
if (!response.ok) throw new Error(`resources ${response.status}`);
|
|
715
|
+
const body = await response.json();
|
|
716
|
+
if (cancelled) return;
|
|
717
|
+
setResources(body.resources ?? []);
|
|
718
|
+
setResourcesKnown(true);
|
|
719
|
+
setFetchedNextAvailable({
|
|
720
|
+
key: primaryVisit,
|
|
721
|
+
times: body.nextAvailableTs ?? {}
|
|
722
|
+
});
|
|
723
|
+
} catch {
|
|
724
|
+
if (!cancelled) {
|
|
725
|
+
setResourcesKnown(true);
|
|
726
|
+
setFetchedNextAvailable({
|
|
727
|
+
key: primaryVisit,
|
|
728
|
+
times: {}
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
})();
|
|
733
|
+
return () => {
|
|
734
|
+
cancelled = true;
|
|
735
|
+
};
|
|
736
|
+
}, [
|
|
737
|
+
api,
|
|
738
|
+
primaryVisit,
|
|
739
|
+
fromTs,
|
|
740
|
+
toTs,
|
|
741
|
+
seededNextAvailable
|
|
742
|
+
]);
|
|
743
|
+
/**
|
|
744
|
+
* The stylist half of a «book again» link, once a list for its service has
|
|
745
|
+
* landed. Compared by VISIT: the link named one service, and a visitor who
|
|
746
|
+
* has since added a wash to it is asking a different question — the list now
|
|
747
|
+
* in hand answers the longer visit, not the one the link vouched for.
|
|
748
|
+
*/
|
|
749
|
+
useEffect(() => {
|
|
750
|
+
const pending = pendingStylist.current;
|
|
751
|
+
if (pending && primaryVisit !== null && primaryVisit !== pending.serviceId) {
|
|
752
|
+
pendingStylist.current = null;
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
if (!canFinishStylist(pending, resources, primaryVisit)) return;
|
|
756
|
+
pendingStylist.current = null;
|
|
757
|
+
dispatch({
|
|
758
|
+
type: "prefill",
|
|
759
|
+
prefill: pending,
|
|
760
|
+
catalogue: prefillCatalogue(services, resources)
|
|
761
|
+
});
|
|
762
|
+
}, [
|
|
763
|
+
resources,
|
|
764
|
+
primaryVisit,
|
|
765
|
+
services
|
|
766
|
+
]);
|
|
767
|
+
/** A party link's stylist, once the family has its services and is on step 3. */
|
|
768
|
+
useEffect(() => {
|
|
769
|
+
const wanted = partyStylist.current;
|
|
770
|
+
if (wanted === null || state.step !== "when" || primaryServiceId === null) return;
|
|
771
|
+
if (resources.length === 0) return;
|
|
772
|
+
partyStylist.current = null;
|
|
773
|
+
dispatch({
|
|
774
|
+
type: "prefill",
|
|
775
|
+
prefill: {
|
|
776
|
+
serviceId: primaryServiceId,
|
|
777
|
+
resourceId: wanted
|
|
778
|
+
},
|
|
779
|
+
catalogue: prefillCatalogue(services, resources)
|
|
780
|
+
});
|
|
781
|
+
}, [
|
|
782
|
+
state.step,
|
|
783
|
+
primaryServiceId,
|
|
784
|
+
resources,
|
|
785
|
+
services
|
|
786
|
+
]);
|
|
787
|
+
/** A restored slot whose stylist has left the roster, dropped before it is submitted. */
|
|
788
|
+
useEffect(() => {
|
|
789
|
+
const restored = restoredStylists.current;
|
|
790
|
+
if (restored === null || resources.length === 0 || primaryServiceId === null) return;
|
|
791
|
+
restoredStylists.current = null;
|
|
792
|
+
const known = new Set(resources.map((resource) => resource.id));
|
|
793
|
+
if (restored.every((id) => known.has(id))) return;
|
|
794
|
+
dispatch({
|
|
795
|
+
type: "pickResource",
|
|
796
|
+
resourceId: null
|
|
797
|
+
});
|
|
798
|
+
}, [resources, primaryServiceId]);
|
|
799
|
+
/**
|
|
800
|
+
* The business's open dates over the window, for the first person's VISIT
|
|
801
|
+
* (cutoffs are per service, and `visitTailMinutes` assumes the answer was for
|
|
802
|
+
* item 0's whole visit — see the machine).
|
|
803
|
+
*/
|
|
804
|
+
const [fetchedSchedule, setFetchedSchedule] = useState(null);
|
|
805
|
+
const [scheduleFailedFor, setScheduleFailedFor] = useState(null);
|
|
806
|
+
const openDays = useMemo(() => {
|
|
807
|
+
if (primaryVisit === null) return null;
|
|
808
|
+
const seeded = seededSchedule[primaryVisit];
|
|
809
|
+
if (seeded) return seeded;
|
|
810
|
+
return fetchedSchedule?.key === primaryVisit ? fetchedSchedule.days : null;
|
|
811
|
+
}, [
|
|
812
|
+
primaryVisit,
|
|
813
|
+
seededSchedule,
|
|
814
|
+
fetchedSchedule
|
|
815
|
+
]);
|
|
816
|
+
useEffect(() => {
|
|
817
|
+
if (primaryVisit === null) return;
|
|
818
|
+
if (primaryVisit in seededSchedule) return;
|
|
819
|
+
let cancelled = false;
|
|
820
|
+
(async () => {
|
|
821
|
+
try {
|
|
822
|
+
const response = await fetch(`${api}/schedule?${windowQuery(primaryVisit, fromTs, toTs)}`);
|
|
823
|
+
if (!response.ok) throw new Error(`schedule ${response.status}`);
|
|
824
|
+
const body = await response.json();
|
|
825
|
+
if (cancelled) return;
|
|
826
|
+
setFetchedSchedule({
|
|
827
|
+
key: primaryVisit,
|
|
828
|
+
days: body.days ?? []
|
|
829
|
+
});
|
|
830
|
+
} catch {
|
|
831
|
+
if (!cancelled) setScheduleFailedFor(primaryVisit);
|
|
832
|
+
}
|
|
833
|
+
})();
|
|
834
|
+
return () => {
|
|
835
|
+
cancelled = true;
|
|
836
|
+
};
|
|
837
|
+
}, [
|
|
838
|
+
api,
|
|
839
|
+
primaryVisit,
|
|
840
|
+
fromTs,
|
|
841
|
+
toTs,
|
|
842
|
+
seededSchedule
|
|
843
|
+
]);
|
|
844
|
+
/** A stylist's display name for an id; `''` from Medal reads as none. */
|
|
845
|
+
const resolveStylistName = useMemo(() => (resourceId) => {
|
|
846
|
+
const name = resources.find((resource) => resource.id === resourceId)?.name;
|
|
847
|
+
return name ? stylistDisplayName(name) || null : null;
|
|
848
|
+
}, [resources]);
|
|
849
|
+
const chosenStylistName = state.resourceId === null ? null : resolveStylistName(state.resourceId);
|
|
850
|
+
const chosenStylistServiceIds = serviceIdsOf(resources, state.resourceId);
|
|
851
|
+
/**
|
|
852
|
+
* A NAMED stylist restored before the list landed who turns out not to cover
|
|
853
|
+
* this basket: «first available» instead, and said so — only on step 3, and
|
|
854
|
+
* only against a list that actually arrived.
|
|
855
|
+
*/
|
|
856
|
+
const [stylistNotice, setStylistNotice] = useState(null);
|
|
857
|
+
useEffect(() => {
|
|
858
|
+
if (!resourcesKnown || resources.length === 0) return;
|
|
859
|
+
if (state.step !== "when" || state.resourceId === null) return;
|
|
860
|
+
const chosen = resources.find((resource) => resource.id === state.resourceId);
|
|
861
|
+
if (chosen !== void 0 && state.items.every((item) => visitServicesOf(item).every((service) => chosen.serviceIds.includes(service.id)))) return;
|
|
862
|
+
dispatch({
|
|
863
|
+
type: "pickResource",
|
|
864
|
+
resourceId: null
|
|
865
|
+
});
|
|
866
|
+
setStylistNotice(labelText(labels["wizard.stylistGone"]));
|
|
867
|
+
}, [
|
|
868
|
+
labels,
|
|
869
|
+
resourcesKnown,
|
|
870
|
+
resources,
|
|
871
|
+
state.step,
|
|
872
|
+
state.resourceId,
|
|
873
|
+
state.items
|
|
874
|
+
]);
|
|
875
|
+
/** The open dates with the cutoffs pulled back to fit the WHOLE visit (the safe direction). */
|
|
876
|
+
const openDaysForVisit = useMemo(() => {
|
|
877
|
+
if (openDays === null) return null;
|
|
878
|
+
const extraMs = wizard.visitTailMinutes(state.items, state.partyMode) * 6e4;
|
|
879
|
+
if (extraMs <= 0) return openDays;
|
|
880
|
+
return openDays.map((day) => ({
|
|
881
|
+
...day,
|
|
882
|
+
lastStartTs: day.lastStartTs === null ? null : day.lastStartTs - extraMs
|
|
883
|
+
}));
|
|
884
|
+
}, [
|
|
885
|
+
wizard,
|
|
886
|
+
openDays,
|
|
887
|
+
state.items,
|
|
888
|
+
state.partyMode
|
|
889
|
+
]);
|
|
890
|
+
const party = wizard.showsPartyMode(state);
|
|
891
|
+
const haveEveryService = basketVisits.every((key) => key in slots);
|
|
892
|
+
const scheduleSettled = openDays !== null || primaryVisit !== null && scheduleFailedFor === primaryVisit;
|
|
893
|
+
const nextAvailableLoading = party ? !haveEveryService && !slotsFailed : primaryVisit !== null && !(primaryVisit in seededNextAvailable) && fetchedNextAvailable?.key !== primaryVisit;
|
|
894
|
+
/** The basket's openings, unfiltered — the parallel search needs two stylists. */
|
|
895
|
+
const basketSlots = useMemo(() => {
|
|
896
|
+
const out = {};
|
|
897
|
+
for (const id of basketKey === "" ? [] : basketKey.split("|")) out[id] = slots[id] ?? [];
|
|
898
|
+
return out;
|
|
899
|
+
}, [basketKey, slots]);
|
|
900
|
+
const preferredSlots = useMemo(() => {
|
|
901
|
+
const preferred = state.resourceId;
|
|
902
|
+
if (preferred === null) return basketSlots;
|
|
903
|
+
return Object.fromEntries(Object.entries(basketSlots).map(([id, list]) => [id, onlyResource(list, preferred)]));
|
|
904
|
+
}, [basketSlots, state.resourceId]);
|
|
905
|
+
const partyNextAvailableTs = useMemo(() => party && state.partyMode === "sequential" && haveEveryService ? kit.partySlots.firstPartyStartPerResource(state.items, basketSlots) : {}, [
|
|
906
|
+
kit,
|
|
907
|
+
party,
|
|
908
|
+
state.partyMode,
|
|
909
|
+
haveEveryService,
|
|
910
|
+
state.items,
|
|
911
|
+
basketSlots
|
|
912
|
+
]);
|
|
913
|
+
const partySlots = useMemo(() => party ? kit.partySlots.findPartySlots(state.items, preferredSlots, state.partyMode) : [], [
|
|
914
|
+
kit,
|
|
915
|
+
party,
|
|
916
|
+
state.items,
|
|
917
|
+
preferredSlots,
|
|
918
|
+
state.partyMode
|
|
919
|
+
]);
|
|
920
|
+
/** The parallel visits, for the day with nothing back to back — over UNFILTERED slots. */
|
|
921
|
+
const partyAlternatives = useMemo(() => party && state.partyMode === "sequential" ? kit.partySlots.findPartySlots(state.items, basketSlots, "parallel") : [], [
|
|
922
|
+
kit,
|
|
923
|
+
party,
|
|
924
|
+
state.items,
|
|
925
|
+
basketSlots,
|
|
926
|
+
state.partyMode
|
|
927
|
+
]);
|
|
928
|
+
const singleSlots = useMemo(() => {
|
|
929
|
+
if (party || primaryVisit === null) return [];
|
|
930
|
+
const list = slots[primaryVisit] ?? [];
|
|
931
|
+
return state.resourceId === null ? [...list] : onlyResource(list, state.resourceId);
|
|
932
|
+
}, [
|
|
933
|
+
party,
|
|
934
|
+
primaryVisit,
|
|
935
|
+
slots,
|
|
936
|
+
state.resourceId
|
|
937
|
+
]);
|
|
938
|
+
/** Create a logged-in parent's new child through the site's portal route. */
|
|
939
|
+
async function saveChild(child) {
|
|
940
|
+
const copy = {
|
|
941
|
+
invalid: labelText(labels["wizard.addChild.invalid"]),
|
|
942
|
+
session: labelText(labels["wizard.addChild.session"]),
|
|
943
|
+
throttled: labelText(labels["wizard.addChild.throttled"])
|
|
944
|
+
};
|
|
945
|
+
const unreachable = labelText(labels["wizard.addChild.unreachable"]);
|
|
946
|
+
try {
|
|
947
|
+
const body = await (await fetch(`${config.paths.portalApi}/persons`, {
|
|
948
|
+
method: "POST",
|
|
949
|
+
headers: { "Content-Type": "application/json" },
|
|
950
|
+
body: JSON.stringify(child),
|
|
951
|
+
cache: "no-store",
|
|
952
|
+
credentials: "same-origin"
|
|
953
|
+
})).json().catch(() => null);
|
|
954
|
+
if (body?.ok === true && body.child) return {
|
|
955
|
+
ok: true,
|
|
956
|
+
child: body.child
|
|
957
|
+
};
|
|
958
|
+
return {
|
|
959
|
+
ok: false,
|
|
960
|
+
message: body?.message ?? copy[body?.reason ?? ""] ?? unreachable
|
|
961
|
+
};
|
|
962
|
+
} catch {
|
|
963
|
+
return {
|
|
964
|
+
ok: false,
|
|
965
|
+
message: unreachable
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Step 1's «+ add child». A logged-in parent's child is created first and
|
|
971
|
+
* ticked once it exists; a guest's lives in this booking.
|
|
972
|
+
*/
|
|
973
|
+
async function addChild(child) {
|
|
974
|
+
const named = state.people.filter((person) => person.key.startsWith("new:"));
|
|
975
|
+
if ((guardian === null ? named : state.people).length >= wizard.maxPeople) return {
|
|
976
|
+
ok: false,
|
|
977
|
+
message: fill(labels["who.family.limit"], { max: wizard.maxPeople })
|
|
978
|
+
};
|
|
979
|
+
if (guardian === null) {
|
|
980
|
+
await Promise.resolve();
|
|
981
|
+
const person = {
|
|
982
|
+
key: `new:${Date.now()}:${named.length}`,
|
|
983
|
+
name: child.name,
|
|
984
|
+
birthYear: child.birthYear,
|
|
985
|
+
...child.birthMonth === void 0 ? {} : { birthMonth: child.birthMonth }
|
|
986
|
+
};
|
|
987
|
+
dispatch({
|
|
988
|
+
type: "choosePeople",
|
|
989
|
+
people: [...named, person],
|
|
990
|
+
advance: false
|
|
991
|
+
});
|
|
992
|
+
return { ok: true };
|
|
993
|
+
}
|
|
994
|
+
const saved = await saveChild(child);
|
|
995
|
+
if (!saved.ok) return saved;
|
|
996
|
+
setAddedChildren((current) => [...current, saved.child]);
|
|
997
|
+
const known = family;
|
|
998
|
+
dispatch({
|
|
999
|
+
type: "addPerson",
|
|
1000
|
+
person: personForChild(saved.child, known.length)
|
|
1001
|
+
});
|
|
1002
|
+
return { ok: true };
|
|
1003
|
+
}
|
|
1004
|
+
async function submit(submission) {
|
|
1005
|
+
if (pendingAttempt !== null) {
|
|
1006
|
+
await send(pendingAttempt.submission, pendingAttempt.submitted, { pending: true });
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
const resourceIds = wizard.itemResourceIds(state);
|
|
1010
|
+
const body = withExtraServices(submission, state.items);
|
|
1011
|
+
if (body === null) {
|
|
1012
|
+
dispatch({
|
|
1013
|
+
type: "submitFailed",
|
|
1014
|
+
error: "invalidInput"
|
|
1015
|
+
});
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
await send(body, {
|
|
1019
|
+
items: state.items,
|
|
1020
|
+
startTs: state.startTs ?? 0,
|
|
1021
|
+
partyMode: state.partyMode,
|
|
1022
|
+
resourceIds,
|
|
1023
|
+
stylistNames: resourceIds.map((id) => id === null ? null : resolveStylistName(id))
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
/** The POST, and everything that can come back from it (also the replay's). */
|
|
1027
|
+
async function send(submission, submitted, sendOptions) {
|
|
1028
|
+
setSubmitting(true);
|
|
1029
|
+
const current = attempt ?? kit.attempts.readAttempt();
|
|
1030
|
+
if (attempt === null) setAttempt(current);
|
|
1031
|
+
kit.attempts.rememberPending(current, {
|
|
1032
|
+
submission,
|
|
1033
|
+
submitted
|
|
1034
|
+
});
|
|
1035
|
+
setPendingAttempt({
|
|
1036
|
+
submission,
|
|
1037
|
+
submitted
|
|
1038
|
+
});
|
|
1039
|
+
if (sendOptions?.replay !== true && sendOptions?.pending !== true) rebookLink.current = null;
|
|
1040
|
+
try {
|
|
1041
|
+
const response = await fetch(`${api}/create`, {
|
|
1042
|
+
method: "POST",
|
|
1043
|
+
headers: { "Content-Type": "application/json" },
|
|
1044
|
+
body: JSON.stringify({
|
|
1045
|
+
...submission,
|
|
1046
|
+
window: {
|
|
1047
|
+
fromTs,
|
|
1048
|
+
toTs
|
|
1049
|
+
}
|
|
1050
|
+
}),
|
|
1051
|
+
...sendOptions?.replay === true ? { signal: AbortSignal.timeout(REPLAY_TIMEOUT_MS) } : {}
|
|
1052
|
+
});
|
|
1053
|
+
const payload = await response.json().catch(() => null);
|
|
1054
|
+
if (!response.ok) {
|
|
1055
|
+
const definite = payload?.error !== void 0 && payload.error !== "upstreamError";
|
|
1056
|
+
if (definite) {
|
|
1057
|
+
kit.attempts.clearAttempt();
|
|
1058
|
+
setPendingAttempt(null);
|
|
1059
|
+
}
|
|
1060
|
+
if (sendOptions?.replay === true || sendOptions?.pending === true && rebookLink.current !== null) {
|
|
1061
|
+
if (definite) {
|
|
1062
|
+
dispatch({ type: "startOver" });
|
|
1063
|
+
restartFromLink();
|
|
1064
|
+
} else dispatch({
|
|
1065
|
+
type: "submitFailed",
|
|
1066
|
+
error: "upstreamError"
|
|
1067
|
+
});
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
if (payload?.error === "slotTaken") {
|
|
1071
|
+
setTakenSlotTs(submitted.startTs);
|
|
1072
|
+
const lost = new Set(submitted.items.map((item) => visitKey(item)));
|
|
1073
|
+
const fresh = freshSlotsOf(payload?.freshSlots, lost);
|
|
1074
|
+
setSlots((previous) => {
|
|
1075
|
+
const next = { ...previous };
|
|
1076
|
+
for (const key of lost) if (key in fresh) next[key] = fresh[key];
|
|
1077
|
+
else delete next[key];
|
|
1078
|
+
return next;
|
|
1079
|
+
});
|
|
1080
|
+
const canSeed = resources.length > 0;
|
|
1081
|
+
setSeededNextAvailable((previous) => ({
|
|
1082
|
+
...Object.fromEntries(Object.entries(previous).filter(([id]) => !lost.has(id))),
|
|
1083
|
+
...canSeed ? Object.fromEntries(Object.entries(fresh).map(([id, list]) => [id, firstOpeningPerResource(list)])) : {}
|
|
1084
|
+
}));
|
|
1085
|
+
setFetchedNextAvailable((previous) => previous !== null && lost.has(previous.key) ? null : previous);
|
|
1086
|
+
kit.attempts.clearAttempt();
|
|
1087
|
+
dispatch({ type: "slotTaken" });
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
if (payload?.error !== void 0 && payload.error !== "upstreamError") kit.attempts.clearAttempt();
|
|
1091
|
+
dispatch({
|
|
1092
|
+
type: "submitFailed",
|
|
1093
|
+
error: asSubmitFailure(payload?.error)
|
|
1094
|
+
});
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
const bookings = payload?.bookings ?? [];
|
|
1098
|
+
if (!isCompleteConfirmation(bookings, submitted.items.length)) {
|
|
1099
|
+
dispatch({
|
|
1100
|
+
type: "submitFailed",
|
|
1101
|
+
error: "upstreamError"
|
|
1102
|
+
});
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
const confirmedBookings = bookings.map((booking) => ({
|
|
1106
|
+
id: booking.id,
|
|
1107
|
+
manageHref: booking.manageToken ? kit.paths.managePath(booking.manageToken) : null
|
|
1108
|
+
}));
|
|
1109
|
+
setConfirmed({
|
|
1110
|
+
bookings: confirmedBookings,
|
|
1111
|
+
submitted
|
|
1112
|
+
});
|
|
1113
|
+
kit.attempts.rememberConfirmed(current, {
|
|
1114
|
+
bookings: confirmedBookings,
|
|
1115
|
+
submitted
|
|
1116
|
+
});
|
|
1117
|
+
setPendingAttempt(null);
|
|
1118
|
+
} catch {
|
|
1119
|
+
dispatch({
|
|
1120
|
+
type: "submitFailed",
|
|
1121
|
+
error: "upstreamError"
|
|
1122
|
+
});
|
|
1123
|
+
} finally {
|
|
1124
|
+
setSubmitting(false);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
/** «Book again» on the confirmation: a clean wizard with a fresh attempt. */
|
|
1128
|
+
function startOver() {
|
|
1129
|
+
kit.attempts.clearAttempt();
|
|
1130
|
+
setAttempt(kit.attempts.readAttempt());
|
|
1131
|
+
setPendingAttempt(null);
|
|
1132
|
+
setSlots({});
|
|
1133
|
+
setSlotsFailed(false);
|
|
1134
|
+
setConfirmed(null);
|
|
1135
|
+
setTakenSlotTs(null);
|
|
1136
|
+
dispatch({ type: "startOver" });
|
|
1137
|
+
restartFromLink();
|
|
1138
|
+
}
|
|
1139
|
+
/** A service tap on step 2 for a party of one — the first carries the link's stylist. */
|
|
1140
|
+
function pickService(service) {
|
|
1141
|
+
const first = deepLinkPending.current && state.items.length === 0;
|
|
1142
|
+
deepLinkPending.current = false;
|
|
1143
|
+
if (first && deepLink.stylist && resources.length === 0) pendingStylist.current = {
|
|
1144
|
+
serviceId: service.id,
|
|
1145
|
+
resourceId: deepLink.stylist
|
|
1146
|
+
};
|
|
1147
|
+
if (!first) {
|
|
1148
|
+
dispatch({
|
|
1149
|
+
type: "pickService",
|
|
1150
|
+
service
|
|
1151
|
+
});
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
dispatch({
|
|
1155
|
+
type: "prefill",
|
|
1156
|
+
prefill: {
|
|
1157
|
+
serviceId: service.id,
|
|
1158
|
+
resourceId: deepLink.stylist,
|
|
1159
|
+
party: null
|
|
1160
|
+
},
|
|
1161
|
+
catalogue: prefillCatalogue(services, resources)
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
/** One family member's service on step 2. */
|
|
1165
|
+
function pickServiceFor(index, service) {
|
|
1166
|
+
if (deepLinkPending.current) {
|
|
1167
|
+
deepLinkPending.current = false;
|
|
1168
|
+
partyStylist.current = deepLink.stylist;
|
|
1169
|
+
}
|
|
1170
|
+
dispatch({
|
|
1171
|
+
type: "pickServiceFor",
|
|
1172
|
+
index,
|
|
1173
|
+
service,
|
|
1174
|
+
resourceServiceIds: chosenStylistServiceIds
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* The multi-select card: person `index` gains `service`, or loses it if they
|
|
1179
|
+
* have it. Never moves the visitor — they say when their list is done with
|
|
1180
|
+
* `continueFromService`. The named stylist's services ride along as for
|
|
1181
|
+
* `pickServiceFor`, so a stylist who cannot do the longer visit is released.
|
|
1182
|
+
*/
|
|
1183
|
+
function toggleServiceFor(index, service) {
|
|
1184
|
+
if (deepLinkPending.current) {
|
|
1185
|
+
deepLinkPending.current = false;
|
|
1186
|
+
partyStylist.current = deepLink.stylist;
|
|
1187
|
+
}
|
|
1188
|
+
dispatch({
|
|
1189
|
+
type: "toggleServiceFor",
|
|
1190
|
+
index,
|
|
1191
|
+
service,
|
|
1192
|
+
resourceServiceIds: chosenStylistServiceIds
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
/** «Neste» under a multi-select card: on to the hour once everybody has a service. */
|
|
1196
|
+
function continueFromService() {
|
|
1197
|
+
if (state.step !== "service" || !wizard.canAdvance(state)) return;
|
|
1198
|
+
dispatch({
|
|
1199
|
+
type: "goToStep",
|
|
1200
|
+
step: "when"
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
function pickResource(resourceId) {
|
|
1204
|
+
setStylistNotice(null);
|
|
1205
|
+
dispatch({
|
|
1206
|
+
type: "pickResource",
|
|
1207
|
+
resourceId
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
function pickSlot(slot) {
|
|
1211
|
+
setTakenSlotTs(null);
|
|
1212
|
+
dispatch({
|
|
1213
|
+
type: "pickSlot",
|
|
1214
|
+
startTs: slot.startTs,
|
|
1215
|
+
resourceId: slot.resourceId
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
function pickPartySlot(slot) {
|
|
1219
|
+
setTakenSlotTs(null);
|
|
1220
|
+
dispatch({
|
|
1221
|
+
type: "pickPartySlot",
|
|
1222
|
+
startTs: slot.startTs,
|
|
1223
|
+
resourceIds: slot.seats.map((seat) => seat.resourceId),
|
|
1224
|
+
mode: slot.mode
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
/** Who a parent seats on step 1; each new child starts on «same as last time». */
|
|
1228
|
+
function choosePeople(people, advance) {
|
|
1229
|
+
dispatch({
|
|
1230
|
+
type: "choosePeople",
|
|
1231
|
+
people,
|
|
1232
|
+
advance,
|
|
1233
|
+
services: people.map((person) => suggestionFor(person)?.service ?? null)
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
/** The step after this one, as «Next» means it. */
|
|
1237
|
+
function next() {
|
|
1238
|
+
const order = [
|
|
1239
|
+
"who",
|
|
1240
|
+
"service",
|
|
1241
|
+
"when",
|
|
1242
|
+
"details"
|
|
1243
|
+
];
|
|
1244
|
+
const following = order[order.indexOf(state.step) + 1];
|
|
1245
|
+
if (following) dispatch({
|
|
1246
|
+
type: "goToStep",
|
|
1247
|
+
step: following
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
const action = (value) => dispatch(value);
|
|
1251
|
+
return {
|
|
1252
|
+
kit,
|
|
1253
|
+
state,
|
|
1254
|
+
dispatch: action,
|
|
1255
|
+
/** The parent: who arrived, who logged in from the sheet, and the merged answer. */
|
|
1256
|
+
login: {
|
|
1257
|
+
arrivedAs,
|
|
1258
|
+
signedIn,
|
|
1259
|
+
guardian,
|
|
1260
|
+
signIn: (who) => setSignedIn({ guardian: who }),
|
|
1261
|
+
vippsConfirm,
|
|
1262
|
+
resumePath: vippsResumePath
|
|
1263
|
+
},
|
|
1264
|
+
people: {
|
|
1265
|
+
family,
|
|
1266
|
+
ageDayKey,
|
|
1267
|
+
personAge,
|
|
1268
|
+
suggestionFor,
|
|
1269
|
+
choosePeople,
|
|
1270
|
+
addChild
|
|
1271
|
+
},
|
|
1272
|
+
catalogue: {
|
|
1273
|
+
services,
|
|
1274
|
+
resources,
|
|
1275
|
+
resourcesKnown,
|
|
1276
|
+
resolveStylistName,
|
|
1277
|
+
chosenStylistName,
|
|
1278
|
+
stylistNotice,
|
|
1279
|
+
deepLinkCategory: deepLink.category
|
|
1280
|
+
},
|
|
1281
|
+
slots: {
|
|
1282
|
+
fromTs,
|
|
1283
|
+
toTs,
|
|
1284
|
+
days,
|
|
1285
|
+
single: singleSlots,
|
|
1286
|
+
party: partySlots,
|
|
1287
|
+
alternatives: partyAlternatives,
|
|
1288
|
+
failed: slotsFailed,
|
|
1289
|
+
ready: haveEveryService && scheduleSettled,
|
|
1290
|
+
takenSlotTs,
|
|
1291
|
+
nextAvailableTs: party ? partyNextAvailableTs : nextAvailableTs,
|
|
1292
|
+
nextAvailableLoading
|
|
1293
|
+
},
|
|
1294
|
+
schedule: {
|
|
1295
|
+
openDays: openDaysForVisit,
|
|
1296
|
+
settled: scheduleSettled
|
|
1297
|
+
},
|
|
1298
|
+
derived: {
|
|
1299
|
+
canAdvance: wizard.canAdvance(state),
|
|
1300
|
+
party,
|
|
1301
|
+
total: wizard.totalPriceOre(state.items, state.startTs),
|
|
1302
|
+
summary: wizard.summaryLine(state, resolveStylistName),
|
|
1303
|
+
visitEnd: state.startTs === null ? null : wizard.visitEndTs(state.items, state.startTs, state.partyMode)
|
|
1304
|
+
},
|
|
1305
|
+
pickService,
|
|
1306
|
+
pickServiceFor,
|
|
1307
|
+
toggleServiceFor,
|
|
1308
|
+
continueFromService,
|
|
1309
|
+
pickResource,
|
|
1310
|
+
pickSlot,
|
|
1311
|
+
pickPartySlot,
|
|
1312
|
+
next,
|
|
1313
|
+
submit,
|
|
1314
|
+
submitting,
|
|
1315
|
+
submissionNonce,
|
|
1316
|
+
confirmed,
|
|
1317
|
+
startOver,
|
|
1318
|
+
/** Ask for the openings again after a failure. */
|
|
1319
|
+
refresh: () => setSlotsAttempt((n) => n + 1),
|
|
1320
|
+
restore: {
|
|
1321
|
+
hydrated,
|
|
1322
|
+
restoring,
|
|
1323
|
+
linked,
|
|
1324
|
+
resuming
|
|
1325
|
+
}
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
//#endregion
|
|
1329
|
+
export { confirmationLines, isCompleteConfirmation, personForChild, useBooking };
|
|
1330
|
+
|
|
1331
|
+
//# sourceMappingURL=useBooking.mjs.map
|