@wix/himalaya-cli 0.1536.0 → 0.1537.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/stdlib/wix-sdk/bookings-v2/src/extensions.d.ts +16 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/data-collections-v1/src/extensions.d.ts +4 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/wix-data/src/index.d.ts +497 -4
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-bookings.mjs +5 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-data-collections.mjs +1 -5
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-data.mjs +315 -7
- package/dist/cli.mjs +805 -534
- package/dist/preview-assets.generated.bin +0 -0
- package/fixtures/wix-canned.json +1 -0
- package/guides/cms-collections.md +31 -0
- package/guides/himi-json-output.md +9 -0
- package/guides/release-notes.md +12 -0
- package/guides/wix-api-usage.md +53 -6
- package/package.json +2 -2
- package/templates/beauty-salon/dev/index.ts +1109 -2310
- package/templates/beauty-salon/tier5-src/_shared/himi-bookings.ts +121 -1
- package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +9 -55
- package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +264 -18
- package/templates/branded-lite/dev/index.ts +1075 -2276
- package/templates/branded-lite/tier5-src/_shared/himi-bookings.ts +121 -1
- package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +9 -55
- package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +264 -18
- package/templates/gym-fitness/dev/index.ts +1075 -2276
- package/templates/gym-fitness/tier5-src/_shared/himi-bookings.ts +121 -1
- package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +9 -55
- package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +264 -18
- package/templates/yoga-studio/dev/index.ts +1137 -2338
- package/templates/yoga-studio/tier5-src/_shared/himi-bookings.ts +121 -1
- package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +9 -55
- package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +264 -18
|
@@ -17,7 +17,8 @@ export function categoryName(value: unknown): string {
|
|
|
17
17
|
return typeof name === "string" ? name.trim() : "";
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
import { zoneOffsetAt } from "./engine-intl.js";
|
|
20
|
+
import { formatDateMedium, formatMonthDay, zoneOffsetAt } from "./engine-intl.js";
|
|
21
|
+
import { formatServiceMoney, formatServicePrice, serviceCurrency } from "./wix-service-price.js";
|
|
21
22
|
|
|
22
23
|
const HAS_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/i;
|
|
23
24
|
const LOCAL_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/;
|
|
@@ -134,3 +135,122 @@ export function courseSessionsAvailability(entries: readonly CourseEntryShape[],
|
|
|
134
135
|
const spots = counts.length ? Math.min(...counts) : undefined;
|
|
135
136
|
return { found: true, spots, bookable: readComplete && upcoming.every((entry) => entry.bookable !== false) && spots !== 0 };
|
|
136
137
|
}
|
|
138
|
+
|
|
139
|
+
// ---- Price, duration and course dates ------------------------------------------------
|
|
140
|
+
// One answer for the services list and the service screen, so a row and the screen it opens
|
|
141
|
+
// say the same price and the same length. Each screen reads its own copy keys: the caller
|
|
142
|
+
// passes a `BookingLabel` reader bound to its prefix ("service." on the service screen,
|
|
143
|
+
// "services." on the list).
|
|
144
|
+
|
|
145
|
+
/** Reads one copy line by its key suffix ("priceFree", "days"). `values` fills {placeholders}. */
|
|
146
|
+
export type BookingLabel = (key: string, fallback: string, values?: Record<string, string | number>) => string;
|
|
147
|
+
|
|
148
|
+
type PricedServiceShape = {
|
|
149
|
+
type?: string;
|
|
150
|
+
duration?: unknown;
|
|
151
|
+
payment?: {
|
|
152
|
+
rateType?: unknown;
|
|
153
|
+
fixed?: { price?: unknown };
|
|
154
|
+
varied?: { minPrice?: unknown; defaultPrice?: unknown };
|
|
155
|
+
custom?: { description?: unknown };
|
|
156
|
+
};
|
|
157
|
+
schedule?: {
|
|
158
|
+
firstSessionStart?: string;
|
|
159
|
+
lastSessionEnd?: string;
|
|
160
|
+
availabilityConstraints?: {
|
|
161
|
+
sessionDurations?: unknown[];
|
|
162
|
+
durations?: Array<{ minutes?: unknown } | undefined>;
|
|
163
|
+
durationRange?: {
|
|
164
|
+
hourOptions?: { minDurationInMinutes?: unknown; maxDurationInMinutes?: unknown };
|
|
165
|
+
dayOptions?: { minDurationInDays?: unknown; maxDurationInDays?: unknown };
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
function trimmed(value: unknown): string {
|
|
172
|
+
return typeof value === "string" ? value.trim() : "";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
176
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
|
|
177
|
+
if (typeof value === "string" && value.trim()) {
|
|
178
|
+
const n = Number(value);
|
|
179
|
+
return Number.isNaN(n) ? undefined : n;
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface ServicePriceLine { text: string; unit: string; isText: boolean }
|
|
185
|
+
|
|
186
|
+
/** The price line by rate type. FIXED → the price, no cents on a whole amount; VARIED →
|
|
187
|
+
* "From {price}"; CUSTOM → the owner's description, no unit; NO_FEE → "Free", no unit. */
|
|
188
|
+
export function servicePriceLine(service: PricedServiceShape, label: BookingLabel): ServicePriceLine {
|
|
189
|
+
const payment = service.payment ?? {};
|
|
190
|
+
const rate = trimmed(payment.rateType).toUpperCase();
|
|
191
|
+
const currency = serviceCurrency(service);
|
|
192
|
+
const unit = service.type === "COURSE"
|
|
193
|
+
? label("unitParticipant", "per participant")
|
|
194
|
+
: label("unitSession", "per session");
|
|
195
|
+
if (rate === "NO_FEE") return { text: label("priceFree", "Free"), unit: "", isText: false };
|
|
196
|
+
if (rate === "CUSTOM") return { text: trimmed(payment.custom?.description), unit: "", isText: true };
|
|
197
|
+
if (rate === "VARIED") {
|
|
198
|
+
const from = formatServiceMoney(payment.varied?.minPrice, currency) || formatServiceMoney(payment.varied?.defaultPrice, currency);
|
|
199
|
+
return { text: from ? label("priceFrom", "From {price}", { price: from }) : "", unit: from ? unit : "", isText: false };
|
|
200
|
+
}
|
|
201
|
+
// FIXED, or a legacy payload with no rate type.
|
|
202
|
+
const text = formatServiceMoney(payment.fixed?.price, currency) || formatServicePrice(service);
|
|
203
|
+
return { text, unit: text ? unit : "", isText: false };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function minutesText(minutes: number): string {
|
|
207
|
+
if (minutes < 60) return `${minutes} min`;
|
|
208
|
+
const hours = Math.floor(minutes / 60);
|
|
209
|
+
const rest = minutes % 60;
|
|
210
|
+
return rest ? `${hours} hr ${rest} min` : `${hours} hr`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** "45 min", "1 hr 30 min"; a service with several lengths, or a flexible range the
|
|
214
|
+
* customer picks from: "30 min - 1 hr", "1 day - 3 days". "" when the record has none. */
|
|
215
|
+
export function serviceDurationText(service: PricedServiceShape, label: BookingLabel): string {
|
|
216
|
+
const constraints = service.schedule?.availabilityConstraints;
|
|
217
|
+
const positive = (values: unknown[]) => values.map(finiteNumber).filter((n): n is number => n != null && n > 0);
|
|
218
|
+
const listed = positive([
|
|
219
|
+
...(constraints?.sessionDurations ?? []),
|
|
220
|
+
...(constraints?.durations ?? []).map((d) => d?.minutes),
|
|
221
|
+
]);
|
|
222
|
+
const hours = constraints?.durationRange?.hourOptions;
|
|
223
|
+
const range = positive([hours?.minDurationInMinutes, hours?.maxDurationInMinutes]);
|
|
224
|
+
const days = constraints?.durationRange?.dayOptions;
|
|
225
|
+
const dayRange = positive([days?.minDurationInDays, days?.maxDurationInDays]);
|
|
226
|
+
if (!listed.length && !range.length && dayRange.length) {
|
|
227
|
+
const dayText = (n: number) => (n === 1 ? label("day", "1 day") : label("days", "{n} days", { n }));
|
|
228
|
+
const first = Math.min(...dayRange);
|
|
229
|
+
const last = Math.max(...dayRange);
|
|
230
|
+
return first === last ? dayText(first) : `${dayText(first)} - ${dayText(last)}`;
|
|
231
|
+
}
|
|
232
|
+
const minutes = listed.length ? listed : range.length ? range : positive([service.duration]);
|
|
233
|
+
if (!minutes.length) return "";
|
|
234
|
+
const min = Math.min(...minutes);
|
|
235
|
+
const max = Math.max(...minutes);
|
|
236
|
+
return min === max ? minutesText(min) : `${minutesText(min)} - ${minutesText(max)}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function validDate(value: string | undefined): Date | undefined {
|
|
240
|
+
if (!value) return undefined;
|
|
241
|
+
const date = new Date(value);
|
|
242
|
+
return Number.isFinite(date.getTime()) ? date : undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function shortDate(date: Date, year: boolean): string {
|
|
246
|
+
return year ? formatDateMedium(date, "en") : formatMonthDay(date, "en");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** A course's dates, "Nov 3 – Dec 15, 2026" (the year on the first date only when the two
|
|
250
|
+
* differ). "" when the record has no first or last session. */
|
|
251
|
+
export function courseDatesText(service: PricedServiceShape): string {
|
|
252
|
+
const first = validDate(service.schedule?.firstSessionStart);
|
|
253
|
+
const last = validDate(service.schedule?.lastSessionEnd);
|
|
254
|
+
if (!first || !last) return "";
|
|
255
|
+
return `${shortDate(first, first.getFullYear() !== last.getFullYear())} – ${shortDate(last, true)}`;
|
|
256
|
+
}
|
|
@@ -3,8 +3,8 @@ import { defineService, navigate } from "@himalaya/state";
|
|
|
3
3
|
import { share as shareSheet } from "@himalaya/share";
|
|
4
4
|
import { open, defaultMode } from "@himalaya/open-url";
|
|
5
5
|
import { getService, queryServiceAvailability, type WixAvailabilityEntry, type WixService, type WixServiceAddress, type WixServiceBookingPolicy } from "@himalaya/wix-bookings";
|
|
6
|
-
import { categoryName, courseReadWindows, courseSessionsAvailability, courseWindowOpen, slotStartInstant } from "../_shared/himi-bookings.js";
|
|
7
|
-
import {
|
|
6
|
+
import { categoryName, courseDatesText, courseReadWindows, courseSessionsAvailability, courseWindowOpen, serviceDurationText, servicePriceLine, slotStartInstant, type BookingLabel, type ServicePriceLine } from "../_shared/himi-bookings.js";
|
|
7
|
+
import { formatServiceMoney, serviceCurrency } from "../_shared/wix-service-price.js";
|
|
8
8
|
import { stashCopy, copyParam, copyTemplate, copyPlural } from "../_shared/himi-copy.js";
|
|
9
9
|
import { get, type DbEntity } from "@himalaya/local-db";
|
|
10
10
|
import { wixMediaUrl, WIX_DETAIL_IMAGE, WIX_LIST_IMAGE } from "@himalaya/wix-media";
|
|
@@ -169,26 +169,12 @@ function numberOf(value: unknown): number | undefined {
|
|
|
169
169
|
|
|
170
170
|
// ---- Price -----------------------------------------------------------------------
|
|
171
171
|
|
|
172
|
-
|
|
172
|
+
/** The service screen's copy keys for the shared price, duration and course-date lines. */
|
|
173
|
+
const serviceLabel: BookingLabel = (key, fallback, values) =>
|
|
174
|
+
values ? copyTemplate(`service.${key}`, fallback, values) : copyParam(`service.${key}`, fallback);
|
|
173
175
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
function priceLine(service: WixService): PriceLine {
|
|
177
|
-
const payment = service.payment ?? {};
|
|
178
|
-
const rate = stringOf(payment.rateType).toUpperCase();
|
|
179
|
-
const currency = serviceCurrency(service);
|
|
180
|
-
const unit = service.type === "COURSE"
|
|
181
|
-
? copyParam("service.unitParticipant", "per participant")
|
|
182
|
-
: copyParam("service.unitSession", "per session");
|
|
183
|
-
if (rate === "NO_FEE") return { text: copyParam("service.priceFree", "Free"), unit: "", isText: false };
|
|
184
|
-
if (rate === "CUSTOM") return { text: stringOf(payment.custom?.description), unit: "", isText: true };
|
|
185
|
-
if (rate === "VARIED") {
|
|
186
|
-
const from = formatServiceMoney(payment.varied?.minPrice, currency) || formatServiceMoney(payment.varied?.defaultPrice, currency);
|
|
187
|
-
return { text: from ? copyTemplate("service.priceFrom", "From {price}", { price: from }) : "", unit: from ? unit : "", isText: false };
|
|
188
|
-
}
|
|
189
|
-
// FIXED, or a legacy payload with no rate type.
|
|
190
|
-
const text = formatServiceMoney(payment.fixed?.price, currency) || formatServicePrice(service);
|
|
191
|
-
return { text, unit: text ? unit : "", isText: false };
|
|
176
|
+
function priceLine(service: WixService): ServicePriceLine {
|
|
177
|
+
return servicePriceLine(service, serviceLabel);
|
|
192
178
|
}
|
|
193
179
|
|
|
194
180
|
// ---- Media -----------------------------------------------------------------------
|
|
@@ -233,37 +219,8 @@ function legacyImageUrl(value: unknown): string {
|
|
|
233
219
|
|
|
234
220
|
// ---- Duration --------------------------------------------------------------------
|
|
235
221
|
|
|
236
|
-
function minutesText(minutes: number): string {
|
|
237
|
-
if (minutes < 60) return `${minutes} min`;
|
|
238
|
-
const hours = Math.floor(minutes / 60);
|
|
239
|
-
const rest = minutes % 60;
|
|
240
|
-
return rest ? `${hours} hr ${rest} min` : `${hours} hr`;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** "45 min", "1 hr 30 min"; a service with several lengths, or a flexible range the
|
|
244
|
-
* customer picks from: "30 min - 1 hr", "1 day - 3 days". */
|
|
245
222
|
function durationText(service: WixService): string {
|
|
246
|
-
|
|
247
|
-
const positive = (values: unknown[]) => values.map(numberOf).filter((n): n is number => n != null && n > 0);
|
|
248
|
-
const listed = positive([
|
|
249
|
-
...(constraints?.sessionDurations ?? []),
|
|
250
|
-
...(constraints?.durations ?? []).map((d) => d?.minutes),
|
|
251
|
-
]);
|
|
252
|
-
const hours = constraints?.durationRange?.hourOptions;
|
|
253
|
-
const range = positive([hours?.minDurationInMinutes, hours?.maxDurationInMinutes]);
|
|
254
|
-
const days = constraints?.durationRange?.dayOptions;
|
|
255
|
-
const dayRange = positive([days?.minDurationInDays, days?.maxDurationInDays]);
|
|
256
|
-
if (!listed.length && !range.length && dayRange.length) {
|
|
257
|
-
const dayText = (n: number) => (n === 1 ? copyParam("service.day", "1 day") : copyTemplate("service.days", "{n} days", { n }));
|
|
258
|
-
const first = Math.min(...dayRange);
|
|
259
|
-
const last = Math.max(...dayRange);
|
|
260
|
-
return first === last ? dayText(first) : `${dayText(first)} - ${dayText(last)}`;
|
|
261
|
-
}
|
|
262
|
-
const minutes = listed.length ? listed : range.length ? range : positive([service.duration]);
|
|
263
|
-
if (!minutes.length) return "";
|
|
264
|
-
const min = Math.min(...minutes);
|
|
265
|
-
const max = Math.max(...minutes);
|
|
266
|
-
return min === max ? minutesText(min) : `${minutesText(min)} - ${minutesText(max)}`;
|
|
223
|
+
return serviceDurationText(service, serviceLabel);
|
|
267
224
|
}
|
|
268
225
|
|
|
269
226
|
// ---- Locations -------------------------------------------------------------------
|
|
@@ -497,10 +454,7 @@ function shortDate(date: Date, year: boolean): string {
|
|
|
497
454
|
}
|
|
498
455
|
|
|
499
456
|
function courseDates(service: WixService): string {
|
|
500
|
-
|
|
501
|
-
const last = validDate(service.schedule?.lastSessionEnd);
|
|
502
|
-
if (!first || !last) return "";
|
|
503
|
-
return `${shortDate(first, first.getFullYear() !== last.getFullYear())} – ${shortDate(last, true)}`;
|
|
457
|
+
return courseDatesText(service);
|
|
504
458
|
}
|
|
505
459
|
|
|
506
460
|
function spotCount(entry: WixAvailabilityEntry | undefined): number | undefined {
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
// Tier-5 bundle: himi-booking — customer-facing services catalog.
|
|
2
2
|
//
|
|
3
|
-
// Visitors
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Visitors browse the site's Wix Bookings services in the business's own order (the
|
|
4
|
+
// order of the site's Book Online page), narrowed by category chips and a name search.
|
|
5
|
+
// Each row carries what the service record has: photo, name, tagline, a Class or
|
|
6
|
+
// Course tag, and duration and price in the same words as the service screen. "Book"
|
|
7
|
+
// shows only where online booking by time is possible. The worker exposes explicit
|
|
8
|
+
// flags so the descriptor renders loading, empty, error and loaded states without raw
|
|
6
9
|
// JavaScript conditions in the screen tree.
|
|
7
10
|
import { defineBackendCollection } from "../_shared/collection-service.js";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
11
|
+
import { COLLECTION_CHROME_MIN_ITEMS } from "../_shared/collection-contract.js";
|
|
12
|
+
import { queryServiceCategories, queryServices, type WixService } from "@himalaya/wix-bookings";
|
|
13
|
+
import { categoryName, courseDatesText, serviceDurationText, servicePriceLine, type BookingLabel } from "../_shared/himi-bookings.js";
|
|
10
14
|
import { formatServicePrice } from "../_shared/wix-service-price.js";
|
|
11
15
|
import { getAll, upsert, type DbEntity } from "@himalaya/local-db";
|
|
12
16
|
import { initials } from "../_shared/himi-text.js";
|
|
13
|
-
import {
|
|
17
|
+
import { copyParam, copyTemplate } from "../_shared/himi-copy.js";
|
|
14
18
|
import { offsetPage } from "../_shared/offset-page.js";
|
|
15
19
|
import { wixMediaUrl } from "@himalaya/wix-media";
|
|
16
20
|
|
|
@@ -24,7 +28,15 @@ import { wixMediaUrl } from "@himalaya/wix-media";
|
|
|
24
28
|
// safe across every consuming app.
|
|
25
29
|
const ENTITY = "services";
|
|
26
30
|
async function readCachedRows(): Promise<Row[]> {
|
|
27
|
-
try {
|
|
31
|
+
try {
|
|
32
|
+
const rows = (await getAll(ENTITY)) as unknown as Partial<Row>[];
|
|
33
|
+
// The store keeps its own insertion order; the business's order is each row's cached position
|
|
34
|
+
// (rows saved before positions existed keep their store order, after the positioned ones).
|
|
35
|
+
const at = (row: Partial<Row>) => (typeof row.position === "number" ? row.position : Number.MAX_SAFE_INTEGER);
|
|
36
|
+
return rows.map((row, index) => ({ row, index }))
|
|
37
|
+
.sort((a, b) => at(a.row) - at(b.row) || a.index - b.index)
|
|
38
|
+
.map(({ row }) => withRowDefaults(row));
|
|
39
|
+
} catch { return []; }
|
|
28
40
|
}
|
|
29
41
|
async function writeCachedRows(rows: Row[]): Promise<void> {
|
|
30
42
|
try { for (const r of rows) await upsert(ENTITY, r as unknown as DbEntity); } catch { /* capability not granted → online-only */ }
|
|
@@ -40,8 +52,29 @@ interface Row {
|
|
|
40
52
|
hasImage: boolean;
|
|
41
53
|
noImage: boolean;
|
|
42
54
|
category: string;
|
|
55
|
+
/** The category's place in the business's order; -1 when the record has none. */
|
|
56
|
+
categoryOrder: number;
|
|
57
|
+
/** The row's place in the business's order when it was read (the offline copy is sorted by it). */
|
|
58
|
+
position?: number;
|
|
59
|
+
tagline: string;
|
|
60
|
+
/** "Class" or "Course"; "" for an appointment. */
|
|
61
|
+
typeTag: string;
|
|
62
|
+
noTypeTag: boolean;
|
|
63
|
+
/** "1 hr · ₪70"; a course: "Nov 3 – Dec 15, 2026 · ₪1,200". "" when the record has neither. */
|
|
64
|
+
meta: string;
|
|
65
|
+
/** Online booking by time is possible: online booking is on and the service is not a course. */
|
|
66
|
+
canBook: boolean;
|
|
67
|
+
noBook: boolean;
|
|
68
|
+
/** The pill's word: "Book", or "Request" when the business approves each booking by hand
|
|
69
|
+
* (the service screen's "Request to Book"). Both open the time screen. */
|
|
70
|
+
bookCta: string;
|
|
71
|
+
bookA11y: string;
|
|
72
|
+
/** The row's spoken name: name, type tag, tagline and meta. */
|
|
73
|
+
a11y: string;
|
|
43
74
|
}
|
|
44
75
|
|
|
76
|
+
interface CategoryChip { id: string; label: string }
|
|
77
|
+
|
|
45
78
|
interface S {
|
|
46
79
|
status: "loading" | "ready";
|
|
47
80
|
query: string;
|
|
@@ -61,6 +94,17 @@ interface S {
|
|
|
61
94
|
notLoading: boolean;
|
|
62
95
|
hasResults: boolean;
|
|
63
96
|
noEmpty: boolean;
|
|
97
|
+
categoryChips: CategoryChip[];
|
|
98
|
+
hideCategories: boolean;
|
|
99
|
+
/** Loading with no rows to show: the skeleton stands in for the list. */
|
|
100
|
+
hideRows: boolean;
|
|
101
|
+
/** The no-match line: a search miss, or a chosen category with nothing in it. */
|
|
102
|
+
noMatchText: string;
|
|
103
|
+
/** Which way out the no-match block offers: Clear Search (a query), else Show All. */
|
|
104
|
+
hasQuery: boolean;
|
|
105
|
+
noQuery: boolean;
|
|
106
|
+
/** The chips' spoken name, from the copy pack (a descriptor accessibilityLabel is not rewritten). */
|
|
107
|
+
categoriesLabel: string;
|
|
64
108
|
}
|
|
65
109
|
|
|
66
110
|
function strOf(arg: unknown): string {
|
|
@@ -69,13 +113,19 @@ function strOf(arg: unknown): string {
|
|
|
69
113
|
return a?.value ?? a?.text ?? "";
|
|
70
114
|
}
|
|
71
115
|
|
|
116
|
+
/** The list's copy keys for the shared price, duration and course-date lines. */
|
|
117
|
+
const listLabel: BookingLabel = (key, fallback, values) =>
|
|
118
|
+
values ? copyTemplate(`services.${key}`, fallback, values) : copyParam(`services.${key}`, fallback);
|
|
119
|
+
|
|
120
|
+
/** The old subtitle's price: Branded Web's book screen still shows it. */
|
|
72
121
|
function money(service: WixService): string {
|
|
73
122
|
// Delegates to the shared formatter so the Bookings v2 `payment.fixed.price`
|
|
74
123
|
// shape + the record's real currency (₪, not a hardcoded $) are honored (#816).
|
|
75
124
|
return formatServicePrice(service);
|
|
76
125
|
}
|
|
77
126
|
|
|
78
|
-
|
|
127
|
+
/** The old subtitle's duration (whole service length only): Branded Web still shows it. */
|
|
128
|
+
function subtitleDuration(service: WixService): string {
|
|
79
129
|
const minutes = Number((service as { duration?: number }).duration ?? 0);
|
|
80
130
|
if (!minutes) return "";
|
|
81
131
|
if (minutes < 60) return `${minutes} min`;
|
|
@@ -125,6 +175,31 @@ function imageUrl(service: WixService): string {
|
|
|
125
175
|
return mediaUrl(service);
|
|
126
176
|
}
|
|
127
177
|
|
|
178
|
+
function categoryOrder(value: unknown): number {
|
|
179
|
+
const order = value && typeof value === "object" ? (value as { sortOrder?: unknown }).sortOrder : undefined;
|
|
180
|
+
return typeof order === "number" && Number.isFinite(order) ? order : -1;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function typeTagOf(type: unknown): string {
|
|
184
|
+
if (type === "CLASS") return copyParam("services.tagClass", "Class");
|
|
185
|
+
if (type === "COURSE") return copyParam("services.tagCourse", "Course");
|
|
186
|
+
return "";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function spokenName(row: Pick<Row, "name" | "typeTag" | "tagline" | "meta">): string {
|
|
190
|
+
return [row.name, row.typeTag, row.tagline, row.meta].filter(Boolean).join(", ");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function bookCtaFor(request: boolean): string {
|
|
194
|
+
return request ? copyParam("services.requestCta", "Request") : copyParam("services.bookCta", "Book");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function bookLabel(name: string, request = false): string {
|
|
198
|
+
return request
|
|
199
|
+
? copyTemplate("services.requestLabel", "Request to book {name}", { name })
|
|
200
|
+
: copyTemplate("services.bookLabel", "Book {name}", { name });
|
|
201
|
+
}
|
|
202
|
+
|
|
128
203
|
function norm(service: WixService): Row {
|
|
129
204
|
const id = service.id || "service";
|
|
130
205
|
const name = service.name || "Service";
|
|
@@ -132,14 +207,22 @@ function norm(service: WixService): Row {
|
|
|
132
207
|
// Live payloads carry `category` as a structured object — extract the name
|
|
133
208
|
// (raw join rendered "[object Object] · 1 hr · $111", production sweep R3).
|
|
134
209
|
categoryName(service.category),
|
|
135
|
-
|
|
210
|
+
subtitleDuration(service),
|
|
136
211
|
money(service),
|
|
137
212
|
].filter(Boolean);
|
|
138
213
|
const image = imageUrl(service);
|
|
214
|
+
const isCourse = service.type === "COURSE";
|
|
215
|
+
const price = servicePriceLine(service, listLabel).text;
|
|
216
|
+
const meta = [isCourse ? courseDatesText(service) : serviceDurationText(service, listLabel), price].filter(Boolean).join(" · ");
|
|
217
|
+
const typeTag = typeTagOf(service.type);
|
|
218
|
+
const tagline = typeof service.tagLine === "string" ? service.tagLine.trim() : "";
|
|
219
|
+
const canBook = service.onlineBooking?.enabled !== false && !isCourse;
|
|
220
|
+
const request = service.onlineBooking?.requireManualApproval === true;
|
|
139
221
|
return {
|
|
140
222
|
id,
|
|
141
223
|
name,
|
|
142
224
|
category: categoryName(service.category),
|
|
225
|
+
categoryOrder: categoryOrder(service.category),
|
|
143
226
|
subtitle: bits.length ? bits.join(" · ") : "Choose a time",
|
|
144
227
|
// WS-C — canonical param-less detail screenId; the descriptor row binds the
|
|
145
228
|
// separate `id` field as a first-class nav param (no more baked suffixes).
|
|
@@ -148,13 +231,123 @@ function norm(service: WixService): Row {
|
|
|
148
231
|
image,
|
|
149
232
|
hasImage: !!image,
|
|
150
233
|
noImage: !image,
|
|
234
|
+
tagline,
|
|
235
|
+
typeTag,
|
|
236
|
+
noTypeTag: !typeTag,
|
|
237
|
+
meta,
|
|
238
|
+
canBook,
|
|
239
|
+
noBook: !canBook,
|
|
240
|
+
bookCta: bookCtaFor(request),
|
|
241
|
+
bookA11y: bookLabel(name, request),
|
|
242
|
+
a11y: spokenName({ name, typeTag, tagline, meta }),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** A row saved by an older worker (offline cache, restored state) has none of the new
|
|
247
|
+
* fields: it renders with no tagline, meta or tag, and no Book pill until the live read. */
|
|
248
|
+
function withRowDefaults(row: Partial<Row>): Row {
|
|
249
|
+
const name = typeof row.name === "string" && row.name ? row.name : "Service";
|
|
250
|
+
const str = (v: unknown) => (typeof v === "string" ? v : "");
|
|
251
|
+
const image = str(row.image);
|
|
252
|
+
const typeTag = str(row.typeTag);
|
|
253
|
+
const tagline = str(row.tagline);
|
|
254
|
+
const meta = str(row.meta);
|
|
255
|
+
const canBook = row.canBook === true;
|
|
256
|
+
return {
|
|
257
|
+
...row,
|
|
258
|
+
id: str(row.id),
|
|
259
|
+
name,
|
|
260
|
+
subtitle: str(row.subtitle),
|
|
261
|
+
detailScreenId: str(row.detailScreenId) || "himi_service",
|
|
262
|
+
initial: str(row.initial),
|
|
263
|
+
image,
|
|
264
|
+
hasImage: !!image,
|
|
265
|
+
noImage: !image,
|
|
266
|
+
category: str(row.category),
|
|
267
|
+
categoryOrder: typeof row.categoryOrder === "number" ? row.categoryOrder : -1,
|
|
268
|
+
tagline,
|
|
269
|
+
typeTag,
|
|
270
|
+
noTypeTag: !typeTag,
|
|
271
|
+
meta,
|
|
272
|
+
canBook,
|
|
273
|
+
noBook: !canBook,
|
|
274
|
+
bookCta: str(row.bookCta) || bookCtaFor(false),
|
|
275
|
+
bookA11y: str(row.bookA11y) || bookLabel(name),
|
|
276
|
+
a11y: str(row.a11y) || spokenName({ name, typeTag, tagline, meta }),
|
|
151
277
|
};
|
|
152
278
|
}
|
|
153
279
|
|
|
280
|
+
// ---- Category chips ---------------------------------------------------------------
|
|
281
|
+
// The site's categories, read once per worker in the background — never holding back the
|
|
282
|
+
// services page — in the owner's order. When the read lands, the chips repaint. A failed read
|
|
283
|
+
// is tried again at most once a minute. Until it succeeds, the categories of the latest rows
|
|
284
|
+
// loaded with no filter and no query stand in, ordered by `category.sortOrder`.
|
|
285
|
+
let siteCategories: string[] | undefined;
|
|
286
|
+
let categoriesInFlight = false;
|
|
287
|
+
let categoriesFailedAt = 0;
|
|
288
|
+
const CATEGORY_RETRY_MS = 60_000;
|
|
289
|
+
const rowCategories = new Map<string, number>();
|
|
290
|
+
|
|
291
|
+
/** Repaint the chips only, on the state already shown. Re-deriving from the query controller
|
|
292
|
+
* would publish its working state, which a revalidation deliberately holds back (a restored list
|
|
293
|
+
* would flash to the skeleton, or shrink to page 1, until every page is back). */
|
|
294
|
+
function republish(): void {
|
|
295
|
+
(svc as unknown as { categoriesLanded?: () => unknown }).categoriesLanded?.();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function withChips(s: S): S {
|
|
299
|
+
const collection = (s as unknown as { collection?: { filter?: unknown; query?: unknown; hideChrome?: unknown } }).collection;
|
|
300
|
+
const filter = typeof collection?.filter === "string" ? collection.filter : "all";
|
|
301
|
+
// Restored rows stand in for the categories when the read failed before any live page — only
|
|
302
|
+
// rows of the whole list (no category, no query), or the stand-in would be one category.
|
|
303
|
+
const whole = filter === "all" && !(typeof collection?.query === "string" && collection.query.trim());
|
|
304
|
+
// Not while a load is out: the rows on screen may still be a previous search's.
|
|
305
|
+
if (!siteCategories?.length && rowCategories.size === 0 && whole && s.isLoading !== true) rememberRowCategories((s.results ?? []).map(withRowDefaults));
|
|
306
|
+
const chips = categoryChips(filter);
|
|
307
|
+
return { ...s, categoryChips: chips, hideCategories: collection?.hideChrome === true || (chips.length < 3 && !filter.startsWith("value:")) };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function startCategoriesRead(): void {
|
|
311
|
+
if (siteCategories || categoriesInFlight) return;
|
|
312
|
+
if (categoriesFailedAt && Date.now() - categoriesFailedAt < CATEGORY_RETRY_MS) return;
|
|
313
|
+
categoriesInFlight = true;
|
|
314
|
+
queryServiceCategories().then((res) => {
|
|
315
|
+
const names = (res.categories ?? []).map((category) => categoryName(category)).filter(Boolean);
|
|
316
|
+
siteCategories = [...new Set(names)];
|
|
317
|
+
}, () => {
|
|
318
|
+
categoriesFailedAt = Date.now();
|
|
319
|
+
}).then(() => {
|
|
320
|
+
categoriesInFlight = false;
|
|
321
|
+
republish();
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function rememberRowCategories(rows: Row[]): void {
|
|
326
|
+
for (const row of rows) {
|
|
327
|
+
if (row.category && !rowCategories.has(row.category)) rowCategories.set(row.category, row.categoryOrder);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function categoryNames(): string[] {
|
|
332
|
+
if (siteCategories?.length) return siteCategories;
|
|
333
|
+
const listed = [...rowCategories.entries()].map(([name, order], index) => ({ name, order, index }));
|
|
334
|
+
listed.sort((a, b) => (a.order < 0 ? Infinity : a.order) - (b.order < 0 ? Infinity : b.order) || a.index - b.index);
|
|
335
|
+
return listed.map((entry) => entry.name);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function categoryChips(filter: string): CategoryChip[] {
|
|
339
|
+
const names = categoryNames();
|
|
340
|
+
const chosen = filter.startsWith("value:") ? filter.slice(6) : "";
|
|
341
|
+
// The chosen category stays, so the customer can always go back to All.
|
|
342
|
+
const all = chosen && !names.includes(chosen) ? [...names, chosen] : names;
|
|
343
|
+
return [{ id: "all", label: copyParam("services.allCategories", "All") }, ...all.map((name) => ({ id: `value:${name}`, label: name }))];
|
|
344
|
+
}
|
|
345
|
+
|
|
154
346
|
const svc = defineBackendCollection<S, Row>("himiBookingServices", {
|
|
155
347
|
restorePagesOnLoad: true,
|
|
156
348
|
state: { status: "loading", query: "", all: [], results: [], emptyText: "", countLabel: "", errored: false,
|
|
157
|
-
notErrored: true, isLoading: true, notLoading: false, hasResults: false, noEmpty: true
|
|
349
|
+
notErrored: true, isLoading: true, notLoading: false, hasResults: false, noEmpty: true,
|
|
350
|
+
categoryChips: [], hideCategories: true, hideRows: false, noMatchText: "", hasQuery: false, noQuery: true, categoriesLabel: "" },
|
|
158
351
|
source: {
|
|
159
352
|
id: row => row.id,
|
|
160
353
|
errorMessage: () => copyParam("services.errorText", "Services could not be loaded."),
|
|
@@ -163,28 +356,81 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
|
|
|
163
356
|
return { items: await readCachedRows(), nextCursor: "", metadata: { fromCache: true } };
|
|
164
357
|
},
|
|
165
358
|
async fetch(criteria, cursor) {
|
|
166
|
-
|
|
359
|
+
// There is no Saved chip any more: a "saved" filter persisted by the old chrome lists everything.
|
|
167
360
|
const filter: Record<string, unknown> = { hidden: { $ne: true } };
|
|
168
361
|
if (criteria.query) filter.name = { $startsWith: criteria.query };
|
|
169
|
-
if (criteria.filter === "saved") filter.id = { $in: criteria.savedIds };
|
|
170
362
|
if (criteria.filter.startsWith("value:")) filter["category.name"] = { $eq: criteria.filter.slice(6) };
|
|
171
|
-
|
|
172
|
-
|
|
363
|
+
// The business's own order — the order of the site's Book Online page.
|
|
364
|
+
const sort = criteria.sort === "default"
|
|
365
|
+
? [{ fieldName: "category.sortOrder", order: "ASC" as const }, { fieldName: "sortOrder", order: "ASC" as const }, { fieldName: "id", order: "ASC" as const }]
|
|
366
|
+
: [{ fieldName: "name", order: criteria.sort === "za" ? "DESC" as const : "ASC" as const }, { fieldName: "id", order: "ASC" as const }];
|
|
367
|
+
if (!cursor) startCategoriesRead();
|
|
368
|
+
const res = await queryServices({ query: { filter, paging: { limit: 50, offset: Number(cursor || 0) }, sort } });
|
|
173
369
|
const raw = res.services ?? [];
|
|
174
|
-
const
|
|
175
|
-
|
|
370
|
+
const offset = Number(cursor || 0);
|
|
371
|
+
const items = raw.filter(row => !!row.id).map((service, index) => ({ ...norm(service), position: offset + index }));
|
|
372
|
+
// Only a read of the whole list in the business's order is saved: the offline copy is read
|
|
373
|
+
// back as that list, and a narrowed or re-sorted read's positions are not positions in it.
|
|
374
|
+
if (!criteria.query && !criteria.filter.startsWith("value:") && criteria.sort === "default") await writeCachedRows(items);
|
|
176
375
|
return { items, ...offsetPage(cursor, raw.length, 50, res.pagingMetadata), metadata: { fromCache: false } };
|
|
177
376
|
},
|
|
178
377
|
},
|
|
378
|
+
actions: {
|
|
379
|
+
categoriesLanded: (s) => withChips(s),
|
|
380
|
+
// A snapshot saved by an older worker comes back without the new row fields; a missing
|
|
381
|
+
// `noBook` would show Book on a course until the live read. Give restored rows the defaults.
|
|
382
|
+
normalizeRestored: (s) => {
|
|
383
|
+
const rows = Array.isArray(s.results) ? s.results : [];
|
|
384
|
+
if (rows.every((row) => typeof (row as Partial<Row>).noBook === "boolean")) return s;
|
|
385
|
+
const fixed = rows.map((row) => withRowDefaults(row as Partial<Row>));
|
|
386
|
+
return { ...s, results: fixed, all: fixed };
|
|
387
|
+
},
|
|
388
|
+
},
|
|
179
389
|
apply(s, page) {
|
|
180
390
|
const loading = page.status === "loading";
|
|
181
391
|
const errored = page.status === "error";
|
|
182
|
-
|
|
392
|
+
const rows = page.items.map(withRowDefaults);
|
|
393
|
+
// The stand-in categories come from the accepted rows of the whole list (a superseded
|
|
394
|
+
// response never reaches apply): a live page replaces them, the device's copy only fills a gap.
|
|
395
|
+
if (!page.criteria.query && page.criteria.filter === "all" && rows.length > 0) {
|
|
396
|
+
// Only a settled page: while a page reloads, the list may still hold the previous
|
|
397
|
+
// (searched) rows under the new criteria.
|
|
398
|
+
if (page.metadata?.fromCache !== true) { if (!loading) { rowCategories.clear(); rememberRowCategories(rows); } }
|
|
399
|
+
else if (rowCategories.size === 0) rememberRowCategories(rows);
|
|
400
|
+
}
|
|
401
|
+
// The same rule as the shared `collection.hideChrome`: no search or chips over a short,
|
|
402
|
+
// un-narrowed list, an empty source, or before the first rows arrive.
|
|
403
|
+
const filtered = page.criteria.filter.startsWith("value:");
|
|
404
|
+
const hasQuery = !!page.criteria.query.trim();
|
|
405
|
+
const narrowed = hasQuery || filtered;
|
|
406
|
+
const sourceEmpty = !loading && !errored && rows.length === 0 && !narrowed;
|
|
407
|
+
const hideChrome = (!narrowed && !page.nextCursor && rows.length < COLLECTION_CHROME_MIN_ITEMS) || sourceEmpty
|
|
408
|
+
|| ((loading || errored) && rows.length === 0 && !narrowed);
|
|
409
|
+
const chips = categoryChips(page.criteria.filter);
|
|
410
|
+
return { ...s, status: loading ? "loading" : "ready", query: page.criteria.query, all: rows, results: rows,
|
|
411
|
+
categoryChips: chips, hideCategories: hideChrome || (chips.length < 3 && !filtered), hideRows: loading && rows.length === 0,
|
|
412
|
+
noMatchText: hasQuery ? copyParam("services.noMatchText", "No services match your search.")
|
|
413
|
+
: copyParam("services.categoryEmptyText", "There are no services in this category."),
|
|
414
|
+
hasQuery, noQuery: !hasQuery, categoriesLabel: copyParam("services.categoriesLabel", "Service categories"),
|
|
183
415
|
nextCursor: page.nextCursor, complete: page.complete, total: page.total, loadingMore: page.loadingMore, pageError: page.pageError,
|
|
184
416
|
fromCache: page.metadata?.fromCache === true, errored, notErrored: !errored, isLoading: loading, notLoading: !loading,
|
|
185
417
|
hasResults: page.items.length > 0, noEmpty: true, countLabel: "",
|
|
186
|
-
|
|
418
|
+
// Only when there is nothing to show: Branded Web's book pane prints this line as it is.
|
|
419
|
+
emptyText: errored ? copyParam("services.errorText", "Services could not be loaded.")
|
|
420
|
+
: loading || rows.length > 0 ? ""
|
|
421
|
+
: hasQuery ? copyParam("services.noMatchText", "No services match your search.")
|
|
422
|
+
: filtered ? copyParam("services.categoryEmptyText", "There are no services in this category.")
|
|
423
|
+
: copyParam("services.emptyText", "There are no services to book yet.") };
|
|
187
424
|
},
|
|
188
425
|
});
|
|
426
|
+
// The old chrome could persist a "saved" filter (there is no Saved chip now) or an A-Z sort
|
|
427
|
+
// (there is no Refine now): both go back to the business's own view before the first read.
|
|
428
|
+
type Retired = { getState?: () => { collection?: { filter?: unknown; sort?: unknown } };
|
|
429
|
+
collectionFilter?: (arg: unknown) => unknown; collectionSort?: (arg: unknown) => unknown };
|
|
430
|
+
(svc as unknown as { normalizeRestored?: () => unknown }).normalizeRestored?.();
|
|
431
|
+
const retired = svc as unknown as Retired;
|
|
432
|
+
const persisted = retired.getState?.()?.collection;
|
|
433
|
+
if (persisted?.filter === "saved") void retired.collectionFilter?.({ selectedIds: ["all"] });
|
|
434
|
+
if (typeof persisted?.sort === "string" && persisted.sort !== "default") void retired.collectionSort?.({ selectedId: "default" });
|
|
189
435
|
svc.load();
|
|
190
436
|
export { svc };
|