@wix/himalaya-cli 0.1530.0 → 0.1532.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.
@@ -1,22 +1,28 @@
1
- // himi-branded-lite-agenda — the Bookings Agenda: every upcoming CLASS session of the site,
2
- // under a collapsible week/month calendar.
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
3
  //
4
4
  // Everyone sees the whole schedule. A signed-in member also sees their own place in it: a
5
5
  // session they booked reads "Booked", one they wait for reads "On waitlist", and each opens
6
- // a sheet to view the session or cancel / leave. A full session with waitlist room offers
7
- // "Waitlist"; a full one without, or one outside the booking window, says so instead of
8
- // offering a Book that would fail.
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
9
  //
10
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. Courses are left out
12
- // too: a course is booked as a whole, never one session, and Wix serves its sessions through
13
- // the course flow rather than the class availability this screen reads.
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.
14
18
  //
15
19
  // The calendar marks every day with a session (one dot) and today (its own ring). The list
16
20
  // starts at the selected day, so a day with nothing on it shows the next sessions instead
17
21
  // of an empty page — the agenda answers "what is on from here".
18
22
  import { defineService, navigate } from "@himalaya/state";
19
- import { addDays, dayKey, toDate, weekOf, type DayDecoration, type Weekday } from "@himalaya/time";
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";
20
26
  import {
21
27
  cancelBookingAsMember, isBookingPolicyViolation, leaveWaitlist, listWaitlists, queryBookings, queryServiceAvailability,
22
28
  queryServices, registerToWaitlist, type WixAvailabilityEntry, type WixBooking, type WixService,
@@ -25,6 +31,7 @@ import { getMyMember } from "@himalaya/wix-members";
25
31
  import { LOADING, loadFailed, type LoadFlags } from "../_shared/branded-load-state.js";
26
32
  import { copyParam, copyTemplate, stashCopy } from "../_shared/himi-copy.js";
27
33
  import { slotStartInstant } from "../_shared/himi-bookings.js";
34
+ import { formatServicePrice } from "../_shared/wix-service-price.js";
28
35
  import { compareText } from "../_shared/engine-intl.js";
29
36
  import { assertMemberAccess, memberAccess, memberAccessChanged, type MemberAccess } from "../_shared/member-access.js";
30
37
  import { atLeast } from "../_shared/member-account-routes.js";
@@ -32,8 +39,9 @@ import { get as getAppInfo } from "@himalaya/app-info";
32
39
 
33
40
  /** How far past the selected day the list reaches. */
34
41
  export const AGENDA_DAYS = 28;
35
- /** Services whose sessions are fixed events, each booked on its own. */
36
- const SESSION_TYPES = new Set(["CLASS"]);
42
+ /** Services whose sessions are fixed events: a class's are each booked on their own, a course's
43
+ * are listed but the course is booked as a whole (on its service screen). */
44
+ const SESSION_TYPES = new Set(["CLASS", "COURSE"]);
37
45
  /** A booking in one of these no longer holds a place. */
38
46
  const RELEASED = new Set(["CANCELED", "CANCELLED", "DECLINED"]);
39
47
 
@@ -46,7 +54,11 @@ export interface Session {
46
54
  /** The Wix session id — what a waitlist registers against. "" when the entry has none. */
47
55
  sessionId: string;
48
56
  serviceId: string;
57
+ /** A class session is booked on its own; a course session belongs to a course booked whole. */
58
+ kind: "class" | "course";
49
59
  title: string;
60
+ /** The service's fixed price ("₪60"), "" when it has none to show (varied, custom, free). */
61
+ price: string;
50
62
  start: string; // ISO instant with offset
51
63
  end: string;
52
64
  day: string; // LOCAL day key
@@ -59,6 +71,8 @@ export interface Session {
59
71
  waitlistTotal: number;
60
72
  /** A booking policy closes it (too early, too late, online booking off): no Book, no Waitlist. */
61
73
  policyClosed: boolean;
74
+ /** The ONLY policy reason is that booking has not opened yet ("Not open yet", not "Closed"). */
75
+ tooEarly: boolean;
62
76
  waitlistOpen: number;
63
77
  }
64
78
 
@@ -87,18 +101,38 @@ interface S extends LoadFlags {
87
101
  decorations: DayDecoration[];
88
102
  events: AgendaEvent[];
89
103
  scrollTo: string;
90
- emptyText: string;
91
- message: string;
92
104
  signedIn: boolean;
93
105
  /** An older Android binary: its Agenda drops each row's fields, so the list is replaced by a
94
106
  * line asking to update. */
95
107
  updateRequired: boolean;
96
- /** The list is hidden while loading or failed, and when an update is required. */
108
+ /** The update-required line — a state of the screen, not an action's result. */
109
+ updateMessage: string;
110
+ /** The list is hidden while loading or failed, when an update is required, and when it is empty. */
97
111
  listHidden: boolean;
112
+ /** Loaded, and the site has no class or course at all. */
113
+ noClasses: boolean;
114
+ /** Loaded, the site has classes, but none from the selected day to the end of the loaded days. */
115
+ nothingAhead: boolean;
116
+ /** The negations a `hiddenPath` reads: each empty state shows only when its own flag is set. */
117
+ notNoClasses: boolean;
118
+ notNothingAhead: boolean;
119
+ // The result sheet: one action's outcome, non-modal, above the tab bar. Two slots that take turns
120
+ // (A = resultOpen, B = resultOpenB): each new result presents in the other one, so it gets its own
121
+ // eight-second countdown rather than the rest of the previous result's.
122
+ resultOpen: boolean;
123
+ resultOpenB: boolean;
124
+ resultText: string;
125
+ /** It went through (a check) … */
126
+ resultOk: boolean;
127
+ /** … or it did not, or needs the member (an info mark). Exactly one is false while open. */
128
+ resultInfo: boolean;
98
129
  // The session sheet (Booked / On waitlist).
99
130
  sheetOpen: boolean;
100
131
  sheetTitle: string;
101
132
  sheetWhen: string;
133
+ sheetMeta: string;
134
+ /** Add to Calendar is for a booked session only. */
135
+ sheetHideCalendar: boolean;
102
136
  sheetHideCancel: boolean;
103
137
  sheetHideLeave: boolean;
104
138
  sheetBusy: boolean;
@@ -106,24 +140,19 @@ interface S extends LoadFlags {
106
140
 
107
141
  const flag = (on: boolean): string => (on ? "true" : "");
108
142
 
109
- /** "14:30" in the device's 24h/12h convention-neutral form the reference uses. */
110
- function clock(iso: string): string {
111
- const d = toDate(iso);
112
- if (!d) return "";
113
- return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`;
114
- }
115
-
116
- /** "(1h)", "(30m)", "(1h 30m)" — the length under the time. */
143
+ /** "1 hr", "45 min", "1 hr 30 min" — the length under the time. */
117
144
  export function lengthLabel(start: string, end: string): string {
118
145
  const mins = Math.round((Date.parse(end) - Date.parse(start)) / 60_000);
119
146
  if (!Number.isFinite(mins) || mins <= 0) return "";
120
147
  const h = Math.floor(mins / 60), m = mins % 60;
121
- const body = h && m
122
- ? copyTemplate("agenda.lengthHoursMinutes", "{h}h {m}m", { h, m })
123
- : h ? copyTemplate("agenda.lengthHours", "{h}h", { h }) : copyTemplate("agenda.lengthMinutes", "{m}m", { m });
124
- return `(${body})`;
148
+ return h && m
149
+ ? copyTemplate("agenda.lengthHoursMinutes", "{h} hr {m} min", { h, m })
150
+ : h ? copyTemplate("agenda.lengthHours", "{h} hr", { h }) : copyTemplate("agenda.lengthMinutes", "{m} min", { m });
125
151
  }
126
152
 
153
+ /** The parts that are known, joined by a middle dot: "Omer Levi · Studio Zen". */
154
+ const joined = (...parts: string[]): string => parts.filter(Boolean).join(" · ");
155
+
127
156
  /** A slot's instant as an ISO string WITH an offset — iOS parses wire dates strictly. */
128
157
  function instantIso(slot: { startDate?: string; timezone?: unknown }, field: "startDate" | "endDate"): string {
129
158
  const ms = slotStartInstant({ startDate: (slot as Record<string, unknown>)[field] as string | undefined, timezone: slot.timezone });
@@ -140,9 +169,13 @@ export function sessionOf(entry: WixAvailabilityEntry, service: WixService): Ses
140
169
  const end = instantIso(slot, "endDate") || start;
141
170
  const raw = entry as unknown as {
142
171
  totalSpots?: number; openSpots?: number; waitingList?: { totalSpots?: number; openSpots?: number };
143
- bookingPolicyViolations?: { tooEarlyToBook?: boolean; tooLateToBook?: boolean; bookOnlineDisabled?: boolean };
172
+ bookingPolicyViolations?: { tooEarlyToBook?: boolean; tooLateToBook?: boolean; bookOnlineDisabled?: boolean; validSubmissionRequired?: boolean };
144
173
  };
145
174
  const policy = raw.bookingPolicyViolations;
175
+ const tooLate = policy?.tooLateToBook === true, offline = policy?.bookOnlineDisabled === true;
176
+ const tooEarly = policy?.tooEarlyToBook === true;
177
+ // A session that also needs a form submitted is not just "not open yet": it never opens for the app.
178
+ const needsForm = policy?.validSubmissionRequired === true;
146
179
  const resource = slot.resource as { name?: string } | undefined;
147
180
  const location = slot.location as { name?: string; formattedAddress?: string } | undefined;
148
181
  const serviceId = String(slot.serviceId ?? service.id ?? "");
@@ -151,7 +184,9 @@ export function sessionOf(entry: WixAvailabilityEntry, service: WixService): Ses
151
184
  key: sessionId || sessionKey(serviceId, start),
152
185
  sessionId,
153
186
  serviceId,
187
+ kind: ((service as WixService & { type?: string }).type ?? "").toUpperCase() === "COURSE" ? "course" : "class",
154
188
  title: service.name ?? "",
189
+ price: formatServicePrice(service),
155
190
  start, end,
156
191
  day: dayKey(start),
157
192
  staff: resource?.name?.trim() ?? "",
@@ -161,7 +196,8 @@ export function sessionOf(entry: WixAvailabilityEntry, service: WixService): Ses
161
196
  bookable: entry.bookable !== false,
162
197
  waitlistTotal: count(raw.waitingList?.totalSpots),
163
198
  waitlistOpen: count(raw.waitingList?.openSpots),
164
- policyClosed: policy?.tooEarlyToBook === true || policy?.tooLateToBook === true || policy?.bookOnlineDisabled === true,
199
+ policyClosed: tooEarly || tooLate || offline || needsForm,
200
+ tooEarly: tooEarly && !tooLate && !offline && !needsForm,
165
201
  };
166
202
  }
167
203
 
@@ -199,22 +235,26 @@ export function bySession(found: Map<string, MemberPlace>, inWindow: Session[]):
199
235
  return out;
200
236
  }
201
237
 
202
- /** "1/12" booked-of-capacity, or "Full". Empty when the site reports no capacity. */
203
- function capacityLabel(s: Session): string {
204
- if (s.total <= 0) return "";
238
+ /** "4 spots left", "1 spot left", or "Full". Empty when the site reports no capacity, and for a
239
+ * course: a session's open spots do not say whether the course has room. */
240
+ function spotsLabel(s: Session): string {
241
+ if (s.kind === "course" || s.total <= 0) return "";
205
242
  if (s.open <= 0) return copyParam("agenda.full", "Full");
206
- return `${s.total - s.open}/${s.total}`;
243
+ return s.open === 1 ? copyParam("agenda.spotLeft", "1 spot left") : copyTemplate("agenda.spotsLeft", "{n} spots left", { n: s.open });
207
244
  }
208
245
 
209
- /** One session → one Agenda row. Exactly one of the trailing states is visible. */
246
+ /** One session → one Agenda row. Exactly one of the trailing controls is visible. */
210
247
  export function eventOf(s: Session, place: MemberPlace | undefined): AgendaEvent {
211
248
  const mine = place?.place ?? "";
249
+ const course = s.kind === "course";
212
250
  const full = s.total > 0 && s.open <= 0;
213
- const canBook = !mine && s.bookable && !full && !s.policyClosed;
251
+ // A course is booked whole on its service screen: its sessions offer View Course, never Book.
252
+ const canBook = !course && !mine && s.bookable && !full && !s.policyClosed;
214
253
  // Availability is capacity AND policy: a full session the policy closes is not joinable either.
215
- const canWait = !mine && full && s.waitlistOpen > 0 && !!s.sessionId && !s.policyClosed;
216
- const closed = !mine && !canBook && !canWait;
217
- const capacity = capacityLabel(s);
254
+ const canWait = !course && !mine && full && s.waitlistOpen > 0 && !!s.sessionId && !s.policyClosed;
255
+ const viewCourse = course && !mine;
256
+ const closed = !course && !mine && !canBook && !canWait;
257
+ const time = timeLabel(s.start);
218
258
  return {
219
259
  id: s.key,
220
260
  title: s.title,
@@ -224,19 +264,24 @@ export function eventOf(s: Session, place: MemberPlace | undefined): AgendaEvent
224
264
  serviceId: s.serviceId,
225
265
  // Choose Time opens on THIS session: two of a class can start at the same time.
226
266
  sessionId: s.sessionId,
227
- time: clock(s.start),
267
+ time,
228
268
  length: lengthLabel(s.start, s.end),
229
- staff: s.staff ? copyTemplate("agenda.withStaff", "With {name}", { name: s.staff }) : "",
230
- capacity,
231
- noCapacity: flag(!capacity),
232
- location: s.location,
233
- noLocation: flag(!s.location),
269
+ meta: joined(s.staff, s.location),
270
+ availability: joined(s.price, spotsLabel(s)),
271
+ isCourse: flag(course),
272
+ notCourse: flag(!course),
273
+ detailsA11y: s.staff
274
+ ? copyTemplate("agenda.detailsA11y", "{title}, {time}, with {staff}", { title: s.title, time, staff: s.staff })
275
+ : [s.title, time].filter(Boolean).join(", "),
276
+ bookA11y: copyTemplate("agenda.bookA11y", "Book {title}, {time}", { title: s.title, time }),
234
277
  hideBook: flag(!canBook),
235
278
  hideWaitlist: flag(!canWait),
279
+ hideCourse: flag(!viewCourse),
236
280
  hideBooked: flag(mine !== "booked"),
237
281
  hideOnWaitlist: flag(mine !== "waitlisted"),
238
282
  hideClosed: flag(!closed),
239
- closedLabel: full ? copyParam("agenda.full", "Full") : copyParam("agenda.closed", "Closed"),
283
+ closedLabel: full ? copyParam("agenda.full", "Full")
284
+ : s.tooEarly ? copyParam("agenda.notOpenYet", "Not open yet") : copyParam("agenda.closed", "Closed"),
240
285
  },
241
286
  };
242
287
  }
@@ -328,6 +373,20 @@ export function keepsSelection(cur: { today: string; selectedDate: string }, sta
328
373
  return !!cur.today && cur.selectedDate === cur.today && cur.today >= start && cur.today <= end;
329
374
  }
330
375
 
376
+ function calendarStamp(ms: number): string {
377
+ return new Date(ms).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
378
+ }
379
+
380
+ /** The Google Calendar template page for a session — the fallback when the device calendar is
381
+ * refused or creates nothing. `encodeURIComponent`, never URLSearchParams: the iOS / macOS
382
+ * worker engine (JavaScriptCore) does not provide it (see himi-events-detail). */
383
+ export function calendarFallbackUrl(session: Pick<Session, "title" | "location" | "staff">, start: number, end: number): string {
384
+ const parts = ["action=TEMPLATE", `text=${encodeURIComponent(session.title)}`, `dates=${calendarStamp(start)}/${calendarStamp(end)}`];
385
+ if (session.location) parts.push(`location=${encodeURIComponent(session.location)}`);
386
+ if (session.staff) parts.push(`details=${encodeURIComponent(session.staff)}`);
387
+ return `https://calendar.google.com/calendar/render?${parts.join("&")}`;
388
+ }
389
+
331
390
  // ── loading ──────────────────────────────────────────────────────────────────
332
391
 
333
392
  let services: WixService[] | null = null;
@@ -337,6 +396,9 @@ let loadedWindow = { start: "", end: "" };
337
396
  let member: MemberAccess | undefined;
338
397
  let sheetKey = "";
339
398
  let busy = false;
399
+ /** An Add to Calendar flow is running (a permission prompt, a save, the calendar page). Its own
400
+ * flag, not `busy`: the session sheet must still close while it runs. */
401
+ let calendarBusy = false;
340
402
 
341
403
  async function sessionServices(): Promise<WixService[]> {
342
404
  if (services) return services;
@@ -452,28 +514,65 @@ async function memberName(): Promise<{ firstName?: string; lastName?: string }>
452
514
  return { firstName: contact?.firstName || undefined, lastName: contact?.lastName || undefined };
453
515
  }
454
516
 
517
+ type ResultFields = Pick<S, "resultOpen" | "resultOpenB" | "resultText" | "resultOk" | "resultInfo">;
518
+ /** The result sheet fields as the actions last SET them — not as the service last committed them:
519
+ * an action's result counts from the moment it is made, before the action's own state commits, so
520
+ * a read landing in between cannot copy the older fields over it. */
521
+ let lastResult: ResultFields = { resultOpen: false, resultOpenB: false, resultText: "", resultOk: false, resultInfo: false };
522
+
523
+ /** `next`, with the result sheet fields as they are NOW: a read that ran while a result was closed
524
+ * (or a newer one opened) must not commit the older result fields it started from. */
525
+ function keepResult(next: S | undefined): S | undefined {
526
+ if (!next) return next;
527
+ return { ...next, ...lastResult };
528
+ }
529
+
530
+ /** Which result slot presented last: the next result takes the other one. */
531
+ let resultSeq = 0;
532
+
533
+ /** One action's outcome, for the result sheet: `ok` when it went through. */
534
+ function result(text: string, ok: boolean): ResultFields {
535
+ const b = ++resultSeq % 2 === 0;
536
+ lastResult = { resultOpen: !b, resultOpenB: b, resultText: text, resultOk: ok, resultInfo: !ok };
537
+ return lastResult;
538
+ }
539
+ const NO_RESULT = { resultOpen: false, resultOpenB: false } as const;
540
+ /** A view action (load, a day or page change, opening the session sheet) closes any result on
541
+ * screen. A write still in flight still says its outcome when it lands: the member started it, and
542
+ * its row may not be on the page they moved to, so the sheet is their only confirmation. */
543
+ function newView(s: S): S {
544
+ lastResult = { ...lastResult, ...NO_RESULT };
545
+ return { ...s, ...NO_RESULT };
546
+ }
547
+
548
+ const emptyFlags = (noClasses: boolean, nothingAhead: boolean) =>
549
+ ({ noClasses, nothingAhead, notNoClasses: !noClasses, notNothingAhead: !nothingAhead });
550
+
455
551
  /** Rebuild everything the screen reads from the module state. */
456
552
  function derive(s: S): S {
457
553
  const events = rowsOk === false ? [] : sessions.filter((x) => x.day >= s.selectedDate).map((x) => eventOf(x, places.get(x.key)));
458
554
  const sheet = sessions.find((x) => x.key === sheetKey);
459
555
  const sheetPlace = sheet ? places.get(sheet.key)?.place ?? "" : "";
556
+ const loaded = s.status === "ready" && rowsOk !== false && !!services;
460
557
  return {
461
558
  ...s,
462
559
  decorations: decorationsOf(sessions, s.today, s.rangeStart, s.rangeEnd),
463
560
  events,
464
561
  scrollTo: events[0] ? dayKey(events[0].start) : "",
465
- emptyText: copyParam("agenda.emptyText", "No classes from this day on."),
466
562
  // The only thing an update-required screen says: no tap or page clears it.
467
- ...(rowsOk === false ? { message: copyParam("agenda.updateRequired", "Update the app to see the class schedule.") } : {}),
563
+ updateMessage: rowsOk === false ? copyParam("agenda.updateRequired", "Update the app to see the class schedule.") : "",
468
564
  signedIn: !!member?.email,
469
565
  updateRequired: rowsOk === false,
470
- listHidden: s.busy === true || rowsOk === false,
566
+ listHidden: s.busy === true || rowsOk === false || events.length === 0,
567
+ ...emptyFlags(loaded && services!.length === 0, loaded && services!.length > 0 && events.length === 0),
471
568
  sheetOpen: !!sheet && !!sheetPlace,
472
569
  sheetTitle: sheet?.title ?? "",
473
- sheetWhen: sheet ? `${clock(sheet.start)} ${lengthLabel(sheet.start, sheet.end)}`.trim() : "",
570
+ sheetWhen: sheet ? joined(dayLabel(sheet.start, { absolute: true }), timeLabel(sheet.start), lengthLabel(sheet.start, sheet.end)) : "",
571
+ sheetMeta: sheet ? joined(sheet.staff, sheet.location) : "",
572
+ sheetHideCalendar: sheetPlace !== "booked",
474
573
  sheetHideCancel: sheetPlace !== "booked",
475
574
  sheetHideLeave: sheetPlace !== "waitlisted",
476
- sheetBusy: busy,
575
+ sheetBusy: busy || calendarBusy,
477
576
  };
478
577
  }
479
578
 
@@ -544,6 +643,10 @@ async function ensureLoaded(base: S, force = false): Promise<S | undefined> {
544
643
  if (covers(loadedWindow, need) && !force) return derive({ ...s, ...READY });
545
644
  const seq = ++loadSeq;
546
645
  const win = covers(loadedWindow, need) ? loadedWindow : need;
646
+ // A result that an action produced while this read ran (Add to Calendar, a failed cancel) is
647
+ // newer than the state this read started from: the commit keeps it rather than closing it.
648
+ const resultAt = resultSeq;
649
+ const fresh = (x: S): S => (resultSeq === resultAt ? x : keepResult(x) ?? x);
547
650
  try {
548
651
  const nextSessions = await sessionsIn(win.start, win.end);
549
652
  const { who, found: nextPlaces } = await placesIn(win.start, win.end, nextSessions);
@@ -552,18 +655,18 @@ async function ensureLoaded(base: S, force = false): Promise<S | undefined> {
552
655
  if (!covers(win, needOf(now))) {
553
656
  // The member paged past what this read covers while it ran. The days already loaded may
554
657
  // cover where they are now (they paged back); otherwise read those.
555
- return covers(loadedWindow, needOf(now)) ? derive({ ...now, ...READY }) : ensureLoaded(now);
658
+ return covers(loadedWindow, needOf(now)) ? derive(fresh({ ...now, ...READY })) : ensureLoaded(fresh(now));
556
659
  }
557
660
  sessions = nextSessions;
558
661
  places = nextPlaces;
559
662
  member = who;
560
663
  loadedWindow = win;
561
- return derive({ ...now, ...READY, message: "" });
664
+ return derive(fresh({ ...now, ...READY }));
562
665
  } catch (err) {
563
666
  if (seq !== loadSeq) return undefined;
564
667
  const now = { ...s, ...wanted };
565
668
  // The member paged back to days already loaded while this read failed: those still stand.
566
- if (!covers(win, needOf(now)) && covers(loadedWindow, needOf(now))) return derive({ ...now, ...READY });
669
+ if (!covers(win, needOf(now)) && covers(loadedWindow, needOf(now))) return derive(fresh({ ...now, ...READY }));
567
670
  // Only the flags: the schedule is not a row collection, and a failed load keeps no rows.
568
671
  const { sessions: _rows, emptyText: _empty, ...flags } = loadFailed("sessions", err, {
569
672
  prefix: "agenda",
@@ -575,7 +678,7 @@ async function ensureLoaded(base: S, force = false): Promise<S | undefined> {
575
678
  sessions = [];
576
679
  places = new Map();
577
680
  loadedWindow = { start: "", end: "" };
578
- return derive({ ...now, ...flags });
681
+ return derive(fresh({ ...now, ...flags }));
579
682
  }
580
683
  }
581
684
 
@@ -593,8 +696,9 @@ const eventIdOf = (p: unknown): string => argOf(p, "eventId") || argOf(p, "id");
593
696
  function initial(): S {
594
697
  return {
595
698
  ...LOADING, firstWeekday: 0, today: "", selectedDate: "", rangeStart: "", rangeEnd: "",
596
- decorations: [], events: [], scrollTo: "", emptyText: "", message: "", signedIn: false, updateRequired: false, listHidden: true,
597
- sheetOpen: false, sheetTitle: "", sheetWhen: "", sheetHideCancel: true, sheetHideLeave: true, sheetBusy: false,
699
+ decorations: [], events: [], scrollTo: "", signedIn: false, updateRequired: false, updateMessage: "", listHidden: true,
700
+ ...emptyFlags(false, false), resultOpen: false, resultOpenB: false, resultText: "", resultOk: false, resultInfo: false,
701
+ sheetOpen: false, sheetTitle: "", sheetWhen: "", sheetMeta: "", sheetHideCalendar: true, sheetHideCancel: true, sheetHideLeave: true, sheetBusy: false,
598
702
  };
599
703
  }
600
704
 
@@ -610,9 +714,13 @@ const svc = defineService<S, {
610
714
  openSheet: (s: S, p?: unknown) => S;
611
715
  closeSheet: (s: S) => S;
612
716
  viewSession: (s: S) => S;
717
+ closeResult: (s: S, p?: unknown) => S;
718
+ addToCalendar: (s: S) => Promise<S>;
613
719
  cancelBooking: (s: S) => Promise<S | undefined>;
614
720
  joinWaitlist: (s: S, p?: unknown) => Promise<S | undefined>;
615
721
  }>("himiBrandedAgenda", {
722
+ // An action's result belongs to that visit: a cold start must not replay it from saved state.
723
+ notHydrated: ["resultOpen", "resultOpenB", "resultText", "resultOk", "resultInfo"],
616
724
  state: initial(),
617
725
  actions: {
618
726
  load: async (s, p) => {
@@ -629,14 +737,16 @@ const svc = defineService<S, {
629
737
  const week = weekOf(today, (((fw % 7) + 7) % 7) as Weekday);
630
738
  // Re-read the catalog when the screen shows again: classes are added, renamed and removed.
631
739
  if (!first) services = null;
740
+ // A result from before the screen was left is not news when it shows again.
632
741
  const asked = first
633
- ? ask(s, { firstWeekday: fw, today, selectedDate: today, rangeStart: week[0], rangeEnd: week[6] })
634
- : ask(s, { firstWeekday: fw, today });
742
+ ? ask(newView(s), { firstWeekday: fw, today, selectedDate: today, rangeStart: week[0], rangeEnd: week[6] })
743
+ : ask(newView(s), { firstWeekday: fw, today });
635
744
  rowsOk ??= await rowsSupported();
636
- if (!rowsOk) return derive({ ...asked, ...READY, message: copyParam("agenda.updateRequired", "Update the app to see the class schedule.") });
745
+ if (!rowsOk) return derive({ ...asked, ...READY });
637
746
  return ensureLoaded(asked, !first);
638
747
  },
639
- retry: async (s) => { services = null; return ensureLoaded({ ...s, ...LOADING }, true); },
748
+ // A view action: it closes the result, as every other one does through `newView`.
749
+ retry: async (s) => { services = null; return ensureLoaded(newView({ ...s, ...LOADING }), true); },
640
750
  // Each compares with the member's LAST ask, not `s`: a read still in flight has not put the
641
751
  // previous page into `s` yet, and paging back to that page is still a change.
642
752
  selectDate: async (s, p) => {
@@ -651,9 +761,9 @@ const svc = defineService<S, {
651
761
  const page = titledDays(cur.rangeStart, cur.rangeEnd);
652
762
  if (span > 7 && (date < page.first || date > page.last)) {
653
763
  const grid = monthGridOf(date, s.firstWeekday);
654
- if (grid) return ensureLoaded(ask({ ...s, message: "" }, { selectedDate: date, rangeStart: grid.start, rangeEnd: grid.end }));
764
+ if (grid) return ensureLoaded(ask(newView(s), { selectedDate: date, rangeStart: grid.start, rangeEnd: grid.end }));
655
765
  }
656
- return ensureLoaded(ask({ ...s, message: "" }, { selectedDate: date }));
766
+ return ensureLoaded(ask(newView(s), { selectedDate: date }));
657
767
  },
658
768
  rangeChanged: async (s, p) => {
659
769
  const start = argOf(p, "startDate"), end = argOf(p, "endDate");
@@ -663,7 +773,7 @@ const svc = defineService<S, {
663
773
  // on screen rather than a week the user has paged away from.
664
774
  const keep = keepsSelection(cur, start, end);
665
775
  const selectedDate = keep ? cur.selectedDate : selectionForRange(cur.today || dayKey(new Date()), start, end, cur.selectedDate);
666
- return ensureLoaded(ask({ ...s, message: "" }, { rangeStart: start, rangeEnd: end, selectedDate }));
776
+ return ensureLoaded(ask(newView(s), { rangeStart: start, rangeEnd: end, selectedDate }));
667
777
  },
668
778
  // An older binary has no week mode and reports only the month. Its grid then spans the
669
779
  // month's whole weeks; load those, so dots still show on every visible day.
@@ -677,13 +787,13 @@ const svc = defineService<S, {
677
787
  if (!start || !last || (start === cur.rangeStart && last === cur.rangeEnd)) return s;
678
788
  const keep = keepsSelection(cur, start, last);
679
789
  const selectedDate = keep ? cur.selectedDate : selectionForRange(cur.today || dayKey(new Date()), start, last, cur.selectedDate);
680
- return ensureLoaded(ask(s, { rangeStart: start, rangeEnd: last, selectedDate }));
790
+ return ensureLoaded(ask(newView(s), { rangeStart: start, rangeEnd: last, selectedDate }));
681
791
  },
682
792
  openSheet: (s, p) => {
683
793
  const key = eventIdOf(p);
684
794
  if (!places.get(key)) return s;
685
795
  sheetKey = key;
686
- return derive({ ...s, message: "" });
796
+ return derive(newView(s));
687
797
  },
688
798
  closeSheet: (s) => {
689
799
  if (busy) return s;
@@ -695,6 +805,62 @@ const svc = defineService<S, {
695
805
  sheetKey = "";
696
806
  return session ? navigate(derive(s), { screenId: "himi_service", args: { id: session.serviceId } }) : derive(s);
697
807
  },
808
+ // From one slot's own close or dismissal (`slot`: "a" | "b"), or both when it names none.
809
+ closeResult: (s, p) => {
810
+ const slot = argOf(p, "slot");
811
+ const closed = slot === "a" ? { resultOpen: false } : slot === "b" ? { resultOpenB: false } : NO_RESULT;
812
+ lastResult = { ...lastResult, ...closed };
813
+ if (slot === "a") return s.resultOpen ? { ...s, resultOpen: false } : s;
814
+ if (slot === "b") return s.resultOpenB ? { ...s, resultOpenB: false } : s;
815
+ return s.resultOpen || s.resultOpenB ? { ...s, ...NO_RESULT } : s;
816
+ },
817
+ // Add a booked session to the device calendar — the event screen's flow: ask for write access,
818
+ // create the event, and fall back to the Google Calendar template page when the calendar is
819
+ // refused or creates nothing. Nothing is written to Wix.
820
+ addToCalendar: async (s): Promise<S> => {
821
+ const session = sessions.find((x) => x.key === sheetKey);
822
+ if (!session || places.get(session.key)?.place !== "booked" || busy || calendarBusy) return s;
823
+ // One calendar flow at a time. The sheet is left as it is while the flow runs (a reload that
824
+ // lands meanwhile must not close it — on iOS the calendar page is presented on it), and the
825
+ // member may close it, view the class, or open another session's sheet. Meanwhile its Add to
826
+ // Calendar and Cancel are inert (`calendarBusy`); a sheet opened during the flow reads them as
827
+ // disabled (sheetBusy). At the end the flow closes the sheet only if it is still this
828
+ // session's. Not `busy`: closeSheet must still work while the flow runs.
829
+ calendarBusy = true;
830
+ const start = Date.parse(session.start);
831
+ const end = Date.parse(session.end) > start ? Date.parse(session.end) : start + 60 * 60 * 1000;
832
+ const fallback = async (failed: string, failedFallback: string): Promise<ResultFields> => {
833
+ const opened = await open(calendarFallbackUrl(session, start, end), "in_app_browser").then((r) => r.opened, () => false);
834
+ // On iOS the in-app browser is presented ON the session sheet; closing the sheet now would
835
+ // close the browser with it. The sheet stays, and the member closes it after the browser.
836
+ // So the flow leaves an open sheet open; only a sheet that did not get the page is closed.
837
+ if (!opened && sheetKey === session.key) sheetKey = "";
838
+ return result(opened ? copyParam("agenda.calendarOpened", "Opened your calendar.") : copyParam(failed, failedFallback), false);
839
+ };
840
+ let outcome: ResultFields;
841
+ try {
842
+ let permission = await getPermissionStatus();
843
+ if (permission !== "granted" && permission !== "limited") permission = await requestPermission("writeOnly");
844
+ if (permission !== "granted" && permission !== "limited") {
845
+ outcome = await fallback("agenda.calendarDenied", "The app cannot use your calendar. Allow it in Settings.");
846
+ } else {
847
+ const created = await createEvent({ title: session.title, start, end, location: session.location, notes: session.staff });
848
+ if (created.id) {
849
+ if (sheetKey === session.key) sheetKey = "";
850
+ outcome = result(copyParam("agenda.calendarAdded", "Added to your calendar."), true);
851
+ } else {
852
+ outcome = await fallback("agenda.calendarFailed", "Could not add this class to your calendar.");
853
+ }
854
+ }
855
+ } catch {
856
+ // A calendar that throws (EventKit refusing the save) gets the same fallback as one that
857
+ // creates nothing: the calendar page, which `fallback` opens without throwing.
858
+ outcome = await fallback("agenda.calendarFailed", "Could not add this class to your calendar.");
859
+ } finally {
860
+ calendarBusy = false;
861
+ }
862
+ return derive({ ...(svc.getState() as S), ...outcome });
863
+ },
698
864
  // Cancel a booking (the MEMBER cancel, so Wix enforces the business's cancellation
699
865
  // policy), or leave a waitlist (which also releases the pending booking it created).
700
866
  cancelBooking: async (s): Promise<S | undefined> => {
@@ -703,7 +869,7 @@ const svc = defineService<S, {
703
869
  const owner = member;
704
870
  const session = sessions.find((x) => x.key === sheetKey);
705
871
  const leaving = place?.place === "waitlisted";
706
- if (!place || !session || busy || (!leaving && !place.revision)) return s;
872
+ if (!place || !session || busy || calendarBusy || (!leaving && !place.revision)) return s;
707
873
  busy = true;
708
874
  try {
709
875
  if (!(await sameMember(owner))) throw memberAccessChanged();
@@ -711,24 +877,24 @@ const svc = defineService<S, {
711
877
  else await cancelBookingAsMember(place.bookingId, place.revision);
712
878
  places.delete(sheetKey);
713
879
  sheetKey = "";
714
- const message = leaving ? copyParam("agenda.leftWaitlist", "You left the waitlist.") : copyParam("agenda.cancelDone", "Booking cancelled.");
880
+ const done = leaving ? copyParam("agenda.leftWaitlist", "You left the waitlist.") : copyParam("agenda.cancelDone", "Booking cancelled.");
715
881
  // Reload against the state as it is NOW: the member may have paged while the write ran.
716
882
  const next = await ensureLoaded(svc.getState() as S, true);
717
883
  busy = false;
718
- return derive({ ...(next ?? { ...(svc.getState() as S), ...wanted }), message });
884
+ return derive({ ...(next ?? { ...(svc.getState() as S), ...wanted }), ...result(done, true) });
719
885
  } catch (err) {
720
886
  sheetKey = "";
721
887
  if ((err as { name?: string } | null)?.name === "MemberAccessChanged") {
722
888
  // Someone else (or nobody) since the load: show their schedule; nothing is cancelled.
723
- const next = await ensureLoaded(svc.getState() as S, true);
889
+ const next = keepResult(await ensureLoaded(svc.getState() as S, true));
724
890
  busy = false;
725
891
  return next ? derive(next) : undefined;
726
892
  }
727
893
  busy = false;
728
- const message = isBookingPolicyViolation(err)
894
+ const failed = isBookingPolicyViolation(err)
729
895
  ? copyParam("agenda.cancelPolicy", "It's too late to cancel in the app. Contact the business.")
730
896
  : copyParam("agenda.cancelFailed", "That didn't go through. Try again.");
731
- return derive({ ...(svc.getState() as S), message });
897
+ return derive({ ...(svc.getState() as S), ...result(failed, false) });
732
898
  }
733
899
  },
734
900
  joinWaitlist: async (s, p): Promise<S | undefined> => {
@@ -755,7 +921,7 @@ const svc = defineService<S, {
755
921
  if (!same(before, current) || !same(owner, current)) {
756
922
  // Another member since the load — or the load was a visitor's, so their places were never
757
923
  // read: show their schedule first; nothing is registered.
758
- const next = await ensureLoaded(svc.getState() as S, true);
924
+ const next = keepResult(await ensureLoaded(svc.getState() as S, true));
759
925
  busy = false;
760
926
  return next ? derive(next) : undefined;
761
927
  }
@@ -763,13 +929,13 @@ const svc = defineService<S, {
763
929
  // Marked at once, so until a reload lands the row reads On waitlist and offers no second
764
930
  // join — whether this reload commits or a newer page overtakes it.
765
931
  places.set(key, { bookingId: res?.booking?.id ?? "", revision: "", place: "waitlisted", registrationId: res?.registrationId, confirmed: false });
766
- const message = copyParam("agenda.joinedWaitlist", "You're on the waitlist.");
932
+ const joined = copyParam("agenda.joinedWaitlist", "You're on the waitlist.");
767
933
  const next = await ensureLoaded(svc.getState() as S, true);
768
934
  busy = false;
769
- return derive({ ...(next ?? { ...(svc.getState() as S), ...wanted }), message });
935
+ return derive({ ...(next ?? { ...(svc.getState() as S), ...wanted }), ...result(joined, true) });
770
936
  } catch {
771
937
  busy = false;
772
- return derive({ ...(svc.getState() as S), message: copyParam("agenda.waitlistFailed", "Couldn't join the waitlist. Try again.") });
938
+ return derive({ ...(svc.getState() as S), ...result(copyParam("agenda.waitlistFailed", "Couldn't join the waitlist. Try again."), false) });
773
939
  }
774
940
  },
775
941
  },