@wix/himalaya-cli 0.1614.0 → 0.1616.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 (106) hide show
  1. package/dist/cli.mjs +2 -0
  2. package/package.json +2 -2
  3. package/template-icons/salon-spa.png +0 -0
  4. package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
  5. package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
  6. package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
  7. package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
  8. package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
  9. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
  10. package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  11. package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +5 -2
  12. package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
  13. package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
  14. package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
  15. package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
  16. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
  17. package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  18. package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
  19. package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
  20. package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
  21. package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
  22. package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
  23. package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
  24. package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
  25. package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
  26. package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
  27. package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
  28. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
  29. package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  30. package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
  31. package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
  32. package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
  33. package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
  34. package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
  35. package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
  36. package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
  37. package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
  38. package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
  39. package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
  40. package/templates/salon-spa/README.md +53 -0
  41. package/templates/salon-spa/config.ts +272 -0
  42. package/templates/salon-spa/dev/index.ts +19893 -0
  43. package/templates/salon-spa/himalaya.content.json +49 -0
  44. package/templates/salon-spa/himalaya.template.json +14 -0
  45. package/templates/salon-spa/icon.png +0 -0
  46. package/templates/salon-spa/platforms.json +1 -0
  47. package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
  48. package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
  49. package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
  50. package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
  51. package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
  52. package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
  53. package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
  54. package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
  55. package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
  56. package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
  57. package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
  58. package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
  59. package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
  60. package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
  61. package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
  62. package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
  63. package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
  64. package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
  65. package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
  66. package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
  67. package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
  68. package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
  69. package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
  70. package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
  71. package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
  72. package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
  73. package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  74. package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
  75. package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
  76. package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
  77. package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
  78. package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
  79. package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
  80. package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
  81. package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
  82. package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
  83. package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
  84. package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
  85. package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
  86. package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
  87. package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
  88. package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
  89. package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
  90. package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
  91. package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
  92. package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
  93. package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
  94. package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
  95. package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
  96. package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
  97. package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
  98. package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
  99. package/templates/salon-spa/tokens.json +341 -0
  100. package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
  101. package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
  102. package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
  103. package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
  104. package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
  105. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
  106. package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
@@ -0,0 +1,925 @@
1
+ import { authFailure, authMessage, identityErrors, type AuthOperation } from "@himalaya/auth";
2
+ // Tier-5 bundle: himi-store — visitor/member account state.
3
+ //
4
+ // SINGLE-SITE BY DESIGN. A Branded Lite build talks to exactly one Wix site, and the
5
+ // binding is the baked Headless `clientId` itself:
6
+ // there is no metaSiteId to move and Wix will not route a consumer token minted for one
7
+ // client at another site. This worker therefore has no site picker — Profile used to carry
8
+ // a "Your sites" list that dispatched `branded_lite.select_site`, which changed the
9
+ // `wix-site-id` header and nothing else, so every read stayed on the baked site while the
10
+ // UI claimed otherwise. Managing several businesses is Owner Lite's job (account auth, a
11
+ // real Sites List, and per-call site scope via `site.forSite`).
12
+ import { defineService, navigate } from "@himalaya/state";
13
+ import { dispatch } from "@himalaya/dispatch";
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
+ import { stashCopy, copyParam, copyTemplate } from "../_shared/himi-copy.js";
17
+ import { memberAccountRoutes } from "../_shared/member-account-routes.js";
18
+ import { createIapCommerce, onlineProgramOrders, pricingPlanOrders, storesAppleSalesEnabled, storesProductOrders } from "@himalaya/iap-checkout";
19
+ import { IAP_RESTORE_KEYS, NO_IAP_RESTORE, createIapRestore, resetUnhydratedKeysOnOlderRuntime, type IapRestoreFields } from "../_shared/iap-worker.js";
20
+
21
+ interface AuthRouteInput {
22
+ authContinuation?: string;
23
+ /** Standalone sign-in only: `register` opens the form on Create account. */
24
+ mode?: string;
25
+ intent?: string;
26
+ contextName?: string;
27
+ returnScreenId?: string;
28
+ returnId?: string;
29
+ returnChannelId?: string;
30
+ /** Standalone sign-in only: "1" when it was opened as a modal (sheet / cover). */
31
+ modal?: string;
32
+ /** Modal only: the tab whose stack the return screen opens on once the modal closes. */
33
+ returnTab?: string;
34
+ }
35
+
36
+ // The screen's boot input survives refresh; consuming it must survive too.
37
+ let routeInputConsumed = false;
38
+ // The form's heading has its copy: a copy pack was stashed, a probe (not the boot load)
39
+ // finished without one, or the visitor switched mode — the form is in use by then.
40
+ let headingReady = false;
41
+
42
+ /** Mid-flow open params land on `himalaya.input` (paramKeys on himi_account). */
43
+ function routeInput(): AuthRouteInput {
44
+ const input = (globalThis as { himalaya?: { input?: AuthRouteInput } }).himalaya?.input;
45
+ return input && typeof input === "object" ? input : {};
46
+ }
47
+
48
+ /** The route opened the standalone form on Create account. Read once, before any probe. */
49
+ const ROUTE_ASKS_REGISTER = routeInput().mode === "register";
50
+
51
+ function strField(arg: unknown, key: string): string {
52
+ if (!arg || typeof arg !== "object") return "";
53
+ const raw = (arg as Record<string, unknown>)[key];
54
+ return typeof raw === "string" ? raw.trim() : "";
55
+ }
56
+
57
+ /** Mid-flow auth (UX F3): intent + return path from himi.open / himalaya.input. */
58
+ function authIntentFromArg(arg: unknown, prev: Pick<S, "authIntent" | "authContextName" | "returnScreenId" | "returnId" | "returnChannelId">): Pick<S, "authIntent" | "authContextName" | "returnScreenId" | "returnId" | "returnChannelId"> {
59
+ const route = routeInputConsumed ? {} : routeInput();
60
+ return {
61
+ authIntent: strField(arg, "intent") || route.intent || prev.authIntent || "",
62
+ authContextName: strField(arg, "contextName") || route.contextName || prev.authContextName || "",
63
+ returnScreenId: strField(arg, "returnScreenId") || route.returnScreenId || prev.returnScreenId || "",
64
+ returnId: strField(arg, "returnId") || route.returnId || prev.returnId || "",
65
+ returnChannelId: strField(arg, "returnChannelId") || route.returnChannelId || prev.returnChannelId || "",
66
+ };
67
+ }
68
+
69
+ /** How the standalone form leaves. A modal is closed: on web `back` pops the tab's stack,
70
+ * not the modal. A pushed form goes back. */
71
+ function leaveNav(): "dismiss" | "back" {
72
+ return routeInput().modal === "1" ? "dismiss" : "back";
73
+ }
74
+
75
+ /** Where a successful sign-in returns to, or null. Never Account itself — that would
76
+ * only push a second copy of the screen the member is already on. */
77
+ function resumeTarget(s: Pick<S, "returnScreenId" | "returnId" | "returnChannelId">): { screenId: string; args?: Record<string, string> } | null {
78
+ if (!s.returnScreenId || s.returnScreenId === "himi_account") return null;
79
+ return s.returnId || s.returnChannelId ? {
80
+ screenId: s.returnScreenId,
81
+ args: { ...(s.returnId ? { id: s.returnId } : {}), ...(s.returnChannelId ? { channelId: s.returnChannelId } : {}) },
82
+ } : { screenId: s.returnScreenId };
83
+ }
84
+
85
+ /** Resume the member-only screen that sent the visitor here, once. The return path is
86
+ * consumed so a later refresh cannot fire it again; the host opens the target only if
87
+ * it is linkable, the same check an authored `himi.open` gets.
88
+ *
89
+ * On the standalone sign-in screen the target is pushed, not a `replace`: on iOS and
90
+ * Android `replace` resets the stack to its root, which would drop the screens the
91
+ * visitor came through. Back from the target shows the form again, and the form sends a
92
+ * member back on appear (settle). With no target the form goes back.
93
+ *
94
+ * A modal form closes. A target pushed from here would open inside the modal (iOS, Android)
95
+ * or under it (web), so a modal that names a `returnTab` hands the target to that tab: the
96
+ * host closes the modal, then pushes it on that tab's stack (Account's member rows). A host
97
+ * showing no such tab (a drawer, a narrow Mac window) drops that request whole, modal and
98
+ * all, so the modal is also dismissed directly: it always closes, and the row opens where
99
+ * the tab exists. With no `returnTab` it only closes, and the screen under it loads again —
100
+ * the screen that opened the modal is the return target. */
101
+ function resumeAfterAuth(next: S): S {
102
+ const target = resumeTarget(next);
103
+ if (!next.isMember) return next;
104
+ const tab = routeInput().modal === "1" ? routeInput().returnTab ?? "" : "";
105
+ if (target && tab && next.standalone) {
106
+ routeInputConsumed = true;
107
+ void dispatch("himi.open", { presentation: "dismiss" });
108
+ const cleared = derive({ ...next, authIntent: "", authContextName: "", returnScreenId: "", returnId: "", returnChannelId: "" });
109
+ return navigate(cleared, target.args ? { tab, screenId: target.screenId, args: target.args } : { tab, screenId: target.screenId });
110
+ }
111
+ if (!target || routeInput().modal === "1") return next.standalone ? navigate(next, { presentation: leaveNav() }) : next;
112
+ routeInputConsumed = true;
113
+ const cleared = derive({ ...next, authIntent: "", authContextName: "", returnScreenId: "", returnId: "", returnChannelId: "" });
114
+ return navigate(cleared, target);
115
+ }
116
+
117
+ /** Account with a standalone sign-in screen (`signInScreen` appear param): a guest who
118
+ * arrives with a sign-in intent or a return path — a member gate, an old deep link — is
119
+ * sent on to that screen with the same params, once. */
120
+ function forwardToSignIn(next: S): S {
121
+ if (!next.signInScreen || next.isMember || routeInputConsumed) return next;
122
+ if (!next.authIntent && !next.returnScreenId) return next;
123
+ routeInputConsumed = true;
124
+ const args: Record<string, string> = { mode: "signin" };
125
+ for (const key of ["authIntent", "authContextName"] as const) {
126
+ if (next[key]) args[key === "authIntent" ? "intent" : "contextName"] = next[key];
127
+ }
128
+ return openSignIn(derive({ ...next, authIntent: "", authContextName: "", returnScreenId: "", returnId: "", returnChannelId: "" }),
129
+ args, resumeTarget(next));
130
+ }
131
+
132
+ /** Account sends a guest to the standalone sign-in screen, with `after`, the member screen to
133
+ * open once signed in. With a `signInPresentation` appear param it opens as that modal, and
134
+ * `after` goes on Account's own tab (`signInReturnTab`) when the modal closes; pushed, the form
135
+ * pushes `after` itself (resumeAfterAuth). */
136
+ function openSignIn(next: S, args: Record<string, string>, after: { screenId: string; args?: Record<string, string> } | null): S {
137
+ const screenId = next.signInScreen || "himi_sign_in";
138
+ const presentation = signInPresentationRequested;
139
+ const back: Record<string, string> = {};
140
+ if (after) {
141
+ back.returnScreenId = after.screenId;
142
+ if (after.args?.id) back.returnId = after.args.id;
143
+ if (after.args?.channelId) back.returnChannelId = after.args.channelId;
144
+ }
145
+ if (!presentation) return navigate(next, { screenId, args: { ...args, ...back } });
146
+ const modalArgs: Record<string, string> = { ...args, modal: "1" };
147
+ // With no tab to open it on, a modal has no way to the member screen: it only closes.
148
+ if (after && signInReturnTabRequested) Object.assign(modalArgs, back, { returnTab: signInReturnTabRequested });
149
+ return navigate(next, { screenId, presentation, args: modalArgs });
150
+ }
151
+
152
+ /** After a load: the standalone sign-in screen sends a member back (a stale link, or a
153
+ * sign-in finished elsewhere); Account forwards a guest with a sign-in intent.
154
+ *
155
+ * The member goes back WITHOUT the return path: only a sign-in made on this form resumes
156
+ * it (authenticate → resumeAfterAuth). The screen is public, so honouring a route's
157
+ * return path here would let a bare link open a member-only screen with no tap. */
158
+ function stashScreenCopy(arg: unknown): void {
159
+ stashCopy(arg);
160
+ if (strField(arg, "copy")) headingReady = true;
161
+ }
162
+
163
+ function settle(next: S, arg: unknown, latest = true): S {
164
+ // A member on the form goes back. Coming back to the form after the pushed
165
+ // target, iOS re-runs only `load()` with no params and DISCARDS its result, so the move is
166
+ // a dispatch, not `_nav`. Never from the boot load: the appear refresh of the same visit
167
+ // sends it.
168
+ if (next.standalone && next.isMember) {
169
+ routeInputConsumed = true;
170
+ const cleared = derive({ ...next, ...NO_VISIT });
171
+ // Only the newest probe moves: web re-shows a form with an arg-less load AND the appear
172
+ // refresh, and both may land, any time apart. The boot load never moves.
173
+ if (!latest || strField(arg, "boot") === "1") return cleared;
174
+ // The appear refresh returns `_nav`, which every host applies — binaries from before
175
+ // worker nav dispatch included. Only an arg-less load, whose result iOS
176
+ // discards, dispatches.
177
+ if (strField(arg, "standalone") === "1") return navigate(cleared, { presentation: leaveNav() });
178
+ // Not from a modal: nothing pushed over it comes back to it, and on iOS the closing modal's
179
+ // own screen reloads here once, after it has asked to close — a late dismiss would close
180
+ // whatever modal is open by then.
181
+ if (routeInput().modal !== "1") void dispatch("himi.open", { presentation: leaveNav() });
182
+ return cleared;
183
+ }
184
+ // Account forwards only on an appear refresh: the boot `load()` has no params and its
185
+ // navigation is dropped, so a forward there was lost and used up the one forward.
186
+ return strField(arg, "signInScreen") ? forwardToSignIn(next) : next;
187
+ }
188
+
189
+ // A tick reads only the session. The profile is read again only when the signed-in email
190
+ // changes, so an idle tick costs no profile read and no re-render. Account opts in
191
+ // (`pollIdentity`): a sign-in on the standalone screen changes the session while Account
192
+ // stays mounted, and not every host re-runs `load` when it comes back.
193
+ const IDENTITY_POLL = { id: "himi-account-identity", intervalMs: 1500, action: "himiStoreAccount.sync" };
194
+
195
+ /** "Hey, <name>" wants a first name. A profile with no display name carries the email. */
196
+ function greetingName(memberName: string): string {
197
+ const name = memberName.trim();
198
+ const bare = name.includes("@") ? name.slice(0, name.indexOf("@")) : name;
199
+ return bare.split(/\s+/)[0] ?? "";
200
+ }
201
+
202
+ // Applied once per worker: the standalone screen opens on the mode its route asked for.
203
+ let routeModeApplied = false;
204
+
205
+ /** One visit's form mode and mid-flow return path. Never restored from a snapshot: the
206
+ * snapshot is keyed by screen, not by the route that opened it. */
207
+ const NO_VISIT = {
208
+ isRegistering: false, isRecovering: false,
209
+ authIntent: "", authContextName: "", returnScreenId: "", returnId: "", returnChannelId: "",
210
+ };
211
+ const VISIT_KEYS = Object.keys(NO_VISIT) as (keyof typeof NO_VISIT)[];
212
+ /** What derive() computes from the visit keys. Hydration copies keys without re-deriving,
213
+ * so these stay out of the snapshot with them, or the first paint mixes two visits. */
214
+ const VISIT_DERIVED_KEYS = ["notRegistering", "notRecovering", "hideSignInChoices", "authTitle", "authBody",
215
+ "hasReturnPath", "noReturnPath", "returnCta"] as const;
216
+
217
+ // Actions are not serialized: two quick Sign in taps both run against the pre-tap
218
+ // state, so without this both would submit credentials and both would resume.
219
+ let authInFlight = false;
220
+ // Never projected or persisted. The renderer retains its own masked draft for retry.
221
+ let passwordDraft = "";
222
+ let editRevision = 0;
223
+ // load, refresh and sync each run a whole probe and replace the whole state. Only the newest
224
+ // one to start may land: a boot `load()` that resolves after the appear refresh would put
225
+ // back a state without the appear params, and a sync that resolves after a Sign out would
226
+ // put the member back.
227
+ let probeSeq = 0;
228
+ let appliedSeq = 0;
229
+ // The appear params belong to the worker session too, like the two flags above: a probe
230
+ // dropped because the visitor typed must not take them with it.
231
+ let signInScreenRequested = "";
232
+ /** Account only: how it opens the standalone sign-in screen ("" = push). */
233
+ let signInPresentationRequested: "" | "sheet" | "full_screen" = "";
234
+ /** Account only: its tab, where a member screen opens after a modal sign-in. */
235
+ let signInReturnTabRequested = "";
236
+ let standaloneRequested = false;
237
+ let pollIdentityRequested = false;
238
+ // iOS can start the arg-less boot load after the appear refresh that asks for the row; the
239
+ // newer boot probe must not land, or be kept, without it.
240
+ let restorePurchasesRequested = false;
241
+ // Bumped by every mode switch, so a dropped probe never puts back a mode the visitor left.
242
+ let modeSwitches = 0;
243
+
244
+ interface AppearConfig { mode: Partial<S>; visit: Partial<S> }
245
+
246
+ /** The appear params the worker session has seen so far — read when a state is built, so a
247
+ * probe that started before an appear refresh still carries what that refresh asked for. */
248
+ function appearBase(): Partial<S> {
249
+ return {
250
+ signInScreen: signInScreenRequested,
251
+ standalone: standaloneRequested,
252
+ pollIdentity: pollIdentityRequested,
253
+ restorePurchases: restorePurchasesRequested,
254
+ ...(pollIdentityRequested ? { _poll: { ...IDENTITY_POLL } } : {}),
255
+ };
256
+ }
257
+
258
+ /** What the appear params ask for. Read before the probe awaits anything. */
259
+ function appearConfig(s: S, arg: unknown): AppearConfig {
260
+ const appearing = strField(arg, "standalone") === "1";
261
+ // Each visit to the form reads its own route again. A pooled worker comes back for a
262
+ // later visit with the same params, after an earlier visit consumed the route.
263
+ if (appearing) {
264
+ routeInputConsumed = false;
265
+ // A pooled worker serves the next visitor too: the fields mount empty, so the last
266
+ // visit's draft must not be what Sign in submits.
267
+ passwordDraft = "";
268
+ }
269
+ signInScreenRequested ||= strField(arg, "signInScreen") || s.signInScreen;
270
+ const presentation = strField(arg, "signInPresentation");
271
+ if (presentation === "sheet" || presentation === "full_screen") signInPresentationRequested = presentation;
272
+ signInReturnTabRequested ||= strField(arg, "signInReturnTab");
273
+ // Only the sign-in screen's route carries `mode`, so it marks the form from the boot load on.
274
+ standaloneRequested ||= s.standalone || appearing || (!routeInputConsumed && !!routeInput().mode);
275
+ pollIdentityRequested ||= s.pollIdentity || strField(arg, "pollIdentity") === "1";
276
+ restorePurchasesRequested ||= s.restorePurchases || strField(arg, "restorePurchases") === "1";
277
+ // The form opens on the mode its route asked for each time it appears, sign-in by
278
+ // default: a pooled worker reopened with the same params must not open on the mode the
279
+ // last visit toggled to.
280
+ let mode: Partial<S> = {};
281
+ if (standaloneRequested && (!routeModeApplied || appearing)) {
282
+ routeModeApplied = true;
283
+ const asked = strField(arg, "mode") || (routeInputConsumed ? "" : routeInput().mode ?? "");
284
+ const isRegistering = asked === "register";
285
+ if (isRegistering !== !!s.isRegistering || s.isRecovering) {
286
+ mode = { isRegistering, isRecovering: false, ...NO_FOCUS_MODE_SWITCH };
287
+ }
288
+ }
289
+ const visit: Partial<S> = appearing
290
+ // The route's own intent and return path too: a dropped probe must not keep the last
291
+ // visit's (an abandoned gate) for the next Sign in to resume.
292
+ ? { email: "", firstName: "", lastName: "", password: "", emailError: "", passwordError: "", message: "", lastNameFocus: "", emailFocus: "", passwordFocus: "", ...authIntentFromArg(arg, NO_VISIT) }
293
+ : {};
294
+ return { mode, visit };
295
+ }
296
+
297
+ /** The probe's state, or — when it must not land — the appear params alone, on the
298
+ * current state. */
299
+ // Bumped when an appear refresh's visit reset lands. A probe that started before it read the
300
+ // last visit's state, so it must not land over the reset (iOS can start the arg-less load
301
+ // after the appear refresh, from the same pre-reset state).
302
+ let visitsApplied = 0;
303
+
304
+ async function freshProbe(s: S, arg?: unknown): Promise<{ next: S; latest: boolean } | { kept: S; latest: boolean }> {
305
+ const seq = ++probeSeq;
306
+ const revision = editRevision;
307
+ const switches = modeSwitches;
308
+ const visits = visitsApplied;
309
+ const config = appearConfig(s, arg);
310
+ const next = await probe(s, arg, config);
311
+ if (authInFlight || revision !== editRevision || seq < appliedSeq || visits !== visitsApplied) {
312
+ const current = svc.getState();
313
+ const mode = authInFlight || switches !== modeSwitches ? {} : config.mode;
314
+ // The new visit's clean form, except for what the visitor typed since it appeared.
315
+ const visit = Object.fromEntries(Object.entries(config.visit)
316
+ .filter(([key]) => current[key as keyof S] === s[key as keyof S]));
317
+ // Dropped only because a visit reset landed meanwhile: this is still the newest probe, so
318
+ // it may still move a member off the form (the reset's own probe was not the newest).
319
+ const latest = !authInFlight && revision === editRevision && seq >= appliedSeq && seq === probeSeq;
320
+ const kept = derive({ ...current, ...appearBase(), ...mode, ...visit });
321
+ // The reset still landed even though its probe was dropped. Invalidate arg-less loads
322
+ // that started from the previous visit's state before they can restore its return path.
323
+ if (strField(arg, "standalone") === "1") visitsApplied++;
324
+ return { kept, latest };
325
+ }
326
+ appliedSeq = seq;
327
+ if (strField(arg, "standalone") === "1") visitsApplied++;
328
+ // A newer probe is still on its way: this one may land, but must not navigate.
329
+ return { next, latest: seq === probeSeq };
330
+ }
331
+ // Descriptor configuration belongs to the worker session. Keep it even when a
332
+ // delayed profile probe is discarded because the user edited the form.
333
+ let memberAccountLinksRequested = false;
334
+ let cartCountRequested = false;
335
+ const translated = (key: string) => copyParam(key, authMessage(key));
336
+
337
+ function validateAuth(s: S, operation: AuthOperation): S {
338
+ const errors = identityErrors({ email: s.email, password: passwordDraft }, operation);
339
+ return derive({ ...s, emailError: errors.email ? translated(errors.email) : "",
340
+ passwordError: errors.password ? translated(errors.password) : "",
341
+ emailFocus: errors.email ? bumpToken(s.emailFocus) : s.emailFocus,
342
+ passwordFocus: !errors.email && errors.password ? bumpToken(s.passwordFocus) : s.passwordFocus,
343
+ message: "" });
344
+ }
345
+ function rejectResult(raw: unknown, operation: AuthOperation) {
346
+ const value = raw as { status?: string; error?: unknown; payload?: { error?: unknown } } | null;
347
+ if (value?.status === "error" || value?.error || value?.payload?.error) throw authFailure(raw, operation, translated);
348
+ }
349
+ async function authenticate(s: S, operation: "signIn" | "register"): Promise<S | undefined> {
350
+ if (authInFlight) return undefined;
351
+ const checked = validateAuth(s, operation);
352
+ if (checked.emailError || checked.passwordError) return checked;
353
+ authInFlight = true;
354
+ const revision = editRevision;
355
+ const password = passwordDraft;
356
+ try {
357
+ await svc.beginAuth(); // publish busy before waiting for the network
358
+ const raw = await dispatch(operation === "signIn" ? "himi.wix.signInDirect" : "himi.wix.registerDirect", {
359
+ email: s.email.trim(), password,
360
+ // An opaque host-issued token, never a return route. Native commits it before the
361
+ // account change can destroy this worker; the replacement does not need this promise.
362
+ ...(routeInput().authContinuation ? { authContinuation: routeInput().authContinuation } : {}),
363
+ ...(operation === "register" ? { firstName: s.firstName, lastName: s.lastName } : {}),
364
+ });
365
+ rejectResult(raw, operation);
366
+ if (revision !== editRevision) return derive({ ...svc.getState(), authBusy: false });
367
+ const next = await probe(svc.getState());
368
+ if (revision !== editRevision) return derive({ ...svc.getState(), authBusy: false });
369
+ if (!next.isMember) throw { code: "unknown" };
370
+ passwordDraft = "";
371
+ return resumeAfterAuth(completedAuth(next));
372
+ } catch (error) {
373
+ return derive({ ...svc.getState(), authBusy: false,
374
+ message: revision === editRevision ? authFailure(error, operation, translated).reason : "" });
375
+ } finally { authInFlight = false; }
376
+ }
377
+
378
+ // Next value of a focus token: never empty once requested, and never a value this worker
379
+ // issued before — one counter for every token, not "previous + 1". A visit reset puts a
380
+ // token back to "" while a pooled form's field may still be mounted; "previous + 1" would
381
+ // then republish a value that field already acted on, and it would ignore the request.
382
+ // A counter rather than Date.now() — deterministic under test, and immune to two focus
383
+ // requests landing inside the same millisecond.
384
+ let focusSeq = 0;
385
+ function bumpToken(_previous: string): string {
386
+ return String(++focusSeq);
387
+ }
388
+
389
+ // A token may be cleared ONLY by a transition that unmounts the field it targets, so
390
+ // the two transitions here clear different sets — the whole rule in two constants.
391
+ //
392
+ // A mode switch re-lays-out the card: the name fields (gated on notRegistering) go
393
+ // away, but Email is rendered in ALL three modes and stays mounted. Clearing
394
+ // `emailFocus` here would be a reset-while-mounted: Email's renderer-side "last token
395
+ // I acted on" survives, so the next bump would republish a value it has already seen
396
+ // and ignores — the Last name → Email chain would silently die after one round-trip.
397
+ const NO_FOCUS_MODE_SWITCH = { lastNameFocus: "" };
398
+
399
+ // Sign-out hides the entire login card (it is gated on isMember), Email included, so
400
+ // here both tokens must go or the re-mounted card auto-focuses Email on logout.
401
+ const NO_FOCUS_ALL = { lastNameFocus: "", emailFocus: "" };
402
+
403
+ // Every focus token, cleared once the visitor types (see setEmail).
404
+ const TYPED = { ...NO_FOCUS_ALL, passwordFocus: "" };
405
+
406
+ function authHeadline(s: S, isRegistering: boolean, isRecovering: boolean): { authTitle: string; authBody: string } {
407
+ if (isRecovering) {
408
+ return {
409
+ authTitle: copyParam("storeAccount.authTitleRecovery", "Reset password"),
410
+ authBody: copyParam("storeAccount.authBodyRecovery", "Enter your member email and we will send a reset link."),
411
+ };
412
+ }
413
+ if (isRegistering) {
414
+ return {
415
+ authTitle: copyParam("storeAccount.authTitleRegister", "Create account"),
416
+ authBody: copyParam("storeAccount.authBodyRegister", "Create a member account to continue — cart, bookings, RSVPs, and memberships stay with you."),
417
+ };
418
+ }
419
+ switch (s.authIntent) {
420
+ case "join_group":
421
+ return {
422
+ authTitle: copyParam("storeAccount.authTitleJoinGroup", "Sign in to join"),
423
+ authBody: s.authContextName
424
+ ? copyTemplate("storeAccount.authBodyJoinGroupNamed", "Sign in to join {name}, then we’ll bring you back.", { name: s.authContextName })
425
+ : copyParam("storeAccount.authBodyJoinGroup", "Sign in to join this group, then we’ll bring you back."),
426
+ };
427
+ case "rsvp":
428
+ return {
429
+ authTitle: copyParam("storeAccount.authTitleRsvp", "Sign in to RSVP"),
430
+ authBody: copyParam("storeAccount.authBodyRsvp", "Sign in to RSVP, then we’ll bring you back."),
431
+ };
432
+ case "book":
433
+ return {
434
+ authTitle: copyParam("storeAccount.authTitleBook", "Sign in to book"),
435
+ authBody: copyParam("storeAccount.authBodyBook", "Sign in to book this time, then we’ll bring you back."),
436
+ };
437
+ case "post":
438
+ return {
439
+ authTitle: copyParam("storeAccount.authTitlePost", "Sign in to post"),
440
+ authBody: copyParam("storeAccount.authBodyPost", "Sign in to join the conversation, then we’ll bring you back."),
441
+ };
442
+ default:
443
+ return {
444
+ authTitle: copyParam("storeAccount.authTitle", "Welcome"),
445
+ authBody: copyParam("storeAccount.authBody", "Sign in to continue — cart, bookings, RSVPs, and memberships stay with you."),
446
+ };
447
+ }
448
+ }
449
+
450
+ interface S extends IapRestoreFields {
451
+ status: "loading" | "ready";
452
+ isMember: boolean;
453
+ notMember: boolean;
454
+ identityLine: string;
455
+ detailLine: string;
456
+ memberName: string;
457
+ memberEmail: string;
458
+ memberImage: string;
459
+ memberInitials: string;
460
+ hasMemberImage: boolean;
461
+ noMemberImage: boolean;
462
+ authTitle: string;
463
+ authBody: string;
464
+ /** Mid-flow gate intent (`join_group` | `rsvp` | `book` | `post` | ""). */
465
+ authIntent: string;
466
+ authContextName: string;
467
+ returnScreenId: string;
468
+ returnId: string;
469
+ returnChannelId: string;
470
+ /** Show Continue when an already-signed-in member opens Account with a return path.
471
+ * A sign-in made here resumes on its own (resumeAfterAuth). */
472
+ hasReturnPath: boolean;
473
+ noReturnPath: boolean;
474
+ returnCta: string;
475
+ email: string;
476
+ /** Legacy snapshot slot, always empty. The active form keeps its password in
477
+ * transient memory for retry; hydration never restores a persisted secret. */
478
+ password: string;
479
+ authBusy: boolean;
480
+ busyMessage?: string;
481
+ emailError: string;
482
+ passwordError: string;
483
+ passwordFocus: string;
484
+ firstName: string;
485
+ lastName: string;
486
+ // Focus TOKENS (`TextFieldProps.focus_path`), not flags: the bound field focuses
487
+ // whenever the token becomes a new non-empty value, so a monotonic counter is the
488
+ // shape — a latched boolean cannot request focus a second time. Every other
489
+ // transition here goes through derive({...s}), which carries them, and they are
490
+ // deliberately never reset: re-publishing a token the renderer has already seen
491
+ // is ignored, which would make the Next key go dead.
492
+ lastNameFocus: string;
493
+ emailFocus: string;
494
+ message: string;
495
+ isLoading: boolean;
496
+ notLoading: boolean;
497
+ isRegistering: boolean;
498
+ notRegistering: boolean;
499
+ isRecovering: boolean;
500
+ notRecovering: boolean;
501
+ hideSignInChoices: boolean;
502
+ activityTitle: string;
503
+ activitySummary: string;
504
+ activityRows: AccountActivityRow[];
505
+ activityEmptyText: string;
506
+ hasActivity: boolean;
507
+ noActivity: boolean;
508
+ /** Set once a descriptor asks for the member-account links (appear param
509
+ * `memberAccountLinks: "1"`). Only then does the probe read app_info, so a descriptor
510
+ * that renders no such link need not declare that capability. */
511
+ memberAccountLinks: boolean;
512
+ /** Hides My Addresses where this runtime cannot reach the address book. */
513
+ noMemberAddresses: boolean;
514
+ /** Hides My Wallet where this runtime cannot reach saved cards. */
515
+ noMemberWallet: boolean;
516
+ /** Set once a descriptor asks for the Restore purchases row (appear param
517
+ * `restorePurchases: "1"`). Only then does the worker reach the App Store layer, so a
518
+ * descriptor that renders no such row need not declare `iap`. */
519
+ restorePurchases: boolean;
520
+ /** Top-bar cart badge, read only once a descriptor asks for it (appear param
521
+ * `cartCount: "1"`), so an app with no store never reads a cart. A guest has a
522
+ * visitor cart too, so this is read for members and guests alike. */
523
+ cartCount: number;
524
+ /** "Hey, guest" / "Hey, <first name>". */
525
+ greeting: string;
526
+ /** The member's first name alone. Account puts it after its own translated "Hey,":
527
+ * the server translates descriptor copy, never worker copy. */
528
+ greetingFirstName: string;
529
+ /** Guest header: hidden for a member and while the first session check runs. */
530
+ hideGuest: boolean;
531
+ /** Member header and the Sign out footer: hidden for a guest and while loading. */
532
+ hideMember: boolean;
533
+ /** Account only (appear param): the standalone sign-in screen a guest is sent to. */
534
+ signInScreen: string;
535
+ /** The standalone sign-in screen itself (appear param `standalone: "1"`). */
536
+ standalone: boolean;
537
+ /** Account only (appear param `pollIdentity: "1"`): re-read the session on a timer. */
538
+ pollIdentity: boolean;
539
+ }
540
+
541
+ // Restore purchases (App Review 3.1.1) for every vertical the app sells through the App Store. The
542
+ // row shows once purchasing is ready on this host (a bound iOS binary with managed StoreKit), to a
543
+ // guest too: the restore itself asks them to sign in.
544
+ const commerce = createIapCommerce({
545
+ adapters: [onlineProgramOrders, pricingPlanOrders, ...(storesAppleSalesEnabled() ? [storesProductOrders] : [])],
546
+ onChange: () => { void svc.restorePublish(); },
547
+ });
548
+ const restore = createIapRestore(commerce, { prefix: "storeAccount", repaint: () => { void svc.restorePublish(); } });
549
+ let commerceEntered = false;
550
+
551
+ /** The first entry reads readiness and recovers held purchases; later ones reconcile, as on any
552
+ * screen that sells. */
553
+ function enterCommerce(): void {
554
+ void (commerceEntered ? commerce.reconcile() : commerce.start());
555
+ commerceEntered = true;
556
+ }
557
+
558
+ function derive(s: S): S {
559
+ const isRegistering = !!s.isRegistering;
560
+ const isRecovering = !!s.isRecovering;
561
+ const hasActivity = s.activityRows.length > 0;
562
+ // Empty until the screen's copy pack (or a probe) arrives: the boot state is built before
563
+ // any copy, and its English default would paint first in a translated app.
564
+ const headline = headingReady ? authHeadline(s, isRegistering, isRecovering) : { authTitle: "", authBody: "" };
565
+ const hasReturnPath = !!s.isMember && !!s.returnScreenId;
566
+ const loading = s.status === "loading";
567
+ const name = s.isMember ? greetingName(s.memberName) : "";
568
+ return {
569
+ ...s,
570
+ greeting: name
571
+ ? copyTemplate("storeAccount.greetingMember", "Hey, {name}", { name })
572
+ : copyParam("storeAccount.greetingGuest", "Hey, guest"),
573
+ greetingFirstName: name,
574
+ hideGuest: loading || !!s.isMember,
575
+ hideMember: loading || !s.isMember,
576
+ password: "",
577
+ busyMessage: s.authBusy ? translated("auth.feedback.busy") : "",
578
+ isLoading: s.status === "loading",
579
+ notLoading: s.status !== "loading",
580
+ notMember: !s.isMember,
581
+ isRegistering,
582
+ notRegistering: !isRegistering,
583
+ isRecovering,
584
+ notRecovering: !isRecovering,
585
+ hideSignInChoices: isRegistering || isRecovering,
586
+ hasActivity,
587
+ noActivity: !hasActivity,
588
+ ...restore.fields(s.restorePurchases && commerce.readiness().status === "ready"),
589
+ authTitle: headline.authTitle,
590
+ authBody: headline.authBody,
591
+ hasReturnPath,
592
+ noReturnPath: !hasReturnPath,
593
+ returnCta: s.authIntent === "join_group"
594
+ ? copyParam("storeAccount.returnJoinGroupCta", "Back to group")
595
+ : s.authIntent === "rsvp"
596
+ ? copyParam("storeAccount.returnRsvpCta", "Back to event")
597
+ : s.authIntent === "post"
598
+ ? copyParam("storeAccount.returnPostCta", "Back to feed")
599
+ : copyParam("storeAccount.returnCta", "Continue"),
600
+ };
601
+ }
602
+
603
+ function orderEmail(order: WixOrder): string {
604
+ return (order.buyerInfo?.email || "").toLowerCase();
605
+ }
606
+
607
+ function itemCount(order: WixOrder): string {
608
+ const total = (order.lineItems ?? []).reduce((sum, item) => sum + (item.quantity || 1), 0);
609
+ if (!total) return "";
610
+ return `${total} ${total === 1 ? "item" : "items"}`;
611
+ }
612
+
613
+ function orderRow(order: WixOrder): AccountActivityRow {
614
+ const total = order.priceSummary?.total?.formattedAmount || "";
615
+ const meta = [fmtDate(order.createdDate), itemCount(order)].filter(Boolean).join(" · ");
616
+ return {
617
+ id: order.id || order.number || "order",
618
+ title: `Order #${order.number || order.id || ""}`.trim(),
619
+ subtitle: total || "Order total unavailable",
620
+ meta,
621
+ status: titleCase(order.fulfillmentStatus || order.paymentStatus) || "Order",
622
+ };
623
+ }
624
+
625
+ async function orderRows(memberEmail: string): Promise<AccountActivityRow[]> {
626
+ if (!memberEmail) return [];
627
+ const res = await searchOrders({
628
+ search: {
629
+ cursorPaging: { limit: 30 },
630
+ sort: [{ fieldName: "createdDate", order: "DESC" }],
631
+ },
632
+ });
633
+ return (res.orders ?? [])
634
+ .filter((order) => orderEmail(order) === memberEmail.toLowerCase())
635
+ .slice(0, 3)
636
+ .map(orderRow);
637
+ }
638
+
639
+ /** A failed read keeps the count the badge last showed (`known`): an unreachable cart
640
+ * must not read as an emptied one. Before any read succeeds, that is 0 — no badge. */
641
+ async function cartItemCount(known: number): Promise<number> {
642
+ try {
643
+ const cart = await getCurrentCart();
644
+ return (cart.lineItems ?? []).reduce((n, li) => n + (li.quantity ?? 1), 0);
645
+ } catch {
646
+ return known;
647
+ }
648
+ }
649
+
650
+ async function probe(s: S, arg?: unknown, config: AppearConfig = appearConfig(s, arg)): Promise<S> {
651
+ memberAccountLinksRequested ||= s.memberAccountLinks || strField(arg, "memberAccountLinks") === "1";
652
+ // A new visit to the form reads its intent and return path from its own route only: the
653
+ // last visit's (an abandoned gate) must not be resumed by a plain sign-in.
654
+ const intent = authIntentFromArg(arg, strField(arg, "standalone") === "1" ? NO_VISIT : s);
655
+ cartCountRequested ||= strField(arg, "cartCount") === "1";
656
+ // Beside the profile, not after it: the badge must not add a round trip to the paint.
657
+ // Not asked for (yet — the boot load runs before the appear params): keep the count the
658
+ // snapshot restored, not a 0 that would empty the badge until the appear refresh lands.
659
+ const cartCountRead = cartCountRequested ? cartItemCount(s.cartCount ?? 0) : Promise.resolve(s.cartCount ?? 0);
660
+ const profile = await loadMemberProfile(copyParam("storeAccount.guestDetail", "Sign in to keep your cart and orders connected."));
661
+ // Not the boot load: it never carries the copy, and the appear refresh that does is coming.
662
+ if (strField(arg, "boot") !== "1") headingReady = true;
663
+ const memberAccountLinks = memberAccountLinksRequested;
664
+ if (restorePurchasesRequested) enterCommerce();
665
+ const routes = memberAccountLinks ? await memberAccountRoutes() : { addresses: false, wallet: false };
666
+ let activityRows: AccountActivityRow[] = [];
667
+ if (profile.isMember) {
668
+ try {
669
+ activityRows = await orderRows(profile.memberEmail);
670
+ } catch {
671
+ activityRows = [];
672
+ }
673
+ }
674
+ const cartCount = await cartCountRead;
675
+ return derive({
676
+ ...s,
677
+ ...intent,
678
+ status: "ready",
679
+ cartCount,
680
+ isMember: profile.isMember,
681
+ identityLine: profile.identityLine,
682
+ detailLine: profile.detailLine,
683
+ memberName: profile.memberName,
684
+ memberEmail: profile.memberEmail,
685
+ memberImage: profile.memberImage,
686
+ memberInitials: profile.memberInitials,
687
+ hasMemberImage: profile.hasMemberImage,
688
+ noMemberImage: profile.noMemberImage,
689
+ email: profile.memberEmail || s.email,
690
+ message: "",
691
+ memberAccountLinks,
692
+ ...appearBase(),
693
+ ...config.mode,
694
+ ...config.visit,
695
+ noMemberAddresses: !routes.addresses,
696
+ noMemberWallet: !routes.wallet,
697
+ activityRows,
698
+ activityTitle: copyParam("storeAccount.activityTitle", "Previous orders"),
699
+ activitySummary: activityRows.length
700
+ ? `${activityRows.length} recent ${activityRows.length === 1 ? "order" : "orders"}`
701
+ : copyParam("storeAccount.activityHint", "Orders are loaded from the store."),
702
+ activityEmptyText: profile.isMember
703
+ ? copyParam("storeAccount.activityEmptyMember", "No previous orders for this member yet.")
704
+ : copyParam("storeAccount.activityEmptyGuest", "Sign in to see orders."),
705
+ });
706
+ }
707
+
708
+ function completedAuth(s: S): S {
709
+ return derive({
710
+ ...s,
711
+ password: "",
712
+ authBusy: false, emailError: "", passwordError: "", passwordFocus: "",
713
+ firstName: "",
714
+ lastName: "",
715
+ isRegistering: false,
716
+ isRecovering: false,
717
+ message: "",
718
+ });
719
+ }
720
+
721
+ const svc = defineService<S, {
722
+ beginAuth: (s: S) => S;
723
+ submit: (s: S) => Promise<S | undefined>;
724
+ load: (s: S, arg?: unknown) => Promise<S>;
725
+ refresh: (s: S, arg?: unknown) => Promise<S>;
726
+ setEmail: (s: S, arg?: unknown) => S;
727
+ setPassword: (s: S, arg?: unknown) => S;
728
+ setFirstName: (s: S, arg?: unknown) => S;
729
+ setLastName: (s: S, arg?: unknown) => S;
730
+ focusLastName: (s: S) => S;
731
+ focusEmail: (s: S) => S;
732
+ showRegister: (s: S) => S;
733
+ showSignIn: (s: S) => S;
734
+ showRecovery: (s: S) => S;
735
+ resetPassword: (s: S) => Promise<S>;
736
+ signIn: (s: S) => Promise<S | undefined>;
737
+ register: (s: S) => Promise<S | undefined>;
738
+ signOut: (s: S) => Promise<S>;
739
+ sync: (s: S) => Promise<S | undefined>;
740
+ openMember: (s: S, arg?: unknown) => Promise<S>;
741
+ leave: (s: S) => S;
742
+ restorePublish: (s: S) => S;
743
+ iapRestore: (s: S) => Promise<undefined>;
744
+ }>("himiStoreAccount", {
745
+ notHydrated: ["password", "authBusy", "emailError", "passwordError", "passwordFocus", "lastNameFocus", "emailFocus", ...VISIT_KEYS, ...VISIT_DERIVED_KEYS, ...IAP_RESTORE_KEYS],
746
+ state: derive({
747
+ status: "loading",
748
+ isMember: false,
749
+ notMember: true,
750
+ identityLine: "",
751
+ detailLine: "",
752
+ memberName: "",
753
+ memberEmail: "",
754
+ memberImage: "",
755
+ memberInitials: "M",
756
+ hasMemberImage: false,
757
+ noMemberImage: true,
758
+ authTitle: "Welcome",
759
+ authBody: "Sign in to continue — cart, bookings, RSVPs, and memberships stay with you.",
760
+ authIntent: "",
761
+ authContextName: "",
762
+ returnScreenId: "",
763
+ returnId: "",
764
+ returnChannelId: "",
765
+ hasReturnPath: false,
766
+ noReturnPath: true,
767
+ returnCta: "Continue",
768
+ email: "",
769
+ password: "",
770
+ authBusy: false, emailError: "", passwordError: "", passwordFocus: "",
771
+ firstName: "",
772
+ lastName: "",
773
+ lastNameFocus: "",
774
+ emailFocus: "",
775
+ message: "",
776
+ isLoading: true,
777
+ notLoading: false,
778
+ // The form's first paint is already on its route's mode: only sign-in's route has `mode`.
779
+ isRegistering: ROUTE_ASKS_REGISTER,
780
+ notRegistering: true,
781
+ isRecovering: false,
782
+ notRecovering: true,
783
+ hideSignInChoices: false,
784
+ activityTitle: "Previous orders",
785
+ activitySummary: "",
786
+ activityRows: [],
787
+ activityEmptyText: "No previous orders for this member yet.",
788
+ hasActivity: false,
789
+ noActivity: true,
790
+ memberAccountLinks: false,
791
+ noMemberAddresses: true,
792
+ noMemberWallet: true,
793
+ restorePurchases: false,
794
+ cartCount: 0,
795
+ greeting: "Hey, guest",
796
+ greetingFirstName: "",
797
+ hideGuest: true,
798
+ hideMember: true,
799
+ signInScreen: "",
800
+ standalone: false,
801
+ pollIdentity: false,
802
+ ...NO_IAP_RESTORE,
803
+ }),
804
+ actions: {
805
+ submit: (s): Promise<S | undefined> => s.isRecovering ? svc.resetPassword() : authenticate(s, s.isRegistering ? "register" : "signIn"),
806
+ beginAuth: (s) => derive({ ...s, authBusy: true, message: "", emailError: "", passwordError: "" }),
807
+ 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; },
808
+ 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; },
809
+ // Idle ticks return undefined, so an unchanged session costs no re-render.
810
+ sync: async (s): Promise<S | undefined> => {
811
+ if (authInFlight) return undefined;
812
+ const email = (await signedInEmail()).toLowerCase();
813
+ // An empty read during a poll is a failed read as often as a sign-out (a network blip,
814
+ // a 5xx): it never turns a member into a guest. Account's own Sign out, and the next
815
+ // appear refresh, handle a real sign-out.
816
+ if (!email && s.isMember) return undefined;
817
+ if (email === s.memberEmail.toLowerCase() && !!email === s.isMember) return undefined;
818
+ const r = await freshProbe(s);
819
+ return "next" in r ? r.next : undefined;
820
+ },
821
+ // A row to a member-only screen shows to a guest too. A member opens it; a guest signs
822
+ // in first, and the sign-in screen then opens the row's screen — as a modal, by closing
823
+ // and pushing it on this tab (openSignIn) — so Back returns here either way.
824
+ openMember: async (s, arg): Promise<S> => {
825
+ const screenId = strField(arg, "screenId");
826
+ if (!screenId) return s;
827
+ const member = s.isMember || !!await signedInEmail();
828
+ // From the state as it is now: a probe may have landed during the identity read.
829
+ const now = svc.getState();
830
+ if (member) return navigate(now, { screenId });
831
+ return openSignIn(now, { mode: "signin" }, { screenId });
832
+ },
833
+ // The standalone form's Continue: leaves the form the way it was opened.
834
+ leave: (s) => navigate(s, { presentation: leaveNav() }),
835
+ // Typing means any focus request was delivered: clear the tokens (safe while mounted, since
836
+ // bumpToken never repeats), so a visit rarely ends — and a pooled form rarely reopens —
837
+ // holding one.
838
+ // A setter given the value it already has is not an edit: a hidden form's secure field
839
+ // clears itself on unmount (setPassword("")), and counting that would drop the probe
840
+ // that is about to move a member off the form.
841
+ 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!) : "") : "" }); },
842
+ 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") : "" }); },
843
+ setFirstName: (s, arg) => { if (strOf(arg) === s.firstName) return s; editRevision++; return derive({ ...s, ...TYPED, firstName: strOf(arg), message: "" }); },
844
+ setLastName: (s, arg) => { if (strOf(arg) === s.lastName) return s; editRevision++; return derive({ ...s, ...TYPED, lastName: strOf(arg), message: "" }); },
845
+ // The register form's Next keys. Only first/last name carry one: they share a
846
+ // single visibility gate (notRegistering) so their target is always on screen.
847
+ // Email deliberately does NOT chain to password — see the comment on the
848
+ // descriptor: password is hidden while recovering, and submitLabel is static.
849
+ focusLastName: (s) => derive({ ...s, lastNameFocus: bumpToken(s.lastNameFocus) }),
850
+ focusEmail: (s) => derive({ ...s, emailFocus: bumpToken(s.emailFocus) }),
851
+ // A mode switch RE-LAYS-OUT this card, mounting and unmounting fields behind
852
+ // `hiddenWhen`. A re-mounted field treats its first seed as "focus me on open", so
853
+ // a carried-over token would drop the user into the MIDDLE of the form — leaving
854
+ // register and coming back would open focused on Last name, skipping First name.
855
+ // Only the tokens whose fields actually unmount here are cleared: see
856
+ // NO_FOCUS_MODE_SWITCH for why Email's is deliberately NOT among them.
857
+ showRegister: (s) => { if (authInFlight) return s; editRevision++; modeSwitches++; headingReady = true; return derive({ ...s, isRegistering: true, isRecovering: false, message: "", emailError: "", passwordError: "", ...NO_FOCUS_MODE_SWITCH }) },
858
+ showSignIn: (s) => { if (authInFlight) return s; editRevision++; modeSwitches++; headingReady = true; return derive({ ...s, isRegistering: false, isRecovering: false, message: "", emailError: "", passwordError: "", ...NO_FOCUS_MODE_SWITCH }) },
859
+ 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 }) },
860
+ resetPassword: async (s): Promise<S> => {
861
+ if (authInFlight) return s;
862
+ const checked = validateAuth(s, "reset");
863
+ if (checked.emailError) return checked;
864
+ authInFlight = true;
865
+ const revision = editRevision;
866
+ try {
867
+ await svc.beginAuth();
868
+ const raw = await dispatch("himi.wix.resetPassword", { email: s.email.trim() });
869
+ rejectResult(raw, "reset");
870
+ if (revision !== editRevision) return derive({ ...svc.getState(), authBusy: false });
871
+ return derive({ ...svc.getState(), authBusy: false, isRecovering: false,
872
+ message: copyParam("storeAccount.resetSent", "If that email is registered, a reset link is on its way.") });
873
+ } catch (error) {
874
+ return derive({ ...svc.getState(), authBusy: false,
875
+ message: revision === editRevision ? authFailure(error, "reset", translated).reason : "" });
876
+ } finally { authInFlight = false; }
877
+ },
878
+ signIn: (s) => authenticate(s, "signIn"),
879
+ register: (s) => authenticate(s, "register"),
880
+ signOut: async (s): Promise<S> => {
881
+ if (authInFlight) return svc.getState();
882
+ passwordDraft = ""; editRevision++;
883
+ routeInputConsumed = true;
884
+ try { await dispatch("himi.wix.signOut"); } catch { /* ignore */ }
885
+ // Do NOT re-probe via getMyData after logout — visitor-scoped my-data can
886
+ // still return a site nickname and resurrect isMember (BUG-3). Trust the
887
+ // cleared session and paint the guest form immediately.
888
+ const guest = guestProfile(copyParam("storeAccount.guestDetail", "Sign in to keep your cart and orders connected."));
889
+ const cleared = {
890
+ ...s,
891
+ ...guest,
892
+ status: "ready" as const,
893
+ isMember: false,
894
+ isRegistering: false,
895
+ isRecovering: false,
896
+ message: "",
897
+ password: "",
898
+ // Signing out re-mounts the whole login card (it hides behind isMember), so a
899
+ // carried token would auto-focus Email and pop the keyboard the instant the
900
+ // user logs out. This is the one transition that unmounts Email too, so unlike
901
+ // the mode switches it clears BOTH tokens.
902
+ ...NO_FOCUS_ALL,
903
+ activityRows: [] as AccountActivityRow[],
904
+ activityEmptyText: copyParam("storeAccount.activityEmptyGuest", "Sign in to see orders."),
905
+ authIntent: "",
906
+ authContextName: "",
907
+ returnScreenId: "",
908
+ returnId: "",
909
+ returnChannelId: "",
910
+ // The member's cart stays with the member; the new visitor session has its own.
911
+ cartCount: cartCountRequested ? await cartItemCount(0) : 0,
912
+ ...(s.pollIdentity ? { _poll: { ...IDENTITY_POLL } } : {}),
913
+ };
914
+ return derive(cleared);
915
+ },
916
+ restorePublish: (s) => derive(s),
917
+ iapRestore: async () => { await restore.run(); return undefined; },
918
+ },
919
+ });
920
+
921
+ 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"]);
922
+ // `boot` tells settle this is not a visit: the host drops this call's navigation, and the
923
+ // appear refresh does the visit's moves.
924
+ svc.load({ boot: "1" });
925
+ export { svc };