@medalsocial/booking 0.1.0 → 0.2.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +2 -2
  3. package/dist/core/config.d.mts +7 -0
  4. package/dist/core/config.mjs +4 -2
  5. package/dist/core/config.mjs.map +1 -1
  6. package/dist/core/draft-store.d.mts +6 -0
  7. package/dist/core/draft-store.mjs +17 -1
  8. package/dist/core/draft-store.mjs.map +1 -1
  9. package/dist/core/index.d.mts +3 -2
  10. package/dist/core/index.mjs +3 -2
  11. package/dist/core/machine.d.mts +53 -6
  12. package/dist/core/machine.mjs +162 -52
  13. package/dist/core/machine.mjs.map +1 -1
  14. package/dist/core/party-slots.d.mts +9 -2
  15. package/dist/core/party-slots.mjs +18 -12
  16. package/dist/core/party-slots.mjs.map +1 -1
  17. package/dist/core/types.d.mts +4 -0
  18. package/dist/core/visit.d.mts +26 -0
  19. package/dist/core/visit.mjs +40 -0
  20. package/dist/core/visit.mjs.map +1 -0
  21. package/dist/next/catalogue.mjs +25 -7
  22. package/dist/next/catalogue.mjs.map +1 -1
  23. package/dist/next/medal.d.mts +7 -0
  24. package/dist/next/medal.mjs +2 -1
  25. package/dist/next/medal.mjs.map +1 -1
  26. package/dist/next/routes/catalogue-routes.mjs +18 -4
  27. package/dist/next/routes/catalogue-routes.mjs.map +1 -1
  28. package/dist/next/routes/create.mjs +47 -9
  29. package/dist/next/routes/create.mjs.map +1 -1
  30. package/dist/next/routes/manage.mjs.map +1 -1
  31. package/dist/next/routes/shared.mjs +29 -1
  32. package/dist/next/routes/shared.mjs.map +1 -1
  33. package/dist/react/BookingWizard.mjs +32 -11
  34. package/dist/react/BookingWizard.mjs.map +1 -1
  35. package/dist/react/labels/screens.d.mts +1 -1
  36. package/dist/react/labels/screens.mjs +12 -0
  37. package/dist/react/labels/screens.mjs.map +1 -1
  38. package/dist/react/labels/wizard.d.mts +7 -0
  39. package/dist/react/labels/wizard.mjs +2 -0
  40. package/dist/react/labels/wizard.mjs.map +1 -1
  41. package/dist/react/useBooking.d.mts +2 -0
  42. package/dist/react/useBooking.mjs +194 -53
  43. package/dist/react/useBooking.mjs.map +1 -1
  44. package/dist/react/wizard/adapters.mjs +7 -5
  45. package/dist/react/wizard/adapters.mjs.map +1 -1
  46. package/dist/react/wizard-error.mjs +29 -0
  47. package/dist/react/wizard-error.mjs.map +1 -0
  48. package/package.json +4 -4
@@ -4,7 +4,9 @@ import "../core/attempt-store.mjs";
4
4
  import { stylistDisplayName } from "../core/display-name.mjs";
5
5
  import { serviceMatches } from "../core/deep-link.mjs";
6
6
  import "../core/draft-store.mjs";
7
+ import { visitKey, visitServicesOf } from "../core/visit.mjs";
7
8
  import { firstOpeningPerResource } from "../core/next-available.mjs";
9
+ import { slotKeysFor } from "../core/party-slots.mjs";
8
10
  import { stripVippsReturn, vippsConfirmFrom } from "../core/portal/vipps-return.mjs";
9
11
  import { useBookingKit } from "./Provider.mjs";
10
12
  import { useSearchParams } from "next/navigation";
@@ -33,18 +35,52 @@ const REPLAY_TIMEOUT_MS = 15e3;
33
35
  function noSubscription() {
34
36
  return () => {};
35
37
  }
36
- /** The `freshSlots` of a `slotTaken` answer, narrowed to the lost services' lists. */
38
+ /** The `freshSlots` of a `slotTaken` answer, narrowed to the lost visits' lists. */
37
39
  function freshSlotsOf(raw, lost) {
38
40
  if (typeof raw !== "object" || raw === null) return {};
39
41
  return Object.fromEntries(Object.entries(raw).filter((entry) => lost.has(entry[0]) && Array.isArray(entry[1])));
40
42
  }
41
- function windowQuery(serviceId, fromTs, toTs) {
43
+ /**
44
+ * The window query for ONE visit, named by its key (`a` or `a+b`): the first
45
+ * service as `service_id`, the rest as `extra_service_ids`. The extras are
46
+ * added only when there are some, so a one-service visit asks with exactly the
47
+ * URL it always did — the slot cache and every seeded answer are keyed by it.
48
+ */
49
+ function windowQuery(key, fromTs, toTs, resourceId) {
50
+ const [serviceId, ...extras] = key.split("+");
42
51
  return new URLSearchParams({
43
52
  service_id: serviceId,
53
+ ...extras.length === 0 ? {} : { extra_service_ids: extras.join(",") },
54
+ ...resourceId ? { resource_id: resourceId } : {},
44
55
  from_ts: String(fromTs),
45
56
  to_ts: String(toTs)
46
57
  });
47
58
  }
59
+ /**
60
+ * The submission with each line's extras on it. meda's details screen builds
61
+ * the body from its own types, which have one service per line, so the shell
62
+ * is the only place that knows the rest of a person's visit. Index-aligned with
63
+ * the basket the screen was drawn from; a body of another length is not one
64
+ * this basket produced, and is sent as it came rather than with extras pinned
65
+ * to the wrong people.
66
+ */
67
+ function withExtraServices(submission, items) {
68
+ if (!items.some((item) => (item.extraServices ?? []).length > 0)) return submission;
69
+ if (submission.items.length !== items.length) {
70
+ console.warn("[@medalsocial/booking] The submission has %d lines for a basket of %d; not sent.", submission.items.length, items.length);
71
+ return null;
72
+ }
73
+ return {
74
+ ...submission,
75
+ items: submission.items.map((line, index) => {
76
+ const extras = items[index].extraServices ?? [];
77
+ return extras.length === 0 ? line : {
78
+ ...line,
79
+ extraServiceIds: extras.map((service) => service.id)
80
+ };
81
+ })
82
+ };
83
+ }
48
84
  /** What the create route said, narrowed to what the machine can hold. */
49
85
  function asSubmitFailure(code) {
50
86
  switch (code) {
@@ -108,8 +144,8 @@ function stylistLeftToFill(prefill) {
108
144
  return prefill?.serviceId && prefill.resourceId ? prefill : null;
109
145
  }
110
146
  /** Whether the stylist list can now vouch for the pending link. */
111
- function canFinishStylist(pending, resources, primaryServiceId) {
112
- return pending !== null && resources.length > 0 && primaryServiceId === pending.serviceId;
147
+ function canFinishStylist(pending, resources, primaryVisit) {
148
+ return pending !== null && resources.length > 0 && primaryVisit === pending.serviceId;
113
149
  }
114
150
  /**
115
151
  * A saved child as the person the machine seats: by `personId` when Medal has
@@ -278,7 +314,12 @@ function useBooking(options) {
278
314
  };
279
315
  };
280
316
  const pendingStylist = useRef(stylistLeftToFill(prefill));
281
- /** Unfiltered openings, keyed by service id. */
317
+ /**
318
+ * Unfiltered openings, keyed by VISIT (`visitKey`): a service id for a
319
+ * one-service visit — which is all the server seed ever holds — and `a+b`
320
+ * for a person having both, whose list is the server's answer for the whole
321
+ * visit rather than either service's own.
322
+ */
282
323
  const [slots, setSlots] = useState(() => options.seed.slots ?? {});
283
324
  const [slotsFailed, setSlotsFailed] = useState(false);
284
325
  const [resources, setResources] = useState(() => options.seed.resources ?? []);
@@ -286,7 +327,7 @@ function useBooking(options) {
286
327
  const [resourcesKnown, setResourcesKnown] = useState(() => options.seed.resources != null);
287
328
  const [seededNextAvailable, setSeededNextAvailable] = useState(() => options.seed.resources ? options.seed.nextAvailable ?? {} : {});
288
329
  const [seededSchedule] = useState(() => options.seed.schedules ?? {});
289
- /** The last «next available» answer, and for WHICH service. */
330
+ /** The last «next available» answer, and for WHICH visit (its key). */
290
331
  const [fetchedNextAvailable, setFetchedNextAvailable] = useState(null);
291
332
  const [submitting, setSubmitting] = useState(false);
292
333
  /**
@@ -400,6 +441,37 @@ function useBooking(options) {
400
441
  return reseated;
401
442
  };
402
443
  /**
444
+ * Each draft line's extras back on it, through `toggleServiceFor` like a tap.
445
+ * Returns whether EVERY extra came back. The machine's refusals are checked
446
+ * here first rather than read off its state, which a dispatch does not hand
447
+ * back: an id the catalogue no longer books, one already on the line (a
448
+ * toggle would take it OFF again), one past `maxServicesPerPerson`, or one
449
+ * more taker than the service's `maxPerBooking` — each is skipped, as an
450
+ * unknown main service is, and makes the answer `false`.
451
+ */
452
+ const restoreExtras = (draft, lines, bookable) => {
453
+ let whole = true;
454
+ const lists = lines.map((service) => [service]);
455
+ draft.items.forEach((item, index) => {
456
+ for (const id of item.extraServiceIds ?? []) {
457
+ const service = bookable.get(id);
458
+ const list = lists[index];
459
+ const takers = lists.filter((other) => other.some((entry) => entry.id === id)).length;
460
+ if (service === void 0 || list.some((entry) => entry.id === id) || list.length >= config.party.maxServicesPerPerson || takers + 1 > service.maxPerBooking) {
461
+ whole = false;
462
+ continue;
463
+ }
464
+ list.push(service);
465
+ dispatch({
466
+ type: "toggleServiceFor",
467
+ index,
468
+ service
469
+ });
470
+ }
471
+ });
472
+ return whole;
473
+ };
474
+ /**
403
475
  * The booking a Vipps login interrupted, rebuilt through the machine's own
404
476
  * actions — so it obeys every rule a tapped one does — and trusting nothing
405
477
  * beyond the catalogue.
@@ -426,6 +498,7 @@ function useBooking(options) {
426
498
  service
427
499
  });
428
500
  const reseated = reseatFamily(draft, resolved);
501
+ const wholeVisits = restoreExtras(draft, resolved, bookable);
429
502
  if (draft.partyMode === "parallel") dispatch({
430
503
  type: "setPartyMode",
431
504
  mode: "parallel"
@@ -449,7 +522,7 @@ function useBooking(options) {
449
522
  value: item.bookedForBirthYear
450
523
  });
451
524
  });
452
- if (draft.startTs === null) return;
525
+ if (draft.startTs === null || !wholeVisits) return;
453
526
  if (draft.partyResourceIds !== null && draft.partyResourceIds.length === resolved.length) {
454
527
  restoredStylists.current = draft.partyResourceIds;
455
528
  dispatch({
@@ -514,6 +587,7 @@ function useBooking(options) {
514
587
  kit.drafts.stashDraft({
515
588
  items: state.items.map((item) => ({
516
589
  serviceId: item.service.id,
590
+ ...item.extraServices?.length ? { extraServiceIds: item.extraServices.map((service) => service.id) } : {},
517
591
  bookedForName: item.bookedForName ?? null,
518
592
  bookedForBirthYear: item.bookedForBirthYear ?? null,
519
593
  adult: item.adult === true,
@@ -575,20 +649,28 @@ function useBooking(options) {
575
649
  });
576
650
  }
577
651
  }, [guardian, phone]);
578
- const basketServiceIds = useMemo(() => [...new Set(state.items.map((item) => item.service.id))], [state.items]);
579
- const basketKey = basketServiceIds.join("|");
652
+ const basketVisits = useMemo(() => slotKeysFor(state.items), [state.items]);
653
+ const basketKey = basketVisits.join("|");
580
654
  const primaryServiceId = state.items[0]?.service.id ?? null;
655
+ /**
656
+ * The first person's whole visit, by key — what the stylists' «neste ledige»
657
+ * and the business's hours are asked for. Equal to `primaryServiceId` for a
658
+ * one-service visit, so the seed (keyed by service id, and only ever for one
659
+ * service) answers exactly the visits it can, and a visit with extras is
660
+ * fetched instead of being read off its first service's openings.
661
+ */
662
+ const primaryVisit = state.items[0] ? visitKey(state.items[0]) : null;
581
663
  const nextAvailableTs = useMemo(() => {
582
- if (primaryServiceId === null) return {};
583
- const seeded = seededNextAvailable[primaryServiceId];
664
+ if (primaryVisit === null) return {};
665
+ const seeded = seededNextAvailable[primaryVisit];
584
666
  if (seeded) return seeded;
585
- return fetchedNextAvailable?.serviceId === primaryServiceId ? fetchedNextAvailable.times : {};
667
+ return fetchedNextAvailable?.key === primaryVisit ? fetchedNextAvailable.times : {};
586
668
  }, [
587
- primaryServiceId,
669
+ primaryVisit,
588
670
  seededNextAvailable,
589
671
  fetchedNextAvailable
590
672
  ]);
591
- const missingKey = basketServiceIds.filter((id) => !(id in slots)).join("|");
673
+ const missingKey = basketVisits.filter((key) => !(key in slots)).join("|");
592
674
  /** Bumped to ask for the openings again after a failure. */
593
675
  const [slotsAttempt, setSlotsAttempt] = useState(0);
594
676
  const api = config.paths.api;
@@ -599,10 +681,10 @@ function useBooking(options) {
599
681
  setSlotsFailed(false);
600
682
  (async () => {
601
683
  try {
602
- const fetched = await Promise.all(wanted.map(async (serviceId) => {
603
- const response = await fetch(`${api}/availability?${windowQuery(serviceId, fromTs, toTs)}`);
684
+ const fetched = await Promise.all(wanted.map(async (key) => {
685
+ const response = await fetch(`${api}/availability?${windowQuery(key, fromTs, toTs)}`);
604
686
  if (!response.ok) throw new Error(`availability ${response.status}`);
605
- return [serviceId, (await response.json()).slots ?? []];
687
+ return [key, (await response.json()).slots ?? []];
606
688
  }));
607
689
  if (cancelled) return;
608
690
  setSlots((previous) => ({
@@ -624,26 +706,26 @@ function useBooking(options) {
624
706
  slotsAttempt
625
707
  ]);
626
708
  useEffect(() => {
627
- if (primaryServiceId === null) return;
628
- if (primaryServiceId in seededNextAvailable) return;
709
+ if (primaryVisit === null) return;
710
+ if (primaryVisit in seededNextAvailable) return;
629
711
  let cancelled = false;
630
712
  (async () => {
631
713
  try {
632
- const response = await fetch(`${api}/resources?${windowQuery(primaryServiceId, fromTs, toTs)}`);
714
+ const response = await fetch(`${api}/resources?${windowQuery(primaryVisit, fromTs, toTs)}`);
633
715
  if (!response.ok) throw new Error(`resources ${response.status}`);
634
716
  const body = await response.json();
635
717
  if (cancelled) return;
636
718
  setResources(body.resources ?? []);
637
719
  setResourcesKnown(true);
638
720
  setFetchedNextAvailable({
639
- serviceId: primaryServiceId,
721
+ key: primaryVisit,
640
722
  times: body.nextAvailableTs ?? {}
641
723
  });
642
724
  } catch {
643
725
  if (!cancelled) {
644
726
  setResourcesKnown(true);
645
727
  setFetchedNextAvailable({
646
- serviceId: primaryServiceId,
728
+ key: primaryVisit,
647
729
  times: {}
648
730
  });
649
731
  }
@@ -654,19 +736,24 @@ function useBooking(options) {
654
736
  };
655
737
  }, [
656
738
  api,
657
- primaryServiceId,
739
+ primaryVisit,
658
740
  fromTs,
659
741
  toTs,
660
742
  seededNextAvailable
661
743
  ]);
662
- /** The stylist half of a «book again» link, once a list for its service has landed. */
744
+ /**
745
+ * The stylist half of a «book again» link, once a list for its service has
746
+ * landed. Compared by VISIT: the link named one service, and a visitor who
747
+ * has since added a wash to it is asking a different question — the list now
748
+ * in hand answers the longer visit, not the one the link vouched for.
749
+ */
663
750
  useEffect(() => {
664
751
  const pending = pendingStylist.current;
665
- if (pending && primaryServiceId !== null && primaryServiceId !== pending.serviceId) {
752
+ if (pending && primaryVisit !== null && primaryVisit !== pending.serviceId) {
666
753
  pendingStylist.current = null;
667
754
  return;
668
755
  }
669
- if (!canFinishStylist(pending, resources, primaryServiceId)) return;
756
+ if (!canFinishStylist(pending, resources, primaryVisit)) return;
670
757
  pendingStylist.current = null;
671
758
  dispatch({
672
759
  type: "prefill",
@@ -675,7 +762,7 @@ function useBooking(options) {
675
762
  });
676
763
  }, [
677
764
  resources,
678
- primaryServiceId,
765
+ primaryVisit,
679
766
  services
680
767
  ]);
681
768
  /** A party link's stylist, once the family has its services and is on step 3. */
@@ -710,35 +797,51 @@ function useBooking(options) {
710
797
  resourceId: null
711
798
  });
712
799
  }, [resources, primaryServiceId]);
713
- /** The business's open dates over the window, for ONE service (cutoffs are per service). */
800
+ /**
801
+ * The open dates over the window, for the first person's VISIT (cutoffs are
802
+ * per service, and `visitTailMinutes` assumes the answer was for item 0's
803
+ * whole visit — see the machine).
804
+ *
805
+ * The seed is the SALON's week, which is what «first available» draws. A named
806
+ * stylist works a different week, so their hours are fetched with `resource_id`
807
+ * and the seed is not allowed to answer for them — a seeded salon week would
808
+ * otherwise paint every open day as theirs.
809
+ */
714
810
  const [fetchedSchedule, setFetchedSchedule] = useState(null);
715
811
  const [scheduleFailedFor, setScheduleFailedFor] = useState(null);
812
+ const scheduleKey = primaryVisit === null ? null : state.resourceId === null ? primaryVisit : `${primaryVisit}#${state.resourceId}`;
716
813
  const openDays = useMemo(() => {
717
- if (primaryServiceId === null) return null;
718
- const seeded = seededSchedule[primaryServiceId];
719
- if (seeded) return seeded;
720
- return fetchedSchedule?.serviceId === primaryServiceId ? fetchedSchedule.days : null;
814
+ if (primaryVisit === null || scheduleKey === null) return null;
815
+ if (fetchedSchedule?.key === scheduleKey) return fetchedSchedule.days;
816
+ if (state.resourceId === null) return seededSchedule[primaryVisit] ?? null;
817
+ if (scheduleFailedFor === scheduleKey) return null;
818
+ return seededSchedule[primaryVisit] ?? (fetchedSchedule?.key === primaryVisit ? fetchedSchedule.days : null);
721
819
  }, [
722
- primaryServiceId,
820
+ primaryVisit,
821
+ state.resourceId,
822
+ scheduleKey,
723
823
  seededSchedule,
724
- fetchedSchedule
824
+ fetchedSchedule,
825
+ scheduleFailedFor
725
826
  ]);
726
827
  useEffect(() => {
727
- if (primaryServiceId === null) return;
728
- if (primaryServiceId in seededSchedule) return;
828
+ if (primaryVisit === null || scheduleKey === null) return;
829
+ if (state.resourceId === null && primaryVisit in seededSchedule) return;
729
830
  let cancelled = false;
831
+ const key = scheduleKey;
832
+ const resourceId = state.resourceId;
730
833
  (async () => {
731
834
  try {
732
- const response = await fetch(`${api}/schedule?${windowQuery(primaryServiceId, fromTs, toTs)}`);
835
+ const response = await fetch(`${api}/schedule?${windowQuery(primaryVisit, fromTs, toTs, resourceId)}`);
733
836
  if (!response.ok) throw new Error(`schedule ${response.status}`);
734
837
  const body = await response.json();
735
838
  if (cancelled) return;
736
839
  setFetchedSchedule({
737
- serviceId: primaryServiceId,
840
+ key,
738
841
  days: body.days ?? []
739
842
  });
740
843
  } catch {
741
- if (!cancelled) setScheduleFailedFor(primaryServiceId);
844
+ if (!cancelled) setScheduleFailedFor(key);
742
845
  }
743
846
  })();
744
847
  return () => {
@@ -746,10 +849,12 @@ function useBooking(options) {
746
849
  };
747
850
  }, [
748
851
  api,
749
- primaryServiceId,
852
+ primaryVisit,
750
853
  fromTs,
751
854
  toTs,
752
- seededSchedule
855
+ seededSchedule,
856
+ state.resourceId,
857
+ scheduleKey
753
858
  ]);
754
859
  /** A stylist's display name for an id; `''` from Medal reads as none. */
755
860
  const resolveStylistName = useMemo(() => (resourceId) => {
@@ -768,7 +873,7 @@ function useBooking(options) {
768
873
  if (!resourcesKnown || resources.length === 0) return;
769
874
  if (state.step !== "when" || state.resourceId === null) return;
770
875
  const chosen = resources.find((resource) => resource.id === state.resourceId);
771
- if (chosen !== void 0 && state.items.every((item) => chosen.serviceIds.includes(item.service.id))) return;
876
+ if (chosen !== void 0 && state.items.every((item) => visitServicesOf(item).every((service) => chosen.serviceIds.includes(service.id)))) return;
772
877
  dispatch({
773
878
  type: "pickResource",
774
879
  resourceId: null
@@ -798,9 +903,9 @@ function useBooking(options) {
798
903
  state.partyMode
799
904
  ]);
800
905
  const party = wizard.showsPartyMode(state);
801
- const haveEveryService = basketServiceIds.every((id) => id in slots);
802
- const scheduleSettled = openDays !== null || primaryServiceId !== null && scheduleFailedFor === primaryServiceId;
803
- const nextAvailableLoading = party ? !haveEveryService && !slotsFailed : primaryServiceId !== null && !(primaryServiceId in seededNextAvailable) && fetchedNextAvailable?.serviceId !== primaryServiceId;
906
+ const haveEveryService = basketVisits.every((key) => key in slots);
907
+ const scheduleSettled = openDays !== null || scheduleKey !== null && scheduleFailedFor === scheduleKey;
908
+ const nextAvailableLoading = party ? !haveEveryService && !slotsFailed : primaryVisit !== null && !(primaryVisit in seededNextAvailable) && fetchedNextAvailable?.key !== primaryVisit;
804
909
  /** The basket's openings, unfiltered — the parallel search needs two stylists. */
805
910
  const basketSlots = useMemo(() => {
806
911
  const out = {};
@@ -836,12 +941,12 @@ function useBooking(options) {
836
941
  state.partyMode
837
942
  ]);
838
943
  const singleSlots = useMemo(() => {
839
- if (party || primaryServiceId === null) return [];
840
- const list = slots[primaryServiceId] ?? [];
944
+ if (party || primaryVisit === null) return [];
945
+ const list = slots[primaryVisit] ?? [];
841
946
  return state.resourceId === null ? [...list] : onlyResource(list, state.resourceId);
842
947
  }, [
843
948
  party,
844
- primaryServiceId,
949
+ primaryVisit,
845
950
  slots,
846
951
  state.resourceId
847
952
  ]);
@@ -917,7 +1022,15 @@ function useBooking(options) {
917
1022
  return;
918
1023
  }
919
1024
  const resourceIds = wizard.itemResourceIds(state);
920
- await send(submission, {
1025
+ const body = withExtraServices(submission, state.items);
1026
+ if (body === null) {
1027
+ dispatch({
1028
+ type: "submitFailed",
1029
+ error: "invalidInput"
1030
+ });
1031
+ return;
1032
+ }
1033
+ await send(body, {
921
1034
  items: state.items,
922
1035
  startTs: state.startTs ?? 0,
923
1036
  partyMode: state.partyMode,
@@ -971,12 +1084,12 @@ function useBooking(options) {
971
1084
  }
972
1085
  if (payload?.error === "slotTaken") {
973
1086
  setTakenSlotTs(submitted.startTs);
974
- const lost = new Set(submitted.items.map((item) => item.service.id));
1087
+ const lost = new Set(submitted.items.map((item) => visitKey(item)));
975
1088
  const fresh = freshSlotsOf(payload?.freshSlots, lost);
976
1089
  setSlots((previous) => {
977
1090
  const next = { ...previous };
978
- for (const serviceId of lost) if (serviceId in fresh) next[serviceId] = fresh[serviceId];
979
- else delete next[serviceId];
1091
+ for (const key of lost) if (key in fresh) next[key] = fresh[key];
1092
+ else delete next[key];
980
1093
  return next;
981
1094
  });
982
1095
  const canSeed = resources.length > 0;
@@ -984,7 +1097,7 @@ function useBooking(options) {
984
1097
  ...Object.fromEntries(Object.entries(previous).filter(([id]) => !lost.has(id))),
985
1098
  ...canSeed ? Object.fromEntries(Object.entries(fresh).map(([id, list]) => [id, firstOpeningPerResource(list)])) : {}
986
1099
  }));
987
- setFetchedNextAvailable((previous) => previous !== null && lost.has(previous.serviceId) ? null : previous);
1100
+ setFetchedNextAvailable((previous) => previous !== null && lost.has(previous.key) ? null : previous);
988
1101
  kit.attempts.clearAttempt();
989
1102
  dispatch({ type: "slotTaken" });
990
1103
  return;
@@ -1076,6 +1189,32 @@ function useBooking(options) {
1076
1189
  resourceServiceIds: chosenStylistServiceIds
1077
1190
  });
1078
1191
  }
1192
+ /**
1193
+ * The multi-select card: person `index` gains `service`, or loses it if they
1194
+ * have it. Never moves the visitor — they say when their list is done with
1195
+ * `continueFromService`. The named stylist's services ride along as for
1196
+ * `pickServiceFor`, so a stylist who cannot do the longer visit is released.
1197
+ */
1198
+ function toggleServiceFor(index, service) {
1199
+ if (deepLinkPending.current) {
1200
+ deepLinkPending.current = false;
1201
+ partyStylist.current = deepLink.stylist;
1202
+ }
1203
+ dispatch({
1204
+ type: "toggleServiceFor",
1205
+ index,
1206
+ service,
1207
+ resourceServiceIds: chosenStylistServiceIds
1208
+ });
1209
+ }
1210
+ /** «Neste» under a multi-select card: on to the hour once everybody has a service. */
1211
+ function continueFromService() {
1212
+ if (state.step !== "service" || !wizard.canAdvance(state)) return;
1213
+ dispatch({
1214
+ type: "goToStep",
1215
+ step: "when"
1216
+ });
1217
+ }
1079
1218
  function pickResource(resourceId) {
1080
1219
  setStylistNotice(null);
1081
1220
  dispatch({
@@ -1180,6 +1319,8 @@ function useBooking(options) {
1180
1319
  },
1181
1320
  pickService,
1182
1321
  pickServiceFor,
1322
+ toggleServiceFor,
1323
+ continueFromService,
1183
1324
  pickResource,
1184
1325
  pickSlot,
1185
1326
  pickPartySlot,