@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +21 -3
  3. package/dist/core/attempt-store.d.mts +1 -0
  4. package/dist/core/attempt-store.mjs +13 -0
  5. package/dist/core/attempt-store.mjs.map +1 -1
  6. package/dist/core/config.d.mts +37 -0
  7. package/dist/core/config.mjs +29 -0
  8. package/dist/core/config.mjs.map +1 -1
  9. package/dist/core/machine.d.mts +27 -1
  10. package/dist/core/machine.mjs +52 -4
  11. package/dist/core/machine.mjs.map +1 -1
  12. package/dist/next/medal.d.mts +8 -1
  13. package/dist/next/medal.mjs +16 -5
  14. package/dist/next/medal.mjs.map +1 -1
  15. package/dist/next/portal/medal-portal.mjs +1 -62
  16. package/dist/next/portal/medal-portal.mjs.map +1 -1
  17. package/dist/next/redact.mjs +76 -0
  18. package/dist/next/redact.mjs.map +1 -0
  19. package/dist/next/routes/create.mjs +67 -9
  20. package/dist/next/routes/create.mjs.map +1 -1
  21. package/dist/next/routes/shared.mjs +3 -0
  22. package/dist/next/routes/shared.mjs.map +1 -1
  23. package/dist/react/BookingWizard.mjs +139 -11
  24. package/dist/react/BookingWizard.mjs.map +1 -1
  25. package/dist/react/LoginSheet.d.mts +19 -1
  26. package/dist/react/LoginSheet.mjs +15 -1
  27. package/dist/react/LoginSheet.mjs.map +1 -1
  28. package/dist/react/Provider.d.mts +2 -1
  29. package/dist/react/Provider.mjs.map +1 -1
  30. package/dist/react/actions.d.mts +16 -5
  31. package/dist/react/actions.mjs +5 -1
  32. package/dist/react/actions.mjs.map +1 -1
  33. package/dist/react/index.d.mts +2 -2
  34. package/dist/react/kit.d.mts +6 -1
  35. package/dist/react/kit.mjs +4 -3
  36. package/dist/react/kit.mjs.map +1 -1
  37. package/dist/react/labels/login.d.mts +2 -0
  38. package/dist/react/labels/login.mjs +4 -2
  39. package/dist/react/labels/login.mjs.map +1 -1
  40. package/dist/react/labels/screens.mjs +58 -2
  41. package/dist/react/labels/screens.mjs.map +1 -1
  42. package/dist/react/labels/wizard.d.mts +8 -0
  43. package/dist/react/labels/wizard.mjs +6 -0
  44. package/dist/react/labels/wizard.mjs.map +1 -1
  45. package/dist/react/screen-labels.mjs +5 -0
  46. package/dist/react/screen-labels.mjs.map +1 -1
  47. package/dist/react/useBooking.d.mts +19 -1
  48. package/dist/react/useBooking.mjs +299 -57
  49. package/dist/react/useBooking.mjs.map +1 -1
  50. package/dist/react/wizard/adapters.mjs +66 -3
  51. package/dist/react/wizard/adapters.mjs.map +1 -1
  52. package/dist/react/wizard/index.d.mts +2 -2
  53. 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
- 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));
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
- const guardian = signedIn?.guardian ?? arrivedAs;
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({ type: "seatFamily" });
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
- 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
- })),
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, 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.
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 wrote = {
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
- wrote
797
+ owns
634
798
  };
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({
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
- }, [guardian, phone]);
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 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).
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
- const seeded = seededSchedule[primaryVisit];
809
- if (seeded) return seeded;
810
- return fetchedSchedule?.key === primaryVisit ? fetchedSchedule.days : null;
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: primaryVisit,
1004
+ key,
828
1005
  days: body.days ?? []
829
1006
  });
830
1007
  } catch {
831
- if (!cancelled) setScheduleFailedFor(primaryVisit);
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 || primaryVisit !== null && scheduleFailedFor === primaryVisit;
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 body = withExtraServices(submission, state.items);
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.clearAttempt();
1241
+ if (sessionGone) kit.attempts.releasePending(current);
1242
+ else kit.attempts.clearAttempt();
1058
1243
  setPendingAttempt(null);
1059
1244
  }
1060
- if (sendOptions?.replay === true || sendOptions?.pending === true && rebookLink.current !== null) {
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
- /** The parent: who arrived, who logged in from the sheet, and the merged answer. */
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
- signIn: (who) => setSignedIn({ guardian: who }),
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,