@tribe-nest/forge 3.60.0 → 3.63.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 (220) hide show
  1. package/package.json +5 -2
  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 +20 -0
  34. package/src/runtime/pages.spec.ts +5 -0
  35. package/src/runtime/pages.ts +32 -6
  36. package/src/seo/_tests/seo.spec.ts +326 -0
  37. package/src/seo/applySeoToHead.ts +98 -0
  38. package/src/seo/index.ts +9 -0
  39. package/src/seo/resolve.ts +211 -0
  40. package/src/seo/seoHead.ts +61 -0
  41. package/src/seo/siteSeoHead.ts +97 -0
  42. package/src/seo/types.ts +99 -0
  43. package/src/server/_tests/siteBootstrap.spec.ts +65 -0
  44. package/src/server/index.ts +33 -4
  45. package/src/types/models.ts +11 -0
  46. package/src/ui/analytics/ForgeAnalytics.tsx +134 -34
  47. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  48. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  49. package/src/ui/analytics/_tests/ForgeAnalytics.webVitals.spec.tsx +129 -0
  50. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  51. package/src/ui/analytics/useMetaEvents.ts +78 -0
  52. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  53. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  54. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  55. package/src/ui/headless/_tests/communityHarness.tsx +10 -3
  56. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  57. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  58. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  59. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  60. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  61. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  62. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  63. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
  64. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
  65. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  66. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
  67. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  68. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  69. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  70. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  71. package/src/ui/headless/auth/useSignupForm.ts +20 -1
  72. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  73. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  74. package/src/ui/headless/communities/index.ts +41 -0
  75. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  76. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  77. package/src/ui/headless/communities/useCommunityHome.ts +3 -0
  78. package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
  79. package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
  80. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  81. package/src/ui/headless/community/communityPoll.ts +297 -0
  82. package/src/ui/headless/community/countries.ts +123 -0
  83. package/src/ui/headless/community/index.ts +81 -1
  84. package/src/ui/headless/community/linkPreview.ts +71 -0
  85. package/src/ui/headless/community/richLexical.ts +27 -2
  86. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  87. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  88. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  89. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  90. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  91. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  92. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  93. package/src/ui/headless/downloads/index.ts +8 -0
  94. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  95. package/src/ui/headless/index.ts +13 -0
  96. package/src/ui/headless/memberHome/index.ts +10 -0
  97. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  98. package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
  99. package/src/ui/headless/portal/index.ts +57 -0
  100. package/src/ui/headless/portal/usePortal.ts +274 -0
  101. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  102. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  103. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  104. package/src/ui/headless/siteApp/appBar.ts +102 -0
  105. package/src/ui/headless/siteApp/appHome.ts +344 -0
  106. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  107. package/src/ui/headless/siteApp/index.ts +58 -0
  108. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  109. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  110. package/src/ui/headless/work/index.ts +2 -0
  111. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  112. package/src/ui/index.ts +11 -0
  113. package/src/ui/styled/AccountDashboard.tsx +712 -352
  114. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  115. package/src/ui/styled/ChatRoom.tsx +126 -16
  116. package/src/ui/styled/Checkout.tsx +8 -0
  117. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  118. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  119. package/src/ui/styled/CoachingDetail.tsx +2 -0
  120. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  121. package/src/ui/styled/CourseDetail.tsx +2 -0
  122. package/src/ui/styled/DownloadPage.tsx +263 -0
  123. package/src/ui/styled/EventConfirmation.tsx +10 -0
  124. package/src/ui/styled/EventDetail.tsx +10 -0
  125. package/src/ui/styled/FilmLibrary.tsx +22 -9
  126. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  127. package/src/ui/styled/ProductDetail.tsx +12 -0
  128. package/src/ui/styled/PwaRegistration.tsx +5 -1
  129. package/src/ui/styled/SignupForm.tsx +18 -3
  130. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
  131. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  132. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  133. package/src/ui/styled/app/AppBar.tsx +298 -0
  134. package/src/ui/styled/app/AppHome.tsx +1139 -0
  135. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  136. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  137. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  138. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  139. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  140. package/src/ui/styled/app/index.ts +7 -0
  141. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  142. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  143. package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
  144. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  145. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  146. package/src/ui/styled/communities/CommunityHome.tsx +134 -11
  147. package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
  148. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
  149. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  150. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  151. package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
  152. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  153. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  154. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  155. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  156. package/src/ui/styled/communities/index.ts +9 -0
  157. package/src/ui/styled/communities/shared.tsx +39 -11
  158. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  159. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  160. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  161. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  162. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  163. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  164. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  165. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  166. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  167. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  168. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  169. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  170. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  171. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  172. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  173. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  174. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  175. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  176. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  177. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  178. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  179. package/src/ui/styled/community/index.ts +37 -2
  180. package/src/ui/styled/forge-utilities.css +234 -0
  181. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  182. package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
  183. package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
  184. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  185. package/src/ui/styled/members/index.ts +8 -0
  186. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  187. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  188. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  189. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  190. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  191. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  192. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  193. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  194. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  195. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  196. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  197. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  198. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  199. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  200. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  201. package/src/ui/styled/portal/index.ts +13 -0
  202. package/src/ui/styled/portal/portalNav.ts +260 -0
  203. package/src/ui/styled/portal/shared.tsx +206 -0
  204. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  205. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  206. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  207. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  208. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  209. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  210. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  211. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  212. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  213. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  214. package/src/ui/styled/work/index.ts +1 -1
  215. package/src/utils/_tests/landing.spec.ts +39 -0
  216. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  217. package/src/utils/_tests/structuredDataSeo.spec.ts +247 -0
  218. package/src/utils/landing.ts +36 -10
  219. package/src/utils/metaEvents.ts +302 -0
  220. package/src/utils/structuredData.ts +339 -1
@@ -51,7 +51,19 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
51
51
  };
52
52
 
53
53
  return (
54
- <div style={{ position: "fixed", bottom: 20, zIndex, ...side }}>
54
+ // Lifted above any fixed bottom bar a page draws (the client portal's on a
55
+ // phone), which publishes its height as `--forge-bottom-bar-height`.
56
+ // Otherwise the bubble sits on top of that bar's last button. A page with
57
+ // its own message box pinned above the bar (a community chat on a phone)
58
+ // publishes `--forge-floating-lift` so the bubble clears its Send button too.
59
+ <div
60
+ style={{
61
+ position: "fixed",
62
+ bottom: "calc(20px + var(--forge-bottom-bar-height, 0px) + var(--forge-floating-lift, 0px))",
63
+ zIndex,
64
+ ...side,
65
+ }}
66
+ >
55
67
  {open && (
56
68
  <div
57
69
  style={{
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState } from "react";
1
+ import { Fragment, useEffect, useRef, useState } from "react";
2
2
  import { Lock } from "lucide-react";
3
3
  import type { ChatChannel, ChatMessage } from "../../data/queries/useChat";
4
4
  import { useForgeT, type ForgeT } from "../../i18n";
@@ -10,6 +10,14 @@ import { MessageBubble } from "./chat/MessageBubble";
10
10
  import { ChatComposer } from "./chat/ChatComposer";
11
11
  import { ChatThreadPanel } from "./chat/ChatThreadPanel";
12
12
  import { NewDmDialog } from "./chat/NewDmDialog";
13
+ import { isCommunityAgentDm, useCommunityAgent } from "../../data/queries/useCommunityAgent";
14
+ import { useCommunityAgentAnswerRatings } from "../headless/community/useCommunityAgent";
15
+ import {
16
+ CommunityAgentAvatar,
17
+ CommunityAgentName,
18
+ CommunityAgentThinking,
19
+ type CommunityAgentIdentity,
20
+ } from "./community/CommunityAgent";
13
21
 
14
22
  export interface ChatRoomProps {}
15
23
 
@@ -29,8 +37,22 @@ export function ChatRoom(_props: ChatRoomProps) {
29
37
  const t = useForgeT();
30
38
  const theme = useForgeTheme();
31
39
  const room = useChatRoom();
40
+ // The community AI: its DM entry point, and its name and avatar on that DM.
41
+ const { agent } = useCommunityAgent();
32
42
  const [showSidebar, setShowSidebar] = useState(true);
33
43
  const [showNewDm, setShowNewDm] = useState(false);
44
+ const [agentDmFailed, setAgentDmFailed] = useState(false);
45
+ const hasAgentDm = room.channels.some((c) => isCommunityAgentDm(c));
46
+
47
+ const openAgentDm = async () => {
48
+ setAgentDmFailed(false);
49
+ try {
50
+ await room.startAgentDm();
51
+ setShowSidebar(false);
52
+ } catch {
53
+ setAgentDmFailed(true);
54
+ }
55
+ };
34
56
 
35
57
  const border = `1px solid ${theme.colors.text}1a`;
36
58
 
@@ -69,10 +91,19 @@ export function ChatRoom(_props: ChatRoomProps) {
69
91
  fontWeight: active ? 700 : 500,
70
92
  }}
71
93
  >
72
- {icon}
73
- <span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
74
- {icon ? (c.name ?? t("forge.chat_room.channel_fallback")) : channelLabel(c, t)}
75
- </span>
94
+ {isCommunityAgentDm(c) ? (
95
+ <>
96
+ <CommunityAgentAvatar agent={agentIdentity(c, agent)} size={20} />
97
+ <CommunityAgentName agent={agentIdentity(c, agent)} fontWeight={active ? 700 : 500} />
98
+ </>
99
+ ) : (
100
+ <>
101
+ {icon}
102
+ <span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
103
+ {icon ? (c.name ?? t("forge.chat_room.channel_fallback")) : channelLabel(c, t)}
104
+ </span>
105
+ </>
106
+ )}
76
107
  </span>
77
108
  {unread > 0 && (
78
109
  <span
@@ -116,7 +147,9 @@ export function ChatRoom(_props: ChatRoomProps) {
116
147
  <div
117
148
  style={{
118
149
  display: "flex",
119
- height: "calc(100vh - 200px)",
150
+ // Inside a member home on a phone the bottom bar takes the last strip of
151
+ // the screen, so the composer ends above it rather than behind it.
152
+ height: "calc(100dvh - 200px - var(--forge-bottom-bar-height, 0px) - env(safe-area-inset-bottom))",
120
153
  border,
121
154
  borderRadius: theme.cornerRadius,
122
155
  overflow: "hidden",
@@ -168,7 +201,42 @@ export function ChatRoom(_props: ChatRoomProps) {
168
201
  t("forge.chat_room.group_dms"),
169
202
  room.channels.filter((c) => c.type === "dm"),
170
203
  )}
171
- {room.channels.length === 0 && (
204
+ {agent?.dmEnabled && !hasAgentDm && (
205
+ <div style={{ padding: "8px 16px" }}>
206
+ <button
207
+ type="button"
208
+ onClick={openAgentDm}
209
+ disabled={room.isStartingAgentDm}
210
+ data-testid="chat-agent-dm-open"
211
+ style={{
212
+ display: "flex",
213
+ alignItems: "center",
214
+ gap: 8,
215
+ width: "100%",
216
+ background: "none",
217
+ border,
218
+ borderRadius: theme.cornerRadius,
219
+ padding: "8px 10px",
220
+ cursor: room.isStartingAgentDm ? "wait" : "pointer",
221
+ color: theme.colors.text,
222
+ fontSize: 13,
223
+ fontWeight: 600,
224
+ textAlign: "left",
225
+ }}
226
+ >
227
+ <CommunityAgentAvatar agent={agent} size={22} />
228
+ <span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
229
+ {t("forge.chat_room.message_agent", { agent_name: agent.name })}
230
+ </span>
231
+ </button>
232
+ </div>
233
+ )}
234
+ {agentDmFailed && (
235
+ <div role="alert" style={{ padding: "0 16px 8px", fontSize: 12 }}>
236
+ {t("forge.chat_room.agent_dm_failed")}
237
+ </div>
238
+ )}
239
+ {room.channels.length === 0 && !agent?.dmEnabled && (
172
240
  <div style={{ padding: 16, opacity: 0.5, fontSize: 13 }}>{t("forge.chat_room.empty_title")}</div>
173
241
  )}
174
242
  </aside>
@@ -176,7 +244,7 @@ export function ChatRoom(_props: ChatRoomProps) {
176
244
  {/* Message panel */}
177
245
  <section className={`${showSidebar ? "fg:hidden" : "fg:flex"} fg:md:flex fg:flex-1 fg:flex-col fg:min-w-0`}>
178
246
  {room.selectedChannel ? (
179
- <MessagePanel room={room} onBack={() => setShowSidebar(true)} border={border} />
247
+ <MessagePanel room={room} onBack={() => setShowSidebar(true)} border={border} agent={agent} />
180
248
  ) : (
181
249
  <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", opacity: 0.5 }}>
182
250
  {t("forge.chat_room.select_channel")}
@@ -193,6 +261,8 @@ export function ChatRoom(_props: ChatRoomProps) {
193
261
  onClose={room.closeThread}
194
262
  onMarkAsRead={room.markAsRead}
195
263
  border={border}
264
+ agentThinking={room.isAgentThinking(room.activeThreadParent.channelId, room.activeThreadParent.id)}
265
+ agentName={agent?.name}
196
266
  />
197
267
  )}
198
268
 
@@ -212,14 +282,24 @@ export function ChatRoom(_props: ChatRoomProps) {
212
282
  );
213
283
  }
214
284
 
285
+ /** The agent as drawn on its DM: the live agent when known, else the channel's own name. */
286
+ function agentIdentity(
287
+ channel: ChatChannel,
288
+ agent: { name: string; avatarUrl: string | null } | null,
289
+ ): CommunityAgentIdentity {
290
+ return { name: agent?.name ?? channel.name ?? "", avatarUrl: agent?.avatarUrl ?? null };
291
+ }
292
+
215
293
  function MessagePanel({
216
294
  room,
217
295
  onBack,
218
296
  border,
297
+ agent,
219
298
  }: {
220
299
  room: ReturnType<typeof useChatRoom>;
221
300
  onBack: () => void;
222
301
  border: string;
302
+ agent: { name: string; avatarUrl: string | null } | null;
223
303
  }) {
224
304
  const t = useForgeT();
225
305
  const theme = useForgeTheme();
@@ -228,13 +308,20 @@ function MessagePanel({
228
308
  // Mark visible messages as read when they change.
229
309
  useEffect(() => {
230
310
  const unreadIds = room.messages
231
- .filter((m: ChatMessage) => m.senderAccountId !== room.currentAccountId)
311
+ .filter((m: ChatMessage) => !m.senderAccountId || m.senderAccountId !== room.currentAccountId)
232
312
  .map((m) => m.id);
233
313
  if (unreadIds.length) room.markAsRead(unreadIds);
234
314
  // eslint-disable-next-line react-hooks/exhaustive-deps
235
315
  }, [room.messages.length, room.selectedChannel?.id]);
236
316
 
237
317
  const title = room.selectedChannel ? channelLabel(room.selectedChannel, t) : t("forge.chat_room.channel_fallback");
318
+ const inAgentDm = isCommunityAgentDm(room.selectedChannel);
319
+ const channelId = room.selectedChannel?.id;
320
+ // The viewer's own ratings on the AI answers in this channel (its DM answers, mostly).
321
+ const ratings = useCommunityAgentAnswerRatings(
322
+ "chat_message",
323
+ room.messages.filter((m) => m.senderAgentId).map((m) => m.id),
324
+ );
238
325
 
239
326
  return (
240
327
  <>
@@ -245,7 +332,14 @@ function MessagePanel({
245
332
  >
246
333
  ←
247
334
  </button>
248
- <strong>{title}</strong>
335
+ {inAgentDm && room.selectedChannel ? (
336
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 8, minWidth: 0 }}>
337
+ <CommunityAgentAvatar agent={agentIdentity(room.selectedChannel, agent)} size={24} />
338
+ <CommunityAgentName agent={agentIdentity(room.selectedChannel, agent)} fontWeight={700} />
339
+ </span>
340
+ ) : (
341
+ <strong>{title}</strong>
342
+ )}
249
343
  </header>
250
344
 
251
345
  {room.pinnedMessages.length > 0 && (
@@ -255,13 +349,29 @@ function MessagePanel({
255
349
  )}
256
350
 
257
351
  <div ref={listRef} style={{ flex: 1, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column-reverse", gap: 10 }}>
352
+ {/* The list runs bottom-up, so the DM's thinking line comes first. */}
353
+ {inAgentDm && room.isAgentThinking(channelId, null) && (
354
+ <div style={{ alignSelf: "flex-start" }}>
355
+ <CommunityAgentThinking name={agent?.name ?? room.selectedChannel?.name} />
356
+ </div>
357
+ )}
258
358
  {room.messages.map((m: ChatMessage) => (
259
- <MessageBubble
260
- key={m.id}
261
- message={m}
262
- mine={m.senderAccountId === room.currentAccountId}
263
- onOpenThread={room.openThread}
264
- />
359
+ <Fragment key={m.id}>
360
+ {/* Answering in this message's thread: shown under the message. */}
361
+ {!inAgentDm && room.isAgentThinking(channelId, m.id) && (
362
+ <div style={{ alignSelf: "flex-start" }}>
363
+ <CommunityAgentThinking name={agent?.name} />
364
+ </div>
365
+ )}
366
+ <MessageBubble
367
+ message={m}
368
+ mine={!!m.senderAccountId && m.senderAccountId === room.currentAccountId}
369
+ onOpenThread={room.openThread}
370
+ showThreadControls={!inAgentDm}
371
+ showAgentFeedback={!!m.senderAgentId && ratings.canRate(m.id)}
372
+ savedAgentRating={ratings.ratingOf(m.id)}
373
+ />
374
+ </Fragment>
265
375
  ))}
266
376
  {room.hasMore && (
267
377
  <button
@@ -18,6 +18,7 @@ import { Loading } from "./Loading";
18
18
  import { CartLineOptions } from "./CartLineOptions";
19
19
  import { HoldNotice } from "./HoldNotice";
20
20
  import { PaystackPayButton } from "./PaystackPayButton";
21
+ import { useMetaInitiateCheckout } from "../analytics/useMetaEvents";
21
22
 
22
23
  export interface CheckoutProps {
23
24
  /** Path used to build the post-payment return URL. Defaults to `/checkout/finalise`. */
@@ -76,6 +77,13 @@ export function Checkout({
76
77
  const pricesIncludeTax = usePricesIncludeTax();
77
78
 
78
79
  const c = useCheckout({ finalisePath });
80
+ // Meta InitiateCheckout, once per mount with a non-empty cart.
81
+ useMetaInitiateCheckout({
82
+ ready: c.isCartReady,
83
+ cartItems: c.cartItems,
84
+ ticketItems: c.ticketItems,
85
+ subTotal: c.subTotal,
86
+ });
79
87
  const taxSummary = summarizeTaxQuote(c.taxQuote, formatCurrency);
80
88
 
81
89
  const go = (path: string) => {
@@ -22,6 +22,8 @@ import { usePublicAuth } from "../../contexts/PublicAuthContext";
22
22
  import { OrderStatus, ProductDeliveryType, type IPublicOrder } from "../../types/models";
23
23
  import { CartLineOptions } from "./CartLineOptions";
24
24
  import { useForgeT } from "../../i18n";
25
+ import { useMetaPurchase } from "../analytics/useMetaEvents";
26
+ import { useAppInstallRequest } from "../headless/siteApp/useAppInstallRequest";
25
27
 
26
28
  export interface CheckoutConfirmationProps {
27
29
  orderId?: string;
@@ -155,6 +157,12 @@ function OrderConfirmation({
155
157
  return () => clearTimeout(t);
156
158
  }, [isPending]);
157
159
 
160
+ // The browser copy of the server's Purchase: same id (the order id), value
161
+ // and currency, so Meta keeps one.
162
+ useMetaPurchase({ recordId: order?.id, paid: isPaid, value: order?.totalAmount, currency: order?.currency });
163
+ // A confirmed purchase is a moment the site app offers itself.
164
+ useAppInstallRequest(isPaid, "purchase");
165
+
158
166
  // Clear the cart once the order is confirmed paid.
159
167
  useEffect(() => {
160
168
  if (isPaid && cartItems.length > 0) clearCart();
@@ -15,6 +15,8 @@ import {
15
15
  } from "./Confirmation";
16
16
  import { useForgeT } from "../../i18n";
17
17
  import { useCoachingBookingFinalize } from "../../data/queries/useFinalize";
18
+ import { useMetaPurchase } from "../analytics/useMetaEvents";
19
+ import { useAppInstallRequest } from "../headless/siteApp/useAppInstallRequest";
18
20
  import { useBookingSecret } from "../headless/booking/useBookingSecret";
19
21
  import { AddToCalendar } from "./AddToCalendar";
20
22
 
@@ -62,6 +64,12 @@ export function CoachingConfirmation({
62
64
  secret ?? undefined,
63
65
  );
64
66
 
67
+ // The browser copy of the server's Purchase, keyed on the booking id. A free
68
+ // booking has no value, so it fires nothing here (it is a Schedule).
69
+ useMetaPurchase({ recordId: data?.id, paid: !error && data?.status === "confirmed", value: data?.totalAmount });
70
+ // A confirmed booking is a moment the site app offers itself.
71
+ useAppInstallRequest(!error && data?.status === "confirmed", "booking");
72
+
65
73
  const primary = theme.colors.primary;
66
74
  const text = theme.colors.text;
67
75
 
@@ -10,6 +10,7 @@ import { Loading } from "./Loading";
10
10
  import { CoachingBooking } from "./CoachingBooking";
11
11
  import { CancellationTerms } from "./CancellationTerms";
12
12
  import { ReviewsSection } from "./ReviewsSection";
13
+ import { useMetaViewContent } from "../analytics/useMetaEvents";
13
14
 
14
15
  export interface CoachingDetailProps {
15
16
  /** Coaching product slug (resolves the product). */
@@ -42,6 +43,7 @@ export function CoachingDetail({
42
43
  const fmt = useAmountFormatter(formatAmount);
43
44
  const pricesIncludeTax = usePricesIncludeTax();
44
45
  const { data: product, isLoading } = useGetCoachingProduct(slug, { initialData: initialCoaching });
46
+ useMetaViewContent(product ? { id: product.id, name: product.title, value: product.price } : null);
45
47
 
46
48
  if (isLoading) return <Loading fullPage />;
47
49
  if (!product) return <p>{t("forge.coaching_detail.not_found")}</p>;
@@ -15,6 +15,8 @@ import {
15
15
  } from "./Confirmation";
16
16
  import { useForgeT } from "../../i18n";
17
17
  import { useCourseBookingFinalize } from "../../data/queries/useFinalize";
18
+ import { useMetaPurchase } from "../analytics/useMetaEvents";
19
+ import { useAppInstallRequest } from "../headless/siteApp/useAppInstallRequest";
18
20
  import { useGetCourse } from "../../data/queries/useCourses";
19
21
  import { useBookingSecret } from "../headless/booking/useBookingSecret";
20
22
 
@@ -52,6 +54,9 @@ export function CourseConfirmation({
52
54
  secret ?? undefined,
53
55
  );
54
56
  const { data: course } = useGetCourse(slug);
57
+ // The browser copy of the server's Purchase, keyed on the booking id.
58
+ useMetaPurchase({ recordId: data?.id, paid: !error && data?.status === "confirmed", value: data?.totalAmount });
59
+ useAppInstallRequest(!error && data?.status === "confirmed", "purchase");
55
60
 
56
61
  const text = theme.colors.text;
57
62
  const primary = theme.colors.primary;
@@ -9,6 +9,7 @@ import { useForgeT } from "../../i18n";
9
9
  import { Loading } from "./Loading";
10
10
  import { CourseCheckout } from "./CourseCheckout";
11
11
  import { ReviewsSection } from "./ReviewsSection";
12
+ import { useMetaViewContent } from "../analytics/useMetaEvents";
12
13
 
13
14
  export interface CourseDetailProps {
14
15
  /** Course slug (resolves the course). */
@@ -47,6 +48,7 @@ export function CourseDetail({
47
48
  const fmt = useAmountFormatter(formatAmount);
48
49
  const pricesIncludeTax = usePricesIncludeTax();
49
50
  const { data: course, isLoading } = useGetCourse(slug, { initialData: initialCourse });
51
+ useMetaViewContent(course ? { id: course.id, name: course.title, value: course.price } : null);
50
52
 
51
53
  if (isLoading) return <Loading fullPage />;
52
54
  if (!course) return <p>{t("forge.course_detail.not_found")}</p>;
@@ -0,0 +1,263 @@
1
+ /**
2
+ * The page a digital delivery email links to (`/i/downloads/$token`).
3
+ *
4
+ * The link finds the order; it does not open it. A signed-in owner downloads
5
+ * straight away. Anyone else is shown what they bought and asked for a code we
6
+ * email to the recipient, because an email link is forwarded, archived and
7
+ * indexed, and a link that alone handed over the files would be a permanent
8
+ * public address. Each file is a short-lived signed read, fetched on the click.
9
+ */
10
+ import { useState } from "react";
11
+ import { Download, FileArchive, Lock, PackageOpen } from "lucide-react";
12
+ import { useForgeT } from "../../i18n";
13
+ import { internalPath } from "../../runtime/pages";
14
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
15
+ import { useDownloadPage, useOtpCodeInput, OTP_LENGTH, type DownloadFileInfo } from "../headless";
16
+ import { Loading } from "./Loading";
17
+ import { usePortalButtons } from "./portal/shared";
18
+ import { formatFileSize } from "./portal/PortalFiles";
19
+
20
+ export function DownloadPage({ token }: { token: string }) {
21
+ const tr = useForgeT();
22
+ const t = useThemeTokens();
23
+ const page = useDownloadPage(token);
24
+
25
+ if (page.status === "loading") return <Loading fullPage />;
26
+
27
+ if (page.status === "missing" || !page.info) {
28
+ return (
29
+ <Shell>
30
+ <div data-testid="download-missing" className="fg:py-10 fg:text-center">
31
+ <PackageOpen size={36} aria-hidden style={{ color: t.muted, margin: "0 auto 12px" }} />
32
+ <h1 style={{ margin: "0 0 8px", fontSize: 22, fontWeight: 700 }}>{tr("forge.download_page.missing_title")}</h1>
33
+ <p style={{ margin: 0, color: t.muted }}>{tr("forge.download_page.missing_body")}</p>
34
+ </div>
35
+ </Shell>
36
+ );
37
+ }
38
+
39
+ const { info } = page;
40
+ const cover = info.items.find((item) => item.coverImage)?.coverImage ?? null;
41
+ const unlocked = page.status === "unlocked";
42
+
43
+ return (
44
+ <Shell>
45
+ <header className="fg:flex fg:items-center fg:gap-4" style={{ marginBottom: 28 }}>
46
+ {cover ? (
47
+ <img
48
+ src={cover}
49
+ alt=""
50
+ className="fg:h-16 fg:w-16 fg:shrink-0"
51
+ style={{ borderRadius: t.cornerRadius, objectFit: "cover" }}
52
+ />
53
+ ) : null}
54
+ <div className="fg:min-w-0">
55
+ <h1 style={{ margin: 0, fontSize: 24, fontWeight: 700, lineHeight: 1.25 }}>
56
+ {info.items.map((item) => item.title).join(", ") || tr("forge.download_page.title")}
57
+ </h1>
58
+ <p style={{ margin: "4px 0 0", color: t.muted, fontSize: 14 }}>
59
+ {info.isGift ? tr("forge.download_page.subtitle_gift") : tr("forge.download_page.subtitle")}
60
+ </p>
61
+ </div>
62
+ </header>
63
+
64
+ <ul data-testid="download-files" style={{ listStyle: "none", margin: 0, padding: 0, borderTop: `1px solid ${t.border}` }}>
65
+ {info.files.map((file) => (
66
+ <FileRow key={file.id} file={file} unlocked={unlocked} urlFor={page.urlFor} />
67
+ ))}
68
+ </ul>
69
+
70
+ {!unlocked ? <CodeGate page={page} token={token} /> : null}
71
+ </Shell>
72
+ );
73
+ }
74
+
75
+ function Shell({ children }: { children: React.ReactNode }) {
76
+ const t = useThemeTokens();
77
+ return (
78
+ <main
79
+ data-testid="download-page"
80
+ className="fg:mx-auto fg:w-full fg:max-w-2xl fg:px-4 fg:py-10 fg:md:py-16"
81
+ style={{ color: t.text, fontFamily: t.fontFamily }}
82
+ >
83
+ {children}
84
+ </main>
85
+ );
86
+ }
87
+
88
+ function FileRow({
89
+ file,
90
+ unlocked,
91
+ urlFor,
92
+ }: {
93
+ file: DownloadFileInfo;
94
+ unlocked: boolean;
95
+ urlFor: (fileId: string) => Promise<string | null>;
96
+ }) {
97
+ const tr = useForgeT();
98
+ const t = useThemeTokens();
99
+ const buttons = usePortalButtons();
100
+ const [busy, setBusy] = useState(false);
101
+ const [failed, setFailed] = useState(false);
102
+ const size = formatFileSize(file.size);
103
+
104
+ const download = async () => {
105
+ setBusy(true);
106
+ setFailed(false);
107
+ try {
108
+ const url = await urlFor(file.id);
109
+ if (!url) throw new Error("no url");
110
+ // Same tab: the signed read answers with `attachment`, so the browser
111
+ // saves the file and the page stays where it is.
112
+ window.location.assign(url);
113
+ } catch {
114
+ setFailed(true);
115
+ } finally {
116
+ setBusy(false);
117
+ }
118
+ };
119
+
120
+ return (
121
+ <li
122
+ data-testid="download-file"
123
+ className="fg:flex fg:items-center fg:gap-3"
124
+ style={{ padding: "14px 0", borderBottom: `1px solid ${t.border}` }}
125
+ >
126
+ <FileArchive size={18} aria-hidden style={{ color: t.muted, flexShrink: 0 }} />
127
+ <div className="fg:min-w-0 fg:flex-1">
128
+ <p className="fg:truncate" style={{ margin: 0, fontWeight: 600 }} title={file.filename}>
129
+ {file.filename}
130
+ </p>
131
+ {size ? <p style={{ margin: "2px 0 0", fontSize: 13, color: t.muted }}>{size}</p> : null}
132
+ {failed ? (
133
+ <p role="alert" style={{ margin: "4px 0 0", fontSize: 13, fontWeight: 600 }}>
134
+ {tr("forge.download_page.download_failed")}
135
+ </p>
136
+ ) : null}
137
+ </div>
138
+ {unlocked ? (
139
+ <button
140
+ type="button"
141
+ data-testid="download-file-button"
142
+ onClick={() => void download()}
143
+ disabled={busy}
144
+ style={{ ...buttons.primary, opacity: busy ? 0.7 : 1 }}
145
+ >
146
+ <Download size={15} aria-hidden />
147
+ {tr("forge.download_page.download")}
148
+ </button>
149
+ ) : (
150
+ <Lock size={16} aria-label={tr("forge.download_page.locked")} style={{ color: t.muted, flexShrink: 0 }} />
151
+ )}
152
+ </li>
153
+ );
154
+ }
155
+
156
+ function CodeGate({ page, token }: { page: ReturnType<typeof useDownloadPage>; token: string }) {
157
+ const tr = useForgeT();
158
+ const t = useThemeTokens();
159
+ const buttons = usePortalButtons();
160
+ const otp = useOtpCodeInput({
161
+ disabled: page.isUnlocking,
162
+ onComplete: (code) => page.submitCode(code),
163
+ });
164
+ const sentTo = page.codeSentTo ?? page.info?.sentTo ?? "";
165
+ const backHere = internalPath("downloads/$token", { token });
166
+ const loginHref = `${internalPath("login")}?redirect=${encodeURIComponent(backHere)}`;
167
+ const error = page.unlockError ?? page.sendCodeError;
168
+
169
+ return (
170
+ <section
171
+ data-testid="download-gate"
172
+ aria-labelledby="download-gate-title"
173
+ style={{ marginTop: 28, padding: 20, border: `1px solid ${t.border}`, borderRadius: t.cornerRadius }}
174
+ >
175
+ <h2 id="download-gate-title" style={{ margin: "0 0 6px", fontSize: 17, fontWeight: 700 }}>
176
+ {tr("forge.download_page.gate_title")}
177
+ </h2>
178
+ {!page.codeSentTo ? (
179
+ <>
180
+ <p style={{ margin: "0 0 16px", color: t.muted, fontSize: 14 }}>
181
+ {tr("forge.download_page.gate_body", { email: sentTo })}
182
+ </p>
183
+ <div className="fg:flex fg:flex-wrap fg:items-center fg:gap-4">
184
+ <button
185
+ type="button"
186
+ data-testid="download-send-code"
187
+ onClick={page.requestCode}
188
+ disabled={page.isSendingCode}
189
+ style={{ ...buttons.primary, opacity: page.isSendingCode ? 0.7 : 1 }}
190
+ >
191
+ {page.isSendingCode ? tr("forge.download_page.sending") : tr("forge.download_page.send_code")}
192
+ </button>
193
+ <a href={loginHref} data-testid="download-sign-in" style={buttons.text}>
194
+ {tr("forge.download_page.sign_in")}
195
+ </a>
196
+ </div>
197
+ </>
198
+ ) : (
199
+ <>
200
+ <p style={{ margin: "0 0 16px", color: t.muted, fontSize: 14 }}>
201
+ {tr("forge.download_page.code_sent", { email: sentTo })}
202
+ </p>
203
+ <form
204
+ onSubmit={(event) => {
205
+ event.preventDefault();
206
+ if (otp.code.length === OTP_LENGTH) page.submitCode(otp.code);
207
+ }}
208
+ >
209
+ <fieldset style={{ border: "none", margin: 0, padding: 0 }}>
210
+ <legend className="fg:sr-only">{tr("forge.download_page.code_label")}</legend>
211
+ <div className="fg:flex fg:gap-2" style={{ marginBottom: 16 }}>
212
+ {otp.digits.map((_, index) => (
213
+ <input
214
+ key={index}
215
+ {...otp.getInputProps(index)}
216
+ data-testid="download-code-digit"
217
+ aria-label={tr("forge.download_page.code_digit", { n: index + 1 })}
218
+ className="fg:h-12 fg:w-11 fg:text-center"
219
+ style={{
220
+ fontSize: 20,
221
+ fontWeight: 600,
222
+ color: t.text,
223
+ background: "transparent",
224
+ border: `1px solid ${t.border}`,
225
+ borderRadius: t.cornerRadius,
226
+ fontFamily: t.fontFamily,
227
+ }}
228
+ />
229
+ ))}
230
+ </div>
231
+ </fieldset>
232
+ <div className="fg:flex fg:flex-wrap fg:items-center fg:gap-4">
233
+ <button
234
+ type="submit"
235
+ data-testid="download-unlock"
236
+ disabled={page.isUnlocking || !otp.isComplete}
237
+ style={{ ...buttons.primary, opacity: page.isUnlocking || !otp.isComplete ? 0.6 : 1 }}
238
+ >
239
+ {page.isUnlocking ? tr("forge.download_page.unlocking") : tr("forge.download_page.unlock")}
240
+ </button>
241
+ <button
242
+ type="button"
243
+ onClick={() => {
244
+ otp.reset();
245
+ page.requestCode();
246
+ }}
247
+ disabled={page.isSendingCode}
248
+ style={buttons.text}
249
+ >
250
+ {tr("forge.download_page.resend")}
251
+ </button>
252
+ </div>
253
+ </form>
254
+ </>
255
+ )}
256
+ {error ? (
257
+ <p role="alert" data-testid="download-error" style={{ margin: "12px 0 0", fontSize: 14, fontWeight: 600 }}>
258
+ {error}
259
+ </p>
260
+ ) : null}
261
+ </section>
262
+ );
263
+ }
@@ -8,6 +8,8 @@ import { useForgeT } from "../../i18n";
8
8
  import { useEventOrderFinalize } from "../../data/queries/useFinalize";
9
9
  import { useEvent } from "../../data/queries/useEvents";
10
10
  import { AddToCalendar } from "./AddToCalendar";
11
+ import { useMetaPurchase } from "../analytics/useMetaEvents";
12
+ import { useAppInstallRequest } from "../headless/siteApp/useAppInstallRequest";
11
13
  import {
12
14
  getTicketOrderOutcome,
13
15
  ticketRequestFailureKind,
@@ -59,6 +61,14 @@ export function EventConfirmation({
59
61
  const formatAmt = useAmountFormatter(formatAmount);
60
62
  const { data: order, isLoading } = useEventOrderFinalize(slug, orderId);
61
63
  const { data: event } = useEvent(slug);
64
+ // The browser copy of the server's Purchase, keyed on the ticket order id.
65
+ useMetaPurchase({
66
+ recordId: order?.id,
67
+ paid: !!order && getTicketOrderOutcome(order) === "paid",
68
+ value: order?.totalAmount,
69
+ currency: order?.currency,
70
+ });
71
+ useAppInstallRequest(!!order && getTicketOrderOutcome(order) === "paid", "purchase");
62
72
 
63
73
  const text = theme.colors.text;
64
74
  const primary = theme.colors.primary;