@tribe-nest/forge 3.58.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
@@ -0,0 +1,137 @@
1
+ import { useEffect } from "react";
2
+ import { useCommunityUpgrade, type UseCommunityUpgradeOptions } from "../../headless/communities/useCommunityUpgrade";
3
+ import { useSiteConfig } from "../../../data/queries/useWebsite";
4
+ import { useForgeT } from "../../../i18n";
5
+ import { usePaymentRenderer } from "../../payment/ForgePaymentProvider";
6
+ import { formatCurrency } from "../../format/useFormatCurrency";
7
+ import { Loading } from "../Loading";
8
+ import { PaystackPayButton } from "../PaystackPayButton";
9
+ import { CommunitySheet, useCommunityStyles } from "./shared";
10
+
11
+ export interface CommunityUpgradeSheetProps extends UseCommunityUpgradeOptions {
12
+ open: boolean;
13
+ onOpenChange: (open: boolean) => void;
14
+ }
15
+
16
+ /**
17
+ * The upgrade sheet: only the tiers above the member's current one. On Stripe
18
+ * one tap moves the saved subscription with a prorated charge; on Paystack the
19
+ * sheet says the card is collected again BEFORE the fan confirms. Every locked
20
+ * item's "Unlock with {tier}" opens this with that tier chosen. Built on
21
+ * `useCommunityUpgrade`.
22
+ */
23
+ export function CommunityUpgradeSheet({ open, onOpenChange, ...options }: CommunityUpgradeSheetProps) {
24
+ const tr = useForgeT();
25
+ const { button, ghostButton, card, muted, errorText, t } = useCommunityStyles();
26
+ const renderPayment = usePaymentRenderer();
27
+ const currency = useSiteConfig().data?.currency;
28
+ const upgrade = useCommunityUpgrade(options);
29
+ const money = (amount: number | null | undefined, code?: string) => formatCurrency(amount ?? 0, code ?? currency);
30
+
31
+ // Re-opened on a different locked item: start on that item's tier.
32
+ useEffect(() => {
33
+ if (open && options.initialTierId && upgrade.selectedTierId !== options.initialTierId) upgrade.select(options.initialTierId);
34
+ if (!open) upgrade.reset();
35
+ // eslint-disable-next-line react-hooks/exhaustive-deps
36
+ }, [open, options.initialTierId]);
37
+
38
+ return (
39
+ <CommunitySheet
40
+ open={open}
41
+ onOpenChange={onOpenChange}
42
+ testId="community-upgrade-sheet"
43
+ title={tr("forge.community_upgrade_sheet.title")}
44
+ closeLabel={tr("forge.community_upgrade_sheet.aria_close")}
45
+ >
46
+ {upgrade.isLoading ? (
47
+ <Loading />
48
+ ) : upgrade.step === "choose" ? (
49
+ upgrade.options.length === 0 ? (
50
+ <p style={muted}>{tr("forge.community_upgrade_sheet.none")}</p>
51
+ ) : (
52
+ <div className="fg:flex fg:flex-col fg:gap-3">
53
+ {upgrade.currentTierName && (
54
+ <p style={muted}>{tr("forge.community_upgrade_sheet.current", { tier_name: upgrade.currentTierName })}</p>
55
+ )}
56
+ {upgrade.options.map((option) => (
57
+ <button
58
+ key={option.id}
59
+ type="button"
60
+ data-testid="community-upgrade-option"
61
+ onClick={() => upgrade.select(option.id)}
62
+ className="fg:flex fg:flex-col fg:gap-1 fg:text-left"
63
+ style={{ ...card, padding: 14, cursor: "pointer", font: "inherit" }}
64
+ >
65
+ <span style={{ fontWeight: 700 }}>{option.name}</span>
66
+ <span style={{ fontSize: 13, color: t.muted }}>
67
+ {option.priceMonthly
68
+ ? tr("forge.community_upgrade_sheet.price_monthly", { amount: money(option.priceMonthly) })
69
+ : option.priceYearly
70
+ ? tr("forge.community_upgrade_sheet.price_yearly", { amount: money(option.priceYearly) })
71
+ : ""}
72
+ </span>
73
+ {!!option.tier?.benefits?.length && (
74
+ <span style={{ fontSize: 13 }}>{option.tier.benefits.slice(0, 3).map((benefit) => benefit.title).join(", ")}</span>
75
+ )}
76
+ </button>
77
+ ))}
78
+ </div>
79
+ )
80
+ ) : upgrade.step === "confirm" ? (
81
+ <div className="fg:flex fg:flex-col fg:gap-3" data-testid="community-upgrade-confirm">
82
+ <p style={{ fontWeight: 700 }}>
83
+ {tr("forge.community_upgrade_sheet.unlock_with", { tier_name: upgrade.selected?.name ?? "" })}
84
+ </p>
85
+ {upgrade.previewLoading ? (
86
+ <Loading />
87
+ ) : upgrade.preview ? (
88
+ <p>
89
+ {upgrade.prorated
90
+ ? tr("forge.community_upgrade_sheet.charge_prorated", { amount: money(upgrade.preview.amount, upgrade.preview.currency) })
91
+ : tr("forge.community_upgrade_sheet.charge_now", { amount: money(upgrade.preview.amount, upgrade.preview.currency) })}
92
+ </p>
93
+ ) : null}
94
+ {/* What confirming will do to the card is a warning about the action, so it stays visible. */}
95
+ {upgrade.requiresCard && (
96
+ <p role="alert" data-testid="community-upgrade-requires-card" style={{ fontWeight: 600 }}>
97
+ {tr("forge.community_upgrade_sheet.requires_card")}
98
+ </p>
99
+ )}
100
+ {upgrade.error && <p role="alert" style={errorText}>{upgrade.error}</p>}
101
+ <div className="fg:flex fg:gap-2">
102
+ <button type="button" style={ghostButton} onClick={upgrade.back}>
103
+ {tr("forge.community_upgrade_sheet.back")}
104
+ </button>
105
+ <button type="button" style={{ ...button, flex: 1 }} disabled={upgrade.isSubmitting} onClick={() => void upgrade.upgrade()}>
106
+ {upgrade.requiresCard ? tr("forge.community_upgrade_sheet.continue_to_card") : tr("forge.community_upgrade_sheet.confirm")}
107
+ </button>
108
+ </div>
109
+ </div>
110
+ ) : upgrade.step === "payment" ? (
111
+ <div className="fg:flex fg:flex-col fg:gap-3" data-testid="community-upgrade-payment">
112
+ {upgrade.isPaystack ? (
113
+ <PaystackPayButton onPay={upgrade.openPaystackCheckout} label={tr("forge.community_upgrade_sheet.pay")} />
114
+ ) : (
115
+ upgrade.clientSecret &&
116
+ renderPayment({
117
+ clientSecret: upgrade.clientSecret,
118
+ returnUrl: upgrade.returnUrl,
119
+ amount: upgrade.preview?.amount ?? 0,
120
+ currency: upgrade.preview?.currency ?? currency,
121
+ mode: "manual",
122
+ onSucceeded: upgrade.onPaymentSucceeded,
123
+ onFailed: () => {},
124
+ })
125
+ )}
126
+ {upgrade.error && <p role="alert" style={errorText}>{upgrade.error}</p>}
127
+ </div>
128
+ ) : upgrade.step === "submitting" ? (
129
+ <Loading />
130
+ ) : (
131
+ <p data-testid="community-upgrade-done" style={{ fontWeight: 700 }}>
132
+ {tr("forge.community_upgrade_sheet.done")}
133
+ </p>
134
+ )}
135
+ </CommunitySheet>
136
+ );
137
+ }
@@ -0,0 +1,89 @@
1
+ import type { CSSProperties } from "react";
2
+ import { useMyMemberships } from "../../headless/communities/useMyMemberships";
3
+ import { getMembershipStatusMessage } from "../../../utils/membershipAccess";
4
+ import { useForgeLocale, useForgeT } from "../../../i18n";
5
+ import { useAmountFormatter } from "../../format/useFormatCurrency";
6
+ import { Loading } from "../Loading";
7
+ import { Pill, shortDate, useCommunityStyles } from "./shared";
8
+
9
+ export interface MyMembershipsListProps {
10
+ /** SPA navigation adapter for "Open community". Defaults to a full-page visit. */
11
+ navigate?: (href: string) => void;
12
+ /** Format an amount in a currency. Defaults to the runtime currency formatter. */
13
+ formatAmount?: (amount: number, currencyCode?: string) => string;
14
+ className?: string;
15
+ style?: CSSProperties;
16
+ }
17
+
18
+ /**
19
+ * Every membership the fan holds with this creator, one card per community,
20
+ * for the account page. Managing one (billing, cancel, downgrade) happens
21
+ * inside its community, so each card links there. Built on `useMyMemberships`.
22
+ */
23
+ export function MyMembershipsList({ navigate, formatAmount, className, style }: MyMembershipsListProps) {
24
+ const tr = useForgeT();
25
+ const locale = useForgeLocale();
26
+ const { t, card, ghostButton, muted } = useCommunityStyles();
27
+ const fmt = useAmountFormatter(formatAmount);
28
+ const { memberships, isLoading } = useMyMemberships();
29
+ const go = navigate ?? ((href: string) => window.location.assign(href));
30
+
31
+ if (isLoading) return <Loading />;
32
+ if (memberships.length === 0) {
33
+ return (
34
+ <p className={className} data-testid="my-memberships-empty" style={{ ...muted, ...style }}>
35
+ {tr("forge.my_memberships_list.empty")}
36
+ </p>
37
+ );
38
+ }
39
+
40
+ return (
41
+ <ul
42
+ className={className}
43
+ data-testid="my-memberships"
44
+ style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 12, ...style }}
45
+ >
46
+ {memberships.map((membership) => {
47
+ const message = getMembershipStatusMessage(membership.access);
48
+ const periodEnd = shortDate(membership.periodEnd, locale);
49
+ return (
50
+ <li key={membership.id} data-testid="my-membership" className="fg:flex fg:flex-col fg:gap-2 fg:sm:flex-row fg:sm:items-center fg:sm:justify-between" style={card}>
51
+ <div className="fg:min-w-0">
52
+ <p style={{ fontWeight: 700 }}>{membership.community.name}</p>
53
+ <div className="fg:flex fg:flex-wrap fg:items-center fg:gap-2" style={{ marginTop: 4 }}>
54
+ {membership.tierName && <Pill>{membership.tierName}</Pill>}
55
+ <span style={{ fontSize: 13, fontWeight: message.tone === "warning" ? 700 : 400, color: message.tone === "warning" ? t.primary : t.muted }}>
56
+ {message.label}
57
+ </span>
58
+ </div>
59
+ {!!membership.amount && (
60
+ <p style={{ ...muted, marginTop: 4 }}>
61
+ {tr("forge.my_memberships_list.price", {
62
+ amount: fmt(membership.amount, membership.currency ?? undefined),
63
+ cycle: membership.billingCycle === "year" ? tr("forge.my_memberships_list.cycle_year") : tr("forge.my_memberships_list.cycle_month"),
64
+ })}
65
+ </p>
66
+ )}
67
+ {membership.inTrial && membership.trialEndsAt ? (
68
+ <p data-testid="my-membership-trial" style={{ ...muted, fontWeight: 600 }}>
69
+ {tr("forge.my_memberships_list.trial_until", { date: shortDate(membership.trialEndsAt, locale) })}
70
+ </p>
71
+ ) : (
72
+ periodEnd && (
73
+ <p style={muted}>
74
+ {membership.cancelAtPeriodEnd
75
+ ? tr("forge.my_memberships_list.ends_on", { date: periodEnd })
76
+ : tr("forge.my_memberships_list.renews_on", { date: periodEnd })}
77
+ </p>
78
+ )
79
+ )}
80
+ </div>
81
+ <button type="button" style={{ ...ghostButton, flexShrink: 0 }} onClick={() => go(membership.homePath)}>
82
+ {tr("forge.my_memberships_list.open")}
83
+ </button>
84
+ </li>
85
+ );
86
+ })}
87
+ </ul>
88
+ );
89
+ }
@@ -0,0 +1,102 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
4
+ import { ForgeThemeProvider } from "../../../theme/ForgeThemeProvider";
5
+ import { CommunityAbout } from "../CommunityAbout";
6
+ import { CommunityHome } from "../CommunityHome";
7
+ import { CommunitiesList } from "../CommunitiesList";
8
+ import {
9
+ COMMUNITY,
10
+ account,
11
+ detailRoute,
12
+ get,
13
+ installAdapter,
14
+ meRoute,
15
+ restoreAdapter,
16
+ tier,
17
+ viewer,
18
+ wrapper,
19
+ } from "../../../headless/_tests/communityHarness";
20
+
21
+ /**
22
+ * The styled community pages are their headless hooks plus markup. These pin
23
+ * the markup's side of the contract: the About page's one button and the
24
+ * in-place sheet, the home header (tier badge, Upgrade, Membership), and the
25
+ * list linking to About pages.
26
+ */
27
+
28
+ afterEach(restoreAdapter);
29
+
30
+ const theme = { colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 };
31
+
32
+ function mount(node: React.ReactNode, opts: Parameters<typeof wrapper>[0] = {}) {
33
+ const Wrapper = wrapper(opts);
34
+ return render(
35
+ <Wrapper>
36
+ <ForgeThemeProvider theme={theme}>{node}</ForgeThemeProvider>
37
+ </Wrapper>,
38
+ );
39
+ }
40
+
41
+ const free = tier({ id: "tier-free", name: "Free", benefits: [{ id: "b1", title: "The feed" }] as never });
42
+
43
+ describe("CommunityAbout", () => {
44
+ it("shows the community, its tiers, and Join free that opens the sheet in place", async () => {
45
+ installAdapter([detailRoute(() => ({ tiers: [free], viewer: viewer({ state: "anonymous" }) }))]);
46
+ mount(<CommunityAbout />);
47
+ expect(await screen.findByRole("heading", { name: "Fan Club" })).toBeTruthy();
48
+ expect((await screen.findAllByTestId("community-about-tier"))[0].textContent).toContain("The feed");
49
+ const join = await screen.findByTestId("community-about-primary");
50
+ await waitFor(() => expect(join.textContent).toBe("Join free"));
51
+ fireEvent.click(join);
52
+ expect(await screen.findByTestId("community-join-sheet")).toBeTruthy();
53
+ expect(window.location.pathname).not.toContain("/i/account");
54
+ });
55
+
56
+ it("opens the sheet straight away on an invite", async () => {
57
+ installAdapter([
58
+ get("/public/communities/fan-club/invite", () => ({ kind: "email", state: "expired", invitedEmail: null, tier: null })),
59
+ detailRoute(() => ({ tiers: [free] })),
60
+ ]);
61
+ mount(<CommunityAbout invite="tok" />);
62
+ const sheet = await screen.findByTestId("community-join-sheet");
63
+ expect((await within(sheet).findByTestId("community-join-invite-problem")).textContent).toContain("expired");
64
+ });
65
+ });
66
+
67
+ describe("CommunityHome", () => {
68
+ it("renders the header with the tier badge, Upgrade and Membership, then the body", async () => {
69
+ installAdapter([
70
+ meRoute(() =>
71
+ viewer({
72
+ state: "member",
73
+ membership: { id: "m-1", tierId: "tier-free", tierName: "Free", status: "active", cancelAtPeriodEnd: false, currentPeriodEnd: null, pendingDowngradeTierId: null },
74
+ upgradeTiers: [{ id: "tier-gold", name: "Gold", priceMonthly: 20, priceYearly: null }],
75
+ }),
76
+ ),
77
+ detailRoute(() => ({ tiers: [free] })),
78
+ ]);
79
+ mount(
80
+ <CommunityHome tab="posts">
81
+ <p data-testid="body">feed</p>
82
+ </CommunityHome>,
83
+ { user: account() },
84
+ );
85
+ expect((await screen.findByTestId("community-home-tier-badge")).textContent).toBe("Free");
86
+ expect(await screen.findByTestId("community-home-upgrade")).toBeTruthy();
87
+ expect(screen.getByTestId("community-home-membership")).toBeTruthy();
88
+ expect(screen.getByTestId("body").textContent).toBe("feed");
89
+ fireEvent.click(screen.getByTestId("community-home-upgrade"));
90
+ expect(await screen.findByTestId("community-upgrade-sheet")).toBeTruthy();
91
+ });
92
+ });
93
+
94
+ describe("CommunitiesList", () => {
95
+ it("links each community to its About page", async () => {
96
+ installAdapter([get("/public/communities", () => [COMMUNITY])]);
97
+ const went: string[] = [];
98
+ mount(<CommunitiesList navigate={(href) => went.push(href)} />, { community: null });
99
+ fireEvent.click(await screen.findByTestId("community-card"));
100
+ expect(went).toEqual(["/i/communities/fan-club"]);
101
+ });
102
+ });
@@ -0,0 +1,10 @@
1
+ // Communities styled blocks. Each is a headless hook from
2
+ // `ui/headless/communities` plus markup, and nothing else.
3
+ export { CommunitiesList, type CommunitiesListProps } from "./CommunitiesList";
4
+ export { CommunityAbout, type CommunityAboutProps } from "./CommunityAbout";
5
+ export { CommunityJoinSheet, type CommunityJoinSheetProps } from "./CommunityJoinSheet";
6
+ export { CommunityPending, type CommunityPendingProps } from "./CommunityPending";
7
+ export { CommunityHome, type CommunityHomeProps, type CommunityHomeTab } from "./CommunityHome";
8
+ export { CommunityUpgradeSheet, type CommunityUpgradeSheetProps } from "./CommunityUpgradeSheet";
9
+ export { CommunityMembershipMenu, type CommunityMembershipMenuProps } from "./CommunityMembershipMenu";
10
+ export { MyMembershipsList, type MyMembershipsListProps } from "./MyMembershipsList";
@@ -0,0 +1,149 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import { X } from "lucide-react";
3
+ import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "../../headless/dialog";
4
+ import { useThemeTokens, type ResolvedThemeTokens } from "../../theme/ForgeThemeProvider";
5
+
6
+ /** The styles every community block shares, from the theme only. */
7
+ export function useCommunityStyles() {
8
+ const t = useThemeTokens();
9
+ const card: CSSProperties = {
10
+ background: t.surface,
11
+ border: `1px solid ${t.border}`,
12
+ borderRadius: t.cornerRadius,
13
+ padding: 20,
14
+ color: t.text,
15
+ };
16
+ const button: CSSProperties = {
17
+ padding: "10px 16px",
18
+ borderRadius: t.cornerRadius,
19
+ background: t.primary,
20
+ color: t.textPrimary,
21
+ border: "none",
22
+ fontWeight: 600,
23
+ cursor: "pointer",
24
+ fontFamily: t.fontFamily,
25
+ };
26
+ const ghostButton: CSSProperties = {
27
+ ...button,
28
+ background: "transparent",
29
+ color: t.text,
30
+ border: `1px solid ${t.border}`,
31
+ };
32
+ const input: CSSProperties = {
33
+ width: "100%",
34
+ height: 40,
35
+ padding: "8px 12px",
36
+ borderRadius: t.cornerRadius,
37
+ border: `1px solid ${t.border}`,
38
+ background: "transparent",
39
+ color: t.text,
40
+ outline: "none",
41
+ boxSizing: "border-box",
42
+ fontFamily: t.fontFamily,
43
+ };
44
+ const muted: CSSProperties = { fontSize: 13, color: t.muted };
45
+ const errorText: CSSProperties = { fontSize: 13, color: "#ef4444", marginTop: 8 };
46
+ return { t, card, button, ghostButton, input, muted, errorText };
47
+ }
48
+
49
+ /**
50
+ * A modal sheet on the shared dialog primitives, so it stands down while a
51
+ * Paystack popup is open (see `DialogRoot`). The hook that owns the flow must
52
+ * live in the COMPONENT that renders this, not inside `children`: a Paystack
53
+ * stand-down remounts the content.
54
+ */
55
+ export function CommunitySheet({
56
+ open,
57
+ onOpenChange,
58
+ title,
59
+ closeLabel,
60
+ children,
61
+ testId,
62
+ }: {
63
+ open: boolean;
64
+ onOpenChange: (open: boolean) => void;
65
+ title: ReactNode;
66
+ closeLabel: string;
67
+ children: ReactNode;
68
+ testId?: string;
69
+ }) {
70
+ const t = useThemeTokens();
71
+ return (
72
+ <DialogRoot open={open} onOpenChange={onOpenChange}>
73
+ <DialogPortal>
74
+ <DialogOverlay style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 50 }} />
75
+ <DialogContent
76
+ data-testid={testId}
77
+ aria-describedby={undefined}
78
+ style={sheetStyle(t)}
79
+ >
80
+ <div className="fg:flex fg:items-center fg:justify-between fg:gap-3" style={{ marginBottom: 12 }}>
81
+ <DialogTitle style={{ fontSize: 18, fontWeight: 700, margin: 0, fontFamily: t.headingFontFamily }}>
82
+ {title}
83
+ </DialogTitle>
84
+ <DialogClose asChild>
85
+ <button
86
+ type="button"
87
+ aria-label={closeLabel}
88
+ style={{ background: "transparent", border: "none", color: t.text, cursor: "pointer", padding: 4 }}
89
+ >
90
+ <X size={18} />
91
+ </button>
92
+ </DialogClose>
93
+ </div>
94
+ {children}
95
+ </DialogContent>
96
+ </DialogPortal>
97
+ </DialogRoot>
98
+ );
99
+ }
100
+
101
+ function sheetStyle(t: ResolvedThemeTokens): CSSProperties {
102
+ return {
103
+ position: "fixed",
104
+ top: "50%",
105
+ left: "50%",
106
+ transform: "translate(-50%, -50%)",
107
+ width: "min(560px, calc(100vw - 32px))",
108
+ maxHeight: "88vh",
109
+ overflowY: "auto",
110
+ background: t.background,
111
+ color: t.text,
112
+ borderRadius: t.cornerRadius,
113
+ padding: 24,
114
+ zIndex: 51,
115
+ fontFamily: t.fontFamily,
116
+ boxSizing: "border-box",
117
+ };
118
+ }
119
+
120
+ /** A small pill, e.g. the member's tier badge. */
121
+ export function Pill({ children, testId }: { children: ReactNode; testId?: string }) {
122
+ const t = useThemeTokens();
123
+ return (
124
+ <span
125
+ data-testid={testId}
126
+ style={{
127
+ display: "inline-flex",
128
+ alignItems: "center",
129
+ padding: "2px 10px",
130
+ borderRadius: 999,
131
+ fontSize: 12,
132
+ fontWeight: 700,
133
+ border: `1px solid ${t.primary}`,
134
+ color: t.primary,
135
+ whiteSpace: "nowrap",
136
+ }}
137
+ >
138
+ {children}
139
+ </span>
140
+ );
141
+ }
142
+
143
+ /** A locale-aware short date. */
144
+ export function shortDate(value: string | null | undefined, locale: string): string {
145
+ if (!value) return "";
146
+ const date = new Date(value);
147
+ if (Number.isNaN(date.getTime())) return "";
148
+ return date.toLocaleDateString(locale, { year: "numeric", month: "short", day: "numeric" });
149
+ }
@@ -7,6 +7,7 @@ import type { ReactNode } from "react";
7
7
  import { CommunityComposer, type CommunityComposerProps } from "../CommunityComposer";
8
8
  import { ForgeClientProvider } from "../../../../provider/ForgeProvider";
9
9
  import { ForgeThemeProvider, defaultForgeTheme } from "../../../theme/ForgeThemeProvider";
10
+ import { InCommunity } from "../../../../_tests/testCommunity";
10
11
 
11
12
  /**
12
13
  * The composer's open/close behaviour, rendered for real.
@@ -40,7 +41,9 @@ function wrap(children: ReactNode) {
40
41
  return (
41
42
  <QueryClientProvider client={client}>
42
43
  <ForgeClientProvider apiUrl="http://localhost/api">
43
- <ForgeThemeProvider theme={defaultForgeTheme}>{children}</ForgeThemeProvider>
44
+ <ForgeThemeProvider theme={defaultForgeTheme}>
45
+ <InCommunity>{children}</InCommunity>
46
+ </ForgeThemeProvider>
44
47
  </ForgeClientProvider>
45
48
  </QueryClientProvider>
46
49
  );
@@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6
6
  import { CommunityPostDetail } from "../CommunityPostDetail";
7
7
  import { ForgeClientProvider } from "../../../../provider/ForgeProvider";
8
8
  import { ForgeThemeProvider, defaultForgeTheme } from "../../../theme/ForgeThemeProvider";
9
+ import { InCommunity } from "../../../../_tests/testCommunity";
9
10
 
10
11
  /**
11
12
  * The thread view has TWO composers with opposite requirements, and this file
@@ -91,7 +92,9 @@ function mountThread({ isLocked = false } = {}) {
91
92
  <QueryClientProvider client={client}>
92
93
  <ForgeClientProvider apiUrl="http://localhost/api">
93
94
  <ForgeThemeProvider theme={defaultForgeTheme}>
94
- <CommunityPostDetail postId="post-1" />
95
+ <InCommunity>
96
+ <CommunityPostDetail postId="post-1" />
97
+ </InCommunity>
95
98
  </ForgeThemeProvider>
96
99
  </ForgeClientProvider>
97
100
  </QueryClientProvider>,
@@ -161,6 +161,12 @@
161
161
  .fg\:ml-2 {
162
162
  margin-left: calc(var(--fg-spacing) * 2);
163
163
  }
164
+ .fg\:line-clamp-3 {
165
+ overflow: hidden;
166
+ display: -webkit-box;
167
+ -webkit-box-orient: vertical;
168
+ -webkit-line-clamp: 3;
169
+ }
164
170
  .fg\:block {
165
171
  display: block;
166
172
  }
@@ -366,6 +372,11 @@
366
372
  .fg\:gap-y-2 {
367
373
  row-gap: calc(var(--fg-spacing) * 2);
368
374
  }
375
+ .fg\:truncate {
376
+ overflow: hidden;
377
+ text-overflow: ellipsis;
378
+ white-space: nowrap;
379
+ }
369
380
  .fg\:overflow-hidden {
370
381
  overflow: hidden;
371
382
  }
@@ -523,6 +534,9 @@
523
534
  .fg\:text-center {
524
535
  text-align: center;
525
536
  }
537
+ .fg\:text-left {
538
+ text-align: left;
539
+ }
526
540
  .fg\:text-right {
527
541
  text-align: right;
528
542
  }
@@ -760,6 +774,11 @@
760
774
  width: 300px;
761
775
  }
762
776
  }
777
+ .fg\:md\:w-\[320px\] {
778
+ @media (width >= 48rem) {
779
+ width: 320px;
780
+ }
781
+ }
763
782
  .fg\:md\:w-\[360px\] {
764
783
  @media (width >= 48rem) {
765
784
  width: 360px;