@tribe-nest/forge 3.59.0 → 3.60.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 (94) hide show
  1. package/package.json +1 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CommunityContext.tsx +192 -0
  4. package/src/data/queries/useChat.ts +42 -39
  5. package/src/data/queries/useCommunities.ts +343 -0
  6. package/src/data/queries/useCommunity.ts +93 -89
  7. package/src/data/queries/useMembership.ts +32 -7
  8. package/src/data/queries/useMembershipCalls.ts +116 -7
  9. package/src/data/queries/usePostCollections.ts +28 -16
  10. package/src/data/queries/usePosts.ts +16 -14
  11. package/src/data/queries/useSubscriptions.ts +22 -8
  12. package/src/i18n/de.json +145 -1
  13. package/src/i18n/en.json +145 -1
  14. package/src/index.ts +19 -7
  15. package/src/runtime/pages.spec.ts +31 -0
  16. package/src/runtime/pages.ts +21 -22
  17. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  18. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  19. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  20. package/src/ui/format/membershipGate.ts +19 -15
  21. package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
  22. package/src/ui/headless/_tests/communityHarness.tsx +205 -0
  23. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  24. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  25. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +382 -0
  26. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +151 -0
  27. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +286 -0
  28. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  29. package/src/ui/headless/auth/useSignupForm.ts +10 -2
  30. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  31. package/src/ui/headless/communities/index.ts +55 -0
  32. package/src/ui/headless/communities/paths.ts +36 -0
  33. package/src/ui/headless/communities/useCommunity.ts +140 -0
  34. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  35. package/src/ui/headless/communities/useCommunityHome.ts +88 -0
  36. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  37. package/src/ui/headless/communities/useCommunityJoin.ts +444 -0
  38. package/src/ui/headless/communities/useCommunityMembership.ts +136 -0
  39. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  40. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  41. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  42. package/src/ui/headless/index.ts +8 -4
  43. package/src/ui/headless/memberHome/index.ts +1 -1
  44. package/src/ui/headless/memberHome/useMemberHomeNav.ts +44 -42
  45. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  46. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  47. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  48. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  49. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  50. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  51. package/src/ui/headless/posts/upsell.ts +54 -8
  52. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  53. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  54. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  55. package/src/ui/headless/posts/usePostItem.ts +6 -5
  56. package/src/ui/index.ts +17 -12
  57. package/src/ui/styled/AccountDashboard.tsx +48 -256
  58. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  59. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  60. package/src/ui/styled/MembershipTiers.tsx +49 -11
  61. package/src/ui/styled/Paywall.tsx +1 -1
  62. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +1 -1
  63. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  64. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  65. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  66. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  67. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  68. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  69. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  70. package/src/ui/styled/communities/CommunityAbout.tsx +140 -0
  71. package/src/ui/styled/communities/CommunityHome.tsx +175 -0
  72. package/src/ui/styled/communities/CommunityJoinSheet.tsx +444 -0
  73. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +133 -0
  74. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  75. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  76. package/src/ui/styled/communities/MyMembershipsList.tsx +89 -0
  77. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  78. package/src/ui/styled/communities/index.ts +10 -0
  79. package/src/ui/styled/communities/shared.tsx +149 -0
  80. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  81. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  82. package/src/ui/styled/forge-utilities.css +19 -0
  83. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  84. package/src/ui/styled/members/MemberCallsCalendar.tsx +142 -10
  85. package/src/ui/styled/members/MemberHomeLayout.tsx +35 -17
  86. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  87. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  88. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  89. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  90. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  91. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  92. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  93. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  94. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
@@ -1,44 +0,0 @@
1
- import type { ReactNode } from "react";
2
- import { usePublicAuth } from "../../../contexts/PublicAuthContext";
3
- import { getMembershipAccess } from "../../../utils/membershipAccess";
4
-
5
- export interface MembershipGateState {
6
- hasAccess: boolean;
7
- isAuthenticated: boolean;
8
- }
9
-
10
- export interface MembershipGateProps {
11
- /**
12
- * Tier ids that unlock the content. Omitted/empty → any active membership
13
- * unlocks. (For per-item gates where the server already returns `hasAccess`,
14
- * pass it via `access`.)
15
- */
16
- tiers?: string[];
17
- /** Override the access decision (e.g. server-provided `post.hasAccess`). */
18
- access?: boolean;
19
- children: ReactNode;
20
- /** Rendered when locked — static node or a render-fn given the gate state. */
21
- fallback?: ReactNode | ((state: MembershipGateState) => ReactNode);
22
- }
23
-
24
- /**
25
- * Headless access gate. Renders children when the member has access, otherwise
26
- * the fallback. Behavior (who has access) is fixed by the server/membership;
27
- * the locked UI is entirely yours.
28
- */
29
- export function MembershipGate({ tiers, access, children, fallback }: MembershipGateProps) {
30
- const { user, isAuthenticated } = usePublicAuth();
31
- const membership = user?.membership;
32
- // The SERVER's access decision, not a `status === "active"` compare. That
33
- // compare locked out members in `past_due`/`grace` — states the backend
34
- // deliberately keeps access through — so a card that bounced cost them the
35
- // content they were still paying for.
36
- const membershipGrantsAccess = getMembershipAccess(membership).hasAccess;
37
- const tierAllows = !tiers?.length || (membership ? tiers.includes(membership.membershipTierId) : false);
38
-
39
- const hasAccess = access ?? (membershipGrantsAccess && tierAllows);
40
-
41
- if (hasAccess) return <>{children}</>;
42
- if (typeof fallback === "function") return <>{fallback({ hasAccess, isAuthenticated })}</>;
43
- return <>{fallback ?? null}</>;
44
- }
@@ -1,149 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { renderToStaticMarkup } from "react-dom/server";
3
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
- import { ForgeClientProvider } from "../../../provider/ForgeProvider";
5
- import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
6
- import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
7
- import { communityKeys, type CommunitySpace } from "../../../data/queries/useCommunity";
8
- import { AccountDashboard } from "../AccountDashboard";
9
-
10
- /**
11
- * The account page's way into the member area.
12
- *
13
- * Before this, a signed-in member landed on the membership card and the only
14
- * doors on it led back to billing: change the plan, cancel it, fix the card.
15
- * The member area they were paying for was reachable only by typing the URL.
16
- *
17
- * The first version gated the link on `/public/community/spaces` returning a
18
- * space the caller could open, which hid it from precisely the fans who had not
19
- * joined yet. Being signed in is now the whole gate: the member home shows the
20
- * locked posts and the way to buy access, so it is never a dead end.
21
- *
22
- * Rendered through `react-dom/server`: no DOM, no effects, so the spaces query
23
- * is SEEDED into the react-query cache rather than mocked, which keeps the real
24
- * data path in place for the rest of the card.
25
- */
26
-
27
- const PROFILE_ID = "profile-1";
28
-
29
- const space = (overrides: Partial<CommunitySpace> = {}): CommunitySpace => ({
30
- id: "space-1",
31
- profileId: PROFILE_ID,
32
- name: "Backstage",
33
- slug: "backstage",
34
- description: null,
35
- type: "feed",
36
- courseId: null,
37
- order: 0,
38
- archivedAt: null,
39
- createdAt: "2026-01-01T00:00:00.000Z",
40
- tiers: [],
41
- ...overrides,
42
- });
43
-
44
- /** A signed-in member holding a paid tier that renews. */
45
- const member = {
46
- id: "account-1",
47
- email: "fan@example.com",
48
- firstName: "Fan",
49
- lastName: "Person",
50
- kind: "fan",
51
- status: "active",
52
- createdAt: "2026-01-01T00:00:00.000Z",
53
- updatedAt: "2026-01-01T00:00:00.000Z",
54
- membership: {
55
- id: "membership-1",
56
- membershipTierId: "tier-1",
57
- endDate: "2026-12-01T00:00:00.000Z",
58
- status: "active",
59
- startDate: "2026-01-01T00:00:00.000Z",
60
- subscriptionAmount: 10,
61
- subscriptionCurrency: "USD",
62
- billingCycle: "month",
63
- membershipTier: { id: "tier-1", name: "Inner Circle", benefits: [] },
64
- },
65
- };
66
-
67
- /** The same signed-in fan, with no membership of any kind. */
68
- const nonMember = { ...member, membership: undefined };
69
-
70
- /**
71
- * The membership tab as a Forge code website draws it.
72
- *
73
- * `spaces === null` stands for the query never having answered, which is the
74
- * state every first paint sits in.
75
- */
76
- function render(opts: {
77
- spaces: CommunitySpace[] | null;
78
- user?: typeof member | typeof nonMember | null;
79
- onNavigateCommunity?: () => void;
80
- }): string {
81
- const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
82
- if (opts.spaces) queryClient.setQueryData(communityKeys.spaces(PROFILE_ID), opts.spaces);
83
- const user = opts.user === undefined ? member : opts.user;
84
-
85
- return renderToStaticMarkup(
86
- <QueryClientProvider client={queryClient}>
87
- <ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
88
- <PublicAuthContext.Provider
89
- value={{ user, currencies: null, userSelectedCurrency: "USD", logout: () => {} } as never}
90
- >
91
- <ForgeThemeProvider
92
- theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
93
- >
94
- <AccountDashboard
95
- tab="membership"
96
- onNavigateMembership={() => {}}
97
- onNavigateCommunity={opts.onNavigateCommunity}
98
- />
99
- </ForgeThemeProvider>
100
- </PublicAuthContext.Provider>
101
- </ForgeClientProvider>
102
- </QueryClientProvider>,
103
- );
104
- }
105
-
106
- describe("AccountDashboard: the way into the member area", () => {
107
- it("REGRESSION: offers the member area to a member who has a space to open", () => {
108
- // The original gap this closes. Without it the membership card's only exits
109
- // are Change and Cancel, and the member area is unreachable from here.
110
- const html = render({ spaces: [space()], onNavigateCommunity: () => {} });
111
-
112
- expect(html).toContain("Member Area");
113
- });
114
-
115
- it("REGRESSION: offers the member area to a signed-in fan with NO membership", () => {
116
- // The second gap. The link used to require an openable space, so a fan who
117
- // had not joined saw no way in, which is the fan the member home is most
118
- // useful to: that page shows the locked posts and the offer to join.
119
- const html = render({ spaces: [], user: nonMember, onNavigateCommunity: () => {} });
120
-
121
- expect(html).toContain("Member Area");
122
- // Still the non-member card and not some other state.
123
- expect(html).toContain("You don&#x27;t have an active membership.");
124
- });
125
-
126
- it("REGRESSION: offers it before the spaces query has answered", () => {
127
- // First paint has no cached answer. The link no longer waits on that read,
128
- // so it does not appear a beat late.
129
- const html = render({ spaces: null, onNavigateCommunity: () => {} });
130
-
131
- expect(html).toContain("Member Area");
132
- });
133
-
134
- it("draws nothing when the host site has no member-area route to send anyone to", () => {
135
- // `onNavigateCommunity` is optional. A site that leaves it out gets no
136
- // button rather than a dead one.
137
- const html = render({ spaces: [space()] });
138
-
139
- expect(html).not.toContain("Member Area");
140
- });
141
-
142
- it("draws nothing for a visitor who is not signed in", () => {
143
- // The member area is behind a login. Offering it to a signed-out visitor
144
- // sends them at a guard, not at the content.
145
- const html = render({ spaces: [space()], user: null, onNavigateCommunity: () => {} });
146
-
147
- expect(html).not.toContain("Member Area");
148
- });
149
- });
@@ -1,105 +0,0 @@
1
- // @vitest-environment jsdom
2
- import { describe, it, expect, afterEach } from "vitest";
3
- import { render, screen, fireEvent, cleanup } from "@testing-library/react";
4
- import axios, { type AxiosAdapter, type InternalAxiosRequestConfig } from "axios";
5
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6
- import { ForgeClientProvider } from "../../../provider/ForgeProvider";
7
- import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
8
- import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
9
- import { AccountDashboard } from "../AccountDashboard";
10
-
11
- /**
12
- * The account page during a free trial: the date the trial ends and what is
13
- * charged after it replace "Renews on", and cancelling says access lasts to the
14
- * trial end with nothing charged.
15
- */
16
-
17
- const DAY = 24 * 60 * 60 * 1000;
18
- const inDays = (n: number) => new Date(Date.now() + n * DAY).toISOString();
19
- const fmt = (iso: string) =>
20
- new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
21
-
22
- const memberWith = (membership: Record<string, unknown>) => ({
23
- id: "account-1",
24
- email: "fan@example.com",
25
- firstName: "Fan",
26
- lastName: "Person",
27
- kind: "fan",
28
- status: "active",
29
- createdAt: "2026-01-01T00:00:00.000Z",
30
- updatedAt: "2026-01-01T00:00:00.000Z",
31
- membership: {
32
- id: "membership-1",
33
- membershipTierId: "tier-1",
34
- status: "active",
35
- startDate: "2026-09-17T00:00:00.000Z",
36
- subscriptionAmount: 10,
37
- subscriptionCurrency: "USD",
38
- billingCycle: "month",
39
- membershipTier: { id: "tier-1", name: "Inner Circle", benefits: [] },
40
- ...membership,
41
- },
42
- });
43
-
44
- let realAdapter: AxiosAdapter | undefined;
45
-
46
- function mount(user: unknown) {
47
- // Every request answers empty, so the tab's side queries never reach a network.
48
- realAdapter = axios.defaults.adapter as AxiosAdapter | undefined;
49
- axios.defaults.adapter = (async (config: InternalAxiosRequestConfig) =>
50
- ({ data: [], status: 200, statusText: "OK", headers: {}, config }) as never) as AxiosAdapter;
51
- const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
52
- return render(
53
- <QueryClientProvider client={queryClient}>
54
- <ForgeClientProvider apiUrl="http://api.test" profileId="profile-1">
55
- <PublicAuthContext.Provider
56
- value={{ user, currencies: null, userSelectedCurrency: "USD", logout: () => {} } as never}
57
- >
58
- <ForgeThemeProvider theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}>
59
- <AccountDashboard tab="membership" onNavigateMembership={() => {}} />
60
- </ForgeThemeProvider>
61
- </PublicAuthContext.Provider>
62
- </ForgeClientProvider>
63
- </QueryClientProvider>,
64
- );
65
- }
66
-
67
- afterEach(() => {
68
- cleanup();
69
- if (realAdapter) axios.defaults.adapter = realAdapter;
70
- });
71
-
72
- describe("AccountDashboard: a membership in its free trial", () => {
73
- it("shows the trial end and what is charged after it, instead of Renews on", () => {
74
- const ends = inDays(10);
75
- mount(memberWith({ trialEndsAt: ends, endDate: ends }));
76
-
77
- const state = screen.getByTestId("membership-trial-state");
78
- expect(state.textContent).toContain(`Free trial until ${fmt(ends)}`);
79
- expect(state.textContent).toMatch(new RegExp(`Then .*10.* per month from ${fmt(ends)}\\.`));
80
- expect(document.body.textContent).not.toContain("Renews on");
81
- });
82
-
83
- it("says access lasts to the trial end with nothing charged when cancelling", () => {
84
- const ends = inDays(10);
85
- mount(memberWith({ trialEndsAt: ends, endDate: ends }));
86
-
87
- fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
88
- const dialog = screen.getByRole("alertdialog");
89
- expect(dialog.textContent).toContain(
90
- `Your free trial will not turn into a paid membership. You keep your benefits until ${fmt(ends)}, and nothing is charged.`,
91
- );
92
- });
93
-
94
- it("says nothing will be charged once a trial is cancelled", () => {
95
- const ends = inDays(10);
96
- mount(memberWith({ trialEndsAt: ends, endDate: ends, cancelAtPeriodEnd: true }));
97
- expect(screen.getByTestId("membership-trial-state").textContent).toContain("Cancelled. Nothing will be charged.");
98
- });
99
-
100
- it("shows no trial line once the trial has ended", () => {
101
- mount(memberWith({ trialEndsAt: inDays(-5), endDate: inDays(25) }));
102
- expect(screen.queryByTestId("membership-trial-state")).toBeNull();
103
- expect(document.body.textContent).toContain("Renews on");
104
- });
105
- });