@wix/himalaya-cli 0.1487.0 → 0.1489.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/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/builder.d.ts +12 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +13 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/bookings-v2/src/extensions.d.ts +48 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-bookings.mjs +22 -0
- package/dist/cli.mjs +145 -42
- package/dist/preview-assets.generated.bin +0 -0
- package/package.json +2 -2
- package/templates/beauty-salon/config.ts +13 -0
- package/templates/beauty-salon/dev/index.ts +784 -0
- package/templates/beauty-salon/himalaya.content.json +3 -0
- package/templates/beauty-salon/tier5-src/_shared/branded-lite-hosted.ts +2 -1
- package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +106 -0
- package/templates/beauty-salon/tier5-src/_shared/himi-bookings.ts +14 -0
- package/templates/beauty-salon/tier5-src/_shared/member-account-routes.ts +2 -1
- package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +25 -11
- package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +769 -0
- package/templates/branded-lite/config.ts +13 -0
- package/templates/branded-lite/dev/index.ts +930 -66
- package/templates/branded-lite/himalaya.content.json +3 -0
- package/templates/branded-lite/tier5-src/_shared/branded-lite-hosted.ts +2 -1
- package/templates/branded-lite/tier5-src/_shared/business-chat-model.ts +5 -3
- package/templates/branded-lite/tier5-src/_shared/business-chat-presenter.ts +84 -5
- package/templates/branded-lite/tier5-src/_shared/himi-bookings.ts +14 -0
- package/templates/branded-lite/tier5-src/_shared/member-account-routes.ts +2 -1
- package/templates/branded-lite/tier5-src/business-chat/index.ts +135 -35
- package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +25 -11
- package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +769 -0
- package/templates/community-club/tier5-src/_shared/branded-lite-hosted.ts +2 -1
- package/templates/community-club/tier5-src/_shared/member-account-routes.ts +2 -1
- package/templates/events-meetups/tier5-src/_shared/branded-lite-hosted.ts +2 -1
- package/templates/events-meetups/tier5-src/_shared/member-account-routes.ts +2 -1
- package/templates/gym-fitness/config.ts +13 -0
- package/templates/gym-fitness/dev/index.ts +784 -0
- package/templates/gym-fitness/himalaya.content.json +3 -0
- package/templates/gym-fitness/tier5-src/_shared/branded-lite-hosted.ts +2 -1
- package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +106 -0
- package/templates/gym-fitness/tier5-src/_shared/himi-bookings.ts +14 -0
- package/templates/gym-fitness/tier5-src/_shared/member-account-routes.ts +2 -1
- package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +25 -11
- package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +769 -0
- package/templates/nonprofit-cause/tier5-src/_shared/branded-lite-hosted.ts +2 -1
- package/templates/nonprofit-cause/tier5-src/_shared/member-account-routes.ts +2 -1
- package/templates/podcast-creator/tier5-src/_shared/branded-lite-hosted.ts +2 -1
- package/templates/podcast-creator/tier5-src/_shared/member-account-routes.ts +2 -1
- package/templates/portfolio-creative/tier5-src/_shared/branded-lite-hosted.ts +2 -1
- package/templates/portfolio-creative/tier5-src/_shared/member-account-routes.ts +2 -1
- package/templates/restaurant/tier5-src/_shared/branded-lite-hosted.ts +2 -1
- package/templates/restaurant/tier5-src/_shared/member-account-routes.ts +2 -1
- package/templates/retail-store/tier5-src/_shared/branded-lite-hosted.ts +2 -1
- package/templates/retail-store/tier5-src/_shared/member-account-routes.ts +2 -1
- package/templates/yoga-studio/config.ts +13 -0
- package/templates/yoga-studio/dev/index.ts +784 -0
- package/templates/yoga-studio/himalaya.content.json +3 -0
- package/templates/yoga-studio/tier5-src/_shared/branded-lite-hosted.ts +2 -1
- package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +106 -0
- package/templates/yoga-studio/tier5-src/_shared/himi-bookings.ts +14 -0
- package/templates/yoga-studio/tier5-src/_shared/member-account-routes.ts +2 -1
- package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +25 -11
- package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +769 -0
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
// himi-branded-lite-agenda — the Bookings Agenda: every upcoming CLASS session of the site,
|
|
2
|
+
// 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 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.
|
|
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. 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.
|
|
14
|
+
//
|
|
15
|
+
// The calendar marks every day with a session (one dot) and today (its own ring). The list
|
|
16
|
+
// starts at the selected day, so a day with nothing on it shows the next sessions instead
|
|
17
|
+
// of an empty page — the agenda answers "what is on from here".
|
|
18
|
+
import { defineService, navigate } from "@himalaya/state";
|
|
19
|
+
import { addDays, dayKey, toDate, weekOf, type DayDecoration, type Weekday } from "@himalaya/time";
|
|
20
|
+
import {
|
|
21
|
+
cancelBookingAsMember, isBookingPolicyViolation, leaveWaitlist, listWaitlists, queryBookings, queryServiceAvailability,
|
|
22
|
+
queryServices, registerToWaitlist, type WixAvailabilityEntry, type WixBooking, type WixService,
|
|
23
|
+
} from "@himalaya/wix-bookings";
|
|
24
|
+
import { getMyMember } from "@himalaya/wix-members";
|
|
25
|
+
import { LOADING, loadFailed, type LoadFlags } from "../_shared/branded-load-state.js";
|
|
26
|
+
import { copyParam, copyTemplate, stashCopy } from "../_shared/himi-copy.js";
|
|
27
|
+
import { slotStartInstant } from "../_shared/himi-bookings.js";
|
|
28
|
+
import { compareText } from "../_shared/engine-intl.js";
|
|
29
|
+
import { assertMemberAccess, memberAccess, memberAccessChanged, type MemberAccess } from "../_shared/member-access.js";
|
|
30
|
+
import { atLeast } from "../_shared/member-account-routes.js";
|
|
31
|
+
import { get as getAppInfo } from "@himalaya/app-info";
|
|
32
|
+
|
|
33
|
+
/** How far past the selected day the list reaches. */
|
|
34
|
+
export const AGENDA_DAYS = 28;
|
|
35
|
+
/** Services whose sessions are fixed events, each booked on its own. */
|
|
36
|
+
const SESSION_TYPES = new Set(["CLASS"]);
|
|
37
|
+
/** A booking in one of these no longer holds a place. */
|
|
38
|
+
const RELEASED = new Set(["CANCELED", "CANCELLED", "DECLINED"]);
|
|
39
|
+
|
|
40
|
+
export type Place = "booked" | "waitlisted" | "";
|
|
41
|
+
|
|
42
|
+
/** One session as the screen needs it. `key` is the row's identity: the Wix session id when the
|
|
43
|
+
* entry has one — two sessions of a class at the same time are two rows — else service + start. */
|
|
44
|
+
export interface Session {
|
|
45
|
+
key: string;
|
|
46
|
+
/** The Wix session id — what a waitlist registers against. "" when the entry has none. */
|
|
47
|
+
sessionId: string;
|
|
48
|
+
serviceId: string;
|
|
49
|
+
title: string;
|
|
50
|
+
start: string; // ISO instant with offset
|
|
51
|
+
end: string;
|
|
52
|
+
day: string; // LOCAL day key
|
|
53
|
+
staff: string;
|
|
54
|
+
location: string;
|
|
55
|
+
total: number;
|
|
56
|
+
open: number;
|
|
57
|
+
bookable: boolean;
|
|
58
|
+
/** The session's waitlist: its size (0 = it has none) and the places left on it. */
|
|
59
|
+
waitlistTotal: number;
|
|
60
|
+
/** A booking policy closes it (too early, too late, online booking off): no Book, no Waitlist. */
|
|
61
|
+
policyClosed: boolean;
|
|
62
|
+
waitlistOpen: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface MemberPlace { bookingId: string; revision: string; place: Place; registrationId?: string; confirmed?: boolean }
|
|
66
|
+
|
|
67
|
+
const count = (v: unknown): number => {
|
|
68
|
+
const n = Number(v ?? 0);
|
|
69
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The `Agenda` wire shape: declared fields plus a flat string `extra` map for the row. */
|
|
73
|
+
export interface AgendaEvent {
|
|
74
|
+
id: string;
|
|
75
|
+
title: string;
|
|
76
|
+
start: string;
|
|
77
|
+
end: string;
|
|
78
|
+
extra: Record<string, string>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface S extends LoadFlags {
|
|
82
|
+
firstWeekday: number;
|
|
83
|
+
today: string;
|
|
84
|
+
selectedDate: string;
|
|
85
|
+
rangeStart: string;
|
|
86
|
+
rangeEnd: string;
|
|
87
|
+
decorations: DayDecoration[];
|
|
88
|
+
events: AgendaEvent[];
|
|
89
|
+
scrollTo: string;
|
|
90
|
+
emptyText: string;
|
|
91
|
+
message: string;
|
|
92
|
+
signedIn: boolean;
|
|
93
|
+
/** An older Android binary: its Agenda drops each row's fields, so the list is replaced by a
|
|
94
|
+
* line asking to update. */
|
|
95
|
+
updateRequired: boolean;
|
|
96
|
+
/** The list is hidden while loading or failed, and when an update is required. */
|
|
97
|
+
listHidden: boolean;
|
|
98
|
+
// The session sheet (Booked / On waitlist).
|
|
99
|
+
sheetOpen: boolean;
|
|
100
|
+
sheetTitle: string;
|
|
101
|
+
sheetWhen: string;
|
|
102
|
+
sheetHideCancel: boolean;
|
|
103
|
+
sheetHideLeave: boolean;
|
|
104
|
+
sheetBusy: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const flag = (on: boolean): string => (on ? "true" : "");
|
|
108
|
+
|
|
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. */
|
|
117
|
+
export function lengthLabel(start: string, end: string): string {
|
|
118
|
+
const mins = Math.round((Date.parse(end) - Date.parse(start)) / 60_000);
|
|
119
|
+
if (!Number.isFinite(mins) || mins <= 0) return "";
|
|
120
|
+
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})`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A slot's instant as an ISO string WITH an offset — iOS parses wire dates strictly. */
|
|
128
|
+
function instantIso(slot: { startDate?: string; timezone?: unknown }, field: "startDate" | "endDate"): string {
|
|
129
|
+
const ms = slotStartInstant({ startDate: (slot as Record<string, unknown>)[field] as string | undefined, timezone: slot.timezone });
|
|
130
|
+
return Number.isFinite(ms) ? new Date(ms).toISOString() : "";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const sessionKey = (serviceId: string, startIso: string): string => `${serviceId}|${Date.parse(startIso)}`;
|
|
134
|
+
|
|
135
|
+
/** A raw availability entry → a Session, or null when it has no usable start. */
|
|
136
|
+
export function sessionOf(entry: WixAvailabilityEntry, service: WixService): Session | null {
|
|
137
|
+
const slot = (entry.slot ?? {}) as Record<string, unknown> & { startDate?: string; endDate?: string; timezone?: unknown };
|
|
138
|
+
const start = instantIso(slot, "startDate");
|
|
139
|
+
if (!start) return null;
|
|
140
|
+
const end = instantIso(slot, "endDate") || start;
|
|
141
|
+
const raw = entry as unknown as {
|
|
142
|
+
totalSpots?: number; openSpots?: number; waitingList?: { totalSpots?: number; openSpots?: number };
|
|
143
|
+
bookingPolicyViolations?: { tooEarlyToBook?: boolean; tooLateToBook?: boolean; bookOnlineDisabled?: boolean };
|
|
144
|
+
};
|
|
145
|
+
const policy = raw.bookingPolicyViolations;
|
|
146
|
+
const resource = slot.resource as { name?: string } | undefined;
|
|
147
|
+
const location = slot.location as { name?: string; formattedAddress?: string } | undefined;
|
|
148
|
+
const serviceId = String(slot.serviceId ?? service.id ?? "");
|
|
149
|
+
const sessionId = typeof slot.sessionId === "string" ? slot.sessionId : "";
|
|
150
|
+
return {
|
|
151
|
+
key: sessionId || sessionKey(serviceId, start),
|
|
152
|
+
sessionId,
|
|
153
|
+
serviceId,
|
|
154
|
+
title: service.name ?? "",
|
|
155
|
+
start, end,
|
|
156
|
+
day: dayKey(start),
|
|
157
|
+
staff: resource?.name?.trim() ?? "",
|
|
158
|
+
location: (location?.name || location?.formattedAddress || "").trim(),
|
|
159
|
+
total: count(raw.totalSpots),
|
|
160
|
+
open: count(raw.openSpots),
|
|
161
|
+
bookable: entry.bookable !== false,
|
|
162
|
+
waitlistTotal: count(raw.waitingList?.totalSpots),
|
|
163
|
+
waitlistOpen: count(raw.waitingList?.openSpots),
|
|
164
|
+
policyClosed: policy?.tooEarlyToBook === true || policy?.tooLateToBook === true || policy?.bookOnlineDisabled === true,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The member's bookings in the window → their place, keyed by the booking's session id when it
|
|
169
|
+
* names one, else by service + start. `bySession` turns that into each row's own key. */
|
|
170
|
+
export function placesOf(bookings: WixBooking[], email: string): Map<string, MemberPlace> {
|
|
171
|
+
const out = new Map<string, MemberPlace>();
|
|
172
|
+
for (const b of bookings) {
|
|
173
|
+
if (!b.id || b.contactDetails?.email?.trim().toLowerCase() !== email) continue;
|
|
174
|
+
const status = (b.status || "").toUpperCase();
|
|
175
|
+
if (RELEASED.has(status)) continue;
|
|
176
|
+
const slot = b.bookedEntity?.slot;
|
|
177
|
+
const serviceId = slot?.serviceId ?? "";
|
|
178
|
+
const start = b.startDate || (slot ? instantIso(slot, "startDate") : "");
|
|
179
|
+
if (!serviceId || !start) continue;
|
|
180
|
+
// Its session when the booking names one, so a parallel session is not marked too.
|
|
181
|
+
const bookedSession = (slot as { sessionId?: unknown } | undefined)?.sessionId;
|
|
182
|
+
out.set(typeof bookedSession === "string" && bookedSession ? bookedSession : sessionKey(serviceId, start), {
|
|
183
|
+
bookingId: b.id,
|
|
184
|
+
revision: String((b as WixBooking & { revision?: string | number }).revision ?? ""),
|
|
185
|
+
place: status === "WAITING_LIST" ? "waitlisted" : "booked",
|
|
186
|
+
confirmed: status === "CONFIRMED",
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Each session's place, under the session's own key: by its session id, else by service + start. */
|
|
193
|
+
export function bySession(found: Map<string, MemberPlace>, inWindow: Session[]): Map<string, MemberPlace> {
|
|
194
|
+
const out = new Map<string, MemberPlace>();
|
|
195
|
+
for (const x of inWindow) {
|
|
196
|
+
const place = (x.sessionId ? found.get(x.sessionId) : undefined) ?? found.get(sessionKey(x.serviceId, x.start));
|
|
197
|
+
if (place) out.set(x.key, place);
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
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 "";
|
|
205
|
+
if (s.open <= 0) return copyParam("agenda.full", "Full");
|
|
206
|
+
return `${s.total - s.open}/${s.total}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** One session → one Agenda row. Exactly one of the trailing states is visible. */
|
|
210
|
+
export function eventOf(s: Session, place: MemberPlace | undefined): AgendaEvent {
|
|
211
|
+
const mine = place?.place ?? "";
|
|
212
|
+
const full = s.total > 0 && s.open <= 0;
|
|
213
|
+
const canBook = !mine && s.bookable && !full && !s.policyClosed;
|
|
214
|
+
// 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);
|
|
218
|
+
return {
|
|
219
|
+
id: s.key,
|
|
220
|
+
title: s.title,
|
|
221
|
+
start: s.start,
|
|
222
|
+
end: s.end,
|
|
223
|
+
extra: {
|
|
224
|
+
serviceId: s.serviceId,
|
|
225
|
+
// Choose Time opens on THIS session: two of a class can start at the same time.
|
|
226
|
+
sessionId: s.sessionId,
|
|
227
|
+
time: clock(s.start),
|
|
228
|
+
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),
|
|
234
|
+
hideBook: flag(!canBook),
|
|
235
|
+
hideWaitlist: flag(!canWait),
|
|
236
|
+
hideBooked: flag(mine !== "booked"),
|
|
237
|
+
hideOnWaitlist: flag(mine !== "waitlisted"),
|
|
238
|
+
hideClosed: flag(!closed),
|
|
239
|
+
closedLabel: full ? copyParam("agenda.full", "Full") : copyParam("agenda.closed", "Closed"),
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The first and last cell any month grid of `day`'s month can have, padding included. */
|
|
245
|
+
function monthGrid(day: string): { start: string; end: string } {
|
|
246
|
+
const d = toDate(day);
|
|
247
|
+
if (!d) return { start: day, end: day };
|
|
248
|
+
return { start: dayKey(new Date(d.getFullYear(), d.getMonth(), 1 - 6)), end: dayKey(new Date(d.getFullYear(), d.getMonth() + 1, 6)) };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** One dot per day that has a session — "something is on", not how much — and the visible
|
|
252
|
+
* days before today dimmed and unselectable: the agenda is what is still to come. */
|
|
253
|
+
export function decorationsOf(sessions: Session[], today: string, rangeStart: string, rangeEnd: string): DayDecoration[] {
|
|
254
|
+
const out: DayDecoration[] = [];
|
|
255
|
+
const dimmed = new Set<string>();
|
|
256
|
+
const dim = (from: string, to: string) => {
|
|
257
|
+
for (let day = from; day && day < today && day <= to; day = dayKey(addDays(day, 1))) {
|
|
258
|
+
if (!dimmed.has(day)) { dimmed.add(day); out.push({ date: day, disabled: true }); }
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
// Bounded by the visible days: a page a year back dims its 7-42 cells, not a year.
|
|
262
|
+
dim(rangeStart, rangeEnd);
|
|
263
|
+
// And this month's grid: an older binary (no week mode) draws it on first open, while it
|
|
264
|
+
// reports nothing until the member pages.
|
|
265
|
+
dim(monthGrid(today).start, today);
|
|
266
|
+
for (const date of [...new Set(sessions.filter((s) => s.day >= today).map((s) => s.day))].sort()) out.push({ date, dotCount: 1 });
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The days to load: the visible days (never before today) through AGENDA_DAYS past the
|
|
271
|
+
* selected day, so the list under the calendar always has its full reach. */
|
|
272
|
+
export function windowFor(today: string, rangeStart: string, rangeEnd: string, selected: string): { start: string; end: string } {
|
|
273
|
+
const start = rangeStart > today ? rangeStart : today;
|
|
274
|
+
const from = selected > today ? selected : today;
|
|
275
|
+
// Through the last cell of the selected day's month grid as well, which is what an older
|
|
276
|
+
// binary draws before it has reported any page: every dot on it is then real.
|
|
277
|
+
const end = [rangeEnd, dayKey(addDays(from, AGENDA_DAYS)), monthGrid(from).end].reduce((a, b) => (b > a ? b : a));
|
|
278
|
+
return { start, end };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Where the selection goes when the calendar pages away from it: the first day of the
|
|
282
|
+
* page (the 1st of the titled month in month view — the grid's first cell is padding),
|
|
283
|
+
* or today when that is on the page. A page wholly before today has no day to select (past
|
|
284
|
+
* days are disabled), so `current` stays selected and the list keeps showing from it. */
|
|
285
|
+
export function selectionForRange(today: string, rangeStart: string, rangeEnd: string, current = today): string {
|
|
286
|
+
const { first, last } = titledDays(rangeStart, rangeEnd);
|
|
287
|
+
if (today >= first && today <= last) return today;
|
|
288
|
+
return last < today ? current : first;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The days a page is about: the whole week, or a month grid's TITLED month — its padding cells
|
|
292
|
+
* belong to the neighbouring months. */
|
|
293
|
+
export function titledDays(rangeStart: string, rangeEnd: string): { first: string; last: string } {
|
|
294
|
+
let first = rangeStart;
|
|
295
|
+
let last = rangeEnd;
|
|
296
|
+
const span = Math.round((Date.parse(rangeEnd) - Date.parse(rangeStart)) / 86_400_000);
|
|
297
|
+
if (span > 7) {
|
|
298
|
+
// A month grid: its padding cells belong to the neighbouring months. Today counts only when
|
|
299
|
+
// it is in the TITLED month — a today in the trailing padding would move the selection, and
|
|
300
|
+
// with it the grid, a month forward.
|
|
301
|
+
const d = toDate(rangeStart);
|
|
302
|
+
if (d) {
|
|
303
|
+
const month = rangeStart.endsWith("-01") ? d : new Date(d.getFullYear(), d.getMonth() + 1, 1);
|
|
304
|
+
first = dayKey(month);
|
|
305
|
+
last = dayKey(new Date(month.getFullYear(), month.getMonth() + 1, 0));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return { first, last };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The month grid (whole weeks) of `day`'s month, as a calendar draws it. */
|
|
312
|
+
function monthGridOf(day: string, firstWeekday: number): { start: string; end: string } | undefined {
|
|
313
|
+
const d = toDate(day);
|
|
314
|
+
if (!d) return undefined;
|
|
315
|
+
const fw = (((firstWeekday % 7) + 7) % 7) as Weekday;
|
|
316
|
+
const start = weekOf(new Date(d.getFullYear(), d.getMonth(), 1), fw)[0];
|
|
317
|
+
const end = weekOf(new Date(d.getFullYear(), d.getMonth() + 1, 0), fw)[6];
|
|
318
|
+
return start && end ? { start, end } : undefined;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Does the selection survive a page change? Inside the page's TITLED days, yes — a selection in a
|
|
322
|
+
* month grid's padding belongs to the neighbouring month. Today stays selected on any page that
|
|
323
|
+
* shows it: opening a week that straddles two months into the month holding most of it must not
|
|
324
|
+
* move the agenda off today. */
|
|
325
|
+
export function keepsSelection(cur: { today: string; selectedDate: string }, start: string, end: string): boolean {
|
|
326
|
+
const page = titledDays(start, end);
|
|
327
|
+
if (cur.selectedDate >= page.first && cur.selectedDate <= page.last) return true;
|
|
328
|
+
return !!cur.today && cur.selectedDate === cur.today && cur.today >= start && cur.today <= end;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ── loading ──────────────────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
let services: WixService[] | null = null;
|
|
334
|
+
let sessions: Session[] = [];
|
|
335
|
+
let places = new Map<string, MemberPlace>();
|
|
336
|
+
let loadedWindow = { start: "", end: "" };
|
|
337
|
+
let member: MemberAccess | undefined;
|
|
338
|
+
let sheetKey = "";
|
|
339
|
+
let busy = false;
|
|
340
|
+
|
|
341
|
+
async function sessionServices(): Promise<WixService[]> {
|
|
342
|
+
if (services) return services;
|
|
343
|
+
// Every page, not the first 100: a class past the first page would silently be missing. Bounded,
|
|
344
|
+
// so a misbehaving paging answer cannot loop forever.
|
|
345
|
+
const all: WixService[] = [];
|
|
346
|
+
for (let page = 0; page < 10; page++) {
|
|
347
|
+
const res = await queryServices({ query: { paging: { limit: 100, offset: page * 100 } } } as never);
|
|
348
|
+
const batch = res.services ?? [];
|
|
349
|
+
all.push(...batch);
|
|
350
|
+
if (batch.length < 100) break;
|
|
351
|
+
}
|
|
352
|
+
services = all.filter((svc) => {
|
|
353
|
+
const raw = svc as WixService & { type?: string; hidden?: boolean };
|
|
354
|
+
return !!svc.id && SESSION_TYPES.has((raw.type ?? "").toUpperCase()) && raw.hidden !== true;
|
|
355
|
+
});
|
|
356
|
+
return services;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Every session of every class service in [start, end]. Each service is its own
|
|
360
|
+
* call — the proven single-service availability query — so one service failing (a draft,
|
|
361
|
+
* a removed schedule) costs its own rows, never the whole agenda. */
|
|
362
|
+
async function sessionsIn(start: string, end: string): Promise<Session[]> {
|
|
363
|
+
const list = await sessionServices();
|
|
364
|
+
const from = new Date(`${start}T00:00:00`).toISOString();
|
|
365
|
+
const to = new Date(`${dayKey(addDays(end, 1))}T00:00:00`).toISOString();
|
|
366
|
+
const settled = await Promise.allSettled(list.map((svc) => queryServiceAvailability(svc.id, from, to, { bookable: false })));
|
|
367
|
+
const failures = settled.filter((r) => r.status === "rejected").length;
|
|
368
|
+
if (list.length && failures === list.length) throw (settled[0] as PromiseRejectedResult).reason;
|
|
369
|
+
const out: Session[] = [];
|
|
370
|
+
settled.forEach((r, i) => {
|
|
371
|
+
if (r.status !== "fulfilled") return;
|
|
372
|
+
for (const entry of r.value.availabilityEntries ?? []) {
|
|
373
|
+
const session = sessionOf(entry, list[i]);
|
|
374
|
+
if (session) out.push(session);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
const seen = new Set<string>();
|
|
378
|
+
return out
|
|
379
|
+
.filter((s) => (seen.has(s.key) ? false : (seen.add(s.key), true)))
|
|
380
|
+
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start) || compareText(a.title, b.title));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Mark the places that are WAITLIST registrations, with the registration id leaving needs.
|
|
384
|
+
* A registration's booking is an ordinary pending booking, so the booking alone cannot say
|
|
385
|
+
* "on the waitlist" — only the session's waitlist can, and only sessions with one are asked. */
|
|
386
|
+
export function markWaitlisted(found: Map<string, MemberPlace>, sessionsByKey: Map<string, Session>, lists: { waitingResource?: string; registrations?: { id?: string; bookingId?: string }[] }[]): void {
|
|
387
|
+
const byBooking = new Map<string, string>();
|
|
388
|
+
for (const list of lists) for (const r of list.registrations ?? []) if (r.bookingId && r.id) byBooking.set(r.bookingId, r.id);
|
|
389
|
+
for (const [key, place] of found) {
|
|
390
|
+
const registrationId = byBooking.get(place.bookingId);
|
|
391
|
+
if (registrationId && sessionsByKey.has(key)) found.set(key, { ...place, place: "waitlisted", registrationId });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** The signed-in member's bookings in the window, with the member they were read for: the two
|
|
396
|
+
* are committed together, so a write checks against the member its place belongs to. A visitor
|
|
397
|
+
* has none; a read that fails fails the load. */
|
|
398
|
+
async function placesIn(start: string, end: string, inWindow: Session[], attempt = 0): Promise<{ who: MemberAccess | undefined; found: Map<string, MemberPlace> }> {
|
|
399
|
+
const who = await memberAccess().catch(() => undefined);
|
|
400
|
+
if (!who?.email) return { who, found: new Map() };
|
|
401
|
+
try {
|
|
402
|
+
const from = new Date(`${start}T00:00:00`).toISOString();
|
|
403
|
+
const to = new Date(`${dayKey(addDays(end, 1))}T00:00:00`).toISOString();
|
|
404
|
+
// Every page, not the first 100: a booking past it would read Book again. Bounded.
|
|
405
|
+
const bookings: WixBooking[] = [];
|
|
406
|
+
for (let page = 0; page < 5; page++) {
|
|
407
|
+
const res = await queryBookings({ query: { filter: { "contactDetails.email": { $eq: who.email }, startDate: { $gte: from, $lt: to } }, paging: { limit: 100, offset: page * 100 } } } as never);
|
|
408
|
+
const batch = res.bookings ?? [];
|
|
409
|
+
bookings.push(...batch);
|
|
410
|
+
if (batch.length < 100) break;
|
|
411
|
+
}
|
|
412
|
+
// The same member still? A sign-out or a switch during the read must not paint one member's
|
|
413
|
+
// bookings for another (the shared member-bookings read checks the same way).
|
|
414
|
+
await assertMemberAccess(who);
|
|
415
|
+
const found = bySession(placesOf(bookings, who.email), inWindow);
|
|
416
|
+
const byKey = new Map(inWindow.map((x) => [x.key, x] as const));
|
|
417
|
+
const waitlisted = [...found.keys()].map((k) => byKey.get(k)).filter((x): x is Session => !!x && x.waitlistTotal > 0 && !!x.sessionId);
|
|
418
|
+
if (waitlisted.length) {
|
|
419
|
+
// Only the session's waitlist can tell a waitlist place from a pending booking. If it cannot
|
|
420
|
+
// be read, that place is neither Booked nor free: the load fails (error state, Retry) rather
|
|
421
|
+
// than offer Book or Waitlist on a session the member already holds.
|
|
422
|
+
markWaitlisted(found, byKey, (await listWaitlists(waitlisted.map((x) => x.sessionId))).list ?? []);
|
|
423
|
+
// The lookup awaited too: still the same member?
|
|
424
|
+
await assertMemberAccess(who);
|
|
425
|
+
}
|
|
426
|
+
return { who, found };
|
|
427
|
+
} catch (err) {
|
|
428
|
+
// A failed read is not "no bookings": showing Book on a session the member holds invites a
|
|
429
|
+
// duplicate. It fails the load (the error state, with Retry), as My Bookings does.
|
|
430
|
+
if ((err as { name?: string } | null)?.name !== "MemberAccessChanged") throw err;
|
|
431
|
+
// A sign-out or a switch during the read: read again, for whoever is signed in now — never
|
|
432
|
+
// commit them with places nobody read for them. A second switch fails the load.
|
|
433
|
+
if (attempt < 1) return placesIn(start, end, inWindow, attempt + 1);
|
|
434
|
+
throw err;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Is `owner` — the member a place was loaded for — still the one signed in, to the same account
|
|
439
|
+
* and business? Asked right before a write, so a sign-out or a switch since the load never
|
|
440
|
+
* writes for the wrong person. */
|
|
441
|
+
async function sameMember(owner: MemberAccess | undefined): Promise<boolean> {
|
|
442
|
+
if (!owner?.email) return false;
|
|
443
|
+
return assertMemberAccess(owner).then(() => true, () => false);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** The member's name for a waitlist registration, when their member record has one. The
|
|
447
|
+
* email alone registers; the name is what the business sees on its waitlist. */
|
|
448
|
+
async function memberName(): Promise<{ firstName?: string; lastName?: string }> {
|
|
449
|
+
// A failed profile read registers by email alone rather than failing the join.
|
|
450
|
+
const res = await getMyMember({ fieldsets: ["FULL"] }).catch(() => undefined);
|
|
451
|
+
const contact = res?.member?.contact as { firstName?: string; lastName?: string } | undefined;
|
|
452
|
+
return { firstName: contact?.firstName || undefined, lastName: contact?.lastName || undefined };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Rebuild everything the screen reads from the module state. */
|
|
456
|
+
function derive(s: S): S {
|
|
457
|
+
const events = rowsOk === false ? [] : sessions.filter((x) => x.day >= s.selectedDate).map((x) => eventOf(x, places.get(x.key)));
|
|
458
|
+
const sheet = sessions.find((x) => x.key === sheetKey);
|
|
459
|
+
const sheetPlace = sheet ? places.get(sheet.key)?.place ?? "" : "";
|
|
460
|
+
return {
|
|
461
|
+
...s,
|
|
462
|
+
decorations: decorationsOf(sessions, s.today, s.rangeStart, s.rangeEnd),
|
|
463
|
+
events,
|
|
464
|
+
scrollTo: events[0] ? dayKey(events[0].start) : "",
|
|
465
|
+
emptyText: copyParam("agenda.emptyText", "No classes from this day on."),
|
|
466
|
+
// 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.") } : {}),
|
|
468
|
+
signedIn: !!member?.email,
|
|
469
|
+
updateRequired: rowsOk === false,
|
|
470
|
+
listHidden: s.busy === true || rowsOk === false,
|
|
471
|
+
sheetOpen: !!sheet && !!sheetPlace,
|
|
472
|
+
sheetTitle: sheet?.title ?? "",
|
|
473
|
+
sheetWhen: sheet ? `${clock(sheet.start)} ${lengthLabel(sheet.start, sheet.end)}`.trim() : "",
|
|
474
|
+
sheetHideCancel: sheetPlace !== "booked",
|
|
475
|
+
sheetHideLeave: sheetPlace !== "waitlisted",
|
|
476
|
+
sheetBusy: busy,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Leave a waitlist. The registration id comes from the load; a place known only by its
|
|
481
|
+
* WAITING_LIST status asks the session's waitlist for it, and a registration Wix no longer lists
|
|
482
|
+
* is released through the member cancel of its booking. */
|
|
483
|
+
async function leave(place: MemberPlace, session: Session, owner: MemberAccess | undefined): Promise<void> {
|
|
484
|
+
let registrationId = place.registrationId ?? "";
|
|
485
|
+
if (!registrationId && session.sessionId) {
|
|
486
|
+
const lists = await listWaitlists([session.sessionId]);
|
|
487
|
+
for (const list of lists.list ?? []) {
|
|
488
|
+
for (const r of list.registrations ?? []) if (r.bookingId === place.bookingId && r.id) registrationId = r.id;
|
|
489
|
+
}
|
|
490
|
+
// The lookup awaited: still the member the place belongs to?
|
|
491
|
+
if (!(await sameMember(owner))) throw memberAccessChanged();
|
|
492
|
+
}
|
|
493
|
+
if (registrationId && session.sessionId) await leaveWaitlist(registrationId, session.sessionId);
|
|
494
|
+
else await cancelBookingAsMember(place.bookingId, place.revision);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** The first runtime whose Android Agenda keeps a worker-bound event's `extra` — every field a row
|
|
498
|
+
* shows, and every flag that hides its other actions. An older Android binary would draw each
|
|
499
|
+
* row blank with all its actions at once, so it is asked to update instead. Checked at run time,
|
|
500
|
+
* not as a descriptor floor: a release takes its highest screen floor as its own. */
|
|
501
|
+
export const AGENDA_MIN_ANDROID_BINARY = "1.6.0";
|
|
502
|
+
let rowsOk: boolean | undefined;
|
|
503
|
+
async function rowsSupported(): Promise<boolean> {
|
|
504
|
+
const info = await getAppInfo().catch(() => undefined);
|
|
505
|
+
if (!info || info.platform !== "android") return true;
|
|
506
|
+
return atLeast(info.binaryVersion, AGENDA_MIN_ANDROID_BINARY);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const READY = { status: "ready" as const, error: "", signInRequired: false, notErrored: true, notLoading: true, busy: false, noSignIn: true };
|
|
510
|
+
|
|
511
|
+
type View = Pick<S, "today" | "rangeStart" | "rangeEnd" | "selectedDate" | "firstWeekday">;
|
|
512
|
+
/** The page and day the member asked for LAST. Every action records it before its first await,
|
|
513
|
+
* so a read that lands after a newer page or day tap shows that view, not the one it started
|
|
514
|
+
* from — paging back before the forward read lands must end on the user's last page. */
|
|
515
|
+
let wanted: View | undefined;
|
|
516
|
+
|
|
517
|
+
/** Record what the member asked for and return the state that shows it. */
|
|
518
|
+
function ask(s: S, view: Partial<View>): S {
|
|
519
|
+
wanted = { ...(wanted ?? { today: s.today, rangeStart: s.rangeStart, rangeEnd: s.rangeEnd, selectedDate: s.selectedDate, firstWeekday: s.firstWeekday }), ...view };
|
|
520
|
+
// The initial state carries no date; an action that lands before `load` still needs today.
|
|
521
|
+
if (!wanted.today) wanted = { ...wanted, today: dayKey(new Date()) };
|
|
522
|
+
return { ...s, ...wanted };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const covers = (win: { start: string; end: string }, need: { start: string; end: string }): boolean =>
|
|
526
|
+
!!win.start && win.start <= need.start && win.end >= need.end;
|
|
527
|
+
const needOf = (s: S) => windowFor(s.today, s.rangeStart, s.rangeEnd, s.selectedDate);
|
|
528
|
+
|
|
529
|
+
/** Bumped by every READ: a read that a newer read overtook stands down (returns `undefined`), and
|
|
530
|
+
* the newer one commits. An action the loaded days already cover reads nothing and bumps nothing,
|
|
531
|
+
* so a day tap never discards a reload in flight. */
|
|
532
|
+
let loadSeq = 0;
|
|
533
|
+
|
|
534
|
+
/** Load the window the state needs, unless it is already loaded. `force` reloads it (the
|
|
535
|
+
* screen re-appeared: the member may have just booked on the Choose Time screen). Resolves to
|
|
536
|
+
* `undefined` when a newer read started while this one awaited. */
|
|
537
|
+
async function ensureLoaded(base: S, force = false): Promise<S | undefined> {
|
|
538
|
+
const s = { ...base, ...wanted };
|
|
539
|
+
if (rowsOk === false) return derive(s);
|
|
540
|
+
const need = needOf(s);
|
|
541
|
+
// Days already loaded mean the last read succeeded (a failure clears them), so the screen is
|
|
542
|
+
// ready — whatever the state this action was dispatched with still said: a calendar's first
|
|
543
|
+
// page report can finish its read while `load`, dispatched with "loading", still waits.
|
|
544
|
+
if (covers(loadedWindow, need) && !force) return derive({ ...s, ...READY });
|
|
545
|
+
const seq = ++loadSeq;
|
|
546
|
+
const win = covers(loadedWindow, need) ? loadedWindow : need;
|
|
547
|
+
try {
|
|
548
|
+
const nextSessions = await sessionsIn(win.start, win.end);
|
|
549
|
+
const { who, found: nextPlaces } = await placesIn(win.start, win.end, nextSessions);
|
|
550
|
+
if (seq !== loadSeq) return undefined;
|
|
551
|
+
const now = { ...s, ...wanted };
|
|
552
|
+
if (!covers(win, needOf(now))) {
|
|
553
|
+
// The member paged past what this read covers while it ran. The days already loaded may
|
|
554
|
+
// cover where they are now (they paged back); otherwise read those.
|
|
555
|
+
return covers(loadedWindow, needOf(now)) ? derive({ ...now, ...READY }) : ensureLoaded(now);
|
|
556
|
+
}
|
|
557
|
+
sessions = nextSessions;
|
|
558
|
+
places = nextPlaces;
|
|
559
|
+
member = who;
|
|
560
|
+
loadedWindow = win;
|
|
561
|
+
return derive({ ...now, ...READY, message: "" });
|
|
562
|
+
} catch (err) {
|
|
563
|
+
if (seq !== loadSeq) return undefined;
|
|
564
|
+
const now = { ...s, ...wanted };
|
|
565
|
+
// 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 });
|
|
567
|
+
// Only the flags: the schedule is not a row collection, and a failed load keeps no rows.
|
|
568
|
+
const { sessions: _rows, emptyText: _empty, ...flags } = loadFailed("sessions", err, {
|
|
569
|
+
prefix: "agenda",
|
|
570
|
+
empty: "No classes from this day on.",
|
|
571
|
+
error: "The schedule couldn't be loaded.",
|
|
572
|
+
signIn: "Sign in to see the schedule.",
|
|
573
|
+
sample: () => [] as Session[],
|
|
574
|
+
});
|
|
575
|
+
sessions = [];
|
|
576
|
+
places = new Map();
|
|
577
|
+
loadedWindow = { start: "", end: "" };
|
|
578
|
+
return derive({ ...now, ...flags });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function argOf(p: unknown, key: string): string {
|
|
583
|
+
const a = p as Record<string, unknown> | undefined;
|
|
584
|
+
const v = a?.[key] ?? (a?.args as Record<string, unknown> | undefined)?.[key] ?? (a?.params as Record<string, unknown> | undefined)?.[key];
|
|
585
|
+
// Numbers too: every renderer sends onMonthChange's year/month as numbers.
|
|
586
|
+
return typeof v === "string" ? v.trim() : typeof v === "number" && Number.isFinite(v) ? String(v) : "";
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const eventIdOf = (p: unknown): string => argOf(p, "eventId") || argOf(p, "id");
|
|
590
|
+
|
|
591
|
+
/** Clock-free, so a template builds to the same release every time: `load` — or the
|
|
592
|
+
* first `ask` before it — fills in today and this week. */
|
|
593
|
+
function initial(): S {
|
|
594
|
+
return {
|
|
595
|
+
...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,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const svc = defineService<S, {
|
|
602
|
+
load: (s: S, p?: unknown) => Promise<S | undefined>;
|
|
603
|
+
retry: (s: S) => Promise<S | undefined>;
|
|
604
|
+
selectDate: (s: S, p?: unknown) => Promise<S | undefined>;
|
|
605
|
+
rangeChanged: (s: S, p?: unknown) => Promise<S | undefined>;
|
|
606
|
+
monthChanged: (s: S, p?: unknown) => Promise<S | undefined>;
|
|
607
|
+
openSheet: (s: S, p?: unknown) => S;
|
|
608
|
+
closeSheet: (s: S) => S;
|
|
609
|
+
viewSession: (s: S) => S;
|
|
610
|
+
cancelBooking: (s: S) => Promise<S | undefined>;
|
|
611
|
+
joinWaitlist: (s: S, p?: unknown) => Promise<S | undefined>;
|
|
612
|
+
}>("himiBrandedAgenda", {
|
|
613
|
+
state: initial(),
|
|
614
|
+
actions: {
|
|
615
|
+
load: async (s, p) => {
|
|
616
|
+
stashCopy(p);
|
|
617
|
+
const fw = Number(argOf(p, "firstWeekday") || s.firstWeekday);
|
|
618
|
+
const today = dayKey(new Date());
|
|
619
|
+
// First appearance: open on this week. A re-appearance keeps the member's place and
|
|
620
|
+
// reloads, so a session booked on Choose Time comes back marked "Booked".
|
|
621
|
+
// This worker has read nothing yet: open on this week. Not the state's own status — a cold
|
|
622
|
+
// boot restores the last run's snapshot ("ready", last week's page) into a fresh worker.
|
|
623
|
+
const first = !loadedWindow.start;
|
|
624
|
+
const week = weekOf(today, (((fw % 7) + 7) % 7) as Weekday);
|
|
625
|
+
// Re-read the catalog when the screen shows again: classes are added, renamed and removed.
|
|
626
|
+
if (!first) services = null;
|
|
627
|
+
const asked = first
|
|
628
|
+
? ask(s, { firstWeekday: fw, today, selectedDate: today, rangeStart: week[0], rangeEnd: week[6] })
|
|
629
|
+
: ask(s, { firstWeekday: fw, today });
|
|
630
|
+
rowsOk ??= await rowsSupported();
|
|
631
|
+
if (!rowsOk) return derive({ ...asked, ...READY, message: copyParam("agenda.updateRequired", "Update the app to see the class schedule.") });
|
|
632
|
+
return ensureLoaded(asked, !first);
|
|
633
|
+
},
|
|
634
|
+
retry: async (s) => { services = null; return ensureLoaded({ ...s, ...LOADING }, true); },
|
|
635
|
+
// Each compares with the member's LAST ask, not `s`: a read still in flight has not put the
|
|
636
|
+
// previous page into `s` yet, and paging back to that page is still a change.
|
|
637
|
+
selectDate: async (s, p) => {
|
|
638
|
+
const date = argOf(p, "date");
|
|
639
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || date === (wanted ?? s).selectedDate) return s;
|
|
640
|
+
// Past days are not selectable — also before the load has dimmed them on the calendar.
|
|
641
|
+
if (date < ((wanted ?? s).today || dayKey(new Date()))) return s;
|
|
642
|
+
// A month grid's padding day (iOS draws them) belongs to the next month, and the grid moves
|
|
643
|
+
// there without reporting a page: move this page with it, so paging back is still a change.
|
|
644
|
+
const cur = wanted ?? s;
|
|
645
|
+
const span = Math.round((Date.parse(cur.rangeEnd) - Date.parse(cur.rangeStart)) / 86_400_000);
|
|
646
|
+
const page = titledDays(cur.rangeStart, cur.rangeEnd);
|
|
647
|
+
if (span > 7 && (date < page.first || date > page.last)) {
|
|
648
|
+
const grid = monthGridOf(date, s.firstWeekday);
|
|
649
|
+
if (grid) return ensureLoaded(ask({ ...s, message: "" }, { selectedDate: date, rangeStart: grid.start, rangeEnd: grid.end }));
|
|
650
|
+
}
|
|
651
|
+
return ensureLoaded(ask({ ...s, message: "" }, { selectedDate: date }));
|
|
652
|
+
},
|
|
653
|
+
rangeChanged: async (s, p) => {
|
|
654
|
+
const start = argOf(p, "startDate"), end = argOf(p, "endDate");
|
|
655
|
+
const cur = wanted ?? s;
|
|
656
|
+
if (!start || !end || (start === cur.rangeStart && end === cur.rangeEnd)) return s;
|
|
657
|
+
// The selection follows the page, so the list under the calendar describes the days
|
|
658
|
+
// on screen rather than a week the user has paged away from.
|
|
659
|
+
const keep = keepsSelection(cur, start, end);
|
|
660
|
+
const selectedDate = keep ? cur.selectedDate : selectionForRange(cur.today || dayKey(new Date()), start, end, cur.selectedDate);
|
|
661
|
+
return ensureLoaded(ask({ ...s, message: "" }, { rangeStart: start, rangeEnd: end, selectedDate }));
|
|
662
|
+
},
|
|
663
|
+
// An older binary has no week mode and reports only the month. Its grid then spans the
|
|
664
|
+
// month's whole weeks; load those, so dots still show on every visible day.
|
|
665
|
+
monthChanged: async (s, p) => {
|
|
666
|
+
const year = Number(argOf(p, "year")), month = Number(argOf(p, "month"));
|
|
667
|
+
if (!year || !month) return s;
|
|
668
|
+
const first = new Date(year, month - 1, 1);
|
|
669
|
+
const start = weekOf(first, (s.firstWeekday % 7) as Weekday)[0];
|
|
670
|
+
const last = weekOf(new Date(year, month, 0), (s.firstWeekday % 7) as Weekday)[6];
|
|
671
|
+
const cur = wanted ?? s;
|
|
672
|
+
if (!start || !last || (start === cur.rangeStart && last === cur.rangeEnd)) return s;
|
|
673
|
+
const keep = keepsSelection(cur, start, last);
|
|
674
|
+
const selectedDate = keep ? cur.selectedDate : selectionForRange(cur.today || dayKey(new Date()), start, last, cur.selectedDate);
|
|
675
|
+
return ensureLoaded(ask(s, { rangeStart: start, rangeEnd: last, selectedDate }));
|
|
676
|
+
},
|
|
677
|
+
openSheet: (s, p) => {
|
|
678
|
+
const key = eventIdOf(p);
|
|
679
|
+
if (!places.get(key)) return s;
|
|
680
|
+
sheetKey = key;
|
|
681
|
+
return derive({ ...s, message: "" });
|
|
682
|
+
},
|
|
683
|
+
closeSheet: (s) => {
|
|
684
|
+
if (busy) return s;
|
|
685
|
+
sheetKey = "";
|
|
686
|
+
return derive(s);
|
|
687
|
+
},
|
|
688
|
+
viewSession: (s) => {
|
|
689
|
+
const session = sessions.find((x) => x.key === sheetKey);
|
|
690
|
+
sheetKey = "";
|
|
691
|
+
return session ? navigate(derive(s), { screenId: "himi_service", args: { id: session.serviceId } }) : derive(s);
|
|
692
|
+
},
|
|
693
|
+
// Cancel a booking (the MEMBER cancel, so Wix enforces the business's cancellation
|
|
694
|
+
// policy), or leave a waitlist (which also releases the pending booking it created).
|
|
695
|
+
cancelBooking: async (s): Promise<S | undefined> => {
|
|
696
|
+
const place = places.get(sheetKey);
|
|
697
|
+
// Read with `place`: a reload that lands meanwhile replaces both together.
|
|
698
|
+
const owner = member;
|
|
699
|
+
const session = sessions.find((x) => x.key === sheetKey);
|
|
700
|
+
const leaving = place?.place === "waitlisted";
|
|
701
|
+
if (!place || !session || busy || (!leaving && !place.revision)) return s;
|
|
702
|
+
busy = true;
|
|
703
|
+
try {
|
|
704
|
+
if (!(await sameMember(owner))) throw memberAccessChanged();
|
|
705
|
+
if (leaving) await leave(place, session, owner);
|
|
706
|
+
else await cancelBookingAsMember(place.bookingId, place.revision);
|
|
707
|
+
places.delete(sheetKey);
|
|
708
|
+
sheetKey = "";
|
|
709
|
+
const message = leaving ? copyParam("agenda.leftWaitlist", "You left the waitlist.") : copyParam("agenda.cancelDone", "Booking cancelled.");
|
|
710
|
+
// Reload against the state as it is NOW: the member may have paged while the write ran.
|
|
711
|
+
const next = await ensureLoaded(svc.getState() as S, true);
|
|
712
|
+
busy = false;
|
|
713
|
+
return derive({ ...(next ?? { ...(svc.getState() as S), ...wanted }), message });
|
|
714
|
+
} catch (err) {
|
|
715
|
+
sheetKey = "";
|
|
716
|
+
if ((err as { name?: string } | null)?.name === "MemberAccessChanged") {
|
|
717
|
+
// Someone else (or nobody) since the load: show their schedule; nothing is cancelled.
|
|
718
|
+
const next = await ensureLoaded(svc.getState() as S, true);
|
|
719
|
+
busy = false;
|
|
720
|
+
return next ? derive(next) : undefined;
|
|
721
|
+
}
|
|
722
|
+
busy = false;
|
|
723
|
+
const message = isBookingPolicyViolation(err)
|
|
724
|
+
? copyParam("agenda.cancelPolicy", "It's too late to cancel in the app. Contact the business.")
|
|
725
|
+
: copyParam("agenda.cancelFailed", "That didn't go through. Try again.");
|
|
726
|
+
return derive({ ...(svc.getState() as S), message });
|
|
727
|
+
}
|
|
728
|
+
},
|
|
729
|
+
joinWaitlist: async (s, p): Promise<S | undefined> => {
|
|
730
|
+
const key = eventIdOf(p);
|
|
731
|
+
const session = sessions.find((x) => x.key === key);
|
|
732
|
+
// A place already held (just joined, or booked) is never joined again.
|
|
733
|
+
if (!session || busy || places.get(key)) return s;
|
|
734
|
+
const owner = member;
|
|
735
|
+
busy = true;
|
|
736
|
+
try {
|
|
737
|
+
// One person's email and name: the member is read before AND after the name, and the
|
|
738
|
+
// registration goes out only when both reads, and the member the places were loaded for, agree.
|
|
739
|
+
const before = await memberAccess().catch(() => undefined);
|
|
740
|
+
if (!before?.email) {
|
|
741
|
+
busy = false;
|
|
742
|
+
return navigate(s, { screenId: "himi_sign_in", args: { mode: "signin", returnScreenId: "himi_agenda" } });
|
|
743
|
+
}
|
|
744
|
+
const name = await memberName();
|
|
745
|
+
const current = await memberAccess().catch(() => undefined);
|
|
746
|
+
const same = (a: MemberAccess | undefined, b: MemberAccess | undefined) => !!a?.email && a.email === b?.email && a.scope === b?.scope;
|
|
747
|
+
if (!same(before, current) || !same(owner, current)) {
|
|
748
|
+
// Another member since the load — or the load was a visitor's, so their places were never
|
|
749
|
+
// read: show their schedule first; nothing is registered.
|
|
750
|
+
const next = await ensureLoaded(svc.getState() as S, true);
|
|
751
|
+
busy = false;
|
|
752
|
+
return next ? derive(next) : undefined;
|
|
753
|
+
}
|
|
754
|
+
const res = await registerToWaitlist(session.sessionId, { email: current!.email, ...name });
|
|
755
|
+
// Marked at once, so until a reload lands the row reads On waitlist and offers no second
|
|
756
|
+
// join — whether this reload commits or a newer page overtakes it.
|
|
757
|
+
places.set(key, { bookingId: res?.booking?.id ?? "", revision: "", place: "waitlisted", registrationId: res?.registrationId, confirmed: false });
|
|
758
|
+
const message = copyParam("agenda.joinedWaitlist", "You're on the waitlist.");
|
|
759
|
+
const next = await ensureLoaded(svc.getState() as S, true);
|
|
760
|
+
busy = false;
|
|
761
|
+
return derive({ ...(next ?? { ...(svc.getState() as S), ...wanted }), message });
|
|
762
|
+
} catch {
|
|
763
|
+
busy = false;
|
|
764
|
+
return derive({ ...(svc.getState() as S), message: copyParam("agenda.waitlistFailed", "Couldn't join the waitlist. Try again.") });
|
|
765
|
+
}
|
|
766
|
+
},
|
|
767
|
+
},
|
|
768
|
+
});
|
|
769
|
+
export { svc };
|