@wix/himalaya-cli 0.1613.0 → 0.1615.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 (130) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/ui/src/index.d.ts +53 -13
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +3 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/ui.mjs +31 -5
  4. package/dist/cli.mjs +1638 -1330
  5. package/dist/preview-assets.generated.bin +0 -0
  6. package/fixtures/wix-canned.json +4 -0
  7. package/guides/component-reference.json +1 -1
  8. package/guides/component-reference.md +3 -0
  9. package/guides/designing-himi-screens.md +4 -1
  10. package/guides/hhp.md +2 -2
  11. package/guides/sdk-reference.json +28 -4
  12. package/package.json +2 -2
  13. package/template-icons/salon-spa.png +0 -0
  14. package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
  15. package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
  16. package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
  17. package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
  18. package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
  19. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
  20. package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  21. package/templates/beauty-salon/tier5-src/himi-branded-lite-home/index.ts +178 -12
  22. package/templates/branded-lite/dev/index.ts +192 -0
  23. package/templates/branded-lite/l10n/en.json +1 -1
  24. package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +5 -2
  25. package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
  26. package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
  27. package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
  28. package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
  29. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
  30. package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  31. package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
  32. package/templates/branded-lite/tier5-src/himi-branded-lite-home/index.ts +178 -12
  33. package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
  34. package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
  35. package/templates/community-club/tier5-src/himi-branded-lite-home/index.ts +178 -12
  36. package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
  37. package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
  38. package/templates/events-meetups/tier5-src/himi-branded-lite-home/index.ts +178 -12
  39. package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
  40. package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
  41. package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
  42. package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
  43. package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
  44. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
  45. package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  46. package/templates/gym-fitness/tier5-src/himi-branded-lite-home/index.ts +178 -12
  47. package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
  48. package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
  49. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-home/index.ts +178 -12
  50. package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
  51. package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
  52. package/templates/podcast-creator/tier5-src/himi-branded-lite-home/index.ts +178 -12
  53. package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
  54. package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
  55. package/templates/portfolio-creative/tier5-src/himi-branded-lite-home/index.ts +178 -12
  56. package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
  57. package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
  58. package/templates/restaurant/tier5-src/himi-branded-lite-home/index.ts +178 -12
  59. package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
  60. package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
  61. package/templates/retail-store/tier5-src/himi-branded-lite-home/index.ts +178 -12
  62. package/templates/salon-spa/README.md +53 -0
  63. package/templates/salon-spa/config.ts +272 -0
  64. package/templates/salon-spa/dev/index.ts +19893 -0
  65. package/templates/salon-spa/himalaya.content.json +49 -0
  66. package/templates/salon-spa/himalaya.template.json +14 -0
  67. package/templates/salon-spa/icon.png +0 -0
  68. package/templates/salon-spa/platforms.json +1 -0
  69. package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
  70. package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
  71. package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
  72. package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
  73. package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
  74. package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
  75. package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
  76. package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
  77. package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
  78. package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
  79. package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
  80. package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
  81. package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
  82. package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
  83. package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
  84. package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
  85. package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
  86. package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
  87. package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
  88. package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
  89. package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
  90. package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
  91. package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
  92. package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
  93. package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
  94. package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
  95. package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  96. package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
  97. package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
  98. package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
  99. package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
  100. package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
  101. package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
  102. package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
  103. package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
  104. package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
  105. package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
  106. package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
  107. package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
  108. package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
  109. package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
  110. package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
  111. package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
  112. package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
  113. package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
  114. package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
  115. package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
  116. package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
  117. package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
  118. package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
  119. package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
  120. package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
  121. package/templates/salon-spa/tokens.json +341 -0
  122. package/templates/yoga-studio/dev/index.ts +192 -0
  123. package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
  124. package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
  125. package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
  126. package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
  127. package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
  128. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
  129. package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  130. package/templates/yoga-studio/tier5-src/himi-branded-lite-home/index.ts +178 -12
@@ -0,0 +1,950 @@
1
+ // himi-branded-lite-agenda — the Bookings Agenda: every upcoming CLASS and COURSE session of the
2
+ // site, under a collapsible week/month calendar.
3
+ //
4
+ // Everyone sees the whole schedule. A signed-in member also sees their own place in it: a
5
+ // session they booked reads "Booked", one they wait for reads "On waitlist", and each opens
6
+ // a sheet to add it to the calendar, view the class, or cancel / leave. A full session with
7
+ // waitlist room offers "Join Waitlist"; a full one without, or one outside the booking window,
8
+ // says so ("Full", "Not open yet", "Closed") instead of offering a Book that would fail.
9
+ //
10
+ // Appointment services are left out on purpose: their availability is every free slot of
11
+ // the day, which is a booking grid, not an agenda of things that happen. Course sessions are
12
+ // listed, tagged "Course", but a course is booked as a whole on its service screen, never one
13
+ // session: its row offers "View Course", and shows no spots (a session's open spots do not say
14
+ // whether the course has room).
15
+ //
16
+ // Action results (joined, cancelled, left, added to the calendar, or a failure) show in a
17
+ // non-modal sheet above the tab bar, so a member scrolled far down the list still sees them.
18
+ //
19
+ // The calendar marks every day with a session (one dot) and today (its own ring). The list
20
+ // starts at the selected day, so a day with nothing on it shows the next sessions instead
21
+ // of an empty page — the agenda answers "what is on from here".
22
+ import { defineService, navigate } from "@himalaya/state";
23
+ import { addDays, dayKey, dayLabel, timeLabel, toDate, weekOf, type DayDecoration, type Weekday } from "@himalaya/time";
24
+ import { createEvent, getPermissionStatus, requestPermission } from "@himalaya/calendar";
25
+ import { open } from "@himalaya/open-url";
26
+ import {
27
+ cancelBookingAsMember, isBookingPolicyViolation, leaveWaitlist, listWaitlists, queryBookings, queryServiceAvailability,
28
+ queryServices, registerToWaitlist, type WixAvailabilityEntry, type WixBooking, type WixService,
29
+ } from "@himalaya/wix-bookings";
30
+ import { getMyMember } from "@himalaya/wix-members";
31
+ import { LOADING, loadFailed, type LoadFlags } from "../_shared/branded-load-state.js";
32
+ import { copyParam, copyTemplate, stashCopy } from "../_shared/himi-copy.js";
33
+ import { slotStartInstant } from "../_shared/himi-bookings.js";
34
+ import { formatServicePrice } from "../_shared/wix-service-price.js";
35
+ import { compareText } from "../_shared/engine-intl.js";
36
+ import { assertMemberAccess, memberAccess, memberAccessChanged, type MemberAccess } from "../_shared/member-access.js";
37
+ import { atLeast } from "../_shared/member-account-routes.js";
38
+ import { get as getAppInfo } from "@himalaya/app-info";
39
+ import {
40
+ distinctChoices, effectiveLocation, locationMenu, readSharedLocation, serviceLocationChoices, writeSharedLocation,
41
+ type LocationChoice, type LocationOptionRow,
42
+ } from "../_shared/booking-location.js";
43
+ import {
44
+ bySession, markWaitlisted, placesIn, placesOf, sessionControl, sessionKey, sessionOf, spotsLabel,
45
+ type MemberPlace, type Place, type Session,
46
+ } from "../_shared/booking-session.js";
47
+
48
+ // The session model is shared with the service screen opened from a row; re-exported for the tests.
49
+ export { bySession, markWaitlisted, placesOf, sessionKey, sessionOf };
50
+ export type { MemberPlace, Place, Session };
51
+
52
+ /** How far past the selected day the list reaches. */
53
+ export const AGENDA_DAYS = 28;
54
+ /** Services whose sessions are fixed events: a class's are each booked on their own, a course's
55
+ * are listed but the course is booked as a whole (on its service screen). */
56
+ const SESSION_TYPES = new Set(["CLASS", "COURSE"]);
57
+ /** The `Agenda` wire shape: declared fields plus a flat string `extra` map for the row. */
58
+ export interface AgendaEvent {
59
+ id: string;
60
+ title: string;
61
+ start: string;
62
+ end: string;
63
+ extra: Record<string, string>;
64
+ }
65
+
66
+ interface S extends LoadFlags {
67
+ firstWeekday: number;
68
+ today: string;
69
+ selectedDate: string;
70
+ rangeStart: string;
71
+ rangeEnd: string;
72
+ decorations: DayDecoration[];
73
+ events: AgendaEvent[];
74
+ scrollTo: string;
75
+ signedIn: boolean;
76
+ /** An older Android binary: its Agenda drops each row's fields, so the list is replaced by a
77
+ * line asking to update. */
78
+ updateRequired: boolean;
79
+ /** The update-required line — a state of the screen, not an action's result. */
80
+ updateMessage: string;
81
+ /** The list is hidden while loading or failed, when an update is required, and when it is empty. */
82
+ listHidden: boolean;
83
+ /** Loaded, and the site has no class or course at all. */
84
+ noClasses: boolean;
85
+ /** Loaded, the site has classes, but none from the selected day to the end of the loaded days. */
86
+ nothingAhead: boolean;
87
+ /** The negations a `hiddenPath` reads: each empty state shows only when its own flag is set. */
88
+ notNoClasses: boolean;
89
+ notNothingAhead: boolean;
90
+ // The result sheet: one action's outcome, non-modal, above the tab bar. Two slots that take turns
91
+ // (A = resultOpen, B = resultOpenB): each new result presents in the other one, so it gets its own
92
+ // eight-second countdown rather than the rest of the previous result's.
93
+ resultOpen: boolean;
94
+ resultOpenB: boolean;
95
+ resultText: string;
96
+ /** It went through (a check) … */
97
+ resultOk: boolean;
98
+ /** … or it did not, or needs the member (an info mark). Exactly one is false while open. */
99
+ resultInfo: boolean;
100
+ // The session sheet (Booked / On waitlist).
101
+ sheetOpen: boolean;
102
+ sheetTitle: string;
103
+ sheetWhen: string;
104
+ sheetMeta: string;
105
+ /** Add to Calendar is for a booked session only. */
106
+ sheetHideCalendar: boolean;
107
+ sheetHideCancel: boolean;
108
+ sheetHideLeave: boolean;
109
+ sheetBusy: boolean;
110
+ /** The sheet session's service: View class reads its declared route (`nav`) from it. "" with no sheet. */
111
+ sheetServiceId: string;
112
+ /** …and its session (id and start), so the service screen opens on it. */
113
+ sheetSessionId: string;
114
+ sheetStart: string;
115
+ // The location picker (two or more locations only): `locationMenu()` in booking-location.ts.
116
+ locationOptions: LocationOptionRow[];
117
+ locationLabel: string;
118
+ locationA11y: string;
119
+ hasLocationMenu: boolean;
120
+ noLocationMenu: boolean;
121
+ /** A location is chosen: the pin turns accent and the line under the title names it. */
122
+ locationActive: boolean;
123
+ noLocationActive: boolean;
124
+ locationSheetOpen: boolean;
125
+ /** A location is chosen and has no session in the loaded days: its own empty state. */
126
+ locationEmpty: boolean;
127
+ notLocationEmpty: boolean;
128
+ }
129
+
130
+ const flag = (on: boolean): string => (on ? "true" : "");
131
+
132
+ /** "1 hr", "45 min", "1 hr 30 min" — the length under the time. */
133
+ export function lengthLabel(start: string, end: string): string {
134
+ const mins = Math.round((Date.parse(end) - Date.parse(start)) / 60_000);
135
+ if (!Number.isFinite(mins) || mins <= 0) return "";
136
+ const h = Math.floor(mins / 60), m = mins % 60;
137
+ return h && m
138
+ ? copyTemplate("agenda.lengthHoursMinutes", "{h} hr {m} min", { h, m })
139
+ : h ? copyTemplate("agenda.lengthHours", "{h} hr", { h }) : copyTemplate("agenda.lengthMinutes", "{m} min", { m });
140
+ }
141
+
142
+ /** The parts that are known, joined by a middle dot: "Omer Levi · Studio Zen". */
143
+ const joined = (...parts: string[]): string => parts.filter(Boolean).join(" · ");
144
+
145
+ /** One session → one Agenda row. Exactly one of the trailing controls is visible. `lastOfDay`: the
146
+ * day's last row draws no divider — the next day's header band starts right under it. */
147
+ export function eventOf(s: Session, place: MemberPlace | undefined, lastOfDay = false): AgendaEvent {
148
+ const { control, closedLabel } = sessionControl(s, place);
149
+ const course = s.kind === "course";
150
+ const time = timeLabel(s.start);
151
+ return {
152
+ id: s.key,
153
+ title: s.title,
154
+ start: s.start,
155
+ end: s.end,
156
+ extra: {
157
+ serviceId: s.serviceId,
158
+ // Choose Time opens on THIS session: two of a class can start at the same time.
159
+ sessionId: s.sessionId,
160
+ time,
161
+ length: lengthLabel(s.start, s.end),
162
+ meta: joined(s.staff, s.location),
163
+ availability: joined(s.price, spotsLabel(s)),
164
+ isCourse: flag(course),
165
+ notCourse: flag(!course),
166
+ detailsA11y: s.staff
167
+ ? copyTemplate("agenda.detailsA11y", "{title}, {time}, with {staff}", { title: s.title, time, staff: s.staff })
168
+ : [s.title, time].filter(Boolean).join(", "),
169
+ bookA11y: copyTemplate("agenda.bookA11y", "Book {title}, {time}", { title: s.title, time }),
170
+ hideBook: flag(control !== "book"),
171
+ hideWaitlist: flag(control !== "waitlist"),
172
+ hideCourse: flag(control !== "course"),
173
+ hideBooked: flag(control !== "booked"),
174
+ hideOnWaitlist: flag(control !== "waitlisted"),
175
+ hideClosed: flag(control !== "closed"),
176
+ closedLabel,
177
+ lastOfDay: flag(lastOfDay),
178
+ },
179
+ };
180
+ }
181
+
182
+ /** The first and last cell any month grid of `day`'s month can have, padding included. */
183
+ function monthGrid(day: string): { start: string; end: string } {
184
+ const d = toDate(day);
185
+ if (!d) return { start: day, end: day };
186
+ return { start: dayKey(new Date(d.getFullYear(), d.getMonth(), 1 - 6)), end: dayKey(new Date(d.getFullYear(), d.getMonth() + 1, 6)) };
187
+ }
188
+
189
+ /** One dot per day that has a session — "something is on", not how much — and the visible
190
+ * days before today dimmed and unselectable: the agenda is what is still to come. */
191
+ export function decorationsOf(sessions: Session[], today: string, rangeStart: string, rangeEnd: string): DayDecoration[] {
192
+ const out: DayDecoration[] = [];
193
+ const dimmed = new Set<string>();
194
+ const dim = (from: string, to: string) => {
195
+ for (let day = from; day && day < today && day <= to; day = dayKey(addDays(day, 1))) {
196
+ if (!dimmed.has(day)) { dimmed.add(day); out.push({ date: day, disabled: true }); }
197
+ }
198
+ };
199
+ // Bounded by the visible days: a page a year back dims its 7-42 cells, not a year.
200
+ dim(rangeStart, rangeEnd);
201
+ // And this month's grid: an older binary (no week mode) draws it on first open, while it
202
+ // reports nothing until the member pages.
203
+ dim(monthGrid(today).start, today);
204
+ for (const date of [...new Set(sessions.filter((s) => s.day >= today).map((s) => s.day))].sort()) out.push({ date, dotCount: 1 });
205
+ return out;
206
+ }
207
+
208
+ /** The days to load: the visible days (never before today) through AGENDA_DAYS past the
209
+ * selected day, so the list under the calendar always has its full reach. */
210
+ export function windowFor(today: string, rangeStart: string, rangeEnd: string, selected: string): { start: string; end: string } {
211
+ const start = rangeStart > today ? rangeStart : today;
212
+ const from = selected > today ? selected : today;
213
+ // Through the last cell of the selected day's month grid as well, which is what an older
214
+ // binary draws before it has reported any page: every dot on it is then real.
215
+ const end = [rangeEnd, dayKey(addDays(from, AGENDA_DAYS)), monthGrid(from).end].reduce((a, b) => (b > a ? b : a));
216
+ return { start, end };
217
+ }
218
+
219
+ /** Where the selection goes when the calendar pages away from it: the first day of the
220
+ * page (the 1st of the titled month in month view — the grid's first cell is padding),
221
+ * or today when that is on the page. A page wholly before today has no day to select (past
222
+ * days are disabled), so `current` stays selected and the list keeps showing from it. */
223
+ export function selectionForRange(today: string, rangeStart: string, rangeEnd: string, current = today): string {
224
+ const { first, last } = titledDays(rangeStart, rangeEnd);
225
+ if (today >= first && today <= last) return today;
226
+ return last < today ? current : first;
227
+ }
228
+
229
+ /** The days a page is about: the whole week, or a month grid's TITLED month — its padding cells
230
+ * belong to the neighbouring months. */
231
+ export function titledDays(rangeStart: string, rangeEnd: string): { first: string; last: string } {
232
+ let first = rangeStart;
233
+ let last = rangeEnd;
234
+ const span = Math.round((Date.parse(rangeEnd) - Date.parse(rangeStart)) / 86_400_000);
235
+ if (span > 7) {
236
+ // A month grid: its padding cells belong to the neighbouring months. Today counts only when
237
+ // it is in the TITLED month — a today in the trailing padding would move the selection, and
238
+ // with it the grid, a month forward.
239
+ const d = toDate(rangeStart);
240
+ if (d) {
241
+ const month = rangeStart.endsWith("-01") ? d : new Date(d.getFullYear(), d.getMonth() + 1, 1);
242
+ first = dayKey(month);
243
+ last = dayKey(new Date(month.getFullYear(), month.getMonth() + 1, 0));
244
+ }
245
+ }
246
+ return { first, last };
247
+ }
248
+
249
+ /** The month grid (whole weeks) of `day`'s month, as a calendar draws it. */
250
+ function monthGridOf(day: string, firstWeekday: number): { start: string; end: string } | undefined {
251
+ const d = toDate(day);
252
+ if (!d) return undefined;
253
+ const fw = (((firstWeekday % 7) + 7) % 7) as Weekday;
254
+ const start = weekOf(new Date(d.getFullYear(), d.getMonth(), 1), fw)[0];
255
+ const end = weekOf(new Date(d.getFullYear(), d.getMonth() + 1, 0), fw)[6];
256
+ return start && end ? { start, end } : undefined;
257
+ }
258
+
259
+ /** Does the selection survive a page change? Inside the page's TITLED days, yes — a selection in a
260
+ * month grid's padding belongs to the neighbouring month. Today stays selected on any page that
261
+ * shows it: opening a week that straddles two months into the month holding most of it must not
262
+ * move the agenda off today. */
263
+ export function keepsSelection(cur: { today: string; selectedDate: string }, start: string, end: string): boolean {
264
+ const page = titledDays(start, end);
265
+ if (cur.selectedDate >= page.first && cur.selectedDate <= page.last) return true;
266
+ return !!cur.today && cur.selectedDate === cur.today && cur.today >= start && cur.today <= end;
267
+ }
268
+
269
+ function calendarStamp(ms: number): string {
270
+ return new Date(ms).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
271
+ }
272
+
273
+ /** The Google Calendar template page for a session — the fallback when the device calendar is
274
+ * refused or creates nothing. `encodeURIComponent`, never URLSearchParams: the iOS / macOS
275
+ * worker engine (JavaScriptCore) does not provide it (see himi-events-detail). */
276
+ export function calendarFallbackUrl(session: Pick<Session, "title" | "location" | "staff">, start: number, end: number): string {
277
+ const parts = ["action=TEMPLATE", `text=${encodeURIComponent(session.title)}`, `dates=${calendarStamp(start)}/${calendarStamp(end)}`];
278
+ if (session.location) parts.push(`location=${encodeURIComponent(session.location)}`);
279
+ if (session.staff) parts.push(`details=${encodeURIComponent(session.staff)}`);
280
+ return `https://calendar.google.com/calendar/render?${parts.join("&")}`;
281
+ }
282
+
283
+ // ── loading ──────────────────────────────────────────────────────────────────
284
+
285
+ let services: WixService[] | null = null;
286
+ let sessions: Session[] = [];
287
+ let places = new Map<string, MemberPlace>();
288
+ let loadedWindow = { start: "", end: "" };
289
+ let member: MemberAccess | undefined;
290
+ let sheetKey = "";
291
+ /** The location the member chose (here, on Services or on Choose Time): "" is every location. */
292
+ let chosenLocation = { id: "", label: "" };
293
+ let locationSheet = false;
294
+ /** Until when a scroll report is ignored: a tap's (or a page's) jump is still settling, and a
295
+ * report the list sent before it must not move the selection back. */
296
+ let quietUntil = 0;
297
+ /** Bumped by a location change: the list's target becomes "day|n", so the renderer applies it again
298
+ * even when the day is the same one — rows above it came or went, and the offset no longer shows
299
+ * it. Cleared by the list's own scroll and a calendar tap, whose plain day the renderer compares
300
+ * with what it reported (an echo of the member's own scroll never moves the list). */
301
+ let scrollNonce = 0;
302
+ let busy = false;
303
+ /** An Add to Calendar flow is running (a permission prompt, a save, the calendar page). Its own
304
+ * flag, not `busy`: the session sheet must still close while it runs. */
305
+ let calendarBusy = false;
306
+
307
+ async function sessionServices(): Promise<WixService[]> {
308
+ if (services) return services;
309
+ // Every page, not the first 100: a class past the first page would silently be missing. Bounded,
310
+ // so a misbehaving paging answer cannot loop forever.
311
+ const all: WixService[] = [];
312
+ for (let page = 0; page < 10; page++) {
313
+ const res = await queryServices({ query: { paging: { limit: 100, offset: page * 100 } } } as never);
314
+ const batch = res.services ?? [];
315
+ all.push(...batch);
316
+ if (batch.length < 100) break;
317
+ }
318
+ services = all.filter((svc) => {
319
+ const raw = svc as WixService & { type?: string; hidden?: boolean };
320
+ return !!svc.id && SESSION_TYPES.has((raw.type ?? "").toUpperCase()) && raw.hidden !== true;
321
+ });
322
+ return services;
323
+ }
324
+
325
+ /** Every session of every class service in [start, end]. Each service is its own
326
+ * call — the proven single-service availability query — so one service failing (a draft,
327
+ * a removed schedule) costs its own rows, never the whole agenda. */
328
+ async function sessionsIn(start: string, end: string): Promise<Session[]> {
329
+ const list = await sessionServices();
330
+ const from = new Date(`${start}T00:00:00`).toISOString();
331
+ const to = new Date(`${dayKey(addDays(end, 1))}T00:00:00`).toISOString();
332
+ const settled = await Promise.allSettled(list.map((svc) => queryServiceAvailability(svc.id, from, to, { bookable: false })));
333
+ const failures = settled.filter((r) => r.status === "rejected").length;
334
+ if (list.length && failures === list.length) throw (settled[0] as PromiseRejectedResult).reason;
335
+ const out: Session[] = [];
336
+ settled.forEach((r, i) => {
337
+ if (r.status !== "fulfilled") return;
338
+ for (const entry of r.value.availabilityEntries ?? []) {
339
+ const session = sessionOf(entry, list[i]);
340
+ if (session) out.push(session);
341
+ }
342
+ });
343
+ return ordered(out);
344
+ }
345
+
346
+ /** Sessions once each, by start then title — the list's order. */
347
+ function ordered(list: Session[]): Session[] {
348
+ const seen = new Set<string>();
349
+ return list
350
+ .filter((s) => (seen.has(s.key) ? false : (seen.add(s.key), true)))
351
+ .sort((a, b) => Date.parse(a.start) - Date.parse(b.start) || compareText(a.title, b.title));
352
+ }
353
+
354
+ /** Is `owner` — the member a place was loaded for — still the one signed in, to the same account
355
+ * and business? Asked right before a write, so a sign-out or a switch since the load never
356
+ * writes for the wrong person. */
357
+ async function sameMember(owner: MemberAccess | undefined): Promise<boolean> {
358
+ if (!owner?.email) return false;
359
+ return assertMemberAccess(owner).then(() => true, () => false);
360
+ }
361
+
362
+ /** The member's name for a waitlist registration, when their member record has one. The
363
+ * email alone registers; the name is what the business sees on its waitlist. */
364
+ async function memberName(): Promise<{ firstName?: string; lastName?: string }> {
365
+ // A failed profile read registers by email alone rather than failing the join.
366
+ const res = await getMyMember({ fieldsets: ["FULL"] }).catch(() => undefined);
367
+ const contact = res?.member?.contact as { firstName?: string; lastName?: string } | undefined;
368
+ return { firstName: contact?.firstName || undefined, lastName: contact?.lastName || undefined };
369
+ }
370
+
371
+ type ResultFields = Pick<S, "resultOpen" | "resultOpenB" | "resultText" | "resultOk" | "resultInfo">;
372
+ /** The result sheet fields as the actions last SET them — not as the service last committed them:
373
+ * an action's result counts from the moment it is made, before the action's own state commits, so
374
+ * a read landing in between cannot copy the older fields over it. */
375
+ let lastResult: ResultFields = { resultOpen: false, resultOpenB: false, resultText: "", resultOk: false, resultInfo: false };
376
+
377
+ /** `next`, with the result sheet fields as they are NOW: a read that ran while a result was closed
378
+ * (or a newer one opened) must not commit the older result fields it started from. */
379
+ function keepResult(next: S | undefined): S | undefined {
380
+ if (!next) return next;
381
+ return { ...next, ...lastResult };
382
+ }
383
+
384
+ /** Which result slot presented last: the next result takes the other one. */
385
+ let resultSeq = 0;
386
+
387
+ /** One action's outcome, for the result sheet: `ok` when it went through. */
388
+ function result(text: string, ok: boolean): ResultFields {
389
+ const b = ++resultSeq % 2 === 0;
390
+ lastResult = { resultOpen: !b, resultOpenB: b, resultText: text, resultOk: ok, resultInfo: !ok };
391
+ return lastResult;
392
+ }
393
+ const NO_RESULT = { resultOpen: false, resultOpenB: false } as const;
394
+ /** A view action (load, a day or page change, opening the session sheet) closes any result on
395
+ * screen. A write still in flight still says its outcome when it lands: the member started it, and
396
+ * its row may not be on the page they moved to, so the sheet is their only confirmation. */
397
+ function newView(s: S): S {
398
+ lastResult = { ...lastResult, ...NO_RESULT };
399
+ return { ...s, ...NO_RESULT };
400
+ }
401
+
402
+ const emptyFlags = (noClasses: boolean, nothingAhead: boolean) =>
403
+ ({ noClasses, nothingAhead, notNoClasses: !noClasses, notNothingAhead: !nothingAhead });
404
+
405
+ /** The locations the member can choose from: every class and course service's own locations, then
406
+ * any the loaded sessions name that no service lists, and the location chosen on another screen
407
+ * (Services, Choose Time) even when nothing here is at it — so the choice is kept, and its empty
408
+ * state says so, rather than silently showing everything. */
409
+ export function agendaLocations(list: readonly WixService[], inWindow: readonly Session[], chosen: { id: string; label: string }): LocationChoice[] {
410
+ return distinctChoices([
411
+ ...list.flatMap((svc) => serviceLocationChoices(svc.locations)),
412
+ ...inWindow.map((x) => ({ key: x.locationKey, label: x.location, sub: x.locationSub })),
413
+ ...(chosen.id && chosen.label ? [{ key: chosen.id, label: chosen.label, sub: "" }] : []),
414
+ ]);
415
+ }
416
+
417
+ /** The day the list scrolls to for `selected`: that day, else the next one with a session, else the
418
+ * last one (a day after every loaded session still shows the list's end, not an empty page). */
419
+ export function scrollDayFor(days: readonly string[], selected: string): string {
420
+ return days.find((d) => d >= selected) ?? days[days.length - 1] ?? "";
421
+ }
422
+
423
+ /** Rebuild everything the screen reads from the module state. */
424
+ function derive(s: S): S {
425
+ const choices = agendaLocations(services ?? [], sessions, chosenLocation);
426
+ const locationKey = effectiveLocation(choices, chosenLocation.id);
427
+ // From today on (the loaded days never start before it), at the chosen location. The list is
428
+ // one run of days: a tap on the calendar SCROLLS it to a day, and scrolling it moves the
429
+ // selection, so it never starts at the selected day (the days above it would vanish).
430
+ const shown = sessions.filter((x) => x.day >= s.today && (!locationKey || x.locationKey === locationKey));
431
+ const events = rowsOk === false ? [] : shown.map((x, i) => eventOf(x, places.get(x.key), shown[i + 1]?.day !== x.day));
432
+ const sheet = sessions.find((x) => x.key === sheetKey);
433
+ const sheetPlace = sheet ? places.get(sheet.key)?.place ?? "" : "";
434
+ const loaded = s.status === "ready" && rowsOk !== false && !!services;
435
+ const locationEmpty = loaded && !!locationKey && shown.length === 0;
436
+ return {
437
+ ...s,
438
+ ...locationMenu(choices, chosenLocation.id),
439
+ locationSheetOpen: locationSheet,
440
+ locationEmpty,
441
+ notLocationEmpty: !locationEmpty,
442
+ // The dots follow the location too: a day with a session only elsewhere has none.
443
+ decorations: decorationsOf(shown, s.today, s.rangeStart, s.rangeEnd),
444
+ events,
445
+ scrollTo: ((day) => (day && scrollNonce && suffixOk !== false ? `${day}|${scrollNonce}` : day))(scrollDayFor([...new Set(shown.map((x) => x.day))], s.selectedDate)),
446
+ // The only thing an update-required screen says: no tap or page clears it.
447
+ updateMessage: rowsOk === false ? copyParam("agenda.updateRequired", "Update the app to see the class schedule.") : "",
448
+ signedIn: !!member?.email,
449
+ updateRequired: rowsOk === false,
450
+ listHidden: s.busy === true || rowsOk === false || events.length === 0,
451
+ ...emptyFlags(loaded && services!.length === 0, loaded && services!.length > 0 && events.length === 0 && !locationEmpty),
452
+ sheetOpen: !!sheet && !!sheetPlace,
453
+ sheetTitle: sheet?.title ?? "",
454
+ sheetWhen: sheet ? joined(dayLabel(sheet.start, { absolute: true }), timeLabel(sheet.start), lengthLabel(sheet.start, sheet.end)) : "",
455
+ sheetMeta: sheet ? joined(sheet.staff, sheet.location) : "",
456
+ sheetHideCalendar: sheetPlace !== "booked",
457
+ sheetHideCancel: sheetPlace !== "booked",
458
+ sheetHideLeave: sheetPlace !== "waitlisted",
459
+ sheetBusy: busy || calendarBusy,
460
+ sheetServiceId: sheet?.serviceId ?? "",
461
+ sheetSessionId: sheet?.sessionId ?? "",
462
+ sheetStart: sheet?.start ?? "",
463
+ };
464
+ }
465
+
466
+ /** Leave a waitlist. The registration id comes from the load; a place known only by its
467
+ * WAITING_LIST status asks the session's waitlist for it, and a registration Wix no longer lists
468
+ * is released through the member cancel of its booking. */
469
+ async function leave(place: MemberPlace, session: Session, owner: MemberAccess | undefined): Promise<void> {
470
+ let registrationId = place.registrationId ?? "";
471
+ if (!registrationId && session.sessionId) {
472
+ const lists = await listWaitlists([session.sessionId]);
473
+ for (const list of lists.list ?? []) {
474
+ for (const r of list.registrations ?? []) if (r.bookingId === place.bookingId && r.id) registrationId = r.id;
475
+ }
476
+ // The lookup awaited: still the member the place belongs to?
477
+ if (!(await sameMember(owner))) throw memberAccessChanged();
478
+ }
479
+ if (registrationId && session.sessionId) await leaveWaitlist(registrationId, session.sessionId);
480
+ else await cancelBookingAsMember(place.bookingId, place.revision);
481
+ }
482
+
483
+ /** The first runtime whose Android Agenda keeps a worker-bound event's `extra` — every field a row
484
+ * shows, and every flag that hides its other actions. An older Android binary would draw each
485
+ * row blank with all its actions at once, so it is asked to update instead. Checked at run time,
486
+ * not as a descriptor floor: a release takes its highest screen floor as its own. */
487
+ export const AGENDA_MIN_ANDROID_BINARY = "1.6.0";
488
+ let rowsOk: boolean | undefined;
489
+ /** A `scrollTo` of "day|nonce" jumps again to the same day (after a location change). A native
490
+ * renderer before 1.8.0 reads the whole value as the day and finds no row, so it gets the plain
491
+ * day: the list then does not re-land on an unchanged day, but it does reach a new one. */
492
+ export const AGENDA_SCROLL_SUFFIX_MIN_BINARY = "1.8.0";
493
+ let suffixOk: boolean | undefined;
494
+
495
+ async function rowsSupported(): Promise<boolean> {
496
+ const info = await getAppInfo().catch(() => undefined);
497
+ const native = !!info && (info.platform === "ios" || info.platform === "android" || info.platform === "macos");
498
+ suffixOk = !native || atLeast(info!.binaryVersion, AGENDA_SCROLL_SUFFIX_MIN_BINARY);
499
+ if (!info || info.platform !== "android") return true;
500
+ return atLeast(info.binaryVersion, AGENDA_MIN_ANDROID_BINARY);
501
+ }
502
+
503
+ const READY = { status: "ready" as const, error: "", signInRequired: false, notErrored: true, notLoading: true, busy: false, noSignIn: true };
504
+
505
+ type View = Pick<S, "today" | "rangeStart" | "rangeEnd" | "selectedDate" | "firstWeekday">;
506
+ /** The page and day the member asked for LAST. Every action records it before its first await,
507
+ * so a read that lands after a newer page or day tap shows that view, not the one it started
508
+ * from — paging back before the forward read lands must end on the user's last page. */
509
+ let wanted: View | undefined;
510
+
511
+ /** Record what the member asked for and return the state that shows it. */
512
+ function ask(s: S, view: Partial<View>): S {
513
+ wanted = { ...(wanted ?? { today: s.today, rangeStart: s.rangeStart, rangeEnd: s.rangeEnd, selectedDate: s.selectedDate, firstWeekday: s.firstWeekday }), ...view };
514
+ // The initial state carries no date; an action that lands before `load` still needs today.
515
+ if (!wanted.today) wanted = { ...wanted, today: dayKey(new Date()) };
516
+ return { ...s, ...wanted };
517
+ }
518
+
519
+ /** The longest run of days the list grows to before a far page replaces it. */
520
+ export const AGENDA_MAX_DAYS = 120;
521
+
522
+ /** The days to read when `need` is not loaded: the loaded days GROWN to reach it when the two touch
523
+ * (the member scrolled or paged on — the rows above them must stay, or the list jumps under their
524
+ * finger), else `need` alone (a far page). Capped at AGENDA_MAX_DAYS. */
525
+ export function grownWindow(loaded: { start: string; end: string }, need: { start: string; end: string }): { start: string; end: string } {
526
+ if (!loaded.start || need.start > dayKey(addDays(loaded.end, 1)) || need.end < dayKey(addDays(loaded.start, -1))) return need;
527
+ const start = loaded.start < need.start ? loaded.start : need.start;
528
+ const end = loaded.end > need.end ? loaded.end : need.end;
529
+ const span = Math.round((Date.parse(end) - Date.parse(start)) / 86_400_000);
530
+ return span > AGENDA_MAX_DAYS ? need : { start, end };
531
+ }
532
+
533
+ const covers = (win: { start: string; end: string }, need: { start: string; end: string }): boolean =>
534
+ !!win.start && win.start <= need.start && win.end >= need.end;
535
+ const needOf = (s: S) => windowFor(s.today, s.rangeStart, s.rangeEnd, s.selectedDate);
536
+
537
+ /** Bumped by every READ: a read that a newer read overtook stands down (returns `undefined`), and
538
+ * the newer one commits. An action the loaded days already cover reads nothing and bumps nothing,
539
+ * so a day tap never discards a reload in flight. */
540
+ let loadSeq = 0;
541
+
542
+ /** Load the window the state needs, unless it is already loaded. `force` reloads it (the
543
+ * screen re-appeared: the member may have just booked on the Choose Time screen). Resolves to
544
+ * `undefined` when a newer read started while this one awaited. */
545
+ async function ensureLoaded(base: S, force = false): Promise<S | undefined> {
546
+ const s = { ...base, ...wanted };
547
+ if (rowsOk === false) return derive(s);
548
+ const need = needOf(s);
549
+ // Days already loaded mean the last read succeeded (a failure clears them), so the screen is
550
+ // ready — whatever the state this action was dispatched with still said: a calendar's first
551
+ // page report can finish its read while `load`, dispatched with "loading", still waits.
552
+ if (covers(loadedWindow, need) && !force) return derive({ ...s, ...READY });
553
+ const seq = ++loadSeq;
554
+ const win = covers(loadedWindow, need) ? loadedWindow : grownWindow(loadedWindow, need);
555
+ // A result that an action produced while this read ran (Add to Calendar, a failed cancel) is
556
+ // newer than the state this read started from: the commit keeps it rather than closing it.
557
+ const resultAt = resultSeq;
558
+ const fresh = (x: S): S => (resultSeq === resultAt ? x : keepResult(x) ?? x);
559
+ try {
560
+ // A window grown to reach new days reads only those days, and keeps what it has: a scroll that
561
+ // extends the list must not re-read weeks of every class (and the member's bookings) each time.
562
+ // A forced reload (the screen re-appeared) and a far page read the whole window.
563
+ const grown = !force && win !== need && !!loadedWindow.start && win.start <= loadedWindow.start && win.end >= loadedWindow.end;
564
+ const parts = grown
565
+ ? [
566
+ ...(win.start < loadedWindow.start ? [{ start: win.start, end: dayKey(addDays(loadedWindow.start, -1)) }] : []),
567
+ ...(win.end > loadedWindow.end ? [{ start: dayKey(addDays(loadedWindow.end, 1)), end: win.end }] : []),
568
+ ]
569
+ : [win];
570
+ const reads = await Promise.all(parts.map(async (part) => {
571
+ const inPart = await sessionsIn(part.start, part.end);
572
+ const { who: reader, found } = await placesIn(part.start, part.end, inPart);
573
+ return { inPart, reader, found };
574
+ }));
575
+ const nextSessions = grown ? ordered([...sessions, ...reads.flatMap((r) => r.inPart)]) : reads[0]!.inPart;
576
+ const nextPlaces = grown ? new Map([...places, ...reads.flatMap((r) => [...r.found])]) : reads[0]!.found;
577
+ const who = reads[0]?.reader ?? member;
578
+ if (seq !== loadSeq) return undefined;
579
+ // The new days were read for another member than the loaded ones (a sign-out or a switch while
580
+ // the member scrolled): never mix two members' places — read the whole window for this one.
581
+ if (grown && reads.some((r) => (r.reader?.email ?? "") !== (member?.email ?? ""))) return ensureLoaded({ ...s, ...wanted }, true);
582
+ const now = { ...s, ...wanted };
583
+ if (!covers(win, needOf(now))) {
584
+ // The member paged past what this read covers while it ran. The days already loaded may
585
+ // cover where they are now (they paged back); otherwise read those.
586
+ return covers(loadedWindow, needOf(now)) ? derive(fresh({ ...now, ...READY })) : ensureLoaded(fresh(now));
587
+ }
588
+ sessions = nextSessions;
589
+ places = nextPlaces;
590
+ member = who;
591
+ loadedWindow = win;
592
+ return derive(fresh({ ...now, ...READY }));
593
+ } catch (err) {
594
+ if (seq !== loadSeq) return undefined;
595
+ const now = { ...s, ...wanted };
596
+ // The member paged back to days already loaded while this read failed: those still stand.
597
+ if (!covers(win, needOf(now)) && covers(loadedWindow, needOf(now))) return derive(fresh({ ...now, ...READY }));
598
+ // Only the flags: the schedule is not a row collection, and a failed load keeps no rows.
599
+ const { sessions: _rows, emptyText: _empty, ...flags } = loadFailed("sessions", err, {
600
+ prefix: "agenda",
601
+ empty: "No classes from this day on.",
602
+ error: "The schedule couldn't be loaded.",
603
+ signIn: "Sign in to see the schedule.",
604
+ sample: () => [] as Session[],
605
+ });
606
+ sessions = [];
607
+ places = new Map();
608
+ loadedWindow = { start: "", end: "" };
609
+ return derive(fresh({ ...now, ...flags }));
610
+ }
611
+ }
612
+
613
+ function argOf(p: unknown, key: string): string {
614
+ const a = p as Record<string, unknown> | undefined;
615
+ const v = a?.[key] ?? (a?.args as Record<string, unknown> | undefined)?.[key] ?? (a?.params as Record<string, unknown> | undefined)?.[key];
616
+ // Numbers too: every renderer sends onMonthChange's year/month as numbers.
617
+ return typeof v === "string" ? v.trim() : typeof v === "number" && Number.isFinite(v) ? String(v) : "";
618
+ }
619
+
620
+ const eventIdOf = (p: unknown): string => argOf(p, "eventId") || argOf(p, "id");
621
+
622
+ /** Clock-free, so a template builds to the same release every time: `load` — or the
623
+ * first `ask` before it — fills in today and this week. */
624
+ function initial(): S {
625
+ return {
626
+ ...LOADING, firstWeekday: 0, today: "", selectedDate: "", rangeStart: "", rangeEnd: "",
627
+ decorations: [], events: [], scrollTo: "", signedIn: false, updateRequired: false, updateMessage: "", listHidden: true,
628
+ ...emptyFlags(false, false), resultOpen: false, resultOpenB: false, resultText: "", resultOk: false, resultInfo: false,
629
+ sheetOpen: false, sheetTitle: "", sheetWhen: "", sheetMeta: "", sheetHideCalendar: true, sheetHideCancel: true, sheetHideLeave: true, sheetBusy: false, sheetServiceId: "", sheetSessionId: "", sheetStart: "",
630
+ locationOptions: [], locationLabel: "", locationA11y: "", hasLocationMenu: false, noLocationMenu: true,
631
+ locationActive: false, noLocationActive: true, locationSheetOpen: false, locationEmpty: false, notLocationEmpty: true,
632
+ };
633
+ }
634
+
635
+ /** How long a jump the worker asked for (a calendar tap, a page, a location) takes to settle. */
636
+ export const JUMP_SETTLE_MS = 900;
637
+ const settleJump = () => { quietUntil = Date.now() + JUMP_SETTLE_MS; };
638
+
639
+ /** A selected day with no session moves to the next day that has one ON THE SAME PAGE (the week
640
+ * or month on screen), so the calendar and the list name the same day — the same rule on every
641
+ * platform. With none left on the page the day stays selected, and the list shows the sessions
642
+ * after it. Never off the page: that would page the calendar away under the member's finger. */
643
+ async function settled(next: S | undefined): Promise<S | undefined> {
644
+ // The day the list scrolls to (a location change adds a "|n" suffix that re-applies it).
645
+ const day = next?.scrollTo.split("|")[0] ?? "";
646
+ if (!next || next.status !== "ready" || !day || day === next.selectedDate) return next;
647
+ if (day < next.selectedDate || day > titledDays(next.rangeStart, next.rangeEnd).last) return next;
648
+ return ensureLoaded(ask(next, { selectedDate: day }));
649
+ }
650
+
651
+ /** How the app opens its sign-in screen, from the appear params ("" = push). */
652
+ let signInPresentation: "" | "sheet" | "full_screen" = "";
653
+ /** The app's sign-in screen (`signInScreenId` appear param): Branded Lite's `himi_sign_in` by
654
+ * default; an app with no such screen names its own (salon-spa: the Profile tab, `himi_account`). */
655
+ let signInScreenId = "himi_sign_in";
656
+
657
+ const svc = defineService<S, {
658
+ load: (s: S, p?: unknown) => Promise<S | undefined>;
659
+ retry: (s: S) => Promise<S | undefined>;
660
+ selectDate: (s: S, p?: unknown) => Promise<S | undefined>;
661
+ rangeChanged: (s: S, p?: unknown) => Promise<S | undefined>;
662
+ monthChanged: (s: S, p?: unknown) => Promise<S | undefined>;
663
+ openSheet: (s: S, p?: unknown) => S;
664
+ closeSheet: (s: S) => S;
665
+ viewSession: (s: S) => S;
666
+ closeResult: (s: S, p?: unknown) => S;
667
+ addToCalendar: (s: S) => Promise<S>;
668
+ cancelBooking: (s: S) => Promise<S | undefined>;
669
+ joinWaitlist: (s: S, p?: unknown) => Promise<S | undefined>;
670
+ visibleDate: (s: S, p?: unknown) => Promise<S | undefined>;
671
+ openLocations: (s: S) => S;
672
+ closeLocations: (s: S) => S;
673
+ selectLocation: (s: S, p?: unknown) => Promise<S | undefined>;
674
+ }>("himiBrandedAgenda", {
675
+ // An action's result belongs to that visit: a cold start must not replay it from saved state,
676
+ // nor reopen the location sheet.
677
+ notHydrated: ["resultOpen", "resultOpenB", "resultText", "resultOk", "resultInfo", "locationSheetOpen"],
678
+ state: initial(),
679
+ actions: {
680
+ load: async (s, p) => {
681
+ stashCopy(p);
682
+ const presentation = argOf(p, "signInPresentation");
683
+ if (presentation === "sheet" || presentation === "full_screen") signInPresentation = presentation;
684
+ const signInScreen = argOf(p, "signInScreenId");
685
+ if (/^[a-z][a-z0-9_]{0,63}$/.test(signInScreen)) signInScreenId = signInScreen;
686
+ const fw = Number(argOf(p, "firstWeekday") || s.firstWeekday);
687
+ const today = dayKey(new Date());
688
+ // First appearance: open on this week. A re-appearance keeps the member's place and
689
+ // reloads, so a session booked on Choose Time comes back marked "Booked".
690
+ // This worker has read nothing yet: open on this week. Not the state's own status — a cold
691
+ // boot restores the last run's snapshot ("ready", last week's page) into a fresh worker.
692
+ const first = !loadedWindow.start;
693
+ const week = weekOf(today, (((fw % 7) + 7) % 7) as Weekday);
694
+ // Re-read the catalog when the screen shows again: classes are added, renamed and removed.
695
+ if (!first) services = null;
696
+ // A result from before the screen was left is not news when it shows again.
697
+ const asked = first
698
+ ? ask(newView(s), { firstWeekday: fw, today, selectedDate: today, rangeStart: week[0], rangeEnd: week[6] })
699
+ : ask(newView(s), { firstWeekday: fw, today });
700
+ // The location chosen on any bookings screen, read again on every appearance: Services or
701
+ // Choose Time may have changed it since.
702
+ chosenLocation = await readSharedLocation();
703
+ rowsOk ??= await rowsSupported();
704
+ if (!rowsOk) return derive({ ...asked, ...READY });
705
+ return first ? settled(await ensureLoaded(asked)) : ensureLoaded(asked, true);
706
+ },
707
+ // A view action: it closes the result, as every other one does through `newView`.
708
+ retry: async (s) => { services = null; return ensureLoaded(newView({ ...s, ...LOADING }), true); },
709
+ // Each compares with the member's LAST ask, not `s`: a read still in flight has not put the
710
+ // previous page into `s` yet, and paging back to that page is still a change.
711
+ selectDate: async (s, p) => {
712
+ const date = argOf(p, "date");
713
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || date === (wanted ?? s).selectedDate) return s;
714
+ // Past days are not selectable — also before the load has dimmed them on the calendar.
715
+ if (date < ((wanted ?? s).today || dayKey(new Date()))) return s;
716
+ settleJump();
717
+ scrollNonce = 0;
718
+ // A month grid's padding day (iOS draws them) belongs to the next month, and the grid moves
719
+ // there without reporting a page: move this page with it, so paging back is still a change.
720
+ const cur = wanted ?? s;
721
+ const span = Math.round((Date.parse(cur.rangeEnd) - Date.parse(cur.rangeStart)) / 86_400_000);
722
+ const page = titledDays(cur.rangeStart, cur.rangeEnd);
723
+ if (span > 7 && (date < page.first || date > page.last)) {
724
+ const grid = monthGridOf(date, s.firstWeekday);
725
+ if (grid) return settled(await ensureLoaded(ask(newView(s), { selectedDate: date, rangeStart: grid.start, rangeEnd: grid.end })));
726
+ }
727
+ return settled(await ensureLoaded(ask(newView(s), { selectedDate: date })));
728
+ },
729
+ rangeChanged: async (s, p) => {
730
+ const start = argOf(p, "startDate"), end = argOf(p, "endDate");
731
+ const cur = wanted ?? s;
732
+ if (!start || !end || (start === cur.rangeStart && end === cur.rangeEnd)) return s;
733
+ // The selection follows the page, so the list under the calendar describes the days
734
+ // on screen rather than a week the user has paged away from.
735
+ const keep = keepsSelection(cur, start, end);
736
+ const selectedDate = keep ? cur.selectedDate : selectionForRange(cur.today || dayKey(new Date()), start, end, cur.selectedDate);
737
+ // The calendar following the list's scroll pages too, and keeps its selection: only a page
738
+ // that moves the selection jumps the list.
739
+ if (!keep) { settleJump(); scrollNonce = 0; }
740
+ // A page that only followed the selection (the list's scroll moved it) is not a view change:
741
+ // a result on screen stays, as it does for the scroll itself.
742
+ return keep
743
+ ? ensureLoaded(ask(s, { rangeStart: start, rangeEnd: end, selectedDate }))
744
+ : settled(await ensureLoaded(ask(newView(s), { rangeStart: start, rangeEnd: end, selectedDate })));
745
+ },
746
+ // An older binary has no week mode and reports only the month. Its grid then spans the
747
+ // month's whole weeks; load those, so dots still show on every visible day.
748
+ monthChanged: async (s, p) => {
749
+ const year = Number(argOf(p, "year")), month = Number(argOf(p, "month"));
750
+ if (!year || !month) return s;
751
+ const first = new Date(year, month - 1, 1);
752
+ const start = weekOf(first, (s.firstWeekday % 7) as Weekday)[0];
753
+ const last = weekOf(new Date(year, month, 0), (s.firstWeekday % 7) as Weekday)[6];
754
+ const cur = wanted ?? s;
755
+ if (!start || !last || (start === cur.rangeStart && last === cur.rangeEnd)) return s;
756
+ const keep = keepsSelection(cur, start, last);
757
+ const selectedDate = keep ? cur.selectedDate : selectionForRange(cur.today || dayKey(new Date()), start, last, cur.selectedDate);
758
+ if (!keep) settleJump();
759
+ return ensureLoaded(ask(newView(s), { rangeStart: start, rangeEnd: last, selectedDate }));
760
+ },
761
+ openSheet: (s, p) => {
762
+ const key = eventIdOf(p);
763
+ if (!places.get(key)) return s;
764
+ sheetKey = key;
765
+ return derive(newView(s));
766
+ },
767
+ closeSheet: (s) => {
768
+ if (busy) return s;
769
+ sheetKey = "";
770
+ return derive(s);
771
+ },
772
+ viewSession: (s) => {
773
+ const session = sessions.find((x) => x.key === sheetKey);
774
+ sheetKey = "";
775
+ return session
776
+ ? navigate(derive(s), { screenId: "himi_service", args: { id: session.serviceId, sessionId: session.sessionId, start: session.start } })
777
+ : derive(s);
778
+ },
779
+ // From one slot's own close or dismissal (`slot`: "a" | "b"), or both when it names none.
780
+ closeResult: (s, p) => {
781
+ const slot = argOf(p, "slot");
782
+ const closed = slot === "a" ? { resultOpen: false } : slot === "b" ? { resultOpenB: false } : NO_RESULT;
783
+ lastResult = { ...lastResult, ...closed };
784
+ if (slot === "a") return s.resultOpen ? { ...s, resultOpen: false } : s;
785
+ if (slot === "b") return s.resultOpenB ? { ...s, resultOpenB: false } : s;
786
+ return s.resultOpen || s.resultOpenB ? { ...s, ...NO_RESULT } : s;
787
+ },
788
+ // Add a booked session to the device calendar — the event screen's flow: ask for write access,
789
+ // create the event, and fall back to the Google Calendar template page when the calendar is
790
+ // refused or creates nothing. Nothing is written to Wix.
791
+ addToCalendar: async (s): Promise<S> => {
792
+ const session = sessions.find((x) => x.key === sheetKey);
793
+ if (!session || places.get(session.key)?.place !== "booked" || busy || calendarBusy) return s;
794
+ // One calendar flow at a time. The sheet is left as it is while the flow runs (a reload that
795
+ // lands meanwhile must not close it — on iOS the calendar page is presented on it), and the
796
+ // member may close it, view the class, or open another session's sheet. Meanwhile its Add to
797
+ // Calendar and Cancel are inert (`calendarBusy`); a sheet opened during the flow reads them as
798
+ // disabled (sheetBusy). At the end the flow closes the sheet only if it is still this
799
+ // session's. Not `busy`: closeSheet must still work while the flow runs.
800
+ calendarBusy = true;
801
+ const start = Date.parse(session.start);
802
+ const end = Date.parse(session.end) > start ? Date.parse(session.end) : start + 60 * 60 * 1000;
803
+ const fallback = async (failed: string, failedFallback: string): Promise<ResultFields> => {
804
+ const opened = await open(calendarFallbackUrl(session, start, end), "in_app_browser").then((r) => r.opened, () => false);
805
+ // On iOS the in-app browser is presented ON the session sheet; closing the sheet now would
806
+ // close the browser with it. The sheet stays, and the member closes it after the browser.
807
+ // So the flow leaves an open sheet open; only a sheet that did not get the page is closed.
808
+ if (!opened && sheetKey === session.key) sheetKey = "";
809
+ return result(opened ? copyParam("agenda.calendarOpened", "Opened your calendar.") : copyParam(failed, failedFallback), false);
810
+ };
811
+ let outcome: ResultFields;
812
+ try {
813
+ let permission = await getPermissionStatus();
814
+ if (permission !== "granted" && permission !== "limited") permission = await requestPermission("writeOnly");
815
+ if (permission !== "granted" && permission !== "limited") {
816
+ outcome = await fallback("agenda.calendarDenied", "The app cannot use your calendar. Allow it in Settings.");
817
+ } else {
818
+ const created = await createEvent({ title: session.title, start, end, location: session.location, notes: session.staff });
819
+ if (created.id) {
820
+ if (sheetKey === session.key) sheetKey = "";
821
+ outcome = result(copyParam("agenda.calendarAdded", "Added to your calendar."), true);
822
+ } else {
823
+ outcome = await fallback("agenda.calendarFailed", "Could not add this class to your calendar.");
824
+ }
825
+ }
826
+ } catch {
827
+ // A calendar that throws (EventKit refusing the save) gets the same fallback as one that
828
+ // creates nothing: the calendar page, which `fallback` opens without throwing.
829
+ outcome = await fallback("agenda.calendarFailed", "Could not add this class to your calendar.");
830
+ } finally {
831
+ calendarBusy = false;
832
+ }
833
+ return derive({ ...(svc.getState() as S), ...outcome });
834
+ },
835
+ // Cancel a booking (the MEMBER cancel, so Wix enforces the business's cancellation
836
+ // policy), or leave a waitlist (which also releases the pending booking it created).
837
+ cancelBooking: async (s): Promise<S | undefined> => {
838
+ const place = places.get(sheetKey);
839
+ // Read with `place`: a reload that lands meanwhile replaces both together.
840
+ const owner = member;
841
+ const session = sessions.find((x) => x.key === sheetKey);
842
+ const leaving = place?.place === "waitlisted";
843
+ if (!place || !session || busy || calendarBusy || (!leaving && !place.revision)) return s;
844
+ busy = true;
845
+ try {
846
+ if (!(await sameMember(owner))) throw memberAccessChanged();
847
+ if (leaving) await leave(place, session, owner);
848
+ else await cancelBookingAsMember(place.bookingId, place.revision);
849
+ places.delete(sheetKey);
850
+ sheetKey = "";
851
+ const done = leaving ? copyParam("agenda.leftWaitlist", "You left the waitlist.") : copyParam("agenda.cancelDone", "Booking cancelled.");
852
+ // Reload against the state as it is NOW: the member may have paged while the write ran.
853
+ const next = await ensureLoaded(svc.getState() as S, true);
854
+ busy = false;
855
+ return derive({ ...(next ?? { ...(svc.getState() as S), ...wanted }), ...result(done, true) });
856
+ } catch (err) {
857
+ sheetKey = "";
858
+ if ((err as { name?: string } | null)?.name === "MemberAccessChanged") {
859
+ // Someone else (or nobody) since the load: show their schedule; nothing is cancelled.
860
+ const next = keepResult(await ensureLoaded(svc.getState() as S, true));
861
+ busy = false;
862
+ return next ? derive(next) : undefined;
863
+ }
864
+ busy = false;
865
+ const failed = isBookingPolicyViolation(err)
866
+ ? copyParam("agenda.cancelPolicy", "It's too late to cancel in the app. Contact the business.")
867
+ : copyParam("agenda.cancelFailed", "That didn't go through. Try again.");
868
+ return derive({ ...(svc.getState() as S), ...result(failed, false) });
869
+ }
870
+ },
871
+ joinWaitlist: async (s, p): Promise<S | undefined> => {
872
+ const key = eventIdOf(p);
873
+ const session = sessions.find((x) => x.key === key);
874
+ // A place already held (just joined, or booked) is never joined again.
875
+ if (!session || busy || places.get(key)) return s;
876
+ const owner = member;
877
+ busy = true;
878
+ try {
879
+ // One person's email and name: the member is read before AND after the name, and the
880
+ // registration goes out only when both reads, and the member the places were loaded for, agree.
881
+ const before = await memberAccess().catch(() => undefined);
882
+ if (!before?.email) {
883
+ busy = false;
884
+ // As a modal it only closes, and this screen loads again (its re-appearance reload).
885
+ return navigate(s, signInPresentation
886
+ ? { screenId: signInScreenId, presentation: signInPresentation, args: { mode: "signin", modal: "1" } }
887
+ : { screenId: signInScreenId, args: { mode: "signin", returnScreenId: "himi_agenda" } });
888
+ }
889
+ const name = await memberName();
890
+ const current = await memberAccess().catch(() => undefined);
891
+ const same = (a: MemberAccess | undefined, b: MemberAccess | undefined) => !!a?.email && a.email === b?.email && a.scope === b?.scope;
892
+ if (!same(before, current) || !same(owner, current)) {
893
+ // Another member since the load — or the load was a visitor's, so their places were never
894
+ // read: show their schedule first; nothing is registered.
895
+ const next = keepResult(await ensureLoaded(svc.getState() as S, true));
896
+ busy = false;
897
+ return next ? derive(next) : undefined;
898
+ }
899
+ const res = await registerToWaitlist(session.sessionId, { email: current!.email, ...name });
900
+ // Marked at once, so until a reload lands the row reads On waitlist and offers no second
901
+ // join — whether this reload commits or a newer page overtakes it.
902
+ places.set(key, { bookingId: res?.booking?.id ?? "", revision: "", place: "waitlisted", registrationId: res?.registrationId, confirmed: false });
903
+ const joined = copyParam("agenda.joinedWaitlist", "You're on the waitlist.");
904
+ const next = await ensureLoaded(svc.getState() as S, true);
905
+ busy = false;
906
+ return derive({ ...(next ?? { ...(svc.getState() as S), ...wanted }), ...result(joined, true) });
907
+ } catch {
908
+ busy = false;
909
+ return derive({ ...(svc.getState() as S), ...result(copyParam("agenda.waitlistFailed", "Couldn't join the waitlist. Try again."), false) });
910
+ }
911
+ },
912
+ // The list's scroll (`onVisibleDateChange`): the day the member reads becomes the selected day,
913
+ // so the calendar (and its week or month) follows the list. Not a view action — a result on
914
+ // screen stays. The list's scroll target follows too, so a return from a session's screen comes
915
+ // back to this day; the renderer does not scroll for its own day (it is where the list is).
916
+ visibleDate: async (s, p) => {
917
+ const date = argOf(p, "date");
918
+ const cur = wanted ?? s;
919
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || date === cur.selectedDate) return s;
920
+ // A report from before a jump the worker asked for: the jump wins.
921
+ if (Date.now() < quietUntil) return s;
922
+ if (date < (cur.today || dayKey(new Date()))) return s;
923
+ scrollNonce = 0;
924
+ return ensureLoaded(ask(s, { selectedDate: date }));
925
+ },
926
+ openLocations: (s) => {
927
+ locationSheet = true;
928
+ return derive({ ...s, ...wanted });
929
+ },
930
+ closeLocations: (s) => {
931
+ if (!locationSheet) return s;
932
+ locationSheet = false;
933
+ return derive({ ...s, ...wanted });
934
+ },
935
+ // A row of the location sheet: "" is every location. Kept for Services and Choose Time too.
936
+ selectLocation: async (s, p): Promise<S | undefined> => {
937
+ const id = argOf(p, "locationId");
938
+ const choice = agendaLocations(services ?? [], sessions, chosenLocation).find((c) => c.key === id);
939
+ chosenLocation = id && choice ? { id, label: choice.label } : { id: "", label: "" };
940
+ locationSheet = false;
941
+ settleJump();
942
+ // Rows above the selected day may have come or gone: the list must land on it again.
943
+ scrollNonce += 1;
944
+ // Not awaited (it never throws): the new list shows at once, whatever the store does.
945
+ void writeSharedLocation(chosenLocation);
946
+ return settled(derive(newView({ ...s, ...wanted })));
947
+ },
948
+ },
949
+ });
950
+ export { svc };