@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.
- package/CHANGELOG.md +20 -0
- package/dist/core/config.mjs +1 -0
- package/dist/core/config.mjs.map +1 -1
- package/dist/core/index.d.mts +2 -1
- package/dist/core/index.mjs +2 -1
- package/dist/core/range.d.mts +30 -0
- package/dist/core/range.mjs +32 -0
- package/dist/core/range.mjs.map +1 -0
- package/dist/next/catalogue.mjs +4 -5
- package/dist/next/catalogue.mjs.map +1 -1
- package/dist/next/handler.mjs +63 -5
- package/dist/next/handler.mjs.map +1 -1
- package/dist/next/medal.mjs +47 -15
- package/dist/next/medal.mjs.map +1 -1
- package/dist/next/routes/create.mjs +3 -6
- package/dist/next/routes/create.mjs.map +1 -1
- package/dist/next/routes/manage.mjs +5 -8
- package/dist/next/routes/manage.mjs.map +1 -1
- package/dist/next/routes/shared.mjs +57 -14
- package/dist/next/routes/shared.mjs.map +1 -1
- package/dist/react/BookingWizard.d.mts +7 -1
- package/dist/react/BookingWizard.mjs +13 -0
- package/dist/react/BookingWizard.mjs.map +1 -1
- package/dist/react/index.d.mts +2 -2
- package/dist/react/useBooking.d.mts +11 -1
- package/dist/react/useBooking.mjs +101 -27
- package/dist/react/useBooking.mjs.map +1 -1
- package/dist/react/wizard/index.d.mts +2 -2
- package/package.json +2 -2
|
@@ -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 [
|
|
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
|
-
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
1563
|
-
|
|
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,
|