@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
@@ -1,14 +1,30 @@
1
1
  import type { CSSProperties, ReactNode } from "react";
2
- import { ExternalLink } from "lucide-react";
3
2
  import { useThemeTokens } from "../../theme/ForgeThemeProvider";
4
- import { useMemberHomeNav, type MemberHomeNavItem, type UseMemberHomeNavOptions } from "../../headless/memberHome/useMemberHomeNav";
3
+ import { useForgeT } from "../../../i18n";
4
+ import {
5
+ useMemberHomeNav,
6
+ type MemberHomeNavItem,
7
+ type MemberHomeNavKey,
8
+ type UseMemberHomeNavOptions,
9
+ } from "../../headless/memberHome/useMemberHomeNav";
10
+
11
+ /** Literal keys, so the translation spec can see every one of them. */
12
+ const TAB_LABEL_KEYS: Record<MemberHomeNavKey, string> = {
13
+ posts: "forge.member_home_layout.tab_posts",
14
+ collections: "forge.member_home_layout.tab_collections",
15
+ spaces: "forge.member_home_layout.tab_spaces",
16
+ calendar: "forge.member_home_layout.tab_calendar",
17
+ chat: "forge.member_home_layout.tab_chat",
18
+ leaderboard: "forge.member_home_layout.tab_leaderboard",
19
+ notifications: "forge.member_home_layout.tab_notifications",
20
+ };
5
21
 
6
22
  export interface MemberHomeLayoutProps extends UseMemberHomeNavOptions {
7
- /** Current location pathname (drives the active tab). */
8
- currentPath: string;
23
+ /** Current location pathname (drives the active tab when `activeKey` is not given). */
24
+ currentPath?: string;
9
25
  /**
10
- * SPA navigation adapter. Defaults to a full-page `window.location` visit —
11
- * pass your router's navigate for client-side transitions.
26
+ * SPA navigation adapter. Defaults to a full-page `window.location` visit.
27
+ * Pass your router's navigate for client-side transitions.
12
28
  */
13
29
  navigate?: (href: string) => void;
14
30
  children: ReactNode;
@@ -17,11 +33,13 @@ export interface MemberHomeLayoutProps extends UseMemberHomeNavOptions {
17
33
  }
18
34
 
19
35
  /**
20
- * The unified member home shell (`/i/members`): persistent nav across
21
- * Posts / Collections / Spaces / Chat / My membership. Top scrollable tab bar
22
- * on small screens, side nav on `lg+`. Tab visibility comes from
23
- * `useMemberHomeNav` (conditional tabs hide until the profile has content).
24
- * Hosts own routing — this only renders `children` for the active route.
36
+ * A community's member home shell (`/i/communities/<slug>/home`): persistent
37
+ * nav across Posts / Collections / Spaces / Calendar / Chat / Leaderboard /
38
+ * Notifications. Top scrollable tab bar on small screens, side nav on `lg+`.
39
+ * Tab visibility comes from `useMemberHomeNav` (conditional tabs hide until the
40
+ * community has content). Must sit inside `<CommunityProvider>`; `CommunityHome`
41
+ * wraps it with the community header. Hosts own routing: this only renders
42
+ * `children` for the active route.
25
43
  */
26
44
  export function MemberHomeLayout({
27
45
  currentPath,
@@ -32,6 +50,7 @@ export function MemberHomeLayout({
32
50
  ...navOptions
33
51
  }: MemberHomeLayoutProps) {
34
52
  const t = useThemeTokens();
53
+ const tr = useForgeT();
35
54
  const { visibleItems, isReady } = useMemberHomeNav({ currentPath, ...navOptions });
36
55
  const go = navigate ?? ((href: string) => window.location.assign(href));
37
56
 
@@ -59,8 +78,7 @@ export function MemberHomeLayout({
59
78
  aria-current={item.active ? "page" : undefined}
60
79
  style={itemStyle(item)}
61
80
  >
62
- {item.label}
63
- {item.external && <ExternalLink size={14} style={{ opacity: 0.7, flexShrink: 0 }} />}
81
+ {tr(TAB_LABEL_KEYS[item.key])}
64
82
  </button>
65
83
  );
66
84
 
@@ -69,18 +87,18 @@ export function MemberHomeLayout({
69
87
  className={`fg:flex fg:w-full fg:flex-col fg:lg:flex-row fg:lg:items-start fg:lg:gap-8 ${className ?? ""}`}
70
88
  style={{ color: t.text, fontFamily: t.fontFamily, ...style }}
71
89
  >
72
- {/* Top tab bar — small screens (horizontally scrollable, no burger). */}
90
+ {/* Top tab bar on small screens (horizontally scrollable, no burger). */}
73
91
  <nav
74
- aria-label="Member home"
92
+ aria-label={tr("forge.member_home_layout.nav_label")}
75
93
  className="fg:flex fg:w-full fg:gap-1 fg:overflow-x-auto fg:lg:hidden"
76
94
  style={{ borderBottom: `1px solid ${t.border}`, paddingBottom: 8, marginBottom: 16 }}
77
95
  >
78
96
  {isReady ? visibleItems.map(renderItem) : null}
79
97
  </nav>
80
98
 
81
- {/* Side nav — lg and up. */}
99
+ {/* Side nav, lg and up. */}
82
100
  <nav
83
- aria-label="Member home"
101
+ aria-label={tr("forge.member_home_layout.nav_label")}
84
102
  className="fg:sticky fg:top-4 fg:hidden fg:w-52 fg:shrink-0 fg:flex-col fg:gap-1 fg:lg:flex"
85
103
  style={{
86
104
  border: `1px solid ${t.border}`,
@@ -3,6 +3,7 @@ import { Bookmark, Heart, Lock, MessageCircle, Pause, Play, FileText, BarChart2
3
3
  import type { IMedia, IPublicPost } from "../../../types/models";
4
4
  import { useAudioPlayer } from "../../../contexts/AudioPlayerContext";
5
5
  import { useThemeTokens } from "../../theme/ForgeThemeProvider";
6
+ import { useForgeT } from "../../../i18n";
6
7
  import { usePostItem, type UsePostItemOptions } from "../../headless/posts/usePostItem";
7
8
 
8
9
  export interface MemberPostCardProps extends UsePostItemOptions {
@@ -16,7 +17,7 @@ export interface MemberPostCardProps extends UsePostItemOptions {
16
17
  }
17
18
 
18
19
  /**
19
- * One member post — parity with the craft members area's `PostItem`: artist
20
+ * One member post: parity with the craft members area's `PostItem`: artist
20
21
  * header, caption, media (image / video / audio via the Forge audio player /
21
22
  * document), like / save / comment engagement, and the locked treatment with
22
23
  * an upgrade CTA (which tier unlocks + membership checkout deep link). Built
@@ -228,6 +229,7 @@ export function MemberPostCard({
228
229
 
229
230
  function LockedBody({ item, go }: { item: ReturnType<typeof usePostItem>; go: (href: string) => void }) {
230
231
  const t = useThemeTokens();
232
+ const tr = useForgeT();
231
233
  const tierName = item.upsell.tier?.name;
232
234
  return (
233
235
  <div
@@ -256,14 +258,16 @@ function LockedBody({ item, go }: { item: ReturnType<typeof usePostItem>; go: (h
256
258
  <Lock size={24} />
257
259
  </span>
258
260
  <div>
259
- <div style={{ fontSize: 16, fontWeight: 700 }}>Content locked</div>
261
+ <div style={{ fontSize: 16, fontWeight: 700 }}>{tr("forge.member_post_card.locked_title")}</div>
260
262
  <div style={{ fontSize: 13, color: t.muted, marginTop: 2 }}>
261
- {tierName ? `Subscribe to ${tierName} to access this content` : "Subscribe to access this content"}
263
+ {tierName
264
+ ? tr("forge.member_post_card.locked_body_tier", { tier_name: tierName })
265
+ : tr("forge.member_post_card.locked_body")}
262
266
  </div>
263
267
  </div>
264
268
  <button
265
269
  type="button"
266
- onClick={() => go(item.upsell.href)}
270
+ onClick={() => (item.upsell.unlock ? item.upsell.unlock() : go(item.upsell.href))}
267
271
  style={{
268
272
  padding: "10px 18px",
269
273
  borderRadius: t.cornerRadius,
@@ -274,7 +278,7 @@ function LockedBody({ item, go }: { item: ReturnType<typeof usePostItem>; go: (h
274
278
  cursor: "pointer",
275
279
  }}
276
280
  >
277
- Subscribe
281
+ {tierName ? tr("forge.member_post_card.unlock_with", { tier_name: tierName }) : tr("forge.member_post_card.unlock")}
278
282
  </button>
279
283
  </div>
280
284
  );
@@ -1,6 +1,9 @@
1
1
  import type { CSSProperties } from "react";
2
2
  import { ArrowLeft, Lock } from "lucide-react";
3
3
  import { useThemeTokens } from "../../theme/ForgeThemeProvider";
4
+ import { useOptionalCommunityContext } from "../../../contexts/CommunityContext";
5
+ import { internalPath } from "../../../runtime/pages";
6
+ import { useForgeT } from "../../../i18n";
4
7
  import {
5
8
  usePostCollectionDetail,
6
9
  type UsePostCollectionDetailOptions,
@@ -9,9 +12,9 @@ import { Loading } from "../Loading";
9
12
  import { MemberPostCard } from "./MemberPostCard";
10
13
 
11
14
  export interface PostCollectionDetailProps extends UsePostCollectionDetailOptions {
12
- /** A collection uuid OR its per-profile slug — the public detail endpoint resolves either. */
15
+ /** A collection uuid OR its per-profile slug: the public detail endpoint resolves either. */
13
16
  collectionId: string;
14
- /** Where the back button returns to (default `/i/members/collections`). */
17
+ /** Where the back button returns to (default: the surrounding community's collections). */
15
18
  backHref?: string;
16
19
  /** Shown as the audio track artist for audio posts inside the collection. */
17
20
  artistName?: string;
@@ -26,17 +29,17 @@ export interface PostCollectionDetailProps extends UsePostCollectionDetailOption
26
29
  }
27
30
 
28
31
  /**
29
- * One collection's drill-in view — parity with the craft members area's
32
+ * One collection's drill-in view: parity with the craft members area's
30
33
  * `CollectionsContent` detail: back link, cover + title + rich-text
31
34
  * description header, then the collection's posts as full member post cards
32
35
  * (engagement, audio queue scoped to the collection, per-post gating). A
33
- * locked collection renders the upgrade CTA instead of posts — access is
36
+ * locked collection renders the upgrade CTA instead of posts: access is
34
37
  * server-computed, never faked client-side. Built on the headless
35
38
  * `usePostCollectionDetail`.
36
39
  */
37
40
  export function PostCollectionDetail({
38
41
  collectionId,
39
- backHref = "/i/members/collections",
42
+ backHref: backHrefProp,
40
43
  artistName,
41
44
  navigate,
42
45
  onRequireAuth,
@@ -46,6 +49,9 @@ export function PostCollectionDetail({
46
49
  ...detailOptions
47
50
  }: PostCollectionDetailProps) {
48
51
  const t = useThemeTokens();
52
+ const tr = useForgeT();
53
+ const slug = useOptionalCommunityContext()?.slug;
54
+ const backHref = backHrefProp ?? (slug ? internalPath("communities/$slug/home/collections", { slug }) : "");
49
55
  const detail = usePostCollectionDetail(collectionId, detailOptions);
50
56
  const go = navigate ?? ((href: string) => window.location.assign(href));
51
57
  const requireAuth =
@@ -169,17 +175,17 @@ export function PostCollectionDetail({
169
175
  <Lock size={24} />
170
176
  </span>
171
177
  <div>
172
- <div style={{ fontSize: 16, fontWeight: 700 }}>Content locked</div>
178
+ <div style={{ fontSize: 16, fontWeight: 700 }}>{tr("forge.post_collection_detail.locked_title")}</div>
173
179
  <div style={{ fontSize: 13, color: t.muted, marginTop: 2 }}>
174
180
  {detail.upsell?.tier?.name
175
- ? `Subscribe to ${detail.upsell.tier.name} to access this content`
176
- : "Subscribe to access this content"}
181
+ ? tr("forge.post_collection_detail.locked_body_tier", { tier_name: detail.upsell.tier.name })
182
+ : tr("forge.post_collection_detail.locked_body")}
177
183
  </div>
178
184
  </div>
179
185
  {detail.upsell && (
180
186
  <button
181
187
  type="button"
182
- onClick={() => go(detail.upsell!.href)}
188
+ onClick={() => (detail.upsell!.unlock ? detail.upsell!.unlock() : go(detail.upsell!.href))}
183
189
  style={{
184
190
  padding: "10px 18px",
185
191
  borderRadius: t.cornerRadius,
@@ -190,7 +196,9 @@ export function PostCollectionDetail({
190
196
  cursor: "pointer",
191
197
  }}
192
198
  >
193
- Subscribe
199
+ {detail.upsell.tier?.name
200
+ ? tr("forge.post_collection_detail.unlock_with", { tier_name: detail.upsell.tier.name })
201
+ : tr("forge.post_collection_detail.unlock")}
194
202
  </button>
195
203
  )}
196
204
  </div>
@@ -2,6 +2,9 @@ import { useState, type CSSProperties } from "react";
2
2
  import { ChevronLeft, ChevronRight, FileText, Lock, Search, X } from "lucide-react";
3
3
  import type { IPublicPostCollection } from "../../../types/models";
4
4
  import { useThemeTokens } from "../../theme/ForgeThemeProvider";
5
+ import { useOptionalCommunityContext } from "../../../contexts/CommunityContext";
6
+ import { internalPath } from "../../../runtime/pages";
7
+ import { useForgeT } from "../../../i18n";
5
8
  import {
6
9
  usePostCollectionsList,
7
10
  type UsePostCollectionsListOptions,
@@ -12,8 +15,8 @@ import { Loading } from "../Loading";
12
15
  export interface PostCollectionsGridProps extends UsePostCollectionsListOptions {
13
16
  /**
14
17
  * Builds the drill-in href for an accessible collection. Receives the
15
- * collection's slug when it has one, else its uuid — the public detail
16
- * endpoint resolves either. Default `` (idOrSlug) => `/i/members/collections/${idOrSlug}` ``.
18
+ * collection's slug when it has one, else its uuid: the public detail
19
+ * endpoint resolves either. Default: the collection's page inside the surrounding community.
17
20
  */
18
21
  getCollectionHref?: (idOrSlug: string) => string;
19
22
  /** SPA navigation adapter (drill-in + locked-collection upsell CTA). Defaults to a full-page visit. */
@@ -31,22 +34,27 @@ const stripHtml = (html: string, len = 100) => {
31
34
  };
32
35
 
33
36
  /**
34
- * The member collections grid — parity with the craft members area's
37
+ * The member collections grid: parity with the craft members area's
35
38
  * `CollectionsContent` list: cover, title, description, post count per
36
- * collection; locked collections render dimmed with a Subscribe CTA that
37
- * deep-links membership checkout (`hasAccess` is server-computed — a locked
39
+ * collection; locked collections render dimmed with an Unlock CTA that
40
+ * deep-links membership checkout (`hasAccess` is server-computed: a locked
38
41
  * collection never opens); accessible collections drill into the detail view.
39
42
  * Debounced search + numbered paging. Built on the headless
40
43
  * `usePostCollectionsList`.
41
44
  */
42
45
  export function PostCollectionsGrid({
43
- getCollectionHref = (idOrSlug) => `/i/members/collections/${idOrSlug}`,
46
+ getCollectionHref: getCollectionHrefProp,
44
47
  navigate,
45
48
  className,
46
49
  style,
47
50
  ...listOptions
48
51
  }: PostCollectionsGridProps) {
49
52
  const t = useThemeTokens();
53
+ const slug = useOptionalCommunityContext()?.slug;
54
+ const getCollectionHref =
55
+ getCollectionHrefProp ??
56
+ ((collectionId: string) =>
57
+ slug ? internalPath("communities/$slug/home/collections/$collectionId", { slug, collectionId }) : "");
50
58
  const list = usePostCollectionsList(listOptions);
51
59
  const go = navigate ?? ((href: string) => window.location.assign(href));
52
60
  const [searchFocused, setSearchFocused] = useState(false);
@@ -176,6 +184,7 @@ function CollectionCard({
176
184
  go: (href: string) => void;
177
185
  }) {
178
186
  const t = useThemeTokens();
187
+ const tr = useForgeT();
179
188
  const locked = !collection.hasAccess;
180
189
  const cover = collection.media?.[0];
181
190
  const description = collection.description ? stripHtml(collection.description) : "";
@@ -255,15 +264,18 @@ function CollectionCard({
255
264
  >
256
265
  <Lock size={18} />
257
266
  </span>
258
- <div style={{ fontSize: 15, fontWeight: 700 }}>Content locked</div>
267
+ <div style={{ fontSize: 15, fontWeight: 700 }}>{tr("forge.post_collections_grid.locked_title")}</div>
259
268
  <div style={{ fontSize: 13, color: t.muted }}>
260
- {tierName ? `Subscribe to ${tierName} to access this content` : "Subscribe to access this content"}
269
+ {tierName
270
+ ? tr("forge.post_collections_grid.locked_body_tier", { tier_name: tierName })
271
+ : tr("forge.post_collections_grid.locked_body")}
261
272
  </div>
262
273
  <button
263
274
  type="button"
264
275
  onClick={(e) => {
265
276
  e.stopPropagation();
266
- go(upsell.href);
277
+ if (upsell.unlock) upsell.unlock();
278
+ else go(upsell.href);
267
279
  }}
268
280
  style={{
269
281
  marginTop: 4,
@@ -277,7 +289,9 @@ function CollectionCard({
277
289
  cursor: "pointer",
278
290
  }}
279
291
  >
280
- Subscribe
292
+ {tierName
293
+ ? tr("forge.post_collections_grid.unlock_with", { tier_name: tierName })
294
+ : tr("forge.post_collections_grid.unlock")}
281
295
  </button>
282
296
  </div>
283
297
  )}
@@ -1,5 +1,6 @@
1
1
  // @vitest-environment jsdom
2
- import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
2
+ import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
4
  import { afterEach, describe, expect, it, vi } from "vitest";
4
5
 
5
6
  import { ForgeThemeProvider } from "../../../theme/ForgeThemeProvider";
@@ -30,8 +31,33 @@ vi.mock("../../../headless/memberHome/useMemberCalls", () => ({
30
31
  }),
31
32
  }));
32
33
 
34
+ /** The transcript reads go through the Forge client; stub it at the provider. */
35
+ const clientGet = vi.fn();
36
+ vi.mock("../../../../provider/ForgeProvider", () => ({
37
+ useForge: () => ({ client: { get: clientGet }, profileId: "profile-1" }),
38
+ }));
39
+
33
40
  import { MemberCallsCalendar } from "../MemberCallsCalendar";
34
41
 
42
+ const transcriptDetail = {
43
+ recordingId: "r1",
44
+ callId: "call-1",
45
+ title: "Weekly Q&A",
46
+ startsAt: "2026-03-10T17:00:00.000Z",
47
+ timezone: "UTC",
48
+ durationSeconds: 1800,
49
+ watchUrl: "https://cdn.test/rec.mp4?sig=fresh",
50
+ transcript: {
51
+ status: "ready",
52
+ language: "en",
53
+ segments: [
54
+ { name: "Ada", startMs: 0, endMs: 2_000, text: "Shall we ship Friday?" },
55
+ { name: "Grace", startMs: 65_000, endMs: 66_000, text: "Agreed." },
56
+ ],
57
+ summary: { title: "Launch", summary: "They agreed to ship.", decisions: ["Ship Friday"], actionItems: [] },
58
+ },
59
+ };
60
+
35
61
  const call = (over: Partial<MyMembershipCall> = {}): MyMembershipCall => ({
36
62
  id: "call-1",
37
63
  title: "Weekly Q&A",
@@ -52,13 +78,17 @@ const call = (over: Partial<MyMembershipCall> = {}): MyMembershipCall => ({
52
78
 
53
79
  const mount = (navigate = vi.fn()) =>
54
80
  render(
55
- <ForgeThemeProvider theme={{ colors: { text: "#111", background: "#fff", primary: "#6d28d9" }, cornerRadius: 8 }}>
56
- <MemberCallsCalendar navigate={navigate} now={new Date("2026-03-10T12:00:00Z")} />
57
- </ForgeThemeProvider>,
81
+ <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
82
+ <ForgeThemeProvider theme={{ colors: { text: "#111", background: "#fff", primary: "#6d28d9" }, cornerRadius: 8 }}>
83
+ <MemberCallsCalendar navigate={navigate} now={new Date("2026-03-10T12:00:00Z")} />
84
+ </ForgeThemeProvider>
85
+ </QueryClientProvider>,
58
86
  );
59
87
 
60
88
  afterEach(() => {
61
89
  calls.length = 0;
90
+ clientGet.mockReset();
91
+ vi.restoreAllMocks();
62
92
  cleanup();
63
93
  });
64
94
 
@@ -95,11 +125,11 @@ describe("MemberCallsCalendar", () => {
95
125
  // The details the list used to show now live in the modal.
96
126
  expect(dialog.textContent).toContain("Bring your questions.");
97
127
  expect(dialog.textContent).toContain("All members");
98
- // Recording, transcript and chat are the modal's remit; the two future
99
- // ones are present as clearly-labelled placeholders.
100
- expect(dialog.textContent).toContain("Transcript");
128
+ // Chat history is the one capability still to come, shown as a labelled
129
+ // placeholder. A call with no recording has no transcript to show.
101
130
  expect(dialog.textContent).toContain("Chat history");
102
131
  expect(dialog.textContent).toContain("Coming soon");
132
+ expect(dialog.textContent).not.toContain("Transcript");
103
133
 
104
134
  // The call is open, so Join is offered and routes to the call page.
105
135
  fireEvent.click(getByText("Join call"));
@@ -111,7 +141,13 @@ describe("MemberCallsCalendar", () => {
111
141
  call({
112
142
  isOpen: false,
113
143
  closesAt: "2026-03-09T20:00:00.000Z",
114
- recording: { id: "r1", status: "ready", durationSeconds: 1800, watchUrl: "https://cdn.test/rec.mp4" },
144
+ recording: {
145
+ id: "r1",
146
+ status: "ready",
147
+ durationSeconds: 1800,
148
+ watchUrl: "https://cdn.test/rec.mp4",
149
+ transcript: null,
150
+ },
115
151
  }),
116
152
  );
117
153
  const { getByTitle, getByText, getByRole, queryByText } = mount();
@@ -124,6 +160,129 @@ describe("MemberCallsCalendar", () => {
124
160
  expect(dialog.textContent).toContain("30 min");
125
161
  });
126
162
 
163
+ const readyCall = () =>
164
+ call({
165
+ isOpen: false,
166
+ closesAt: "2026-03-09T20:00:00.000Z",
167
+ recording: {
168
+ id: "r1",
169
+ status: "ready",
170
+ durationSeconds: 1800,
171
+ watchUrl: "https://cdn.test/rec.mp4?sig=abc",
172
+ transcript: {
173
+ status: "ready",
174
+ summary: { title: "Launch", summary: "They agreed to ship.", decisions: ["Ship Friday"], actionItems: [] },
175
+ },
176
+ },
177
+ });
178
+
179
+ it("shows the SUMMARY in the modal and none of the lines: a long meeting does not belong there", () => {
180
+ calls.push(readyCall());
181
+ const { getByTitle, getByRole, getByText } = mount();
182
+ fireEvent.click(getByTitle("Weekly Q&A"));
183
+ const dialog = getByRole("dialog");
184
+
185
+ expect(dialog.textContent).toContain("They agreed to ship.");
186
+ expect(dialog.textContent).not.toContain("Shall we ship Friday?");
187
+ expect(getByText("Read transcript")).toBeTruthy();
188
+ expect(getByText("Download")).toBeTruthy();
189
+ expect(clientGet).not.toHaveBeenCalled();
190
+ });
191
+
192
+ it("opens the transcript full-screen beside the video, and a line seeks it", async () => {
193
+ clientGet.mockResolvedValue({ data: transcriptDetail });
194
+ vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
195
+ calls.push(readyCall());
196
+ const { getByTitle, getByText, findByTestId, getByRole } = mount();
197
+ fireEvent.click(getByTitle("Weekly Q&A"));
198
+
199
+ fireEvent.click(getByText("Read transcript"));
200
+
201
+ const video = (await findByTestId("member-transcript-video")) as HTMLVideoElement;
202
+ expect(clientGet).toHaveBeenCalledWith("/public/scheduled-calls/mine/recordings/r1/transcript", {
203
+ params: { profileId: "profile-1" },
204
+ });
205
+ expect(video.getAttribute("src")).toBe("https://cdn.test/rec.mp4?sig=fresh");
206
+ // Summary first; the lines one tab away.
207
+ expect(getByText("They agreed to ship.")).toBeTruthy();
208
+ fireEvent.click(getByRole("tab", { name: "Transcript" }));
209
+ fireEvent.click(getByText("Agreed."));
210
+ expect(video.currentTime).toBe(65);
211
+ });
212
+
213
+ it("narrows a long transcript to a search", async () => {
214
+ clientGet.mockResolvedValue({ data: transcriptDetail });
215
+ calls.push(readyCall());
216
+ const { getByTitle, getByText, findByTestId, getByRole, getByLabelText, queryByText } = mount();
217
+ fireEvent.click(getByTitle("Weekly Q&A"));
218
+ fireEvent.click(getByText("Read transcript"));
219
+ await findByTestId("member-transcript-video");
220
+ fireEvent.click(getByRole("tab", { name: "Transcript" }));
221
+
222
+ fireEvent.change(getByLabelText("Search the transcript"), { target: { value: "friday" } });
223
+
224
+ expect(getByRole("status").textContent).toBe("1 match");
225
+ expect(queryByText("Agreed.")).toBeNull();
226
+ });
227
+
228
+ it("recovers a lapsed video address by fetching a fresh one", async () => {
229
+ clientGet.mockResolvedValue({ data: transcriptDetail });
230
+ calls.push(readyCall());
231
+ const { getByTitle, getByText, findByTestId } = mount();
232
+ fireEvent.click(getByTitle("Weekly Q&A"));
233
+ fireEvent.click(getByText("Read transcript"));
234
+ const video = await findByTestId("member-transcript-video");
235
+ clientGet.mockResolvedValue({ data: { ...transcriptDetail, watchUrl: "https://cdn.test/rec.mp4?sig=again" } });
236
+
237
+ await act(async () => {
238
+ fireEvent.error(video);
239
+ });
240
+
241
+ await waitFor(() =>
242
+ expect(
243
+ (document.querySelector("[data-testid=member-transcript-video]") as HTMLVideoElement).getAttribute("src"),
244
+ ).toBe("https://cdn.test/rec.mp4?sig=again"),
245
+ );
246
+ });
247
+
248
+ it("downloads the transcript from the modal without opening it", async () => {
249
+ clientGet.mockResolvedValue({ data: transcriptDetail });
250
+ const createObjectURL = vi.fn(() => "blob:transcript");
251
+ Object.assign(URL, { createObjectURL, revokeObjectURL: vi.fn() });
252
+ const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
253
+ calls.push(readyCall());
254
+ const { getByTitle, getByText, queryByTestId } = mount();
255
+ fireEvent.click(getByTitle("Weekly Q&A"));
256
+
257
+ fireEvent.click(getByText("Download"));
258
+
259
+ await waitFor(() => expect(click).toHaveBeenCalledTimes(1));
260
+ expect(clientGet).toHaveBeenCalledWith("/public/scheduled-calls/mine/recordings/r1/transcript", {
261
+ params: { profileId: "profile-1" },
262
+ });
263
+ expect(queryByTestId("member-transcript-video")).toBeNull();
264
+ });
265
+
266
+ it("tells a member a transcript is being prepared, and nothing about why one might not exist", () => {
267
+ calls.push(
268
+ call({
269
+ isOpen: false,
270
+ closesAt: "2026-03-09T20:00:00.000Z",
271
+ recording: {
272
+ id: "r1",
273
+ status: "ready",
274
+ durationSeconds: 1800,
275
+ watchUrl: "https://cdn.test/rec.mp4",
276
+ transcript: { status: "processing" },
277
+ },
278
+ }),
279
+ );
280
+ const { getByTitle, getByRole } = mount();
281
+ fireEvent.click(getByTitle("Weekly Q&A"));
282
+
283
+ expect(getByRole("status").textContent).toContain("The transcript is being prepared");
284
+ });
285
+
127
286
  it("closes on the backdrop", () => {
128
287
  calls.push(call());
129
288
  const { getByTitle, getByRole, queryByRole } = mount();
@@ -0,0 +1,67 @@
1
+ import type { MemberCallTranscriptDetail, MemberCallTranscriptSegment } from "../../../data/queries/useMembershipCalls";
2
+
3
+ /** `12:05`, or `1:02:05` past the hour. */
4
+ export function formatOffset(ms: number): string {
5
+ const total = Math.max(0, Math.floor(ms / 1000));
6
+ const hours = Math.floor(total / 3600);
7
+ const minutes = Math.floor((total % 3600) / 60);
8
+ const seconds = String(total % 60).padStart(2, "0");
9
+ return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
10
+ }
11
+
12
+ /**
13
+ * The index of the line being spoken at `ms`: the last one that has started.
14
+ * Binary search, because a long meeting has thousands of lines and this runs
15
+ * on every `timeupdate`.
16
+ */
17
+ export function activeSegmentIndex(segments: MemberCallTranscriptSegment[], ms: number): number {
18
+ let low = 0;
19
+ let high = segments.length - 1;
20
+ let found = -1;
21
+ while (low <= high) {
22
+ const mid = (low + high) >> 1;
23
+ if (segments[mid]!.startMs <= ms) {
24
+ found = mid;
25
+ low = mid + 1;
26
+ } else {
27
+ high = mid - 1;
28
+ }
29
+ }
30
+ return found;
31
+ }
32
+
33
+ /** The whole transcript as a plain-text file: the call, the summary, every line. */
34
+ export function transcriptDocument(detail: MemberCallTranscriptDetail): string {
35
+ const when = new Date(detail.startsAt).toLocaleString("en-US", {
36
+ dateStyle: "long",
37
+ timeStyle: "short",
38
+ timeZone: detail.timezone || undefined,
39
+ });
40
+ const parts = [detail.title, when, ""];
41
+ const summary = detail.transcript.summary;
42
+ if (summary) {
43
+ parts.push("SUMMARY", summary.summary, "");
44
+ if (summary.decisions.length) parts.push("DECISIONS", ...summary.decisions.map((d) => `- ${d}`), "");
45
+ if (summary.actionItems.length) parts.push("ACTION ITEMS", ...summary.actionItems.map((a) => `- ${a}`), "");
46
+ }
47
+ parts.push(
48
+ "TRANSCRIPT",
49
+ ...detail.transcript.segments.map((s) => `[${formatOffset(s.startMs)}] ${s.name}: ${s.text}`),
50
+ );
51
+ return parts.join("\n") + "\n";
52
+ }
53
+
54
+ /** Hand the browser a .txt of the transcript. */
55
+ export function downloadTranscript(detail: MemberCallTranscriptDetail): void {
56
+ const slug = detail.title
57
+ .toLowerCase()
58
+ .replace(/[^a-z0-9]+/g, "-")
59
+ .replace(/^-+|-+$/g, "")
60
+ .slice(0, 60);
61
+ const url = URL.createObjectURL(new Blob([transcriptDocument(detail)], { type: "text/plain;charset=utf-8" }));
62
+ const link = document.createElement("a");
63
+ link.href = url;
64
+ link.download = `${slug || "call"}-${detail.startsAt.slice(0, 10)}-transcript.txt`;
65
+ link.click();
66
+ URL.revokeObjectURL(url);
67
+ }
@@ -72,11 +72,11 @@ describe("safeRedirectPath (C13)", () => {
72
72
  describe("keeps legitimate in-app destinations", () => {
73
73
  it.each([
74
74
  "/i/account",
75
- "/i/members",
75
+ "/i/communities",
76
76
  "/i/courses/123",
77
77
  "/i/checkout?step=2",
78
78
  "/i/orders?status=paid&page=3",
79
- "/i/members#section",
79
+ "/i/communities#section",
80
80
  "/",
81
81
  // A path that merely CONTAINS a scheme-looking segment is still a path.
82
82
  "/i/redirect?to=https%3A%2F%2Fexample.com",