@wix/himalaya-cli 0.1033.0 → 0.1035.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 (127) hide show
  1. package/dist/cli.mjs +17 -15
  2. package/dist/preview-assets.generated.bin +0 -0
  3. package/package.json +2 -2
  4. package/templates/beauty-salon/dev/index.ts +398 -1
  5. package/templates/beauty-salon/tier5-src/_shared/collection-contract.ts +4 -1
  6. package/templates/beauty-salon/tier5-src/_shared/collection-service.ts +36 -5
  7. package/templates/beauty-salon/tier5-src/_shared/empty-flags.ts +33 -0
  8. package/templates/beauty-salon/tier5-src/himi-store-saved/index.ts +3 -2
  9. package/templates/beauty-salon/tier5-src/wix-spaces-collections/index.ts +3 -2
  10. package/templates/beauty-salon/tier5-src/wix-spaces-my-subscriptions/index.ts +11 -2
  11. package/templates/beauty-salon/tier5-src/wix-spaces-whats-new/index.ts +3 -2
  12. package/templates/beauty-salon-owner/dev/index.ts +1216 -0
  13. package/templates/beauty-salon-owner/tier5-src/_shared/collection-contract.ts +4 -1
  14. package/templates/beauty-salon-owner/tier5-src/_shared/collection-service.ts +36 -5
  15. package/templates/branded-lite/dev/index.ts +2239 -343
  16. package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +106 -0
  17. package/templates/branded-lite/tier5-src/_shared/collection-contract.ts +4 -1
  18. package/templates/branded-lite/tier5-src/_shared/collection-service.ts +36 -5
  19. package/templates/branded-lite/tier5-src/_shared/empty-flags.ts +33 -0
  20. package/templates/branded-lite/tier5-src/himi-branded-lite-badges/index.ts +20 -16
  21. package/templates/branded-lite/tier5-src/himi-branded-lite-coupons/index.ts +20 -20
  22. package/templates/branded-lite/tier5-src/himi-branded-lite-donations/index.ts +20 -16
  23. package/templates/branded-lite/tier5-src/himi-branded-lite-forms/index.ts +23 -31
  24. package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +19 -14
  25. package/templates/branded-lite/tier5-src/himi-branded-lite-giftcards/index.ts +20 -16
  26. package/templates/branded-lite/tier5-src/himi-branded-lite-inbox/index.ts +20 -15
  27. package/templates/branded-lite/tier5-src/himi-branded-lite-invoices/index.ts +20 -16
  28. package/templates/branded-lite/tier5-src/himi-branded-lite-loyalty/index.ts +20 -17
  29. package/templates/branded-lite/tier5-src/himi-branded-lite-notifications/index.ts +20 -18
  30. package/templates/branded-lite/tier5-src/himi-branded-lite-notifsettings/index.ts +23 -19
  31. package/templates/branded-lite/tier5-src/himi-branded-lite-podcasts/index.ts +1 -1
  32. package/templates/branded-lite/tier5-src/himi-branded-lite-reservations/index.ts +24 -20
  33. package/templates/branded-lite/tier5-src/himi-branded-lite-reviews/index.ts +20 -17
  34. package/templates/branded-lite/tier5-src/himi-branded-lite-videos/index.ts +19 -15
  35. package/templates/branded-lite/tier5-src/himi-store-saved/index.ts +3 -2
  36. package/templates/branded-lite/tier5-src/wix-spaces-collections/index.ts +3 -2
  37. package/templates/branded-lite/tier5-src/wix-spaces-forum/index.ts +3 -2
  38. package/templates/branded-lite/tier5-src/wix-spaces-members/index.ts +19 -6
  39. package/templates/branded-lite/tier5-src/wix-spaces-my-subscriptions/index.ts +11 -2
  40. package/templates/branded-lite/tier5-src/wix-spaces-new-message/index.ts +3 -2
  41. package/templates/branded-lite/tier5-src/wix-spaces-whats-new/index.ts +3 -2
  42. package/templates/community-club/dev/index.ts +283 -2
  43. package/templates/community-club/tier5-src/_shared/collection-contract.ts +4 -1
  44. package/templates/community-club/tier5-src/_shared/collection-service.ts +36 -5
  45. package/templates/community-club/tier5-src/_shared/empty-flags.ts +33 -0
  46. package/templates/community-club/tier5-src/wix-spaces-whats-new/index.ts +3 -2
  47. package/templates/community-club-owner/dev/index.ts +1216 -0
  48. package/templates/community-club-owner/tier5-src/_shared/collection-contract.ts +4 -1
  49. package/templates/community-club-owner/tier5-src/_shared/collection-service.ts +36 -5
  50. package/templates/events-meetups/dev/index.ts +197 -1
  51. package/templates/events-meetups/tier5-src/_shared/collection-contract.ts +4 -1
  52. package/templates/events-meetups/tier5-src/_shared/collection-service.ts +36 -5
  53. package/templates/events-meetups/tier5-src/_shared/empty-flags.ts +33 -0
  54. package/templates/events-meetups/tier5-src/wix-spaces-whats-new/index.ts +3 -2
  55. package/templates/events-meetups-owner/dev/index.ts +1088 -0
  56. package/templates/events-meetups-owner/tier5-src/_shared/collection-contract.ts +4 -1
  57. package/templates/events-meetups-owner/tier5-src/_shared/collection-service.ts +36 -5
  58. package/templates/gym-fitness/dev/index.ts +462 -1
  59. package/templates/gym-fitness/tier5-src/_shared/collection-contract.ts +4 -1
  60. package/templates/gym-fitness/tier5-src/_shared/collection-service.ts +36 -5
  61. package/templates/gym-fitness/tier5-src/_shared/empty-flags.ts +33 -0
  62. package/templates/gym-fitness/tier5-src/himi-store-saved/index.ts +3 -2
  63. package/templates/gym-fitness/tier5-src/wix-spaces-collections/index.ts +3 -2
  64. package/templates/gym-fitness/tier5-src/wix-spaces-my-subscriptions/index.ts +11 -2
  65. package/templates/gym-fitness/tier5-src/wix-spaces-whats-new/index.ts +3 -2
  66. package/templates/gym-fitness-owner/dev/index.ts +1280 -0
  67. package/templates/gym-fitness-owner/tier5-src/_shared/collection-contract.ts +4 -1
  68. package/templates/gym-fitness-owner/tier5-src/_shared/collection-service.ts +36 -5
  69. package/templates/nonprofit-cause/dev/index.ts +264 -1
  70. package/templates/nonprofit-cause/tier5-src/_shared/collection-contract.ts +4 -1
  71. package/templates/nonprofit-cause/tier5-src/_shared/collection-service.ts +36 -5
  72. package/templates/nonprofit-cause/tier5-src/_shared/empty-flags.ts +33 -0
  73. package/templates/nonprofit-cause/tier5-src/wix-spaces-my-subscriptions/index.ts +11 -2
  74. package/templates/nonprofit-cause/tier5-src/wix-spaces-whats-new/index.ts +3 -2
  75. package/templates/nonprofit-cause-owner/dev/index.ts +1152 -0
  76. package/templates/nonprofit-cause-owner/tier5-src/_shared/collection-contract.ts +4 -1
  77. package/templates/nonprofit-cause-owner/tier5-src/_shared/collection-service.ts +36 -5
  78. package/templates/owner-lite/dev/index.ts +1280 -0
  79. package/templates/owner-lite/tier5-src/_shared/collection-contract.ts +4 -1
  80. package/templates/owner-lite/tier5-src/_shared/collection-service.ts +36 -5
  81. package/templates/podcast-creator/dev/index.ts +286 -23
  82. package/templates/podcast-creator/tier5-src/_shared/collection-contract.ts +4 -1
  83. package/templates/podcast-creator/tier5-src/_shared/collection-service.ts +36 -5
  84. package/templates/podcast-creator/tier5-src/_shared/empty-flags.ts +33 -0
  85. package/templates/podcast-creator/tier5-src/himi-branded-lite-podcasts/index.ts +1 -1
  86. package/templates/podcast-creator/tier5-src/wix-spaces-my-subscriptions/index.ts +11 -2
  87. package/templates/podcast-creator/tier5-src/wix-spaces-whats-new/index.ts +3 -2
  88. package/templates/podcast-creator-owner/dev/index.ts +1216 -0
  89. package/templates/podcast-creator-owner/tier5-src/_shared/collection-contract.ts +4 -1
  90. package/templates/podcast-creator-owner/tier5-src/_shared/collection-service.ts +36 -5
  91. package/templates/portfolio-creative/dev/index.ts +353 -23
  92. package/templates/portfolio-creative/tier5-src/_shared/collection-contract.ts +4 -1
  93. package/templates/portfolio-creative/tier5-src/_shared/collection-service.ts +36 -5
  94. package/templates/portfolio-creative/tier5-src/_shared/empty-flags.ts +33 -0
  95. package/templates/portfolio-creative/tier5-src/himi-store-saved/index.ts +3 -2
  96. package/templates/portfolio-creative/tier5-src/wix-spaces-collections/index.ts +3 -2
  97. package/templates/portfolio-creative/tier5-src/wix-spaces-whats-new/index.ts +3 -2
  98. package/templates/portfolio-creative-owner/dev/index.ts +1216 -0
  99. package/templates/portfolio-creative-owner/tier5-src/_shared/collection-contract.ts +4 -1
  100. package/templates/portfolio-creative-owner/tier5-src/_shared/collection-service.ts +36 -5
  101. package/templates/restaurant/dev/index.ts +197 -1
  102. package/templates/restaurant/tier5-src/_shared/collection-contract.ts +4 -1
  103. package/templates/restaurant/tier5-src/_shared/collection-service.ts +36 -5
  104. package/templates/restaurant/tier5-src/_shared/empty-flags.ts +33 -0
  105. package/templates/restaurant/tier5-src/wix-spaces-whats-new/index.ts +3 -2
  106. package/templates/restaurant-owner/dev/index.ts +1280 -0
  107. package/templates/restaurant-owner/tier5-src/_shared/collection-contract.ts +4 -1
  108. package/templates/restaurant-owner/tier5-src/_shared/collection-service.ts +36 -5
  109. package/templates/retail-store/dev/index.ts +203 -1
  110. package/templates/retail-store/tier5-src/_shared/collection-contract.ts +4 -1
  111. package/templates/retail-store/tier5-src/_shared/collection-service.ts +36 -5
  112. package/templates/retail-store/tier5-src/_shared/empty-flags.ts +33 -0
  113. package/templates/retail-store/tier5-src/himi-store-saved/index.ts +3 -2
  114. package/templates/retail-store/tier5-src/wix-spaces-collections/index.ts +3 -2
  115. package/templates/retail-store/tier5-src/wix-spaces-whats-new/index.ts +3 -2
  116. package/templates/retail-store-owner/dev/index.ts +1152 -0
  117. package/templates/retail-store-owner/tier5-src/_shared/collection-contract.ts +4 -1
  118. package/templates/retail-store-owner/tier5-src/_shared/collection-service.ts +36 -5
  119. package/templates/yoga-studio/dev/index.ts +392 -1
  120. package/templates/yoga-studio/tier5-src/_shared/collection-contract.ts +4 -1
  121. package/templates/yoga-studio/tier5-src/_shared/collection-service.ts +36 -5
  122. package/templates/yoga-studio/tier5-src/_shared/empty-flags.ts +33 -0
  123. package/templates/yoga-studio/tier5-src/wix-spaces-my-subscriptions/index.ts +11 -2
  124. package/templates/yoga-studio/tier5-src/wix-spaces-whats-new/index.ts +3 -2
  125. package/templates/yoga-studio-owner/dev/index.ts +1152 -0
  126. package/templates/yoga-studio-owner/tier5-src/_shared/collection-contract.ts +4 -1
  127. package/templates/yoga-studio-owner/tier5-src/_shared/collection-service.ts +36 -5
@@ -0,0 +1,106 @@
1
+ // Load-state honesty for the branded-lite vertical workers.
2
+ //
3
+ // These workers used to answer every failed or empty query with representative SAMPLE
4
+ // rows — discount codes, gift-card balances, invoices, reservations, inbox messages —
5
+ // under a "Sample content" banner. On a customer build that is invented data presented
6
+ // as the customer's own: a code that does not exist, a balance in the wrong currency, an
7
+ // "Overdue" invoice, a booking labelled "tap to cancel". The banner did not make it
8
+ // honest; the rows were still the first thing a visitor read.
9
+ //
10
+ // The rule now:
11
+ // • query FAILED → status "error" with a customer sentence, a Retry, and — when
12
+ // Wix refused an anonymous visitor (401) — a Sign in path instead;
13
+ // • query was EMPTY → status "ready", no rows, the vertical's own empty sentence;
14
+ // • sample rows → ONLY when the host explicitly asked for sample data (the web
15
+ // preview's sample data mode, `__himiOwnerPreview: "sample"`).
16
+ // Never on a device, never as a fallback.
17
+ //
18
+ // Every field here is flat and boolean-gated because descriptors can only
19
+ // `hiddenWhen(<path>)` on a TRUE value: `notErrored` hides the error card, `noSignIn`
20
+ // hides the sign-in CTA, `signInRequired` hides Retry when retrying cannot help.
21
+ import { copyParam } from "./himi-copy.js";
22
+
23
+ export interface LoadFlags {
24
+ status: "loading" | "ready" | "error";
25
+ /** The customer-facing failure sentence; "" unless status is "error". */
26
+ error: string;
27
+ signInRequired: boolean;
28
+ notErrored: boolean;
29
+ /** hiddenWhen gate for a loading skeleton. */
30
+ notLoading: boolean;
31
+ /** Loading or failed — gates content that has nothing to say in either. */
32
+ busy: boolean;
33
+ noSignIn: boolean;
34
+ isMock: boolean;
35
+ notMock: boolean;
36
+ notMockEmpty: boolean;
37
+ notMockUnavailable: boolean;
38
+ }
39
+
40
+ export const LOADING: LoadFlags = {
41
+ status: "loading", error: "", signInRequired: false, notErrored: true, notLoading: false, busy: true, noSignIn: true,
42
+ isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true,
43
+ };
44
+
45
+ const READY: LoadFlags = { ...LOADING, status: "ready", notLoading: true, busy: false };
46
+
47
+ /** Did the HOST ask for sample data? Only the web preview's sample mode does; a native
48
+ * build, a visitor preview and a live owner preview never carry it. */
49
+ export function samplesRequested(): boolean {
50
+ const input = (globalThis as { himalaya?: { input?: Record<string, unknown> } }).himalaya?.input;
51
+ return input?.__himiOwnerPreview === "sample";
52
+ }
53
+
54
+ /** Wix refused an ANONYMOUS visitor: signing in is the fix, not retrying. A 403 is not
55
+ * this — it also answers "feature not installed" and a signed-in member without the
56
+ * permission, where a Sign in button leads nowhere — so it stays a Retry-able failure. */
57
+ export function needsSignIn(err: unknown): boolean {
58
+ const msg = String((err as { message?: unknown })?.message ?? err ?? "");
59
+ return /\b401\b|unauthori[sz]ed|unauthenticated/i.test(msg);
60
+ }
61
+
62
+ export interface LoadCopy<T> {
63
+ /** The copy namespace (`giftCards`, `coupons`, …) — keys resolve through copyParam. */
64
+ prefix: string;
65
+ /** Shown when the site genuinely has none. */
66
+ empty: string;
67
+ /** Shown when the query failed. */
68
+ error: string;
69
+ /** Shown when Wix refused the visitor (member-only data). */
70
+ signIn: string;
71
+ /** Sample rows for the explicit sample preview ONLY. */
72
+ sample: () => T[];
73
+ }
74
+
75
+ type Rows<K extends string, T> = { [P in K]: T[] } & { emptyText: string } & LoadFlags;
76
+
77
+ /** A query answered. Rows win; an empty answer is a real empty state. */
78
+ export function loaded<K extends string, T>(key: K, rows: T[], copy: LoadCopy<T>): Rows<K, T> {
79
+ if (rows.length) return { ...READY, [key]: rows, emptyText: "" } as Rows<K, T>;
80
+ if (samplesRequested()) return sampleRows(key, copy, "empty");
81
+ return { ...READY, [key]: [], emptyText: copyParam(`${copy.prefix}.emptyText`, copy.empty) } as Rows<K, T>;
82
+ }
83
+
84
+ /** A query threw. Never rows the customer did not create. */
85
+ export function loadFailed<K extends string, T>(key: K, err: unknown, copy: LoadCopy<T>): Rows<K, T> {
86
+ if (samplesRequested()) return sampleRows(key, copy, "unavailable");
87
+ const signIn = needsSignIn(err);
88
+ return {
89
+ ...READY,
90
+ status: "error",
91
+ [key]: [],
92
+ emptyText: "",
93
+ error: signIn ? copyParam(`${copy.prefix}.signInText`, copy.signIn) : copyParam(`${copy.prefix}.errorText`, copy.error),
94
+ signInRequired: signIn,
95
+ notErrored: false,
96
+ busy: true,
97
+ noSignIn: !signIn,
98
+ } as Rows<K, T>;
99
+ }
100
+
101
+ function sampleRows<K extends string, T>(key: K, copy: LoadCopy<T>, reason: "empty" | "unavailable"): Rows<K, T> {
102
+ return {
103
+ ...READY, [key]: copy.sample(), emptyText: "",
104
+ isMock: true, notMock: false, notMockEmpty: reason !== "empty", notMockUnavailable: reason !== "unavailable",
105
+ } as Rows<K, T>;
106
+ }
@@ -30,6 +30,9 @@ export interface CollectionConfig {
30
30
  * `screenId` opens that screen with the row id; without it the verb reuses the
31
31
  * row's own tap action (the detail screen that owns the verb). */
32
32
  primaryVerb?: { label: string; screenId?: string };
33
+ /** A state field the DOMAIN narrows `rows` by (e.g. a category picker). While it is set,
34
+ * zero rows is a filtered result, not an empty source. */
35
+ narrowedBy?: string;
33
36
  }
34
37
  /** At or below this many records, with no query/filter narrowing them and no further
35
38
  * page, the list/grid toggle, Refine, chips, search and count are noise. */
@@ -41,7 +44,7 @@ export const COLLECTIONS: Record<string, CollectionConfig> = {
41
44
  defaultFilter: 'value:Upcoming', searchPlaceholder: 'Exact booking ID', filterValues: ['Upcoming', 'Past', 'Cancelled'],
42
45
  sortLabels: { default: 'Soonest', az: 'Oldest', za: 'Latest' } },
43
46
  himiEvents: { rows: 'results', title: 'title', subtitle: 'dateLine', image: 'image', view: 'grid', backendQuery: true, searchPlaceholder: 'Exact event title', filterValues: ['Upcoming', 'Past'], sortLabels: { default: 'Date', az: 'Title A–Z', za: 'Title Z–A' }, primaryVerb: { label: 'Register' } },
44
- himiBlogFeed: { rows: 'posts', title: 'title', subtitle: 'excerpt', image: 'image', filter: 'category', filterValuesFrom: 'categories', view: 'grid' },
47
+ himiBlogFeed: { rows: 'posts', title: 'title', subtitle: 'excerpt', image: 'image', filter: 'category', filterValuesFrom: 'categories', view: 'grid', narrowedBy: 'selectedCategoryId' },
45
48
  himiOwnerLiteProducts: { rows: 'products', title: 'name', subtitle: 'price', image: 'image', view: 'grid', backendQuery: true },
46
49
  himiOwnerModules: { instanceInput: 'module', rows: 'rows', title: 'title', subtitle: 'subtitle', image: 'image', filter: 'statusLabel', view: 'list', paged: true, reviewAction: 'prepareRecord', reviewSelectedAction: 'prepareSelected' },
47
50
  himiOwnerLiteServices: { rows: 'services', title: 'name', subtitle: 'subtitle', view: 'list', backendQuery: true, searchPlaceholder: 'Service name starts with…' },
@@ -122,14 +122,38 @@ export function decorateCollection(name: string, def: { state: State; actions?:
122
122
  const errored = s.status === 'error' || s.isErrored === true || s.errored === true || backendState?.status === 'error';
123
123
  const domainError = typeof s.error === 'string' ? s.error.trim() : '';
124
124
  const queryError = typeof backendState?.error === 'string' ? backendState.error.trim() : '';
125
- const errorMessage = queryError || domainError || copyParam('collection.loadError', 'This collection could not load. Try again.');
125
+ // Some domains carry their failure sentence in `emptyText` (portfolio, podcasts);
126
+ // the screen folds that node into this block, so keep the domain's own words.
127
+ const domainEmptyError = errored && typeof s.emptyText === 'string' ? s.emptyText.trim() : '';
128
+ const errorMessage = queryError || domainError || domainEmptyError || copyParam('collection.loadError', 'This collection could not load. Try again.');
126
129
  const canRetry = !!backend || typeof def.actions?.refresh === 'function' || typeof def.actions?.load === 'function' || typeof def.actions?.collectionRetry === 'function';
130
+ // Wix refused the visitor: retrying cannot help, signing in can.
131
+ const signInRequired = errored && s.signInRequired === true;
132
+ // "the source has nothing" and "your filter matched nothing" are
133
+ // different states. Only the second may offer "Clear filters", and the list chrome
134
+ // (view, refine, chips, counter, search) means nothing over a source with no rows.
135
+ // Loaded-row collections hold the whole source in `rows`, so zero rows IS an empty
136
+ // source. A backend query returns only matches, so zero rows is source-empty only
137
+ // when nothing narrowed it.
138
+ // Anything the customer (or the domain) narrowed the result by. While narrowed, zero rows
139
+ // is "your filter matched nothing" and the controls that got them here must stay.
140
+ // The DOMAIN's own filter (config.narrowedBy, e.g. blog category) narrowed the rows: the
141
+ // domain owns that empty state and its clear action. The shared "Clear filters" block
142
+ // cannot reset a domain filter, so it never shows for one.
143
+ const domainNarrowed = !!config.narrowedBy && !!s[config.narrowedBy];
127
144
  // A default view is what the screen opens on; anything else is the member's own
128
145
  // narrowing, which keeps the chrome (and the Clear that undoes it) on screen.
129
146
  // "All" and the collection's own default (e.g. Upcoming) are both un-narrowed: Clear
130
147
  // resets to "All", so showing it on either would offer a reset that changes nothing.
131
- const narrowed = !!prefs.query.trim() || (prefs.filter !== 'all' && prefs.filter !== initialCollection(config).filter);
132
- const hideChrome = !narrowed && !s.nextCursor && projected.loaded < COLLECTION_CHROME_MIN_ITEMS;
148
+ const narrowed = !!prefs.query.trim() || (prefs.filter !== 'all' && prefs.filter !== initialCollection(config).filter) || domainNarrowed;
149
+ const sourceEmpty = !loading && !errored && rows.length === 0 &&
150
+ (!(backend && config.backendQuery) || !narrowed) && !domainNarrowed;
151
+ const emptySourceText = (typeof s.emptyText === 'string' && s.emptyText.trim()) || copyParam('collection.emptySource', 'Nothing here yet');
152
+ // Chrome is noise over a small un-narrowed list (fewer than COLLECTION_CHROME_MIN_ITEMS
153
+ // records, no further page), over an empty source, and before the FIRST load or an
154
+ // un-narrowed failure. A re-query while typing keeps it (and the search field's focus).
155
+ const hideChrome = (!narrowed && !s.nextCursor && projected.loaded < COLLECTION_CHROME_MIN_ITEMS)
156
+ || sourceEmpty || ((loading || errored) && rows.length === 0 && !narrowed);
133
157
  return { ...s, collection: { ...prefs, ...projected,
134
158
  hideChrome, noActiveCriteria: !narrowed,
135
159
  viewName, viewMessage, saveMessage, noSavedViews: prefs.savedViews.length === 0,
@@ -138,8 +162,15 @@ export function decorateCollection(name: string, def: { state: State; actions?:
138
162
  noPageError: s.pageError !== true,
139
163
  noOffline: s.fromCache !== true,
140
164
  noError: !errored,
141
- noRetry: !canRetry,
165
+ noRetry: !canRetry || signInRequired,
166
+ noSignIn: !signInRequired,
167
+ errorTitle: signInRequired ? copyParam('collection.signInTitle', 'Sign in to see this') : copyParam('collection.loadErrorTitle', 'This collection could not load'),
142
168
  errorMessage,
169
+ noLoading: !loading,
170
+ // De-dup against a domain that paints its OWN empty block (screen-kit emptyBlock is
171
+ // gated on the domain's top-level `noEmpty`): only one empty state ever shows.
172
+ noEmptySource: !sourceEmpty || s.noEmpty === false,
173
+ emptySourceText,
143
174
  hideList: prefs.view !== 'list', hideGrid: prefs.view !== 'grid',
144
175
  hideComfortable: prefs.density !== 'comfortable', hideCompact: prefs.density !== 'compact',
145
176
  selectingLabel: prefs.selecting ? copyParam('collection.done', 'Done') : copyParam('collection.select', 'Select'),
@@ -147,7 +178,7 @@ export function decorateCollection(name: string, def: { state: State; actions?:
147
178
  cannotSaveSelected: !prefs.selectedIds.length || prefs.selectedIds.some(id => !rows.some(row => collectionId(row) === id)),
148
179
  filtersPresented, previewPresented: !!previewId, preview: projected.items.filter(row => row.collectionId === previewId),
149
180
  persistenceLabel: persisted.available ? copyParam('collection.onDevice', 'Saved on this device') : copyParam('collection.sessionOnly', 'Choices last for this session'),
150
- noEmpty: loading || errored || projected.items.length > 0,
181
+ noEmpty: loading || errored || sourceEmpty || domainNarrowed || projected.items.length > 0,
151
182
  summary: backend && config.backendQuery
152
183
  ? (s.fromCache ? copyParam('collection.cached', 'Saved offline · may be outdated')
153
184
  : typeof s.total === 'number' ? `${projected.items.length} / ${s.total} ${copyParam('collection.results', 'results')}`
@@ -0,0 +1,33 @@
1
+ // Empty-collection flags for descriptors.
2
+ //
3
+ // A bound Grid/LazyList with zero rows still renders a zero-height box, and a stack
4
+ // allocates its `spacing` between CHILDREN, not between painted children — so every
5
+ // empty list left a full gap of blank on the screen (the measure pass's `dead-gap`, on
6
+ // iOS and Android as much as on web). `hiddenWhen` hides on a TRUE scalar only, and an
7
+ // empty array is not one, so a descriptor cannot gate a list on its own rows.
8
+ //
9
+ // `withEmptyFlags(def, { rows: "noRows" })` derives `noRows = rows is empty` into the
10
+ // initial state and after EVERY action (sync or async), so a builder can write
11
+ // `hiddenWhen(grid(...), "$service:svc.noRows")` without each reducer remembering to.
12
+ type State = Record<string, unknown>;
13
+ type Action = (s: State, ...args: unknown[]) => unknown;
14
+
15
+ export function withEmptyFlags<D extends { state: object; actions?: object }>(def: D, flags: Record<string, string>): D {
16
+ const derive = <T>(s: T): T => {
17
+ if (!s || typeof s !== "object") return s;
18
+ const out = { ...(s as State) };
19
+ for (const [rows, flag] of Object.entries(flags)) {
20
+ const v = out[rows];
21
+ out[flag] = !Array.isArray(v) || v.length === 0;
22
+ }
23
+ return out as T;
24
+ };
25
+ const actions: Record<string, Action> = {};
26
+ for (const [name, action] of Object.entries((def.actions ?? {}) as Record<string, Action>)) {
27
+ actions[name] = (s, ...args) => {
28
+ const r = action(s, ...args);
29
+ return r && typeof (r as Promise<unknown>).then === "function" ? (r as Promise<unknown>).then(derive) : derive(r);
30
+ };
31
+ }
32
+ return { ...def, state: derive(def.state), actions } as D;
33
+ }
@@ -1,17 +1,16 @@
1
- // himi-branded-lite-badges — branded-lite "Badges" worker with a SAMPLE-DATA
2
- // fallback (same pattern as the other branded-lite verticals). Reads the site's
3
- // real Wix Badges catalog (list); when the site has no Badges app / no badges
4
- // (the query errors — e.g. 404/428 — or returns empty), it substitutes
5
- // representative SAMPLE badges and flags `isMock`, so the screen shows a clearly-
6
- // labelled "Sample content" banner. Real badges always win the moment any exist.
1
+ // himi-branded-lite-badges — reads the site's real Wix Badges catalog (query).
2
+ // a failed query is an error with Retry (or Sign in when Wix refuses the
3
+ // visitor) and an empty one is a real empty state — never sample rows presented as
4
+ // the customer's own. Samples appear only in the host's explicit sample preview
5
+ // (_shared/branded-load-state.ts).
7
6
  import { defineService } from "../_shared/collection-service.js";
7
+ import { LOADING, loaded, loadFailed, type LoadCopy, type LoadFlags } from "../_shared/branded-load-state.js";
8
8
  // v4 (the live Badges service) has no plain GET list — only the query endpoint;
9
9
  // GET /badges/v4/badges 404s. v1-v3 are back-compat proxies that 404 here too.
10
10
  import { query as queryBadges, type WixBadge } from "@himalaya/wix-badges";
11
11
 
12
12
  interface Row { id: string; title: string; subtitle: string }
13
- interface S {
14
- status: "loading" | "ready";
13
+ interface S extends LoadFlags {
15
14
  badges: Row[]; emptyText: string;
16
15
  isMock: boolean; notMock: boolean; /** #2849 — hiddenWhen() hides on TRUE, so these are inverted: the empty-state banner
17
16
  * shows only when the site really has none, and the unavailable one only when the
@@ -32,21 +31,26 @@ const MOCK: Row[] = [
32
31
  { id: "sample-vip", title: "VIP", subtitle: "A valued long-standing customer." },
33
32
  ];
34
33
 
35
- function mockState(s: S, reason: "empty" | "unavailable"): S {
36
- return { ...s, status: "ready", badges: MOCK.map((r) => ({ ...r })), emptyText: "", isMock: true, notMock: false, notMockEmpty: reason !== "empty", notMockUnavailable: reason !== "unavailable"};
37
- }
34
+ /** sample rows reach the screen ONLY in the host's explicit sample preview;
35
+ * a failed or empty query is an honest error / empty state (branded-load-state.ts). */
36
+ const COPY: LoadCopy<Row> = {
37
+ prefix: "badges",
38
+ empty: "No badges yet.",
39
+ error: "Badges couldn't be loaded.",
40
+ signIn: "Sign in to see community badges.",
41
+ sample: () => MOCK.map((r) => ({ ...r })),
42
+ };
38
43
 
39
44
  const svc = defineService<S, { load: (s: S) => Promise<S> }>("himiBrandedBadges", {
40
- state: { status: "loading", badges: [], emptyText: "", isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true},
45
+ state: { ...LOADING, badges: [], emptyText: "" },
41
46
  actions: {
42
47
  load: async (s) => {
43
48
  try {
44
49
  const r = await queryBadges({ query: { cursorPaging: { limit: 50 } } });
45
50
  const badges = (r.badges ?? []).map(norm);
46
- if (badges.length) return { ...s, status: "ready", badges, emptyText: "", isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true};
47
- return mockState(s, "empty");
48
- } catch {
49
- return mockState(s, "unavailable");
51
+ return { ...s, ...loaded("badges", badges, COPY) };
52
+ } catch (err) {
53
+ return { ...s, ...loadFailed("badges", err, COPY) };
50
54
  }
51
55
  },
52
56
  },
@@ -1,15 +1,14 @@
1
- // himi-branded-lite-coupons — branded-lite coupons/offers LIST worker with a
2
- // SAMPLE-DATA fallback (same pattern as himi-branded-lite-forum). Reads the real
3
- // Wix Coupons (queryCoupons); when the site has no Coupons app / no coupons (the
4
- // query errors — e.g. 404/428 — or returns empty), it substitutes representative
5
- // SAMPLE offers and flags `isMock`, so the screen shows a clearly-labelled
6
- // "Sample content" banner. Real data always wins the moment coupons exist.
1
+ // himi-branded-lite-coupons — reads the site's real Wix Coupons (queryCoupons).
2
+ // a failed query is an error with Retry (or Sign in when Wix refuses the
3
+ // visitor) and an empty one is a real empty state — never sample rows presented as
4
+ // the customer's own. Samples appear only in the host's explicit sample preview
5
+ // (_shared/branded-load-state.ts).
7
6
  import { defineService } from "../_shared/collection-service.js";
7
+ import { LOADING, loaded, loadFailed, type LoadCopy, type LoadFlags } from "../_shared/branded-load-state.js";
8
8
  import { queryCoupons, type WixCoupon } from "@himalaya/wix-coupons";
9
9
 
10
10
  interface Row { id: string; name: string; subtitle: string }
11
- interface S {
12
- status: "loading" | "ready";
11
+ interface S extends LoadFlags {
13
12
  coupons: Row[]; emptyText: string;
14
13
  isMock: boolean; notMock: boolean; /** #2849 — hiddenWhen() hides on TRUE, so these are inverted: the empty-state banner
15
14
  * shows only when the site really has none, and the unavailable one only when the
@@ -30,25 +29,26 @@ const MOCK: Row[] = [
30
29
  { id: "sample-loyal10", name: "Loyalty reward — $10 off", subtitle: "Code: LOYAL10 · members only" },
31
30
  ];
32
31
 
33
- /** The sample fallback, shared by both arms so neither can drift from the other — this
34
- * worker inlined it twice, which is how a rename reached the constants and missed the
35
- * uses. */
36
- function mockState(s: S, reason: "empty" | "unavailable"): S {
37
- return { ...s, status: "ready", coupons: MOCK, emptyText: "", isMock: true, notMock: false,
38
- notMockEmpty: reason !== "empty", notMockUnavailable: reason !== "unavailable" };
39
- }
32
+ /** sample rows reach the screen ONLY in the host's explicit sample preview;
33
+ * a failed or empty query is an honest error / empty state (branded-load-state.ts). */
34
+ const COPY: LoadCopy<Row> = {
35
+ prefix: "coupons",
36
+ empty: "No offers right now — check back soon.",
37
+ error: "Offers couldn't be loaded.",
38
+ signIn: "Sign in to see offers available to you.",
39
+ sample: () => MOCK.map((r) => ({ ...r })),
40
+ };
40
41
 
41
42
  const svc = defineService<S, { load: (s: S) => Promise<S> }>("himiBrandedCoupons", {
42
- state: { status: "loading", coupons: [], emptyText: "", isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true},
43
+ state: { ...LOADING, coupons: [], emptyText: "" },
43
44
  actions: {
44
45
  load: async (s) => {
45
46
  try {
46
47
  const r = await queryCoupons({ paging: { limit: 30 } } as never);
47
48
  const coupons = (((r as unknown as { coupons?: WixCoupon[] }).coupons) ?? []).map(norm);
48
- if (coupons.length) return { ...s, status: "ready", coupons, emptyText: "", isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true};
49
- return mockState(s, "empty");
50
- } catch {
51
- return mockState(s, "unavailable");
49
+ return { ...s, ...loaded("coupons", coupons, COPY) };
50
+ } catch (err) {
51
+ return { ...s, ...loadFailed("coupons", err, COPY) };
52
52
  }
53
53
  },
54
54
  },
@@ -1,15 +1,14 @@
1
- // himi-branded-lite-donations — branded-lite "Donations" worker with a SAMPLE-DATA
2
- // fallback (same pattern as the other branded-lite verticals). Reads the site's real
3
- // Wix Donations campaigns (queryDonationCampaigns); when the site has no Donations app /
4
- // no campaigns (the query errors — e.g. 404/428 — or returns empty), it substitutes
5
- // representative SAMPLE campaigns and flags `isMock`, so the screen shows a clearly-
6
- // labelled "Sample content" banner. Real campaigns always win the moment any exist.
1
+ // himi-branded-lite-donations — reads the site's real Wix Donations campaigns (queryDonationCampaigns).
2
+ // a failed query is an error with Retry (or Sign in when Wix refuses the
3
+ // visitor) and an empty one is a real empty state — never sample rows presented as
4
+ // the customer's own. Samples appear only in the host's explicit sample preview
5
+ // (_shared/branded-load-state.ts).
7
6
  import { defineService } from "../_shared/collection-service.js";
7
+ import { LOADING, loaded, loadFailed, type LoadCopy, type LoadFlags } from "../_shared/branded-load-state.js";
8
8
  import { queryDonationCampaigns, type WixDonationCampaign } from "@himalaya/wix-donations";
9
9
 
10
10
  interface Row { id: string; title: string; subtitle: string }
11
- interface S {
12
- status: "loading" | "ready";
11
+ interface S extends LoadFlags {
13
12
  campaigns: Row[]; emptyText: string;
14
13
  isMock: boolean; notMock: boolean; /** #2849 — hiddenWhen() hides on TRUE, so these are inverted: the empty-state banner
15
14
  * shows only when the site really has none, and the unavailable one only when the
@@ -40,21 +39,26 @@ const MOCK: Row[] = [
40
39
  { id: "sample-relief", title: "Winter Relief Fund", subtitle: "Goal: $5,000 · Goal reached" },
41
40
  ];
42
41
 
43
- function mockState(s: S, reason: "empty" | "unavailable"): S {
44
- return { ...s, status: "ready", campaigns: MOCK.map((r) => ({ ...r })), emptyText: "", isMock: true, notMock: false, notMockEmpty: reason !== "empty", notMockUnavailable: reason !== "unavailable"};
45
- }
42
+ /** sample rows reach the screen ONLY in the host's explicit sample preview;
43
+ * a failed or empty query is an honest error / empty state (branded-load-state.ts). */
44
+ const COPY: LoadCopy<Row> = {
45
+ prefix: "donations",
46
+ empty: "No donation campaigns right now.",
47
+ error: "Donation campaigns couldn't be loaded.",
48
+ signIn: "Sign in to see donation campaigns.",
49
+ sample: () => MOCK.map((r) => ({ ...r })),
50
+ };
46
51
 
47
52
  const svc = defineService<S, { load: (s: S) => Promise<S> }>("himiBrandedDonations", {
48
- state: { status: "loading", campaigns: [], emptyText: "", isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true},
53
+ state: { ...LOADING, campaigns: [], emptyText: "" },
49
54
  actions: {
50
55
  load: async (s) => {
51
56
  try {
52
57
  const r = await queryDonationCampaigns({ query: { cursorPaging: { limit: 20 } } });
53
58
  const campaigns = (r.donationCampaigns ?? []).filter((c) => c.archived !== true).map(norm);
54
- if (campaigns.length) return { ...s, status: "ready", campaigns, emptyText: "", isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true};
55
- return mockState(s, "empty");
56
- } catch {
57
- return mockState(s, "unavailable");
59
+ return { ...s, ...loaded("campaigns", campaigns, COPY) };
60
+ } catch (err) {
61
+ return { ...s, ...loadFailed("campaigns", err, COPY) };
58
62
  }
59
63
  },
60
64
  },
@@ -1,18 +1,16 @@
1
- // himi-branded-lite-forms — branded-lite "Forms" worker with a SAMPLE-DATA fallback
2
- // (same pattern as the other branded-lite verticals). Reads the site's real Wix Forms
3
- // (queryForms, which REQUIRES a namespace filter — a bare query is answered
4
- // `400 MISSING_NAMESPACE_FILTER` for every site, #2769); when the site has no Forms
5
- // app (403/404/428) or has no forms, it substitutes representative SAMPLE forms and
6
- // flags `isMock`, so the screen shows a clearly-labelled "Sample content" banner.
7
- // A query that FAILS for any other reason is reported as a failure — sample content
8
- // there would state "this site has none yet", which the app never established.
9
- // Real forms always win the moment any exist.
1
+ // himi-branded-lite-forms — reads the site's real Wix Forms (queryForms, which
2
+ // REQUIRES a namespace filter — a bare query is answered `400 MISSING_NAMESPACE_FILTER`
3
+ // for every site, #2769).
4
+ // a failed query is an error with Retry (or Sign in when Wix refuses the
5
+ // visitor) and an empty one is a real empty state — never sample rows presented as
6
+ // the customer's own. Samples appear only in the host's explicit sample preview
7
+ // (_shared/branded-load-state.ts).
10
8
  import { defineService } from "../_shared/collection-service.js";
9
+ import { LOADING, loaded, loadFailed, type LoadCopy, type LoadFlags } from "../_shared/branded-load-state.js";
11
10
  import { queryForms, type WixForm } from "@himalaya/wix-forms";
12
11
 
13
12
  interface Row { id: string; title: string; subtitle: string }
14
- interface S {
15
- status: "loading" | "ready" | "error";
13
+ interface S extends LoadFlags {
16
14
  forms: Row[]; emptyText: string;
17
15
  isMock: boolean; notMock: boolean; /** #2849 — hiddenWhen() hides on TRUE, so these are inverted: the empty-state banner
18
16
  * shows only when the site really has none, and the unavailable one only when the
@@ -35,39 +33,33 @@ function norm(f: WixForm): Row {
35
33
  return { id: f.id || "form", title, subtitle: parts.join(" · ") };
36
34
  }
37
35
 
38
- /** Two banners, because the sample rows answer two different questions and only one of
39
- * them is a statement about the customer's site.
40
- *
41
- * There used to be one banner, "this site has no forms yet", shown whenever the rows were
42
- * samples — including when the query had FAILED. A site WITH forms got told it had none.
43
- * The sample fallback itself is deliberate and covered by the renderer's offline test,
44
- * which keeps these verticals showing labelled sample content rather than a blank screen.
45
- * So what was wrong was never the samples — it was claiming to know something the app had
46
- * not established. */
36
+ /** Preview-only sample forms (see COPY below). */
47
37
  const MOCK: Row[] = [
48
38
  { id: "sample-contact", title: "Contact Us", subtitle: "4 fields · Published" },
49
39
  { id: "sample-subscribe", title: "Newsletter Signup", subtitle: "2 fields · Published" },
50
40
  { id: "sample-feedback", title: "Feedback", subtitle: "5 fields · Published" },
51
41
  ];
52
42
 
53
- function mockState(s: S, reason: "empty" | "unavailable"): S {
54
- return { ...s, status: "ready", forms: MOCK.map((r) => ({ ...r })), emptyText: "", isMock: true, notMock: false, notMockEmpty: reason !== "empty", notMockUnavailable: reason !== "unavailable"};
55
- }
43
+ /** sample rows reach the screen ONLY in the host's explicit sample preview;
44
+ * a failed or empty query is an honest error / empty state (branded-load-state.ts). */
45
+ const COPY: LoadCopy<Row> = {
46
+ prefix: "forms",
47
+ empty: "No forms available yet.",
48
+ error: "Forms couldn't be loaded.",
49
+ signIn: "Sign in to see available forms.",
50
+ sample: () => MOCK.map((r) => ({ ...r })),
51
+ };
56
52
 
57
53
  const svc = defineService<S, { load: (s: S) => Promise<S> }>("himiBrandedForms", {
58
- state: { status: "loading", forms: [], emptyText: "", isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true},
54
+ state: { ...LOADING, forms: [], emptyText: "" },
59
55
  actions: {
60
56
  load: async (s) => {
61
57
  try {
62
58
  const r = await queryForms({ filter: { namespace: FORMS_NAMESPACE }, paging: { limit: 50 } });
63
59
  const forms = (r.forms ?? []).map(norm);
64
- if (forms.length) return { ...s, status: "ready", forms, emptyText: "", isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true};
65
- return mockState(s, "empty");
66
- } catch {
67
- // The samples still render — a blank screen helps nobody and this vertical
68
- // deliberately ships a fallback — but the banner no longer asserts the site is
69
- // empty, because a failed query established no such thing.
70
- return mockState(s, "unavailable");
60
+ return { ...s, ...loaded("forms", forms, COPY) };
61
+ } catch (err) {
62
+ return { ...s, ...loadFailed("forms", err, COPY) };
71
63
  }
72
64
  },
73
65
  },
@@ -4,13 +4,14 @@
4
4
  // codegen — the real consumer app (wix-private/wix-one-app-pro-gallery) fetches it
5
5
  // from the photography one-app viewer REST endpoint. So this worker mirrors that: it
6
6
  // GETs the site's galleries from that endpoint through the raw wixApi bridge (absolute
7
- // URL + the app's WixApiCapability auth), with the sample-data fallback. When the site
8
- // has no Pro Gallery / it isn't readable (errors or empty), it substitutes SAMPLE
9
- // galleries and flags `isMock`. Real galleries always win the moment any exist.
7
+ // URL + the app's WixApiCapability auth). a failed query is an error with
8
+ // Retry (or Sign in), an empty one a real empty state; sample galleries appear only
9
+ // in the host's explicit sample preview (_shared/branded-load-state.ts).
10
10
  //
11
11
  // Endpoint verbatim from wix-one-app-pro-gallery/src/constants (API_BASE_URL) +
12
12
  // api/photo-gallery-api.js (fetchGalleries).
13
13
  import { defineService } from "../_shared/collection-service.js";
14
+ import { LOADING, loaded, loadFailed, type LoadCopy, type LoadFlags } from "../_shared/branded-load-state.js";
14
15
 
15
16
  const GALLERIES_URL =
16
17
  "https://www.wix.com/_api/photography-one-app-viewer/galleries?itemsPerGallery=1&offset=0&limit=30&withHidden=false&useNew=true";
@@ -22,8 +23,7 @@ function wixApi(): WixApi | null {
22
23
 
23
24
  interface RawGallery { id?: string; name?: string; totalItemsCount?: number }
24
25
  interface Row { id: string; title: string; subtitle: string }
25
- interface S {
26
- status: "loading" | "ready";
26
+ interface S extends LoadFlags {
27
27
  galleries: Row[]; emptyText: string;
28
28
  isMock: boolean; notMock: boolean; /** #2849 — hiddenWhen() hides on TRUE, so these are inverted: the empty-state banner
29
29
  * shows only when the site really has none, and the unavailable one only when the
@@ -45,17 +45,23 @@ const MOCK: Row[] = [
45
45
  { id: "sample-team", title: "The Team", subtitle: "9 photos" },
46
46
  ];
47
47
 
48
- function mockState(s: S, reason: "empty" | "unavailable"): S {
49
- return { ...s, status: "ready", galleries: MOCK.map((r) => ({ ...r })), emptyText: "", isMock: true, notMock: false, notMockEmpty: reason !== "empty", notMockUnavailable: reason !== "unavailable"};
50
- }
48
+ /** sample rows reach the screen ONLY in the host's explicit sample preview;
49
+ * a failed or empty query is an honest error / empty state (branded-load-state.ts). */
50
+ const COPY: LoadCopy<Row> = {
51
+ prefix: "galleries",
52
+ empty: "No galleries yet — check back soon.",
53
+ error: "Galleries couldn't be loaded.",
54
+ signIn: "Sign in to see galleries.",
55
+ sample: () => MOCK.map((r) => ({ ...r })),
56
+ };
51
57
 
52
58
  const svc = defineService<S, { load: (s: S) => Promise<S> }>("himiBrandedGalleries", {
53
- state: { status: "loading", galleries: [], emptyText: "", isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true},
59
+ state: { ...LOADING, galleries: [], emptyText: "" },
54
60
  actions: {
55
61
  load: async (s) => {
56
62
  try {
57
63
  const api = wixApi();
58
- if (!api) return mockState(s, "unavailable");
64
+ if (!api) return { ...s, ...loadFailed("galleries", new Error("Wix bridge unavailable"), COPY) };
59
65
  const res = (await api.call("GET", GALLERIES_URL)) as
60
66
  { galleries?: RawGallery[] } | RawGallery[] | { data?: { galleries?: RawGallery[] } };
61
67
  // Accept {galleries:[...]}, a top-level array, or a {data:{galleries}} envelope.
@@ -64,10 +70,9 @@ const svc = defineService<S, { load: (s: S) => Promise<S> }>("himiBrandedGalleri
64
70
  (Array.isArray(res) ? (res as RawGallery[]) : undefined) ??
65
71
  (res as { data?: { galleries?: RawGallery[] } })?.data?.galleries ?? [];
66
72
  const galleries = raw.map(norm);
67
- if (galleries.length) return { ...s, status: "ready", galleries, emptyText: "", isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true};
68
- return mockState(s, "empty");
69
- } catch {
70
- return mockState(s, "unavailable");
73
+ return { ...s, ...loaded("galleries", galleries, COPY) };
74
+ } catch (err) {
75
+ return { ...s, ...loadFailed("galleries", err, COPY) };
71
76
  }
72
77
  },
73
78
  },