@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.
- package/CHANGELOG.md +34 -0
- package/README.md +1 -0
- package/dist/core/config.d.mts +3 -0
- package/dist/core/config.mjs +5 -2
- package/dist/core/config.mjs.map +1 -1
- package/dist/core/draft-store.d.mts +2 -0
- package/dist/core/draft-store.mjs +1 -0
- package/dist/core/draft-store.mjs.map +1 -1
- package/dist/core/index.d.mts +2 -1
- package/dist/core/index.mjs +2 -1
- package/dist/core/machine.d.mts +16 -0
- package/dist/core/machine.mjs +59 -3
- package/dist/core/machine.mjs.map +1 -1
- package/dist/core/party-slots.d.mts +6 -1
- package/dist/core/party-slots.mjs +6 -5
- package/dist/core/party-slots.mjs.map +1 -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 +20 -2
- package/dist/react/BookingWizard.mjs.map +1 -1
- package/dist/react/index.d.mts +2 -2
- package/dist/react/labels/screens.mjs +4 -0
- package/dist/react/labels/screens.mjs.map +1 -1
- package/dist/react/useBooking.d.mts +11 -1
- package/dist/react/useBooking.mjs +152 -31
- package/dist/react/useBooking.mjs.map +1 -1
- package/dist/react/wizard/adapters.mjs +33 -1
- package/dist/react/wizard/adapters.mjs.map +1 -1
- package/dist/react/wizard/index.d.mts +2 -2
- 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 [
|
|
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
|
-
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1563
|
-
|
|
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,
|