@tribe-nest/forge 3.4.0 → 3.9.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/package.json +1 -1
- package/src/data/queries/_tests/eventWaitlist.spec.ts +122 -0
- package/src/data/queries/_tests/passTransfers.spec.ts +89 -0
- package/src/data/queries/_tests/walletPass.spec.ts +159 -0
- package/src/data/queries/useEventWaitlist.ts +429 -0
- package/src/data/queries/useMyBookings.ts +211 -0
- package/src/data/queries/useMyTickets.ts +154 -0
- package/src/data/queries/usePassTransfers.ts +318 -0
- package/src/data/queries/useWalletPass.ts +236 -0
- package/src/index.ts +5 -0
- package/src/server/_tests/siteBootstrap.spec.ts +131 -0
- package/src/server/index.ts +122 -6
- package/src/types/diagnostics.ts +49 -0
- package/src/ui/index.ts +19 -0
- package/src/ui/shell/PoweredBy.tsx +6 -4
- package/src/ui/shell/PreviewDiagnostics.tsx +80 -0
- package/src/ui/shell/TribeNestApp.tsx +24 -0
- package/src/ui/shell/diagnosticsGating.spec.ts +102 -0
- package/src/ui/shell/diagnosticsGating.ts +90 -0
- package/src/ui/shell/shellGating.spec.ts +40 -1
- package/src/ui/shell/shellGating.ts +33 -0
- package/src/ui/styled/AccountDashboard.tsx +598 -1
- package/src/ui/styled/EventDetail.tsx +6 -0
- package/src/ui/styled/EventWaitlist.tsx +448 -0
- package/src/ui/styled/TicketTransfer.tsx +393 -0
- package/src/ui/styled/WalletPassButtons.tsx +208 -0
- package/src/ui/styled/_tests/WalletPassButtons.spec.tsx +223 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { useMutation, useQueries, useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Events 2.3 — "Add to Apple Wallet" / "Save to Google Wallet" for a ticket the
|
|
7
|
+
* signed-in buyer already owns.
|
|
8
|
+
*
|
|
9
|
+
* Lives in Forge rather than in either app because both rendering surfaces —
|
|
10
|
+
* the client PWA and code websites — need exactly this, and a second copy is
|
|
11
|
+
* how the two drift (the same reason `useMyTickets` and `useEventWaitlist` live
|
|
12
|
+
* here).
|
|
13
|
+
*
|
|
14
|
+
* ## The endpoints
|
|
15
|
+
*
|
|
16
|
+
* ```
|
|
17
|
+
* GET /public/events/passes/:passId/wallet?profileId=… status → WalletPassStatus (200)
|
|
18
|
+
* GET /public/events/passes/:passId/wallet/apple?profileId=… the .pkpass bytes (200)
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* `:passId` is `event_passes.id` — the string `TN-` followed by digits, NOT a
|
|
22
|
+
* UUID and NOT an order id. The API validates that shape and answers 404 for
|
|
23
|
+
* anything else, so callers must pass a real pass id rather than the order id
|
|
24
|
+
* `useMyTickets` returns.
|
|
25
|
+
*
|
|
26
|
+
* Both endpoints resolve the buyer from the SESSION and match it against the
|
|
27
|
+
* order's email; nothing about identity is accepted as input. A pass that
|
|
28
|
+
* exists but belongs to someone else answers **404, not 403** — deliberately, so
|
|
29
|
+
* the endpoint cannot be used as an oracle over other people's purchases. Which
|
|
30
|
+
* means: for this UI, "404" and "no such pass" are the same answer and neither
|
|
31
|
+
* is worth surfacing.
|
|
32
|
+
*
|
|
33
|
+
* ## THE RULE THIS FILE EXISTS TO ENFORCE: invisible when unavailable
|
|
34
|
+
*
|
|
35
|
+
* **No wallet signing credentials exist in production today.** The Apple Pass
|
|
36
|
+
* Type ID certificate and the Google Issuer ID are still being obtained, so the
|
|
37
|
+
* status endpoint currently answers, for every ticket:
|
|
38
|
+
*
|
|
39
|
+
* ```json
|
|
40
|
+
* { "passId": "TN-123", "available": false, "reason": "not_configured",
|
|
41
|
+
* "apple": { "available": false, "downloadUrl": null },
|
|
42
|
+
* "google": { "available": false, "saveUrl": null } }
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* That is a 200. It is a successful answer meaning "this feature is off", not an
|
|
46
|
+
* error — and a buyer who will never get a wallet pass must not be able to tell
|
|
47
|
+
* the feature exists. So `hasWalletPass()` is the single predicate every caller
|
|
48
|
+
* gates on, and it is false for:
|
|
49
|
+
*
|
|
50
|
+
* - `available: false` for ANY reason (`not_configured`, `rotating_qr` — a
|
|
51
|
+
* pass on signed rotating QR cannot carry a static wallet barcode — or
|
|
52
|
+
* `order_not_active` for a cancelled/refunded order);
|
|
53
|
+
* - a query that failed, 401'd, 404'd, or has not answered yet;
|
|
54
|
+
* - both providers coming back unusable even on an `available: true` row.
|
|
55
|
+
*
|
|
56
|
+
* There is no loading state, no disabled button, no "coming soon" and no error
|
|
57
|
+
* toast anywhere in this feature. Absence is the entire design.
|
|
58
|
+
*
|
|
59
|
+
* ## Why the `.pkpass` is fetched, not linked
|
|
60
|
+
*
|
|
61
|
+
* The download endpoint is authenticated by the member bearer token, which lives
|
|
62
|
+
* in memory/localStorage and is attached by the Forge Axios client — an `<a
|
|
63
|
+
* href>` straight at the API would send no `Authorization` header and 401. So
|
|
64
|
+
* the bytes are fetched through the client and handed to the browser as an
|
|
65
|
+
* object URL carrying the `application/vnd.apple.pkpass` type, which is what
|
|
66
|
+
* makes iOS open Wallet instead of saving a nameless file.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/** The MIME type iOS keys off. A wrong one downloads as a zip and never opens. */
|
|
70
|
+
export const PKPASS_MIME_TYPE = "application/vnd.apple.pkpass";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Why a pass cannot go into a wallet right now. Mirrors the API's
|
|
74
|
+
* `WalletPassUnavailableReason`. Present for diagnostics and logging — it is
|
|
75
|
+
* deliberately never rendered, since every value means "show nothing".
|
|
76
|
+
*/
|
|
77
|
+
export type WalletPassUnavailableReason = "not_configured" | "rotating_qr" | "order_not_active";
|
|
78
|
+
|
|
79
|
+
export type WalletPassStatus = {
|
|
80
|
+
passId: string;
|
|
81
|
+
/** The only field the UI branches on. */
|
|
82
|
+
available: boolean;
|
|
83
|
+
/** Null when `available` is true. */
|
|
84
|
+
reason: WalletPassUnavailableReason | null;
|
|
85
|
+
apple: {
|
|
86
|
+
available: boolean;
|
|
87
|
+
/** API-relative path INCLUDING `?profileId=…`; pass it to the Forge client as-is. */
|
|
88
|
+
downloadUrl: string | null;
|
|
89
|
+
};
|
|
90
|
+
google: {
|
|
91
|
+
available: boolean;
|
|
92
|
+
/** An absolute `https://pay.google.com/gp/v/save/<jwt>` URL. */
|
|
93
|
+
saveUrl: string | null;
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** `event_passes.id`. Cheap client-side mirror of the API's own guard, so an order id never becomes a request. */
|
|
98
|
+
export const isEventPassId = (value: unknown): value is string =>
|
|
99
|
+
typeof value === "string" && /^TN-\d+$/.test(value);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Is there anything to draw?
|
|
103
|
+
*
|
|
104
|
+
* The ONE predicate the whole feature gates on. Written as a total function over
|
|
105
|
+
* `unknown`-ish input so an undefined/loading/errored query is false by
|
|
106
|
+
* construction rather than by the caller remembering to check.
|
|
107
|
+
*/
|
|
108
|
+
export const hasWalletPass = (status?: WalletPassStatus | null): boolean =>
|
|
109
|
+
!!status?.available && (status.apple.available || !!status.google.saveUrl);
|
|
110
|
+
|
|
111
|
+
const walletPassKey = (passId?: string, accountId?: string, profileId?: string) =>
|
|
112
|
+
["wallet-pass", passId, accountId, profileId] as const;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* What this buyer can add to a wallet for ONE pass.
|
|
116
|
+
*
|
|
117
|
+
* `accountId` gates the query on being signed in and keys the cache; it is never
|
|
118
|
+
* sent, and sending it would not help — the API takes the buyer's identity from
|
|
119
|
+
* the session and ignores any supplied by the caller.
|
|
120
|
+
*
|
|
121
|
+
* `retry: false` because every failure here is an ANSWER, not a fault: 401 means
|
|
122
|
+
* anonymous, 404 means "not yours / no such pass", and both mean draw nothing.
|
|
123
|
+
* Retrying would only delay that conclusion and multiply the requests a page of
|
|
124
|
+
* tickets makes.
|
|
125
|
+
*/
|
|
126
|
+
export function useWalletPass(passId?: string, accountId?: string) {
|
|
127
|
+
const { client, profileId } = useForge();
|
|
128
|
+
|
|
129
|
+
return useQuery<WalletPassStatus>({
|
|
130
|
+
queryKey: walletPassKey(passId, accountId, profileId),
|
|
131
|
+
queryFn: async () => {
|
|
132
|
+
const res = await client.get(`/public/events/passes/${passId}/wallet`, { params: { profileId } });
|
|
133
|
+
return res.data;
|
|
134
|
+
},
|
|
135
|
+
enabled: isEventPassId(passId) && !!accountId && !!profileId && !!client,
|
|
136
|
+
retry: false,
|
|
137
|
+
// A save JWT carries no expiry and a `.pkpass` is regenerated per request,
|
|
138
|
+
// so nothing here goes stale within a session. Re-asking on every focus
|
|
139
|
+
// would just re-sign a pass to learn the same answer.
|
|
140
|
+
staleTime: 5 * 60 * 1000,
|
|
141
|
+
refetchOnWindowFocus: false,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type WalletPassEntry = {
|
|
146
|
+
passId: string;
|
|
147
|
+
status: WalletPassStatus;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The same question for SEVERAL passes — an order is usually more than one
|
|
152
|
+
* ticket, and each pass is its own wallet artefact with its own barcode.
|
|
153
|
+
*
|
|
154
|
+
* Returns only the passes that actually have something to offer, so a caller
|
|
155
|
+
* renders `entries` directly and gets the invisible-when-unavailable behaviour
|
|
156
|
+
* without writing a condition. `isLoading` is exposed for completeness but must
|
|
157
|
+
* NOT be used to draw a placeholder: a spinner where a button will never appear
|
|
158
|
+
* is exactly the tell this feature must not give.
|
|
159
|
+
*/
|
|
160
|
+
export function useWalletPasses(passIds: string[] | undefined, accountId?: string) {
|
|
161
|
+
const { client, profileId } = useForge();
|
|
162
|
+
|
|
163
|
+
const ids = useMemo(() => (passIds ?? []).filter(isEventPassId), [passIds]);
|
|
164
|
+
|
|
165
|
+
const queries = useQueries({
|
|
166
|
+
queries: ids.map((passId) => ({
|
|
167
|
+
queryKey: walletPassKey(passId, accountId, profileId),
|
|
168
|
+
queryFn: async () => {
|
|
169
|
+
const res = await client.get(`/public/events/passes/${passId}/wallet`, { params: { profileId } });
|
|
170
|
+
return res.data as WalletPassStatus;
|
|
171
|
+
},
|
|
172
|
+
enabled: !!accountId && !!profileId && !!client,
|
|
173
|
+
retry: false,
|
|
174
|
+
staleTime: 5 * 60 * 1000,
|
|
175
|
+
refetchOnWindowFocus: false,
|
|
176
|
+
})),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// `useQueries` hands back a fresh array identity every render, so the memo is
|
|
180
|
+
// keyed on when its members last changed rather than on the array itself —
|
|
181
|
+
// the same trick `useEventWaitlistPlaces` uses.
|
|
182
|
+
const stamp = queries.map((query) => query.dataUpdatedAt).join(",");
|
|
183
|
+
|
|
184
|
+
const entries = useMemo(() => {
|
|
185
|
+
const out: WalletPassEntry[] = [];
|
|
186
|
+
queries.forEach((query, index) => {
|
|
187
|
+
const status = query.data;
|
|
188
|
+
if (status && hasWalletPass(status)) out.push({ passId: ids[index]!, status });
|
|
189
|
+
});
|
|
190
|
+
return out;
|
|
191
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
192
|
+
}, [ids, stamp]);
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
/** ONLY the passes with a real wallet artefact. Empty is the production state today. */
|
|
196
|
+
entries,
|
|
197
|
+
/** True while any pass is still being asked about. Never render on this. */
|
|
198
|
+
isLoading: queries.some((query) => query.isLoading),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Fetch the signed `.pkpass` and hand it to the browser.
|
|
204
|
+
*
|
|
205
|
+
* `downloadUrl` comes from the status response rather than being rebuilt here —
|
|
206
|
+
* it already carries the `profileId` the endpoint requires, and reconstructing
|
|
207
|
+
* it would be a second place for the path to drift from the API.
|
|
208
|
+
*/
|
|
209
|
+
export function useDownloadApplePass() {
|
|
210
|
+
const { client } = useForge();
|
|
211
|
+
|
|
212
|
+
return useMutation<void, unknown, { passId: string; downloadUrl: string }>({
|
|
213
|
+
mutationFn: async ({ passId, downloadUrl }) => {
|
|
214
|
+
const res = await client.get(downloadUrl, { responseType: "blob" });
|
|
215
|
+
|
|
216
|
+
// Re-wrapped rather than used as-is: whether Axios preserves the server's
|
|
217
|
+
// content type on a Blob varies by adapter, and the type is the entire
|
|
218
|
+
// mechanism by which iOS opens Wallet instead of downloading a file.
|
|
219
|
+
const blob = new Blob([res.data as BlobPart], { type: PKPASS_MIME_TYPE });
|
|
220
|
+
const url = URL.createObjectURL(blob);
|
|
221
|
+
|
|
222
|
+
const anchor = document.createElement("a");
|
|
223
|
+
anchor.href = url;
|
|
224
|
+
anchor.download = `${passId}.pkpass`;
|
|
225
|
+
anchor.rel = "noopener";
|
|
226
|
+
document.body.appendChild(anchor);
|
|
227
|
+
anchor.click();
|
|
228
|
+
anchor.remove();
|
|
229
|
+
|
|
230
|
+
// Deferred, not immediate: Safari — the browser that matters most for a
|
|
231
|
+
// `.pkpass` — hands the URL to Wallet asynchronously, and revoking in the
|
|
232
|
+
// same tick can leave the holder with a pass that silently fails to open.
|
|
233
|
+
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -81,6 +81,11 @@ export * from "./data/queries/useCohortPage";
|
|
|
81
81
|
export * from "./data/queries/useCourseAccess";
|
|
82
82
|
export * from "./data/queries/useBroadcasts";
|
|
83
83
|
export * from "./data/queries/useAccount";
|
|
84
|
+
export * from "./data/queries/useMyTickets";
|
|
85
|
+
export * from "./data/queries/usePassTransfers";
|
|
86
|
+
export * from "./data/queries/useMyBookings";
|
|
87
|
+
export * from "./data/queries/useEventWaitlist";
|
|
88
|
+
export * from "./data/queries/useWalletPass";
|
|
84
89
|
export * from "./data/queries/useFinalize";
|
|
85
90
|
export * from "./data/queries/useSubscriptions";
|
|
86
91
|
export * from "./data/queries/useEngagement";
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { probeApi, fetchSiteBootstrap } from "../index";
|
|
3
|
+
|
|
4
|
+
// `fetch` is the seam — these are pure network-shape tests, no server needed.
|
|
5
|
+
const stubFetch = (impl: (url: string, init?: RequestInit) => Promise<Response> | Response) =>
|
|
6
|
+
vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit) => Promise.resolve(impl(String(input), init))));
|
|
7
|
+
|
|
8
|
+
const json = (body: unknown, status = 200) =>
|
|
9
|
+
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.unstubAllGlobals();
|
|
13
|
+
vi.restoreAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const API = "https://api.example.test";
|
|
17
|
+
|
|
18
|
+
describe("probeApi", () => {
|
|
19
|
+
it("reports ok against the public liveness route", async () => {
|
|
20
|
+
stubFetch((url) => {
|
|
21
|
+
expect(url).toBe(`${API}/healthcheck`);
|
|
22
|
+
return new Response("", { status: 200 });
|
|
23
|
+
});
|
|
24
|
+
const r = await probeApi(API);
|
|
25
|
+
expect(r).toMatchObject({ ok: true, url: `${API}/healthcheck`, status: 200 });
|
|
26
|
+
expect(r.ms).toBeGreaterThanOrEqual(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("reports not-ok on a non-2xx without throwing", async () => {
|
|
30
|
+
stubFetch(() => new Response("nope", { status: 403 }));
|
|
31
|
+
expect(await probeApi(API)).toMatchObject({ ok: false, status: 403 });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("reports the transport error on a network failure", async () => {
|
|
35
|
+
stubFetch(() => {
|
|
36
|
+
throw new TypeError("fetch failed");
|
|
37
|
+
});
|
|
38
|
+
expect(await probeApi(API)).toMatchObject({ ok: false, error: "fetch failed" });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("gives up after the timeout instead of stalling the render", async () => {
|
|
42
|
+
// Never resolves on its own — only the abort ends it. A black-holed network
|
|
43
|
+
// must not hold SSR open.
|
|
44
|
+
stubFetch(
|
|
45
|
+
(_url, init) =>
|
|
46
|
+
new Promise<Response>((_resolve, reject) => {
|
|
47
|
+
init?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
const r = await probeApi(API, { timeoutMs: 20 });
|
|
51
|
+
expect(r.ok).toBe(false);
|
|
52
|
+
expect(r.error).toBe("no response within 20ms");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("fetchSiteBootstrap", () => {
|
|
57
|
+
it("returns data and NO diagnostics on a healthy render", async () => {
|
|
58
|
+
stubFetch((url) =>
|
|
59
|
+
url.includes("/content")
|
|
60
|
+
? json({ version: 3, defaultLocale: "en", locales: ["en"], fields: { "theme.background": { type: "color", localized: false, value: "#0A0A0A" } } })
|
|
61
|
+
: json({ currency: "USD" }),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const r = await fetchSiteBootstrap({ apiUrl: API, profileId: "p1", websiteVersionId: "v1", state: "draft" });
|
|
65
|
+
expect(r.diagnostics).toBeNull();
|
|
66
|
+
expect(r.siteConfig).toEqual({ currency: "USD" });
|
|
67
|
+
expect(r.contentDoc.fields["theme.background"]).toMatchObject({ value: "#0A0A0A" });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("requests the state it was given", async () => {
|
|
71
|
+
const seen: string[] = [];
|
|
72
|
+
stubFetch((url) => {
|
|
73
|
+
seen.push(url);
|
|
74
|
+
return url.includes("/content") ? json({ version: 0, defaultLocale: "en", locales: ["en"], fields: {} }) : json({});
|
|
75
|
+
});
|
|
76
|
+
await fetchSiteBootstrap({ apiUrl: API, profileId: "p1", websiteVersionId: "v1", state: "draft" });
|
|
77
|
+
expect(seen.some((u) => u.includes("state=draft") && u.includes("websiteVersionId=v1"))).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// THE REGRESSION THIS EXISTS FOR: an unreachable API used to be
|
|
81
|
+
// indistinguishable from an unconfigured site — both rendered Forge's white
|
|
82
|
+
// default theme with no signal anywhere.
|
|
83
|
+
it("reports every failed fetch, and probes to say WHY", async () => {
|
|
84
|
+
stubFetch(() => {
|
|
85
|
+
throw new TypeError("fetch failed");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const r = await fetchSiteBootstrap({ apiUrl: API, profileId: "p1", websiteVersionId: "v1", state: "draft" });
|
|
89
|
+
|
|
90
|
+
// Still degrades — a site never crashes on a bad API.
|
|
91
|
+
expect(r.contentDoc.fields).toEqual({});
|
|
92
|
+
expect(r.siteConfig).toBeNull();
|
|
93
|
+
// ...but no longer silently.
|
|
94
|
+
expect(r.diagnostics?.apiUrl).toBe(API);
|
|
95
|
+
expect(r.diagnostics?.failures).toEqual([
|
|
96
|
+
{ what: "content", status: undefined, error: "fetch failed" },
|
|
97
|
+
{ what: "siteConfig", status: undefined, error: "fetch failed" },
|
|
98
|
+
]);
|
|
99
|
+
expect(r.diagnostics?.probe).toMatchObject({ ok: false, url: `${API}/healthcheck` });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("carries the HTTP status when the request completed but was rejected", async () => {
|
|
103
|
+
stubFetch((url) => (url.includes("/content") ? new Response("denied", { status: 403 }) : json({ currency: "USD" })));
|
|
104
|
+
|
|
105
|
+
const r = await fetchSiteBootstrap({ apiUrl: API, profileId: "p1", websiteVersionId: "v1", state: "draft" });
|
|
106
|
+
expect(r.diagnostics?.failures).toEqual([{ what: "content", status: 403, error: "HTTP 403" }]);
|
|
107
|
+
// The healthy one still lands — a partial failure isn't a total one.
|
|
108
|
+
expect(r.siteConfig).toEqual({ currency: "USD" });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("does not treat 'nothing to fetch' as a failure", async () => {
|
|
112
|
+
// No version + no profile baked in → no request went out → nothing failed.
|
|
113
|
+
const fetchSpy = vi.fn();
|
|
114
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
115
|
+
|
|
116
|
+
const r = await fetchSiteBootstrap({ apiUrl: API });
|
|
117
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
118
|
+
expect(r.diagnostics).toBeNull();
|
|
119
|
+
expect(r.siteConfig).toBeNull();
|
|
120
|
+
expect(r.contentDoc.fields).toEqual({});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("skips the probe when asked (it costs a request)", async () => {
|
|
124
|
+
stubFetch(() => {
|
|
125
|
+
throw new TypeError("fetch failed");
|
|
126
|
+
});
|
|
127
|
+
const r = await fetchSiteBootstrap({ apiUrl: API, profileId: "p1", websiteVersionId: "v1", probe: false });
|
|
128
|
+
expect(r.diagnostics?.failures).toHaveLength(2);
|
|
129
|
+
expect(r.diagnostics?.probe).toBeUndefined();
|
|
130
|
+
});
|
|
131
|
+
});
|
package/src/server/index.ts
CHANGED
|
@@ -26,6 +26,11 @@ export * from "./appAuth";
|
|
|
26
26
|
export * from "./appUsers";
|
|
27
27
|
|
|
28
28
|
import { emptyContentDocument, type ContentDocument } from "../content/types";
|
|
29
|
+
import type { ApiProbeResult, ForgeSsrDiagnostics, SsrFetchFailure } from "../types/diagnostics";
|
|
30
|
+
|
|
31
|
+
// Re-exported so a site's `__root.tsx` can type its loader return without
|
|
32
|
+
// reaching past the `@tribe-nest/forge/server` entry it already imports.
|
|
33
|
+
export type { ApiProbeResult, ForgeSsrDiagnostics, SsrFetchFailure };
|
|
29
34
|
import type {
|
|
30
35
|
IEvent,
|
|
31
36
|
IPublicProduct,
|
|
@@ -43,20 +48,37 @@ import type {
|
|
|
43
48
|
import { collectionParamsToQuery, splitCollectionQuery } from "../data/collectionParams";
|
|
44
49
|
import type { SiteConfig } from "../data/queries/useWebsite";
|
|
45
50
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
51
|
+
/**
|
|
52
|
+
* One public-API GET, KEEPING the failure reason. `getJson` (below) throws that
|
|
53
|
+
* reason away on purpose — a site must degrade, not crash — but the preview needs
|
|
54
|
+
* to tell "nothing is configured yet" apart from "I could not reach the API",
|
|
55
|
+
* which otherwise render identically. See `fetchSiteBootstrap`.
|
|
56
|
+
*/
|
|
57
|
+
type JsonResult<T> = { ok: true; data: T } | { ok: false; status?: number; error: string };
|
|
58
|
+
|
|
59
|
+
async function getJsonResult<T>(
|
|
60
|
+
apiUrl: string,
|
|
61
|
+
path: string,
|
|
62
|
+
params?: Record<string, string | undefined>,
|
|
63
|
+
): Promise<JsonResult<T>> {
|
|
48
64
|
try {
|
|
49
65
|
const qs = new URLSearchParams();
|
|
50
66
|
for (const [k, v] of Object.entries(params ?? {})) if (v != null) qs.set(k, v);
|
|
51
67
|
const query = qs.toString();
|
|
52
68
|
const res = await fetch(`${apiUrl}${path}${query ? `?${query}` : ""}`);
|
|
53
|
-
if (!res.ok) return
|
|
54
|
-
return (await res.json()) as T;
|
|
55
|
-
} catch {
|
|
56
|
-
return
|
|
69
|
+
if (!res.ok) return { ok: false, status: res.status, error: `HTTP ${res.status}` };
|
|
70
|
+
return { ok: true, data: (await res.json()) as T };
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
57
73
|
}
|
|
58
74
|
}
|
|
59
75
|
|
|
76
|
+
/** GET a public endpoint and parse JSON, returning `null` on any failure. */
|
|
77
|
+
async function getJson<T>(apiUrl: string, path: string, params?: Record<string, string | undefined>): Promise<T | null> {
|
|
78
|
+
const res = await getJsonResult<T>(apiUrl, path, params);
|
|
79
|
+
return res.ok ? res.data : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
60
82
|
/** POST a JSON body to a public endpoint and parse JSON, `null` on any failure. */
|
|
61
83
|
async function postJson<T>(apiUrl: string, path: string, body: unknown): Promise<T | null> {
|
|
62
84
|
try {
|
|
@@ -98,6 +120,100 @@ export function fetchSiteConfig(opts: { apiUrl: string; profileId?: string }): P
|
|
|
98
120
|
return getJson<SiteConfig>(opts.apiUrl, "/public/websites/site-config", { profileId: opts.profileId });
|
|
99
121
|
}
|
|
100
122
|
|
|
123
|
+
/** The API's unauthenticated liveness route — no tenant, no params, no body. */
|
|
124
|
+
const PROBE_PATH = "/healthcheck";
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Can this runtime reach the TribeNest API at all? Calls a public route and
|
|
128
|
+
* reports what happened, rather than degrading to a fallback.
|
|
129
|
+
*
|
|
130
|
+
* This exists because the SSR runtime is NOT the visitor's browser: a site
|
|
131
|
+
* renders inside a Cloudflare Worker (published) or inside the preview sandbox
|
|
132
|
+
* (the builder), and those have their own egress. A sandbox that cannot reach the
|
|
133
|
+
* API still renders a complete-looking page — every server fetch just quietly
|
|
134
|
+
* returns nothing, so the site falls back to Forge's baseline theme while all the
|
|
135
|
+
* client-side data (fetched from the visitor's browser, which CAN reach the API)
|
|
136
|
+
* loads normally. The result reads as "the theme is broken", not as "the preview
|
|
137
|
+
* has no network", which is the wrong thing to go and debug.
|
|
138
|
+
*
|
|
139
|
+
* Never throws: a probe that fails IS the answer.
|
|
140
|
+
*/
|
|
141
|
+
export async function probeApi(apiUrl: string, opts?: { timeoutMs?: number }): Promise<ApiProbeResult> {
|
|
142
|
+
const url = `${apiUrl}${PROBE_PATH}`;
|
|
143
|
+
const timeoutMs = opts?.timeoutMs ?? 5000;
|
|
144
|
+
const started = Date.now();
|
|
145
|
+
// AbortSignal.timeout() isn't in every runtime this ships to; the controller
|
|
146
|
+
// pair is. Without a timeout a black-holed network stalls the whole render.
|
|
147
|
+
const controller = new AbortController();
|
|
148
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
149
|
+
try {
|
|
150
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
151
|
+
return { ok: res.ok, url, status: res.status, ms: Date.now() - started };
|
|
152
|
+
} catch (err) {
|
|
153
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
url,
|
|
157
|
+
// An abort is the timeout firing — say so, rather than surfacing the
|
|
158
|
+
// runtime's own wording for a cancelled request.
|
|
159
|
+
error: controller.signal.aborted ? `no response within ${timeoutMs}ms` : error,
|
|
160
|
+
ms: Date.now() - started,
|
|
161
|
+
};
|
|
162
|
+
} finally {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The whole SSR bootstrap in one call: the content document + the tenant's
|
|
169
|
+
* runtime config, fetched in parallel — PLUS what failed, if anything.
|
|
170
|
+
*
|
|
171
|
+
* Prefer this over calling `fetchContentDocument` / `fetchSiteConfig` separately.
|
|
172
|
+
* They return the same data, but they cannot report a failure (an unreachable API
|
|
173
|
+
* and a brand-new site both produce an empty document), so a shell built on them
|
|
174
|
+
* can only ever show Forge's defaults and stay silent about why.
|
|
175
|
+
*
|
|
176
|
+
* `diagnostics` is `null` on a healthy render and is only populated when a fetch
|
|
177
|
+
* actually failed — the reachability probe costs a request, so it runs only then.
|
|
178
|
+
* Pass it to `<TribeNestApp diagnostics={…}>`, which surfaces it in the builder
|
|
179
|
+
* preview and on review Workers, and never on the live published site.
|
|
180
|
+
*/
|
|
181
|
+
export async function fetchSiteBootstrap(opts: {
|
|
182
|
+
apiUrl: string;
|
|
183
|
+
profileId?: string;
|
|
184
|
+
websiteVersionId?: string;
|
|
185
|
+
state?: "draft" | "published";
|
|
186
|
+
/** Set false to skip the reachability probe even when a fetch failed. */
|
|
187
|
+
probe?: boolean;
|
|
188
|
+
}): Promise<{ contentDoc: ContentDocument; siteConfig: SiteConfig | null; diagnostics: ForgeSsrDiagnostics | null }> {
|
|
189
|
+
const { apiUrl, profileId, websiteVersionId, state = "published" } = opts;
|
|
190
|
+
|
|
191
|
+
const [content, siteConfig] = await Promise.all([
|
|
192
|
+
websiteVersionId
|
|
193
|
+
? getJsonResult<ContentDocument>(apiUrl, "/public/websites/content", { websiteVersionId, state })
|
|
194
|
+
: Promise.resolve<JsonResult<ContentDocument>>({ ok: true, data: emptyContentDocument() }),
|
|
195
|
+
profileId
|
|
196
|
+
? getJsonResult<SiteConfig>(apiUrl, "/public/websites/site-config", { profileId })
|
|
197
|
+
: Promise.resolve<JsonResult<SiteConfig | null>>({ ok: true, data: null }),
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
// A deploy with no version / no profile baked in isn't a failure — there was
|
|
201
|
+
// nothing to fetch. Only a request that actually went out and lost counts.
|
|
202
|
+
const failures: SsrFetchFailure[] = [];
|
|
203
|
+
if (!content.ok) failures.push({ what: "content", status: content.status, error: content.error });
|
|
204
|
+
if (!siteConfig.ok) failures.push({ what: "siteConfig", status: siteConfig.status, error: siteConfig.error });
|
|
205
|
+
|
|
206
|
+
const diagnostics: ForgeSsrDiagnostics | null = failures.length
|
|
207
|
+
? { apiUrl, failures, probe: opts.probe === false ? undefined : await probeApi(apiUrl) }
|
|
208
|
+
: null;
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
contentDoc: content.ok ? (content.data ?? emptyContentDocument()) : emptyContentDocument(),
|
|
212
|
+
siteConfig: siteConfig.ok ? (siteConfig.data ?? null) : null,
|
|
213
|
+
diagnostics,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
101
217
|
/** Fetch a single event by id or slug for SSR. */
|
|
102
218
|
export function fetchEventServer(opts: { apiUrl: string; profileId?: string; idOrSlug: string }): Promise<IEvent | null> {
|
|
103
219
|
return getJson<IEvent>(opts.apiUrl, `/public/events/${encodeURIComponent(opts.idOrSlug)}`, {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// SSR bootstrap diagnostics — shared by the server fetchers (which produce them)
|
|
2
|
+
// and the preview banner in `forge/ui` (which renders them). They live here, in a
|
|
3
|
+
// React-free and fetch-free module, so the UI can import the types without
|
|
4
|
+
// dragging the server entry (jobs, appAuth, appUsers) into a client bundle.
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS EXISTS: every public fetch in `forge/server` degrades to `null` / an
|
|
7
|
+
// empty document on failure, so a site never crashes when the API hiccups. The
|
|
8
|
+
// cost of that is that "this creator hasn't set a theme yet" and "I could not
|
|
9
|
+
// reach the API at all" render as the SAME screen — Forge's baseline theme, a
|
|
10
|
+
// white background and a purple accent. That ambiguity is invisible in the
|
|
11
|
+
// builder preview and cost real debugging time: the published site was correctly
|
|
12
|
+
// dark while the preview stayed white, and nothing anywhere said why.
|
|
13
|
+
//
|
|
14
|
+
// Keeping the failure reason instead of discarding it is what makes the preview
|
|
15
|
+
// able to say "I could not reach the API", which is a different sentence from
|
|
16
|
+
// "you have not set a theme".
|
|
17
|
+
|
|
18
|
+
/** One public-API GET that failed during the SSR bootstrap. */
|
|
19
|
+
export type SsrFetchFailure = {
|
|
20
|
+
/** Which bootstrap fetch this was. */
|
|
21
|
+
what: "content" | "siteConfig";
|
|
22
|
+
/** HTTP status, when the request completed but was not ok. Absent on a transport error. */
|
|
23
|
+
status?: number;
|
|
24
|
+
/** Transport error message, or `HTTP <status>`. */
|
|
25
|
+
error: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The result of calling a known-good public route to test API reachability. */
|
|
29
|
+
export type ApiProbeResult = {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
/** The exact URL probed, so the message can name it. */
|
|
32
|
+
url: string;
|
|
33
|
+
status?: number;
|
|
34
|
+
error?: string;
|
|
35
|
+
/** Round-trip milliseconds (a timeout shows up here as ~the timeout). */
|
|
36
|
+
ms: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What went wrong while bootstrapping this render. `null` on a healthy render —
|
|
41
|
+
* it is only ever populated when a bootstrap fetch actually failed.
|
|
42
|
+
*/
|
|
43
|
+
export type ForgeSsrDiagnostics = {
|
|
44
|
+
/** The API base URL this deploy is pointed at. */
|
|
45
|
+
apiUrl: string;
|
|
46
|
+
failures: SsrFetchFailure[];
|
|
47
|
+
/** Only run when a bootstrap fetch already failed — it costs a request. */
|
|
48
|
+
probe?: ApiProbeResult;
|
|
49
|
+
};
|
package/src/ui/index.ts
CHANGED
|
@@ -72,8 +72,10 @@ export { MembershipTiers, type MembershipTiersProps } from "./styled/MembershipT
|
|
|
72
72
|
export { EventsList, type EventsListProps } from "./styled/EventsList";
|
|
73
73
|
export { EventTickets, type EventTicketsProps } from "./styled/EventTickets";
|
|
74
74
|
export { EventCountdown, type EventCountdownProps } from "./styled/EventCountdown";
|
|
75
|
+
export { EventWaitlist, type EventWaitlistProps, formatCountdown } from "./styled/EventWaitlist";
|
|
75
76
|
export { EventDetail, type EventDetailProps } from "./styled/EventDetail";
|
|
76
77
|
export { AddToCalendar, type AddToCalendarProps } from "./styled/AddToCalendar";
|
|
78
|
+
export { WalletPassButtons, type WalletPassButtonsProps } from "./styled/WalletPassButtons";
|
|
77
79
|
export { CoachingBooking, type CoachingBookingProps } from "./styled/CoachingBooking";
|
|
78
80
|
export { CoachingDetail, type CoachingDetailProps } from "./styled/CoachingDetail";
|
|
79
81
|
export { CourseCheckout, type CourseCheckoutProps } from "./styled/CourseCheckout";
|
|
@@ -85,6 +87,12 @@ export { SignupForm, type SignupFormProps } from "./styled/SignupForm";
|
|
|
85
87
|
export { ForgotPasswordForm, type ForgotPasswordFormProps } from "./styled/ForgotPasswordForm";
|
|
86
88
|
export { ResetPasswordForm, type ResetPasswordFormProps } from "./styled/ResetPasswordForm";
|
|
87
89
|
export { AccountDashboard, type AccountDashboardProps, type AccountTabKey, ACCOUNT_TABS } from "./styled/AccountDashboard";
|
|
90
|
+
export {
|
|
91
|
+
TicketTransferPanel,
|
|
92
|
+
type TicketTransferPanelProps,
|
|
93
|
+
ClaimTicketTransfer,
|
|
94
|
+
type ClaimTicketTransferProps,
|
|
95
|
+
} from "./styled/TicketTransfer";
|
|
88
96
|
export { AudioPlayer, type AudioPlayerProps } from "./styled/AudioPlayer";
|
|
89
97
|
export { ForgeAnalytics, type ForgeAnalyticsProps } from "./analytics/ForgeAnalytics";
|
|
90
98
|
export { PageMetaPixel, usePageMetaPixel, type PageMetaPixelProps } from "./analytics/PageMetaPixel";
|
|
@@ -141,6 +149,17 @@ export { PwaRegistration, type PwaRegistrationProps } from "./styled/PwaRegistra
|
|
|
141
149
|
export { InstallBanner, type InstallBannerProps } from "./styled/InstallBanner";
|
|
142
150
|
// The single root component — providers + audio + analytics + PWA + consent in one.
|
|
143
151
|
export { TribeNestApp, type TribeNestAppProps } from "./shell/TribeNestApp";
|
|
152
|
+
// The SSR-bootstrap failure banner + its gating. Exported so a bespoke shell that
|
|
153
|
+
// doesn't use <TribeNestApp> can still fail loudly instead of silently rendering
|
|
154
|
+
// Forge's defaults when the API is unreachable.
|
|
155
|
+
export { PreviewDiagnostics } from "./shell/PreviewDiagnostics";
|
|
156
|
+
export {
|
|
157
|
+
previewDiagnosticsEnabled,
|
|
158
|
+
resolvePreviewDiagnostics,
|
|
159
|
+
previewDiagnosticsMessage,
|
|
160
|
+
type PreviewDiagnosticsMessage,
|
|
161
|
+
} from "./shell/diagnosticsGating";
|
|
162
|
+
export type { ApiProbeResult, ForgeSsrDiagnostics, SsrFetchFailure } from "../types/diagnostics";
|
|
144
163
|
export { PushOptIn, type PushOptInProps } from "./styled/PushOptIn";
|
|
145
164
|
|
|
146
165
|
// Community (forum) styled blocks.
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
1
2
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
2
|
-
|
|
3
|
-
/** Where the badge points. utm params so the referral traffic is attributable. */
|
|
4
|
-
const HREF = "https://tribenest.co/?utm_source=powered_by&utm_medium=badge&utm_campaign=creator_site";
|
|
3
|
+
import { poweredByHref } from "./shellGating";
|
|
5
4
|
|
|
6
5
|
export interface PoweredByProps {
|
|
7
6
|
/** Live released build only — see `shellPoweredByEnabled`. */
|
|
@@ -27,7 +26,10 @@ export interface PoweredByProps {
|
|
|
27
26
|
*/
|
|
28
27
|
export function PoweredBy({ enabled = true }: PoweredByProps) {
|
|
29
28
|
const t = useThemeTokens();
|
|
29
|
+
const { subdomain, profileId, appId } = useForge();
|
|
30
30
|
if (!enabled) return null;
|
|
31
|
+
// Tagged from the identity ForgeProvider was built with — see `poweredByHref`.
|
|
32
|
+
const href = poweredByHref({ subdomain, profileId, appId });
|
|
31
33
|
return (
|
|
32
34
|
<div
|
|
33
35
|
style={{
|
|
@@ -39,7 +41,7 @@ export function PoweredBy({ enabled = true }: PoweredByProps) {
|
|
|
39
41
|
}}
|
|
40
42
|
>
|
|
41
43
|
<a
|
|
42
|
-
href={
|
|
44
|
+
href={href}
|
|
43
45
|
target="_blank"
|
|
44
46
|
// nofollow: the same link on every site we host is exactly the pattern
|
|
45
47
|
// Google reads as a link scheme. Drop it if the SEO value is judged
|