@wix/himalaya-cli 0.1614.0 → 0.1615.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +1 -0
- package/package.json +2 -2
- package/template-icons/salon-spa.png +0 -0
- package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
- package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/salon-spa/README.md +53 -0
- package/templates/salon-spa/config.ts +272 -0
- package/templates/salon-spa/dev/index.ts +19893 -0
- package/templates/salon-spa/himalaya.content.json +49 -0
- package/templates/salon-spa/himalaya.template.json +14 -0
- package/templates/salon-spa/icon.png +0 -0
- package/templates/salon-spa/platforms.json +1 -0
- package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
- package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
- package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
- package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
- package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
- package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
- package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
- package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
- package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
- package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
- package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
- package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
- package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
- package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
- package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
- package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
- package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
- package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
- package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
- package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
- package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
- package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
- package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
- package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
- package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
- package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
- package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
- package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
- package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
- package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
- package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
- package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
- package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
- package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
- package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
- package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
- package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
- package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
- package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
- package/templates/salon-spa/tokens.json +341 -0
- package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
// Tier-5 bundle: himi-booking — customer-facing services catalog.
|
|
2
|
+
//
|
|
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. Opened with a
|
|
7
|
+
// `category` nav param (a category tile), the list starts at that category. A name search
|
|
8
|
+
// keeps the grouping. With two or more locations a pin limits the list to the services offered
|
|
9
|
+
// at one location; the choice is shared with the Agenda and Choose Time screens. Each row
|
|
10
|
+
// carries what the service record has: photo, name, tagline, a Class or Course tag, and
|
|
11
|
+
// duration and price in the same words as the service screen. "Book" shows only where online
|
|
12
|
+
// booking by time is possible. The worker exposes explicit flags so the descriptor renders
|
|
13
|
+
// loading, empty, error and loaded states without raw JavaScript conditions in the screen tree.
|
|
14
|
+
import { defineService } from "@himalaya/state";
|
|
15
|
+
import { defineBackendCollection } from "../_shared/collection-service.js";
|
|
16
|
+
import { COLLECTION_CHROME_MIN_ITEMS } from "../_shared/collection-contract.js";
|
|
17
|
+
import { queryServiceCategories, queryServices, type WixService } from "@himalaya/wix-bookings";
|
|
18
|
+
import { businessTimeZone } from "../_shared/app-locale.js";
|
|
19
|
+
import { categoryName, courseDatesText, courseHasEnded, courseNeedsZone, serviceDurationText, servicePriceLine, type BookingLabel } from "../_shared/himi-bookings.js";
|
|
20
|
+
import { formatServicePrice } from "../_shared/wix-service-price.js";
|
|
21
|
+
import { getAll, upsert, type DbEntity } from "@himalaya/local-db";
|
|
22
|
+
import { get as getAppInfo } from "@himalaya/app-info";
|
|
23
|
+
import { atLeast } from "../_shared/member-account-routes.js";
|
|
24
|
+
import { initials } from "../_shared/himi-text.js";
|
|
25
|
+
import { copyParam, copyTemplate } from "../_shared/himi-copy.js";
|
|
26
|
+
import { offsetPage } from "../_shared/offset-page.js";
|
|
27
|
+
import { wixMediaUrl } from "@himalaya/wix-media";
|
|
28
|
+
import {
|
|
29
|
+
distinctChoices, effectiveLocation, locationMenu, readSharedLocation, serviceLocationChoices, writeSharedLocation,
|
|
30
|
+
type LocationChoice, type LocationOptionRow, type SharedLocation,
|
|
31
|
+
} from "../_shared/booking-location.js";
|
|
32
|
+
|
|
33
|
+
// Offline-first (data-offline): the services catalog is a public read-through —
|
|
34
|
+
// paint the last-synced rows from the on-device store first (instant, works
|
|
35
|
+
// offline), then revalidate from Wix Bookings and refresh the cache. DEGRADES
|
|
36
|
+
// GRACEFULLY: every local-db call is wrapped so a bundle that ships in an app
|
|
37
|
+
// which hasn't granted `local_database` (+ declared the `services` entity in its
|
|
38
|
+
// dataSchema) just falls back to today's online-only path — the capability fails
|
|
39
|
+
// closed per-entity, so the try/catch is the seam that keeps this shared bundle
|
|
40
|
+
// safe across every consuming app.
|
|
41
|
+
const ENTITY = "services";
|
|
42
|
+
async function readCachedRows(): Promise<Row[]> {
|
|
43
|
+
try {
|
|
44
|
+
const rows = (await getAll(ENTITY)) as unknown as Partial<Row>[];
|
|
45
|
+
// The store keeps its own insertion order; the business's order is each row's cached position
|
|
46
|
+
// (rows saved before positions existed keep their store order, after the positioned ones).
|
|
47
|
+
const at = (row: Partial<Row>) => (typeof row.position === "number" ? row.position : Number.MAX_SAFE_INTEGER);
|
|
48
|
+
return rows.map((row, index) => ({ row, index }))
|
|
49
|
+
.sort((a, b) => at(a.row) - at(b.row) || a.index - b.index)
|
|
50
|
+
.map(({ row }) => withRowDefaults(row));
|
|
51
|
+
} catch { return []; }
|
|
52
|
+
}
|
|
53
|
+
async function writeCachedRows(rows: Row[]): Promise<void> {
|
|
54
|
+
try { for (const r of rows) await upsert(ENTITY, r as unknown as DbEntity); } catch { /* capability not granted → online-only */ }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface Row {
|
|
58
|
+
id: string;
|
|
59
|
+
name: string;
|
|
60
|
+
subtitle: string;
|
|
61
|
+
detailScreenId: string;
|
|
62
|
+
initial: string;
|
|
63
|
+
image: string;
|
|
64
|
+
hasImage: boolean;
|
|
65
|
+
noImage: boolean;
|
|
66
|
+
category: string;
|
|
67
|
+
/** The category's identity: its Wix id, else "name:<name>"; "" for a service with no category. */
|
|
68
|
+
categoryKey: string;
|
|
69
|
+
/** The category's place in the business's order; -1 when the record has none. */
|
|
70
|
+
categoryOrder: number;
|
|
71
|
+
/** The row's place in the business's order when it was read (the offline copy is sorted by it). */
|
|
72
|
+
position?: number;
|
|
73
|
+
tagline: string;
|
|
74
|
+
/** "Class" or "Course"; "" for an appointment. */
|
|
75
|
+
typeTag: string;
|
|
76
|
+
noTypeTag: boolean;
|
|
77
|
+
endedText: string;
|
|
78
|
+
/** Retain course dates so a cached catalog can refresh its status after the course ends. */
|
|
79
|
+
courseSchedule?: { firstSessionStart?: string; lastSessionEnd?: string; timeZone?: string };
|
|
80
|
+
/** "1 hr · ₪70"; a course: "Nov 3 – Dec 15, 2026 · ₪1,200". "" when the record has neither. */
|
|
81
|
+
meta: string;
|
|
82
|
+
/** Online booking by time is possible: online booking is on and the service is not a course. */
|
|
83
|
+
canBook: boolean;
|
|
84
|
+
noBook: boolean;
|
|
85
|
+
/** The pill's word: "Book", or "Request" when the business approves each booking by hand
|
|
86
|
+
* (the service screen's "Request to Book"). Both open the time screen. */
|
|
87
|
+
bookCta: string;
|
|
88
|
+
bookA11y: string;
|
|
89
|
+
/** The row's spoken name: name, type tag, tagline and meta. */
|
|
90
|
+
a11y: string;
|
|
91
|
+
/** Where the service is offered, as location choices (`serviceLocationChoices`). */
|
|
92
|
+
locations: LocationChoice[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A row of the bound list: a category's title, or a service under it. */
|
|
96
|
+
type ListRow = Row & { isSection: boolean; notSection: boolean; sectionTitle: string };
|
|
97
|
+
|
|
98
|
+
interface CategoryChip { id: string; label: string }
|
|
99
|
+
|
|
100
|
+
interface S {
|
|
101
|
+
status: "loading" | "ready";
|
|
102
|
+
query: string;
|
|
103
|
+
nextCursor?: string;
|
|
104
|
+
complete?: boolean;
|
|
105
|
+
pageError?: boolean;
|
|
106
|
+
loadingMore?: boolean;
|
|
107
|
+
total?: number;
|
|
108
|
+
fromCache?: boolean;
|
|
109
|
+
/** The services read (Branded Web prints `results` as it is): every location, no titles. */
|
|
110
|
+
all: Row[];
|
|
111
|
+
results: Row[];
|
|
112
|
+
/** What the Services list shows: each category's title row, then its services, at the chosen
|
|
113
|
+
* location. Title rows are `cat:<category id>`, service rows the service id. */
|
|
114
|
+
listRows: ListRow[];
|
|
115
|
+
emptyText: string;
|
|
116
|
+
countLabel: string;
|
|
117
|
+
errored: boolean;
|
|
118
|
+
notErrored: boolean;
|
|
119
|
+
isLoading: boolean;
|
|
120
|
+
notLoading: boolean;
|
|
121
|
+
hasResults: boolean;
|
|
122
|
+
noEmpty: boolean;
|
|
123
|
+
/** One anchor chip per category the list shows, in the list's order. */
|
|
124
|
+
categoryChips: CategoryChip[];
|
|
125
|
+
hideCategories: boolean;
|
|
126
|
+
/** The chip of the category at the top of the list (or the one tapped last). */
|
|
127
|
+
activeCategory: string;
|
|
128
|
+
/** "<row id>|<n>": the list scrolls that row to the top when this changes; "" scrolls nothing. */
|
|
129
|
+
scrollTo: string;
|
|
130
|
+
/** Loading with no rows to show: the skeleton stands in for the list. */
|
|
131
|
+
hideRows: boolean;
|
|
132
|
+
/** The no-match line of a search. */
|
|
133
|
+
noMatchText: string;
|
|
134
|
+
/** A search is typed: the no-match block offers Clear Search. */
|
|
135
|
+
hasQuery: boolean;
|
|
136
|
+
noQuery: boolean;
|
|
137
|
+
/** The chips' spoken name, from the copy pack (a descriptor accessibilityLabel is not rewritten). */
|
|
138
|
+
categoriesLabel: string;
|
|
139
|
+
// The location picker (two or more locations only): `locationMenu()` in booking-location.ts.
|
|
140
|
+
locationOptions: LocationOptionRow[];
|
|
141
|
+
locationLabel: string;
|
|
142
|
+
locationA11y: string;
|
|
143
|
+
hasLocationMenu: boolean;
|
|
144
|
+
noLocationMenu: boolean;
|
|
145
|
+
locationActive: boolean;
|
|
146
|
+
noLocationActive: boolean;
|
|
147
|
+
locationSheetOpen: boolean;
|
|
148
|
+
/** A location is chosen and none of the services read is offered there. */
|
|
149
|
+
locationEmpty: boolean;
|
|
150
|
+
notLocationEmpty: boolean;
|
|
151
|
+
locationEmptyText: string;
|
|
152
|
+
/** The location this list was last limited to, kept with the state so a cold boot shows the
|
|
153
|
+
* same list before the shared choice is read again. */
|
|
154
|
+
chosenLocationId: string;
|
|
155
|
+
chosenLocationLabel: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The list's copy keys for the shared price, duration and course-date lines. */
|
|
159
|
+
const listLabel: BookingLabel = (key, fallback, values) =>
|
|
160
|
+
values ? copyTemplate(`services.${key}`, fallback, values) : copyParam(`services.${key}`, fallback);
|
|
161
|
+
|
|
162
|
+
/** The old subtitle's price: Branded Web's book screen still shows it. */
|
|
163
|
+
function money(service: WixService): string {
|
|
164
|
+
// Delegates to the shared formatter so the Bookings v2 `payment.fixed.price`
|
|
165
|
+
// shape + the record's real currency (₪, not a hardcoded $) are honored (#816).
|
|
166
|
+
return formatServicePrice(service);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The old subtitle's duration (whole service length only): Branded Web still shows it. */
|
|
170
|
+
function subtitleDuration(service: WixService): string {
|
|
171
|
+
const minutes = Number((service as { duration?: number }).duration ?? 0);
|
|
172
|
+
if (!minutes) return "";
|
|
173
|
+
if (minutes < 60) return `${minutes} min`;
|
|
174
|
+
const hours = Math.floor(minutes / 60);
|
|
175
|
+
const rest = minutes % 60;
|
|
176
|
+
return rest ? `${hours} hr ${rest} min` : `${hours} hr`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function mediaUrl(value: unknown): string {
|
|
180
|
+
if (typeof value === "string") return wixMediaUrl(value);
|
|
181
|
+
if (!value || typeof value !== "object") return "";
|
|
182
|
+
if (Array.isArray(value)) {
|
|
183
|
+
for (const item of value) {
|
|
184
|
+
const url = mediaUrl(item);
|
|
185
|
+
if (url) return url;
|
|
186
|
+
}
|
|
187
|
+
return "";
|
|
188
|
+
}
|
|
189
|
+
const node = value as {
|
|
190
|
+
url?: unknown;
|
|
191
|
+
src?: unknown;
|
|
192
|
+
imageUrl?: unknown;
|
|
193
|
+
imageSrc?: unknown;
|
|
194
|
+
image?: unknown;
|
|
195
|
+
thumbnail?: unknown;
|
|
196
|
+
mediaItem?: unknown;
|
|
197
|
+
mainMedia?: unknown;
|
|
198
|
+
main?: unknown;
|
|
199
|
+
media?: unknown;
|
|
200
|
+
items?: unknown;
|
|
201
|
+
photos?: unknown;
|
|
202
|
+
gallery?: unknown;
|
|
203
|
+
coverImage?: unknown;
|
|
204
|
+
};
|
|
205
|
+
for (const direct of [node.url, node.src, node.imageUrl, node.imageSrc]) {
|
|
206
|
+
const url = wixMediaUrl(direct);
|
|
207
|
+
if (url) return url;
|
|
208
|
+
}
|
|
209
|
+
for (const nested of [node.image, node.thumbnail, node.mediaItem, node.mainMedia, node.main, node.media, node.items, node.photos, node.gallery, node.coverImage]) {
|
|
210
|
+
const url = mediaUrl(nested);
|
|
211
|
+
if (url) return url;
|
|
212
|
+
}
|
|
213
|
+
return "";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function imageUrl(service: WixService): string {
|
|
217
|
+
return mediaUrl(service);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function categoryOrder(value: unknown): number {
|
|
221
|
+
const order = value && typeof value === "object" ? (value as { sortOrder?: unknown }).sortOrder : undefined;
|
|
222
|
+
return typeof order === "number" && Number.isFinite(order) ? order : -1;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A category's identity: its id, else its name (a string category, a name-only reference). */
|
|
226
|
+
function categoryKeyOf(id: unknown, name: string): string {
|
|
227
|
+
if (!name) return "";
|
|
228
|
+
return typeof id === "string" && id ? id : `name:${name}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The service's category. A Wix Bookings v2 service has ONE category (`category`), so a service
|
|
232
|
+
* shows under its one category. Should a payload ever carry several (`categories`), the rule is
|
|
233
|
+
* the same on every surface: the first one only, never a copy of the row under each. */
|
|
234
|
+
function categoryOf(service: WixService): { key: string; name: string; order: number } {
|
|
235
|
+
const raw = service as { category?: unknown; categories?: unknown };
|
|
236
|
+
const first = raw.category ?? (Array.isArray(raw.categories) ? raw.categories[0] : undefined);
|
|
237
|
+
const name = categoryName(first);
|
|
238
|
+
const id = first && typeof first === "object" ? (first as { id?: unknown }).id : undefined;
|
|
239
|
+
return { key: categoryKeyOf(id, name), name, order: categoryOrder(first) };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function typeTagOf(type: unknown): string {
|
|
243
|
+
if (type === "CLASS") return copyParam("services.tagClass", "Class");
|
|
244
|
+
if (type === "COURSE") return copyParam("services.tagCourse", "Course");
|
|
245
|
+
return "";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function spokenName(row: Pick<Row, "name" | "typeTag" | "tagline" | "meta" | "endedText">): string {
|
|
249
|
+
return [row.name, row.typeTag, row.endedText, row.tagline, row.meta].filter(Boolean).join(", ");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function bookCtaFor(request: boolean): string {
|
|
253
|
+
return request ? copyParam("services.requestCta", "Request") : copyParam("services.bookCta", "Book");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function bookLabel(name: string, request = false): string {
|
|
257
|
+
return request
|
|
258
|
+
? copyTemplate("services.requestLabel", "Request to book {name}", { name })
|
|
259
|
+
: copyTemplate("services.bookLabel", "Book {name}", { name });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function norm(service: WixService): Row {
|
|
263
|
+
const id = service.id || "service";
|
|
264
|
+
const name = service.name || "Service";
|
|
265
|
+
const bits = [
|
|
266
|
+
// Live payloads carry `category` as a structured object — extract the name
|
|
267
|
+
// (raw join rendered "[object Object] · 1 hr · $111", production sweep R3).
|
|
268
|
+
categoryName(service.category),
|
|
269
|
+
subtitleDuration(service),
|
|
270
|
+
money(service),
|
|
271
|
+
].filter(Boolean);
|
|
272
|
+
const image = imageUrl(service);
|
|
273
|
+
const isCourse = service.type === "COURSE";
|
|
274
|
+
// A session time Wix lists without an offset is the business's wall time: the schedule carries the
|
|
275
|
+
// provisioned zone that places it (none provisioned: not Ended, never a guess), and the cached row
|
|
276
|
+
// keeps it so an offline list can still age out.
|
|
277
|
+
const courseSchedule = isCourse ? {
|
|
278
|
+
firstSessionStart: service.schedule?.firstSessionStart,
|
|
279
|
+
lastSessionEnd: service.schedule?.lastSessionEnd,
|
|
280
|
+
...(businessTimeZone() && courseNeedsZone(service.schedule) ? { timeZone: businessTimeZone() } : {}),
|
|
281
|
+
} : undefined;
|
|
282
|
+
const endedText = courseSchedule && courseHasEnded({ schedule: courseSchedule }, Date.now()) ? copyParam("services.ended", "Ended") : "";
|
|
283
|
+
const price = servicePriceLine(service, listLabel).text;
|
|
284
|
+
const meta = [isCourse ? courseDatesText(service) : serviceDurationText(service, listLabel), price].filter(Boolean).join(" · ");
|
|
285
|
+
const typeTag = typeTagOf(service.type);
|
|
286
|
+
const tagline = typeof service.tagLine === "string" ? service.tagLine.trim() : "";
|
|
287
|
+
const canBook = service.onlineBooking?.enabled !== false && !isCourse;
|
|
288
|
+
const request = service.onlineBooking?.requireManualApproval === true;
|
|
289
|
+
const category = categoryOf(service);
|
|
290
|
+
return {
|
|
291
|
+
id,
|
|
292
|
+
name,
|
|
293
|
+
category: category.name,
|
|
294
|
+
categoryKey: category.key,
|
|
295
|
+
categoryOrder: category.order,
|
|
296
|
+
subtitle: bits.length ? bits.join(" · ") : "Choose a time",
|
|
297
|
+
// WS-C — canonical param-less detail screenId; the descriptor row binds the
|
|
298
|
+
// separate `id` field as a first-class nav param (no more baked suffixes).
|
|
299
|
+
detailScreenId: "himi_service",
|
|
300
|
+
initial: initials(name),
|
|
301
|
+
image,
|
|
302
|
+
hasImage: !!image,
|
|
303
|
+
noImage: !image,
|
|
304
|
+
tagline,
|
|
305
|
+
typeTag,
|
|
306
|
+
noTypeTag: !typeTag,
|
|
307
|
+
endedText,
|
|
308
|
+
courseSchedule,
|
|
309
|
+
meta,
|
|
310
|
+
canBook,
|
|
311
|
+
noBook: !canBook,
|
|
312
|
+
bookCta: bookCtaFor(request),
|
|
313
|
+
bookA11y: bookLabel(name, request),
|
|
314
|
+
a11y: spokenName({ name, typeTag, endedText, tagline, meta }),
|
|
315
|
+
locations: serviceLocationChoices((service as { locations?: unknown }).locations),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** A row saved by an older worker (offline cache, restored state) has none of the new
|
|
320
|
+
* fields: it renders with no tagline, meta or tag, and no Book pill until the live read. */
|
|
321
|
+
function withRowDefaults(row: Partial<Row>, shownEnded?: string): Row {
|
|
322
|
+
const name = typeof row.name === "string" && row.name ? row.name : "Service";
|
|
323
|
+
const str = (v: unknown) => (typeof v === "string" ? v : "");
|
|
324
|
+
const image = str(row.image);
|
|
325
|
+
const typeTag = str(row.typeTag);
|
|
326
|
+
const tagline = str(row.tagline);
|
|
327
|
+
const meta = str(row.meta);
|
|
328
|
+
const canBook = row.canBook === true;
|
|
329
|
+
const category = str(row.category);
|
|
330
|
+
const courseSchedule = row.courseSchedule;
|
|
331
|
+
const endedText = courseSchedule && courseHasEnded({ schedule: courseSchedule }, Date.now())
|
|
332
|
+
? copyParam("services.ended", "Ended", shownEnded || str(row.endedText) || "Ended") : "";
|
|
333
|
+
return {
|
|
334
|
+
...row,
|
|
335
|
+
id: str(row.id),
|
|
336
|
+
name,
|
|
337
|
+
subtitle: str(row.subtitle),
|
|
338
|
+
detailScreenId: str(row.detailScreenId) || "himi_service",
|
|
339
|
+
initial: str(row.initial),
|
|
340
|
+
image,
|
|
341
|
+
hasImage: !!image,
|
|
342
|
+
noImage: !image,
|
|
343
|
+
category,
|
|
344
|
+
categoryKey: str(row.categoryKey) || categoryKeyOf("", category),
|
|
345
|
+
categoryOrder: typeof row.categoryOrder === "number" ? row.categoryOrder : -1,
|
|
346
|
+
tagline,
|
|
347
|
+
typeTag,
|
|
348
|
+
noTypeTag: !typeTag,
|
|
349
|
+
endedText,
|
|
350
|
+
meta,
|
|
351
|
+
canBook,
|
|
352
|
+
noBook: !canBook,
|
|
353
|
+
bookCta: str(row.bookCta) || bookCtaFor(false),
|
|
354
|
+
bookA11y: str(row.bookA11y) || bookLabel(name),
|
|
355
|
+
// Recomputed, not restored: a course that ended since the row was saved says so.
|
|
356
|
+
a11y: spokenName({ name, typeTag, endedText, tagline, meta }),
|
|
357
|
+
locations: Array.isArray(row.locations) ? row.locations.filter((l) => !!l && typeof l.key === "string" && !!l.key) : [],
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ---- Categories -------------------------------------------------------------------
|
|
362
|
+
// The site's categories, read once per worker in the background — never holding back the
|
|
363
|
+
// services read — give the business's order of the groups. When the read lands, the list
|
|
364
|
+
// repaints. A failed read is tried again at most once a minute. Until it succeeds, the groups
|
|
365
|
+
// follow each row's `category.sortOrder` (the order the services read already sorts by).
|
|
366
|
+
interface SiteCategory { key: string; name: string }
|
|
367
|
+
let siteCategories: SiteCategory[] | undefined;
|
|
368
|
+
let categoriesInFlight = false;
|
|
369
|
+
let categoriesFailedAt = 0;
|
|
370
|
+
const CATEGORY_RETRY_MS = 60_000;
|
|
371
|
+
/** A restored Ended label bridges only the first live page after boot, because an app that sends no
|
|
372
|
+
* copy pack gives no sign that none is coming: after it, current copy (pack or stock) decides. */
|
|
373
|
+
let restoredEndedBridge = true;
|
|
374
|
+
|
|
375
|
+
/** The title row of the services with no category (last, and only beside other groups). */
|
|
376
|
+
const UNCATEGORIZED = "cat:none";
|
|
377
|
+
const sectionIdOf = (key: string): string => (key ? `cat:${encodeURIComponent(key)}` : UNCATEGORIZED);
|
|
378
|
+
|
|
379
|
+
/** Repaint on the state already shown. Re-deriving from the query controller would publish its
|
|
380
|
+
* working state, which a revalidation deliberately holds back (a restored list would flash to
|
|
381
|
+
* the skeleton, or shrink to page 1, until every page is back). */
|
|
382
|
+
function republish(): void {
|
|
383
|
+
(svc as unknown as { categoriesLanded?: () => unknown }).categoriesLanded?.();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function startCategoriesRead(): void {
|
|
387
|
+
if (siteCategories || categoriesInFlight) return;
|
|
388
|
+
if (categoriesFailedAt && Date.now() - categoriesFailedAt < CATEGORY_RETRY_MS) return;
|
|
389
|
+
categoriesInFlight = true;
|
|
390
|
+
queryServiceCategories().then((res) => {
|
|
391
|
+
const out: SiteCategory[] = [];
|
|
392
|
+
for (const category of res.categories ?? []) {
|
|
393
|
+
const name = categoryName(category);
|
|
394
|
+
const key = categoryKeyOf(category.id, name);
|
|
395
|
+
if (key && !out.some((c) => c.key === key)) out.push({ key, name });
|
|
396
|
+
}
|
|
397
|
+
siteCategories = out;
|
|
398
|
+
}, () => {
|
|
399
|
+
categoriesFailedAt = Date.now();
|
|
400
|
+
}).then(() => {
|
|
401
|
+
categoriesInFlight = false;
|
|
402
|
+
republish();
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
interface Section { id: string; title: string; rows: Row[] }
|
|
407
|
+
|
|
408
|
+
/** The rows grouped by their one category: the business's order of categories (the categories
|
|
409
|
+
* read, else each row's category sortOrder, else first seen), each group in the rows' own order,
|
|
410
|
+
* and the services with no category last. A category with no row has no group. */
|
|
411
|
+
function sectionsOf(rows: Row[]): Section[] {
|
|
412
|
+
const site = siteCategories ?? [];
|
|
413
|
+
const groups = new Map<string, Section & { rank: number; order: number; seen: number }>();
|
|
414
|
+
for (const row of rows) {
|
|
415
|
+
// A row that names a category the read also has joins it, whatever shape its reference had.
|
|
416
|
+
let index = row.categoryKey ? site.findIndex((c) => c.key === row.categoryKey) : -1;
|
|
417
|
+
if (index < 0 && row.category) index = site.findIndex((c) => c.name === row.category);
|
|
418
|
+
const key = index >= 0 ? site[index].key : row.categoryKey;
|
|
419
|
+
const id = sectionIdOf(key);
|
|
420
|
+
let group = groups.get(id);
|
|
421
|
+
if (!group) {
|
|
422
|
+
group = { id, title: index >= 0 ? site[index].name : row.category, rows: [], rank: index, order: row.categoryOrder, seen: groups.size };
|
|
423
|
+
groups.set(id, group);
|
|
424
|
+
}
|
|
425
|
+
group.rows.push(row);
|
|
426
|
+
}
|
|
427
|
+
const place = (g: { id: string; rank: number; order: number; seen: number }): number[] =>
|
|
428
|
+
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];
|
|
429
|
+
return [...groups.values()]
|
|
430
|
+
.sort((a, b) => { const pa = place(a), pb = place(b); return pa[0] - pb[0] || pa[1] - pb[1] || pa[2] - pb[2]; })
|
|
431
|
+
.map(({ id, title, rows: groupRows }) => ({
|
|
432
|
+
id, title: id === UNCATEGORIZED ? copyParam("services.otherCategory", "Other services") : title, rows: groupRows,
|
|
433
|
+
}));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const BLANK_ROW: Omit<Row, "id"> = {
|
|
437
|
+
name: "", subtitle: "", detailScreenId: "", initial: "", image: "", hasImage: false, noImage: true,
|
|
438
|
+
category: "", categoryKey: "", categoryOrder: -1, tagline: "", typeTag: "", noTypeTag: true, endedText: "", meta: "",
|
|
439
|
+
canBook: false, noBook: true, bookCta: "", bookA11y: "", a11y: "", locations: [],
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
// ---- Anchors -------------------------------------------------------------------------
|
|
443
|
+
// A chip tap scrolls the list to its category's title row (`scrollTo`, a new "|n" each tap, so a
|
|
444
|
+
// second tap on the same chip scrolls again) and lights that chip. The list reports the row at
|
|
445
|
+
// its top when the MEMBER scrolls (`topItemChanged`); its category's chip lights up.
|
|
446
|
+
let activeCategory = "";
|
|
447
|
+
/** The member has tapped a chip or scrolled the list: the lit chip is theirs. Until then it is the
|
|
448
|
+
* first category, whatever order the rows arrived in — the catalog's first rows can come before
|
|
449
|
+
* the business's category order, and the first chip of THAT list stayed lit over the real top. */
|
|
450
|
+
let activeChosen = false;
|
|
451
|
+
let scrollTarget = "";
|
|
452
|
+
let jumps = 0;
|
|
453
|
+
/** This worker run's own mark on its jump values. A renderer remembers the value it last jumped
|
|
454
|
+
* for, and a screen restored from an earlier visit can bring that memory back: a value that
|
|
455
|
+
* repeats across runs ("cat:x|1") would then be taken as done. Not in the state, so the boot
|
|
456
|
+
* state (which a release hashes) does not change with the clock. */
|
|
457
|
+
const RUN = Date.now().toString(36);
|
|
458
|
+
let quietUntil = 0;
|
|
459
|
+
/** How long a jump the worker asked for takes to settle: a top-row report inside it is the jump's
|
|
460
|
+
* own (the renderers suppress those, this is the belt to their braces). */
|
|
461
|
+
const JUMP_SETTLE_MS = 900;
|
|
462
|
+
/** The category this list was opened on (the `category` nav param: a category's Wix id, its
|
|
463
|
+
* `name:<name>` key, or its name), e.g. from a category tile. Its chip lights and the list jumps
|
|
464
|
+
* to its title once that group shows, and once more when this run's live page lands. After that
|
|
465
|
+
* — or as soon as the member scrolls or taps a chip — the member leads.
|
|
466
|
+
*
|
|
467
|
+
* Armed by the screen's own `load` (its onAppear), never by the boot request: the iOS and
|
|
468
|
+
* Android lists act on a jump that CHANGES while they are on screen (a chip tap), and drop one
|
|
469
|
+
* that is already in the state when they mount. The second jump is for a screen restored from
|
|
470
|
+
* its last visit: the host puts back that visit's scroll position after the first one. */
|
|
471
|
+
let pendingCategory = "";
|
|
472
|
+
let bootLoad = true;
|
|
473
|
+
/** This run's own live read has landed (a restored snapshot's rows do not count). */
|
|
474
|
+
let liveLanded = false;
|
|
475
|
+
let jumpedFromCache = false;
|
|
476
|
+
/** The `category` param the list last started at, and that visit's `categoryActivation` (a
|
|
477
|
+
* nonce a category tap sends, so a second tap on the SAME category is a new visit too). */
|
|
478
|
+
let openedFor = "";
|
|
479
|
+
let openedActivation = "";
|
|
480
|
+
function inputParam(key: "category" | "categoryActivation"): string {
|
|
481
|
+
const value = (globalThis as { himalaya?: { input?: Record<string, unknown> } }).himalaya?.input?.[key];
|
|
482
|
+
return typeof value === "string" ? value.trim().slice(0, 200) : "";
|
|
483
|
+
}
|
|
484
|
+
const inputCategory = (): string => inputParam("category");
|
|
485
|
+
/** Read on every `load`, not once at boot: a pooled worker is handed the next screen's input just
|
|
486
|
+
* before its `load` runs (the web host keeps one services worker for every push). The same param
|
|
487
|
+
* again is the screen coming back (a pop, a refresh), which keeps the member's place. */
|
|
488
|
+
function armCategory(): boolean {
|
|
489
|
+
const next = inputCategory();
|
|
490
|
+
const activation = inputParam("categoryActivation");
|
|
491
|
+
if (next === openedFor && activation === openedActivation) return false;
|
|
492
|
+
openedFor = next;
|
|
493
|
+
openedActivation = activation;
|
|
494
|
+
pendingCategory = next;
|
|
495
|
+
jumpedFromCache = false;
|
|
496
|
+
// A new visit is a new run for a pooled worker: the last visit's live page is not this one's,
|
|
497
|
+
// so the jump that waits for "this run's live read" waits for the read this load starts.
|
|
498
|
+
liveLanded = false;
|
|
499
|
+
// An open with no category ("See all") starts at the top: the last visit's lit chip and its
|
|
500
|
+
// jump do not carry over to it.
|
|
501
|
+
if (!next) { activeCategory = ""; activeChosen = false; scrollTarget = ""; }
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ---- Location ------------------------------------------------------------------------
|
|
506
|
+
/** The member's choice (shared with Agenda and Choose Time through the on-device store). */
|
|
507
|
+
let chosenLocation: SharedLocation = { id: "", label: "" };
|
|
508
|
+
/** Every location the whole catalog is offered at: a search narrows the rows, never the menu. */
|
|
509
|
+
let catalogLocations: LocationChoice[] = [];
|
|
510
|
+
let locationSheet = false;
|
|
511
|
+
|
|
512
|
+
/** The first runtime whose LazyList jumps to a row (`scrollToItemPath`) and reports the row at its
|
|
513
|
+
* top (`onTopItemChange`). An older native binary ignores both, so its chips would light without
|
|
514
|
+
* moving the list: they are hidden there, and the grouped list with its titles stays. Web is served
|
|
515
|
+
* with the release's own renderer, so it always has them. */
|
|
516
|
+
export const ANCHORS_MIN_BINARY = "1.8.0";
|
|
517
|
+
let anchorsOk: boolean | undefined;
|
|
518
|
+
async function anchorsSupported(): Promise<boolean> {
|
|
519
|
+
const info = await getAppInfo().catch(() => undefined);
|
|
520
|
+
if (!info || (info.platform !== "ios" && info.platform !== "android" && info.platform !== "macos")) return true;
|
|
521
|
+
return atLeast(info.binaryVersion, ANCHORS_MIN_BINARY);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function readLocation(): Promise<void> {
|
|
525
|
+
chosenLocation = await readSharedLocation();
|
|
526
|
+
anchorsOk ??= await anchorsSupported();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function argOf(p: unknown, key: string): string {
|
|
530
|
+
const a = p as Record<string, unknown> | undefined;
|
|
531
|
+
const v = a?.[key] ?? (a?.args as Record<string, unknown> | undefined)?.[key] ?? (a?.params as Record<string, unknown> | undefined)?.[key];
|
|
532
|
+
return typeof v === "string" ? v.trim() : "";
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Everything the list shows, rebuilt from the rows read and the module's choices. */
|
|
536
|
+
function display(s: S): S {
|
|
537
|
+
const rows = Array.isArray(s.results) ? s.results : [];
|
|
538
|
+
const loading = s.isLoading === true;
|
|
539
|
+
const errored = s.errored === true;
|
|
540
|
+
const choices = catalogLocations;
|
|
541
|
+
const locationKey = effectiveLocation(choices, chosenLocation.id);
|
|
542
|
+
const shown = locationKey ? rows.filter((row) => (row.locations ?? []).some((l) => l.key === locationKey)) : rows;
|
|
543
|
+
const sections = sectionsOf(shown);
|
|
544
|
+
// Titles once there is something to tell apart: never over a list with no category at all.
|
|
545
|
+
const titled = sections.length > 1 || (sections.length === 1 && sections[0].id !== UNCATEGORIZED);
|
|
546
|
+
const listRows: ListRow[] = sections.flatMap((section) => [
|
|
547
|
+
...(titled ? [{ ...BLANK_ROW, id: section.id, isSection: true, notSection: false, sectionTitle: section.title }] : []),
|
|
548
|
+
...section.rows.map((row) => ({ ...row, isSection: false, notSection: true, sectionTitle: "" })),
|
|
549
|
+
]);
|
|
550
|
+
const chips = titled && sections.length >= 2 ? sections.map((section) => ({ id: section.id, label: section.title })) : [];
|
|
551
|
+
const live = liveLanded && !loading;
|
|
552
|
+
if (pendingCategory && chips.length && (live || !jumpedFromCache)) {
|
|
553
|
+
const opened = chips.find((chip) => chip.id === sectionIdOf(pendingCategory) || chip.label === pendingCategory);
|
|
554
|
+
if (opened) {
|
|
555
|
+
activeCategory = opened.id;
|
|
556
|
+
activeChosen = true;
|
|
557
|
+
scrollTarget = `${opened.id}|${RUN}.${++jumps}`;
|
|
558
|
+
quietUntil = Date.now() + JUMP_SETTLE_MS;
|
|
559
|
+
if (live) pendingCategory = ""; else jumpedFromCache = true;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
// No chips (a reload in flight, a single group): the lit chip waits for the list to come back.
|
|
563
|
+
if (chips.length && (!activeChosen || !chips.some((chip) => chip.id === activeCategory))) activeCategory = chips[0]!.id;
|
|
564
|
+
const target = scrollTarget.split("|")[0];
|
|
565
|
+
if (scrollTarget && !listRows.some((row) => row.id === target)) scrollTarget = "";
|
|
566
|
+
// The same rule as the shared `collection.hideChrome`: no search or chips over a short,
|
|
567
|
+
// un-narrowed list, an empty source, or before the first rows arrive.
|
|
568
|
+
const narrowed = s.hasQuery === true;
|
|
569
|
+
const sourceEmpty = !loading && !errored && rows.length === 0 && !narrowed;
|
|
570
|
+
const hideChrome = (!narrowed && !s.nextCursor && rows.length < COLLECTION_CHROME_MIN_ITEMS) || sourceEmpty
|
|
571
|
+
|| ((loading || errored) && rows.length === 0 && !narrowed);
|
|
572
|
+
const locationEmpty = !loading && !errored && !!locationKey && rows.length > 0 && shown.length === 0;
|
|
573
|
+
const menu = locationMenu(choices, chosenLocation.id);
|
|
574
|
+
return {
|
|
575
|
+
...s,
|
|
576
|
+
listRows,
|
|
577
|
+
categoryChips: chips,
|
|
578
|
+
hideCategories: hideChrome || chips.length < 2 || anchorsOk === false,
|
|
579
|
+
activeCategory,
|
|
580
|
+
scrollTo: scrollTarget,
|
|
581
|
+
...menu,
|
|
582
|
+
locationSheetOpen: locationSheet && menu.hasLocationMenu,
|
|
583
|
+
locationEmpty,
|
|
584
|
+
notLocationEmpty: !locationEmpty,
|
|
585
|
+
locationEmptyText: locationEmpty ? copyTemplate("services.locationEmptyText", "There are no services at {location}.", { location: menu.locationLabel }) : "",
|
|
586
|
+
chosenLocationId: chosenLocation.id,
|
|
587
|
+
chosenLocationLabel: chosenLocation.label,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** The section a row of the list belongs to: a title row is its own; a service, its group's. */
|
|
592
|
+
function sectionOfRow(listRows: ListRow[], id: string): string {
|
|
593
|
+
let section = "";
|
|
594
|
+
for (const row of listRows) {
|
|
595
|
+
if (row.isSection) section = row.id;
|
|
596
|
+
if (row.id === id) return section;
|
|
597
|
+
}
|
|
598
|
+
return "";
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const PAGE = 100;
|
|
602
|
+
/** Grouping and anchors need the whole catalog: every page is read up front, up to this many
|
|
603
|
+
* (1,000 services). A bigger catalog keeps a cursor, and the list pages the rest as before. */
|
|
604
|
+
const MAX_PAGES = 10;
|
|
605
|
+
|
|
606
|
+
// A cold boot takes these from `state`, never from the last run: a jump the last run asked for
|
|
607
|
+
// would scroll the restored list on its first frame, and a sheet would reopen.
|
|
608
|
+
const registerServices = ((name: string, def: Parameters<typeof defineService>[1]) =>
|
|
609
|
+
defineService(name, { ...def, notHydrated: ["scrollTo", "locationSheetOpen"], perVisit: ["scrollTo", "locationSheetOpen"] } as never)) as typeof defineService;
|
|
610
|
+
|
|
611
|
+
const svc = defineBackendCollection<S, Row>("himiBookingServices", {
|
|
612
|
+
register: registerServices,
|
|
613
|
+
restorePagesOnLoad: true,
|
|
614
|
+
onLoad() {
|
|
615
|
+
let reset: { query: string } | undefined;
|
|
616
|
+
if (bootLoad) bootLoad = false;
|
|
617
|
+
// A new visit at a category drops a search the member left on this list: a saved "massage"
|
|
618
|
+
// would hide the category the tile opened, and its jump would never land.
|
|
619
|
+
else if (armCategory() && pendingCategory) reset = { query: "" };
|
|
620
|
+
// Copy is stashed before this hook. Repaint restored labels immediately, even
|
|
621
|
+
// when the background query holds the old page through a slow or failed read.
|
|
622
|
+
(svc as unknown as { normalizeRestored?: () => unknown }).normalizeRestored?.();
|
|
623
|
+
return reset;
|
|
624
|
+
},
|
|
625
|
+
state: { status: "loading", query: "", all: [], results: [], listRows: [], emptyText: "", countLabel: "", errored: false,
|
|
626
|
+
notErrored: true, isLoading: true, notLoading: false, hasResults: false, noEmpty: true,
|
|
627
|
+
categoryChips: [], hideCategories: true, activeCategory: "", scrollTo: "", hideRows: false, noMatchText: "", hasQuery: false, noQuery: true,
|
|
628
|
+
categoriesLabel: "", locationOptions: [], locationLabel: "", locationA11y: "", hasLocationMenu: false, noLocationMenu: true,
|
|
629
|
+
locationActive: false, noLocationActive: true, locationSheetOpen: false, locationEmpty: false, notLocationEmpty: true,
|
|
630
|
+
locationEmptyText: "", chosenLocationId: "", chosenLocationLabel: "" },
|
|
631
|
+
source: {
|
|
632
|
+
id: row => row.id,
|
|
633
|
+
errorMessage: () => copyParam("services.errorText", "Services could not be loaded."),
|
|
634
|
+
async cached(criteria) {
|
|
635
|
+
if (criteria.query || criteria.filter !== "all" || criteria.sort !== "default") return undefined;
|
|
636
|
+
await readLocation();
|
|
637
|
+
return { items: await readCachedRows(), nextCursor: "", metadata: { fromCache: true } };
|
|
638
|
+
},
|
|
639
|
+
async fetch(criteria, cursor) {
|
|
640
|
+
// There is no Saved chip any more: a "saved" filter persisted by the old chrome lists everything.
|
|
641
|
+
const filter: Record<string, unknown> = { hidden: { $ne: true } };
|
|
642
|
+
if (criteria.query) filter.name = { $startsWith: criteria.query };
|
|
643
|
+
if (criteria.filter.startsWith("value:")) filter["category.name"] = { $eq: criteria.filter.slice(6) };
|
|
644
|
+
// The business's own order — the order of the site's Book Online page.
|
|
645
|
+
const sort = criteria.sort === "default"
|
|
646
|
+
? [{ fieldName: "category.sortOrder", order: "ASC" as const }, { fieldName: "sortOrder", order: "ASC" as const }, { fieldName: "id", order: "ASC" as const }]
|
|
647
|
+
: [{ fieldName: "name", order: criteria.sort === "za" ? "DESC" as const : "ASC" as const }, { fieldName: "id", order: "ASC" as const }];
|
|
648
|
+
if (!cursor) {
|
|
649
|
+
startCategoriesRead();
|
|
650
|
+
// Read again on every load and appearance: Agenda or Choose Time may have changed it.
|
|
651
|
+
await readLocation();
|
|
652
|
+
// The menu offers the whole catalog's places, and a search or category read lists only its
|
|
653
|
+
// matches: a cold boot into a persisted search takes them from the device's copy of the list.
|
|
654
|
+
if (catalogLocations.length === 0 && (criteria.query || criteria.filter !== "all")) {
|
|
655
|
+
catalogLocations = distinctChoices((await readCachedRows()).flatMap((row) => row.locations));
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const items: Row[] = [];
|
|
659
|
+
let offset = Number(cursor || 0);
|
|
660
|
+
let next = "";
|
|
661
|
+
let total: number | undefined;
|
|
662
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
663
|
+
let res;
|
|
664
|
+
try {
|
|
665
|
+
res = await queryServices({ query: { filter, paging: { limit: PAGE, offset }, sort } });
|
|
666
|
+
} catch (error) {
|
|
667
|
+
// A later page failing keeps the pages already read: the list pages the rest (and says
|
|
668
|
+
// so if it fails again). The first page failing is the read failing.
|
|
669
|
+
if (page === 0) throw error;
|
|
670
|
+
next = String(offset);
|
|
671
|
+
break;
|
|
672
|
+
}
|
|
673
|
+
const raw = res.services ?? [];
|
|
674
|
+
items.push(...raw.filter(row => !!row.id).map((service, index) => ({ ...norm(service), position: offset + index })));
|
|
675
|
+
const paged = offsetPage(String(offset), raw.length, PAGE, res.pagingMetadata);
|
|
676
|
+
next = paged.nextCursor;
|
|
677
|
+
total = paged.total ?? total;
|
|
678
|
+
if (!next) break;
|
|
679
|
+
offset = Number(next);
|
|
680
|
+
}
|
|
681
|
+
// Only a read of the whole list in the business's order is saved: the offline copy is read
|
|
682
|
+
// back as that list, and a narrowed or re-sorted read's positions are not positions in it.
|
|
683
|
+
if (!criteria.query && !criteria.filter.startsWith("value:") && criteria.sort === "default") await writeCachedRows(items);
|
|
684
|
+
return { items, nextCursor: next, ...(total === undefined ? {} : { total }), metadata: { fromCache: false } };
|
|
685
|
+
},
|
|
686
|
+
},
|
|
687
|
+
actions: {
|
|
688
|
+
categoriesLanded: (s) => display(s),
|
|
689
|
+
// A snapshot saved by an older worker comes back without the new row fields; a missing
|
|
690
|
+
// `noBook` would show Book on a course until the live read. Give restored rows the defaults,
|
|
691
|
+
// and the restored list its groups and the location it was limited to.
|
|
692
|
+
normalizeRestored: (s) => {
|
|
693
|
+
const rows = (Array.isArray(s.results) ? s.results : []).map((row) => withRowDefaults(row as Partial<Row>));
|
|
694
|
+
if (typeof s.chosenLocationId === "string" && s.chosenLocationId) {
|
|
695
|
+
chosenLocation = { id: s.chosenLocationId, label: typeof s.chosenLocationLabel === "string" ? s.chosenLocationLabel : "" };
|
|
696
|
+
}
|
|
697
|
+
if (!s.query) catalogLocations = distinctChoices(rows.flatMap((row) => row.locations));
|
|
698
|
+
return display({ ...s, results: rows, all: rows });
|
|
699
|
+
},
|
|
700
|
+
// A chip: scroll to its category and light it. Re-tapping the lit chip deselects it on every
|
|
701
|
+
// renderer (`selectedIds: []`): that is the same chip again, so it scrolls again.
|
|
702
|
+
scrollToCategory: (s, p) => {
|
|
703
|
+
const ids = (p as { selectedIds?: unknown } | undefined)?.selectedIds
|
|
704
|
+
?? ((p as { args?: { selectedIds?: unknown } } | undefined)?.args?.selectedIds);
|
|
705
|
+
const picked = Array.isArray(ids) && typeof ids[0] === "string" ? ids[0] : activeCategory;
|
|
706
|
+
if (!(s.categoryChips ?? []).some((chip) => chip.id === picked)) return s;
|
|
707
|
+
pendingCategory = "";
|
|
708
|
+
activeCategory = picked;
|
|
709
|
+
activeChosen = true;
|
|
710
|
+
scrollTarget = `${picked}|${RUN}.${++jumps}`;
|
|
711
|
+
quietUntil = Date.now() + JUMP_SETTLE_MS;
|
|
712
|
+
return display(s);
|
|
713
|
+
},
|
|
714
|
+
// The row at the top of the list after the member scrolled: its category's chip lights up.
|
|
715
|
+
topItemChanged: (s, p) => {
|
|
716
|
+
if (Date.now() < quietUntil) return s;
|
|
717
|
+
const rows = Array.isArray(s.listRows) ? s.listRows : [];
|
|
718
|
+
// A search miss or an empty location has no category to follow.
|
|
719
|
+
if (!rows.some((row) => !row.isSection)) return s;
|
|
720
|
+
const section = sectionOfRow(rows, argOf(p, "id"));
|
|
721
|
+
if (!section || !(s.categoryChips ?? []).some((chip) => chip.id === section)) return s;
|
|
722
|
+
pendingCategory = "";
|
|
723
|
+
activeCategory = section;
|
|
724
|
+
activeChosen = true;
|
|
725
|
+
// The member has moved on from the last jump: a re-render never scrolls back to it.
|
|
726
|
+
scrollTarget = "";
|
|
727
|
+
return display(s);
|
|
728
|
+
},
|
|
729
|
+
openLocations: (s) => {
|
|
730
|
+
locationSheet = true;
|
|
731
|
+
return display(s);
|
|
732
|
+
},
|
|
733
|
+
closeLocations: (s) => {
|
|
734
|
+
if (!locationSheet) return s;
|
|
735
|
+
locationSheet = false;
|
|
736
|
+
return display(s);
|
|
737
|
+
},
|
|
738
|
+
// A row of the location sheet: "" is every location. Agenda and Choose Time read it too.
|
|
739
|
+
selectLocation: async (s, p) => {
|
|
740
|
+
const id = argOf(p, "locationId");
|
|
741
|
+
const choice = catalogLocations.find((c) => c.key === id);
|
|
742
|
+
chosenLocation = id && choice ? { id, label: choice.label } : { id: "", label: "" };
|
|
743
|
+
locationSheet = false;
|
|
744
|
+
scrollTarget = "";
|
|
745
|
+
// Not awaited (it never throws): the state returned is this dispatch's own, so no later commit
|
|
746
|
+
// — a revalidation landing during a native round trip — is overwritten by it.
|
|
747
|
+
void writeSharedLocation(chosenLocation);
|
|
748
|
+
return display(s);
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
apply(s, page) {
|
|
752
|
+
const loading = page.status === "loading";
|
|
753
|
+
const errored = page.status === "error";
|
|
754
|
+
// A live row is built before a cold boot's copy pack arrives: the first live page keeps the
|
|
755
|
+
// Ended label the restored row for the same course showed, until a current pack replaces it.
|
|
756
|
+
const shown = new Map((restoredEndedBridge && Array.isArray(s.results) ? s.results : []).map((row) => [row.id, row.endedText]));
|
|
757
|
+
const rows = page.items.map((item) => withRowDefaults(item, shown.get(item.id)));
|
|
758
|
+
if (page.metadata?.fromCache === false && !loading && !errored) { restoredEndedBridge = false; liveLanded = true; }
|
|
759
|
+
const filtered = page.criteria.filter.startsWith("value:");
|
|
760
|
+
const hasQuery = !!page.criteria.query.trim();
|
|
761
|
+
// The menu offers every location of the whole catalog: a settled live read replaces it, the
|
|
762
|
+
// device's copy only fills a gap (a superseded response never reaches apply).
|
|
763
|
+
if (!hasQuery && !filtered && rows.length > 0) {
|
|
764
|
+
const live = page.metadata?.fromCache !== true;
|
|
765
|
+
if ((live && !loading) || catalogLocations.length === 0) catalogLocations = distinctChoices(rows.flatMap((row) => row.locations));
|
|
766
|
+
}
|
|
767
|
+
return display({ ...s, status: loading ? "loading" : "ready", query: page.criteria.query, all: rows, results: rows,
|
|
768
|
+
hideRows: loading && rows.length === 0,
|
|
769
|
+
noMatchText: copyParam("services.noMatchText", "No services match your search."),
|
|
770
|
+
hasQuery, noQuery: !hasQuery, categoriesLabel: copyParam("services.categoriesLabel", "Service categories"),
|
|
771
|
+
nextCursor: page.nextCursor, complete: page.complete, total: page.total, loadingMore: page.loadingMore, pageError: page.pageError,
|
|
772
|
+
fromCache: page.metadata?.fromCache === true, errored, notErrored: !errored, isLoading: loading, notLoading: !loading,
|
|
773
|
+
hasResults: page.items.length > 0, noEmpty: true, countLabel: "",
|
|
774
|
+
// Only when there is nothing to show: Branded Web's book pane prints this line as it is.
|
|
775
|
+
emptyText: errored ? copyParam("services.errorText", "Services could not be loaded.")
|
|
776
|
+
: loading || rows.length > 0 ? ""
|
|
777
|
+
: hasQuery ? copyParam("services.noMatchText", "No services match your search.")
|
|
778
|
+
: copyParam("services.emptyText", "There are no services to book yet.") });
|
|
779
|
+
},
|
|
780
|
+
});
|
|
781
|
+
// The old chrome could persist a "saved" filter (there is no Saved chip now), a category filter
|
|
782
|
+
// (the chips are anchors now, the list always shows every category) or an A-Z sort (there is no
|
|
783
|
+
// Refine now): all go back to the business's own whole view before the first read.
|
|
784
|
+
type Retired = { getState?: () => { collection?: { filter?: unknown; sort?: unknown } };
|
|
785
|
+
collectionFilter?: (arg: unknown) => unknown; collectionSort?: (arg: unknown) => unknown };
|
|
786
|
+
const retired = svc as unknown as Retired;
|
|
787
|
+
const persisted = retired.getState?.()?.collection;
|
|
788
|
+
if (typeof persisted?.filter === "string" && persisted.filter !== "all") void retired.collectionFilter?.({ selectedIds: ["all"] });
|
|
789
|
+
if (typeof persisted?.sort === "string" && persisted.sort !== "default") void retired.collectionSort?.({ selectedId: "default" });
|
|
790
|
+
svc.load();
|
|
791
|
+
export { svc };
|