@medalsocial/booking 0.1.0 → 0.2.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 (48) hide show
  1. package/CHANGELOG.md +13 -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 +176 -50
  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,51 @@ 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) {
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(",") },
44
54
  from_ts: String(fromTs),
45
55
  to_ts: String(toTs)
46
56
  });
47
57
  }
58
+ /**
59
+ * The submission with each line's extras on it. meda's details screen builds
60
+ * the body from its own types, which have one service per line, so the shell
61
+ * is the only place that knows the rest of a person's visit. Index-aligned with
62
+ * the basket the screen was drawn from; a body of another length is not one
63
+ * this basket produced, and is sent as it came rather than with extras pinned
64
+ * to the wrong people.
65
+ */
66
+ function withExtraServices(submission, items) {
67
+ if (!items.some((item) => (item.extraServices ?? []).length > 0)) return submission;
68
+ if (submission.items.length !== items.length) {
69
+ console.warn("[@medalsocial/booking] The submission has %d lines for a basket of %d; not sent.", submission.items.length, items.length);
70
+ return null;
71
+ }
72
+ return {
73
+ ...submission,
74
+ items: submission.items.map((line, index) => {
75
+ const extras = items[index].extraServices ?? [];
76
+ return extras.length === 0 ? line : {
77
+ ...line,
78
+ extraServiceIds: extras.map((service) => service.id)
79
+ };
80
+ })
81
+ };
82
+ }
48
83
  /** What the create route said, narrowed to what the machine can hold. */
49
84
  function asSubmitFailure(code) {
50
85
  switch (code) {
@@ -108,8 +143,8 @@ function stylistLeftToFill(prefill) {
108
143
  return prefill?.serviceId && prefill.resourceId ? prefill : null;
109
144
  }
110
145
  /** 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;
146
+ function canFinishStylist(pending, resources, primaryVisit) {
147
+ return pending !== null && resources.length > 0 && primaryVisit === pending.serviceId;
113
148
  }
114
149
  /**
115
150
  * A saved child as the person the machine seats: by `personId` when Medal has
@@ -278,7 +313,12 @@ function useBooking(options) {
278
313
  };
279
314
  };
280
315
  const pendingStylist = useRef(stylistLeftToFill(prefill));
281
- /** Unfiltered openings, keyed by service id. */
316
+ /**
317
+ * Unfiltered openings, keyed by VISIT (`visitKey`): a service id for a
318
+ * one-service visit — which is all the server seed ever holds — and `a+b`
319
+ * for a person having both, whose list is the server's answer for the whole
320
+ * visit rather than either service's own.
321
+ */
282
322
  const [slots, setSlots] = useState(() => options.seed.slots ?? {});
283
323
  const [slotsFailed, setSlotsFailed] = useState(false);
284
324
  const [resources, setResources] = useState(() => options.seed.resources ?? []);
@@ -286,7 +326,7 @@ function useBooking(options) {
286
326
  const [resourcesKnown, setResourcesKnown] = useState(() => options.seed.resources != null);
287
327
  const [seededNextAvailable, setSeededNextAvailable] = useState(() => options.seed.resources ? options.seed.nextAvailable ?? {} : {});
288
328
  const [seededSchedule] = useState(() => options.seed.schedules ?? {});
289
- /** The last «next available» answer, and for WHICH service. */
329
+ /** The last «next available» answer, and for WHICH visit (its key). */
290
330
  const [fetchedNextAvailable, setFetchedNextAvailable] = useState(null);
291
331
  const [submitting, setSubmitting] = useState(false);
292
332
  /**
@@ -400,6 +440,37 @@ function useBooking(options) {
400
440
  return reseated;
401
441
  };
402
442
  /**
443
+ * Each draft line's extras back on it, through `toggleServiceFor` like a tap.
444
+ * Returns whether EVERY extra came back. The machine's refusals are checked
445
+ * here first rather than read off its state, which a dispatch does not hand
446
+ * back: an id the catalogue no longer books, one already on the line (a
447
+ * toggle would take it OFF again), one past `maxServicesPerPerson`, or one
448
+ * more taker than the service's `maxPerBooking` — each is skipped, as an
449
+ * unknown main service is, and makes the answer `false`.
450
+ */
451
+ const restoreExtras = (draft, lines, bookable) => {
452
+ let whole = true;
453
+ const lists = lines.map((service) => [service]);
454
+ draft.items.forEach((item, index) => {
455
+ for (const id of item.extraServiceIds ?? []) {
456
+ const service = bookable.get(id);
457
+ const list = lists[index];
458
+ const takers = lists.filter((other) => other.some((entry) => entry.id === id)).length;
459
+ if (service === void 0 || list.some((entry) => entry.id === id) || list.length >= config.party.maxServicesPerPerson || takers + 1 > service.maxPerBooking) {
460
+ whole = false;
461
+ continue;
462
+ }
463
+ list.push(service);
464
+ dispatch({
465
+ type: "toggleServiceFor",
466
+ index,
467
+ service
468
+ });
469
+ }
470
+ });
471
+ return whole;
472
+ };
473
+ /**
403
474
  * The booking a Vipps login interrupted, rebuilt through the machine's own
404
475
  * actions — so it obeys every rule a tapped one does — and trusting nothing
405
476
  * beyond the catalogue.
@@ -426,6 +497,7 @@ function useBooking(options) {
426
497
  service
427
498
  });
428
499
  const reseated = reseatFamily(draft, resolved);
500
+ const wholeVisits = restoreExtras(draft, resolved, bookable);
429
501
  if (draft.partyMode === "parallel") dispatch({
430
502
  type: "setPartyMode",
431
503
  mode: "parallel"
@@ -449,7 +521,7 @@ function useBooking(options) {
449
521
  value: item.bookedForBirthYear
450
522
  });
451
523
  });
452
- if (draft.startTs === null) return;
524
+ if (draft.startTs === null || !wholeVisits) return;
453
525
  if (draft.partyResourceIds !== null && draft.partyResourceIds.length === resolved.length) {
454
526
  restoredStylists.current = draft.partyResourceIds;
455
527
  dispatch({
@@ -514,6 +586,7 @@ function useBooking(options) {
514
586
  kit.drafts.stashDraft({
515
587
  items: state.items.map((item) => ({
516
588
  serviceId: item.service.id,
589
+ ...item.extraServices?.length ? { extraServiceIds: item.extraServices.map((service) => service.id) } : {},
517
590
  bookedForName: item.bookedForName ?? null,
518
591
  bookedForBirthYear: item.bookedForBirthYear ?? null,
519
592
  adult: item.adult === true,
@@ -575,20 +648,28 @@ function useBooking(options) {
575
648
  });
576
649
  }
577
650
  }, [guardian, phone]);
578
- const basketServiceIds = useMemo(() => [...new Set(state.items.map((item) => item.service.id))], [state.items]);
579
- const basketKey = basketServiceIds.join("|");
651
+ const basketVisits = useMemo(() => slotKeysFor(state.items), [state.items]);
652
+ const basketKey = basketVisits.join("|");
580
653
  const primaryServiceId = state.items[0]?.service.id ?? null;
654
+ /**
655
+ * The first person's whole visit, by key — what the stylists' «neste ledige»
656
+ * and the business's hours are asked for. Equal to `primaryServiceId` for a
657
+ * one-service visit, so the seed (keyed by service id, and only ever for one
658
+ * service) answers exactly the visits it can, and a visit with extras is
659
+ * fetched instead of being read off its first service's openings.
660
+ */
661
+ const primaryVisit = state.items[0] ? visitKey(state.items[0]) : null;
581
662
  const nextAvailableTs = useMemo(() => {
582
- if (primaryServiceId === null) return {};
583
- const seeded = seededNextAvailable[primaryServiceId];
663
+ if (primaryVisit === null) return {};
664
+ const seeded = seededNextAvailable[primaryVisit];
584
665
  if (seeded) return seeded;
585
- return fetchedNextAvailable?.serviceId === primaryServiceId ? fetchedNextAvailable.times : {};
666
+ return fetchedNextAvailable?.key === primaryVisit ? fetchedNextAvailable.times : {};
586
667
  }, [
587
- primaryServiceId,
668
+ primaryVisit,
588
669
  seededNextAvailable,
589
670
  fetchedNextAvailable
590
671
  ]);
591
- const missingKey = basketServiceIds.filter((id) => !(id in slots)).join("|");
672
+ const missingKey = basketVisits.filter((key) => !(key in slots)).join("|");
592
673
  /** Bumped to ask for the openings again after a failure. */
593
674
  const [slotsAttempt, setSlotsAttempt] = useState(0);
594
675
  const api = config.paths.api;
@@ -599,10 +680,10 @@ function useBooking(options) {
599
680
  setSlotsFailed(false);
600
681
  (async () => {
601
682
  try {
602
- const fetched = await Promise.all(wanted.map(async (serviceId) => {
603
- const response = await fetch(`${api}/availability?${windowQuery(serviceId, fromTs, toTs)}`);
683
+ const fetched = await Promise.all(wanted.map(async (key) => {
684
+ const response = await fetch(`${api}/availability?${windowQuery(key, fromTs, toTs)}`);
604
685
  if (!response.ok) throw new Error(`availability ${response.status}`);
605
- return [serviceId, (await response.json()).slots ?? []];
686
+ return [key, (await response.json()).slots ?? []];
606
687
  }));
607
688
  if (cancelled) return;
608
689
  setSlots((previous) => ({
@@ -624,26 +705,26 @@ function useBooking(options) {
624
705
  slotsAttempt
625
706
  ]);
626
707
  useEffect(() => {
627
- if (primaryServiceId === null) return;
628
- if (primaryServiceId in seededNextAvailable) return;
708
+ if (primaryVisit === null) return;
709
+ if (primaryVisit in seededNextAvailable) return;
629
710
  let cancelled = false;
630
711
  (async () => {
631
712
  try {
632
- const response = await fetch(`${api}/resources?${windowQuery(primaryServiceId, fromTs, toTs)}`);
713
+ const response = await fetch(`${api}/resources?${windowQuery(primaryVisit, fromTs, toTs)}`);
633
714
  if (!response.ok) throw new Error(`resources ${response.status}`);
634
715
  const body = await response.json();
635
716
  if (cancelled) return;
636
717
  setResources(body.resources ?? []);
637
718
  setResourcesKnown(true);
638
719
  setFetchedNextAvailable({
639
- serviceId: primaryServiceId,
720
+ key: primaryVisit,
640
721
  times: body.nextAvailableTs ?? {}
641
722
  });
642
723
  } catch {
643
724
  if (!cancelled) {
644
725
  setResourcesKnown(true);
645
726
  setFetchedNextAvailable({
646
- serviceId: primaryServiceId,
727
+ key: primaryVisit,
647
728
  times: {}
648
729
  });
649
730
  }
@@ -654,19 +735,24 @@ function useBooking(options) {
654
735
  };
655
736
  }, [
656
737
  api,
657
- primaryServiceId,
738
+ primaryVisit,
658
739
  fromTs,
659
740
  toTs,
660
741
  seededNextAvailable
661
742
  ]);
662
- /** The stylist half of a «book again» link, once a list for its service has landed. */
743
+ /**
744
+ * The stylist half of a «book again» link, once a list for its service has
745
+ * landed. Compared by VISIT: the link named one service, and a visitor who
746
+ * has since added a wash to it is asking a different question — the list now
747
+ * in hand answers the longer visit, not the one the link vouched for.
748
+ */
663
749
  useEffect(() => {
664
750
  const pending = pendingStylist.current;
665
- if (pending && primaryServiceId !== null && primaryServiceId !== pending.serviceId) {
751
+ if (pending && primaryVisit !== null && primaryVisit !== pending.serviceId) {
666
752
  pendingStylist.current = null;
667
753
  return;
668
754
  }
669
- if (!canFinishStylist(pending, resources, primaryServiceId)) return;
755
+ if (!canFinishStylist(pending, resources, primaryVisit)) return;
670
756
  pendingStylist.current = null;
671
757
  dispatch({
672
758
  type: "prefill",
@@ -675,7 +761,7 @@ function useBooking(options) {
675
761
  });
676
762
  }, [
677
763
  resources,
678
- primaryServiceId,
764
+ primaryVisit,
679
765
  services
680
766
  ]);
681
767
  /** A party link's stylist, once the family has its services and is on step 3. */
@@ -710,35 +796,39 @@ function useBooking(options) {
710
796
  resourceId: null
711
797
  });
712
798
  }, [resources, primaryServiceId]);
713
- /** The business's open dates over the window, for ONE service (cutoffs are per service). */
799
+ /**
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).
803
+ */
714
804
  const [fetchedSchedule, setFetchedSchedule] = useState(null);
715
805
  const [scheduleFailedFor, setScheduleFailedFor] = useState(null);
716
806
  const openDays = useMemo(() => {
717
- if (primaryServiceId === null) return null;
718
- const seeded = seededSchedule[primaryServiceId];
807
+ if (primaryVisit === null) return null;
808
+ const seeded = seededSchedule[primaryVisit];
719
809
  if (seeded) return seeded;
720
- return fetchedSchedule?.serviceId === primaryServiceId ? fetchedSchedule.days : null;
810
+ return fetchedSchedule?.key === primaryVisit ? fetchedSchedule.days : null;
721
811
  }, [
722
- primaryServiceId,
812
+ primaryVisit,
723
813
  seededSchedule,
724
814
  fetchedSchedule
725
815
  ]);
726
816
  useEffect(() => {
727
- if (primaryServiceId === null) return;
728
- if (primaryServiceId in seededSchedule) return;
817
+ if (primaryVisit === null) return;
818
+ if (primaryVisit in seededSchedule) return;
729
819
  let cancelled = false;
730
820
  (async () => {
731
821
  try {
732
- const response = await fetch(`${api}/schedule?${windowQuery(primaryServiceId, fromTs, toTs)}`);
822
+ const response = await fetch(`${api}/schedule?${windowQuery(primaryVisit, fromTs, toTs)}`);
733
823
  if (!response.ok) throw new Error(`schedule ${response.status}`);
734
824
  const body = await response.json();
735
825
  if (cancelled) return;
736
826
  setFetchedSchedule({
737
- serviceId: primaryServiceId,
827
+ key: primaryVisit,
738
828
  days: body.days ?? []
739
829
  });
740
830
  } catch {
741
- if (!cancelled) setScheduleFailedFor(primaryServiceId);
831
+ if (!cancelled) setScheduleFailedFor(primaryVisit);
742
832
  }
743
833
  })();
744
834
  return () => {
@@ -746,7 +836,7 @@ function useBooking(options) {
746
836
  };
747
837
  }, [
748
838
  api,
749
- primaryServiceId,
839
+ primaryVisit,
750
840
  fromTs,
751
841
  toTs,
752
842
  seededSchedule
@@ -768,7 +858,7 @@ function useBooking(options) {
768
858
  if (!resourcesKnown || resources.length === 0) return;
769
859
  if (state.step !== "when" || state.resourceId === null) return;
770
860
  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;
861
+ if (chosen !== void 0 && state.items.every((item) => visitServicesOf(item).every((service) => chosen.serviceIds.includes(service.id)))) return;
772
862
  dispatch({
773
863
  type: "pickResource",
774
864
  resourceId: null
@@ -798,9 +888,9 @@ function useBooking(options) {
798
888
  state.partyMode
799
889
  ]);
800
890
  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;
891
+ const haveEveryService = basketVisits.every((key) => key in slots);
892
+ const scheduleSettled = openDays !== null || primaryVisit !== null && scheduleFailedFor === primaryVisit;
893
+ const nextAvailableLoading = party ? !haveEveryService && !slotsFailed : primaryVisit !== null && !(primaryVisit in seededNextAvailable) && fetchedNextAvailable?.key !== primaryVisit;
804
894
  /** The basket's openings, unfiltered — the parallel search needs two stylists. */
805
895
  const basketSlots = useMemo(() => {
806
896
  const out = {};
@@ -836,12 +926,12 @@ function useBooking(options) {
836
926
  state.partyMode
837
927
  ]);
838
928
  const singleSlots = useMemo(() => {
839
- if (party || primaryServiceId === null) return [];
840
- const list = slots[primaryServiceId] ?? [];
929
+ if (party || primaryVisit === null) return [];
930
+ const list = slots[primaryVisit] ?? [];
841
931
  return state.resourceId === null ? [...list] : onlyResource(list, state.resourceId);
842
932
  }, [
843
933
  party,
844
- primaryServiceId,
934
+ primaryVisit,
845
935
  slots,
846
936
  state.resourceId
847
937
  ]);
@@ -917,7 +1007,15 @@ function useBooking(options) {
917
1007
  return;
918
1008
  }
919
1009
  const resourceIds = wizard.itemResourceIds(state);
920
- await send(submission, {
1010
+ const body = withExtraServices(submission, state.items);
1011
+ if (body === null) {
1012
+ dispatch({
1013
+ type: "submitFailed",
1014
+ error: "invalidInput"
1015
+ });
1016
+ return;
1017
+ }
1018
+ await send(body, {
921
1019
  items: state.items,
922
1020
  startTs: state.startTs ?? 0,
923
1021
  partyMode: state.partyMode,
@@ -971,12 +1069,12 @@ function useBooking(options) {
971
1069
  }
972
1070
  if (payload?.error === "slotTaken") {
973
1071
  setTakenSlotTs(submitted.startTs);
974
- const lost = new Set(submitted.items.map((item) => item.service.id));
1072
+ const lost = new Set(submitted.items.map((item) => visitKey(item)));
975
1073
  const fresh = freshSlotsOf(payload?.freshSlots, lost);
976
1074
  setSlots((previous) => {
977
1075
  const next = { ...previous };
978
- for (const serviceId of lost) if (serviceId in fresh) next[serviceId] = fresh[serviceId];
979
- else delete next[serviceId];
1076
+ for (const key of lost) if (key in fresh) next[key] = fresh[key];
1077
+ else delete next[key];
980
1078
  return next;
981
1079
  });
982
1080
  const canSeed = resources.length > 0;
@@ -984,7 +1082,7 @@ function useBooking(options) {
984
1082
  ...Object.fromEntries(Object.entries(previous).filter(([id]) => !lost.has(id))),
985
1083
  ...canSeed ? Object.fromEntries(Object.entries(fresh).map(([id, list]) => [id, firstOpeningPerResource(list)])) : {}
986
1084
  }));
987
- setFetchedNextAvailable((previous) => previous !== null && lost.has(previous.serviceId) ? null : previous);
1085
+ setFetchedNextAvailable((previous) => previous !== null && lost.has(previous.key) ? null : previous);
988
1086
  kit.attempts.clearAttempt();
989
1087
  dispatch({ type: "slotTaken" });
990
1088
  return;
@@ -1076,6 +1174,32 @@ function useBooking(options) {
1076
1174
  resourceServiceIds: chosenStylistServiceIds
1077
1175
  });
1078
1176
  }
1177
+ /**
1178
+ * The multi-select card: person `index` gains `service`, or loses it if they
1179
+ * have it. Never moves the visitor — they say when their list is done with
1180
+ * `continueFromService`. The named stylist's services ride along as for
1181
+ * `pickServiceFor`, so a stylist who cannot do the longer visit is released.
1182
+ */
1183
+ function toggleServiceFor(index, service) {
1184
+ if (deepLinkPending.current) {
1185
+ deepLinkPending.current = false;
1186
+ partyStylist.current = deepLink.stylist;
1187
+ }
1188
+ dispatch({
1189
+ type: "toggleServiceFor",
1190
+ index,
1191
+ service,
1192
+ resourceServiceIds: chosenStylistServiceIds
1193
+ });
1194
+ }
1195
+ /** «Neste» under a multi-select card: on to the hour once everybody has a service. */
1196
+ function continueFromService() {
1197
+ if (state.step !== "service" || !wizard.canAdvance(state)) return;
1198
+ dispatch({
1199
+ type: "goToStep",
1200
+ step: "when"
1201
+ });
1202
+ }
1079
1203
  function pickResource(resourceId) {
1080
1204
  setStylistNotice(null);
1081
1205
  dispatch({
@@ -1180,6 +1304,8 @@ function useBooking(options) {
1180
1304
  },
1181
1305
  pickService,
1182
1306
  pickServiceFor,
1307
+ toggleServiceFor,
1308
+ continueFromService,
1183
1309
  pickResource,
1184
1310
  pickSlot,
1185
1311
  pickPartySlot,