@wix/himalaya-cli 0.1622.0 → 0.1624.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/core/sdk/ui/src/index.d.ts +28 -2
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +18 -1
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/badges-v2/src/extensions.d.ts +42 -1
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/events/src/index.d.ts +8 -3
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/ui.mjs +15 -1
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-badges.mjs +13 -0
- package/dist/cli.mjs +3511 -2583
- package/dist/preview-assets.generated.bin +0 -0
- package/guides/component-reference.json +3 -1
- package/guides/component-reference.md +21 -2
- package/guides/designing-himi-screens.md +3 -1
- package/guides/himi-json-output.md +34 -0
- package/guides/one-app-to-himi-app.md +104 -0
- package/guides/sdk-reference.json +55 -5
- package/package.json +2 -2
- package/templates/branded-lite/tier5-src/_shared/access-gates-table.ts +8 -0
- package/templates/branded-lite/tier5-src/_shared/access-gates.ts +341 -0
- package/templates/branded-lite/tier5-src/himi-branded-lite-access-nav/index.ts +45 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// One App access gates: who may open a tab, decided in the app-level nav worker.
|
|
2
|
+
//
|
|
3
|
+
// A One App owner can lock a page (layout item) for members only, for holders of a badge, or for holders of a
|
|
4
|
+
// paid plan, and choose whether a viewer who may not enter sees a lock screen or no tab at all. The One App
|
|
5
|
+
// evaluates that per viewer on its server (`DisplayCriteria.getLayoutItemContentDisplayInfo`, `EnrichedViewer`)
|
|
6
|
+
// and draws the lock on the client. A migrated Himi app carries the same rules in `access-gates-table.ts`
|
|
7
|
+
// (written by `himi one-app access`) and this module answers them for the viewer.
|
|
8
|
+
//
|
|
9
|
+
// PRESENTATION ONLY. The data behind a gated tab stays protected by its own Wix API (a Visitor's call still
|
|
10
|
+
// gets a 401/403/428). The descriptor is served to every device, so static content in it is not a secret; the
|
|
11
|
+
// One App also sends a gated page's structure to the viewer it denies. This is the rule an owner set on the
|
|
12
|
+
// tab, kept, and nothing stronger.
|
|
13
|
+
//
|
|
14
|
+
// FAIL CLOSED. Every gate starts `pending` (locked, and hidden when it may be hidden) and opens only on
|
|
15
|
+
// positive proof. A read that fails leaves the member locked with the call to action for that rule. The state is
|
|
16
|
+
// declared `notPersisted` and `notHydrated` on the service, so a restored snapshot can never unlock a tab, and a
|
|
17
|
+
// new identity drops every gate back to `pending` before the next answer is read.
|
|
18
|
+
//
|
|
19
|
+
// ONE FLAG PER GATE, bound by the app: the tab bar binds `$service:brandedLiteNav.accessHidden.<key>` (with
|
|
20
|
+
// `hiddenUntilResolved`), and the gated screen's `FragmentSlot` binds `$app:brandedLiteNav.accessState.<key>` (a screen's
|
|
21
|
+
// own worker is not this one, so `$app:` reads the app-level nav worker), with the locked case as its default, so an unresolved value renders the lock and the page body is not mounted.
|
|
22
|
+
//
|
|
23
|
+
// Shared by the two app-level nav workers (`himi-branded-lite-nav`, `himi-branded-lite-header-badges`), which
|
|
24
|
+
// name the same service. With an empty table nothing here reads anything.
|
|
25
|
+
import { hostSessionFromGlobal } from "@himalaya/iap";
|
|
26
|
+
import { activePlanIds } from "@himalaya/checkout";
|
|
27
|
+
import { queryBadgeAssignments } from "@himalaya/wix-badges";
|
|
28
|
+
import { getMyMember } from "@himalaya/wix-members";
|
|
29
|
+
import { listPublicPlans } from "@himalaya/wix-pricing-plans";
|
|
30
|
+
import { ACCESS_GATES } from "./access-gates-table.js";
|
|
31
|
+
|
|
32
|
+
// ───────────────────────────── the table ─────────────────────────────
|
|
33
|
+
|
|
34
|
+
/** What the owner required, as the One App stores it on the layout item. */
|
|
35
|
+
export type AccessRule =
|
|
36
|
+
| { kind: "membersOnly" }
|
|
37
|
+
| { kind: "badges"; ids: string[] }
|
|
38
|
+
| { kind: "paidPlans"; ids: string[] };
|
|
39
|
+
|
|
40
|
+
export interface AccessGate {
|
|
41
|
+
/** Flag-safe id (`[a-z0-9_]`), the key under `accessState` and `accessHidden`. */
|
|
42
|
+
key: string;
|
|
43
|
+
/** The One App layout item the rule was authored on. */
|
|
44
|
+
layoutItemId: string;
|
|
45
|
+
/** The Himi screen whose body is gated. */
|
|
46
|
+
screenId: string;
|
|
47
|
+
/** The page's title, for the lock screen. */
|
|
48
|
+
title: string;
|
|
49
|
+
rule: AccessRule;
|
|
50
|
+
/** `accessScreenSettings.screenVisible`: `locked` shows a lock screen, `hidden` removes the tab. */
|
|
51
|
+
mode: "locked" | "hidden";
|
|
52
|
+
/** `accessScreenSettings.membersOnly`: only a signed-in member may see the lock; a Visitor gets no tab. */
|
|
53
|
+
hideFromVisitors: boolean;
|
|
54
|
+
/** The owner's lock message and button label, or "" for the stock copy. */
|
|
55
|
+
message: string;
|
|
56
|
+
ctaTitle: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The lock screen a gated screen shows, and the one `pending` that renders until the first answer lands. */
|
|
60
|
+
export type AccessCase = "pending" | "open" | "signIn" | "plans" | "contact";
|
|
61
|
+
|
|
62
|
+
export interface AccessOutcome {
|
|
63
|
+
state: AccessCase;
|
|
64
|
+
/** The tab should not be listed at all. */
|
|
65
|
+
hidden: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Everything the rules ask about the viewer. */
|
|
69
|
+
export interface AccessViewer {
|
|
70
|
+
member: boolean;
|
|
71
|
+
badgeIds: ReadonlySet<string>;
|
|
72
|
+
/** Plans the member holds an active order for. */
|
|
73
|
+
planIds: ReadonlySet<string>;
|
|
74
|
+
/** Plans the site offers (public, not archived): a lock can only offer a plan that can still be bought. */
|
|
75
|
+
publicPlanIds: ReadonlySet<string>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const anyIn = (ids: readonly string[], held: ReadonlySet<string>): boolean => ids.some((id) => held.has(id));
|
|
79
|
+
|
|
80
|
+
/** The plan ids a gate asks for; none for a rule of another kind. */
|
|
81
|
+
const requiredPlanIds = (gate: AccessGate): readonly string[] => gate.rule.kind === "paidPlans" ? gate.rule.ids : [];
|
|
82
|
+
|
|
83
|
+
/** Whether a Visitor or a member who may not enter should still see the tab (as a lock). */
|
|
84
|
+
function listed(gate: AccessGate, visitor: boolean): boolean {
|
|
85
|
+
if (gate.mode === "hidden") return false;
|
|
86
|
+
return !(visitor && gate.hideFromVisitors);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The One App's rule for one gate. Mirrors `DisplayCriteria.getLayoutItemContentDisplayInfo` and
|
|
91
|
+
* `EnrichedViewer.filterLayoutItems`:
|
|
92
|
+
* - a Visitor is blocked by every rule kind, and always gets the members-only lock (`signIn`);
|
|
93
|
+
* - a member holds the badge (one of its ids), holds an active plan (one of its ids), or passes a members-only rule;
|
|
94
|
+
* - a blocked member gets the lock for the rule's own kind: a badge asks to contact the business, a plan offers the
|
|
95
|
+
* plans, or asks to contact the business when none of its plans is on offer any more (hidden or archived), as the
|
|
96
|
+
* One App does (`LayoutItem.ts` `onPermissionZeroPlansClick`).
|
|
97
|
+
* There is no owner or admin bypass: an owner uses the owner app.
|
|
98
|
+
*/
|
|
99
|
+
export function decide(gate: AccessGate, viewer: AccessViewer): AccessOutcome {
|
|
100
|
+
if (!viewer.member) return { state: "signIn", hidden: !listed(gate, true) };
|
|
101
|
+
switch (gate.rule.kind) {
|
|
102
|
+
case "membersOnly":
|
|
103
|
+
return { state: "open", hidden: false };
|
|
104
|
+
case "badges":
|
|
105
|
+
return anyIn(gate.rule.ids, viewer.badgeIds) ? { state: "open", hidden: false } : { state: "contact", hidden: !listed(gate, false) };
|
|
106
|
+
case "paidPlans":
|
|
107
|
+
if (anyIn(gate.rule.ids, viewer.planIds)) return { state: "open", hidden: false };
|
|
108
|
+
return { state: anyIn(gate.rule.ids, viewer.publicPlanIds) ? "plans" : "contact", hidden: !listed(gate, false) };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Before any answer: locked, and hidden for every gate that some viewer would not see listed. */
|
|
113
|
+
export function pendingOutcome(gate: AccessGate): AccessOutcome {
|
|
114
|
+
return { state: "pending", hidden: gate.mode === "hidden" || gate.hideFromVisitors };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ───────────────────────────── the state ─────────────────────────────
|
|
118
|
+
|
|
119
|
+
export interface AccessGateState {
|
|
120
|
+
/** `AccessCase` per gate key. Bound by the gated screen's `FragmentSlot`. */
|
|
121
|
+
accessState: Record<string, AccessCase>;
|
|
122
|
+
/** True per gate key when the tab is not listed. Bound by the tab bar, fail-closed. */
|
|
123
|
+
accessHidden: Record<string, boolean>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const ACCESS_STATE_KEYS: ("accessState" | "accessHidden")[] = ["accessState", "accessHidden"];
|
|
127
|
+
|
|
128
|
+
export function pendingAccessState(gates: readonly AccessGate[]): AccessGateState {
|
|
129
|
+
const accessState: Record<string, AccessCase> = {};
|
|
130
|
+
const accessHidden: Record<string, boolean> = {};
|
|
131
|
+
for (const gate of gates) {
|
|
132
|
+
const outcome = pendingOutcome(gate);
|
|
133
|
+
accessState[gate.key] = outcome.state;
|
|
134
|
+
accessHidden[gate.key] = outcome.hidden;
|
|
135
|
+
}
|
|
136
|
+
return { accessState, accessHidden };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The initial service state: every gate pending. Empty for an app with no gates. */
|
|
140
|
+
export const ACCESS_GATES_INITIAL: AccessGateState = pendingAccessState(ACCESS_GATES);
|
|
141
|
+
|
|
142
|
+
/** The action signatures a service that uses `accessGates` declares in its `defineService` generic. */
|
|
143
|
+
// A `type` for the same reason as `HeaderBadgeActionSignatures`: `defineService` wants an index signature.
|
|
144
|
+
export type AccessGateActionSignatures<S> = {
|
|
145
|
+
/** Re-read who the viewer is and what they hold, then re-decide every gate. Only the newest read lands. */
|
|
146
|
+
refreshAccess: (s: S) => Promise<S>;
|
|
147
|
+
/** Put every gate back to pending (a new identity), committed at once so nothing stale stays open. */
|
|
148
|
+
dropAccess: (s: S) => Promise<S>;
|
|
149
|
+
/** Reserved lifecycle hooks the app-service host delivers to every service that declares them. */
|
|
150
|
+
onForeground: (s: S) => Promise<S>;
|
|
151
|
+
onIdentityChange: (s: S) => Promise<S>;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export interface AccessGates<S> {
|
|
155
|
+
actions: AccessGateActionSignatures<S>;
|
|
156
|
+
/** Call once after `defineService`: the launch read, and the session clock's notifications. */
|
|
157
|
+
start(): void;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
type Update<S> = (s: S, change: (current: S) => Promise<S>) => Promise<S>;
|
|
161
|
+
type Self = () => { refreshAccess(): Promise<unknown>; dropAccess(): Promise<unknown> };
|
|
162
|
+
|
|
163
|
+
const sameJson = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
|
|
164
|
+
|
|
165
|
+
/** The plans the site offers: public and not archived (the public list already leaves archived plans out). */
|
|
166
|
+
async function readPublicPlanIds(): Promise<Set<string>> {
|
|
167
|
+
const res = await listPublicPlans();
|
|
168
|
+
return new Set((res.plans ?? []).filter((plan) => plan.id && !plan.archived).map((plan) => plan.id));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The bridge's refusal of a viewer who is not a member: `HTTP 401` or `HTTP 403` in its rejection text (which a caller
|
|
172
|
+
* may prefix). A Visitor gets exactly `403 PERMISSION_DENIED` from `/members/my` (observed live). Any other status
|
|
173
|
+
* (a 408, a 429, a 5xx) or a network failure says nothing about who the viewer is, so it must not decide anyone as a Visitor. */
|
|
174
|
+
function isRefusal(error: unknown): boolean {
|
|
175
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
176
|
+
const at = text.indexOf("HTTP ");
|
|
177
|
+
if (at < 0) return false;
|
|
178
|
+
const status = Number(text.slice(at + 5, at + 8));
|
|
179
|
+
return status === 401 || status === 403;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Most pages of badge assignments one member can need (100 per page). A member past this is not realistic. */
|
|
183
|
+
const MAX_BADGE_PAGES = 20;
|
|
184
|
+
|
|
185
|
+
/** The badge ids a member holds. A read that cannot finish rejects, so the caller keeps the member locked. */
|
|
186
|
+
async function readBadgeIds(memberId: string): Promise<Set<string>> {
|
|
187
|
+
const held = new Set<string>();
|
|
188
|
+
const seen = new Set<string>();
|
|
189
|
+
let cursor: string | undefined;
|
|
190
|
+
for (let page = 0; page < MAX_BADGE_PAGES; page++) {
|
|
191
|
+
// The cursor encodes the filter: a Wix query refuses a filter sent beside a cursor, so only the first page carries it.
|
|
192
|
+
const res = await queryBadgeAssignments({
|
|
193
|
+
query: cursor ? { cursorPaging: { limit: 100, cursor } } : { filter: { memberId: { $in: [memberId] } }, cursorPaging: { limit: 100 } },
|
|
194
|
+
});
|
|
195
|
+
for (const assignment of res.badgeAssignments ?? []) if (assignment.badgeId) held.add(assignment.badgeId);
|
|
196
|
+
const next = res.pagingMetadata?.cursors?.next;
|
|
197
|
+
if (!res.pagingMetadata?.hasNext) return held;
|
|
198
|
+
// More pages were promised but no cursor to reach them: a truncated answer, not a complete one. It must fail the read (the
|
|
199
|
+
// member stays locked, or an already-open page stays open) instead of passing a partial set off as everything.
|
|
200
|
+
if (!next) throw new Error("Badge assignment page said more were available but gave no cursor.");
|
|
201
|
+
if (seen.has(next)) throw new Error("Badge assignment pagination did not advance.");
|
|
202
|
+
seen.add(next);
|
|
203
|
+
cursor = next;
|
|
204
|
+
}
|
|
205
|
+
throw new Error("A member holds more badge assignments than one read pages.");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* @param gates the app's table (a test passes its own)
|
|
210
|
+
* @param update serializes state changes for the worker, so overlapping actions build on the preceding result
|
|
211
|
+
* @param self the worker's own service handle (read lazily: it does not exist yet when this is called)
|
|
212
|
+
*/
|
|
213
|
+
export function accessGates<S extends AccessGateState>(update: Update<S>, self: Self, gates: readonly AccessGate[] = ACCESS_GATES): AccessGates<S> {
|
|
214
|
+
/** Which read is the newest: an older one that finishes later must not overwrite a newer answer. */
|
|
215
|
+
let read = 0;
|
|
216
|
+
/** The host session generation the gates on show were decided under. */
|
|
217
|
+
let generation = -1;
|
|
218
|
+
const session = hostSessionFromGlobal();
|
|
219
|
+
const needsBadges = gates.some((gate) => gate.rule.kind === "badges");
|
|
220
|
+
const needsPlans = gates.some((gate) => gate.rule.kind === "paidPlans");
|
|
221
|
+
|
|
222
|
+
/** The signed-in member's id, `null` for a Visitor, `undefined` when it could not be told (a failed read that is
|
|
223
|
+
* not a refusal, such as being offline). A host that fences calls to the member publishes the id with its
|
|
224
|
+
* session clock; any other host (web) answers it from the member record, which a Visitor cannot read. */
|
|
225
|
+
async function memberIdOf(): Promise<string | null | undefined> {
|
|
226
|
+
if (session.identityBound) {
|
|
227
|
+
const snapshot = session.current();
|
|
228
|
+
if (snapshot.kind !== "member") return null;
|
|
229
|
+
const id = session.identity(snapshot)?.memberId;
|
|
230
|
+
if (id) return id;
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
return (await getMyMember({ fieldsets: ["PUBLIC"] })).member?.id || null;
|
|
234
|
+
} catch (error) {
|
|
235
|
+
return isRefusal(error) ? null : undefined;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const pending = (current: S): S => ({ ...current, ...pendingAccessState(gates) });
|
|
240
|
+
|
|
241
|
+
const actions: AccessGateActionSignatures<S> = {
|
|
242
|
+
refreshAccess: async (s) => {
|
|
243
|
+
if (!gates.length) return s;
|
|
244
|
+
const mine = ++read;
|
|
245
|
+
const startedUnder = session.current().generation;
|
|
246
|
+
const memberId = await memberIdOf();
|
|
247
|
+
let badgeIds = new Set<string>();
|
|
248
|
+
let planIds = new Set<string>();
|
|
249
|
+
let publicPlanIds = new Set<string>();
|
|
250
|
+
let badgesFailed = false;
|
|
251
|
+
let plansFailed = false;
|
|
252
|
+
if (memberId) {
|
|
253
|
+
const [badges, plans] = await Promise.all([
|
|
254
|
+
needsBadges ? readBadgeIds(memberId).catch(() => { badgesFailed = true; return new Set<string>(); }) : Promise.resolve(new Set<string>()),
|
|
255
|
+
needsPlans ? activePlanIds().then((ids) => new Set(ids)).catch(() => { plansFailed = true; return new Set<string>(); }) : Promise.resolve(new Set<string>()),
|
|
256
|
+
]);
|
|
257
|
+
badgeIds = badges;
|
|
258
|
+
planIds = plans;
|
|
259
|
+
// Which plans can still be bought matters only to a member a plan gate blocks: read it then, not for everyone.
|
|
260
|
+
const required = gates.map(requiredPlanIds);
|
|
261
|
+
if (plansFailed) {
|
|
262
|
+
// What the member holds is unknown: offer every required plan, the plans list is the useful action.
|
|
263
|
+
publicPlanIds = new Set(required.flat());
|
|
264
|
+
} else if (needsPlans && required.some((ids) => ids.length > 0 && !anyIn(ids, planIds))) {
|
|
265
|
+
// A read of the offer that fails offers every required plan too, and the plans list reads for itself.
|
|
266
|
+
publicPlanIds = await readPublicPlanIds().catch(() => new Set(required.flat()));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const viewer: AccessViewer = { member: memberId !== null && memberId !== undefined, badgeIds, planIds, publicPlanIds };
|
|
270
|
+
return update(s, async (current) => {
|
|
271
|
+
// Drop an answer read for another read or another identity: the newer one decides.
|
|
272
|
+
if (mine !== read || session.current().generation !== startedUnder) return current;
|
|
273
|
+
// Who the viewer is could not be told: change nothing (pending stays pending, so locked).
|
|
274
|
+
if (memberId === undefined) return current;
|
|
275
|
+
const accessState: Record<string, AccessCase> = {};
|
|
276
|
+
const accessHidden: Record<string, boolean> = {};
|
|
277
|
+
for (const gate of gates) {
|
|
278
|
+
const readFailed = (gate.rule.kind === "badges" && badgesFailed) || (gate.rule.kind === "paidPlans" && plansFailed);
|
|
279
|
+
// A failed read must not lock out a member already shown the page (a blip while offline). Otherwise the
|
|
280
|
+
// member is treated as not holding the badge or plan: locked, with that rule's call to action.
|
|
281
|
+
if (readFailed && current.accessState[gate.key] === "open") {
|
|
282
|
+
accessState[gate.key] = "open";
|
|
283
|
+
accessHidden[gate.key] = false;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const outcome = decide(gate, viewer);
|
|
287
|
+
accessState[gate.key] = outcome.state;
|
|
288
|
+
accessHidden[gate.key] = outcome.hidden;
|
|
289
|
+
}
|
|
290
|
+
// Publish only a change: an unchanged answer reaches no view (and costs the worker nothing).
|
|
291
|
+
if (sameJson(current.accessState, accessState) && sameJson(current.accessHidden, accessHidden)) return current;
|
|
292
|
+
return { ...current, accessState, accessHidden };
|
|
293
|
+
});
|
|
294
|
+
},
|
|
295
|
+
dropAccess: async (s) => {
|
|
296
|
+
++read; // a read already in flight belongs to the previous identity
|
|
297
|
+
return update(s, async (current) => pending(current));
|
|
298
|
+
},
|
|
299
|
+
onForeground: async (s) => update(s, async (current) => current).finally(() => { void self().refreshAccess(); }),
|
|
300
|
+
onIdentityChange: async (s) => {
|
|
301
|
+
++read;
|
|
302
|
+
const dropped = await update(s, async (current) => pending(current));
|
|
303
|
+
void self().refreshAccess();
|
|
304
|
+
return dropped;
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
/** A session notification: a new generation is a new identity, so the previous one's gates close first. */
|
|
309
|
+
async function onSession(): Promise<void> {
|
|
310
|
+
const now = session.current().generation;
|
|
311
|
+
if (now !== generation) {
|
|
312
|
+
generation = now;
|
|
313
|
+
await self().dropAccess();
|
|
314
|
+
}
|
|
315
|
+
await self().refreshAccess();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
actions,
|
|
320
|
+
start(): void {
|
|
321
|
+
if (!gates.length) return;
|
|
322
|
+
// Subscribing notifies at once, which is the launch read; a host with no clock needs the explicit one.
|
|
323
|
+
let notified = false;
|
|
324
|
+
session.subscribe(() => { notified = true; void onSession(); });
|
|
325
|
+
if (!notified) void self().refreshAccess();
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The reserved lifecycle hooks of a service that serves more than one piece (header badges, access gates): the host
|
|
332
|
+
* calls one `onForeground` and one `onIdentityChange`, so each piece's hook runs in turn.
|
|
333
|
+
*/
|
|
334
|
+
export function chainLifecycle<S>(
|
|
335
|
+
...pieces: Array<{ onForeground: (s: S) => Promise<S>; onIdentityChange: (s: S) => Promise<S> }>
|
|
336
|
+
): { onForeground: (s: S) => Promise<S>; onIdentityChange: (s: S) => Promise<S> } {
|
|
337
|
+
return {
|
|
338
|
+
onForeground: async (s) => { let next = s; for (const piece of pieces) next = await piece.onForeground(next); return next; },
|
|
339
|
+
onIdentityChange: async (s) => { let next = s; for (const piece of pieces) next = await piece.onIdentityChange(next); return next; },
|
|
340
|
+
};
|
|
341
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The app-level nav worker (`navigation.navLogicBundle`) of a migrated One App that keeps locked pages: the live counts
|
|
2
|
+
// behind the header's bell, chat and cart buttons (the same piece as `himi-branded-lite-header-badges`), plus the page
|
|
3
|
+
// access gates (members only, badge, paid plan) the source's owner set (`_shared/access-gates.ts`).
|
|
4
|
+
//
|
|
5
|
+
// It is a separate bundle on purpose. The gates read the member, their badges and their plans, which an app that locks
|
|
6
|
+
// no page has no use for, and a worker's source is parsed at every boot. `himi one-app access` points an app's
|
|
7
|
+
// `navLogicBundle` here only when the source locks a page; every other app keeps the badges-only worker, unchanged.
|
|
8
|
+
//
|
|
9
|
+
// The service is named `brandedLiteNav`, like the full worker's, so a top-bar action binds the same
|
|
10
|
+
// `$service:brandedLiteNav.<field>` path and the tab bar and gated screens bind `accessHidden` and `accessState`
|
|
11
|
+
// under it. A scaffold ships this source INERT: nothing loads it until the app's config names it.
|
|
12
|
+
|
|
13
|
+
import { defineService } from "@himalaya/state";
|
|
14
|
+
import { HEADER_BADGE_KEYS, headerBadges, type HeaderBadgeActionSignatures, type HeaderBadges, type HeaderBadgeState } from "../_shared/header-badges.js";
|
|
15
|
+
import { ACCESS_GATES_INITIAL, ACCESS_STATE_KEYS, accessGates, chainLifecycle, type AccessGateActionSignatures, type AccessGates, type AccessGateState } from "../_shared/access-gates.js";
|
|
16
|
+
|
|
17
|
+
type State = HeaderBadgeState & AccessGateState;
|
|
18
|
+
|
|
19
|
+
// State changes await native work; overlapping actions must use the preceding result.
|
|
20
|
+
let stateQueue: Promise<State> | undefined;
|
|
21
|
+
function updateInOrder(s: State, update: (current: State) => Promise<State>): Promise<State> {
|
|
22
|
+
const previous = stateQueue ?? Promise.resolve(s);
|
|
23
|
+
const result = previous.then(update);
|
|
24
|
+
stateQueue = result.catch(() => previous);
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const badges: HeaderBadges<State> = headerBadges<State>(updateInOrder, () => svc);
|
|
29
|
+
const access: AccessGates<State> = accessGates<State>(updateInOrder, () => svc);
|
|
30
|
+
|
|
31
|
+
const svc = defineService<State, HeaderBadgeActionSignatures<State> & AccessGateActionSignatures<State>>("brandedLiteNav", {
|
|
32
|
+
// Every count unavailable until the first read lands, and no failure to report. Every gate pending (locked).
|
|
33
|
+
state: { notificationsBadge: null, chatBadge: null, cartBadge: null, errorText: "", ...ACCESS_GATES_INITIAL },
|
|
34
|
+
// The counts and the gates describe THIS run (see _shared/header-badges.ts, _shared/access-gates.ts): never stored,
|
|
35
|
+
// never restored, so a snapshot can neither show another day's numbers nor keep a tab open.
|
|
36
|
+
notPersisted: [...HEADER_BADGE_KEYS, ...ACCESS_STATE_KEYS],
|
|
37
|
+
notHydrated: [...HEADER_BADGE_KEYS, ...ACCESS_STATE_KEYS],
|
|
38
|
+
// The host calls one `onForeground` and one `onIdentityChange`: each piece runs in turn.
|
|
39
|
+
actions: { ...badges.actions, ...access.actions, ...chainLifecycle<State>(badges.actions, access.actions) },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export { svc };
|
|
43
|
+
|
|
44
|
+
badges.start();
|
|
45
|
+
access.start();
|