@wix/himalaya-cli 0.1578.0 → 0.1579.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 (55) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/calendar/src/index.d.ts +42 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/sdk-types/src/capabilities.generated.d.ts +1 -1
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/audio-record.mjs +1 -1
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/calendar.mjs +46 -1
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/calls.mjs +1 -1
  6. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/contacts.mjs +1 -1
  7. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/location.mjs +1 -1
  8. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/motion.mjs +1 -1
  9. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/photos.mjs +1 -1
  10. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdk-types.mjs +1 -1
  11. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/tasks.mjs +1 -1
  12. package/dist/cli.mjs +44 -38
  13. package/dist/preview-assets.generated.bin +0 -0
  14. package/guides/one-app-section-mappings.md +26 -0
  15. package/guides/sdk-reference.json +43 -1
  16. package/guides/web-page-sdk-reference.md +1 -1
  17. package/package.json +2 -2
  18. package/templates/beauty-salon/dev/index.ts +14 -0
  19. package/templates/beauty-salon/tier5-src/_shared/himi-bookings.ts +21 -0
  20. package/templates/beauty-salon/tier5-src/_shared/himi-copy.ts +8 -3
  21. package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +36 -12
  22. package/templates/beauty-salon-owner/tier5-src/_shared/himi-copy.ts +8 -3
  23. package/templates/branded-lite/dev/index.ts +14 -0
  24. package/templates/branded-lite/tier5-src/_shared/himi-bookings.ts +21 -0
  25. package/templates/branded-lite/tier5-src/_shared/himi-copy.ts +8 -3
  26. package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +36 -12
  27. package/templates/branded-lite/tier5-src/himi-branded-lite-programs/index.ts +40 -2
  28. package/templates/brandy/tier5-src/_shared/himi-copy.ts +8 -3
  29. package/templates/community-club/tier5-src/_shared/himi-copy.ts +8 -3
  30. package/templates/community-club-owner/tier5-src/_shared/himi-copy.ts +8 -3
  31. package/templates/events-meetups/tier5-src/_shared/himi-copy.ts +8 -3
  32. package/templates/events-meetups-owner/tier5-src/_shared/himi-copy.ts +8 -3
  33. package/templates/gym-fitness/dev/index.ts +14 -0
  34. package/templates/gym-fitness/tier5-src/_shared/himi-bookings.ts +21 -0
  35. package/templates/gym-fitness/tier5-src/_shared/himi-copy.ts +8 -3
  36. package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +36 -12
  37. package/templates/gym-fitness-owner/tier5-src/_shared/himi-copy.ts +8 -3
  38. package/templates/himi-chat/tier5-src/_shared/himi-copy.ts +8 -3
  39. package/templates/nonprofit-cause/tier5-src/_shared/himi-copy.ts +8 -3
  40. package/templates/nonprofit-cause-owner/tier5-src/_shared/himi-copy.ts +8 -3
  41. package/templates/owner-lite/tier5-src/_shared/himi-copy.ts +8 -3
  42. package/templates/podcast-creator/tier5-src/_shared/himi-copy.ts +8 -3
  43. package/templates/podcast-creator-owner/tier5-src/_shared/himi-copy.ts +8 -3
  44. package/templates/portfolio-creative/tier5-src/_shared/himi-copy.ts +8 -3
  45. package/templates/portfolio-creative-owner/tier5-src/_shared/himi-copy.ts +8 -3
  46. package/templates/restaurant/tier5-src/_shared/himi-copy.ts +8 -3
  47. package/templates/restaurant-owner/tier5-src/_shared/himi-copy.ts +8 -3
  48. package/templates/retail-store/tier5-src/_shared/himi-copy.ts +8 -3
  49. package/templates/retail-store-owner/tier5-src/_shared/himi-copy.ts +8 -3
  50. package/templates/yoga-studio/dev/index.ts +14 -0
  51. package/templates/yoga-studio/tier5-src/_shared/himi-bookings.ts +21 -0
  52. package/templates/yoga-studio/tier5-src/_shared/himi-copy.ts +8 -3
  53. package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +36 -12
  54. package/templates/yoga-studio/tier5-src/himi-branded-lite-programs/index.ts +40 -2
  55. package/templates/yoga-studio-owner/tier5-src/_shared/himi-copy.ts +8 -3
@@ -19,7 +19,7 @@ the SDK sources, so it matches the shipped SDKs.
19
19
  - [`base44`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fbase44?format=md) — A Base44 app's backend. 11 methods · Brandy: conditional.
20
20
  - [`biometrics`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fbiometrics?format=md) — Biometric authentication capability. 2 methods.
21
21
  - [`browser_auth`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fbrowser_auth?format=md) — Native browser SSO. 1 method.
22
- - [`calendar`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fcalendar?format=md) — Device calendar. 6 methods.
22
+ - [`calendar`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fcalendar?format=md) — Device calendar. 10 methods.
23
23
  - [`calls`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fcalls?format=md) — Real-time 1:1 audio calls (Stage 1). 16 methods.
24
24
  - [`camera`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fcamera?format=md) — Camera capability. 4 methods · returns FileHandles.
25
25
  - [`clipboard`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fclipboard?format=md) — System clipboard. 5 methods.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.1578.0",
3
+ "version": "0.1579.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -71,5 +71,5 @@
71
71
  "artifactId": "himalaya-cli"
72
72
  }
73
73
  },
74
- "falconPackageHash": "0a206d4a3d13e9f1302334dcaeb9d70a507f8c916a8ac190fe8bfd8e"
74
+ "falconPackageHash": "939c5230409902d493ec5b28c46a83496669032bc8f663eabd42dcf6"
75
75
  }
@@ -20977,6 +20977,20 @@ const SCREENS = runtimeSetting({
20977
20977
  "hiddenPath": "$item.noTypeTag"
20978
20978
  }
20979
20979
  },
20980
+ {
20981
+ "id": "himi_services.row.$item.id/ended",
20982
+ "body": {
20983
+ "package": "himalaya",
20984
+ "type": "Text",
20985
+ "props": {
20986
+ "text": "$item.endedText",
20987
+ "style": "caption",
20988
+ "color": "textSecondary",
20989
+ "maxLines": 1,
20990
+ "collapseWhenEmpty": true
20991
+ }
20992
+ }
20993
+ },
20980
20994
  {
20981
20995
  "id": "",
20982
20996
  "body": {
@@ -80,6 +80,19 @@ type CoursePolicyShape = {
80
80
  };
81
81
  };
82
82
 
83
+ /** A completed course, independent of whether bookings are currently allowed. Missing,
84
+ * invalid or reversed dates are unknown, never evidence that a course has ended. */
85
+ export function courseHasEnded(service: CoursePolicyShape, now: number): boolean {
86
+ const start = service.schedule?.firstSessionStart ?? "";
87
+ const end = service.schedule?.lastSessionEnd ?? "";
88
+ // The schedule has no zone beside these fields. A local wall time must not be interpreted
89
+ // in the device's zone and presented as a confirmed business end instant.
90
+ if (!HAS_OFFSET.test(start) || !HAS_OFFSET.test(end)) return false;
91
+ const first = validDate(start)?.getTime() ?? NaN;
92
+ const last = validDate(end)?.getTime() ?? NaN;
93
+ return Number.isFinite(now) && Number.isFinite(first) && Number.isFinite(last) && first <= last && last <= now;
94
+ }
95
+
83
96
  /** Whether the business takes course bookings at `now` (ms): the course has not ended, has not
84
97
  * started (unless the policy lets people join late), and `now` is inside its booking window —
85
98
  * which the early and late limits count from the first session. */
@@ -238,6 +251,14 @@ export function serviceDurationText(service: PricedServiceShape, label: BookingL
238
251
 
239
252
  function validDate(value: string | undefined): Date | undefined {
240
253
  if (!value) return undefined;
254
+ const parts = /^(\d{4})-(\d{2})-(\d{2})(?:T|$)/i.exec(value);
255
+ if (!parts) return undefined;
256
+ const year = Number(parts[1]), month = Number(parts[2]) - 1, day = Number(parts[3]);
257
+ // Date accepts February 30 by rolling into March. Check the stated calendar day first;
258
+ // setUTCFullYear also preserves years 0–99, which Date.UTC would reinterpret as 1900–1999.
259
+ const calendar = new Date(0);
260
+ calendar.setUTCFullYear(year, month, day);
261
+ if (calendar.getUTCFullYear() !== year || calendar.getUTCMonth() !== month || calendar.getUTCDate() !== day) return undefined;
241
262
  const date = new Date(value);
242
263
  return Number.isFinite(date.getTime()) ? date : undefined;
243
264
  }
@@ -21,6 +21,7 @@
21
21
  export type CopyPackValue = string | { source: string; translation: string };
22
22
 
23
23
  let stashedCopy: Record<string, CopyPackValue> = Object.create(null);
24
+ let copyReceived = false;
24
25
 
25
26
  /** Absorb the `copy` onAppear param (customer strings or stock translation records)
26
27
  * from an action arg. Call first in every action that can receive onAppear
@@ -44,15 +45,19 @@ export function stashCopy(arg: unknown): void {
44
45
  }
45
46
  }
46
47
  stashedCopy = next;
48
+ copyReceived = true;
47
49
  } catch {
48
50
  // Malformed pack param — keep the stock copy rather than dying mid-action.
49
51
  }
50
52
  }
51
53
 
52
- /** The provisioned override for `key`, or `fallback` (the stock copy) when no
53
- * pack / no such key reached this worker. */
54
- export function copyParam(key: string, fallback: string): string {
54
+ /** The provisioned override for `key`, or `fallback` (the stock copy). An optional
55
+ * restored label survives only until a current copy pack arrives. */
56
+ export function copyParam(key: string, fallback: string, restoredFallback = fallback): string {
55
57
  const value = stashedCopy[key];
58
+ // A disk-restored row may paint before onAppear supplies its copy pack. Keep its
59
+ // last label only before a pack arrives; even an empty current pack selects stock copy.
60
+ if (value === undefined) return copyReceived ? fallback : restoredFallback;
56
61
  if (typeof value === "string") return value !== "" ? value : fallback;
57
62
  return value?.source === fallback && value.translation !== "" ? value.translation : fallback;
58
63
  }
@@ -10,7 +10,7 @@
10
10
  import { defineBackendCollection } from "../_shared/collection-service.js";
11
11
  import { COLLECTION_CHROME_MIN_ITEMS } from "../_shared/collection-contract.js";
12
12
  import { queryServiceCategories, queryServices, type WixService } from "@himalaya/wix-bookings";
13
- import { categoryName, courseDatesText, serviceDurationText, servicePriceLine, type BookingLabel } from "../_shared/himi-bookings.js";
13
+ import { categoryName, courseDatesText, courseHasEnded, serviceDurationText, servicePriceLine, type BookingLabel } from "../_shared/himi-bookings.js";
14
14
  import { formatServicePrice } from "../_shared/wix-service-price.js";
15
15
  import { getAll, upsert, type DbEntity } from "@himalaya/local-db";
16
16
  import { initials } from "../_shared/himi-text.js";
@@ -60,6 +60,9 @@ interface Row {
60
60
  /** "Class" or "Course"; "" for an appointment. */
61
61
  typeTag: string;
62
62
  noTypeTag: boolean;
63
+ endedText: string;
64
+ /** Retain course dates so a cached catalog can refresh its status after the course ends. */
65
+ courseSchedule?: { firstSessionStart?: string; lastSessionEnd?: string };
63
66
  /** "1 hr · ₪70"; a course: "Nov 3 – Dec 15, 2026 · ₪1,200". "" when the record has neither. */
64
67
  meta: string;
65
68
  /** Online booking by time is possible: online booking is on and the service is not a course. */
@@ -186,8 +189,8 @@ function typeTagOf(type: unknown): string {
186
189
  return "";
187
190
  }
188
191
 
189
- function spokenName(row: Pick<Row, "name" | "typeTag" | "tagline" | "meta">): string {
190
- return [row.name, row.typeTag, row.tagline, row.meta].filter(Boolean).join(", ");
192
+ function spokenName(row: Pick<Row, "name" | "typeTag" | "tagline" | "meta" | "endedText">): string {
193
+ return [row.name, row.typeTag, row.endedText, row.tagline, row.meta].filter(Boolean).join(", ");
191
194
  }
192
195
 
193
196
  function bookCtaFor(request: boolean): string {
@@ -212,6 +215,11 @@ function norm(service: WixService): Row {
212
215
  ].filter(Boolean);
213
216
  const image = imageUrl(service);
214
217
  const isCourse = service.type === "COURSE";
218
+ const courseSchedule = isCourse ? {
219
+ firstSessionStart: service.schedule?.firstSessionStart,
220
+ lastSessionEnd: service.schedule?.lastSessionEnd,
221
+ } : undefined;
222
+ const endedText = isCourse && courseHasEnded(service, Date.now()) ? copyParam("services.ended", "Ended") : "";
215
223
  const price = servicePriceLine(service, listLabel).text;
216
224
  const meta = [isCourse ? courseDatesText(service) : serviceDurationText(service, listLabel), price].filter(Boolean).join(" · ");
217
225
  const typeTag = typeTagOf(service.type);
@@ -234,18 +242,20 @@ function norm(service: WixService): Row {
234
242
  tagline,
235
243
  typeTag,
236
244
  noTypeTag: !typeTag,
245
+ endedText,
246
+ courseSchedule,
237
247
  meta,
238
248
  canBook,
239
249
  noBook: !canBook,
240
250
  bookCta: bookCtaFor(request),
241
251
  bookA11y: bookLabel(name, request),
242
- a11y: spokenName({ name, typeTag, tagline, meta }),
252
+ a11y: spokenName({ name, typeTag, endedText, tagline, meta }),
243
253
  };
244
254
  }
245
255
 
246
256
  /** A row saved by an older worker (offline cache, restored state) has none of the new
247
257
  * fields: it renders with no tagline, meta or tag, and no Book pill until the live read. */
248
- function withRowDefaults(row: Partial<Row>): Row {
258
+ function withRowDefaults(row: Partial<Row>, shownEnded?: string): Row {
249
259
  const name = typeof row.name === "string" && row.name ? row.name : "Service";
250
260
  const str = (v: unknown) => (typeof v === "string" ? v : "");
251
261
  const image = str(row.image);
@@ -253,6 +263,9 @@ function withRowDefaults(row: Partial<Row>): Row {
253
263
  const tagline = str(row.tagline);
254
264
  const meta = str(row.meta);
255
265
  const canBook = row.canBook === true;
266
+ const courseSchedule = row.courseSchedule;
267
+ const endedText = courseSchedule && courseHasEnded({ schedule: courseSchedule }, Date.now())
268
+ ? copyParam("services.ended", "Ended", shownEnded || str(row.endedText) || "Ended") : "";
256
269
  return {
257
270
  ...row,
258
271
  id: str(row.id),
@@ -268,12 +281,13 @@ function withRowDefaults(row: Partial<Row>): Row {
268
281
  tagline,
269
282
  typeTag,
270
283
  noTypeTag: !typeTag,
284
+ endedText,
271
285
  meta,
272
286
  canBook,
273
287
  noBook: !canBook,
274
288
  bookCta: str(row.bookCta) || bookCtaFor(false),
275
289
  bookA11y: str(row.bookA11y) || bookLabel(name),
276
- a11y: str(row.a11y) || spokenName({ name, typeTag, tagline, meta }),
290
+ a11y: spokenName({ name, typeTag, endedText, tagline, meta }),
277
291
  };
278
292
  }
279
293
 
@@ -287,6 +301,9 @@ let categoriesInFlight = false;
287
301
  let categoriesFailedAt = 0;
288
302
  const CATEGORY_RETRY_MS = 60_000;
289
303
  const rowCategories = new Map<string, number>();
304
+ /** A restored Ended label bridges only the first live page after boot, because an app that sends no
305
+ * copy pack gives no sign that none is coming: after it, current copy (pack or stock) decides. */
306
+ let restoredEndedBridge = true;
290
307
 
291
308
  /** Repaint the chips only, on the state already shown. Re-deriving from the query controller
292
309
  * would publish its working state, which a revalidation deliberately holds back (a restored list
@@ -302,7 +319,7 @@ function withChips(s: S): S {
302
319
  // rows of the whole list (no category, no query), or the stand-in would be one category.
303
320
  const whole = filter === "all" && !(typeof collection?.query === "string" && collection.query.trim());
304
321
  // 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));
322
+ if (!siteCategories?.length && rowCategories.size === 0 && whole && s.isLoading !== true) rememberRowCategories((s.results ?? []).map((row) => withRowDefaults(row)));
306
323
  const chips = categoryChips(filter);
307
324
  return { ...s, categoryChips: chips, hideCategories: collection?.hideChrome === true || (chips.length < 3 && !filter.startsWith("value:")) };
308
325
  }
@@ -345,6 +362,11 @@ function categoryChips(filter: string): CategoryChip[] {
345
362
 
346
363
  const svc = defineBackendCollection<S, Row>("himiBookingServices", {
347
364
  restorePagesOnLoad: true,
365
+ onLoad() {
366
+ // Copy is stashed before this hook. Repaint restored labels immediately, even
367
+ // when the background query holds the old page through a slow or failed read.
368
+ (svc as unknown as { normalizeRestored?: () => unknown }).normalizeRestored?.();
369
+ },
348
370
  state: { status: "loading", query: "", all: [], results: [], emptyText: "", countLabel: "", errored: false,
349
371
  notErrored: true, isLoading: true, notLoading: false, hasResults: false, noEmpty: true,
350
372
  categoryChips: [], hideCategories: true, hideRows: false, noMatchText: "", hasQuery: false, noQuery: true, categoriesLabel: "" },
@@ -377,11 +399,10 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
377
399
  },
378
400
  actions: {
379
401
  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.
402
+ // Normalize old snapshots and refresh Ended/copy before revalidation settles.
403
+ // A missing `noBook` must never show Book on a restored course.
382
404
  normalizeRestored: (s) => {
383
405
  const rows = Array.isArray(s.results) ? s.results : [];
384
- if (rows.every((row) => typeof (row as Partial<Row>).noBook === "boolean")) return s;
385
406
  const fixed = rows.map((row) => withRowDefaults(row as Partial<Row>));
386
407
  return { ...s, results: fixed, all: fixed };
387
408
  },
@@ -389,7 +410,11 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
389
410
  apply(s, page) {
390
411
  const loading = page.status === "loading";
391
412
  const errored = page.status === "error";
392
- const rows = page.items.map(withRowDefaults);
413
+ // A live row is built before a cold boot's copy pack arrives: the first live page keeps the
414
+ // Ended label the restored row for the same course showed, until a current pack replaces it.
415
+ const shown = new Map((restoredEndedBridge && Array.isArray(s.results) ? s.results : []).map((row) => [row.id, row.endedText]));
416
+ const rows = page.items.map((item) => withRowDefaults(item, shown.get(item.id)));
417
+ if (page.metadata?.fromCache === false && !loading && !errored) restoredEndedBridge = false;
393
418
  // The stand-in categories come from the accepted rows of the whole list (a superseded
394
419
  // response never reaches apply): a live page replaces them, the device's copy only fills a gap.
395
420
  if (!page.criteria.query && page.criteria.filter === "all" && rows.length > 0) {
@@ -427,7 +452,6 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
427
452
  // (there is no Refine now): both go back to the business's own view before the first read.
428
453
  type Retired = { getState?: () => { collection?: { filter?: unknown; sort?: unknown } };
429
454
  collectionFilter?: (arg: unknown) => unknown; collectionSort?: (arg: unknown) => unknown };
430
- (svc as unknown as { normalizeRestored?: () => unknown }).normalizeRestored?.();
431
455
  const retired = svc as unknown as Retired;
432
456
  const persisted = retired.getState?.()?.collection;
433
457
  if (persisted?.filter === "saved") void retired.collectionFilter?.({ selectedIds: ["all"] });
@@ -21,6 +21,7 @@
21
21
  export type CopyPackValue = string | { source: string; translation: string };
22
22
 
23
23
  let stashedCopy: Record<string, CopyPackValue> = Object.create(null);
24
+ let copyReceived = false;
24
25
 
25
26
  /** Absorb the `copy` onAppear param (customer strings or stock translation records)
26
27
  * from an action arg. Call first in every action that can receive onAppear
@@ -44,15 +45,19 @@ export function stashCopy(arg: unknown): void {
44
45
  }
45
46
  }
46
47
  stashedCopy = next;
48
+ copyReceived = true;
47
49
  } catch {
48
50
  // Malformed pack param — keep the stock copy rather than dying mid-action.
49
51
  }
50
52
  }
51
53
 
52
- /** The provisioned override for `key`, or `fallback` (the stock copy) when no
53
- * pack / no such key reached this worker. */
54
- export function copyParam(key: string, fallback: string): string {
54
+ /** The provisioned override for `key`, or `fallback` (the stock copy). An optional
55
+ * restored label survives only until a current copy pack arrives. */
56
+ export function copyParam(key: string, fallback: string, restoredFallback = fallback): string {
55
57
  const value = stashedCopy[key];
58
+ // A disk-restored row may paint before onAppear supplies its copy pack. Keep its
59
+ // last label only before a pack arrives; even an empty current pack selects stock copy.
60
+ if (value === undefined) return copyReceived ? fallback : restoredFallback;
56
61
  if (typeof value === "string") return value !== "" ? value : fallback;
57
62
  return value?.source === fallback && value.translation !== "" ? value.translation : fallback;
58
63
  }
@@ -22326,6 +22326,20 @@ const SCREENS = runtimeSetting({
22326
22326
  "hiddenPath": "$item.noTypeTag"
22327
22327
  }
22328
22328
  },
22329
+ {
22330
+ "id": "himi_services.row.$item.id/ended",
22331
+ "body": {
22332
+ "package": "himalaya",
22333
+ "type": "Text",
22334
+ "props": {
22335
+ "text": "$item.endedText",
22336
+ "style": "caption",
22337
+ "color": "textSecondary",
22338
+ "maxLines": 1,
22339
+ "collapseWhenEmpty": true
22340
+ }
22341
+ }
22342
+ },
22329
22343
  {
22330
22344
  "id": "",
22331
22345
  "body": {
@@ -80,6 +80,19 @@ type CoursePolicyShape = {
80
80
  };
81
81
  };
82
82
 
83
+ /** A completed course, independent of whether bookings are currently allowed. Missing,
84
+ * invalid or reversed dates are unknown, never evidence that a course has ended. */
85
+ export function courseHasEnded(service: CoursePolicyShape, now: number): boolean {
86
+ const start = service.schedule?.firstSessionStart ?? "";
87
+ const end = service.schedule?.lastSessionEnd ?? "";
88
+ // The schedule has no zone beside these fields. A local wall time must not be interpreted
89
+ // in the device's zone and presented as a confirmed business end instant.
90
+ if (!HAS_OFFSET.test(start) || !HAS_OFFSET.test(end)) return false;
91
+ const first = validDate(start)?.getTime() ?? NaN;
92
+ const last = validDate(end)?.getTime() ?? NaN;
93
+ return Number.isFinite(now) && Number.isFinite(first) && Number.isFinite(last) && first <= last && last <= now;
94
+ }
95
+
83
96
  /** Whether the business takes course bookings at `now` (ms): the course has not ended, has not
84
97
  * started (unless the policy lets people join late), and `now` is inside its booking window —
85
98
  * which the early and late limits count from the first session. */
@@ -238,6 +251,14 @@ export function serviceDurationText(service: PricedServiceShape, label: BookingL
238
251
 
239
252
  function validDate(value: string | undefined): Date | undefined {
240
253
  if (!value) return undefined;
254
+ const parts = /^(\d{4})-(\d{2})-(\d{2})(?:T|$)/i.exec(value);
255
+ if (!parts) return undefined;
256
+ const year = Number(parts[1]), month = Number(parts[2]) - 1, day = Number(parts[3]);
257
+ // Date accepts February 30 by rolling into March. Check the stated calendar day first;
258
+ // setUTCFullYear also preserves years 0–99, which Date.UTC would reinterpret as 1900–1999.
259
+ const calendar = new Date(0);
260
+ calendar.setUTCFullYear(year, month, day);
261
+ if (calendar.getUTCFullYear() !== year || calendar.getUTCMonth() !== month || calendar.getUTCDate() !== day) return undefined;
241
262
  const date = new Date(value);
242
263
  return Number.isFinite(date.getTime()) ? date : undefined;
243
264
  }
@@ -21,6 +21,7 @@
21
21
  export type CopyPackValue = string | { source: string; translation: string };
22
22
 
23
23
  let stashedCopy: Record<string, CopyPackValue> = Object.create(null);
24
+ let copyReceived = false;
24
25
 
25
26
  /** Absorb the `copy` onAppear param (customer strings or stock translation records)
26
27
  * from an action arg. Call first in every action that can receive onAppear
@@ -44,15 +45,19 @@ export function stashCopy(arg: unknown): void {
44
45
  }
45
46
  }
46
47
  stashedCopy = next;
48
+ copyReceived = true;
47
49
  } catch {
48
50
  // Malformed pack param — keep the stock copy rather than dying mid-action.
49
51
  }
50
52
  }
51
53
 
52
- /** The provisioned override for `key`, or `fallback` (the stock copy) when no
53
- * pack / no such key reached this worker. */
54
- export function copyParam(key: string, fallback: string): string {
54
+ /** The provisioned override for `key`, or `fallback` (the stock copy). An optional
55
+ * restored label survives only until a current copy pack arrives. */
56
+ export function copyParam(key: string, fallback: string, restoredFallback = fallback): string {
55
57
  const value = stashedCopy[key];
58
+ // A disk-restored row may paint before onAppear supplies its copy pack. Keep its
59
+ // last label only before a pack arrives; even an empty current pack selects stock copy.
60
+ if (value === undefined) return copyReceived ? fallback : restoredFallback;
56
61
  if (typeof value === "string") return value !== "" ? value : fallback;
57
62
  return value?.source === fallback && value.translation !== "" ? value.translation : fallback;
58
63
  }
@@ -10,7 +10,7 @@
10
10
  import { defineBackendCollection } from "../_shared/collection-service.js";
11
11
  import { COLLECTION_CHROME_MIN_ITEMS } from "../_shared/collection-contract.js";
12
12
  import { queryServiceCategories, queryServices, type WixService } from "@himalaya/wix-bookings";
13
- import { categoryName, courseDatesText, serviceDurationText, servicePriceLine, type BookingLabel } from "../_shared/himi-bookings.js";
13
+ import { categoryName, courseDatesText, courseHasEnded, serviceDurationText, servicePriceLine, type BookingLabel } from "../_shared/himi-bookings.js";
14
14
  import { formatServicePrice } from "../_shared/wix-service-price.js";
15
15
  import { getAll, upsert, type DbEntity } from "@himalaya/local-db";
16
16
  import { initials } from "../_shared/himi-text.js";
@@ -60,6 +60,9 @@ interface Row {
60
60
  /** "Class" or "Course"; "" for an appointment. */
61
61
  typeTag: string;
62
62
  noTypeTag: boolean;
63
+ endedText: string;
64
+ /** Retain course dates so a cached catalog can refresh its status after the course ends. */
65
+ courseSchedule?: { firstSessionStart?: string; lastSessionEnd?: string };
63
66
  /** "1 hr · ₪70"; a course: "Nov 3 – Dec 15, 2026 · ₪1,200". "" when the record has neither. */
64
67
  meta: string;
65
68
  /** Online booking by time is possible: online booking is on and the service is not a course. */
@@ -186,8 +189,8 @@ function typeTagOf(type: unknown): string {
186
189
  return "";
187
190
  }
188
191
 
189
- function spokenName(row: Pick<Row, "name" | "typeTag" | "tagline" | "meta">): string {
190
- return [row.name, row.typeTag, row.tagline, row.meta].filter(Boolean).join(", ");
192
+ function spokenName(row: Pick<Row, "name" | "typeTag" | "tagline" | "meta" | "endedText">): string {
193
+ return [row.name, row.typeTag, row.endedText, row.tagline, row.meta].filter(Boolean).join(", ");
191
194
  }
192
195
 
193
196
  function bookCtaFor(request: boolean): string {
@@ -212,6 +215,11 @@ function norm(service: WixService): Row {
212
215
  ].filter(Boolean);
213
216
  const image = imageUrl(service);
214
217
  const isCourse = service.type === "COURSE";
218
+ const courseSchedule = isCourse ? {
219
+ firstSessionStart: service.schedule?.firstSessionStart,
220
+ lastSessionEnd: service.schedule?.lastSessionEnd,
221
+ } : undefined;
222
+ const endedText = isCourse && courseHasEnded(service, Date.now()) ? copyParam("services.ended", "Ended") : "";
215
223
  const price = servicePriceLine(service, listLabel).text;
216
224
  const meta = [isCourse ? courseDatesText(service) : serviceDurationText(service, listLabel), price].filter(Boolean).join(" · ");
217
225
  const typeTag = typeTagOf(service.type);
@@ -234,18 +242,20 @@ function norm(service: WixService): Row {
234
242
  tagline,
235
243
  typeTag,
236
244
  noTypeTag: !typeTag,
245
+ endedText,
246
+ courseSchedule,
237
247
  meta,
238
248
  canBook,
239
249
  noBook: !canBook,
240
250
  bookCta: bookCtaFor(request),
241
251
  bookA11y: bookLabel(name, request),
242
- a11y: spokenName({ name, typeTag, tagline, meta }),
252
+ a11y: spokenName({ name, typeTag, endedText, tagline, meta }),
243
253
  };
244
254
  }
245
255
 
246
256
  /** A row saved by an older worker (offline cache, restored state) has none of the new
247
257
  * fields: it renders with no tagline, meta or tag, and no Book pill until the live read. */
248
- function withRowDefaults(row: Partial<Row>): Row {
258
+ function withRowDefaults(row: Partial<Row>, shownEnded?: string): Row {
249
259
  const name = typeof row.name === "string" && row.name ? row.name : "Service";
250
260
  const str = (v: unknown) => (typeof v === "string" ? v : "");
251
261
  const image = str(row.image);
@@ -253,6 +263,9 @@ function withRowDefaults(row: Partial<Row>): Row {
253
263
  const tagline = str(row.tagline);
254
264
  const meta = str(row.meta);
255
265
  const canBook = row.canBook === true;
266
+ const courseSchedule = row.courseSchedule;
267
+ const endedText = courseSchedule && courseHasEnded({ schedule: courseSchedule }, Date.now())
268
+ ? copyParam("services.ended", "Ended", shownEnded || str(row.endedText) || "Ended") : "";
256
269
  return {
257
270
  ...row,
258
271
  id: str(row.id),
@@ -268,12 +281,13 @@ function withRowDefaults(row: Partial<Row>): Row {
268
281
  tagline,
269
282
  typeTag,
270
283
  noTypeTag: !typeTag,
284
+ endedText,
271
285
  meta,
272
286
  canBook,
273
287
  noBook: !canBook,
274
288
  bookCta: str(row.bookCta) || bookCtaFor(false),
275
289
  bookA11y: str(row.bookA11y) || bookLabel(name),
276
- a11y: str(row.a11y) || spokenName({ name, typeTag, tagline, meta }),
290
+ a11y: spokenName({ name, typeTag, endedText, tagline, meta }),
277
291
  };
278
292
  }
279
293
 
@@ -287,6 +301,9 @@ let categoriesInFlight = false;
287
301
  let categoriesFailedAt = 0;
288
302
  const CATEGORY_RETRY_MS = 60_000;
289
303
  const rowCategories = new Map<string, number>();
304
+ /** A restored Ended label bridges only the first live page after boot, because an app that sends no
305
+ * copy pack gives no sign that none is coming: after it, current copy (pack or stock) decides. */
306
+ let restoredEndedBridge = true;
290
307
 
291
308
  /** Repaint the chips only, on the state already shown. Re-deriving from the query controller
292
309
  * would publish its working state, which a revalidation deliberately holds back (a restored list
@@ -302,7 +319,7 @@ function withChips(s: S): S {
302
319
  // rows of the whole list (no category, no query), or the stand-in would be one category.
303
320
  const whole = filter === "all" && !(typeof collection?.query === "string" && collection.query.trim());
304
321
  // 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));
322
+ if (!siteCategories?.length && rowCategories.size === 0 && whole && s.isLoading !== true) rememberRowCategories((s.results ?? []).map((row) => withRowDefaults(row)));
306
323
  const chips = categoryChips(filter);
307
324
  return { ...s, categoryChips: chips, hideCategories: collection?.hideChrome === true || (chips.length < 3 && !filter.startsWith("value:")) };
308
325
  }
@@ -345,6 +362,11 @@ function categoryChips(filter: string): CategoryChip[] {
345
362
 
346
363
  const svc = defineBackendCollection<S, Row>("himiBookingServices", {
347
364
  restorePagesOnLoad: true,
365
+ onLoad() {
366
+ // Copy is stashed before this hook. Repaint restored labels immediately, even
367
+ // when the background query holds the old page through a slow or failed read.
368
+ (svc as unknown as { normalizeRestored?: () => unknown }).normalizeRestored?.();
369
+ },
348
370
  state: { status: "loading", query: "", all: [], results: [], emptyText: "", countLabel: "", errored: false,
349
371
  notErrored: true, isLoading: true, notLoading: false, hasResults: false, noEmpty: true,
350
372
  categoryChips: [], hideCategories: true, hideRows: false, noMatchText: "", hasQuery: false, noQuery: true, categoriesLabel: "" },
@@ -377,11 +399,10 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
377
399
  },
378
400
  actions: {
379
401
  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.
402
+ // Normalize old snapshots and refresh Ended/copy before revalidation settles.
403
+ // A missing `noBook` must never show Book on a restored course.
382
404
  normalizeRestored: (s) => {
383
405
  const rows = Array.isArray(s.results) ? s.results : [];
384
- if (rows.every((row) => typeof (row as Partial<Row>).noBook === "boolean")) return s;
385
406
  const fixed = rows.map((row) => withRowDefaults(row as Partial<Row>));
386
407
  return { ...s, results: fixed, all: fixed };
387
408
  },
@@ -389,7 +410,11 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
389
410
  apply(s, page) {
390
411
  const loading = page.status === "loading";
391
412
  const errored = page.status === "error";
392
- const rows = page.items.map(withRowDefaults);
413
+ // A live row is built before a cold boot's copy pack arrives: the first live page keeps the
414
+ // Ended label the restored row for the same course showed, until a current pack replaces it.
415
+ const shown = new Map((restoredEndedBridge && Array.isArray(s.results) ? s.results : []).map((row) => [row.id, row.endedText]));
416
+ const rows = page.items.map((item) => withRowDefaults(item, shown.get(item.id)));
417
+ if (page.metadata?.fromCache === false && !loading && !errored) restoredEndedBridge = false;
393
418
  // The stand-in categories come from the accepted rows of the whole list (a superseded
394
419
  // response never reaches apply): a live page replaces them, the device's copy only fills a gap.
395
420
  if (!page.criteria.query && page.criteria.filter === "all" && rows.length > 0) {
@@ -427,7 +452,6 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
427
452
  // (there is no Refine now): both go back to the business's own view before the first read.
428
453
  type Retired = { getState?: () => { collection?: { filter?: unknown; sort?: unknown } };
429
454
  collectionFilter?: (arg: unknown) => unknown; collectionSort?: (arg: unknown) => unknown };
430
- (svc as unknown as { normalizeRestored?: () => unknown }).normalizeRestored?.();
431
455
  const retired = svc as unknown as Retired;
432
456
  const persisted = retired.getState?.()?.collection;
433
457
  if (persisted?.filter === "saved") void retired.collectionFilter?.({ selectedIds: ["all"] });
@@ -193,9 +193,47 @@ const PRICING_NOT_HYDRATED = ["rows", "collection", "hidePriceRetry"] as const;
193
193
  let content: { status: State["status"]; programs: ProgramContent[] } = { status: "loading", programs: [] };
194
194
  let scopeIncomplete = false;
195
195
  let loadSeq = 0;
196
+ const object = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
197
+ function sectionOrder(value: unknown): string[] {
198
+ try { if (typeof value === "string") value = JSON.parse(value); } catch { return []; }
199
+ const settings = object(object(value).settings);
200
+ const sorting = object(settings.sortingSettings);
201
+ return sorting.ordering === "MANUAL" && Array.isArray(sorting.challengesOrder)
202
+ ? sorting.challengesOrder.filter((id): id is string => typeof id === "string" && !!id)
203
+ : [];
204
+ }
205
+ const routeInput = () => (globalThis as { himalaya?: { input?: Record<string, unknown> } }).himalaya?.input;
206
+ /** The whole route input identifies the route, so a change to any field ends the previous section's order. */
207
+ const inputKey = () => { try { return JSON.stringify(routeInput() ?? {}); } catch { return ""; } };
208
+ let lastInputKey = inputKey();
209
+ let manualOrder = sectionOrder(routeInput()?.programsSection);
210
+
211
+ /** Source ordering promotes configured IDs; it never selects a subset of the catalog. */
212
+ function orderRows(rows: ProgramRow[]): ProgramRow[] {
213
+ if (!manualOrder.length) return rows;
214
+ const remaining = new Map(rows.map(row => [row.id, row]));
215
+ const ordered: ProgramRow[] = [];
216
+ for (const id of manualOrder) {
217
+ const row = remaining.get(id);
218
+ if (row) { ordered.push(row); remaining.delete(id); }
219
+ }
220
+ return [...ordered, ...remaining.values()];
221
+ }
196
222
 
197
223
  async function load(_state: State, arg?: unknown): Promise<undefined> {
198
224
  stashCopy(arg);
225
+ const params = object(arg);
226
+ const hasSection = Object.prototype.hasOwnProperty.call(params, "programsSection");
227
+ const nextInputKey = inputKey();
228
+ const inputChanged = nextInputKey !== lastInputKey;
229
+ if (inputChanged) {
230
+ lastInputKey = nextInputKey;
231
+ manualOrder = sectionOrder(routeInput()?.programsSection);
232
+ }
233
+ if (hasSection) {
234
+ manualOrder = sectionOrder(params.programsSection);
235
+ }
236
+ if (inputChanged || hasSection) void svc.pricingPublish();
199
237
  const seq = ++loadSeq;
200
238
  const programs = await fetchPrograms(() => seq === loadSeq).catch(() => undefined);
201
239
  if (seq !== loadSeq) return undefined;
@@ -220,8 +258,8 @@ function pricedState(state: State): State {
220
258
  const item = onlineProgramItem({ id: program.wixId });
221
259
  return item ? pricing.view(item.reference) : undefined;
222
260
  });
223
- const rows = content.programs.map(({ wixId: _wixId, ...row }, i) =>
224
- ({ ...row, priceLine: views[i] ? iapPriceFields(views[i]!, copy).priceText : "" }));
261
+ const rows = orderRows(content.programs.map(({ wixId: _wixId, ...row }, i) =>
262
+ ({ ...row, priceLine: views[i] ? iapPriceFields(views[i]!, copy).priceText : "" })));
225
263
  const shown = content.status === "ready" ? ready(rows) : content.status === "error" ? errored({ ...state, rows }) : INITIAL_STATE;
226
264
  return {
227
265
  ...state, ...shown, programs: content.programs,
@@ -21,6 +21,7 @@
21
21
  export type CopyPackValue = string | { source: string; translation: string };
22
22
 
23
23
  let stashedCopy: Record<string, CopyPackValue> = Object.create(null);
24
+ let copyReceived = false;
24
25
 
25
26
  /** Absorb the `copy` onAppear param (customer strings or stock translation records)
26
27
  * from an action arg. Call first in every action that can receive onAppear
@@ -44,15 +45,19 @@ export function stashCopy(arg: unknown): void {
44
45
  }
45
46
  }
46
47
  stashedCopy = next;
48
+ copyReceived = true;
47
49
  } catch {
48
50
  // Malformed pack param — keep the stock copy rather than dying mid-action.
49
51
  }
50
52
  }
51
53
 
52
- /** The provisioned override for `key`, or `fallback` (the stock copy) when no
53
- * pack / no such key reached this worker. */
54
- export function copyParam(key: string, fallback: string): string {
54
+ /** The provisioned override for `key`, or `fallback` (the stock copy). An optional
55
+ * restored label survives only until a current copy pack arrives. */
56
+ export function copyParam(key: string, fallback: string, restoredFallback = fallback): string {
55
57
  const value = stashedCopy[key];
58
+ // A disk-restored row may paint before onAppear supplies its copy pack. Keep its
59
+ // last label only before a pack arrives; even an empty current pack selects stock copy.
60
+ if (value === undefined) return copyReceived ? fallback : restoredFallback;
56
61
  if (typeof value === "string") return value !== "" ? value : fallback;
57
62
  return value?.source === fallback && value.translation !== "" ? value.translation : fallback;
58
63
  }