@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.
@@ -0,0 +1,80 @@
1
+ import { useState } from "react";
2
+ import type { ForgeSsrDiagnostics } from "../../types/diagnostics";
3
+ import { previewDiagnosticsMessage } from "./diagnosticsGating";
4
+
5
+ /**
6
+ * The loud failure. Rendered by `<TribeNestApp>` in the builder preview and on
7
+ * review Workers — never on the live published site (see
8
+ * `previewDiagnosticsEnabled`).
9
+ *
10
+ * EVERY COLOR HERE IS A LITERAL, on purpose. This banner announces that the
11
+ * theme could not be fetched, so it cannot itself be themed: `var(--forge-*)`
12
+ * would resolve to the very fallbacks it is warning about, and on a dark-ish
13
+ * default it could render invisible. It is also the one component that must
14
+ * survive a totally broken render, so it takes nothing from context.
15
+ */
16
+ export function PreviewDiagnostics({ diagnostics }: { diagnostics: ForgeSsrDiagnostics }) {
17
+ const [open, setOpen] = useState(false);
18
+ const msg = previewDiagnosticsMessage(diagnostics);
19
+
20
+ return (
21
+ <div
22
+ role="alert"
23
+ style={{
24
+ position: "fixed",
25
+ top: 0,
26
+ left: 0,
27
+ right: 0,
28
+ // Above anything a site can reasonably stack, including sticky navs.
29
+ zIndex: 2147483000,
30
+ background: "#7f1d1d",
31
+ color: "#fef2f2",
32
+ borderBottom: "1px solid #dc2626",
33
+ fontFamily:
34
+ 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
35
+ fontSize: 13,
36
+ lineHeight: 1.45,
37
+ boxShadow: "0 2px 12px rgba(0,0,0,0.35)",
38
+ }}
39
+ >
40
+ <div style={{ maxWidth: 900, margin: "0 auto", padding: "10px 14px" }}>
41
+ <div style={{ display: "flex", alignItems: "flex-start", gap: 10 }}>
42
+ <span aria-hidden style={{ fontSize: 15, lineHeight: 1.3 }}>
43
+ ⚠️
44
+ </span>
45
+ <div style={{ flex: 1, minWidth: 0 }}>
46
+ <strong style={{ fontWeight: 700 }}>{msg.title}</strong>
47
+ <div style={{ marginTop: 3, color: "#fecaca" }}>{msg.consequence}</div>
48
+ </div>
49
+ <button
50
+ type="button"
51
+ onClick={() => setOpen((v) => !v)}
52
+ aria-expanded={open}
53
+ style={{
54
+ flexShrink: 0,
55
+ background: "transparent",
56
+ border: "1px solid #f87171",
57
+ borderRadius: 6,
58
+ color: "#fef2f2",
59
+ cursor: "pointer",
60
+ fontSize: 12,
61
+ padding: "3px 9px",
62
+ }}
63
+ >
64
+ {open ? "Hide" : "Details"}
65
+ </button>
66
+ </div>
67
+
68
+ {open && (
69
+ <ul style={{ margin: "9px 0 0", padding: "0 0 0 26px", color: "#fecaca" }}>
70
+ {msg.details.map((line) => (
71
+ <li key={line} style={{ marginTop: 2, wordBreak: "break-word" }}>
72
+ {line}
73
+ </li>
74
+ ))}
75
+ </ul>
76
+ )}
77
+ </div>
78
+ </div>
79
+ );
80
+ }
@@ -8,7 +8,10 @@ import { CookieConsent } from "../styled/CookieConsent";
8
8
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
9
9
  import { useInitialSiteConfig } from "../../provider/SiteConfigProvider";
10
10
  import { PoweredBy } from "./PoweredBy";
11
+ import { PreviewDiagnostics } from "./PreviewDiagnostics";
11
12
  import { shellPoweredByEnabled, shellPwaEnabled } from "./shellGating";
13
+ import { previewDiagnosticsEnabled, resolvePreviewDiagnostics } from "./diagnosticsGating";
14
+ import type { ForgeSsrDiagnostics } from "../../types/diagnostics";
12
15
  import { captureAttributionRefFromUrl, readAttributionRef } from "../../utils/attribution";
13
16
  import { captureLandingFromUrl, postLandingBeacon } from "../../utils/landing";
14
17
 
@@ -38,6 +41,13 @@ export interface TribeNestAppProps extends Omit<ForgeProviderProps, "children">
38
41
  state?: "draft" | "published";
39
42
  /** Href for the client-injected manifest link fallback. */
40
43
  manifestHref?: string;
44
+ /**
45
+ * What failed while bootstrapping this render, from `fetchSiteBootstrap`.
46
+ * Surfaced as a banner in the editor preview / review Workers only. Optional:
47
+ * a shell that still calls `fetchContentDocument`/`fetchSiteConfig` separately
48
+ * gets a less detailed banner inferred from a null `initialSiteConfig`.
49
+ */
50
+ diagnostics?: ForgeSsrDiagnostics | null;
41
51
  /** Turn individual shell concerns off (all default on). */
42
52
  analytics?: boolean;
43
53
  pwa?: boolean;
@@ -100,6 +110,7 @@ export function TribeNestApp({
100
110
  editable,
101
111
  state,
102
112
  manifestHref = "/manifest.webmanifest",
113
+ diagnostics,
103
114
  analytics = true,
104
115
  pwa = true,
105
116
  cookieConsent = true,
@@ -108,8 +119,21 @@ export function TribeNestApp({
108
119
  // Register the SW + offer install only on the live published site — never in
109
120
  // the HMR editor or on a preview-<versionId>.* review Worker (state="draft").
110
121
  const pwaEnabled = shellPwaEnabled({ pwa, editable, state });
122
+ // A failed SSR bootstrap renders a complete-looking page built entirely from
123
+ // fallbacks — indistinguishable from an unconfigured site. Say so, where the
124
+ // people who can fix it are looking.
125
+ const ssrFailure = previewDiagnosticsEnabled({ editable, state })
126
+ ? resolvePreviewDiagnostics({
127
+ diagnostics,
128
+ profileId: forgeProps.profileId,
129
+ siteConfig: forgeProps.initialSiteConfig,
130
+ apiUrl: forgeProps.apiUrl,
131
+ })
132
+ : null;
111
133
  return (
112
134
  <ForgeProvider editable={editable} {...forgeProps}>
135
+ {/* First in the tree: it must render even if everything below it doesn't. */}
136
+ {ssrFailure && <PreviewDiagnostics diagnostics={ssrFailure} />}
113
137
  {children}
114
138
  {/* "Powered by TribeNest" — last in the page flow, so it sits below the
115
139
  tenant's own footer. Released builds only (live + published). */}
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ previewDiagnosticsEnabled,
4
+ resolvePreviewDiagnostics,
5
+ previewDiagnosticsMessage,
6
+ } from "./diagnosticsGating";
7
+ import type { ForgeSsrDiagnostics } from "../../types/diagnostics";
8
+
9
+ describe("previewDiagnosticsEnabled", () => {
10
+ it("shows in the in-editor sandbox", () => {
11
+ expect(previewDiagnosticsEnabled({ editable: true, state: "draft" })).toBe(true);
12
+ // editable wins even if a shell mislabels the state.
13
+ expect(previewDiagnosticsEnabled({ editable: true, state: "published" })).toBe(true);
14
+ });
15
+
16
+ it("shows on a draft review Worker, which is not editable", () => {
17
+ expect(previewDiagnosticsEnabled({ editable: false, state: "draft" })).toBe(true);
18
+ });
19
+
20
+ it("NEVER shows on the live published site", () => {
21
+ expect(previewDiagnosticsEnabled({ editable: false, state: "published" })).toBe(false);
22
+ expect(previewDiagnosticsEnabled({ state: "published" })).toBe(false);
23
+ // A shell that passes neither must not leak the banner to visitors.
24
+ expect(previewDiagnosticsEnabled({})).toBe(false);
25
+ });
26
+ });
27
+
28
+ describe("resolvePreviewDiagnostics", () => {
29
+ const failed: ForgeSsrDiagnostics = {
30
+ apiUrl: "https://api.tribenest.co",
31
+ failures: [{ what: "content", error: "fetch failed" }],
32
+ };
33
+
34
+ it("passes through real diagnostics from fetchSiteBootstrap", () => {
35
+ expect(resolvePreviewDiagnostics({ diagnostics: failed, profileId: "p1", siteConfig: {} })).toBe(failed);
36
+ });
37
+
38
+ it("returns null on a healthy render", () => {
39
+ expect(resolvePreviewDiagnostics({ diagnostics: null, profileId: "p1", siteConfig: { currency: "USD" } })).toBeNull();
40
+ });
41
+
42
+ it("ignores an empty failure list", () => {
43
+ const clean: ForgeSsrDiagnostics = { apiUrl: "https://api.tribenest.co", failures: [] };
44
+ expect(resolvePreviewDiagnostics({ diagnostics: clean, profileId: "p1", siteConfig: {} })).toBeNull();
45
+ });
46
+
47
+ // The fallback that covers every already-deployed shell still calling
48
+ // fetchContentDocument/fetchSiteConfig separately — no file edit needed.
49
+ it("infers a failure from a null siteConfig when a profileId IS baked in", () => {
50
+ const d = resolvePreviewDiagnostics({ profileId: "p1", siteConfig: null, apiUrl: "https://api.tribenest.co" });
51
+ expect(d).toEqual({
52
+ apiUrl: "https://api.tribenest.co",
53
+ failures: [{ what: "siteConfig", error: "no response" }],
54
+ });
55
+ });
56
+
57
+ it("does NOT infer a failure when there was no profile to fetch for", () => {
58
+ // No profileId baked in → nothing was requested → a null config is correct,
59
+ // not a failure. Crying wolf here would train people to ignore the banner.
60
+ expect(resolvePreviewDiagnostics({ profileId: undefined, siteConfig: null })).toBeNull();
61
+ expect(resolvePreviewDiagnostics({ profileId: "", siteConfig: null })).toBeNull();
62
+ });
63
+ });
64
+
65
+ describe("previewDiagnosticsMessage", () => {
66
+ it("names the network as the cause when the probe also failed", () => {
67
+ const msg = previewDiagnosticsMessage({
68
+ apiUrl: "https://api.tribenest.co",
69
+ failures: [
70
+ { what: "content", error: "fetch failed" },
71
+ { what: "siteConfig", error: "fetch failed" },
72
+ ],
73
+ probe: { ok: false, url: "https://api.tribenest.co/healthcheck", error: "no response within 5000ms", ms: 5001 },
74
+ });
75
+ expect(msg.title).toBe("This preview can't reach the TribeNest API");
76
+ expect(msg.details.some((l) => l.includes("no route to the API"))).toBe(true);
77
+ expect(msg.details).toContain("API: https://api.tribenest.co");
78
+ });
79
+
80
+ it("distinguishes a reachable API from a per-route failure", () => {
81
+ const msg = previewDiagnosticsMessage({
82
+ apiUrl: "https://api.tribenest.co",
83
+ failures: [{ what: "content", error: "HTTP 500", status: 500 }],
84
+ probe: { ok: true, url: "https://api.tribenest.co/healthcheck", status: 200, ms: 42 },
85
+ });
86
+ expect(msg.title).toBe("This preview couldn't load your site data");
87
+ expect(msg.details.some((l) => l.includes("per-route failure"))).toBe(true);
88
+ });
89
+
90
+ it("always states that the defaults on screen are the symptom, not the setting", () => {
91
+ // This sentence is the entire point of the banner — the failure mode it was
92
+ // built for is a preview that looks configured-but-wrong rather than broken.
93
+ const msg = previewDiagnosticsMessage({ apiUrl: "", failures: [{ what: "siteConfig", error: "no response" }] });
94
+ expect(msg.consequence).toContain("default theme");
95
+ expect(msg.consequence).toContain("published site is unaffected");
96
+ });
97
+
98
+ it("omits the API line when the shell baked in no url", () => {
99
+ const msg = previewDiagnosticsMessage({ apiUrl: "", failures: [{ what: "content", error: "boom" }] });
100
+ expect(msg.details.some((l) => l.startsWith("API:"))).toBe(false);
101
+ });
102
+ });
@@ -0,0 +1,90 @@
1
+ // Pure logic for the preview's SSR-failure banner, kept React-free so it's
2
+ // unit-testable in the node test env (no jsdom) — same split as `shellGating.ts`,
3
+ // which is also why this is not named after the component it feeds: a
4
+ // `previewDiagnostics.ts` next to `PreviewDiagnostics.tsx` differs only in
5
+ // casing, which tsc rejects outright on a case-insensitive filesystem.
6
+ import type { ForgeSsrDiagnostics } from "../../types/diagnostics";
7
+
8
+ /**
9
+ * WHERE the banner is allowed to appear: any build that is NOT the released live
10
+ * site — the in-editor sandbox (`editable`) or a preview-<versionId> review
11
+ * Worker (`state === "draft"`).
12
+ *
13
+ * The inverse of `shellPwaEnabled` / `shellPoweredByEnabled`, and deliberately so:
14
+ * a visitor to a live site must never be shown our plumbing. On the live site the
15
+ * degraded render is still the right behaviour — the point of this banner is that
16
+ * the person who can FIX it (the creator, or us) is the one looking at it.
17
+ */
18
+ export function previewDiagnosticsEnabled(opts: { editable?: boolean; state?: "draft" | "published" }): boolean {
19
+ return !!opts.editable || opts.state === "draft";
20
+ }
21
+
22
+ /**
23
+ * WHETHER there is anything to report, and what.
24
+ *
25
+ * Two sources, because the shells are versioned independently of Forge:
26
+ *
27
+ * 1. `diagnostics` — the precise answer, from `fetchSiteBootstrap`. Only shells
28
+ * that have been updated to call it supply this.
29
+ * 2. The fallback — a deploy that HAS a `profileId` baked in but rendered with a
30
+ * null `siteConfig` cannot be healthy: the fetch went out and came back with
31
+ * nothing. Every already-deployed site whose `__root.tsx` still calls the old
32
+ * `fetchSiteConfig` gets a (less detailed) banner from this on a Forge bump
33
+ * alone, with no file edit.
34
+ *
35
+ * Returns null when there is nothing wrong.
36
+ */
37
+ export function resolvePreviewDiagnostics(opts: {
38
+ diagnostics?: ForgeSsrDiagnostics | null;
39
+ profileId?: string;
40
+ siteConfig?: unknown;
41
+ apiUrl?: string;
42
+ }): ForgeSsrDiagnostics | null {
43
+ if (opts.diagnostics && opts.diagnostics.failures.length > 0) return opts.diagnostics;
44
+ if (opts.profileId && opts.siteConfig == null) {
45
+ return {
46
+ apiUrl: opts.apiUrl ?? "",
47
+ failures: [{ what: "siteConfig", error: "no response" }],
48
+ };
49
+ }
50
+ return null;
51
+ }
52
+
53
+ export type PreviewDiagnosticsMessage = {
54
+ title: string;
55
+ /** The one sentence that resolves the confusion this banner exists for. */
56
+ consequence: string;
57
+ /** Specifics — one line per failure, plus the probe verdict. */
58
+ details: string[];
59
+ };
60
+
61
+ const WHAT_LABEL: Record<string, string> = {
62
+ content: "content document (your text, images and theme)",
63
+ siteConfig: "site config (currency, payment provider, tracking)",
64
+ };
65
+
66
+ /** Human copy for the banner. Pure — no React, no formatting concerns. */
67
+ export function previewDiagnosticsMessage(d: ForgeSsrDiagnostics): PreviewDiagnosticsMessage {
68
+ const details = d.failures.map((f) => `Couldn't load the ${WHAT_LABEL[f.what] ?? f.what} — ${f.error}.`);
69
+
70
+ if (d.probe) {
71
+ details.push(
72
+ d.probe.ok
73
+ ? `The API answered ${d.probe.url} in ${d.probe.ms}ms, so this looks like a per-route failure rather than a network one.`
74
+ : `Couldn't reach ${d.probe.url} either — ${d.probe.error ?? `HTTP ${d.probe.status}`} after ${d.probe.ms}ms. This preview has no route to the API.`,
75
+ );
76
+ }
77
+ if (d.apiUrl) details.push(`API: ${d.apiUrl}`);
78
+
79
+ return {
80
+ title:
81
+ d.probe && !d.probe.ok
82
+ ? "This preview can't reach the TribeNest API"
83
+ : "This preview couldn't load your site data",
84
+ // The sentence that would have saved the debugging session this was built
85
+ // after: the defaults on screen are a symptom, not the setting.
86
+ consequence:
87
+ "You're seeing Forge's default theme (white background, purple accent) and empty content because the data never arrived — not because of how your site is configured. Your published site is unaffected.",
88
+ details,
89
+ };
90
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { shellPoweredByEnabled, shellPwaEnabled } from "./shellGating";
2
+ import { poweredByHref, shellPoweredByEnabled, shellPwaEnabled } from "./shellGating";
3
3
 
4
4
  // The PWA (SW register + install prompt) must be ON only for the live published
5
5
  // site, and OFF everywhere else (editor, preview/draft), so review Workers never
@@ -46,3 +46,42 @@ describe("shellPoweredByEnabled", () => {
46
46
  expect(shellPoweredByEnabled({ editable: false, state: "published", hideBadge: false })).toBe(true);
47
47
  });
48
48
  });
49
+
50
+ // The badge is only worth carrying if we can tell WHICH site sent the visitor,
51
+ // so the identifying params are the point of the link, not decoration.
52
+ describe("poweredByHref", () => {
53
+ const parse = (href: string) => new URL(href).searchParams;
54
+
55
+ it("identifies the referring site by subdomain", () => {
56
+ const p = parse(poweredByHref({ subdomain: "ladygaga", profileId: "prof-1" }));
57
+ expect(p.get("utm_source")).toBe("powered_by");
58
+ expect(p.get("utm_medium")).toBe("badge");
59
+ expect(p.get("utm_campaign")).toBe("creator_site");
60
+ expect(p.get("utm_content")).toBe("ladygaga");
61
+ });
62
+
63
+ it("falls back to the profileId when no subdomain is baked in", () => {
64
+ expect(parse(poweredByHref({ profileId: "prof-1" })).get("utm_content")).toBe("prof-1");
65
+ });
66
+
67
+ it("distinguishes a mini-app from a website, and one app from another", () => {
68
+ const p = parse(poweredByHref({ subdomain: "ladygaga", appId: "app-7" }));
69
+ expect(p.get("utm_campaign")).toBe("creator_app");
70
+ expect(p.get("utm_content")).toBe("ladygaga"); // the tenant
71
+ expect(p.get("utm_term")).toBe("app-7"); // the deploy
72
+ });
73
+
74
+ it("omits utm_content rather than emitting an empty one when identity is missing", () => {
75
+ expect(parse(poweredByHref({})).has("utm_content")).toBe(false);
76
+ });
77
+
78
+ it("url-encodes an identity that needs it", () => {
79
+ // A subdomain can't contain these, but utm_content also carries profile ids
80
+ // and one day whatever replaces them — encode rather than trust the shape.
81
+ expect(parse(poweredByHref({ subdomain: "a b&c" })).get("utm_content")).toBe("a b&c");
82
+ });
83
+
84
+ it("always points at the marketing site", () => {
85
+ expect(poweredByHref({ subdomain: "x" }).startsWith("https://tribenest.co/?")).toBe(true);
86
+ });
87
+ });
@@ -23,3 +23,36 @@ export function shellPoweredByEnabled(opts: {
23
23
  }): boolean {
24
24
  return !opts.hideBadge && !opts.editable && opts.state === "published";
25
25
  }
26
+
27
+ /** Marketing site the badge points at. */
28
+ const TRIBENEST_URL = "https://tribenest.co/";
29
+
30
+ /**
31
+ * The badge href, tagged so a signup can be traced back to the exact site that
32
+ * referred it.
33
+ *
34
+ * utm_source=powered_by the badge, as against any other tribenest.co inbound
35
+ * utm_medium=badge
36
+ * utm_campaign creator_site | creator_app — which surface it was
37
+ * utm_content WHICH site: the tenant subdomain, falling back to the
38
+ * profileId when a deploy has no subdomain baked in
39
+ * utm_term the appId on a mini-app, so two apps on one profile
40
+ * stay distinguishable (utm_content is the tenant, not
41
+ * the deploy)
42
+ *
43
+ * Built from the identity `ForgeProvider` was constructed with — baked at build
44
+ * time — never from `window.location`. That keeps it identical on the server and
45
+ * the client (no hydration mismatch), and correct on custom domains, where the
46
+ * hostname says nothing about which tenant it is.
47
+ */
48
+ export function poweredByHref(opts: { subdomain?: string; profileId?: string; appId?: string }): string {
49
+ const params = new URLSearchParams({
50
+ utm_source: "powered_by",
51
+ utm_medium: "badge",
52
+ utm_campaign: opts.appId ? "creator_app" : "creator_site",
53
+ });
54
+ const site = opts.subdomain || opts.profileId;
55
+ if (site) params.set("utm_content", site);
56
+ if (opts.appId) params.set("utm_term", opts.appId);
57
+ return `${TRIBENEST_URL}?${params.toString()}`;
58
+ }