@wix/himalaya-cli 0.1252.0 → 0.1254.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 (97) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/base44/src/index.d.ts +3 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/sdk-types/src/capabilities.generated.d.ts +1 -1
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/audio-record.mjs +1 -1
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/base44.mjs +4 -0
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/calendar.mjs +1 -1
  6. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/calls.mjs +1 -1
  7. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/contacts.mjs +1 -1
  8. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/location.mjs +1 -1
  9. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/motion.mjs +1 -1
  10. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/photos.mjs +1 -1
  11. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdk-types.mjs +1 -1
  12. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/tasks.mjs +1 -1
  13. package/dist/cli.mjs +22 -22
  14. package/dist/preview-assets.generated.bin +0 -0
  15. package/guides/base44-app-to-himi-app.md +30 -8
  16. package/guides/getting-started.md +8 -0
  17. package/guides/himi-skill.md +8 -0
  18. package/guides/sdk-reference.json +7 -1
  19. package/guides/skill.md +2 -1
  20. package/package.json +2 -2
  21. package/templates/beauty-salon-owner/dev/index.ts +7 -5
  22. package/templates/beauty-salon-owner/tier5-src/_shared/owner-module-data.ts +78 -7
  23. package/templates/beauty-salon-owner/tier5-src/_shared/owner-order-attention.ts +18 -0
  24. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-drawer/index.ts +321 -9
  25. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-home/index.ts +2 -10
  26. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-search/index.ts +5 -1
  27. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-search/logic.ts +36 -9
  28. package/templates/community-club-owner/dev/index.ts +7 -5
  29. package/templates/community-club-owner/tier5-src/_shared/owner-module-data.ts +78 -7
  30. package/templates/community-club-owner/tier5-src/_shared/owner-order-attention.ts +18 -0
  31. package/templates/community-club-owner/tier5-src/himi-owner-lite-drawer/index.ts +321 -9
  32. package/templates/community-club-owner/tier5-src/himi-owner-lite-home/index.ts +2 -10
  33. package/templates/community-club-owner/tier5-src/himi-owner-lite-search/index.ts +5 -1
  34. package/templates/community-club-owner/tier5-src/himi-owner-lite-search/logic.ts +36 -9
  35. package/templates/events-meetups-owner/dev/index.ts +7 -5
  36. package/templates/events-meetups-owner/tier5-src/_shared/owner-module-data.ts +78 -7
  37. package/templates/events-meetups-owner/tier5-src/_shared/owner-order-attention.ts +18 -0
  38. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-drawer/index.ts +321 -9
  39. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-home/index.ts +2 -10
  40. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-search/index.ts +5 -1
  41. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-search/logic.ts +36 -9
  42. package/templates/gym-fitness-owner/dev/index.ts +7 -5
  43. package/templates/gym-fitness-owner/tier5-src/_shared/owner-module-data.ts +78 -7
  44. package/templates/gym-fitness-owner/tier5-src/_shared/owner-order-attention.ts +18 -0
  45. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-drawer/index.ts +321 -9
  46. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-home/index.ts +2 -10
  47. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-search/index.ts +5 -1
  48. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-search/logic.ts +36 -9
  49. package/templates/nonprofit-cause-owner/dev/index.ts +7 -5
  50. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-module-data.ts +78 -7
  51. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-order-attention.ts +18 -0
  52. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-drawer/index.ts +321 -9
  53. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-home/index.ts +2 -10
  54. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-search/index.ts +5 -1
  55. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-search/logic.ts +36 -9
  56. package/templates/owner-lite/dev/index.ts +7 -5
  57. package/templates/owner-lite/tier5-src/_shared/owner-module-data.ts +78 -7
  58. package/templates/owner-lite/tier5-src/_shared/owner-order-attention.ts +18 -0
  59. package/templates/owner-lite/tier5-src/himi-owner-lite-drawer/index.ts +321 -9
  60. package/templates/owner-lite/tier5-src/himi-owner-lite-home/index.ts +2 -10
  61. package/templates/owner-lite/tier5-src/himi-owner-lite-search/index.ts +5 -1
  62. package/templates/owner-lite/tier5-src/himi-owner-lite-search/logic.ts +36 -9
  63. package/templates/podcast-creator-owner/dev/index.ts +7 -5
  64. package/templates/podcast-creator-owner/tier5-src/_shared/owner-module-data.ts +78 -7
  65. package/templates/podcast-creator-owner/tier5-src/_shared/owner-order-attention.ts +18 -0
  66. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-drawer/index.ts +321 -9
  67. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-home/index.ts +2 -10
  68. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-search/index.ts +5 -1
  69. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-search/logic.ts +36 -9
  70. package/templates/portfolio-creative-owner/dev/index.ts +7 -5
  71. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-module-data.ts +78 -7
  72. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-order-attention.ts +18 -0
  73. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-drawer/index.ts +321 -9
  74. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-home/index.ts +2 -10
  75. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-search/index.ts +5 -1
  76. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-search/logic.ts +36 -9
  77. package/templates/restaurant-owner/dev/index.ts +7 -5
  78. package/templates/restaurant-owner/tier5-src/_shared/owner-module-data.ts +78 -7
  79. package/templates/restaurant-owner/tier5-src/_shared/owner-order-attention.ts +18 -0
  80. package/templates/restaurant-owner/tier5-src/himi-owner-lite-drawer/index.ts +321 -9
  81. package/templates/restaurant-owner/tier5-src/himi-owner-lite-home/index.ts +2 -10
  82. package/templates/restaurant-owner/tier5-src/himi-owner-lite-search/index.ts +5 -1
  83. package/templates/restaurant-owner/tier5-src/himi-owner-lite-search/logic.ts +36 -9
  84. package/templates/retail-store-owner/dev/index.ts +7 -5
  85. package/templates/retail-store-owner/tier5-src/_shared/owner-module-data.ts +78 -7
  86. package/templates/retail-store-owner/tier5-src/_shared/owner-order-attention.ts +18 -0
  87. package/templates/retail-store-owner/tier5-src/himi-owner-lite-drawer/index.ts +321 -9
  88. package/templates/retail-store-owner/tier5-src/himi-owner-lite-home/index.ts +2 -10
  89. package/templates/retail-store-owner/tier5-src/himi-owner-lite-search/index.ts +5 -1
  90. package/templates/retail-store-owner/tier5-src/himi-owner-lite-search/logic.ts +36 -9
  91. package/templates/yoga-studio-owner/dev/index.ts +7 -5
  92. package/templates/yoga-studio-owner/tier5-src/_shared/owner-module-data.ts +78 -7
  93. package/templates/yoga-studio-owner/tier5-src/_shared/owner-order-attention.ts +18 -0
  94. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-drawer/index.ts +321 -9
  95. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-home/index.ts +2 -10
  96. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-search/index.ts +5 -1
  97. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-search/logic.ts +36 -9
@@ -19,8 +19,277 @@
19
19
 
20
20
  import { defineService } from "@himalaya/state";
21
21
  import { serializeForAgent } from "@himalaya/agent";
22
+ import { searchOrders } from "@himalaya/wix-ecom";
23
+ import { queryBookings, type WixBooking } from "@himalaya/wix-bookings";
24
+ import { site } from "@himalaya/wix-platform";
22
25
  import { activeScreenFromInput, attentionRow, navRow, withDerived, type DrawerState } from "./logic.js";
23
- import { fallbackNameFrom, selectedSiteName } from "../_shared/owner-selected-site.js";
26
+ import { UPCOMING_BOOKING_STATUSES, upcomingBookingCount } from "../_shared/owner-bookings.js";
27
+ import { ownerOrderAttentionFilter } from "../_shared/owner-order-attention.js";
28
+ import { fallbackNameFrom, readSelectedSite, selectedSiteName } from "../_shared/owner-selected-site.js";
29
+
30
+ const ATTENTION_PAGE = 50;
31
+ const DISMISSED_AT_KEY = "himi.ownerLite.drawer.attentionDismissedAt.v1";
32
+ const OBSERVED_ACTIVITY_AT_KEY = "himi.ownerLite.drawer.latestObservedActivityAt.v1";
33
+ const DISMISSED_BOOKINGS_KEY = "himi.ownerLite.drawer.dismissedBookings.v1";
34
+ const OBSERVED_BOOKINGS_KEY = "himi.ownerLite.drawer.observedBookings.v1";
35
+ const DISMISSED_ORDERS_KEY = "himi.ownerLite.drawer.dismissedOrders.v1";
36
+ const OBSERVED_ORDERS_KEY = "himi.ownerLite.drawer.observedOrders.v1";
37
+ let refreshRevision = 0;
38
+
39
+ type DrawerStorage = { getItem(key: string): string | null; setItem(key: string, value: string): void };
40
+ type DrawerHost = typeof globalThis & { himalaya?: { storage?: DrawerStorage; input?: Record<string, unknown> } };
41
+
42
+ function currentSiteId(): string {
43
+ const selected = readSelectedSite()?.id;
44
+ if (selected) return selected;
45
+ const business = (globalThis as DrawerHost).himalaya?.input?.business;
46
+ return typeof business === "string" && business.trim() !== "default" ? business.trim() : "";
47
+ }
48
+
49
+ function dismissalKey(siteId = currentSiteId()): string {
50
+ const input = (globalThis as DrawerHost).himalaya?.input;
51
+ const key = siteId || (typeof input?.business === "string" ? input.business.trim() : "") || "default";
52
+ return DISMISSED_AT_KEY + ":" + encodeURIComponent(key);
53
+ }
54
+
55
+ function dismissedAt(key = dismissalKey()): string {
56
+ // If persistence is unavailable, fail the refresh instead of treating a failed
57
+ // read as “not dismissed” and unexpectedly restoring hidden reminders.
58
+ const value = (globalThis as DrawerHost).himalaya?.storage?.getItem(key) ?? "";
59
+ return value && Number.isFinite(Date.parse(value)) ? value : "";
60
+ }
61
+
62
+ function latestActivityAt(record: { createdDate?: unknown; updatedDate?: unknown }): string {
63
+ const timestamps = [record.createdDate, record.updatedDate]
64
+ .filter((value): value is string => typeof value === "string" && Number.isFinite(Date.parse(value)))
65
+ .sort((a, b) => Date.parse(b) - Date.parse(a));
66
+ return timestamps[0] ?? "";
67
+ }
68
+
69
+ function newerActivity(left: string, right: string): string {
70
+ return !left || (right && Date.parse(right) > Date.parse(left)) ? right : left;
71
+ }
72
+
73
+ function rememberObservedActivity(siteKey: string, value: string): void {
74
+ if (!value) return;
75
+ try {
76
+ const storage = (globalThis as DrawerHost).himalaya?.storage;
77
+ const key = `${OBSERVED_ACTIVITY_AT_KEY}:${encodeURIComponent(siteKey)}`;
78
+ const previous = storage?.getItem(key) ?? "";
79
+ const latest = newerActivity(previous, value);
80
+ if (latest) storage?.setItem(key, latest);
81
+ } catch { /* best-effort local preference */ }
82
+ }
83
+
84
+ type RecordSnapshot = Record<string, string>;
85
+ type BookingSnapshot = RecordSnapshot;
86
+ type OrderSnapshot = RecordSnapshot;
87
+
88
+ function bookingSnapshotKey(prefix: string, siteKey: string): string {
89
+ return `${prefix}:${encodeURIComponent(siteKey)}`;
90
+ }
91
+
92
+ function readRecordSnapshot(key: string): RecordSnapshot | undefined {
93
+ const raw = (globalThis as DrawerHost).himalaya?.storage?.getItem(key);
94
+ if (raw === null || raw === undefined) return undefined;
95
+ const value = JSON.parse(raw) as unknown;
96
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
97
+ throw new Error("Stored drawer reminder snapshot must be an object");
98
+ }
99
+ const entries = Object.entries(value);
100
+ if (entries.some((entry) => typeof entry[1] !== "string")) {
101
+ throw new Error("Stored drawer reminder snapshot values must be strings");
102
+ }
103
+ return Object.fromEntries(entries) as RecordSnapshot;
104
+ }
105
+
106
+ const readBookingSnapshot = (key: string): BookingSnapshot | undefined => readRecordSnapshot(key);
107
+
108
+ function orderSignature(order: {
109
+ archived?: unknown;
110
+ status?: unknown;
111
+ paymentStatus?: unknown;
112
+ fulfillmentStatus?: unknown;
113
+ createdDate?: unknown;
114
+ updatedDate?: unknown;
115
+ }): string {
116
+ return JSON.stringify({
117
+ archived: order.archived ?? null,
118
+ status: order.status ?? "",
119
+ paymentStatus: order.paymentStatus ?? "",
120
+ fulfillmentStatus: order.fulfillmentStatus ?? "",
121
+ createdDate: order.createdDate ?? "",
122
+ updatedDate: order.updatedDate ?? "",
123
+ });
124
+ }
125
+
126
+ function orderSnapshot(orders: Array<{ id?: string } & Parameters<typeof orderSignature>[0]>): OrderSnapshot {
127
+ return Object.fromEntries(orders.filter((order) => order.id)
128
+ .map((order) => [order.id!, orderSignature(order)]));
129
+ }
130
+
131
+ function rememberObservedOrders(siteKey: string, snapshot: OrderSnapshot): void {
132
+ try {
133
+ (globalThis as DrawerHost).himalaya?.storage?.setItem(
134
+ bookingSnapshotKey(OBSERVED_ORDERS_KEY, siteKey), JSON.stringify(snapshot),
135
+ );
136
+ } catch { /* best-effort local preference */ }
137
+ }
138
+
139
+ function bookingSignature(booking: WixBooking): string {
140
+ return JSON.stringify({
141
+ status: booking.status ?? "",
142
+ paymentStatus: booking.paymentStatus ?? "",
143
+ startDate: booking.startDate ?? "",
144
+ endDate: booking.endDate ?? "",
145
+ slotStartDate: booking.bookedEntity?.slot?.startDate ?? "",
146
+ slotEndDate: booking.bookedEntity?.slot?.endDate ?? "",
147
+ totalParticipants: booking.totalParticipants ?? null,
148
+ serviceId: booking.bookedEntity?.slot?.serviceId ?? "",
149
+ serviceTitle: booking.bookedEntity?.title ?? "",
150
+ });
151
+ }
152
+
153
+ function bookingSnapshot(bookings: WixBooking[]): BookingSnapshot {
154
+ return Object.fromEntries(bookings.filter((booking) => booking.id)
155
+ .map((booking) => [booking.id, bookingSignature(booking)]));
156
+ }
157
+
158
+ function rememberObservedBookings(siteKey: string, snapshot: BookingSnapshot): void {
159
+ try {
160
+ (globalThis as DrawerHost).himalaya?.storage?.setItem(
161
+ bookingSnapshotKey(OBSERVED_BOOKINGS_KEY, siteKey), JSON.stringify(snapshot),
162
+ );
163
+ } catch { /* best-effort local preference */ }
164
+ }
165
+
166
+ function rememberDismissal(siteKey: string): void {
167
+ try {
168
+ const storage = (globalThis as DrawerHost).himalaya?.storage;
169
+ const observedKey = bookingSnapshotKey(OBSERVED_BOOKINGS_KEY, siteKey);
170
+ const dismissedKey = bookingSnapshotKey(DISMISSED_BOOKINGS_KEY, siteKey);
171
+ storage?.setItem(dismissedKey, storage?.getItem(observedKey) ?? "{}");
172
+ const observedOrdersKey = bookingSnapshotKey(OBSERVED_ORDERS_KEY, siteKey);
173
+ const dismissedOrdersKey = bookingSnapshotKey(DISMISSED_ORDERS_KEY, siteKey);
174
+ storage?.setItem(dismissedOrdersKey, storage?.getItem(observedOrdersKey) ?? "{}");
175
+ } catch { /* best-effort local preference */ }
176
+ try {
177
+ const storage = (globalThis as DrawerHost).himalaya?.storage;
178
+ const observed = storage?.getItem(`${OBSERVED_ACTIVITY_AT_KEY}:${encodeURIComponent(siteKey)}`) ?? "";
179
+ // Wix record timestamps are server-generated. Do not substitute device time:
180
+ // an ahead clock can hide later Wix activity. Order snapshots cover records
181
+ // without timestamps.
182
+ const previous = dismissedAt(siteKey);
183
+ storage?.setItem(siteKey, observed || previous);
184
+ } catch { /* best-effort local preference */ }
185
+ }
186
+
187
+ async function countAttentionOrders(
188
+ filter: Record<string, unknown>, siteId: string, dismissed: string, dismissedSnapshot: OrderSnapshot | undefined,
189
+ ): Promise<{ count: number; latestActivity: string; snapshot: OrderSnapshot }> {
190
+ const orders: Array<{ id?: string } & Parameters<typeof orderSignature>[0]> = [];
191
+ let latestActivity = "";
192
+ let cursor = "";
193
+ const seenIds = new Set<string>();
194
+ const seen = new Set<string>();
195
+ while (true) {
196
+ const response = await searchOrders({ search: {
197
+ filter,
198
+ sort: [{ fieldName: "createdDate", order: "DESC" }, { fieldName: "id", order: "ASC" }],
199
+ cursorPaging: { limit: ATTENTION_PAGE, ...(cursor ? { cursor } : {}) },
200
+ } }, siteId ? site.forSite(siteId) : undefined);
201
+ for (const order of response.orders ?? []) {
202
+ latestActivity = newerActivity(latestActivity, latestActivityAt(order));
203
+ if (order.id && seenIds.has(order.id)) continue;
204
+ if (order.id) seenIds.add(order.id);
205
+ orders.push(order);
206
+ }
207
+ const metadata = response.metadata as { hasNext?: boolean; cursors?: { next?: string | null } } | undefined;
208
+ const next = metadata?.cursors?.next ?? "";
209
+ if (metadata?.hasNext === false) break;
210
+ // Keep the count already observed if the API advertises another page without
211
+ // giving us a usable cursor. This is a lower bound, but still alerts the owner.
212
+ if (!next || seen.has(next)) break;
213
+ seen.add(next);
214
+ cursor = next;
215
+ }
216
+ const snapshot = orderSnapshot(orders);
217
+ const hasDismissal = Boolean(dismissed) || dismissedSnapshot !== undefined;
218
+ const count = hasDismissal
219
+ ? orders.filter((order) => {
220
+ const activity = latestActivityAt(order);
221
+ if (dismissed && activity) return Date.parse(activity) > Date.parse(dismissed);
222
+ if (dismissedSnapshot !== undefined) {
223
+ return !order.id || dismissedSnapshot[order.id] === undefined
224
+ || dismissedSnapshot[order.id] !== orderSignature(order);
225
+ }
226
+ return activity !== "" && Date.parse(activity) > Date.parse(dismissed);
227
+ }).length
228
+ : orders.length;
229
+ return { count, latestActivity, snapshot };
230
+ }
231
+
232
+ async function countUpcomingBookings(
233
+ filter: Record<string, unknown>, now: number, dismissed: string, dismissedSnapshot: BookingSnapshot | undefined, siteId: string,
234
+ ): Promise<{ count: number; latestActivity: string; snapshot: BookingSnapshot }> {
235
+ const bookings: WixBooking[] = [];
236
+ const seenIds = new Set<string>();
237
+ const seenPages = new Set<string>();
238
+ let offset = 0;
239
+ let latestActivity = "";
240
+ const result = () => {
241
+ const hasDismissal = Boolean(dismissed) || dismissedSnapshot !== undefined;
242
+ const visible = hasDismissal
243
+ ? bookings.filter((booking) => {
244
+ const createdAfterDismissal = dismissed !== ""
245
+ && Date.parse(booking.createdDate ?? "") > Date.parse(dismissed);
246
+ if (createdAfterDismissal) return true;
247
+ if (!dismissedSnapshot) return false; // Legacy timestamp-only dismissal.
248
+ if (!booking.id) return false;
249
+ return dismissedSnapshot[booking.id] === undefined
250
+ || dismissedSnapshot[booking.id] !== bookingSignature(booking);
251
+ })
252
+ : bookings;
253
+ return {
254
+ count: upcomingBookingCount(visible, now),
255
+ latestActivity,
256
+ snapshot: bookingSnapshot(bookings),
257
+ };
258
+ };
259
+ while (true) {
260
+ const response = await queryBookings({ query: {
261
+ filter,
262
+ sort: [{ fieldName: "startDate", order: "ASC" }, { fieldName: "id", order: "ASC" }],
263
+ paging: { limit: ATTENTION_PAGE, offset },
264
+ } }, siteId ? site.forSite(siteId) : undefined);
265
+ const page = response.bookings ?? [];
266
+ const pageSignature = page.map((booking) => booking.id
267
+ ? `id:${booking.id}`
268
+ : `record:${JSON.stringify(booking)}`).sort().join("\0");
269
+ if (pageSignature && seenPages.has(pageSignature)) {
270
+ return result();
271
+ }
272
+ if (pageSignature) seenPages.add(pageSignature);
273
+ for (const booking of page) {
274
+ latestActivity = newerActivity(latestActivity, latestActivityAt(booking));
275
+ if (booking.id && seenIds.has(booking.id)) continue;
276
+ if (booking.id) seenIds.add(booking.id);
277
+ bookings.push(booking);
278
+ }
279
+ // Some Wix responses mark a total as too large to count even though the
280
+ // committed SDK metadata type does not include that wire field.
281
+ const metadata = response.pagingMetadata as (typeof response.pagingMetadata & { tooManyToCount?: boolean }) | undefined;
282
+ const total = metadata?.total;
283
+ if (typeof total === "number" && Number.isFinite(total) && metadata?.tooManyToCount !== true) {
284
+ const expected = Math.max(0, Math.floor(total));
285
+ if (bookings.length >= expected || page.length === 0) return result();
286
+ offset += page.length;
287
+ continue;
288
+ }
289
+ if (page.length < ATTENTION_PAGE) return result();
290
+ offset += page.length;
291
+ }
292
+ }
24
293
 
25
294
  /** The shell passes the screen it is showing as worker input, so the drawer can mark
26
295
  * that destination selected the way the declarative drawer does. */
@@ -35,7 +304,7 @@ const currentSiteName = () => selectedSiteName(fallbackBusinessName);
35
304
  const drawer = defineService<DrawerState, {
36
305
  /** The panel opened (root onAppear): re-read the live site and the active screen.
37
306
  * `arg.businessName` is the cold-start fallback for the site name. */
38
- refresh: (s: DrawerState, arg?: unknown) => DrawerState;
307
+ refresh: (s: DrawerState, arg?: unknown) => Promise<DrawerState>;
39
308
  /** Re-read the active screen from host input. An explicit
40
309
  * `{ screenId }` wins, for a host that pushes the change instead. */
41
310
  syncActive: (s: DrawerState, args?: { screenId?: string }) => DrawerState;
@@ -57,20 +326,59 @@ const drawer = defineService<DrawerState, {
57
326
  navRow("drawer_manage", "Manage", "grid", "himi_owner_lite_manage"),
58
327
  navRow("drawer_account", "Account", "person", "himi_owner_lite_account"),
59
328
  ],
60
- attention: [
61
- attentionRow("att_orders", "Orders", "cart", "himi_owner_lite_orders", 3),
62
- attentionRow("att_appts", "Appointments", "calendar", "himi_owner_lite_appointments", 1),
63
- ],
329
+ // Live rows arrive only after refresh. A cold/failed read never invents reminders.
330
+ attention: [],
64
331
  confirmingClear: false,
65
332
  activeScreenId: hostActiveScreen(),
66
333
  }),
67
334
  actions: {
68
- refresh: (s, arg) => {
335
+ refresh: async (s, arg): Promise<DrawerState> => {
69
336
  fallbackBusinessName = fallbackNameFrom(arg, fallbackBusinessName);
70
337
  const screenId = (arg as { screenId?: unknown } | undefined)?.screenId;
338
+ const now = Date.now();
339
+ // Invalidate older work before any read that can fail, including persistence.
340
+ const revision = ++refreshRevision;
341
+ const siteId = currentSiteId();
342
+ const siteKey = dismissalKey(siteId);
343
+ const dismissed = dismissedAt(siteKey);
344
+ const dismissedSnapshot = readBookingSnapshot(bookingSnapshotKey(DISMISSED_BOOKINGS_KEY, siteKey));
345
+ const dismissedOrders = readRecordSnapshot(bookingSnapshotKey(DISMISSED_ORDERS_KEY, siteKey));
346
+ // Only the newest same-site refresh may apply, so a slower earlier request
347
+ // cannot replace a result from a newer refresh.
348
+ const [ordersResult, bookingsResult] = await Promise.allSettled([
349
+ countAttentionOrders(ownerOrderAttentionFilter(), siteId, dismissed, dismissedOrders),
350
+ countUpcomingBookings({
351
+ startDate: { $gte: new Date(now).toISOString() },
352
+ status: { $in: UPCOMING_BOOKING_STATUSES },
353
+ }, now, dismissed, dismissedSnapshot, siteId),
354
+ ]);
355
+ // A user may dismiss while either request is in flight. Do not let this
356
+ // older result put the dismissed rows back into the current state.
357
+ if (revision !== refreshRevision || siteKey !== dismissalKey()) return drawer.getState();
358
+ const latestActivity = newerActivity(
359
+ ordersResult.status === "fulfilled" ? ordersResult.value.latestActivity : "",
360
+ bookingsResult.status === "fulfilled" ? bookingsResult.value.latestActivity : "",
361
+ );
362
+ rememberObservedActivity(siteKey, latestActivity);
363
+ if (ordersResult.status === "fulfilled") rememberObservedOrders(siteKey, ordersResult.value.snapshot);
364
+ if (bookingsResult.status === "fulfilled") rememberObservedBookings(siteKey, bookingsResult.value.snapshot);
365
+ const attention: DrawerState["attention"] = [];
366
+ if (ordersResult.status === "fulfilled") {
367
+ const count = ordersResult.value.count;
368
+ if (count > 0) attention.push(attentionRow("att_orders", "Orders", "cart", "himi_owner_lite_orders", count));
369
+ }
370
+ if (bookingsResult.status === "fulfilled") {
371
+ const count = bookingsResult.value.count;
372
+ if (count > 0) attention.push(attentionRow("att_appts", "Appointments", "calendar", "himi_owner_lite_appointments", count));
373
+ }
374
+ // Other synchronous drawer actions (such as opening the clear-confirmation
375
+ // prompt) may run while the requests are in flight. Keep those latest fields
376
+ // when committing the refreshed data instead of restoring this action's snapshot.
377
+ const latest = drawer.getState();
71
378
  return snap(withDerived({
72
- ...s,
379
+ ...latest,
73
380
  siteName: currentSiteName(),
381
+ attention,
74
382
  activeScreenId: typeof screenId === "string" ? screenId : hostActiveScreen(),
75
383
  }));
76
384
  },
@@ -80,7 +388,11 @@ const drawer = defineService<DrawerState, {
80
388
  })),
81
389
  askClearAttention: (s) => snap(withDerived({ ...s, confirmingClear: true })),
82
390
  cancelClearAttention: (s) => snap(withDerived({ ...s, confirmingClear: false })),
83
- clearAttention: (s) => snap(withDerived({ ...s, attention: [], confirmingClear: false })),
391
+ clearAttention: (s) => {
392
+ refreshRevision += 1;
393
+ rememberDismissal(dismissalKey());
394
+ return snap(withDerived({ ...s, attention: [], confirmingClear: false }));
395
+ },
84
396
  },
85
397
  });
86
398
 
@@ -10,6 +10,7 @@ import { countSites } from "@himalaya/wix-site-list";
10
10
  import { searchOrders, type WixOrder } from "@himalaya/wix-ecom";
11
11
  import { queryBookings } from "@himalaya/wix-bookings";
12
12
  import { UPCOMING_BOOKING_STATUSES, upcomingBookingCount } from "../_shared/owner-bookings.js";
13
+ import { ownerOrderAttentionFilter } from "../_shared/owner-order-attention.js";
13
14
  import { queryMembers } from "@himalaya/wix-members";
14
15
  import { queryReviews } from "@himalaya/wix-reviews";
15
16
  import { queryReservations } from "@himalaya/wix-table-reservations";
@@ -102,8 +103,6 @@ const UPCOMING_RESERVATION_STATUSES = ["HELD", "RESERVED", "REQUESTED"];
102
103
  const UPCOMING_EVENT_STATUSES = ["UPCOMING", "SCHEDULED"];
103
104
  const ATTENTION_MEMBER_STATUSES = ["PENDING"];
104
105
  const ATTENTION_REVIEW_STATUSES = ["IN_MODERATION", "SUBMITTED"];
105
- const ATTENTION_PAYMENT_STATUSES = ["NOT_PAID", "PARTIALLY_PAID"];
106
- const ATTENTION_FULFILLMENT_STATUSES = ["NOT_FULFILLED", "PARTIALLY_FULFILLED"];
107
106
  const SOURCE_LABELS: Record<HomeSourceId, string> = {
108
107
  products: "Products", posts: "Posts", events: "Events", sites: "Sites",
109
108
  orders: "Orders", bookings: "Bookings", members: "Members",
@@ -469,14 +468,7 @@ async function loadSourcePart(source: HomeSourceId, part: SourcePart): Promise<S
469
468
  case "sites": return countSites({}) as Promise<SourcePayload>;
470
469
  case "orders": {
471
470
  if (part === "primary") return searchOrders({ search: {
472
- filter: {
473
- archived: false,
474
- status: { $nin: ["CANCELED", "CANCELLED"] },
475
- $or: [
476
- { paymentStatus: { $in: ATTENTION_PAYMENT_STATUSES } },
477
- { paymentStatus: "PAID", fulfillmentStatus: { $in: ATTENTION_FULFILLMENT_STATUSES } },
478
- ],
479
- },
471
+ filter: ownerOrderAttentionFilter(),
480
472
  sort: [{ fieldName: "createdDate", order: "DESC" }, { fieldName: "id", order: "ASC" }],
481
473
  cursorPaging: { limit: TODAY_PAGE },
482
474
  } }) as Promise<SourcePayload>;
@@ -213,7 +213,10 @@ function copyFromParams(): SearchCopy {
213
213
  allFailed: copyParam("ownerSearch.allFailed", DEFAULT_COPY.allFailed),
214
214
  loadedOnly: copyParam("ownerSearch.loadedOnly", DEFAULT_COPY.loadedOnly),
215
215
  loadingSummary: copyParam("ownerSearch.loading", DEFAULT_COPY.loadingSummary),
216
- emptySummary: copyParam("ownerSearch.empty", DEFAULT_COPY.emptySummary),
216
+ // Keep the pre-worker-heading copy key alive for existing copy packs. The
217
+ // newer `empty` key takes precedence when present; both flow through the
218
+ // query interpolator so neither can hide what the owner searched.
219
+ emptySummary: copyParam("ownerSearch.empty", copyParam("ownerSearch.emptyTitle", DEFAULT_COPY.emptySummary)),
217
220
  idleSummary: copyParam("ownerSearch.idle", DEFAULT_COPY.idleSummary),
218
221
  permission: copyParam("ownerSearch.permission", DEFAULT_COPY.permission),
219
222
  setup: copyParam("ownerSearch.setup", DEFAULT_COPY.setup),
@@ -240,6 +243,7 @@ const IDLE: State = {
240
243
  anyLoading: false,
241
244
  showEmpty: false,
242
245
  hideEmpty: true,
246
+ emptyTitle: "",
243
247
  };
244
248
 
245
249
  /** Rebuilt on `load`, because the allowlist arrives with the descriptor. */
@@ -106,6 +106,14 @@ export interface SearchItem {
106
106
  /** Stable across refreshes, so a re-fetch cannot re-key a row under the
107
107
  * user's finger: `h:<source>`, `r:<source>:<id>`, `f:<source>`. */
108
108
  key: string;
109
+ /** The row's LIST identity — always equal to `key`, never blank.
110
+ *
111
+ * A bound list keys its rows on `id` (the web BoundList does, verbatim). This
112
+ * carried the record id, which left every heading and footer at `""` and let
113
+ * two domains that reuse a record id collide — so React reconciled headings
114
+ * onto each other as groups landed and painted "Contacts" three times.
115
+ * What a record OPENS is `recordId`. */
116
+ id: string;
109
117
  /** The source this row belongs to — a retry or a pager addresses it. */
110
118
  sourceId: string;
111
119
  // Exactly one kind is true. Pre-negated, because a `hiddenWhen` takes one path.
@@ -122,7 +130,9 @@ export interface SearchItem {
122
130
  isLoading: boolean;
123
131
  notLoading: boolean;
124
132
  // Record.
125
- id: string;
133
+ /** The record this row opens — the destination loads it by this id. Blank on
134
+ * headings and footers, which open nothing. */
135
+ recordId: string;
126
136
  title: string;
127
137
  subtitle: string;
128
138
  statusLabel: string;
@@ -163,6 +173,10 @@ export interface SearchState {
163
173
  * nobody has typed into yet, and again over every result still in flight. */
164
174
  showEmpty: boolean;
165
175
  hideEmpty: boolean;
176
+ /** The empty state's heading, naming the query ("No records match “mug”").
177
+ * Blank unless `showEmpty` — and while it shows, `summary` is blank, because
178
+ * the two sat one above the other saying the same sentence. */
179
+ emptyTitle: string;
166
180
  }
167
181
 
168
182
  export interface SearchCopy {
@@ -192,7 +206,9 @@ export const DEFAULT_COPY: SearchCopy = {
192
206
  allFailed: "No area could be searched",
193
207
  loadedOnly: "{n} in loaded records",
194
208
  loadingSummary: "Searching…",
195
- emptySummary: "No records matched",
209
+ /** The empty state's heading. `{q}` is the query the owner typed, so the
210
+ * state says WHAT matched nothing rather than only that something did not. */
211
+ emptySummary: "No records match “{q}”",
196
212
  idleSummary: "Search your business records",
197
213
  permission: "You don’t have access to this area",
198
214
  setup: "This area isn’t set up on this site",
@@ -231,6 +247,14 @@ function fill(template: string, n: number, token = "{n}"): string {
231
247
  return template.includes(token) ? template.split(token).join(String(n)) : `${template} ${n}`;
232
248
  }
233
249
 
250
+ /** `{q}` → the query. Custom copy stays query-specific even when it omits the
251
+ * placeholder, so the empty state always identifies what was searched. */
252
+ function fillQuery(template: string, query: string): string {
253
+ return template.includes("{q}")
254
+ ? template.split("{q}").join(query)
255
+ : `${template}: “${query}”`;
256
+ }
257
+
234
258
  function idleGroup(adapter: SearchAdapter): SearchGroup {
235
259
  return decorate({
236
260
  id: adapter.id,
@@ -273,9 +297,9 @@ function baseItem(sourceId: string): SearchItem {
273
297
  isHeader: false, notHeader: true,
274
298
  isRecord: false, notRecord: true,
275
299
  isFooter: false, notFooter: true,
276
- label: "", countLabel: "",
300
+ id: "", label: "", countLabel: "",
277
301
  isLoading: false, notLoading: true,
278
- id: "", title: "", subtitle: "", statusLabel: "", image: "", screenId: "",
302
+ recordId: "", title: "", subtitle: "", statusLabel: "", image: "", screenId: "",
279
303
  error: "", hasError: false, noError: true,
280
304
  canLoadMore: false, cannotLoadMore: true, isLoadingMore: false,
281
305
  };
@@ -302,7 +326,7 @@ export function projectItems(groups: SearchGroup[]): SearchItem[] {
302
326
  if (silent) continue;
303
327
  items.push({
304
328
  ...baseItem(group.id),
305
- key: `h:${group.id}`,
329
+ key: `h:${group.id}`, id: `h:${group.id}`,
306
330
  isHeader: true, notHeader: false,
307
331
  label: group.label,
308
332
  // A count is only shown once there is something to count: "0" under a
@@ -313,9 +337,9 @@ export function projectItems(groups: SearchGroup[]): SearchItem[] {
313
337
  for (const row of group.rows) {
314
338
  items.push({
315
339
  ...baseItem(group.id),
316
- key: `r:${group.id}:${row.id}`,
340
+ key: `r:${group.id}:${row.id}`, id: `r:${group.id}:${row.id}`,
317
341
  isRecord: true, notRecord: false,
318
- id: row.id, title: row.title, subtitle: row.subtitle,
342
+ recordId: row.id, title: row.title, subtitle: row.subtitle,
319
343
  statusLabel: row.statusLabel, image: row.image, screenId: row.screenId,
320
344
  });
321
345
  }
@@ -325,7 +349,7 @@ export function projectItems(groups: SearchGroup[]): SearchItem[] {
325
349
  if (group.hasError || group.problem !== "" || group.canLoadMore) {
326
350
  items.push({
327
351
  ...baseItem(group.id),
328
- key: `f:${group.id}`,
352
+ key: `f:${group.id}`, id: `f:${group.id}`,
329
353
  isFooter: true, notFooter: false,
330
354
  error: group.error,
331
355
  hasError: group.error !== "", noError: group.error === "",
@@ -393,7 +417,9 @@ export function createGroupedSearch(
393
417
  : anyLoading && total === 0
394
418
  ? copy.loadingSummary
395
419
  : total === 0
396
- ? (anyAnswered ? copy.emptySummary : copy.allFailed)
420
+ // Empty and answered: the empty block carries the sentence, so the
421
+ // summary line above it steps aside rather than repeating it.
422
+ ? (anyAnswered ? "" : copy.allFailed)
397
423
  : total === 1
398
424
  ? copy.resultsOne
399
425
  : areas === 1
@@ -416,6 +442,7 @@ export function createGroupedSearch(
416
442
  anyLoading,
417
443
  showEmpty: emptyAndAnswered,
418
444
  hideEmpty: !emptyAndAnswered,
445
+ emptyTitle: emptyAndAnswered ? fillQuery(copy.emptySummary, query) : "",
419
446
  };
420
447
  };
421
448
 
@@ -52895,7 +52895,7 @@ const SCREENS = runtimeSetting({
52895
52895
  "contentType": "application/javascript+ts-bundle",
52896
52896
  "declaredCapabilities": [
52897
52897
  "wix_api",
52898
- "local_database"
52898
+ "wix_platform"
52899
52899
  ],
52900
52900
  "minBinaryVersion": "0.1.0",
52901
52901
  "bundleVersion": "0.0.1"
@@ -52980,7 +52980,8 @@ const SCREENS = runtimeSetting({
52980
52980
  "props": {
52981
52981
  "text": "$service:himiOwnerLiteSearch.summary",
52982
52982
  "style": "caption",
52983
- "color": "textSecondary"
52983
+ "color": "textSecondary",
52984
+ "collapseWhenEmpty": true
52984
52985
  }
52985
52986
  }
52986
52987
  },
@@ -53095,7 +53096,7 @@ const SCREENS = runtimeSetting({
53095
53096
  "actionId": "himi.open",
53096
53097
  "params": {
53097
53098
  "screenId": "$item.screenId",
53098
- "id": "$item.id"
53099
+ "id": "$item.recordId"
53099
53100
  }
53100
53101
  }
53101
53102
  }
@@ -53201,8 +53202,9 @@ const SCREENS = runtimeSetting({
53201
53202
  "package": "himalaya",
53202
53203
  "type": "Text",
53203
53204
  "props": {
53204
- "text": "No records matched",
53205
- "style": "bodyEmphasis"
53205
+ "text": "$service:himiOwnerLiteSearch.emptyTitle",
53206
+ "style": "bodyEmphasis",
53207
+ "maxLines": 2
53206
53208
  }
53207
53209
  }
53208
53210
  },