@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.
Files changed (266) hide show
  1. package/package.json +2 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CartContext.tsx +34 -1
  4. package/src/contexts/CommunityContext.tsx +192 -0
  5. package/src/contexts/PublicAuthContext.tsx +15 -3
  6. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  7. package/src/data/queries/useAccount.ts +33 -2
  8. package/src/data/queries/useAnalytics.ts +6 -6
  9. package/src/data/queries/useCallChat.ts +2 -2
  10. package/src/data/queries/useChat.ts +52 -40
  11. package/src/data/queries/useCheckouts.ts +2 -1
  12. package/src/data/queries/useCoachingAvailability.ts +10 -1
  13. package/src/data/queries/useCommunities.ts +345 -0
  14. package/src/data/queries/useCommunity.ts +450 -102
  15. package/src/data/queries/useCommunityAgent.ts +247 -0
  16. package/src/data/queries/useCommunityCalendar.ts +150 -0
  17. package/src/data/queries/useCommunityClassroom.ts +75 -0
  18. package/src/data/queries/useCommunityMembers.ts +108 -0
  19. package/src/data/queries/useCommunityModeration.ts +327 -0
  20. package/src/data/queries/useCommunityWiki.ts +137 -0
  21. package/src/data/queries/useCourses.ts +2 -0
  22. package/src/data/queries/useEmailSignIn.ts +79 -0
  23. package/src/data/queries/useEvents.ts +6 -1
  24. package/src/data/queries/useForms.ts +5 -1
  25. package/src/data/queries/useLeadMagnet.ts +6 -2
  26. package/src/data/queries/useMembership.ts +32 -7
  27. package/src/data/queries/useMembershipCalls.ts +116 -7
  28. package/src/data/queries/useMyBookings.ts +24 -1
  29. package/src/data/queries/useMyTickets.ts +24 -1
  30. package/src/data/queries/useNotifications.ts +6 -2
  31. package/src/data/queries/useOrders.ts +2 -1
  32. package/src/data/queries/usePWA.ts +111 -35
  33. package/src/data/queries/usePostCollections.ts +28 -16
  34. package/src/data/queries/usePosts.ts +16 -14
  35. package/src/data/queries/useSiteApp.ts +130 -0
  36. package/src/data/queries/useSubscriptions.ts +28 -9
  37. package/src/i18n/de.json +891 -2
  38. package/src/i18n/en.json +891 -2
  39. package/src/index.ts +36 -7
  40. package/src/runtime/pages.spec.ts +36 -0
  41. package/src/runtime/pages.ts +52 -27
  42. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  43. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  44. package/src/types/models.ts +11 -0
  45. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  46. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  47. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  48. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  49. package/src/ui/analytics/useMetaEvents.ts +78 -0
  50. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  51. package/src/ui/format/membershipGate.ts +19 -15
  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/communityGate.spec.tsx +80 -0
  56. package/src/ui/headless/_tests/communityHarness.tsx +212 -0
  57. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  58. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  59. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  60. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  61. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  62. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  63. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  64. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  65. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  66. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +596 -0
  67. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +179 -0
  68. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  69. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +295 -0
  70. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  71. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  72. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  73. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  74. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  75. package/src/ui/headless/auth/useSignupForm.ts +30 -3
  76. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  77. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  78. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  79. package/src/ui/headless/communities/index.ts +96 -0
  80. package/src/ui/headless/communities/paths.ts +36 -0
  81. package/src/ui/headless/communities/useCommunity.ts +140 -0
  82. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  83. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  84. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  85. package/src/ui/headless/communities/useCommunityHome.ts +91 -0
  86. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  87. package/src/ui/headless/communities/useCommunityJoin.ts +494 -0
  88. package/src/ui/headless/communities/useCommunityMembership.ts +182 -0
  89. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  90. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  91. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  92. package/src/ui/headless/community/communityPoll.ts +297 -0
  93. package/src/ui/headless/community/countries.ts +123 -0
  94. package/src/ui/headless/community/index.ts +81 -1
  95. package/src/ui/headless/community/linkPreview.ts +71 -0
  96. package/src/ui/headless/community/richLexical.ts +27 -2
  97. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  98. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  99. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  100. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  101. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  102. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  103. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  104. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  105. package/src/ui/headless/downloads/index.ts +8 -0
  106. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  107. package/src/ui/headless/index.ts +21 -4
  108. package/src/ui/headless/memberHome/index.ts +11 -1
  109. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  110. package/src/ui/headless/memberHome/useMemberHomeNav.ts +84 -44
  111. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  112. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  113. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  114. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  115. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  116. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  117. package/src/ui/headless/portal/index.ts +57 -0
  118. package/src/ui/headless/portal/usePortal.ts +274 -0
  119. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  120. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  121. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  122. package/src/ui/headless/posts/upsell.ts +54 -8
  123. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  124. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  125. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  126. package/src/ui/headless/posts/usePostItem.ts +6 -5
  127. package/src/ui/headless/siteApp/appBar.ts +102 -0
  128. package/src/ui/headless/siteApp/appHome.ts +344 -0
  129. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  130. package/src/ui/headless/siteApp/index.ts +58 -0
  131. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  132. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  133. package/src/ui/headless/work/index.ts +2 -0
  134. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  135. package/src/ui/index.ts +28 -12
  136. package/src/ui/styled/AccountDashboard.tsx +754 -602
  137. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  138. package/src/ui/styled/ChatRoom.tsx +126 -16
  139. package/src/ui/styled/Checkout.tsx +8 -0
  140. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  141. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  142. package/src/ui/styled/CoachingDetail.tsx +2 -0
  143. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  144. package/src/ui/styled/CourseDetail.tsx +2 -0
  145. package/src/ui/styled/DownloadPage.tsx +263 -0
  146. package/src/ui/styled/EventConfirmation.tsx +10 -0
  147. package/src/ui/styled/EventDetail.tsx +10 -0
  148. package/src/ui/styled/FilmLibrary.tsx +22 -9
  149. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  150. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  151. package/src/ui/styled/MembershipTiers.tsx +49 -11
  152. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  153. package/src/ui/styled/Paywall.tsx +1 -1
  154. package/src/ui/styled/ProductDetail.tsx +12 -0
  155. package/src/ui/styled/PwaRegistration.tsx +5 -1
  156. package/src/ui/styled/SignupForm.tsx +18 -3
  157. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +6 -6
  158. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  159. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  160. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  161. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  162. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  163. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  164. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  165. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  166. package/src/ui/styled/app/AppBar.tsx +298 -0
  167. package/src/ui/styled/app/AppHome.tsx +1139 -0
  168. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  169. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  170. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  171. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  172. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  173. package/src/ui/styled/app/index.ts +7 -0
  174. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  175. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  176. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  177. package/src/ui/styled/communities/CommunityAbout.tsx +145 -0
  178. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  179. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  180. package/src/ui/styled/communities/CommunityHome.tsx +298 -0
  181. package/src/ui/styled/communities/CommunityJoinSheet.tsx +544 -0
  182. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +249 -0
  183. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  184. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  185. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  186. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  187. package/src/ui/styled/communities/MyMembershipsList.tsx +115 -0
  188. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  189. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  190. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  191. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  192. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  193. package/src/ui/styled/communities/index.ts +19 -0
  194. package/src/ui/styled/communities/shared.tsx +177 -0
  195. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  196. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  197. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  198. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  199. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  200. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  201. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  202. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  203. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  204. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  205. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  206. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  207. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  208. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  209. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  210. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  211. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  212. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  213. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  214. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  215. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  216. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  217. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  218. package/src/ui/styled/community/index.ts +37 -2
  219. package/src/ui/styled/forge-utilities.css +253 -0
  220. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  221. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  222. package/src/ui/styled/members/MemberCallsCalendar.tsx +41 -54
  223. package/src/ui/styled/members/MemberHomeLayout.tsx +327 -29
  224. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  225. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  226. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  227. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  228. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  229. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  230. package/src/ui/styled/members/index.ts +8 -0
  231. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  232. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  233. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  234. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  235. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  236. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  237. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  238. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  239. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  240. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  241. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  242. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  243. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  244. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  245. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  246. package/src/ui/styled/portal/index.ts +13 -0
  247. package/src/ui/styled/portal/portalNav.ts +260 -0
  248. package/src/ui/styled/portal/shared.tsx +206 -0
  249. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  250. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  251. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  252. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  253. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  254. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  255. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  256. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  257. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  258. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  259. package/src/ui/styled/work/index.ts +1 -1
  260. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  261. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  262. package/src/utils/landing.ts +19 -6
  263. package/src/utils/metaEvents.ts +302 -0
  264. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  265. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  266. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
@@ -0,0 +1,1139 @@
1
+ import type { CSSProperties, MouseEvent, ReactNode } from "react";
2
+ import {
3
+ Bell,
4
+ BookOpen,
5
+ Bookmark,
6
+ CalendarDays,
7
+ ChevronRight,
8
+ FileSignature,
9
+ FolderKanban,
10
+ Receipt,
11
+ Settings,
12
+ ShoppingBag,
13
+ Ticket,
14
+ Users,
15
+ Video,
16
+ type LucideIcon,
17
+ } from "lucide-react";
18
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
19
+ import { useForgeLocale, useForgeT, type ForgeT } from "../../../i18n";
20
+ import { internalPath } from "../../../runtime/pages";
21
+ import { useNotificationUnreadCount } from "../../../data/queries/useNotifications";
22
+ import { useAmountFormatter } from "../../format/useFormatCurrency";
23
+ import { useEmailCodeSignIn } from "../../headless/auth/useEmailCodeSignIn";
24
+ import { useAppCommunityUnread, useAppHome } from "../../headless/siteApp/useAppHome";
25
+ import {
26
+ greetingPart,
27
+ type AppAttentionAction,
28
+ type AppCommunityCard,
29
+ type AppHomeSectionView,
30
+ type AppLearningCourse,
31
+ type AppNextCall,
32
+ type AppNextTicket,
33
+ type AppPortalTile,
34
+ type AppPortalTileKey,
35
+ } from "../../headless/siteApp/appHome";
36
+ import type { IEvent, IPublicProduct, PublicCourse } from "../../../types/models";
37
+ import { Loading } from "../Loading";
38
+
39
+ /** Literal keys, so the translation spec can see every one of them. */
40
+ const TILE_LABEL_KEYS: Record<AppPortalTileKey, string> = {
41
+ projects: "forge.app_home.tile_projects",
42
+ bookings: "forge.app_home.tile_bookings",
43
+ invoices: "forge.app_home.tile_invoices",
44
+ documents: "forge.app_home.tile_documents",
45
+ courses: "forge.app_home.tile_courses",
46
+ orders: "forge.app_home.tile_orders",
47
+ tickets: "forge.app_home.tile_tickets",
48
+ saved: "forge.app_home.tile_saved",
49
+ settings: "forge.app_home.tile_settings",
50
+ };
51
+
52
+ const TILE_ICONS: Record<AppPortalTileKey, LucideIcon> = {
53
+ projects: FolderKanban,
54
+ bookings: CalendarDays,
55
+ invoices: Receipt,
56
+ documents: FileSignature,
57
+ courses: BookOpen,
58
+ orders: ShoppingBag,
59
+ tickets: Ticket,
60
+ saved: Bookmark,
61
+ settings: Settings,
62
+ };
63
+
64
+ const ACTION_KEYS: Record<AppAttentionAction["action"], string> = {
65
+ pay: "forge.app_home.action_pay",
66
+ review: "forge.app_home.action_review",
67
+ reply: "forge.app_home.action_reply",
68
+ update: "forge.app_home.action_update",
69
+ };
70
+
71
+ export interface AppHomeProps {
72
+ /**
73
+ * SPA navigation adapter. Defaults to a full-page `window.location` visit.
74
+ * Pass your router's navigate for client-side transitions.
75
+ */
76
+ navigate?: (href: string) => void;
77
+ className?: string;
78
+ style?: CSSProperties;
79
+ }
80
+
81
+ type Go = (href: string) => void;
82
+
83
+ /**
84
+ * The site app's home (`/i/app`): a slim header, a greeting, then the sections
85
+ * the creator configured, in their order, each drawn only when it has
86
+ * something to show (the portal is always drawn). Signed out, it is the app's
87
+ * front door: the name, an email-first sign in, and a way to look around.
88
+ *
89
+ * Built on `useAppHome`; pair it with `<AppBar />`.
90
+ */
91
+ export function AppHome({ navigate, className, style }: AppHomeProps) {
92
+ const t = useThemeTokens();
93
+ const home = useAppHome();
94
+ const go: Go = navigate ?? ((href: string) => window.location.assign(href));
95
+
96
+ if (home.isLoading) return <Loading fullPage />;
97
+
98
+ return (
99
+ <div
100
+ data-testid="app-home"
101
+ className={`fg:mx-auto fg:flex fg:w-full fg:max-w-[720px] fg:flex-col ${className ?? ""}`}
102
+ style={{ color: t.text, fontFamily: t.fontFamily, ...style }}
103
+ >
104
+ {home.isSignedIn ? (
105
+ <SignedInHome home={home} go={go} />
106
+ ) : (
107
+ <SignedOutHome appName={home.appName} iconUrl={home.iconUrl} go={go} />
108
+ )}
109
+ </div>
110
+ );
111
+ }
112
+
113
+ // ---- Signed in -----------------------------------------------------------------------
114
+
115
+ function SignedInHome({ home, go }: { home: ReturnType<typeof useAppHome>; go: Go }) {
116
+ const t = useThemeTokens();
117
+ const tr = useForgeT();
118
+ const part = greetingPart(new Date());
119
+ const name = home.firstName;
120
+ // Literal keys, so the translation spec can see every one of them.
121
+ const greeting = name
122
+ ? {
123
+ morning: tr("forge.app_home.greeting_morning_name", { name }),
124
+ afternoon: tr("forge.app_home.greeting_afternoon_name", { name }),
125
+ evening: tr("forge.app_home.greeting_evening_name", { name }),
126
+ }[part]
127
+ : {
128
+ morning: tr("forge.app_home.greeting_morning"),
129
+ afternoon: tr("forge.app_home.greeting_afternoon"),
130
+ evening: tr("forge.app_home.greeting_evening"),
131
+ }[part];
132
+ const needs = home.sections.find((section) => section.kind === "needs_you");
133
+ const waiting = needs?.kind === "needs_you" ? needs.actions.length : 0;
134
+
135
+ return (
136
+ <>
137
+ <AppHeader appName={home.appName} iconUrl={home.iconUrl} firstName={name} go={go} />
138
+ <div className="fg:flex fg:flex-col fg:gap-7" style={{ padding: "20px 16px 24px" }}>
139
+ <div>
140
+ <h1 style={{ margin: 0, fontSize: 26, fontWeight: 800, fontFamily: t.headingFontFamily, letterSpacing: "-0.02em" }}>
141
+ {greeting}
142
+ </h1>
143
+ {waiting > 0 ? (
144
+ <p data-testid="app-home-lead" style={{ margin: "4px 0 0", fontSize: 15, color: t.muted }}>
145
+ {waiting === 1 ? tr("forge.app_home.lead_one") : tr("forge.app_home.lead_many", { count: waiting })}
146
+ </p>
147
+ ) : null}
148
+ </div>
149
+ {home.sections.map((section) => (
150
+ <SectionView key={`${section.kind}:${section.key}`} section={section} go={go} />
151
+ ))}
152
+ </div>
153
+ </>
154
+ );
155
+ }
156
+
157
+ function SectionView({ section, go }: { section: AppHomeSectionView; go: Go }) {
158
+ switch (section.kind) {
159
+ case "needs_you":
160
+ return <NeedsYou actions={section.actions} go={go} />;
161
+ case "portal":
162
+ return <PortalGrid tiles={section.tiles} go={go} />;
163
+ case "communities":
164
+ return <YourCommunities communities={section.communities} go={go} />;
165
+ case "coming_up":
166
+ return <ComingUp call={section.call} ticket={section.ticket} go={go} />;
167
+ case "learning":
168
+ return <ContinueLearning course={section.course} go={go} />;
169
+ case "explore_communities":
170
+ return <ExploreCommunities communities={section.communities} go={go} />;
171
+ case "events":
172
+ return <UpcomingEvents events={section.events} go={go} />;
173
+ case "courses":
174
+ return <CoursesList courses={section.courses} go={go} />;
175
+ case "shop":
176
+ return <Shop products={section.products} go={go} />;
177
+ case "link":
178
+ return <LinkSection label={section.label} href={section.href} go={go} />;
179
+ }
180
+ }
181
+
182
+ function AppHeader({
183
+ appName,
184
+ iconUrl,
185
+ firstName,
186
+ go,
187
+ }: {
188
+ appName: string | null;
189
+ iconUrl: string | null;
190
+ firstName: string | null;
191
+ go: Go;
192
+ }) {
193
+ const t = useThemeTokens();
194
+ const tr = useForgeT();
195
+ const { data: unread } = useNotificationUnreadCount(true);
196
+ const initial = (firstName ?? "").charAt(0).toUpperCase() || "?";
197
+ return (
198
+ <header
199
+ data-testid="app-home-header"
200
+ className="fg:flex fg:items-center fg:gap-3"
201
+ style={{ minHeight: 60, padding: "0 16px", borderBottom: `1px solid ${t.border}` }}
202
+ >
203
+ <AppMark iconUrl={iconUrl} name={appName} size={32} />
204
+ <div className="fg:min-w-0 fg:flex-1 fg:truncate" style={{ fontWeight: 700, fontSize: 16 }}>
205
+ {appName}
206
+ </div>
207
+ <AppLink
208
+ href={internalPath("account/notifications")}
209
+ go={go}
210
+ testId="app-home-bell"
211
+ ariaLabel={tr("forge.app_home.aria_notifications")}
212
+ style={{ position: "relative", display: "inline-flex", padding: 8, color: t.text }}
213
+ >
214
+ <Bell size={20} aria-hidden />
215
+ {unread && unread > 0 ? (
216
+ <span
217
+ data-testid="app-home-bell-dot"
218
+ aria-hidden
219
+ style={{ position: "absolute", top: 7, right: 7, width: 8, height: 8, borderRadius: 4, background: t.primary }}
220
+ />
221
+ ) : null}
222
+ </AppLink>
223
+ <AppLink
224
+ href={internalPath("account")}
225
+ go={go}
226
+ testId="app-home-avatar"
227
+ ariaLabel={tr("forge.app_home.aria_account")}
228
+ style={{
229
+ width: 36,
230
+ height: 36,
231
+ borderRadius: 18,
232
+ display: "inline-flex",
233
+ alignItems: "center",
234
+ justifyContent: "center",
235
+ background: t.surface,
236
+ color: t.primary,
237
+ fontWeight: 700,
238
+ fontSize: 13,
239
+ textDecoration: "none",
240
+ }}
241
+ >
242
+ {initial}
243
+ </AppLink>
244
+ </header>
245
+ );
246
+ }
247
+
248
+ // ---- Sections ------------------------------------------------------------------------
249
+
250
+ function NeedsYou({ actions, go }: { actions: AppAttentionAction[]; go: Go }) {
251
+ const t = useThemeTokens();
252
+ const tr = useForgeT();
253
+ return (
254
+ <section aria-label={tr("forge.app_home.needs_you")} data-testid="app-section-needs_you" className="fg:flex fg:flex-col fg:gap-2">
255
+ {actions.map((entry) => (
256
+ <div
257
+ key={`${entry.item.kind}:${entry.item.id}`}
258
+ data-testid={`app-attention-${entry.item.kind}`}
259
+ className="fg:flex fg:items-center fg:gap-3"
260
+ style={{
261
+ padding: 14,
262
+ borderRadius: Math.max(t.cornerRadius, 12),
263
+ border: `1px solid ${t.border}`,
264
+ borderLeft: `3px solid ${t.primary}`,
265
+ background: t.surface,
266
+ }}
267
+ >
268
+ <div className="fg:min-w-0 fg:flex-1">
269
+ <div className="fg:truncate" style={{ fontWeight: 600, fontSize: 15 }}>
270
+ {attentionTitle(entry, tr)}
271
+ </div>
272
+ </div>
273
+ <AppLink
274
+ href={entry.href}
275
+ go={go}
276
+ testId="app-attention-action"
277
+ style={{ ...pillButton(t), background: t.primary, color: t.textPrimary, border: "none" }}
278
+ >
279
+ {tr(ACTION_KEYS[entry.action])}
280
+ </AppLink>
281
+ </div>
282
+ ))}
283
+ </section>
284
+ );
285
+ }
286
+
287
+ function attentionTitle(entry: AppAttentionAction, tr: ForgeT): string {
288
+ const item = entry.item;
289
+ switch (item.kind) {
290
+ case "invoice_due":
291
+ case "invoice_overdue":
292
+ return tr("forge.app_home.attention_invoice", { number: item.invoiceNumber });
293
+ case "approval_requested":
294
+ return tr("forge.app_home.attention_approval", { milestone: item.milestoneName });
295
+ case "document_to_sign":
296
+ return item.title;
297
+ case "message_unread":
298
+ return item.unreadCount === 1
299
+ ? tr("forge.app_home.attention_message_one")
300
+ : tr("forge.app_home.attention_message_many", { count: item.unreadCount });
301
+ case "membership_payment_failed":
302
+ return tr("forge.app_home.attention_payment_failed", { community: item.communityName });
303
+ }
304
+ }
305
+
306
+ function PortalGrid({ tiles, go }: { tiles: AppPortalTile[]; go: Go }) {
307
+ const t = useThemeTokens();
308
+ const tr = useForgeT();
309
+ return (
310
+ <Section
311
+ testId="app-section-portal"
312
+ title={tr("forge.app_home.portal")}
313
+ link={{ label: tr("forge.app_home.open"), href: internalPath("account") }}
314
+ go={go}
315
+ >
316
+ <div className="fg:grid fg:grid-cols-4 fg:gap-2">
317
+ {tiles.map((tile) => {
318
+ const Icon = TILE_ICONS[tile.key];
319
+ return (
320
+ <AppLink
321
+ key={tile.key}
322
+ href={tile.href}
323
+ go={go}
324
+ testId={`app-portal-tile-${tile.key}`}
325
+ className="fg:flex fg:flex-col fg:items-center fg:gap-1"
326
+ style={{
327
+ position: "relative",
328
+ padding: "12px 4px",
329
+ borderRadius: Math.max(t.cornerRadius, 12),
330
+ background: t.surface,
331
+ color: t.text,
332
+ textDecoration: "none",
333
+ minWidth: 0,
334
+ }}
335
+ >
336
+ <Icon size={22} aria-hidden />
337
+ <span className="fg:max-w-full fg:truncate" style={{ fontSize: 12, fontWeight: 600 }}>
338
+ {tr(TILE_LABEL_KEYS[tile.key])}
339
+ </span>
340
+ {tile.attention > 0 ? (
341
+ <span data-testid={`app-portal-tile-${tile.key}-badge`} style={badge(t)}>
342
+ {tile.attention}
343
+ </span>
344
+ ) : null}
345
+ </AppLink>
346
+ );
347
+ })}
348
+ </div>
349
+ </Section>
350
+ );
351
+ }
352
+
353
+ function YourCommunities({ communities, go }: { communities: AppCommunityCard[]; go: Go }) {
354
+ const tr = useForgeT();
355
+ return (
356
+ <Section
357
+ testId="app-section-communities"
358
+ title={tr("forge.app_home.your_communities")}
359
+ link={{ label: tr("forge.app_home.all"), href: internalPath("communities") }}
360
+ go={go}
361
+ >
362
+ <div className="fg:-mr-4 fg:flex fg:gap-3 fg:overflow-x-auto fg:pr-4" style={{ scrollbarWidth: "none" }}>
363
+ {communities.map((community) => (
364
+ <CommunityCard key={community.id} community={community} go={go} />
365
+ ))}
366
+ </div>
367
+ </Section>
368
+ );
369
+ }
370
+
371
+ function CommunityCard({ community, go }: { community: AppCommunityCard; go: Go }) {
372
+ const t = useThemeTokens();
373
+ const tr = useForgeT();
374
+ const { data: unread } = useAppCommunityUnread(community.id);
375
+ const meta = [community.tierName, community.memberCount !== null ? tr("forge.app_home.members", { count: community.memberCount }) : null]
376
+ .filter(Boolean)
377
+ .join(" · ");
378
+ return (
379
+ <AppLink
380
+ href={community.href}
381
+ go={go}
382
+ testId={`app-community-${community.slug}`}
383
+ className="fg:flex fg:shrink-0 fg:flex-col fg:gap-2"
384
+ style={{ width: 200, color: t.text, textDecoration: "none" }}
385
+ >
386
+ <div
387
+ style={{
388
+ position: "relative",
389
+ height: 96,
390
+ borderRadius: Math.max(t.cornerRadius, 14),
391
+ background: community.coverUrl
392
+ ? `center / cover no-repeat url(${JSON.stringify(community.coverUrl)})`
393
+ : t.surface,
394
+ border: `1px solid ${t.border}`,
395
+ }}
396
+ >
397
+ <span style={{ position: "absolute", left: 10, bottom: -14 }}>
398
+ <AppMark iconUrl={community.logoUrl} name={community.name} size={36} />
399
+ </span>
400
+ {unread && unread > 0 ? (
401
+ <span
402
+ data-testid={`app-community-${community.slug}-unread`}
403
+ style={{ ...badge(t), position: "absolute", top: 8, right: 8, padding: "2px 8px", height: "auto" }}
404
+ >
405
+ {tr("forge.app_home.new_count", { count: unread })}
406
+ </span>
407
+ ) : null}
408
+ </div>
409
+ <div style={{ paddingTop: 10 }}>
410
+ <div className="fg:truncate" style={{ fontWeight: 700, fontSize: 15 }}>
411
+ {community.name}
412
+ </div>
413
+ {meta ? (
414
+ <div className="fg:truncate" style={{ fontSize: 13, color: t.muted }}>
415
+ {meta}
416
+ </div>
417
+ ) : null}
418
+ </div>
419
+ </AppLink>
420
+ );
421
+ }
422
+
423
+ function ComingUp({ call, ticket, go }: { call: AppNextCall | null; ticket: AppNextTicket | null; go: Go }) {
424
+ const t = useThemeTokens();
425
+ const tr = useForgeT();
426
+ return (
427
+ <Section testId="app-section-coming_up" title={tr("forge.app_home.coming_up")} go={go}>
428
+ <div className="fg:flex fg:flex-col fg:gap-2">
429
+ {call ? (
430
+ <AgendaRow
431
+ testId="app-coming-up-call"
432
+ at={call.startsAt}
433
+ title={call.title}
434
+ meta={call.context}
435
+ action={
436
+ call.joinable ? (
437
+ <AppLink
438
+ href={call.href}
439
+ go={go}
440
+ testId="app-coming-up-join"
441
+ style={{ ...pillButton(t), background: t.primary, color: t.textPrimary, border: "none" }}
442
+ >
443
+ <Video size={16} aria-hidden style={{ marginRight: 6 }} />
444
+ {tr("forge.app_home.join")}
445
+ </AppLink>
446
+ ) : (
447
+ <AppLink href={call.href} go={go} testId="app-coming-up-call-details" style={pillButton(t)}>
448
+ {tr("forge.app_home.details")}
449
+ </AppLink>
450
+ )
451
+ }
452
+ />
453
+ ) : null}
454
+ {ticket ? (
455
+ <AgendaRow
456
+ testId="app-coming-up-ticket"
457
+ at={ticket.startsAt}
458
+ title={ticket.title}
459
+ meta={ticket.count === 1 ? tr("forge.app_home.tickets_one") : tr("forge.app_home.tickets_many", { count: ticket.count })}
460
+ action={
461
+ <AppLink href={ticket.href} go={go} testId="app-coming-up-ticket-view" style={pillButton(t)}>
462
+ {tr("forge.app_home.view_ticket")}
463
+ </AppLink>
464
+ }
465
+ />
466
+ ) : null}
467
+ </div>
468
+ </Section>
469
+ );
470
+ }
471
+
472
+ function AgendaRow({
473
+ at,
474
+ title,
475
+ meta,
476
+ action,
477
+ testId,
478
+ }: {
479
+ at: string;
480
+ title: string;
481
+ meta: string | null;
482
+ action: ReactNode;
483
+ testId: string;
484
+ }) {
485
+ const t = useThemeTokens();
486
+ const locale = useForgeLocale();
487
+ const date = new Date(at);
488
+ const valid = !Number.isNaN(date.getTime());
489
+ return (
490
+ <div
491
+ data-testid={testId}
492
+ className="fg:flex fg:items-center fg:gap-3"
493
+ style={{ padding: 12, borderRadius: Math.max(t.cornerRadius, 12), border: `1px solid ${t.border}` }}
494
+ >
495
+ <div aria-hidden style={{ width: 52, flexShrink: 0, textAlign: "center", lineHeight: 1.15 }}>
496
+ <div style={{ fontSize: 11, fontWeight: 700, color: t.primary, textTransform: "uppercase" }}>
497
+ {valid ? new Intl.DateTimeFormat(locale, { month: "short" }).format(date) : ""}
498
+ </div>
499
+ <div style={{ fontSize: 20, fontWeight: 800 }}>{valid ? new Intl.DateTimeFormat(locale, { day: "numeric" }).format(date) : ""}</div>
500
+ <div style={{ fontSize: 11, color: t.muted }}>
501
+ {valid ? new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit" }).format(date) : ""}
502
+ </div>
503
+ </div>
504
+ <div className="fg:min-w-0 fg:flex-1">
505
+ <div className="fg:truncate" style={{ fontWeight: 600, fontSize: 15 }}>
506
+ {title}
507
+ </div>
508
+ {meta ? (
509
+ <div className="fg:truncate" style={{ fontSize: 13, color: t.muted }}>
510
+ {meta}
511
+ </div>
512
+ ) : null}
513
+ </div>
514
+ {action}
515
+ </div>
516
+ );
517
+ }
518
+
519
+ function ContinueLearning({ course, go }: { course: AppLearningCourse; go: Go }) {
520
+ const t = useThemeTokens();
521
+ const tr = useForgeT();
522
+ const progress = course.progress;
523
+ return (
524
+ <Section testId="app-section-learning" title={tr("forge.app_home.continue_learning")} go={go}>
525
+ <AppLink
526
+ href={course.href}
527
+ go={go}
528
+ testId="app-learning-course"
529
+ className="fg:flex fg:items-center fg:gap-3"
530
+ style={{
531
+ padding: 12,
532
+ borderRadius: Math.max(t.cornerRadius, 12),
533
+ border: `1px solid ${t.border}`,
534
+ color: t.text,
535
+ textDecoration: "none",
536
+ }}
537
+ >
538
+ <span
539
+ className="fg:flex fg:items-center fg:justify-center"
540
+ style={{ width: 56, height: 56, borderRadius: 12, background: t.surface, color: t.primary, flexShrink: 0 }}
541
+ >
542
+ <BookOpen size={22} aria-hidden />
543
+ </span>
544
+ <div className="fg:min-w-0 fg:flex-1 fg:flex fg:flex-col fg:gap-1">
545
+ <div className="fg:truncate" style={{ fontWeight: 600, fontSize: 15 }}>
546
+ {course.title}
547
+ </div>
548
+ {progress ? (
549
+ <>
550
+ <div style={{ fontSize: 13, color: t.muted }}>
551
+ {tr("forge.app_home.lessons_done", { done: progress.done, total: progress.total })}
552
+ </div>
553
+ <div
554
+ role="progressbar"
555
+ aria-valuemin={0}
556
+ aria-valuemax={progress.total}
557
+ aria-valuenow={progress.done}
558
+ aria-label={tr("forge.app_home.aria_course_progress")}
559
+ data-testid="app-learning-progress"
560
+ style={{ height: 6, borderRadius: 3, background: t.border, overflow: "hidden" }}
561
+ >
562
+ <div style={{ width: `${Math.round(progress.ratio * 100)}%`, height: "100%", background: t.primary }} />
563
+ </div>
564
+ </>
565
+ ) : null}
566
+ </div>
567
+ <ChevronRight size={18} aria-hidden style={{ color: t.muted, flexShrink: 0 }} />
568
+ </AppLink>
569
+ </Section>
570
+ );
571
+ }
572
+
573
+ function ExploreCommunities({ communities, go }: { communities: AppCommunityCard[]; go: Go }) {
574
+ const t = useThemeTokens();
575
+ const tr = useForgeT();
576
+ return (
577
+ <Section
578
+ testId="app-section-explore_communities"
579
+ title={tr("forge.app_home.explore_communities")}
580
+ link={{ label: tr("forge.app_home.see_all"), href: internalPath("communities") }}
581
+ go={go}
582
+ >
583
+ <div className="fg:flex fg:flex-col fg:gap-3">
584
+ {communities.map((community) => (
585
+ <div key={community.id} data-testid={`app-explore-${community.slug}`} className="fg:flex fg:items-center fg:gap-3">
586
+ <AppMark iconUrl={community.logoUrl} name={community.name} size={48} />
587
+ <div className="fg:min-w-0 fg:flex-1">
588
+ <div className="fg:truncate" style={{ fontWeight: 600, fontSize: 15 }}>
589
+ {community.name}
590
+ </div>
591
+ {community.memberCount !== null ? (
592
+ <div style={{ fontSize: 13, color: t.muted }}>{tr("forge.app_home.members", { count: community.memberCount })}</div>
593
+ ) : null}
594
+ </div>
595
+ <AppLink href={community.href} go={go} testId={`app-explore-${community.slug}-join`} style={pillButton(t)}>
596
+ {community.joinMode === "application"
597
+ ? tr("forge.app_home.apply_community")
598
+ : community.joinMode === "invite"
599
+ ? tr("forge.app_home.community_details")
600
+ : tr("forge.app_home.join_community")}
601
+ </AppLink>
602
+ </div>
603
+ ))}
604
+ </div>
605
+ </Section>
606
+ );
607
+ }
608
+
609
+ function UpcomingEvents({ events, go }: { events: IEvent[]; go: Go }) {
610
+ const t = useThemeTokens();
611
+ const tr = useForgeT();
612
+ const locale = useForgeLocale();
613
+ return (
614
+ <Section
615
+ testId="app-section-events"
616
+ title={tr("forge.app_home.upcoming_events")}
617
+ link={{ label: tr("forge.app_home.all_events"), href: internalPath("events") }}
618
+ go={go}
619
+ >
620
+ <div className="fg:-mr-4 fg:flex fg:gap-3 fg:overflow-x-auto fg:pr-4" style={{ scrollbarWidth: "none" }}>
621
+ {events.map((event) => {
622
+ const date = new Date(event.dateTime);
623
+ const image = event.media?.find((m) => m.type === "image")?.url ?? event.media?.[0]?.url ?? null;
624
+ return (
625
+ <AppLink
626
+ key={event.id}
627
+ href={internalPath("events/$slug", { slug: event.slug || event.id })}
628
+ go={go}
629
+ testId={`app-event-${event.id}`}
630
+ className="fg:flex fg:shrink-0 fg:flex-col fg:gap-2"
631
+ style={{ width: 220, color: t.text, textDecoration: "none" }}
632
+ >
633
+ <div
634
+ style={{
635
+ position: "relative",
636
+ height: 124,
637
+ borderRadius: Math.max(t.cornerRadius, 14),
638
+ background: image ? `center / cover no-repeat url(${JSON.stringify(image)})` : t.surface,
639
+ }}
640
+ >
641
+ {!Number.isNaN(date.getTime()) ? (
642
+ <span
643
+ style={{
644
+ position: "absolute",
645
+ left: 10,
646
+ top: 10,
647
+ padding: "4px 8px",
648
+ borderRadius: 8,
649
+ background: t.background,
650
+ color: t.text,
651
+ fontSize: 12,
652
+ fontWeight: 700,
653
+ textTransform: "uppercase",
654
+ }}
655
+ >
656
+ {new Intl.DateTimeFormat(locale, { day: "numeric", month: "short" }).format(date)}
657
+ </span>
658
+ ) : null}
659
+ </div>
660
+ <div className="fg:truncate" style={{ fontWeight: 600, fontSize: 15 }}>
661
+ {event.title}
662
+ </div>
663
+ </AppLink>
664
+ );
665
+ })}
666
+ </div>
667
+ </Section>
668
+ );
669
+ }
670
+
671
+ function CoursesList({ courses, go }: { courses: PublicCourse[]; go: Go }) {
672
+ const t = useThemeTokens();
673
+ const tr = useForgeT();
674
+ const format = useAmountFormatter();
675
+ return (
676
+ <Section
677
+ testId="app-section-courses"
678
+ title={tr("forge.app_home.courses")}
679
+ link={{ label: tr("forge.app_home.all_courses"), href: internalPath("courses") }}
680
+ go={go}
681
+ >
682
+ <div className="fg:flex fg:flex-col fg:gap-3">
683
+ {courses.map((course) => {
684
+ const image = course.media?.find((m) => m.type === "image")?.url ?? null;
685
+ const price = Number(course.price);
686
+ return (
687
+ <AppLink
688
+ key={course.id}
689
+ href={internalPath("courses/$slug", { slug: course.slug || course.id })}
690
+ go={go}
691
+ testId={`app-course-${course.id}`}
692
+ className="fg:flex fg:items-center fg:gap-3"
693
+ style={{ color: t.text, textDecoration: "none" }}
694
+ >
695
+ <span
696
+ style={{
697
+ width: 64,
698
+ height: 64,
699
+ borderRadius: 12,
700
+ flexShrink: 0,
701
+ background: image ? `center / cover no-repeat url(${JSON.stringify(image)})` : t.surface,
702
+ }}
703
+ />
704
+ <div className="fg:min-w-0 fg:flex-1">
705
+ <div className="fg:truncate" style={{ fontWeight: 600, fontSize: 15 }}>
706
+ {course.title}
707
+ </div>
708
+ <div style={{ fontSize: 13, color: t.muted }}>
709
+ {Number.isFinite(price) && price > 0 ? format(price) : tr("forge.app_home.free")}
710
+ </div>
711
+ </div>
712
+ </AppLink>
713
+ );
714
+ })}
715
+ </div>
716
+ </Section>
717
+ );
718
+ }
719
+
720
+ function Shop({ products, go }: { products: IPublicProduct[]; go: Go }) {
721
+ const t = useThemeTokens();
722
+ const tr = useForgeT();
723
+ const format = useAmountFormatter();
724
+ return (
725
+ <Section
726
+ testId="app-section-shop"
727
+ title={tr("forge.app_home.from_the_shop")}
728
+ link={{ label: tr("forge.app_home.shop"), href: internalPath("store") }}
729
+ go={go}
730
+ >
731
+ <div className="fg:grid fg:grid-cols-2 fg:gap-3">
732
+ {products.map((product) => {
733
+ const image = product.media?.find((m) => m.type === "image")?.url ?? product.media?.[0]?.url ?? null;
734
+ const prices = (product.variants ?? []).map((v) => Number(v.price)).filter((p) => Number.isFinite(p));
735
+ const from = prices.length ? Math.min(...prices) : null;
736
+ return (
737
+ <AppLink
738
+ key={product.id}
739
+ href={internalPath("store/$slug", { slug: product.slug || product.id })}
740
+ go={go}
741
+ testId={`app-product-${product.id}`}
742
+ className="fg:flex fg:min-w-0 fg:flex-col fg:gap-2"
743
+ style={{ color: t.text, textDecoration: "none" }}
744
+ >
745
+ <span
746
+ style={{
747
+ aspectRatio: "1 / 1",
748
+ borderRadius: Math.max(t.cornerRadius, 14),
749
+ background: image ? `center / cover no-repeat url(${JSON.stringify(image)})` : t.surface,
750
+ }}
751
+ />
752
+ <span className="fg:truncate" style={{ fontWeight: 600, fontSize: 14 }}>
753
+ {product.title}
754
+ </span>
755
+ {from !== null ? <span style={{ fontSize: 13, color: t.muted }}>{from > 0 ? format(from) : tr("forge.app_home.free")}</span> : null}
756
+ </AppLink>
757
+ );
758
+ })}
759
+ </div>
760
+ </Section>
761
+ );
762
+ }
763
+
764
+ function LinkSection({ label, href, go }: { label: string; href: string; go: Go }) {
765
+ const t = useThemeTokens();
766
+ return (
767
+ <AppLink
768
+ href={href}
769
+ go={go}
770
+ testId="app-section-link"
771
+ className="fg:flex fg:items-center fg:gap-3"
772
+ style={{
773
+ padding: 16,
774
+ borderRadius: Math.max(t.cornerRadius, 14),
775
+ border: `1px solid ${t.border}`,
776
+ background: t.surface,
777
+ color: t.text,
778
+ textDecoration: "none",
779
+ fontWeight: 700,
780
+ fontSize: 16,
781
+ }}
782
+ >
783
+ <span className="fg:min-w-0 fg:flex-1 fg:truncate">{label}</span>
784
+ <ChevronRight size={18} aria-hidden style={{ color: t.muted, flexShrink: 0 }} />
785
+ </AppLink>
786
+ );
787
+ }
788
+
789
+ // ---- Signed out -------------------------------------------------------------------------
790
+
791
+ function SignedOutHome({ appName, iconUrl, go }: { appName: string | null; iconUrl: string | null; go: Go }) {
792
+ const t = useThemeTokens();
793
+ const tr = useForgeT();
794
+ const flow = useEmailCodeSignIn();
795
+ const field: CSSProperties = { display: "flex", flexDirection: "column", gap: 6, fontSize: 14, fontWeight: 600 };
796
+ const input: CSSProperties = {
797
+ height: 52,
798
+ boxSizing: "border-box",
799
+ padding: "0 16px",
800
+ borderRadius: Math.max(t.cornerRadius, 12),
801
+ border: `1px solid ${t.border}`,
802
+ background: "transparent",
803
+ color: t.text,
804
+ fontSize: 16,
805
+ fontFamily: t.fontFamily,
806
+ width: "100%",
807
+ };
808
+ const submit: CSSProperties = {
809
+ height: 52,
810
+ borderRadius: Math.max(t.cornerRadius, 12),
811
+ border: "none",
812
+ background: t.primary,
813
+ color: t.textPrimary,
814
+ fontWeight: 700,
815
+ fontSize: 16,
816
+ fontFamily: t.fontFamily,
817
+ cursor: "pointer",
818
+ opacity: flow.isSubmitting ? 0.7 : 1,
819
+ };
820
+ const link: CSSProperties = {
821
+ background: "transparent",
822
+ border: "none",
823
+ color: t.muted,
824
+ fontSize: 14,
825
+ fontWeight: 600,
826
+ cursor: "pointer",
827
+ padding: 4,
828
+ fontFamily: t.fontFamily,
829
+ };
830
+ const error = flow.error ? (
831
+ <p role="alert" style={{ margin: 0, fontSize: 14, color: t.text, fontWeight: 600 }}>
832
+ {flow.error}
833
+ </p>
834
+ ) : null;
835
+
836
+ const explore: Array<{ key: string; label: string; href: string; icon: LucideIcon }> = [
837
+ { key: "communities", label: tr("forge.app_home.explore_link_communities"), href: internalPath("communities"), icon: Users },
838
+ { key: "events", label: tr("forge.app_home.explore_link_events"), href: internalPath("events"), icon: CalendarDays },
839
+ { key: "courses", label: tr("forge.app_home.explore_link_courses"), href: internalPath("courses"), icon: BookOpen },
840
+ { key: "shop", label: tr("forge.app_home.explore_link_shop"), href: internalPath("store"), icon: ShoppingBag },
841
+ ];
842
+
843
+ return (
844
+ <div data-testid="app-home-signed-out" className="fg:flex fg:flex-col fg:gap-7" style={{ padding: "48px 24px 24px" }}>
845
+ <div className="fg:flex fg:flex-col fg:items-center fg:gap-3" style={{ textAlign: "center" }}>
846
+ <AppMark iconUrl={iconUrl} name={appName} size={72} />
847
+ <h1 style={{ margin: 0, fontSize: 26, fontWeight: 800, fontFamily: t.headingFontFamily, letterSpacing: "-0.02em" }}>
848
+ {appName}
849
+ </h1>
850
+ </div>
851
+
852
+ {flow.step === "email" ? (
853
+ <form
854
+ data-testid="app-sign-in-email"
855
+ className="fg:flex fg:flex-col fg:gap-3"
856
+ onSubmit={(event) => {
857
+ event.preventDefault();
858
+ void flow.submitEmail();
859
+ }}
860
+ >
861
+ <label style={field}>
862
+ {tr("forge.app_home.email")}
863
+ <input
864
+ style={input}
865
+ type="email"
866
+ inputMode="email"
867
+ autoComplete="email"
868
+ placeholder={tr("forge.app_home.email_placeholder")}
869
+ value={flow.email}
870
+ onChange={(event) => flow.setEmail(event.target.value)}
871
+ />
872
+ </label>
873
+ {error}
874
+ <button type="submit" disabled={flow.isSubmitting} style={submit}>
875
+ {tr("forge.app_home.continue")}
876
+ </button>
877
+ </form>
878
+ ) : flow.step === "code" ? (
879
+ <form
880
+ data-testid="app-sign-in-code"
881
+ className="fg:flex fg:flex-col fg:gap-3"
882
+ onSubmit={(event) => {
883
+ event.preventDefault();
884
+ void flow.submitCode();
885
+ }}
886
+ >
887
+ <p style={{ margin: 0, fontSize: 15 }}>{tr("forge.app_home.code_sent", { email: flow.email })}</p>
888
+ <label style={field}>
889
+ {tr("forge.app_home.code")}
890
+ <input
891
+ style={{ ...input, letterSpacing: 4 }}
892
+ inputMode="numeric"
893
+ autoComplete="one-time-code"
894
+ maxLength={6}
895
+ value={flow.code}
896
+ onChange={(event) => flow.setCode(event.target.value.replace(/\D/g, ""))}
897
+ autoFocus
898
+ />
899
+ </label>
900
+ {error}
901
+ {flow.resent && !flow.error ? (
902
+ <p role="status" style={{ margin: 0, fontSize: 13, color: t.muted }}>
903
+ {tr("forge.app_home.code_resent")}
904
+ </p>
905
+ ) : null}
906
+ <button type="submit" disabled={flow.isSubmitting} style={submit}>
907
+ {tr("forge.app_home.verify")}
908
+ </button>
909
+ <div className="fg:flex fg:flex-wrap fg:justify-between fg:gap-2">
910
+ <button type="button" style={link} onClick={flow.changeEmail}>
911
+ {tr("forge.app_home.different_email")}
912
+ </button>
913
+ <button type="button" style={link} disabled={flow.isSubmitting} onClick={() => void flow.resendCode()}>
914
+ {tr("forge.app_home.resend")}
915
+ </button>
916
+ </div>
917
+ </form>
918
+ ) : (
919
+ <form
920
+ data-testid="app-sign-in-profile"
921
+ className="fg:flex fg:flex-col fg:gap-3"
922
+ onSubmit={(event) => {
923
+ event.preventDefault();
924
+ void flow.submitProfile();
925
+ }}
926
+ >
927
+ <p style={{ margin: 0, fontWeight: 600 }}>{tr("forge.app_home.new_here", { email: flow.email })}</p>
928
+ <div className="fg:flex fg:gap-2">
929
+ <label style={{ ...field, flex: 1, minWidth: 0 }}>
930
+ {tr("forge.app_home.first_name")}
931
+ <input
932
+ style={input}
933
+ value={flow.firstName}
934
+ maxLength={100}
935
+ autoComplete="given-name"
936
+ onChange={(event) => flow.setFirstName(event.target.value)}
937
+ />
938
+ </label>
939
+ <label style={{ ...field, flex: 1, minWidth: 0 }}>
940
+ {tr("forge.app_home.last_name")}
941
+ <input
942
+ style={input}
943
+ value={flow.lastName}
944
+ maxLength={100}
945
+ autoComplete="family-name"
946
+ onChange={(event) => flow.setLastName(event.target.value)}
947
+ />
948
+ </label>
949
+ </div>
950
+ <label className="fg:flex fg:items-center fg:gap-2" style={{ fontSize: 13 }}>
951
+ <input
952
+ type="checkbox"
953
+ checked={flow.acceptedTerms}
954
+ onChange={(event) => flow.setAcceptedTerms(event.target.checked)}
955
+ style={{ accentColor: t.primary }}
956
+ />
957
+ {tr("forge.app_home.accept_terms")}
958
+ </label>
959
+ {error}
960
+ <button type="submit" disabled={flow.isSubmitting} style={submit}>
961
+ {tr("forge.app_home.continue")}
962
+ </button>
963
+ <button type="button" style={link} onClick={flow.changeEmail}>
964
+ {tr("forge.app_home.start_again")}
965
+ </button>
966
+ </form>
967
+ )}
968
+
969
+ <div className="fg:flex fg:items-center fg:gap-3" style={{ color: t.muted, fontSize: 13 }}>
970
+ <span className="fg:flex-1" style={{ height: 1, background: t.border }} />
971
+ {tr("forge.app_home.look_around")}
972
+ <span className="fg:flex-1" style={{ height: 1, background: t.border }} />
973
+ </div>
974
+
975
+ <div className="fg:grid fg:grid-cols-2 fg:gap-2">
976
+ {explore.map(({ key, label, href, icon: Icon }) => (
977
+ <AppLink
978
+ key={key}
979
+ href={href}
980
+ go={go}
981
+ testId={`app-explore-link-${key}`}
982
+ className="fg:flex fg:items-center fg:gap-2"
983
+ style={{
984
+ minHeight: 52,
985
+ padding: "0 14px",
986
+ borderRadius: Math.max(t.cornerRadius, 12),
987
+ background: t.surface,
988
+ color: t.text,
989
+ fontWeight: 600,
990
+ fontSize: 14,
991
+ textDecoration: "none",
992
+ }}
993
+ >
994
+ <Icon size={20} aria-hidden />
995
+ {label}
996
+ </AppLink>
997
+ ))}
998
+ </div>
999
+ </div>
1000
+ );
1001
+ }
1002
+
1003
+ // ---- Pieces ---------------------------------------------------------------------------------
1004
+
1005
+ type Tokens = ReturnType<typeof useThemeTokens>;
1006
+
1007
+ function pillButton(t: Tokens): CSSProperties {
1008
+ return {
1009
+ display: "inline-flex",
1010
+ alignItems: "center",
1011
+ flexShrink: 0,
1012
+ minHeight: 36,
1013
+ padding: "0 16px",
1014
+ borderRadius: 999,
1015
+ border: `1px solid ${t.border}`,
1016
+ color: t.text,
1017
+ fontWeight: 600,
1018
+ fontSize: 14,
1019
+ textDecoration: "none",
1020
+ whiteSpace: "nowrap",
1021
+ };
1022
+ }
1023
+
1024
+ function badge(t: Tokens): CSSProperties {
1025
+ return {
1026
+ position: "absolute",
1027
+ top: 6,
1028
+ right: 6,
1029
+ minWidth: 18,
1030
+ height: 18,
1031
+ padding: "0 5px",
1032
+ borderRadius: 9,
1033
+ background: t.primary,
1034
+ color: t.textPrimary,
1035
+ fontSize: 11,
1036
+ fontWeight: 700,
1037
+ lineHeight: "18px",
1038
+ textAlign: "center",
1039
+ boxSizing: "border-box",
1040
+ };
1041
+ }
1042
+
1043
+ function Section({
1044
+ title,
1045
+ link,
1046
+ go,
1047
+ testId,
1048
+ children,
1049
+ }: {
1050
+ title: string;
1051
+ link?: { label: string; href: string };
1052
+ go: Go;
1053
+ testId: string;
1054
+ children: ReactNode;
1055
+ }) {
1056
+ const t = useThemeTokens();
1057
+ return (
1058
+ <section aria-label={title} data-testid={testId} className="fg:flex fg:flex-col fg:gap-3">
1059
+ <div className="fg:flex fg:items-baseline fg:justify-between fg:gap-3">
1060
+ <h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, fontFamily: t.headingFontFamily }}>{title}</h2>
1061
+ {link ? (
1062
+ <AppLink href={link.href} go={go} style={{ fontSize: 14, fontWeight: 600, color: t.primary, textDecoration: "none" }}>
1063
+ {link.label}
1064
+ </AppLink>
1065
+ ) : null}
1066
+ </div>
1067
+ {children}
1068
+ </section>
1069
+ );
1070
+ }
1071
+
1072
+ /** The app's mark: its icon, or its first letter on the theme's primary. */
1073
+ function AppMark({ iconUrl, name, size }: { iconUrl: string | null; name: string | null; size: number }) {
1074
+ const t = useThemeTokens();
1075
+ const radius = Math.round(size * 0.28);
1076
+ if (iconUrl) {
1077
+ return <img src={iconUrl} alt="" style={{ width: size, height: size, borderRadius: radius, objectFit: "cover", flexShrink: 0 }} />;
1078
+ }
1079
+ return (
1080
+ <span
1081
+ aria-hidden
1082
+ className="fg:inline-flex fg:items-center fg:justify-center"
1083
+ style={{
1084
+ width: size,
1085
+ height: size,
1086
+ borderRadius: radius,
1087
+ background: t.primary,
1088
+ color: t.textPrimary,
1089
+ fontWeight: 800,
1090
+ fontSize: Math.round(size * 0.42),
1091
+ flexShrink: 0,
1092
+ }}
1093
+ >
1094
+ {(name ?? "").trim().charAt(0).toUpperCase()}
1095
+ </span>
1096
+ );
1097
+ }
1098
+
1099
+ /**
1100
+ * A real link that navigates in-app on a plain left click. An absolute URL (a
1101
+ * creator's link section pointing off-site) opens as an ordinary link.
1102
+ */
1103
+ function AppLink({
1104
+ href,
1105
+ go,
1106
+ children,
1107
+ style,
1108
+ className,
1109
+ testId,
1110
+ ariaLabel,
1111
+ }: {
1112
+ href: string;
1113
+ go: Go;
1114
+ children: ReactNode;
1115
+ style?: CSSProperties;
1116
+ className?: string;
1117
+ testId?: string;
1118
+ ariaLabel?: string;
1119
+ }) {
1120
+ const external = /^https?:\/\//i.test(href);
1121
+ return (
1122
+ <a
1123
+ href={href}
1124
+ className={className}
1125
+ style={style}
1126
+ data-testid={testId}
1127
+ aria-label={ariaLabel}
1128
+ {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
1129
+ onClick={(event: MouseEvent<HTMLAnchorElement>) => {
1130
+ if (external) return;
1131
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
1132
+ event.preventDefault();
1133
+ go(href);
1134
+ }}
1135
+ >
1136
+ {children}
1137
+ </a>
1138
+ );
1139
+ }