@medalsocial/booking 0.1.0 → 0.2.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 +13 -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 +176 -50
- 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,51 @@ 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) {
|
|
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(",") },
|
|
44
54
|
from_ts: String(fromTs),
|
|
45
55
|
to_ts: String(toTs)
|
|
46
56
|
});
|
|
47
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* The submission with each line's extras on it. meda's details screen builds
|
|
60
|
+
* the body from its own types, which have one service per line, so the shell
|
|
61
|
+
* is the only place that knows the rest of a person's visit. Index-aligned with
|
|
62
|
+
* the basket the screen was drawn from; a body of another length is not one
|
|
63
|
+
* this basket produced, and is sent as it came rather than with extras pinned
|
|
64
|
+
* to the wrong people.
|
|
65
|
+
*/
|
|
66
|
+
function withExtraServices(submission, items) {
|
|
67
|
+
if (!items.some((item) => (item.extraServices ?? []).length > 0)) return submission;
|
|
68
|
+
if (submission.items.length !== items.length) {
|
|
69
|
+
console.warn("[@medalsocial/booking] The submission has %d lines for a basket of %d; not sent.", submission.items.length, items.length);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
...submission,
|
|
74
|
+
items: submission.items.map((line, index) => {
|
|
75
|
+
const extras = items[index].extraServices ?? [];
|
|
76
|
+
return extras.length === 0 ? line : {
|
|
77
|
+
...line,
|
|
78
|
+
extraServiceIds: extras.map((service) => service.id)
|
|
79
|
+
};
|
|
80
|
+
})
|
|
81
|
+
};
|
|
82
|
+
}
|
|
48
83
|
/** What the create route said, narrowed to what the machine can hold. */
|
|
49
84
|
function asSubmitFailure(code) {
|
|
50
85
|
switch (code) {
|
|
@@ -108,8 +143,8 @@ function stylistLeftToFill(prefill) {
|
|
|
108
143
|
return prefill?.serviceId && prefill.resourceId ? prefill : null;
|
|
109
144
|
}
|
|
110
145
|
/** Whether the stylist list can now vouch for the pending link. */
|
|
111
|
-
function canFinishStylist(pending, resources,
|
|
112
|
-
return pending !== null && resources.length > 0 &&
|
|
146
|
+
function canFinishStylist(pending, resources, primaryVisit) {
|
|
147
|
+
return pending !== null && resources.length > 0 && primaryVisit === pending.serviceId;
|
|
113
148
|
}
|
|
114
149
|
/**
|
|
115
150
|
* A saved child as the person the machine seats: by `personId` when Medal has
|
|
@@ -278,7 +313,12 @@ function useBooking(options) {
|
|
|
278
313
|
};
|
|
279
314
|
};
|
|
280
315
|
const pendingStylist = useRef(stylistLeftToFill(prefill));
|
|
281
|
-
/**
|
|
316
|
+
/**
|
|
317
|
+
* Unfiltered openings, keyed by VISIT (`visitKey`): a service id for a
|
|
318
|
+
* one-service visit — which is all the server seed ever holds — and `a+b`
|
|
319
|
+
* for a person having both, whose list is the server's answer for the whole
|
|
320
|
+
* visit rather than either service's own.
|
|
321
|
+
*/
|
|
282
322
|
const [slots, setSlots] = useState(() => options.seed.slots ?? {});
|
|
283
323
|
const [slotsFailed, setSlotsFailed] = useState(false);
|
|
284
324
|
const [resources, setResources] = useState(() => options.seed.resources ?? []);
|
|
@@ -286,7 +326,7 @@ function useBooking(options) {
|
|
|
286
326
|
const [resourcesKnown, setResourcesKnown] = useState(() => options.seed.resources != null);
|
|
287
327
|
const [seededNextAvailable, setSeededNextAvailable] = useState(() => options.seed.resources ? options.seed.nextAvailable ?? {} : {});
|
|
288
328
|
const [seededSchedule] = useState(() => options.seed.schedules ?? {});
|
|
289
|
-
/** The last «next available» answer, and for WHICH
|
|
329
|
+
/** The last «next available» answer, and for WHICH visit (its key). */
|
|
290
330
|
const [fetchedNextAvailable, setFetchedNextAvailable] = useState(null);
|
|
291
331
|
const [submitting, setSubmitting] = useState(false);
|
|
292
332
|
/**
|
|
@@ -400,6 +440,37 @@ function useBooking(options) {
|
|
|
400
440
|
return reseated;
|
|
401
441
|
};
|
|
402
442
|
/**
|
|
443
|
+
* Each draft line's extras back on it, through `toggleServiceFor` like a tap.
|
|
444
|
+
* Returns whether EVERY extra came back. The machine's refusals are checked
|
|
445
|
+
* here first rather than read off its state, which a dispatch does not hand
|
|
446
|
+
* back: an id the catalogue no longer books, one already on the line (a
|
|
447
|
+
* toggle would take it OFF again), one past `maxServicesPerPerson`, or one
|
|
448
|
+
* more taker than the service's `maxPerBooking` — each is skipped, as an
|
|
449
|
+
* unknown main service is, and makes the answer `false`.
|
|
450
|
+
*/
|
|
451
|
+
const restoreExtras = (draft, lines, bookable) => {
|
|
452
|
+
let whole = true;
|
|
453
|
+
const lists = lines.map((service) => [service]);
|
|
454
|
+
draft.items.forEach((item, index) => {
|
|
455
|
+
for (const id of item.extraServiceIds ?? []) {
|
|
456
|
+
const service = bookable.get(id);
|
|
457
|
+
const list = lists[index];
|
|
458
|
+
const takers = lists.filter((other) => other.some((entry) => entry.id === id)).length;
|
|
459
|
+
if (service === void 0 || list.some((entry) => entry.id === id) || list.length >= config.party.maxServicesPerPerson || takers + 1 > service.maxPerBooking) {
|
|
460
|
+
whole = false;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
list.push(service);
|
|
464
|
+
dispatch({
|
|
465
|
+
type: "toggleServiceFor",
|
|
466
|
+
index,
|
|
467
|
+
service
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
return whole;
|
|
472
|
+
};
|
|
473
|
+
/**
|
|
403
474
|
* The booking a Vipps login interrupted, rebuilt through the machine's own
|
|
404
475
|
* actions — so it obeys every rule a tapped one does — and trusting nothing
|
|
405
476
|
* beyond the catalogue.
|
|
@@ -426,6 +497,7 @@ function useBooking(options) {
|
|
|
426
497
|
service
|
|
427
498
|
});
|
|
428
499
|
const reseated = reseatFamily(draft, resolved);
|
|
500
|
+
const wholeVisits = restoreExtras(draft, resolved, bookable);
|
|
429
501
|
if (draft.partyMode === "parallel") dispatch({
|
|
430
502
|
type: "setPartyMode",
|
|
431
503
|
mode: "parallel"
|
|
@@ -449,7 +521,7 @@ function useBooking(options) {
|
|
|
449
521
|
value: item.bookedForBirthYear
|
|
450
522
|
});
|
|
451
523
|
});
|
|
452
|
-
if (draft.startTs === null) return;
|
|
524
|
+
if (draft.startTs === null || !wholeVisits) return;
|
|
453
525
|
if (draft.partyResourceIds !== null && draft.partyResourceIds.length === resolved.length) {
|
|
454
526
|
restoredStylists.current = draft.partyResourceIds;
|
|
455
527
|
dispatch({
|
|
@@ -514,6 +586,7 @@ function useBooking(options) {
|
|
|
514
586
|
kit.drafts.stashDraft({
|
|
515
587
|
items: state.items.map((item) => ({
|
|
516
588
|
serviceId: item.service.id,
|
|
589
|
+
...item.extraServices?.length ? { extraServiceIds: item.extraServices.map((service) => service.id) } : {},
|
|
517
590
|
bookedForName: item.bookedForName ?? null,
|
|
518
591
|
bookedForBirthYear: item.bookedForBirthYear ?? null,
|
|
519
592
|
adult: item.adult === true,
|
|
@@ -575,20 +648,28 @@ function useBooking(options) {
|
|
|
575
648
|
});
|
|
576
649
|
}
|
|
577
650
|
}, [guardian, phone]);
|
|
578
|
-
const
|
|
579
|
-
const basketKey =
|
|
651
|
+
const basketVisits = useMemo(() => slotKeysFor(state.items), [state.items]);
|
|
652
|
+
const basketKey = basketVisits.join("|");
|
|
580
653
|
const primaryServiceId = state.items[0]?.service.id ?? null;
|
|
654
|
+
/**
|
|
655
|
+
* The first person's whole visit, by key — what the stylists' «neste ledige»
|
|
656
|
+
* and the business's hours are asked for. Equal to `primaryServiceId` for a
|
|
657
|
+
* one-service visit, so the seed (keyed by service id, and only ever for one
|
|
658
|
+
* service) answers exactly the visits it can, and a visit with extras is
|
|
659
|
+
* fetched instead of being read off its first service's openings.
|
|
660
|
+
*/
|
|
661
|
+
const primaryVisit = state.items[0] ? visitKey(state.items[0]) : null;
|
|
581
662
|
const nextAvailableTs = useMemo(() => {
|
|
582
|
-
if (
|
|
583
|
-
const seeded = seededNextAvailable[
|
|
663
|
+
if (primaryVisit === null) return {};
|
|
664
|
+
const seeded = seededNextAvailable[primaryVisit];
|
|
584
665
|
if (seeded) return seeded;
|
|
585
|
-
return fetchedNextAvailable?.
|
|
666
|
+
return fetchedNextAvailable?.key === primaryVisit ? fetchedNextAvailable.times : {};
|
|
586
667
|
}, [
|
|
587
|
-
|
|
668
|
+
primaryVisit,
|
|
588
669
|
seededNextAvailable,
|
|
589
670
|
fetchedNextAvailable
|
|
590
671
|
]);
|
|
591
|
-
const missingKey =
|
|
672
|
+
const missingKey = basketVisits.filter((key) => !(key in slots)).join("|");
|
|
592
673
|
/** Bumped to ask for the openings again after a failure. */
|
|
593
674
|
const [slotsAttempt, setSlotsAttempt] = useState(0);
|
|
594
675
|
const api = config.paths.api;
|
|
@@ -599,10 +680,10 @@ function useBooking(options) {
|
|
|
599
680
|
setSlotsFailed(false);
|
|
600
681
|
(async () => {
|
|
601
682
|
try {
|
|
602
|
-
const fetched = await Promise.all(wanted.map(async (
|
|
603
|
-
const response = await fetch(`${api}/availability?${windowQuery(
|
|
683
|
+
const fetched = await Promise.all(wanted.map(async (key) => {
|
|
684
|
+
const response = await fetch(`${api}/availability?${windowQuery(key, fromTs, toTs)}`);
|
|
604
685
|
if (!response.ok) throw new Error(`availability ${response.status}`);
|
|
605
|
-
return [
|
|
686
|
+
return [key, (await response.json()).slots ?? []];
|
|
606
687
|
}));
|
|
607
688
|
if (cancelled) return;
|
|
608
689
|
setSlots((previous) => ({
|
|
@@ -624,26 +705,26 @@ function useBooking(options) {
|
|
|
624
705
|
slotsAttempt
|
|
625
706
|
]);
|
|
626
707
|
useEffect(() => {
|
|
627
|
-
if (
|
|
628
|
-
if (
|
|
708
|
+
if (primaryVisit === null) return;
|
|
709
|
+
if (primaryVisit in seededNextAvailable) return;
|
|
629
710
|
let cancelled = false;
|
|
630
711
|
(async () => {
|
|
631
712
|
try {
|
|
632
|
-
const response = await fetch(`${api}/resources?${windowQuery(
|
|
713
|
+
const response = await fetch(`${api}/resources?${windowQuery(primaryVisit, fromTs, toTs)}`);
|
|
633
714
|
if (!response.ok) throw new Error(`resources ${response.status}`);
|
|
634
715
|
const body = await response.json();
|
|
635
716
|
if (cancelled) return;
|
|
636
717
|
setResources(body.resources ?? []);
|
|
637
718
|
setResourcesKnown(true);
|
|
638
719
|
setFetchedNextAvailable({
|
|
639
|
-
|
|
720
|
+
key: primaryVisit,
|
|
640
721
|
times: body.nextAvailableTs ?? {}
|
|
641
722
|
});
|
|
642
723
|
} catch {
|
|
643
724
|
if (!cancelled) {
|
|
644
725
|
setResourcesKnown(true);
|
|
645
726
|
setFetchedNextAvailable({
|
|
646
|
-
|
|
727
|
+
key: primaryVisit,
|
|
647
728
|
times: {}
|
|
648
729
|
});
|
|
649
730
|
}
|
|
@@ -654,19 +735,24 @@ function useBooking(options) {
|
|
|
654
735
|
};
|
|
655
736
|
}, [
|
|
656
737
|
api,
|
|
657
|
-
|
|
738
|
+
primaryVisit,
|
|
658
739
|
fromTs,
|
|
659
740
|
toTs,
|
|
660
741
|
seededNextAvailable
|
|
661
742
|
]);
|
|
662
|
-
/**
|
|
743
|
+
/**
|
|
744
|
+
* The stylist half of a «book again» link, once a list for its service has
|
|
745
|
+
* landed. Compared by VISIT: the link named one service, and a visitor who
|
|
746
|
+
* has since added a wash to it is asking a different question — the list now
|
|
747
|
+
* in hand answers the longer visit, not the one the link vouched for.
|
|
748
|
+
*/
|
|
663
749
|
useEffect(() => {
|
|
664
750
|
const pending = pendingStylist.current;
|
|
665
|
-
if (pending &&
|
|
751
|
+
if (pending && primaryVisit !== null && primaryVisit !== pending.serviceId) {
|
|
666
752
|
pendingStylist.current = null;
|
|
667
753
|
return;
|
|
668
754
|
}
|
|
669
|
-
if (!canFinishStylist(pending, resources,
|
|
755
|
+
if (!canFinishStylist(pending, resources, primaryVisit)) return;
|
|
670
756
|
pendingStylist.current = null;
|
|
671
757
|
dispatch({
|
|
672
758
|
type: "prefill",
|
|
@@ -675,7 +761,7 @@ function useBooking(options) {
|
|
|
675
761
|
});
|
|
676
762
|
}, [
|
|
677
763
|
resources,
|
|
678
|
-
|
|
764
|
+
primaryVisit,
|
|
679
765
|
services
|
|
680
766
|
]);
|
|
681
767
|
/** A party link's stylist, once the family has its services and is on step 3. */
|
|
@@ -710,35 +796,39 @@ function useBooking(options) {
|
|
|
710
796
|
resourceId: null
|
|
711
797
|
});
|
|
712
798
|
}, [resources, primaryServiceId]);
|
|
713
|
-
/**
|
|
799
|
+
/**
|
|
800
|
+
* The business's open dates over the window, for the first person's VISIT
|
|
801
|
+
* (cutoffs are per service, and `visitTailMinutes` assumes the answer was for
|
|
802
|
+
* item 0's whole visit — see the machine).
|
|
803
|
+
*/
|
|
714
804
|
const [fetchedSchedule, setFetchedSchedule] = useState(null);
|
|
715
805
|
const [scheduleFailedFor, setScheduleFailedFor] = useState(null);
|
|
716
806
|
const openDays = useMemo(() => {
|
|
717
|
-
if (
|
|
718
|
-
const seeded = seededSchedule[
|
|
807
|
+
if (primaryVisit === null) return null;
|
|
808
|
+
const seeded = seededSchedule[primaryVisit];
|
|
719
809
|
if (seeded) return seeded;
|
|
720
|
-
return fetchedSchedule?.
|
|
810
|
+
return fetchedSchedule?.key === primaryVisit ? fetchedSchedule.days : null;
|
|
721
811
|
}, [
|
|
722
|
-
|
|
812
|
+
primaryVisit,
|
|
723
813
|
seededSchedule,
|
|
724
814
|
fetchedSchedule
|
|
725
815
|
]);
|
|
726
816
|
useEffect(() => {
|
|
727
|
-
if (
|
|
728
|
-
if (
|
|
817
|
+
if (primaryVisit === null) return;
|
|
818
|
+
if (primaryVisit in seededSchedule) return;
|
|
729
819
|
let cancelled = false;
|
|
730
820
|
(async () => {
|
|
731
821
|
try {
|
|
732
|
-
const response = await fetch(`${api}/schedule?${windowQuery(
|
|
822
|
+
const response = await fetch(`${api}/schedule?${windowQuery(primaryVisit, fromTs, toTs)}`);
|
|
733
823
|
if (!response.ok) throw new Error(`schedule ${response.status}`);
|
|
734
824
|
const body = await response.json();
|
|
735
825
|
if (cancelled) return;
|
|
736
826
|
setFetchedSchedule({
|
|
737
|
-
|
|
827
|
+
key: primaryVisit,
|
|
738
828
|
days: body.days ?? []
|
|
739
829
|
});
|
|
740
830
|
} catch {
|
|
741
|
-
if (!cancelled) setScheduleFailedFor(
|
|
831
|
+
if (!cancelled) setScheduleFailedFor(primaryVisit);
|
|
742
832
|
}
|
|
743
833
|
})();
|
|
744
834
|
return () => {
|
|
@@ -746,7 +836,7 @@ function useBooking(options) {
|
|
|
746
836
|
};
|
|
747
837
|
}, [
|
|
748
838
|
api,
|
|
749
|
-
|
|
839
|
+
primaryVisit,
|
|
750
840
|
fromTs,
|
|
751
841
|
toTs,
|
|
752
842
|
seededSchedule
|
|
@@ -768,7 +858,7 @@ function useBooking(options) {
|
|
|
768
858
|
if (!resourcesKnown || resources.length === 0) return;
|
|
769
859
|
if (state.step !== "when" || state.resourceId === null) return;
|
|
770
860
|
const chosen = resources.find((resource) => resource.id === state.resourceId);
|
|
771
|
-
if (chosen !== void 0 && state.items.every((item) => chosen.serviceIds.includes(
|
|
861
|
+
if (chosen !== void 0 && state.items.every((item) => visitServicesOf(item).every((service) => chosen.serviceIds.includes(service.id)))) return;
|
|
772
862
|
dispatch({
|
|
773
863
|
type: "pickResource",
|
|
774
864
|
resourceId: null
|
|
@@ -798,9 +888,9 @@ function useBooking(options) {
|
|
|
798
888
|
state.partyMode
|
|
799
889
|
]);
|
|
800
890
|
const party = wizard.showsPartyMode(state);
|
|
801
|
-
const haveEveryService =
|
|
802
|
-
const scheduleSettled = openDays !== null ||
|
|
803
|
-
const nextAvailableLoading = party ? !haveEveryService && !slotsFailed :
|
|
891
|
+
const haveEveryService = basketVisits.every((key) => key in slots);
|
|
892
|
+
const scheduleSettled = openDays !== null || primaryVisit !== null && scheduleFailedFor === primaryVisit;
|
|
893
|
+
const nextAvailableLoading = party ? !haveEveryService && !slotsFailed : primaryVisit !== null && !(primaryVisit in seededNextAvailable) && fetchedNextAvailable?.key !== primaryVisit;
|
|
804
894
|
/** The basket's openings, unfiltered — the parallel search needs two stylists. */
|
|
805
895
|
const basketSlots = useMemo(() => {
|
|
806
896
|
const out = {};
|
|
@@ -836,12 +926,12 @@ function useBooking(options) {
|
|
|
836
926
|
state.partyMode
|
|
837
927
|
]);
|
|
838
928
|
const singleSlots = useMemo(() => {
|
|
839
|
-
if (party ||
|
|
840
|
-
const list = slots[
|
|
929
|
+
if (party || primaryVisit === null) return [];
|
|
930
|
+
const list = slots[primaryVisit] ?? [];
|
|
841
931
|
return state.resourceId === null ? [...list] : onlyResource(list, state.resourceId);
|
|
842
932
|
}, [
|
|
843
933
|
party,
|
|
844
|
-
|
|
934
|
+
primaryVisit,
|
|
845
935
|
slots,
|
|
846
936
|
state.resourceId
|
|
847
937
|
]);
|
|
@@ -917,7 +1007,15 @@ function useBooking(options) {
|
|
|
917
1007
|
return;
|
|
918
1008
|
}
|
|
919
1009
|
const resourceIds = wizard.itemResourceIds(state);
|
|
920
|
-
|
|
1010
|
+
const body = withExtraServices(submission, state.items);
|
|
1011
|
+
if (body === null) {
|
|
1012
|
+
dispatch({
|
|
1013
|
+
type: "submitFailed",
|
|
1014
|
+
error: "invalidInput"
|
|
1015
|
+
});
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
await send(body, {
|
|
921
1019
|
items: state.items,
|
|
922
1020
|
startTs: state.startTs ?? 0,
|
|
923
1021
|
partyMode: state.partyMode,
|
|
@@ -971,12 +1069,12 @@ function useBooking(options) {
|
|
|
971
1069
|
}
|
|
972
1070
|
if (payload?.error === "slotTaken") {
|
|
973
1071
|
setTakenSlotTs(submitted.startTs);
|
|
974
|
-
const lost = new Set(submitted.items.map((item) => item
|
|
1072
|
+
const lost = new Set(submitted.items.map((item) => visitKey(item)));
|
|
975
1073
|
const fresh = freshSlotsOf(payload?.freshSlots, lost);
|
|
976
1074
|
setSlots((previous) => {
|
|
977
1075
|
const next = { ...previous };
|
|
978
|
-
for (const
|
|
979
|
-
else delete next[
|
|
1076
|
+
for (const key of lost) if (key in fresh) next[key] = fresh[key];
|
|
1077
|
+
else delete next[key];
|
|
980
1078
|
return next;
|
|
981
1079
|
});
|
|
982
1080
|
const canSeed = resources.length > 0;
|
|
@@ -984,7 +1082,7 @@ function useBooking(options) {
|
|
|
984
1082
|
...Object.fromEntries(Object.entries(previous).filter(([id]) => !lost.has(id))),
|
|
985
1083
|
...canSeed ? Object.fromEntries(Object.entries(fresh).map(([id, list]) => [id, firstOpeningPerResource(list)])) : {}
|
|
986
1084
|
}));
|
|
987
|
-
setFetchedNextAvailable((previous) => previous !== null && lost.has(previous.
|
|
1085
|
+
setFetchedNextAvailable((previous) => previous !== null && lost.has(previous.key) ? null : previous);
|
|
988
1086
|
kit.attempts.clearAttempt();
|
|
989
1087
|
dispatch({ type: "slotTaken" });
|
|
990
1088
|
return;
|
|
@@ -1076,6 +1174,32 @@ function useBooking(options) {
|
|
|
1076
1174
|
resourceServiceIds: chosenStylistServiceIds
|
|
1077
1175
|
});
|
|
1078
1176
|
}
|
|
1177
|
+
/**
|
|
1178
|
+
* The multi-select card: person `index` gains `service`, or loses it if they
|
|
1179
|
+
* have it. Never moves the visitor — they say when their list is done with
|
|
1180
|
+
* `continueFromService`. The named stylist's services ride along as for
|
|
1181
|
+
* `pickServiceFor`, so a stylist who cannot do the longer visit is released.
|
|
1182
|
+
*/
|
|
1183
|
+
function toggleServiceFor(index, service) {
|
|
1184
|
+
if (deepLinkPending.current) {
|
|
1185
|
+
deepLinkPending.current = false;
|
|
1186
|
+
partyStylist.current = deepLink.stylist;
|
|
1187
|
+
}
|
|
1188
|
+
dispatch({
|
|
1189
|
+
type: "toggleServiceFor",
|
|
1190
|
+
index,
|
|
1191
|
+
service,
|
|
1192
|
+
resourceServiceIds: chosenStylistServiceIds
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
/** «Neste» under a multi-select card: on to the hour once everybody has a service. */
|
|
1196
|
+
function continueFromService() {
|
|
1197
|
+
if (state.step !== "service" || !wizard.canAdvance(state)) return;
|
|
1198
|
+
dispatch({
|
|
1199
|
+
type: "goToStep",
|
|
1200
|
+
step: "when"
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1079
1203
|
function pickResource(resourceId) {
|
|
1080
1204
|
setStylistNotice(null);
|
|
1081
1205
|
dispatch({
|
|
@@ -1180,6 +1304,8 @@ function useBooking(options) {
|
|
|
1180
1304
|
},
|
|
1181
1305
|
pickService,
|
|
1182
1306
|
pickServiceFor,
|
|
1307
|
+
toggleServiceFor,
|
|
1308
|
+
continueFromService,
|
|
1183
1309
|
pickResource,
|
|
1184
1310
|
pickSlot,
|
|
1185
1311
|
pickPartySlot,
|