@medalsocial/booking 0.4.0 → 0.4.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.
@@ -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);
@@ -838,6 +865,23 @@ function useBooking(options) {
838
865
  const missingKey = basketVisits.filter((key) => !(key in slots)).join("|");
839
866
  /** Bumped to ask for the openings again after a failure. */
840
867
  const [slotsAttempt, setSlotsAttempt] = useState(0);
868
+ /**
869
+ * The last read that failed, for the monitoring tap (`onEvent`). Every
870
+ * failure here used to be swallowed into a card: the visitor saw «Vi får
871
+ * ikke hentet ledige tider», and nobody else ever learnt that they had.
872
+ */
873
+ const [readErrors, setReadErrors] = useState([]);
874
+ const noteReadError = useCallback((endpoint, error) => {
875
+ setReadErrors((previous) => {
876
+ const seq = (previous[previous.length - 1]?.seq ?? 0) + 1;
877
+ const next = {
878
+ endpoint,
879
+ status: error instanceof ReadFailure ? error.status : null,
880
+ seq
881
+ };
882
+ return [...previous.slice(-19), next];
883
+ });
884
+ }, []);
841
885
  const api = config.paths.api;
842
886
  useEffect(() => {
843
887
  if (missingKey === "") return;
@@ -845,20 +889,19 @@ function useBooking(options) {
845
889
  let cancelled = false;
846
890
  setSlotsFailed(false);
847
891
  (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
- }
892
+ const settled = await Promise.allSettled(wanted.map(async (key) => {
893
+ const response = await fetch(`${api}/availability?${windowQuery(key, fromTs, toTs)}`);
894
+ if (!response.ok) throw new ReadFailure("availability", response.status);
895
+ return [key, (await response.json()).slots ?? []];
896
+ }));
897
+ if (cancelled) return;
898
+ const fetched = settled.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
899
+ if (fetched.length > 0) setSlots((previous) => ({
900
+ ...previous,
901
+ ...Object.fromEntries(fetched)
902
+ }));
903
+ for (const result of settled) if (result.status === "rejected") noteReadError("availability", result.reason);
904
+ if (fetched.length < wanted.length) setSlotsFailed(true);
862
905
  })();
863
906
  return () => {
864
907
  cancelled = true;
@@ -868,7 +911,8 @@ function useBooking(options) {
868
911
  missingKey,
869
912
  fromTs,
870
913
  toTs,
871
- slotsAttempt
914
+ slotsAttempt,
915
+ noteReadError
872
916
  ]);
873
917
  useEffect(() => {
874
918
  if (primaryVisit === null) return;
@@ -877,7 +921,7 @@ function useBooking(options) {
877
921
  (async () => {
878
922
  try {
879
923
  const response = await fetch(`${api}/resources?${windowQuery(primaryVisit, fromTs, toTs)}`);
880
- if (!response.ok) throw new Error(`resources ${response.status}`);
924
+ if (!response.ok) throw new ReadFailure("resources", response.status);
881
925
  const body = await response.json();
882
926
  if (cancelled) return;
883
927
  setResources(body.resources ?? []);
@@ -886,8 +930,9 @@ function useBooking(options) {
886
930
  key: primaryVisit,
887
931
  times: body.nextAvailableTs ?? {}
888
932
  });
889
- } catch {
933
+ } catch (error) {
890
934
  if (!cancelled) {
935
+ noteReadError("resources", error);
891
936
  setResourcesKnown(true);
892
937
  setFetchedNextAvailable({
893
938
  key: primaryVisit,
@@ -904,7 +949,9 @@ function useBooking(options) {
904
949
  primaryVisit,
905
950
  fromTs,
906
951
  toTs,
907
- seededNextAvailable
952
+ seededNextAvailable,
953
+ slotsAttempt,
954
+ noteReadError
908
955
  ]);
909
956
  /**
910
957
  * The stylist half of a «book again» link, once a list for its service has
@@ -999,15 +1046,16 @@ function useBooking(options) {
999
1046
  (async () => {
1000
1047
  try {
1001
1048
  const response = await fetch(`${api}/schedule?${windowQuery(primaryVisit, fromTs, toTs, resourceId)}`);
1002
- if (!response.ok) throw new Error(`schedule ${response.status}`);
1049
+ if (!response.ok) throw new ReadFailure("schedule", response.status);
1003
1050
  const body = await response.json();
1004
1051
  if (cancelled) return;
1005
1052
  setFetchedSchedule({
1006
1053
  key,
1007
1054
  days: body.days ?? []
1008
1055
  });
1009
- } catch {
1056
+ } catch (error) {
1010
1057
  if (!cancelled) {
1058
+ noteReadError("schedule", error);
1011
1059
  setScheduleFailedFor(key);
1012
1060
  setFetchedSchedule((current) => current?.key === key ? null : current);
1013
1061
  }
@@ -1023,8 +1071,33 @@ function useBooking(options) {
1023
1071
  toTs,
1024
1072
  seededSchedule,
1025
1073
  state.resourceId,
1026
- scheduleKey
1074
+ scheduleKey,
1075
+ slotsAttempt,
1076
+ noteReadError
1027
1077
  ]);
1078
+ /**
1079
+ * «Prøv igjen»: ask for the openings, the stylists and the hours again.
1080
+ *
1081
+ * The seed's stylists and hours are answers too, so a retry asks them again
1082
+ * rather than trusting the page's copy of them. A page left open past
1083
+ * midnight also starts a fresh window from now — a retry that resent the
1084
+ * same request would get the same answer, and yesterday's window would
1085
+ * offer hours that are gone and stop a day short — dropping every opening
1086
+ * with it, since they describe the old window.
1087
+ */
1088
+ const refresh = useCallback(() => {
1089
+ const now = Date.now();
1090
+ setSeededNextAvailable({});
1091
+ setSeedRetired(true);
1092
+ if (clock.dayKey(now) !== clock.dayKey(fromTs)) {
1093
+ setSlots({});
1094
+ setResourcesKnown(false);
1095
+ setFetchedNextAvailable(null);
1096
+ setFetchedSchedule(null);
1097
+ setFromTs(now);
1098
+ }
1099
+ setSlotsAttempt((n) => n + 1);
1100
+ }, [clock, fromTs]);
1028
1101
  /** A stylist's display name for an id; `''` from Medal reads as none. */
1029
1102
  const resolveStylistName = useMemo(() => (resourceId) => {
1030
1103
  const name = resources.find((resource) => resource.id === resourceId)?.name;
@@ -1233,7 +1306,7 @@ function useBooking(options) {
1233
1306
  toTs
1234
1307
  }
1235
1308
  }),
1236
- ...sendOptions?.replay === true ? { signal: AbortSignal.timeout(REPLAY_TIMEOUT_MS) } : {}
1309
+ ...sendOptions?.replay === true ? { signal: timeoutSignal(REPLAY_TIMEOUT_MS) } : {}
1237
1310
  });
1238
1311
  const payload = await response.json().catch(() => null);
1239
1312
  if (!response.ok) {
@@ -1559,8 +1632,9 @@ function useBooking(options) {
1559
1632
  submissionNonce,
1560
1633
  confirmed,
1561
1634
  startOver,
1562
- /** Ask for the openings again after a failure. */
1563
- refresh: () => setSlotsAttempt((n) => n + 1),
1635
+ refresh,
1636
+ /** The recent reads that failed, oldest first (`onEvent`'s `read_error`). */
1637
+ readErrors,
1564
1638
  restore: {
1565
1639
  hydrated,
1566
1640
  restoring,