@tribe-nest/forge 3.60.0 → 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/package.json +2 -1
  2. package/src/contexts/CartContext.tsx +34 -1
  3. package/src/contexts/PublicAuthContext.tsx +15 -3
  4. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  5. package/src/data/queries/useAccount.ts +33 -2
  6. package/src/data/queries/useAnalytics.ts +6 -6
  7. package/src/data/queries/useCallChat.ts +2 -2
  8. package/src/data/queries/useChat.ts +10 -1
  9. package/src/data/queries/useCheckouts.ts +2 -1
  10. package/src/data/queries/useCoachingAvailability.ts +10 -1
  11. package/src/data/queries/useCommunities.ts +2 -0
  12. package/src/data/queries/useCommunity.ts +362 -18
  13. package/src/data/queries/useCommunityAgent.ts +247 -0
  14. package/src/data/queries/useCommunityCalendar.ts +150 -0
  15. package/src/data/queries/useCommunityClassroom.ts +75 -0
  16. package/src/data/queries/useCommunityMembers.ts +108 -0
  17. package/src/data/queries/useCommunityModeration.ts +327 -0
  18. package/src/data/queries/useCommunityWiki.ts +137 -0
  19. package/src/data/queries/useCourses.ts +2 -0
  20. package/src/data/queries/useEmailSignIn.ts +79 -0
  21. package/src/data/queries/useEvents.ts +6 -1
  22. package/src/data/queries/useForms.ts +5 -1
  23. package/src/data/queries/useLeadMagnet.ts +6 -2
  24. package/src/data/queries/useMyBookings.ts +24 -1
  25. package/src/data/queries/useMyTickets.ts +24 -1
  26. package/src/data/queries/useNotifications.ts +6 -2
  27. package/src/data/queries/useOrders.ts +2 -1
  28. package/src/data/queries/usePWA.ts +111 -35
  29. package/src/data/queries/useSiteApp.ts +130 -0
  30. package/src/data/queries/useSubscriptions.ts +6 -1
  31. package/src/i18n/de.json +746 -1
  32. package/src/i18n/en.json +746 -1
  33. package/src/index.ts +17 -0
  34. package/src/runtime/pages.spec.ts +5 -0
  35. package/src/runtime/pages.ts +32 -6
  36. package/src/types/models.ts +11 -0
  37. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  38. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  39. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  40. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  41. package/src/ui/analytics/useMetaEvents.ts +78 -0
  42. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  43. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  44. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  45. package/src/ui/headless/_tests/communityHarness.tsx +10 -3
  46. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  47. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  48. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  49. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  50. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  51. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  52. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  53. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
  54. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
  55. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  56. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
  57. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  58. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  59. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  60. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  61. package/src/ui/headless/auth/useSignupForm.ts +20 -1
  62. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  63. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  64. package/src/ui/headless/communities/index.ts +41 -0
  65. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  66. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  67. package/src/ui/headless/communities/useCommunityHome.ts +3 -0
  68. package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
  69. package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
  70. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  71. package/src/ui/headless/community/communityPoll.ts +297 -0
  72. package/src/ui/headless/community/countries.ts +123 -0
  73. package/src/ui/headless/community/index.ts +81 -1
  74. package/src/ui/headless/community/linkPreview.ts +71 -0
  75. package/src/ui/headless/community/richLexical.ts +27 -2
  76. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  77. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  78. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  79. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  80. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  81. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  82. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  83. package/src/ui/headless/downloads/index.ts +8 -0
  84. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  85. package/src/ui/headless/index.ts +13 -0
  86. package/src/ui/headless/memberHome/index.ts +10 -0
  87. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  88. package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
  89. package/src/ui/headless/portal/index.ts +57 -0
  90. package/src/ui/headless/portal/usePortal.ts +274 -0
  91. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  92. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  93. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  94. package/src/ui/headless/siteApp/appBar.ts +102 -0
  95. package/src/ui/headless/siteApp/appHome.ts +344 -0
  96. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  97. package/src/ui/headless/siteApp/index.ts +58 -0
  98. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  99. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  100. package/src/ui/headless/work/index.ts +2 -0
  101. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  102. package/src/ui/index.ts +11 -0
  103. package/src/ui/styled/AccountDashboard.tsx +712 -352
  104. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  105. package/src/ui/styled/ChatRoom.tsx +126 -16
  106. package/src/ui/styled/Checkout.tsx +8 -0
  107. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  108. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  109. package/src/ui/styled/CoachingDetail.tsx +2 -0
  110. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  111. package/src/ui/styled/CourseDetail.tsx +2 -0
  112. package/src/ui/styled/DownloadPage.tsx +263 -0
  113. package/src/ui/styled/EventConfirmation.tsx +10 -0
  114. package/src/ui/styled/EventDetail.tsx +10 -0
  115. package/src/ui/styled/FilmLibrary.tsx +22 -9
  116. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  117. package/src/ui/styled/ProductDetail.tsx +12 -0
  118. package/src/ui/styled/PwaRegistration.tsx +5 -1
  119. package/src/ui/styled/SignupForm.tsx +18 -3
  120. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
  121. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  122. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  123. package/src/ui/styled/app/AppBar.tsx +298 -0
  124. package/src/ui/styled/app/AppHome.tsx +1139 -0
  125. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  126. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  127. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  128. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  129. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  130. package/src/ui/styled/app/index.ts +7 -0
  131. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  132. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  133. package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
  134. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  135. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  136. package/src/ui/styled/communities/CommunityHome.tsx +134 -11
  137. package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
  138. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
  139. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  140. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  141. package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
  142. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  143. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  144. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  145. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  146. package/src/ui/styled/communities/index.ts +9 -0
  147. package/src/ui/styled/communities/shared.tsx +39 -11
  148. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  149. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  150. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  151. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  152. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  153. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  154. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  155. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  156. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  157. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  158. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  159. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  160. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  161. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  162. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  163. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  164. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  165. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  166. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  167. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  168. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  169. package/src/ui/styled/community/index.ts +37 -2
  170. package/src/ui/styled/forge-utilities.css +234 -0
  171. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  172. package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
  173. package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
  174. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  175. package/src/ui/styled/members/index.ts +8 -0
  176. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  177. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  178. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  179. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  180. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  181. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  182. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  183. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  184. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  185. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  186. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  187. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  188. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  189. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  190. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  191. package/src/ui/styled/portal/index.ts +13 -0
  192. package/src/ui/styled/portal/portalNav.ts +260 -0
  193. package/src/ui/styled/portal/shared.tsx +206 -0
  194. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  195. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  196. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  197. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  198. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  199. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  200. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  201. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  202. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  203. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  204. package/src/ui/styled/work/index.ts +1 -1
  205. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  206. package/src/utils/landing.ts +19 -6
  207. package/src/utils/metaEvents.ts +302 -0
@@ -0,0 +1,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
+ }