@medalsocial/booking 0.4.0 → 0.5.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 (44) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +1 -0
  3. package/dist/core/config.d.mts +3 -0
  4. package/dist/core/config.mjs +5 -2
  5. package/dist/core/config.mjs.map +1 -1
  6. package/dist/core/draft-store.d.mts +2 -0
  7. package/dist/core/draft-store.mjs +1 -0
  8. package/dist/core/draft-store.mjs.map +1 -1
  9. package/dist/core/index.d.mts +2 -1
  10. package/dist/core/index.mjs +2 -1
  11. package/dist/core/machine.d.mts +16 -0
  12. package/dist/core/machine.mjs +59 -3
  13. package/dist/core/machine.mjs.map +1 -1
  14. package/dist/core/party-slots.d.mts +6 -1
  15. package/dist/core/party-slots.mjs +6 -5
  16. package/dist/core/party-slots.mjs.map +1 -1
  17. package/dist/core/range.d.mts +30 -0
  18. package/dist/core/range.mjs +32 -0
  19. package/dist/core/range.mjs.map +1 -0
  20. package/dist/next/catalogue.mjs +4 -5
  21. package/dist/next/catalogue.mjs.map +1 -1
  22. package/dist/next/handler.mjs +63 -5
  23. package/dist/next/handler.mjs.map +1 -1
  24. package/dist/next/medal.mjs +47 -15
  25. package/dist/next/medal.mjs.map +1 -1
  26. package/dist/next/routes/create.mjs +3 -6
  27. package/dist/next/routes/create.mjs.map +1 -1
  28. package/dist/next/routes/manage.mjs +5 -8
  29. package/dist/next/routes/manage.mjs.map +1 -1
  30. package/dist/next/routes/shared.mjs +57 -14
  31. package/dist/next/routes/shared.mjs.map +1 -1
  32. package/dist/react/BookingWizard.d.mts +7 -1
  33. package/dist/react/BookingWizard.mjs +20 -2
  34. package/dist/react/BookingWizard.mjs.map +1 -1
  35. package/dist/react/index.d.mts +2 -2
  36. package/dist/react/labels/screens.mjs +4 -0
  37. package/dist/react/labels/screens.mjs.map +1 -1
  38. package/dist/react/useBooking.d.mts +11 -1
  39. package/dist/react/useBooking.mjs +152 -31
  40. package/dist/react/useBooking.mjs.map +1 -1
  41. package/dist/react/wizard/adapters.mjs +33 -1
  42. package/dist/react/wizard/adapters.mjs.map +1 -1
  43. package/dist/react/wizard/index.d.mts +2 -2
  44. package/package.json +4 -4
@@ -11,7 +11,7 @@ import { slotKeysFor } from "../core/party-slots.mjs";
11
11
  import { stripVippsReturn, vippsConfirmFrom } from "../core/portal/vipps-return.mjs";
12
12
  import { useBookingKit } from "./Provider.mjs";
13
13
  import { useSearchParams } from "next/navigation";
14
- import { useEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from "react";
14
+ import { useCallback, useEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from "react";
15
15
  //#region src/react/useBooking.ts
16
16
  /**
17
17
  * `useBooking()` — the booking wizard without its markup.
@@ -48,6 +48,30 @@ function draftItem(item, idOnly = false) {
48
48
  }
49
49
  /** How long a replayed submission may take before the restore gate gives up. */
50
50
  const REPLAY_TIMEOUT_MS = 15e3;
51
+ /**
52
+ * `AbortSignal.timeout` where the browser has it, a timer-driven controller
53
+ * where it does not. Safari only gained it in 16, and a booking resumed on an
54
+ * older iPhone threw on the call and showed «we do not know yet» instead of
55
+ * asking.
56
+ */
57
+ function timeoutSignal(ms) {
58
+ if (typeof AbortSignal.timeout === "function") return AbortSignal.timeout(ms);
59
+ const controller = new AbortController();
60
+ setTimeout(controller.abort.bind(controller), ms);
61
+ return controller.signal;
62
+ }
63
+ /** A non-2xx answer, keeping the status a monitoring tap reports. */
64
+ var ReadFailure = class extends Error {
65
+ endpoint;
66
+ status;
67
+ constructor(endpoint, status) {
68
+ super(`${endpoint} ${status}`);
69
+ this.endpoint = endpoint;
70
+ this.status = status;
71
+ }
72
+ };
73
+ /** The seed's schedules once a retry has moved the window past them. */
74
+ const NO_SEEDED_SCHEDULE = {};
51
75
  /** «hydrated» only ever flips once; `useSyncExternalStore` wants a subscription. */
52
76
  function noSubscription() {
53
77
  return () => {};
@@ -412,7 +436,7 @@ function useBooking(options) {
412
436
  /** A stylist a party link named, applied once the family reaches step 3. */
413
437
  const partyStylist = useRef(arrivedAs !== null ? prefill?.resourceId ?? deepLink.stylist : null);
414
438
  const childByKey = useMemo(() => new Map((family ?? []).map((child, index) => [personForChild(child, index).key, child])), [family]);
415
- const [fromTs] = useState(() => options.seed.fromTs ?? Date.now());
439
+ const [fromTs, setFromTs] = useState(() => options.seed.fromTs ?? Date.now());
416
440
  const { toTs, days } = useMemo(() => clock.window(fromTs, rangeDays), [
417
441
  clock,
418
442
  fromTs,
@@ -458,7 +482,10 @@ function useBooking(options) {
458
482
  /** Whether the stylist list has been answered at all — seeded, fetched or failed. */
459
483
  const [resourcesKnown, setResourcesKnown] = useState(() => options.seed.resources != null);
460
484
  const [seededNextAvailable, setSeededNextAvailable] = useState(() => options.seed.resources ? options.seed.nextAvailable ?? {} : {});
461
- const [seededSchedule] = useState(() => options.seed.schedules ?? {});
485
+ const [initialSchedule] = useState(() => options.seed.schedules ?? {});
486
+ /** Set once a retry has moved the window past the seed's day; the seed no longer answers. */
487
+ const [seedRetired, setSeedRetired] = useState(false);
488
+ const seededSchedule = seedRetired ? NO_SEEDED_SCHEDULE : initialSchedule;
462
489
  /** The last «next available» answer, and for WHICH visit (its key). */
463
490
  const [fetchedNextAvailable, setFetchedNextAvailable] = useState(null);
464
491
  const [submitting, setSubmitting] = useState(false);
@@ -646,6 +673,13 @@ function useBooking(options) {
646
673
  type: "pickResource",
647
674
  resourceId: draft.resourceId
648
675
  });
676
+ draft.seatResourceIds?.forEach((resourceId, index) => {
677
+ if (resourceId !== null) dispatch({
678
+ type: "pickSeatResource",
679
+ index,
680
+ resourceId
681
+ });
682
+ });
649
683
  draft.items.forEach((item, index) => {
650
684
  if (reseated.has(index)) return;
651
685
  if (item.bookedForName !== null) dispatch({
@@ -736,6 +770,7 @@ function useBooking(options) {
736
770
  resourceId: state.resourceId,
737
771
  partyMode: state.partyMode,
738
772
  partyModeChosen: state.partyModeChosen === true,
773
+ ...state.seatResourceIds ? { seatResourceIds: state.people.map((p) => state.seatResourceIds?.[p.key] ?? null) } : {},
739
774
  startTs: state.startTs,
740
775
  resolvedResourceId: state.resolvedResourceId,
741
776
  partyResourceIds: state.partyResourceIds
@@ -749,6 +784,7 @@ function useBooking(options) {
749
784
  state.resourceId,
750
785
  state.partyMode,
751
786
  state.partyModeChosen,
787
+ state.seatResourceIds,
752
788
  state.startTs,
753
789
  state.resolvedResourceId,
754
790
  state.partyResourceIds
@@ -838,6 +874,23 @@ function useBooking(options) {
838
874
  const missingKey = basketVisits.filter((key) => !(key in slots)).join("|");
839
875
  /** Bumped to ask for the openings again after a failure. */
840
876
  const [slotsAttempt, setSlotsAttempt] = useState(0);
877
+ /**
878
+ * The last read that failed, for the monitoring tap (`onEvent`). Every
879
+ * failure here used to be swallowed into a card: the visitor saw «Vi får
880
+ * ikke hentet ledige tider», and nobody else ever learnt that they had.
881
+ */
882
+ const [readErrors, setReadErrors] = useState([]);
883
+ const noteReadError = useCallback((endpoint, error) => {
884
+ setReadErrors((previous) => {
885
+ const seq = (previous[previous.length - 1]?.seq ?? 0) + 1;
886
+ const next = {
887
+ endpoint,
888
+ status: error instanceof ReadFailure ? error.status : null,
889
+ seq
890
+ };
891
+ return [...previous.slice(-19), next];
892
+ });
893
+ }, []);
841
894
  const api = config.paths.api;
842
895
  useEffect(() => {
843
896
  if (missingKey === "") return;
@@ -845,20 +898,19 @@ function useBooking(options) {
845
898
  let cancelled = false;
846
899
  setSlotsFailed(false);
847
900
  (async () => {
848
- try {
849
- const fetched = await Promise.all(wanted.map(async (key) => {
850
- const response = await fetch(`${api}/availability?${windowQuery(key, fromTs, toTs)}`);
851
- if (!response.ok) throw new Error(`availability ${response.status}`);
852
- return [key, (await response.json()).slots ?? []];
853
- }));
854
- if (cancelled) return;
855
- setSlots((previous) => ({
856
- ...previous,
857
- ...Object.fromEntries(fetched)
858
- }));
859
- } catch {
860
- if (!cancelled) setSlotsFailed(true);
861
- }
901
+ const settled = await Promise.allSettled(wanted.map(async (key) => {
902
+ const response = await fetch(`${api}/availability?${windowQuery(key, fromTs, toTs)}`);
903
+ if (!response.ok) throw new ReadFailure("availability", response.status);
904
+ return [key, (await response.json()).slots ?? []];
905
+ }));
906
+ if (cancelled) return;
907
+ const fetched = settled.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
908
+ if (fetched.length > 0) setSlots((previous) => ({
909
+ ...previous,
910
+ ...Object.fromEntries(fetched)
911
+ }));
912
+ for (const result of settled) if (result.status === "rejected") noteReadError("availability", result.reason);
913
+ if (fetched.length < wanted.length) setSlotsFailed(true);
862
914
  })();
863
915
  return () => {
864
916
  cancelled = true;
@@ -868,7 +920,8 @@ function useBooking(options) {
868
920
  missingKey,
869
921
  fromTs,
870
922
  toTs,
871
- slotsAttempt
923
+ slotsAttempt,
924
+ noteReadError
872
925
  ]);
873
926
  useEffect(() => {
874
927
  if (primaryVisit === null) return;
@@ -877,7 +930,7 @@ function useBooking(options) {
877
930
  (async () => {
878
931
  try {
879
932
  const response = await fetch(`${api}/resources?${windowQuery(primaryVisit, fromTs, toTs)}`);
880
- if (!response.ok) throw new Error(`resources ${response.status}`);
933
+ if (!response.ok) throw new ReadFailure("resources", response.status);
881
934
  const body = await response.json();
882
935
  if (cancelled) return;
883
936
  setResources(body.resources ?? []);
@@ -886,8 +939,9 @@ function useBooking(options) {
886
939
  key: primaryVisit,
887
940
  times: body.nextAvailableTs ?? {}
888
941
  });
889
- } catch {
942
+ } catch (error) {
890
943
  if (!cancelled) {
944
+ noteReadError("resources", error);
891
945
  setResourcesKnown(true);
892
946
  setFetchedNextAvailable({
893
947
  key: primaryVisit,
@@ -904,7 +958,9 @@ function useBooking(options) {
904
958
  primaryVisit,
905
959
  fromTs,
906
960
  toTs,
907
- seededNextAvailable
961
+ seededNextAvailable,
962
+ slotsAttempt,
963
+ noteReadError
908
964
  ]);
909
965
  /**
910
966
  * The stylist half of a «book again» link, once a list for its service has
@@ -999,15 +1055,16 @@ function useBooking(options) {
999
1055
  (async () => {
1000
1056
  try {
1001
1057
  const response = await fetch(`${api}/schedule?${windowQuery(primaryVisit, fromTs, toTs, resourceId)}`);
1002
- if (!response.ok) throw new Error(`schedule ${response.status}`);
1058
+ if (!response.ok) throw new ReadFailure("schedule", response.status);
1003
1059
  const body = await response.json();
1004
1060
  if (cancelled) return;
1005
1061
  setFetchedSchedule({
1006
1062
  key,
1007
1063
  days: body.days ?? []
1008
1064
  });
1009
- } catch {
1065
+ } catch (error) {
1010
1066
  if (!cancelled) {
1067
+ noteReadError("schedule", error);
1011
1068
  setScheduleFailedFor(key);
1012
1069
  setFetchedSchedule((current) => current?.key === key ? null : current);
1013
1070
  }
@@ -1023,8 +1080,33 @@ function useBooking(options) {
1023
1080
  toTs,
1024
1081
  seededSchedule,
1025
1082
  state.resourceId,
1026
- scheduleKey
1083
+ scheduleKey,
1084
+ slotsAttempt,
1085
+ noteReadError
1027
1086
  ]);
1087
+ /**
1088
+ * «Prøv igjen»: ask for the openings, the stylists and the hours again.
1089
+ *
1090
+ * The seed's stylists and hours are answers too, so a retry asks them again
1091
+ * rather than trusting the page's copy of them. A page left open past
1092
+ * midnight also starts a fresh window from now — a retry that resent the
1093
+ * same request would get the same answer, and yesterday's window would
1094
+ * offer hours that are gone and stop a day short — dropping every opening
1095
+ * with it, since they describe the old window.
1096
+ */
1097
+ const refresh = useCallback(() => {
1098
+ const now = Date.now();
1099
+ setSeededNextAvailable({});
1100
+ setSeedRetired(true);
1101
+ if (clock.dayKey(now) !== clock.dayKey(fromTs)) {
1102
+ setSlots({});
1103
+ setResourcesKnown(false);
1104
+ setFetchedNextAvailable(null);
1105
+ setFetchedSchedule(null);
1106
+ setFromTs(now);
1107
+ }
1108
+ setSlotsAttempt((n) => n + 1);
1109
+ }, [clock, fromTs]);
1028
1110
  /** A stylist's display name for an id; `''` from Medal reads as none. */
1029
1111
  const resolveStylistName = useMemo(() => (resourceId) => {
1030
1112
  const name = resources.find((resource) => resource.id === resourceId)?.name;
@@ -1056,6 +1138,40 @@ function useBooking(options) {
1056
1138
  state.resourceId,
1057
1139
  state.items
1058
1140
  ]);
1141
+ /**
1142
+ * The same for «who cuts whom»: a person's own stylist who has left the
1143
+ * roster, or no longer does that person's visit (a draft restored after a
1144
+ * Vipps round trip), would leave the seat with nobody to seat it and the
1145
+ * family with no times at all. Released to anyone, and said so.
1146
+ */
1147
+ useEffect(() => {
1148
+ if (!resourcesKnown || resources.length === 0 || state.step !== "when") return;
1149
+ const wishes = state.seatResourceIds;
1150
+ if (wishes === void 0) return;
1151
+ let released = false;
1152
+ state.people.forEach((person, index) => {
1153
+ const wish = wishes[person.key];
1154
+ const item = state.items[index];
1155
+ if (wish === void 0 || item === void 0) return;
1156
+ const chosen = resources.find((resource) => resource.id === wish);
1157
+ if (chosen !== void 0 && visitServicesOf(item).every((service) => chosen.serviceIds.includes(service.id))) return;
1158
+ dispatch({
1159
+ type: "pickSeatResource",
1160
+ index,
1161
+ resourceId: null
1162
+ });
1163
+ released = true;
1164
+ });
1165
+ if (released) setStylistNotice(labelText(labels["wizard.stylistGone"]));
1166
+ }, [
1167
+ labels,
1168
+ resourcesKnown,
1169
+ resources,
1170
+ state.step,
1171
+ state.seatResourceIds,
1172
+ state.people,
1173
+ state.items
1174
+ ]);
1059
1175
  /** The open dates with the cutoffs pulled back to fit the WHOLE visit (the safe direction). */
1060
1176
  const openDaysForVisit = useMemo(() => {
1061
1177
  if (openDays === null) return null;
@@ -1094,20 +1210,24 @@ function useBooking(options) {
1094
1210
  state.items,
1095
1211
  basketSlots
1096
1212
  ]);
1097
- const partySlots = useMemo(() => party ? kit.partySlots.findPartySlots(state.items, preferredSlots, state.partyMode) : [], [
1213
+ /** Each line's own stylist, in basket order, for a party seen at once. */
1214
+ const seatResourceIds = useMemo(() => state.people.map((person) => state.seatResourceIds?.[person.key] ?? null), [state.people, state.seatResourceIds]);
1215
+ const partySlots = useMemo(() => party ? kit.partySlots.findPartySlots(state.items, preferredSlots, state.partyMode, seatResourceIds) : [], [
1098
1216
  kit,
1099
1217
  party,
1100
1218
  state.items,
1101
1219
  preferredSlots,
1102
- state.partyMode
1220
+ state.partyMode,
1221
+ seatResourceIds
1103
1222
  ]);
1104
1223
  /** The parallel visits, for the day with nothing back to back — over UNFILTERED slots. */
1105
- const partyAlternatives = useMemo(() => party && state.partyMode === "sequential" ? kit.partySlots.findPartySlots(state.items, basketSlots, "parallel") : [], [
1224
+ const partyAlternatives = useMemo(() => party && state.partyMode === "sequential" ? kit.partySlots.findPartySlots(state.items, basketSlots, "parallel", seatResourceIds) : [], [
1106
1225
  kit,
1107
1226
  party,
1108
1227
  state.items,
1109
1228
  basketSlots,
1110
- state.partyMode
1229
+ state.partyMode,
1230
+ seatResourceIds
1111
1231
  ]);
1112
1232
  const singleSlots = useMemo(() => {
1113
1233
  if (party || primaryVisit === null) return [];
@@ -1233,7 +1353,7 @@ function useBooking(options) {
1233
1353
  toTs
1234
1354
  }
1235
1355
  }),
1236
- ...sendOptions?.replay === true ? { signal: AbortSignal.timeout(REPLAY_TIMEOUT_MS) } : {}
1356
+ ...sendOptions?.replay === true ? { signal: timeoutSignal(REPLAY_TIMEOUT_MS) } : {}
1237
1357
  });
1238
1358
  const payload = await response.json().catch(() => null);
1239
1359
  if (!response.ok) {
@@ -1559,8 +1679,9 @@ function useBooking(options) {
1559
1679
  submissionNonce,
1560
1680
  confirmed,
1561
1681
  startOver,
1562
- /** Ask for the openings again after a failure. */
1563
- refresh: () => setSlotsAttempt((n) => n + 1),
1682
+ refresh,
1683
+ /** The recent reads that failed, oldest first (`onEvent`'s `read_error`). */
1684
+ readErrors,
1564
1685
  restore: {
1565
1686
  hydrated,
1566
1687
  restoring,