@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
@@ -0,0 +1,88 @@
1
+ import type { CSSProperties } from "react";
2
+ import { Users } from "lucide-react";
3
+ import { useCommunities } from "../../headless/communities/useCommunity";
4
+ import { communityAboutPath } from "../../headless/communities/paths";
5
+ import type { PublicCommunity } from "../../../data/queries/useCommunities";
6
+ import { useForgeT } from "../../../i18n";
7
+ import { Loading } from "../Loading";
8
+ import { useCommunityStyles } from "./shared";
9
+
10
+ export interface CommunitiesListProps {
11
+ /** SPA navigation adapter. Defaults to a full-page visit. */
12
+ navigate?: (href: string) => void;
13
+ /** Where a community card leads. Default its About page, `/i/communities/<slug>`. */
14
+ getCommunityHref?: (community: PublicCommunity) => string;
15
+ /** Optional page heading. Pass `null` to omit. */
16
+ heading?: string | null;
17
+ className?: string;
18
+ style?: CSSProperties;
19
+ }
20
+
21
+ /** Every community on the profile, as cards. Built on `useCommunities()`. */
22
+ export function CommunitiesList({ navigate, getCommunityHref, heading, className, style }: CommunitiesListProps) {
23
+ const tr = useForgeT();
24
+ const { t, card } = useCommunityStyles();
25
+ const { communities, isLoading } = useCommunities();
26
+ const go = navigate ?? ((href: string) => window.location.assign(href));
27
+ const hrefOf = getCommunityHref ?? ((community: PublicCommunity) => communityAboutPath(community.slug));
28
+ const resolvedHeading = heading === null ? null : (heading ?? tr("forge.communities_list.heading"));
29
+
30
+ if (isLoading) return <Loading fullPage />;
31
+
32
+ return (
33
+ <div className={className} style={{ color: t.text, fontFamily: t.fontFamily, ...style }}>
34
+ {resolvedHeading && (
35
+ <h1 style={{ fontSize: 30, fontWeight: 800, marginBottom: 24, fontFamily: t.headingFontFamily }}>
36
+ {resolvedHeading}
37
+ </h1>
38
+ )}
39
+ {communities.length === 0 ? (
40
+ <p style={{ opacity: 0.8 }}>{tr("forge.communities_list.empty")}</p>
41
+ ) : (
42
+ <div className="fg:grid fg:grid-cols-1 fg:gap-5 fg:sm:grid-cols-2 fg:lg:grid-cols-3">
43
+ {communities.map((community) => (
44
+ <button
45
+ key={community.id}
46
+ type="button"
47
+ data-testid="community-card"
48
+ onClick={() => go(hrefOf(community))}
49
+ className="fg:flex fg:flex-col fg:text-left"
50
+ style={{ ...card, padding: 0, overflow: "hidden", cursor: "pointer", font: "inherit" }}
51
+ >
52
+ <div
53
+ style={{
54
+ height: 140,
55
+ background: community.coverUrl ? `center / cover no-repeat url(${JSON.stringify(community.coverUrl)})` : `${t.primary}22`,
56
+ }}
57
+ />
58
+ <div className="fg:flex fg:flex-col fg:gap-2" style={{ padding: 16 }}>
59
+ <div className="fg:flex fg:items-center fg:gap-3">
60
+ {community.logoUrl && (
61
+ <img
62
+ src={community.logoUrl}
63
+ alt=""
64
+ style={{ width: 36, height: 36, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }}
65
+ />
66
+ )}
67
+ <h2 style={{ fontSize: 18, fontWeight: 700, margin: 0, fontFamily: t.headingFontFamily }}>
68
+ {community.name}
69
+ </h2>
70
+ </div>
71
+ {community.category && <span style={{ fontSize: 12, color: t.muted }}>{community.category}</span>}
72
+ {community.description && (
73
+ <p style={{ fontSize: 14, opacity: 0.85, margin: 0 }} className="fg:line-clamp-3">
74
+ {community.description}
75
+ </p>
76
+ )}
77
+ <span className="fg:flex fg:items-center fg:gap-1" style={{ fontSize: 13, color: t.muted }}>
78
+ <Users size={14} />
79
+ {tr("forge.communities_list.member_count", { count: community.memberCount })}
80
+ </span>
81
+ </div>
82
+ </button>
83
+ ))}
84
+ </div>
85
+ )}
86
+ </div>
87
+ );
88
+ }
@@ -0,0 +1,140 @@
1
+ import type { CSSProperties } from "react";
2
+ import { Users } from "lucide-react";
3
+ import { useCommunityAbout, type UseCommunityAboutOptions } from "../../headless/communities/useCommunityAbout";
4
+ import { useForgeT } from "../../../i18n";
5
+ import { useAmountFormatter } from "../../format/useFormatCurrency";
6
+ import { membershipPriceLabel } from "../../format/membershipPriceLabel";
7
+ import { Loading } from "../Loading";
8
+ import { CommunityJoinSheet } from "./CommunityJoinSheet";
9
+ import { useCommunityStyles } from "./shared";
10
+
11
+ export interface CommunityAboutProps extends UseCommunityAboutOptions {
12
+ /** Format a tier price for display. Defaults to the runtime currency formatter. */
13
+ formatAmount?: (amount: number) => string;
14
+ className?: string;
15
+ style?: CSSProperties;
16
+ }
17
+
18
+ /**
19
+ * A community's About page: cover, name, member count, description, what each
20
+ * tier gives, and one main button. For a visitor it is Join, which opens the
21
+ * join sheet IN PLACE; for a member it goes inside; for a pending applicant it
22
+ * goes to the pending screen. `?invite=` and `?join=` open the sheet straight
23
+ * away. Must sit inside `<CommunityProvider>`. Built on `useCommunityAbout`.
24
+ */
25
+ export function CommunityAbout({ formatAmount, className, style, ...options }: CommunityAboutProps) {
26
+ const tr = useForgeT();
27
+ const { t, card, button, ghostButton, muted } = useCommunityStyles();
28
+ const fmt = useAmountFormatter(formatAmount);
29
+ const about = useCommunityAbout(options);
30
+
31
+ if (about.isLoading && !about.community) return <Loading fullPage />;
32
+ if (about.notFound || !about.community) {
33
+ return <p style={{ color: t.text }}>{tr("forge.community_about.not_found")}</p>;
34
+ }
35
+ const community = about.community;
36
+ const offer = about.joinOffer;
37
+ const mainLabel =
38
+ about.action === "enter"
39
+ ? tr("forge.community_about.go_inside")
40
+ : about.action === "pending"
41
+ ? tr("forge.community_about.view_application")
42
+ : offer?.kind === "free"
43
+ ? tr("forge.community_about.join_free")
44
+ : offer?.kind === "paid"
45
+ ? tr(offer.cycle === "year" ? "forge.community_about.join_from_yearly" : "forge.community_about.join_from", { amount: fmt(offer.amount) })
46
+ : offer?.kind === "apply"
47
+ ? tr("forge.community_about.apply")
48
+ : tr("forge.community_about.join");
49
+
50
+ return (
51
+ <div className={className} style={{ color: t.text, fontFamily: t.fontFamily, ...style }}>
52
+ <div
53
+ style={{
54
+ height: 220,
55
+ borderRadius: t.cornerRadius,
56
+ marginBottom: 24,
57
+ background: community.coverUrl ? `center / cover no-repeat url(${JSON.stringify(community.coverUrl)})` : `${t.primary}22`,
58
+ }}
59
+ />
60
+ <div className="fg:flex fg:flex-col fg:gap-6 fg:md:flex-row fg:md:items-start">
61
+ <div className="fg:flex fg:min-w-0 fg:flex-1 fg:flex-col fg:gap-3">
62
+ <div className="fg:flex fg:items-center fg:gap-3">
63
+ {community.logoUrl && (
64
+ <img src={community.logoUrl} alt="" style={{ width: 56, height: 56, borderRadius: "50%", objectFit: "cover" }} />
65
+ )}
66
+ <div>
67
+ <h1 style={{ fontSize: 30, fontWeight: 800, margin: 0, fontFamily: t.headingFontFamily }}>{community.name}</h1>
68
+ <p className="fg:flex fg:items-center fg:gap-1" style={muted}>
69
+ <Users size={14} />
70
+ {tr("forge.community_about.member_count", { count: community.memberCount })}
71
+ {community.category ? ` · ${community.category}` : ""}
72
+ </p>
73
+ </div>
74
+ </div>
75
+ {community.description && <p style={{ whiteSpace: "pre-line", lineHeight: 1.6 }}>{community.description}</p>}
76
+ {about.isPending && (
77
+ <p role="status" data-testid="community-about-pending" style={{ fontWeight: 600 }}>
78
+ {tr("forge.community_about.pending")}
79
+ </p>
80
+ )}
81
+ </div>
82
+ <div className="fg:w-full fg:md:w-[320px]" style={{ ...card, flexShrink: 0 }}>
83
+ <button type="button" data-testid="community-about-primary" onClick={about.primary} style={{ ...button, width: "100%" }}>
84
+ {mainLabel}
85
+ </button>
86
+ </div>
87
+ </div>
88
+
89
+ {about.tiers.length > 0 && (
90
+ <section style={{ marginTop: 32 }}>
91
+ <h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
92
+ {tr("forge.community_about.tiers_title")}
93
+ </h2>
94
+ <div className="fg:grid fg:grid-cols-1 fg:gap-4 fg:sm:grid-cols-2 fg:lg:grid-cols-3">
95
+ {about.tiers.map((tier) => {
96
+ const current = about.membership?.tierId === tier.id;
97
+ return (
98
+ <div key={tier.id} data-testid="community-about-tier" className="fg:flex fg:flex-col fg:gap-2" style={card}>
99
+ <h3 style={{ fontSize: 17, fontWeight: 700, margin: 0 }}>{tier.name}</h3>
100
+ <p style={{ fontWeight: 700, color: t.primary }}>{membershipPriceLabel(tier, tr, fmt)}</p>
101
+ {tier.description && <p style={{ fontSize: 14, opacity: 0.85 }}>{tier.description}</p>}
102
+ {!!tier.benefits?.length && (
103
+ <ul className="fg:flex fg:flex-col fg:gap-1" style={{ listStyle: "none", padding: 0, margin: 0, fontSize: 14 }}>
104
+ {tier.benefits.map((benefit) => (
105
+ <li key={benefit.id} className="fg:flex fg:gap-2">
106
+ <span style={{ color: t.primary }}>✓</span>
107
+ {benefit.title}
108
+ </li>
109
+ ))}
110
+ </ul>
111
+ )}
112
+ <div style={{ marginTop: "auto" }}>
113
+ {current ? (
114
+ <p style={muted}>{tr("forge.community_about.current_tier")}</p>
115
+ ) : about.action === "join" ? (
116
+ <button type="button" style={{ ...ghostButton, width: "100%" }} onClick={() => about.openJoin(tier.id)}>
117
+ {tier.joinMode === "application" ? tr("forge.community_about.apply") : tr("forge.community_about.join_tier")}
118
+ </button>
119
+ ) : null}
120
+ </div>
121
+ </div>
122
+ );
123
+ })}
124
+ </div>
125
+ </section>
126
+ )}
127
+
128
+ {about.sheetOpen && (
129
+ <CommunityJoinSheet
130
+ open={about.sheetOpen}
131
+ onOpenChange={about.setSheetOpen}
132
+ tierId={about.sheetTierId}
133
+ invite={about.invite}
134
+ join={about.join}
135
+ navigate={options.navigate}
136
+ />
137
+ )}
138
+ </div>
139
+ );
140
+ }
@@ -0,0 +1,175 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import { CommunityUpgradeOpenerProvider } from "../../../contexts/CommunityContext";
3
+ import { useCommunityHome, type UseCommunityHomeOptions } from "../../headless/communities/useCommunityHome";
4
+ import type { MemberHomeNavKey } from "../../headless/memberHome/useMemberHomeNav";
5
+ import { internalPath } from "../../../runtime/pages";
6
+ import { useForgeT } from "../../../i18n";
7
+ import { MemberHomeLayout } from "../members/MemberHomeLayout";
8
+ import { MemberPostsFeed } from "../members/MemberPostsFeed";
9
+ import { PostCollectionsGrid } from "../members/PostCollectionsGrid";
10
+ import { MemberCallsCalendar } from "../members/MemberCallsCalendar";
11
+ import { CommunitySpaces } from "../community/CommunitySpaces";
12
+ import { CommunityLeaderboard } from "../community/CommunityLeaderboard";
13
+ import { CommunityNotificationsInbox } from "../community/CommunityNotifications";
14
+ import { ChatRoom } from "../ChatRoom";
15
+ import { Loading } from "../Loading";
16
+ import { CommunityMembershipMenu } from "./CommunityMembershipMenu";
17
+ import { CommunityUpgradeSheet } from "./CommunityUpgradeSheet";
18
+ import { Pill, useCommunityStyles } from "./shared";
19
+
20
+ /** The member home tabs. `profile` is a member's profile page, which sits under Spaces in the nav. */
21
+ export type CommunityHomeTab = "posts" | "collections" | "spaces" | "leaderboard" | "notifications" | "profile" | "calendar" | "chat";
22
+
23
+ export interface CommunityHomeProps extends UseCommunityHomeOptions {
24
+ /** The active tab. Drives the nav; with no `children`, also which default body renders. */
25
+ tab?: CommunityHomeTab;
26
+ /** SPA navigation adapter. Defaults to a full-page visit. */
27
+ navigate?: (href: string) => void;
28
+ /** Current pathname, used for the active tab when `tab` is not given. */
29
+ currentPath?: string;
30
+ /**
31
+ * The tab's body. Omitted, the default Forge block for `tab` renders (the
32
+ * posts feed, collections, spaces, calendar, chat, leaderboard or
33
+ * notifications), so `<CommunityHome />` alone is a working member home.
34
+ */
35
+ children?: ReactNode;
36
+ className?: string;
37
+ style?: CSSProperties;
38
+ }
39
+
40
+ const NAV_KEY: Record<CommunityHomeTab, MemberHomeNavKey> = {
41
+ posts: "posts",
42
+ collections: "collections",
43
+ spaces: "spaces",
44
+ profile: "spaces",
45
+ leaderboard: "leaderboard",
46
+ notifications: "notifications",
47
+ calendar: "calendar",
48
+ chat: "chat",
49
+ };
50
+
51
+ /**
52
+ * Inside a community: a header with the community's name, the member's tier
53
+ * badge, an Upgrade button whenever a higher tier exists, and the Membership
54
+ * menu; then the member home tabs (`MemberHomeLayout`). Every locked item
55
+ * inside opens the ONE upgrade sheet this provides. A Stripe return
56
+ * (`confirmSubscription`) is reconciled here. Must sit inside
57
+ * `<CommunityProvider>`. Built on `useCommunityHome`.
58
+ */
59
+ export function CommunityHome({
60
+ tab,
61
+ navigate,
62
+ currentPath,
63
+ children,
64
+ welcome,
65
+ confirmSubscription,
66
+ className,
67
+ style,
68
+ }: CommunityHomeProps) {
69
+ const tr = useForgeT();
70
+ const { t, button, ghostButton, card } = useCommunityStyles();
71
+ const home = useCommunityHome({ welcome, confirmSubscription });
72
+ const go = navigate ?? ((href: string) => window.location.assign(href));
73
+
74
+ if (home.isLoading && !home.community) return <Loading fullPage />;
75
+ if (home.notFound || !home.community) return <p style={{ color: t.text }}>{tr("forge.community_home.not_found")}</p>;
76
+ const community = home.community;
77
+
78
+ return (
79
+ <CommunityUpgradeOpenerProvider value={home.opener}>
80
+ <div className={className} style={{ color: t.text, fontFamily: t.fontFamily, ...style }}>
81
+ <header
82
+ data-testid="community-home-header"
83
+ className="fg:flex fg:flex-col fg:gap-3 fg:sm:flex-row fg:sm:items-center fg:sm:justify-between"
84
+ style={{ marginBottom: 20 }}
85
+ >
86
+ <div className="fg:flex fg:min-w-0 fg:items-center fg:gap-3">
87
+ {community.logoUrl && (
88
+ <img src={community.logoUrl} alt="" style={{ width: 44, height: 44, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }} />
89
+ )}
90
+ <h1 className="fg:truncate" style={{ fontSize: 24, fontWeight: 800, margin: 0, fontFamily: t.headingFontFamily }}>
91
+ {community.name}
92
+ </h1>
93
+ {home.membership && <Pill testId="community-home-tier-badge">{home.membership.tierName}</Pill>}
94
+ </div>
95
+ <div className="fg:flex fg:flex-wrap fg:items-center fg:gap-2">
96
+ {home.showUpgrade && (
97
+ <button type="button" data-testid="community-home-upgrade" style={button} onClick={() => home.openUpgrade(null)}>
98
+ {tr("forge.community_home.upgrade")}
99
+ </button>
100
+ )}
101
+ {home.membership && (
102
+ <button type="button" data-testid="community-home-membership" style={ghostButton} onClick={() => home.setMembershipOpen(true)}>
103
+ {tr("forge.community_home.membership")}
104
+ </button>
105
+ )}
106
+ {home.showJoin && home.joinPath && (
107
+ <button type="button" data-testid="community-home-join" style={button} onClick={() => go(home.joinPath)}>
108
+ {tr("forge.community_home.join")}
109
+ </button>
110
+ )}
111
+ </div>
112
+ </header>
113
+
114
+ {home.confirming && (
115
+ <p role="status" style={{ ...card, marginBottom: 16 }}>
116
+ {tr("forge.community_home.confirming")}
117
+ </p>
118
+ )}
119
+ {home.welcomeVisible && (
120
+ <div role="status" data-testid="community-home-welcome" className="fg:flex fg:items-center fg:justify-between fg:gap-3" style={{ ...card, marginBottom: 16 }}>
121
+ <p style={{ fontWeight: 600 }}>{tr("forge.community_home.welcome", { community_name: community.name })}</p>
122
+ <button type="button" style={ghostButton} onClick={home.dismissWelcome}>
123
+ {tr("forge.community_home.dismiss")}
124
+ </button>
125
+ </div>
126
+ )}
127
+
128
+ <MemberHomeLayout currentPath={currentPath} activeKey={tab ? NAV_KEY[tab] : undefined} navigate={go}>
129
+ {children ?? <DefaultTabBody tab={tab ?? "posts"} slug={community.slug} go={go} />}
130
+ </MemberHomeLayout>
131
+
132
+ <CommunityUpgradeSheet
133
+ open={home.upgradeOpen}
134
+ onOpenChange={home.setUpgradeOpen}
135
+ initialTierId={home.upgradeTierId}
136
+ navigate={navigate}
137
+ />
138
+ <CommunityMembershipMenu open={home.membershipOpen} onOpenChange={home.setMembershipOpen} />
139
+ </div>
140
+ </CommunityUpgradeOpenerProvider>
141
+ );
142
+ }
143
+
144
+ /** The default body for a tab, when the host renders none of its own. */
145
+ function DefaultTabBody({ tab, slug, go }: { tab: CommunityHomeTab; slug: string; go: (href: string) => void }) {
146
+ const thread = (postId: string) => internalPath("communities/$slug/home/spaces/posts/$postId", { slug, postId });
147
+ const profile = (accountId: string) => internalPath("communities/$slug/home/profile/$accountId", { slug, accountId });
148
+ switch (tab) {
149
+ case "posts":
150
+ return <MemberPostsFeed navigate={go} signupPath={internalPath("signup")} loginPath={internalPath("login")} />;
151
+ case "collections":
152
+ return <PostCollectionsGrid navigate={go} signupPath={internalPath("signup")} />;
153
+ case "spaces":
154
+ return (
155
+ <CommunitySpaces
156
+ onOpenSpace={(space) => go(internalPath("communities/$slug/home/spaces/$spaceId", { slug, spaceId: space.slug ?? space.id }))}
157
+ />
158
+ );
159
+ case "leaderboard":
160
+ return <CommunityLeaderboard onOpenProfile={(accountId) => go(profile(accountId))} />;
161
+ case "notifications":
162
+ return <CommunityNotificationsInbox onOpenPost={(postId) => go(thread(postId))} />;
163
+ case "calendar":
164
+ return (
165
+ <MemberCallsCalendar
166
+ navigate={go}
167
+ getCallHref={(publicId) => internalPath("video-calls/$callId", { callId: publicId })}
168
+ />
169
+ );
170
+ case "chat":
171
+ return <ChatRoom />;
172
+ default:
173
+ return null;
174
+ }
175
+ }