@medalsocial/booking 0.2.0 → 0.3.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 +28 -0
- package/README.md +21 -3
- package/dist/core/attempt-store.d.mts +1 -0
- package/dist/core/attempt-store.mjs +13 -0
- package/dist/core/attempt-store.mjs.map +1 -1
- package/dist/core/config.d.mts +37 -0
- package/dist/core/config.mjs +29 -0
- package/dist/core/config.mjs.map +1 -1
- package/dist/core/machine.d.mts +27 -1
- package/dist/core/machine.mjs +52 -4
- package/dist/core/machine.mjs.map +1 -1
- package/dist/next/medal.d.mts +8 -1
- package/dist/next/medal.mjs +16 -5
- package/dist/next/medal.mjs.map +1 -1
- package/dist/next/portal/medal-portal.mjs +1 -62
- package/dist/next/portal/medal-portal.mjs.map +1 -1
- package/dist/next/redact.mjs +76 -0
- package/dist/next/redact.mjs.map +1 -0
- package/dist/next/routes/create.mjs +67 -9
- package/dist/next/routes/create.mjs.map +1 -1
- package/dist/next/routes/shared.mjs +3 -0
- package/dist/next/routes/shared.mjs.map +1 -1
- package/dist/react/BookingWizard.mjs +139 -11
- package/dist/react/BookingWizard.mjs.map +1 -1
- package/dist/react/LoginSheet.d.mts +19 -1
- package/dist/react/LoginSheet.mjs +15 -1
- package/dist/react/LoginSheet.mjs.map +1 -1
- package/dist/react/Provider.d.mts +2 -1
- package/dist/react/Provider.mjs.map +1 -1
- package/dist/react/actions.d.mts +16 -5
- package/dist/react/actions.mjs +5 -1
- package/dist/react/actions.mjs.map +1 -1
- package/dist/react/index.d.mts +2 -2
- package/dist/react/kit.d.mts +6 -1
- package/dist/react/kit.mjs +4 -3
- package/dist/react/kit.mjs.map +1 -1
- package/dist/react/labels/login.d.mts +2 -0
- package/dist/react/labels/login.mjs +4 -2
- package/dist/react/labels/login.mjs.map +1 -1
- package/dist/react/labels/screens.mjs +58 -2
- package/dist/react/labels/screens.mjs.map +1 -1
- package/dist/react/labels/wizard.d.mts +8 -0
- package/dist/react/labels/wizard.mjs +6 -0
- package/dist/react/labels/wizard.mjs.map +1 -1
- package/dist/react/screen-labels.mjs +5 -0
- package/dist/react/screen-labels.mjs.map +1 -1
- package/dist/react/useBooking.d.mts +19 -1
- package/dist/react/useBooking.mjs +299 -57
- package/dist/react/useBooking.mjs.map +1 -1
- package/dist/react/wizard/adapters.mjs +66 -3
- package/dist/react/wizard/adapters.mjs.map +1 -1
- package/dist/react/wizard/index.d.mts +2 -2
- package/package.json +4 -4
|
@@ -5,6 +5,7 @@ import { stylistDisplayName } from "../core/display-name.mjs";
|
|
|
5
5
|
import { serviceMatches } from "../core/deep-link.mjs";
|
|
6
6
|
import "../core/draft-store.mjs";
|
|
7
7
|
import { visitKey, visitServicesOf } from "../core/visit.mjs";
|
|
8
|
+
import "../core/machine.mjs";
|
|
8
9
|
import { firstOpeningPerResource } from "../core/next-available.mjs";
|
|
9
10
|
import { slotKeysFor } from "../core/party-slots.mjs";
|
|
10
11
|
import { stripVippsReturn, vippsConfirmFrom } from "../core/portal/vipps-return.mjs";
|
|
@@ -29,6 +30,22 @@ import { useEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore
|
|
|
29
30
|
* Moved from the source app's wizard shell with its behaviour unchanged; the
|
|
30
31
|
* long-form reasoning for each rule is kept beside the rule.
|
|
31
32
|
*/
|
|
33
|
+
/**
|
|
34
|
+
* A line item as a draft keeps it. `idOnly` — nobody is logged in — keeps a
|
|
35
|
+
* saved child by its id ALONE: the login the draft waits for decides, and only
|
|
36
|
+
* that child's own parent gets the name back (`reseatFor`).
|
|
37
|
+
*/
|
|
38
|
+
function draftItem(item, idOnly = false) {
|
|
39
|
+
const saved = idOnly && item.bookedForPersonId !== void 0;
|
|
40
|
+
return {
|
|
41
|
+
serviceId: item.service.id,
|
|
42
|
+
...item.extraServices?.length ? { extraServiceIds: item.extraServices.map((service) => service.id) } : {},
|
|
43
|
+
bookedForName: saved ? null : item.bookedForName ?? null,
|
|
44
|
+
bookedForBirthYear: saved ? null : item.bookedForBirthYear ?? null,
|
|
45
|
+
adult: item.adult === true,
|
|
46
|
+
personId: item.bookedForPersonId ?? null
|
|
47
|
+
};
|
|
48
|
+
}
|
|
32
49
|
/** How long a replayed submission may take before the restore gate gives up. */
|
|
33
50
|
const REPLAY_TIMEOUT_MS = 15e3;
|
|
34
51
|
/** «hydrated» only ever flips once; `useSyncExternalStore` wants a subscription. */
|
|
@@ -46,11 +63,12 @@ function freshSlotsOf(raw, lost) {
|
|
|
46
63
|
* added only when there are some, so a one-service visit asks with exactly the
|
|
47
64
|
* URL it always did — the slot cache and every seeded answer are keyed by it.
|
|
48
65
|
*/
|
|
49
|
-
function windowQuery(key, fromTs, toTs) {
|
|
66
|
+
function windowQuery(key, fromTs, toTs, resourceId) {
|
|
50
67
|
const [serviceId, ...extras] = key.split("+");
|
|
51
68
|
return new URLSearchParams({
|
|
52
69
|
service_id: serviceId,
|
|
53
70
|
...extras.length === 0 ? {} : { extra_service_ids: extras.join(",") },
|
|
71
|
+
...resourceId ? { resource_id: resourceId } : {},
|
|
54
72
|
from_ts: String(fromTs),
|
|
55
73
|
to_ts: String(toTs)
|
|
56
74
|
});
|
|
@@ -80,6 +98,27 @@ function withExtraServices(submission, items) {
|
|
|
80
98
|
})
|
|
81
99
|
};
|
|
82
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Under `account.required`, each line's saved child back on it whatever phone
|
|
103
|
+
* was typed. The details screen sends a person id only under the parent's own
|
|
104
|
+
* number (the phone rule), but with an account required the booking goes on
|
|
105
|
+
* the logged-in parent's contact anyway, so a child sent by name alone would
|
|
106
|
+
* be created there a second time. The machine's id is the line's own: a name
|
|
107
|
+
* typed over a saved child clears it. The route checks every id against the
|
|
108
|
+
* session's own family before it is forwarded.
|
|
109
|
+
*/
|
|
110
|
+
function withSessionPersons(submission, items) {
|
|
111
|
+
return {
|
|
112
|
+
...submission,
|
|
113
|
+
items: submission.items.map((line, index) => {
|
|
114
|
+
const personId = items[index]?.bookedForPersonId;
|
|
115
|
+
return personId === void 0 ? line : {
|
|
116
|
+
...line,
|
|
117
|
+
bookedForPersonId: personId
|
|
118
|
+
};
|
|
119
|
+
})
|
|
120
|
+
};
|
|
121
|
+
}
|
|
83
122
|
/** What the create route said, narrowed to what the machine can hold. */
|
|
84
123
|
function asSubmitFailure(code) {
|
|
85
124
|
switch (code) {
|
|
@@ -224,11 +263,13 @@ function setupFor(kit) {
|
|
|
224
263
|
const unasked = config.party.askWhoFirst ? null : kit.childCategory !== null ? { children: 1 } : { adult: true };
|
|
225
264
|
const seated = seat ?? unasked;
|
|
226
265
|
const base = seated !== null && "adult" in seated ? withAdult() : wizard.initialState();
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
266
|
+
const state = prefill === null ? seated === null ? wizard.initialState() : "adult" in seated ? withAdult() : withGuests(seated.children) : wizard.applyPrefill(base, prefill, prefillCatalogue(services, resources));
|
|
267
|
+
if (config.screens.guestParty && seated === null && state.step === "who" && state.people.length === 0) return wizard.reduce(state, {
|
|
268
|
+
type: "choosePeople",
|
|
269
|
+
people: [wizard.guestChild(1)],
|
|
270
|
+
advance: false
|
|
271
|
+
});
|
|
272
|
+
return state;
|
|
232
273
|
}
|
|
233
274
|
return {
|
|
234
275
|
prefillFromQuery,
|
|
@@ -237,6 +278,11 @@ function setupFor(kit) {
|
|
|
237
278
|
initialWizardState
|
|
238
279
|
};
|
|
239
280
|
}
|
|
281
|
+
const CONTACT_FIELDS = [
|
|
282
|
+
"name",
|
|
283
|
+
"phone",
|
|
284
|
+
"email"
|
|
285
|
+
];
|
|
240
286
|
function useBooking(options) {
|
|
241
287
|
const { kit } = useBookingKit(options, options.contact);
|
|
242
288
|
const { config, labels, wizard, clock, age, phone, deepLinks } = kit;
|
|
@@ -250,7 +296,17 @@ function useBooking(options) {
|
|
|
250
296
|
* logged-in parent, who must not be offered the login again.
|
|
251
297
|
*/
|
|
252
298
|
const [signedIn, setSignedIn] = useState(null);
|
|
253
|
-
|
|
299
|
+
/**
|
|
300
|
+
* The create route said nobody is logged in (`accountRequired`): the session
|
|
301
|
+
* the page arrived with — or the one taken here — has run out. From then on
|
|
302
|
+
* the parent the page found is not trusted; only a fresh login is. Sticky:
|
|
303
|
+
* a fresh login clears `sessionLost` (the notice), never this.
|
|
304
|
+
*/
|
|
305
|
+
const [arrivalExpired, setArrivalExpired] = useState(false);
|
|
306
|
+
/** The gate is back because the session ran out — until the next login. */
|
|
307
|
+
const [sessionLost, setSessionLost] = useState(false);
|
|
308
|
+
const arrivedAsNow = arrivalExpired ? null : arrivedAs;
|
|
309
|
+
const guardian = signedIn === null ? arrivedAsNow : signedIn.guardian;
|
|
254
310
|
const query = useSearchParams();
|
|
255
311
|
const [prefill] = useState(() => setup.prefillFromQuery(query, services));
|
|
256
312
|
const [deepLink] = useState(() => setup.deepLinkFromQuery(query));
|
|
@@ -271,11 +327,88 @@ function useBooking(options) {
|
|
|
271
327
|
/** Children a logged-in parent added from step 1 during this visit. */
|
|
272
328
|
const [addedChildren, setAddedChildren] = useState([]);
|
|
273
329
|
const family = useMemo(() => guardian === null ? null : [...guardian.family, ...addedChildren], [guardian, addedChildren]);
|
|
330
|
+
/**
|
|
331
|
+
* Guest chairs an account change left standing for a seat with services —
|
|
332
|
+
* the previous parent's child unseated, or a sent visit's child nobody could
|
|
333
|
+
* vouch for — which `seatFamily` keeps: they are the visit being booked.
|
|
334
|
+
*/
|
|
335
|
+
const [keptSeats, setKeptSeats] = useState([]);
|
|
274
336
|
/** Guest seats go when a family appears over them (`seatFamily`). */
|
|
275
|
-
const guestSeated = family !== null && state.people.some(wizard.isGuestSeat);
|
|
337
|
+
const guestSeated = family !== null && state.people.some((person) => wizard.isGuestSeat(person) && !keptSeats.includes(person.key));
|
|
276
338
|
useEffect(() => {
|
|
277
|
-
if (guestSeated) dispatch({
|
|
339
|
+
if (guestSeated) dispatch({
|
|
340
|
+
type: "seatFamily",
|
|
341
|
+
keep: keptSeats
|
|
342
|
+
});
|
|
278
343
|
}, [guestSeated, state.step]);
|
|
344
|
+
/**
|
|
345
|
+
* The saved child behind each guest chair a visit was rebuilt into while
|
|
346
|
+
* nobody was logged in (`restoreDraft`), by seat key — names left out — for
|
|
347
|
+
* the next login to claim by id (`reseatFor`).
|
|
348
|
+
*/
|
|
349
|
+
const awaitingIds = useRef(null);
|
|
350
|
+
/**
|
|
351
|
+
* The account the party's saved children were seated under — its e-mail,
|
|
352
|
+
* `null` before anybody. Kept across a lost session on purpose: the seats
|
|
353
|
+
* outlive it, held under the gate, and it is the NEXT login that decides
|
|
354
|
+
* whether they still fit (`reseatFor`).
|
|
355
|
+
*/
|
|
356
|
+
const seatedFor = useRef(arrivedAs?.email ?? null);
|
|
357
|
+
useEffect(() => {
|
|
358
|
+
if (guardian !== null) seatedFor.current = guardian.email;
|
|
359
|
+
}, [guardian]);
|
|
360
|
+
/**
|
|
361
|
+
* A login from the sheet or the gate, before it lands: when it is ANOTHER
|
|
362
|
+
* account than the one the party was seated under — or one whose profile
|
|
363
|
+
* nobody could read — the previous parent's children are not this one's to
|
|
364
|
+
* book. Their seats become guest chairs (the services and the hour stay),
|
|
365
|
+
* and what the old parent added here goes with them. Only a person id says
|
|
366
|
+
* a child is the new parent's too: a child without one is matched by place,
|
|
367
|
+
* name and year, which two families can share. A guest chair rebuilt from a
|
|
368
|
+
* sent visit while nobody was logged in (`awaitingIds`) is this parent's
|
|
369
|
+
* child again when the id is theirs, and stays a blank chair when it is not;
|
|
370
|
+
* a login with no readable profile decides nothing, so the ids wait on for
|
|
371
|
+
* the next one — including those of the seats it just let go, which the
|
|
372
|
+
* next readable login claims back when they are its own. In the same batch as the login, so the form it opens never draws the old
|
|
373
|
+
* names; the chairs it leaves keep their services on a step back.
|
|
374
|
+
*/
|
|
375
|
+
function reseatFor(who) {
|
|
376
|
+
const before = seatedFor.current;
|
|
377
|
+
const now = who?.email ?? null;
|
|
378
|
+
const awaiting = who === null ? null : awaitingIds.current;
|
|
379
|
+
seatedFor.current = now;
|
|
380
|
+
if (who !== null) awaitingIds.current = null;
|
|
381
|
+
const switched = before !== null && before !== now;
|
|
382
|
+
if (!switched && awaiting === null) return;
|
|
383
|
+
if (switched) setAddedChildren([]);
|
|
384
|
+
const theirs = who?.family ?? [];
|
|
385
|
+
const keys = [];
|
|
386
|
+
const into = {};
|
|
387
|
+
for (const person of state.people) {
|
|
388
|
+
const id = awaiting?.get(person.key) ?? person.personId;
|
|
389
|
+
const at = theirs.findIndex((child) => id !== void 0 && child.personId === id);
|
|
390
|
+
if (at !== -1 && person.personId === id) continue;
|
|
391
|
+
if (at !== -1) into[person.key] = personForChild(theirs[at], at);
|
|
392
|
+
if (at !== -1 || switched && person.key !== "self" && (person.personId !== void 0 || !wizard.isGuestSeat(person))) keys.push(person.key);
|
|
393
|
+
}
|
|
394
|
+
const action = {
|
|
395
|
+
type: "unseatPeople",
|
|
396
|
+
keys,
|
|
397
|
+
into
|
|
398
|
+
};
|
|
399
|
+
const after = wizard.reduce(state, action).people;
|
|
400
|
+
const kept = after.filter((person, index) => wizard.isGuestSeat(person) && (keys.includes(state.people[index].key) || awaiting?.has(person.key)));
|
|
401
|
+
setKeptSeats((current) => [...current, ...kept.map((person) => person.key)]);
|
|
402
|
+
if (who === null && switched) {
|
|
403
|
+
const waiting = new Map(awaitingIds.current ?? []);
|
|
404
|
+
after.forEach((person, index) => {
|
|
405
|
+
const before = state.people[index];
|
|
406
|
+
if (keys.includes(before.key) && before.personId !== void 0) waiting.set(person.key, before.personId);
|
|
407
|
+
});
|
|
408
|
+
awaitingIds.current = waiting.size > 0 ? waiting : null;
|
|
409
|
+
}
|
|
410
|
+
if (keys.length > 0) dispatch(action);
|
|
411
|
+
}
|
|
279
412
|
/** A stylist a party link named, applied once the family reaches step 3. */
|
|
280
413
|
const partyStylist = useRef(arrivedAs !== null ? prefill?.resourceId ?? deepLink.stylist : null);
|
|
281
414
|
const childByKey = useMemo(() => new Map((family ?? []).map((child, index) => [personForChild(child, index).key, child])), [family]);
|
|
@@ -480,6 +613,13 @@ function useBooking(options) {
|
|
|
480
613
|
const resolved = draft.items.map((item) => bookable.get(item.serviceId));
|
|
481
614
|
const [first, ...rest] = resolved;
|
|
482
615
|
if (first === void 0 || resolved.some((service) => service === void 0)) return;
|
|
616
|
+
if (family === null) {
|
|
617
|
+
const ids = /* @__PURE__ */ new Map();
|
|
618
|
+
draft.items.forEach((item, index) => {
|
|
619
|
+
if (item.personId && !item.adult) ids.set(wizard.guestChild(index + 1).key, item.personId);
|
|
620
|
+
});
|
|
621
|
+
awaitingIds.current = ids.size > 0 ? ids : null;
|
|
622
|
+
}
|
|
483
623
|
if (family === null && draft.items.every((item) => typeof item.adult === "boolean")) dispatch({
|
|
484
624
|
type: "choosePeople",
|
|
485
625
|
people: draft.items.map((item, index) => item.adult ? {
|
|
@@ -580,18 +720,19 @@ function useBooking(options) {
|
|
|
580
720
|
/** The draft, kept current on every answer rather than written on the Vipps tap. */
|
|
581
721
|
useEffect(() => {
|
|
582
722
|
if (confirmed !== null || state.items.length === 0) {
|
|
723
|
+
if (confirmed !== null) awaitingIds.current = null;
|
|
583
724
|
kit.drafts.clearDraft();
|
|
584
725
|
return;
|
|
585
726
|
}
|
|
586
727
|
kit.drafts.stashDraft({
|
|
587
|
-
items: state.items.map((item) =>
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
})
|
|
728
|
+
items: state.items.map((item, index) => {
|
|
729
|
+
const line = draftItem(item, guardian === null);
|
|
730
|
+
const waiting = awaitingIds.current?.get(state.people[index].key) ?? null;
|
|
731
|
+
return {
|
|
732
|
+
...line,
|
|
733
|
+
personId: line.personId ?? waiting
|
|
734
|
+
};
|
|
735
|
+
}),
|
|
595
736
|
resourceId: state.resourceId,
|
|
596
737
|
partyMode: state.partyMode,
|
|
597
738
|
startTs: state.startTs,
|
|
@@ -601,53 +742,76 @@ function useBooking(options) {
|
|
|
601
742
|
}, [
|
|
602
743
|
kit,
|
|
603
744
|
confirmed,
|
|
745
|
+
guardian,
|
|
604
746
|
state.items,
|
|
747
|
+
state.people,
|
|
605
748
|
state.resourceId,
|
|
606
749
|
state.partyMode,
|
|
607
750
|
state.startTs,
|
|
608
751
|
state.resolvedResourceId,
|
|
609
752
|
state.partyResourceIds
|
|
610
753
|
]);
|
|
754
|
+
/**
|
|
755
|
+
* The fields a logged-in parent's profile wrote, by value, for as long as it
|
|
756
|
+
* still owns them — and only those: a field the visitor filled or changed is
|
|
757
|
+
* never in here, whoever logs in next.
|
|
758
|
+
*/
|
|
759
|
+
const guardianFilled = useRef(null);
|
|
611
760
|
/** A live mirror of the contact fields, for the prefill below. */
|
|
612
761
|
const latestContact = useRef(state.contact);
|
|
613
762
|
useEffect(() => {
|
|
614
763
|
latestContact.current = state.contact;
|
|
764
|
+
const owns = guardianFilled.current?.owns;
|
|
765
|
+
if (owns === void 0) return;
|
|
766
|
+
for (const field of CONTACT_FIELDS) if (field in owns && state.contact[field] !== owns[field]) delete owns[field];
|
|
615
767
|
});
|
|
616
768
|
/**
|
|
617
|
-
* The logged-in parent's details into the fields — once per parent
|
|
618
|
-
*
|
|
619
|
-
*
|
|
769
|
+
* The logged-in parent's details into the fields — once per parent. A field
|
|
770
|
+
* the previous parent's profile still owns is theirs to replace: with this
|
|
771
|
+
* parent's value, or emptied when this profile has none (a shared device
|
|
772
|
+
* must not keep the last account's phone). A blank field is filled. A field
|
|
773
|
+
* the visitor owns is never touched, and stays theirs.
|
|
620
774
|
*/
|
|
621
|
-
const guardianFilled = useRef(null);
|
|
622
775
|
useEffect(() => {
|
|
623
|
-
if (guardian === null || guardianFilled.current?.email === guardian.email) return;
|
|
624
|
-
const previous = guardianFilled.current?.wrote;
|
|
625
776
|
const contact = latestContact.current;
|
|
626
|
-
const
|
|
777
|
+
const previous = guardianFilled.current?.owns ?? {};
|
|
778
|
+
if (guardian === null) {
|
|
779
|
+
if (signedIn === null || guardianFilled.current === null) return;
|
|
780
|
+
guardianFilled.current = null;
|
|
781
|
+
for (const field of CONTACT_FIELDS) if (field in previous) dispatch({
|
|
782
|
+
type: "setContact",
|
|
783
|
+
field,
|
|
784
|
+
value: ""
|
|
785
|
+
});
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (guardianFilled.current?.email === guardian.email) return;
|
|
789
|
+
const profile = {
|
|
627
790
|
name: [guardian.firstName, guardian.lastName].filter(Boolean).join(" ").trim(),
|
|
628
791
|
phone: guardian.phone === null ? "" : phone.nationalDigits(guardian.phone),
|
|
629
792
|
email: guardian.email
|
|
630
793
|
};
|
|
794
|
+
const owns = {};
|
|
631
795
|
guardianFilled.current = {
|
|
632
796
|
email: guardian.email,
|
|
633
|
-
|
|
797
|
+
owns
|
|
634
798
|
};
|
|
635
|
-
for (const field of
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
"
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
if (value === "") continue;
|
|
642
|
-
const held = contact[field].trim();
|
|
643
|
-
if (held !== "" && held !== previous?.[field]) continue;
|
|
644
|
-
dispatch({
|
|
799
|
+
for (const field of CONTACT_FIELDS) {
|
|
800
|
+
const value = profile[field];
|
|
801
|
+
const theirs = field in previous;
|
|
802
|
+
if (!theirs && contact[field].trim() !== "") continue;
|
|
803
|
+
if (value !== "") owns[field] = value;
|
|
804
|
+
if (value !== "" || theirs) dispatch({
|
|
645
805
|
type: "setContact",
|
|
646
806
|
field,
|
|
647
807
|
value
|
|
648
808
|
});
|
|
649
809
|
}
|
|
650
|
-
}, [
|
|
810
|
+
}, [
|
|
811
|
+
guardian,
|
|
812
|
+
signedIn,
|
|
813
|
+
phone
|
|
814
|
+
]);
|
|
651
815
|
const basketVisits = useMemo(() => slotKeysFor(state.items), [state.items]);
|
|
652
816
|
const basketKey = basketVisits.join("|");
|
|
653
817
|
const primaryServiceId = state.items[0]?.service.id ?? null;
|
|
@@ -797,38 +961,54 @@ function useBooking(options) {
|
|
|
797
961
|
});
|
|
798
962
|
}, [resources, primaryServiceId]);
|
|
799
963
|
/**
|
|
800
|
-
* The
|
|
801
|
-
*
|
|
802
|
-
*
|
|
964
|
+
* The open dates over the window, for the first person's VISIT (cutoffs are
|
|
965
|
+
* per service, and `visitTailMinutes` assumes the answer was for item 0's
|
|
966
|
+
* whole visit — see the machine).
|
|
967
|
+
*
|
|
968
|
+
* The seed is the SALON's week, which is what «first available» draws. A named
|
|
969
|
+
* stylist works a different week, so their hours are fetched with `resource_id`
|
|
970
|
+
* and the seed is not allowed to answer for them — a seeded salon week would
|
|
971
|
+
* otherwise paint every open day as theirs.
|
|
803
972
|
*/
|
|
804
973
|
const [fetchedSchedule, setFetchedSchedule] = useState(null);
|
|
805
974
|
const [scheduleFailedFor, setScheduleFailedFor] = useState(null);
|
|
975
|
+
const scheduleKey = primaryVisit === null ? null : state.resourceId === null ? primaryVisit : `${primaryVisit}#${state.resourceId}`;
|
|
806
976
|
const openDays = useMemo(() => {
|
|
807
|
-
if (primaryVisit === null) return null;
|
|
808
|
-
|
|
809
|
-
if (
|
|
810
|
-
|
|
977
|
+
if (primaryVisit === null || scheduleKey === null) return null;
|
|
978
|
+
if (fetchedSchedule?.key === scheduleKey) return fetchedSchedule.days;
|
|
979
|
+
if (state.resourceId === null) return seededSchedule[primaryVisit] ?? null;
|
|
980
|
+
if (scheduleFailedFor === scheduleKey) return null;
|
|
981
|
+
return seededSchedule[primaryVisit] ?? (fetchedSchedule?.key === primaryVisit ? fetchedSchedule.days : null);
|
|
811
982
|
}, [
|
|
812
983
|
primaryVisit,
|
|
984
|
+
state.resourceId,
|
|
985
|
+
scheduleKey,
|
|
813
986
|
seededSchedule,
|
|
814
|
-
fetchedSchedule
|
|
987
|
+
fetchedSchedule,
|
|
988
|
+
scheduleFailedFor
|
|
815
989
|
]);
|
|
816
990
|
useEffect(() => {
|
|
817
|
-
if (primaryVisit === null) return;
|
|
818
|
-
if (primaryVisit in seededSchedule) return;
|
|
991
|
+
if (primaryVisit === null || scheduleKey === null) return;
|
|
992
|
+
if (state.resourceId === null && primaryVisit in seededSchedule) return;
|
|
819
993
|
let cancelled = false;
|
|
994
|
+
const key = scheduleKey;
|
|
995
|
+
const resourceId = state.resourceId;
|
|
996
|
+
setScheduleFailedFor((current) => current === key ? null : current);
|
|
820
997
|
(async () => {
|
|
821
998
|
try {
|
|
822
|
-
const response = await fetch(`${api}/schedule?${windowQuery(primaryVisit, fromTs, toTs)}`);
|
|
999
|
+
const response = await fetch(`${api}/schedule?${windowQuery(primaryVisit, fromTs, toTs, resourceId)}`);
|
|
823
1000
|
if (!response.ok) throw new Error(`schedule ${response.status}`);
|
|
824
1001
|
const body = await response.json();
|
|
825
1002
|
if (cancelled) return;
|
|
826
1003
|
setFetchedSchedule({
|
|
827
|
-
key
|
|
1004
|
+
key,
|
|
828
1005
|
days: body.days ?? []
|
|
829
1006
|
});
|
|
830
1007
|
} catch {
|
|
831
|
-
if (!cancelled)
|
|
1008
|
+
if (!cancelled) {
|
|
1009
|
+
setScheduleFailedFor(key);
|
|
1010
|
+
setFetchedSchedule((current) => current?.key === key ? null : current);
|
|
1011
|
+
}
|
|
832
1012
|
}
|
|
833
1013
|
})();
|
|
834
1014
|
return () => {
|
|
@@ -839,7 +1019,9 @@ function useBooking(options) {
|
|
|
839
1019
|
primaryVisit,
|
|
840
1020
|
fromTs,
|
|
841
1021
|
toTs,
|
|
842
|
-
seededSchedule
|
|
1022
|
+
seededSchedule,
|
|
1023
|
+
state.resourceId,
|
|
1024
|
+
scheduleKey
|
|
843
1025
|
]);
|
|
844
1026
|
/** A stylist's display name for an id; `''` from Medal reads as none. */
|
|
845
1027
|
const resolveStylistName = useMemo(() => (resourceId) => {
|
|
@@ -889,7 +1071,7 @@ function useBooking(options) {
|
|
|
889
1071
|
]);
|
|
890
1072
|
const party = wizard.showsPartyMode(state);
|
|
891
1073
|
const haveEveryService = basketVisits.every((key) => key in slots);
|
|
892
|
-
const scheduleSettled = openDays !== null ||
|
|
1074
|
+
const scheduleSettled = openDays !== null || scheduleKey !== null && scheduleFailedFor === scheduleKey;
|
|
893
1075
|
const nextAvailableLoading = party ? !haveEveryService && !slotsFailed : primaryVisit !== null && !(primaryVisit in seededNextAvailable) && fetchedNextAvailable?.key !== primaryVisit;
|
|
894
1076
|
/** The basket's openings, unfiltered — the parallel search needs two stylists. */
|
|
895
1077
|
const basketSlots = useMemo(() => {
|
|
@@ -1007,7 +1189,8 @@ function useBooking(options) {
|
|
|
1007
1189
|
return;
|
|
1008
1190
|
}
|
|
1009
1191
|
const resourceIds = wizard.itemResourceIds(state);
|
|
1010
|
-
const
|
|
1192
|
+
const extended = withExtraServices(submission, state.items);
|
|
1193
|
+
const body = extended !== null && config.account.required ? withSessionPersons(extended, state.items) : extended;
|
|
1011
1194
|
if (body === null) {
|
|
1012
1195
|
dispatch({
|
|
1013
1196
|
type: "submitFailed",
|
|
@@ -1053,12 +1236,25 @@ function useBooking(options) {
|
|
|
1053
1236
|
const payload = await response.json().catch(() => null);
|
|
1054
1237
|
if (!response.ok) {
|
|
1055
1238
|
const definite = payload?.error !== void 0 && payload.error !== "upstreamError";
|
|
1239
|
+
const sessionGone = payload?.error === "accountRequired";
|
|
1056
1240
|
if (definite) {
|
|
1057
|
-
kit.attempts.
|
|
1241
|
+
if (sessionGone) kit.attempts.releasePending(current);
|
|
1242
|
+
else kit.attempts.clearAttempt();
|
|
1058
1243
|
setPendingAttempt(null);
|
|
1059
1244
|
}
|
|
1060
|
-
|
|
1245
|
+
const wasResumed = sendOptions?.replay === true || sendOptions?.pending === true && rebookLink.current !== null;
|
|
1246
|
+
if (sessionGone) {
|
|
1247
|
+
setSignedIn(null);
|
|
1248
|
+
setArrivalExpired(true);
|
|
1249
|
+
setSessionLost(true);
|
|
1250
|
+
if (options.loginOffered !== false) {
|
|
1251
|
+
if (sendOptions?.replay === true || sendOptions?.pending === true) rebuildSubmitted(submitted);
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
if (wasResumed) {
|
|
1061
1256
|
if (definite) {
|
|
1257
|
+
forgetChairs();
|
|
1062
1258
|
dispatch({ type: "startOver" });
|
|
1063
1259
|
restartFromLink();
|
|
1064
1260
|
} else dispatch({
|
|
@@ -1067,6 +1263,13 @@ function useBooking(options) {
|
|
|
1067
1263
|
});
|
|
1068
1264
|
return;
|
|
1069
1265
|
}
|
|
1266
|
+
if (sessionGone) {
|
|
1267
|
+
dispatch({
|
|
1268
|
+
type: "submitFailed",
|
|
1269
|
+
error: "upstreamError"
|
|
1270
|
+
});
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1070
1273
|
if (payload?.error === "slotTaken") {
|
|
1071
1274
|
setTakenSlotTs(submitted.startTs);
|
|
1072
1275
|
const lost = new Set(submitted.items.map((item) => visitKey(item)));
|
|
@@ -1124,6 +1327,23 @@ function useBooking(options) {
|
|
|
1124
1327
|
setSubmitting(false);
|
|
1125
1328
|
}
|
|
1126
1329
|
}
|
|
1330
|
+
/**
|
|
1331
|
+
* The visit a refused replay sent, back in the machine as a draft would be
|
|
1332
|
+
* — the hour on «Bekreft» — in place of the link that was waiting on it.
|
|
1333
|
+
*/
|
|
1334
|
+
function rebuildSubmitted(submitted) {
|
|
1335
|
+
rebookLink.current = null;
|
|
1336
|
+
const { items, resourceIds } = submitted;
|
|
1337
|
+
restoreDraft({
|
|
1338
|
+
items: items.map((item) => draftItem(item, true)),
|
|
1339
|
+
resourceId: null,
|
|
1340
|
+
partyMode: submitted.partyMode,
|
|
1341
|
+
startTs: submitted.startTs,
|
|
1342
|
+
resolvedResourceId: resourceIds[0] ?? null,
|
|
1343
|
+
partyResourceIds: items.length > 1 && resourceIds.every((id) => id !== null) ? resourceIds : null,
|
|
1344
|
+
savedAt: Date.now()
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1127
1347
|
/** «Book again» on the confirmation: a clean wizard with a fresh attempt. */
|
|
1128
1348
|
function startOver() {
|
|
1129
1349
|
kit.attempts.clearAttempt();
|
|
@@ -1133,9 +1353,15 @@ function useBooking(options) {
|
|
|
1133
1353
|
setSlotsFailed(false);
|
|
1134
1354
|
setConfirmed(null);
|
|
1135
1355
|
setTakenSlotTs(null);
|
|
1356
|
+
forgetChairs();
|
|
1136
1357
|
dispatch({ type: "startOver" });
|
|
1137
1358
|
restartFromLink();
|
|
1138
1359
|
}
|
|
1360
|
+
/** A new visit: no chair of the last one is kept or waits for its parent. */
|
|
1361
|
+
function forgetChairs() {
|
|
1362
|
+
setKeptSeats([]);
|
|
1363
|
+
awaitingIds.current = null;
|
|
1364
|
+
}
|
|
1139
1365
|
/** A service tap on step 2 for a party of one — the first carries the link's stylist. */
|
|
1140
1366
|
function pickService(service) {
|
|
1141
1367
|
const first = deepLinkPending.current && state.items.length === 0;
|
|
@@ -1226,6 +1452,10 @@ function useBooking(options) {
|
|
|
1226
1452
|
}
|
|
1227
1453
|
/** Who a parent seats on step 1; each new child starts on «same as last time». */
|
|
1228
1454
|
function choosePeople(people, advance) {
|
|
1455
|
+
const stays = (key) => people.includes(state.people.find((p) => p.key === key));
|
|
1456
|
+
setKeptSeats((current) => current.filter(stays));
|
|
1457
|
+
const waiting = [...awaitingIds.current ?? []].filter(([key]) => stays(key));
|
|
1458
|
+
awaitingIds.current = waiting.length > 0 ? new Map(waiting) : null;
|
|
1229
1459
|
dispatch({
|
|
1230
1460
|
type: "choosePeople",
|
|
1231
1461
|
people,
|
|
@@ -1252,12 +1482,23 @@ function useBooking(options) {
|
|
|
1252
1482
|
kit,
|
|
1253
1483
|
state,
|
|
1254
1484
|
dispatch: action,
|
|
1255
|
-
/**
|
|
1485
|
+
/**
|
|
1486
|
+
* The parent: who arrived (`null` once the create route said the session
|
|
1487
|
+
* ran out), who logged in from the sheet, the merged answer, and whether
|
|
1488
|
+
* anybody is logged in at all (a good code whose profile read failed is).
|
|
1489
|
+
*/
|
|
1256
1490
|
login: {
|
|
1257
|
-
arrivedAs,
|
|
1491
|
+
arrivedAs: arrivedAsNow,
|
|
1258
1492
|
signedIn,
|
|
1259
1493
|
guardian,
|
|
1260
|
-
|
|
1494
|
+
loggedIn: signedIn !== null || arrivedAsNow !== null,
|
|
1495
|
+
/** `/create` said the session ran out, and no login has happened since. */
|
|
1496
|
+
sessionLost,
|
|
1497
|
+
signIn: (who) => {
|
|
1498
|
+
reseatFor(who);
|
|
1499
|
+
setSessionLost(false);
|
|
1500
|
+
setSignedIn({ guardian: who });
|
|
1501
|
+
},
|
|
1261
1502
|
vippsConfirm,
|
|
1262
1503
|
resumePath: vippsResumePath
|
|
1263
1504
|
},
|
|
@@ -1300,6 +1541,7 @@ function useBooking(options) {
|
|
|
1300
1541
|
party,
|
|
1301
1542
|
total: wizard.totalPriceOre(state.items, state.startTs),
|
|
1302
1543
|
summary: wizard.summaryLine(state, resolveStylistName),
|
|
1544
|
+
summaryParts: wizard.summaryParts(state, resolveStylistName),
|
|
1303
1545
|
visitEnd: state.startTs === null ? null : wizard.visitEndTs(state.items, state.startTs, state.partyMode)
|
|
1304
1546
|
},
|
|
1305
1547
|
pickService,
|