@wix/himalaya-cli 0.1440.0 → 0.1441.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 (50) hide show
  1. package/dist/cli.mjs +95 -37
  2. package/dist/preview-assets.generated.bin +0 -0
  3. package/guides/himi-mcp.md +21 -11
  4. package/guides/skill.md +1 -1
  5. package/guides/trim-a-template.md +4 -1
  6. package/package.json +2 -2
  7. package/templates/beauty-salon/config.ts +14 -0
  8. package/templates/beauty-salon/dev/index.ts +1385 -1382
  9. package/templates/beauty-salon/himalaya.content.json +3 -0
  10. package/templates/beauty-salon/tier5-src/himi-store-account/index.ts +322 -27
  11. package/templates/branded-lite/config.ts +14 -0
  12. package/templates/branded-lite/dev/index.ts +1479 -1511
  13. package/templates/branded-lite/himalaya.content.json +3 -0
  14. package/templates/branded-lite/tier5-src/himi-store-account/index.ts +322 -27
  15. package/templates/community-club/config.ts +14 -0
  16. package/templates/community-club/dev/index.ts +1253 -1068
  17. package/templates/community-club/himalaya.content.json +3 -0
  18. package/templates/community-club/tier5-src/himi-store-account/index.ts +322 -27
  19. package/templates/events-meetups/config.ts +14 -0
  20. package/templates/events-meetups/dev/index.ts +1222 -1037
  21. package/templates/events-meetups/himalaya.content.json +3 -0
  22. package/templates/events-meetups/tier5-src/himi-store-account/index.ts +322 -27
  23. package/templates/gym-fitness/config.ts +14 -0
  24. package/templates/gym-fitness/dev/index.ts +1296 -1300
  25. package/templates/gym-fitness/himalaya.content.json +3 -0
  26. package/templates/gym-fitness/tier5-src/himi-store-account/index.ts +322 -27
  27. package/templates/nonprofit-cause/config.ts +14 -0
  28. package/templates/nonprofit-cause/dev/index.ts +1276 -1193
  29. package/templates/nonprofit-cause/himalaya.content.json +3 -0
  30. package/templates/nonprofit-cause/tier5-src/himi-store-account/index.ts +322 -27
  31. package/templates/podcast-creator/config.ts +14 -0
  32. package/templates/podcast-creator/dev/index.ts +1271 -1188
  33. package/templates/podcast-creator/himalaya.content.json +3 -0
  34. package/templates/podcast-creator/tier5-src/himi-store-account/index.ts +322 -27
  35. package/templates/portfolio-creative/config.ts +14 -0
  36. package/templates/portfolio-creative/dev/index.ts +1371 -1273
  37. package/templates/portfolio-creative/himalaya.content.json +3 -0
  38. package/templates/portfolio-creative/tier5-src/himi-store-account/index.ts +322 -27
  39. package/templates/restaurant/config.ts +14 -0
  40. package/templates/restaurant/dev/index.ts +1334 -1168
  41. package/templates/restaurant/himalaya.content.json +3 -0
  42. package/templates/restaurant/tier5-src/himi-store-account/index.ts +322 -27
  43. package/templates/retail-store/config.ts +14 -0
  44. package/templates/retail-store/dev/index.ts +1399 -1294
  45. package/templates/retail-store/himalaya.content.json +3 -0
  46. package/templates/retail-store/tier5-src/himi-store-account/index.ts +322 -27
  47. package/templates/yoga-studio/config.ts +14 -0
  48. package/templates/yoga-studio/dev/index.ts +1367 -1291
  49. package/templates/yoga-studio/himalaya.content.json +3 -0
  50. package/templates/yoga-studio/tier5-src/himi-store-account/index.ts +322 -27
@@ -64,6 +64,9 @@
64
64
  "himi_saved": {
65
65
  "displayName": "Saved"
66
66
  },
67
+ "himi_sign_in": {
68
+ "displayName": "Sign In"
69
+ },
67
70
  "himi_subscription": {
68
71
  "displayName": "Subscription"
69
72
  },
@@ -9,16 +9,18 @@ import { authFailure, authMessage, identityErrors, type AuthOperation } from "@h
9
9
  // `wix-site-id` header and nothing else, so every read stayed on the baked site while the
10
10
  // UI claimed otherwise. Managing several businesses is Owner Lite's job (account auth, a
11
11
  // real Sites List, and per-call site scope via `site.forSite`).
12
- import { defineService, navigate, type NavDirective } from "@himalaya/state";
12
+ import { defineService, navigate } from "@himalaya/state";
13
13
  import { dispatch } from "@himalaya/dispatch";
14
- import { searchOrders, type WixOrder } from "@himalaya/wix-ecom";
15
- import { guestProfile, loadMemberProfile, strOf, fmtDate, titleCase, type AccountActivityRow } from "../_shared/himi-account";
14
+ import { getCurrentCart, searchOrders, type WixOrder } from "@himalaya/wix-ecom";
15
+ import { guestProfile, loadMemberProfile, signedInEmail, strOf, fmtDate, titleCase, type AccountActivityRow } from "../_shared/himi-account";
16
16
  import { stashCopy, copyParam, copyTemplate } from "../_shared/himi-copy.js";
17
17
  import { memberAccountRoutes } from "../_shared/member-account-routes.js";
18
18
  import { createIapCommerce, onlineProgramOrders, pricingPlanOrders, storesAppleSalesEnabled, storesProductOrders } from "@himalaya/iap-checkout";
19
19
  import { IAP_RESTORE_KEYS, NO_IAP_RESTORE, createIapRestore, resetUnhydratedKeysOnOlderRuntime, type IapRestoreFields } from "../_shared/iap-worker.js";
20
20
 
21
21
  interface AuthRouteInput {
22
+ /** Standalone sign-in only: `register` opens the form on Create account. */
23
+ mode?: string;
22
24
  intent?: string;
23
25
  contextName?: string;
24
26
  returnScreenId?: string;
@@ -28,6 +30,9 @@ interface AuthRouteInput {
28
30
 
29
31
  // The screen's boot input survives refresh; consuming it must survive too.
30
32
  let routeInputConsumed = false;
33
+ // The form's heading has its copy: a copy pack was stashed, a probe (not the boot load)
34
+ // finished without one, or the visitor switched mode — the form is in use by then.
35
+ let headingReady = false;
31
36
 
32
37
  /** Mid-flow open params land on `himalaya.input` (paramKeys on himi_account). */
33
38
  function routeInput(): AuthRouteInput {
@@ -35,6 +40,9 @@ function routeInput(): AuthRouteInput {
35
40
  return input && typeof input === "object" ? input : {};
36
41
  }
37
42
 
43
+ /** The route opened the standalone form on Create account. Read once, before any probe. */
44
+ const ROUTE_ASKS_REGISTER = routeInput().mode === "register";
45
+
38
46
  function strField(arg: unknown, key: string): string {
39
47
  if (!arg || typeof arg !== "object") return "";
40
48
  const raw = (arg as Record<string, unknown>)[key];
@@ -55,7 +63,7 @@ function authIntentFromArg(arg: unknown, prev: Pick<S, "authIntent" | "authConte
55
63
 
56
64
  /** Where a successful sign-in returns to, or null. Never Account itself — that would
57
65
  * only push a second copy of the screen the member is already on. */
58
- function resumeTarget(s: Pick<S, "returnScreenId" | "returnId" | "returnChannelId">): NavDirective | null {
66
+ function resumeTarget(s: Pick<S, "returnScreenId" | "returnId" | "returnChannelId">): { screenId: string; args?: Record<string, string> } | null {
59
67
  if (!s.returnScreenId || s.returnScreenId === "himi_account") return null;
60
68
  return s.returnId || s.returnChannelId ? {
61
69
  screenId: s.returnScreenId,
@@ -65,23 +73,204 @@ function resumeTarget(s: Pick<S, "returnScreenId" | "returnId" | "returnChannelI
65
73
 
66
74
  /** Resume the member-only screen that sent the visitor here, once. The return path is
67
75
  * consumed so a later refresh cannot fire it again; the host opens the target only if
68
- * it is linkable, the same check an authored `himi.open` gets. */
76
+ * it is linkable, the same check an authored `himi.open` gets.
77
+ *
78
+ * On the standalone sign-in screen the target is pushed, not a `replace`: on iOS and
79
+ * Android `replace` resets the stack to its root, which would drop the screens the
80
+ * visitor came through. Back from the target shows the form again, and the form sends a
81
+ * member back on appear (settle). With no target the form goes back. */
69
82
  function resumeAfterAuth(next: S): S {
70
83
  const target = resumeTarget(next);
71
- if (!next.isMember || !target) return next;
84
+ if (!next.isMember) return next;
85
+ if (!target) return next.standalone ? navigate(next, { presentation: "back" }) : next;
72
86
  routeInputConsumed = true;
73
- return navigate(derive({ ...next, authIntent: "", authContextName: "", returnScreenId: "", returnId: "", returnChannelId: "" }), target);
87
+ const cleared = derive({ ...next, authIntent: "", authContextName: "", returnScreenId: "", returnId: "", returnChannelId: "" });
88
+ return navigate(cleared, target);
89
+ }
90
+
91
+ /** Account with a standalone sign-in screen (`signInScreen` appear param): a guest who
92
+ * arrives with a sign-in intent or a return path — a member gate, an old deep link — is
93
+ * sent on to that screen with the same params, once. */
94
+ function forwardToSignIn(next: S): S {
95
+ if (!next.signInScreen || next.isMember || routeInputConsumed) return next;
96
+ if (!next.authIntent && !next.returnScreenId) return next;
97
+ routeInputConsumed = true;
98
+ const args: Record<string, string> = { mode: "signin" };
99
+ for (const key of ["authIntent", "authContextName", "returnScreenId", "returnId", "returnChannelId"] as const) {
100
+ if (next[key]) args[key === "authIntent" ? "intent" : key === "authContextName" ? "contextName" : key] = next[key];
101
+ }
102
+ return navigate(derive({ ...next, authIntent: "", authContextName: "", returnScreenId: "", returnId: "", returnChannelId: "" }),
103
+ { screenId: next.signInScreen, args });
104
+ }
105
+
106
+ /** After a load: the standalone sign-in screen sends a member back (a stale link, or a
107
+ * sign-in finished elsewhere); Account forwards a guest with a sign-in intent.
108
+ *
109
+ * The member goes back WITHOUT the return path: only a sign-in made on this form resumes
110
+ * it (authenticate → resumeAfterAuth). The screen is public, so honouring a route's
111
+ * return path here would let a bare link open a member-only screen with no tap. */
112
+ function stashScreenCopy(arg: unknown): void {
113
+ stashCopy(arg);
114
+ if (strField(arg, "copy")) headingReady = true;
115
+ }
116
+
117
+ function settle(next: S, arg: unknown, latest = true): S {
118
+ // A member on the form goes back. Coming back to the form after the pushed
119
+ // target, iOS re-runs only `load()` with no params and DISCARDS its result, so the move is
120
+ // a dispatch, not `_nav`. Never from the boot load: the appear refresh of the same visit
121
+ // sends it.
122
+ if (next.standalone && next.isMember) {
123
+ routeInputConsumed = true;
124
+ const cleared = derive({ ...next, ...NO_VISIT });
125
+ // Only the newest probe moves: web re-shows a form with an arg-less load AND the appear
126
+ // refresh, and both may land, any time apart. The boot load never moves.
127
+ if (!latest || strField(arg, "boot") === "1") return cleared;
128
+ // The appear refresh returns `_nav`, which every host applies — binaries from before
129
+ // worker nav dispatch included. Only an arg-less load, whose result iOS
130
+ // discards, dispatches.
131
+ if (strField(arg, "standalone") === "1") return navigate(cleared, { presentation: "back" });
132
+ void dispatch("himi.open", { presentation: "back" });
133
+ return cleared;
134
+ }
135
+ // Account forwards only on an appear refresh: the boot `load()` has no params and its
136
+ // navigation is dropped, so a forward there was lost and used up the one forward.
137
+ return strField(arg, "signInScreen") ? forwardToSignIn(next) : next;
74
138
  }
75
139
 
140
+ // A tick reads only the session. The profile is read again only when the signed-in email
141
+ // changes, so an idle tick costs no profile read and no re-render. Account opts in
142
+ // (`pollIdentity`): a sign-in on the standalone screen changes the session while Account
143
+ // stays mounted, and not every host re-runs `load` when it comes back.
144
+ const IDENTITY_POLL = { id: "himi-account-identity", intervalMs: 1500, action: "himiStoreAccount.sync" };
145
+
146
+ /** "Hey, <name>" wants a first name. A profile with no display name carries the email. */
147
+ function greetingName(memberName: string): string {
148
+ const name = memberName.trim();
149
+ const bare = name.includes("@") ? name.slice(0, name.indexOf("@")) : name;
150
+ return bare.split(/\s+/)[0] ?? "";
151
+ }
152
+
153
+ // Applied once per worker: the standalone screen opens on the mode its route asked for.
154
+ let routeModeApplied = false;
155
+
156
+ /** One visit's form mode and mid-flow return path. Never restored from a snapshot: the
157
+ * snapshot is keyed by screen, not by the route that opened it. */
158
+ const NO_VISIT = {
159
+ isRegistering: false, isRecovering: false,
160
+ authIntent: "", authContextName: "", returnScreenId: "", returnId: "", returnChannelId: "",
161
+ };
162
+ const VISIT_KEYS = Object.keys(NO_VISIT) as (keyof typeof NO_VISIT)[];
163
+ /** What derive() computes from the visit keys. Hydration copies keys without re-deriving,
164
+ * so these stay out of the snapshot with them, or the first paint mixes two visits. */
165
+ const VISIT_DERIVED_KEYS = ["notRegistering", "notRecovering", "hideSignInChoices", "authTitle", "authBody",
166
+ "hasReturnPath", "noReturnPath", "returnCta"] as const;
167
+
76
168
  // Actions are not serialized: two quick Sign in taps both run against the pre-tap
77
169
  // state, so without this both would submit credentials and both would resume.
78
170
  let authInFlight = false;
79
171
  // Never projected or persisted. The renderer retains its own masked draft for retry.
80
172
  let passwordDraft = "";
81
173
  let editRevision = 0;
174
+ // load, refresh and sync each run a whole probe and replace the whole state. Only the newest
175
+ // one to start may land: a boot `load()` that resolves after the appear refresh would put
176
+ // back a state without the appear params, and a sync that resolves after a Sign out would
177
+ // put the member back.
178
+ let probeSeq = 0;
179
+ let appliedSeq = 0;
180
+ // The appear params belong to the worker session too, like the two flags above: a probe
181
+ // dropped because the visitor typed must not take them with it.
182
+ let signInScreenRequested = "";
183
+ let standaloneRequested = false;
184
+ let pollIdentityRequested = false;
185
+ // Bumped by every mode switch, so a dropped probe never puts back a mode the visitor left.
186
+ let modeSwitches = 0;
187
+
188
+ interface AppearConfig { mode: Partial<S>; visit: Partial<S> }
189
+
190
+ /** The appear params the worker session has seen so far — read when a state is built, so a
191
+ * probe that started before an appear refresh still carries what that refresh asked for. */
192
+ function appearBase(): Partial<S> {
193
+ return {
194
+ signInScreen: signInScreenRequested,
195
+ standalone: standaloneRequested,
196
+ pollIdentity: pollIdentityRequested,
197
+ ...(pollIdentityRequested ? { _poll: { ...IDENTITY_POLL } } : {}),
198
+ };
199
+ }
200
+
201
+ /** What the appear params ask for. Read before the probe awaits anything. */
202
+ function appearConfig(s: S, arg: unknown): AppearConfig {
203
+ const appearing = strField(arg, "standalone") === "1";
204
+ // Each visit to the form reads its own route again. A pooled worker comes back for a
205
+ // later visit with the same params, after an earlier visit consumed the route.
206
+ if (appearing) {
207
+ routeInputConsumed = false;
208
+ // A pooled worker serves the next visitor too: the fields mount empty, so the last
209
+ // visit's draft must not be what Sign in submits.
210
+ passwordDraft = "";
211
+ }
212
+ signInScreenRequested ||= strField(arg, "signInScreen") || s.signInScreen;
213
+ // Only the sign-in screen's route carries `mode`, so it marks the form from the boot load on.
214
+ standaloneRequested ||= s.standalone || appearing || (!routeInputConsumed && !!routeInput().mode);
215
+ pollIdentityRequested ||= s.pollIdentity || strField(arg, "pollIdentity") === "1";
216
+ // The form opens on the mode its route asked for each time it appears, sign-in by
217
+ // default: a pooled worker reopened with the same params must not open on the mode the
218
+ // last visit toggled to.
219
+ let mode: Partial<S> = {};
220
+ if (standaloneRequested && (!routeModeApplied || appearing)) {
221
+ routeModeApplied = true;
222
+ const asked = strField(arg, "mode") || (routeInputConsumed ? "" : routeInput().mode ?? "");
223
+ const isRegistering = asked === "register";
224
+ if (isRegistering !== !!s.isRegistering || s.isRecovering) {
225
+ mode = { isRegistering, isRecovering: false, ...NO_FOCUS_MODE_SWITCH };
226
+ }
227
+ }
228
+ const visit: Partial<S> = appearing
229
+ // The route's own intent and return path too: a dropped probe must not keep the last
230
+ // visit's (an abandoned gate) for the next Sign in to resume.
231
+ ? { email: "", firstName: "", lastName: "", password: "", emailError: "", passwordError: "", message: "", lastNameFocus: "", emailFocus: "", passwordFocus: "", ...authIntentFromArg(arg, NO_VISIT) }
232
+ : {};
233
+ return { mode, visit };
234
+ }
235
+
236
+ /** The probe's state, or — when it must not land — the appear params alone, on the
237
+ * current state. */
238
+ // Bumped when an appear refresh's visit reset lands. A probe that started before it read the
239
+ // last visit's state, so it must not land over the reset (iOS can start the arg-less load
240
+ // after the appear refresh, from the same pre-reset state).
241
+ let visitsApplied = 0;
242
+
243
+ async function freshProbe(s: S, arg?: unknown): Promise<{ next: S; latest: boolean } | { kept: S; latest: boolean }> {
244
+ const seq = ++probeSeq;
245
+ const revision = editRevision;
246
+ const switches = modeSwitches;
247
+ const visits = visitsApplied;
248
+ const config = appearConfig(s, arg);
249
+ const next = await probe(s, arg, config);
250
+ if (authInFlight || revision !== editRevision || seq < appliedSeq || visits !== visitsApplied) {
251
+ const current = svc.getState();
252
+ const mode = authInFlight || switches !== modeSwitches ? {} : config.mode;
253
+ // The new visit's clean form, except for what the visitor typed since it appeared.
254
+ const visit = Object.fromEntries(Object.entries(config.visit)
255
+ .filter(([key]) => current[key as keyof S] === s[key as keyof S]));
256
+ // Dropped only because a visit reset landed meanwhile: this is still the newest probe, so
257
+ // it may still move a member off the form (the reset's own probe was not the newest).
258
+ const latest = !authInFlight && revision === editRevision && seq >= appliedSeq && seq === probeSeq;
259
+ const kept = derive({ ...current, ...appearBase(), ...mode, ...visit });
260
+ // The reset still landed even though its probe was dropped. Invalidate arg-less loads
261
+ // that started from the previous visit's state before they can restore its return path.
262
+ if (strField(arg, "standalone") === "1") visitsApplied++;
263
+ return { kept, latest };
264
+ }
265
+ appliedSeq = seq;
266
+ if (strField(arg, "standalone") === "1") visitsApplied++;
267
+ // A newer probe is still on its way: this one may land, but must not navigate.
268
+ return { next, latest: seq === probeSeq };
269
+ }
82
270
  // Descriptor configuration belongs to the worker session. Keep it even when a
83
271
  // delayed profile probe is discarded because the user edited the form.
84
272
  let memberAccountLinksRequested = false;
273
+ let cartCountRequested = false;
85
274
  const translated = (key: string) => copyParam(key, authMessage(key));
86
275
 
87
276
  function validateAuth(s: S, operation: AuthOperation): S {
@@ -122,11 +311,15 @@ async function authenticate(s: S, operation: "signIn" | "register"): Promise<S |
122
311
  } finally { authInFlight = false; }
123
312
  }
124
313
 
125
- // Next value of a focus token: monotonic, and never empty once requested. A counter
126
- // rather than Date.now() — deterministic under test, and immune to two focus
314
+ // Next value of a focus token: never empty once requested, and never a value this worker
315
+ // issued before — one counter for every token, not "previous + 1". A visit reset puts a
316
+ // token back to "" while a pooled form's field may still be mounted; "previous + 1" would
317
+ // then republish a value that field already acted on, and it would ignore the request.
318
+ // A counter rather than Date.now() — deterministic under test, and immune to two focus
127
319
  // requests landing inside the same millisecond.
128
- function bumpToken(t: string): string {
129
- return String(Number(t || 0) + 1);
320
+ let focusSeq = 0;
321
+ function bumpToken(_previous: string): string {
322
+ return String(++focusSeq);
130
323
  }
131
324
 
132
325
  // A token may be cleared ONLY by a transition that unmounts the field it targets, so
@@ -143,6 +336,9 @@ const NO_FOCUS_MODE_SWITCH = { lastNameFocus: "" };
143
336
  // here both tokens must go or the re-mounted card auto-focuses Email on logout.
144
337
  const NO_FOCUS_ALL = { lastNameFocus: "", emailFocus: "" };
145
338
 
339
+ // Every focus token, cleared once the visitor types (see setEmail).
340
+ const TYPED = { ...NO_FOCUS_ALL, passwordFocus: "" };
341
+
146
342
  function authHeadline(s: S, isRegistering: boolean, isRecovering: boolean): { authTitle: string; authBody: string } {
147
343
  if (isRecovering) {
148
344
  return {
@@ -252,6 +448,25 @@ interface S extends IapRestoreFields {
252
448
  * `restorePurchases: "1"`). Only then does the worker reach the App Store layer, so a
253
449
  * descriptor that renders no such row need not declare `iap`. */
254
450
  restorePurchases: boolean;
451
+ /** Top-bar cart badge, read only once a descriptor asks for it (appear param
452
+ * `cartCount: "1"`), so an app with no store never reads a cart. A guest has a
453
+ * visitor cart too, so this is read for members and guests alike. */
454
+ cartCount: number;
455
+ /** "Hey, guest" / "Hey, <first name>". */
456
+ greeting: string;
457
+ /** The member's first name alone. Account puts it after its own translated "Hey,":
458
+ * the server translates descriptor copy, never worker copy. */
459
+ greetingFirstName: string;
460
+ /** Guest header: hidden for a member and while the first session check runs. */
461
+ hideGuest: boolean;
462
+ /** Member header and the Sign out footer: hidden for a guest and while loading. */
463
+ hideMember: boolean;
464
+ /** Account only (appear param): the standalone sign-in screen a guest is sent to. */
465
+ signInScreen: string;
466
+ /** The standalone sign-in screen itself (appear param `standalone: "1"`). */
467
+ standalone: boolean;
468
+ /** Account only (appear param `pollIdentity: "1"`): re-read the session on a timer. */
469
+ pollIdentity: boolean;
255
470
  }
256
471
 
257
472
  // Restore purchases (App Review 3.1.1) for every vertical the app sells through the App Store. The
@@ -275,10 +490,20 @@ function derive(s: S): S {
275
490
  const isRegistering = !!s.isRegistering;
276
491
  const isRecovering = !!s.isRecovering;
277
492
  const hasActivity = s.activityRows.length > 0;
278
- const headline = authHeadline(s, isRegistering, isRecovering);
493
+ // Empty until the screen's copy pack (or a probe) arrives: the boot state is built before
494
+ // any copy, and its English default would paint first in a translated app.
495
+ const headline = headingReady ? authHeadline(s, isRegistering, isRecovering) : { authTitle: "", authBody: "" };
279
496
  const hasReturnPath = !!s.isMember && !!s.returnScreenId;
497
+ const loading = s.status === "loading";
498
+ const name = s.isMember ? greetingName(s.memberName) : "";
280
499
  return {
281
500
  ...s,
501
+ greeting: name
502
+ ? copyTemplate("storeAccount.greetingMember", "Hey, {name}", { name })
503
+ : copyParam("storeAccount.greetingGuest", "Hey, guest"),
504
+ greetingFirstName: name,
505
+ hideGuest: loading || !!s.isMember,
506
+ hideMember: loading || !s.isMember,
282
507
  password: "",
283
508
  busyMessage: s.authBusy ? translated("auth.feedback.busy") : "",
284
509
  isLoading: s.status === "loading",
@@ -342,10 +567,30 @@ async function orderRows(memberEmail: string): Promise<AccountActivityRow[]> {
342
567
  .map(orderRow);
343
568
  }
344
569
 
345
- async function probe(s: S, arg?: unknown): Promise<S> {
570
+ /** A failed read keeps the count the badge last showed (`known`): an unreachable cart
571
+ * must not read as an emptied one. Before any read succeeds, that is 0 — no badge. */
572
+ async function cartItemCount(known: number): Promise<number> {
573
+ try {
574
+ const cart = await getCurrentCart();
575
+ return (cart.lineItems ?? []).reduce((n, li) => n + (li.quantity ?? 1), 0);
576
+ } catch {
577
+ return known;
578
+ }
579
+ }
580
+
581
+ async function probe(s: S, arg?: unknown, config: AppearConfig = appearConfig(s, arg)): Promise<S> {
346
582
  memberAccountLinksRequested ||= s.memberAccountLinks || strField(arg, "memberAccountLinks") === "1";
347
- const intent = authIntentFromArg(arg, s);
583
+ // A new visit to the form reads its intent and return path from its own route only: the
584
+ // last visit's (an abandoned gate) must not be resumed by a plain sign-in.
585
+ const intent = authIntentFromArg(arg, strField(arg, "standalone") === "1" ? NO_VISIT : s);
586
+ cartCountRequested ||= strField(arg, "cartCount") === "1";
587
+ // Beside the profile, not after it: the badge must not add a round trip to the paint.
588
+ // Not asked for (yet — the boot load runs before the appear params): keep the count the
589
+ // snapshot restored, not a 0 that would empty the badge until the appear refresh lands.
590
+ const cartCountRead = cartCountRequested ? cartItemCount(s.cartCount ?? 0) : Promise.resolve(s.cartCount ?? 0);
348
591
  const profile = await loadMemberProfile(copyParam("storeAccount.guestDetail", "Sign in to keep your cart and orders connected."));
592
+ // Not the boot load: it never carries the copy, and the appear refresh that does is coming.
593
+ if (strField(arg, "boot") !== "1") headingReady = true;
349
594
  const memberAccountLinks = memberAccountLinksRequested;
350
595
  const restorePurchases = s.restorePurchases || strField(arg, "restorePurchases") === "1";
351
596
  if (restorePurchases) enterCommerce();
@@ -358,10 +603,12 @@ async function probe(s: S, arg?: unknown): Promise<S> {
358
603
  activityRows = [];
359
604
  }
360
605
  }
606
+ const cartCount = await cartCountRead;
361
607
  return derive({
362
608
  ...s,
363
609
  ...intent,
364
610
  status: "ready",
611
+ cartCount,
365
612
  isMember: profile.isMember,
366
613
  identityLine: profile.identityLine,
367
614
  detailLine: profile.detailLine,
@@ -375,6 +622,9 @@ async function probe(s: S, arg?: unknown): Promise<S> {
375
622
  message: "",
376
623
  memberAccountLinks,
377
624
  restorePurchases,
625
+ ...appearBase(),
626
+ ...config.mode,
627
+ ...config.visit,
378
628
  noMemberAddresses: !routes.addresses,
379
629
  noMemberWallet: !routes.wallet,
380
630
  activityRows,
@@ -419,10 +669,12 @@ const svc = defineService<S, {
419
669
  signIn: (s: S) => Promise<S | undefined>;
420
670
  register: (s: S) => Promise<S | undefined>;
421
671
  signOut: (s: S) => Promise<S>;
672
+ sync: (s: S) => Promise<S | undefined>;
673
+ openMember: (s: S, arg?: unknown) => Promise<S>;
422
674
  restorePublish: (s: S) => S;
423
675
  iapRestore: (s: S) => Promise<undefined>;
424
676
  }>("himiStoreAccount", {
425
- notHydrated: ["password", "authBusy", "emailError", "passwordError", "passwordFocus", ...IAP_RESTORE_KEYS],
677
+ notHydrated: ["password", "authBusy", "emailError", "passwordError", "passwordFocus", "lastNameFocus", "emailFocus", ...VISIT_KEYS, ...VISIT_DERIVED_KEYS, ...IAP_RESTORE_KEYS],
426
678
  state: derive({
427
679
  status: "loading",
428
680
  isMember: false,
@@ -455,7 +707,8 @@ const svc = defineService<S, {
455
707
  message: "",
456
708
  isLoading: true,
457
709
  notLoading: false,
458
- isRegistering: false,
710
+ // The form's first paint is already on its route's mode: only sign-in's route has `mode`.
711
+ isRegistering: ROUTE_ASKS_REGISTER,
459
712
  notRegistering: true,
460
713
  isRecovering: false,
461
714
  notRecovering: true,
@@ -470,17 +723,54 @@ const svc = defineService<S, {
470
723
  noMemberAddresses: true,
471
724
  noMemberWallet: true,
472
725
  restorePurchases: false,
726
+ cartCount: 0,
727
+ greeting: "Hey, guest",
728
+ greetingFirstName: "",
729
+ hideGuest: true,
730
+ hideMember: true,
731
+ signInScreen: "",
732
+ standalone: false,
733
+ pollIdentity: false,
473
734
  ...NO_IAP_RESTORE,
474
735
  }),
475
736
  actions: {
476
737
  submit: (s): Promise<S | undefined> => s.isRecovering ? svc.resetPassword() : authenticate(s, s.isRegistering ? "register" : "signIn"),
477
738
  beginAuth: (s) => derive({ ...s, authBusy: true, message: "", emailError: "", passwordError: "" }),
478
- load: async (s, arg): Promise<S> => { stashCopy(arg); const revision = editRevision; const next = await probe(s, arg); return authInFlight || revision !== editRevision ? svc.getState() : next; },
479
- refresh: async (s, arg): Promise<S> => { stashCopy(arg); const revision = editRevision; const next = await probe(s, arg); return authInFlight || revision !== editRevision ? svc.getState() : next; },
480
- setEmail: (s, arg) => { editRevision++; const next = { ...s, email: strOf(arg).trim(), message: "" }; return derive({ ...next, emailError: s.emailError ? (identityErrors({ email: next.email }, "reset").email ? translated(identityErrors({ email: next.email }, "reset").email!) : "") : "" }); },
481
- setPassword: (s, arg) => { editRevision++; passwordDraft = strOf(arg); return derive({ ...s, message: "", passwordError: s.passwordError && !passwordDraft ? translated("auth.validation.passwordRequired") : "" }); },
482
- setFirstName: (s, arg) => { editRevision++; return derive({ ...s, firstName: strOf(arg), message: "" }); },
483
- setLastName: (s, arg) => { editRevision++; return derive({ ...s, lastName: strOf(arg), message: "" }); },
739
+ load: async (s, arg): Promise<S> => { stashScreenCopy(arg); const r = await freshProbe(s, arg); return "next" in r ? settle(r.next, arg, r.latest) : r.latest ? settle(r.kept, arg, true) : r.kept; },
740
+ refresh: async (s, arg): Promise<S> => { stashScreenCopy(arg); const r = await freshProbe(s, arg); return "next" in r ? settle(r.next, arg, r.latest) : r.latest ? settle(r.kept, arg, true) : r.kept; },
741
+ // Idle ticks return undefined, so an unchanged session costs no re-render.
742
+ sync: async (s): Promise<S | undefined> => {
743
+ if (authInFlight) return undefined;
744
+ const email = (await signedInEmail()).toLowerCase();
745
+ // An empty read during a poll is a failed read as often as a sign-out (a network blip,
746
+ // a 5xx): it never turns a member into a guest. Account's own Sign out, and the next
747
+ // appear refresh, handle a real sign-out.
748
+ if (!email && s.isMember) return undefined;
749
+ if (email === s.memberEmail.toLowerCase() && !!email === s.isMember) return undefined;
750
+ const r = await freshProbe(s);
751
+ return "next" in r ? r.next : undefined;
752
+ },
753
+ // A row to a member-only screen shows to a guest too. A member opens it; a guest signs
754
+ // in first and the sign-in screen replaces itself with it, so Back returns here.
755
+ openMember: async (s, arg): Promise<S> => {
756
+ const screenId = strField(arg, "screenId");
757
+ if (!screenId) return s;
758
+ const member = s.isMember || !!await signedInEmail();
759
+ // From the state as it is now: a probe may have landed during the identity read.
760
+ const now = svc.getState();
761
+ if (member) return navigate(now, { screenId });
762
+ return navigate(now, { screenId: now.signInScreen || "himi_sign_in", args: { mode: "signin", returnScreenId: screenId } });
763
+ },
764
+ // Typing means any focus request was delivered: clear the tokens (safe while mounted, since
765
+ // bumpToken never repeats), so a visit rarely ends — and a pooled form rarely reopens —
766
+ // holding one.
767
+ // A setter given the value it already has is not an edit: a hidden form's secure field
768
+ // clears itself on unmount (setPassword("")), and counting that would drop the probe
769
+ // that is about to move a member off the form.
770
+ setEmail: (s, arg) => { if (strOf(arg).trim() === s.email) return s; editRevision++; const next = { ...s, ...TYPED, email: strOf(arg).trim(), message: "" }; return derive({ ...next, emailError: s.emailError ? (identityErrors({ email: next.email }, "reset").email ? translated(identityErrors({ email: next.email }, "reset").email!) : "") : "" }); },
771
+ setPassword: (s, arg) => { if (strOf(arg) === passwordDraft) return s; editRevision++; passwordDraft = strOf(arg); return derive({ ...s, ...TYPED, message: "", passwordError: s.passwordError && !passwordDraft ? translated("auth.validation.passwordRequired") : "" }); },
772
+ setFirstName: (s, arg) => { if (strOf(arg) === s.firstName) return s; editRevision++; return derive({ ...s, ...TYPED, firstName: strOf(arg), message: "" }); },
773
+ setLastName: (s, arg) => { if (strOf(arg) === s.lastName) return s; editRevision++; return derive({ ...s, ...TYPED, lastName: strOf(arg), message: "" }); },
484
774
  // The register form's Next keys. Only first/last name carry one: they share a
485
775
  // single visibility gate (notRegistering) so their target is always on screen.
486
776
  // Email deliberately does NOT chain to password — see the comment on the
@@ -493,9 +783,9 @@ const svc = defineService<S, {
493
783
  // register and coming back would open focused on Last name, skipping First name.
494
784
  // Only the tokens whose fields actually unmount here are cleared: see
495
785
  // NO_FOCUS_MODE_SWITCH for why Email's is deliberately NOT among them.
496
- showRegister: (s) => { if (authInFlight) return s; editRevision++; return derive({ ...s, isRegistering: true, isRecovering: false, message: "", emailError: "", passwordError: "", ...NO_FOCUS_MODE_SWITCH }) },
497
- showSignIn: (s) => { if (authInFlight) return s; editRevision++; return derive({ ...s, isRegistering: false, isRecovering: false, message: "", emailError: "", passwordError: "", ...NO_FOCUS_MODE_SWITCH }) },
498
- showRecovery: (s) => { if (authInFlight) return s; editRevision++; return derive({ ...s, isRegistering: false, isRecovering: true, password: "", message: "", emailError: "", passwordError: "", ...NO_FOCUS_MODE_SWITCH }) },
786
+ showRegister: (s) => { if (authInFlight) return s; editRevision++; modeSwitches++; headingReady = true; return derive({ ...s, isRegistering: true, isRecovering: false, message: "", emailError: "", passwordError: "", ...NO_FOCUS_MODE_SWITCH }) },
787
+ showSignIn: (s) => { if (authInFlight) return s; editRevision++; modeSwitches++; headingReady = true; return derive({ ...s, isRegistering: false, isRecovering: false, message: "", emailError: "", passwordError: "", ...NO_FOCUS_MODE_SWITCH }) },
788
+ showRecovery: (s) => { if (authInFlight) return s; editRevision++; modeSwitches++; headingReady = true; return derive({ ...s, isRegistering: false, isRecovering: true, password: "", message: "", emailError: "", passwordError: "", ...NO_FOCUS_MODE_SWITCH }) },
499
789
  resetPassword: async (s): Promise<S> => {
500
790
  if (authInFlight) return s;
501
791
  const checked = validateAuth(s, "reset");
@@ -546,6 +836,9 @@ const svc = defineService<S, {
546
836
  returnScreenId: "",
547
837
  returnId: "",
548
838
  returnChannelId: "",
839
+ // The member's cart stays with the member; the new visitor session has its own.
840
+ cartCount: cartCountRequested ? await cartItemCount(0) : 0,
841
+ ...(s.pollIdentity ? { _poll: { ...IDENTITY_POLL } } : {}),
549
842
  };
550
843
  return derive(cleared);
551
844
  },
@@ -554,6 +847,8 @@ const svc = defineService<S, {
554
847
  },
555
848
  });
556
849
 
557
- resetUnhydratedKeysOnOlderRuntime(svc, { ...svc.getState(), ...NO_IAP_RESTORE }, IAP_RESTORE_KEYS);
558
- svc.load();
850
+ resetUnhydratedKeysOnOlderRuntime(svc, derive({ ...svc.getState(), ...NO_IAP_RESTORE, ...NO_VISIT, ...NO_FOCUS_ALL, passwordFocus: "", isRegistering: ROUTE_ASKS_REGISTER }), [...IAP_RESTORE_KEYS, ...VISIT_KEYS, ...VISIT_DERIVED_KEYS, "lastNameFocus", "emailFocus", "passwordFocus"]);
851
+ // `boot` tells settle this is not a visit: the host drops this call's navigation, and the
852
+ // appear refresh does the visit's moves.
853
+ svc.load({ boot: "1" });
559
854
  export { svc };
@@ -236,6 +236,20 @@ export default defineApp({
236
236
  ],
237
237
  "parentScreenId": "",
238
238
  "tabId": "profile"
239
+ },
240
+ {
241
+ "screenId": "himi_sign_in",
242
+ "isPublic": true,
243
+ "paramKeys": [
244
+ "mode",
245
+ "intent",
246
+ "contextName",
247
+ "returnScreenId",
248
+ "returnId",
249
+ "returnChannelId"
250
+ ],
251
+ "parentScreenId": "himi_account",
252
+ "tabId": "profile"
239
253
  }
240
254
  ],
241
255
  "tabBar": {