@wix/himalaya-cli 0.1613.0 → 0.1615.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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 +1638 -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/template-icons/salon-spa.png +0 -0
- package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/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/_shared/branded-load-state.ts +5 -2
- package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
- package/templates/branded-lite/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/community-club/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/events-meetups/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/gym-fitness/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/nonprofit-cause/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/podcast-creator/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/portfolio-creative/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/restaurant/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/retail-store/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/salon-spa/README.md +53 -0
- package/templates/salon-spa/config.ts +272 -0
- package/templates/salon-spa/dev/index.ts +19893 -0
- package/templates/salon-spa/himalaya.content.json +49 -0
- package/templates/salon-spa/himalaya.template.json +14 -0
- package/templates/salon-spa/icon.png +0 -0
- package/templates/salon-spa/platforms.json +1 -0
- package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
- package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
- package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
- package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
- package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
- package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
- package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
- package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
- package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
- package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
- package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
- package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
- package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
- package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
- package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
- package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
- package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
- package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
- package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
- package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
- package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
- package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
- package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
- package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
- package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
- package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
- package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
- package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
- package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
- package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
- package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
- package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
- package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
- package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
- package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
- package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
- package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
- package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
- package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
- package/templates/salon-spa/tokens.json +341 -0
- package/templates/yoga-studio/dev/index.ts +192 -0
- package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/yoga-studio/tier5-src/himi-branded-lite-home/index.ts +178 -12
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Published Gallery page photos are carried by site analyze in the descriptor and
|
|
4
4
|
// displayed without a Pro Gallery request. Sites with no page gallery use the Wix
|
|
5
|
-
// API gateway
|
|
5
|
+
// API gateway: the list takes each gallery's first item as its cover, and opening a
|
|
6
|
+
// gateway gallery reads its items. Failed reads remain errors; sample data is explicit
|
|
7
|
+
// preview only.
|
|
6
8
|
//
|
|
7
9
|
import { defineService } from "../_shared/collection-service.js";
|
|
8
10
|
import { get as getAppInfo } from "@himalaya/app-info";
|
|
9
11
|
import { LOADING, loaded, loadFailed, type LoadCopy, type LoadFlags } from "../_shared/branded-load-state.js";
|
|
10
|
-
import { presence, type PresenceFlags } from "../_shared/detail-presence.js";
|
|
12
|
+
import { isNotFoundError, presence, type PresenceFlags } from "../_shared/detail-presence.js";
|
|
13
|
+
import { wixMediaUrl } from "@himalaya/wix-media";
|
|
11
14
|
|
|
12
15
|
const GALLERIES_PATH = "/progallery/v2/galleries";
|
|
13
16
|
const LEGACY_GALLERIES_URL =
|
|
@@ -20,9 +23,12 @@ function wixApi(): WixApi | null {
|
|
|
20
23
|
return (globalThis as { himalaya?: { wixApi?: WixApi } }).himalaya?.wixApi ?? null;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
interface
|
|
26
|
+
interface RawItem { id?: unknown; title?: unknown; image?: { imageInfo?: unknown } }
|
|
27
|
+
interface RawGallery { id: string; name?: string; totalItems?: number; totalItemsCount?: number; items?: RawItem[]; source?: "gateway" | "legacy" }
|
|
24
28
|
interface StaticGallery { id: string; title: string; photos: string[] }
|
|
25
|
-
|
|
29
|
+
/** `source`: which route listed the gallery. Only a GATEWAY gallery can be opened live by id —
|
|
30
|
+
* a legacy row's id (or a synthesized `gallery-<n>`) is unknown to the gateway. */
|
|
31
|
+
interface Row { id: string; title: string; subtitle: string; name: string; description: string; image: string; noCover: boolean; hasCover: boolean; source: "gateway" | "legacy" | "page" }
|
|
26
32
|
interface PhotoRow { id: string; url: string; title: string }
|
|
27
33
|
interface S extends LoadFlags, PresenceFlags {
|
|
28
34
|
galleries: Row[]; emptyText: string;
|
|
@@ -56,12 +62,56 @@ function parseStaticGalleries(raw: unknown): StaticGallery[] {
|
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
let staticGalleries: StaticGallery[] = [];
|
|
65
|
+
/** The gateway gallery open on screen: each `open` counts up, and a read answers for its own. */
|
|
66
|
+
let openSeq = 0;
|
|
67
|
+
type PhotosRead = { seq: number; title?: string; photos?: PhotoRow[] } | { seq: number; error: unknown };
|
|
68
|
+
|
|
69
|
+
/** A Pro Gallery image item's URL. `imageInfo` is either a Wix media object (`{ id, url }`,
|
|
70
|
+
* where `url` may be the bare media id) or a `wix:image://` string; anything else is "". */
|
|
71
|
+
function itemUrl(item: RawItem | undefined): string {
|
|
72
|
+
const info = item?.image?.imageInfo;
|
|
73
|
+
if (typeof info === "string") return wixMediaUrl(info);
|
|
74
|
+
if (!info || typeof info !== "object") return "";
|
|
75
|
+
const media = info as { url?: unknown; id?: unknown };
|
|
76
|
+
return wixMediaUrl(media.url) || wixMediaUrl(media.id);
|
|
77
|
+
}
|
|
59
78
|
|
|
60
79
|
function norm(g: RawGallery, i: number): Row {
|
|
61
80
|
const title = (g.name || "").trim() || `Gallery ${i + 1}`;
|
|
62
81
|
const n = g.totalItems ?? g.totalItemsCount ?? 0;
|
|
63
82
|
const subtitle = n ? (n === 1 ? "1 photo" : `${n} photos`) : "";
|
|
64
|
-
|
|
83
|
+
// The list asks for one item per gallery (itemLimit=1): that item is the cover.
|
|
84
|
+
const image = itemUrl(Array.isArray(g.items) ? g.items[0] : undefined);
|
|
85
|
+
return { id: g.id, title, subtitle, name: title, description: subtitle, image, noCover: !image, hasCover: !!image, source: g.source ?? "gateway" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A gateway gallery's photos, in the gallery's own order, page by page (`itemOffset` /
|
|
89
|
+
* `itemLimit`; `totalItems` says when to stop — `offset`/`limit` are ignored on this route).
|
|
90
|
+
* Capped so a huge gallery cannot stall the screen. Video and text items carry no image and
|
|
91
|
+
* are skipped. */
|
|
92
|
+
const ITEM_PAGE = 100, ITEM_MAX = 500;
|
|
93
|
+
async function readGalleryPhotos(api: WixApi, id: string): Promise<{ title: string; photos: PhotoRow[] }> {
|
|
94
|
+
let name: unknown = "";
|
|
95
|
+
const items: RawItem[] = [];
|
|
96
|
+
for (let offset = 0; offset < ITEM_MAX; offset += ITEM_PAGE) {
|
|
97
|
+
const res = await api.call("GET", `${GALLERIES_PATH}/${encodeURIComponent(id)}?itemOffset=${offset}&itemLimit=${ITEM_PAGE}`) as { gallery?: { name?: unknown; items?: unknown; totalItems?: unknown } } | null;
|
|
98
|
+
const gallery = res?.gallery;
|
|
99
|
+
if (!gallery || typeof gallery !== "object") throw new Error("Malformed Pro Gallery response");
|
|
100
|
+
if (offset === 0) name = gallery.name;
|
|
101
|
+
const page = Array.isArray(gallery.items) ? gallery.items as RawItem[] : [];
|
|
102
|
+
items.push(...page);
|
|
103
|
+
const total = typeof gallery.totalItems === "number" ? gallery.totalItems : undefined;
|
|
104
|
+
if (page.length < ITEM_PAGE || (total !== undefined && items.length >= total)) break;
|
|
105
|
+
}
|
|
106
|
+
const photos = items.flatMap((item, index): PhotoRow[] => {
|
|
107
|
+
// The original media URL, like a page gallery's: the photo viewer (`view`) accepts only
|
|
108
|
+
// that form, and the renderer sizes the image to its slot.
|
|
109
|
+
const url = itemUrl(item).replace(/\/v1\/.*$/, "");
|
|
110
|
+
if (!url) return [];
|
|
111
|
+
const title = typeof item.title === "string" && item.title.trim() ? item.title.trim() : `Photo ${index + 1}`;
|
|
112
|
+
return [{ id: typeof item.id === "string" && item.id ? item.id : `${id}-${index}`, url, title }];
|
|
113
|
+
});
|
|
114
|
+
return { title: typeof name === "string" ? name.trim() : "", photos };
|
|
65
115
|
}
|
|
66
116
|
|
|
67
117
|
async function readGalleries(api: WixApi): Promise<RawGallery[]> {
|
|
@@ -88,7 +138,7 @@ async function readLegacyGalleries(api: WixApi): Promise<RawGallery[]> {
|
|
|
88
138
|
return raw.map((gallery, index) => {
|
|
89
139
|
if (!gallery || typeof gallery !== "object") throw new Error("Malformed legacy Pro Gallery row");
|
|
90
140
|
const row = gallery as Partial<RawGallery>;
|
|
91
|
-
return { ...row, id: typeof row.id === "string" && row.id ? row.id : `gallery-${index}
|
|
141
|
+
return { ...row, id: typeof row.id === "string" && row.id ? row.id : `gallery-${index}`, source: "legacy" as const };
|
|
92
142
|
});
|
|
93
143
|
}
|
|
94
144
|
|
|
@@ -118,9 +168,9 @@ async function readCompatibleGalleries(api: WixApi): Promise<RawGallery[]> {
|
|
|
118
168
|
}
|
|
119
169
|
|
|
120
170
|
const MOCK: Row[] = [
|
|
121
|
-
{ id: "sample-work", title: "Our Work", subtitle: "24 photos", name: "Our Work", description: "24 photos", image: "", noCover: true },
|
|
122
|
-
{ id: "sample-events", title: "Events", subtitle: "48 photos", name: "Events", description: "48 photos", image: "", noCover: true },
|
|
123
|
-
{ id: "sample-team", title: "The Team", subtitle: "9 photos", name: "The Team", description: "9 photos", image: "", noCover: true },
|
|
171
|
+
{ id: "sample-work", title: "Our Work", subtitle: "24 photos", name: "Our Work", description: "24 photos", image: "", noCover: true, hasCover: false, source: "page" },
|
|
172
|
+
{ id: "sample-events", title: "Events", subtitle: "48 photos", name: "Events", description: "48 photos", image: "", noCover: true, hasCover: false, source: "page" },
|
|
173
|
+
{ id: "sample-team", title: "The Team", subtitle: "9 photos", name: "The Team", description: "9 photos", image: "", noCover: true, hasCover: false, source: "page" },
|
|
124
174
|
];
|
|
125
175
|
|
|
126
176
|
/** sample rows reach the screen ONLY in the host's explicit sample preview;
|
|
@@ -133,14 +183,21 @@ const COPY: LoadCopy<Row> = {
|
|
|
133
183
|
sample: () => MOCK.map((r) => ({ ...r })),
|
|
134
184
|
};
|
|
135
185
|
|
|
186
|
+
/** The newest state, for an action that answers after an await (`s` is from before it). */
|
|
187
|
+
const current = (s: S): S => (svc.getState?.() as S | undefined) ?? s;
|
|
188
|
+
|
|
136
189
|
const svc = defineService<S, {
|
|
137
190
|
load: (s: S, arg?: { staticGalleries?: unknown }) => Promise<S>;
|
|
138
191
|
open: (s: S, arg?: { staticGalleries?: unknown }) => S;
|
|
192
|
+
photosRead: (s: S, arg?: PhotosRead) => S | undefined;
|
|
139
193
|
view: (s: S) => S;
|
|
140
194
|
}>("himiBrandedGalleries", {
|
|
141
195
|
state: { ...LOADING, ...presence("loading"), galleries: [], emptyText: "", galleryTitle: "", photos: [], photoUrl: "", photoTitle: "", noGallery: true, hasPhotos: false, hasPhoto: false, noPhoto: true },
|
|
142
196
|
actions: {
|
|
143
197
|
load: async (s, arg) => {
|
|
198
|
+
// No `openSeq` bump here: the list and a gallery never share a worker (workers are pooled by
|
|
199
|
+
// screen and input), and a host's refresh runs `load` on the gallery's own worker, where a
|
|
200
|
+
// bump would drop that gallery's read in flight and leave its screen blank.
|
|
144
201
|
try {
|
|
145
202
|
if (arg && "staticGalleries" in arg) {
|
|
146
203
|
staticGalleries = [];
|
|
@@ -149,7 +206,7 @@ const svc = defineService<S, {
|
|
|
149
206
|
if (staticGalleries.length) {
|
|
150
207
|
const rows = staticGalleries.map((gallery) => {
|
|
151
208
|
const subtitle = gallery.photos.length === 1 ? "1 photo" : `${gallery.photos.length} photos`;
|
|
152
|
-
return { id: gallery.id, title: gallery.title, subtitle, name: gallery.title, description: subtitle, image: gallery.photos[0], noCover: false };
|
|
209
|
+
return { id: gallery.id, title: gallery.title, subtitle, name: gallery.title, description: subtitle, image: gallery.photos[0], noCover: false, hasCover: true, source: "page" as const };
|
|
153
210
|
});
|
|
154
211
|
return { ...s, ...loaded("galleries", rows, COPY) };
|
|
155
212
|
}
|
|
@@ -157,12 +214,17 @@ const svc = defineService<S, {
|
|
|
157
214
|
if (!api) return { ...s, ...loadFailed("galleries", new Error("Wix bridge unavailable"), COPY) };
|
|
158
215
|
const raw = await readCompatibleGalleries(api);
|
|
159
216
|
const galleries = raw.map(norm);
|
|
160
|
-
|
|
217
|
+
// From the state as it is NOW: on a gallery's own worker, its photos may have landed
|
|
218
|
+
// (`photosRead`) during the read, and `s` is from before it.
|
|
219
|
+
return { ...current(s), ...loaded("galleries", galleries, COPY) };
|
|
161
220
|
} catch (err) {
|
|
162
|
-
return { ...s, ...loadFailed("galleries", err, COPY) };
|
|
221
|
+
return { ...current(s), ...loadFailed("galleries", err, COPY) };
|
|
163
222
|
}
|
|
164
223
|
},
|
|
165
224
|
open: (s, arg) => {
|
|
225
|
+
// Every open, live or not, makes the reads of earlier opens stale (`photosRead` drops them):
|
|
226
|
+
// a page or legacy gallery opened after a slow gateway read must not get that read's photos.
|
|
227
|
+
const seq = ++openSeq;
|
|
166
228
|
try {
|
|
167
229
|
if (arg && "staticGalleries" in arg) {
|
|
168
230
|
staticGalleries = [];
|
|
@@ -174,11 +236,41 @@ const svc = defineService<S, {
|
|
|
174
236
|
}
|
|
175
237
|
const id = input("id");
|
|
176
238
|
const gallery = staticGalleries.find((entry) => entry.id === id);
|
|
239
|
+
// No page galleries were carried in: a gateway (Pro Gallery app) gallery is read live, but
|
|
240
|
+
// ONLY when the tap says the list row came from the gateway (`source: "gateway"`, the row's
|
|
241
|
+
// own field). A legacy row's id and a sample row's id ("sample-work") are unknown to the
|
|
242
|
+
// gateway, and a tap that passes no source (Branded Lite's) cannot tell them apart: those
|
|
243
|
+
// open as before, by their title.
|
|
244
|
+
const api = wixApi();
|
|
245
|
+
const live = input("source") === "gateway" && !/^gallery-\d+$/.test(id);
|
|
246
|
+
if (id && !staticGalleries.length && api && live) {
|
|
247
|
+
// The read lands through `photosRead`, tagged with this open: a pooled worker shows THIS
|
|
248
|
+
// gallery loading at once (never the last one's photos), and a slower read for a gallery
|
|
249
|
+
// left since is dropped instead of overwriting the one on screen.
|
|
250
|
+
const name = input("name");
|
|
251
|
+
void readGalleryPhotos(api, id).then(
|
|
252
|
+
({ title, photos }) => svc.photosRead({ seq, title: title || name, photos }),
|
|
253
|
+
(err: unknown) => svc.photosRead({ seq, error: err }),
|
|
254
|
+
);
|
|
255
|
+
return { ...s, ...LOADING, ...presence("loading"), galleryTitle: name, photos: [], noGallery: true, hasPhotos: false,
|
|
256
|
+
photoUrl: "", photoTitle: "", hasPhoto: false, noPhoto: true };
|
|
257
|
+
}
|
|
177
258
|
const missing = !id || (staticGalleries.length > 0 && !gallery);
|
|
178
259
|
return { ...s, ...loaded("galleries", s.galleries, COPY), ...presence(missing ? "missing" : "found"), galleryTitle: gallery?.title || input("name"),
|
|
179
260
|
photos: (gallery?.photos ?? []).map((url, index) => ({ id: `${id}-${index}`, url, title: `Photo ${index + 1}` })),
|
|
180
261
|
noGallery: missing, hasPhotos: !!gallery?.photos.length, noPhoto: true };
|
|
181
262
|
},
|
|
263
|
+
photosRead: (s, arg) => {
|
|
264
|
+
if (!arg || arg.seq !== openSeq) return undefined;
|
|
265
|
+
if ("error" in arg) {
|
|
266
|
+
// A 404 is the gallery being GONE — the not-found state alone, never also an error card
|
|
267
|
+
// whose Retry cannot succeed. Any other failure is the error state.
|
|
268
|
+
return { ...s, ...(isNotFoundError(arg.error) ? { ...loaded("galleries", s.galleries, COPY), ...presence("missing") } : { ...loadFailed("galleries", arg.error, COPY), ...presence("failed") }),
|
|
269
|
+
galleryTitle: "", photos: [], noGallery: true, hasPhotos: false, photoUrl: "", photoTitle: "", hasPhoto: false, noPhoto: true };
|
|
270
|
+
}
|
|
271
|
+
return { ...s, ...loaded("galleries", s.galleries, COPY), ...presence("found"), galleryTitle: arg.title ?? "",
|
|
272
|
+
photos: arg.photos ?? [], noGallery: false, hasPhotos: (arg.photos ?? []).length > 0, photoUrl: "", photoTitle: "", hasPhoto: false, noPhoto: true };
|
|
273
|
+
},
|
|
182
274
|
view: (s) => {
|
|
183
275
|
const photoUrl = input("url");
|
|
184
276
|
const valid = /^https:\/\/static\.wixstatic\.com\/media\/[A-Za-z0-9_~.-]+$/.test(photoUrl);
|
|
@@ -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;
|
|
@@ -35,11 +35,14 @@ export interface LoadFlags {
|
|
|
35
35
|
notMock: boolean;
|
|
36
36
|
notMockEmpty: boolean;
|
|
37
37
|
notMockUnavailable: boolean;
|
|
38
|
+
/** hiddenWhen gate for the empty state: false ONLY when the site answered with no rows
|
|
39
|
+
* (`notMockEmpty` is true then — it gates the sample banner, not the empty state). */
|
|
40
|
+
hideEmpty: boolean;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
export const LOADING: LoadFlags = {
|
|
41
44
|
status: "loading", error: "", signInRequired: false, notErrored: true, notLoading: false, busy: true, noSignIn: true,
|
|
42
|
-
isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true,
|
|
45
|
+
isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true, hideEmpty: true,
|
|
43
46
|
};
|
|
44
47
|
|
|
45
48
|
const READY: LoadFlags = { ...LOADING, status: "ready", notLoading: true, busy: false };
|
|
@@ -78,7 +81,7 @@ type Rows<K extends string, T> = { [P in K]: T[] } & { emptyText: string } & Loa
|
|
|
78
81
|
export function loaded<K extends string, T>(key: K, rows: T[], copy: LoadCopy<T>): Rows<K, T> {
|
|
79
82
|
if (rows.length) return { ...READY, [key]: rows, emptyText: "" } as Rows<K, T>;
|
|
80
83
|
if (samplesRequested()) return sampleRows(key, copy, "empty");
|
|
81
|
-
return { ...READY, [key]: [], emptyText: copyParam(`${copy.prefix}.emptyText`, copy.empty) } as Rows<K, T>;
|
|
84
|
+
return { ...READY, [key]: [], emptyText: copyParam(`${copy.prefix}.emptyText`, copy.empty), hideEmpty: false } as Rows<K, T>;
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
/** A query threw. Never rows the customer did not create. */
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { dispatch } from "@himalaya/dispatch";
|
|
2
2
|
// Relative for node resolution (see himi-store.ts note); rewritten off-repo.
|
|
3
3
|
import { displayName, getMyData, initials, type WixMyData } from "@himalaya/wix-users";
|
|
4
|
+
import { getMyMember } from "@himalaya/wix-members";
|
|
5
|
+
import { wixMediaUrl } from "@himalaya/wix-media";
|
|
4
6
|
import { formatDateMedium, formatWeekdayDateTime } from "../_shared/engine-intl.js";
|
|
5
7
|
|
|
6
8
|
export interface AccountProfile {
|
|
@@ -82,6 +84,31 @@ function profileFromData(data: WixMyData, fallbackEmail = ""): AccountProfile {
|
|
|
82
84
|
};
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
/** The signed-in site member's name and photo from their member record, or undefined when it
|
|
88
|
+
* cannot be read or names nobody (the caller then shows the email). */
|
|
89
|
+
async function siteMemberProfile(email: string): Promise<AccountProfile | undefined> {
|
|
90
|
+
try {
|
|
91
|
+
const member = (await getMyMember({ fieldsets: ["FULL"] }))?.member;
|
|
92
|
+
const name = [member?.contact?.firstName, member?.contact?.lastName].map((part) => (part ?? "").trim()).filter(Boolean).join(" ")
|
|
93
|
+
|| (member?.profile?.nickname ?? "").trim();
|
|
94
|
+
if (!name) return undefined;
|
|
95
|
+
const image = wixMediaUrl(member?.profile?.photo?.url) || wixMediaUrl(member?.profile?.photo?.id) || "";
|
|
96
|
+
return {
|
|
97
|
+
isMember: true,
|
|
98
|
+
identityLine: "Signed in",
|
|
99
|
+
detailLine: email || name,
|
|
100
|
+
memberName: name,
|
|
101
|
+
memberEmail: email || member?.loginEmail || "",
|
|
102
|
+
memberImage: image,
|
|
103
|
+
memberInitials: initialsFromName(name) || "M",
|
|
104
|
+
hasMemberImage: !!image,
|
|
105
|
+
noMemberImage: !image,
|
|
106
|
+
};
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
85
112
|
export function guestProfile(detailLine: string): AccountProfile {
|
|
86
113
|
return {
|
|
87
114
|
isMember: false,
|
|
@@ -118,6 +145,10 @@ export async function loadMemberProfile(guestDetailLine: string): Promise<Accoun
|
|
|
118
145
|
const data = await getMyData();
|
|
119
146
|
return profileFromData(data, storedEmail);
|
|
120
147
|
} catch {
|
|
148
|
+
// The account profile is an owner endpoint: the web host refuses it, and a site member need
|
|
149
|
+
// not have one. The member's own record (the Members API) still names them.
|
|
150
|
+
const member = await siteMemberProfile(storedEmail);
|
|
151
|
+
if (member) return member;
|
|
121
152
|
return {
|
|
122
153
|
isMember: true,
|
|
123
154
|
identityLine: "Signed in",
|