@wix/himalaya-cli 0.1613.0 → 0.1614.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 +53 -13
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +3 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/ui.mjs +31 -5
- package/dist/cli.mjs +1637 -1330
- package/dist/preview-assets.generated.bin +0 -0
- package/fixtures/wix-canned.json +4 -0
- package/guides/component-reference.json +1 -1
- package/guides/component-reference.md +3 -0
- package/guides/designing-himi-screens.md +4 -1
- package/guides/hhp.md +2 -2
- package/guides/sdk-reference.json +28 -4
- package/package.json +2 -2
- package/templates/beauty-salon/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/branded-lite/dev/index.ts +192 -0
- package/templates/branded-lite/l10n/en.json +1 -1
- package/templates/branded-lite/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/community-club/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/events-meetups/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/gym-fitness/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/nonprofit-cause/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/podcast-creator/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/portfolio-creative/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/restaurant/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/retail-store/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/yoga-studio/dev/index.ts +192 -0
- package/templates/yoga-studio/tier5-src/himi-branded-lite-home/index.ts +178 -12
|
Binary file
|
package/fixtures/wix-canned.json
CHANGED
|
@@ -625,6 +625,10 @@
|
|
|
625
625
|
"total": 2
|
|
626
626
|
}
|
|
627
627
|
},
|
|
628
|
+
"POST /online-programs/participants/v3/join-applications/query": {
|
|
629
|
+
"joinApplications": [],
|
|
630
|
+
"pagingMetadata": { "count": 0, "total": 0 }
|
|
631
|
+
},
|
|
628
632
|
"GET /pricing-plans/v2/plans/public": {
|
|
629
633
|
"plans": [
|
|
630
634
|
{
|
|
@@ -565,7 +565,7 @@
|
|
|
565
565
|
"category": "Feedback",
|
|
566
566
|
"propsType": "SnackbarProps",
|
|
567
567
|
"bindings": [],
|
|
568
|
-
"propsSource": "export interface SnackbarProps {\n message: string;\n action?: Action;\n durationMs?: number;\n severity?: Severity;\n /** #1059 — action label; overridable for localized hosts. Default \"Take action\". */\n actionLabel?: string;\n}"
|
|
568
|
+
"propsSource": "export interface SnackbarProps {\n message: string;\n action?: Action;\n durationMs?: number;\n severity?: Severity;\n /** #1059 — action label; overridable for localized hosts. Default \"Take action\". */\n actionLabel?: string;\n /** #4661 — a 40 pt/dp thumbnail at the leading edge (`https:` or a local file/asset URL; any\n * other is ignored), drawn by the same presenter as a worker's `ui.snackbar({ imageUrl })`. */\n imageUrl?: string;\n}"
|
|
569
569
|
},
|
|
570
570
|
{
|
|
571
571
|
"name": "AlertDialog",
|
|
@@ -1200,6 +1200,9 @@ export interface SnackbarProps {
|
|
|
1200
1200
|
severity?: Severity;
|
|
1201
1201
|
/** #1059 — action label; overridable for localized hosts. Default "Take action". */
|
|
1202
1202
|
actionLabel?: string;
|
|
1203
|
+
/** #4661 — a 40 pt/dp thumbnail at the leading edge (`https:` or a local file/asset URL; any
|
|
1204
|
+
* other is ignored), drawn by the same presenter as a worker's `ui.snackbar({ imageUrl })`. */
|
|
1205
|
+
imageUrl?: string;
|
|
1203
1206
|
}
|
|
1204
1207
|
```
|
|
1205
1208
|
|
|
@@ -307,7 +307,10 @@ contextual actions/detail; `Tooltip`/`Popover` for a small anchored hint. *Reach
|
|
|
307
307
|
inline where you place it; for a transient toast that floats at the bottom of the screen — an app
|
|
308
308
|
error after a failed action, say — use the `snackbar` presentation verb or `ui.snackbar({ message,
|
|
309
309
|
tone: "danger" })` from a worker: it floats above the tab bar, animates in and out, queues, honours
|
|
310
|
-
`durationMs`, and is announced to screen readers on every platform.
|
|
310
|
+
`durationMs`, and is announced to screen readers on every platform. It stays a toast: two lines of
|
|
311
|
+
message, one optional 40 pt thumbnail (`imageUrl`), one action of up to 24 characters, 2–10 s on screen.
|
|
312
|
+
For "Item deleted — Undo", a screen worker can `await` it and read `actionTapped` instead of wiring an
|
|
313
|
+
action id; anything richer is a descriptor overlay, not a snackbar.
|
|
311
314
|
|
|
312
315
|
**Status & data-viz** — `Badge`/`Tag`/`Chip` for compact labels; `Rating` for stars (`size: "sm"` in a
|
|
313
316
|
dense row); `ProgressBar` for determinate progress, `ActivityIndicator` for indeterminate; `Gauge` for
|
package/guides/hhp.md
CHANGED
|
@@ -210,13 +210,13 @@ Declare the mockup each screen was designed from in `hhp.json`:
|
|
|
210
210
|
A screen may also carry `params` (the navigation input a device opens it with, used by native capture, #4935) and `fidelity: { "warn": 80, "fail": 60 }`. Thresholds can also be set app-wide at the top level.
|
|
211
211
|
|
|
212
212
|
```bash
|
|
213
|
-
himi fidelity --content <dir> [--screen a,b] [--variant light,dark,rtl,tablet] [--ios <udid>] [--android <serial>] [--ios-tablet <udid>] [--android-tablet <serial>] [--app-id <id>] [--release <id>] [--no-native] [--json] [--post-pr <n>]
|
|
213
|
+
himi fidelity --content <dir> [--screen a,b] [--variant light,dark,rtl,tablet] [--ios <udid>] [--android <serial>] [--ios-tablet <udid>] [--android-tablet <serial>] [--app-id <id>] [--release <id>] [--serve <port>] [--no-native] [--json] [--post-pr <n>]
|
|
214
214
|
```
|
|
215
215
|
|
|
216
216
|
For each screen and variant (`light`, `dark`, `rtl`, `tablet`), the command does four things:
|
|
217
217
|
|
|
218
218
|
1. **Captures the mockup.** It draws the page in Chromium at the variant's width. Dark mode is drawn by emulating `prefers-color-scheme`. RTL is drawn by setting `dir=rtl` on the page.
|
|
219
|
-
2. **Captures the web render** from the app's recorded data, as `himi render` does by default. Native frames come from Himi Player on the devices you pass: `--ios <udid>` and `--android <serial>`, plus `--ios-tablet` and `--android-tablet` for the tablet variant. Only those devices are touched. For each variant the command sets dark or light appearance and, for `rtl`, the Hebrew language (iOS `AppleLanguages`, Android per-app locale, API 33+). It opens `himi://app/<app>/screen/<screen>` and captures once two frames match. Every setting it changed is restored afterwards, also on Ctrl-C. A variant with no device is reported `skipped`, never passed. Without `--release <id>` the Player shows the app's **live** release, not your working copy. A screen that declares `params` is skipped natively for now, because the Player's deep link does not carry them yet. On a fresh iOS simulator, open one `himi://` link by hand first and accept iOS's "Open in Himi Player?" prompt: the command cannot answer it, and until it is accepted every iOS capture shows the prompt.
|
|
219
|
+
2. **Captures the web render** from the app's recorded data, as `himi render` does by default. Native frames come from Himi Player on the devices you pass: `--ios <udid>` and `--android <serial>`, plus `--ios-tablet` and `--android-tablet` for the tablet variant. Only those devices are touched. For each variant the command sets dark or light appearance and, for `rtl`, the Hebrew language (iOS `AppleLanguages`, Android per-app locale, API 33+). It opens `himi://app/<app>/screen/<screen>` and captures once two frames match. Every setting it changed is restored afterwards, also on Ctrl-C. A variant with no device is reported `skipped`, never passed. Without `--release <id>` or `--serve <port>` the Player shows the app's **live** release, not your working copy. `--serve <port>` serves the working copy on `127.0.0.1:<port>` as `himi run` does, so native and web render the same release. The Player reads a local origin only from a Debug build that compiled it in (see [View your package on a device](himi-player-implementation.md#view-your-package-on-a-device)): build it with `HIMALAYA_API_BASE_URL_INFO=http://127.0.0.1:<port>` on iOS and `-PhimalayaDevServeBaseUrl=http://10.0.2.2:<port>` on Android. An iOS device whose Player reads another origin is skipped with the rebuild to run. Android's origin cannot be read back from the installed app, so check that build yourself. A screen that declares `params` is skipped natively for now, because the Player's deep link does not carry them yet. On a fresh iOS simulator, open one `himi://` link by hand first and accept iOS's "Open in Himi Player?" prompt: the command cannot answer it, and until it is accepted every iOS capture shows the prompt.
|
|
220
220
|
3. **Scores mockup↔web** with ImageMagick SSIM, globally and per tile. The worst tile says where the screens differ most.
|
|
221
221
|
4. **Asks a fresh model** two questions, using the repo's fidelity judge prompt. The model never ran in the session that wrote the app.
|
|
222
222
|
- **Design:** does WEB look like the MOCKUP? This judges the port.
|
|
@@ -5332,19 +5332,43 @@
|
|
|
5332
5332
|
"name": "SnackbarOptions",
|
|
5333
5333
|
"kind": "interface",
|
|
5334
5334
|
"jsdoc": "",
|
|
5335
|
-
"decl": "export interface SnackbarOptions {\n message: string;\n /** Default \"info\". */\n tone?: SnackbarTone;\n /** Auto-dismiss delay in ms, on every host.
|
|
5335
|
+
"decl": "export interface SnackbarOptions {\n /** At most two lines on screen; a longer message is ellipsised. */\n message: string;\n /** Default \"info\". */\n tone?: SnackbarTone;\n /** Auto-dismiss delay in ms, clamped on every host to {@link SNACKBAR_LIMITS} (2–10 s, at least\n * 4 s with an `action` so it can be reached by VoiceOver / TalkBack before it goes). Absent, 0 or\n * less: the default, 3000 (4000 with an `action`). */\n durationMs?: number;\n /** A thumbnail of the thing the toast is about: a 40 pt square at the leading edge. `https:` on\n * every host; a local `file:` URL on iOS and macOS only, whose image pipeline already reads local\n * files. Any other URL is ignored (the toast shows without it), and an image that fails to load\n * just hides. */\n imageUrl?: string;\n /** One action button (\"Undo\", \"View\"). Tapping it dismisses the snackbar and resolves the call\n * `{ actionTapped: true }`. With an `actionId` the tap also runs that action with `args`. The id\n * must be a worker action:\n * - one of this bundle's own `tier5.<svc>.<action>` ids — run on THIS worker, never another\n * screen's or the app-level worker's;\n * - an app service's `app.<svc>.<action>` — needs `app_services` and an exported action (an app\n * service's own id needs neither). The web host refuses it until it hosts app services.\n * Any other id (`capability.*`, `himi.*`, a registered handler) is refused when the snackbar is\n * requested: the call rejects and nothing is shown. A host that cannot tell which worker asked\n * (React Native, an embedded web reg\n/* … */"
|
|
5336
5336
|
},
|
|
5337
5337
|
{
|
|
5338
5338
|
"name": "SnackbarAction",
|
|
5339
5339
|
"kind": "interface",
|
|
5340
5340
|
"jsdoc": "",
|
|
5341
|
-
"decl": "export interface SnackbarAction {\n /** The button's text — also its VoiceOver / TalkBack / screen-reader label. */\n label: string;\n /** What the tap runs: one of this bundle's own `tier5.<svc>.<action>` ids, or
|
|
5341
|
+
"decl": "export interface SnackbarAction {\n /** The button's text — also its VoiceOver / TalkBack / screen-reader label. At most 24\n * characters; a longer label is ellipsised. */\n label: string;\n /** What the tap runs, if anything: one of this bundle's own `tier5.<svc>.<action>` ids, or\n * `app.<svc>.<action>`. A screen worker may leave it out and branch on `actionTapped` instead\n * (\"Undo\"); an app service may not. */\n actionId?: string;\n /** The action's arguments, which the reducer receives as typed JSON. */\n args?: Record<string, unknown>;\n}"
|
|
5342
|
+
},
|
|
5343
|
+
{
|
|
5344
|
+
"name": "SnackbarResult",
|
|
5345
|
+
"kind": "interface",
|
|
5346
|
+
"jsdoc": "/** What a snackbar call resolves with. */",
|
|
5347
|
+
"decl": "export interface SnackbarResult {\n /** The user tapped the action. Always false for a snackbar without one (resolved on\n * presentation); with one, false when it was dismissed any other way — timed out, swiped, closed,\n * or replaced by a newer snackbar. */\n actionTapped: boolean;\n}"
|
|
5348
|
+
},
|
|
5349
|
+
{
|
|
5350
|
+
"name": "SNACKBAR_LIMITS",
|
|
5351
|
+
"kind": "const",
|
|
5352
|
+
"jsdoc": "/** #4661 — the constraints that keep a snackbar a toast, enforced here and again by every host. */",
|
|
5353
|
+
"decl": "export const SNACKBAR_LIMITS = {\n messageLines: 2,\n labelChars: 24,\n minDurationMs: 2000,\n /** The floor with an action: long enough to reach it with a screen reader. */\n minActionDurationMs: 4000,\n maxDurationMs: 10_000,\n /** The thumbnail's side, in pt / dp / CSS px. */\n imageSize: 40,\n} as const;"
|
|
5354
|
+
},
|
|
5355
|
+
{
|
|
5356
|
+
"name": "snackbarDurationMs",
|
|
5357
|
+
"kind": "function",
|
|
5358
|
+
"jsdoc": "/** The duration a host shows a snackbar for: absent / non-finite / 0 or less → undefined (the host\n * default applies), else clamped to {@link SNACKBAR_LIMITS}. */",
|
|
5359
|
+
"decl": "export function snackbarDurationMs(durationMs: number | undefined, hasAction: boolean): number | undefined;"
|
|
5360
|
+
},
|
|
5361
|
+
{
|
|
5362
|
+
"name": "snackbarLabel",
|
|
5363
|
+
"kind": "function",
|
|
5364
|
+
"jsdoc": "/** An action label as every host draws it: at most {@link SNACKBAR_LIMITS}.labelChars characters\n * (by code point, so an emoji is never cut in half), the last one an ellipsis when it was cut. */",
|
|
5365
|
+
"decl": "export function snackbarLabel(label: string): string;"
|
|
5342
5366
|
},
|
|
5343
5367
|
{
|
|
5344
5368
|
"name": "snackbar",
|
|
5345
5369
|
"kind": "function",
|
|
5346
|
-
"jsdoc": "/** Show a transient snackbar toast: it floats above the tab bar, slides and fades in and out\n * (fades only under reduced motion), queues behind one that is already showing, and is
|
|
5347
|
-
"decl": "export async function snackbar(options: SnackbarOptions): Promise<
|
|
5370
|
+
"jsdoc": "/** Show a transient snackbar toast: it floats above the tab bar, slides and fades in and out\n * (fades only under reduced motion), queues behind one that is already showing, and is announced\n * by VoiceOver / TalkBack / the screen reader. Without an `action` it resolves\n * `{ actionTapped: false }` once it is queued, as before; with one it resolves when the snackbar\n * goes, `{ actionTapped: true }` only if the action was tapped (see {@link SnackbarResult}).\n * Rejects with `no_ui_host` if no presenter is attached; with an `action`, also with\n * `invalid_action` (empty label), `action_id_required` (an app service with no `actionId`),\n * `action_not_allowed` (not one of this bundle's actions), or, for an `app.*` id, `undeclared` (no\n * `app_services`), `not_exported`, `no_service`, `no_action`, `not_booted` or `unavailable`. */",
|
|
5371
|
+
"decl": "export async function snackbar(options: SnackbarOptions): Promise<SnackbarResult>;"
|
|
5348
5372
|
},
|
|
5349
5373
|
{
|
|
5350
5374
|
"name": "version",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wix/himalaya-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1614.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
|
|
6
6
|
"keywords": [
|
|
@@ -72,5 +72,5 @@
|
|
|
72
72
|
"artifactId": "himalaya-cli"
|
|
73
73
|
}
|
|
74
74
|
},
|
|
75
|
-
"falconPackageHash": "
|
|
75
|
+
"falconPackageHash": "5d96af6574de509f64d0dfcd8cb4660e4a7d81a0deb73b852e474acd"
|
|
76
76
|
}
|
|
@@ -9,7 +9,14 @@ import { withIapPresentation } from "../_shared/iap-presentation.js";
|
|
|
9
9
|
import { queryServices, type WixService } from "@himalaya/wix-bookings";
|
|
10
10
|
import { getPost, queryPosts, type WixPost } from "@himalaya/wix-blog";
|
|
11
11
|
import { queryGroups, type WixGroup } from "@himalaya/wix-groups";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
listMyParticipations,
|
|
14
|
+
participationsByProgram,
|
|
15
|
+
queryJoinApplications,
|
|
16
|
+
queryPrograms,
|
|
17
|
+
type WixProgram,
|
|
18
|
+
} from "@himalaya/wix-online-programs";
|
|
19
|
+
import { getMyMember } from "@himalaya/wix-members";
|
|
13
20
|
import { listFeedItems, type WixFeedItem, type WixFeedProfile } from "@himalaya/wix-social-feed";
|
|
14
21
|
import { queryItems as queryMenuItems, querySections as queryMenuSections } from "@himalaya/wix-restaurants";
|
|
15
22
|
import { normalizeEvent, queryEvents } from "../_shared/himi-events.js";
|
|
@@ -166,6 +173,13 @@ interface ProgramCard {
|
|
|
166
173
|
noImage: boolean;
|
|
167
174
|
}
|
|
168
175
|
|
|
176
|
+
/** A member's own program state, kept separate from the visitor catalog cards. */
|
|
177
|
+
interface MemberProgramCard extends ProgramCard {
|
|
178
|
+
stateLabel: string;
|
|
179
|
+
progressFraction: number;
|
|
180
|
+
hideProgress: boolean;
|
|
181
|
+
}
|
|
182
|
+
|
|
169
183
|
/** A communityPulse latest-activity Carousel card (design §4.5). */
|
|
170
184
|
interface FeedHighlight {
|
|
171
185
|
id: string;
|
|
@@ -308,6 +322,8 @@ interface State {
|
|
|
308
322
|
noNextEvent: boolean;
|
|
309
323
|
homePrograms: ProgramCard[];
|
|
310
324
|
noHomePrograms: boolean;
|
|
325
|
+
memberPrograms: MemberProgramCard[];
|
|
326
|
+
noMemberPrograms: boolean;
|
|
311
327
|
/** The videos module's carousel — the newest ready Wix Video videos, the channels
|
|
312
328
|
* taking turns. Guarded on `videos`; same silent-fail contract. */
|
|
313
329
|
homeVideos: (WixVideoCard & CaptionFlags)[];
|
|
@@ -549,6 +565,7 @@ interface ExtrasSignal {
|
|
|
549
565
|
upcomingEvents: EventEntry[];
|
|
550
566
|
nextEventCards: NextEventCard[];
|
|
551
567
|
homePrograms: ProgramCard[];
|
|
568
|
+
memberPrograms: MemberProgramCard[];
|
|
552
569
|
homeVideos: (WixVideoCard & CaptionFlags)[];
|
|
553
570
|
feedHighlights: FeedHighlight[];
|
|
554
571
|
memberAvatars: CoachAvatar[];
|
|
@@ -559,8 +576,17 @@ interface ExtrasSignal {
|
|
|
559
576
|
menuCategories: MenuCategory[];
|
|
560
577
|
}
|
|
561
578
|
|
|
579
|
+
let homeProgramCandidates: { generation: number; programs: WixProgram[] } = { generation: 0, programs: [] };
|
|
580
|
+
/** A finite number from a progress field, or `fallback` when the read left it out or sent junk. */
|
|
581
|
+
function finiteOr(value: unknown, fallback: number): number {
|
|
582
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** How many microtask turns `memberProgramsLoaded` waits for refresh's own result to land (see the action). */
|
|
586
|
+
const MEMBER_PROGRAMS_APPLY_RETRIES = 50;
|
|
587
|
+
|
|
562
588
|
function emptyExtrasSignal(): ExtrasSignal {
|
|
563
|
-
return { upcomingEvents: [], nextEventCards: [], homePrograms: [], homeVideos: [], feedHighlights: [], memberAvatars: [], groupCards: [], announcementTitle: "", postCards: [], popularDishes: [], menuCategories: [] };
|
|
589
|
+
return { upcomingEvents: [], nextEventCards: [], homePrograms: [], memberPrograms: [], homeVideos: [], feedHighlights: [], memberAvatars: [], groupCards: [], announcementTitle: "", postCards: [], popularDishes: [], menuCategories: [] };
|
|
564
590
|
}
|
|
565
591
|
|
|
566
592
|
/** Cover URL probe for a blog post — the himi-blog-feed imageUrl fallbacks
|
|
@@ -670,13 +696,13 @@ async function fetchUpcomingEvents(): Promise<{ entries: EventEntry[]; nextEvent
|
|
|
670
696
|
}
|
|
671
697
|
}
|
|
672
698
|
|
|
673
|
-
async function fetchHomePrograms(): Promise<ProgramCard[]> {
|
|
699
|
+
async function fetchHomePrograms(): Promise<{ cards: ProgramCard[]; programs: WixProgram[] }> {
|
|
674
700
|
try {
|
|
675
701
|
const response = await queryPrograms({ cursorPaging: { limit: 12 } });
|
|
676
|
-
|
|
702
|
+
const programs = (response.programs ?? [])
|
|
677
703
|
.filter((p: WixProgram) => ((p.state ?? p.status ?? "").toUpperCase()) !== "DRAFT")
|
|
678
|
-
.slice(0,
|
|
679
|
-
|
|
704
|
+
.slice(0, 12);
|
|
705
|
+
const cards = programs.slice(0, 6).map((p: WixProgram) => {
|
|
680
706
|
const desc = (p as { description?: unknown }).description;
|
|
681
707
|
const nestedTitle = desc && typeof desc === "object" ? (desc as { title?: string }).title : undefined;
|
|
682
708
|
const name = cleanTitle(p.settings?.title ?? nestedTitle ?? p.title) || copyParam("home.programFallbackName", "Program");
|
|
@@ -692,7 +718,112 @@ async function fetchHomePrograms(): Promise<ProgramCard[]> {
|
|
|
692
718
|
noImage: !image,
|
|
693
719
|
};
|
|
694
720
|
});
|
|
721
|
+
return { cards, programs };
|
|
695
722
|
} catch {
|
|
723
|
+
return { cards: [], programs: [] };
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** Member state is best-effort Home enrichment: visitors and unsupported member sessions
|
|
728
|
+
* collapse the rail, while the public Programs section remains available. */
|
|
729
|
+
async function fetchMemberProgramCards(programs: WixProgram[]): Promise<MemberProgramCard[]> {
|
|
730
|
+
try {
|
|
731
|
+
const member = await memberAccess();
|
|
732
|
+
if (!member.email) return [];
|
|
733
|
+
const memberId = (await getMyMember()).member?.id;
|
|
734
|
+
if (!memberId) return [];
|
|
735
|
+
|
|
736
|
+
const [participantResult, applicationResult] = await Promise.allSettled([
|
|
737
|
+
listMyParticipations(memberId, { maxPages: 2 }),
|
|
738
|
+
queryJoinApplications({ memberId, limit: 100 }),
|
|
739
|
+
]);
|
|
740
|
+
// These are independent member reads. An unverified participant-query permission
|
|
741
|
+
// must not also hide applications that the member-scoped endpoint can return.
|
|
742
|
+
const participants = participantResult.status === "fulfilled" ? participantResult.value : [];
|
|
743
|
+
const applications = applicationResult.status === "fulfilled" ? applicationResult.value : { joinApplications: [] };
|
|
744
|
+
const participation = participationsByProgram(participants);
|
|
745
|
+
// An application that already became a participant keeps its status and carries `participantId`
|
|
746
|
+
// (the SDK's PENDING_JOIN_APPLICATION_STATUSES note): the participation is the standing, and a member
|
|
747
|
+
// who later left must not read as "Approved" from the application that once admitted them. Newest
|
|
748
|
+
// first, one per program, and only programs with no participation of their own — so the admitted
|
|
749
|
+
// rows a joined member accumulates never take the lookup slots of a real pending request.
|
|
750
|
+
const applicationByProgram = new Map<string, { status?: string }>();
|
|
751
|
+
for (const application of applications.joinApplications ?? []) {
|
|
752
|
+
if (!application.programId || application.participantId || participation.has(application.programId)) continue;
|
|
753
|
+
if (!applicationByProgram.has(application.programId)) applicationByProgram.set(application.programId, application);
|
|
754
|
+
}
|
|
755
|
+
// The public catalog preview is intentionally capped at 12. Resolve the
|
|
756
|
+
// member's six most recent enrolled programs and six open applications
|
|
757
|
+
// outside that window in one filtered query so both remain visible.
|
|
758
|
+
const knownIds = new Set(programs.map((program) => program.id).filter((id): id is string => !!id));
|
|
759
|
+
const participantProgramIds = [...participation.keys()].slice(0, 6);
|
|
760
|
+
const applicationProgramIds = [...applicationByProgram.keys()].filter((id) => !participantProgramIds.includes(id)).slice(0, 6);
|
|
761
|
+
const candidateIds = [...participantProgramIds, ...applicationProgramIds].slice(0, 12);
|
|
762
|
+
const missingIds = candidateIds.filter((id) => !knownIds.has(id));
|
|
763
|
+
const extraPrograms = missingIds.length
|
|
764
|
+
? await queryPrograms({ filter: { id: { $in: missingIds } }, cursorPaging: { limit: missingIds.length } })
|
|
765
|
+
.then((response) => response.programs ?? []).catch(() => [])
|
|
766
|
+
: [];
|
|
767
|
+
const memberPrograms = [...programs, ...extraPrograms.filter((program) => !!program.id && !knownIds.has(program.id))];
|
|
768
|
+
await assertMemberAccess(member);
|
|
769
|
+
|
|
770
|
+
return memberPrograms.flatMap((program) => {
|
|
771
|
+
if (!program.id) return [];
|
|
772
|
+
const participant = participation.get(program.id);
|
|
773
|
+
const application = applicationByProgram.get(program.id);
|
|
774
|
+
if (!participant && !application) return [];
|
|
775
|
+
const desc = (program as { description?: unknown }).description;
|
|
776
|
+
const nestedTitle = desc && typeof desc === "object" ? (desc as { title?: string }).title : undefined;
|
|
777
|
+
const name = cleanTitle(program.settings?.title ?? nestedTitle ?? program.title) || copyParam("home.programFallbackName", "Program");
|
|
778
|
+
const image = programImage(program);
|
|
779
|
+
// Progress may be absent (a `fields` the read did not ask for, or a program with no steps): the
|
|
780
|
+
// bar then sits at 0 and the label comes from `progressStatus`, never from NaN arithmetic.
|
|
781
|
+
const available = finiteOr(participant?.progress?.totalStepsAvailable, 0);
|
|
782
|
+
const completed = finiteOr(participant?.progress?.totalStepsCompleted, 0);
|
|
783
|
+
const percentage = finiteOr(participant?.progress?.completionPercentage, Number.NaN);
|
|
784
|
+
const hasProgress = available > 0 || Number.isFinite(percentage);
|
|
785
|
+
const rawFraction = available > 0 ? completed / available : Number.isFinite(percentage) ? percentage / 100 : 0;
|
|
786
|
+
const progressFraction = Math.max(0, Math.min(1, rawFraction));
|
|
787
|
+
let stateLabel: string;
|
|
788
|
+
if (participant) {
|
|
789
|
+
const enrollmentStatus = (participant.enrollmentStatus ?? "").toUpperCase();
|
|
790
|
+
const progressStatus = (participant.progressStatus ?? "").toUpperCase();
|
|
791
|
+
stateLabel = enrollmentStatus === "SUSPENDED"
|
|
792
|
+
? copyParam("home.memberProgramSuspended", "Access paused")
|
|
793
|
+
: progressStatus === "COMPLETED" || enrollmentStatus === "COMPLETED"
|
|
794
|
+
? copyParam("home.memberProgramCompleted", "Completed")
|
|
795
|
+
: progressStatus === "FAILED"
|
|
796
|
+
? copyParam("home.memberProgramFailed", "Failed")
|
|
797
|
+
: progressStatus === "IN_PROGRESS" || (progressStatus !== "NOT_STARTED" && progressFraction > 0)
|
|
798
|
+
? copyParam("home.memberProgramInProgress", "In progress")
|
|
799
|
+
: copyParam("home.memberProgramNotStarted", "Not started");
|
|
800
|
+
} else {
|
|
801
|
+
const status = (application?.status ?? "").toUpperCase();
|
|
802
|
+
stateLabel = status === "INVITED"
|
|
803
|
+
? copyParam("home.memberProgramInvited", "Invitation")
|
|
804
|
+
: status === "APPROVED"
|
|
805
|
+
? copyParam("home.memberProgramApproved", "Approved")
|
|
806
|
+
: status === "SUSPENDED"
|
|
807
|
+
? copyParam("home.memberProgramSuspended", "Access paused")
|
|
808
|
+
: copyParam("home.memberProgramPending", "Request pending");
|
|
809
|
+
}
|
|
810
|
+
return [{
|
|
811
|
+
id: program.id,
|
|
812
|
+
name,
|
|
813
|
+
description: "",
|
|
814
|
+
image,
|
|
815
|
+
initial: initials(name),
|
|
816
|
+
hasImage: !!image,
|
|
817
|
+
noImage: !image,
|
|
818
|
+
stateLabel,
|
|
819
|
+
progressFraction,
|
|
820
|
+
hideProgress: !hasProgress || !participant || (participant.enrollmentStatus ?? "").toUpperCase() === "SUSPENDED",
|
|
821
|
+
}];
|
|
822
|
+
});
|
|
823
|
+
} catch (error) {
|
|
824
|
+
// The member rail is optional; never let a member-state refusal blank public Home.
|
|
825
|
+
const status = (error as { status?: unknown } | null)?.status ?? /\bHTTP (\d{3})\b/.exec(String(error))?.[1];
|
|
826
|
+
console.warn("[home.member-programs] member state unavailable", status !== undefined ? `HTTP ${String(status)}` : (error as { name?: string } | null)?.name ?? "unknown");
|
|
696
827
|
return [];
|
|
697
828
|
}
|
|
698
829
|
}
|
|
@@ -747,7 +878,7 @@ function groupCard(group: WixGroup): GroupCard {
|
|
|
747
878
|
};
|
|
748
879
|
}
|
|
749
880
|
|
|
750
|
-
async function fetchExtrasSignal(arg: unknown): Promise<ExtrasSignal> {
|
|
881
|
+
async function fetchExtrasSignal(arg: unknown, generation: number): Promise<ExtrasSignal> {
|
|
751
882
|
const verticals = parseVerticals(arg);
|
|
752
883
|
const extras = emptyExtrasSignal();
|
|
753
884
|
const jobs: Promise<void>[] = [];
|
|
@@ -758,7 +889,12 @@ async function fetchExtrasSignal(arg: unknown): Promise<ExtrasSignal> {
|
|
|
758
889
|
}));
|
|
759
890
|
}
|
|
760
891
|
if (verticals.includes("programs")) {
|
|
761
|
-
jobs.push(fetchHomePrograms().then((cards) => {
|
|
892
|
+
jobs.push(fetchHomePrograms().then(({ cards, programs }) => {
|
|
893
|
+
extras.homePrograms = cards;
|
|
894
|
+
// An older refresh can finish this query after a newer one. Keep its
|
|
895
|
+
// candidates from replacing the current generation's member-read input.
|
|
896
|
+
if (generation === refreshGen) homeProgramCandidates = { generation, programs };
|
|
897
|
+
}));
|
|
762
898
|
}
|
|
763
899
|
if (verticals.includes("videos") && !videoSurfaceRefused) {
|
|
764
900
|
const caption = videoCaptionOf(paramValue(arg, "videoCaption"));
|
|
@@ -883,6 +1019,7 @@ function derive(state: State): State {
|
|
|
883
1019
|
noUpcomingEvents: state.upcomingEvents.length === 0,
|
|
884
1020
|
noNextEvent: state.nextEventCards.length === 0,
|
|
885
1021
|
noHomePrograms: state.homePrograms.length === 0,
|
|
1022
|
+
noMemberPrograms: state.memberPrograms.length === 0,
|
|
886
1023
|
noHomeVideos: state.homeVideos.length === 0,
|
|
887
1024
|
noFeedHighlights: state.feedHighlights.length === 0,
|
|
888
1025
|
noMemberAvatars: state.memberAvatars.length === 0,
|
|
@@ -977,6 +1114,8 @@ function baseState(): State {
|
|
|
977
1114
|
noNextEvent: true,
|
|
978
1115
|
homePrograms: [],
|
|
979
1116
|
noHomePrograms: true,
|
|
1117
|
+
memberPrograms: [],
|
|
1118
|
+
noMemberPrograms: true,
|
|
980
1119
|
homeVideos: [],
|
|
981
1120
|
noHomeVideos: true,
|
|
982
1121
|
feedHighlights: [],
|
|
@@ -1026,7 +1165,7 @@ async function refreshPublic(state: State, arg?: unknown): Promise<State | undef
|
|
|
1026
1165
|
// failure just collapses their blocks), so a store outage cannot blank them
|
|
1027
1166
|
// and vice versa. paramValue() stashes the appear params on first read, so the
|
|
1028
1167
|
// arg-less fallback below still resolves the stashed verticals.
|
|
1029
|
-
const [bookings, extras, saved] = await Promise.all([fetchBookingsSignal(arg), fetchExtrasSignal(arg), fetchSavedSignal(arg)]);
|
|
1168
|
+
const [bookings, extras, saved] = await Promise.all([fetchBookingsSignal(arg), fetchExtrasSignal(arg, gen), fetchSavedSignal(arg)]);
|
|
1030
1169
|
if (gen !== refreshGen) return undefined;
|
|
1031
1170
|
// A screen that declares no "store" vertical renders no products rail, so there is
|
|
1032
1171
|
// nothing to fetch and nothing that could be "not loaded" — skip the call outright
|
|
@@ -1186,8 +1325,17 @@ async function refresh(state: State, arg?: unknown): Promise<State | undefined>
|
|
|
1186
1325
|
void refreshPersonal(generation, arg);
|
|
1187
1326
|
const publicState = await refreshPublic(state, arg);
|
|
1188
1327
|
if (!publicState || generation !== personalGeneration) return undefined;
|
|
1328
|
+
// The member-only read runs after public Home has landed. It is deliberately not awaited:
|
|
1329
|
+
// a slow member endpoint must not hold the public catalog or hero behind authentication.
|
|
1330
|
+
const programCandidates = homeProgramCandidates;
|
|
1331
|
+
if (programCandidates.generation === refreshGen && parseVerticals(arg).includes("programs")) {
|
|
1332
|
+
void fetchMemberProgramCards(programCandidates.programs).then((rows) => {
|
|
1333
|
+
void svc.memberProgramsLoaded({ generation: programCandidates.generation, rows });
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1189
1336
|
const current = svc.getState();
|
|
1190
|
-
return derive({ ...publicState,
|
|
1337
|
+
return derive({ ...publicState, memberPrograms: current.memberPrograms, noMemberPrograms: current.noMemberPrograms,
|
|
1338
|
+
nextBooking: current.nextBooking, hideNextBooking: current.hideNextBooking,
|
|
1191
1339
|
memberProgressLabel: current.memberProgressLabel, memberProgressFraction: current.memberProgressFraction,
|
|
1192
1340
|
hideMemberProgress: current.hideMemberProgress, personalError: current.personalError, hidePersonalError: current.hidePersonalError,
|
|
1193
1341
|
// The weekly goal is PERSONAL (member bookings); the public refresh resets it, so keep it.
|
|
@@ -1214,6 +1362,7 @@ const svc = defineService<State, {
|
|
|
1214
1362
|
refresh: (state: State, arg?: unknown) => Promise<State | undefined>;
|
|
1215
1363
|
dismissOnboarding: (state: State) => State;
|
|
1216
1364
|
clearPersonal: (state: State) => State;
|
|
1365
|
+
memberProgramsLoaded: (state: State, arg: { generation: number; rows: MemberProgramCard[]; attempt?: number }) => State | undefined;
|
|
1217
1366
|
openShopCategory: (state: State, arg?: unknown) => State;
|
|
1218
1367
|
personalLoaded: (state: State, arg: { generation: number; rows: MemberBookingRow[]; progress: { label: string; fraction: number }; error: boolean; weekly: { count: number; fraction: number; label: string } | null }) => State;
|
|
1219
1368
|
categoriesLoaded: (state: State, arg: { generation: number; categories: StoreCategoryCard[] }) => State | undefined;
|
|
@@ -1221,10 +1370,27 @@ const svc = defineService<State, {
|
|
|
1221
1370
|
state: baseState(),
|
|
1222
1371
|
// The host's site is read fresh at every boot: a snapshot of the last session's site must not
|
|
1223
1372
|
// decide this one's verticals or name (an owner installs or removes one; another site opens).
|
|
1224
|
-
notHydrated: ["hosted", "hostedName", "hostedCards", "homeCategories", "noHomeCategories"],
|
|
1373
|
+
notHydrated: ["hosted", "hostedName", "hostedCards", "homeCategories", "noHomeCategories", "memberPrograms", "noMemberPrograms"],
|
|
1374
|
+
// A member's standing never reaches the on-device snapshot either: the next boot may be another member's.
|
|
1375
|
+
notPersisted: ["memberPrograms", "noMemberPrograms"],
|
|
1225
1376
|
actions: {
|
|
1226
|
-
clearPersonal: state => derive({ ...state, nextBooking: [], hideNextBooking: true, memberProgressLabel: "", memberProgressFraction: 0, hideMemberProgress: true, personalError: false, hidePersonalError: true,
|
|
1377
|
+
clearPersonal: state => derive({ ...state, nextBooking: [], hideNextBooking: true, memberProgressLabel: "", memberProgressFraction: 0, hideMemberProgress: true, memberPrograms: [], noMemberPrograms: true, personalError: false, hidePersonalError: true,
|
|
1227
1378
|
weeklyClassCount: 0, weeklyClassGoalFraction: 0, weeklyGoalLabel: "" }),
|
|
1379
|
+
// The member read starts inside `refresh` and, for a visitor (identity answers synchronously),
|
|
1380
|
+
// resolves within the same microtask turn — BEFORE the runtime has applied refresh's own result.
|
|
1381
|
+
// The `state` handed here is then refresh's PRE-state (status "loading", every rail empty), and
|
|
1382
|
+
// spreading it wrote a loading home over the ready one; the description-projection test caught
|
|
1383
|
+
// exactly that. So an early arrival re-queues itself (a microtask at a time, bounded) until the
|
|
1384
|
+
// state it would spread is the applied one. A stale generation keeps the current state
|
|
1385
|
+
// (`undefined`), never its own stale snapshot.
|
|
1386
|
+
memberProgramsLoaded: (state, arg) => {
|
|
1387
|
+
if (arg.generation !== refreshGen) return undefined;
|
|
1388
|
+
if (state.status === "loading" && (arg.attempt ?? 0) < MEMBER_PROGRAMS_APPLY_RETRIES) {
|
|
1389
|
+
void Promise.resolve().then(() => svc.memberProgramsLoaded({ ...arg, attempt: (arg.attempt ?? 0) + 1 }));
|
|
1390
|
+
return undefined;
|
|
1391
|
+
}
|
|
1392
|
+
return derive({ ...state, memberPrograms: arg.rows, noMemberPrograms: arg.rows.length === 0 });
|
|
1393
|
+
},
|
|
1228
1394
|
openShopCategory: (state, arg) => {
|
|
1229
1395
|
const category = String((arg as { category?: unknown } | undefined)?.category ?? "");
|
|
1230
1396
|
if (!category) return state;
|