@wix/himalaya-cli 0.1493.0 → 0.1494.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.
Files changed (39) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/builder.d.ts +2 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/bookings-v2/src/extensions.d.ts +52 -2
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/bookings-v2/src/index.d.ts +180 -0
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-bookings.mjs +4 -2
  5. package/dist/cli.mjs +30 -29
  6. package/dist/preview-assets.generated.bin +0 -0
  7. package/guides/qa-builds.md +2 -2
  8. package/guides/sdk-reference.json +256 -11
  9. package/package.json +2 -2
  10. package/templates/beauty-salon/dev/index.ts +1624 -270
  11. package/templates/beauty-salon/tier5-src/_shared/wix-service-price.ts +18 -0
  12. package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +514 -117
  13. package/templates/beauty-salon/tokens.json +6 -0
  14. package/templates/branded-lite/dev/index.ts +1621 -260
  15. package/templates/branded-lite/tier5-src/_shared/wix-service-price.ts +18 -0
  16. package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +514 -117
  17. package/templates/branded-lite/tokens.json +1 -0
  18. package/templates/community-club/tier5-src/_shared/wix-service-price.ts +18 -0
  19. package/templates/community-club/tokens.json +6 -0
  20. package/templates/events-meetups/tier5-src/_shared/wix-service-price.ts +18 -0
  21. package/templates/events-meetups/tokens.json +6 -0
  22. package/templates/gym-fitness/dev/index.ts +1623 -269
  23. package/templates/gym-fitness/tier5-src/_shared/wix-service-price.ts +18 -0
  24. package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +514 -117
  25. package/templates/gym-fitness/tokens.json +6 -0
  26. package/templates/nonprofit-cause/tier5-src/_shared/wix-service-price.ts +18 -0
  27. package/templates/nonprofit-cause/tokens.json +6 -0
  28. package/templates/podcast-creator/tier5-src/_shared/wix-service-price.ts +18 -0
  29. package/templates/podcast-creator/tokens.json +6 -0
  30. package/templates/portfolio-creative/tier5-src/_shared/wix-service-price.ts +18 -0
  31. package/templates/portfolio-creative/tokens.json +6 -0
  32. package/templates/restaurant/tier5-src/_shared/wix-service-price.ts +18 -0
  33. package/templates/restaurant/tokens.json +6 -0
  34. package/templates/retail-store/tier5-src/_shared/wix-service-price.ts +18 -0
  35. package/templates/retail-store/tokens.json +6 -0
  36. package/templates/yoga-studio/dev/index.ts +1617 -263
  37. package/templates/yoga-studio/tier5-src/_shared/wix-service-price.ts +18 -0
  38. package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +514 -117
  39. package/templates/yoga-studio/tokens.json +6 -0
@@ -1,12 +1,13 @@
1
1
  // Tier-5 bundle: himi-booking — single service detail.
2
- import { defineService } from "@himalaya/state";
2
+ import { defineService, navigate } from "@himalaya/state";
3
3
  import { share as shareSheet } from "@himalaya/share";
4
- import { getService, type WixService } from "@himalaya/wix-bookings";
4
+ import { open, defaultMode } from "@himalaya/open-url";
5
+ import { getService, type WixService, type WixServiceAddress, type WixServiceBookingPolicy } from "@himalaya/wix-bookings";
5
6
  import { categoryName } from "../_shared/himi-bookings.js";
6
- import { formatServicePrice } from "../_shared/wix-service-price.js";
7
- import { stashCopy, copyParam } from "../_shared/himi-copy.js";
7
+ import { formatServicePrice, formatServiceMoney, serviceCurrency } from "../_shared/wix-service-price.js";
8
+ import { stashCopy, copyParam, copyTemplate } from "../_shared/himi-copy.js";
8
9
  import { get, type DbEntity } from "@himalaya/local-db";
9
- import { wixMediaUrl, WIX_DETAIL_IMAGE } from "@himalaya/wix-media";
10
+ import { wixMediaUrl, WIX_DETAIL_IMAGE, WIX_LIST_IMAGE } from "@himalaya/wix-media";
10
11
 
11
12
  // Offline-first (data-offline): the service detail is a public read-through. On a
12
13
  // fetch failure we fall back to the last-synced row for this service written by
@@ -18,26 +19,87 @@ async function readCached(id: string): Promise<DbEntity | null> {
18
19
  try { return await get(ENTITY, id); } catch { /* capability not granted → online-only */ return null; }
19
20
  }
20
21
 
22
+ /** Staff names come back only on request (Wix "Get Service" `conditionalFields`). */
23
+ const SERVICE_FIELDS = { conditionalFields: ["STAFF_MEMBER_DETAILS"] };
24
+ /** At most this many location rows, in record order. */
25
+ const MAX_LOCATIONS = 3;
26
+
27
+ interface ServiceImage { url: string; alt: string }
28
+ interface LocationRow {
29
+ index: number;
30
+ title: string;
31
+ /** A maps search for the address; "" for the client's place. */
32
+ directionsUrl: string;
33
+ tappable: boolean;
34
+ notTappable: boolean;
35
+ a11y: string;
36
+ }
37
+ interface PolicyLine { text: string; owner: boolean; notOwner: boolean }
38
+
21
39
  interface S {
22
40
  status: "loading" | "ready";
23
41
  id: string;
24
42
  name: string;
25
- subtitle: string;
26
- description: string;
27
- /** Labeled facts (price / duration / category) — previously collapsed into
28
- * the one footnote `subtitle`; each collapses when the catalog carries none. */
29
- price: string;
30
- duration: string;
43
+ /** The category name, shown as the small label above the name. */
31
44
  category: string;
32
- hasFacts: boolean;
33
- noFacts: boolean;
34
- noPrice: boolean;
35
- noDuration: boolean;
36
45
  noCategory: boolean;
37
- initial: string;
46
+ tagline: string;
47
+ noTagline: boolean;
48
+ /** The price line: "₪90" + "per session", "From ₪60", "Free", or a CUSTOM description. */
49
+ priceText: string;
50
+ priceUnit: string;
51
+ /** A CUSTOM rate: the owner's own words stand in for the price, with no unit. */
52
+ priceIsText: boolean;
53
+ noPrice: boolean;
54
+ noPriceUnit: boolean;
55
+ /** Hide the amount row (no price, or a CUSTOM text price). */
56
+ noPriceAmount: boolean;
57
+ /** Hide the CUSTOM text price (any other rate). */
58
+ noPriceDescription: boolean;
59
+ priceA11y: string;
60
+ images: ServiceImage[];
61
+ /** The first image, for the one-image case. */
38
62
  image: string;
63
+ hasGallery: boolean;
64
+ noGallery: boolean;
65
+ hasOneImage: boolean;
66
+ noOneImage: boolean;
39
67
  hasImage: boolean;
40
68
  noImage: boolean;
69
+ durationText: string;
70
+ noDuration: boolean;
71
+ locations: LocationRow[];
72
+ noLocations: boolean;
73
+ online: boolean;
74
+ noOnline: boolean;
75
+ staffText: string;
76
+ staffImage: string;
77
+ staffInitials: string;
78
+ hasStaffImage: boolean;
79
+ noStaffImage: boolean;
80
+ noStaff: boolean;
81
+ payText: string;
82
+ paySub: string;
83
+ noPay: boolean;
84
+ noPaySub: boolean;
85
+ /** The business confirms each booking: the button says "Request to Book". */
86
+ requestToBook: boolean;
87
+ notRequestToBook: boolean;
88
+ bookableOnline: boolean;
89
+ notBookableOnline: boolean;
90
+ about: string;
91
+ noAbout: boolean;
92
+ policyLines: PolicyLine[];
93
+ noPolicy: boolean;
94
+ policyExpanded: boolean;
95
+ policyCollapsed: boolean;
96
+ /** No fact row has data: the facts section and its divider hide. */
97
+ noFacts: boolean;
98
+ /** No price, duration or category: Branded Web's summary grid hides. */
99
+ noKeyFacts: boolean;
100
+ /** Loading, error or empty: the service content hides. */
101
+ hideContent: boolean;
102
+ hideFooter: boolean;
41
103
  /** Share payload — the service page URL when the payload carries one. */
42
104
  shareUrl: string;
43
105
  timeScreenId: string;
@@ -62,69 +124,315 @@ function inputId(): string {
62
124
  }
63
125
 
64
126
  function stringOf(value: unknown): string {
65
- return typeof value === "string" ? value : "";
127
+ return typeof value === "string" ? value.trim() : "";
66
128
  }
67
129
 
68
130
  function numberOf(value: unknown): number | undefined {
69
- if (typeof value === "number") return value;
70
- if (typeof value === "string" && value.trim()) return Number(value);
131
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
132
+ if (typeof value === "string" && value.trim()) {
133
+ const n = Number(value);
134
+ return Number.isNaN(n) ? undefined : n;
135
+ }
71
136
  return undefined;
72
137
  }
73
138
 
74
- function money(service: WixService): string {
75
- // Delegates to the shared formatter so the Bookings v2 `payment.fixed.price`
76
- // shape + the record's real currency (₪, not a hardcoded $) are honored (#816).
77
- return formatServicePrice(service);
139
+ // ---- Price -----------------------------------------------------------------------
140
+
141
+ interface PriceLine { text: string; unit: string; isText: boolean }
142
+
143
+ /** The price line by rate type. FIXED → the price; VARIED → "From {price}"; CUSTOM → the
144
+ * owner's description, no unit; NO_FEE → "Free", no unit. */
145
+ function priceLine(service: WixService): PriceLine {
146
+ const payment = service.payment ?? {};
147
+ const rate = stringOf(payment.rateType).toUpperCase();
148
+ const currency = serviceCurrency(service);
149
+ const unit = copyParam("service.unitSession", "per session");
150
+ if (rate === "NO_FEE") return { text: copyParam("service.priceFree", "Free"), unit: "", isText: false };
151
+ if (rate === "CUSTOM") return { text: stringOf(payment.custom?.description), unit: "", isText: true };
152
+ if (rate === "VARIED") {
153
+ const from = formatServiceMoney(payment.varied?.minPrice, currency) || formatServiceMoney(payment.varied?.defaultPrice, currency);
154
+ return { text: from ? copyTemplate("service.priceFrom", "From {price}", { price: from }) : "", unit: from ? unit : "", isText: false };
155
+ }
156
+ // FIXED, or a legacy payload with no rate type.
157
+ const text = formatServiceMoney(payment.fixed?.price, currency) || formatServicePrice(service);
158
+ return { text, unit: text ? unit : "", isText: false };
78
159
  }
79
160
 
80
- function durationText(service: WixService): string {
81
- const minutes = numberOf((service as { duration?: unknown }).duration) ?? 0;
82
- if (!minutes) return "";
83
- if (minutes < 60) return `${minutes} min`;
84
- const hours = Math.floor(minutes / 60);
85
- const rest = minutes % 60;
86
- return rest ? `${hours} hr ${rest} min` : `${hours} hr`;
161
+ // ---- Media -----------------------------------------------------------------------
162
+
163
+ /** Every distinct image of the service: `media.items`, then `mainMedia`, then `coverMedia`. */
164
+ function serviceImages(service: WixService, name: string): ServiceImage[] {
165
+ const media = service.media ?? {};
166
+ const out: ServiceImage[] = [];
167
+ for (const item of [...(media.items ?? []), media.mainMedia, media.coverMedia]) {
168
+ const url = wixMediaUrl(item?.image?.url, WIX_DETAIL_IMAGE);
169
+ if (url && !out.some((image) => image.url === url)) out.push({ url, alt: stringOf(item?.image?.altText) || name });
170
+ }
171
+ if (!out.length) {
172
+ // Older payloads carry one image in another shape.
173
+ const legacy = legacyImageUrl(service);
174
+ if (legacy) out.push({ url: legacy, alt: name });
175
+ }
176
+ return out;
87
177
  }
88
178
 
89
- function mediaUrl(value: unknown): string {
179
+ function legacyImageUrl(value: unknown): string {
90
180
  if (typeof value === "string") return wixMediaUrl(value, WIX_DETAIL_IMAGE);
91
181
  if (!value || typeof value !== "object") return "";
92
182
  if (Array.isArray(value)) {
93
183
  for (const item of value) {
94
- const url = mediaUrl(item);
184
+ const url = legacyImageUrl(item);
95
185
  if (url) return url;
96
186
  }
97
187
  return "";
98
188
  }
99
- const node = value as {
100
- url?: unknown;
101
- src?: unknown;
102
- imageUrl?: unknown;
103
- imageSrc?: unknown;
104
- image?: unknown;
105
- thumbnail?: unknown;
106
- mediaItem?: unknown;
107
- mainMedia?: unknown;
108
- main?: unknown;
109
- media?: unknown;
110
- items?: unknown;
111
- photos?: unknown;
112
- gallery?: unknown;
113
- coverImage?: unknown;
114
- };
189
+ const node = value as Record<string, unknown>;
115
190
  for (const direct of [node.url, node.src, node.imageUrl, node.imageSrc]) {
116
191
  const url = wixMediaUrl(direct, WIX_DETAIL_IMAGE);
117
192
  if (url) return url;
118
193
  }
119
- for (const nested of [node.image, node.thumbnail, node.mediaItem, node.mainMedia, node.main, node.media, node.items, node.photos, node.gallery, node.coverImage]) {
120
- const url = mediaUrl(nested);
194
+ for (const key of ["image", "thumbnail", "mediaItem", "mainMedia", "main", "media", "items", "photos", "gallery", "coverImage"]) {
195
+ const url = legacyImageUrl(node[key]);
121
196
  if (url) return url;
122
197
  }
123
198
  return "";
124
199
  }
125
200
 
126
- function imageUrl(service: WixService): string {
127
- return mediaUrl(service);
201
+ // ---- Duration --------------------------------------------------------------------
202
+
203
+ function minutesText(minutes: number): string {
204
+ if (minutes < 60) return `${minutes} min`;
205
+ const hours = Math.floor(minutes / 60);
206
+ const rest = minutes % 60;
207
+ return rest ? `${hours} hr ${rest} min` : `${hours} hr`;
208
+ }
209
+
210
+ /** "45 min", "1 hr 30 min"; a service with several lengths, or a flexible range the
211
+ * customer picks from: "30 min - 1 hr", "1 day - 3 days". */
212
+ function durationText(service: WixService): string {
213
+ const constraints = service.schedule?.availabilityConstraints;
214
+ const positive = (values: unknown[]) => values.map(numberOf).filter((n): n is number => n != null && n > 0);
215
+ const listed = positive([
216
+ ...(constraints?.sessionDurations ?? []),
217
+ ...(constraints?.durations ?? []).map((d) => d?.minutes),
218
+ ]);
219
+ const hours = constraints?.durationRange?.hourOptions;
220
+ const range = positive([hours?.minDurationInMinutes, hours?.maxDurationInMinutes]);
221
+ const days = constraints?.durationRange?.dayOptions;
222
+ const dayRange = positive([days?.minDurationInDays, days?.maxDurationInDays]);
223
+ if (!listed.length && !range.length && dayRange.length) {
224
+ const dayText = (n: number) => (n === 1 ? copyParam("service.day", "1 day") : copyTemplate("service.days", "{n} days", { n }));
225
+ const first = Math.min(...dayRange);
226
+ const last = Math.max(...dayRange);
227
+ return first === last ? dayText(first) : `${dayText(first)} - ${dayText(last)}`;
228
+ }
229
+ const minutes = listed.length ? listed : range.length ? range : positive([service.duration]);
230
+ if (!minutes.length) return "";
231
+ const min = Math.min(...minutes);
232
+ const max = Math.max(...minutes);
233
+ return min === max ? minutesText(min) : `${minutesText(min)} - ${minutesText(max)}`;
234
+ }
235
+
236
+ // ---- Locations -------------------------------------------------------------------
237
+
238
+ /** The address as one line, or "" when it names no place. A country alone (the Wix default
239
+ * "Location 1") is not an address. */
240
+ function addressText(address: WixServiceAddress | undefined): string {
241
+ if (!address) return "";
242
+ const formatted = stringOf(address.formattedAddress);
243
+ if (formatted) return formatted;
244
+ const street = [stringOf(address.streetAddress?.number), stringOf(address.streetAddress?.name)].filter(Boolean).join(" ");
245
+ const line = [street, stringOf(address.streetAddress?.apt)].filter(Boolean).join(" ") || stringOf(address.addressLine);
246
+ const city = stringOf(address.city);
247
+ if (!line && !city) return "";
248
+ return [line, city, stringOf(address.subdivision), stringOf(address.postalCode), stringOf(address.country)].filter(Boolean).join(", ");
249
+ }
250
+
251
+ function directionsUrl(address: string): string {
252
+ return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`;
253
+ }
254
+
255
+ /** One row per location the customer can act on: a BUSINESS or CUSTOM location with an
256
+ * address (tap → maps), or the client's place. At most {@link MAX_LOCATIONS}. */
257
+ function locationRows(service: WixService): LocationRow[] {
258
+ const rows: LocationRow[] = [];
259
+ for (const location of service.locations ?? []) {
260
+ if (rows.length >= MAX_LOCATIONS) break;
261
+ const type = stringOf(location?.type).toUpperCase();
262
+ if (type === "CUSTOMER") {
263
+ const title = copyParam("service.clientPlace", "Client's place");
264
+ rows.push({ index: rows.length, title, directionsUrl: "", tappable: false, notTappable: true, a11y: title });
265
+ continue;
266
+ }
267
+ const place = type === "BUSINESS" ? location.business : type === "CUSTOM" ? location.custom : undefined;
268
+ if (!place) continue;
269
+ const address = addressText(place.address) || addressText(location.calculatedAddress);
270
+ if (!address) continue;
271
+ const title = (type === "BUSINESS" ? stringOf(location.business?.name) : "") || address;
272
+ rows.push({
273
+ index: rows.length,
274
+ title,
275
+ directionsUrl: directionsUrl(address),
276
+ tappable: true,
277
+ notTappable: false,
278
+ a11y: copyTemplate("service.directionsLabel", "Get directions to {place}", { place: title }),
279
+ });
280
+ }
281
+ return rows;
282
+ }
283
+
284
+ // ---- Staff -----------------------------------------------------------------------
285
+
286
+ function initials(name: string): string {
287
+ return name.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]!.toUpperCase()).join("");
288
+ }
289
+
290
+ function firstName(name: string): string {
291
+ return name.split(/\s+/)[0] ?? name;
292
+ }
293
+
294
+ /** "With Noa Levi", "With Noa or Dana", "With Noa, Dana and 2 more". Empty when the
295
+ * record carries no staff details (a visitor may not be allowed to read them). */
296
+ function staffLine(service: WixService): { text: string; image: string; initials: string } {
297
+ const staff = (service.staffMemberDetails?.staffMembers ?? []).filter((member) => stringOf(member?.name));
298
+ if (!staff.length) return { text: "", image: "", initials: "" };
299
+ const names = staff.map((member) => stringOf(member.name));
300
+ const text = names.length === 1
301
+ ? copyTemplate("service.staffOne", "With {name}", { name: names[0]! })
302
+ : names.length === 2
303
+ ? copyTemplate("service.staffTwo", "With {a} or {b}", { a: firstName(names[0]!), b: firstName(names[1]!) })
304
+ : copyTemplate("service.staffMany", "With {a}, {b} and {count} more", { a: firstName(names[0]!), b: firstName(names[1]!), count: names.length - 2 });
305
+ return { text, image: wixMediaUrl(staff[0]!.mainMedia?.image?.url, WIX_LIST_IMAGE), initials: initials(names[0]!) };
306
+ }
307
+
308
+ // ---- How to pay ------------------------------------------------------------------
309
+
310
+ /** The payment row, by the first rule that matches. Never mentions pricing plans. */
311
+ function payLine(service: WixService): { text: string; sub: string } {
312
+ const payment = service.payment ?? {};
313
+ const rate = stringOf(payment.rateType).toUpperCase();
314
+ if (rate === "NO_FEE") return { text: "", sub: "" };
315
+ const options = payment.options ?? {};
316
+ const currency = serviceCurrency(service);
317
+ const depositMoney = payment.fixed?.deposit ?? payment.varied?.deposit;
318
+ const deposit = numberOf(depositMoney?.value);
319
+ if (options.deposit && deposit != null && deposit > 0) {
320
+ const price = rate === "VARIED" ? undefined : numberOf(payment.fixed?.price?.value);
321
+ const remaining = price != null && price > deposit ? price - deposit : undefined;
322
+ const depositCurrency = stringOf(depositMoney?.currency) || currency;
323
+ return {
324
+ text: copyTemplate("service.payDeposit", "{amount} deposit", { amount: formatServiceMoney(depositMoney, currency) }),
325
+ sub: remaining != null
326
+ ? copyTemplate("service.payDueLater", "{amount} due later", { amount: formatServiceMoney({ value: String(remaining), currency: depositCurrency }, currency) })
327
+ : "",
328
+ };
329
+ }
330
+ if (options.online && options.inPerson) return { text: copyParam("service.payBoth", "Pay now or in person"), sub: "" };
331
+ if (options.online) return { text: copyParam("service.payOnline", "Pay now"), sub: "" };
332
+ if (options.inPerson) return { text: copyParam("service.payInPerson", "Pay in person"), sub: "" };
333
+ return { text: "", sub: "" };
334
+ }
335
+
336
+ // ---- Our policy ------------------------------------------------------------------
337
+
338
+ /** Minutes as words: under 60 → minutes; under 2 days, or not whole days → hours; else days. */
339
+ function windowText(minutes: number): string {
340
+ if (minutes < 60) {
341
+ return minutes === 1 ? copyParam("service.minute", "1 minute") : copyTemplate("service.minutes", "{n} minutes", { n: minutes });
342
+ }
343
+ if (minutes < 2880 || minutes % 1440 !== 0) {
344
+ const hours = Math.round((minutes / 60) * 10) / 10;
345
+ return hours === 1 ? copyParam("service.hour", "1 hour") : copyTemplate("service.hours", "{n} hours", { n: hours });
346
+ }
347
+ const days = minutes / 1440;
348
+ return days === 1 ? copyParam("service.day", "1 day") : copyTemplate("service.days", "{n} days", { n: days });
349
+ }
350
+
351
+ type Change = { kind: "none" } | { kind: "anytime" } | { kind: "until"; minutes: number } | { kind: "off" };
352
+
353
+ function changeRule(enabled: boolean | undefined, limited: boolean | undefined, minutes: number | undefined): Change {
354
+ if (enabled === undefined) return { kind: "none" };
355
+ if (!enabled) return { kind: "off" };
356
+ return limited && minutes != null && minutes > 0 ? { kind: "until", minutes } : { kind: "anytime" };
357
+ }
358
+
359
+ /** Strip HTML from the owner's policy text but keep its line breaks. */
360
+ function policyText(text: string): string {
361
+ return text.replace(/<br\s*\/?>/gi, "\n").replace(/<\/p>/gi, "\n").replace(/<[^>]*>/g, "").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
362
+ }
363
+
364
+ /** The lines of "Our policy", from the business's own booking policy. The app never
365
+ * invents policy text: each line exists only when the policy states it. */
366
+ function policyLines(policy: WixServiceBookingPolicy | undefined): PolicyLine[] {
367
+ if (!policy) return [];
368
+ const lines: PolicyLine[] = [];
369
+ const add = (text: string, owner = false) => { if (text) lines.push({ text, owner, notOwner: !owner }); };
370
+
371
+ const custom = policy.customPolicyDescription;
372
+ if (custom?.enabled) add(policyText(stringOf(custom.description)), true);
373
+
374
+ const cancel = changeRule(policy.cancellationPolicy?.enabled, policy.cancellationPolicy?.limitLatestCancellation, numberOf(policy.cancellationPolicy?.latestCancellationInMinutes));
375
+ const reschedule = changeRule(policy.reschedulePolicy?.enabled, policy.reschedulePolicy?.limitLatestReschedule, numberOf(policy.reschedulePolicy?.latestRescheduleInMinutes));
376
+ if (cancel.kind === "until" && reschedule.kind === "until" && cancel.minutes === reschedule.minutes) {
377
+ add(copyTemplate("service.changeUntil", "Cancel or reschedule online up to {window} before the session starts.", { window: windowText(cancel.minutes) }));
378
+ } else if (cancel.kind === "anytime" && reschedule.kind === "anytime") {
379
+ add(copyParam("service.changeAnytime", "You can cancel or reschedule online at any time."));
380
+ } else {
381
+ if (cancel.kind === "until") add(copyTemplate("service.cancelUntil", "Cancel online up to {window} before the session starts.", { window: windowText(cancel.minutes) }));
382
+ if (cancel.kind === "anytime") add(copyParam("service.cancelAnytime", "You can cancel online at any time."));
383
+ if (cancel.kind === "off") add(copyParam("service.cancelNotOnline", "You can't cancel online. Contact us to cancel."));
384
+ if (reschedule.kind === "until") add(copyTemplate("service.rescheduleUntil", "Reschedule online up to {window} before the session starts.", { window: windowText(reschedule.minutes) }));
385
+ if (reschedule.kind === "anytime") add(copyParam("service.rescheduleAnytime", "You can reschedule online at any time."));
386
+ if (reschedule.kind === "off") add(copyParam("service.rescheduleNotOnline", "You can't reschedule online."));
387
+ }
388
+
389
+ const fees = policy.cancellationFeePolicy;
390
+ if (fees?.enabled) {
391
+ for (const window of fees.cancellationWindows ?? []) {
392
+ const percentage = stringOf(window?.percentage);
393
+ const fee = formatServiceMoney(window?.amount) || (percentage ? `${percentage}%` : "");
394
+ if (!fee) continue;
395
+ const start = numberOf(window?.startInMinutes);
396
+ if (start == null || start <= 0) add(copyTemplate("service.feeLate", "Late cancellations have a {fee} fee.", { fee }));
397
+ else if (cancel.kind === "until" && cancel.minutes === start) add(copyTemplate("service.feeAfterThat", "Cancellations after that have a {fee} fee.", { fee }));
398
+ else add(copyTemplate("service.feeWindow", "Cancellations less than {window} before the session starts have a {fee} fee.", { window: windowText(start), fee }));
399
+ }
400
+ }
401
+
402
+ const early = policy.limitEarlyBookingPolicy;
403
+ const earliest = numberOf(early?.earliestBookingInMinutes);
404
+ if (early?.enabled && earliest != null && earliest > 0) add(copyTemplate("service.bookingsOpen", "Bookings open {window} before the session starts.", { window: windowText(earliest) }));
405
+ const late = policy.limitLateBookingPolicy;
406
+ const latest = numberOf(late?.latestBookingInMinutes);
407
+ if (late?.enabled && latest != null && latest > 0) add(copyTemplate("service.bookingsClose", "Bookings close {window} before the session starts.", { window: windowText(latest) }));
408
+
409
+ // Live payloads also carry `participantsPolicy.enabled` (not in the V2 types); a disabled
410
+ // policy keeps its configured maximum, which is not a rule the customer is held to.
411
+ const participants = policy.participantsPolicy as { enabled?: boolean; maxParticipantsPerBooking?: number } | undefined;
412
+ const max = numberOf(participants?.maxParticipantsPerBooking);
413
+ if (participants?.enabled !== false && max != null && max > 1) add(copyTemplate("service.maxParticipants", "Book for up to {n} participants.", { n: max }));
414
+ return lines;
415
+ }
416
+
417
+ // ---- Share -----------------------------------------------------------------------
418
+
419
+ /** The service's public page URL when the payload carries one ("" otherwise). Bookings
420
+ * V2 puts it at `urls.servicePage.url`; the other shapes are older payloads. */
421
+ function serviceShareUrl(service: WixService): string {
422
+ const page = stringOf(service.urls?.servicePage?.url);
423
+ if (/^https?:\/\//i.test(page)) return page;
424
+ const raw = service as unknown as {
425
+ url?: unknown;
426
+ servicePageUrl?: { base?: string; path?: string; relativePath?: string } | string;
427
+ };
428
+ if (typeof raw.url === "string" && /^https?:\/\//i.test(raw.url)) return raw.url;
429
+ const legacy = raw.servicePageUrl;
430
+ if (typeof legacy === "string" && /^https?:\/\//i.test(legacy)) return legacy;
431
+ if (legacy && typeof legacy === "object" && typeof legacy.base === "string" && legacy.base) {
432
+ const path = typeof legacy.path === "string" ? legacy.path : typeof legacy.relativePath === "string" ? legacy.relativePath : "";
433
+ return `${legacy.base.replace(/\/$/, "")}${path && !path.startsWith("/") ? "/" : ""}${path}`;
434
+ }
435
+ return "";
128
436
  }
129
437
 
130
438
  function plain(text: string | undefined): string {
@@ -134,68 +442,90 @@ function plain(text: string | undefined): string {
134
442
  .trim();
135
443
  }
136
444
 
445
+ // ---- State -----------------------------------------------------------------------
446
+
137
447
  function derive(s: S): S {
138
- const hasFacts = !!(s.price || s.duration || s.category);
448
+ const isLoading = s.status === "loading";
449
+ const hasService = !!s.name;
450
+ const hasImage = s.images.length > 0;
139
451
  return {
140
452
  ...s,
141
- isLoading: s.status === "loading",
142
- notLoading: s.status !== "loading",
453
+ isLoading,
454
+ notLoading: !isLoading,
143
455
  notErrored: !s.errored,
144
- hasService: !!s.name,
145
- hasFacts,
146
- noFacts: !hasFacts,
147
- noPrice: !s.price,
148
- noDuration: !s.duration,
456
+ hasService,
457
+ noEmpty: isLoading || s.errored || hasService,
149
458
  noCategory: !s.category,
150
- noEmpty: s.status === "loading" || s.errored || !!s.name,
459
+ noTagline: !s.tagline,
460
+ noPrice: !s.priceText,
461
+ noPriceUnit: !s.priceUnit,
462
+ noPriceAmount: !s.priceText || s.priceIsText,
463
+ noPriceDescription: !s.priceText || !s.priceIsText,
464
+ priceA11y: s.priceText && !s.priceIsText
465
+ ? copyTemplate("service.priceLabel", "Price, {price}", { price: [s.priceText, s.priceUnit].filter(Boolean).join(" ") })
466
+ : "",
467
+ image: s.images[0]?.url ?? "",
468
+ hasGallery: s.images.length > 1,
469
+ noGallery: s.images.length < 2,
470
+ hasOneImage: s.images.length === 1,
471
+ noOneImage: s.images.length !== 1,
472
+ hasImage,
473
+ noImage: !hasImage,
474
+ noDuration: !s.durationText,
475
+ noLocations: s.locations.length === 0,
476
+ noOnline: !s.online,
477
+ noStaff: !s.staffText,
478
+ hasStaffImage: !!s.staffImage,
479
+ noStaffImage: !s.staffImage,
480
+ noPay: !s.payText,
481
+ noPaySub: !s.paySub,
482
+ notRequestToBook: !s.requestToBook,
483
+ notBookableOnline: !s.bookableOnline,
484
+ noAbout: !s.about,
485
+ noPolicy: s.policyLines.length === 0,
486
+ policyCollapsed: !s.policyExpanded,
487
+ noFacts: !s.durationText && s.locations.length === 0 && !s.online && !s.staffText && !s.payText,
488
+ noKeyFacts: !s.priceText && !s.durationText && !s.category,
489
+ hideContent: isLoading || s.errored || !hasService,
490
+ hideFooter: isLoading || s.errored || !hasService || !s.bookableOnline,
151
491
  emptyText: s.errored
152
492
  ? copyParam("service.errorText", "Service details could not be loaded.")
153
493
  : copyParam("service.emptyText", "This service is not available."),
154
494
  };
155
495
  }
156
496
 
157
- /** The service's public page URL when the payload carries one ("" otherwise). */
158
- function serviceShareUrl(service: WixService): string {
159
- const raw = service as unknown as {
160
- url?: unknown;
161
- servicePageUrl?: { base?: string; path?: string; relativePath?: string } | string;
162
- };
163
- if (typeof raw.url === "string" && /^https?:\/\//i.test(raw.url)) return raw.url;
164
- const page = raw.servicePageUrl;
165
- if (typeof page === "string" && /^https?:\/\//i.test(page)) return page;
166
- if (page && typeof page === "object" && typeof page.base === "string" && page.base) {
167
- const path = typeof page.path === "string" ? page.path : typeof page.relativePath === "string" ? page.relativePath : "";
168
- return `${page.base.replace(/\/$/, "")}${path && !path.startsWith("/") ? "/" : ""}${path}`;
169
- }
170
- return "";
171
- }
172
-
173
497
  function withService(s: S, service: WixService): S {
174
498
  const serviceId = stringOf(service.id) || s.id;
175
499
  const name = stringOf(service.name) || "Service";
176
- const price = money(service);
177
- const duration = durationText(service);
178
- // Live payloads carry `category` as a structured object — extract the name
179
- // (the list worker joins the same field; see _shared/himi-bookings.ts).
180
- const category = categoryName(service.category);
181
- const bits = [category, duration, price].filter(Boolean);
182
- const image = imageUrl(service);
500
+ const price = priceLine(service);
501
+ const staff = staffLine(service);
502
+ const pay = payLine(service);
183
503
  return derive({
184
504
  ...s,
185
505
  status: "ready",
186
506
  errored: false,
187
- notErrored: true,
188
507
  id: serviceId,
189
508
  name,
190
- subtitle: bits.join(" · "),
191
- description: plain(service.description) || copyParam("service.descriptionFallback", "Choose a time that works for you."),
192
- price,
193
- duration,
194
- category,
195
- initial: (name.trim()[0] || "S").toUpperCase(),
196
- image,
197
- hasImage: !!image,
198
- noImage: !image,
509
+ // Live payloads carry `category` as a structured object — extract the name
510
+ // (the list worker joins the same field; see _shared/himi-bookings.ts).
511
+ category: categoryName(service.category),
512
+ tagline: stringOf(service.tagLine),
513
+ priceText: price.text,
514
+ priceUnit: price.unit,
515
+ priceIsText: price.isText,
516
+ images: serviceImages(service, name),
517
+ durationText: durationText(service),
518
+ locations: locationRows(service),
519
+ online: service.conferencing?.enabled === true,
520
+ staffText: staff.text,
521
+ staffImage: staff.image,
522
+ staffInitials: staff.initials,
523
+ payText: pay.text,
524
+ paySub: pay.sub,
525
+ requestToBook: service.onlineBooking?.requireManualApproval === true,
526
+ bookableOnline: service.onlineBooking?.enabled !== false,
527
+ about: plain(service.description),
528
+ policyLines: policyLines(service.bookingPolicy),
199
529
  shareUrl: serviceShareUrl(service),
200
530
  // WS-C — canonical param-less target; the descriptor's choose-time CTA
201
531
  // binds the separate `id` state field as a first-class nav param.
@@ -203,29 +533,84 @@ function withService(s: S, service: WixService): S {
203
533
  });
204
534
  }
205
535
 
536
+ /** The service record: with staff details when this token may read them, else without. */
537
+ async function fetchService(id: string): Promise<WixService | undefined> {
538
+ try {
539
+ return (await getService(id, SERVICE_FIELDS)).service;
540
+ } catch {
541
+ // A visitor token that may not read STAFF_MEMBER_DETAILS must not lose the whole screen.
542
+ return (await getService(id)).service;
543
+ }
544
+ }
545
+
546
+ /** `{ index }` from a row tap, as a number (`$item.index` may arrive as a string). */
547
+ function tappedIndex(arg: unknown): number {
548
+ const a = arg as { index?: unknown; args?: { index?: unknown } } | null | undefined;
549
+ return numberOf(a?.index ?? a?.args?.index) ?? -1;
550
+ }
551
+
206
552
  const id = inputId();
207
553
  const svc = defineService<S, {
208
554
  load: (s: S, arg?: unknown) => Promise<S>;
209
555
  share: (s: S) => Promise<S>;
556
+ togglePolicy: (s: S) => S;
557
+ openDirections: (s: S, arg?: unknown) => Promise<S>;
558
+ chooseTime: (s: S) => S;
210
559
  }>("himiBookingService", {
211
560
  state: derive({
212
561
  status: "loading",
213
562
  id,
214
563
  name: "",
215
- subtitle: "",
216
- description: "",
217
- price: "",
218
- duration: "",
219
564
  category: "",
220
- hasFacts: false,
221
- noFacts: true,
222
- noPrice: true,
223
- noDuration: true,
224
565
  noCategory: true,
225
- initial: "S",
566
+ tagline: "",
567
+ noTagline: true,
568
+ priceText: "",
569
+ priceUnit: "",
570
+ priceIsText: false,
571
+ noPrice: true,
572
+ noPriceUnit: true,
573
+ noPriceAmount: true,
574
+ noPriceDescription: true,
575
+ priceA11y: "",
576
+ images: [],
226
577
  image: "",
578
+ hasGallery: false,
579
+ noGallery: true,
580
+ hasOneImage: false,
581
+ noOneImage: true,
227
582
  hasImage: false,
228
583
  noImage: true,
584
+ durationText: "",
585
+ noDuration: true,
586
+ locations: [],
587
+ noLocations: true,
588
+ online: false,
589
+ noOnline: true,
590
+ staffText: "",
591
+ staffImage: "",
592
+ staffInitials: "",
593
+ hasStaffImage: false,
594
+ noStaffImage: true,
595
+ noStaff: true,
596
+ payText: "",
597
+ paySub: "",
598
+ noPay: true,
599
+ noPaySub: true,
600
+ requestToBook: false,
601
+ notRequestToBook: true,
602
+ bookableOnline: true,
603
+ notBookableOnline: false,
604
+ about: "",
605
+ noAbout: true,
606
+ policyLines: [],
607
+ noPolicy: true,
608
+ policyExpanded: false,
609
+ policyCollapsed: true,
610
+ noFacts: true,
611
+ noKeyFacts: true,
612
+ hideContent: true,
613
+ hideFooter: true,
229
614
  shareUrl: "",
230
615
  timeScreenId: id ? "himi_time" : "himi_services", // WS-C canonical + `id` nav param
231
616
  emptyText: "",
@@ -239,39 +624,36 @@ const svc = defineService<S, {
239
624
  actions: {
240
625
  load: async (s, arg) => {
241
626
  stashCopy(arg);
242
- if (!s.id) return derive({ ...s, status: "ready", errored: true, notErrored: false });
627
+ if (!s.id) return derive({ ...s, status: "ready", errored: true });
628
+ const start = { ...s, policyExpanded: false };
243
629
  try {
244
- const res = await getService(s.id);
245
- return res.service ? withService(s, res.service) : derive({ ...s, status: "ready" });
630
+ const service = await fetchService(s.id);
631
+ return service ? withService(start, service) : derive({ ...start, status: "ready" });
246
632
  } catch {
247
633
  // Offline: paint from the last-synced services cache (written by the list
248
- // worker) before falling back to the error state.
634
+ // worker) before falling back to the error state. The cache holds the name,
635
+ // the category and one image; every other row stays hidden.
249
636
  const cached = await readCached(s.id);
250
637
  if (cached) {
251
638
  const name = String(cached.name ?? "") || "Service";
252
639
  const image = String(cached.image ?? "");
253
640
  return derive({
254
- ...s,
641
+ ...start,
255
642
  status: "ready",
256
643
  errored: false,
257
- notErrored: true,
258
644
  id: s.id,
259
645
  name,
260
- subtitle: String(cached.subtitle ?? ""),
261
- description: "Choose a time that works for you.",
262
- initial: String(cached.initial ?? "") || (name.trim()[0] || "S").toUpperCase(),
263
- image,
264
- hasImage: !!image,
265
- noImage: !image,
646
+ category: String(cached.category ?? ""),
647
+ images: image ? [{ url: image, alt: name }] : [],
266
648
  timeScreenId: `himi_time_${s.id}`,
267
649
  });
268
650
  }
269
- return derive({ ...s, status: "ready", errored: true, notErrored: false, name: "" });
651
+ return derive({ ...start, status: "ready", errored: true, name: "" });
270
652
  }
271
653
  },
272
654
  share: async (s) => {
273
655
  if (!s.name) return s;
274
- const text = [s.name, s.subtitle].filter(Boolean).join(" — ");
656
+ const text = [s.name, s.tagline].filter(Boolean).join(" — ");
275
657
  try {
276
658
  await shareSheet({ subject: s.name, text, ...(s.shareUrl ? { url: s.shareUrl } : {}) });
277
659
  return s;
@@ -279,6 +661,21 @@ const svc = defineService<S, {
279
661
  return s;
280
662
  }
281
663
  },
664
+ togglePolicy: (s) => derive({ ...s, policyExpanded: !s.policyExpanded }),
665
+ // The footer button opens the time screen for the loaded service. A worker directive,
666
+ // not a `himi.open` with a `$service:` id param: the iOS and Android sticky footers
667
+ // dispatch their taps without resolving `$service:` params, so the id never arrived.
668
+ chooseTime: (s) => (s.id && s.hasService ? navigate(s, { screenId: "himi_time", args: { id: s.id } }) : s),
669
+ openDirections: async (s, arg) => {
670
+ const url = s.locations[tappedIndex(arg)]?.directionsUrl ?? "";
671
+ if (!url) return s;
672
+ try {
673
+ await open(url, defaultMode(url));
674
+ } catch {
675
+ // Nothing took the link; the screen stays as it was.
676
+ }
677
+ return s;
678
+ },
282
679
  },
283
680
  });
284
681
  svc.load();