@wix/himalaya-cli 0.1581.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 +17195 -17232
- 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
|
@@ -1,22 +1,32 @@
|
|
|
1
1
|
// Tier-5 bundle: himi-booking — customer-facing services catalog.
|
|
2
2
|
//
|
|
3
|
-
// Visitors browse
|
|
4
|
-
// order of the site's Book Online page)
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
3
|
+
// Visitors browse every Wix Bookings service of the site, grouped by category under a small
|
|
4
|
+
// title, in the business's own order (the order of the site's Book Online page). The category
|
|
5
|
+
// chips are anchors, not filters: a tap scrolls the list to its category's title, and the chip
|
|
6
|
+
// of the category at the top of the list lights up while the member scrolls. A name search
|
|
7
|
+
// keeps the grouping. With two or more locations a pin limits the list to the services offered
|
|
8
|
+
// at one location; the choice is shared with the Agenda and Choose Time screens. Each row
|
|
9
|
+
// carries what the service record has: photo, name, tagline, a Class or Course tag, and
|
|
10
|
+
// duration and price in the same words as the service screen. "Book" shows only where online
|
|
11
|
+
// booking by time is possible. The worker exposes explicit flags so the descriptor renders
|
|
12
|
+
// loading, empty, error and loaded states without raw JavaScript conditions in the screen tree.
|
|
13
|
+
import { defineService } from "@himalaya/state";
|
|
10
14
|
import { defineBackendCollection } from "../_shared/collection-service.js";
|
|
11
15
|
import { COLLECTION_CHROME_MIN_ITEMS } from "../_shared/collection-contract.js";
|
|
12
16
|
import { queryServiceCategories, queryServices, type WixService } from "@himalaya/wix-bookings";
|
|
13
17
|
import { categoryName, courseDatesText, courseHasEnded, serviceDurationText, servicePriceLine, type BookingLabel } from "../_shared/himi-bookings.js";
|
|
14
18
|
import { formatServicePrice } from "../_shared/wix-service-price.js";
|
|
15
19
|
import { getAll, upsert, type DbEntity } from "@himalaya/local-db";
|
|
20
|
+
import { get as getAppInfo } from "@himalaya/app-info";
|
|
21
|
+
import { atLeast } from "../_shared/member-account-routes.js";
|
|
16
22
|
import { initials } from "../_shared/himi-text.js";
|
|
17
23
|
import { copyParam, copyTemplate } from "../_shared/himi-copy.js";
|
|
18
24
|
import { offsetPage } from "../_shared/offset-page.js";
|
|
19
25
|
import { wixMediaUrl } from "@himalaya/wix-media";
|
|
26
|
+
import {
|
|
27
|
+
distinctChoices, effectiveLocation, locationMenu, readSharedLocation, serviceLocationChoices, writeSharedLocation,
|
|
28
|
+
type LocationChoice, type LocationOptionRow, type SharedLocation,
|
|
29
|
+
} from "../_shared/booking-location.js";
|
|
20
30
|
|
|
21
31
|
// Offline-first (data-offline): the services catalog is a public read-through —
|
|
22
32
|
// paint the last-synced rows from the on-device store first (instant, works
|
|
@@ -52,6 +62,8 @@ interface Row {
|
|
|
52
62
|
hasImage: boolean;
|
|
53
63
|
noImage: boolean;
|
|
54
64
|
category: string;
|
|
65
|
+
/** The category's identity: its Wix id, else "name:<name>"; "" for a service with no category. */
|
|
66
|
+
categoryKey: string;
|
|
55
67
|
/** The category's place in the business's order; -1 when the record has none. */
|
|
56
68
|
categoryOrder: number;
|
|
57
69
|
/** The row's place in the business's order when it was read (the offline copy is sorted by it). */
|
|
@@ -74,8 +86,13 @@ interface Row {
|
|
|
74
86
|
bookA11y: string;
|
|
75
87
|
/** The row's spoken name: name, type tag, tagline and meta. */
|
|
76
88
|
a11y: string;
|
|
89
|
+
/** Where the service is offered, as location choices (`serviceLocationChoices`). */
|
|
90
|
+
locations: LocationChoice[];
|
|
77
91
|
}
|
|
78
92
|
|
|
93
|
+
/** A row of the bound list: a category's title, or a service under it. */
|
|
94
|
+
type ListRow = Row & { isSection: boolean; notSection: boolean; sectionTitle: string };
|
|
95
|
+
|
|
79
96
|
interface CategoryChip { id: string; label: string }
|
|
80
97
|
|
|
81
98
|
interface S {
|
|
@@ -87,8 +104,12 @@ interface S {
|
|
|
87
104
|
loadingMore?: boolean;
|
|
88
105
|
total?: number;
|
|
89
106
|
fromCache?: boolean;
|
|
107
|
+
/** The services read (Branded Web prints `results` as it is): every location, no titles. */
|
|
90
108
|
all: Row[];
|
|
91
109
|
results: Row[];
|
|
110
|
+
/** What the Services list shows: each category's title row, then its services, at the chosen
|
|
111
|
+
* location. Title rows are `cat:<category id>`, service rows the service id. */
|
|
112
|
+
listRows: ListRow[];
|
|
92
113
|
emptyText: string;
|
|
93
114
|
countLabel: string;
|
|
94
115
|
errored: boolean;
|
|
@@ -97,23 +118,39 @@ interface S {
|
|
|
97
118
|
notLoading: boolean;
|
|
98
119
|
hasResults: boolean;
|
|
99
120
|
noEmpty: boolean;
|
|
121
|
+
/** One anchor chip per category the list shows, in the list's order. */
|
|
100
122
|
categoryChips: CategoryChip[];
|
|
101
123
|
hideCategories: boolean;
|
|
124
|
+
/** The chip of the category at the top of the list (or the one tapped last). */
|
|
125
|
+
activeCategory: string;
|
|
126
|
+
/** "<row id>|<n>": the list scrolls that row to the top when this changes; "" scrolls nothing. */
|
|
127
|
+
scrollTo: string;
|
|
102
128
|
/** Loading with no rows to show: the skeleton stands in for the list. */
|
|
103
129
|
hideRows: boolean;
|
|
104
|
-
/** The no-match line
|
|
130
|
+
/** The no-match line of a search. */
|
|
105
131
|
noMatchText: string;
|
|
106
|
-
/**
|
|
132
|
+
/** A search is typed: the no-match block offers Clear Search. */
|
|
107
133
|
hasQuery: boolean;
|
|
108
134
|
noQuery: boolean;
|
|
109
135
|
/** The chips' spoken name, from the copy pack (a descriptor accessibilityLabel is not rewritten). */
|
|
110
136
|
categoriesLabel: string;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
137
|
+
// The location picker (two or more locations only): `locationMenu()` in booking-location.ts.
|
|
138
|
+
locationOptions: LocationOptionRow[];
|
|
139
|
+
locationLabel: string;
|
|
140
|
+
locationA11y: string;
|
|
141
|
+
hasLocationMenu: boolean;
|
|
142
|
+
noLocationMenu: boolean;
|
|
143
|
+
locationActive: boolean;
|
|
144
|
+
noLocationActive: boolean;
|
|
145
|
+
locationSheetOpen: boolean;
|
|
146
|
+
/** A location is chosen and none of the services read is offered there. */
|
|
147
|
+
locationEmpty: boolean;
|
|
148
|
+
notLocationEmpty: boolean;
|
|
149
|
+
locationEmptyText: string;
|
|
150
|
+
/** The location this list was last limited to, kept with the state so a cold boot shows the
|
|
151
|
+
* same list before the shared choice is read again. */
|
|
152
|
+
chosenLocationId: string;
|
|
153
|
+
chosenLocationLabel: string;
|
|
117
154
|
}
|
|
118
155
|
|
|
119
156
|
/** The list's copy keys for the shared price, duration and course-date lines. */
|
|
@@ -183,6 +220,23 @@ function categoryOrder(value: unknown): number {
|
|
|
183
220
|
return typeof order === "number" && Number.isFinite(order) ? order : -1;
|
|
184
221
|
}
|
|
185
222
|
|
|
223
|
+
/** A category's identity: its id, else its name (a string category, a name-only reference). */
|
|
224
|
+
function categoryKeyOf(id: unknown, name: string): string {
|
|
225
|
+
if (!name) return "";
|
|
226
|
+
return typeof id === "string" && id ? id : `name:${name}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The service's category. A Wix Bookings v2 service has ONE category (`category`), so a service
|
|
230
|
+
* shows under its one category. Should a payload ever carry several (`categories`), the rule is
|
|
231
|
+
* the same on every surface: the first one only, never a copy of the row under each. */
|
|
232
|
+
function categoryOf(service: WixService): { key: string; name: string; order: number } {
|
|
233
|
+
const raw = service as { category?: unknown; categories?: unknown };
|
|
234
|
+
const first = raw.category ?? (Array.isArray(raw.categories) ? raw.categories[0] : undefined);
|
|
235
|
+
const name = categoryName(first);
|
|
236
|
+
const id = first && typeof first === "object" ? (first as { id?: unknown }).id : undefined;
|
|
237
|
+
return { key: categoryKeyOf(id, name), name, order: categoryOrder(first) };
|
|
238
|
+
}
|
|
239
|
+
|
|
186
240
|
function typeTagOf(type: unknown): string {
|
|
187
241
|
if (type === "CLASS") return copyParam("services.tagClass", "Class");
|
|
188
242
|
if (type === "COURSE") return copyParam("services.tagCourse", "Course");
|
|
@@ -226,11 +280,13 @@ function norm(service: WixService): Row {
|
|
|
226
280
|
const tagline = typeof service.tagLine === "string" ? service.tagLine.trim() : "";
|
|
227
281
|
const canBook = service.onlineBooking?.enabled !== false && !isCourse;
|
|
228
282
|
const request = service.onlineBooking?.requireManualApproval === true;
|
|
283
|
+
const category = categoryOf(service);
|
|
229
284
|
return {
|
|
230
285
|
id,
|
|
231
286
|
name,
|
|
232
|
-
category:
|
|
233
|
-
|
|
287
|
+
category: category.name,
|
|
288
|
+
categoryKey: category.key,
|
|
289
|
+
categoryOrder: category.order,
|
|
234
290
|
subtitle: bits.length ? bits.join(" · ") : "Choose a time",
|
|
235
291
|
// WS-C — canonical param-less detail screenId; the descriptor row binds the
|
|
236
292
|
// separate `id` field as a first-class nav param (no more baked suffixes).
|
|
@@ -250,6 +306,7 @@ function norm(service: WixService): Row {
|
|
|
250
306
|
bookCta: bookCtaFor(request),
|
|
251
307
|
bookA11y: bookLabel(name, request),
|
|
252
308
|
a11y: spokenName({ name, typeTag, endedText, tagline, meta }),
|
|
309
|
+
locations: serviceLocationChoices((service as { locations?: unknown }).locations),
|
|
253
310
|
};
|
|
254
311
|
}
|
|
255
312
|
|
|
@@ -263,6 +320,7 @@ function withRowDefaults(row: Partial<Row>, shownEnded?: string): Row {
|
|
|
263
320
|
const tagline = str(row.tagline);
|
|
264
321
|
const meta = str(row.meta);
|
|
265
322
|
const canBook = row.canBook === true;
|
|
323
|
+
const category = str(row.category);
|
|
266
324
|
const courseSchedule = row.courseSchedule;
|
|
267
325
|
const endedText = courseSchedule && courseHasEnded({ schedule: courseSchedule }, Date.now())
|
|
268
326
|
? copyParam("services.ended", "Ended", shownEnded || str(row.endedText) || "Ended") : "";
|
|
@@ -276,7 +334,8 @@ function withRowDefaults(row: Partial<Row>, shownEnded?: string): Row {
|
|
|
276
334
|
image,
|
|
277
335
|
hasImage: !!image,
|
|
278
336
|
noImage: !image,
|
|
279
|
-
category
|
|
337
|
+
category,
|
|
338
|
+
categoryKey: str(row.categoryKey) || categoryKeyOf("", category),
|
|
280
339
|
categoryOrder: typeof row.categoryOrder === "number" ? row.categoryOrder : -1,
|
|
281
340
|
tagline,
|
|
282
341
|
typeTag,
|
|
@@ -287,50 +346,49 @@ function withRowDefaults(row: Partial<Row>, shownEnded?: string): Row {
|
|
|
287
346
|
noBook: !canBook,
|
|
288
347
|
bookCta: str(row.bookCta) || bookCtaFor(false),
|
|
289
348
|
bookA11y: str(row.bookA11y) || bookLabel(name),
|
|
349
|
+
// Recomputed, not restored: a course that ended since the row was saved says so.
|
|
290
350
|
a11y: spokenName({ name, typeTag, endedText, tagline, meta }),
|
|
351
|
+
locations: Array.isArray(row.locations) ? row.locations.filter((l) => !!l && typeof l.key === "string" && !!l.key) : [],
|
|
291
352
|
};
|
|
292
353
|
}
|
|
293
354
|
|
|
294
|
-
// ----
|
|
355
|
+
// ---- Categories -------------------------------------------------------------------
|
|
295
356
|
// The site's categories, read once per worker in the background — never holding back the
|
|
296
|
-
// services
|
|
297
|
-
// is tried again at most once a minute. Until it succeeds, the
|
|
298
|
-
//
|
|
299
|
-
|
|
357
|
+
// services read — give the business's order of the groups. When the read lands, the list
|
|
358
|
+
// repaints. A failed read is tried again at most once a minute. Until it succeeds, the groups
|
|
359
|
+
// follow each row's `category.sortOrder` (the order the services read already sorts by).
|
|
360
|
+
interface SiteCategory { key: string; name: string }
|
|
361
|
+
let siteCategories: SiteCategory[] | undefined;
|
|
300
362
|
let categoriesInFlight = false;
|
|
301
363
|
let categoriesFailedAt = 0;
|
|
302
364
|
const CATEGORY_RETRY_MS = 60_000;
|
|
303
|
-
const rowCategories = new Map<string, number>();
|
|
304
365
|
/** A restored Ended label bridges only the first live page after boot, because an app that sends no
|
|
305
366
|
* copy pack gives no sign that none is coming: after it, current copy (pack or stock) decides. */
|
|
306
367
|
let restoredEndedBridge = true;
|
|
307
368
|
|
|
308
|
-
/**
|
|
309
|
-
|
|
310
|
-
|
|
369
|
+
/** The title row of the services with no category (last, and only beside other groups). */
|
|
370
|
+
const UNCATEGORIZED = "cat:none";
|
|
371
|
+
const sectionIdOf = (key: string): string => (key ? `cat:${encodeURIComponent(key)}` : UNCATEGORIZED);
|
|
372
|
+
|
|
373
|
+
/** Repaint on the state already shown. Re-deriving from the query controller would publish its
|
|
374
|
+
* working state, which a revalidation deliberately holds back (a restored list would flash to
|
|
375
|
+
* the skeleton, or shrink to page 1, until every page is back). */
|
|
311
376
|
function republish(): void {
|
|
312
377
|
(svc as unknown as { categoriesLanded?: () => unknown }).categoriesLanded?.();
|
|
313
378
|
}
|
|
314
379
|
|
|
315
|
-
function withChips(s: S): S {
|
|
316
|
-
const collection = (s as unknown as { collection?: { filter?: unknown; query?: unknown; hideChrome?: unknown } }).collection;
|
|
317
|
-
const filter = typeof collection?.filter === "string" ? collection.filter : "all";
|
|
318
|
-
// Restored rows stand in for the categories when the read failed before any live page — only
|
|
319
|
-
// rows of the whole list (no category, no query), or the stand-in would be one category.
|
|
320
|
-
const whole = filter === "all" && !(typeof collection?.query === "string" && collection.query.trim());
|
|
321
|
-
// Not while a load is out: the rows on screen may still be a previous search's.
|
|
322
|
-
if (!siteCategories?.length && rowCategories.size === 0 && whole && s.isLoading !== true) rememberRowCategories((s.results ?? []).map((row) => withRowDefaults(row)));
|
|
323
|
-
const chips = categoryChips(filter);
|
|
324
|
-
return { ...s, categoryChips: chips, hideCategories: collection?.hideChrome === true || (chips.length < 3 && !filter.startsWith("value:")) };
|
|
325
|
-
}
|
|
326
|
-
|
|
327
380
|
function startCategoriesRead(): void {
|
|
328
381
|
if (siteCategories || categoriesInFlight) return;
|
|
329
382
|
if (categoriesFailedAt && Date.now() - categoriesFailedAt < CATEGORY_RETRY_MS) return;
|
|
330
383
|
categoriesInFlight = true;
|
|
331
384
|
queryServiceCategories().then((res) => {
|
|
332
|
-
const
|
|
333
|
-
|
|
385
|
+
const out: SiteCategory[] = [];
|
|
386
|
+
for (const category of res.categories ?? []) {
|
|
387
|
+
const name = categoryName(category);
|
|
388
|
+
const key = categoryKeyOf(category.id, name);
|
|
389
|
+
if (key && !out.some((c) => c.key === key)) out.push({ key, name });
|
|
390
|
+
}
|
|
391
|
+
siteCategories = out;
|
|
334
392
|
}, () => {
|
|
335
393
|
categoriesFailedAt = Date.now();
|
|
336
394
|
}).then(() => {
|
|
@@ -339,42 +397,173 @@ function startCategoriesRead(): void {
|
|
|
339
397
|
});
|
|
340
398
|
}
|
|
341
399
|
|
|
342
|
-
|
|
400
|
+
interface Section { id: string; title: string; rows: Row[] }
|
|
401
|
+
|
|
402
|
+
/** The rows grouped by their one category: the business's order of categories (the categories
|
|
403
|
+
* read, else each row's category sortOrder, else first seen), each group in the rows' own order,
|
|
404
|
+
* and the services with no category last. A category with no row has no group. */
|
|
405
|
+
function sectionsOf(rows: Row[]): Section[] {
|
|
406
|
+
const site = siteCategories ?? [];
|
|
407
|
+
const groups = new Map<string, Section & { rank: number; order: number; seen: number }>();
|
|
343
408
|
for (const row of rows) {
|
|
344
|
-
|
|
409
|
+
// A row that names a category the read also has joins it, whatever shape its reference had.
|
|
410
|
+
let index = row.categoryKey ? site.findIndex((c) => c.key === row.categoryKey) : -1;
|
|
411
|
+
if (index < 0 && row.category) index = site.findIndex((c) => c.name === row.category);
|
|
412
|
+
const key = index >= 0 ? site[index].key : row.categoryKey;
|
|
413
|
+
const id = sectionIdOf(key);
|
|
414
|
+
let group = groups.get(id);
|
|
415
|
+
if (!group) {
|
|
416
|
+
group = { id, title: index >= 0 ? site[index].name : row.category, rows: [], rank: index, order: row.categoryOrder, seen: groups.size };
|
|
417
|
+
groups.set(id, group);
|
|
418
|
+
}
|
|
419
|
+
group.rows.push(row);
|
|
345
420
|
}
|
|
421
|
+
const place = (g: { id: string; rank: number; order: number; seen: number }): number[] =>
|
|
422
|
+
g.id === UNCATEGORIZED ? [2, 0, g.seen] : g.rank >= 0 ? [0, g.rank, g.seen] : [1, g.order < 0 ? Number.MAX_SAFE_INTEGER : g.order, g.seen];
|
|
423
|
+
return [...groups.values()]
|
|
424
|
+
.sort((a, b) => { const pa = place(a), pb = place(b); return pa[0] - pb[0] || pa[1] - pb[1] || pa[2] - pb[2]; })
|
|
425
|
+
.map(({ id, title, rows: groupRows }) => ({
|
|
426
|
+
id, title: id === UNCATEGORIZED ? copyParam("services.otherCategory", "Other services") : title, rows: groupRows,
|
|
427
|
+
}));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const BLANK_ROW: Omit<Row, "id"> = {
|
|
431
|
+
name: "", subtitle: "", detailScreenId: "", initial: "", image: "", hasImage: false, noImage: true,
|
|
432
|
+
category: "", categoryKey: "", categoryOrder: -1, tagline: "", typeTag: "", noTypeTag: true, endedText: "", meta: "",
|
|
433
|
+
canBook: false, noBook: true, bookCta: "", bookA11y: "", a11y: "", locations: [],
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
// ---- Anchors -------------------------------------------------------------------------
|
|
437
|
+
// A chip tap scrolls the list to its category's title row (`scrollTo`, a new "|n" each tap, so a
|
|
438
|
+
// second tap on the same chip scrolls again) and lights that chip. The list reports the row at
|
|
439
|
+
// its top when the MEMBER scrolls (`topItemChanged`); its category's chip lights up.
|
|
440
|
+
let activeCategory = "";
|
|
441
|
+
/** The member has tapped a chip or scrolled the list: the lit chip is theirs. Until then it is the
|
|
442
|
+
* first category, whatever order the rows arrived in — the catalog's first rows can come before
|
|
443
|
+
* the business's category order, and the first chip of THAT list stayed lit over the real top. */
|
|
444
|
+
let activeChosen = false;
|
|
445
|
+
let scrollTarget = "";
|
|
446
|
+
let jumps = 0;
|
|
447
|
+
let quietUntil = 0;
|
|
448
|
+
/** How long a jump the worker asked for takes to settle: a top-row report inside it is the jump's
|
|
449
|
+
* own (the renderers suppress those, this is the belt to their braces). */
|
|
450
|
+
const JUMP_SETTLE_MS = 900;
|
|
451
|
+
|
|
452
|
+
// ---- Location ------------------------------------------------------------------------
|
|
453
|
+
/** The member's choice (shared with Agenda and Choose Time through the on-device store). */
|
|
454
|
+
let chosenLocation: SharedLocation = { id: "", label: "" };
|
|
455
|
+
/** Every location the whole catalog is offered at: a search narrows the rows, never the menu. */
|
|
456
|
+
let catalogLocations: LocationChoice[] = [];
|
|
457
|
+
let locationSheet = false;
|
|
458
|
+
|
|
459
|
+
/** The first runtime whose LazyList jumps to a row (`scrollToItemPath`) and reports the row at its
|
|
460
|
+
* top (`onTopItemChange`). An older native binary ignores both, so its chips would light without
|
|
461
|
+
* moving the list: they are hidden there, and the grouped list with its titles stays. Web is served
|
|
462
|
+
* with the release's own renderer, so it always has them. */
|
|
463
|
+
export const ANCHORS_MIN_BINARY = "1.8.0";
|
|
464
|
+
let anchorsOk: boolean | undefined;
|
|
465
|
+
async function anchorsSupported(): Promise<boolean> {
|
|
466
|
+
const info = await getAppInfo().catch(() => undefined);
|
|
467
|
+
if (!info || (info.platform !== "ios" && info.platform !== "android" && info.platform !== "macos")) return true;
|
|
468
|
+
return atLeast(info.binaryVersion, ANCHORS_MIN_BINARY);
|
|
346
469
|
}
|
|
347
470
|
|
|
348
|
-
function
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
listed.sort((a, b) => (a.order < 0 ? Infinity : a.order) - (b.order < 0 ? Infinity : b.order) || a.index - b.index);
|
|
352
|
-
return listed.map((entry) => entry.name);
|
|
471
|
+
async function readLocation(): Promise<void> {
|
|
472
|
+
chosenLocation = await readSharedLocation();
|
|
473
|
+
anchorsOk ??= await anchorsSupported();
|
|
353
474
|
}
|
|
354
475
|
|
|
355
|
-
function
|
|
356
|
-
const
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
const all = chosen && !names.includes(chosen) ? [...names, chosen] : names;
|
|
360
|
-
return [{ id: "all", label: copyParam("services.allCategories", "All") }, ...all.map((name) => ({ id: `value:${name}`, label: name }))];
|
|
476
|
+
function argOf(p: unknown, key: string): string {
|
|
477
|
+
const a = p as Record<string, unknown> | undefined;
|
|
478
|
+
const v = a?.[key] ?? (a?.args as Record<string, unknown> | undefined)?.[key] ?? (a?.params as Record<string, unknown> | undefined)?.[key];
|
|
479
|
+
return typeof v === "string" ? v.trim() : "";
|
|
361
480
|
}
|
|
362
481
|
|
|
482
|
+
/** Everything the list shows, rebuilt from the rows read and the module's choices. */
|
|
483
|
+
function display(s: S): S {
|
|
484
|
+
const rows = Array.isArray(s.results) ? s.results : [];
|
|
485
|
+
const loading = s.isLoading === true;
|
|
486
|
+
const errored = s.errored === true;
|
|
487
|
+
const choices = catalogLocations;
|
|
488
|
+
const locationKey = effectiveLocation(choices, chosenLocation.id);
|
|
489
|
+
const shown = locationKey ? rows.filter((row) => (row.locations ?? []).some((l) => l.key === locationKey)) : rows;
|
|
490
|
+
const sections = sectionsOf(shown);
|
|
491
|
+
// Titles once there is something to tell apart: never over a list with no category at all.
|
|
492
|
+
const titled = sections.length > 1 || (sections.length === 1 && sections[0].id !== UNCATEGORIZED);
|
|
493
|
+
const listRows: ListRow[] = sections.flatMap((section) => [
|
|
494
|
+
...(titled ? [{ ...BLANK_ROW, id: section.id, isSection: true, notSection: false, sectionTitle: section.title }] : []),
|
|
495
|
+
...section.rows.map((row) => ({ ...row, isSection: false, notSection: true, sectionTitle: "" })),
|
|
496
|
+
]);
|
|
497
|
+
const chips = titled && sections.length >= 2 ? sections.map((section) => ({ id: section.id, label: section.title })) : [];
|
|
498
|
+
// No chips (a reload in flight, a single group): the lit chip waits for the list to come back.
|
|
499
|
+
if (chips.length && (!activeChosen || !chips.some((chip) => chip.id === activeCategory))) activeCategory = chips[0]!.id;
|
|
500
|
+
const target = scrollTarget.split("|")[0];
|
|
501
|
+
if (scrollTarget && !listRows.some((row) => row.id === target)) scrollTarget = "";
|
|
502
|
+
// The same rule as the shared `collection.hideChrome`: no search or chips over a short,
|
|
503
|
+
// un-narrowed list, an empty source, or before the first rows arrive.
|
|
504
|
+
const narrowed = s.hasQuery === true;
|
|
505
|
+
const sourceEmpty = !loading && !errored && rows.length === 0 && !narrowed;
|
|
506
|
+
const hideChrome = (!narrowed && !s.nextCursor && rows.length < COLLECTION_CHROME_MIN_ITEMS) || sourceEmpty
|
|
507
|
+
|| ((loading || errored) && rows.length === 0 && !narrowed);
|
|
508
|
+
const locationEmpty = !loading && !errored && !!locationKey && rows.length > 0 && shown.length === 0;
|
|
509
|
+
const menu = locationMenu(choices, chosenLocation.id);
|
|
510
|
+
return {
|
|
511
|
+
...s,
|
|
512
|
+
listRows,
|
|
513
|
+
categoryChips: chips,
|
|
514
|
+
hideCategories: hideChrome || chips.length < 2 || anchorsOk === false,
|
|
515
|
+
activeCategory,
|
|
516
|
+
scrollTo: scrollTarget,
|
|
517
|
+
...menu,
|
|
518
|
+
locationSheetOpen: locationSheet && menu.hasLocationMenu,
|
|
519
|
+
locationEmpty,
|
|
520
|
+
notLocationEmpty: !locationEmpty,
|
|
521
|
+
locationEmptyText: locationEmpty ? copyTemplate("services.locationEmptyText", "There are no services at {location}.", { location: menu.locationLabel }) : "",
|
|
522
|
+
chosenLocationId: chosenLocation.id,
|
|
523
|
+
chosenLocationLabel: chosenLocation.label,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** The section a row of the list belongs to: a title row is its own; a service, its group's. */
|
|
528
|
+
function sectionOfRow(listRows: ListRow[], id: string): string {
|
|
529
|
+
let section = "";
|
|
530
|
+
for (const row of listRows) {
|
|
531
|
+
if (row.isSection) section = row.id;
|
|
532
|
+
if (row.id === id) return section;
|
|
533
|
+
}
|
|
534
|
+
return "";
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const PAGE = 100;
|
|
538
|
+
/** Grouping and anchors need the whole catalog: every page is read up front, up to this many
|
|
539
|
+
* (1,000 services). A bigger catalog keeps a cursor, and the list pages the rest as before. */
|
|
540
|
+
const MAX_PAGES = 10;
|
|
541
|
+
|
|
542
|
+
// A cold boot takes these from `state`, never from the last run: a jump the last run asked for
|
|
543
|
+
// would scroll the restored list on its first frame, and a sheet would reopen.
|
|
544
|
+
const registerServices = ((name: string, def: Parameters<typeof defineService>[1]) =>
|
|
545
|
+
defineService(name, { ...def, notHydrated: ["scrollTo", "locationSheetOpen"], perVisit: ["scrollTo", "locationSheetOpen"] } as never)) as typeof defineService;
|
|
546
|
+
|
|
363
547
|
const svc = defineBackendCollection<S, Row>("himiBookingServices", {
|
|
548
|
+
register: registerServices,
|
|
364
549
|
restorePagesOnLoad: true,
|
|
365
550
|
onLoad() {
|
|
366
551
|
// Copy is stashed before this hook. Repaint restored labels immediately, even
|
|
367
552
|
// when the background query holds the old page through a slow or failed read.
|
|
368
553
|
(svc as unknown as { normalizeRestored?: () => unknown }).normalizeRestored?.();
|
|
369
554
|
},
|
|
370
|
-
state: { status: "loading", query: "", all: [], results: [], emptyText: "", countLabel: "", errored: false,
|
|
555
|
+
state: { status: "loading", query: "", all: [], results: [], listRows: [], emptyText: "", countLabel: "", errored: false,
|
|
371
556
|
notErrored: true, isLoading: true, notLoading: false, hasResults: false, noEmpty: true,
|
|
372
|
-
categoryChips: [], hideCategories: true, hideRows: false, noMatchText: "", hasQuery: false, noQuery: true,
|
|
557
|
+
categoryChips: [], hideCategories: true, activeCategory: "", scrollTo: "", hideRows: false, noMatchText: "", hasQuery: false, noQuery: true,
|
|
558
|
+
categoriesLabel: "", locationOptions: [], locationLabel: "", locationA11y: "", hasLocationMenu: false, noLocationMenu: true,
|
|
559
|
+
locationActive: false, noLocationActive: true, locationSheetOpen: false, locationEmpty: false, notLocationEmpty: true,
|
|
560
|
+
locationEmptyText: "", chosenLocationId: "", chosenLocationLabel: "" },
|
|
373
561
|
source: {
|
|
374
562
|
id: row => row.id,
|
|
375
563
|
errorMessage: () => copyParam("services.errorText", "Services could not be loaded."),
|
|
376
564
|
async cached(criteria) {
|
|
377
565
|
if (criteria.query || criteria.filter !== "all" || criteria.sort !== "default") return undefined;
|
|
566
|
+
await readLocation();
|
|
378
567
|
return { items: await readCachedRows(), nextCursor: "", metadata: { fromCache: true } };
|
|
379
568
|
},
|
|
380
569
|
async fetch(criteria, cursor) {
|
|
@@ -386,25 +575,105 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
|
|
|
386
575
|
const sort = criteria.sort === "default"
|
|
387
576
|
? [{ fieldName: "category.sortOrder", order: "ASC" as const }, { fieldName: "sortOrder", order: "ASC" as const }, { fieldName: "id", order: "ASC" as const }]
|
|
388
577
|
: [{ fieldName: "name", order: criteria.sort === "za" ? "DESC" as const : "ASC" as const }, { fieldName: "id", order: "ASC" as const }];
|
|
389
|
-
if (!cursor)
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
578
|
+
if (!cursor) {
|
|
579
|
+
startCategoriesRead();
|
|
580
|
+
// Read again on every load and appearance: Agenda or Choose Time may have changed it.
|
|
581
|
+
await readLocation();
|
|
582
|
+
// The menu offers the whole catalog's places, and a search or category read lists only its
|
|
583
|
+
// matches: a cold boot into a persisted search takes them from the device's copy of the list.
|
|
584
|
+
if (catalogLocations.length === 0 && (criteria.query || criteria.filter !== "all")) {
|
|
585
|
+
catalogLocations = distinctChoices((await readCachedRows()).flatMap((row) => row.locations));
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const items: Row[] = [];
|
|
589
|
+
let offset = Number(cursor || 0);
|
|
590
|
+
let next = "";
|
|
591
|
+
let total: number | undefined;
|
|
592
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
593
|
+
let res;
|
|
594
|
+
try {
|
|
595
|
+
res = await queryServices({ query: { filter, paging: { limit: PAGE, offset }, sort } });
|
|
596
|
+
} catch (error) {
|
|
597
|
+
// A later page failing keeps the pages already read: the list pages the rest (and says
|
|
598
|
+
// so if it fails again). The first page failing is the read failing.
|
|
599
|
+
if (page === 0) throw error;
|
|
600
|
+
next = String(offset);
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
const raw = res.services ?? [];
|
|
604
|
+
items.push(...raw.filter(row => !!row.id).map((service, index) => ({ ...norm(service), position: offset + index })));
|
|
605
|
+
const paged = offsetPage(String(offset), raw.length, PAGE, res.pagingMetadata);
|
|
606
|
+
next = paged.nextCursor;
|
|
607
|
+
total = paged.total ?? total;
|
|
608
|
+
if (!next) break;
|
|
609
|
+
offset = Number(next);
|
|
610
|
+
}
|
|
394
611
|
// Only a read of the whole list in the business's order is saved: the offline copy is read
|
|
395
612
|
// back as that list, and a narrowed or re-sorted read's positions are not positions in it.
|
|
396
613
|
if (!criteria.query && !criteria.filter.startsWith("value:") && criteria.sort === "default") await writeCachedRows(items);
|
|
397
|
-
return { items, ...
|
|
614
|
+
return { items, nextCursor: next, ...(total === undefined ? {} : { total }), metadata: { fromCache: false } };
|
|
398
615
|
},
|
|
399
616
|
},
|
|
400
617
|
actions: {
|
|
401
|
-
categoriesLanded: (s) =>
|
|
402
|
-
//
|
|
403
|
-
//
|
|
618
|
+
categoriesLanded: (s) => display(s),
|
|
619
|
+
// A snapshot saved by an older worker comes back without the new row fields; a missing
|
|
620
|
+
// `noBook` would show Book on a course until the live read. Give restored rows the defaults,
|
|
621
|
+
// and the restored list its groups and the location it was limited to.
|
|
404
622
|
normalizeRestored: (s) => {
|
|
405
|
-
const rows = Array.isArray(s.results) ? s.results : [];
|
|
406
|
-
|
|
407
|
-
|
|
623
|
+
const rows = (Array.isArray(s.results) ? s.results : []).map((row) => withRowDefaults(row as Partial<Row>));
|
|
624
|
+
if (typeof s.chosenLocationId === "string" && s.chosenLocationId) {
|
|
625
|
+
chosenLocation = { id: s.chosenLocationId, label: typeof s.chosenLocationLabel === "string" ? s.chosenLocationLabel : "" };
|
|
626
|
+
}
|
|
627
|
+
if (!s.query) catalogLocations = distinctChoices(rows.flatMap((row) => row.locations));
|
|
628
|
+
return display({ ...s, results: rows, all: rows });
|
|
629
|
+
},
|
|
630
|
+
// A chip: scroll to its category and light it. Re-tapping the lit chip deselects it on every
|
|
631
|
+
// renderer (`selectedIds: []`): that is the same chip again, so it scrolls again.
|
|
632
|
+
scrollToCategory: (s, p) => {
|
|
633
|
+
const ids = (p as { selectedIds?: unknown } | undefined)?.selectedIds
|
|
634
|
+
?? ((p as { args?: { selectedIds?: unknown } } | undefined)?.args?.selectedIds);
|
|
635
|
+
const picked = Array.isArray(ids) && typeof ids[0] === "string" ? ids[0] : activeCategory;
|
|
636
|
+
if (!(s.categoryChips ?? []).some((chip) => chip.id === picked)) return s;
|
|
637
|
+
activeCategory = picked;
|
|
638
|
+
activeChosen = true;
|
|
639
|
+
scrollTarget = `${picked}|${++jumps}`;
|
|
640
|
+
quietUntil = Date.now() + JUMP_SETTLE_MS;
|
|
641
|
+
return display(s);
|
|
642
|
+
},
|
|
643
|
+
// The row at the top of the list after the member scrolled: its category's chip lights up.
|
|
644
|
+
topItemChanged: (s, p) => {
|
|
645
|
+
if (Date.now() < quietUntil) return s;
|
|
646
|
+
const rows = Array.isArray(s.listRows) ? s.listRows : [];
|
|
647
|
+
// A search miss or an empty location has no category to follow.
|
|
648
|
+
if (!rows.some((row) => !row.isSection)) return s;
|
|
649
|
+
const section = sectionOfRow(rows, argOf(p, "id"));
|
|
650
|
+
if (!section || !(s.categoryChips ?? []).some((chip) => chip.id === section)) return s;
|
|
651
|
+
activeCategory = section;
|
|
652
|
+
activeChosen = true;
|
|
653
|
+
// The member has moved on from the last jump: a re-render never scrolls back to it.
|
|
654
|
+
scrollTarget = "";
|
|
655
|
+
return display(s);
|
|
656
|
+
},
|
|
657
|
+
openLocations: (s) => {
|
|
658
|
+
locationSheet = true;
|
|
659
|
+
return display(s);
|
|
660
|
+
},
|
|
661
|
+
closeLocations: (s) => {
|
|
662
|
+
if (!locationSheet) return s;
|
|
663
|
+
locationSheet = false;
|
|
664
|
+
return display(s);
|
|
665
|
+
},
|
|
666
|
+
// A row of the location sheet: "" is every location. Agenda and Choose Time read it too.
|
|
667
|
+
selectLocation: async (s, p) => {
|
|
668
|
+
const id = argOf(p, "locationId");
|
|
669
|
+
const choice = catalogLocations.find((c) => c.key === id);
|
|
670
|
+
chosenLocation = id && choice ? { id, label: choice.label } : { id: "", label: "" };
|
|
671
|
+
locationSheet = false;
|
|
672
|
+
scrollTarget = "";
|
|
673
|
+
// Not awaited (it never throws): the state returned is this dispatch's own, so no later commit
|
|
674
|
+
// — a revalidation landing during a native round trip — is overwritten by it.
|
|
675
|
+
void writeSharedLocation(chosenLocation);
|
|
676
|
+
return display(s);
|
|
408
677
|
},
|
|
409
678
|
},
|
|
410
679
|
apply(s, page) {
|
|
@@ -415,27 +684,17 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
|
|
|
415
684
|
const shown = new Map((restoredEndedBridge && Array.isArray(s.results) ? s.results : []).map((row) => [row.id, row.endedText]));
|
|
416
685
|
const rows = page.items.map((item) => withRowDefaults(item, shown.get(item.id)));
|
|
417
686
|
if (page.metadata?.fromCache === false && !loading && !errored) restoredEndedBridge = false;
|
|
418
|
-
// The stand-in categories come from the accepted rows of the whole list (a superseded
|
|
419
|
-
// response never reaches apply): a live page replaces them, the device's copy only fills a gap.
|
|
420
|
-
if (!page.criteria.query && page.criteria.filter === "all" && rows.length > 0) {
|
|
421
|
-
// Only a settled page: while a page reloads, the list may still hold the previous
|
|
422
|
-
// (searched) rows under the new criteria.
|
|
423
|
-
if (page.metadata?.fromCache !== true) { if (!loading) { rowCategories.clear(); rememberRowCategories(rows); } }
|
|
424
|
-
else if (rowCategories.size === 0) rememberRowCategories(rows);
|
|
425
|
-
}
|
|
426
|
-
// The same rule as the shared `collection.hideChrome`: no search or chips over a short,
|
|
427
|
-
// un-narrowed list, an empty source, or before the first rows arrive.
|
|
428
687
|
const filtered = page.criteria.filter.startsWith("value:");
|
|
429
688
|
const hasQuery = !!page.criteria.query.trim();
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
689
|
+
// The menu offers every location of the whole catalog: a settled live read replaces it, the
|
|
690
|
+
// device's copy only fills a gap (a superseded response never reaches apply).
|
|
691
|
+
if (!hasQuery && !filtered && rows.length > 0) {
|
|
692
|
+
const live = page.metadata?.fromCache !== true;
|
|
693
|
+
if ((live && !loading) || catalogLocations.length === 0) catalogLocations = distinctChoices(rows.flatMap((row) => row.locations));
|
|
694
|
+
}
|
|
695
|
+
return display({ ...s, status: loading ? "loading" : "ready", query: page.criteria.query, all: rows, results: rows,
|
|
696
|
+
hideRows: loading && rows.length === 0,
|
|
697
|
+
noMatchText: copyParam("services.noMatchText", "No services match your search."),
|
|
439
698
|
hasQuery, noQuery: !hasQuery, categoriesLabel: copyParam("services.categoriesLabel", "Service categories"),
|
|
440
699
|
nextCursor: page.nextCursor, complete: page.complete, total: page.total, loadingMore: page.loadingMore, pageError: page.pageError,
|
|
441
700
|
fromCache: page.metadata?.fromCache === true, errored, notErrored: !errored, isLoading: loading, notLoading: !loading,
|
|
@@ -444,17 +703,17 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
|
|
|
444
703
|
emptyText: errored ? copyParam("services.errorText", "Services could not be loaded.")
|
|
445
704
|
: loading || rows.length > 0 ? ""
|
|
446
705
|
: hasQuery ? copyParam("services.noMatchText", "No services match your search.")
|
|
447
|
-
:
|
|
448
|
-
: copyParam("services.emptyText", "There are no services to book yet.") };
|
|
706
|
+
: copyParam("services.emptyText", "There are no services to book yet.") });
|
|
449
707
|
},
|
|
450
708
|
});
|
|
451
|
-
// The old chrome could persist a "saved" filter (there is no Saved chip now)
|
|
452
|
-
// (
|
|
709
|
+
// The old chrome could persist a "saved" filter (there is no Saved chip now), a category filter
|
|
710
|
+
// (the chips are anchors now, the list always shows every category) or an A-Z sort (there is no
|
|
711
|
+
// Refine now): all go back to the business's own whole view before the first read.
|
|
453
712
|
type Retired = { getState?: () => { collection?: { filter?: unknown; sort?: unknown } };
|
|
454
713
|
collectionFilter?: (arg: unknown) => unknown; collectionSort?: (arg: unknown) => unknown };
|
|
455
714
|
const retired = svc as unknown as Retired;
|
|
456
715
|
const persisted = retired.getState?.()?.collection;
|
|
457
|
-
if (persisted?.filter === "
|
|
716
|
+
if (typeof persisted?.filter === "string" && persisted.filter !== "all") void retired.collectionFilter?.({ selectedIds: ["all"] });
|
|
458
717
|
if (typeof persisted?.sort === "string" && persisted.sort !== "default") void retired.collectionSort?.({ selectedId: "default" });
|
|
459
718
|
svc.load();
|
|
460
719
|
export { svc };
|