@tribe-nest/forge 3.60.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.
Files changed (207) hide show
  1. package/package.json +2 -1
  2. package/src/contexts/CartContext.tsx +34 -1
  3. package/src/contexts/PublicAuthContext.tsx +15 -3
  4. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  5. package/src/data/queries/useAccount.ts +33 -2
  6. package/src/data/queries/useAnalytics.ts +6 -6
  7. package/src/data/queries/useCallChat.ts +2 -2
  8. package/src/data/queries/useChat.ts +10 -1
  9. package/src/data/queries/useCheckouts.ts +2 -1
  10. package/src/data/queries/useCoachingAvailability.ts +10 -1
  11. package/src/data/queries/useCommunities.ts +2 -0
  12. package/src/data/queries/useCommunity.ts +362 -18
  13. package/src/data/queries/useCommunityAgent.ts +247 -0
  14. package/src/data/queries/useCommunityCalendar.ts +150 -0
  15. package/src/data/queries/useCommunityClassroom.ts +75 -0
  16. package/src/data/queries/useCommunityMembers.ts +108 -0
  17. package/src/data/queries/useCommunityModeration.ts +327 -0
  18. package/src/data/queries/useCommunityWiki.ts +137 -0
  19. package/src/data/queries/useCourses.ts +2 -0
  20. package/src/data/queries/useEmailSignIn.ts +79 -0
  21. package/src/data/queries/useEvents.ts +6 -1
  22. package/src/data/queries/useForms.ts +5 -1
  23. package/src/data/queries/useLeadMagnet.ts +6 -2
  24. package/src/data/queries/useMyBookings.ts +24 -1
  25. package/src/data/queries/useMyTickets.ts +24 -1
  26. package/src/data/queries/useNotifications.ts +6 -2
  27. package/src/data/queries/useOrders.ts +2 -1
  28. package/src/data/queries/usePWA.ts +111 -35
  29. package/src/data/queries/useSiteApp.ts +130 -0
  30. package/src/data/queries/useSubscriptions.ts +6 -1
  31. package/src/i18n/de.json +746 -1
  32. package/src/i18n/en.json +746 -1
  33. package/src/index.ts +17 -0
  34. package/src/runtime/pages.spec.ts +5 -0
  35. package/src/runtime/pages.ts +32 -6
  36. package/src/types/models.ts +11 -0
  37. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  38. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  39. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  40. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  41. package/src/ui/analytics/useMetaEvents.ts +78 -0
  42. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  43. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  44. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  45. package/src/ui/headless/_tests/communityHarness.tsx +10 -3
  46. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  47. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  48. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  49. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  50. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  51. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  52. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  53. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
  54. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
  55. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  56. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
  57. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  58. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  59. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  60. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  61. package/src/ui/headless/auth/useSignupForm.ts +20 -1
  62. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  63. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  64. package/src/ui/headless/communities/index.ts +41 -0
  65. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  66. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  67. package/src/ui/headless/communities/useCommunityHome.ts +3 -0
  68. package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
  69. package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
  70. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  71. package/src/ui/headless/community/communityPoll.ts +297 -0
  72. package/src/ui/headless/community/countries.ts +123 -0
  73. package/src/ui/headless/community/index.ts +81 -1
  74. package/src/ui/headless/community/linkPreview.ts +71 -0
  75. package/src/ui/headless/community/richLexical.ts +27 -2
  76. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  77. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  78. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  79. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  80. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  81. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  82. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  83. package/src/ui/headless/downloads/index.ts +8 -0
  84. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  85. package/src/ui/headless/index.ts +13 -0
  86. package/src/ui/headless/memberHome/index.ts +10 -0
  87. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  88. package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
  89. package/src/ui/headless/portal/index.ts +57 -0
  90. package/src/ui/headless/portal/usePortal.ts +274 -0
  91. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  92. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  93. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  94. package/src/ui/headless/siteApp/appBar.ts +102 -0
  95. package/src/ui/headless/siteApp/appHome.ts +344 -0
  96. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  97. package/src/ui/headless/siteApp/index.ts +58 -0
  98. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  99. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  100. package/src/ui/headless/work/index.ts +2 -0
  101. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  102. package/src/ui/index.ts +11 -0
  103. package/src/ui/styled/AccountDashboard.tsx +712 -352
  104. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  105. package/src/ui/styled/ChatRoom.tsx +126 -16
  106. package/src/ui/styled/Checkout.tsx +8 -0
  107. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  108. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  109. package/src/ui/styled/CoachingDetail.tsx +2 -0
  110. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  111. package/src/ui/styled/CourseDetail.tsx +2 -0
  112. package/src/ui/styled/DownloadPage.tsx +263 -0
  113. package/src/ui/styled/EventConfirmation.tsx +10 -0
  114. package/src/ui/styled/EventDetail.tsx +10 -0
  115. package/src/ui/styled/FilmLibrary.tsx +22 -9
  116. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  117. package/src/ui/styled/ProductDetail.tsx +12 -0
  118. package/src/ui/styled/PwaRegistration.tsx +5 -1
  119. package/src/ui/styled/SignupForm.tsx +18 -3
  120. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
  121. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  122. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  123. package/src/ui/styled/app/AppBar.tsx +298 -0
  124. package/src/ui/styled/app/AppHome.tsx +1139 -0
  125. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  126. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  127. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  128. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  129. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  130. package/src/ui/styled/app/index.ts +7 -0
  131. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  132. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  133. package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
  134. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  135. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  136. package/src/ui/styled/communities/CommunityHome.tsx +134 -11
  137. package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
  138. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
  139. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  140. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  141. package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
  142. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  143. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  144. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  145. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  146. package/src/ui/styled/communities/index.ts +9 -0
  147. package/src/ui/styled/communities/shared.tsx +39 -11
  148. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  149. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  150. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  151. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  152. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  153. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  154. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  155. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  156. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  157. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  158. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  159. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  160. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  161. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  162. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  163. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  164. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  165. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  166. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  167. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  168. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  169. package/src/ui/styled/community/index.ts +37 -2
  170. package/src/ui/styled/forge-utilities.css +234 -0
  171. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  172. package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
  173. package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
  174. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  175. package/src/ui/styled/members/index.ts +8 -0
  176. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  177. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  178. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  179. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  180. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  181. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  182. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  183. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  184. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  185. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  186. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  187. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  188. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  189. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  190. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  191. package/src/ui/styled/portal/index.ts +13 -0
  192. package/src/ui/styled/portal/portalNav.ts +260 -0
  193. package/src/ui/styled/portal/shared.tsx +206 -0
  194. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  195. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  196. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  197. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  198. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  199. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  200. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  201. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  202. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  203. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  204. package/src/ui/styled/work/index.ts +1 -1
  205. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  206. package/src/utils/landing.ts +19 -6
  207. package/src/utils/metaEvents.ts +302 -0
@@ -0,0 +1,484 @@
1
+ import { useState, type CSSProperties, type MouseEvent } from "react";
2
+ import { CalendarDays, CalendarPlus, ChevronLeft, ChevronRight, MapPin, Ticket, Video } from "lucide-react";
3
+ import { useForgeLocale, useForgeT } from "../../../i18n";
4
+ import {
5
+ calendarDayKey,
6
+ calendarItemIsOver,
7
+ callJoinable,
8
+ useCommunityCalendar,
9
+ type CommunityCalendarDay,
10
+ type UseCommunityCalendarOptions,
11
+ } from "../../headless/communities/useCommunityCalendar";
12
+ import type {
13
+ CommunityCalendarCall,
14
+ CommunityCalendarEvent,
15
+ CommunityRsvpResponse,
16
+ } from "../../../data/queries/useCommunityCalendar";
17
+ import { internalPath } from "../../../runtime/pages";
18
+ import { CallHelpHint } from "../../media/CallHelpHint";
19
+ import { CallTranscriptViewer } from "../members/CallTranscriptViewer";
20
+ import { CallDetailsModal } from "../members/MemberCallsCalendar";
21
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
22
+ import {
23
+ CallRecordingProcessing,
24
+ CallRecordingWatchLink,
25
+ CallTranscriptPanel,
26
+ callRecordingWatchable,
27
+ } from "../members/CallRecordingActions";
28
+ import { Loading } from "../Loading";
29
+ import { useCommunityStyles } from "./shared";
30
+
31
+ export interface CommunityCalendarProps extends UseCommunityCalendarOptions {
32
+ /** The call page, from the call's public id. Defaults to the built-in video call page. */
33
+ getCallHref?: (callPublicId: string) => string;
34
+ /** An event's page, from its slug. Defaults to the built-in event page. */
35
+ getEventHref?: (eventSlug: string) => string;
36
+ /** SPA navigation adapter. Defaults to a plain link. */
37
+ navigate?: (href: string) => void;
38
+ className?: string;
39
+ style?: CSSProperties;
40
+ }
41
+
42
+ type Calendar = ReturnType<typeof useCommunityCalendar>;
43
+
44
+ /**
45
+ * The Calendar tab of a community's member home: this community's calls and
46
+ * the events promoted into it, one month at a time, grouped by day with what
47
+ * is still ahead first. A call can be answered going or not going (pressing
48
+ * the answer again clears it), joined while its room is open, and added to a
49
+ * calendar app; once it is over, its recording can be watched and its
50
+ * transcript read, with the same pieces the members calendar uses
51
+ * (`CallRecordingActions`). An event opens its own page, where tickets are.
52
+ * Members only.
53
+ * Must sit inside `<CommunityProvider>`. Built on `useCommunityCalendar`.
54
+ */
55
+ export function CommunityCalendar({
56
+ getCallHref = (callPublicId) => internalPath("video-calls/$callId", { callId: callPublicId }),
57
+ getEventHref = (eventSlug) => internalPath("events/$slug", { slug: eventSlug }),
58
+ navigate,
59
+ className,
60
+ style,
61
+ ...options
62
+ }: CommunityCalendarProps) {
63
+ const tr = useForgeT();
64
+ const locale = useForgeLocale();
65
+ const { t, card, ghostButton } = useCommunityStyles();
66
+ const calendar = useCommunityCalendar(options);
67
+ const themeTokens = useThemeTokens();
68
+ // The recording whose transcript is open full-screen, beside its video.
69
+ const [readingRecordingId, setReadingRecordingId] = useState<string | null>(null);
70
+ // The call whose details are open: the same dialog the members calendar uses.
71
+ const [openCallId, setOpenCallId] = useState<string | null>(null);
72
+
73
+ if (calendar.isLoading) return <Loading />;
74
+ if (calendar.forbidden) {
75
+ return (
76
+ <div data-testid="community-calendar-forbidden" className={className} style={{ ...card, textAlign: "center", ...style }}>
77
+ {tr("forge.community_calendar.forbidden")}
78
+ </div>
79
+ );
80
+ }
81
+
82
+ const navButton: CSSProperties = { ...ghostButton, display: "inline-flex", alignItems: "center", padding: "6px 10px", fontSize: 13 };
83
+ const monthLabel = calendar.monthStart.toLocaleDateString(locale, { month: "long", year: "numeric" });
84
+ const empty = !calendar.isError && calendar.upcoming.length === 0 && calendar.past.length === 0;
85
+ const rowProps = {
86
+ calendar,
87
+ getCallHref,
88
+ getEventHref,
89
+ navigate,
90
+ onReadTranscript: setReadingRecordingId,
91
+ onOpenCall: setOpenCallId,
92
+ };
93
+ // Re-read from the latest fetch, so a recording that finishes while the
94
+ // dialog is open appears in it.
95
+ const openCall = openCallId
96
+ ? [...calendar.upcoming, ...calendar.past]
97
+ .flatMap((day) => day.items)
98
+ .find((item): item is CommunityCalendarCall => item.kind === "call" && item.id === openCallId)
99
+ : undefined;
100
+ const go = (href: string) => {
101
+ if (navigate) navigate(href);
102
+ else if (typeof window !== "undefined") window.location.assign(href);
103
+ };
104
+
105
+ return (
106
+ <section
107
+ data-testid="community-calendar"
108
+ className={className}
109
+ style={{ display: "flex", flexDirection: "column", gap: 16, color: t.text, fontFamily: t.fontFamily, ...style }}
110
+ >
111
+ <header className="fg:flex fg:flex-wrap fg:items-center fg:gap-2">
112
+ <h2
113
+ data-testid="community-calendar-month"
114
+ className="fg:flex fg:items-center fg:gap-2"
115
+ style={{ margin: 0, fontSize: 18, fontWeight: 700, flex: "1 1 auto", fontFamily: t.headingFontFamily }}
116
+ >
117
+ {monthLabel}
118
+ <CallHelpHint label={tr("forge.community_calendar.help_label")}>{tr("forge.community_calendar.help")}</CallHelpHint>
119
+ </h2>
120
+ <button type="button" onClick={calendar.goToPreviousMonth} aria-label={tr("forge.community_calendar.previous_month")} style={navButton}>
121
+ <ChevronLeft size={16} aria-hidden />
122
+ </button>
123
+ {!calendar.isCurrentMonth && (
124
+ <button type="button" onClick={calendar.goToToday} style={navButton}>
125
+ {tr("forge.community_calendar.this_month")}
126
+ </button>
127
+ )}
128
+ <button type="button" onClick={calendar.goToNextMonth} aria-label={tr("forge.community_calendar.next_month")} style={navButton}>
129
+ <ChevronRight size={16} aria-hidden />
130
+ </button>
131
+ </header>
132
+
133
+ {calendar.isError && (
134
+ <p role="alert" style={{ margin: 0 }}>
135
+ {tr("forge.community_calendar.error")}
136
+ </p>
137
+ )}
138
+
139
+ {empty && (
140
+ <div data-testid="community-calendar-empty" style={{ ...card, textAlign: "center", color: t.muted }}>
141
+ <CalendarDays size={26} style={{ opacity: 0.5 }} aria-hidden />
142
+ <p style={{ margin: "8px 0 0" }}>{tr("forge.community_calendar.empty")}</p>
143
+ </div>
144
+ )}
145
+
146
+ {calendar.upcoming.map((day) => (
147
+ <DayGroup key={day.key} day={day} {...rowProps} />
148
+ ))}
149
+
150
+ {calendar.past.length > 0 && (
151
+ <>
152
+ <h3 style={{ margin: "8px 0 0", fontSize: 14, fontWeight: 700, color: t.muted }}>
153
+ {tr("forge.community_calendar.past_heading")}
154
+ </h3>
155
+ {calendar.past.map((day) => (
156
+ <DayGroup key={day.key} day={day} {...rowProps} />
157
+ ))}
158
+ </>
159
+ )}
160
+
161
+ {openCall && (
162
+ <CallDetailsModal
163
+ call={openCall}
164
+ now={calendar.now}
165
+ tokens={themeTokens}
166
+ onClose={() => setOpenCallId(null)}
167
+ onJoin={() => openCall.callPublicId && go(getCallHref(openCall.callPublicId))}
168
+ onReadTranscript={(recordingId) => {
169
+ setOpenCallId(null);
170
+ setReadingRecordingId(recordingId);
171
+ }}
172
+ />
173
+ )}
174
+
175
+ {readingRecordingId && (
176
+ <CallTranscriptViewer recordingId={readingRecordingId} onClose={() => setReadingRecordingId(null)} />
177
+ )}
178
+ </section>
179
+ );
180
+ }
181
+
182
+ interface RowProps {
183
+ calendar: Calendar;
184
+ getCallHref: (callPublicId: string) => string;
185
+ getEventHref: (eventSlug: string) => string;
186
+ navigate?: (href: string) => void;
187
+ /** Open a recording's transcript full-screen beside its video. */
188
+ onReadTranscript: (recordingId: string) => void;
189
+ /** Open a call's details dialog. */
190
+ onOpenCall: (callId: string) => void;
191
+ }
192
+
193
+ function DayGroup({ day, ...rowProps }: { day: CommunityCalendarDay } & RowProps) {
194
+ const tr = useForgeT();
195
+ const locale = useForgeLocale();
196
+ const { t } = useCommunityStyles();
197
+ const isToday = day.key === calendarDayKey(rowProps.calendar.now);
198
+ const label = day.date.toLocaleDateString(locale, { weekday: "long", month: "long", day: "numeric" });
199
+ return (
200
+ <div data-testid="community-calendar-day" data-day={day.key} style={{ display: "flex", flexDirection: "column", gap: 8 }}>
201
+ <h4
202
+ style={{
203
+ margin: 0,
204
+ fontSize: 12,
205
+ fontWeight: 700,
206
+ textTransform: "uppercase",
207
+ letterSpacing: 0.4,
208
+ color: isToday ? t.primary : t.muted,
209
+ }}
210
+ >
211
+ {isToday ? tr("forge.community_calendar.today", { date: label }) : label}
212
+ </h4>
213
+ <ul className="fg:flex fg:flex-col fg:gap-2" style={{ listStyle: "none", margin: 0, padding: 0 }}>
214
+ {day.items.map((item) => (
215
+ <li key={`${item.kind}-${item.id}`}>
216
+ {item.kind === "call" ? <CallRow call={item} {...rowProps} /> : <EventRow event={item} {...rowProps} />}
217
+ </li>
218
+ ))}
219
+ </ul>
220
+ </div>
221
+ );
222
+ }
223
+
224
+ function useTimeRange() {
225
+ const tr = useForgeT();
226
+ const locale = useForgeLocale();
227
+ const time = (value: string) => new Date(value).toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" });
228
+ return (startsAt: string, endsAt: string | null) =>
229
+ endsAt ? tr("forge.community_calendar.time_range", { start: time(startsAt), end: time(endsAt) }) : time(startsAt);
230
+ }
231
+
232
+ function useRowStyles() {
233
+ const { t } = useCommunityStyles();
234
+ const row: CSSProperties = {
235
+ display: "flex",
236
+ flexDirection: "column",
237
+ gap: 8,
238
+ padding: "12px 14px",
239
+ border: `1px solid ${t.border}`,
240
+ borderRadius: t.cornerRadius,
241
+ background: t.surface,
242
+ };
243
+ const small: CSSProperties = {
244
+ display: "inline-flex",
245
+ alignItems: "center",
246
+ gap: 6,
247
+ padding: "6px 12px",
248
+ borderRadius: t.cornerRadius,
249
+ border: `1px solid ${t.border}`,
250
+ background: "transparent",
251
+ color: t.text,
252
+ fontFamily: t.fontFamily,
253
+ fontSize: 13,
254
+ fontWeight: 600,
255
+ cursor: "pointer",
256
+ textDecoration: "none",
257
+ };
258
+ const badge = (strong: boolean): CSSProperties => ({
259
+ fontSize: 11,
260
+ fontWeight: 700,
261
+ textTransform: "uppercase",
262
+ letterSpacing: 0.4,
263
+ padding: "2px 8px",
264
+ borderRadius: 999,
265
+ background: strong ? t.primary : "transparent",
266
+ color: strong ? t.textPrimary : t.muted,
267
+ border: strong ? "none" : `1px solid ${t.border}`,
268
+ });
269
+ return { t, row, small, badge };
270
+ }
271
+
272
+ function AddToCalendar({ href }: { href: string }) {
273
+ const tr = useForgeT();
274
+ const { small } = useRowStyles();
275
+ return (
276
+ <a data-testid="community-calendar-ics" href={href} style={small}>
277
+ <CalendarPlus size={14} aria-hidden />
278
+ {tr("forge.community_calendar.add_to_calendar")}
279
+ </a>
280
+ );
281
+ }
282
+
283
+ function CallRow({ call, calendar, getCallHref, navigate, onReadTranscript, onOpenCall }: { call: CommunityCalendarCall } & RowProps) {
284
+ const recording = call.recording ?? null;
285
+ const tr = useForgeT();
286
+ const timeRange = useTimeRange();
287
+ const { t, row, small, badge } = useRowStyles();
288
+ const over = calendarItemIsOver(call, calendar.now);
289
+ const joinable = callJoinable(call);
290
+ const joinHref = joinable ? getCallHref(call.callPublicId!) : null;
291
+ const saving = calendar.pendingCallId === call.id;
292
+ const error = calendar.rsvpErrorFor(call.id);
293
+
294
+ const answerButton = (response: CommunityRsvpResponse, labelKey: string, count: number) => {
295
+ const chosen = call.rsvp.mine === response;
296
+ return (
297
+ <button
298
+ type="button"
299
+ data-testid={`community-calendar-rsvp-${response}`}
300
+ aria-pressed={chosen}
301
+ disabled={saving || over}
302
+ onClick={() => calendar.answer(call, response)}
303
+ style={{
304
+ ...small,
305
+ background: chosen ? t.primary : "transparent",
306
+ color: chosen ? t.textPrimary : t.text,
307
+ border: chosen ? `1px solid ${t.primary}` : small.border,
308
+ cursor: saving || over ? "default" : "pointer",
309
+ opacity: over ? 0.6 : 1,
310
+ }}
311
+ >
312
+ {tr(labelKey, { count })}
313
+ </button>
314
+ );
315
+ };
316
+
317
+ const join = (e: MouseEvent<HTMLAnchorElement>) => {
318
+ if (!navigate || !joinHref) return;
319
+ e.preventDefault();
320
+ navigate(joinHref);
321
+ };
322
+
323
+ return (
324
+ <article data-testid="community-calendar-call" style={row}>
325
+ <div className="fg:flex fg:items-start fg:gap-3">
326
+ <Video size={18} style={{ color: t.primary, flexShrink: 0, marginTop: 2 }} aria-hidden />
327
+ <div className="fg:min-w-0 fg:flex-1" style={{ display: "flex", flexDirection: "column", gap: 2 }}>
328
+ <span className="fg:flex fg:flex-wrap fg:items-center fg:gap-2">
329
+ <button
330
+ type="button"
331
+ data-testid="community-calendar-call-open"
332
+ onClick={() => onOpenCall(call.id)}
333
+ style={{
334
+ padding: 0,
335
+ border: "none",
336
+ background: "transparent",
337
+ cursor: "pointer",
338
+ fontFamily: "inherit",
339
+ fontSize: 15,
340
+ fontWeight: 700,
341
+ color: t.primary,
342
+ textAlign: "left",
343
+ textDecoration: "underline",
344
+ textUnderlineOffset: 3,
345
+ }}
346
+ >
347
+ {call.title}
348
+ </button>
349
+ {call.isOpen && <span style={badge(true)}>{tr("forge.community_calendar.live_now")}</span>}
350
+ </span>
351
+ <span style={{ fontSize: 13, color: t.muted }}>
352
+ {tr("forge.community_calendar.call_label", { time: timeRange(call.startsAt, call.endsAt) })}
353
+ </span>
354
+ {call.description && (
355
+ <span
356
+ style={{
357
+ fontSize: 13,
358
+ color: t.text,
359
+ opacity: 0.85,
360
+ display: "-webkit-box",
361
+ WebkitLineClamp: 2,
362
+ WebkitBoxOrient: "vertical",
363
+ overflow: "hidden",
364
+ }}
365
+ >
366
+ {call.description}
367
+ </span>
368
+ )}
369
+ </div>
370
+ </div>
371
+ <div className="fg:flex fg:flex-wrap fg:items-center fg:gap-2">
372
+ {joinHref && (
373
+ <a
374
+ data-testid="community-calendar-join"
375
+ href={joinHref}
376
+ onClick={join}
377
+ style={{ ...small, background: t.primary, color: t.textPrimary, border: `1px solid ${t.primary}` }}
378
+ >
379
+ {tr("forge.community_calendar.join")}
380
+ </a>
381
+ )}
382
+ {/* Answers are for calls still ahead; once it is over, what is left
383
+ is the recording. */}
384
+ {!over && answerButton("going", "forge.community_calendar.going_count", call.rsvp.going)}
385
+ {!over && answerButton("not_going", "forge.community_calendar.not_going_count", call.rsvp.notGoing)}
386
+ {!over && <AddToCalendar href={call.icsUrl} />}
387
+ <button type="button" data-testid="community-calendar-call-details" onClick={() => onOpenCall(call.id)} style={small}>
388
+ {tr("forge.community_calendar.details")}
389
+ <ChevronRight size={14} aria-hidden />
390
+ </button>
391
+ {over && recording && callRecordingWatchable(recording) && <CallRecordingWatchLink recording={recording} />}
392
+ {over && recording?.status === "processing" && <CallRecordingProcessing />}
393
+ </div>
394
+ {over && recording?.transcript && (
395
+ <CallTranscriptPanel
396
+ recordingId={recording.id}
397
+ transcript={recording.transcript}
398
+ onRead={() => onReadTranscript(recording.id)}
399
+ />
400
+ )}
401
+ {error && (
402
+ <p role="alert" data-testid="community-calendar-rsvp-error" style={{ margin: 0, fontSize: 13, color: t.text }}>
403
+ {tr(
404
+ error === "closed"
405
+ ? "forge.community_calendar.rsvp_closed"
406
+ : error === "not_found"
407
+ ? "forge.community_calendar.rsvp_not_found"
408
+ : "forge.community_calendar.rsvp_failed",
409
+ )}
410
+ </p>
411
+ )}
412
+ </article>
413
+ );
414
+ }
415
+
416
+ /** "Venue, City" from an event's address, when it has one. */
417
+ function eventPlace(event: CommunityCalendarEvent): string | null {
418
+ const address = event.address;
419
+ if (!address || typeof address !== "object") return null;
420
+ const parts = [address.name, address.city].map((part) => (typeof part === "string" ? part.trim() : "")).filter(Boolean);
421
+ return parts.length ? parts.join(", ") : null;
422
+ }
423
+
424
+ function EventRow({ event, calendar, getEventHref, navigate }: { event: CommunityCalendarEvent } & RowProps) {
425
+ const tr = useForgeT();
426
+ const timeRange = useTimeRange();
427
+ const { t, row, small, badge } = useRowStyles();
428
+ const href = getEventHref(event.slug);
429
+ const over = calendarItemIsOver(event, calendar.now);
430
+ const place = event.type === "virtual" ? tr("forge.community_calendar.online") : eventPlace(event);
431
+
432
+ const open = (e: MouseEvent<HTMLAnchorElement>) => {
433
+ if (!navigate) return;
434
+ e.preventDefault();
435
+ navigate(href);
436
+ };
437
+
438
+ return (
439
+ <article data-testid="community-calendar-event" data-cancelled={event.cancelled ? "true" : undefined} style={row}>
440
+ <div className="fg:flex fg:items-start fg:gap-3">
441
+ <Ticket size={18} style={{ color: event.cancelled ? t.muted : t.primary, flexShrink: 0, marginTop: 2 }} aria-hidden />
442
+ <div className="fg:min-w-0 fg:flex-1" style={{ display: "flex", flexDirection: "column", gap: 2 }}>
443
+ <span className="fg:flex fg:flex-wrap fg:items-center fg:gap-2">
444
+ <a
445
+ data-testid="community-calendar-event-link"
446
+ href={href}
447
+ onClick={open}
448
+ style={{
449
+ fontSize: 15,
450
+ fontWeight: 700,
451
+ color: event.cancelled ? t.text : t.primary,
452
+ textDecoration: event.cancelled ? "line-through" : "underline",
453
+ textUnderlineOffset: 3,
454
+ }}
455
+ >
456
+ {event.title}
457
+ </a>
458
+ {event.cancelled && (
459
+ <span data-testid="community-calendar-cancelled" style={badge(false)}>
460
+ {tr("forge.community_calendar.cancelled")}
461
+ </span>
462
+ )}
463
+ </span>
464
+ <span style={{ fontSize: 13, color: t.muted }}>
465
+ {tr("forge.community_calendar.event_label", { time: timeRange(event.startsAt, event.endsAt) })}
466
+ </span>
467
+ {place && (
468
+ <span className="fg:inline-flex fg:items-center fg:gap-1" style={{ fontSize: 13, color: t.muted }}>
469
+ <MapPin size={13} aria-hidden />
470
+ {place}
471
+ </span>
472
+ )}
473
+ </div>
474
+ </div>
475
+ <div className="fg:flex fg:flex-wrap fg:items-center fg:gap-2">
476
+ <a data-testid="community-calendar-event-view" href={href} onClick={open} style={small}>
477
+ {tr("forge.community_calendar.view_event")}
478
+ <ChevronRight size={14} aria-hidden />
479
+ </a>
480
+ {!event.cancelled && !over && <AddToCalendar href={event.icsUrl} />}
481
+ </div>
482
+ </article>
483
+ );
484
+ }