@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.
- package/dist/cli.mjs +11 -3
- package/package.json +2 -2
- package/templates/beauty-salon/dev/index.ts +1300 -430
- package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +240 -74
- package/templates/branded-lite/dev/index.ts +1300 -430
- package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +240 -74
- package/templates/gym-fitness/dev/index.ts +1300 -430
- package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +240 -74
- package/templates/yoga-studio/dev/index.ts +1300 -430
- package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +240 -74
|
@@ -1,22 +1,28 @@
|
|
|
1
|
-
// himi-branded-lite-agenda — the Bookings Agenda: every upcoming CLASS session of the
|
|
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
|
|
7
|
-
// "Waitlist"; a full one without, or one outside the booking window,
|
|
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.
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
|
36
|
-
|
|
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
|
|
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
|
-
/** "
|
|
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
|
-
|
|
122
|
-
? copyTemplate("agenda.lengthHoursMinutes", "{h}
|
|
123
|
-
: h ? copyTemplate("agenda.lengthHours", "{h}
|
|
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:
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
217
|
-
const
|
|
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
|
|
267
|
+
time,
|
|
228
268
|
length: lengthLabel(s.start, s.end),
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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")
|
|
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
|
-
|
|
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 ?
|
|
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
|
|
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: "",
|
|
597
|
-
|
|
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
|
|
745
|
+
if (!rowsOk) return derive({ ...asked, ...READY });
|
|
637
746
|
return ensureLoaded(asked, !first);
|
|
638
747
|
},
|
|
639
|
-
|
|
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(
|
|
764
|
+
if (grid) return ensureLoaded(ask(newView(s), { selectedDate: date, rangeStart: grid.start, rangeEnd: grid.end }));
|
|
655
765
|
}
|
|
656
|
-
return ensureLoaded(ask(
|
|
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(
|
|
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(
|
|
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
|
|
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 }),
|
|
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
|
|
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),
|
|
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
|
|
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 }),
|
|
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),
|
|
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
|
},
|