@wix/himalaya-cli 0.1614.0 → 0.1615.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 +1 -0
- package/package.json +2 -2
- package/template-icons/salon-spa.png +0 -0
- package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
- package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/salon-spa/README.md +53 -0
- package/templates/salon-spa/config.ts +272 -0
- package/templates/salon-spa/dev/index.ts +19893 -0
- package/templates/salon-spa/himalaya.content.json +49 -0
- package/templates/salon-spa/himalaya.template.json +14 -0
- package/templates/salon-spa/icon.png +0 -0
- package/templates/salon-spa/platforms.json +1 -0
- package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
- package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
- package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
- package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
- package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
- package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
- package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
- package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
- package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
- package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
- package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
- package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
- package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
- package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
- package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
- package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
- package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
- package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
- package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
- package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
- package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
- package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
- package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
- package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
- package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
- package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
- package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
- package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
- package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
- package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
- package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
- package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
- package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
- package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
- package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
- package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
- package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
- package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
- package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
- package/templates/salon-spa/tokens.json +341 -0
- package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
- 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 };
|