@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
@@ -142,6 +142,9 @@
142
142
  "himi_groups": {
143
143
  "displayName": "Groups"
144
144
  },
145
+ "himi_inbox": {
146
+ "displayName": "Conversations"
147
+ },
145
148
  "himi_invoice": {
146
149
  "displayName": "Invoice"
147
150
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$source": "en",
3
- "$generated": "2026-11-09T18:25:39.914Z",
3
+ "$generated": "2026-11-10T10:43:25.380Z",
4
4
  "messages": {
5
5
  "h_042c862e4467": {
6
6
  "source": "Try again",
@@ -27,7 +27,7 @@
27
27
  "himi_member_programs#payload.body.props.children[5].body.props.children[2].body.props.title",
28
28
  "himi_plans#payload.body.props.children[4].body.props.children[2].body.props.title",
29
29
  "himi_feed#payload.body.props.children[8].body.props.children[2].body.props.title",
30
- "himi_members#payload.body.props.children[5].body.props.children[1].body.props.title",
30
+ "himi_members#payload.body.props.children[6].body.props.children[1].body.props.title",
31
31
  "himi_coupons#payload.body.props.children[4].body.props.children[8].body.props.children[2].body.props.title",
32
32
  "himi_notifications#payload.body.props.children[6].body.props.children[8].body.props.children[2].body.props.title",
33
33
  "himi_loyalty#payload.body.props.children[5].body.props.children[1].body.props.title",
@@ -47,7 +47,6 @@
47
47
  "himi_my_groups#payload.body.props.children[2].body.props.children[2].body.props.title",
48
48
  "himi_faq#payload.body.props.children[6].body.props.children[2].body.props.title",
49
49
  "himi_steps#payload.body.props.children[4].body.props.children[2].body.props.title",
50
- "himi_cms#payload.body.props.children[3].body.props.children[7].body.props.children[2].body.props.title",
51
50
  "himi_donations#payload.body.props.children[4].body.props.children[8].body.props.children[2].body.props.title",
52
51
  "himi_gift_cards#payload.body.props.children[4].body.props.children[8].body.props.children[2].body.props.title",
53
52
  "himi_videos#payload.body.props.children[7].body.props.children[8].body.props.children[2].body.props.title",
@@ -57,6 +56,7 @@
57
56
  "himi_form#payload.body.props.children[6].body.props.ctaLabel",
58
57
  "himi_galleries#payload.body.props.children[4].body.props.children[8].body.props.children[2].body.props.title",
59
58
  "himi_business_chat#payload.body.props.children[1].body.props.children[3].body.props.title",
59
+ "himi_inbox#payload.body.props.children[7].body.props.children[1].body.props.title",
60
60
  "himi_food_orders#payload.body.props.children[3].body.props.children[2].body.props.title",
61
61
  "himi_order#payload.body.props.children[2].body.props.ctaLabel",
62
62
  "himi_product#payload.body.props.children[0].body.props.children[2].body.props.title",
@@ -163,7 +163,7 @@
163
163
  "kind": "modifier.accessibilityLabel",
164
164
  "occurrences": [
165
165
  "himi_chat_thread#stickyFooter.body.props.children[0].body.props.children[0].modifiers.accessibilityLabel",
166
- "himi_business_chat#stickyFooter.body.props.children[1].body.props.children[0].modifiers.accessibilityLabel"
166
+ "himi_business_chat#stickyFooter.body.props.children[1].body.props.children[0].body.props.children[1].modifiers.accessibilityLabel"
167
167
  ],
168
168
  "translation": "",
169
169
  "key": "chatThread.composerLabel"
@@ -297,7 +297,7 @@
297
297
  "kind": "TextField.placeholder",
298
298
  "occurrences": [
299
299
  "himi_chat_thread#stickyFooter.body.props.children[0].body.props.children[0].body.props.placeholder",
300
- "himi_business_chat#stickyFooter.body.props.children[1].body.props.children[0].body.props.placeholder"
300
+ "himi_business_chat#stickyFooter.body.props.children[1].body.props.children[0].body.props.children[1].body.props.placeholder"
301
301
  ],
302
302
  "translation": "",
303
303
  "key": "chatThread.composerPlaceholder"
@@ -424,7 +424,6 @@
424
424
  "himi_forum_post#payload.body.props.children[3].body.props.title",
425
425
  "himi_my_events#payload.body.props.children[1].body.props.children[3].body.props.title",
426
426
  "himi_my_groups#payload.body.props.children[6].body.props.children[3].body.props.title",
427
- "himi_cms#payload.body.props.children[3].body.props.children[7].body.props.children[3].body.props.title",
428
427
  "himi_donations#payload.body.props.children[4].body.props.children[8].body.props.children[3].body.props.title",
429
428
  "himi_gift_cards#payload.body.props.children[4].body.props.children[8].body.props.children[3].body.props.title",
430
429
  "himi_videos#payload.body.props.children[7].body.props.children[8].body.props.children[3].body.props.title",
@@ -434,6 +433,7 @@
434
433
  "himi_forms#payload.body.props.children[7].body.props.ctaLabel",
435
434
  "himi_galleries#payload.body.props.children[4].body.props.children[8].body.props.children[3].body.props.title",
436
435
  "himi_business_chat#payload.body.props.children[1].body.props.children[2].body.props.title",
436
+ "himi_inbox#payload.body.props.children[7].body.props.children[2].body.props.title",
437
437
  "himi_food_orders#payload.body.props.children[11].body.props.children[1].body.props.children[3].body.props.title",
438
438
  "himi_order#payload.body.props.children[6].body.props.ctaLabel",
439
439
  "himi_time#payload.body.props.children[5].body.props.children[4].body.props.children[0].body.props.children[5].body.props.title",
@@ -97,6 +97,7 @@ export const HOSTED_SCREEN_VERTICALS: Readonly<Record<string, readonly BrandedLi
97
97
  himi_gift_card: ["giftCards"],
98
98
  himi_gift_cards: ["giftCards"],
99
99
  himi_groups: ["groups"],
100
+ himi_inbox: ["inbox"],
100
101
  himi_group_about: ["groups"],
101
102
  himi_my_groups: ["groups"],
102
103
  himi_invoice: ["invoices"],
@@ -27,7 +27,10 @@ export function smartChatHistory(aiMessages: unknown[], memberPage: unknown[], p
27
27
  const mine = sender === 'SITE_VISITOR' && VISITOR_TYPES.has(kind);
28
28
  if (!mine && !(sender === 'ASSISTANT' && ASSISTANT_TYPES.has(kind)) && !(sender === 'USER' && STAFF_TYPES.has(kind))) continue;
29
29
  const body = object(message.body);
30
- const bodyText = [text(body.mainText), text(body.footerText)].filter(Boolean).join('\n');
30
+ const selectedAction = kind === 'VISITOR_OPTION_SELECTED' && Array.isArray(body.additionalData)
31
+ ? body.additionalData.map(object).find(part => part.type === 'select_option' && text(part.action).trim())?.action
32
+ : '';
33
+ const bodyText = [text(body.mainText) || text(selectedAction), text(body.footerText)].filter(Boolean).join('\n');
31
34
  const rich = memberRows.get(text(message.inboxId));
32
35
  // A quick-reply-only metadata answer is not an empty bubble (the options are shown by the composer).
33
36
  if (!bodyText && !rich) continue;
@@ -1,3 +1,4 @@
1
+ import { isBusinessRoom } from "./inbox-rooms.js";
1
2
  import { businessChatCopy } from "./business-chat-copy.js";
2
3
  import { formatMonthDay } from "./engine-intl.js";
3
4
  type RecordValue = Record<string, unknown>;
@@ -78,13 +79,7 @@ const object = (value: unknown): RecordValue =>
78
79
  /** Return a sole existing two-party business room, or a unique business-name match among several;
79
80
  * never create a room or guess among remaining matches. */
80
81
  export function selectExistingBusinessRoom(rooms: unknown[], businessName?: string, requireUnambiguous = false): { id: string; participantId: string } | undefined {
81
- let candidates = rooms.filter((value) => {
82
- const room = object(value);
83
- return typeof room.id === "string" && !!room.id &&
84
- typeof room.participantId === "string" && !!room.participantId &&
85
- room.participantCount === 2 && room.clientReferenceType === "contactId" &&
86
- Object.keys(object(room.businessContext)).length > 0;
87
- });
82
+ let candidates = rooms.filter(isBusinessRoom);
88
83
  const candidateCount = candidates.length;
89
84
  if (candidateCount > 1 && businessName?.trim()) {
90
85
  // Keep case significant so differently named businesses are not conflated.
@@ -92,7 +92,6 @@ export const COLLECTIONS: Record<string, CollectionConfig> = {
92
92
  searchPlaceholder: 'Customer name, email or order #', sortLabels: { default: 'Newest', az: 'Customer A–Z', za: 'Customer Z–A' },
93
93
  filterValues: ['Recent 7 days', 'Paid', 'Unpaid', 'Partially paid', 'Pending', 'Refunded', 'Partly refunded'] },
94
94
  himiBrandedGroups: { rows: 'rows', title: 'name', subtitle: 'description', image: 'image', view: 'grid' },
95
- himiBrandedCms: { rows: 'rows', title: 'name', subtitle: 'subtitle', view: 'grid' },
96
95
  himiBrandedCmsCollection: { instanceInput: 'collection', rows: 'rows', title: 'title', subtitle: 'subtitle', view: 'list' },
97
96
  himiBrandedOrders: { rows: 'rows', title: 'title', subtitle: 'subtitle', image: 'image', metadata: ['status', 'meta'], rowStyle: 'flat', view: 'list', backendQuery: true,
98
97
  searchPlaceholder: 'Search by order number', countNoun: ['order', 'orders'], filterValues: ['Not fulfilled', 'Partially fulfilled', 'Fulfilled'],
@@ -70,7 +70,7 @@ export function headerBadges<S extends HeaderBadgeState>(update: Update<S>, self
70
70
  const actions: HeaderBadgeActionSignatures<S> = {
71
71
  refreshBadges: async (s) => {
72
72
  const mine = ++read;
73
- const badges = await readSourceBadges(session.site());
73
+ const badges = await readSourceBadges(session.current().kind === "member");
74
74
  return update(s, async (current) => mine === read
75
75
  ? { ...current, notificationsBadge: badges.notifications, chatBadge: badges.chat, cartBadge: badges.cart, errorText: badges.failed.length ? `could not read: ${badges.failed.join(", ")}` : "" }
76
76
  : current);
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  queryDataItems as queryWixDataItems,
3
+ countDataItems,
3
4
  insertDataItem,
4
5
  updateDataItem,
5
6
  removeDataItem,
@@ -8,7 +9,7 @@ import {
8
9
  } from "@himalaya/wix-data";
9
10
 
10
11
  export type { WixDataItem, WixQueryDataItemsResponse };
11
- export { insertDataItem, updateDataItem, removeDataItem };
12
+ export { countDataItems, insertDataItem, updateDataItem, removeDataItem };
12
13
 
13
14
  export interface CmsFieldDefinition {
14
15
  key: string;
@@ -0,0 +1,68 @@
1
+ // The member's Inbox rows: every chatroom the member-role chat list returns, business and member alike.
2
+ // Pure, so the worker and its tests share one classification of what a room is.
3
+
4
+ type RecordValue = Record<string, unknown>;
5
+ const object = (value: unknown): RecordValue =>
6
+ value !== null && typeof value === "object" && !Array.isArray(value) ? value as RecordValue : {};
7
+
8
+ /** The two-party room between the member and the business (the Smart Chat room). Another member's
9
+ * room has no `businessContext`, and a contact-less room is not the business's. */
10
+ export function isBusinessRoom(value: unknown): boolean {
11
+ const room = object(value);
12
+ return typeof room.id === "string" && !!room.id &&
13
+ typeof room.participantId === "string" && !!room.participantId &&
14
+ room.participantCount === 2 && room.clientReferenceType === "contactId" &&
15
+ Object.keys(object(room.businessContext)).length > 0;
16
+ }
17
+
18
+ export interface InboxRow {
19
+ id: string;
20
+ title: string;
21
+ subtitle: string;
22
+ /** The screen a tap opens: the business chat for the business room, a thread for any other. */
23
+ screenId: "himi_business_chat" | "himi_chat_thread";
24
+ /** `chatroom` makes the thread use `id` as the room instead of resolving it as a member id. */
25
+ kind: "chatroom" | "";
26
+ photo: string;
27
+ initial: string;
28
+ unread: boolean;
29
+ unreadDot: string;
30
+ /** Sort key only; not bound to the screen. */
31
+ at: number;
32
+ }
33
+
34
+ const text = (value: unknown): string => (typeof value === "string" ? value.trim() : "");
35
+
36
+ function titleOf(room: RecordValue, business: boolean): string {
37
+ const display = text(object(room.displayData).name);
38
+ if (business) return text(object(room.businessContext).name) || display;
39
+ return display;
40
+ }
41
+
42
+ /** Newest conversation first; archived rooms and rooms without an id are dropped. `fallbackTitle` names a
43
+ * room that carries no name (the caller passes the localised word, not a hard-coded English one). */
44
+ export function inboxRows(rooms: unknown[], fallbackTitle: string): InboxRow[] {
45
+ const rows: InboxRow[] = [];
46
+ for (const value of rooms) {
47
+ const room = object(value);
48
+ const id = text(room.id);
49
+ if (!id || room.isArchived === true) continue;
50
+ const business = isBusinessRoom(room);
51
+ const title = titleOf(room, business) || fallbackTitle;
52
+ const last = object(room.lastMessage);
53
+ const unread = typeof room.unreadCount === "number" && room.unreadCount > 0;
54
+ rows.push({
55
+ id,
56
+ title,
57
+ subtitle: text(last.summary),
58
+ screenId: business ? "himi_business_chat" : "himi_chat_thread",
59
+ kind: business ? "" : "chatroom",
60
+ photo: text(object(object(room.displayData).image).url),
61
+ initial: (title[0] ?? "#").toUpperCase(),
62
+ unread,
63
+ unreadDot: unread ? "●" : "",
64
+ at: typeof last.createdAt === "number" ? last.createdAt : typeof room.createdAt === "number" ? room.createdAt : 0,
65
+ });
66
+ }
67
+ return rows.sort((a, b) => b.at - a.at);
68
+ }
@@ -0,0 +1,34 @@
1
+ // The One App's "Active 11h ago" / "Last seen Apr 5, 2023" line for a member, from the same inputs and by
2
+ // the same rule as its member list (wix-one-app-members-area getActiveSinceText).
3
+ import type { WixMemberActivity } from "@himalaya/wix-members";
4
+ import { copyParam, copyTemplate } from "./himi-copy.js";
5
+ import { appLocale } from "./app-locale.js";
6
+ import { formatDateMedium } from "./engine-intl.js";
7
+
8
+ /** Less than this many days reads as "Active Nd ago"; from here the One App prints the date instead. */
9
+ const DAYS_AS_COUNT = 30;
10
+
11
+ /** "" when there is nothing to say: a member who never opened the app shows no line. */
12
+ export function activityText(activity: Pick<WixMemberActivity, "online" | "lastOpenApp"> | undefined): string {
13
+ if (!activity) return "";
14
+ const activeNow = () => copyParam("members.activeNow", "Active now");
15
+ if (activity.online) return activeNow();
16
+ const last = activity.lastOpenApp;
17
+ if (!last) return "";
18
+ const amount = typeof last.amount === "number" ? last.amount : 0;
19
+ switch (last.unit) {
20
+ case "min":
21
+ return amount === 0 ? activeNow() : copyTemplate("members.activeMinutesAgo", "Active {amount}m ago", { amount });
22
+ case "hour":
23
+ return copyTemplate("members.activeHoursAgo", "Active {amount}h ago", { amount });
24
+ case "day": {
25
+ const daysAgo = () => copyTemplate("members.activeDaysAgo", "Active {amount}d ago", { amount });
26
+ if ((amount && amount < DAYS_AS_COUNT) || !last.ts) return daysAgo();
27
+ const at = Number(last.ts);
28
+ if (!Number.isFinite(at)) return daysAgo();
29
+ return copyTemplate("members.lastSeen", "Last seen {date}", { date: formatDateMedium(new Date(at), appLocale()) });
30
+ }
31
+ default:
32
+ return "";
33
+ }
34
+ }
@@ -0,0 +1,167 @@
1
+ // What a members-directory row shows beyond name and photo: the role line, the member's badges and the
2
+ // Follow control. One call per page for each source, and none of it may fail the directory: a refused or
3
+ // failed read leaves that row element out (no chip, a plain Follow), never an error in place of the list.
4
+ import { query as queryBadges, queryBadgeAssignments, type WixBadge } from "@himalaya/wix-badges";
5
+ import { getMyMember, listFollowedMembers, queryMemberActivity } from "@himalaya/wix-members";
6
+ import { compareIso } from "./engine-intl.js";
7
+ import { copyParam } from "./himi-copy.js";
8
+ import { activityText } from "./member-activity.js";
9
+ import type { ReadAccess } from "./member-read.js";
10
+
11
+ /** The One App row draws ONE badge chip and a "+N" chip for the rest. */
12
+ const MAX_BADGE_CHIPS = 1;
13
+ const PAGE_LIMIT = 100;
14
+ const CATALOG_PAGES = 5;
15
+ const ASSIGNMENT_PAGES = 10;
16
+ /** The chip's text is white (`textOnAccent`); this is the least contrast it may have against the badge colour. */
17
+ const MIN_CONTRAST = 3;
18
+
19
+ export interface MemberRowExtras {
20
+ /** Role line, then the last-active line when there is one. The role line reads "Member" for everyone: the activity route
21
+ * also answers a role (an admin's is "ADMIN"), but the One App's wording for the other roles is not recorded here,
22
+ * so none is guessed. */
23
+ subtitle: string;
24
+ badgeTitle: string; badgeColor: string; noBadge: boolean;
25
+ badgeMore: string; noBadgeMore: boolean;
26
+ followLabel: string; isFollowing: boolean;
27
+ /** Hidden for a Visitor (who gets the sign-in variant) and on the signed-in member's own row. */
28
+ hideFollow: boolean;
29
+ /** Only a Visitor sees this one: Follow needs a member, so the tap opens sign-in. */
30
+ hideSignInFollow: boolean;
31
+ }
32
+
33
+ export function followLabel(isFollowing: boolean): string {
34
+ return copyParam(isFollowing ? "members.followingCta" : "members.followCta", isFollowing ? "Following" : "Follow");
35
+ }
36
+
37
+ interface BadgeInfo { title: string; color: string; rank: number }
38
+
39
+ function hexRgb(value: string | undefined): [number, number, number] | undefined {
40
+ const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec((value ?? "").trim());
41
+ if (!m) return undefined;
42
+ const h = m[1].length === 3 ? m[1].split("").map(c => c + c).join("") : m[1];
43
+ return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16)) as [number, number, number];
44
+ }
45
+ function luminance([r, g, b]: [number, number, number]): number {
46
+ const lin = (v: number) => { const c = v / 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
47
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
48
+ }
49
+ /** The badge colour as `#rrggbb`, darkened until white text on it reads (a pastel chip would otherwise
50
+ * lose its label in one of the two themes). "" when the badge carries no usable colour, so the row's
51
+ * token background shows instead. */
52
+ export function chipColor(value: string | undefined): string {
53
+ let rgb = hexRgb(value);
54
+ if (!rgb) return "";
55
+ while ((1.05 / (luminance(rgb) + 0.05)) < MIN_CONTRAST && (rgb[0] || rgb[1] || rgb[2])) {
56
+ rgb = rgb.map(c => Math.floor(c * 0.9)) as [number, number, number];
57
+ }
58
+ return "#" + rgb.map(c => c.toString(16).padStart(2, "0")).join("");
59
+ }
60
+
61
+ let catalogTenant = "";
62
+ let catalog: Map<string, BadgeInfo> | undefined;
63
+
64
+ /** The site's badges, newest first: that is the order the One App picks a member's first chip in. */
65
+ async function badgeCatalog(tenant: string): Promise<Map<string, BadgeInfo>> {
66
+ if (catalog && catalogTenant === tenant) return catalog;
67
+ const badges: WixBadge[] = [];
68
+ let cursor: string | undefined;
69
+ for (let page = 0; page < CATALOG_PAGES; page++) {
70
+ const res = await queryBadges({ query: { cursorPaging: cursor ? { cursor } : { limit: PAGE_LIMIT } } });
71
+ badges.push(...(res.badges ?? []));
72
+ cursor = res.pagingMetadata?.hasNext ? res.pagingMetadata.cursors?.next : undefined;
73
+ if (!cursor) break;
74
+ }
75
+ if (cursor) throw new Error("Badge catalog is longer than the pages read");
76
+ const newestFirst = badges.filter(b => b.id && b.title?.trim())
77
+ .sort((a, b) => compareIso(String(b.createdDate ?? ""), String(a.createdDate ?? "")));
78
+ catalogTenant = tenant;
79
+ return catalog = new Map(newestFirst.map((b, rank) => [b.id as string, { title: (b.title as string).trim(), color: chipColor(b.backgroundColor), rank }]));
80
+ }
81
+
82
+ /** memberId → badge ids, for the page's members. A cursor request carries the cursor alone: the service
83
+ * refuses (400) a follow-up page that repeats the filter or the limit. A page the pages cap cuts short is refused whole: a
84
+ * partial list would show the wrong first chip and a wrong "+N". */
85
+ async function badgesByMember(ids: string[]): Promise<Map<string, string[]>> {
86
+ const byMember = new Map<string, string[]>();
87
+ let cursor: string | undefined;
88
+ for (let page = 0; page < ASSIGNMENT_PAGES; page++) {
89
+ const res = await queryBadgeAssignments({ query: cursor ? { cursorPaging: { cursor } } : { filter: { memberId: { $in: ids } }, cursorPaging: { limit: PAGE_LIMIT } } });
90
+ for (const a of res.badgeAssignments ?? []) {
91
+ if (!a.memberId || !a.badgeId) continue;
92
+ const held = byMember.get(a.memberId) ?? [];
93
+ held.push(a.badgeId); byMember.set(a.memberId, held);
94
+ }
95
+ cursor = res.pagingMetadata?.hasNext ? res.pagingMetadata.cursors?.next : undefined;
96
+ if (!cursor) return byMember;
97
+ }
98
+ throw new Error("Badge assignments are longer than the pages read");
99
+ }
100
+
101
+ async function readBadges(ids: string[], tenant: string): Promise<Map<string, BadgeInfo[]>> {
102
+ const [known, held] = await Promise.all([badgeCatalog(tenant), badgesByMember(ids)]);
103
+ const result = new Map<string, BadgeInfo[]>();
104
+ for (const [memberId, badgeIds] of held) {
105
+ const badges = badgeIds.map(id => known.get(id)).filter((b): b is BadgeInfo => !!b).sort((a, b) => a.rank - b.rank);
106
+ if (badges.length) result.set(memberId, badges);
107
+ }
108
+ return result;
109
+ }
110
+
111
+ /** memberId → "Active 11h ago" etc., for the members the One App service knows and has something to say about.
112
+ * An older native build refuses the route; that leaves the activity line out and nothing else. */
113
+ async function readActivity(ids: string[]): Promise<Map<string, string>> {
114
+ const texts = new Map<string, string>();
115
+ for (const activity of await queryMemberActivity(ids)) {
116
+ const text = activityText(activity);
117
+ if (text) texts.set(activity.id, text);
118
+ }
119
+ return texts;
120
+ }
121
+
122
+ async function readFollowed(ids: string[], access: ReadAccess): Promise<Set<string>> {
123
+ // Follow state is the signed-in member's own; a Visitor has none to read.
124
+ return access.member.email ? new Set(await listFollowedMembers(ids)) : new Set();
125
+ }
126
+
127
+ let selfScope = "";
128
+ let selfId = "";
129
+ async function ownMemberId(access: ReadAccess): Promise<string> {
130
+ if (!access.member.email) return "";
131
+ const scope = JSON.stringify([access.tenant, access.member.email]);
132
+ if (selfScope !== scope) {
133
+ const id = (await getMyMember({ fieldsets: ["PUBLIC"] })).member?.id ?? "";
134
+ // An answer without a member is not a lookup to remember: the next page asks again, so the member's own row
135
+ // does not keep a Follow button for the rest of the session.
136
+ if (!id) return "";
137
+ selfId = id;
138
+ selfScope = scope;
139
+ }
140
+ return selfId;
141
+ }
142
+
143
+ const attempt = async <T>(read: Promise<T>, fallback: T): Promise<T> => { try { return await read; } catch { return fallback; } };
144
+
145
+ /** The per-row extras for one page of members. Each source fails on its own. */
146
+ export async function memberRowExtras(ids: string[], access: ReadAccess): Promise<(id: string) => MemberRowExtras> {
147
+ const [badges, activity, followed, self] = await Promise.all([
148
+ attempt(readBadges(ids, access.tenant), new Map<string, BadgeInfo[]>()),
149
+ attempt(readActivity(ids), new Map<string, string>()),
150
+ attempt(readFollowed(ids, access), new Set<string>()),
151
+ attempt(ownMemberId(access), ""),
152
+ ]);
153
+ const visitor = !access.member.email;
154
+ const role = copyParam("members.roleMember", "Member");
155
+ return (id) => {
156
+ const held = badges.get(id) ?? [];
157
+ const more = held.length - MAX_BADGE_CHIPS;
158
+ const isFollowing = followed.has(id);
159
+ return {
160
+ subtitle: [role, activity.get(id)].filter(Boolean).join(" · "),
161
+ badgeTitle: held[0]?.title ?? "", badgeColor: held[0]?.color ?? "", noBadge: !held.length,
162
+ badgeMore: more > 0 ? `+${more}` : "", noBadgeMore: more <= 0,
163
+ followLabel: followLabel(isFollowing), isFollowing,
164
+ hideFollow: visitor || id === self, hideSignInFollow: !visitor,
165
+ };
166
+ };
167
+ }
@@ -0,0 +1,141 @@
1
+ // Where a tap on a member notification goes. The route table is DERIVED from the Wix member-link catalog
2
+ // (via the pinned copy in ./wix-member-links.ts), the one an app's push taps use, so a
3
+ // notification in the inbox opens exactly what the same notification opens as a push: each destination's canonical
4
+ // Himi screen, with the item id under `id` (branded-lite's `push.routes` is built the same way).
5
+ //
6
+ // Beyond the catalog there are a few links the One App registry and the Business Manager send that a member can
7
+ // receive, listed in EXTRA_ROUTES with where each was read (wix-private/mobile-modules at 7cdefdc557fb):
8
+ // blog wix-one-app-new-blog/src/services/get-deep-links.ts (the multi-site form of a post link)
9
+ // inbox mobile-apps-inbox/src/module/deepLinks.ts (a chatroom)
10
+ // stores, bookings, club
11
+ // hybrid-mobile-platform/src/pushNotifications/deepLinks.ts (links sent by the Business Manager,
12
+ // Ping automations and the owner app to a branded app's members)
13
+ // Loyalty, coupons and FAQ links are NOT here: the catalog records that no push reaches them, and a route for a
14
+ // link nothing sends is dead config that nobody notices.
15
+ //
16
+ // A link that matches no row is NOT guessed at: it falls back to the notification's web link (https only) or to
17
+ // a visible "can't open", never to a made-up screen. A screen the app does not have opens nothing, exactly as the
18
+ // same push tap would. Programs have no catalog entry and so fall back; add a row, and a test, when a real payload
19
+ // names one.
20
+
21
+ import { MEMBER_LINK_DESTINATIONS } from "./wix-member-links.js";
22
+
23
+ /** The notification's own link, as the Members Feed Viewer returns it. */
24
+ export interface NotificationLink {
25
+ webUrlData?: { webUrl?: string };
26
+ mobileData?: { data?: string };
27
+ }
28
+
29
+ export interface NotificationTarget {
30
+ screenId: string;
31
+ /** Screen arguments. Nav params are strings. */
32
+ args: Record<string, string>;
33
+ }
34
+
35
+ export type NotificationOpen =
36
+ | { kind: "screen"; target: NotificationTarget }
37
+ | { kind: "url"; url: string }
38
+ | { kind: "none" };
39
+
40
+ export interface Route {
41
+ /** `a/:name/b` — `:name` captures one segment; every other segment must match exactly. */
42
+ pattern: string;
43
+ screenId: string;
44
+ /** Capture name → screen argument name. `kind` and the like are fixed arguments. */
45
+ args?: Record<string, string>;
46
+ fixed?: Record<string, string>;
47
+ }
48
+
49
+ /** The catalog's links, each opening its destination's canonical screen with the item under `id`. The inbox
50
+ * itself is left out: a notification that points at the list has nowhere further to go. */
51
+ const CATALOG_ROUTES: readonly Route[] = MEMBER_LINK_DESTINATIONS
52
+ .filter((destination) => destination.id !== "notifications.list")
53
+ .flatMap((destination) => destination.matches.map((pattern): Route => ({
54
+ pattern,
55
+ screenId: destination.canonicalScreen,
56
+ ...(destination.screenParam ? { args: { [destination.screenParam]: "id" } } : {}),
57
+ })));
58
+
59
+ const CHATROOM = { screenId: "himi_chat_thread", args: { chatroomId: "id" }, fixed: { kind: "chatroom" } };
60
+
61
+ const EXTRA_ROUTES: readonly Route[] = [
62
+ { pattern: "blog/post/:metaSiteId/:postId", screenId: "himi_blog_post", args: { postId: "id" } },
63
+ { pattern: "inbox/chatroom-by-id/:chatroomId", ...CHATROOM },
64
+ { pattern: "inbox/conversation-by-chatroom-id/:chatroomId", ...CHATROOM },
65
+ { pattern: "stores/:metaSiteId/catalog-for-inbox/:productId", screenId: "himi_product", args: { productId: "id" } },
66
+ { pattern: "bookings/:metaSiteId/service/:serviceId/details", screenId: "himi_service", args: { serviceId: "id" } },
67
+ { pattern: "platform/club/:tabId/:metaSiteId", screenId: "himi_branded_home" },
68
+ ];
69
+
70
+ export const NOTIFICATION_ROUTES: readonly Route[] = [...CATALOG_ROUTES, ...EXTRA_ROUTES];
71
+
72
+ /** Every screen a notification link can open: what an app is asked about when it says which of them it serves. */
73
+ export const NOTIFICATION_SCREEN_IDS: readonly string[] = [...new Set(NOTIFICATION_ROUTES.map((route) => route.screenId))];
74
+
75
+ /** A captured id: a GUID, a slug or a short token. Anything else (an encoded separator, a space, an empty
76
+ * segment, a segment of only dots such as `..`) means the link is not one of ours, so it opens nothing. */
77
+ const SAFE_SEGMENT = /^(?!\.+$)[A-Za-z0-9._~-]{1,128}$/;
78
+
79
+ /** The path of a `<scheme>://<path>` link, without query or fragment. null when it is not one. */
80
+ function pathOf(link: string): string | null {
81
+ const body = link.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").split(/[?#]/, 1)[0].replace(/^\/+|\/+$/g, "");
82
+ return body ? body : null;
83
+ }
84
+
85
+ function match(route: Route, segments: string[]): NotificationTarget | null {
86
+ const parts = route.pattern.split("/");
87
+ if (parts.length !== segments.length) return null;
88
+ const captured: Record<string, string> = {};
89
+ for (let i = 0; i < parts.length; i++) {
90
+ if (parts[i].startsWith(":")) {
91
+ if (!SAFE_SEGMENT.test(segments[i])) return null;
92
+ captured[parts[i].slice(1)] = segments[i];
93
+ } else if (parts[i] !== segments[i]) {
94
+ return null;
95
+ }
96
+ }
97
+ const args: Record<string, string> = { ...route.fixed };
98
+ for (const [capture, name] of Object.entries(route.args ?? {})) args[name] = captured[capture];
99
+ return { screenId: route.screenId, args };
100
+ }
101
+
102
+ /** The screen a `wix://` link opens, or null when no row of the table claims it. */
103
+ export function screenForLink(link: string | undefined): NotificationTarget | null {
104
+ const path = link ? pathOf(link) : null;
105
+ if (!path) return null;
106
+ const segments = path.split("/");
107
+ for (const route of NOTIFICATION_ROUTES) {
108
+ const target = match(route, segments);
109
+ if (target) return target;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ /** Not every Tier-5 JS engine has `URL`, so a link is read by hand: `https://<host>[:port][/…]`, where the host
115
+ * is a dotted name of letters, digits and hyphens. Credentials (`user@`), a backslash, whitespace or any
116
+ * control character, and every other scheme (http, javascript:, data:, a custom scheme) are refused: a
117
+ * notification's web link is written by the site, so it is not trusted to run anything. */
118
+ const HTTPS_LINK = /^https:\/\/([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+)(?::\d{1,5})?(?:[/?#][^\s\\\u0000-\u001f\u007f]*)?$/;
119
+
120
+ /** An https link, or null. */
121
+ export function safeWebUrl(raw: string | undefined): string | null {
122
+ const url = raw?.trim() ?? "";
123
+ // The cap keeps the match cheap on a hostile, site-written value.
124
+ return url.length <= 2048 && HTTPS_LINK.test(url) ? url : null;
125
+ }
126
+
127
+ /** The host of a link `safeWebUrl` accepted, for showing the member where a tap goes. */
128
+ export function urlHost(url: string): string {
129
+ return HTTPS_LINK.exec(url)?.[1] ?? "";
130
+ }
131
+
132
+ /** What a tap on this notification does. A recognised app link wins over the web link: a native screen is the
133
+ * intent, and the web link is only the way out for a notification that names no screen of ours. */
134
+ export function notificationOpen(link: NotificationLink | undefined, served?: ReadonlySet<string>): NotificationOpen {
135
+ const target = screenForLink(link?.mobileData?.data);
136
+ // `served` is the screens this app actually has: a link to one it lacks (an order link in an app without the Store) is
137
+ // not opened, and falls to the web link or the visible "can't open", exactly like a link no row claims.
138
+ if (target && (!served || served.has(target.screenId))) return { kind: "screen", target };
139
+ const url = safeWebUrl(link?.webUrlData?.webUrl);
140
+ return url ? { kind: "url", url } : { kind: "none" };
141
+ }
@@ -40,11 +40,13 @@ async function unavailable<T>(read: () => Promise<T>): Promise<T | null> {
40
40
  const SHOPPER_CHANGED = new Error("shopper changed");
41
41
 
42
42
  /** One refresh. Notifications and chat are a Member's: a Visitor has neither inbox, so both read null without
43
- * a request. Notifications also need `site`, the site the native bridge is bound to (the host session's own,
44
- * never a caller's choice); without one that counter stays unavailable. Cart is the current shopper's.
43
+ * a request. Notifications also need `hostMember`, the host's own session clock saying it is a signed-in member
44
+ * (a host with no clock, web and older binaries, stays unavailable). The worker names no site: native sends
45
+ * the bridge's bound site, or none in the multi-tenant Player, where the member credential fixes the business.
46
+ * Cart is the current shopper's.
45
47
  * Offline reads nothing. A count that finishes after the signed-in identity changed is dropped, so one
46
48
  * member's number never lands on the next member's header. */
47
- export async function readSourceBadges(site: string | null): Promise<SourceBadges> {
49
+ export async function readSourceBadges(hostMember: boolean): Promise<SourceBadges> {
48
50
  // Whatever goes wrong, the answer is "unavailable": this runs fire-and-forget at launch.
49
51
  const read = unavailable(async (): Promise<SourceBadges> => {
50
52
  if (!connectivity().online) return NO_SOURCE_BADGES;
@@ -60,7 +62,7 @@ export async function readSourceBadges(site: string | null): Promise<SourceBadge
60
62
  try { return await run(); } catch (error) { if (error !== SHOPPER_CHANGED) failed.push(key); return null; }
61
63
  };
62
64
  const [notifications, chat, cart] = await Promise.all([
63
- member && site ? attempt("notifications", () => stillHere(countUnseenNotifications({ site }))) : null,
65
+ member && hostMember ? attempt("notifications", () => stillHere(countUnseenNotifications())) : null,
64
66
  member ? attempt("chat", () => unreadRoomCount(identity)) : null,
65
67
  attempt("cart", () => stillHere(cartItemCount())),
66
68
  ]);