@wix/himalaya-cli 0.983.0 → 0.984.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 (29) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/notification-preferences/src/index.d.ts +105 -33
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-notification-preferences.mjs +66 -5
  3. package/dist/cli.mjs +3 -0
  4. package/fixtures/wix-canned.json +36 -29
  5. package/guides/sdk-reference.json +86 -49
  6. package/package.json +2 -2
  7. package/templates/beauty-salon/dev/index.ts +27 -2
  8. package/templates/beauty-salon/tier5-src/himi-branded-lite-order/index.ts +14 -6
  9. package/templates/beauty-salon/tier5-src/himi-store-account/index.ts +59 -20
  10. package/templates/branded-lite/dev/index.ts +27 -2
  11. package/templates/branded-lite/tier5-src/himi-branded-lite-notifsettings/index.ts +42 -14
  12. package/templates/branded-lite/tier5-src/himi-branded-lite-order/index.ts +14 -6
  13. package/templates/branded-lite/tier5-src/himi-store-account/index.ts +59 -20
  14. package/templates/community-club/tier5-src/himi-store-account/index.ts +59 -20
  15. package/templates/events-meetups/tier5-src/himi-store-account/index.ts +59 -20
  16. package/templates/gym-fitness/dev/index.ts +27 -2
  17. package/templates/gym-fitness/tier5-src/himi-branded-lite-order/index.ts +14 -6
  18. package/templates/gym-fitness/tier5-src/himi-store-account/index.ts +59 -20
  19. package/templates/nonprofit-cause/tier5-src/himi-store-account/index.ts +59 -20
  20. package/templates/podcast-creator/tier5-src/himi-store-account/index.ts +59 -20
  21. package/templates/portfolio-creative/dev/index.ts +25 -1
  22. package/templates/portfolio-creative/tier5-src/himi-branded-lite-order/index.ts +14 -6
  23. package/templates/portfolio-creative/tier5-src/himi-store-account/index.ts +59 -20
  24. package/templates/restaurant/tier5-src/himi-store-account/index.ts +59 -20
  25. package/templates/retail-store/dev/index.ts +25 -1
  26. package/templates/retail-store/tier5-src/himi-branded-lite-order/index.ts +14 -6
  27. package/templates/retail-store/tier5-src/himi-store-account/index.ts +59 -20
  28. package/templates/yoga-studio/dev/index.ts +2 -1
  29. package/templates/yoga-studio/tier5-src/himi-store-account/index.ts +59 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.983.0",
3
+ "version": "0.984.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -65,5 +65,5 @@
65
65
  "artifactId": "himalaya-cli"
66
66
  }
67
67
  },
68
- "falconPackageHash": "7ef8920fa57356d1c20238a8c413cad1047a8b15da818664fd95a13b"
68
+ "falconPackageHash": "e36ec2c01ec4bdda409b35153adfc3c768ee3d817a44b697dd584dda"
69
69
  }
@@ -9201,7 +9201,8 @@ const SCREENS = runtimeSetting({
9201
9201
  "action": {
9202
9202
  "actionId": "himi.open",
9203
9203
  "params": {
9204
- "screenId": "himi_account"
9204
+ "screenId": "himi_account",
9205
+ "returnScreenId": "himi_orders"
9205
9206
  }
9206
9207
  }
9207
9208
  }
@@ -14352,7 +14353,8 @@ const SCREENS = runtimeSetting({
14352
14353
  "action": {
14353
14354
  "actionId": "himi.open",
14354
14355
  "params": {
14355
- "screenId": "himi_account"
14356
+ "screenId": "himi_account",
14357
+ "returnScreenId": "himi_bookings"
14356
14358
  }
14357
14359
  }
14358
14360
  }
@@ -23909,6 +23911,29 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
23909
23911
  },
23910
23912
  "hiddenPath": "$service:himiBrandedOrder.noEmpty"
23911
23913
  }
23914
+ },
23915
+ {
23916
+ "id": "himi_order.signin",
23917
+ "body": {
23918
+ "package": "himalaya",
23919
+ "type": "Button",
23920
+ "props": {
23921
+ "title": "Sign in",
23922
+ "style": "primary",
23923
+ "fullWidth": true,
23924
+ "action": {
23925
+ "actionId": "himi.open",
23926
+ "params": {
23927
+ "screenId": "himi_account",
23928
+ "returnScreenId": "himi_order",
23929
+ "returnId": "$service:himiBrandedOrder.id"
23930
+ }
23931
+ }
23932
+ }
23933
+ },
23934
+ "modifiers": {
23935
+ "hiddenPath": "$service:himiBrandedOrder.noSignIn"
23936
+ }
23912
23937
  }
23913
23938
  ]
23914
23939
  }
@@ -63,6 +63,10 @@ interface State extends LiveSessionFields {
63
63
  cannotReorder: boolean;
64
64
  reorderMessage: string;
65
65
  emptyText: string;
66
+ /** No member session is what stands between the visitor and this order — drives the
67
+ * sign-in CTA, which returns here with `id` once the member has signed in. */
68
+ needsSignIn: boolean;
69
+ noSignIn: boolean;
66
70
  isLoading: boolean;
67
71
  notLoading: boolean;
68
72
  isErrored: boolean;
@@ -180,6 +184,7 @@ function derive(state: State): State {
180
184
  isErrored: state.status === "error",
181
185
  notErrored: state.status !== "error",
182
186
  noEmpty: state.status !== "ready" || hasContent,
187
+ noSignIn: !state.needsSignIn,
183
188
  hideContent: !hasContent,
184
189
  hasShipping: !!state.shipping,
185
190
  noShipping: !state.shipping,
@@ -249,7 +254,7 @@ function orderState(order: WixOrder): Partial<State> {
249
254
  let loadVersion = 0;
250
255
  let loadedMember: OrderMember | undefined;
251
256
  function clearOrder(state: State): State {
252
- return derive({ ...state, title: "", placedAt: "", fulfillmentStatus: "", paymentStatus: "", subtotal: "", shipping: "", tax: "", discount: "", total: "", rows: [], reorderRefs: [], reorderMessage: "", emptyText: "" });
257
+ return derive({ ...state, title: "", placedAt: "", fulfillmentStatus: "", paymentStatus: "", subtotal: "", shipping: "", tax: "", discount: "", total: "", rows: [], reorderRefs: [], reorderMessage: "", emptyText: "", needsSignIn: false });
253
258
  }
254
259
  async function load(state: State, arg?: unknown): Promise<State | undefined> {
255
260
  const version = ++loadVersion;
@@ -259,9 +264,10 @@ async function load(state: State, arg?: unknown): Promise<State | undefined> {
259
264
  const finish = (next: State) => version === loadVersion ? derive(next) : undefined;
260
265
  const id = inputId();
261
266
  const member = await orderMember();
262
- if (!id || !member.email) {
263
- return finish({ ...state, status: "ready", emptyText: member.email ? copyParam("order.notFoundEmptyText", "Order not found.") : copyParam("order.signInEmptyText", "Sign in to view this order.") });
264
- }
267
+ const signInText = copyParam("order.signInEmptyText", "Sign in to view this order.");
268
+ if (!id) return finish({ ...state, status: "ready", emptyText: member.email ? copyParam("order.notFoundEmptyText", "Order not found.") : signInText });
269
+ const signInRequired = (status: State["status"]) => finish({ ...state, id, status, needsSignIn: true, emptyText: signInText });
270
+ if (!member.email) return signInRequired("ready");
265
271
  try {
266
272
  const order = await findOwnedOrder(id, member.email);
267
273
  await assertOrderMember(member);
@@ -272,11 +278,11 @@ async function load(state: State, arg?: unknown): Promise<State | undefined> {
272
278
  } catch (error) {
273
279
  if (version !== loadVersion) return undefined;
274
280
  try { await assertOrderMember(member); } catch {
275
- return finish({ ...state, status: "error", emptyText: copyParam("order.signInEmptyText", "Sign in to view this order.") });
281
+ return signInRequired("error");
276
282
  }
277
283
  const cached = orderAccessDenied(error) ? null : await readCachedOrder(id, member);
278
284
  try { await assertOrderMember(member); } catch {
279
- return finish({ ...state, status: "error", emptyText: copyParam("order.signInEmptyText", "Sign in to view this order.") });
285
+ return signInRequired("error");
280
286
  }
281
287
  if (cached) {
282
288
  return finish({ ...state, status: "ready", title: String(cached.title ?? "") || copyTemplate("order.orderTitle", "Order #{number}", { number: id }),
@@ -326,6 +332,8 @@ const svc = defineService<State, {
326
332
  cannotReorder: true,
327
333
  reorderMessage: "",
328
334
  emptyText: "",
335
+ needsSignIn: false,
336
+ noSignIn: true,
329
337
  isLoading: true,
330
338
  notLoading: false,
331
339
  isErrored: false,
@@ -8,7 +8,7 @@
8
8
  // `wix-site-id` header and nothing else, so every read stayed on the baked site while the
9
9
  // UI claimed otherwise. Managing several businesses is Owner Lite's job (account auth, a
10
10
  // real Sites List, and per-call site scope via `site.forSite`).
11
- import { defineService } from "@himalaya/state";
11
+ import { defineService, navigate, type NavDirective } from "@himalaya/state";
12
12
  import { dispatch } from "@himalaya/dispatch";
13
13
  import { searchOrders, type WixOrder } from "@himalaya/wix-ecom";
14
14
  import { guestProfile, loadMemberProfile, strOf, fmtDate, titleCase, type AccountActivityRow } from "../_shared/himi-account";
@@ -21,6 +21,9 @@ interface AuthRouteInput {
21
21
  returnId?: string;
22
22
  }
23
23
 
24
+ // The screen's boot input survives refresh; consuming it must survive too.
25
+ let routeInputConsumed = false;
26
+
24
27
  /** Mid-flow open params land on `himalaya.input` (paramKeys on himi_account). */
25
28
  function routeInput(): AuthRouteInput {
26
29
  const input = (globalThis as { himalaya?: { input?: AuthRouteInput } }).himalaya?.input;
@@ -35,7 +38,7 @@ function strField(arg: unknown, key: string): string {
35
38
 
36
39
  /** Mid-flow auth (UX F3): intent + return path from himi.open / himalaya.input. */
37
40
  function authIntentFromArg(arg: unknown, prev: Pick<S, "authIntent" | "authContextName" | "returnScreenId" | "returnId">): Pick<S, "authIntent" | "authContextName" | "returnScreenId" | "returnId"> {
38
- const route = routeInput();
41
+ const route = routeInputConsumed ? {} : routeInput();
39
42
  return {
40
43
  authIntent: strField(arg, "intent") || route.intent || prev.authIntent || "",
41
44
  authContextName: strField(arg, "contextName") || route.contextName || prev.authContextName || "",
@@ -44,6 +47,42 @@ function authIntentFromArg(arg: unknown, prev: Pick<S, "authIntent" | "authConte
44
47
  };
45
48
  }
46
49
 
50
+ /** Where a successful sign-in returns to, or null. Never Account itself — that would
51
+ * only push a second copy of the screen the member is already on. */
52
+ function resumeTarget(s: Pick<S, "returnScreenId" | "returnId">): NavDirective | null {
53
+ if (!s.returnScreenId || s.returnScreenId === "himi_account") return null;
54
+ return s.returnId ? { screenId: s.returnScreenId, args: { id: s.returnId } } : { screenId: s.returnScreenId };
55
+ }
56
+
57
+ /** Resume the member-only screen that sent the visitor here, once. The return path is
58
+ * consumed so a later refresh cannot fire it again; the host opens the target only if
59
+ * it is linkable, the same check an authored `himi.open` gets. */
60
+ function resumeAfterAuth(next: S): S {
61
+ const target = resumeTarget(next);
62
+ if (!next.isMember || !target) return next;
63
+ routeInputConsumed = true;
64
+ return navigate(derive({ ...next, authIntent: "", authContextName: "", returnScreenId: "", returnId: "" }), target);
65
+ }
66
+
67
+ // Actions are not serialized: two quick Sign in taps both run against the pre-tap
68
+ // state, so without this both would submit credentials and both would resume.
69
+ let authInFlight = false;
70
+
71
+ async function authenticate(s: S, submit: () => Promise<unknown>, notMemberMessage: string, failedMessage: string): Promise<S | undefined> {
72
+ if (authInFlight) return undefined;
73
+ authInFlight = true;
74
+ try {
75
+ await submit();
76
+ const next = await probe(optimisticMember(s, s.email));
77
+ if (!next.isMember) return derive({ ...next, message: notMemberMessage });
78
+ return resumeAfterAuth(derive({ ...next, message: "" }));
79
+ } catch {
80
+ return derive({ ...s, message: failedMessage });
81
+ } finally {
82
+ authInFlight = false;
83
+ }
84
+ }
85
+
47
86
  // Next value of a focus token: monotonic, and never empty once requested. A counter
48
87
  // rather than Date.now() — deterministic under test, and immune to two focus
49
88
  // requests landing inside the same millisecond.
@@ -123,7 +162,8 @@ interface S {
123
162
  authContextName: string;
124
163
  returnScreenId: string;
125
164
  returnId: string;
126
- /** Show Continue after a successful mid-flow sign-in. */
165
+ /** Show Continue when an already-signed-in member opens Account with a return path.
166
+ * A sign-in made here resumes on its own (resumeAfterAuth). */
127
167
  hasReturnPath: boolean;
128
168
  noReturnPath: boolean;
129
169
  returnCta: string;
@@ -296,8 +336,8 @@ const svc = defineService<S, {
296
336
  showSignIn: (s: S) => S;
297
337
  showRecovery: (s: S) => S;
298
338
  resetPassword: (s: S) => Promise<S>;
299
- signIn: (s: S) => Promise<S>;
300
- register: (s: S) => Promise<S>;
339
+ signIn: (s: S) => Promise<S | undefined>;
340
+ register: (s: S) => Promise<S | undefined>;
301
341
  signOut: (s: S) => Promise<S>;
302
342
  }>("himiStoreAccount", {
303
343
  state: derive({
@@ -381,30 +421,29 @@ const svc = defineService<S, {
381
421
  },
382
422
  signIn: async (s) => {
383
423
  if (!s.email || !s.password) return derive({ ...s, message: copyParam("storeAccount.validationEmailPassword", "Enter your email and password.") });
384
- try {
385
- await dispatch("himi.wix.signInDirect", { email: s.email, password: s.password });
386
- const next = await probe(optimisticMember(s, s.email));
387
- return derive({ ...next, message: next.isMember ? "" : copyParam("storeAccount.signInCheckDetails", "Sign in failed. Check your details.") });
388
- } catch {
389
- return derive({ ...s, message: copyParam("storeAccount.signInFailed", "Sign in failed. Try again.") });
390
- }
424
+ return authenticate(
425
+ s,
426
+ () => dispatch("himi.wix.signInDirect", { email: s.email, password: s.password }),
427
+ copyParam("storeAccount.signInCheckDetails", "Sign in failed. Check your details."),
428
+ copyParam("storeAccount.signInFailed", "Sign in failed. Try again."),
429
+ );
391
430
  },
392
431
  register: async (s) => {
393
432
  if (!s.email || !s.password) return derive({ ...s, message: copyParam("storeAccount.validationEmailPassword", "Enter your email and password.") });
394
- try {
395
- await dispatch("himi.wix.registerDirect", {
433
+ return authenticate(
434
+ s,
435
+ () => dispatch("himi.wix.registerDirect", {
396
436
  email: s.email,
397
437
  password: s.password,
398
438
  firstName: s.firstName,
399
439
  lastName: s.lastName,
400
- });
401
- const next = await probe(optimisticMember(s, s.email));
402
- return derive({ ...next, message: next.isMember ? "" : copyParam("storeAccount.registerTrySignIn", "Account creation failed. Try signing in.") });
403
- } catch {
404
- return derive({ ...s, message: copyParam("storeAccount.registerFailed", "Account creation failed. Try another email.") });
405
- }
440
+ }),
441
+ copyParam("storeAccount.registerTrySignIn", "Account creation failed. Try signing in."),
442
+ copyParam("storeAccount.registerFailed", "Account creation failed. Try another email."),
443
+ );
406
444
  },
407
445
  signOut: async (s) => {
446
+ routeInputConsumed = true;
408
447
  try { await dispatch("himi.wix.signOut"); } catch { /* ignore */ }
409
448
  // Do NOT re-probe via getMyData after logout — visitor-scoped my-data can
410
449
  // still return a site nickname and resurrect isMember (BUG-3). Trust the
@@ -10164,7 +10164,8 @@ const SCREENS = runtimeSetting({
10164
10164
  "action": {
10165
10165
  "actionId": "himi.open",
10166
10166
  "params": {
10167
- "screenId": "himi_account"
10167
+ "screenId": "himi_account",
10168
+ "returnScreenId": "himi_orders"
10168
10169
  }
10169
10170
  }
10170
10171
  }
@@ -15312,7 +15313,8 @@ const SCREENS = runtimeSetting({
15312
15313
  "action": {
15313
15314
  "actionId": "himi.open",
15314
15315
  "params": {
15315
- "screenId": "himi_account"
15316
+ "screenId": "himi_account",
15317
+ "returnScreenId": "himi_bookings"
15316
15318
  }
15317
15319
  }
15318
15320
  }
@@ -74098,6 +74100,29 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
74098
74100
  },
74099
74101
  "hiddenPath": "$service:himiBrandedOrder.noEmpty"
74100
74102
  }
74103
+ },
74104
+ {
74105
+ "id": "himi_order.signin",
74106
+ "body": {
74107
+ "package": "himalaya",
74108
+ "type": "Button",
74109
+ "props": {
74110
+ "title": "Sign in",
74111
+ "style": "primary",
74112
+ "fullWidth": true,
74113
+ "action": {
74114
+ "actionId": "himi.open",
74115
+ "params": {
74116
+ "screenId": "himi_account",
74117
+ "returnScreenId": "himi_order",
74118
+ "returnId": "$service:himiBrandedOrder.id"
74119
+ }
74120
+ }
74121
+ }
74122
+ },
74123
+ "modifiers": {
74124
+ "hiddenPath": "$service:himiBrandedOrder.noSignIn"
74125
+ }
74101
74126
  }
74102
74127
  ]
74103
74128
  }
@@ -13,11 +13,20 @@ import {
13
13
  getMyNotificationPreferences,
14
14
  upsertMyNotificationPreferences,
15
15
  muteStatusFor,
16
- type NotificationTopic,
16
+ readTopics,
17
+ readPreferences,
18
+ isDeliveredNow,
19
+ isLocked,
20
+ preferenceFor,
21
+ type NotificationTopicDefinition,
17
22
  type PreferenceInfo,
18
23
  } from "@himalaya/wix-notification-preferences";
19
24
 
20
- interface Row { id: string; name: string; subtitle: string; on: boolean }
25
+ /** The channel this surface delivers on — read for the row state, written on toggle.
26
+ * One constant, so the read and the write cannot drift apart. */
27
+ const CHANNEL = "MOBILE";
28
+
29
+ interface Row { id: string; name: string; subtitle: string; on: boolean; locked: boolean }
21
30
  interface S {
22
31
  status: "loading" | "ready";
23
32
  topics: Row[]; emptyText: string;
@@ -28,11 +37,20 @@ interface S {
28
37
  notMockEmpty: boolean; notMockUnavailable: boolean;
29
38
  }
30
39
 
31
- /** A topic delivers unless an explicit MUTED preference silences it. The status
32
- * is nested at stateData.state.muteStatus (PreferenceStateData → PreferenceState). */
40
+ /** Whether this topic is actually being DELIVERED — not merely what the recipient
41
+ * asked for. `isDeliveredNow` prefers the server's `granted` verdict, so a topic the
42
+ * member unmuted under a channel-wide mute renders OFF instead of promising a
43
+ * notification that will never arrive. No preference row at all ⇒ the topic's default,
44
+ * which is delivery. */
33
45
  function isOn(topicId: string, prefs: PreferenceInfo[]): boolean {
34
- const p = prefs.find((x) => x.topicId === topicId);
35
- return p?.stateData?.state?.muteStatus !== "MUTED";
46
+ const p = preferenceFor(topicId, prefs, CHANNEL);
47
+ if (!p) return true;
48
+ return isDeliveredNow(p.stateData);
49
+ }
50
+
51
+ /** A row the server says cannot be changed here (a broader rule overrides it). */
52
+ function locked(topicId: string, prefs: PreferenceInfo[]): boolean {
53
+ return isLocked(preferenceFor(topicId, prefs, CHANNEL)?.stateData);
36
54
  }
37
55
 
38
56
  /** Row subtitle doubles as an affordance: current state + how to change it. */
@@ -41,12 +59,17 @@ function subtitleFor(on: boolean): string {
41
59
  }
42
60
 
43
61
  function row(id: string, name: string, on: boolean): Row {
44
- return { id, name, subtitle: subtitleFor(on), on };
62
+ return { id, name, subtitle: subtitleFor(on), on, locked: false };
45
63
  }
46
64
 
47
- function norm(t: NotificationTopic, prefs: PreferenceInfo[]): Row {
65
+ function norm(t: NotificationTopicDefinition, prefs: PreferenceInfo[]): Row {
48
66
  const name = (t.displayName || "Notification").trim() || "Notification";
49
- return row(t.id || name, name, isOn(t.id, prefs));
67
+ const id = t.topicId || name;
68
+ const r = row(id, name, isOn(id, prefs));
69
+ // An overridden row says so instead of offering a toggle that cannot take effect.
70
+ return locked(id, prefs)
71
+ ? { ...r, locked: true, subtitle: "Managed by your channel settings" }
72
+ : r;
50
73
  }
51
74
 
52
75
  const MOCK: Row[] = [
@@ -72,9 +95,9 @@ const svc = defineService<S, {
72
95
  getNotificationTopics(),
73
96
  getMyNotificationPreferences().catch(() => ({} as Awaited<ReturnType<typeof getMyNotificationPreferences>>)),
74
97
  ]);
75
- const prefs = prefsRes.preferences?.preferencesInfo ?? [];
76
- const topics = (topicsRes.topics ?? []).map((t) => norm(t, prefs));
77
- if (topics.length) return { ...s, status: "ready", topics, emptyText: "", isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true};
98
+ const prefs = readPreferences(prefsRes);
99
+ const topics = readTopics(topicsRes).map((t) => norm(t, prefs));
100
+ if (topics.length) return { ...s, status: "ready", topics, emptyText: "", isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true };
78
101
  return mockState(s, "empty");
79
102
  } catch {
80
103
  return mockState(s, "unavailable");
@@ -84,7 +107,8 @@ const svc = defineService<S, {
84
107
  // upsert is best-effort and a no-op on sample rows.
85
108
  toggle: async (s, p) => {
86
109
  const id = p?.id;
87
- if (!id) return s;
110
+ const target = s.topics.find((r) => r.id === id);
111
+ if (!id || !target || target.locked) return s;
88
112
  let nextOn = true;
89
113
  const topics = s.topics.map((r) => {
90
114
  if (r.id !== id) return r;
@@ -93,7 +117,11 @@ const svc = defineService<S, {
93
117
  });
94
118
  if (!s.isMock && !id.startsWith("sample-")) {
95
119
  try {
96
- await upsertMyNotificationPreferences([{ topicId: id, channel: "MOBILE", stateData: { state: { muteStatus: muteStatusFor(nextOn) } } }]);
120
+ // `stateData.state` is a SCALAR enum — the old nested `{ muteStatus }` was a
121
+ // shape this service does not have, so every write was malformed.
122
+ await upsertMyNotificationPreferences([
123
+ { topicId: id, channel: CHANNEL, stateData: { state: muteStatusFor(nextOn) } },
124
+ ]);
97
125
  } catch { /* keep the optimistic state */ }
98
126
  }
99
127
  return { ...s, topics };
@@ -63,6 +63,10 @@ interface State extends LiveSessionFields {
63
63
  cannotReorder: boolean;
64
64
  reorderMessage: string;
65
65
  emptyText: string;
66
+ /** No member session is what stands between the visitor and this order — drives the
67
+ * sign-in CTA, which returns here with `id` once the member has signed in. */
68
+ needsSignIn: boolean;
69
+ noSignIn: boolean;
66
70
  isLoading: boolean;
67
71
  notLoading: boolean;
68
72
  isErrored: boolean;
@@ -180,6 +184,7 @@ function derive(state: State): State {
180
184
  isErrored: state.status === "error",
181
185
  notErrored: state.status !== "error",
182
186
  noEmpty: state.status !== "ready" || hasContent,
187
+ noSignIn: !state.needsSignIn,
183
188
  hideContent: !hasContent,
184
189
  hasShipping: !!state.shipping,
185
190
  noShipping: !state.shipping,
@@ -249,7 +254,7 @@ function orderState(order: WixOrder): Partial<State> {
249
254
  let loadVersion = 0;
250
255
  let loadedMember: OrderMember | undefined;
251
256
  function clearOrder(state: State): State {
252
- return derive({ ...state, title: "", placedAt: "", fulfillmentStatus: "", paymentStatus: "", subtotal: "", shipping: "", tax: "", discount: "", total: "", rows: [], reorderRefs: [], reorderMessage: "", emptyText: "" });
257
+ return derive({ ...state, title: "", placedAt: "", fulfillmentStatus: "", paymentStatus: "", subtotal: "", shipping: "", tax: "", discount: "", total: "", rows: [], reorderRefs: [], reorderMessage: "", emptyText: "", needsSignIn: false });
253
258
  }
254
259
  async function load(state: State, arg?: unknown): Promise<State | undefined> {
255
260
  const version = ++loadVersion;
@@ -259,9 +264,10 @@ async function load(state: State, arg?: unknown): Promise<State | undefined> {
259
264
  const finish = (next: State) => version === loadVersion ? derive(next) : undefined;
260
265
  const id = inputId();
261
266
  const member = await orderMember();
262
- if (!id || !member.email) {
263
- return finish({ ...state, status: "ready", emptyText: member.email ? copyParam("order.notFoundEmptyText", "Order not found.") : copyParam("order.signInEmptyText", "Sign in to view this order.") });
264
- }
267
+ const signInText = copyParam("order.signInEmptyText", "Sign in to view this order.");
268
+ if (!id) return finish({ ...state, status: "ready", emptyText: member.email ? copyParam("order.notFoundEmptyText", "Order not found.") : signInText });
269
+ const signInRequired = (status: State["status"]) => finish({ ...state, id, status, needsSignIn: true, emptyText: signInText });
270
+ if (!member.email) return signInRequired("ready");
265
271
  try {
266
272
  const order = await findOwnedOrder(id, member.email);
267
273
  await assertOrderMember(member);
@@ -272,11 +278,11 @@ async function load(state: State, arg?: unknown): Promise<State | undefined> {
272
278
  } catch (error) {
273
279
  if (version !== loadVersion) return undefined;
274
280
  try { await assertOrderMember(member); } catch {
275
- return finish({ ...state, status: "error", emptyText: copyParam("order.signInEmptyText", "Sign in to view this order.") });
281
+ return signInRequired("error");
276
282
  }
277
283
  const cached = orderAccessDenied(error) ? null : await readCachedOrder(id, member);
278
284
  try { await assertOrderMember(member); } catch {
279
- return finish({ ...state, status: "error", emptyText: copyParam("order.signInEmptyText", "Sign in to view this order.") });
285
+ return signInRequired("error");
280
286
  }
281
287
  if (cached) {
282
288
  return finish({ ...state, status: "ready", title: String(cached.title ?? "") || copyTemplate("order.orderTitle", "Order #{number}", { number: id }),
@@ -326,6 +332,8 @@ const svc = defineService<State, {
326
332
  cannotReorder: true,
327
333
  reorderMessage: "",
328
334
  emptyText: "",
335
+ needsSignIn: false,
336
+ noSignIn: true,
329
337
  isLoading: true,
330
338
  notLoading: false,
331
339
  isErrored: false,
@@ -8,7 +8,7 @@
8
8
  // `wix-site-id` header and nothing else, so every read stayed on the baked site while the
9
9
  // UI claimed otherwise. Managing several businesses is Owner Lite's job (account auth, a
10
10
  // real Sites List, and per-call site scope via `site.forSite`).
11
- import { defineService } from "@himalaya/state";
11
+ import { defineService, navigate, type NavDirective } from "@himalaya/state";
12
12
  import { dispatch } from "@himalaya/dispatch";
13
13
  import { searchOrders, type WixOrder } from "@himalaya/wix-ecom";
14
14
  import { guestProfile, loadMemberProfile, strOf, fmtDate, titleCase, type AccountActivityRow } from "../_shared/himi-account";
@@ -21,6 +21,9 @@ interface AuthRouteInput {
21
21
  returnId?: string;
22
22
  }
23
23
 
24
+ // The screen's boot input survives refresh; consuming it must survive too.
25
+ let routeInputConsumed = false;
26
+
24
27
  /** Mid-flow open params land on `himalaya.input` (paramKeys on himi_account). */
25
28
  function routeInput(): AuthRouteInput {
26
29
  const input = (globalThis as { himalaya?: { input?: AuthRouteInput } }).himalaya?.input;
@@ -35,7 +38,7 @@ function strField(arg: unknown, key: string): string {
35
38
 
36
39
  /** Mid-flow auth (UX F3): intent + return path from himi.open / himalaya.input. */
37
40
  function authIntentFromArg(arg: unknown, prev: Pick<S, "authIntent" | "authContextName" | "returnScreenId" | "returnId">): Pick<S, "authIntent" | "authContextName" | "returnScreenId" | "returnId"> {
38
- const route = routeInput();
41
+ const route = routeInputConsumed ? {} : routeInput();
39
42
  return {
40
43
  authIntent: strField(arg, "intent") || route.intent || prev.authIntent || "",
41
44
  authContextName: strField(arg, "contextName") || route.contextName || prev.authContextName || "",
@@ -44,6 +47,42 @@ function authIntentFromArg(arg: unknown, prev: Pick<S, "authIntent" | "authConte
44
47
  };
45
48
  }
46
49
 
50
+ /** Where a successful sign-in returns to, or null. Never Account itself — that would
51
+ * only push a second copy of the screen the member is already on. */
52
+ function resumeTarget(s: Pick<S, "returnScreenId" | "returnId">): NavDirective | null {
53
+ if (!s.returnScreenId || s.returnScreenId === "himi_account") return null;
54
+ return s.returnId ? { screenId: s.returnScreenId, args: { id: s.returnId } } : { screenId: s.returnScreenId };
55
+ }
56
+
57
+ /** Resume the member-only screen that sent the visitor here, once. The return path is
58
+ * consumed so a later refresh cannot fire it again; the host opens the target only if
59
+ * it is linkable, the same check an authored `himi.open` gets. */
60
+ function resumeAfterAuth(next: S): S {
61
+ const target = resumeTarget(next);
62
+ if (!next.isMember || !target) return next;
63
+ routeInputConsumed = true;
64
+ return navigate(derive({ ...next, authIntent: "", authContextName: "", returnScreenId: "", returnId: "" }), target);
65
+ }
66
+
67
+ // Actions are not serialized: two quick Sign in taps both run against the pre-tap
68
+ // state, so without this both would submit credentials and both would resume.
69
+ let authInFlight = false;
70
+
71
+ async function authenticate(s: S, submit: () => Promise<unknown>, notMemberMessage: string, failedMessage: string): Promise<S | undefined> {
72
+ if (authInFlight) return undefined;
73
+ authInFlight = true;
74
+ try {
75
+ await submit();
76
+ const next = await probe(optimisticMember(s, s.email));
77
+ if (!next.isMember) return derive({ ...next, message: notMemberMessage });
78
+ return resumeAfterAuth(derive({ ...next, message: "" }));
79
+ } catch {
80
+ return derive({ ...s, message: failedMessage });
81
+ } finally {
82
+ authInFlight = false;
83
+ }
84
+ }
85
+
47
86
  // Next value of a focus token: monotonic, and never empty once requested. A counter
48
87
  // rather than Date.now() — deterministic under test, and immune to two focus
49
88
  // requests landing inside the same millisecond.
@@ -123,7 +162,8 @@ interface S {
123
162
  authContextName: string;
124
163
  returnScreenId: string;
125
164
  returnId: string;
126
- /** Show Continue after a successful mid-flow sign-in. */
165
+ /** Show Continue when an already-signed-in member opens Account with a return path.
166
+ * A sign-in made here resumes on its own (resumeAfterAuth). */
127
167
  hasReturnPath: boolean;
128
168
  noReturnPath: boolean;
129
169
  returnCta: string;
@@ -296,8 +336,8 @@ const svc = defineService<S, {
296
336
  showSignIn: (s: S) => S;
297
337
  showRecovery: (s: S) => S;
298
338
  resetPassword: (s: S) => Promise<S>;
299
- signIn: (s: S) => Promise<S>;
300
- register: (s: S) => Promise<S>;
339
+ signIn: (s: S) => Promise<S | undefined>;
340
+ register: (s: S) => Promise<S | undefined>;
301
341
  signOut: (s: S) => Promise<S>;
302
342
  }>("himiStoreAccount", {
303
343
  state: derive({
@@ -381,30 +421,29 @@ const svc = defineService<S, {
381
421
  },
382
422
  signIn: async (s) => {
383
423
  if (!s.email || !s.password) return derive({ ...s, message: copyParam("storeAccount.validationEmailPassword", "Enter your email and password.") });
384
- try {
385
- await dispatch("himi.wix.signInDirect", { email: s.email, password: s.password });
386
- const next = await probe(optimisticMember(s, s.email));
387
- return derive({ ...next, message: next.isMember ? "" : copyParam("storeAccount.signInCheckDetails", "Sign in failed. Check your details.") });
388
- } catch {
389
- return derive({ ...s, message: copyParam("storeAccount.signInFailed", "Sign in failed. Try again.") });
390
- }
424
+ return authenticate(
425
+ s,
426
+ () => dispatch("himi.wix.signInDirect", { email: s.email, password: s.password }),
427
+ copyParam("storeAccount.signInCheckDetails", "Sign in failed. Check your details."),
428
+ copyParam("storeAccount.signInFailed", "Sign in failed. Try again."),
429
+ );
391
430
  },
392
431
  register: async (s) => {
393
432
  if (!s.email || !s.password) return derive({ ...s, message: copyParam("storeAccount.validationEmailPassword", "Enter your email and password.") });
394
- try {
395
- await dispatch("himi.wix.registerDirect", {
433
+ return authenticate(
434
+ s,
435
+ () => dispatch("himi.wix.registerDirect", {
396
436
  email: s.email,
397
437
  password: s.password,
398
438
  firstName: s.firstName,
399
439
  lastName: s.lastName,
400
- });
401
- const next = await probe(optimisticMember(s, s.email));
402
- return derive({ ...next, message: next.isMember ? "" : copyParam("storeAccount.registerTrySignIn", "Account creation failed. Try signing in.") });
403
- } catch {
404
- return derive({ ...s, message: copyParam("storeAccount.registerFailed", "Account creation failed. Try another email.") });
405
- }
440
+ }),
441
+ copyParam("storeAccount.registerTrySignIn", "Account creation failed. Try signing in."),
442
+ copyParam("storeAccount.registerFailed", "Account creation failed. Try another email."),
443
+ );
406
444
  },
407
445
  signOut: async (s) => {
446
+ routeInputConsumed = true;
408
447
  try { await dispatch("himi.wix.signOut"); } catch { /* ignore */ }
409
448
  // Do NOT re-probe via getMyData after logout — visitor-scoped my-data can
410
449
  // still return a site nickname and resurrect isMember (BUG-3). Trust the