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