@medalsocial/booking 0.2.1 → 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 +22 -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 +269 -42
  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. */
@@ -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
- if (prefill === null) {
229
- if (seated === null) return wizard.initialState();
230
- return "adult" in seated ? withAdult() : withGuests(seated.children);
231
- }
232
- 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;
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
- 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;
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({ type: "seatFamily" });
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
- serviceId: item.service.id,
590
- ...item.extraServices?.length ? { extraServiceIds: item.extraServices.map((service) => service.id) } : {},
591
- bookedForName: item.bookedForName ?? null,
592
- bookedForBirthYear: item.bookedForBirthYear ?? null,
593
- adult: item.adult === true,
594
- personId: item.bookedForPersonId ?? null
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, into
619
- * fields still BLANK or still holding exactly what the PREVIOUS parent's
620
- * 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.
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 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 = {
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
- wrote
797
+ owns
635
798
  };
636
- for (const field of [
637
- "name",
638
- "phone",
639
- "email"
640
- ]) {
641
- const value = wrote[field];
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
- }, [guardian, phone]);
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) setScheduleFailedFor(key);
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 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;
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.clearAttempt();
1241
+ if (sessionGone) kit.attempts.releasePending(current);
1242
+ else kit.attempts.clearAttempt();
1073
1243
  setPendingAttempt(null);
1074
1244
  }
1075
- 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) {
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
- /** 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
+ */
1271
1490
  login: {
1272
- arrivedAs,
1491
+ arrivedAs: arrivedAsNow,
1273
1492
  signedIn,
1274
1493
  guardian,
1275
- 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
+ },
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,