@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.
- package/dist/cli.mjs +95 -37
- package/dist/preview-assets.generated.bin +0 -0
- package/guides/himi-mcp.md +21 -11
- package/guides/skill.md +1 -1
- package/guides/trim-a-template.md +4 -1
- package/package.json +2 -2
- package/templates/beauty-salon/config.ts +14 -0
- package/templates/beauty-salon/dev/index.ts +1385 -1382
- package/templates/beauty-salon/himalaya.content.json +3 -0
- package/templates/beauty-salon/tier5-src/himi-store-account/index.ts +322 -27
- package/templates/branded-lite/config.ts +14 -0
- package/templates/branded-lite/dev/index.ts +1479 -1511
- package/templates/branded-lite/himalaya.content.json +3 -0
- package/templates/branded-lite/tier5-src/himi-store-account/index.ts +322 -27
- package/templates/community-club/config.ts +14 -0
- package/templates/community-club/dev/index.ts +1253 -1068
- package/templates/community-club/himalaya.content.json +3 -0
- package/templates/community-club/tier5-src/himi-store-account/index.ts +322 -27
- package/templates/events-meetups/config.ts +14 -0
- package/templates/events-meetups/dev/index.ts +1222 -1037
- package/templates/events-meetups/himalaya.content.json +3 -0
- package/templates/events-meetups/tier5-src/himi-store-account/index.ts +322 -27
- package/templates/gym-fitness/config.ts +14 -0
- package/templates/gym-fitness/dev/index.ts +1296 -1300
- package/templates/gym-fitness/himalaya.content.json +3 -0
- package/templates/gym-fitness/tier5-src/himi-store-account/index.ts +322 -27
- package/templates/nonprofit-cause/config.ts +14 -0
- package/templates/nonprofit-cause/dev/index.ts +1276 -1193
- package/templates/nonprofit-cause/himalaya.content.json +3 -0
- package/templates/nonprofit-cause/tier5-src/himi-store-account/index.ts +322 -27
- package/templates/podcast-creator/config.ts +14 -0
- package/templates/podcast-creator/dev/index.ts +1271 -1188
- package/templates/podcast-creator/himalaya.content.json +3 -0
- package/templates/podcast-creator/tier5-src/himi-store-account/index.ts +322 -27
- package/templates/portfolio-creative/config.ts +14 -0
- package/templates/portfolio-creative/dev/index.ts +1371 -1273
- package/templates/portfolio-creative/himalaya.content.json +3 -0
- package/templates/portfolio-creative/tier5-src/himi-store-account/index.ts +322 -27
- package/templates/restaurant/config.ts +14 -0
- package/templates/restaurant/dev/index.ts +1334 -1168
- package/templates/restaurant/himalaya.content.json +3 -0
- package/templates/restaurant/tier5-src/himi-store-account/index.ts +322 -27
- package/templates/retail-store/config.ts +14 -0
- package/templates/retail-store/dev/index.ts +1399 -1294
- package/templates/retail-store/himalaya.content.json +3 -0
- package/templates/retail-store/tier5-src/himi-store-account/index.ts +322 -27
- package/templates/yoga-studio/config.ts +14 -0
- package/templates/yoga-studio/dev/index.ts +1367 -1291
- package/templates/yoga-studio/himalaya.content.json +3 -0
- package/templates/yoga-studio/tier5-src/himi-store-account/index.ts +322 -27
|
@@ -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
|
|
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">):
|
|
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
|
|
84
|
+
if (!next.isMember) return next;
|
|
85
|
+
if (!target) return next.standalone ? navigate(next, { presentation: "back" }) : next;
|
|
72
86
|
routeInputConsumed = true;
|
|
73
|
-
|
|
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:
|
|
126
|
-
//
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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> => {
|
|
479
|
-
refresh: async (s, arg): Promise<S> => {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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 };
|
|
@@ -261,6 +261,20 @@ export default defineApp({
|
|
|
261
261
|
],
|
|
262
262
|
"parentScreenId": "",
|
|
263
263
|
"tabId": "profile"
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
"screenId": "himi_sign_in",
|
|
267
|
+
"isPublic": true,
|
|
268
|
+
"paramKeys": [
|
|
269
|
+
"mode",
|
|
270
|
+
"intent",
|
|
271
|
+
"contextName",
|
|
272
|
+
"returnScreenId",
|
|
273
|
+
"returnId",
|
|
274
|
+
"returnChannelId"
|
|
275
|
+
],
|
|
276
|
+
"parentScreenId": "himi_account",
|
|
277
|
+
"tabId": "profile"
|
|
264
278
|
}
|
|
265
279
|
],
|
|
266
280
|
"tabBar": {
|