@wix/himalaya-cli 0.1364.0 → 0.1365.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 (22) hide show
  1. package/package.json +2 -2
  2. package/templates/beauty-salon/dev/index.ts +90 -0
  3. package/templates/beauty-salon/tier5-src/_shared/hosted-checkout.ts +4 -2
  4. package/templates/beauty-salon/tier5-src/himi-branded-lite-orders/index.ts +66 -6
  5. package/templates/branded-lite/dev/index.ts +90 -0
  6. package/templates/branded-lite/tier5-src/_shared/hosted-checkout.ts +4 -2
  7. package/templates/branded-lite/tier5-src/himi-branded-lite-orders/index.ts +66 -6
  8. package/templates/community-club/tier5-src/_shared/hosted-checkout.ts +4 -2
  9. package/templates/events-meetups/tier5-src/_shared/hosted-checkout.ts +4 -2
  10. package/templates/gym-fitness/dev/index.ts +90 -0
  11. package/templates/gym-fitness/tier5-src/_shared/hosted-checkout.ts +4 -2
  12. package/templates/gym-fitness/tier5-src/himi-branded-lite-orders/index.ts +66 -6
  13. package/templates/nonprofit-cause/tier5-src/_shared/hosted-checkout.ts +4 -2
  14. package/templates/podcast-creator/tier5-src/_shared/hosted-checkout.ts +4 -2
  15. package/templates/portfolio-creative/dev/index.ts +90 -0
  16. package/templates/portfolio-creative/tier5-src/_shared/hosted-checkout.ts +4 -2
  17. package/templates/portfolio-creative/tier5-src/himi-branded-lite-orders/index.ts +66 -6
  18. package/templates/restaurant/tier5-src/_shared/hosted-checkout.ts +4 -2
  19. package/templates/retail-store/dev/index.ts +90 -0
  20. package/templates/retail-store/tier5-src/_shared/hosted-checkout.ts +4 -2
  21. package/templates/retail-store/tier5-src/himi-branded-lite-orders/index.ts +66 -6
  22. package/templates/yoga-studio/tier5-src/_shared/hosted-checkout.ts +4 -2
@@ -12399,6 +12399,96 @@ const SCREENS = runtimeSetting({
12399
12399
  "hiddenPath": "$service:himiBrandedOrders.noEmpty"
12400
12400
  }
12401
12401
  },
12402
+ {
12403
+ "id": "himi_orders.on_site_card",
12404
+ "body": {
12405
+ "package": "himalaya",
12406
+ "type": "VStack",
12407
+ "props": {
12408
+ "spacing": 8,
12409
+ "alignment": "leading",
12410
+ "children": [
12411
+ {
12412
+ "id": "",
12413
+ "body": {
12414
+ "package": "himalaya",
12415
+ "type": "Icon",
12416
+ "props": {
12417
+ "name": "bag",
12418
+ "size": 28,
12419
+ "tint": "textTertiary"
12420
+ }
12421
+ },
12422
+ "modifiers": {
12423
+ "accessibilityRole": "decorative"
12424
+ }
12425
+ },
12426
+ {
12427
+ "id": "",
12428
+ "body": {
12429
+ "package": "himalaya",
12430
+ "type": "Text",
12431
+ "props": {
12432
+ "text": "$service:himiBrandedOrders.emptyText",
12433
+ "style": "bodyEmphasis",
12434
+ "color": "textSecondary",
12435
+ "collapseWhenEmpty": true
12436
+ }
12437
+ }
12438
+ },
12439
+ {
12440
+ "id": "himi_orders.on_site",
12441
+ "body": {
12442
+ "package": "himalaya",
12443
+ "type": "Button",
12444
+ "props": {
12445
+ "title": "View my orders on the site",
12446
+ "style": "primary",
12447
+ "action": {
12448
+ "actionId": "tier5.himiBrandedOrders.openOnSite"
12449
+ }
12450
+ }
12451
+ }
12452
+ },
12453
+ {
12454
+ "id": "",
12455
+ "body": {
12456
+ "package": "himalaya",
12457
+ "type": "Text",
12458
+ "props": {
12459
+ "text": "$service:himiBrandedOrders.siteHint",
12460
+ "style": "footnote",
12461
+ "color": "textSecondary",
12462
+ "collapseWhenEmpty": true
12463
+ }
12464
+ },
12465
+ "modifiers": {
12466
+ "hiddenPath": "$service:himiBrandedOrders.noSiteHint"
12467
+ }
12468
+ }
12469
+ ]
12470
+ }
12471
+ },
12472
+ "modifiers": {
12473
+ "background": {
12474
+ "token": "surfaceElevated"
12475
+ },
12476
+ "border": {
12477
+ "width": 1,
12478
+ "color": {
12479
+ "token": "border"
12480
+ }
12481
+ },
12482
+ "cornerRadius": 16,
12483
+ "padding": {
12484
+ "leading": 16,
12485
+ "trailing": 16,
12486
+ "top": 16,
12487
+ "bottom": 16
12488
+ },
12489
+ "hiddenPath": "$service:himiBrandedOrders.notOnSite"
12490
+ }
12491
+ },
12402
12492
  {
12403
12493
  "id": "himi_orders.member_gate",
12404
12494
  "body": {
@@ -6,7 +6,8 @@
6
6
  //
7
7
  // Classic Wix Events tickets and classic Pricing Plans have no ecom checkout: for those the
8
8
  // worker names the page by id (`himi.host.memberSitePage`) and the host opens the site's own event or
9
- // plans page the same way, signed in — where the member picks and pays on the site.
9
+ // plans page the same way, signed in — where the member picks and pays on the site. A member's orders
10
+ // are the same (Wix lists them to no member identity), so My orders opens the site's own page too.
10
11
 
11
12
  import { dispatch } from "@himalaya/dispatch";
12
13
  import { hostSiteFromInput } from "./branded-lite-hosted.js";
@@ -22,7 +23,7 @@ export const HOSTED_SITE_PAGE_ACTION = "himi.host.memberSitePage";
22
23
 
23
24
  /** A site page a hosted worker may ask its host for — ids only, never a URL: the host reads the URL
24
25
  * from Wix itself and decides whether the member may be signed in there. */
25
- export type HostedSitePage = { page: "event"; eventId: string } | { page: "plans"; planId?: string };
26
+ export type HostedSitePage = { page: "event"; eventId: string } | { page: "plans"; planId?: string } | { page: "orders" };
26
27
 
27
28
  /** Whether this worker runs inside a host that opened a site for it. */
28
29
  export function isHostedWorker(): boolean {
@@ -63,6 +64,7 @@ export function hostedCheckoutOpener(checkoutId: string): HostOpener {
63
64
  export function hostedSitePageOpener(page: HostedSitePage): HostOpener {
64
65
  const params: Record<string, string> = page.page === "event"
65
66
  ? { page: "event", eventId: page.eventId }
67
+ : page.page === "orders" ? { page: "orders" }
66
68
  : { page: "plans", ...(page.planId ? { planId: page.planId } : {}) };
67
69
  return hostOpener(HOSTED_SITE_PAGE_ACTION, params);
68
70
  }
@@ -5,6 +5,7 @@ import { getAll, upsert, type DbEntity } from "@himalaya/local-db";
5
5
  import { formatDateMedium } from "../_shared/engine-intl.js";
6
6
  import { orderMember, assertOrderMember, ownedOrderCache, orderAccessDenied, orderAccessChanged, type OrderMember } from "../_shared/member-orders.js";
7
7
  import { wixMediaUrl } from "@himalaya/wix-media";
8
+ import { isHostedWorker, hostedSitePageOpener } from "../_shared/hosted-checkout.js";
8
9
 
9
10
  // Only ownership-tagged rows from this account/business/member can be reused.
10
11
  // Legacy untagged cache rows are deliberately ignored. Missing host scope or
@@ -60,6 +61,13 @@ interface State {
60
61
  pageError?: boolean;
61
62
  loadingMore?: boolean;
62
63
  fromCache?: boolean;
64
+ /** Hosted in a host shell (Spaces), signed in: the member's orders live on the site's own page. */
65
+ onSite?: boolean;
66
+ /** A search or filter narrows this page: the list (and its Clear) stays visible beside the on-site card. */
67
+ narrowed?: boolean;
68
+ notOnSite?: boolean;
69
+ siteHint?: string;
70
+ noSiteHint?: boolean;
63
71
  }
64
72
 
65
73
  function formatDate(value?: string): string {
@@ -129,9 +137,15 @@ function derive(state: State): State {
129
137
  isErrored: state.status === "error",
130
138
  notErrored: state.status !== "error",
131
139
  // A guest gets the shared member gate, not the "Browse products" empty card.
132
- noEmpty: state.status !== "ready" || hasRows || state.needsSignIn === true,
140
+ // Never "No orders" while a next page could still hold one.
141
+ noEmpty: state.status !== "ready" || hasRows || state.needsSignIn === true || state.onSite === true || !!state.nextCursor,
133
142
  noSignIn: state.status !== "ready" || state.needsSignIn !== true,
134
- hideList: state.status !== "ready" || state.needsSignIn === true,
143
+ // The on-site card replaces the list's empty state — except under a search or filter, where the list keeps
144
+ // its search, filters and Clear, so the member is never stranded behind criteria they cannot clear.
145
+ hideList: state.status !== "ready" || state.needsSignIn === true || (state.onSite === true && state.narrowed !== true),
146
+ notOnSite: state.status !== "ready" || state.onSite !== true || state.needsSignIn === true,
147
+ // Hidden outright when empty: a collapsed text still takes the card's stack spacing.
148
+ noSiteHint: !state.siteHint,
135
149
  };
136
150
  }
137
151
 
@@ -139,11 +153,15 @@ const FULFILLMENT: Record<string, string> = {
139
153
  "Not fulfilled": "NOT_FULFILLED", "Partially fulfilled": "PARTIALLY_FULFILLED", "Fulfilled": "FULFILLED",
140
154
  };
141
155
  let pageMember: OrderMember | undefined;
156
+ let siteOpenInFlight = false;
157
+ // Whose open failed: the hint belongs to that member and scope only, never to whoever signs in next.
158
+ let siteHintOwner = "";
159
+ const memberKey = (member: { email: string; scope?: string }) => member.email ? `${member.email}\u0000${member.scope ?? ""}` : "";
142
160
  let cacheWrite = Promise.resolve();
143
161
  const svc = defineBackendCollection("himiBrandedOrders", {
144
162
  restorePagesOnLoad: true,
145
163
  state: derive({ status: "loading", rows: [], identityLine: "", emptyText: "", isLoading: true,
146
- notLoading: false, isErrored: false, notErrored: true, noEmpty: true, hideList: true }),
164
+ notLoading: false, isErrored: false, notErrored: true, noEmpty: true, hideList: true, siteHint: "" }),
147
165
  source: {
148
166
  id: (row: OrderRow) => row.id,
149
167
  clearOnError: orderAccessDenied,
@@ -153,11 +171,14 @@ const svc = defineBackendCollection("himiBrandedOrders", {
153
171
  async fetch(criteria, cursor) {
154
172
  const member = await orderMember();
155
173
  if (cursor && (!pageMember || member.email !== pageMember.email || member.scope !== pageMember.scope)) throw orderAccessChanged();
156
- const metadata = { email: member.email, scope: member.scope, needsSignIn: !member.email, fromCache: false };
174
+ const narrowed = !!criteria.query.trim() || criteria.filter !== "all";
175
+ const metadata = { email: member.email, scope: member.scope, needsSignIn: !member.email, fromCache: false, narrowed };
157
176
  if (!member.email) return { items: [], nextCursor: "", metadata };
158
177
  const query = criteria.query.replace(/^#/, "");
159
178
  const validNumber = /^\d+$/.test(query) && Number.isSafeInteger(Number(query));
160
- if ((query && !validNumber) || (criteria.filter === "saved" && !criteria.savedIds.length)) return { items: [], nextCursor: "", metadata };
179
+ if ((query && !validNumber) || (criteria.filter === "saved" && !criteria.savedIds.length)) {
180
+ return { items: [], nextCursor: "", metadata };
181
+ }
161
182
  const filter: Record<string, unknown> = { "buyerInfo.email": { $eq: member.email } };
162
183
  if (query) filter.number = { $eq: Number(query) };
163
184
  if (criteria.filter === "saved") filter.id = { $in: criteria.savedIds };
@@ -175,7 +196,8 @@ const svc = defineBackendCollection("himiBrandedOrders", {
175
196
  const paging = response.metadata as { hasNext?: boolean; cursors?: { next?: string | null } } | undefined;
176
197
  const nextCursor = paging?.cursors?.next ?? "";
177
198
  if (paging?.hasNext && !nextCursor) throw new Error("Orders response is missing its next-page cursor");
178
- return { items: (response.orders ?? []).filter(order => !!order.id && orderEmail(order) === member.email).map(orderRow), nextCursor, metadata };
199
+ const items = (response.orders ?? []).filter(order => !!order.id && orderEmail(order) === member.email).map(orderRow);
200
+ return { items, nextCursor, metadata };
179
201
  } catch (error) {
180
202
  await assertOrderMember(member);
181
203
  // Cached rows never impersonate a backend query or survive access denial.
@@ -195,14 +217,52 @@ const svc = defineBackendCollection("himiBrandedOrders", {
195
217
  cacheWrite = cacheWrite.then(() => writeCachedOrders(page.items, member));
196
218
  }
197
219
  const status = page.status === "idle" ? "loading" : page.status;
220
+ // Hosted in a host shell as its signed-in member, an empty answer is not proof of no orders: Wix documents
221
+ // `ecom/v1/orders/search` for a Wix user or app identity only, and answers the member a successful EMPTY list.
222
+ // So hosted, "No orders" is never claimed — once every page is read (none left to load), whatever the criteria,
223
+ // the site's own My Orders page is offered, opened by the host (`himi.host.memberSitePage {page:"orders"}`).
224
+ // Rows Wix does return still list here.
225
+ const onSite = isHostedWorker() && !!member.email && status === "ready" && !page.items.length && !page.nextCursor
226
+ && !page.pageError && page.metadata?.fromCache !== true;
198
227
  return derive({ ...state, status, rows: page.items, nextCursor: page.nextCursor, complete: page.complete,
199
228
  pageError: page.pageError, loadingMore: page.loadingMore, fromCache: page.metadata?.fromCache === true,
200
229
  needsSignIn: page.metadata?.needsSignIn === true,
230
+ onSite,
231
+ narrowed: page.metadata?.narrowed === true,
232
+ siteHint: siteHintOwner && siteHintOwner === memberKey(member) && onSite ? state.siteHint : "",
201
233
  identityLine: member.email ? copyTemplate("orders.identityLine", "Signed in as {email}", { email: member.email }) : "",
202
234
  emptyText: status === "error" ? page.error : !member.email ? copyParam("orders.signInEmptyText", "Sign in to see your orders.")
235
+ : onSite ? copyParam("orders.onSiteText", "Your orders are on the site.")
203
236
  : page.items.length ? "" : copyParam("orders.emptyText", "No orders for this member yet."),
204
237
  });
205
238
  },
239
+ actions: {
240
+ // The site's My Orders page, opened by the host signed in as the lent member where it can (a visitor who
241
+ // signs in on the site otherwise). Nothing is charged, so the guard only re-checks the member is unchanged.
242
+ async openOnSite(s: State): Promise<State | undefined> {
243
+ // One host open at a time: a second tap while the first is in flight would only replace its ticket.
244
+ if (siteOpenInFlight || s.onSite !== true) return s;
245
+ siteOpenInFlight = true;
246
+ let opened: boolean | "withdrawn";
247
+ let member: OrderMember;
248
+ try {
249
+ member = await orderMember();
250
+ opened = await hostedSitePageOpener({ page: "orders" })(async () => {
251
+ const now = await orderMember();
252
+ return now.email === member.email && now.scope === member.scope;
253
+ });
254
+ } finally {
255
+ siteOpenInFlight = false;
256
+ }
257
+ // A member who changed while the host worked never inherits this answer.
258
+ const now = await orderMember();
259
+ if (memberKey(now) !== memberKey(member)) return undefined;
260
+ siteHintOwner = opened === false ? memberKey(member) : "";
261
+ const latest = svc.getState() as unknown as State;
262
+ return derive({ ...latest, siteHint: opened === false
263
+ ? copyParam("orders.onSiteFailedText", "The site's orders page could not be opened. Visit the site to see your orders.") : "" });
264
+ },
265
+ },
206
266
  });
207
267
  svc.load();
208
268
  export { svc };
@@ -6,7 +6,8 @@
6
6
  //
7
7
  // Classic Wix Events tickets and classic Pricing Plans have no ecom checkout: for those the
8
8
  // worker names the page by id (`himi.host.memberSitePage`) and the host opens the site's own event or
9
- // plans page the same way, signed in — where the member picks and pays on the site.
9
+ // plans page the same way, signed in — where the member picks and pays on the site. A member's orders
10
+ // are the same (Wix lists them to no member identity), so My orders opens the site's own page too.
10
11
 
11
12
  import { dispatch } from "@himalaya/dispatch";
12
13
  import { hostSiteFromInput } from "./branded-lite-hosted.js";
@@ -22,7 +23,7 @@ export const HOSTED_SITE_PAGE_ACTION = "himi.host.memberSitePage";
22
23
 
23
24
  /** A site page a hosted worker may ask its host for — ids only, never a URL: the host reads the URL
24
25
  * from Wix itself and decides whether the member may be signed in there. */
25
- export type HostedSitePage = { page: "event"; eventId: string } | { page: "plans"; planId?: string };
26
+ export type HostedSitePage = { page: "event"; eventId: string } | { page: "plans"; planId?: string } | { page: "orders" };
26
27
 
27
28
  /** Whether this worker runs inside a host that opened a site for it. */
28
29
  export function isHostedWorker(): boolean {
@@ -63,6 +64,7 @@ export function hostedCheckoutOpener(checkoutId: string): HostOpener {
63
64
  export function hostedSitePageOpener(page: HostedSitePage): HostOpener {
64
65
  const params: Record<string, string> = page.page === "event"
65
66
  ? { page: "event", eventId: page.eventId }
67
+ : page.page === "orders" ? { page: "orders" }
66
68
  : { page: "plans", ...(page.planId ? { planId: page.planId } : {}) };
67
69
  return hostOpener(HOSTED_SITE_PAGE_ACTION, params);
68
70
  }
@@ -6,7 +6,8 @@
6
6
  //
7
7
  // Classic Wix Events tickets and classic Pricing Plans have no ecom checkout: for those the
8
8
  // worker names the page by id (`himi.host.memberSitePage`) and the host opens the site's own event or
9
- // plans page the same way, signed in — where the member picks and pays on the site.
9
+ // plans page the same way, signed in — where the member picks and pays on the site. A member's orders
10
+ // are the same (Wix lists them to no member identity), so My orders opens the site's own page too.
10
11
 
11
12
  import { dispatch } from "@himalaya/dispatch";
12
13
  import { hostSiteFromInput } from "./branded-lite-hosted.js";
@@ -22,7 +23,7 @@ export const HOSTED_SITE_PAGE_ACTION = "himi.host.memberSitePage";
22
23
 
23
24
  /** A site page a hosted worker may ask its host for — ids only, never a URL: the host reads the URL
24
25
  * from Wix itself and decides whether the member may be signed in there. */
25
- export type HostedSitePage = { page: "event"; eventId: string } | { page: "plans"; planId?: string };
26
+ export type HostedSitePage = { page: "event"; eventId: string } | { page: "plans"; planId?: string } | { page: "orders" };
26
27
 
27
28
  /** Whether this worker runs inside a host that opened a site for it. */
28
29
  export function isHostedWorker(): boolean {
@@ -63,6 +64,7 @@ export function hostedCheckoutOpener(checkoutId: string): HostOpener {
63
64
  export function hostedSitePageOpener(page: HostedSitePage): HostOpener {
64
65
  const params: Record<string, string> = page.page === "event"
65
66
  ? { page: "event", eventId: page.eventId }
67
+ : page.page === "orders" ? { page: "orders" }
66
68
  : { page: "plans", ...(page.planId ? { planId: page.planId } : {}) };
67
69
  return hostOpener(HOSTED_SITE_PAGE_ACTION, params);
68
70
  }
@@ -11595,6 +11595,96 @@ const SCREENS = runtimeSetting({
11595
11595
  "hiddenPath": "$service:himiBrandedOrders.noEmpty"
11596
11596
  }
11597
11597
  },
11598
+ {
11599
+ "id": "himi_orders.on_site_card",
11600
+ "body": {
11601
+ "package": "himalaya",
11602
+ "type": "VStack",
11603
+ "props": {
11604
+ "spacing": 8,
11605
+ "alignment": "leading",
11606
+ "children": [
11607
+ {
11608
+ "id": "",
11609
+ "body": {
11610
+ "package": "himalaya",
11611
+ "type": "Icon",
11612
+ "props": {
11613
+ "name": "bag",
11614
+ "size": 28,
11615
+ "tint": "textTertiary"
11616
+ }
11617
+ },
11618
+ "modifiers": {
11619
+ "accessibilityRole": "decorative"
11620
+ }
11621
+ },
11622
+ {
11623
+ "id": "",
11624
+ "body": {
11625
+ "package": "himalaya",
11626
+ "type": "Text",
11627
+ "props": {
11628
+ "text": "$service:himiBrandedOrders.emptyText",
11629
+ "style": "bodyEmphasis",
11630
+ "color": "textSecondary",
11631
+ "collapseWhenEmpty": true
11632
+ }
11633
+ }
11634
+ },
11635
+ {
11636
+ "id": "himi_orders.on_site",
11637
+ "body": {
11638
+ "package": "himalaya",
11639
+ "type": "Button",
11640
+ "props": {
11641
+ "title": "View my orders on the site",
11642
+ "style": "primary",
11643
+ "action": {
11644
+ "actionId": "tier5.himiBrandedOrders.openOnSite"
11645
+ }
11646
+ }
11647
+ }
11648
+ },
11649
+ {
11650
+ "id": "",
11651
+ "body": {
11652
+ "package": "himalaya",
11653
+ "type": "Text",
11654
+ "props": {
11655
+ "text": "$service:himiBrandedOrders.siteHint",
11656
+ "style": "footnote",
11657
+ "color": "textSecondary",
11658
+ "collapseWhenEmpty": true
11659
+ }
11660
+ },
11661
+ "modifiers": {
11662
+ "hiddenPath": "$service:himiBrandedOrders.noSiteHint"
11663
+ }
11664
+ }
11665
+ ]
11666
+ }
11667
+ },
11668
+ "modifiers": {
11669
+ "background": {
11670
+ "token": "surfaceElevated"
11671
+ },
11672
+ "border": {
11673
+ "width": 1,
11674
+ "color": {
11675
+ "token": "border"
11676
+ }
11677
+ },
11678
+ "cornerRadius": 16,
11679
+ "padding": {
11680
+ "leading": 16,
11681
+ "trailing": 16,
11682
+ "top": 16,
11683
+ "bottom": 16
11684
+ },
11685
+ "hiddenPath": "$service:himiBrandedOrders.notOnSite"
11686
+ }
11687
+ },
11598
11688
  {
11599
11689
  "id": "himi_orders.member_gate",
11600
11690
  "body": {
@@ -6,7 +6,8 @@
6
6
  //
7
7
  // Classic Wix Events tickets and classic Pricing Plans have no ecom checkout: for those the
8
8
  // worker names the page by id (`himi.host.memberSitePage`) and the host opens the site's own event or
9
- // plans page the same way, signed in — where the member picks and pays on the site.
9
+ // plans page the same way, signed in — where the member picks and pays on the site. A member's orders
10
+ // are the same (Wix lists them to no member identity), so My orders opens the site's own page too.
10
11
 
11
12
  import { dispatch } from "@himalaya/dispatch";
12
13
  import { hostSiteFromInput } from "./branded-lite-hosted.js";
@@ -22,7 +23,7 @@ export const HOSTED_SITE_PAGE_ACTION = "himi.host.memberSitePage";
22
23
 
23
24
  /** A site page a hosted worker may ask its host for — ids only, never a URL: the host reads the URL
24
25
  * from Wix itself and decides whether the member may be signed in there. */
25
- export type HostedSitePage = { page: "event"; eventId: string } | { page: "plans"; planId?: string };
26
+ export type HostedSitePage = { page: "event"; eventId: string } | { page: "plans"; planId?: string } | { page: "orders" };
26
27
 
27
28
  /** Whether this worker runs inside a host that opened a site for it. */
28
29
  export function isHostedWorker(): boolean {
@@ -63,6 +64,7 @@ export function hostedCheckoutOpener(checkoutId: string): HostOpener {
63
64
  export function hostedSitePageOpener(page: HostedSitePage): HostOpener {
64
65
  const params: Record<string, string> = page.page === "event"
65
66
  ? { page: "event", eventId: page.eventId }
67
+ : page.page === "orders" ? { page: "orders" }
66
68
  : { page: "plans", ...(page.planId ? { planId: page.planId } : {}) };
67
69
  return hostOpener(HOSTED_SITE_PAGE_ACTION, params);
68
70
  }
@@ -5,6 +5,7 @@ import { getAll, upsert, type DbEntity } from "@himalaya/local-db";
5
5
  import { formatDateMedium } from "../_shared/engine-intl.js";
6
6
  import { orderMember, assertOrderMember, ownedOrderCache, orderAccessDenied, orderAccessChanged, type OrderMember } from "../_shared/member-orders.js";
7
7
  import { wixMediaUrl } from "@himalaya/wix-media";
8
+ import { isHostedWorker, hostedSitePageOpener } from "../_shared/hosted-checkout.js";
8
9
 
9
10
  // Only ownership-tagged rows from this account/business/member can be reused.
10
11
  // Legacy untagged cache rows are deliberately ignored. Missing host scope or
@@ -60,6 +61,13 @@ interface State {
60
61
  pageError?: boolean;
61
62
  loadingMore?: boolean;
62
63
  fromCache?: boolean;
64
+ /** Hosted in a host shell (Spaces), signed in: the member's orders live on the site's own page. */
65
+ onSite?: boolean;
66
+ /** A search or filter narrows this page: the list (and its Clear) stays visible beside the on-site card. */
67
+ narrowed?: boolean;
68
+ notOnSite?: boolean;
69
+ siteHint?: string;
70
+ noSiteHint?: boolean;
63
71
  }
64
72
 
65
73
  function formatDate(value?: string): string {
@@ -129,9 +137,15 @@ function derive(state: State): State {
129
137
  isErrored: state.status === "error",
130
138
  notErrored: state.status !== "error",
131
139
  // A guest gets the shared member gate, not the "Browse products" empty card.
132
- noEmpty: state.status !== "ready" || hasRows || state.needsSignIn === true,
140
+ // Never "No orders" while a next page could still hold one.
141
+ noEmpty: state.status !== "ready" || hasRows || state.needsSignIn === true || state.onSite === true || !!state.nextCursor,
133
142
  noSignIn: state.status !== "ready" || state.needsSignIn !== true,
134
- hideList: state.status !== "ready" || state.needsSignIn === true,
143
+ // The on-site card replaces the list's empty state — except under a search or filter, where the list keeps
144
+ // its search, filters and Clear, so the member is never stranded behind criteria they cannot clear.
145
+ hideList: state.status !== "ready" || state.needsSignIn === true || (state.onSite === true && state.narrowed !== true),
146
+ notOnSite: state.status !== "ready" || state.onSite !== true || state.needsSignIn === true,
147
+ // Hidden outright when empty: a collapsed text still takes the card's stack spacing.
148
+ noSiteHint: !state.siteHint,
135
149
  };
136
150
  }
137
151
 
@@ -139,11 +153,15 @@ const FULFILLMENT: Record<string, string> = {
139
153
  "Not fulfilled": "NOT_FULFILLED", "Partially fulfilled": "PARTIALLY_FULFILLED", "Fulfilled": "FULFILLED",
140
154
  };
141
155
  let pageMember: OrderMember | undefined;
156
+ let siteOpenInFlight = false;
157
+ // Whose open failed: the hint belongs to that member and scope only, never to whoever signs in next.
158
+ let siteHintOwner = "";
159
+ const memberKey = (member: { email: string; scope?: string }) => member.email ? `${member.email}\u0000${member.scope ?? ""}` : "";
142
160
  let cacheWrite = Promise.resolve();
143
161
  const svc = defineBackendCollection("himiBrandedOrders", {
144
162
  restorePagesOnLoad: true,
145
163
  state: derive({ status: "loading", rows: [], identityLine: "", emptyText: "", isLoading: true,
146
- notLoading: false, isErrored: false, notErrored: true, noEmpty: true, hideList: true }),
164
+ notLoading: false, isErrored: false, notErrored: true, noEmpty: true, hideList: true, siteHint: "" }),
147
165
  source: {
148
166
  id: (row: OrderRow) => row.id,
149
167
  clearOnError: orderAccessDenied,
@@ -153,11 +171,14 @@ const svc = defineBackendCollection("himiBrandedOrders", {
153
171
  async fetch(criteria, cursor) {
154
172
  const member = await orderMember();
155
173
  if (cursor && (!pageMember || member.email !== pageMember.email || member.scope !== pageMember.scope)) throw orderAccessChanged();
156
- const metadata = { email: member.email, scope: member.scope, needsSignIn: !member.email, fromCache: false };
174
+ const narrowed = !!criteria.query.trim() || criteria.filter !== "all";
175
+ const metadata = { email: member.email, scope: member.scope, needsSignIn: !member.email, fromCache: false, narrowed };
157
176
  if (!member.email) return { items: [], nextCursor: "", metadata };
158
177
  const query = criteria.query.replace(/^#/, "");
159
178
  const validNumber = /^\d+$/.test(query) && Number.isSafeInteger(Number(query));
160
- if ((query && !validNumber) || (criteria.filter === "saved" && !criteria.savedIds.length)) return { items: [], nextCursor: "", metadata };
179
+ if ((query && !validNumber) || (criteria.filter === "saved" && !criteria.savedIds.length)) {
180
+ return { items: [], nextCursor: "", metadata };
181
+ }
161
182
  const filter: Record<string, unknown> = { "buyerInfo.email": { $eq: member.email } };
162
183
  if (query) filter.number = { $eq: Number(query) };
163
184
  if (criteria.filter === "saved") filter.id = { $in: criteria.savedIds };
@@ -175,7 +196,8 @@ const svc = defineBackendCollection("himiBrandedOrders", {
175
196
  const paging = response.metadata as { hasNext?: boolean; cursors?: { next?: string | null } } | undefined;
176
197
  const nextCursor = paging?.cursors?.next ?? "";
177
198
  if (paging?.hasNext && !nextCursor) throw new Error("Orders response is missing its next-page cursor");
178
- return { items: (response.orders ?? []).filter(order => !!order.id && orderEmail(order) === member.email).map(orderRow), nextCursor, metadata };
199
+ const items = (response.orders ?? []).filter(order => !!order.id && orderEmail(order) === member.email).map(orderRow);
200
+ return { items, nextCursor, metadata };
179
201
  } catch (error) {
180
202
  await assertOrderMember(member);
181
203
  // Cached rows never impersonate a backend query or survive access denial.
@@ -195,14 +217,52 @@ const svc = defineBackendCollection("himiBrandedOrders", {
195
217
  cacheWrite = cacheWrite.then(() => writeCachedOrders(page.items, member));
196
218
  }
197
219
  const status = page.status === "idle" ? "loading" : page.status;
220
+ // Hosted in a host shell as its signed-in member, an empty answer is not proof of no orders: Wix documents
221
+ // `ecom/v1/orders/search` for a Wix user or app identity only, and answers the member a successful EMPTY list.
222
+ // So hosted, "No orders" is never claimed — once every page is read (none left to load), whatever the criteria,
223
+ // the site's own My Orders page is offered, opened by the host (`himi.host.memberSitePage {page:"orders"}`).
224
+ // Rows Wix does return still list here.
225
+ const onSite = isHostedWorker() && !!member.email && status === "ready" && !page.items.length && !page.nextCursor
226
+ && !page.pageError && page.metadata?.fromCache !== true;
198
227
  return derive({ ...state, status, rows: page.items, nextCursor: page.nextCursor, complete: page.complete,
199
228
  pageError: page.pageError, loadingMore: page.loadingMore, fromCache: page.metadata?.fromCache === true,
200
229
  needsSignIn: page.metadata?.needsSignIn === true,
230
+ onSite,
231
+ narrowed: page.metadata?.narrowed === true,
232
+ siteHint: siteHintOwner && siteHintOwner === memberKey(member) && onSite ? state.siteHint : "",
201
233
  identityLine: member.email ? copyTemplate("orders.identityLine", "Signed in as {email}", { email: member.email }) : "",
202
234
  emptyText: status === "error" ? page.error : !member.email ? copyParam("orders.signInEmptyText", "Sign in to see your orders.")
235
+ : onSite ? copyParam("orders.onSiteText", "Your orders are on the site.")
203
236
  : page.items.length ? "" : copyParam("orders.emptyText", "No orders for this member yet."),
204
237
  });
205
238
  },
239
+ actions: {
240
+ // The site's My Orders page, opened by the host signed in as the lent member where it can (a visitor who
241
+ // signs in on the site otherwise). Nothing is charged, so the guard only re-checks the member is unchanged.
242
+ async openOnSite(s: State): Promise<State | undefined> {
243
+ // One host open at a time: a second tap while the first is in flight would only replace its ticket.
244
+ if (siteOpenInFlight || s.onSite !== true) return s;
245
+ siteOpenInFlight = true;
246
+ let opened: boolean | "withdrawn";
247
+ let member: OrderMember;
248
+ try {
249
+ member = await orderMember();
250
+ opened = await hostedSitePageOpener({ page: "orders" })(async () => {
251
+ const now = await orderMember();
252
+ return now.email === member.email && now.scope === member.scope;
253
+ });
254
+ } finally {
255
+ siteOpenInFlight = false;
256
+ }
257
+ // A member who changed while the host worked never inherits this answer.
258
+ const now = await orderMember();
259
+ if (memberKey(now) !== memberKey(member)) return undefined;
260
+ siteHintOwner = opened === false ? memberKey(member) : "";
261
+ const latest = svc.getState() as unknown as State;
262
+ return derive({ ...latest, siteHint: opened === false
263
+ ? copyParam("orders.onSiteFailedText", "The site's orders page could not be opened. Visit the site to see your orders.") : "" });
264
+ },
265
+ },
206
266
  });
207
267
  svc.load();
208
268
  export { svc };
@@ -6,7 +6,8 @@
6
6
  //
7
7
  // Classic Wix Events tickets and classic Pricing Plans have no ecom checkout: for those the
8
8
  // worker names the page by id (`himi.host.memberSitePage`) and the host opens the site's own event or
9
- // plans page the same way, signed in — where the member picks and pays on the site.
9
+ // plans page the same way, signed in — where the member picks and pays on the site. A member's orders
10
+ // are the same (Wix lists them to no member identity), so My orders opens the site's own page too.
10
11
 
11
12
  import { dispatch } from "@himalaya/dispatch";
12
13
  import { hostSiteFromInput } from "./branded-lite-hosted.js";
@@ -22,7 +23,7 @@ export const HOSTED_SITE_PAGE_ACTION = "himi.host.memberSitePage";
22
23
 
23
24
  /** A site page a hosted worker may ask its host for — ids only, never a URL: the host reads the URL
24
25
  * from Wix itself and decides whether the member may be signed in there. */
25
- export type HostedSitePage = { page: "event"; eventId: string } | { page: "plans"; planId?: string };
26
+ export type HostedSitePage = { page: "event"; eventId: string } | { page: "plans"; planId?: string } | { page: "orders" };
26
27
 
27
28
  /** Whether this worker runs inside a host that opened a site for it. */
28
29
  export function isHostedWorker(): boolean {
@@ -63,6 +64,7 @@ export function hostedCheckoutOpener(checkoutId: string): HostOpener {
63
64
  export function hostedSitePageOpener(page: HostedSitePage): HostOpener {
64
65
  const params: Record<string, string> = page.page === "event"
65
66
  ? { page: "event", eventId: page.eventId }
67
+ : page.page === "orders" ? { page: "orders" }
66
68
  : { page: "plans", ...(page.planId ? { planId: page.planId } : {}) };
67
69
  return hostOpener(HOSTED_SITE_PAGE_ACTION, params);
68
70
  }