@wix/himalaya-cli 0.1575.0 → 0.1577.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/loyalty/src/index.d.ts +64 -6
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/online-programs/src/index.d.ts +87 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-loyalty.mjs +15 -3
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-online-programs.mjs +42 -0
- package/dist/cli.mjs +10821 -10593
- package/dist/preview-assets.generated.bin +0 -0
- package/fixtures/wix-canned.json +56 -3
- package/guides/himi-json-output.md +8 -0
- package/guides/one-app-section-mappings.md +1 -1
- package/guides/one-app-to-himi-app.md +50 -1
- package/guides/sdk-reference.json +133 -9
- package/package.json +2 -2
- package/templates/beauty-salon/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/beauty-salon/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/beauty-salon-owner/dev/index.ts +52 -27
- package/templates/beauty-salon-owner/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-post/index.ts +20 -8
- package/templates/branded-lite/config.ts +7 -0
- package/templates/branded-lite/dev/index.ts +833 -0
- package/templates/branded-lite/himalaya.content.json +3 -0
- package/templates/branded-lite/l10n/en.json +4 -2
- package/templates/branded-lite/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/branded-lite/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/branded-lite/tier5-src/himi-branded-lite-member-programs/index.ts +658 -0
- package/templates/community-club/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/community-club/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/community-club-owner/dev/index.ts +52 -27
- package/templates/community-club-owner/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/community-club-owner/tier5-src/himi-owner-lite-post/index.ts +20 -8
- package/templates/events-meetups/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/events-meetups/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/events-meetups-owner/dev/index.ts +52 -27
- package/templates/events-meetups-owner/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-post/index.ts +20 -8
- package/templates/gym-fitness/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/gym-fitness/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/gym-fitness-owner/dev/index.ts +52 -27
- package/templates/gym-fitness-owner/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-post/index.ts +20 -8
- package/templates/himi-chat/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/nonprofit-cause/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/nonprofit-cause/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/nonprofit-cause-owner/dev/index.ts +52 -27
- package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-post/index.ts +20 -8
- package/templates/owner-lite/dev/index.ts +52 -27
- package/templates/owner-lite/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/owner-lite/tier5-src/himi-owner-lite-post/index.ts +20 -8
- package/templates/podcast-creator/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/podcast-creator/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/podcast-creator-owner/dev/index.ts +52 -27
- package/templates/podcast-creator-owner/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-post/index.ts +20 -8
- package/templates/portfolio-creative/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/portfolio-creative/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/portfolio-creative-owner/dev/index.ts +52 -27
- package/templates/portfolio-creative-owner/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-post/index.ts +20 -8
- package/templates/restaurant/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/restaurant/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/restaurant-owner/dev/index.ts +52 -27
- package/templates/restaurant-owner/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-post/index.ts +20 -8
- package/templates/retail-store/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/retail-store/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/retail-store-owner/dev/index.ts +52 -27
- package/templates/retail-store-owner/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-post/index.ts +20 -8
- package/templates/yoga-studio/config.ts +7 -0
- package/templates/yoga-studio/dev/index.ts +833 -0
- package/templates/yoga-studio/himalaya.content.json +3 -0
- package/templates/yoga-studio/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/yoga-studio/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/yoga-studio/tier5-src/himi-branded-lite-member-programs/index.ts +658 -0
- package/templates/yoga-studio-owner/dev/index.ts +52 -27
- package/templates/yoga-studio-owner/tier5-src/_shared/owner-load-error.ts +8 -0
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-post/index.ts +20 -8
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
// himi_member_programs — the MEMBER'S programs list: every published program decorated with the
|
|
2
|
+
// signed-in member's standing (Joined / Completed / Ended / Pending / Approved / Suspended), a
|
|
3
|
+
// progress bar once they started, the App Store or Wix price before they join, and two filters —
|
|
4
|
+
// "All programs / Programs I joined" and a program category. The counterpart of the One App
|
|
5
|
+
// `MemberChallengesList` section (wix-one-app-achievements): same reads, same card rules.
|
|
6
|
+
//
|
|
7
|
+
// Reads per page (all v3, member identity): queryPrograms (the page) → in parallel
|
|
8
|
+
// getParticipationStats (counts), the member's participations (one cursor walk, cached for the
|
|
9
|
+
// scope) and, for an unjoined private, secret or priced program, its current join application. The
|
|
10
|
+
// "joined" filter walks the participations FIRST and queries programs by id, as the One App does.
|
|
11
|
+
//
|
|
12
|
+
// Not a `defineBackendCollection`: the shared collection chrome replaces the row with its generic
|
|
13
|
+
// card and carries one filter axis, while this card needs a status label over the cover, a
|
|
14
|
+
// ProgressBar and two filters. The shared query controller is reused as it is.
|
|
15
|
+
import { defineService as register, navigate } from "@himalaya/state";
|
|
16
|
+
import {
|
|
17
|
+
queryPrograms, getParticipationStats, queryMemberParticipants, getCurrentJoinApplication, listProgramCategories,
|
|
18
|
+
type WixProgram, type WixProgramFull, type WixParticipant, type WixJoinApplication, type WixProgramCategory,
|
|
19
|
+
} from "@himalaya/wix-online-programs";
|
|
20
|
+
import { getMyMember } from "@himalaya/wix-members";
|
|
21
|
+
import { iapPriceFields, onlineProgramItem, type IapPollDirective } from "@himalaya/iap";
|
|
22
|
+
import { createIapCommerce } from "@himalaya/iap-checkout";
|
|
23
|
+
import { createCollectionQuery, type CollectionPage, type CollectionQueryState } from "../_shared/collection-query.js";
|
|
24
|
+
import { memberAccess, memberAccessChanged, memberAccessDenied, type MemberAccess } from "../_shared/member-access.js";
|
|
25
|
+
import { cleanTitle, initials } from "../_shared/himi-text.js";
|
|
26
|
+
import { programImage } from "../_shared/himi-media.js";
|
|
27
|
+
import { descriptionBody } from "../_shared/rich-body.js";
|
|
28
|
+
import { programDescription } from "../_shared/program-description.js";
|
|
29
|
+
import { isNotFoundError } from "../_shared/detail-presence.js";
|
|
30
|
+
import { copyParam, copyTemplate, stashCopy } from "../_shared/himi-copy.js";
|
|
31
|
+
import { formatCurrency, formatDateMedium } from "../_shared/engine-intl.js";
|
|
32
|
+
import { iapPriceCopy, resetUnhydratedKeysOnOlderRuntime } from "../_shared/iap-worker.js";
|
|
33
|
+
|
|
34
|
+
export const SERVICE = "himiBrandedMemberPrograms";
|
|
35
|
+
const PAGE_SIZE = 20;
|
|
36
|
+
const DETAIL_SCREEN = "himi_program";
|
|
37
|
+
|
|
38
|
+
// ── Row model ────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/** The member's standing on one program — the One App participant/application states, folded. */
|
|
41
|
+
export type Standing = "none" | "joined" | "completed" | "finished" | "suspended" | "pending" | "approved" | "invited";
|
|
42
|
+
|
|
43
|
+
/** A card as loaded, before pricing. */
|
|
44
|
+
export interface ProgramContent {
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
description: string;
|
|
48
|
+
image: string;
|
|
49
|
+
initial: string;
|
|
50
|
+
hasImage: boolean;
|
|
51
|
+
noImage: boolean;
|
|
52
|
+
timelineLine: string;
|
|
53
|
+
/** "Public · 12 participants" — hidden once joined or over, as on the One App card. */
|
|
54
|
+
accessLine: string;
|
|
55
|
+
standing: Standing;
|
|
56
|
+
statusLabel: string;
|
|
57
|
+
statusIcon: string;
|
|
58
|
+
statusColor: string;
|
|
59
|
+
hasStatus: boolean;
|
|
60
|
+
noStatus: boolean;
|
|
61
|
+
/** The sentence under the lines: "Join request sent", "Finished with 40% progress", … */
|
|
62
|
+
descriptionLine: string;
|
|
63
|
+
/** 0..1 — shown as a ProgressBar while joined and started. */
|
|
64
|
+
progress: number;
|
|
65
|
+
progressLabel: string;
|
|
66
|
+
hasProgress: boolean;
|
|
67
|
+
noProgress: boolean;
|
|
68
|
+
/** The row may show a price: not joined, not over. */
|
|
69
|
+
pricable: boolean;
|
|
70
|
+
/** The commerce (Wix) price as text, for the regular route; "" when free or unknown. */
|
|
71
|
+
commercePrice: string;
|
|
72
|
+
free: boolean;
|
|
73
|
+
actionLabel: string;
|
|
74
|
+
actionDisabled: boolean;
|
|
75
|
+
detailScreenId: string;
|
|
76
|
+
categoryIds: string[];
|
|
77
|
+
}
|
|
78
|
+
export interface ProgramRow extends ProgramContent {
|
|
79
|
+
priceLine: string;
|
|
80
|
+
hasPrice: boolean;
|
|
81
|
+
noPrice: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface Option { id: string; label: string }
|
|
85
|
+
|
|
86
|
+
export interface State {
|
|
87
|
+
status: "loading" | "ready" | "error";
|
|
88
|
+
rows: ProgramRow[];
|
|
89
|
+
isLoading: boolean;
|
|
90
|
+
notLoading: boolean;
|
|
91
|
+
isErrored: boolean;
|
|
92
|
+
notErrored: boolean;
|
|
93
|
+
noEmpty: boolean;
|
|
94
|
+
hideList: boolean;
|
|
95
|
+
emptyText: string;
|
|
96
|
+
errorText: string;
|
|
97
|
+
identityLine: string;
|
|
98
|
+
needsSignIn: boolean;
|
|
99
|
+
noSignIn: boolean;
|
|
100
|
+
nextCursor: string;
|
|
101
|
+
noMore: boolean;
|
|
102
|
+
noPageError: boolean;
|
|
103
|
+
loadingMore: boolean;
|
|
104
|
+
statusFilter: "all" | "joined";
|
|
105
|
+
statusOptions: Option[];
|
|
106
|
+
hideStatusFilter: boolean;
|
|
107
|
+
categoryId: string;
|
|
108
|
+
categoryChips: Option[];
|
|
109
|
+
hideCategoryFilter: boolean;
|
|
110
|
+
hidePriceRetry: boolean;
|
|
111
|
+
priceRetryText: string;
|
|
112
|
+
summary: string;
|
|
113
|
+
_poll?: IapPollDirective;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Program fields (v3 Program DTO; the list and the detail share the shape) ─────
|
|
117
|
+
|
|
118
|
+
const full = (p: WixProgram): WixProgramFull => p as WixProgramFull;
|
|
119
|
+
|
|
120
|
+
function programTitle(p: WixProgram): string {
|
|
121
|
+
return cleanTitle(p.description?.title ?? p.settings?.title ?? p.title ?? p.details?.title)
|
|
122
|
+
|| copyParam("memberPrograms.programFallbackName", "Program");
|
|
123
|
+
}
|
|
124
|
+
function programDetails(p: WixProgram): string {
|
|
125
|
+
// The body may be a Ricos document or serialized text: read it the way the catalog and detail do.
|
|
126
|
+
const raw = descriptionBody(programDescription(p)).bodyText.replace(/\s+/g, " ").trim();
|
|
127
|
+
return raw.length > 140 ? `${raw.slice(0, 139).trimEnd()}…` : raw;
|
|
128
|
+
}
|
|
129
|
+
function programStatus(p: WixProgram): string {
|
|
130
|
+
return (p.status ?? p.state ?? "").toUpperCase();
|
|
131
|
+
}
|
|
132
|
+
const isEnded = (p: WixProgram) => programStatus(p) === "ENDED" || programStatus(p) === "FINISHED";
|
|
133
|
+
const isDraft = (p: WixProgram) => programStatus(p) === "DRAFT";
|
|
134
|
+
function accessType(p: WixProgram): string {
|
|
135
|
+
return (full(p).restrictions?.accessType ?? "PUBLIC").toUpperCase();
|
|
136
|
+
}
|
|
137
|
+
function maxParticipants(p: WixProgram): number {
|
|
138
|
+
const value = Number((full(p).restrictions as { maxParticipants?: unknown } | undefined)?.maxParticipants ?? 0);
|
|
139
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
140
|
+
}
|
|
141
|
+
function stepCount(p: WixProgram): number {
|
|
142
|
+
const value = Number(full(p).contentSummary?.stepCount ?? 0);
|
|
143
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
144
|
+
}
|
|
145
|
+
function categoryIdsOf(p: WixProgram): string[] {
|
|
146
|
+
const ids = (p as { categoryIds?: unknown }).categoryIds;
|
|
147
|
+
return Array.isArray(ids) ? ids.filter((id): id is string => typeof id === "string") : [];
|
|
148
|
+
}
|
|
149
|
+
/** The commerce price as text — "" when the program is free or the price is malformed. */
|
|
150
|
+
function commercePriceOf(p: WixProgram): string {
|
|
151
|
+
const price = full(p).price;
|
|
152
|
+
const amount = Number(price?.value);
|
|
153
|
+
if (!price?.currency || !Number.isFinite(amount) || amount <= 0) return "";
|
|
154
|
+
try { return formatCurrency(amount, price.currency); } catch { return `${price.value} ${price.currency}`; }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function date(value?: string): string {
|
|
158
|
+
if (!value) return "";
|
|
159
|
+
const at = new Date(value);
|
|
160
|
+
return Number.isNaN(at.getTime()) ? "" : formatDateMedium(at);
|
|
161
|
+
}
|
|
162
|
+
function daysBetween(fromIso: string, now: number): number {
|
|
163
|
+
const from = Date.parse(fromIso);
|
|
164
|
+
return Number.isFinite(from) ? Math.floor((now - from) / 86_400_000) : NaN;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Member standing ──────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
function standingOf(p: WixProgram, participant: WixParticipant | undefined, application: WixJoinApplication | undefined): Standing {
|
|
170
|
+
if (participant) {
|
|
171
|
+
const progress = (participant.progressStatus ?? "").toUpperCase();
|
|
172
|
+
const enrollment = (participant.enrollmentStatus ?? "").toUpperCase();
|
|
173
|
+
if (progress === "COMPLETED") return "completed";
|
|
174
|
+
if (progress === "FAILED" || isEnded(p)) return "finished";
|
|
175
|
+
if (enrollment === "SUSPENDED") return "suspended";
|
|
176
|
+
return "joined";
|
|
177
|
+
}
|
|
178
|
+
const status = (application?.status ?? "").toUpperCase();
|
|
179
|
+
const payment = (application?.paymentStatus ?? "").toUpperCase();
|
|
180
|
+
if (status === "APPROVAL_PENDING") return "pending";
|
|
181
|
+
if (status === "APPROVED") {
|
|
182
|
+
// The same reading as joinProgram: a free or settled approval is enrolling, and an offline payment
|
|
183
|
+
// waits on the owner. Only an approval that still owes money asks the member to pay.
|
|
184
|
+
if (payment === "NO_PAYMENT_REQUIRED" || payment === "PAYMENT_SUCCESSFUL") return application?.participantId ? "joined" : "pending";
|
|
185
|
+
if (payment === "PENDING_OWNER_APPROVAL") return "pending";
|
|
186
|
+
return "approved";
|
|
187
|
+
}
|
|
188
|
+
if (status === "PAYMENT_PENDING") return "approved";
|
|
189
|
+
if (status === "INVITED") return "invited";
|
|
190
|
+
return "none";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const STATUS: Record<Exclude<Standing, "none">, { key: string; fallback: string; icon: string; color: string }> = {
|
|
194
|
+
joined: { key: "memberPrograms.status.joined", fallback: "Joined", icon: "checkmark", color: "accentPrimary" },
|
|
195
|
+
completed: { key: "memberPrograms.status.completed", fallback: "Program complete", icon: "trophy", color: "success" },
|
|
196
|
+
finished: { key: "memberPrograms.status.ended", fallback: "Program ended", icon: "flag", color: "textSecondary" },
|
|
197
|
+
suspended: { key: "memberPrograms.status.suspended", fallback: "Participation suspended", icon: "close", color: "warning" },
|
|
198
|
+
pending: { key: "memberPrograms.status.pending", fallback: "Pending request", icon: "clock", color: "warning" },
|
|
199
|
+
approved: { key: "memberPrograms.status.approved", fallback: "Request approved", icon: "checkmark", color: "success" },
|
|
200
|
+
invited: { key: "memberPrograms.status.invited", fallback: "Invited", icon: "mail", color: "accentPrimary" },
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
function timelineLine(p: WixProgram, participant: WixParticipant | undefined, standing: Standing, now: number): string {
|
|
204
|
+
const timeline = full(p).timeline;
|
|
205
|
+
const duration = Number(timeline?.durationInDays ?? 0);
|
|
206
|
+
const days = Number.isFinite(duration) && duration > 0 ? duration : 0;
|
|
207
|
+
const started = participant?.enrollmentInfo?.startDate;
|
|
208
|
+
if ((standing === "joined" || standing === "suspended") && started && daysBetween(started, now) >= 0) {
|
|
209
|
+
const day = daysBetween(started, now) + 1;
|
|
210
|
+
return days
|
|
211
|
+
? copyTemplate("memberPrograms.timeline.startedOf", "Started {date} · day {day} of {days}", { date: date(started), day, days })
|
|
212
|
+
: copyTemplate("memberPrograms.timeline.started", "Started {date} · day {day}", { date: date(started), day });
|
|
213
|
+
}
|
|
214
|
+
if (standing === "completed" || standing === "finished") {
|
|
215
|
+
// The deadline is the enrollment's scheduled end: it is the end date only once it has passed.
|
|
216
|
+
const ended = participant?.enrollmentInfo?.deadline;
|
|
217
|
+
return ended && Date.parse(ended) <= now ? copyTemplate("memberPrograms.timeline.ended", "Ended {date}", { date: date(ended) }) : "";
|
|
218
|
+
}
|
|
219
|
+
if (timeline?.selfPaced) {
|
|
220
|
+
return days
|
|
221
|
+
? copyTemplate("memberPrograms.timeline.days", "{days} days · self-paced", { days })
|
|
222
|
+
: copyParam("memberPrograms.timeline.selfPaced", "Self-paced");
|
|
223
|
+
}
|
|
224
|
+
if (timeline?.startDate) {
|
|
225
|
+
const start = date(timeline.startDate);
|
|
226
|
+
if (!start) return "";
|
|
227
|
+
return days
|
|
228
|
+
? copyTemplate("memberPrograms.timeline.scheduled", "Starts {date} · {days} days", { date: start, days })
|
|
229
|
+
: copyTemplate("memberPrograms.timeline.starts", "Starts {date}", { date: start });
|
|
230
|
+
}
|
|
231
|
+
return days ? copyTemplate("memberPrograms.timeline.daysOnly", "{days} days", { days }) : "";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function accessLine(p: WixProgram, participants: number | undefined): string {
|
|
235
|
+
const access = accessType(p);
|
|
236
|
+
const label = access === "PRIVATE" ? copyParam("memberPrograms.access.private", "Private")
|
|
237
|
+
: access === "SECRET" ? copyParam("memberPrograms.access.secret", "Secret")
|
|
238
|
+
: copyParam("memberPrograms.access.public", "Public");
|
|
239
|
+
if (participants === undefined) return label;
|
|
240
|
+
const count = participants === 1
|
|
241
|
+
? copyParam("memberPrograms.participantsOne", "1 participant")
|
|
242
|
+
: copyTemplate("memberPrograms.participantsMany", "{count} participants", { count: participants });
|
|
243
|
+
return `${label} · ${count}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function progressOf(p: WixProgram, participant: WixParticipant | undefined): number {
|
|
247
|
+
const done = Number(participant?.progress?.totalStepsCompleted ?? 0);
|
|
248
|
+
const total = Number(participant?.progress?.totalStepsAvailable ?? 0) || stepCount(p);
|
|
249
|
+
const pct = Number(participant?.progress?.completionPercentage);
|
|
250
|
+
if (Number.isFinite(pct) && pct > 0) return Math.min(1, pct / 100);
|
|
251
|
+
if (!total || !Number.isFinite(done) || done <= 0) return 0;
|
|
252
|
+
return Math.min(1, done / total);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function programContent(o: { program: WixProgram; participant?: WixParticipant; application?: WixJoinApplication; applicationUnknown?: boolean; participationsUnknown?: boolean; participants?: number; member: boolean; now: number }): ProgramContent {
|
|
256
|
+
const { program: p } = o;
|
|
257
|
+
const standing = standingOf(p, o.participant, o.application);
|
|
258
|
+
const over = standing === "completed" || standing === "finished";
|
|
259
|
+
const joined = standing === "joined" || standing === "suspended";
|
|
260
|
+
const status = standing === "none" ? undefined : STATUS[standing];
|
|
261
|
+
const progress = joined ? progressOf(p, o.participant) : 0;
|
|
262
|
+
const pct = Math.round(progress * 100);
|
|
263
|
+
const started = joined && progress > 0;
|
|
264
|
+
const image = programImage(p);
|
|
265
|
+
const name = programTitle(p);
|
|
266
|
+
// An ended program with no known participation (a degraded participations read) offers no join and no price.
|
|
267
|
+
const closed = standing === "none" && isEnded(p);
|
|
268
|
+
const capped = standing === "none" && !closed && maxParticipants(p) > 0;
|
|
269
|
+
const countFull = capped && o.participants !== undefined && o.participants >= maxParticipants(p);
|
|
270
|
+
// Not knowing the count of a capped program, or whether an application exists, the card claims neither
|
|
271
|
+
// "Join" nor "Program is full": View, and the detail screen decides.
|
|
272
|
+
// A failed participations read leaves every unjoined-looking card unknown too: the member may be in it.
|
|
273
|
+
const standingUnknown = standing === "none" && o.member
|
|
274
|
+
&& (!!o.applicationUnknown || !!o.participationsUnknown || (capped && o.participants === undefined));
|
|
275
|
+
// "Full" only for a member whose own standing is known: an unknown one may already be in it.
|
|
276
|
+
const limitReached = countFull && !standingUnknown;
|
|
277
|
+
const commercePrice = commercePriceOf(p);
|
|
278
|
+
const descriptionLine = standing === "completed" ? copyParam("memberPrograms.line.completed", "Completed")
|
|
279
|
+
: standing === "finished" ? copyTemplate("memberPrograms.line.finished", "Finished with {pct}% progress", { pct: Math.round(progressOf(p, o.participant) * 100) })
|
|
280
|
+
: standing === "pending" ? copyParam("memberPrograms.line.pending", "Join request sent")
|
|
281
|
+
: standing === "approved" ? copyParam("memberPrograms.line.approved", "Request approved · complete your payment")
|
|
282
|
+
: standing === "invited" ? copyParam("memberPrograms.line.invited", "You're invited to join")
|
|
283
|
+
: standing === "suspended" ? copyParam("memberPrograms.line.suspended", "Your participation is paused")
|
|
284
|
+
: "";
|
|
285
|
+
// An invitation is accepted on the website (joinProgram cannot accept one), so an invited card's CTA is View.
|
|
286
|
+
const actionLabel = !o.member ? copyParam("memberPrograms.action.view", "View")
|
|
287
|
+
: limitReached ? copyParam("memberPrograms.action.full", "Program is full")
|
|
288
|
+
: joined || over || closed || standing === "pending" || standing === "invited" || standingUnknown ? copyParam("memberPrograms.action.view", "View")
|
|
289
|
+
: standing === "approved" ? copyParam("memberPrograms.action.pay", "Complete payment")
|
|
290
|
+
: accessType(p) === "PRIVATE" ? copyParam("memberPrograms.action.request", "Request to join")
|
|
291
|
+
// Invite-only: with no invitation there is nothing to join from here.
|
|
292
|
+
: accessType(p) === "SECRET" ? copyParam("memberPrograms.action.view", "View")
|
|
293
|
+
: commercePrice ? copyParam("memberPrograms.action.joinPay", "Join & pay")
|
|
294
|
+
: copyParam("memberPrograms.action.join", "Join");
|
|
295
|
+
return {
|
|
296
|
+
id: p.id, name, description: programDetails(p), image, initial: initials(name) || "P", hasImage: !!image, noImage: !image,
|
|
297
|
+
timelineLine: timelineLine(p, o.participant, standing, o.now),
|
|
298
|
+
accessLine: joined || over || closed ? "" : accessLine(p, o.participants),
|
|
299
|
+
standing,
|
|
300
|
+
statusLabel: status ? copyParam(status.key, status.fallback) : "", statusIcon: status?.icon ?? "", statusColor: status?.color ?? "textSecondary",
|
|
301
|
+
hasStatus: !!status, noStatus: !status,
|
|
302
|
+
descriptionLine,
|
|
303
|
+
progress, progressLabel: started ? copyTemplate("memberPrograms.progressLabel", "{pct}% complete", { pct }) : "",
|
|
304
|
+
hasProgress: started, noProgress: !started,
|
|
305
|
+
pricable: !joined && !over && !closed && !descriptionLine,
|
|
306
|
+
commercePrice, free: !commercePrice,
|
|
307
|
+
// Only a member's CTA is the join action; a guest's "View" stays a navigation.
|
|
308
|
+
actionLabel, actionDisabled: limitReached && o.member,
|
|
309
|
+
detailScreenId: DETAIL_SCREEN,
|
|
310
|
+
categoryIds: categoryIdsOf(p),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Reads ────────────────────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
interface Scope { email: string; memberId: string; scope: string }
|
|
317
|
+
interface PageMeta extends Record<string, unknown> { email: string; memberId: string; scope: string; needsSignIn: boolean; joinedFilter: boolean }
|
|
318
|
+
|
|
319
|
+
type Participations = Map<string, WixParticipant>;
|
|
320
|
+
/** The member's participations for this scope — walked once per first page, reused by its later pages. */
|
|
321
|
+
let participationCache: { key: string; value: Participations } | undefined;
|
|
322
|
+
/** Keyed by email AND tenant scope: one email is a different Wix member on every site. */
|
|
323
|
+
let memberIdCache: { email: string; scope: string; id: string } | undefined;
|
|
324
|
+
/** Whether the last scope read had a signed-in member — survives a failed page, whose metadata the controller drops. */
|
|
325
|
+
let memberSeen = false;
|
|
326
|
+
|
|
327
|
+
async function resolveScope(): Promise<Scope> {
|
|
328
|
+
const access: MemberAccess = await memberAccess();
|
|
329
|
+
memberSeen = !!access.email;
|
|
330
|
+
if (!access.email) return { email: "", memberId: "", scope: "" };
|
|
331
|
+
let cached = memberIdCache;
|
|
332
|
+
if (cached?.email !== access.email || cached.scope !== access.scope) {
|
|
333
|
+
// A failed lookup is an unknown id, like a record with none: the catalog degrades, the joined filter errors.
|
|
334
|
+
const response = await getMyMember().catch(() => undefined);
|
|
335
|
+
cached = { email: access.email, scope: access.scope, id: String(response?.member?.id ?? "") };
|
|
336
|
+
// A member record with no id yet is not an answer to keep: the next read asks again.
|
|
337
|
+
memberIdCache = cached.id ? cached : undefined;
|
|
338
|
+
}
|
|
339
|
+
return { email: access.email, memberId: cached.id, scope: access.scope };
|
|
340
|
+
}
|
|
341
|
+
// Email and tenant scope ARE the member; the id is derived from them, and may only now have resolved
|
|
342
|
+
// (a lookup that failed once, then succeeded) — the same member, not a changed one.
|
|
343
|
+
const sameScope = (a: Scope, b: Scope) => a.email === b.email && a.scope === b.scope;
|
|
344
|
+
|
|
345
|
+
async function walkParticipations(memberId: string): Promise<Participations> {
|
|
346
|
+
const byProgram: Participations = new Map();
|
|
347
|
+
let cursor: string | undefined;
|
|
348
|
+
for (let page = 0; page < 50; page++) {
|
|
349
|
+
const response = await queryMemberParticipants({ memberId, cursor, limit: 100 });
|
|
350
|
+
for (const participant of response.participants ?? []) {
|
|
351
|
+
if (!participant.programId || participant.memberId !== memberId) continue;
|
|
352
|
+
const current = byProgram.get(participant.programId);
|
|
353
|
+
// Duplicate enrollments: the latest createdDate wins (the One App's rule).
|
|
354
|
+
if (!current || Date.parse(participant.createdDate ?? "") > Date.parse(current.createdDate ?? "")) byProgram.set(participant.programId, participant);
|
|
355
|
+
}
|
|
356
|
+
const paging = response.pagingMetadata as { hasNext?: boolean; cursors?: { next?: string | null } } | undefined;
|
|
357
|
+
const next = paging?.cursors?.next ?? "";
|
|
358
|
+
if (paging?.hasNext === false) break;
|
|
359
|
+
// More pages promised but none reachable: the map is incomplete, and an incomplete map is a failed read.
|
|
360
|
+
if (!next) { if (paging?.hasNext) throw new Error("Participants response is missing its next-page cursor"); break; }
|
|
361
|
+
if (next === cursor) throw new Error("Participants response repeated its cursor");
|
|
362
|
+
cursor = next;
|
|
363
|
+
// The last page allowed still points at another: what was read is not the whole membership.
|
|
364
|
+
if (page === 49) throw new Error("Participants walk truncated");
|
|
365
|
+
}
|
|
366
|
+
return byProgram;
|
|
367
|
+
}
|
|
368
|
+
/** `required` (the "Programs I joined" filter) lets a failure through: that list IS the member's
|
|
369
|
+
* participations. The catalog degrades instead — every program shows, without the member's standing. */
|
|
370
|
+
async function participationsFor(scope: Scope, firstPage: boolean, required: boolean): Promise<{ map: Participations; complete: boolean }> {
|
|
371
|
+
// A guest has no participations; a signed-in member whose id is unknown has unknown ones.
|
|
372
|
+
if (!scope.memberId) {
|
|
373
|
+
if (scope.email && required) throw new Error("Current member unavailable");
|
|
374
|
+
return { map: new Map(), complete: !scope.email };
|
|
375
|
+
}
|
|
376
|
+
const key = `${scope.memberId}\u0000${scope.scope}`;
|
|
377
|
+
if (!firstPage && participationCache?.key === key) return { map: participationCache.value, complete: true };
|
|
378
|
+
try {
|
|
379
|
+
const value = await walkParticipations(scope.memberId);
|
|
380
|
+
participationCache = { key, value };
|
|
381
|
+
return { map: value, complete: true };
|
|
382
|
+
} catch (error) {
|
|
383
|
+
if (required) throw error;
|
|
384
|
+
participationCache = undefined;
|
|
385
|
+
return { map: new Map(), complete: false };
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Only where an application can exist: an unjoined PRIVATE, SECRET (invite-only) or priced program, for a member. */
|
|
390
|
+
async function applicationsFor(programs: WixProgram[], participations: Participations, scope: Scope): Promise<{ map: Map<string, WixJoinApplication>; unknown: Set<string> }> {
|
|
391
|
+
const result = new Map<string, WixJoinApplication>();
|
|
392
|
+
// A failed read is not "no application": those programs make no join claim (see programContent).
|
|
393
|
+
const unknown = new Set<string>();
|
|
394
|
+
if (!scope.memberId) return { map: result, unknown };
|
|
395
|
+
const candidates = programs.filter((p) => !participations.has(p.id) && (accessType(p) === "PRIVATE" || accessType(p) === "SECRET" || !!commercePriceOf(p)));
|
|
396
|
+
const settled = await Promise.allSettled(candidates.map((p) => getCurrentJoinApplication(p.id)));
|
|
397
|
+
settled.forEach((outcome, i) => {
|
|
398
|
+
// 404 / NOT_FOUND is the endpoint's "this member never applied"; any other rejection is unknown.
|
|
399
|
+
if (outcome.status === "rejected") { if (!isNotFoundError(outcome.reason)) unknown.add(candidates[i].id); }
|
|
400
|
+
else if (outcome.value.joinApplication?.status) result.set(candidates[i].id, outcome.value.joinApplication);
|
|
401
|
+
});
|
|
402
|
+
return { map: result, unknown };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function statsFor(programs: WixProgram[]): Promise<Map<string, number>> {
|
|
406
|
+
const counts = new Map<string, number>();
|
|
407
|
+
// Only a count something reported: an absent one stays unknown, never a fabricated 0.
|
|
408
|
+
for (const p of programs) {
|
|
409
|
+
const own = Number(p.participantsCount ?? p.participants?.count);
|
|
410
|
+
if (Number.isFinite(own)) counts.set(p.id, own);
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
const response = await getParticipationStats(programs.map((p) => p.id));
|
|
414
|
+
for (const stat of response.participationStats ?? []) {
|
|
415
|
+
if (stat.programId && typeof stat.participantsCount === "number") counts.set(stat.programId, stat.participantsCount);
|
|
416
|
+
}
|
|
417
|
+
} catch { /* the program's own count stands */ }
|
|
418
|
+
return counts;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
let categories: WixProgramCategory[] | undefined;
|
|
422
|
+
let categoriesFailed = false;
|
|
423
|
+
async function loadCategories(): Promise<void> {
|
|
424
|
+
try {
|
|
425
|
+
const response = await listProgramCategories();
|
|
426
|
+
categories = (response.categories ?? []).filter((c) => c.id && c.label);
|
|
427
|
+
categoriesFailed = false;
|
|
428
|
+
// A picked category the site no longer has would keep the query filtered behind a hidden control.
|
|
429
|
+
if (categoryId && !categories.some((c) => c.id === categoryId)) categoryId = "";
|
|
430
|
+
} catch {
|
|
431
|
+
categoriesFailed = categories === undefined;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** The controller's criteria.filter carries both axes: "<status>|<categoryId>". */
|
|
436
|
+
const encodeFilter = (status: "all" | "joined", categoryId: string) => `${status}|${categoryId}`;
|
|
437
|
+
function decodeFilter(filter: string): { status: "all" | "joined"; categoryId: string } {
|
|
438
|
+
const [status, ...rest] = filter.split("|");
|
|
439
|
+
return { status: status === "joined" ? "joined" : "all", categoryId: rest.join("|") };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
let statusFilter: "all" | "joined" = "all";
|
|
443
|
+
let categoryId = "";
|
|
444
|
+
let pageScope: Scope | undefined;
|
|
445
|
+
|
|
446
|
+
async function fetchPage(filter: string, cursor: string): Promise<CollectionPage<ProgramContent>> {
|
|
447
|
+
const scope = await resolveScope();
|
|
448
|
+
if (cursor && (!pageScope || !sameScope(pageScope, scope))) throw memberAccessChanged();
|
|
449
|
+
const { status, categoryId: picked } = decodeFilter(filter);
|
|
450
|
+
const metadata: PageMeta = { email: scope.email, memberId: scope.memberId, scope: scope.scope, needsSignIn: !scope.email, joinedFilter: status === "joined" };
|
|
451
|
+
const now = Date.now();
|
|
452
|
+
if (!cursor) await loadCategories();
|
|
453
|
+
// Built from the refreshed list: a category the site no longer has (reset by loadCategories) filters nothing.
|
|
454
|
+
const category = picked && categories && !categories.some((c) => c.id === picked) ? "" : picked;
|
|
455
|
+
const { map: participations, complete: participationsKnown } = await participationsFor(scope, !cursor, status === "joined");
|
|
456
|
+
const programFilter: Record<string, unknown> = { status: { $in: ["PUBLISHED", "ENDED"] } };
|
|
457
|
+
if (category) programFilter.categoryIds = { $hasSome: [category] };
|
|
458
|
+
if (status === "joined") {
|
|
459
|
+
if (!participations.size) return { items: [], nextCursor: "", metadata };
|
|
460
|
+
programFilter.id = { $in: [...participations.keys()] };
|
|
461
|
+
}
|
|
462
|
+
// A page whose every program is dropped (ended and never joined) would leave the list hidden with
|
|
463
|
+
// nothing on screen to call loadMore, so read on until a page has rows or the catalog ends.
|
|
464
|
+
let pageCursor = cursor;
|
|
465
|
+
for (let read = 0; ; read++) {
|
|
466
|
+
// A cursor page carries only the cursor — it already encodes the filter and sort — as the
|
|
467
|
+
// participants query of the same service does.
|
|
468
|
+
const response = await queryPrograms(pageCursor
|
|
469
|
+
? { cursorPaging: { limit: PAGE_SIZE, cursor: pageCursor } }
|
|
470
|
+
: { filter: programFilter, sort: [{ fieldName: "createdDate", order: "DESC" }], cursorPaging: { limit: PAGE_SIZE } });
|
|
471
|
+
const after = await resolveScope();
|
|
472
|
+
if (!sameScope(scope, after)) throw memberAccessChanged();
|
|
473
|
+
const programs = (response.programs ?? []).filter((p) => p.id && !isDraft(p))
|
|
474
|
+
// An ended program the member never joined is not theirs to see (the One App's visibility rule) —
|
|
475
|
+
// applied only when the member's participations are known, so a degraded read hides nothing they joined.
|
|
476
|
+
.filter((p) => !isEnded(p) || participations.has(p.id) || !participationsKnown);
|
|
477
|
+
const pagingMetadata = response.pagingMetadata as { hasNext?: boolean; cursors?: { next?: string | null } } | undefined;
|
|
478
|
+
// A last page may still carry a cursor: `hasNext: false` wins, as in the catalog bundle.
|
|
479
|
+
const nextCursor = pagingMetadata?.hasNext === false ? "" : pagingMetadata?.cursors?.next ?? "";
|
|
480
|
+
if (pagingMetadata?.hasNext && !nextCursor) throw new Error("Programs response is missing its next-page cursor");
|
|
481
|
+
if (!programs.length && nextCursor && nextCursor !== pageCursor) {
|
|
482
|
+
if (read + 1 < MAX_EMPTY_READS) { pageCursor = nextCursor; continue; }
|
|
483
|
+
// Past the cap, end the list rather than hand back an empty page with a cursor nothing can load.
|
|
484
|
+
return { items: [], nextCursor: "", metadata };
|
|
485
|
+
}
|
|
486
|
+
const [counts, applications] = await Promise.all([statsFor(programs), applicationsFor(programs, participations, scope)]);
|
|
487
|
+
const items = programs.map((p) => programContent({ program: p, participant: participations.get(p.id), application: applications.map.get(p.id), applicationUnknown: applications.unknown.has(p.id), participationsUnknown: !participationsKnown, participants: counts.get(p.id), member: !!scope.memberId, now }));
|
|
488
|
+
// A revalidating load publishes nothing until it settles, so page 1 records the scope its later pages
|
|
489
|
+
// are read under here, not only from the published snapshot.
|
|
490
|
+
if (!cursor) pageScope = scope;
|
|
491
|
+
return { items, nextCursor, metadata };
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
/** How many all-dropped pages one fetch reads, in all, before it ends the list. */
|
|
495
|
+
const MAX_EMPTY_READS = 50;
|
|
496
|
+
|
|
497
|
+
// ── State ────────────────────────────────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
const commerce = createIapCommerce({ onChange: () => { void svc.pricingPublish(); } });
|
|
500
|
+
const pricing = commerce.pricing;
|
|
501
|
+
const PRICING_NOT_HYDRATED = ["rows", "hidePriceRetry"] as const;
|
|
502
|
+
let scopeIncomplete = false;
|
|
503
|
+
let page: CollectionQueryState<ProgramContent> | undefined;
|
|
504
|
+
|
|
505
|
+
const INITIAL_STATE: State = {
|
|
506
|
+
status: "loading", rows: [], isLoading: true, notLoading: false, isErrored: false, notErrored: true, noEmpty: true, hideList: true,
|
|
507
|
+
emptyText: "", errorText: "", identityLine: "", needsSignIn: false, noSignIn: true, nextCursor: "", noMore: true, noPageError: true, loadingMore: false,
|
|
508
|
+
statusFilter: "all", statusOptions: [], hideStatusFilter: true, categoryId: "", categoryChips: [], hideCategoryFilter: true,
|
|
509
|
+
hidePriceRetry: true, priceRetryText: "", summary: "",
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
function pricedRows(items: ProgramContent[]): ProgramRow[] {
|
|
513
|
+
const copy = iapPriceCopy("memberPrograms");
|
|
514
|
+
return items.map((row) => {
|
|
515
|
+
if (!row.pricable) return { ...row, priceLine: "", hasPrice: false, noPrice: true };
|
|
516
|
+
const item = onlineProgramItem({ id: row.id }, row.commercePrice);
|
|
517
|
+
const view = item ? pricing.view(item.reference) : undefined;
|
|
518
|
+
const fields = view ? iapPriceFields(view, copy) : undefined;
|
|
519
|
+
const priceLine = fields?.priceText || (row.free ? copyParam("memberPrograms.free", "Free") : row.commercePrice);
|
|
520
|
+
return { ...row, priceLine, hasPrice: !!priceLine, noPrice: !priceLine };
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function derive(state: State): State {
|
|
525
|
+
const current = page;
|
|
526
|
+
const meta = (current?.metadata ?? {}) as Partial<PageMeta>;
|
|
527
|
+
const status: State["status"] = !current || current.status === "idle" || current.status === "loading" ? "loading" : current.status === "error" ? "error" : "ready";
|
|
528
|
+
const member = !!meta.email || memberSeen;
|
|
529
|
+
const items = current?.items ?? [];
|
|
530
|
+
const rows = pricedRows(items);
|
|
531
|
+
const views = items.filter((row) => row.pricable).map((row) => { const item = onlineProgramItem({ id: row.id }, row.commercePrice); return item ? pricing.view(item.reference) : undefined; });
|
|
532
|
+
const needsSignIn = status === "ready" && !member && statusFilter === "joined";
|
|
533
|
+
const statusOptions: Option[] = [
|
|
534
|
+
{ id: "all", label: copyParam("memberPrograms.filterAll", "All programs") },
|
|
535
|
+
{ id: "joined", label: copyParam("memberPrograms.filterJoined", "Programs I joined") },
|
|
536
|
+
];
|
|
537
|
+
const categoryChips: Option[] = [{ id: "", label: copyParam("memberPrograms.filterAllCategories", "All categories") },
|
|
538
|
+
...(categories ?? []).map((c) => ({ id: String(c.id), label: String(c.label) }))];
|
|
539
|
+
const emptyText = status !== "ready" || rows.length ? ""
|
|
540
|
+
: needsSignIn ? copyParam("memberPrograms.signInEmptyText", "Sign in to see the programs you joined.")
|
|
541
|
+
: categoryId ? copyParam("memberPrograms.emptyCategoryText", "No programs in this category yet.")
|
|
542
|
+
: statusFilter === "joined" ? copyParam("memberPrograms.emptyJoinedText", "You haven't joined a program yet.")
|
|
543
|
+
: copyParam("memberPrograms.emptyText", "No programs yet.");
|
|
544
|
+
return {
|
|
545
|
+
...state,
|
|
546
|
+
status, rows,
|
|
547
|
+
isLoading: status === "loading", notLoading: status !== "loading",
|
|
548
|
+
isErrored: status === "error", notErrored: status !== "error",
|
|
549
|
+
// The sign-in gate is the empty state of a signed-out joined filter: never both cards at once.
|
|
550
|
+
noEmpty: status !== "ready" || rows.length > 0 || !!current?.nextCursor || needsSignIn,
|
|
551
|
+
hideList: status !== "ready" || rows.length === 0,
|
|
552
|
+
emptyText,
|
|
553
|
+
errorText: status === "error" ? (current?.error || copyParam("memberPrograms.errorText", "Programs could not be loaded.")) : "",
|
|
554
|
+
identityLine: meta.email ? copyTemplate("memberPrograms.identityLine", "Signed in as {email}", { email: meta.email ?? "" }) : "",
|
|
555
|
+
needsSignIn, noSignIn: !needsSignIn,
|
|
556
|
+
nextCursor: current && !current.pageError && !current.loadingMore ? current.nextCursor : "",
|
|
557
|
+
noMore: !current?.nextCursor || current.pageError || current.loadingMore,
|
|
558
|
+
noPageError: !current?.pageError,
|
|
559
|
+
loadingMore: !!current?.loadingMore,
|
|
560
|
+
statusFilter, statusOptions,
|
|
561
|
+
// A guest sees the catalog, never a "joined" filter they cannot satisfy — unless they are already on
|
|
562
|
+
// it (signed out there), when the control is their way back to the catalog. It stays up while a
|
|
563
|
+
// filter switch or a refresh reloads, so the control never flickers away under the finger.
|
|
564
|
+
hideStatusFilter: !member && statusFilter !== "joined",
|
|
565
|
+
categoryId, categoryChips,
|
|
566
|
+
hideCategoryFilter: categoriesFailed || !categories || categories.length === 0,
|
|
567
|
+
hidePriceRetry: status !== "ready" || !views.some((view) => view && view.route !== "disabled" && (scopeIncomplete || view.retryable)),
|
|
568
|
+
priceRetryText: copyParam("memberPrograms.priceRetryText", "Some prices could not be loaded."),
|
|
569
|
+
// The rows loaded so far are not the catalog: a count is only true once the last page is in.
|
|
570
|
+
summary: status === "ready" && rows.length && !current?.nextCursor ? (rows.length === 1 ? copyParam("memberPrograms.summaryOne", "1 program") : copyTemplate("memberPrograms.summaryMany", "{count} programs", { count: rows.length })) : "",
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const controller = createCollectionQuery<ProgramContent>({
|
|
575
|
+
id: (row) => row.id,
|
|
576
|
+
fetch: (criteria, cursor) => fetchPage(criteria.filter, cursor),
|
|
577
|
+
clearOnError: memberAccessDenied,
|
|
578
|
+
errorMessage: (error) => memberAccessDenied(error)
|
|
579
|
+
? copyParam("memberPrograms.accessErrorText", "Programs are unavailable for this account. Sign in again.")
|
|
580
|
+
: copyParam("memberPrograms.errorText", "Programs could not be loaded."),
|
|
581
|
+
}, (snapshot) => {
|
|
582
|
+
page = snapshot;
|
|
583
|
+
const meta = snapshot.metadata as Partial<PageMeta> | undefined;
|
|
584
|
+
if (snapshot.status === "ready" && !snapshot.loadingMore && !snapshot.pageError && meta) pageScope = { email: meta.email ?? "", memberId: meta.memberId ?? "", scope: meta.scope ?? "" };
|
|
585
|
+
if (snapshot.status === "loading" || snapshot.status === "error") pageScope = undefined;
|
|
586
|
+
void svc.pageChanged();
|
|
587
|
+
if (snapshot.status === "ready" && !snapshot.loadingMore) void priceContent();
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
const criteria = () => ({ filter: encodeFilter(statusFilter, categoryId) });
|
|
591
|
+
|
|
592
|
+
/** Prices the programs on screen now. Content never waits on this. */
|
|
593
|
+
async function priceContent(): Promise<void> {
|
|
594
|
+
const items = (page?.items ?? []).filter((row) => row.pricable).flatMap((row) => onlineProgramItem({ id: row.id }, row.commercePrice) ?? []);
|
|
595
|
+
if (!items.length) return;
|
|
596
|
+
scopeIncomplete = !(await commerce.loadPrices(items)).scopeComplete;
|
|
597
|
+
void svc.pricingPublish();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
type Actions = {
|
|
601
|
+
load: (state: State, arg?: unknown) => Promise<undefined>;
|
|
602
|
+
refresh: (state: State, arg?: unknown) => Promise<undefined>;
|
|
603
|
+
loadMore: (state: State) => Promise<undefined>;
|
|
604
|
+
retry: (state: State) => Promise<undefined>;
|
|
605
|
+
setStatus: (state: State, arg?: unknown) => Promise<undefined> | State;
|
|
606
|
+
setCategory: (state: State, arg?: unknown) => Promise<undefined> | State;
|
|
607
|
+
open: (state: State, arg?: unknown) => State | undefined;
|
|
608
|
+
pageChanged: (state: State) => State;
|
|
609
|
+
pricingPublish: (state: State) => State;
|
|
610
|
+
pricingStart: (state: State) => State;
|
|
611
|
+
pricingRefresh: (state: State) => Promise<undefined>;
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const actions: Actions = {
|
|
615
|
+
async load(_state, arg) {
|
|
616
|
+
stashCopy(arg);
|
|
617
|
+
// A load of what the screen already shows is the screen coming back: it revalidates behind its rows.
|
|
618
|
+
await controller.query(criteria(), true, true);
|
|
619
|
+
return undefined;
|
|
620
|
+
},
|
|
621
|
+
async refresh(_state, arg) { stashCopy(arg); await controller.query(criteria()); return undefined; },
|
|
622
|
+
async loadMore() { await controller.loadMore(); return undefined; },
|
|
623
|
+
async retry() { await controller.retry(); return undefined; },
|
|
624
|
+
setStatus(state, arg) {
|
|
625
|
+
const value = (arg as { selectedId?: unknown } | undefined)?.selectedId;
|
|
626
|
+
const next = value === "joined" ? "joined" : value === "all" ? "all" : undefined;
|
|
627
|
+
if (!next || next === statusFilter) return state;
|
|
628
|
+
statusFilter = next;
|
|
629
|
+
return controller.query(criteria()).then(() => undefined);
|
|
630
|
+
},
|
|
631
|
+
setCategory(state, arg) {
|
|
632
|
+
const picked = (arg as { selectedIds?: unknown; selectedId?: unknown } | undefined);
|
|
633
|
+
const raw = Array.isArray(picked?.selectedIds) ? picked.selectedIds[0] : picked?.selectedId;
|
|
634
|
+
const next = typeof raw === "string" ? raw : "";
|
|
635
|
+
if (next && !(categories ?? []).some((c) => c.id === next)) return state;
|
|
636
|
+
if (next === categoryId) return state;
|
|
637
|
+
categoryId = next;
|
|
638
|
+
return controller.query(criteria()).then(() => undefined);
|
|
639
|
+
},
|
|
640
|
+
// The card's one action: every standing opens the program, which owns joining, paying and leaving.
|
|
641
|
+
open(state, arg) {
|
|
642
|
+
const id = (arg as { id?: unknown } | undefined)?.id;
|
|
643
|
+
if (typeof id !== "string" || !state.rows.some((row) => row.id === id && !row.actionDisabled)) return state;
|
|
644
|
+
return navigate(state, { screenId: DETAIL_SCREEN, args: { id } });
|
|
645
|
+
},
|
|
646
|
+
pageChanged: derive,
|
|
647
|
+
pricingPublish: derive,
|
|
648
|
+
pricingStart: (state) => ({ ...derive(state), _poll: pricing.pollDirective(`${SERVICE}.pricingRefresh`) }),
|
|
649
|
+
pricingRefresh: async () => { await priceContent(); return undefined; },
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
// Prices are this run's: never restored from a cold-boot snapshot.
|
|
653
|
+
const svc = register<State, Actions>(SERVICE, { state: INITIAL_STATE, actions, notHydrated: [...PRICING_NOT_HYDRATED] } as unknown as Parameters<typeof register<State, Actions>>[1]);
|
|
654
|
+
resetUnhydratedKeysOnOlderRuntime(svc, INITIAL_STATE, PRICING_NOT_HYDRATED);
|
|
655
|
+
void svc.pricingStart();
|
|
656
|
+
// Kick the first load at module scope, like every other list bundle; onAppear re-loads with the copy pack.
|
|
657
|
+
svc.load();
|
|
658
|
+
export { svc };
|