@medalsocial/booking 0.1.0 → 0.2.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 +19 -0
- package/README.md +2 -2
- package/dist/core/config.d.mts +7 -0
- package/dist/core/config.mjs +4 -2
- package/dist/core/config.mjs.map +1 -1
- package/dist/core/draft-store.d.mts +6 -0
- package/dist/core/draft-store.mjs +17 -1
- package/dist/core/draft-store.mjs.map +1 -1
- package/dist/core/index.d.mts +3 -2
- package/dist/core/index.mjs +3 -2
- package/dist/core/machine.d.mts +53 -6
- package/dist/core/machine.mjs +162 -52
- package/dist/core/machine.mjs.map +1 -1
- package/dist/core/party-slots.d.mts +9 -2
- package/dist/core/party-slots.mjs +18 -12
- package/dist/core/party-slots.mjs.map +1 -1
- package/dist/core/types.d.mts +4 -0
- package/dist/core/visit.d.mts +26 -0
- package/dist/core/visit.mjs +40 -0
- package/dist/core/visit.mjs.map +1 -0
- package/dist/next/catalogue.mjs +25 -7
- package/dist/next/catalogue.mjs.map +1 -1
- package/dist/next/medal.d.mts +7 -0
- package/dist/next/medal.mjs +2 -1
- package/dist/next/medal.mjs.map +1 -1
- package/dist/next/routes/catalogue-routes.mjs +18 -4
- package/dist/next/routes/catalogue-routes.mjs.map +1 -1
- package/dist/next/routes/create.mjs +47 -9
- package/dist/next/routes/create.mjs.map +1 -1
- package/dist/next/routes/manage.mjs.map +1 -1
- package/dist/next/routes/shared.mjs +29 -1
- package/dist/next/routes/shared.mjs.map +1 -1
- package/dist/react/BookingWizard.mjs +32 -11
- package/dist/react/BookingWizard.mjs.map +1 -1
- package/dist/react/labels/screens.d.mts +1 -1
- package/dist/react/labels/screens.mjs +12 -0
- package/dist/react/labels/screens.mjs.map +1 -1
- package/dist/react/labels/wizard.d.mts +7 -0
- package/dist/react/labels/wizard.mjs +2 -0
- package/dist/react/labels/wizard.mjs.map +1 -1
- package/dist/react/useBooking.d.mts +2 -0
- package/dist/react/useBooking.mjs +194 -53
- package/dist/react/useBooking.mjs.map +1 -1
- package/dist/react/wizard/adapters.mjs +7 -5
- package/dist/react/wizard/adapters.mjs.map +1 -1
- package/dist/react/wizard-error.mjs +29 -0
- package/dist/react/wizard-error.mjs.map +1 -0
- 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,52 @@ 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
|
|
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
|
-
|
|
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, resourceId) {
|
|
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(",") },
|
|
54
|
+
...resourceId ? { resource_id: resourceId } : {},
|
|
44
55
|
from_ts: String(fromTs),
|
|
45
56
|
to_ts: String(toTs)
|
|
46
57
|
});
|
|
47
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* The submission with each line's extras on it. meda's details screen builds
|
|
61
|
+
* the body from its own types, which have one service per line, so the shell
|
|
62
|
+
* is the only place that knows the rest of a person's visit. Index-aligned with
|
|
63
|
+
* the basket the screen was drawn from; a body of another length is not one
|
|
64
|
+
* this basket produced, and is sent as it came rather than with extras pinned
|
|
65
|
+
* to the wrong people.
|
|
66
|
+
*/
|
|
67
|
+
function withExtraServices(submission, items) {
|
|
68
|
+
if (!items.some((item) => (item.extraServices ?? []).length > 0)) return submission;
|
|
69
|
+
if (submission.items.length !== items.length) {
|
|
70
|
+
console.warn("[@medalsocial/booking] The submission has %d lines for a basket of %d; not sent.", submission.items.length, items.length);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
...submission,
|
|
75
|
+
items: submission.items.map((line, index) => {
|
|
76
|
+
const extras = items[index].extraServices ?? [];
|
|
77
|
+
return extras.length === 0 ? line : {
|
|
78
|
+
...line,
|
|
79
|
+
extraServiceIds: extras.map((service) => service.id)
|
|
80
|
+
};
|
|
81
|
+
})
|
|
82
|
+
};
|
|
83
|
+
}
|
|
48
84
|
/** What the create route said, narrowed to what the machine can hold. */
|
|
49
85
|
function asSubmitFailure(code) {
|
|
50
86
|
switch (code) {
|
|
@@ -108,8 +144,8 @@ function stylistLeftToFill(prefill) {
|
|
|
108
144
|
return prefill?.serviceId && prefill.resourceId ? prefill : null;
|
|
109
145
|
}
|
|
110
146
|
/** Whether the stylist list can now vouch for the pending link. */
|
|
111
|
-
function canFinishStylist(pending, resources,
|
|
112
|
-
return pending !== null && resources.length > 0 &&
|
|
147
|
+
function canFinishStylist(pending, resources, primaryVisit) {
|
|
148
|
+
return pending !== null && resources.length > 0 && primaryVisit === pending.serviceId;
|
|
113
149
|
}
|
|
114
150
|
/**
|
|
115
151
|
* A saved child as the person the machine seats: by `personId` when Medal has
|
|
@@ -278,7 +314,12 @@ function useBooking(options) {
|
|
|
278
314
|
};
|
|
279
315
|
};
|
|
280
316
|
const pendingStylist = useRef(stylistLeftToFill(prefill));
|
|
281
|
-
/**
|
|
317
|
+
/**
|
|
318
|
+
* Unfiltered openings, keyed by VISIT (`visitKey`): a service id for a
|
|
319
|
+
* one-service visit — which is all the server seed ever holds — and `a+b`
|
|
320
|
+
* for a person having both, whose list is the server's answer for the whole
|
|
321
|
+
* visit rather than either service's own.
|
|
322
|
+
*/
|
|
282
323
|
const [slots, setSlots] = useState(() => options.seed.slots ?? {});
|
|
283
324
|
const [slotsFailed, setSlotsFailed] = useState(false);
|
|
284
325
|
const [resources, setResources] = useState(() => options.seed.resources ?? []);
|
|
@@ -286,7 +327,7 @@ function useBooking(options) {
|
|
|
286
327
|
const [resourcesKnown, setResourcesKnown] = useState(() => options.seed.resources != null);
|
|
287
328
|
const [seededNextAvailable, setSeededNextAvailable] = useState(() => options.seed.resources ? options.seed.nextAvailable ?? {} : {});
|
|
288
329
|
const [seededSchedule] = useState(() => options.seed.schedules ?? {});
|
|
289
|
-
/** The last «next available» answer, and for WHICH
|
|
330
|
+
/** The last «next available» answer, and for WHICH visit (its key). */
|
|
290
331
|
const [fetchedNextAvailable, setFetchedNextAvailable] = useState(null);
|
|
291
332
|
const [submitting, setSubmitting] = useState(false);
|
|
292
333
|
/**
|
|
@@ -400,6 +441,37 @@ function useBooking(options) {
|
|
|
400
441
|
return reseated;
|
|
401
442
|
};
|
|
402
443
|
/**
|
|
444
|
+
* Each draft line's extras back on it, through `toggleServiceFor` like a tap.
|
|
445
|
+
* Returns whether EVERY extra came back. The machine's refusals are checked
|
|
446
|
+
* here first rather than read off its state, which a dispatch does not hand
|
|
447
|
+
* back: an id the catalogue no longer books, one already on the line (a
|
|
448
|
+
* toggle would take it OFF again), one past `maxServicesPerPerson`, or one
|
|
449
|
+
* more taker than the service's `maxPerBooking` — each is skipped, as an
|
|
450
|
+
* unknown main service is, and makes the answer `false`.
|
|
451
|
+
*/
|
|
452
|
+
const restoreExtras = (draft, lines, bookable) => {
|
|
453
|
+
let whole = true;
|
|
454
|
+
const lists = lines.map((service) => [service]);
|
|
455
|
+
draft.items.forEach((item, index) => {
|
|
456
|
+
for (const id of item.extraServiceIds ?? []) {
|
|
457
|
+
const service = bookable.get(id);
|
|
458
|
+
const list = lists[index];
|
|
459
|
+
const takers = lists.filter((other) => other.some((entry) => entry.id === id)).length;
|
|
460
|
+
if (service === void 0 || list.some((entry) => entry.id === id) || list.length >= config.party.maxServicesPerPerson || takers + 1 > service.maxPerBooking) {
|
|
461
|
+
whole = false;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
list.push(service);
|
|
465
|
+
dispatch({
|
|
466
|
+
type: "toggleServiceFor",
|
|
467
|
+
index,
|
|
468
|
+
service
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
return whole;
|
|
473
|
+
};
|
|
474
|
+
/**
|
|
403
475
|
* The booking a Vipps login interrupted, rebuilt through the machine's own
|
|
404
476
|
* actions — so it obeys every rule a tapped one does — and trusting nothing
|
|
405
477
|
* beyond the catalogue.
|
|
@@ -426,6 +498,7 @@ function useBooking(options) {
|
|
|
426
498
|
service
|
|
427
499
|
});
|
|
428
500
|
const reseated = reseatFamily(draft, resolved);
|
|
501
|
+
const wholeVisits = restoreExtras(draft, resolved, bookable);
|
|
429
502
|
if (draft.partyMode === "parallel") dispatch({
|
|
430
503
|
type: "setPartyMode",
|
|
431
504
|
mode: "parallel"
|
|
@@ -449,7 +522,7 @@ function useBooking(options) {
|
|
|
449
522
|
value: item.bookedForBirthYear
|
|
450
523
|
});
|
|
451
524
|
});
|
|
452
|
-
if (draft.startTs === null) return;
|
|
525
|
+
if (draft.startTs === null || !wholeVisits) return;
|
|
453
526
|
if (draft.partyResourceIds !== null && draft.partyResourceIds.length === resolved.length) {
|
|
454
527
|
restoredStylists.current = draft.partyResourceIds;
|
|
455
528
|
dispatch({
|
|
@@ -514,6 +587,7 @@ function useBooking(options) {
|
|
|
514
587
|
kit.drafts.stashDraft({
|
|
515
588
|
items: state.items.map((item) => ({
|
|
516
589
|
serviceId: item.service.id,
|
|
590
|
+
...item.extraServices?.length ? { extraServiceIds: item.extraServices.map((service) => service.id) } : {},
|
|
517
591
|
bookedForName: item.bookedForName ?? null,
|
|
518
592
|
bookedForBirthYear: item.bookedForBirthYear ?? null,
|
|
519
593
|
adult: item.adult === true,
|
|
@@ -575,20 +649,28 @@ function useBooking(options) {
|
|
|
575
649
|
});
|
|
576
650
|
}
|
|
577
651
|
}, [guardian, phone]);
|
|
578
|
-
const
|
|
579
|
-
const basketKey =
|
|
652
|
+
const basketVisits = useMemo(() => slotKeysFor(state.items), [state.items]);
|
|
653
|
+
const basketKey = basketVisits.join("|");
|
|
580
654
|
const primaryServiceId = state.items[0]?.service.id ?? null;
|
|
655
|
+
/**
|
|
656
|
+
* The first person's whole visit, by key — what the stylists' «neste ledige»
|
|
657
|
+
* and the business's hours are asked for. Equal to `primaryServiceId` for a
|
|
658
|
+
* one-service visit, so the seed (keyed by service id, and only ever for one
|
|
659
|
+
* service) answers exactly the visits it can, and a visit with extras is
|
|
660
|
+
* fetched instead of being read off its first service's openings.
|
|
661
|
+
*/
|
|
662
|
+
const primaryVisit = state.items[0] ? visitKey(state.items[0]) : null;
|
|
581
663
|
const nextAvailableTs = useMemo(() => {
|
|
582
|
-
if (
|
|
583
|
-
const seeded = seededNextAvailable[
|
|
664
|
+
if (primaryVisit === null) return {};
|
|
665
|
+
const seeded = seededNextAvailable[primaryVisit];
|
|
584
666
|
if (seeded) return seeded;
|
|
585
|
-
return fetchedNextAvailable?.
|
|
667
|
+
return fetchedNextAvailable?.key === primaryVisit ? fetchedNextAvailable.times : {};
|
|
586
668
|
}, [
|
|
587
|
-
|
|
669
|
+
primaryVisit,
|
|
588
670
|
seededNextAvailable,
|
|
589
671
|
fetchedNextAvailable
|
|
590
672
|
]);
|
|
591
|
-
const missingKey =
|
|
673
|
+
const missingKey = basketVisits.filter((key) => !(key in slots)).join("|");
|
|
592
674
|
/** Bumped to ask for the openings again after a failure. */
|
|
593
675
|
const [slotsAttempt, setSlotsAttempt] = useState(0);
|
|
594
676
|
const api = config.paths.api;
|
|
@@ -599,10 +681,10 @@ function useBooking(options) {
|
|
|
599
681
|
setSlotsFailed(false);
|
|
600
682
|
(async () => {
|
|
601
683
|
try {
|
|
602
|
-
const fetched = await Promise.all(wanted.map(async (
|
|
603
|
-
const response = await fetch(`${api}/availability?${windowQuery(
|
|
684
|
+
const fetched = await Promise.all(wanted.map(async (key) => {
|
|
685
|
+
const response = await fetch(`${api}/availability?${windowQuery(key, fromTs, toTs)}`);
|
|
604
686
|
if (!response.ok) throw new Error(`availability ${response.status}`);
|
|
605
|
-
return [
|
|
687
|
+
return [key, (await response.json()).slots ?? []];
|
|
606
688
|
}));
|
|
607
689
|
if (cancelled) return;
|
|
608
690
|
setSlots((previous) => ({
|
|
@@ -624,26 +706,26 @@ function useBooking(options) {
|
|
|
624
706
|
slotsAttempt
|
|
625
707
|
]);
|
|
626
708
|
useEffect(() => {
|
|
627
|
-
if (
|
|
628
|
-
if (
|
|
709
|
+
if (primaryVisit === null) return;
|
|
710
|
+
if (primaryVisit in seededNextAvailable) return;
|
|
629
711
|
let cancelled = false;
|
|
630
712
|
(async () => {
|
|
631
713
|
try {
|
|
632
|
-
const response = await fetch(`${api}/resources?${windowQuery(
|
|
714
|
+
const response = await fetch(`${api}/resources?${windowQuery(primaryVisit, fromTs, toTs)}`);
|
|
633
715
|
if (!response.ok) throw new Error(`resources ${response.status}`);
|
|
634
716
|
const body = await response.json();
|
|
635
717
|
if (cancelled) return;
|
|
636
718
|
setResources(body.resources ?? []);
|
|
637
719
|
setResourcesKnown(true);
|
|
638
720
|
setFetchedNextAvailable({
|
|
639
|
-
|
|
721
|
+
key: primaryVisit,
|
|
640
722
|
times: body.nextAvailableTs ?? {}
|
|
641
723
|
});
|
|
642
724
|
} catch {
|
|
643
725
|
if (!cancelled) {
|
|
644
726
|
setResourcesKnown(true);
|
|
645
727
|
setFetchedNextAvailable({
|
|
646
|
-
|
|
728
|
+
key: primaryVisit,
|
|
647
729
|
times: {}
|
|
648
730
|
});
|
|
649
731
|
}
|
|
@@ -654,19 +736,24 @@ function useBooking(options) {
|
|
|
654
736
|
};
|
|
655
737
|
}, [
|
|
656
738
|
api,
|
|
657
|
-
|
|
739
|
+
primaryVisit,
|
|
658
740
|
fromTs,
|
|
659
741
|
toTs,
|
|
660
742
|
seededNextAvailable
|
|
661
743
|
]);
|
|
662
|
-
/**
|
|
744
|
+
/**
|
|
745
|
+
* The stylist half of a «book again» link, once a list for its service has
|
|
746
|
+
* landed. Compared by VISIT: the link named one service, and a visitor who
|
|
747
|
+
* has since added a wash to it is asking a different question — the list now
|
|
748
|
+
* in hand answers the longer visit, not the one the link vouched for.
|
|
749
|
+
*/
|
|
663
750
|
useEffect(() => {
|
|
664
751
|
const pending = pendingStylist.current;
|
|
665
|
-
if (pending &&
|
|
752
|
+
if (pending && primaryVisit !== null && primaryVisit !== pending.serviceId) {
|
|
666
753
|
pendingStylist.current = null;
|
|
667
754
|
return;
|
|
668
755
|
}
|
|
669
|
-
if (!canFinishStylist(pending, resources,
|
|
756
|
+
if (!canFinishStylist(pending, resources, primaryVisit)) return;
|
|
670
757
|
pendingStylist.current = null;
|
|
671
758
|
dispatch({
|
|
672
759
|
type: "prefill",
|
|
@@ -675,7 +762,7 @@ function useBooking(options) {
|
|
|
675
762
|
});
|
|
676
763
|
}, [
|
|
677
764
|
resources,
|
|
678
|
-
|
|
765
|
+
primaryVisit,
|
|
679
766
|
services
|
|
680
767
|
]);
|
|
681
768
|
/** A party link's stylist, once the family has its services and is on step 3. */
|
|
@@ -710,35 +797,51 @@ function useBooking(options) {
|
|
|
710
797
|
resourceId: null
|
|
711
798
|
});
|
|
712
799
|
}, [resources, primaryServiceId]);
|
|
713
|
-
/**
|
|
800
|
+
/**
|
|
801
|
+
* The open dates over the window, for the first person's VISIT (cutoffs are
|
|
802
|
+
* per service, and `visitTailMinutes` assumes the answer was for item 0's
|
|
803
|
+
* whole visit — see the machine).
|
|
804
|
+
*
|
|
805
|
+
* The seed is the SALON's week, which is what «first available» draws. A named
|
|
806
|
+
* stylist works a different week, so their hours are fetched with `resource_id`
|
|
807
|
+
* and the seed is not allowed to answer for them — a seeded salon week would
|
|
808
|
+
* otherwise paint every open day as theirs.
|
|
809
|
+
*/
|
|
714
810
|
const [fetchedSchedule, setFetchedSchedule] = useState(null);
|
|
715
811
|
const [scheduleFailedFor, setScheduleFailedFor] = useState(null);
|
|
812
|
+
const scheduleKey = primaryVisit === null ? null : state.resourceId === null ? primaryVisit : `${primaryVisit}#${state.resourceId}`;
|
|
716
813
|
const openDays = useMemo(() => {
|
|
717
|
-
if (
|
|
718
|
-
|
|
719
|
-
if (
|
|
720
|
-
|
|
814
|
+
if (primaryVisit === null || scheduleKey === null) return null;
|
|
815
|
+
if (fetchedSchedule?.key === scheduleKey) return fetchedSchedule.days;
|
|
816
|
+
if (state.resourceId === null) return seededSchedule[primaryVisit] ?? null;
|
|
817
|
+
if (scheduleFailedFor === scheduleKey) return null;
|
|
818
|
+
return seededSchedule[primaryVisit] ?? (fetchedSchedule?.key === primaryVisit ? fetchedSchedule.days : null);
|
|
721
819
|
}, [
|
|
722
|
-
|
|
820
|
+
primaryVisit,
|
|
821
|
+
state.resourceId,
|
|
822
|
+
scheduleKey,
|
|
723
823
|
seededSchedule,
|
|
724
|
-
fetchedSchedule
|
|
824
|
+
fetchedSchedule,
|
|
825
|
+
scheduleFailedFor
|
|
725
826
|
]);
|
|
726
827
|
useEffect(() => {
|
|
727
|
-
if (
|
|
728
|
-
if (
|
|
828
|
+
if (primaryVisit === null || scheduleKey === null) return;
|
|
829
|
+
if (state.resourceId === null && primaryVisit in seededSchedule) return;
|
|
729
830
|
let cancelled = false;
|
|
831
|
+
const key = scheduleKey;
|
|
832
|
+
const resourceId = state.resourceId;
|
|
730
833
|
(async () => {
|
|
731
834
|
try {
|
|
732
|
-
const response = await fetch(`${api}/schedule?${windowQuery(
|
|
835
|
+
const response = await fetch(`${api}/schedule?${windowQuery(primaryVisit, fromTs, toTs, resourceId)}`);
|
|
733
836
|
if (!response.ok) throw new Error(`schedule ${response.status}`);
|
|
734
837
|
const body = await response.json();
|
|
735
838
|
if (cancelled) return;
|
|
736
839
|
setFetchedSchedule({
|
|
737
|
-
|
|
840
|
+
key,
|
|
738
841
|
days: body.days ?? []
|
|
739
842
|
});
|
|
740
843
|
} catch {
|
|
741
|
-
if (!cancelled) setScheduleFailedFor(
|
|
844
|
+
if (!cancelled) setScheduleFailedFor(key);
|
|
742
845
|
}
|
|
743
846
|
})();
|
|
744
847
|
return () => {
|
|
@@ -746,10 +849,12 @@ function useBooking(options) {
|
|
|
746
849
|
};
|
|
747
850
|
}, [
|
|
748
851
|
api,
|
|
749
|
-
|
|
852
|
+
primaryVisit,
|
|
750
853
|
fromTs,
|
|
751
854
|
toTs,
|
|
752
|
-
seededSchedule
|
|
855
|
+
seededSchedule,
|
|
856
|
+
state.resourceId,
|
|
857
|
+
scheduleKey
|
|
753
858
|
]);
|
|
754
859
|
/** A stylist's display name for an id; `''` from Medal reads as none. */
|
|
755
860
|
const resolveStylistName = useMemo(() => (resourceId) => {
|
|
@@ -768,7 +873,7 @@ function useBooking(options) {
|
|
|
768
873
|
if (!resourcesKnown || resources.length === 0) return;
|
|
769
874
|
if (state.step !== "when" || state.resourceId === null) return;
|
|
770
875
|
const chosen = resources.find((resource) => resource.id === state.resourceId);
|
|
771
|
-
if (chosen !== void 0 && state.items.every((item) => chosen.serviceIds.includes(
|
|
876
|
+
if (chosen !== void 0 && state.items.every((item) => visitServicesOf(item).every((service) => chosen.serviceIds.includes(service.id)))) return;
|
|
772
877
|
dispatch({
|
|
773
878
|
type: "pickResource",
|
|
774
879
|
resourceId: null
|
|
@@ -798,9 +903,9 @@ function useBooking(options) {
|
|
|
798
903
|
state.partyMode
|
|
799
904
|
]);
|
|
800
905
|
const party = wizard.showsPartyMode(state);
|
|
801
|
-
const haveEveryService =
|
|
802
|
-
const scheduleSettled = openDays !== null ||
|
|
803
|
-
const nextAvailableLoading = party ? !haveEveryService && !slotsFailed :
|
|
906
|
+
const haveEveryService = basketVisits.every((key) => key in slots);
|
|
907
|
+
const scheduleSettled = openDays !== null || scheduleKey !== null && scheduleFailedFor === scheduleKey;
|
|
908
|
+
const nextAvailableLoading = party ? !haveEveryService && !slotsFailed : primaryVisit !== null && !(primaryVisit in seededNextAvailable) && fetchedNextAvailable?.key !== primaryVisit;
|
|
804
909
|
/** The basket's openings, unfiltered — the parallel search needs two stylists. */
|
|
805
910
|
const basketSlots = useMemo(() => {
|
|
806
911
|
const out = {};
|
|
@@ -836,12 +941,12 @@ function useBooking(options) {
|
|
|
836
941
|
state.partyMode
|
|
837
942
|
]);
|
|
838
943
|
const singleSlots = useMemo(() => {
|
|
839
|
-
if (party ||
|
|
840
|
-
const list = slots[
|
|
944
|
+
if (party || primaryVisit === null) return [];
|
|
945
|
+
const list = slots[primaryVisit] ?? [];
|
|
841
946
|
return state.resourceId === null ? [...list] : onlyResource(list, state.resourceId);
|
|
842
947
|
}, [
|
|
843
948
|
party,
|
|
844
|
-
|
|
949
|
+
primaryVisit,
|
|
845
950
|
slots,
|
|
846
951
|
state.resourceId
|
|
847
952
|
]);
|
|
@@ -917,7 +1022,15 @@ function useBooking(options) {
|
|
|
917
1022
|
return;
|
|
918
1023
|
}
|
|
919
1024
|
const resourceIds = wizard.itemResourceIds(state);
|
|
920
|
-
|
|
1025
|
+
const body = withExtraServices(submission, state.items);
|
|
1026
|
+
if (body === null) {
|
|
1027
|
+
dispatch({
|
|
1028
|
+
type: "submitFailed",
|
|
1029
|
+
error: "invalidInput"
|
|
1030
|
+
});
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
await send(body, {
|
|
921
1034
|
items: state.items,
|
|
922
1035
|
startTs: state.startTs ?? 0,
|
|
923
1036
|
partyMode: state.partyMode,
|
|
@@ -971,12 +1084,12 @@ function useBooking(options) {
|
|
|
971
1084
|
}
|
|
972
1085
|
if (payload?.error === "slotTaken") {
|
|
973
1086
|
setTakenSlotTs(submitted.startTs);
|
|
974
|
-
const lost = new Set(submitted.items.map((item) => item
|
|
1087
|
+
const lost = new Set(submitted.items.map((item) => visitKey(item)));
|
|
975
1088
|
const fresh = freshSlotsOf(payload?.freshSlots, lost);
|
|
976
1089
|
setSlots((previous) => {
|
|
977
1090
|
const next = { ...previous };
|
|
978
|
-
for (const
|
|
979
|
-
else delete next[
|
|
1091
|
+
for (const key of lost) if (key in fresh) next[key] = fresh[key];
|
|
1092
|
+
else delete next[key];
|
|
980
1093
|
return next;
|
|
981
1094
|
});
|
|
982
1095
|
const canSeed = resources.length > 0;
|
|
@@ -984,7 +1097,7 @@ function useBooking(options) {
|
|
|
984
1097
|
...Object.fromEntries(Object.entries(previous).filter(([id]) => !lost.has(id))),
|
|
985
1098
|
...canSeed ? Object.fromEntries(Object.entries(fresh).map(([id, list]) => [id, firstOpeningPerResource(list)])) : {}
|
|
986
1099
|
}));
|
|
987
|
-
setFetchedNextAvailable((previous) => previous !== null && lost.has(previous.
|
|
1100
|
+
setFetchedNextAvailable((previous) => previous !== null && lost.has(previous.key) ? null : previous);
|
|
988
1101
|
kit.attempts.clearAttempt();
|
|
989
1102
|
dispatch({ type: "slotTaken" });
|
|
990
1103
|
return;
|
|
@@ -1076,6 +1189,32 @@ function useBooking(options) {
|
|
|
1076
1189
|
resourceServiceIds: chosenStylistServiceIds
|
|
1077
1190
|
});
|
|
1078
1191
|
}
|
|
1192
|
+
/**
|
|
1193
|
+
* The multi-select card: person `index` gains `service`, or loses it if they
|
|
1194
|
+
* have it. Never moves the visitor — they say when their list is done with
|
|
1195
|
+
* `continueFromService`. The named stylist's services ride along as for
|
|
1196
|
+
* `pickServiceFor`, so a stylist who cannot do the longer visit is released.
|
|
1197
|
+
*/
|
|
1198
|
+
function toggleServiceFor(index, service) {
|
|
1199
|
+
if (deepLinkPending.current) {
|
|
1200
|
+
deepLinkPending.current = false;
|
|
1201
|
+
partyStylist.current = deepLink.stylist;
|
|
1202
|
+
}
|
|
1203
|
+
dispatch({
|
|
1204
|
+
type: "toggleServiceFor",
|
|
1205
|
+
index,
|
|
1206
|
+
service,
|
|
1207
|
+
resourceServiceIds: chosenStylistServiceIds
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
/** «Neste» under a multi-select card: on to the hour once everybody has a service. */
|
|
1211
|
+
function continueFromService() {
|
|
1212
|
+
if (state.step !== "service" || !wizard.canAdvance(state)) return;
|
|
1213
|
+
dispatch({
|
|
1214
|
+
type: "goToStep",
|
|
1215
|
+
step: "when"
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1079
1218
|
function pickResource(resourceId) {
|
|
1080
1219
|
setStylistNotice(null);
|
|
1081
1220
|
dispatch({
|
|
@@ -1180,6 +1319,8 @@ function useBooking(options) {
|
|
|
1180
1319
|
},
|
|
1181
1320
|
pickService,
|
|
1182
1321
|
pickServiceFor,
|
|
1322
|
+
toggleServiceFor,
|
|
1323
|
+
continueFromService,
|
|
1183
1324
|
pickResource,
|
|
1184
1325
|
pickSlot,
|
|
1185
1326
|
pickPartySlot,
|