@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.
- package/package.json +1 -1
- package/src/_tests/testCommunity.tsx +28 -0
- package/src/contexts/CommunityContext.tsx +192 -0
- package/src/data/queries/useChat.ts +42 -39
- package/src/data/queries/useCommunities.ts +343 -0
- package/src/data/queries/useCommunity.ts +93 -89
- package/src/data/queries/useMembership.ts +32 -7
- package/src/data/queries/useMembershipCalls.ts +116 -7
- package/src/data/queries/usePostCollections.ts +28 -16
- package/src/data/queries/usePosts.ts +16 -14
- package/src/data/queries/useSubscriptions.ts +22 -8
- package/src/i18n/de.json +145 -1
- package/src/i18n/en.json +145 -1
- package/src/index.ts +19 -7
- package/src/runtime/pages.spec.ts +31 -0
- package/src/runtime/pages.ts +21 -22
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
- package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
- package/src/ui/format/membershipGate.ts +19 -15
- package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
- package/src/ui/headless/_tests/communityHarness.tsx +205 -0
- package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
- package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
- package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +382 -0
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +151 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +286 -0
- package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
- package/src/ui/headless/auth/useSignupForm.ts +10 -2
- package/src/ui/headless/communities/CommunityGate.tsx +67 -0
- package/src/ui/headless/communities/index.ts +55 -0
- package/src/ui/headless/communities/paths.ts +36 -0
- package/src/ui/headless/communities/useCommunity.ts +140 -0
- package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
- package/src/ui/headless/communities/useCommunityHome.ts +88 -0
- package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +444 -0
- package/src/ui/headless/communities/useCommunityMembership.ts +136 -0
- package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
- package/src/ui/headless/communities/useMyMemberships.ts +59 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
- package/src/ui/headless/index.ts +8 -4
- package/src/ui/headless/memberHome/index.ts +1 -1
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +44 -42
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
- package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
- package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
- package/src/ui/headless/posts/upsell.ts +54 -8
- package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
- package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
- package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
- package/src/ui/headless/posts/usePostItem.ts +6 -5
- package/src/ui/index.ts +17 -12
- package/src/ui/styled/AccountDashboard.tsx +48 -256
- package/src/ui/styled/MembershipCheckout.tsx +37 -17
- package/src/ui/styled/MembershipTierCallout.tsx +20 -1
- package/src/ui/styled/MembershipTiers.tsx +49 -11
- package/src/ui/styled/Paywall.tsx +1 -1
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +1 -1
- package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
- package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
- package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
- package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
- package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
- package/src/ui/styled/communities/CommunityAbout.tsx +140 -0
- package/src/ui/styled/communities/CommunityHome.tsx +175 -0
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +444 -0
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +133 -0
- package/src/ui/styled/communities/CommunityPending.tsx +49 -0
- package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +89 -0
- package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
- package/src/ui/styled/communities/index.ts +10 -0
- package/src/ui/styled/communities/shared.tsx +149 -0
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
- package/src/ui/styled/forge-utilities.css +19 -0
- package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +142 -10
- package/src/ui/styled/members/MemberHomeLayout.tsx +35 -17
- package/src/ui/styled/members/MemberPostCard.tsx +9 -5
- package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
- package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
- package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
- package/src/ui/styled/members/callTranscriptText.ts +67 -0
- package/src/utils/_tests/safeRedirect.spec.ts +2 -2
- package/src/ui/headless/membership/MembershipGate.tsx +0 -44
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
- 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 {
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
21
|
-
* Posts / Collections / Spaces /
|
|
22
|
-
* on small screens, side nav on `lg+`.
|
|
23
|
-
* `useMemberHomeNav` (conditional tabs hide until the
|
|
24
|
-
*
|
|
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.
|
|
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
|
|
90
|
+
{/* Top tab bar on small screens (horizontally scrollable, no burger). */}
|
|
73
91
|
<nav
|
|
74
|
-
aria-label="
|
|
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
|
|
99
|
+
{/* Side nav, lg and up. */}
|
|
82
100
|
<nav
|
|
83
|
-
aria-label="
|
|
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
|
|
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 }}>
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 }}>
|
|
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
|
-
?
|
|
176
|
-
: "
|
|
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
|
-
|
|
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
|
|
16
|
-
* endpoint resolves either. Default
|
|
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
|
|
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
|
|
37
|
-
* deep-links membership checkout (`hasAccess` is server-computed
|
|
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
|
|
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 }}>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
56
|
-
<
|
|
57
|
-
|
|
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
|
-
//
|
|
99
|
-
//
|
|
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: {
|
|
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/
|
|
75
|
+
"/i/communities",
|
|
76
76
|
"/i/courses/123",
|
|
77
77
|
"/i/checkout?step=2",
|
|
78
78
|
"/i/orders?status=paid&page=3",
|
|
79
|
-
"/i/
|
|
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",
|