@wix/himalaya-cli 0.1582.0 → 0.1583.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/builder.d.ts +8 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +26 -2
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/validator.d.ts +18 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/core.mjs +5 -1
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdui.mjs +5 -1
- package/dist/cli.mjs +888 -553
- package/dist/preview-assets.generated.bin +0 -0
- package/guides/component-reference.json +4 -3
- package/guides/component-reference.md +24 -2
- package/package.json +2 -2
- package/templates/beauty-salon/config.ts +20 -1
- package/templates/beauty-salon/dev/index.ts +1751 -629
- package/templates/beauty-salon/tier5-src/_shared/booking-location.ts +147 -0
- package/templates/beauty-salon/tier5-src/_shared/booking-session.ts +222 -0
- package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +277 -14
- package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +351 -92
- package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +25 -5
- package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +219 -220
- package/templates/branded-lite/config.ts +20 -1
- package/templates/branded-lite/dev/index.ts +1688 -566
- package/templates/branded-lite/l10n/en.json +2 -2
- package/templates/branded-lite/tier5-src/_shared/booking-location.ts +147 -0
- package/templates/branded-lite/tier5-src/_shared/booking-session.ts +222 -0
- package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +277 -14
- package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +351 -92
- package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +25 -5
- package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +219 -220
- package/templates/community-club/config.ts +20 -1
- package/templates/events-meetups/config.ts +20 -1
- package/templates/gym-fitness/config.ts +20 -1
- package/templates/gym-fitness/dev/index.ts +1751 -629
- package/templates/gym-fitness/tier5-src/_shared/booking-location.ts +147 -0
- package/templates/gym-fitness/tier5-src/_shared/booking-session.ts +222 -0
- package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +277 -14
- package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +351 -92
- package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +25 -5
- package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +219 -220
- package/templates/nonprofit-cause/config.ts +20 -1
- package/templates/podcast-creator/config.ts +20 -1
- package/templates/portfolio-creative/config.ts +20 -1
- package/templates/restaurant/config.ts +20 -1
- package/templates/retail-store/config.ts +20 -1
- package/templates/yoga-studio/config.ts +20 -1
- package/templates/yoga-studio/dev/index.ts +1533 -411
- package/templates/yoga-studio/tier5-src/_shared/booking-location.ts +147 -0
- package/templates/yoga-studio/tier5-src/_shared/booking-session.ts +222 -0
- package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +277 -14
- package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +351 -92
- package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +25 -5
- package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +219 -220
|
@@ -36,53 +36,24 @@ import { compareText } from "../_shared/engine-intl.js";
|
|
|
36
36
|
import { assertMemberAccess, memberAccess, memberAccessChanged, type MemberAccess } from "../_shared/member-access.js";
|
|
37
37
|
import { atLeast } from "../_shared/member-account-routes.js";
|
|
38
38
|
import { get as getAppInfo } from "@himalaya/app-info";
|
|
39
|
+
import {
|
|
40
|
+
distinctChoices, effectiveLocation, locationMenu, readSharedLocation, serviceLocationChoices, writeSharedLocation,
|
|
41
|
+
type LocationChoice, type LocationOptionRow,
|
|
42
|
+
} from "../_shared/booking-location.js";
|
|
43
|
+
import {
|
|
44
|
+
bySession, markWaitlisted, placesIn, placesOf, sessionControl, sessionKey, sessionOf, spotsLabel,
|
|
45
|
+
type MemberPlace, type Place, type Session,
|
|
46
|
+
} from "../_shared/booking-session.js";
|
|
47
|
+
|
|
48
|
+
// The session model is shared with the service screen opened from a row; re-exported for the tests.
|
|
49
|
+
export { bySession, markWaitlisted, placesOf, sessionKey, sessionOf };
|
|
50
|
+
export type { MemberPlace, Place, Session };
|
|
39
51
|
|
|
40
52
|
/** How far past the selected day the list reaches. */
|
|
41
53
|
export const AGENDA_DAYS = 28;
|
|
42
54
|
/** Services whose sessions are fixed events: a class's are each booked on their own, a course's
|
|
43
55
|
* are listed but the course is booked as a whole (on its service screen). */
|
|
44
56
|
const SESSION_TYPES = new Set(["CLASS", "COURSE"]);
|
|
45
|
-
/** A booking in one of these no longer holds a place. */
|
|
46
|
-
const RELEASED = new Set(["CANCELED", "CANCELLED", "DECLINED"]);
|
|
47
|
-
|
|
48
|
-
export type Place = "booked" | "waitlisted" | "";
|
|
49
|
-
|
|
50
|
-
/** One session as the screen needs it. `key` is the row's identity: the Wix session id when the
|
|
51
|
-
* entry has one — two sessions of a class at the same time are two rows — else service + start. */
|
|
52
|
-
export interface Session {
|
|
53
|
-
key: string;
|
|
54
|
-
/** The Wix session id — what a waitlist registers against. "" when the entry has none. */
|
|
55
|
-
sessionId: string;
|
|
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";
|
|
59
|
-
title: string;
|
|
60
|
-
/** The service's fixed price ("₪60"), "" when it has none to show (varied, custom, free). */
|
|
61
|
-
price: string;
|
|
62
|
-
start: string; // ISO instant with offset
|
|
63
|
-
end: string;
|
|
64
|
-
day: string; // LOCAL day key
|
|
65
|
-
staff: string;
|
|
66
|
-
location: string;
|
|
67
|
-
total: number;
|
|
68
|
-
open: number;
|
|
69
|
-
bookable: boolean;
|
|
70
|
-
/** The session's waitlist: its size (0 = it has none) and the places left on it. */
|
|
71
|
-
waitlistTotal: number;
|
|
72
|
-
/** A booking policy closes it (too early, too late, online booking off): no Book, no Waitlist. */
|
|
73
|
-
policyClosed: boolean;
|
|
74
|
-
/** The ONLY policy reason is that booking has not opened yet ("Not open yet", not "Closed"). */
|
|
75
|
-
tooEarly: boolean;
|
|
76
|
-
waitlistOpen: number;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export interface MemberPlace { bookingId: string; revision: string; place: Place; registrationId?: string; confirmed?: boolean }
|
|
80
|
-
|
|
81
|
-
const count = (v: unknown): number => {
|
|
82
|
-
const n = Number(v ?? 0);
|
|
83
|
-
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
84
|
-
};
|
|
85
|
-
|
|
86
57
|
/** The `Agenda` wire shape: declared fields plus a flat string `extra` map for the row. */
|
|
87
58
|
export interface AgendaEvent {
|
|
88
59
|
id: string;
|
|
@@ -138,6 +109,22 @@ interface S extends LoadFlags {
|
|
|
138
109
|
sheetBusy: boolean;
|
|
139
110
|
/** The sheet session's service: View class reads its declared route (`nav`) from it. "" with no sheet. */
|
|
140
111
|
sheetServiceId: string;
|
|
112
|
+
/** …and its session (id and start), so the service screen opens on it. */
|
|
113
|
+
sheetSessionId: string;
|
|
114
|
+
sheetStart: string;
|
|
115
|
+
// The location picker (two or more locations only): `locationMenu()` in booking-location.ts.
|
|
116
|
+
locationOptions: LocationOptionRow[];
|
|
117
|
+
locationLabel: string;
|
|
118
|
+
locationA11y: string;
|
|
119
|
+
hasLocationMenu: boolean;
|
|
120
|
+
noLocationMenu: boolean;
|
|
121
|
+
/** A location is chosen: the pin turns accent and the line under the title names it. */
|
|
122
|
+
locationActive: boolean;
|
|
123
|
+
noLocationActive: boolean;
|
|
124
|
+
locationSheetOpen: boolean;
|
|
125
|
+
/** A location is chosen and has no session in the loaded days: its own empty state. */
|
|
126
|
+
locationEmpty: boolean;
|
|
127
|
+
notLocationEmpty: boolean;
|
|
141
128
|
}
|
|
142
129
|
|
|
143
130
|
const flag = (on: boolean): string => (on ? "true" : "");
|
|
@@ -155,107 +142,11 @@ export function lengthLabel(start: string, end: string): string {
|
|
|
155
142
|
/** The parts that are known, joined by a middle dot: "Omer Levi · Studio Zen". */
|
|
156
143
|
const joined = (...parts: string[]): string => parts.filter(Boolean).join(" · ");
|
|
157
144
|
|
|
158
|
-
/**
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export const sessionKey = (serviceId: string, startIso: string): string => `${serviceId}|${Date.parse(startIso)}`;
|
|
165
|
-
|
|
166
|
-
/** A raw availability entry → a Session, or null when it has no usable start. */
|
|
167
|
-
export function sessionOf(entry: WixAvailabilityEntry, service: WixService): Session | null {
|
|
168
|
-
const slot = (entry.slot ?? {}) as Record<string, unknown> & { startDate?: string; endDate?: string; timezone?: unknown };
|
|
169
|
-
const start = instantIso(slot, "startDate");
|
|
170
|
-
if (!start) return null;
|
|
171
|
-
const end = instantIso(slot, "endDate") || start;
|
|
172
|
-
const raw = entry as unknown as {
|
|
173
|
-
totalSpots?: number; openSpots?: number; waitingList?: { totalSpots?: number; openSpots?: number };
|
|
174
|
-
bookingPolicyViolations?: { tooEarlyToBook?: boolean; tooLateToBook?: boolean; bookOnlineDisabled?: boolean; validSubmissionRequired?: boolean };
|
|
175
|
-
};
|
|
176
|
-
const policy = raw.bookingPolicyViolations;
|
|
177
|
-
const tooLate = policy?.tooLateToBook === true, offline = policy?.bookOnlineDisabled === true;
|
|
178
|
-
const tooEarly = policy?.tooEarlyToBook === true;
|
|
179
|
-
// A session that also needs a form submitted is not just "not open yet": it never opens for the app.
|
|
180
|
-
const needsForm = policy?.validSubmissionRequired === true;
|
|
181
|
-
const resource = slot.resource as { name?: string } | undefined;
|
|
182
|
-
const location = slot.location as { name?: string; formattedAddress?: string } | undefined;
|
|
183
|
-
const serviceId = String(slot.serviceId ?? service.id ?? "");
|
|
184
|
-
const sessionId = typeof slot.sessionId === "string" ? slot.sessionId : "";
|
|
185
|
-
return {
|
|
186
|
-
key: sessionId || sessionKey(serviceId, start),
|
|
187
|
-
sessionId,
|
|
188
|
-
serviceId,
|
|
189
|
-
kind: ((service as WixService & { type?: string }).type ?? "").toUpperCase() === "COURSE" ? "course" : "class",
|
|
190
|
-
title: service.name ?? "",
|
|
191
|
-
price: formatServicePrice(service),
|
|
192
|
-
start, end,
|
|
193
|
-
day: dayKey(start),
|
|
194
|
-
staff: resource?.name?.trim() ?? "",
|
|
195
|
-
location: (location?.name || location?.formattedAddress || "").trim(),
|
|
196
|
-
total: count(raw.totalSpots),
|
|
197
|
-
open: count(raw.openSpots),
|
|
198
|
-
bookable: entry.bookable !== false,
|
|
199
|
-
waitlistTotal: count(raw.waitingList?.totalSpots),
|
|
200
|
-
waitlistOpen: count(raw.waitingList?.openSpots),
|
|
201
|
-
policyClosed: tooEarly || tooLate || offline || needsForm,
|
|
202
|
-
tooEarly: tooEarly && !tooLate && !offline && !needsForm,
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** The member's bookings in the window → their place, keyed by the booking's session id when it
|
|
207
|
-
* names one, else by service + start. `bySession` turns that into each row's own key. */
|
|
208
|
-
export function placesOf(bookings: WixBooking[], email: string): Map<string, MemberPlace> {
|
|
209
|
-
const out = new Map<string, MemberPlace>();
|
|
210
|
-
for (const b of bookings) {
|
|
211
|
-
if (!b.id || b.contactDetails?.email?.trim().toLowerCase() !== email) continue;
|
|
212
|
-
const status = (b.status || "").toUpperCase();
|
|
213
|
-
if (RELEASED.has(status)) continue;
|
|
214
|
-
const slot = b.bookedEntity?.slot;
|
|
215
|
-
const serviceId = slot?.serviceId ?? "";
|
|
216
|
-
const start = b.startDate || (slot ? instantIso(slot, "startDate") : "");
|
|
217
|
-
if (!serviceId || !start) continue;
|
|
218
|
-
// Its session when the booking names one, so a parallel session is not marked too.
|
|
219
|
-
const bookedSession = (slot as { sessionId?: unknown } | undefined)?.sessionId;
|
|
220
|
-
out.set(typeof bookedSession === "string" && bookedSession ? bookedSession : sessionKey(serviceId, start), {
|
|
221
|
-
bookingId: b.id,
|
|
222
|
-
revision: String((b as WixBooking & { revision?: string | number }).revision ?? ""),
|
|
223
|
-
place: status === "WAITING_LIST" ? "waitlisted" : "booked",
|
|
224
|
-
confirmed: status === "CONFIRMED",
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
return out;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** Each session's place, under the session's own key: by its session id, else by service + start. */
|
|
231
|
-
export function bySession(found: Map<string, MemberPlace>, inWindow: Session[]): Map<string, MemberPlace> {
|
|
232
|
-
const out = new Map<string, MemberPlace>();
|
|
233
|
-
for (const x of inWindow) {
|
|
234
|
-
const place = (x.sessionId ? found.get(x.sessionId) : undefined) ?? found.get(sessionKey(x.serviceId, x.start));
|
|
235
|
-
if (place) out.set(x.key, place);
|
|
236
|
-
}
|
|
237
|
-
return out;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** "4 spots left", "1 spot left", or "Full". Empty when the site reports no capacity, and for a
|
|
241
|
-
* course: a session's open spots do not say whether the course has room. */
|
|
242
|
-
function spotsLabel(s: Session): string {
|
|
243
|
-
if (s.kind === "course" || s.total <= 0) return "";
|
|
244
|
-
if (s.open <= 0) return copyParam("agenda.full", "Full");
|
|
245
|
-
return s.open === 1 ? copyParam("agenda.spotLeft", "1 spot left") : copyTemplate("agenda.spotsLeft", "{n} spots left", { n: s.open });
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/** One session → one Agenda row. Exactly one of the trailing controls is visible. */
|
|
249
|
-
export function eventOf(s: Session, place: MemberPlace | undefined): AgendaEvent {
|
|
250
|
-
const mine = place?.place ?? "";
|
|
145
|
+
/** One session → one Agenda row. Exactly one of the trailing controls is visible. `lastOfDay`: the
|
|
146
|
+
* day's last row draws no divider — the next day's header band starts right under it. */
|
|
147
|
+
export function eventOf(s: Session, place: MemberPlace | undefined, lastOfDay = false): AgendaEvent {
|
|
148
|
+
const { control, closedLabel } = sessionControl(s, place);
|
|
251
149
|
const course = s.kind === "course";
|
|
252
|
-
const full = s.total > 0 && s.open <= 0;
|
|
253
|
-
// A course is booked whole on its service screen: its sessions offer View Course, never Book.
|
|
254
|
-
const canBook = !course && !mine && s.bookable && !full && !s.policyClosed;
|
|
255
|
-
// Availability is capacity AND policy: a full session the policy closes is not joinable either.
|
|
256
|
-
const canWait = !course && !mine && full && s.waitlistOpen > 0 && !!s.sessionId && !s.policyClosed;
|
|
257
|
-
const viewCourse = course && !mine;
|
|
258
|
-
const closed = !course && !mine && !canBook && !canWait;
|
|
259
150
|
const time = timeLabel(s.start);
|
|
260
151
|
return {
|
|
261
152
|
id: s.key,
|
|
@@ -276,14 +167,14 @@ export function eventOf(s: Session, place: MemberPlace | undefined): AgendaEvent
|
|
|
276
167
|
? copyTemplate("agenda.detailsA11y", "{title}, {time}, with {staff}", { title: s.title, time, staff: s.staff })
|
|
277
168
|
: [s.title, time].filter(Boolean).join(", "),
|
|
278
169
|
bookA11y: copyTemplate("agenda.bookA11y", "Book {title}, {time}", { title: s.title, time }),
|
|
279
|
-
hideBook: flag(
|
|
280
|
-
hideWaitlist: flag(
|
|
281
|
-
hideCourse: flag(
|
|
282
|
-
hideBooked: flag(
|
|
283
|
-
hideOnWaitlist: flag(
|
|
284
|
-
hideClosed: flag(
|
|
285
|
-
closedLabel
|
|
286
|
-
|
|
170
|
+
hideBook: flag(control !== "book"),
|
|
171
|
+
hideWaitlist: flag(control !== "waitlist"),
|
|
172
|
+
hideCourse: flag(control !== "course"),
|
|
173
|
+
hideBooked: flag(control !== "booked"),
|
|
174
|
+
hideOnWaitlist: flag(control !== "waitlisted"),
|
|
175
|
+
hideClosed: flag(control !== "closed"),
|
|
176
|
+
closedLabel,
|
|
177
|
+
lastOfDay: flag(lastOfDay),
|
|
287
178
|
},
|
|
288
179
|
};
|
|
289
180
|
}
|
|
@@ -397,6 +288,17 @@ let places = new Map<string, MemberPlace>();
|
|
|
397
288
|
let loadedWindow = { start: "", end: "" };
|
|
398
289
|
let member: MemberAccess | undefined;
|
|
399
290
|
let sheetKey = "";
|
|
291
|
+
/** The location the member chose (here, on Services or on Choose Time): "" is every location. */
|
|
292
|
+
let chosenLocation = { id: "", label: "" };
|
|
293
|
+
let locationSheet = false;
|
|
294
|
+
/** Until when a scroll report is ignored: a tap's (or a page's) jump is still settling, and a
|
|
295
|
+
* report the list sent before it must not move the selection back. */
|
|
296
|
+
let quietUntil = 0;
|
|
297
|
+
/** Bumped by a location change: the list's target becomes "day|n", so the renderer applies it again
|
|
298
|
+
* even when the day is the same one — rows above it came or went, and the offset no longer shows
|
|
299
|
+
* it. Cleared by the list's own scroll and a calendar tap, whose plain day the renderer compares
|
|
300
|
+
* with what it reported (an echo of the member's own scroll never moves the list). */
|
|
301
|
+
let scrollNonce = 0;
|
|
400
302
|
let busy = false;
|
|
401
303
|
/** An Add to Calendar flow is running (a permission prompt, a save, the calendar page). Its own
|
|
402
304
|
* flag, not `busy`: the session sheet must still close while it runs. */
|
|
@@ -438,67 +340,17 @@ async function sessionsIn(start: string, end: string): Promise<Session[]> {
|
|
|
438
340
|
if (session) out.push(session);
|
|
439
341
|
}
|
|
440
342
|
});
|
|
343
|
+
return ordered(out);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Sessions once each, by start then title — the list's order. */
|
|
347
|
+
function ordered(list: Session[]): Session[] {
|
|
441
348
|
const seen = new Set<string>();
|
|
442
|
-
return
|
|
349
|
+
return list
|
|
443
350
|
.filter((s) => (seen.has(s.key) ? false : (seen.add(s.key), true)))
|
|
444
351
|
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start) || compareText(a.title, b.title));
|
|
445
352
|
}
|
|
446
353
|
|
|
447
|
-
/** Mark the places that are WAITLIST registrations, with the registration id leaving needs.
|
|
448
|
-
* A registration's booking is an ordinary pending booking, so the booking alone cannot say
|
|
449
|
-
* "on the waitlist" — only the session's waitlist can, and only sessions with one are asked. */
|
|
450
|
-
export function markWaitlisted(found: Map<string, MemberPlace>, sessionsByKey: Map<string, Session>, lists: { waitingResource?: string; registrations?: { id?: string; bookingId?: string }[] }[]): void {
|
|
451
|
-
const byBooking = new Map<string, string>();
|
|
452
|
-
for (const list of lists) for (const r of list.registrations ?? []) if (r.bookingId && r.id) byBooking.set(r.bookingId, r.id);
|
|
453
|
-
for (const [key, place] of found) {
|
|
454
|
-
const registrationId = byBooking.get(place.bookingId);
|
|
455
|
-
if (registrationId && sessionsByKey.has(key)) found.set(key, { ...place, place: "waitlisted", registrationId });
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
/** The signed-in member's bookings in the window, with the member they were read for: the two
|
|
460
|
-
* are committed together, so a write checks against the member its place belongs to. A visitor
|
|
461
|
-
* has none; a read that fails fails the load. */
|
|
462
|
-
async function placesIn(start: string, end: string, inWindow: Session[], attempt = 0): Promise<{ who: MemberAccess | undefined; found: Map<string, MemberPlace> }> {
|
|
463
|
-
const who = await memberAccess().catch(() => undefined);
|
|
464
|
-
if (!who?.email) return { who, found: new Map() };
|
|
465
|
-
try {
|
|
466
|
-
const from = new Date(`${start}T00:00:00`).toISOString();
|
|
467
|
-
const to = new Date(`${dayKey(addDays(end, 1))}T00:00:00`).toISOString();
|
|
468
|
-
// Every page, not the first 100: a booking past it would read Book again. Bounded.
|
|
469
|
-
const bookings: WixBooking[] = [];
|
|
470
|
-
for (let page = 0; page < 5; page++) {
|
|
471
|
-
const res = await queryBookings({ query: { filter: { "contactDetails.email": { $eq: who.email }, startDate: { $gte: from, $lt: to } }, paging: { limit: 100, offset: page * 100 } } } as never);
|
|
472
|
-
const batch = res.bookings ?? [];
|
|
473
|
-
bookings.push(...batch);
|
|
474
|
-
if (batch.length < 100) break;
|
|
475
|
-
}
|
|
476
|
-
// The same member still? A sign-out or a switch during the read must not paint one member's
|
|
477
|
-
// bookings for another (the shared member-bookings read checks the same way).
|
|
478
|
-
await assertMemberAccess(who);
|
|
479
|
-
const found = bySession(placesOf(bookings, who.email), inWindow);
|
|
480
|
-
const byKey = new Map(inWindow.map((x) => [x.key, x] as const));
|
|
481
|
-
const waitlisted = [...found.keys()].map((k) => byKey.get(k)).filter((x): x is Session => !!x && x.waitlistTotal > 0 && !!x.sessionId);
|
|
482
|
-
if (waitlisted.length) {
|
|
483
|
-
// Only the session's waitlist can tell a waitlist place from a pending booking. If it cannot
|
|
484
|
-
// be read, that place is neither Booked nor free: the load fails (error state, Retry) rather
|
|
485
|
-
// than offer Book or Waitlist on a session the member already holds.
|
|
486
|
-
markWaitlisted(found, byKey, (await listWaitlists(waitlisted.map((x) => x.sessionId))).list ?? []);
|
|
487
|
-
// The lookup awaited too: still the same member?
|
|
488
|
-
await assertMemberAccess(who);
|
|
489
|
-
}
|
|
490
|
-
return { who, found };
|
|
491
|
-
} catch (err) {
|
|
492
|
-
// A failed read is not "no bookings": showing Book on a session the member holds invites a
|
|
493
|
-
// duplicate. It fails the load (the error state, with Retry), as My Bookings does.
|
|
494
|
-
if ((err as { name?: string } | null)?.name !== "MemberAccessChanged") throw err;
|
|
495
|
-
// A sign-out or a switch during the read: read again, for whoever is signed in now — never
|
|
496
|
-
// commit them with places nobody read for them. A second switch fails the load.
|
|
497
|
-
if (attempt < 1) return placesIn(start, end, inWindow, attempt + 1);
|
|
498
|
-
throw err;
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
354
|
/** Is `owner` — the member a place was loaded for — still the one signed in, to the same account
|
|
503
355
|
* and business? Asked right before a write, so a sign-out or a switch since the load never
|
|
504
356
|
* writes for the wrong person. */
|
|
@@ -550,23 +402,53 @@ function newView(s: S): S {
|
|
|
550
402
|
const emptyFlags = (noClasses: boolean, nothingAhead: boolean) =>
|
|
551
403
|
({ noClasses, nothingAhead, notNoClasses: !noClasses, notNothingAhead: !nothingAhead });
|
|
552
404
|
|
|
405
|
+
/** The locations the member can choose from: every class and course service's own locations, then
|
|
406
|
+
* any the loaded sessions name that no service lists, and the location chosen on another screen
|
|
407
|
+
* (Services, Choose Time) even when nothing here is at it — so the choice is kept, and its empty
|
|
408
|
+
* state says so, rather than silently showing everything. */
|
|
409
|
+
export function agendaLocations(list: readonly WixService[], inWindow: readonly Session[], chosen: { id: string; label: string }): LocationChoice[] {
|
|
410
|
+
return distinctChoices([
|
|
411
|
+
...list.flatMap((svc) => serviceLocationChoices(svc.locations)),
|
|
412
|
+
...inWindow.map((x) => ({ key: x.locationKey, label: x.location, sub: x.locationSub })),
|
|
413
|
+
...(chosen.id && chosen.label ? [{ key: chosen.id, label: chosen.label, sub: "" }] : []),
|
|
414
|
+
]);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** The day the list scrolls to for `selected`: that day, else the next one with a session, else the
|
|
418
|
+
* last one (a day after every loaded session still shows the list's end, not an empty page). */
|
|
419
|
+
export function scrollDayFor(days: readonly string[], selected: string): string {
|
|
420
|
+
return days.find((d) => d >= selected) ?? days[days.length - 1] ?? "";
|
|
421
|
+
}
|
|
422
|
+
|
|
553
423
|
/** Rebuild everything the screen reads from the module state. */
|
|
554
424
|
function derive(s: S): S {
|
|
555
|
-
const
|
|
425
|
+
const choices = agendaLocations(services ?? [], sessions, chosenLocation);
|
|
426
|
+
const locationKey = effectiveLocation(choices, chosenLocation.id);
|
|
427
|
+
// From today on (the loaded days never start before it), at the chosen location. The list is
|
|
428
|
+
// one run of days: a tap on the calendar SCROLLS it to a day, and scrolling it moves the
|
|
429
|
+
// selection, so it never starts at the selected day (the days above it would vanish).
|
|
430
|
+
const shown = sessions.filter((x) => x.day >= s.today && (!locationKey || x.locationKey === locationKey));
|
|
431
|
+
const events = rowsOk === false ? [] : shown.map((x, i) => eventOf(x, places.get(x.key), shown[i + 1]?.day !== x.day));
|
|
556
432
|
const sheet = sessions.find((x) => x.key === sheetKey);
|
|
557
433
|
const sheetPlace = sheet ? places.get(sheet.key)?.place ?? "" : "";
|
|
558
434
|
const loaded = s.status === "ready" && rowsOk !== false && !!services;
|
|
435
|
+
const locationEmpty = loaded && !!locationKey && shown.length === 0;
|
|
559
436
|
return {
|
|
560
437
|
...s,
|
|
561
|
-
|
|
438
|
+
...locationMenu(choices, chosenLocation.id),
|
|
439
|
+
locationSheetOpen: locationSheet,
|
|
440
|
+
locationEmpty,
|
|
441
|
+
notLocationEmpty: !locationEmpty,
|
|
442
|
+
// The dots follow the location too: a day with a session only elsewhere has none.
|
|
443
|
+
decorations: decorationsOf(shown, s.today, s.rangeStart, s.rangeEnd),
|
|
562
444
|
events,
|
|
563
|
-
scrollTo:
|
|
445
|
+
scrollTo: ((day) => (day && scrollNonce && suffixOk !== false ? `${day}|${scrollNonce}` : day))(scrollDayFor([...new Set(shown.map((x) => x.day))], s.selectedDate)),
|
|
564
446
|
// The only thing an update-required screen says: no tap or page clears it.
|
|
565
447
|
updateMessage: rowsOk === false ? copyParam("agenda.updateRequired", "Update the app to see the class schedule.") : "",
|
|
566
448
|
signedIn: !!member?.email,
|
|
567
449
|
updateRequired: rowsOk === false,
|
|
568
450
|
listHidden: s.busy === true || rowsOk === false || events.length === 0,
|
|
569
|
-
...emptyFlags(loaded && services!.length === 0, loaded && services!.length > 0 && events.length === 0),
|
|
451
|
+
...emptyFlags(loaded && services!.length === 0, loaded && services!.length > 0 && events.length === 0 && !locationEmpty),
|
|
570
452
|
sheetOpen: !!sheet && !!sheetPlace,
|
|
571
453
|
sheetTitle: sheet?.title ?? "",
|
|
572
454
|
sheetWhen: sheet ? joined(dayLabel(sheet.start, { absolute: true }), timeLabel(sheet.start), lengthLabel(sheet.start, sheet.end)) : "",
|
|
@@ -576,6 +458,8 @@ function derive(s: S): S {
|
|
|
576
458
|
sheetHideLeave: sheetPlace !== "waitlisted",
|
|
577
459
|
sheetBusy: busy || calendarBusy,
|
|
578
460
|
sheetServiceId: sheet?.serviceId ?? "",
|
|
461
|
+
sheetSessionId: sheet?.sessionId ?? "",
|
|
462
|
+
sheetStart: sheet?.start ?? "",
|
|
579
463
|
};
|
|
580
464
|
}
|
|
581
465
|
|
|
@@ -602,8 +486,16 @@ async function leave(place: MemberPlace, session: Session, owner: MemberAccess |
|
|
|
602
486
|
* not as a descriptor floor: a release takes its highest screen floor as its own. */
|
|
603
487
|
export const AGENDA_MIN_ANDROID_BINARY = "1.6.0";
|
|
604
488
|
let rowsOk: boolean | undefined;
|
|
489
|
+
/** A `scrollTo` of "day|nonce" jumps again to the same day (after a location change). A native
|
|
490
|
+
* renderer before 1.8.0 reads the whole value as the day and finds no row, so it gets the plain
|
|
491
|
+
* day: the list then does not re-land on an unchanged day, but it does reach a new one. */
|
|
492
|
+
export const AGENDA_SCROLL_SUFFIX_MIN_BINARY = "1.8.0";
|
|
493
|
+
let suffixOk: boolean | undefined;
|
|
494
|
+
|
|
605
495
|
async function rowsSupported(): Promise<boolean> {
|
|
606
496
|
const info = await getAppInfo().catch(() => undefined);
|
|
497
|
+
const native = !!info && (info.platform === "ios" || info.platform === "android" || info.platform === "macos");
|
|
498
|
+
suffixOk = !native || atLeast(info!.binaryVersion, AGENDA_SCROLL_SUFFIX_MIN_BINARY);
|
|
607
499
|
if (!info || info.platform !== "android") return true;
|
|
608
500
|
return atLeast(info.binaryVersion, AGENDA_MIN_ANDROID_BINARY);
|
|
609
501
|
}
|
|
@@ -624,6 +516,20 @@ function ask(s: S, view: Partial<View>): S {
|
|
|
624
516
|
return { ...s, ...wanted };
|
|
625
517
|
}
|
|
626
518
|
|
|
519
|
+
/** The longest run of days the list grows to before a far page replaces it. */
|
|
520
|
+
export const AGENDA_MAX_DAYS = 120;
|
|
521
|
+
|
|
522
|
+
/** The days to read when `need` is not loaded: the loaded days GROWN to reach it when the two touch
|
|
523
|
+
* (the member scrolled or paged on — the rows above them must stay, or the list jumps under their
|
|
524
|
+
* finger), else `need` alone (a far page). Capped at AGENDA_MAX_DAYS. */
|
|
525
|
+
export function grownWindow(loaded: { start: string; end: string }, need: { start: string; end: string }): { start: string; end: string } {
|
|
526
|
+
if (!loaded.start || need.start > dayKey(addDays(loaded.end, 1)) || need.end < dayKey(addDays(loaded.start, -1))) return need;
|
|
527
|
+
const start = loaded.start < need.start ? loaded.start : need.start;
|
|
528
|
+
const end = loaded.end > need.end ? loaded.end : need.end;
|
|
529
|
+
const span = Math.round((Date.parse(end) - Date.parse(start)) / 86_400_000);
|
|
530
|
+
return span > AGENDA_MAX_DAYS ? need : { start, end };
|
|
531
|
+
}
|
|
532
|
+
|
|
627
533
|
const covers = (win: { start: string; end: string }, need: { start: string; end: string }): boolean =>
|
|
628
534
|
!!win.start && win.start <= need.start && win.end >= need.end;
|
|
629
535
|
const needOf = (s: S) => windowFor(s.today, s.rangeStart, s.rangeEnd, s.selectedDate);
|
|
@@ -645,15 +551,34 @@ async function ensureLoaded(base: S, force = false): Promise<S | undefined> {
|
|
|
645
551
|
// page report can finish its read while `load`, dispatched with "loading", still waits.
|
|
646
552
|
if (covers(loadedWindow, need) && !force) return derive({ ...s, ...READY });
|
|
647
553
|
const seq = ++loadSeq;
|
|
648
|
-
const win = covers(loadedWindow, need) ? loadedWindow : need;
|
|
554
|
+
const win = covers(loadedWindow, need) ? loadedWindow : grownWindow(loadedWindow, need);
|
|
649
555
|
// A result that an action produced while this read ran (Add to Calendar, a failed cancel) is
|
|
650
556
|
// newer than the state this read started from: the commit keeps it rather than closing it.
|
|
651
557
|
const resultAt = resultSeq;
|
|
652
558
|
const fresh = (x: S): S => (resultSeq === resultAt ? x : keepResult(x) ?? x);
|
|
653
559
|
try {
|
|
654
|
-
|
|
655
|
-
|
|
560
|
+
// A window grown to reach new days reads only those days, and keeps what it has: a scroll that
|
|
561
|
+
// extends the list must not re-read weeks of every class (and the member's bookings) each time.
|
|
562
|
+
// A forced reload (the screen re-appeared) and a far page read the whole window.
|
|
563
|
+
const grown = !force && win !== need && !!loadedWindow.start && win.start <= loadedWindow.start && win.end >= loadedWindow.end;
|
|
564
|
+
const parts = grown
|
|
565
|
+
? [
|
|
566
|
+
...(win.start < loadedWindow.start ? [{ start: win.start, end: dayKey(addDays(loadedWindow.start, -1)) }] : []),
|
|
567
|
+
...(win.end > loadedWindow.end ? [{ start: dayKey(addDays(loadedWindow.end, 1)), end: win.end }] : []),
|
|
568
|
+
]
|
|
569
|
+
: [win];
|
|
570
|
+
const reads = await Promise.all(parts.map(async (part) => {
|
|
571
|
+
const inPart = await sessionsIn(part.start, part.end);
|
|
572
|
+
const { who: reader, found } = await placesIn(part.start, part.end, inPart);
|
|
573
|
+
return { inPart, reader, found };
|
|
574
|
+
}));
|
|
575
|
+
const nextSessions = grown ? ordered([...sessions, ...reads.flatMap((r) => r.inPart)]) : reads[0]!.inPart;
|
|
576
|
+
const nextPlaces = grown ? new Map([...places, ...reads.flatMap((r) => [...r.found])]) : reads[0]!.found;
|
|
577
|
+
const who = reads[0]?.reader ?? member;
|
|
656
578
|
if (seq !== loadSeq) return undefined;
|
|
579
|
+
// The new days were read for another member than the loaded ones (a sign-out or a switch while
|
|
580
|
+
// the member scrolled): never mix two members' places — read the whole window for this one.
|
|
581
|
+
if (grown && reads.some((r) => (r.reader?.email ?? "") !== (member?.email ?? ""))) return ensureLoaded({ ...s, ...wanted }, true);
|
|
657
582
|
const now = { ...s, ...wanted };
|
|
658
583
|
if (!covers(win, needOf(now))) {
|
|
659
584
|
// The member paged past what this read covers while it ran. The days already loaded may
|
|
@@ -701,10 +626,28 @@ function initial(): S {
|
|
|
701
626
|
...LOADING, firstWeekday: 0, today: "", selectedDate: "", rangeStart: "", rangeEnd: "",
|
|
702
627
|
decorations: [], events: [], scrollTo: "", signedIn: false, updateRequired: false, updateMessage: "", listHidden: true,
|
|
703
628
|
...emptyFlags(false, false), resultOpen: false, resultOpenB: false, resultText: "", resultOk: false, resultInfo: false,
|
|
704
|
-
sheetOpen: false, sheetTitle: "", sheetWhen: "", sheetMeta: "", sheetHideCalendar: true, sheetHideCancel: true, sheetHideLeave: true, sheetBusy: false, sheetServiceId: "",
|
|
629
|
+
sheetOpen: false, sheetTitle: "", sheetWhen: "", sheetMeta: "", sheetHideCalendar: true, sheetHideCancel: true, sheetHideLeave: true, sheetBusy: false, sheetServiceId: "", sheetSessionId: "", sheetStart: "",
|
|
630
|
+
locationOptions: [], locationLabel: "", locationA11y: "", hasLocationMenu: false, noLocationMenu: true,
|
|
631
|
+
locationActive: false, noLocationActive: true, locationSheetOpen: false, locationEmpty: false, notLocationEmpty: true,
|
|
705
632
|
};
|
|
706
633
|
}
|
|
707
634
|
|
|
635
|
+
/** How long a jump the worker asked for (a calendar tap, a page, a location) takes to settle. */
|
|
636
|
+
export const JUMP_SETTLE_MS = 900;
|
|
637
|
+
const settleJump = () => { quietUntil = Date.now() + JUMP_SETTLE_MS; };
|
|
638
|
+
|
|
639
|
+
/** A selected day with no session moves to the next day that has one ON THE SAME PAGE (the week
|
|
640
|
+
* or month on screen), so the calendar and the list name the same day — the same rule on every
|
|
641
|
+
* platform. With none left on the page the day stays selected, and the list shows the sessions
|
|
642
|
+
* after it. Never off the page: that would page the calendar away under the member's finger. */
|
|
643
|
+
async function settled(next: S | undefined): Promise<S | undefined> {
|
|
644
|
+
// The day the list scrolls to (a location change adds a "|n" suffix that re-applies it).
|
|
645
|
+
const day = next?.scrollTo.split("|")[0] ?? "";
|
|
646
|
+
if (!next || next.status !== "ready" || !day || day === next.selectedDate) return next;
|
|
647
|
+
if (day < next.selectedDate || day > titledDays(next.rangeStart, next.rangeEnd).last) return next;
|
|
648
|
+
return ensureLoaded(ask(next, { selectedDate: day }));
|
|
649
|
+
}
|
|
650
|
+
|
|
708
651
|
/** How the app opens its sign-in screen, from the appear params ("" = push). */
|
|
709
652
|
let signInPresentation: "" | "sheet" | "full_screen" = "";
|
|
710
653
|
|
|
@@ -721,9 +664,14 @@ const svc = defineService<S, {
|
|
|
721
664
|
addToCalendar: (s: S) => Promise<S>;
|
|
722
665
|
cancelBooking: (s: S) => Promise<S | undefined>;
|
|
723
666
|
joinWaitlist: (s: S, p?: unknown) => Promise<S | undefined>;
|
|
667
|
+
visibleDate: (s: S, p?: unknown) => Promise<S | undefined>;
|
|
668
|
+
openLocations: (s: S) => S;
|
|
669
|
+
closeLocations: (s: S) => S;
|
|
670
|
+
selectLocation: (s: S, p?: unknown) => Promise<S | undefined>;
|
|
724
671
|
}>("himiBrandedAgenda", {
|
|
725
|
-
// An action's result belongs to that visit: a cold start must not replay it from saved state
|
|
726
|
-
|
|
672
|
+
// An action's result belongs to that visit: a cold start must not replay it from saved state,
|
|
673
|
+
// nor reopen the location sheet.
|
|
674
|
+
notHydrated: ["resultOpen", "resultOpenB", "resultText", "resultOk", "resultInfo", "locationSheetOpen"],
|
|
727
675
|
state: initial(),
|
|
728
676
|
actions: {
|
|
729
677
|
load: async (s, p) => {
|
|
@@ -744,9 +692,12 @@ const svc = defineService<S, {
|
|
|
744
692
|
const asked = first
|
|
745
693
|
? ask(newView(s), { firstWeekday: fw, today, selectedDate: today, rangeStart: week[0], rangeEnd: week[6] })
|
|
746
694
|
: ask(newView(s), { firstWeekday: fw, today });
|
|
695
|
+
// The location chosen on any bookings screen, read again on every appearance: Services or
|
|
696
|
+
// Choose Time may have changed it since.
|
|
697
|
+
chosenLocation = await readSharedLocation();
|
|
747
698
|
rowsOk ??= await rowsSupported();
|
|
748
699
|
if (!rowsOk) return derive({ ...asked, ...READY });
|
|
749
|
-
return ensureLoaded(asked,
|
|
700
|
+
return first ? settled(await ensureLoaded(asked)) : ensureLoaded(asked, true);
|
|
750
701
|
},
|
|
751
702
|
// A view action: it closes the result, as every other one does through `newView`.
|
|
752
703
|
retry: async (s) => { services = null; return ensureLoaded(newView({ ...s, ...LOADING }), true); },
|
|
@@ -757,6 +708,8 @@ const svc = defineService<S, {
|
|
|
757
708
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || date === (wanted ?? s).selectedDate) return s;
|
|
758
709
|
// Past days are not selectable — also before the load has dimmed them on the calendar.
|
|
759
710
|
if (date < ((wanted ?? s).today || dayKey(new Date()))) return s;
|
|
711
|
+
settleJump();
|
|
712
|
+
scrollNonce = 0;
|
|
760
713
|
// A month grid's padding day (iOS draws them) belongs to the next month, and the grid moves
|
|
761
714
|
// there without reporting a page: move this page with it, so paging back is still a change.
|
|
762
715
|
const cur = wanted ?? s;
|
|
@@ -764,9 +717,9 @@ const svc = defineService<S, {
|
|
|
764
717
|
const page = titledDays(cur.rangeStart, cur.rangeEnd);
|
|
765
718
|
if (span > 7 && (date < page.first || date > page.last)) {
|
|
766
719
|
const grid = monthGridOf(date, s.firstWeekday);
|
|
767
|
-
if (grid) return ensureLoaded(ask(newView(s), { selectedDate: date, rangeStart: grid.start, rangeEnd: grid.end }));
|
|
720
|
+
if (grid) return settled(await ensureLoaded(ask(newView(s), { selectedDate: date, rangeStart: grid.start, rangeEnd: grid.end })));
|
|
768
721
|
}
|
|
769
|
-
return ensureLoaded(ask(newView(s), { selectedDate: date }));
|
|
722
|
+
return settled(await ensureLoaded(ask(newView(s), { selectedDate: date })));
|
|
770
723
|
},
|
|
771
724
|
rangeChanged: async (s, p) => {
|
|
772
725
|
const start = argOf(p, "startDate"), end = argOf(p, "endDate");
|
|
@@ -776,7 +729,14 @@ const svc = defineService<S, {
|
|
|
776
729
|
// on screen rather than a week the user has paged away from.
|
|
777
730
|
const keep = keepsSelection(cur, start, end);
|
|
778
731
|
const selectedDate = keep ? cur.selectedDate : selectionForRange(cur.today || dayKey(new Date()), start, end, cur.selectedDate);
|
|
779
|
-
|
|
732
|
+
// The calendar following the list's scroll pages too, and keeps its selection: only a page
|
|
733
|
+
// that moves the selection jumps the list.
|
|
734
|
+
if (!keep) { settleJump(); scrollNonce = 0; }
|
|
735
|
+
// A page that only followed the selection (the list's scroll moved it) is not a view change:
|
|
736
|
+
// a result on screen stays, as it does for the scroll itself.
|
|
737
|
+
return keep
|
|
738
|
+
? ensureLoaded(ask(s, { rangeStart: start, rangeEnd: end, selectedDate }))
|
|
739
|
+
: settled(await ensureLoaded(ask(newView(s), { rangeStart: start, rangeEnd: end, selectedDate })));
|
|
780
740
|
},
|
|
781
741
|
// An older binary has no week mode and reports only the month. Its grid then spans the
|
|
782
742
|
// month's whole weeks; load those, so dots still show on every visible day.
|
|
@@ -790,6 +750,7 @@ const svc = defineService<S, {
|
|
|
790
750
|
if (!start || !last || (start === cur.rangeStart && last === cur.rangeEnd)) return s;
|
|
791
751
|
const keep = keepsSelection(cur, start, last);
|
|
792
752
|
const selectedDate = keep ? cur.selectedDate : selectionForRange(cur.today || dayKey(new Date()), start, last, cur.selectedDate);
|
|
753
|
+
if (!keep) settleJump();
|
|
793
754
|
return ensureLoaded(ask(newView(s), { rangeStart: start, rangeEnd: last, selectedDate }));
|
|
794
755
|
},
|
|
795
756
|
openSheet: (s, p) => {
|
|
@@ -806,7 +767,9 @@ const svc = defineService<S, {
|
|
|
806
767
|
viewSession: (s) => {
|
|
807
768
|
const session = sessions.find((x) => x.key === sheetKey);
|
|
808
769
|
sheetKey = "";
|
|
809
|
-
return session
|
|
770
|
+
return session
|
|
771
|
+
? navigate(derive(s), { screenId: "himi_service", args: { id: session.serviceId, sessionId: session.sessionId, start: session.start } })
|
|
772
|
+
: derive(s);
|
|
810
773
|
},
|
|
811
774
|
// From one slot's own close or dismissal (`slot`: "a" | "b"), or both when it names none.
|
|
812
775
|
closeResult: (s, p) => {
|
|
@@ -941,6 +904,42 @@ const svc = defineService<S, {
|
|
|
941
904
|
return derive({ ...(svc.getState() as S), ...result(copyParam("agenda.waitlistFailed", "Couldn't join the waitlist. Try again."), false) });
|
|
942
905
|
}
|
|
943
906
|
},
|
|
907
|
+
// The list's scroll (`onVisibleDateChange`): the day the member reads becomes the selected day,
|
|
908
|
+
// so the calendar (and its week or month) follows the list. Not a view action — a result on
|
|
909
|
+
// screen stays. The list's scroll target follows too, so a return from a session's screen comes
|
|
910
|
+
// back to this day; the renderer does not scroll for its own day (it is where the list is).
|
|
911
|
+
visibleDate: async (s, p) => {
|
|
912
|
+
const date = argOf(p, "date");
|
|
913
|
+
const cur = wanted ?? s;
|
|
914
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || date === cur.selectedDate) return s;
|
|
915
|
+
// A report from before a jump the worker asked for: the jump wins.
|
|
916
|
+
if (Date.now() < quietUntil) return s;
|
|
917
|
+
if (date < (cur.today || dayKey(new Date()))) return s;
|
|
918
|
+
scrollNonce = 0;
|
|
919
|
+
return ensureLoaded(ask(s, { selectedDate: date }));
|
|
920
|
+
},
|
|
921
|
+
openLocations: (s) => {
|
|
922
|
+
locationSheet = true;
|
|
923
|
+
return derive({ ...s, ...wanted });
|
|
924
|
+
},
|
|
925
|
+
closeLocations: (s) => {
|
|
926
|
+
if (!locationSheet) return s;
|
|
927
|
+
locationSheet = false;
|
|
928
|
+
return derive({ ...s, ...wanted });
|
|
929
|
+
},
|
|
930
|
+
// A row of the location sheet: "" is every location. Kept for Services and Choose Time too.
|
|
931
|
+
selectLocation: async (s, p): Promise<S | undefined> => {
|
|
932
|
+
const id = argOf(p, "locationId");
|
|
933
|
+
const choice = agendaLocations(services ?? [], sessions, chosenLocation).find((c) => c.key === id);
|
|
934
|
+
chosenLocation = id && choice ? { id, label: choice.label } : { id: "", label: "" };
|
|
935
|
+
locationSheet = false;
|
|
936
|
+
settleJump();
|
|
937
|
+
// Rows above the selected day may have come or gone: the list must land on it again.
|
|
938
|
+
scrollNonce += 1;
|
|
939
|
+
// Not awaited (it never throws): the new list shows at once, whatever the store does.
|
|
940
|
+
void writeSharedLocation(chosenLocation);
|
|
941
|
+
return settled(derive(newView({ ...s, ...wanted })));
|
|
942
|
+
},
|
|
944
943
|
},
|
|
945
944
|
});
|
|
946
945
|
export { svc };
|
|
@@ -376,7 +376,7 @@ export default defineApp({
|
|
|
376
376
|
// Your on-device database. It must declare every entity a worker reads or writes,
|
|
377
377
|
// or those calls fail closed — keep it in step with `local_database` above.
|
|
378
378
|
dataSchema: {
|
|
379
|
-
"version":
|
|
379
|
+
"version": 3,
|
|
380
380
|
"entities": [
|
|
381
381
|
{
|
|
382
382
|
"name": "products",
|
|
@@ -457,6 +457,25 @@ export default defineApp({
|
|
|
457
457
|
}
|
|
458
458
|
]
|
|
459
459
|
},
|
|
460
|
+
{
|
|
461
|
+
"name": "booking_prefs",
|
|
462
|
+
"encrypted": false,
|
|
463
|
+
"fields": [
|
|
464
|
+
{
|
|
465
|
+
"name": "id",
|
|
466
|
+
"type": "string",
|
|
467
|
+
"required": true
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
"name": "locationId",
|
|
471
|
+
"type": "string"
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
"name": "locationLabel",
|
|
475
|
+
"type": "string"
|
|
476
|
+
}
|
|
477
|
+
]
|
|
478
|
+
},
|
|
460
479
|
{
|
|
461
480
|
"name": "cms_collections",
|
|
462
481
|
"encrypted": false,
|