@tribe-nest/forge 3.2.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.
Files changed (56) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/_tests/eventWaitlist.spec.ts +122 -0
  3. package/src/data/queries/_tests/passTransfers.spec.ts +89 -0
  4. package/src/data/queries/_tests/walletPass.spec.ts +159 -0
  5. package/src/data/queries/useCheckouts.ts +84 -1
  6. package/src/data/queries/useCoachingAvailability.ts +18 -3
  7. package/src/data/queries/useCourses.ts +42 -1
  8. package/src/data/queries/useEventWaitlist.ts +429 -0
  9. package/src/data/queries/useEvents.ts +15 -1
  10. package/src/data/queries/useMyBookings.ts +211 -0
  11. package/src/data/queries/useMyTickets.ts +154 -0
  12. package/src/data/queries/usePassTransfers.ts +318 -0
  13. package/src/data/queries/usePaymentFlow.ts +12 -0
  14. package/src/data/queries/useWalletPass.ts +236 -0
  15. package/src/data/queries/useWebsite.ts +6 -0
  16. package/src/index.ts +14 -0
  17. package/src/server/_tests/siteBootstrap.spec.ts +131 -0
  18. package/src/server/index.ts +122 -6
  19. package/src/types/diagnostics.ts +49 -0
  20. package/src/types/models.ts +66 -0
  21. package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
  22. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
  23. package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
  24. package/src/ui/headless/checkout/useCheckout.ts +156 -8
  25. package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
  26. package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
  27. package/src/ui/headless/coupon/useCouponField.ts +164 -0
  28. package/src/ui/headless/course/useCourseCheckout.ts +113 -18
  29. package/src/ui/headless/event/useEventCheckout.ts +53 -2
  30. package/src/ui/headless/index.ts +15 -0
  31. package/src/ui/index.ts +26 -0
  32. package/src/ui/shell/PoweredBy.tsx +62 -0
  33. package/src/ui/shell/PreviewDiagnostics.tsx +80 -0
  34. package/src/ui/shell/TribeNestApp.tsx +39 -1
  35. package/src/ui/shell/diagnosticsGating.spec.ts +102 -0
  36. package/src/ui/shell/diagnosticsGating.ts +90 -0
  37. package/src/ui/shell/shellGating.spec.ts +60 -1
  38. package/src/ui/shell/shellGating.ts +47 -0
  39. package/src/ui/styled/AccountDashboard.tsx +598 -1
  40. package/src/ui/styled/AddToCalendar.tsx +104 -0
  41. package/src/ui/styled/Checkout.tsx +45 -14
  42. package/src/ui/styled/CoachingBooking.tsx +28 -8
  43. package/src/ui/styled/CoachingConfirmation.tsx +12 -0
  44. package/src/ui/styled/CourseCheckout.tsx +49 -18
  45. package/src/ui/styled/DiscountCode.tsx +206 -0
  46. package/src/ui/styled/EventConfirmation.tsx +68 -22
  47. package/src/ui/styled/EventDetail.tsx +24 -5
  48. package/src/ui/styled/EventTickets.tsx +49 -5
  49. package/src/ui/styled/EventWaitlist.tsx +448 -0
  50. package/src/ui/styled/TicketTransfer.tsx +393 -0
  51. package/src/ui/styled/WalletPassButtons.tsx +208 -0
  52. package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
  53. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
  54. package/src/ui/styled/_tests/WalletPassButtons.spec.tsx +223 -0
  55. package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
  56. package/src/utils/ticketOrderOutcome.ts +125 -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
+ }
@@ -29,6 +29,12 @@ export interface SiteConfig {
29
29
  * installable manifest + head tags. `null` when the creator has no pwaConfig
30
30
  * (minimal name-only manifest, not installable). Screenshots are optional. */
31
31
  pwa?: PwaConfig | null;
32
+ /** Suppresses the "Powered by TribeNest" badge on released builds. Server-side
33
+ * on purpose: the tenant owns `__root.tsx`, so anything expressed as a prop is
34
+ * an opt-out. Absent/false today — this is the lever a white-label plan
35
+ * entitlement flips, and it works without waiting for a Forge bump to reach
36
+ * every site. */
37
+ hideTribeNestBadge?: boolean;
32
38
  }
33
39
 
34
40
  /** The PWA identity block on `site-config` (mirrors `pwaConfig` on the profile). */
package/src/index.ts CHANGED
@@ -37,6 +37,15 @@ export {
37
37
  export type { AuthActions } from "./contexts/PublicAuthContext";
38
38
  export { PUBLIC_REFRESH_TOKEN_KEY, APP_REFRESH_TOKEN_KEY } from "./client/tokenStorage";
39
39
  export { safeRedirectPath } from "./utils/safeRedirect";
40
+ // Ticket-confirmation copy decision — shared so the Forge block and the client
41
+ // app's finalise page can never tell a refunded buyer two different stories.
42
+ export {
43
+ getTicketOrderOutcome,
44
+ isTicketOrderRefunded,
45
+ TICKET_ORDER_REFUNDED_COPY,
46
+ type TicketOrderOutcome,
47
+ type TicketOrderOutcomeInput,
48
+ } from "./utils/ticketOrderOutcome";
40
49
  // App-user auth (mini-apps) + the /admin guard.
41
50
  export {
42
51
  useAppAuth,
@@ -72,6 +81,11 @@ export * from "./data/queries/useCohortPage";
72
81
  export * from "./data/queries/useCourseAccess";
73
82
  export * from "./data/queries/useBroadcasts";
74
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";
75
89
  export * from "./data/queries/useFinalize";
76
90
  export * from "./data/queries/useSubscriptions";
77
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
+ });
@@ -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
- /** GET a public endpoint and parse JSON, returning `null` on any failure. */
47
- async function getJson<T>(apiUrl: string, path: string, params?: Record<string, string | undefined>): Promise<T | null> {
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 null;
54
- return (await res.json()) as T;
55
- } catch {
56
- return null;
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
+ };