@wix/himalaya-cli 0.1537.0 → 0.1539.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 (140) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/state/src/index.d.ts +7 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/stores-v1/src/display.d.ts +4 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/state.mjs +23 -0
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-events.mjs +1 -0
  5. package/dist/cli.mjs +50 -7
  6. package/guides/component-reference.json +24 -39
  7. package/guides/component-reference.md +59 -13
  8. package/guides/dynamic-list.service.ts +7 -1
  9. package/guides/form-submit.service.ts +7 -1
  10. package/guides/himi-json-output.md +4 -2
  11. package/guides/iap-products.md +13 -5
  12. package/guides/index.json +2 -2
  13. package/guides/recipe-four-state.md +10 -1
  14. package/guides/sdk-reference.json +966 -46
  15. package/guides/search-list.service.ts +7 -1
  16. package/package.json +2 -2
  17. package/templates/beauty-salon/dev/index.ts +5021 -4164
  18. package/templates/beauty-salon/tier5-src/_shared/collection-contract.ts +9 -3
  19. package/templates/beauty-salon/tier5-src/_shared/collection-service.ts +6 -3
  20. package/templates/beauty-salon/tier5-src/_shared/engine-intl.ts +5 -2
  21. package/templates/beauty-salon/tier5-src/_shared/himi-store-categories.ts +18 -12
  22. package/templates/beauty-salon/tier5-src/_shared/store-order-presentation.ts +56 -0
  23. package/templates/beauty-salon/tier5-src/_shared/wix-service-price.ts +4 -2
  24. package/templates/beauty-salon/tier5-src/himi-branded-lite-category/index.ts +196 -0
  25. package/templates/beauty-salon/tier5-src/himi-branded-lite-order/index.ts +29 -6
  26. package/templates/beauty-salon/tier5-src/himi-branded-lite-orders/index.ts +58 -5
  27. package/templates/beauty-salon/tier5-src/wix-spaces-collections/index.ts +37 -88
  28. package/templates/beauty-salon-owner/tier5-src/_shared/collection-contract.ts +9 -3
  29. package/templates/beauty-salon-owner/tier5-src/_shared/collection-service.ts +6 -3
  30. package/templates/beauty-salon-owner/tier5-src/_shared/engine-intl.ts +5 -2
  31. package/templates/branded-lite/dev/index.ts +4933 -4079
  32. package/templates/branded-lite/tier5-src/_shared/collection-contract.ts +9 -3
  33. package/templates/branded-lite/tier5-src/_shared/collection-service.ts +6 -3
  34. package/templates/branded-lite/tier5-src/_shared/engine-intl.ts +5 -2
  35. package/templates/branded-lite/tier5-src/_shared/himi-store-categories.ts +18 -12
  36. package/templates/branded-lite/tier5-src/_shared/store-order-presentation.ts +56 -0
  37. package/templates/branded-lite/tier5-src/_shared/wix-service-price.ts +4 -2
  38. package/templates/branded-lite/tier5-src/himi-branded-lite-category/index.ts +196 -0
  39. package/templates/branded-lite/tier5-src/himi-branded-lite-order/index.ts +29 -6
  40. package/templates/branded-lite/tier5-src/himi-branded-lite-orders/index.ts +58 -5
  41. package/templates/branded-lite/tier5-src/wix-spaces-collections/index.ts +37 -88
  42. package/templates/community-club/tier5-src/_shared/collection-contract.ts +9 -3
  43. package/templates/community-club/tier5-src/_shared/collection-service.ts +6 -3
  44. package/templates/community-club/tier5-src/_shared/engine-intl.ts +5 -2
  45. package/templates/community-club/tier5-src/_shared/himi-store-categories.ts +18 -12
  46. package/templates/community-club/tier5-src/_shared/wix-service-price.ts +4 -2
  47. package/templates/community-club-owner/tier5-src/_shared/collection-contract.ts +9 -3
  48. package/templates/community-club-owner/tier5-src/_shared/collection-service.ts +6 -3
  49. package/templates/community-club-owner/tier5-src/_shared/engine-intl.ts +5 -2
  50. package/templates/events-meetups/tier5-src/_shared/collection-contract.ts +9 -3
  51. package/templates/events-meetups/tier5-src/_shared/collection-service.ts +6 -3
  52. package/templates/events-meetups/tier5-src/_shared/engine-intl.ts +5 -2
  53. package/templates/events-meetups/tier5-src/_shared/himi-store-categories.ts +18 -12
  54. package/templates/events-meetups/tier5-src/_shared/wix-service-price.ts +4 -2
  55. package/templates/events-meetups-owner/tier5-src/_shared/collection-contract.ts +9 -3
  56. package/templates/events-meetups-owner/tier5-src/_shared/collection-service.ts +6 -3
  57. package/templates/events-meetups-owner/tier5-src/_shared/engine-intl.ts +5 -2
  58. package/templates/gym-fitness/dev/index.ts +5018 -4161
  59. package/templates/gym-fitness/tier5-src/_shared/collection-contract.ts +9 -3
  60. package/templates/gym-fitness/tier5-src/_shared/collection-service.ts +6 -3
  61. package/templates/gym-fitness/tier5-src/_shared/engine-intl.ts +5 -2
  62. package/templates/gym-fitness/tier5-src/_shared/himi-store-categories.ts +18 -12
  63. package/templates/gym-fitness/tier5-src/_shared/store-order-presentation.ts +56 -0
  64. package/templates/gym-fitness/tier5-src/_shared/wix-service-price.ts +4 -2
  65. package/templates/gym-fitness/tier5-src/himi-branded-lite-category/index.ts +196 -0
  66. package/templates/gym-fitness/tier5-src/himi-branded-lite-order/index.ts +29 -6
  67. package/templates/gym-fitness/tier5-src/himi-branded-lite-orders/index.ts +58 -5
  68. package/templates/gym-fitness/tier5-src/wix-spaces-collections/index.ts +37 -88
  69. package/templates/gym-fitness-owner/tier5-src/_shared/collection-contract.ts +9 -3
  70. package/templates/gym-fitness-owner/tier5-src/_shared/collection-service.ts +6 -3
  71. package/templates/gym-fitness-owner/tier5-src/_shared/engine-intl.ts +5 -2
  72. package/templates/nonprofit-cause/tier5-src/_shared/collection-contract.ts +9 -3
  73. package/templates/nonprofit-cause/tier5-src/_shared/collection-service.ts +6 -3
  74. package/templates/nonprofit-cause/tier5-src/_shared/engine-intl.ts +5 -2
  75. package/templates/nonprofit-cause/tier5-src/_shared/himi-store-categories.ts +18 -12
  76. package/templates/nonprofit-cause/tier5-src/_shared/wix-service-price.ts +4 -2
  77. package/templates/nonprofit-cause-owner/tier5-src/_shared/collection-contract.ts +9 -3
  78. package/templates/nonprofit-cause-owner/tier5-src/_shared/collection-service.ts +6 -3
  79. package/templates/nonprofit-cause-owner/tier5-src/_shared/engine-intl.ts +5 -2
  80. package/templates/owner-lite/tier5-src/_shared/collection-contract.ts +9 -3
  81. package/templates/owner-lite/tier5-src/_shared/collection-service.ts +6 -3
  82. package/templates/owner-lite/tier5-src/_shared/engine-intl.ts +5 -2
  83. package/templates/podcast-creator/tier5-src/_shared/collection-contract.ts +9 -3
  84. package/templates/podcast-creator/tier5-src/_shared/collection-service.ts +6 -3
  85. package/templates/podcast-creator/tier5-src/_shared/engine-intl.ts +5 -2
  86. package/templates/podcast-creator/tier5-src/_shared/himi-store-categories.ts +18 -12
  87. package/templates/podcast-creator/tier5-src/_shared/wix-service-price.ts +4 -2
  88. package/templates/podcast-creator-owner/tier5-src/_shared/collection-contract.ts +9 -3
  89. package/templates/podcast-creator-owner/tier5-src/_shared/collection-service.ts +6 -3
  90. package/templates/podcast-creator-owner/tier5-src/_shared/engine-intl.ts +5 -2
  91. package/templates/portfolio-creative/dev/index.ts +5017 -4157
  92. package/templates/portfolio-creative/tier5-src/_shared/collection-contract.ts +9 -3
  93. package/templates/portfolio-creative/tier5-src/_shared/collection-service.ts +6 -3
  94. package/templates/portfolio-creative/tier5-src/_shared/engine-intl.ts +5 -2
  95. package/templates/portfolio-creative/tier5-src/_shared/himi-store-categories.ts +18 -12
  96. package/templates/portfolio-creative/tier5-src/_shared/store-order-presentation.ts +56 -0
  97. package/templates/portfolio-creative/tier5-src/_shared/wix-service-price.ts +4 -2
  98. package/templates/portfolio-creative/tier5-src/himi-branded-lite-category/index.ts +196 -0
  99. package/templates/portfolio-creative/tier5-src/himi-branded-lite-order/index.ts +29 -6
  100. package/templates/portfolio-creative/tier5-src/himi-branded-lite-orders/index.ts +58 -5
  101. package/templates/portfolio-creative/tier5-src/wix-spaces-collections/index.ts +37 -88
  102. package/templates/portfolio-creative-owner/tier5-src/_shared/collection-contract.ts +9 -3
  103. package/templates/portfolio-creative-owner/tier5-src/_shared/collection-service.ts +6 -3
  104. package/templates/portfolio-creative-owner/tier5-src/_shared/engine-intl.ts +5 -2
  105. package/templates/restaurant/tier5-src/_shared/collection-contract.ts +9 -3
  106. package/templates/restaurant/tier5-src/_shared/collection-service.ts +6 -3
  107. package/templates/restaurant/tier5-src/_shared/engine-intl.ts +5 -2
  108. package/templates/restaurant/tier5-src/_shared/himi-store-categories.ts +18 -12
  109. package/templates/restaurant/tier5-src/_shared/wix-service-price.ts +4 -2
  110. package/templates/restaurant-owner/tier5-src/_shared/collection-contract.ts +9 -3
  111. package/templates/restaurant-owner/tier5-src/_shared/collection-service.ts +6 -3
  112. package/templates/restaurant-owner/tier5-src/_shared/engine-intl.ts +5 -2
  113. package/templates/retail-store/dev/index.ts +5159 -4302
  114. package/templates/retail-store/tier5-src/_shared/collection-contract.ts +9 -3
  115. package/templates/retail-store/tier5-src/_shared/collection-service.ts +6 -3
  116. package/templates/retail-store/tier5-src/_shared/engine-intl.ts +5 -2
  117. package/templates/retail-store/tier5-src/_shared/himi-store-categories.ts +18 -12
  118. package/templates/retail-store/tier5-src/_shared/store-order-presentation.ts +56 -0
  119. package/templates/retail-store/tier5-src/_shared/wix-service-price.ts +4 -2
  120. package/templates/retail-store/tier5-src/himi-branded-lite-category/index.ts +196 -0
  121. package/templates/retail-store/tier5-src/himi-branded-lite-order/index.ts +29 -6
  122. package/templates/retail-store/tier5-src/himi-branded-lite-orders/index.ts +58 -5
  123. package/templates/retail-store/tier5-src/wix-spaces-collections/index.ts +37 -88
  124. package/templates/retail-store-owner/tier5-src/_shared/collection-contract.ts +9 -3
  125. package/templates/retail-store-owner/tier5-src/_shared/collection-service.ts +6 -3
  126. package/templates/retail-store-owner/tier5-src/_shared/engine-intl.ts +5 -2
  127. package/templates/yoga-studio/dev/index.ts +0 -3
  128. package/templates/yoga-studio/tier5-src/_shared/collection-contract.ts +9 -3
  129. package/templates/yoga-studio/tier5-src/_shared/collection-service.ts +6 -3
  130. package/templates/yoga-studio/tier5-src/_shared/engine-intl.ts +5 -2
  131. package/templates/yoga-studio/tier5-src/_shared/himi-store-categories.ts +18 -12
  132. package/templates/yoga-studio/tier5-src/_shared/wix-service-price.ts +4 -2
  133. package/templates/yoga-studio-owner/tier5-src/_shared/collection-contract.ts +9 -3
  134. package/templates/yoga-studio-owner/tier5-src/_shared/collection-service.ts +6 -3
  135. package/templates/yoga-studio-owner/tier5-src/_shared/engine-intl.ts +5 -2
  136. package/templates/beauty-salon/tier5-src/wix-spaces-collection-detail/index.ts +0 -170
  137. package/templates/branded-lite/tier5-src/wix-spaces-collection-detail/index.ts +0 -170
  138. package/templates/gym-fitness/tier5-src/wix-spaces-collection-detail/index.ts +0 -170
  139. package/templates/portfolio-creative/tier5-src/wix-spaces-collection-detail/index.ts +0 -170
  140. package/templates/retail-store/tier5-src/wix-spaces-collection-detail/index.ts +0 -170
@@ -2,6 +2,8 @@ import { compareText, compareIso } from "./engine-intl.js";
2
2
  /** Shared OTA collection contract. No host APIs: used by workers and builders. */
3
3
  export interface CollectionConfig {
4
4
  rows: string;
5
+ /** Flat customer history rows, divided rather than boxed. */
6
+ rowStyle?: "flat";
5
7
  instanceInput?: string;
6
8
  title: string;
7
9
  subtitle?: string;
@@ -84,7 +86,7 @@ export const COLLECTIONS: Record<string, CollectionConfig> = {
84
86
  himiBrandedGroups: { rows: 'rows', title: 'name', subtitle: 'description', image: 'image', view: 'grid' },
85
87
  himiBrandedCms: { rows: 'rows', title: 'name', subtitle: 'subtitle', view: 'grid' },
86
88
  himiBrandedCmsCollection: { instanceInput: 'collection', rows: 'rows', title: 'title', subtitle: 'subtitle', view: 'list' },
87
- himiBrandedOrders: { rows: 'rows', title: 'title', subtitle: 'subtitle', view: 'list', backendQuery: true,
89
+ himiBrandedOrders: { rows: 'rows', title: 'title', subtitle: 'subtitle', image: 'image', metadata: ['status', 'meta'], rowStyle: 'flat', view: 'list', backendQuery: true,
88
90
  searchPlaceholder: 'Search by order number', countNoun: ['order', 'orders'], filterValues: ['Not fulfilled', 'Partially fulfilled', 'Fulfilled'],
89
91
  sortLabels: { default: 'Newest', az: 'Oldest', za: 'Order # ↓' } },
90
92
  himiBrandedPlans: { rows: 'rows', title: 'name', subtitle: 'description', metadata: ['badge', 'priceLine', 'periodLine', 'trialBadge', 'perk1', 'perk2', 'perk3', 'joinNote'], view: 'grid', gridColumns: 1, metaMaxLines: 8, countNoun: ['plan', 'plans'] },
@@ -139,7 +141,10 @@ export function normalizeCollection(config: CollectionConfig, value: unknown): C
139
141
  density: s.density === 'compact' ? 'compact' : 'comfortable',
140
142
  sort: s.sort === 'az' || s.sort === 'za' ? s.sort : 'default',
141
143
  query: typeof s.query === 'string' ? s.query.slice(0, 256) : '',
142
- filter: typeof s.filter === 'string' ? s.filter.slice(0, 256) : config.defaultFilter ?? 'all',
144
+ // Flat collections no longer offer Saved. Migrate its persisted value before
145
+ // the first backend request so an invisible old filter cannot empty the list.
146
+ filter: config.rowStyle === 'flat' && s.filter === 'saved' ? 'all'
147
+ : typeof s.filter === 'string' ? s.filter.slice(0, 256) : config.defaultFilter ?? 'all',
143
148
  savedIds: Array.isArray(s.savedIds) ? [...new Set(s.savedIds.filter((id): id is string => typeof id === 'string' && id.length > 0 && id.length <= 256))].slice(0, 500) : [],
144
149
  savedViews: Array.isArray(s.savedViews) ? s.savedViews.filter((v): v is SavedCollectionView => !!v && typeof v === 'object' && typeof v.name === 'string' && !!v.name.trim()).slice(0, 8).map((v, i) => ({
145
150
  id: `view-${i}`, name: v.name.trim().slice(0, 40),
@@ -147,7 +152,8 @@ export function normalizeCollection(config: CollectionConfig, value: unknown): C
147
152
  density: v.density === 'compact' ? 'compact' : 'comfortable',
148
153
  sort: v.sort === 'az' || v.sort === 'za' ? v.sort : 'default',
149
154
  query: typeof v.query === 'string' ? v.query.slice(0, 256) : '',
150
- filter: typeof v.filter === 'string' ? v.filter.slice(0, 256) : 'all',
155
+ filter: config.rowStyle === 'flat' && v.filter === 'saved' ? 'all'
156
+ : typeof v.filter === 'string' ? v.filter.slice(0, 256) : 'all',
151
157
  })) : [],
152
158
  };
153
159
  }
@@ -95,7 +95,10 @@ export function decorateCollection(name: string, def: { state: State; actions?:
95
95
  if (sort === 'default' || sort === 'az' || sort === 'za') { prefs.sort = sort; changed = true; }
96
96
  }
97
97
  if (Object.prototype.hasOwnProperty.call(input, 'collectionFilter')) {
98
- const filter = String(input.collectionFilter ?? '');
98
+ const requestedFilter = String(input.collectionFilter ?? '');
99
+ // Old links to Saved must migrate with the stored preference when this
100
+ // collection no longer exposes that filter.
101
+ const filter = config.rowStyle === 'flat' && requestedFilter === 'saved' ? 'all' : requestedFilter;
99
102
  const configured = config.filterValues ?? [];
100
103
  const rows = Array.isArray(state[config.rows]) ? state[config.rows] as CollectionRow[] : [];
101
104
  const derived = config.filter ? rows.map(row => String(row[config.filter!] ?? '')).filter(Boolean) : [];
@@ -139,7 +142,7 @@ export function decorateCollection(name: string, def: { state: State; actions?:
139
142
  copyParam('collection.itemActionsFor', 'Actions for {title}'), copyParam('collection.itemActions', 'Item actions'));
140
143
  });
141
144
  if (previewId && !rows.some(row => collectionId(row) === previewId)) previewId = '';
142
- const filterChoices = [{ id: 'all', label: copyParam('collection.all', 'All') }, { id: 'saved', label: copyParam('collection.saved', 'Saved') },
145
+ const filterChoices = [{ id: 'all', label: copyParam('collection.all', 'All') }, ...(config.rowStyle === 'flat' ? [] : [{ id: 'saved', label: copyParam('collection.saved', 'Saved') }]),
143
146
  ...projected.filters.map(label => ({ id: `value:${label}`, label: copyParam(`collection.filter.${label}`, label) }))];
144
147
  const loading = s.status === 'loading' || s.isLoading === true;
145
148
  // Older domain reducers expose `errored` rather than `isErrored`; backend
@@ -220,7 +223,7 @@ export function decorateCollection(name: string, def: { state: State; actions?:
220
223
  ? [copyParam(`collection.${name}.countOne`, config.countNoun[0]), copyParam(`collection.${name}.countMany`, config.countNoun[1])] : undefined }),
221
224
  selectedSummary: `${prefs.selectedIds.length} ${copyParam('collection.selected', 'selected')}`,
222
225
  filterChoices,
223
- chips: config.filterValues ? filterChoices.filter(choice => ['all', 'saved', prefs.filter].includes(choice.id)) : filterChoices,
226
+ chips: config.filterValues && config.rowStyle !== 'flat' ? filterChoices.filter(choice => ['all', 'saved', prefs.filter].includes(choice.id)) : filterChoices,
224
227
  } };
225
228
  };
226
229
  const actions: Record<string, Reducer> = {};
@@ -410,8 +410,11 @@ export function compareDateDesc(a: string, b: string): number {
410
410
  // kind of number. So: one money pattern, one clock, one range.
411
411
 
412
412
  // Money uses `formatCurrency(amount, currency, "en")` — the currency's NATURAL decimals
413
- // ("₪70.00", "¥500"). Passing 0 fraction digits for whole amounts is what made a list read
414
- // "₪70 · ₪69.50".
413
+ // ("₪70.00", "¥500") — for Shop, Menu and every other price. The one exception is a Bookings
414
+ // SERVICE price (`_shared/wix-service-price.ts`): it drops the cents of a whole amount ("₪70",
415
+ // "₪69.50"), the format the redesigned service screen and services list were approved with. It
416
+ // must read the same on every screen that shows a service (the list, the service screen, the
417
+ // time screen, Home, the agenda); do not put those back on natural decimals one screen at a time.
415
418
 
416
419
  const KNOWN_SYMBOLS = new Set(Object.values(CURRENCY_SYMBOL));
417
420
 
@@ -1,170 +0,0 @@
1
- // Tier-5 bundle: Wix Spaces clone — Collection detail (ON-DEVICE, site-scoped).
2
- // Prefer Catalog V3 `getStoreCategory`; V1 `getCollection` 428s on CATALOG_V3 sites.
3
- //
4
- // #4155 — the screen showed the category header ("N products") and never the products.
5
- // Once the category resolves, its products load through `searchStoreProducts({ categoryId })`
6
- // with the same row shape as `himi-store-shop`, so a category reads as a filtered Shop.
7
- //
8
- // Two lifecycles, deliberately kept apart:
9
- // • the CATEGORY — presence flags (loading / found / missing / failed), as every detail screen;
10
- // • its PRODUCTS — a cursor-paged list (`createCollectionQuery`) with its own loading, failed
11
- // and empty states. A product read that fails leaves a FOUND category found: the header
12
- // stays and the list offers a retry. It never turns into "Category not found".
13
- import { defineService } from "@himalaya/state";
14
- import {
15
- categoryCount,
16
- getCollection,
17
- getStoreCategory,
18
- type WixCollection,
19
- } from "@himalaya/wix-stores-collections";
20
- import { presence, presenceAfterError, detailInputId, type PresenceFlags } from "../_shared/detail-presence.js";
21
- import { createCollectionQuery, type CollectionQueryState } from "../_shared/collection-query.js";
22
- import { productImage, productPrice, searchStoreProducts, type StoreProduct } from "../_shared/himi-store.js";
23
- import { cleanTitle, initials } from "../_shared/himi-text.js";
24
-
25
- interface Row {
26
- id: string;
27
- name: string;
28
- subtitle: string;
29
- image: string;
30
- initial: string;
31
- hasImage: boolean;
32
- noImage: boolean;
33
- detailScreenId: string;
34
- }
35
-
36
- interface S extends PresenceFlags {
37
- status: "loading" | "ready";
38
- name: string;
39
- subtitle: string;
40
- description: string;
41
- /** Which `load` the committed state belongs to — a products snapshot is only ever
42
- * projected onto the category it was fetched for. */
43
- loadId: number;
44
- products: Row[];
45
- /** Load-more cursor — non-empty while another page exists and none is in flight. */
46
- productsCursor: string;
47
- hideProducts: boolean;
48
- hideProductsLoading: boolean;
49
- /** Gates the products retry block (the category's own failure is `hideLoadError`). */
50
- productsNotErrored: boolean;
51
- /** Gates the "next page failed" retry under the list. A failed load-more keeps the rows
52
- * and clears the cursor, so without this the list would just stop and read as complete. */
53
- productsNoPageError: boolean;
54
- /** One plain line for a screen that has no dedicated empty / error blocks ("" otherwise). */
55
- productsMessage: string;
56
- /** Gates the "no products in this category" block (screen-kit `emptyBlock` reads `noEmpty`). */
57
- noEmpty: boolean;
58
- }
59
-
60
- /** The same row `himi-store-shop` emits, so the screen reuses the Shop tile. */
61
- function row(p: StoreProduct): Row {
62
- const name = cleanTitle(p.name) || "Product";
63
- const image = productImage(p);
64
- return {
65
- id: p.id ?? "",
66
- name,
67
- subtitle: productPrice(p),
68
- image,
69
- initial: initials(name),
70
- hasImage: !!image,
71
- noImage: !image,
72
- detailScreenId: "himi_product",
73
- };
74
- }
75
-
76
- let categoryId = "";
77
- let loadId = 0;
78
- const products = createCollectionQuery<Row>({
79
- id: (item) => item.id,
80
- async fetch(_criteria, cursor) {
81
- const response = await searchStoreProducts({ categoryId, limit: 50, cursor: cursor || undefined });
82
- return {
83
- items: (response.products ?? []).map(row).filter((item) => !!item.id),
84
- nextCursor: response.pagingMetadata?.cursors?.next ?? "",
85
- total: response.pagingMetadata?.total,
86
- };
87
- },
88
- }, () => { void svc.productsChanged(); });
89
-
90
- /** Project the products snapshot onto a state whose category is FOUND. Anything else
91
- * (loading, missing, failed) shows no product UI at all. */
92
- function withProducts(s: S, page: CollectionQueryState<Row> = products.getState()): S {
93
- const found = s.found;
94
- const items = found ? page.items : [];
95
- const loading = page.status === "idle" || page.status === "loading";
96
- return {
97
- ...s,
98
- products: items,
99
- productsCursor: found && !page.pageError && !page.loadingMore ? page.nextCursor : "",
100
- hideProducts: items.length === 0,
101
- hideProductsLoading: !(found && loading && items.length === 0),
102
- productsNotErrored: !(found && page.status === "error"),
103
- productsNoPageError: !(found && page.pageError),
104
- noEmpty: !(found && page.status === "ready" && items.length === 0),
105
- productsMessage: !found ? ""
106
- : page.status === "error" || page.pageError ? "Products couldn't load. Pull to refresh to try again."
107
- : page.status === "ready" && items.length === 0 ? "No products in this category yet." : "",
108
- };
109
- }
110
-
111
- const NO_PRODUCTS = { products: [], productsCursor: "", hideProducts: true, hideProductsLoading: true,
112
- productsNotErrored: true, productsNoPageError: true, noEmpty: true, productsMessage: "" };
113
-
114
- const initial: S = { status: "loading", name: "", subtitle: "", description: "", loadId: 0, ...NO_PRODUCTS, ...presence("loading") };
115
-
116
- /** The category header for `id`: V3 category first, V1 collection as the fallback. */
117
- async function readCategory(id: string): Promise<Pick<S, "name" | "subtitle" | "description">> {
118
- try {
119
- const c = (await getStoreCategory(id)).category;
120
- if (c?.id || c?.name) return { name: c.name || "Category", subtitle: categoryCount(c), description: c.description || "" };
121
- } catch { /* fall through to V1 */ }
122
- const res = await getCollection(id);
123
- const c = (((res as unknown as { collection?: WixCollection }).collection) ?? (res as unknown as WixCollection)) as unknown as {
124
- name?: string; numberOfProducts?: number; description?: string;
125
- };
126
- const n = c.numberOfProducts;
127
- return {
128
- name: c.name || "Collection",
129
- subtitle: n != null ? `${n} ${n === 1 ? "product" : "products"}` : "",
130
- description: c.description || "",
131
- };
132
- }
133
-
134
- const svc = defineService<S, {
135
- load: (s: S) => Promise<S | undefined>;
136
- productsChanged: (s: S) => S | undefined;
137
- loadMoreProducts: (s: S) => Promise<undefined>;
138
- retryProducts: (s: S) => Promise<undefined>;
139
- }>("spacesCollectionDetail", {
140
- state: initial,
141
- // A cold boot must not paint another category's products from last run's snapshot.
142
- notHydrated: ["products", "productsCursor", "hideProducts", "hideProductsLoading", "productsNotErrored", "productsNoPageError", "noEmpty", "productsMessage", "loadId"],
143
- actions: {
144
- load: async (s) => {
145
- const run = ++loadId;
146
- const id = detailInputId();
147
- if (!id) return { ...s, ...NO_PRODUCTS, loadId: run, status: "ready", ...presence("missing") };
148
- let header: Pick<S, "name" | "subtitle" | "description">;
149
- try {
150
- header = await readCategory(id);
151
- } catch (e) {
152
- if (run !== loadId) return undefined;
153
- return { ...s, ...NO_PRODUCTS, loadId: run, status: "ready", ...presenceAfterError(e) };
154
- }
155
- // A newer load (onAppear after the boot load, pull-to-refresh) owns the screen now.
156
- if (run !== loadId) return undefined;
157
- categoryId = id;
158
- // Starts the first page; its snapshots land through `productsChanged`, which ignores
159
- // them until THIS state (stamped `run`) is committed. The "loading" snapshot is read
160
- // synchronously below, so the header paints with the products skeleton under it.
161
- void products.query({});
162
- return withProducts({ ...s, ...header, loadId: run, status: "ready", ...presence("found") });
163
- },
164
- productsChanged: (s) => (s.loadId === loadId && s.found ? withProducts(s) : undefined),
165
- loadMoreProducts: async () => { await products.loadMore(); return undefined; },
166
- retryProducts: async () => { await products.retry(); return undefined; },
167
- },
168
- });
169
- svc.load();
170
- export { svc };
@@ -1,170 +0,0 @@
1
- // Tier-5 bundle: Wix Spaces clone — Collection detail (ON-DEVICE, site-scoped).
2
- // Prefer Catalog V3 `getStoreCategory`; V1 `getCollection` 428s on CATALOG_V3 sites.
3
- //
4
- // #4155 — the screen showed the category header ("N products") and never the products.
5
- // Once the category resolves, its products load through `searchStoreProducts({ categoryId })`
6
- // with the same row shape as `himi-store-shop`, so a category reads as a filtered Shop.
7
- //
8
- // Two lifecycles, deliberately kept apart:
9
- // • the CATEGORY — presence flags (loading / found / missing / failed), as every detail screen;
10
- // • its PRODUCTS — a cursor-paged list (`createCollectionQuery`) with its own loading, failed
11
- // and empty states. A product read that fails leaves a FOUND category found: the header
12
- // stays and the list offers a retry. It never turns into "Category not found".
13
- import { defineService } from "@himalaya/state";
14
- import {
15
- categoryCount,
16
- getCollection,
17
- getStoreCategory,
18
- type WixCollection,
19
- } from "@himalaya/wix-stores-collections";
20
- import { presence, presenceAfterError, detailInputId, type PresenceFlags } from "../_shared/detail-presence.js";
21
- import { createCollectionQuery, type CollectionQueryState } from "../_shared/collection-query.js";
22
- import { productImage, productPrice, searchStoreProducts, type StoreProduct } from "../_shared/himi-store.js";
23
- import { cleanTitle, initials } from "../_shared/himi-text.js";
24
-
25
- interface Row {
26
- id: string;
27
- name: string;
28
- subtitle: string;
29
- image: string;
30
- initial: string;
31
- hasImage: boolean;
32
- noImage: boolean;
33
- detailScreenId: string;
34
- }
35
-
36
- interface S extends PresenceFlags {
37
- status: "loading" | "ready";
38
- name: string;
39
- subtitle: string;
40
- description: string;
41
- /** Which `load` the committed state belongs to — a products snapshot is only ever
42
- * projected onto the category it was fetched for. */
43
- loadId: number;
44
- products: Row[];
45
- /** Load-more cursor — non-empty while another page exists and none is in flight. */
46
- productsCursor: string;
47
- hideProducts: boolean;
48
- hideProductsLoading: boolean;
49
- /** Gates the products retry block (the category's own failure is `hideLoadError`). */
50
- productsNotErrored: boolean;
51
- /** Gates the "next page failed" retry under the list. A failed load-more keeps the rows
52
- * and clears the cursor, so without this the list would just stop and read as complete. */
53
- productsNoPageError: boolean;
54
- /** One plain line for a screen that has no dedicated empty / error blocks ("" otherwise). */
55
- productsMessage: string;
56
- /** Gates the "no products in this category" block (screen-kit `emptyBlock` reads `noEmpty`). */
57
- noEmpty: boolean;
58
- }
59
-
60
- /** The same row `himi-store-shop` emits, so the screen reuses the Shop tile. */
61
- function row(p: StoreProduct): Row {
62
- const name = cleanTitle(p.name) || "Product";
63
- const image = productImage(p);
64
- return {
65
- id: p.id ?? "",
66
- name,
67
- subtitle: productPrice(p),
68
- image,
69
- initial: initials(name),
70
- hasImage: !!image,
71
- noImage: !image,
72
- detailScreenId: "himi_product",
73
- };
74
- }
75
-
76
- let categoryId = "";
77
- let loadId = 0;
78
- const products = createCollectionQuery<Row>({
79
- id: (item) => item.id,
80
- async fetch(_criteria, cursor) {
81
- const response = await searchStoreProducts({ categoryId, limit: 50, cursor: cursor || undefined });
82
- return {
83
- items: (response.products ?? []).map(row).filter((item) => !!item.id),
84
- nextCursor: response.pagingMetadata?.cursors?.next ?? "",
85
- total: response.pagingMetadata?.total,
86
- };
87
- },
88
- }, () => { void svc.productsChanged(); });
89
-
90
- /** Project the products snapshot onto a state whose category is FOUND. Anything else
91
- * (loading, missing, failed) shows no product UI at all. */
92
- function withProducts(s: S, page: CollectionQueryState<Row> = products.getState()): S {
93
- const found = s.found;
94
- const items = found ? page.items : [];
95
- const loading = page.status === "idle" || page.status === "loading";
96
- return {
97
- ...s,
98
- products: items,
99
- productsCursor: found && !page.pageError && !page.loadingMore ? page.nextCursor : "",
100
- hideProducts: items.length === 0,
101
- hideProductsLoading: !(found && loading && items.length === 0),
102
- productsNotErrored: !(found && page.status === "error"),
103
- productsNoPageError: !(found && page.pageError),
104
- noEmpty: !(found && page.status === "ready" && items.length === 0),
105
- productsMessage: !found ? ""
106
- : page.status === "error" || page.pageError ? "Products couldn't load. Pull to refresh to try again."
107
- : page.status === "ready" && items.length === 0 ? "No products in this category yet." : "",
108
- };
109
- }
110
-
111
- const NO_PRODUCTS = { products: [], productsCursor: "", hideProducts: true, hideProductsLoading: true,
112
- productsNotErrored: true, productsNoPageError: true, noEmpty: true, productsMessage: "" };
113
-
114
- const initial: S = { status: "loading", name: "", subtitle: "", description: "", loadId: 0, ...NO_PRODUCTS, ...presence("loading") };
115
-
116
- /** The category header for `id`: V3 category first, V1 collection as the fallback. */
117
- async function readCategory(id: string): Promise<Pick<S, "name" | "subtitle" | "description">> {
118
- try {
119
- const c = (await getStoreCategory(id)).category;
120
- if (c?.id || c?.name) return { name: c.name || "Category", subtitle: categoryCount(c), description: c.description || "" };
121
- } catch { /* fall through to V1 */ }
122
- const res = await getCollection(id);
123
- const c = (((res as unknown as { collection?: WixCollection }).collection) ?? (res as unknown as WixCollection)) as unknown as {
124
- name?: string; numberOfProducts?: number; description?: string;
125
- };
126
- const n = c.numberOfProducts;
127
- return {
128
- name: c.name || "Collection",
129
- subtitle: n != null ? `${n} ${n === 1 ? "product" : "products"}` : "",
130
- description: c.description || "",
131
- };
132
- }
133
-
134
- const svc = defineService<S, {
135
- load: (s: S) => Promise<S | undefined>;
136
- productsChanged: (s: S) => S | undefined;
137
- loadMoreProducts: (s: S) => Promise<undefined>;
138
- retryProducts: (s: S) => Promise<undefined>;
139
- }>("spacesCollectionDetail", {
140
- state: initial,
141
- // A cold boot must not paint another category's products from last run's snapshot.
142
- notHydrated: ["products", "productsCursor", "hideProducts", "hideProductsLoading", "productsNotErrored", "productsNoPageError", "noEmpty", "productsMessage", "loadId"],
143
- actions: {
144
- load: async (s) => {
145
- const run = ++loadId;
146
- const id = detailInputId();
147
- if (!id) return { ...s, ...NO_PRODUCTS, loadId: run, status: "ready", ...presence("missing") };
148
- let header: Pick<S, "name" | "subtitle" | "description">;
149
- try {
150
- header = await readCategory(id);
151
- } catch (e) {
152
- if (run !== loadId) return undefined;
153
- return { ...s, ...NO_PRODUCTS, loadId: run, status: "ready", ...presenceAfterError(e) };
154
- }
155
- // A newer load (onAppear after the boot load, pull-to-refresh) owns the screen now.
156
- if (run !== loadId) return undefined;
157
- categoryId = id;
158
- // Starts the first page; its snapshots land through `productsChanged`, which ignores
159
- // them until THIS state (stamped `run`) is committed. The "loading" snapshot is read
160
- // synchronously below, so the header paints with the products skeleton under it.
161
- void products.query({});
162
- return withProducts({ ...s, ...header, loadId: run, status: "ready", ...presence("found") });
163
- },
164
- productsChanged: (s) => (s.loadId === loadId && s.found ? withProducts(s) : undefined),
165
- loadMoreProducts: async () => { await products.loadMore(); return undefined; },
166
- retryProducts: async () => { await products.retry(); return undefined; },
167
- },
168
- });
169
- svc.load();
170
- export { svc };
@@ -1,170 +0,0 @@
1
- // Tier-5 bundle: Wix Spaces clone — Collection detail (ON-DEVICE, site-scoped).
2
- // Prefer Catalog V3 `getStoreCategory`; V1 `getCollection` 428s on CATALOG_V3 sites.
3
- //
4
- // #4155 — the screen showed the category header ("N products") and never the products.
5
- // Once the category resolves, its products load through `searchStoreProducts({ categoryId })`
6
- // with the same row shape as `himi-store-shop`, so a category reads as a filtered Shop.
7
- //
8
- // Two lifecycles, deliberately kept apart:
9
- // • the CATEGORY — presence flags (loading / found / missing / failed), as every detail screen;
10
- // • its PRODUCTS — a cursor-paged list (`createCollectionQuery`) with its own loading, failed
11
- // and empty states. A product read that fails leaves a FOUND category found: the header
12
- // stays and the list offers a retry. It never turns into "Category not found".
13
- import { defineService } from "@himalaya/state";
14
- import {
15
- categoryCount,
16
- getCollection,
17
- getStoreCategory,
18
- type WixCollection,
19
- } from "@himalaya/wix-stores-collections";
20
- import { presence, presenceAfterError, detailInputId, type PresenceFlags } from "../_shared/detail-presence.js";
21
- import { createCollectionQuery, type CollectionQueryState } from "../_shared/collection-query.js";
22
- import { productImage, productPrice, searchStoreProducts, type StoreProduct } from "../_shared/himi-store.js";
23
- import { cleanTitle, initials } from "../_shared/himi-text.js";
24
-
25
- interface Row {
26
- id: string;
27
- name: string;
28
- subtitle: string;
29
- image: string;
30
- initial: string;
31
- hasImage: boolean;
32
- noImage: boolean;
33
- detailScreenId: string;
34
- }
35
-
36
- interface S extends PresenceFlags {
37
- status: "loading" | "ready";
38
- name: string;
39
- subtitle: string;
40
- description: string;
41
- /** Which `load` the committed state belongs to — a products snapshot is only ever
42
- * projected onto the category it was fetched for. */
43
- loadId: number;
44
- products: Row[];
45
- /** Load-more cursor — non-empty while another page exists and none is in flight. */
46
- productsCursor: string;
47
- hideProducts: boolean;
48
- hideProductsLoading: boolean;
49
- /** Gates the products retry block (the category's own failure is `hideLoadError`). */
50
- productsNotErrored: boolean;
51
- /** Gates the "next page failed" retry under the list. A failed load-more keeps the rows
52
- * and clears the cursor, so without this the list would just stop and read as complete. */
53
- productsNoPageError: boolean;
54
- /** One plain line for a screen that has no dedicated empty / error blocks ("" otherwise). */
55
- productsMessage: string;
56
- /** Gates the "no products in this category" block (screen-kit `emptyBlock` reads `noEmpty`). */
57
- noEmpty: boolean;
58
- }
59
-
60
- /** The same row `himi-store-shop` emits, so the screen reuses the Shop tile. */
61
- function row(p: StoreProduct): Row {
62
- const name = cleanTitle(p.name) || "Product";
63
- const image = productImage(p);
64
- return {
65
- id: p.id ?? "",
66
- name,
67
- subtitle: productPrice(p),
68
- image,
69
- initial: initials(name),
70
- hasImage: !!image,
71
- noImage: !image,
72
- detailScreenId: "himi_product",
73
- };
74
- }
75
-
76
- let categoryId = "";
77
- let loadId = 0;
78
- const products = createCollectionQuery<Row>({
79
- id: (item) => item.id,
80
- async fetch(_criteria, cursor) {
81
- const response = await searchStoreProducts({ categoryId, limit: 50, cursor: cursor || undefined });
82
- return {
83
- items: (response.products ?? []).map(row).filter((item) => !!item.id),
84
- nextCursor: response.pagingMetadata?.cursors?.next ?? "",
85
- total: response.pagingMetadata?.total,
86
- };
87
- },
88
- }, () => { void svc.productsChanged(); });
89
-
90
- /** Project the products snapshot onto a state whose category is FOUND. Anything else
91
- * (loading, missing, failed) shows no product UI at all. */
92
- function withProducts(s: S, page: CollectionQueryState<Row> = products.getState()): S {
93
- const found = s.found;
94
- const items = found ? page.items : [];
95
- const loading = page.status === "idle" || page.status === "loading";
96
- return {
97
- ...s,
98
- products: items,
99
- productsCursor: found && !page.pageError && !page.loadingMore ? page.nextCursor : "",
100
- hideProducts: items.length === 0,
101
- hideProductsLoading: !(found && loading && items.length === 0),
102
- productsNotErrored: !(found && page.status === "error"),
103
- productsNoPageError: !(found && page.pageError),
104
- noEmpty: !(found && page.status === "ready" && items.length === 0),
105
- productsMessage: !found ? ""
106
- : page.status === "error" || page.pageError ? "Products couldn't load. Pull to refresh to try again."
107
- : page.status === "ready" && items.length === 0 ? "No products in this category yet." : "",
108
- };
109
- }
110
-
111
- const NO_PRODUCTS = { products: [], productsCursor: "", hideProducts: true, hideProductsLoading: true,
112
- productsNotErrored: true, productsNoPageError: true, noEmpty: true, productsMessage: "" };
113
-
114
- const initial: S = { status: "loading", name: "", subtitle: "", description: "", loadId: 0, ...NO_PRODUCTS, ...presence("loading") };
115
-
116
- /** The category header for `id`: V3 category first, V1 collection as the fallback. */
117
- async function readCategory(id: string): Promise<Pick<S, "name" | "subtitle" | "description">> {
118
- try {
119
- const c = (await getStoreCategory(id)).category;
120
- if (c?.id || c?.name) return { name: c.name || "Category", subtitle: categoryCount(c), description: c.description || "" };
121
- } catch { /* fall through to V1 */ }
122
- const res = await getCollection(id);
123
- const c = (((res as unknown as { collection?: WixCollection }).collection) ?? (res as unknown as WixCollection)) as unknown as {
124
- name?: string; numberOfProducts?: number; description?: string;
125
- };
126
- const n = c.numberOfProducts;
127
- return {
128
- name: c.name || "Collection",
129
- subtitle: n != null ? `${n} ${n === 1 ? "product" : "products"}` : "",
130
- description: c.description || "",
131
- };
132
- }
133
-
134
- const svc = defineService<S, {
135
- load: (s: S) => Promise<S | undefined>;
136
- productsChanged: (s: S) => S | undefined;
137
- loadMoreProducts: (s: S) => Promise<undefined>;
138
- retryProducts: (s: S) => Promise<undefined>;
139
- }>("spacesCollectionDetail", {
140
- state: initial,
141
- // A cold boot must not paint another category's products from last run's snapshot.
142
- notHydrated: ["products", "productsCursor", "hideProducts", "hideProductsLoading", "productsNotErrored", "productsNoPageError", "noEmpty", "productsMessage", "loadId"],
143
- actions: {
144
- load: async (s) => {
145
- const run = ++loadId;
146
- const id = detailInputId();
147
- if (!id) return { ...s, ...NO_PRODUCTS, loadId: run, status: "ready", ...presence("missing") };
148
- let header: Pick<S, "name" | "subtitle" | "description">;
149
- try {
150
- header = await readCategory(id);
151
- } catch (e) {
152
- if (run !== loadId) return undefined;
153
- return { ...s, ...NO_PRODUCTS, loadId: run, status: "ready", ...presenceAfterError(e) };
154
- }
155
- // A newer load (onAppear after the boot load, pull-to-refresh) owns the screen now.
156
- if (run !== loadId) return undefined;
157
- categoryId = id;
158
- // Starts the first page; its snapshots land through `productsChanged`, which ignores
159
- // them until THIS state (stamped `run`) is committed. The "loading" snapshot is read
160
- // synchronously below, so the header paints with the products skeleton under it.
161
- void products.query({});
162
- return withProducts({ ...s, ...header, loadId: run, status: "ready", ...presence("found") });
163
- },
164
- productsChanged: (s) => (s.loadId === loadId && s.found ? withProducts(s) : undefined),
165
- loadMoreProducts: async () => { await products.loadMore(); return undefined; },
166
- retryProducts: async () => { await products.retry(); return undefined; },
167
- },
168
- });
169
- svc.load();
170
- export { svc };