@tribe-nest/forge 3.59.0 → 3.61.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 +2 -1
- package/src/_tests/testCommunity.tsx +28 -0
- package/src/contexts/CartContext.tsx +34 -1
- package/src/contexts/CommunityContext.tsx +192 -0
- package/src/contexts/PublicAuthContext.tsx +15 -3
- package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
- package/src/data/queries/useAccount.ts +33 -2
- package/src/data/queries/useAnalytics.ts +6 -6
- package/src/data/queries/useCallChat.ts +2 -2
- package/src/data/queries/useChat.ts +52 -40
- package/src/data/queries/useCheckouts.ts +2 -1
- package/src/data/queries/useCoachingAvailability.ts +10 -1
- package/src/data/queries/useCommunities.ts +345 -0
- package/src/data/queries/useCommunity.ts +450 -102
- package/src/data/queries/useCommunityAgent.ts +247 -0
- package/src/data/queries/useCommunityCalendar.ts +150 -0
- package/src/data/queries/useCommunityClassroom.ts +75 -0
- package/src/data/queries/useCommunityMembers.ts +108 -0
- package/src/data/queries/useCommunityModeration.ts +327 -0
- package/src/data/queries/useCommunityWiki.ts +137 -0
- package/src/data/queries/useCourses.ts +2 -0
- package/src/data/queries/useEmailSignIn.ts +79 -0
- package/src/data/queries/useEvents.ts +6 -1
- package/src/data/queries/useForms.ts +5 -1
- package/src/data/queries/useLeadMagnet.ts +6 -2
- package/src/data/queries/useMembership.ts +32 -7
- package/src/data/queries/useMembershipCalls.ts +116 -7
- package/src/data/queries/useMyBookings.ts +24 -1
- package/src/data/queries/useMyTickets.ts +24 -1
- package/src/data/queries/useNotifications.ts +6 -2
- package/src/data/queries/useOrders.ts +2 -1
- package/src/data/queries/usePWA.ts +111 -35
- package/src/data/queries/usePostCollections.ts +28 -16
- package/src/data/queries/usePosts.ts +16 -14
- package/src/data/queries/useSiteApp.ts +130 -0
- package/src/data/queries/useSubscriptions.ts +28 -9
- package/src/i18n/de.json +891 -2
- package/src/i18n/en.json +891 -2
- package/src/index.ts +36 -7
- package/src/runtime/pages.spec.ts +36 -0
- package/src/runtime/pages.ts +52 -27
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
- package/src/types/models.ts +11 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
- package/src/ui/analytics/PageMetaPixel.tsx +23 -8
- package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
- package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
- package/src/ui/analytics/useMetaEvents.ts +78 -0
- package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
- package/src/ui/format/membershipGate.ts +19 -15
- package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
- package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
- package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
- package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
- package/src/ui/headless/_tests/communityHarness.tsx +212 -0
- package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
- package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
- package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
- package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
- package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
- package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
- package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
- package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
- package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
- package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +596 -0
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +179 -0
- package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +295 -0
- package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
- package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
- package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
- package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
- package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
- package/src/ui/headless/auth/useSignupForm.ts +30 -3
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
- package/src/ui/headless/chat/useChatRoom.ts +35 -1
- package/src/ui/headless/communities/CommunityGate.tsx +67 -0
- package/src/ui/headless/communities/index.ts +96 -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/useCommunityCalendar.ts +156 -0
- package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
- package/src/ui/headless/communities/useCommunityHome.ts +91 -0
- package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +494 -0
- package/src/ui/headless/communities/useCommunityMembership.ts +182 -0
- package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
- package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
- package/src/ui/headless/communities/useMyMemberships.ts +59 -0
- package/src/ui/headless/community/communityPoll.ts +297 -0
- package/src/ui/headless/community/countries.ts +123 -0
- package/src/ui/headless/community/index.ts +81 -1
- package/src/ui/headless/community/linkPreview.ts +71 -0
- package/src/ui/headless/community/richLexical.ts +27 -2
- package/src/ui/headless/community/useCommunityAgent.ts +285 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
- package/src/ui/headless/community/useCommunityMembers.ts +175 -0
- package/src/ui/headless/community/useCommunityModeration.ts +300 -0
- package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
- package/src/ui/headless/community/useCommunityThread.ts +16 -3
- package/src/ui/headless/community/useMentionComposer.ts +17 -9
- package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
- package/src/ui/headless/downloads/index.ts +8 -0
- package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
- package/src/ui/headless/index.ts +21 -4
- package/src/ui/headless/memberHome/index.ts +11 -1
- package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +84 -44
- 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/portal/index.ts +57 -0
- package/src/ui/headless/portal/usePortal.ts +274 -0
- package/src/ui/headless/portal/usePortalFiles.ts +68 -0
- package/src/ui/headless/portal/usePortalMessages.ts +189 -0
- package/src/ui/headless/portal/usePortalPreview.ts +142 -0
- 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/headless/siteApp/appBar.ts +102 -0
- package/src/ui/headless/siteApp/appHome.ts +344 -0
- package/src/ui/headless/siteApp/appPrompts.ts +229 -0
- package/src/ui/headless/siteApp/index.ts +58 -0
- package/src/ui/headless/siteApp/useAppHome.ts +210 -0
- package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
- package/src/ui/headless/work/index.ts +2 -0
- package/src/ui/headless/work/useWorkPortal.ts +37 -26
- package/src/ui/index.ts +28 -12
- package/src/ui/styled/AccountDashboard.tsx +754 -602
- package/src/ui/styled/AiAgentWidget.tsx +13 -1
- package/src/ui/styled/ChatRoom.tsx +126 -16
- package/src/ui/styled/Checkout.tsx +8 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingDetail.tsx +2 -0
- package/src/ui/styled/CourseConfirmation.tsx +5 -0
- package/src/ui/styled/CourseDetail.tsx +2 -0
- package/src/ui/styled/DownloadPage.tsx +263 -0
- package/src/ui/styled/EventConfirmation.tsx +10 -0
- package/src/ui/styled/EventDetail.tsx +10 -0
- package/src/ui/styled/FilmLibrary.tsx +22 -9
- 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/MusicLinkPage.tsx +5 -5
- package/src/ui/styled/Paywall.tsx +1 -1
- package/src/ui/styled/ProductDetail.tsx +12 -0
- package/src/ui/styled/PwaRegistration.tsx +5 -1
- package/src/ui/styled/SignupForm.tsx +18 -3
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +6 -6
- 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/AccountNotifications.spec.tsx +135 -0
- package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
- package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
- 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/app/AppBar.tsx +298 -0
- package/src/ui/styled/app/AppHome.tsx +1139 -0
- package/src/ui/styled/app/AppHomeMark.tsx +66 -0
- package/src/ui/styled/app/AppPrompts.tsx +464 -0
- package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
- package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
- package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
- package/src/ui/styled/app/index.ts +7 -0
- package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
- package/src/ui/styled/chat/MessageBubble.tsx +43 -6
- package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
- package/src/ui/styled/communities/CommunityAbout.tsx +145 -0
- package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
- package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
- package/src/ui/styled/communities/CommunityHome.tsx +298 -0
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +544 -0
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +249 -0
- package/src/ui/styled/communities/CommunityPending.tsx +49 -0
- package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
- package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
- package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +115 -0
- package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
- package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
- package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
- package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
- package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
- package/src/ui/styled/communities/index.ts +19 -0
- package/src/ui/styled/communities/shared.tsx +177 -0
- package/src/ui/styled/community/CommunityAgent.tsx +286 -0
- package/src/ui/styled/community/CommunityComposer.tsx +266 -15
- package/src/ui/styled/community/CommunityFeed.tsx +50 -1
- package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
- package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
- package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
- package/src/ui/styled/community/CommunityMembers.tsx +387 -0
- package/src/ui/styled/community/CommunityModeration.tsx +587 -0
- package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
- package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
- package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
- package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
- package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
- package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
- package/src/ui/styled/community/CommunityRichText.tsx +177 -32
- package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
- package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
- package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
- package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
- package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
- package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
- package/src/ui/styled/community/index.ts +37 -2
- package/src/ui/styled/forge-utilities.css +253 -0
- package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
- package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +41 -54
- package/src/ui/styled/members/MemberHomeLayout.tsx +327 -29
- package/src/ui/styled/members/MemberPostCard.tsx +9 -5
- package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
- 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/ui/styled/members/index.ts +8 -0
- package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
- package/src/ui/styled/portal/PortalActivity.tsx +167 -0
- package/src/ui/styled/portal/PortalBilling.tsx +197 -0
- package/src/ui/styled/portal/PortalFiles.tsx +279 -0
- package/src/ui/styled/portal/PortalHome.tsx +401 -0
- package/src/ui/styled/portal/PortalMessages.tsx +300 -0
- package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
- package/src/ui/styled/portal/PortalProjects.tsx +407 -0
- package/src/ui/styled/portal/PortalShell.tsx +397 -0
- package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
- package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
- package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
- package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
- package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
- package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
- package/src/ui/styled/portal/index.ts +13 -0
- package/src/ui/styled/portal/portalNav.ts +260 -0
- package/src/ui/styled/portal/shared.tsx +206 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
- package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
- package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
- package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
- package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
- package/src/ui/styled/work/WorkReportBody.tsx +223 -248
- package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
- package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
- package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
- package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
- package/src/ui/styled/work/index.ts +1 -1
- package/src/utils/_tests/metaEvents.spec.ts +264 -0
- package/src/utils/_tests/safeRedirect.spec.ts +2 -2
- package/src/utils/landing.ts +19 -6
- package/src/utils/metaEvents.ts +302 -0
- 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,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
|
+
}
|
|
@@ -6,3 +6,11 @@ export { MemberPostCard, type MemberPostCardProps } from "./MemberPostCard";
|
|
|
6
6
|
export { PostCollectionsGrid, type PostCollectionsGridProps } from "./PostCollectionsGrid";
|
|
7
7
|
export { PostCollectionDetail, type PostCollectionDetailProps } from "./PostCollectionDetail";
|
|
8
8
|
export { MemberCallsCalendar, type MemberCallsCalendarProps } from "./MemberCallsCalendar";
|
|
9
|
+
export {
|
|
10
|
+
CallRecordingWatchLink,
|
|
11
|
+
CallRecordingProcessing,
|
|
12
|
+
CallTranscriptPanel,
|
|
13
|
+
callRecordingWatchable,
|
|
14
|
+
type CallRecording,
|
|
15
|
+
type CallRecordingWatchLinkProps,
|
|
16
|
+
} from "./CallRecordingActions";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useForgeT } from "../../../i18n";
|
|
2
|
+
import { AccountSection, type AccountSectionProps, type AccountTabKey } from "../AccountDashboard";
|
|
3
|
+
import { PortalHeading } from "./shared";
|
|
4
|
+
|
|
5
|
+
/** The portal sections that are the account dashboard's panels, by their dashboard key. */
|
|
6
|
+
export type PortalAccountSectionKey =
|
|
7
|
+
| "bookings"
|
|
8
|
+
| "memberships"
|
|
9
|
+
| "courses"
|
|
10
|
+
| "films"
|
|
11
|
+
| "orders"
|
|
12
|
+
| "tickets"
|
|
13
|
+
| "waitlist"
|
|
14
|
+
| "saved"
|
|
15
|
+
| "settings"
|
|
16
|
+
| "notifications";
|
|
17
|
+
|
|
18
|
+
const DASHBOARD_KEY: Record<PortalAccountSectionKey, AccountTabKey> = {
|
|
19
|
+
bookings: "bookings",
|
|
20
|
+
memberships: "membership",
|
|
21
|
+
courses: "courses",
|
|
22
|
+
films: "rentals",
|
|
23
|
+
orders: "orders",
|
|
24
|
+
tickets: "tickets",
|
|
25
|
+
waitlist: "waitlist",
|
|
26
|
+
saved: "saved",
|
|
27
|
+
settings: "account",
|
|
28
|
+
notifications: "notifications",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One of the account dashboard's panels as a portal page, under its own title.
|
|
33
|
+
*
|
|
34
|
+
* The panels are the dashboard's own components, so a fix to one is a fix to
|
|
35
|
+
* both. Only the title is the portal's.
|
|
36
|
+
*/
|
|
37
|
+
export function PortalAccountSection({
|
|
38
|
+
section,
|
|
39
|
+
...rest
|
|
40
|
+
}: Omit<AccountSectionProps, "section"> & { section: PortalAccountSectionKey }) {
|
|
41
|
+
const tr = useForgeT();
|
|
42
|
+
// Literal keys, so the translation spec can see every one of them.
|
|
43
|
+
const title: Record<PortalAccountSectionKey, string> = {
|
|
44
|
+
bookings: tr("forge.portal_account_section.bookings"),
|
|
45
|
+
memberships: tr("forge.portal_account_section.memberships"),
|
|
46
|
+
courses: tr("forge.portal_account_section.courses"),
|
|
47
|
+
films: tr("forge.portal_account_section.films"),
|
|
48
|
+
orders: tr("forge.portal_account_section.orders"),
|
|
49
|
+
tickets: tr("forge.portal_account_section.tickets"),
|
|
50
|
+
waitlist: tr("forge.portal_account_section.waitlist"),
|
|
51
|
+
saved: tr("forge.portal_account_section.saved"),
|
|
52
|
+
settings: tr("forge.portal_account_section.settings"),
|
|
53
|
+
notifications: tr("forge.portal_account_section.notifications"),
|
|
54
|
+
};
|
|
55
|
+
return (
|
|
56
|
+
<div data-testid={`portal-section-${section}`}>
|
|
57
|
+
<PortalHeading title={title[section]} />
|
|
58
|
+
<AccountSection section={DASHBOARD_KEY[section]} {...rest} />
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CalendarCheck,
|
|
3
|
+
CheckCircle2,
|
|
4
|
+
FileCheck2,
|
|
5
|
+
FileSignature,
|
|
6
|
+
MessageSquare,
|
|
7
|
+
Receipt,
|
|
8
|
+
ShoppingBag,
|
|
9
|
+
Stamp,
|
|
10
|
+
type LucideIcon,
|
|
11
|
+
} from "lucide-react";
|
|
12
|
+
import { useForgeLocale, useForgeT, type ForgeT } from "../../../i18n";
|
|
13
|
+
import { internalPath } from "../../../runtime/pages";
|
|
14
|
+
import { useThemeTokens } from "../../theme/ForgeThemeProvider";
|
|
15
|
+
import { usePortalActivity, type PortalActivityItem, type PortalActivityKind } from "../../headless/portal/usePortal";
|
|
16
|
+
import { Loading } from "../Loading";
|
|
17
|
+
import { PortalEmpty, PortalHeading, PortalLink, PortalList, usePortalButtons } from "./shared";
|
|
18
|
+
|
|
19
|
+
const ICONS: Record<PortalActivityKind, LucideIcon> = {
|
|
20
|
+
invoice_issued: Receipt,
|
|
21
|
+
invoice_paid: CheckCircle2,
|
|
22
|
+
document_sent: FileSignature,
|
|
23
|
+
document_signed: FileCheck2,
|
|
24
|
+
milestone_completed: CheckCircle2,
|
|
25
|
+
comment_added: MessageSquare,
|
|
26
|
+
approval_requested: Stamp,
|
|
27
|
+
approval_decided: Stamp,
|
|
28
|
+
order_placed: ShoppingBag,
|
|
29
|
+
booking_confirmed: CalendarCheck,
|
|
30
|
+
message_received: MessageSquare,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** One sentence per event, written for the person reading it. */
|
|
34
|
+
export function activitySentence(item: PortalActivityItem, tr: ForgeT): string {
|
|
35
|
+
const vars = { title: item.title };
|
|
36
|
+
switch (item.kind) {
|
|
37
|
+
case "invoice_issued":
|
|
38
|
+
return tr("forge.portal_activity.invoice_issued", vars);
|
|
39
|
+
case "invoice_paid":
|
|
40
|
+
return tr("forge.portal_activity.invoice_paid", vars);
|
|
41
|
+
case "document_sent":
|
|
42
|
+
return tr("forge.portal_activity.document_sent", vars);
|
|
43
|
+
case "document_signed":
|
|
44
|
+
return tr("forge.portal_activity.document_signed", vars);
|
|
45
|
+
case "milestone_completed":
|
|
46
|
+
return tr("forge.portal_activity.milestone_completed", vars);
|
|
47
|
+
case "comment_added":
|
|
48
|
+
return tr("forge.portal_activity.comment_added", vars);
|
|
49
|
+
case "approval_requested":
|
|
50
|
+
return tr("forge.portal_activity.approval_requested", vars);
|
|
51
|
+
case "approval_decided":
|
|
52
|
+
return item.detail === "changes_requested"
|
|
53
|
+
? tr("forge.portal_activity.approval_changes", vars)
|
|
54
|
+
: tr("forge.portal_activity.approval_approved", vars);
|
|
55
|
+
case "order_placed":
|
|
56
|
+
return tr("forge.portal_activity.order_placed", vars);
|
|
57
|
+
case "booking_confirmed":
|
|
58
|
+
return tr("forge.portal_activity.booking_confirmed", vars);
|
|
59
|
+
case "message_received":
|
|
60
|
+
return tr("forge.portal_activity.message_received", vars);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Where an event leads: the thing it happened to. */
|
|
65
|
+
export function activityHref(item: PortalActivityItem): string {
|
|
66
|
+
switch (item.kind) {
|
|
67
|
+
case "invoice_issued":
|
|
68
|
+
case "invoice_paid":
|
|
69
|
+
return internalPath("invoice/$invoiceId", { invoiceId: item.entityId });
|
|
70
|
+
case "document_sent":
|
|
71
|
+
case "document_signed":
|
|
72
|
+
return internalPath("account/documents");
|
|
73
|
+
case "order_placed":
|
|
74
|
+
return internalPath("account/orders");
|
|
75
|
+
case "booking_confirmed":
|
|
76
|
+
return internalPath("account/bookings");
|
|
77
|
+
case "message_received":
|
|
78
|
+
return internalPath("account/messages");
|
|
79
|
+
default:
|
|
80
|
+
return item.projectId
|
|
81
|
+
? internalPath("account/projects/$projectId", { projectId: item.projectId })
|
|
82
|
+
: internalPath("account/projects");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** "3 hours ago", "yesterday", in the site's language. */
|
|
87
|
+
export function useRelativeTime() {
|
|
88
|
+
const locale = useForgeLocale();
|
|
89
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
|
90
|
+
return (iso: string) => {
|
|
91
|
+
const seconds = Math.round((Date.parse(iso) - Date.now()) / 1000);
|
|
92
|
+
const abs = Math.abs(seconds);
|
|
93
|
+
if (abs < 60) return rtf.format(0, "minute");
|
|
94
|
+
if (abs < 3600) return rtf.format(Math.round(seconds / 60), "minute");
|
|
95
|
+
if (abs < 86400) return rtf.format(Math.round(seconds / 3600), "hour");
|
|
96
|
+
if (abs < 86400 * 30) return rtf.format(Math.round(seconds / 86400), "day");
|
|
97
|
+
return new Date(iso).toLocaleDateString(locale, { day: "numeric", month: "short", year: "numeric" });
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function ActivityRows({ items }: { items: PortalActivityItem[] }) {
|
|
102
|
+
const t = useThemeTokens();
|
|
103
|
+
const tr = useForgeT();
|
|
104
|
+
const ago = useRelativeTime();
|
|
105
|
+
return (
|
|
106
|
+
<PortalList>
|
|
107
|
+
{items.map((item) => {
|
|
108
|
+
const Icon = ICONS[item.kind];
|
|
109
|
+
return (
|
|
110
|
+
<li key={item.id} data-testid="portal-activity-item" style={{ borderBottom: `1px solid ${t.border}` }}>
|
|
111
|
+
<PortalLink
|
|
112
|
+
href={activityHref(item)}
|
|
113
|
+
className="fg:flex fg:items-start fg:gap-3 fg:py-3"
|
|
114
|
+
style={{ color: t.text, textDecoration: "none" }}
|
|
115
|
+
>
|
|
116
|
+
<Icon size={16} aria-hidden style={{ marginTop: 3, color: t.muted, flexShrink: 0 }} />
|
|
117
|
+
<span className="fg:min-w-0 fg:flex-1">
|
|
118
|
+
<span style={{ display: "block", fontSize: 15, lineHeight: 1.45 }}>{activitySentence(item, tr)}</span>
|
|
119
|
+
{item.projectName ? (
|
|
120
|
+
<span style={{ display: "block", fontSize: 13, color: t.muted }}>{item.projectName}</span>
|
|
121
|
+
) : null}
|
|
122
|
+
</span>
|
|
123
|
+
<time dateTime={item.occurredAt} style={{ fontSize: 13, color: t.muted, whiteSpace: "nowrap" }}>
|
|
124
|
+
{ago(item.occurredAt)}
|
|
125
|
+
</time>
|
|
126
|
+
</PortalLink>
|
|
127
|
+
</li>
|
|
128
|
+
);
|
|
129
|
+
})}
|
|
130
|
+
</PortalList>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The whole timeline (`/i/account/activity`). */
|
|
135
|
+
export function PortalActivity() {
|
|
136
|
+
const tr = useForgeT();
|
|
137
|
+
const buttons = usePortalButtons();
|
|
138
|
+
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = usePortalActivity(30);
|
|
139
|
+
const items = data?.pages.flatMap((p) => p.items) ?? [];
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div data-testid="portal-activity">
|
|
143
|
+
<PortalHeading title={tr("forge.portal_activity.title")} />
|
|
144
|
+
{isLoading ? (
|
|
145
|
+
<Loading />
|
|
146
|
+
) : items.length === 0 ? (
|
|
147
|
+
<PortalEmpty title={tr("forge.portal_activity.empty_title")} body={tr("forge.portal_activity.empty_body")} />
|
|
148
|
+
) : (
|
|
149
|
+
<>
|
|
150
|
+
<ActivityRows items={items} />
|
|
151
|
+
{hasNextPage ? (
|
|
152
|
+
<div style={{ marginTop: 20 }}>
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
style={buttons.quiet}
|
|
156
|
+
disabled={isFetchingNextPage}
|
|
157
|
+
onClick={() => void fetchNextPage()}
|
|
158
|
+
>
|
|
159
|
+
{isFetchingNextPage ? tr("forge.portal_activity.loading") : tr("forge.portal_activity.load_more")}
|
|
160
|
+
</button>
|
|
161
|
+
</div>
|
|
162
|
+
) : null}
|
|
163
|
+
</>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|