@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,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 { 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
@@ -26,3 +26,62 @@ describe("shellPwaEnabled", () => {
26
26
  expect(shellPwaEnabled({ editable: false, state: "published" })).toBe(true);
27
27
  });
28
28
  });
29
+
30
+ // "Powered by TribeNest" ships on released builds only, and the ONLY way to
31
+ // remove it is the server-supplied flag — there is no client-side opt-out,
32
+ // because the tenant owns __root.tsx.
33
+ describe("shellPoweredByEnabled", () => {
34
+ it("is true on a live published site", () => {
35
+ expect(shellPoweredByEnabled({ editable: false, state: "published" })).toBe(true);
36
+ });
37
+
38
+ it("is false in the editor and on draft/preview deploys", () => {
39
+ expect(shellPoweredByEnabled({ editable: true, state: "published" })).toBe(false);
40
+ expect(shellPoweredByEnabled({ editable: false, state: "draft" })).toBe(false);
41
+ expect(shellPoweredByEnabled({ editable: false })).toBe(false); // state undefined
42
+ });
43
+
44
+ it("is false only when the SERVER says to hide it", () => {
45
+ expect(shellPoweredByEnabled({ editable: false, state: "published", hideBadge: true })).toBe(false);
46
+ expect(shellPoweredByEnabled({ editable: false, state: "published", hideBadge: false })).toBe(true);
47
+ });
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
+ });
@@ -9,3 +9,50 @@ export function shellPwaEnabled(opts: {
9
9
  }): boolean {
10
10
  return (opts.pwa ?? true) && !opts.editable && opts.state === "published";
11
11
  }
12
+
13
+ // "Powered by TribeNest" shows on RELEASED builds only — the live published
14
+ // site, never the HMR editor or a preview-<versionId>.* review Worker. Same
15
+ // released-build test as the PWA, with one extra lever: `hideBadge` comes from
16
+ // the server (`siteConfig.hideTribeNestBadge`), which is where a white-label
17
+ // plan entitlement turns it off. It is deliberately not a `TribeNestApp` prop —
18
+ // the tenant owns __root.tsx, so a prop would make the badge opt-out.
19
+ export function shellPoweredByEnabled(opts: {
20
+ editable?: boolean;
21
+ state?: "draft" | "published";
22
+ hideBadge?: boolean;
23
+ }): boolean {
24
+ return !opts.hideBadge && !opts.editable && opts.state === "published";
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
+ }