@medalsocial/booking 0.2.1 → 0.3.1
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 +143 -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 +269 -42
- 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/dist/react/wizard/wall-clock.mjs +24 -0
- package/dist/react/wizard/wall-clock.mjs.map +1 -0
- 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. */
|
|
@@ -81,6 +98,27 @@ function withExtraServices(submission, items) {
|
|
|
81
98
|
})
|
|
82
99
|
};
|
|
83
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
|
+
}
|
|
84
122
|
/** What the create route said, narrowed to what the machine can hold. */
|
|
85
123
|
function asSubmitFailure(code) {
|
|
86
124
|
switch (code) {
|
|
@@ -225,11 +263,13 @@ function setupFor(kit) {
|
|
|
225
263
|
const unasked = config.party.askWhoFirst ? null : kit.childCategory !== null ? { children: 1 } : { adult: true };
|
|
226
264
|
const seated = seat ?? unasked;
|
|
227
265
|
const base = seated !== null && "adult" in seated ? withAdult() : wizard.initialState();
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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;
|
|
233
273
|
}
|
|
234
274
|
return {
|
|
235
275
|
prefillFromQuery,
|
|
@@ -238,6 +278,11 @@ function setupFor(kit) {
|
|
|
238
278
|
initialWizardState
|
|
239
279
|
};
|
|
240
280
|
}
|
|
281
|
+
const CONTACT_FIELDS = [
|
|
282
|
+
"name",
|
|
283
|
+
"phone",
|
|
284
|
+
"email"
|
|
285
|
+
];
|
|
241
286
|
function useBooking(options) {
|
|
242
287
|
const { kit } = useBookingKit(options, options.contact);
|
|
243
288
|
const { config, labels, wizard, clock, age, phone, deepLinks } = kit;
|
|
@@ -251,7 +296,17 @@ function useBooking(options) {
|
|
|
251
296
|
* logged-in parent, who must not be offered the login again.
|
|
252
297
|
*/
|
|
253
298
|
const [signedIn, setSignedIn] = useState(null);
|
|
254
|
-
|
|
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;
|
|
255
310
|
const query = useSearchParams();
|
|
256
311
|
const [prefill] = useState(() => setup.prefillFromQuery(query, services));
|
|
257
312
|
const [deepLink] = useState(() => setup.deepLinkFromQuery(query));
|
|
@@ -272,11 +327,88 @@ function useBooking(options) {
|
|
|
272
327
|
/** Children a logged-in parent added from step 1 during this visit. */
|
|
273
328
|
const [addedChildren, setAddedChildren] = useState([]);
|
|
274
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([]);
|
|
275
336
|
/** Guest seats go when a family appears over them (`seatFamily`). */
|
|
276
|
-
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));
|
|
277
338
|
useEffect(() => {
|
|
278
|
-
if (guestSeated) dispatch({
|
|
339
|
+
if (guestSeated) dispatch({
|
|
340
|
+
type: "seatFamily",
|
|
341
|
+
keep: keptSeats
|
|
342
|
+
});
|
|
279
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
|
+
}
|
|
280
412
|
/** A stylist a party link named, applied once the family reaches step 3. */
|
|
281
413
|
const partyStylist = useRef(arrivedAs !== null ? prefill?.resourceId ?? deepLink.stylist : null);
|
|
282
414
|
const childByKey = useMemo(() => new Map((family ?? []).map((child, index) => [personForChild(child, index).key, child])), [family]);
|
|
@@ -481,6 +613,13 @@ function useBooking(options) {
|
|
|
481
613
|
const resolved = draft.items.map((item) => bookable.get(item.serviceId));
|
|
482
614
|
const [first, ...rest] = resolved;
|
|
483
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
|
+
}
|
|
484
623
|
if (family === null && draft.items.every((item) => typeof item.adult === "boolean")) dispatch({
|
|
485
624
|
type: "choosePeople",
|
|
486
625
|
people: draft.items.map((item, index) => item.adult ? {
|
|
@@ -581,18 +720,19 @@ function useBooking(options) {
|
|
|
581
720
|
/** The draft, kept current on every answer rather than written on the Vipps tap. */
|
|
582
721
|
useEffect(() => {
|
|
583
722
|
if (confirmed !== null || state.items.length === 0) {
|
|
723
|
+
if (confirmed !== null) awaitingIds.current = null;
|
|
584
724
|
kit.drafts.clearDraft();
|
|
585
725
|
return;
|
|
586
726
|
}
|
|
587
727
|
kit.drafts.stashDraft({
|
|
588
|
-
items: state.items.map((item) =>
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
})
|
|
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
|
+
}),
|
|
596
736
|
resourceId: state.resourceId,
|
|
597
737
|
partyMode: state.partyMode,
|
|
598
738
|
startTs: state.startTs,
|
|
@@ -602,53 +742,76 @@ function useBooking(options) {
|
|
|
602
742
|
}, [
|
|
603
743
|
kit,
|
|
604
744
|
confirmed,
|
|
745
|
+
guardian,
|
|
605
746
|
state.items,
|
|
747
|
+
state.people,
|
|
606
748
|
state.resourceId,
|
|
607
749
|
state.partyMode,
|
|
608
750
|
state.startTs,
|
|
609
751
|
state.resolvedResourceId,
|
|
610
752
|
state.partyResourceIds
|
|
611
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);
|
|
612
760
|
/** A live mirror of the contact fields, for the prefill below. */
|
|
613
761
|
const latestContact = useRef(state.contact);
|
|
614
762
|
useEffect(() => {
|
|
615
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];
|
|
616
767
|
});
|
|
617
768
|
/**
|
|
618
|
-
* The logged-in parent's details into the fields — once per parent
|
|
619
|
-
*
|
|
620
|
-
*
|
|
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.
|
|
621
774
|
*/
|
|
622
|
-
const guardianFilled = useRef(null);
|
|
623
775
|
useEffect(() => {
|
|
624
|
-
if (guardian === null || guardianFilled.current?.email === guardian.email) return;
|
|
625
|
-
const previous = guardianFilled.current?.wrote;
|
|
626
776
|
const contact = latestContact.current;
|
|
627
|
-
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 = {
|
|
628
790
|
name: [guardian.firstName, guardian.lastName].filter(Boolean).join(" ").trim(),
|
|
629
791
|
phone: guardian.phone === null ? "" : phone.nationalDigits(guardian.phone),
|
|
630
792
|
email: guardian.email
|
|
631
793
|
};
|
|
794
|
+
const owns = {};
|
|
632
795
|
guardianFilled.current = {
|
|
633
796
|
email: guardian.email,
|
|
634
|
-
|
|
797
|
+
owns
|
|
635
798
|
};
|
|
636
|
-
for (const field of
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
"
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
if (value === "") continue;
|
|
643
|
-
const held = contact[field].trim();
|
|
644
|
-
if (held !== "" && held !== previous?.[field]) continue;
|
|
645
|
-
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({
|
|
646
805
|
type: "setContact",
|
|
647
806
|
field,
|
|
648
807
|
value
|
|
649
808
|
});
|
|
650
809
|
}
|
|
651
|
-
}, [
|
|
810
|
+
}, [
|
|
811
|
+
guardian,
|
|
812
|
+
signedIn,
|
|
813
|
+
phone
|
|
814
|
+
]);
|
|
652
815
|
const basketVisits = useMemo(() => slotKeysFor(state.items), [state.items]);
|
|
653
816
|
const basketKey = basketVisits.join("|");
|
|
654
817
|
const primaryServiceId = state.items[0]?.service.id ?? null;
|
|
@@ -830,6 +993,7 @@ function useBooking(options) {
|
|
|
830
993
|
let cancelled = false;
|
|
831
994
|
const key = scheduleKey;
|
|
832
995
|
const resourceId = state.resourceId;
|
|
996
|
+
setScheduleFailedFor((current) => current === key ? null : current);
|
|
833
997
|
(async () => {
|
|
834
998
|
try {
|
|
835
999
|
const response = await fetch(`${api}/schedule?${windowQuery(primaryVisit, fromTs, toTs, resourceId)}`);
|
|
@@ -841,7 +1005,10 @@ function useBooking(options) {
|
|
|
841
1005
|
days: body.days ?? []
|
|
842
1006
|
});
|
|
843
1007
|
} catch {
|
|
844
|
-
if (!cancelled)
|
|
1008
|
+
if (!cancelled) {
|
|
1009
|
+
setScheduleFailedFor(key);
|
|
1010
|
+
setFetchedSchedule((current) => current?.key === key ? null : current);
|
|
1011
|
+
}
|
|
845
1012
|
}
|
|
846
1013
|
})();
|
|
847
1014
|
return () => {
|
|
@@ -1022,7 +1189,8 @@ function useBooking(options) {
|
|
|
1022
1189
|
return;
|
|
1023
1190
|
}
|
|
1024
1191
|
const resourceIds = wizard.itemResourceIds(state);
|
|
1025
|
-
const
|
|
1192
|
+
const extended = withExtraServices(submission, state.items);
|
|
1193
|
+
const body = extended !== null && config.account.required ? withSessionPersons(extended, state.items) : extended;
|
|
1026
1194
|
if (body === null) {
|
|
1027
1195
|
dispatch({
|
|
1028
1196
|
type: "submitFailed",
|
|
@@ -1068,12 +1236,25 @@ function useBooking(options) {
|
|
|
1068
1236
|
const payload = await response.json().catch(() => null);
|
|
1069
1237
|
if (!response.ok) {
|
|
1070
1238
|
const definite = payload?.error !== void 0 && payload.error !== "upstreamError";
|
|
1239
|
+
const sessionGone = payload?.error === "accountRequired";
|
|
1071
1240
|
if (definite) {
|
|
1072
|
-
kit.attempts.
|
|
1241
|
+
if (sessionGone) kit.attempts.releasePending(current);
|
|
1242
|
+
else kit.attempts.clearAttempt();
|
|
1073
1243
|
setPendingAttempt(null);
|
|
1074
1244
|
}
|
|
1075
|
-
|
|
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) {
|
|
1076
1256
|
if (definite) {
|
|
1257
|
+
forgetChairs();
|
|
1077
1258
|
dispatch({ type: "startOver" });
|
|
1078
1259
|
restartFromLink();
|
|
1079
1260
|
} else dispatch({
|
|
@@ -1082,6 +1263,13 @@ function useBooking(options) {
|
|
|
1082
1263
|
});
|
|
1083
1264
|
return;
|
|
1084
1265
|
}
|
|
1266
|
+
if (sessionGone) {
|
|
1267
|
+
dispatch({
|
|
1268
|
+
type: "submitFailed",
|
|
1269
|
+
error: "upstreamError"
|
|
1270
|
+
});
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1085
1273
|
if (payload?.error === "slotTaken") {
|
|
1086
1274
|
setTakenSlotTs(submitted.startTs);
|
|
1087
1275
|
const lost = new Set(submitted.items.map((item) => visitKey(item)));
|
|
@@ -1139,6 +1327,23 @@ function useBooking(options) {
|
|
|
1139
1327
|
setSubmitting(false);
|
|
1140
1328
|
}
|
|
1141
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
|
+
}
|
|
1142
1347
|
/** «Book again» on the confirmation: a clean wizard with a fresh attempt. */
|
|
1143
1348
|
function startOver() {
|
|
1144
1349
|
kit.attempts.clearAttempt();
|
|
@@ -1148,9 +1353,15 @@ function useBooking(options) {
|
|
|
1148
1353
|
setSlotsFailed(false);
|
|
1149
1354
|
setConfirmed(null);
|
|
1150
1355
|
setTakenSlotTs(null);
|
|
1356
|
+
forgetChairs();
|
|
1151
1357
|
dispatch({ type: "startOver" });
|
|
1152
1358
|
restartFromLink();
|
|
1153
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
|
+
}
|
|
1154
1365
|
/** A service tap on step 2 for a party of one — the first carries the link's stylist. */
|
|
1155
1366
|
function pickService(service) {
|
|
1156
1367
|
const first = deepLinkPending.current && state.items.length === 0;
|
|
@@ -1241,6 +1452,10 @@ function useBooking(options) {
|
|
|
1241
1452
|
}
|
|
1242
1453
|
/** Who a parent seats on step 1; each new child starts on «same as last time». */
|
|
1243
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;
|
|
1244
1459
|
dispatch({
|
|
1245
1460
|
type: "choosePeople",
|
|
1246
1461
|
people,
|
|
@@ -1267,12 +1482,23 @@ function useBooking(options) {
|
|
|
1267
1482
|
kit,
|
|
1268
1483
|
state,
|
|
1269
1484
|
dispatch: action,
|
|
1270
|
-
/**
|
|
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
|
+
*/
|
|
1271
1490
|
login: {
|
|
1272
|
-
arrivedAs,
|
|
1491
|
+
arrivedAs: arrivedAsNow,
|
|
1273
1492
|
signedIn,
|
|
1274
1493
|
guardian,
|
|
1275
|
-
|
|
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
|
+
},
|
|
1276
1502
|
vippsConfirm,
|
|
1277
1503
|
resumePath: vippsResumePath
|
|
1278
1504
|
},
|
|
@@ -1315,6 +1541,7 @@ function useBooking(options) {
|
|
|
1315
1541
|
party,
|
|
1316
1542
|
total: wizard.totalPriceOre(state.items, state.startTs),
|
|
1317
1543
|
summary: wizard.summaryLine(state, resolveStylistName),
|
|
1544
|
+
summaryParts: wizard.summaryParts(state, resolveStylistName),
|
|
1318
1545
|
visitEnd: state.startTs === null ? null : wizard.visitEndTs(state.items, state.startTs, state.partyMode)
|
|
1319
1546
|
},
|
|
1320
1547
|
pickService,
|