@wix/himalaya-cli 0.1581.0 → 0.1583.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.
Files changed (50) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/builder.d.ts +8 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +26 -2
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/validator.d.ts +18 -0
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/core.mjs +5 -1
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdui.mjs +5 -1
  6. package/dist/cli.mjs +17195 -17232
  7. package/dist/preview-assets.generated.bin +0 -0
  8. package/guides/component-reference.json +4 -3
  9. package/guides/component-reference.md +24 -2
  10. package/package.json +2 -2
  11. package/templates/beauty-salon/config.ts +20 -1
  12. package/templates/beauty-salon/dev/index.ts +1751 -629
  13. package/templates/beauty-salon/tier5-src/_shared/booking-location.ts +147 -0
  14. package/templates/beauty-salon/tier5-src/_shared/booking-session.ts +222 -0
  15. package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +277 -14
  16. package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +351 -92
  17. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +25 -5
  18. package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +219 -220
  19. package/templates/branded-lite/config.ts +20 -1
  20. package/templates/branded-lite/dev/index.ts +1688 -566
  21. package/templates/branded-lite/l10n/en.json +2 -2
  22. package/templates/branded-lite/tier5-src/_shared/booking-location.ts +147 -0
  23. package/templates/branded-lite/tier5-src/_shared/booking-session.ts +222 -0
  24. package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +277 -14
  25. package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +351 -92
  26. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +25 -5
  27. package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +219 -220
  28. package/templates/community-club/config.ts +20 -1
  29. package/templates/events-meetups/config.ts +20 -1
  30. package/templates/gym-fitness/config.ts +20 -1
  31. package/templates/gym-fitness/dev/index.ts +1751 -629
  32. package/templates/gym-fitness/tier5-src/_shared/booking-location.ts +147 -0
  33. package/templates/gym-fitness/tier5-src/_shared/booking-session.ts +222 -0
  34. package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +277 -14
  35. package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +351 -92
  36. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +25 -5
  37. package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +219 -220
  38. package/templates/nonprofit-cause/config.ts +20 -1
  39. package/templates/podcast-creator/config.ts +20 -1
  40. package/templates/portfolio-creative/config.ts +20 -1
  41. package/templates/restaurant/config.ts +20 -1
  42. package/templates/retail-store/config.ts +20 -1
  43. package/templates/yoga-studio/config.ts +20 -1
  44. package/templates/yoga-studio/dev/index.ts +1533 -411
  45. package/templates/yoga-studio/tier5-src/_shared/booking-location.ts +147 -0
  46. package/templates/yoga-studio/tier5-src/_shared/booking-session.ts +222 -0
  47. package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +277 -14
  48. package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +351 -92
  49. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +25 -5
  50. package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +219 -220
@@ -1,8 +1,19 @@
1
1
  // Tier-5 bundle: himi-booking — single service detail.
2
+ //
3
+ // Opened from an Agenda row (`sessionId` + `start` in the input), the screen is about THAT
4
+ // session: its date and time show under the name and in the facts, and the footer offers what
5
+ // the Agenda row offers, from the same session state (`_shared/booking-session.ts`): Book (Choose
6
+ // Time opens with the session picked), Join Waitlist (joined from here), a disabled Book with the
7
+ // reason, or the member's own place (Booked / On waitlist). A course keeps its course footer.
8
+ // Opened without a session (the Services list), the footer is "Choose a Time", as before.
2
9
  import { defineService, navigate } from "@himalaya/state";
3
10
  import { share as shareSheet } from "@himalaya/share";
4
11
  import { open, defaultMode } from "@himalaya/open-url";
5
- import { getService, queryServiceAvailability, type WixAvailabilityEntry, type WixService, type WixServiceAddress, type WixServiceBookingPolicy } from "@himalaya/wix-bookings";
12
+ import { addDays, dayKey, dayLabel, timeLabel } from "@himalaya/time";
13
+ import { getService, queryServiceAvailability, registerToWaitlist, type WixAvailabilityEntry, type WixService, type WixServiceAddress, type WixServiceBookingPolicy } from "@himalaya/wix-bookings";
14
+ import { getMyMember } from "@himalaya/wix-members";
15
+ import { memberAccess, type MemberAccess } from "../_shared/member-access.js";
16
+ import { placesIn, sessionControl, sessionOf, type MemberPlace, type Session, type SessionControl } from "../_shared/booking-session.js";
6
17
  import { categoryName, courseDatesText, courseReadWindows, courseSessionsAvailability, courseWindowOpen, serviceDurationText, servicePriceLine, slotStartInstant, type BookingLabel, type ServicePriceLine } from "../_shared/himi-bookings.js";
7
18
  import { formatServiceMoney, serviceCurrency } from "../_shared/wix-service-price.js";
8
19
  import { stashCopy, copyParam, copyTemplate, copyPlural } from "../_shared/himi-copy.js";
@@ -145,6 +156,32 @@ interface S {
145
156
  notLoading: boolean;
146
157
  hasService: boolean;
147
158
  noEmpty: boolean;
159
+ // ---- The session an Agenda row opened this screen on (all "" without one) ----
160
+ /** Its Wix session id ("" when it has none) and start instant: Book opens Choose Time on it. */
161
+ sessionId: string;
162
+ sessionStart: string;
163
+ /** "Tue, Nov 5 · 6:00 PM" under the name; the date and the time range apart for the fact row. */
164
+ sessionWhen: string;
165
+ sessionDate: string;
166
+ sessionTime: string;
167
+ noSession: boolean;
168
+ /** The control the Agenda row shows for this session (`sessionControl`), "" without a session. */
169
+ sessionControl: "" | SessionControl;
170
+ /** Why a closed session cannot be booked: "Full", "Not open yet" or "Closed". */
171
+ closedLabel: string;
172
+ noSessionBook: boolean;
173
+ noSessionRequest: boolean;
174
+ noSessionWaitlist: boolean;
175
+ noSessionClosed: boolean;
176
+ noSessionBooked: boolean;
177
+ noSessionWaitlisted: boolean;
178
+ /** "Change time" shows while the member can still choose another session. */
179
+ noChangeTime: boolean;
180
+ /** The outcome of a waitlist join, above the footer's control: it went through (a check) or not. */
181
+ sessionMessage: string;
182
+ sessionMessageOk: boolean;
183
+ sessionMessageFailed: boolean;
184
+ noSessionMessage: boolean;
148
185
  }
149
186
 
150
187
  function inputId(): string {
@@ -158,6 +195,15 @@ function inputId(): string {
158
195
  return screenId.startsWith("himi_service_") ? screenId.slice("himi_service_".length) : "";
159
196
  }
160
197
 
198
+ /** The session an Agenda row opened this screen on, from the same input as `id`: `sessionId`
199
+ * (the Wix session id, "" for a session without one) and `start` (its ISO instant). No valid
200
+ * `start` — opened from the Services list — and the screen has no session. */
201
+ function inputSession(): { sessionId: string; start: string } {
202
+ const input = (globalThis as { himalaya?: { input?: { sessionId?: unknown; start?: unknown } } }).himalaya?.input;
203
+ const start = typeof input?.start === "string" && Number.isFinite(Date.parse(input.start)) ? input.start : "";
204
+ return { sessionId: start && typeof input?.sessionId === "string" ? input.sessionId.trim() : "", start };
205
+ }
206
+
161
207
  function stringOf(value: unknown): string {
162
208
  return typeof value === "string" ? value.trim() : "";
163
209
  }
@@ -546,12 +592,146 @@ async function withGroupAvailability(s: S, service: WixService): Promise<S> {
546
592
  }
547
593
  }
548
594
 
595
+ // ---- The session from the Agenda -------------------------------------------------
596
+
597
+ /** The session as last read, with the member its place was read for: a join checks against them. */
598
+ interface Picked { session: Session; place: MemberPlace | undefined; who: MemberAccess | undefined }
599
+
600
+ const NO_SESSION = {
601
+ sessionId: "", sessionStart: "", sessionWhen: "", sessionDate: "", sessionTime: "", sessionControl: "" as const, closedLabel: "",
602
+ };
603
+ const NO_SESSION_MESSAGE = { sessionMessage: "", sessionMessageOk: false, sessionMessageFailed: false };
604
+
605
+ let picked: Picked | undefined;
606
+ /** The record the last load showed: a join that finds another member re-reads its session. */
607
+ let loadedService: WixService | undefined;
608
+ /** A waitlist join is in flight: a second tap registers nothing. */
609
+ let joining = false;
610
+
611
+ /** The input session as the Agenda read it: the service's availability on that LOCAL day (the
612
+ * Agenda reads whole local days), the entry with the session id — or, for a session without
613
+ * one, the same start instant — and the signed-in member's place in it. `undefined` when the
614
+ * screen has no session, the service is not a class or course, the session is no longer listed,
615
+ * or a read fails: the screen then offers "Choose a Time", as from the Services list. */
616
+ async function readSession(service: WixService): Promise<Picked | undefined> {
617
+ const want = inputSession();
618
+ const type = stringOf(service.type).toUpperCase();
619
+ if (!want.start || (type !== "CLASS" && type !== "COURSE")) return undefined;
620
+ const day = dayKey(want.start);
621
+ try {
622
+ const from = new Date(`${day}T00:00:00`).toISOString();
623
+ const to = new Date(`${dayKey(addDays(day, 1))}T00:00:00`).toISOString();
624
+ const res = await queryServiceAvailability(service.id, from, to, { bookable: false });
625
+ const sessions = (res.availabilityEntries ?? []).map((entry) => sessionOf(entry, service)).filter((x): x is Session => !!x);
626
+ const at = Date.parse(want.start);
627
+ // Two sessions of a class can start at the same instant: the session id decides when there is one.
628
+ const session = want.sessionId
629
+ ? sessions.find((x) => x.sessionId === want.sessionId)
630
+ : sessions.find((x) => !x.sessionId && Date.parse(x.start) === at) ?? sessions.find((x) => Date.parse(x.start) === at);
631
+ if (!session) return undefined;
632
+ // A failed bookings read is not "no place": Book on a session the member holds invites a
633
+ // duplicate, so the screen falls back to Choose a Time instead (placesIn throws).
634
+ const { who, found } = await placesIn(day, day, [session]);
635
+ return { session, place: found.get(session.key), who };
636
+ } catch (err) {
637
+ // Not silent: the screen still offers "Choose a Time", and the host log says why it did not
638
+ // offer the session's own control.
639
+ console.warn("himi_service: the Agenda session could not be read; offering Choose a Time", err);
640
+ return undefined;
641
+ }
642
+ }
643
+
644
+ /** How long the session read may take before the screen paints without it. The read asks the host
645
+ * for the member's identity, and a host whose identity dispatch never settles must not pin the
646
+ * skeleton (the time screen's D5 case). */
647
+ const SESSION_READ_BUDGET_MS = 10_000;
648
+
649
+ /** `readSession`, or `undefined` once the budget is spent. A worker engine with no timer waits
650
+ * for the read itself. */
651
+ function readSessionBounded(service: WixService): Promise<Picked | undefined> {
652
+ const host = globalThis as { setTimeout?: (fn: () => void, ms: number) => unknown; clearTimeout?: (timer: unknown) => void };
653
+ const read = readSession(service);
654
+ if (typeof host.setTimeout !== "function") return read;
655
+ return new Promise((resolve) => {
656
+ const timer = host.setTimeout!(() => resolve(undefined), SESSION_READ_BUDGET_MS);
657
+ void read.then((sel) => {
658
+ try { host.clearTimeout?.(timer); } catch { /* best-effort */ }
659
+ resolve(sel);
660
+ });
661
+ });
662
+ }
663
+
664
+ /** A class session's own spots: "4 spots left", or full — with "You can join the waitlist" only
665
+ * when Join Waitlist is offered. The service-wide line describes the NEXT session, which may be
666
+ * another one. Unknown capacity (the site reports none) shows no line, as on the Agenda row. */
667
+ function sessionSpots(session: Session, control: SessionControl): Pick<S, "spotsText" | "spotsWarning"> {
668
+ if (session.total <= 0) return { spotsText: "", spotsWarning: false };
669
+ if (session.open > 0) return { spotsText: spotText(session.open), spotsWarning: session.open <= 3 };
670
+ return {
671
+ spotsText: control === "waitlist"
672
+ ? copyParam("service.classFullWaitlist", "The class is fully booked. You can join the waitlist.")
673
+ : copyParam("service.classFull", "The class is fully booked."),
674
+ spotsWarning: false,
675
+ };
676
+ }
677
+
678
+ /** `s` showing `sel` — or no session. Records `sel` as the session a join acts on. */
679
+ function withSession(s: S, sel: Picked | undefined): S {
680
+ picked = sel;
681
+ if (!sel) return derive({ ...s, ...NO_SESSION });
682
+ const { session, place } = sel;
683
+ const { control, closedLabel } = sessionControl(session, place);
684
+ const date = dayLabel(session.start, { absolute: true });
685
+ const time = Date.parse(session.end) > Date.parse(session.start)
686
+ ? `${timeLabel(session.start)} – ${timeLabel(session.end)}`
687
+ : timeLabel(session.start);
688
+ return derive({
689
+ ...s,
690
+ ...(session.kind === "class" ? sessionSpots(session, control) : {}),
691
+ sessionId: session.sessionId,
692
+ sessionStart: session.start,
693
+ sessionDate: date,
694
+ sessionTime: time,
695
+ // The start only: beside "Change Time" a full range broke mid-range on a phone. The fact row
696
+ // below carries the range.
697
+ sessionWhen: [date, timeLabel(session.start)].filter(Boolean).join(" · "),
698
+ sessionControl: control,
699
+ closedLabel,
700
+ });
701
+ }
702
+
703
+ /** The member's name for a waitlist registration, when their member record has one (the Agenda's
704
+ * rule). The email alone registers; a failed profile read registers by email alone. */
705
+ async function memberName(): Promise<{ firstName?: string; lastName?: string }> {
706
+ const res = await getMyMember({ fieldsets: ["FULL"] }).catch(() => undefined);
707
+ const contact = res?.member?.contact as { firstName?: string; lastName?: string } | undefined;
708
+ return { firstName: contact?.firstName || undefined, lastName: contact?.lastName || undefined };
709
+ }
710
+
711
+ const sameMember = (a: MemberAccess | undefined, b: MemberAccess | undefined): boolean =>
712
+ !!a?.email && a.email === b?.email && a.scope === b?.scope;
713
+
714
+ /** Where a visitor's Join Waitlist signs in: the app's sign-in screen and how it opens, from the
715
+ * appear (or tap) params — Branded Lite passes `himi_sign_in` as a sheet. */
716
+ let signInScreenId = "";
717
+ let signInPresentation: "" | "sheet" | "full_screen" = "";
718
+ function rememberSignIn(arg: unknown): void {
719
+ const a = (arg ?? {}) as { signInScreenId?: unknown; signInPresentation?: unknown };
720
+ if (typeof a.signInScreenId === "string" && a.signInScreenId.trim()) signInScreenId = a.signInScreenId.trim();
721
+ if (a.signInPresentation === "sheet" || a.signInPresentation === "full_screen") signInPresentation = a.signInPresentation;
722
+ }
723
+
549
724
  // ---- State -----------------------------------------------------------------------
550
725
 
551
726
  function derive(s: S): S {
552
727
  const isLoading = s.status === "loading";
553
728
  const hasService = !!s.name;
554
729
  const hasImage = s.images.length > 0;
730
+ const control = s.sessionControl;
731
+ // The session's own control replaces the service-wide one; a course session not yet booked
732
+ // keeps the course footer ("course"), as on the Agenda, where it opens this screen.
733
+ const sessionCta = control !== "" && control !== "course";
734
+ const ownPlace = control === "booked" || control === "waitlisted";
555
735
  return {
556
736
  ...s,
557
737
  isLoading,
@@ -593,20 +773,33 @@ function derive(s: S): S {
593
773
  noPay: !s.payText,
594
774
  noPaySub: !s.paySub,
595
775
  notRequestToBook: !s.requestToBook,
596
- hideChooseTime: s.isCourse || s.requestToBook,
597
- hideRequestTime: s.isCourse || !s.requestToBook,
598
- noCourseCtaBook: !s.courseCtaBook,
599
- noCourseCtaRequest: !s.courseCtaRequest,
600
- noCourseCtaFull: !s.courseCtaFull,
601
- noCourseCtaClosed: !s.courseCtaClosed,
776
+ hideChooseTime: s.isCourse || s.requestToBook || sessionCta,
777
+ hideRequestTime: s.isCourse || !s.requestToBook || sessionCta,
778
+ noCourseCtaBook: !s.courseCtaBook || sessionCta,
779
+ noCourseCtaRequest: !s.courseCtaRequest || sessionCta,
780
+ noCourseCtaFull: !s.courseCtaFull || sessionCta,
781
+ noCourseCtaClosed: !s.courseCtaClosed || sessionCta,
782
+ noSession: !s.sessionWhen,
783
+ // Book where the Agenda row says Book; "Request to Book" when the business confirms each one.
784
+ noSessionBook: control !== "book" || s.requestToBook,
785
+ noSessionRequest: control !== "book" || !s.requestToBook,
786
+ noSessionWaitlist: control !== "waitlist",
787
+ noSessionClosed: control !== "closed",
788
+ noSessionBooked: control !== "booked",
789
+ noSessionWaitlisted: control !== "waitlisted",
790
+ // Change Time opens the time screen, which cannot book a service closed to online booking.
791
+ noChangeTime: (control !== "book" && control !== "waitlist" && control !== "closed") || !s.bookableOnline,
792
+ noSessionMessage: !s.sessionMessage,
602
793
  notBookableOnline: !s.bookableOnline,
603
794
  noAbout: !s.about && !s.aboutJson,
604
795
  noPolicy: s.policyLines.length === 0,
605
796
  policyCollapsed: !s.policyExpanded,
606
- noFacts: !s.courseDates && !s.durationText && !s.spotsText && s.locations.length === 0 && !s.online && !s.staffText && !s.payText,
797
+ noFacts: !s.sessionWhen && !s.courseDates && !s.durationText && !s.spotsText && s.locations.length === 0 && !s.online && !s.staffText && !s.payText,
607
798
  noKeyFacts: !s.priceText && !s.durationText && !s.category,
608
799
  hideContent: isLoading || s.errored || !hasService,
609
- hideFooter: isLoading || s.errored || !hasService || !s.bookableOnline,
800
+ // The session's own status still shows when the service has since closed online booking: the
801
+ // member's place, or the Agenda row's disabled Book with its reason.
802
+ hideFooter: isLoading || s.errored || !hasService || (!s.bookableOnline && !ownPlace && control !== "closed"),
610
803
  emptyText: s.errored
611
804
  ? copyParam("service.errorText", "Service details could not be loaded.")
612
805
  : copyParam("service.emptyText", "This service is not available."),
@@ -701,7 +894,12 @@ const svc = defineService<S, {
701
894
  toggleSchedule: (s: S) => S;
702
895
  openDirections: (s: S, arg?: unknown) => Promise<S>;
703
896
  chooseTime: (s: S) => S;
897
+ book: (s: S) => S;
898
+ changeTime: (s: S) => S;
899
+ joinWaitlist: (s: S, arg?: unknown) => Promise<S>;
704
900
  }>("himiBookingService", {
901
+ // A join's outcome belongs to that visit: a cold start must not replay it from saved state.
902
+ notHydrated: ["sessionMessage", "sessionMessageOk", "sessionMessageFailed", "noSessionMessage"],
705
903
  state: derive({
706
904
  status: "loading",
707
905
  id,
@@ -796,24 +994,42 @@ const svc = defineService<S, {
796
994
  notLoading: false,
797
995
  hasService: false,
798
996
  noEmpty: true,
997
+ ...NO_SESSION,
998
+ noSession: true,
999
+ noSessionBook: true,
1000
+ noSessionRequest: true,
1001
+ noSessionWaitlist: true,
1002
+ noSessionClosed: true,
1003
+ noSessionBooked: true,
1004
+ noSessionWaitlisted: true,
1005
+ noChangeTime: true,
1006
+ ...NO_SESSION_MESSAGE,
1007
+ noSessionMessage: true,
799
1008
  }),
800
1009
  actions: {
801
1010
  load: async (s, arg) => {
802
1011
  stashCopy(arg);
1012
+ rememberSignIn(arg);
803
1013
  if (!s.id) return derive({ ...s, status: "ready", errored: true });
804
- const start = { ...s, policyExpanded: false, scheduleExpanded: false };
1014
+ // A new view: a join's outcome from before is not news when the screen shows again.
1015
+ const start = { ...s, policyExpanded: false, scheduleExpanded: false, ...NO_SESSION_MESSAGE };
805
1016
  try {
806
1017
  const service = await fetchService(s.id);
807
- return service ? withGroupAvailability(withService(start, service), service) : derive({ ...start, status: "ready" });
1018
+ if (!service) return withSession(derive({ ...start, status: "ready" }), undefined);
1019
+ loadedService = service;
1020
+ // The session the Agenda row opened on, read beside the service-wide availability.
1021
+ const [shown, sel] = await Promise.all([withGroupAvailability(withService(start, service), service), readSessionBounded(service)]);
1022
+ return withSession(shown, sel);
808
1023
  } catch {
809
1024
  // Offline: paint from the last-synced services cache (written by the list
810
1025
  // worker) before falling back to the error state. The cache holds the name,
811
1026
  // the category and one image; every other row stays hidden.
812
1027
  const cached = await readCached(s.id);
1028
+ // No session state offline: the member's place in it cannot be read.
813
1029
  if (cached) {
814
1030
  const name = String(cached.name ?? "") || "Service";
815
1031
  const image = String(cached.image ?? "");
816
- return derive({
1032
+ return withSession({
817
1033
  ...start,
818
1034
  status: "ready",
819
1035
  errored: false,
@@ -822,9 +1038,9 @@ const svc = defineService<S, {
822
1038
  category: String(cached.category ?? ""),
823
1039
  images: image ? [{ url: image, alt: name }] : [],
824
1040
  timeScreenId: `himi_time_${s.id}`,
825
- });
1041
+ }, undefined);
826
1042
  }
827
- return derive({ ...start, status: "ready", errored: true, name: "" });
1043
+ return withSession({ ...start, status: "ready", errored: true, name: "" }, undefined);
828
1044
  }
829
1045
  },
830
1046
  openLink: async (_state, arg) => { await openRichLink(arg); return undefined; },
@@ -844,6 +1060,53 @@ const svc = defineService<S, {
844
1060
  // not a `himi.open` with a `$service:` id param: the iOS and Android sticky footers
845
1061
  // dispatch their taps without resolving `$service:` params, so the id never arrived.
846
1062
  chooseTime: (s) => (s.id && s.hasService ? navigate(s, { screenId: "himi_time", args: { id: s.id, ...(s.isCourse ? { course: "true" } : {}) } }) : s),
1063
+ // Book the session the screen opened on: Choose Time opens with it picked — the Agenda row's
1064
+ // own Book (`{ id, start, sessionId }`), so the member does not choose the time again.
1065
+ book: (s) => (s.id && s.hasService && s.sessionControl === "book"
1066
+ ? navigate(s, { screenId: "himi_time", args: { id: s.id, start: s.sessionStart, sessionId: s.sessionId } })
1067
+ : s),
1068
+ // "Change time": Choose Time for the service, with nothing picked.
1069
+ changeTime: (s) => (s.id && s.hasService ? navigate(s, { screenId: "himi_time", args: { id: s.id } }) : s),
1070
+ // Join the session's waitlist from here — the Agenda row's join: a visitor signs in first; the
1071
+ // member's email and name are read, and the registration goes out only while the member is the
1072
+ // one the session's place was read for. The screen then shows "On waitlist".
1073
+ joinWaitlist: async (s, arg): Promise<S> => {
1074
+ rememberSignIn(arg);
1075
+ const sel = picked;
1076
+ if (!sel || joining || s.sessionControl !== "waitlist" || !sel.session.sessionId) return s;
1077
+ joining = true;
1078
+ try {
1079
+ const before = await memberAccess().catch(() => undefined);
1080
+ if (!before?.email) {
1081
+ joining = false;
1082
+ // Without a sign-in screen, Choose Time opens on the session: it signs a guest in and
1083
+ // joins the waitlist itself. A sign-in sheet only closes, and this screen loads again.
1084
+ if (!signInScreenId) return navigate(s, { screenId: "himi_time", args: { id: s.id, start: s.sessionStart, sessionId: s.sessionId } });
1085
+ return navigate(s, signInPresentation
1086
+ ? { screenId: signInScreenId, presentation: signInPresentation, args: { mode: "signin", modal: "1" } }
1087
+ : { screenId: signInScreenId, args: { mode: "signin" } });
1088
+ }
1089
+ const name = await memberName();
1090
+ const current = await memberAccess().catch(() => undefined);
1091
+ if (!sameMember(before, current) || !sameMember(sel.who, current)) {
1092
+ // Another member since the load — or the load was a visitor's, whose place was never
1093
+ // read: read the session again for whoever is signed in now; nothing is registered.
1094
+ const reread = loadedService ? await readSession(loadedService) : undefined;
1095
+ joining = false;
1096
+ return withSession(svc.getState() as S, reread);
1097
+ }
1098
+ const res = await registerToWaitlist(sel.session.sessionId, { email: current!.email, ...name });
1099
+ const place: MemberPlace = { bookingId: res?.booking?.id ?? "", revision: "", place: "waitlisted", registrationId: res?.registrationId, confirmed: false };
1100
+ joining = false;
1101
+ // Against the state as it is NOW; a load that landed meanwhile read this session again.
1102
+ const now = svc.getState() as S;
1103
+ return withSession({ ...now, sessionMessage: copyParam("agenda.joinedWaitlist", "You're on the waitlist."), sessionMessageOk: true, sessionMessageFailed: false },
1104
+ { ...(picked?.session.key === sel.session.key ? picked : sel), place });
1105
+ } catch {
1106
+ joining = false;
1107
+ return derive({ ...(svc.getState() as S), sessionMessage: copyParam("agenda.waitlistFailed", "Couldn't join the waitlist. Try again."), sessionMessageOk: false, sessionMessageFailed: true });
1108
+ }
1109
+ },
847
1110
  openDirections: async (s, arg) => {
848
1111
  const url = s.locations[tappedIndex(arg)]?.directionsUrl ?? "";
849
1112
  if (!url) return s;