@wix/himalaya-cli 0.1633.0 → 0.1635.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 (107) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/app-config.d.ts +1 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/app-module.d.ts +2 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/wix-payments-contract.d.ts +22 -10
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/wix-payments-wire.d.ts +16 -6
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/wix-payments.d.ts +11 -4
  6. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +6 -0
  7. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/apple-iap-contract.d.ts +10 -4
  8. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/members-feed-viewer/src/index.d.ts +10 -6
  9. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/members-v1/src/index.d.ts +34 -0
  10. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap-checkout.mjs +84 -16
  11. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap.mjs +67 -10
  12. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/screens.mjs +83 -15
  13. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdui.mjs +1 -0
  14. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-members-feed-viewer.mjs +8 -7
  15. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-members.mjs +29 -0
  16. package/dist/browser-plane/assets.json +166 -0
  17. package/dist/browser-plane/host.html +156 -0
  18. package/dist/browser-plane/package-reader.js +4 -0
  19. package/dist/browser-plane/preview-deferred/368faca44fb9f7207e4af784e94750b1ee59c1098b8689e0f4a918926f244fb5.bin +0 -0
  20. package/dist/browser-plane/preview-deferred/37c179b4fefb63ea9637322b84583a2df53aa226c150294eea1675c14b1b8e29.bin +0 -0
  21. package/dist/browser-plane/preview-deferred/4ecf270136af928612c76fe4ebade91d8156975e5ea4c0cf9225501dd1788e02.bin +0 -0
  22. package/dist/browser-plane/preview-deferred/521a0e528916d678d7588b2cddf3dadd3bb959a6d62426382f61c77a82017e35.bin +1 -0
  23. package/dist/browser-plane/preview-deferred/62e2fe4718129f36ecdb0ae4de385bd6b81ecc876146447167c6c140fad699a9.bin +0 -0
  24. package/dist/browser-plane/preview-deferred/642feab8a48ddf6c7a89d7c2a7c0edfa0e6e773f671287e460e222703563191e.bin +0 -0
  25. package/dist/browser-plane/preview-deferred/67feb362281a3040ee05592769227c58a98ec3c8073dac95ecb5629141c67061.bin +56 -0
  26. package/dist/browser-plane/preview-deferred/97ed8f1a6772abe26e3d8af5cecfc4b02faa94497d585b0398b5053b739a70c1.bin +0 -0
  27. package/dist/browser-plane/preview-deferred/a33cfe728c584fdba4fcc1fd54bcdc2f9f2f13889ddbb5b2bd1d0f8cbe49b84e.bin +28 -0
  28. package/dist/browser-plane/preview-deferred/bf4c16a2774edd38a921edf3099469866cd74c094fd596f3fa4e81f54a04dfaa.bin +0 -0
  29. package/dist/browser-plane/preview-deferred/d7598e12c5dbef095ff8272cfc55da0250bd07fbdecbac8a530b9b277872a134.bin +0 -0
  30. package/dist/browser-plane/preview-deferred/dced97a77624bc5f8b1ae65850360908297f4588e09feb6f22792d29dc7e765b.bin +0 -0
  31. package/dist/browser-plane/preview-deferred/dee56f09fd3efc127c1ea95557edc74110c1460a46287da36e0af25efd341ef5.bin +0 -0
  32. package/dist/browser-plane/preview-deferred/ec4c7d63772d980d6e147b2df6215fd2737e4b04cebe0cf7e11dc695fb63cdcf.bin +0 -0
  33. package/dist/browser-plane/sw.js +13 -0
  34. package/dist/browser-plane/t5-frame.html +350 -0
  35. package/dist/browser-plane/t5-worker-frame.html +350 -0
  36. package/dist/cli.mjs +6594 -5078
  37. package/dist/preview-assets.generated.bin +0 -0
  38. package/fixtures/wix-canned.json +34 -1
  39. package/guides/designing-himi-screens.md +5 -1
  40. package/guides/himi-json-output.md +16 -1
  41. package/guides/iap-products.md +8 -0
  42. package/guides/one-app-to-himi-app.md +81 -5
  43. package/guides/sdk-reference.json +56 -11
  44. package/guides/skill.md +1 -0
  45. package/package.json +3 -3
  46. package/templates/beauty-salon/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  47. package/templates/beauty-salon/tier5-src/_shared/collection-contract.ts +0 -1
  48. package/templates/beauty-salon-owner/tier5-src/_shared/collection-contract.ts +0 -1
  49. package/templates/branded-lite/config.ts +7 -0
  50. package/templates/branded-lite/dev/index.ts +1109 -2307
  51. package/templates/branded-lite/himalaya.content.json +3 -0
  52. package/templates/branded-lite/l10n/en.json +6 -6
  53. package/templates/branded-lite/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  54. package/templates/branded-lite/tier5-src/_shared/business-chat-history.ts +4 -1
  55. package/templates/branded-lite/tier5-src/_shared/business-chat-presenter.ts +2 -7
  56. package/templates/branded-lite/tier5-src/_shared/collection-contract.ts +0 -1
  57. package/templates/branded-lite/tier5-src/_shared/header-badges.ts +1 -1
  58. package/templates/branded-lite/tier5-src/_shared/himi-cms.ts +2 -1
  59. package/templates/branded-lite/tier5-src/_shared/inbox-rooms.ts +68 -0
  60. package/templates/branded-lite/tier5-src/_shared/member-activity.ts +34 -0
  61. package/templates/branded-lite/tier5-src/_shared/member-row-extras.ts +167 -0
  62. package/templates/branded-lite/tier5-src/_shared/notification-target.ts +141 -0
  63. package/templates/branded-lite/tier5-src/_shared/source-badges.ts +6 -4
  64. package/templates/branded-lite/tier5-src/_shared/wix-member-links.ts +114 -0
  65. package/templates/branded-lite/tier5-src/business-chat/index.ts +114 -59
  66. package/templates/branded-lite/tier5-src/himi-branded-lite-cms/index.ts +160 -39
  67. package/templates/branded-lite/tier5-src/himi-branded-lite-inbox/index.ts +83 -0
  68. package/templates/branded-lite/tier5-src/himi-branded-lite-notifications/index.ts +62 -7
  69. package/templates/branded-lite/tier5-src/wix-spaces-members/index.ts +60 -12
  70. package/templates/community-club/dev/index.ts +210 -2044
  71. package/templates/community-club/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  72. package/templates/community-club/tier5-src/_shared/collection-contract.ts +0 -1
  73. package/templates/community-club/tier5-src/_shared/himi-cms.ts +2 -1
  74. package/templates/community-club/tier5-src/himi-branded-lite-cms/index.ts +160 -39
  75. package/templates/community-club-owner/tier5-src/_shared/collection-contract.ts +0 -1
  76. package/templates/events-meetups/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  77. package/templates/events-meetups/tier5-src/_shared/collection-contract.ts +0 -1
  78. package/templates/events-meetups-owner/tier5-src/_shared/collection-contract.ts +0 -1
  79. package/templates/gym-fitness/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  80. package/templates/gym-fitness/tier5-src/_shared/collection-contract.ts +0 -1
  81. package/templates/gym-fitness-owner/tier5-src/_shared/collection-contract.ts +0 -1
  82. package/templates/nonprofit-cause/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  83. package/templates/nonprofit-cause/tier5-src/_shared/collection-contract.ts +0 -1
  84. package/templates/nonprofit-cause-owner/tier5-src/_shared/collection-contract.ts +0 -1
  85. package/templates/owner-lite/tier5-src/_shared/collection-contract.ts +0 -1
  86. package/templates/podcast-creator/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  87. package/templates/podcast-creator/tier5-src/_shared/collection-contract.ts +0 -1
  88. package/templates/podcast-creator-owner/tier5-src/_shared/collection-contract.ts +0 -1
  89. package/templates/portfolio-creative/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  90. package/templates/portfolio-creative/tier5-src/_shared/collection-contract.ts +0 -1
  91. package/templates/portfolio-creative-owner/tier5-src/_shared/collection-contract.ts +0 -1
  92. package/templates/restaurant/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  93. package/templates/restaurant/tier5-src/_shared/collection-contract.ts +0 -1
  94. package/templates/restaurant-owner/tier5-src/_shared/collection-contract.ts +0 -1
  95. package/templates/retail-store/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  96. package/templates/retail-store/tier5-src/_shared/collection-contract.ts +0 -1
  97. package/templates/retail-store-owner/tier5-src/_shared/collection-contract.ts +0 -1
  98. package/templates/salon-spa/dev/index.ts +112 -70
  99. package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  100. package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +4 -1
  101. package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +2 -7
  102. package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +0 -1
  103. package/templates/salon-spa/tier5-src/_shared/inbox-rooms.ts +68 -0
  104. package/templates/salon-spa/tier5-src/business-chat/index.ts +114 -59
  105. package/templates/yoga-studio/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  106. package/templates/yoga-studio/tier5-src/_shared/collection-contract.ts +0 -1
  107. package/templates/yoga-studio-owner/tier5-src/_shared/collection-contract.ts +0 -1
@@ -1,58 +1,135 @@
1
1
  import { defineService } from "../_shared/collection-service.js";
2
- import { collectionDefinitions, type CmsCollectionDefinition } from "../_shared/himi-cms";
3
- import { copyParam, stashCopy } from "../_shared/himi-copy.js";
2
+ import { collectionDefinitions, countDataItems, type CmsCollectionDefinition } from "../_shared/himi-cms";
3
+ import { copyParam, copyPlural, stashCopy } from "../_shared/himi-copy.js";
4
4
  import { getAll, upsert, type DbEntity } from "@himalaya/local-db";
5
5
 
6
6
  // Offline-first (data-offline): this catalog is derived from the app's BAKED CMS
7
7
  // contract (himalaya.input.cmsCollections) — there is no live wixApi fetch to
8
- // revalidate, so it is structurally read-only. We still add the read-through seam
9
- // around that baked load path: the derived rows are mirrored into the on-device store,
8
+ // revalidate the NAMES, so they are structurally read-only. We still add the read-through seam
9
+ // around that baked load path: the derived entries are mirrored into the on-device store,
10
10
  // and if the contract ever arrives empty we fall back to the last-synced cache.
11
11
  // DEGRADES GRACEFULLY: every local-db call is wrapped so a build that hasn't granted
12
12
  // `local_database` (+ declared the `cms_collections` entity) behaves exactly as before.
13
+ //
14
+ // The item COUNT under each name is the one live read. It is never cached: a stale number would
15
+ // read as the business's own, and a count the API did not return is not shown at all.
13
16
  const ENTITY = "cms_collections";
14
- async function readCached(): Promise<CollectionRow[]> {
15
- try { return (await getAll(ENTITY)) as unknown as CollectionRow[]; } catch { return []; }
17
+
18
+ /** What the screen needs to know about a collection, with no per-visit state. Mirrored to the cache. */
19
+ interface Entry {
20
+ id: string;
21
+ title: string;
22
+ detailScreenId: string;
23
+ isMembersOnly: boolean;
24
+ }
25
+
26
+ async function readCached(): Promise<Entry[]> {
27
+ try {
28
+ return (await getAll(ENTITY)).map((r) => {
29
+ const stored = r as unknown as Partial<Entry> & { meta?: string };
30
+ return {
31
+ id: String(stored.id ?? ""),
32
+ title: typeof stored.title === "string" ? stored.title : String(stored.id ?? ""),
33
+ detailScreenId: typeof stored.detailScreenId === "string" ? stored.detailScreenId : `himi_cms_collection_${encodeURIComponent(String(stored.id ?? ""))}`,
34
+ // A row cached before counts carried its "Members only" label in `meta`.
35
+ isMembersOnly: stored.isMembersOnly ?? Boolean(stored.meta),
36
+ };
37
+ }).filter((entry) => entry.id !== "");
38
+ } catch { return []; }
16
39
  }
17
- async function writeCached(rows: CollectionRow[]): Promise<void> {
18
- try { for (const r of rows) await upsert(ENTITY, r as unknown as DbEntity); } catch { /* capability not granted → online-only */ }
40
+ async function writeCached(entries: Entry[]): Promise<void> {
41
+ try { for (const e of entries) await upsert(ENTITY, e as unknown as DbEntity); } catch { /* capability not granted → online-only */ }
19
42
  }
20
43
 
44
+ /** "pending" until the count call settles; "none" for a members-only collection, which shows a lock instead. */
45
+ type CountStatus = "pending" | "ready" | "error" | "none";
46
+
21
47
  interface CollectionRow {
22
48
  id: string;
23
49
  title: string;
24
- subtitle: string;
25
- meta: string;
26
- initial: string;
27
50
  detailScreenId: string;
51
+ isMembersOnly: boolean;
52
+ membersOnlyText: string;
53
+ countStatus: CountStatus;
54
+ countText: string;
55
+ /** The row's accessible name: the collection, then whichever caption it shows. */
56
+ accessibilityLabel: string;
57
+ // The descriptor hides a node when its bound flag is TRUE, so each caption has its inverse.
58
+ noCountLoading: boolean;
59
+ noCountText: boolean;
60
+ noMembersOnly: boolean;
28
61
  }
29
62
 
30
63
  interface State {
31
64
  status: "ready";
32
- all: CollectionRow[];
33
65
  rows: CollectionRow[];
34
- emptyText: string;
35
- noRows: boolean;
66
+ /** More than 5 collections: the name search shows. */
67
+ showSearch: boolean;
68
+ hideSearch: boolean;
69
+ /** The contract holds no collection at all. */
36
70
  noEmpty: boolean;
71
+ /** A search matched none of them. */
72
+ noNoMatch: boolean;
73
+ noRows: boolean;
37
74
  }
38
75
 
39
- function row(collection: CmsCollectionDefinition): CollectionRow {
40
- const title = collection.displayName.trim() || collection.id;
41
- // No field count and no "Available to visitors": both are back-office schema/permission
42
- // metadata, meaningless to the visitor reading the card ([branded-lite-qa] #3077). "Members
43
- // only" stays — it tells the visitor they will need to sign in, which is theirs to know.
76
+ /** A name search earns its place only past this many collections. */
77
+ const SEARCH_THRESHOLD = 5;
78
+
79
+ let entries: Entry[] = [];
80
+ let query = "";
81
+ /** A settled count by collection id. A failed refresh never replaces a count already known. */
82
+ const counts = new Map<string, { status: "ready" | "error"; count: number }>();
83
+ /** Ids with a count call on the wire, so a second `load` (onAppear fires on every visit) does not double it. */
84
+ const inFlight = new Set<string>();
85
+
86
+ function entryFor(collection: CmsCollectionDefinition): Entry {
44
87
  return {
45
88
  id: collection.id,
46
- title,
47
- subtitle: "",
48
- meta: collection.readRole === "ANYONE" ? "" : copyParam("cms.membersOnlyAccessLabel", "Members only"),
49
- initial: (title[0] || "C").toUpperCase(),
89
+ title: collection.displayName.trim() || collection.id,
50
90
  detailScreenId: `himi_cms_collection_${encodeURIComponent(collection.id)}`,
91
+ isMembersOnly: collection.readRole !== "ANYONE",
51
92
  };
52
93
  }
53
94
 
54
- function ready(all: CollectionRow[], rows = all, emptyText = copyParam("cms.emptyText", "No CMS collections are published to this app yet.")): State {
55
- return { status: "ready", all, rows, emptyText, noRows: rows.length === 0, noEmpty: rows.length > 0 };
95
+ function rowFor(entry: Entry): CollectionRow {
96
+ const membersOnlyText = copyParam("cms.membersOnlyAccessLabel", "Members only");
97
+ const known = counts.get(entry.id);
98
+ const countStatus: CountStatus = entry.isMembersOnly ? "none" : known?.status ?? "pending";
99
+ const countText = countStatus === "ready" && known
100
+ ? copyPlural("cms.itemCount", "{n, plural, one {# item} other {# items}}", known.count)
101
+ : "";
102
+ const caption = entry.isMembersOnly ? membersOnlyText : countText;
103
+ return {
104
+ id: entry.id,
105
+ title: entry.title,
106
+ detailScreenId: entry.detailScreenId,
107
+ isMembersOnly: entry.isMembersOnly,
108
+ membersOnlyText,
109
+ countStatus,
110
+ countText,
111
+ accessibilityLabel: caption ? `${entry.title}, ${caption}` : entry.title,
112
+ noCountLoading: countStatus !== "pending",
113
+ noCountText: countStatus !== "ready",
114
+ noMembersOnly: !entry.isMembersOnly,
115
+ };
116
+ }
117
+
118
+ /** Every visible field, derived from the module's truth at the moment of the call, so an action that
119
+ * settles later (a count arriving, a search typed) can never publish an older picture of it. */
120
+ function view(): State {
121
+ const needle = query.trim().toLowerCase();
122
+ const shown = needle ? entries.filter((entry) => entry.title.toLowerCase().includes(needle)) : entries;
123
+ const showSearch = entries.length > SEARCH_THRESHOLD;
124
+ return {
125
+ status: "ready",
126
+ rows: shown.map(rowFor),
127
+ showSearch,
128
+ hideSearch: !showSearch,
129
+ noEmpty: entries.length > 0,
130
+ noNoMatch: entries.length === 0 || shown.length > 0,
131
+ noRows: shown.length === 0,
132
+ };
56
133
  }
57
134
 
58
135
  function value(arg: unknown, key: string): string {
@@ -61,30 +138,74 @@ function value(arg: unknown, key: string): string {
61
138
  return typeof result === "string" ? result : "";
62
139
  }
63
140
 
141
+ /** The count the API returned, or undefined: a missing or malformed total is never read as zero. */
142
+ function totalOf(response: { totalCount?: number } | undefined): number | undefined {
143
+ const total = response?.totalCount;
144
+ return typeof total === "number" && Number.isFinite(total) && total >= 0 ? total : undefined;
145
+ }
146
+
147
+ /** One count call per readable collection, all at once; each row fills as ITS call returns. */
148
+ function startCounts(): void {
149
+ for (const entry of entries) {
150
+ if (entry.isMembersOnly || inFlight.has(entry.id)) continue;
151
+ inFlight.add(entry.id);
152
+ const settle = (total: number | undefined) => { inFlight.delete(entry.id); void svc.countSettled({ id: entry.id, total }); };
153
+ countDataItems(entry.id).then((response) => settle(totalOf(response)), () => settle(undefined));
154
+ }
155
+ }
156
+
64
157
  const inputCollections = (globalThis as { himalaya?: { input?: { cmsCollections?: unknown } } }).himalaya?.input?.cmsCollections;
158
+ entries = collectionDefinitions(typeof inputCollections === "string" ? inputCollections : "").map(entryFor);
159
+ const initialState = view();
160
+ const derivedStateKeys = Object.keys(initialState) as Array<keyof State>;
65
161
 
66
162
  const svc = defineService<State, {
67
- load: (state: State, arg?: unknown) => Promise<State>;
163
+ load: (state: State, arg?: unknown) => Promise<State | undefined>;
68
164
  search: (state: State, arg?: unknown) => State;
165
+ countSettled: (state: State, arg: { id: string; total: number | undefined }) => State;
69
166
  }>("himiBrandedCms", {
70
- state: ready(collectionDefinitions(typeof inputCollections === "string" ? inputCollections : "").map(row)),
167
+ state: initialState,
168
+ // Rows and visibility are derived from this run's contract/query/counts.
169
+ // A prior search or count must not override them on a cold boot. Collection
170
+ // definitions already have their own explicit local-database cache.
171
+ notHydrated: derivedStateKeys,
172
+ notPersisted: derivedStateKeys,
71
173
  actions: {
72
- load: async (_state, arg) => {
174
+ load: async (state, arg) => {
73
175
  stashCopy(arg);
74
- const all = collectionDefinitions(value(arg, "collections")).map(row);
75
- if (all.length) { await writeCached(all); return ready(all); }
76
- // No baked contract rows: fall back to the last-synced cache (offline), else empty.
77
- const cached = await readCached();
78
- return cached.length ? ready(cached) : ready(all);
176
+ // The remounted SearchField starts empty. Reset before awaiting the cache so
177
+ // a search typed during this load is not cleared by its eventual completion.
178
+ query = "";
179
+ const given = value(arg, "collections");
180
+ const baked = collectionDefinitions(given).map(entryFor);
181
+ if (baked.length) {
182
+ entries = baked;
183
+ await writeCached(baked);
184
+ } else if (given !== "" || !entries.length) {
185
+ // The contract named no collection (or nothing at all seeded this screen): what was seeded from the
186
+ // input no longer stands. Fall back to the last-synced cache (offline), else the empty state.
187
+ entries = await readCached();
188
+ }
189
+ startCounts();
190
+ // Actions are not serialized: a count that settles while this one is still committing would be
191
+ // overwritten by the older picture returned here. The names normally match what the screen already
192
+ // shows (the same contract seeded it), so then there is nothing to commit and nothing to overwrite.
193
+ const next = view();
194
+ return JSON.stringify(next) === JSON.stringify(state) ? undefined : next;
195
+ },
196
+ search: (_state, arg) => {
197
+ query = value(arg, "value");
198
+ return view();
79
199
  },
80
- search: (state, arg) => {
81
- const query = value(arg, "value").trim().toLowerCase();
82
- const rows = query
83
- ? state.all.filter((item) => `${item.title} ${item.subtitle} ${item.meta}`.toLowerCase().includes(query))
84
- : state.all;
85
- return ready(state.all, rows, query ? copyParam("cms.searchEmptyText", "No collections match your search.") : undefined);
200
+ countSettled: (_state, arg) => {
201
+ if (arg.total !== undefined) counts.set(arg.id, { status: "ready", count: arg.total });
202
+ else if (counts.get(arg.id)?.status !== "ready") counts.set(arg.id, { status: "error", count: 0 });
203
+ return view();
86
204
  },
87
205
  },
88
206
  });
89
207
 
208
+ // NO module-scope `startCounts()`: a bridge call made during the bundle's own evaluation stalls on
209
+ // the Android WebView (see himi-owner-lite-aria-history). The names paint from the baked input, and
210
+ // onAppear's `load` starts the counts right after that first frame.
90
211
  export { svc };
@@ -0,0 +1,83 @@
1
+ // himi-branded-lite-inbox — the member's Inbox: every conversation the member-role chat list returns, the
2
+ // business's and other members'. Only the member-role list is used (`GET inbox/v2/conversations`, the call the
3
+ // first Inbox made, is an OWNER endpoint that answers a member with 404). A failed query is an error with Retry
4
+ // (or Sign in for a visitor) and an empty one is a real empty state — never sample rows presented as the
5
+ // customer's own. Samples appear only in the host's explicit sample preview (_shared/branded-load-state.ts).
6
+ import { defineService } from "../_shared/collection-service.js";
7
+ import { LOADING, loaded, loadFailed, type LoadCopy, type LoadFlags } from "../_shared/branded-load-state.js";
8
+ import { getChatToken, listChatroomsPage } from "@himalaya/wix-chat-web";
9
+ import { assertMemberAccess, memberAccess } from "../_shared/member-access.js";
10
+ import { inboxRows, type InboxRow } from "../_shared/inbox-rooms.js";
11
+ import { copyParam, stashCopy } from "../_shared/himi-copy.js";
12
+
13
+ type Row = Omit<InboxRow, "at">;
14
+ interface S extends LoadFlags {
15
+ conversations: Row[]; emptyText: string;
16
+ isMock: boolean; notMock: boolean; /** #2849 — hiddenWhen() hides on TRUE, so these are inverted: the empty-state banner
17
+ * shows only when the site really has none, and the unavailable one only when the query failed. A failed
18
+ * query must never render the sentence that claims the customer has no conversations. */
19
+ notMockEmpty: boolean; notMockUnavailable: boolean;
20
+ }
21
+
22
+ const MEMBER = { role: "member" } as const;
23
+ const PAGE_SIZE = 50;
24
+ // 2000 conversations: far beyond a real member. The cap only stops a runaway cursor from looping, and reaching it is an
25
+ // error, never a quietly shorter list.
26
+ const MAX_PAGES = 40;
27
+
28
+ const MOCK: Row[] = [
29
+ { id: "sample-support", title: "Support", subtitle: "Hi! Let us know how we can help.", screenId: "himi_business_chat", kind: "", photo: "", initial: "S", unread: false, unreadDot: "" },
30
+ { id: "sample-member", title: "A member", subtitle: "Welcome 👋 Thanks for joining us!", screenId: "himi_chat_thread", kind: "chatroom", photo: "", initial: "A", unread: false, unreadDot: "" },
31
+ ];
32
+
33
+ /** Sample rows reach the screen ONLY in the host's explicit sample preview; a failed or empty query is an
34
+ * honest error / empty state (branded-load-state.ts). */
35
+ const COPY: LoadCopy<Row> = {
36
+ prefix: "inbox",
37
+ empty: "No messages yet.",
38
+ error: "Your messages couldn't be loaded.",
39
+ signIn: "Sign in to see your messages.",
40
+ sample: () => MOCK.map((r) => ({ ...r })),
41
+ };
42
+
43
+ const signInRequired = () => new Error("Unauthenticated: sign in to see your messages");
44
+
45
+ async function listAllRooms(): Promise<unknown[]> {
46
+ // One token for every page; a refused one fails the whole load rather than showing half a list.
47
+ const { chatToken } = await getChatToken(MEMBER);
48
+ const rooms: unknown[] = [];
49
+ let nextPointer: number | string | undefined;
50
+ for (let page = 0; page < MAX_PAGES; page++) {
51
+ const result = await listChatroomsPage({ ...MEMBER, chatToken, pageSize: PAGE_SIZE, ...(nextPointer !== undefined ? { nextPointer } : {}) });
52
+ rooms.push(...result.items);
53
+ nextPointer = result.nextPointer;
54
+ if (nextPointer === undefined) return rooms;
55
+ }
56
+ throw new Error("Conversation list is longer than the pages read");
57
+ }
58
+
59
+ const svc = defineService<S, { load: (s: S, arg?: unknown) => Promise<S> }>("himiBrandedInbox", {
60
+ state: { ...LOADING, conversations: [], emptyText: "" },
61
+ actions: {
62
+ load: async (s, arg) => {
63
+ // The screen's onAppear carries the copy pack (Hebrew, an owner's wording). This service is not a collection, so
64
+ // nothing else stashes it; a retry sends no params and keeps the pack already stashed.
65
+ stashCopy(arg);
66
+ try {
67
+ const member = await memberAccess();
68
+ if (!member.email) throw signInRequired();
69
+ const rows = inboxRows(await listAllRooms(), copyParam("inbox.conversationFallback", "Conversation")).map(({ at: _at, ...row }) => row);
70
+ // A sign-out or account switch while the list loaded must not paint the previous member's rooms.
71
+ await assertMemberAccess(member);
72
+ return { ...s, ...loaded("conversations", rows, COPY) };
73
+ } catch (err) {
74
+ const failed = loadFailed("conversations", (await memberAccess()).email ? err : signInRequired(), COPY);
75
+ // The failure sentence is the screen banner's stock key, not a second key for the same English (the extractor
76
+ // keeps one key per extracted row); loadFailed would read `inbox.errorText`, which does not exist.
77
+ return { ...s, ...failed, ...(failed.signInRequired ? {} : { error: copyParam("inbox.unavailableBody", COPY.error) }) };
78
+ }
79
+ },
80
+ },
81
+ });
82
+ svc.load();
83
+ export { svc };
@@ -10,6 +10,10 @@ import { defineService as registerService } from "../_shared/collection-service.
10
10
  import { LOADING, loaded, loadFailed, type LoadCopy, type LoadFlags } from "../_shared/branded-load-state.js";
11
11
  import { queryNotifications, markRead, markAllRead, type MfvNotification } from "@himalaya/wix-members-feed-viewer";
12
12
  import { assertMemberAccess, memberAccess, type MemberAccess } from "../_shared/member-access.js";
13
+ import { navigate } from "@himalaya/state";
14
+ import { notificationOpen, urlHost, type NotificationOpen } from "../_shared/notification-target.js";
15
+ import { openRichLink } from "../_shared/rich-content-link.js";
16
+ import { copyParam, copyTemplate } from "../_shared/himi-copy.js";
13
17
 
14
18
  interface Row {
15
19
  id: string;
@@ -22,9 +26,13 @@ interface Row {
22
26
  photo: string;
23
27
  initial: string;
24
28
  dest: string;
29
+ /** What a tap does (a screen, a web link or nothing). Absent on the preview samples, which open nothing. */
30
+ open?: NotificationOpen;
25
31
  }
26
32
  interface S extends LoadFlags {
27
33
  items: Row[]; emptyText: string; actionMessage: string;
34
+ /** The web link the last tap named and is waiting for a second tap to open (see `open`). */
35
+ pendingUrl: string;
28
36
  /** Ownership travels with the rows, including a state captured by an async write. */
29
37
  inboxMember: MemberAccess | null;
30
38
  isMock: boolean; notMock: boolean;
@@ -67,7 +75,17 @@ function photo(n: MfvNotification): string {
67
75
  return n.icon?.personAvatarData?.data || n.icon?.webUrlData?.webUrl || "";
68
76
  }
69
77
 
70
- function norm(n: MfvNotification): Row {
78
+ /** The screens the Notifications screen says its app serves, or undefined when it did not say. */
79
+ function servedScreens(arg: unknown): ReadonlySet<string> | undefined {
80
+ const served = (arg as { served?: unknown } | null | undefined)?.served;
81
+ // A Retry reaches `load` with no params (the collection layer re-dispatches the action bare), so the list the screen
82
+ // last named is kept: dropping it would let every catalog link be tried again.
83
+ if (typeof served === "string") lastServed = new Set(served.split(",").filter(Boolean));
84
+ return lastServed;
85
+ }
86
+ let lastServed: ReadonlySet<string> | undefined;
87
+
88
+ function norm(n: MfvNotification, served?: ReadonlySet<string>): Row {
71
89
  const name = (n.title || n.message || "Notification").trim() || "Notification";
72
90
  const rel = relativeTime(n.sentDate);
73
91
  const lead = n.title && n.message ? n.message.trim() : "";
@@ -83,6 +101,7 @@ function norm(n: MfvNotification): Row {
83
101
  photo: p,
84
102
  initial: (name[0] || "#").toUpperCase(),
85
103
  dest: dest(n),
104
+ open: notificationOpen(n.deeplink, served),
86
105
  };
87
106
  }
88
107
 
@@ -128,12 +147,13 @@ function currentInbox(s: S): S | undefined {
128
147
  }
129
148
 
130
149
  const defineService = withOnlineWrites(registerService, ["markOneRead","markAll"], { messageFields: ["actionMessage"] });
131
- const INITIAL = { ...LOADING, items: [] as Row[], emptyText: "", actionMessage: "", inboxMember: null };
150
+ const INITIAL = { ...LOADING, items: [] as Row[], emptyText: "", actionMessage: "", pendingUrl: "", inboxMember: null };
132
151
  const svc = defineService<S, {
133
152
  prepare: (s: S) => S;
134
- load: (s: S) => Promise<S>;
153
+ load: (s: S, arg?: unknown) => Promise<S>;
135
154
  markOneRead: (s: S, id: string) => Promise<S | undefined>;
136
155
  markAll: (s: S) => Promise<S | undefined>;
156
+ open: (s: S, arg?: unknown) => Promise<S | undefined>;
137
157
  }>("himiBrandedNotifications", {
138
158
  state: INITIAL,
139
159
  actions: {
@@ -141,14 +161,16 @@ const svc = defineService<S, {
141
161
  // Native hosts may already cold-paint their account-scoped snapshot; this
142
162
  // reducer does not control that earlier host phase.
143
163
  prepare: (s) => ({ ...s, ...INITIAL }),
144
- load: async (s) => {
164
+ load: async (s, arg) => {
165
+ // The screen names which of the link targets this app serves (a comma list); none given means any may be tried.
166
+ const served = servedScreens(arg);
145
167
  try {
146
168
  const member = await requireMember();
147
- const items = (await queryNotifications(25)).map(norm);
169
+ const items = (await queryNotifications(25)).map((n) => norm(n, served));
148
170
  await assertMemberAccess(member);
149
- return { ...s, ...loaded("items", items, COPY), inboxMember: member, actionMessage: "" };
171
+ return { ...s, ...loaded("items", items, COPY), inboxMember: member, actionMessage: "", pendingUrl: "" };
150
172
  } catch (err) {
151
- return { ...s, ...loadFailed("items", (await memberAccess()).email ? err : signInRequired(), COPY), inboxMember: null };
173
+ return { ...s, ...loadFailed("items", (await memberAccess()).email ? err : signInRequired(), COPY), inboxMember: null, actionMessage: "", pendingUrl: "" };
152
174
  }
153
175
  },
154
176
  // Mark one read; optimistically clear its dot. Real write is best-effort and
@@ -174,6 +196,39 @@ const svc = defineService<S, {
174
196
  const current = currentInbox(s);
175
197
  return current ? { ...current, items: current.items.map((r) => (r.id === id ? { ...r, unread: false, unreadDot: "" } : r)) } : undefined;
176
198
  },
199
+ // A tap on a row opens what the notification points at, like the One App: a Himi screen when its link names
200
+ // one of ours (see _shared/notification-target.ts), else its https link, else a visible "can't open".
201
+ // A web link is written by the site, so it needs a SECOND tap on the same row, after the first tap has
202
+ // shown where it goes. It never replaces the inbox, and an unknown link never guesses a screen.
203
+ open: async (s, arg): Promise<S | undefined> => {
204
+ const id = String((arg as { id?: unknown } | null | undefined)?.id ?? "");
205
+ const row = s.items.find((r) => r.id === id);
206
+ if (!row || s.busy) return undefined;
207
+ if (!s.isMock) {
208
+ try { await requireInboxMember(s); } catch (err) {
209
+ const current = currentInbox(s);
210
+ return current ? { ...current, ...loadFailed("items", err, COPY), inboxMember: null } : undefined;
211
+ }
212
+ }
213
+ const current = currentInbox(s);
214
+ if (!current) return undefined;
215
+ const action = row.open ?? { kind: "none" as const };
216
+ if (action.kind === "url") {
217
+ if (current.pendingUrl !== action.url) {
218
+ return { ...current, pendingUrl: action.url, actionMessage: copyTemplate("notifications.openLinkText", "Tap again to open {host}.", { host: urlHost(action.url) }) };
219
+ }
220
+ const opened = await openRichLink(action.url);
221
+ const now = currentInbox(s);
222
+ return now ? { ...now, pendingUrl: "", actionMessage: opened ? "" : copyParam("notifications.cantOpenText", "This notification can't be opened here.") } : undefined;
223
+ }
224
+ if (action.kind === "none") {
225
+ return { ...current, pendingUrl: "", actionMessage: copyParam("notifications.cantOpenText", "This notification can't be opened here.") };
226
+ }
227
+ // Read on open: the dot clears at once, the write is best effort and never holds the navigation.
228
+ if (!id.startsWith("sample-") && row.unread) markRead([id]).catch(() => undefined);
229
+ const read = { ...current, pendingUrl: "", actionMessage: "", items: current.items.map((r) => (r.id === id ? { ...r, unread: false, unreadDot: "" } : r)) };
230
+ return navigate(read, { screenId: action.target.screenId, args: action.target.args });
231
+ },
177
232
  // Mark everything read; optimistically clear all dots.
178
233
  markAll: async (s): Promise<S | undefined> => {
179
234
  if (s.busy) return undefined;
@@ -1,23 +1,34 @@
1
1
  // Public site-member directory. Search, sorting, totals and offset paging match
2
2
  // OneApp's Members v1 query; no owner roster or build-time count is substituted.
3
3
  import { defineService } from "@himalaya/state";
4
- import { queryMembers, type WixMember } from "@himalaya/wix-members";
4
+ import { queryMembers, followMember, unfollowMember, type WixMember } from "@himalaya/wix-members";
5
5
  import { createCollectionQuery } from "../_shared/collection-query.js";
6
6
  import { readAccess, assertReadAccess, readAccessDenied, type ReadAccess } from "../_shared/member-read.js";
7
7
  import { memberAccessChanged } from "../_shared/member-access.js";
8
8
  import { withEmptyFlags } from "../_shared/empty-flags.js";
9
+ import { copyParam, stashCopy } from "../_shared/himi-copy.js";
10
+ import { memberRowExtras, followLabel, type MemberRowExtras } from "../_shared/member-row-extras.js";
9
11
 
10
- interface Row { id: string; name: string; subtitle: string; initial: string; photo: string; sectionTitle: string }
12
+ interface Row extends MemberRowExtras { id: string; name: string; initial: string; photo: string; sectionTitle: string }
11
13
  interface S {
12
14
  status: "loading" | "ready" | "error";
13
15
  members: Row[]; emptyText: string;
14
16
  query: string; total: number | null; searchLabel: string;
15
17
  nextCursor: string; noMore: boolean; noPageError: boolean;
16
18
  error: string; notErrored: boolean; notLoading: boolean; hideSearch: boolean;
19
+ followHint: string;
17
20
  }
18
21
  const PAGE_SIZE = 50;
19
22
  const SEARCH_FIELDS = ["contact.firstName", "contact.lastName", "profile.nickname"];
20
23
  let directoryAccess: ReadAccess | undefined;
24
+ // A Follow the signed-in member just made or undone. Rows keep the state they were read with, so this
25
+ // shows the change at once; the next first-page read replaces it with what the server says.
26
+ const followChanges = new Map<string, boolean>();
27
+ const pendingFollows = new Set<string>();
28
+
29
+ /** Who a directory read, and a write that follows it, belong to: the site and the signed-in member. A reload
30
+ * builds a NEW ReadAccess object for the same reader, so identity says nothing; this key does. */
31
+ const readerKey = (access: ReadAccess | undefined): string => access ? JSON.stringify([access.tenant, access.member.scope, access.member.email]) : "";
21
32
 
22
33
  function name(m: WixMember): string {
23
34
  const nick = m.profile?.nickname?.trim();
@@ -25,10 +36,14 @@ function name(m: WixMember): string {
25
36
  const full = [m.contact?.firstName, m.contact?.lastName].filter(Boolean).join(" ").trim();
26
37
  return full || "Member";
27
38
  }
28
- function row(m: WixMember): Row {
39
+ function row(m: WixMember, extras: MemberRowExtras): Row {
29
40
  const n = name(m);
30
41
  // Login emails are not directory subtitles. Only public profile content is presented.
31
- return { id: m.id, name: n, subtitle: "", initial: (n[0] || "#").toUpperCase(), photo: m.profile?.photo?.url || "", sectionTitle: "" };
42
+ return { id: m.id, name: n, initial: (n[0] || "#").toUpperCase(), photo: m.profile?.photo?.url || "", sectionTitle: "", ...extras };
43
+ }
44
+ function withFollowChange(item: Row): Row {
45
+ const following = followChanges.get(item.id);
46
+ return following === undefined ? item : { ...item, isFollowing: following, followLabel: followLabel(following) };
32
47
  }
33
48
  function sections(rows: Row[]): Row[] {
34
49
  const groups = new Map<string, Row[]>();
@@ -47,16 +62,17 @@ const directory = createCollectionQuery<Row>({
47
62
  if (!Number.isSafeInteger(offset) || offset < 0) throw new Error("Invalid member page offset");
48
63
  const access = cursor ? directoryAccess : await readAccess();
49
64
  if (!access) throw memberAccessChanged();
50
- if (!cursor) directoryAccess = access;
65
+ if (!cursor) { directoryAccess = access; followChanges.clear(); followHint = ""; }
51
66
  await assertReadAccess(access);
52
67
  const response = await queryMembers({ query: {
53
68
  filter: { status: "APPROVED", privacyStatus: "PUBLIC" },
54
69
  sorting: [{ fieldName: "profile.nickname", order: "ASC" }],
55
70
  paging: { limit: PAGE_SIZE, offset },
56
71
  }, ...(criteria.query ? { search: { expression: criteria.query, fields: SEARCH_FIELDS } } : {}) });
72
+ if (!Array.isArray(response.members) || response.members.some(member => !member?.id)) throw new Error("Invalid member page");
73
+ const extras = response.members.length ? await memberRowExtras(response.members.map(member => member.id), access) : undefined;
57
74
  await assertReadAccess(access);
58
75
  if (access !== directoryAccess) throw memberAccessChanged();
59
- if (!Array.isArray(response.members) || response.members.some(member => !member?.id)) throw new Error("Invalid member page");
60
76
  const metadata = response.metadata;
61
77
  if (metadata?.offset !== undefined && metadata.offset !== offset) throw new Error("Mismatched member page offset");
62
78
  const total = !metadata?.tooManyToCount && typeof metadata?.total === "number" && Number.isSafeInteger(metadata.total) && metadata.total >= 0
@@ -64,7 +80,7 @@ const directory = createCollectionQuery<Row>({
64
80
  const next = offset + response.members.length;
65
81
  if (!response.members.length && total !== undefined && total > next) throw new Error("Incomplete member page");
66
82
  return {
67
- items: response.members.map(row), total,
83
+ items: response.members.map(member => row(member, extras!(member.id))), total,
68
84
  nextCursor: response.members.length && (total === undefined ? response.members.length === PAGE_SIZE : next < total) ? String(next) : "",
69
85
  };
70
86
  },
@@ -73,6 +89,7 @@ const directory = createCollectionQuery<Row>({
73
89
  // Set once the reader has typed a search: from then on the field stays mounted, even while a cleared search
74
90
  // reloads the full directory (no rows, no query yet), so it never unmounts and loses focus mid-edit.
75
91
  let searched = false;
92
+ let followHint = "";
76
93
 
77
94
  function project(): S {
78
95
  const page = directory.getState();
@@ -81,7 +98,7 @@ function project(): S {
81
98
  const failed = page.status === "error";
82
99
  return {
83
100
  status: failed ? "error" : loading ? "loading" : "ready",
84
- members: sections(page.items), query: page.criteria.query, total,
101
+ members: sections(page.items.map(withFollowChange)), query: page.criteria.query, total,
85
102
  searchLabel: total === null ? "" : `${total} ${total === 1 ? "member" : "members"}`,
86
103
  emptyText: page.status === "ready" && !page.items.length ? page.criteria.query ? "No results" : "No members yet" : "",
87
104
  nextCursor: loading || page.loadingMore || page.pageError ? "" : page.nextCursor,
@@ -94,21 +111,24 @@ function project(): S {
94
111
  // A successful empty SEARCH keeps its field reachable (to clear the query); a directory that is
95
112
  // loading or truly empty (no query, no rows) has nothing to search.
96
113
  hideSearch: failed || (!page.items.length && !page.criteria.query && !searched),
114
+ followHint,
97
115
  };
98
116
  }
99
117
  const svc = defineService<S, {
100
- load: (s: S) => Promise<undefined>;
118
+ load: (s: S, arg?: unknown) => Promise<undefined>;
101
119
  filter: (s: S, arg?: unknown) => Promise<undefined>;
102
120
  loadMore: (s: S) => Promise<undefined>;
103
121
  retry: (s: S) => Promise<undefined>;
122
+ toggleFollow: (s: S, arg?: unknown) => Promise<S | undefined>;
104
123
  changed: (s: S) => S;
105
124
  }>("spacesMembers", withEmptyFlags({
106
125
  state: { status: "loading", members: [], emptyText: "", query: "", total: null, searchLabel: "", nextCursor: "", noMore: true, noPageError: true,
107
- error: "", notErrored: true, notLoading: false, hideSearch: true },
126
+ error: "", notErrored: true, notLoading: false, hideSearch: true, followHint: "" },
108
127
  // A restored worker must re-read the current reader/site before showing a roster.
109
- notHydrated: ["status", "members", "emptyText", "query", "total", "searchLabel", "nextCursor", "noMore", "noPageError", "error", "notErrored", "notLoading", "hideSearch"],
128
+ notHydrated: ["status", "members", "emptyText", "query", "total", "searchLabel", "nextCursor", "noMore", "noPageError", "error", "notErrored", "notLoading", "hideSearch", "followHint"],
110
129
  actions: {
111
- load: async () => { await directory.query(directory.getState().criteria); return undefined; },
130
+ // The screen's onAppear carries the copy pack (the owner's wording, the stock translations) and the app language.
131
+ load: async (_s, arg) => { stashCopy(arg); await directory.query(directory.getState().criteria); return undefined; },
112
132
  filter: async (_s, arg) => {
113
133
  const value = typeof arg === "string" ? arg : (arg as { value?: unknown })?.value;
114
134
  if (typeof value === "string" && value.trim()) searched = true;
@@ -116,6 +136,34 @@ const svc = defineService<S, {
116
136
  },
117
137
  loadMore: async () => { await directory.loadMore(); return undefined; },
118
138
  retry: async () => { await directory.retry(); return undefined; },
139
+ // Only a signed-in member reaches this: a Visitor's Follow opens sign-in from the descriptor instead.
140
+ toggleFollow: async (_s, arg) => {
141
+ const id = (arg as { id?: unknown } | undefined)?.id;
142
+ const access = directoryAccess;
143
+ if (typeof id !== "string" || !id || !access?.member.email || pendingFollows.has(id)) return undefined;
144
+ const row = directory.getState().items.find(item => item.id === id);
145
+ if (!row) return undefined;
146
+ const following = withFollowChange(row).isFollowing;
147
+ pendingFollows.add(id);
148
+ try {
149
+ await assertReadAccess(access);
150
+ if (following) await unfollowMember(id); else await followMember(id);
151
+ await assertReadAccess(access);
152
+ // A first-page reload (typing in search, Retry) may have replaced directoryAccess while the write was in
153
+ // flight. The write still stands for the same reader on the same site, so it is recorded; only another
154
+ // reader's list must not take it.
155
+ if (readerKey(access) !== readerKey(directoryAccess)) return undefined;
156
+ followChanges.set(id, !following);
157
+ followHint = "";
158
+ } catch {
159
+ // A failed write belongs to the member who tapped: the control keeps its state and says so.
160
+ if (readerKey(access) !== readerKey(directoryAccess)) return undefined;
161
+ followHint = copyParam("members.followError", "Couldn't update Follow — try again.");
162
+ } finally {
163
+ pendingFollows.delete(id);
164
+ }
165
+ return project();
166
+ },
119
167
  changed: () => project(),
120
168
  },
121
169
  }, { members: "noMembers" }));