@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
@@ -1,8 +1,9 @@
1
1
  import { useEffect, useState } from "react";
2
+ import { Check } from "lucide-react";
2
3
  import {
3
4
  usePublicAuth,
4
- useUserOrders,
5
- useMyTickets,
5
+ useUserOrdersPaged,
6
+ useMyTicketsPaged,
6
7
  useCancelMyTicket,
7
8
  isMyTicketRequestOpenOrEnded,
8
9
  myTicketRequestState,
@@ -12,14 +13,19 @@ import {
12
13
  useMyWaitlistPlaces,
13
14
  useLeaveEventWaitlist,
14
15
  type WaitlistPlace,
15
- useMyBookings,
16
+ useMyBookingsPaged,
16
17
  useRescheduleBooking,
17
18
  useCancelMyBooking,
18
19
  useCoachingAvailability,
19
20
  type MyBooking,
20
21
  useSavedPosts,
21
- useNotificationPreferences,
22
- useUpdateNotificationPreference,
22
+ useNotificationMatrix,
23
+ useUpdateNotificationMatrix,
24
+ usePushSubscription,
25
+ useForge,
26
+ type NotificationMatrix,
27
+ type NotificationChannel,
28
+ type DigestCadence,
23
29
  useMyCourses,
24
30
  useMyFilmRentals,
25
31
  type MyCourse,
@@ -36,13 +42,13 @@ import {
36
42
  useCancelAccountDeletion,
37
43
  useAccountDeletionStatus,
38
44
  OrderStatus,
39
- type NotificationPreferences,
40
45
  } from "../../index";
41
46
  import { useForgeT, type ForgeT } from "../../i18n";
42
47
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
43
48
  import { useSiteConfig } from "../../data/queries/useWebsite";
44
49
  import { useAmountFormatter } from "../format/useFormatCurrency";
45
50
  import { Loading } from "./Loading";
51
+ import { CallHelpHint } from "../media/CallHelpHint";
46
52
  import { bookingCallWindow } from "../media/bookingWindow";
47
53
  import { formatCountdown } from "./EventWaitlist";
48
54
  import { AddToCalendar } from "./AddToCalendar";
@@ -149,47 +155,116 @@ const defaultFormatDate = (value: string | number | Date): string =>
149
155
 
150
156
  // ---- shared styling helpers -------------------------------------------------
151
157
 
158
+ /**
159
+ * The panels' look: the client portal's. No box around a section and no box
160
+ * around each item; items are rows divided by hairlines, and the theme's
161
+ * primary is kept for actions and for state the person has to act on.
162
+ */
152
163
  function useCardStyles() {
153
164
  const t = useThemeTokens();
165
+ const control: React.CSSProperties = {
166
+ display: "inline-flex",
167
+ alignItems: "center",
168
+ justifyContent: "center",
169
+ gap: 6,
170
+ minHeight: 38,
171
+ padding: "8px 16px",
172
+ borderRadius: t.cornerRadius,
173
+ fontFamily: t.fontFamily,
174
+ fontSize: 14,
175
+ fontWeight: 600,
176
+ cursor: "pointer",
177
+ textDecoration: "none",
178
+ whiteSpace: "nowrap",
179
+ };
154
180
  return {
155
181
  t,
156
- card: {
157
- background: t.surface,
158
- border: `1px solid ${t.primary}20`,
159
- borderRadius: t.cornerRadius,
160
- padding: 24,
161
- color: t.text,
162
- } as React.CSSProperties,
182
+ /** A section: no chrome of its own, the page title already names it. */
183
+ card: { color: t.text } as React.CSSProperties,
184
+ /** The list a section's items sit in. */
185
+ list: { display: "flex", flexDirection: "column", borderTop: `1px solid ${t.border}` } as React.CSSProperties,
186
+ /** One item: a row, divided from the next by a hairline. */
187
+ row: { padding: "20px 0", borderBottom: `1px solid ${t.border}` } as React.CSSProperties,
188
+ /** Secondary text under an item's title. */
189
+ meta: { fontSize: 13, color: t.muted, lineHeight: 1.5 } as React.CSSProperties,
163
190
  button: {
164
- padding: "8px 14px",
165
- borderRadius: t.cornerRadius,
191
+ ...control,
166
192
  background: t.primary,
167
- color: t.background,
168
- border: "none",
169
- fontWeight: 600,
170
- cursor: "pointer",
193
+ color: t.textPrimary,
194
+ border: `1px solid ${t.primary}`,
171
195
  } as React.CSSProperties,
172
196
  ghostButton: {
173
- padding: "8px 14px",
174
- borderRadius: t.cornerRadius,
197
+ ...control,
175
198
  background: "transparent",
176
199
  color: t.text,
177
- border: `1px solid ${t.primary}40`,
178
- cursor: "pointer",
200
+ border: `1px solid ${t.border}`,
179
201
  } as React.CSSProperties,
180
202
  input: {
181
203
  width: "100%",
182
204
  height: 40,
183
205
  padding: "8px 12px",
184
206
  borderRadius: t.cornerRadius,
185
- border: `1px solid ${t.primary}40`,
207
+ border: `1px solid ${t.border}`,
186
208
  background: "transparent",
187
209
  color: t.text,
210
+ fontFamily: t.fontFamily,
211
+ fontSize: 15,
188
212
  outline: "none",
213
+ boxSizing: "border-box",
189
214
  } as React.CSSProperties,
215
+ /** An error the person must read: the theme's text, weighted, never a hard-coded red. */
216
+ alert: { fontSize: 14, fontWeight: 600, color: t.text, marginTop: 12 } as React.CSSProperties,
190
217
  };
191
218
  }
192
219
 
220
+ /** An empty section says what will appear and stays quiet about it. */
221
+ /** "Show more" under a list that loads a page at a time. Draws nothing once everything is loaded. */
222
+ function ShowMore({
223
+ hasNextPage,
224
+ isFetchingNextPage,
225
+ onMore,
226
+ }: {
227
+ hasNextPage?: boolean;
228
+ isFetchingNextPage: boolean;
229
+ onMore: () => void;
230
+ }) {
231
+ const { ghostButton } = useCardStyles();
232
+ const tr = useForgeT();
233
+ if (!hasNextPage) return null;
234
+ return (
235
+ <div style={{ marginTop: 20 }}>
236
+ <button
237
+ type="button"
238
+ data-testid="account-show-more"
239
+ onClick={onMore}
240
+ disabled={isFetchingNextPage}
241
+ style={{ ...ghostButton, cursor: isFetchingNextPage ? "wait" : "pointer" }}
242
+ >
243
+ {isFetchingNextPage ? tr("forge.account_dashboard.loading_more") : tr("forge.account_dashboard.show_more")}
244
+ </button>
245
+ </div>
246
+ );
247
+ }
248
+
249
+ function EmptySection({ children }: { children: React.ReactNode }) {
250
+ const t = useThemeTokens();
251
+ return (
252
+ <div
253
+ data-testid="account-section-empty"
254
+ style={{
255
+ padding: "36px 24px",
256
+ border: `1px dashed ${t.border}`,
257
+ borderRadius: t.cornerRadius,
258
+ textAlign: "center",
259
+ color: t.muted,
260
+ fontSize: 15,
261
+ }}
262
+ >
263
+ {children}
264
+ </div>
265
+ );
266
+ }
267
+
193
268
  // ---- shared context passed down to tabs ------------------------------------
194
269
 
195
270
  interface TabContext {
@@ -275,15 +350,16 @@ export function AccountDashboard({
275
350
  const hasCourses = (courses?.length ?? 0) > 0;
276
351
  const visibleTabs = ACCOUNT_TABS.filter(
277
352
  (key) =>
278
- (key !== "rentals" || hasRentals || tab === "rentals") &&
279
- (key !== "courses" || hasCourses || tab === "courses"),
353
+ (key !== "rentals" || hasRentals || tab === "rentals") && (key !== "courses" || hasCourses || tab === "courses"),
280
354
  );
281
355
 
282
356
  if (!user) return <Loading fullPage />;
283
357
 
284
358
  return (
285
359
  <div className={className} style={{ maxWidth: 900, margin: "0 auto", color: t.text, ...style }}>
286
- <div style={{ marginBottom: 24, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 16 }}>
360
+ <div
361
+ style={{ marginBottom: 24, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 16 }}
362
+ >
287
363
  <div>
288
364
  <h1 style={{ fontSize: 28, fontWeight: 800, fontFamily: t.headingFontFamily }}>
289
365
  {tr("forge.account_dashboard.title")}
@@ -292,7 +368,14 @@ export function AccountDashboard({
292
368
  </div>
293
369
  <button
294
370
  onClick={() => logout()}
295
- style={{ padding: "8px 14px", borderRadius: t.cornerRadius, background: "transparent", color: t.text, border: `1px solid ${t.primary}40`, cursor: "pointer" }}
371
+ style={{
372
+ padding: "8px 14px",
373
+ borderRadius: t.cornerRadius,
374
+ background: "transparent",
375
+ color: t.text,
376
+ border: `1px solid ${t.primary}40`,
377
+ cursor: "pointer",
378
+ }}
296
379
  >
297
380
  {tr("forge.account_dashboard.log_out")}
298
381
  </button>
@@ -333,6 +416,69 @@ export function AccountDashboard({
333
416
  );
334
417
  }
335
418
 
419
+ // ---- one section, outside the dashboard -----------------------------------
420
+
421
+ export type AccountSectionProps = Omit<AccountDashboardProps, "tab" | "onTabChange" | "className" | "style"> & {
422
+ /** Which section to draw. Same keys as the dashboard's tabs. */
423
+ section: AccountTabKey;
424
+ };
425
+
426
+ /**
427
+ * One of the dashboard's sections on its own, with no header and no tab strip.
428
+ *
429
+ * The client portal draws its own navigation (a rail on desktop, a bottom bar
430
+ * on mobile) and gives each section its own URL, so it needs the panels
431
+ * without the dashboard's chrome. The panels are the same components the
432
+ * dashboard renders, so the two can never drift apart.
433
+ */
434
+ export function AccountSection({
435
+ section,
436
+ navigate,
437
+ onNavigateFilm,
438
+ onNavigateCourse,
439
+ formatAmount,
440
+ formatDate,
441
+ currency,
442
+ }: AccountSectionProps) {
443
+ const { user } = usePublicAuth();
444
+ const { data: siteConfig } = useSiteConfig();
445
+ const { data: courses } = useMyCourses({ accountId: user?.id });
446
+ const fmtAmount = useAmountFormatter(formatAmount);
447
+ const ctx: TabContext = {
448
+ formatAmount: fmtAmount,
449
+ formatDate: formatDate ?? defaultFormatDate,
450
+ currency: currency ?? siteConfig?.currency,
451
+ navigate,
452
+ onNavigateFilm,
453
+ onNavigateCourse,
454
+ };
455
+
456
+ if (!user) return <Loading />;
457
+
458
+ switch (section) {
459
+ case "membership":
460
+ return <MembershipTab ctx={ctx} />;
461
+ case "orders":
462
+ return <OrdersTab ctx={ctx} />;
463
+ case "tickets":
464
+ return <TicketsTab ctx={ctx} />;
465
+ case "waitlist":
466
+ return <WaitlistTab ctx={ctx} />;
467
+ case "bookings":
468
+ return <BookingsTab ctx={ctx} />;
469
+ case "rentals":
470
+ return <RentalsTab ctx={ctx} />;
471
+ case "courses":
472
+ return <CoursesTab ctx={ctx} courses={courses ?? []} />;
473
+ case "saved":
474
+ return <SavedTab />;
475
+ case "account":
476
+ return <AccountTab ctx={ctx} />;
477
+ case "notifications":
478
+ return <NotificationsTab />;
479
+ }
480
+ }
481
+
336
482
  // ---- Membership tab ---------------------------------------------------------
337
483
 
338
484
  /**
@@ -343,14 +489,10 @@ export function AccountDashboard({
343
489
  * fan here; this is for someone who wants everything in one place.
344
490
  */
345
491
  function MembershipTab({ ctx }: { ctx: TabContext }) {
346
- const { t, card } = useCardStyles();
347
- const tr = useForgeT();
492
+ const { card } = useCardStyles();
348
493
  return (
349
494
  <div style={card}>
350
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
351
- {tr("forge.account_dashboard.membership_title")}
352
- </h2>
353
- <MyMembershipsList navigate={ctx.navigate} formatAmount={ctx.formatAmount} />
495
+ <MyMembershipsList navigate={ctx.navigate} formatAmount={ctx.formatAmount} layout="rows" />
354
496
  <MembershipApplicationsSection ctx={ctx} />
355
497
  </div>
356
498
  );
@@ -379,10 +521,12 @@ const APPLICATION_STATUS_LABEL: Record<MembershipApplicationStatus, string> = {
379
521
  function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
380
522
  const { user } = usePublicAuth();
381
523
  const tr = useForgeT();
382
- const { t, ghostButton, button } = useCardStyles();
524
+ const { t, ghostButton, button, list, row, meta, alert } = useCardStyles();
383
525
  const { formatAmount, formatDate, currency } = ctx;
384
526
  const { data: applications } = useMyMembershipApplications(user?.id);
385
- const { data: communities } = usePublicCommunities({ enabled: !!applications?.some((a) => a.status === "payment_failed") });
527
+ const { data: communities } = usePublicCommunities({
528
+ enabled: !!applications?.some((a) => a.status === "payment_failed"),
529
+ });
386
530
  /** An approved application whose card failed is finished on its community's About page, on that tier. */
387
531
  const completeHref = (application: MembershipApplication) => {
388
532
  const communityId = (application as MembershipApplication & { communityId?: string }).communityId;
@@ -415,24 +559,28 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
415
559
  };
416
560
 
417
561
  return (
418
- <div style={{ marginTop: 24, paddingTop: 16, borderTop: `1px solid ${t.primary}20` }}>
419
- <h3 style={{ fontWeight: 700, marginBottom: 12, fontFamily: t.headingFontFamily }}>
562
+ <div style={{ marginTop: 40 }}>
563
+ <h2 style={{ fontSize: 18, fontWeight: 600, margin: "0 0 12px", fontFamily: t.headingFontFamily }}>
420
564
  {tr("forge.account_dashboard.applications_title")}
421
- </h3>
422
- <ul
423
- data-testid="membership-applications"
424
- style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 12 }}
425
- >
565
+ </h2>
566
+ <ul data-testid="membership-applications" style={{ ...list, listStyle: "none", padding: 0, margin: 0 }}>
426
567
  {applications.map((application) => (
427
568
  <li
428
569
  key={application.id}
429
- style={{ display: "flex", gap: 12, justifyContent: "space-between", alignItems: "center", flexWrap: "wrap" }}
570
+ style={{
571
+ ...row,
572
+ display: "flex",
573
+ gap: 12,
574
+ justifyContent: "space-between",
575
+ alignItems: "center",
576
+ flexWrap: "wrap",
577
+ }}
430
578
  >
431
579
  <div style={{ minWidth: 0 }}>
432
- <p style={{ fontWeight: 600 }}>{application.membershipTierName ?? ""}</p>
433
- <p style={{ fontSize: 13, opacity: 0.85 }}>{tr(APPLICATION_STATUS_LABEL[application.status])}</p>
580
+ <p style={{ fontWeight: 600, margin: 0 }}>{application.membershipTierName ?? ""}</p>
581
+ <p style={{ ...meta, margin: "2px 0 0" }}>{tr(APPLICATION_STATUS_LABEL[application.status])}</p>
434
582
  {application.amount != null && application.billingCycle && (
435
- <p style={{ fontSize: 13, opacity: 0.8 }}>
583
+ <p style={{ ...meta, margin: 0 }}>
436
584
  {tr("forge.account_dashboard.membership_price", {
437
585
  amount: formatAmount(application.amount, application.currency || currency),
438
586
  cycle: application.billingCycle,
@@ -440,35 +588,28 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
440
588
  </p>
441
589
  )}
442
590
  {application.status === "payment_failed" && application.paymentWindowExpiresAt && (
443
- <p style={{ fontSize: 13, fontWeight: 600 }}>
591
+ <p style={{ fontSize: 13, fontWeight: 600, margin: 0 }}>
444
592
  {tr("forge.account_dashboard.application_complete_by", {
445
593
  date: formatDate(application.paymentWindowExpiresAt),
446
594
  })}
447
595
  </p>
448
596
  )}
449
597
  {application.status === "declined" && application.decisionNote && (
450
- <p style={{ fontSize: 13, opacity: 0.8, maxWidth: 480 }}>{application.decisionNote}</p>
598
+ <p style={{ ...meta, margin: 0, maxWidth: 480 }}>{application.decisionNote}</p>
451
599
  )}
452
- <p style={{ fontSize: 12, opacity: 0.65 }}>
600
+ <p style={{ ...meta, margin: 0 }}>
453
601
  {tr("forge.account_dashboard.application_submitted_on", { date: formatDate(application.createdAt) })}
454
602
  </p>
455
603
  </div>
456
604
  {canWithdrawMembershipApplication(application.status) && (
457
- <button
458
- onClick={() => void onWithdraw(application)}
459
- disabled={withdraw.isPending}
460
- style={ghostButton}
461
- >
605
+ <button onClick={() => void onWithdraw(application)} disabled={withdraw.isPending} style={ghostButton}>
462
606
  {withdraw.isPending && withdraw.variables?.applicationId === application.id
463
607
  ? tr("forge.account_dashboard.application_withdrawing")
464
608
  : tr("forge.account_dashboard.application_withdraw")}
465
609
  </button>
466
610
  )}
467
611
  {application.status === "payment_failed" && (
468
- <a
469
- href={completeHref(application)}
470
- style={{ ...button, textDecoration: "none" }}
471
- >
612
+ <a href={completeHref(application)} style={button}>
472
613
  {tr("forge.account_dashboard.application_complete")}
473
614
  </a>
474
615
  )}
@@ -476,7 +617,7 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
476
617
  ))}
477
618
  </ul>
478
619
  {error && (
479
- <p role="alert" style={{ fontSize: 13, marginTop: 8, color: t.primary }}>
620
+ <p role="alert" style={alert}>
480
621
  {error}
481
622
  </p>
482
623
  )}
@@ -508,15 +649,15 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
508
649
  */
509
650
  function TicketsTab({ ctx }: { ctx: TabContext }) {
510
651
  const { user } = usePublicAuth();
511
- const { t, card } = useCardStyles();
652
+ const { t, card, list, row, meta, ghostButton } = useCardStyles();
512
653
  const tr = useForgeT();
513
654
  const { formatAmount, formatDate, currency } = ctx;
514
- const { data, isLoading } = useMyTickets(user?.id);
655
+ const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = useMyTicketsPaged(user?.id);
515
656
  const cancelTicket = useCancelMyTicket();
516
657
  const [pendingId, setPendingId] = useState<string | null>(null);
517
658
  const [error, setError] = useState<string | null>(null);
518
659
 
519
- const tickets = data?.data ?? [];
660
+ const tickets = data?.pages.flatMap((page) => page.data) ?? [];
520
661
 
521
662
  const onCancel = async (ticket: MyTicket) => {
522
663
  setError(null);
@@ -535,18 +676,15 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
535
676
 
536
677
  return (
537
678
  <div style={card}>
538
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
539
- {tr("forge.account_dashboard.tickets_title")}
540
- </h2>
541
679
  {error && (
542
- <div style={{ marginBottom: 16, padding: 12, borderRadius: t.cornerRadius, border: `1px solid ${t.primary}40`, fontSize: 14 }}>
680
+ <p role="alert" style={{ fontSize: 14, fontWeight: 600, margin: "0 0 16px" }}>
543
681
  {error}
544
- </div>
682
+ </p>
545
683
  )}
546
684
  {isLoading ? (
547
685
  <Loading />
548
686
  ) : tickets.length > 0 ? (
549
- <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
687
+ <div style={list}>
550
688
  {tickets.map((ticket) => {
551
689
  const cancelled = !!ticket.selfCancelledAt;
552
690
  // Absent on an older API build, where every row WAS a purchase.
@@ -562,24 +700,23 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
562
700
  const requestState = myTicketRequestState(ticket);
563
701
  const isRequestRow = isMyTicketRequestOpenOrEnded(ticket);
564
702
  const requestEnded = isRequestRow && requestState !== "pending";
703
+ // A show that has happened needs no calendar entry, wallet pass or
704
+ // transfer: those controls are for getting INTO it.
705
+ const eventOver = new Date(ticket.eventEndDateTime ?? ticket.eventDateTime).getTime() < Date.now();
706
+ const heldPassIds = myTicketHeldPassIds(ticket);
565
707
  return (
566
708
  <div
567
709
  key={ticket.id}
568
- data-testid={requestState ? `my-ticket-request-${requestState}` : undefined}
569
- style={{
570
- border: `1px solid ${t.primary}20`,
571
- borderRadius: t.cornerRadius,
572
- padding: 16,
573
- opacity: cancelled || requestEnded ? 0.6 : 1,
574
- }}
710
+ data-testid={requestState ? `my-ticket-request-${requestState}` : "my-ticket"}
711
+ style={{ ...row, opacity: cancelled || requestEnded ? 0.6 : 1 }}
575
712
  >
576
- <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12, gap: 12 }}>
713
+ <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8, gap: 12 }}>
577
714
  <div>
578
- <div style={{ fontWeight: 600 }}>{ticket.eventTitle}</div>
579
- <div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(ticket.eventDateTime)}</div>
715
+ <div style={{ fontWeight: 600, fontSize: 16 }}>{ticket.eventTitle}</div>
716
+ <div style={meta}>{formatDate(ticket.eventDateTime)}</div>
580
717
  </div>
581
718
  {requestState && (
582
- <span style={{ fontSize: 13, color: t.primary, textAlign: "right" }}>
719
+ <span style={{ fontSize: 13, fontWeight: 600, textAlign: "right" }}>
583
720
  {requestState === "pending"
584
721
  ? tr("forge.account_dashboard.request_pending")
585
722
  : requestState === "approved"
@@ -592,20 +729,29 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
592
729
  </span>
593
730
  )}
594
731
  {cancelled && !requestState && (
595
- <span style={{ fontSize: 13, color: t.primary }}>{tr("forge.account_dashboard.cancelled")}</span>
732
+ <span style={{ fontSize: 13, color: t.muted }}>{tr("forge.account_dashboard.cancelled")}</span>
596
733
  )}
597
734
  {/* Says WHY this is in the list at all. Without it a
598
735
  transferred ticket looks like a purchase the buyer cannot
599
736
  remember making, with no prices on it. */}
600
737
  {!cancelled && gifted && (
601
- <span style={{ fontSize: 13, color: t.primary }}>
738
+ <span style={{ fontSize: 13, color: t.muted }}>
602
739
  {tr("forge.account_dashboard.ticket_transferred")}
603
740
  </span>
604
741
  )}
605
742
  </div>
606
743
 
607
744
  {ticket.items.map((item) => (
608
- <div key={item.id} style={{ display: "flex", justifyContent: "space-between", fontSize: 14, padding: "4px 0" }}>
745
+ <div
746
+ key={item.id}
747
+ style={{
748
+ display: "flex",
749
+ justifyContent: "space-between",
750
+ fontSize: 14,
751
+ padding: "2px 0",
752
+ color: t.muted,
753
+ }}
754
+ >
609
755
  <span>
610
756
  {tr("forge.account_dashboard.ticket_line", {
611
757
  title: item.ticketTitle ?? tr("forge.account_dashboard.ticket_fallback_title"),
@@ -622,7 +768,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
622
768
  ))}
623
769
 
624
770
  {!gifted && (
625
- <div style={{ display: "flex", justifyContent: "space-between", fontWeight: 700, marginTop: 12, paddingTop: 12, borderTop: `1px solid ${t.primary}20` }}>
771
+ <div style={{ display: "flex", justifyContent: "space-between", fontWeight: 600, marginTop: 6 }}>
626
772
  {/* Nothing has been taken on a pending request, and never
627
773
  will be on one that ended: the label says which. */}
628
774
  <span>
@@ -630,7 +776,9 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
630
776
  ? tr("forge.account_dashboard.total_on_approval")
631
777
  : tr("forge.account_dashboard.total")}
632
778
  </span>
633
- <span style={{ color: t.primary }}>{formatAmount(Number(ticket.totalAmount), ticket.currency || currency)}</span>
779
+ <span style={{ fontVariantNumeric: "tabular-nums" }}>
780
+ {formatAmount(Number(ticket.totalAmount), ticket.currency || currency)}
781
+ </span>
634
782
  </div>
635
783
  )}
636
784
 
@@ -663,7 +811,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
663
811
  for a called-off show, and the component draws nothing. */}
664
812
  {/* Not offered on a request: the fan has not been admitted to
665
813
  anything yet, and a calendar entry says they have. */}
666
- {!isRequestRow && (
814
+ {!isRequestRow && !eventOver && (
667
815
  <AddToCalendar
668
816
  variant="compact"
669
817
  title={ticket.eventTitle}
@@ -686,7 +834,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
686
834
  Eligibility (cancelled order, rotating QR) is the server's
687
835
  call, exactly as it is for cancellation above.
688
836
  */}
689
- <WalletPassButtons passIds={myTicketHeldPassIds(ticket)} variant="compact" style={{ marginTop: 12 }} />
837
+ {!eventOver && <WalletPassButtons passIds={heldPassIds} variant="compact" style={{ marginTop: 12 }} />}
690
838
 
691
839
  {/*
692
840
  Transfer (2.2). The email a transfer sends points at
@@ -707,11 +855,20 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
707
855
  own `pendingTransfer`: which is what lets a sender withdraw
708
856
  from a device that is not the one they sent from.
709
857
  */}
710
- <TicketTransferPanel
711
- passIds={myTicketHeldPassIds(ticket)}
712
- passes={myTicketPasses(ticket)}
713
- style={{ marginTop: 12 }}
714
- />
858
+ {/* Folded behind one toggle: a ticket for four people otherwise
859
+ draws four transfer boxes before anything else on the row. */}
860
+ {!eventOver && heldPassIds.length > 0 && (
861
+ <details style={{ marginTop: 12 }}>
862
+ <summary style={{ cursor: "pointer", fontSize: 14, fontWeight: 600, color: t.text }}>
863
+ {tr("forge.account_dashboard.ticket_transfer_toggle", { count: heldPassIds.length })}
864
+ </summary>
865
+ <TicketTransferPanel
866
+ passIds={heldPassIds}
867
+ passes={myTicketPasses(ticket)}
868
+ style={{ marginTop: 12 }}
869
+ />
870
+ </details>
871
+ )}
715
872
 
716
873
  {/* The terms they agreed to, in the artist's own words where given. */}
717
874
  {ticket.cancellation.description && (
@@ -725,16 +882,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
725
882
  <button
726
883
  onClick={() => void onCancel(ticket)}
727
884
  disabled={pendingId === ticket.id}
728
- style={{
729
- marginTop: 12,
730
- padding: "8px 14px",
731
- borderRadius: t.cornerRadius,
732
- border: `1px solid ${t.primary}40`,
733
- background: "transparent",
734
- color: t.text,
735
- cursor: pendingId === ticket.id ? "wait" : "pointer",
736
- fontWeight: 600,
737
- }}
885
+ style={{ ...ghostButton, marginTop: 12, cursor: pendingId === ticket.id ? "wait" : "pointer" }}
738
886
  >
739
887
  {pendingId === ticket.id
740
888
  ? tr("forge.account_dashboard.cancelling")
@@ -746,8 +894,9 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
746
894
  })}
747
895
  </div>
748
896
  ) : (
749
- <p style={{ fontSize: 14, opacity: 0.7 }}>{tr("forge.account_dashboard.tickets_empty")}</p>
897
+ <EmptySection>{tr("forge.account_dashboard.tickets_empty")}</EmptySection>
750
898
  )}
899
+ <ShowMore hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} onMore={() => void fetchNextPage()} />
751
900
  </div>
752
901
  );
753
902
  }
@@ -770,26 +919,23 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
770
919
  */
771
920
  function WaitlistTab({ ctx }: { ctx: TabContext }) {
772
921
  const { user } = usePublicAuth();
773
- const { t, card } = useCardStyles();
922
+ const { card, list } = useCardStyles();
774
923
  const tr = useForgeT();
775
924
  const { formatDate } = ctx;
776
925
  const { places, isLoading } = useMyWaitlistPlaces(user?.id);
777
926
 
778
927
  return (
779
928
  <div style={card}>
780
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
781
- {tr("forge.account_dashboard.waitlists_title")}
782
- </h2>
783
929
  {isLoading ? (
784
930
  <Loading />
785
931
  ) : places.length > 0 ? (
786
- <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
932
+ <div style={list}>
787
933
  {places.map((place) => (
788
934
  <WaitlistPlaceRow key={place.id} place={place} formatDate={formatDate} />
789
935
  ))}
790
936
  </div>
791
937
  ) : (
792
- <p style={{ fontSize: 14, opacity: 0.7 }}>{tr("forge.account_dashboard.waitlists_empty")}</p>
938
+ <EmptySection>{tr("forge.account_dashboard.waitlists_empty")}</EmptySection>
793
939
  )}
794
940
  </div>
795
941
  );
@@ -802,7 +948,7 @@ function WaitlistPlaceRow({
802
948
  place: WaitlistPlace;
803
949
  formatDate: (value: string | number | Date) => string;
804
950
  }) {
805
- const { t } = useCardStyles();
951
+ const { t, row, meta, ghostButton, alert } = useCardStyles();
806
952
  const tr = useForgeT();
807
953
  const leave = useLeaveEventWaitlist(place.eventId);
808
954
  const [error, setError] = useState<string | null>(null);
@@ -822,19 +968,13 @@ function WaitlistPlaceRow({
822
968
  const deadline = place.offer.claimExpiresAt ? new Date(place.offer.claimExpiresAt) : null;
823
969
 
824
970
  return (
825
- <div
826
- style={{
827
- border: `1px solid ${t.primary}${place.offer.offerOpen ? "80" : "20"}`,
828
- borderRadius: t.cornerRadius,
829
- padding: 16,
830
- }}
831
- >
971
+ <div style={row}>
832
972
  <div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
833
973
  <div>
834
- <div style={{ fontWeight: 600 }}>
974
+ <div style={{ fontWeight: 600, fontSize: 16 }}>
835
975
  {place.event?.title ?? tr("forge.account_dashboard.waitlist_event_fallback")}
836
976
  </div>
837
- <div style={{ fontSize: 13, opacity: 0.7 }}>
977
+ <div style={meta}>
838
978
  {tr(
839
979
  place.quantity === 1
840
980
  ? "forge.account_dashboard.waitlist_ticket_line_one"
@@ -845,21 +985,17 @@ function WaitlistPlaceRow({
845
985
  },
846
986
  )}
847
987
  </div>
848
- {place.event?.dateTime && (
849
- <div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(place.event.dateTime)}</div>
850
- )}
988
+ {place.event?.dateTime && <div style={meta}>{formatDate(place.event.dateTime)}</div>}
851
989
  </div>
852
990
  {place.offer.offerOpen ? (
853
- <span style={{ fontWeight: 700, color: t.primary }}>
854
- {tr("forge.account_dashboard.waitlist_offer_open")}
855
- </span>
991
+ <span style={{ fontWeight: 700, color: t.primary }}>{tr("forge.account_dashboard.waitlist_offer_open")}</span>
856
992
  ) : place.position != null ? (
857
- <span style={{ fontSize: 14, opacity: 0.85 }}>
993
+ <span style={{ fontSize: 14, color: t.muted }}>
858
994
  {tr("forge.account_dashboard.waitlist_position_label")}{" "}
859
- <strong style={{ color: t.primary }}>#{place.position}</strong>
995
+ <strong style={{ color: t.text }}>#{place.position}</strong>
860
996
  </span>
861
997
  ) : (
862
- <span style={{ fontSize: 14, opacity: 0.85 }}>{tr("forge.account_dashboard.waitlist_on_list")}</span>
998
+ <span style={{ fontSize: 14, color: t.muted }}>{tr("forge.account_dashboard.waitlist_on_list")}</span>
863
999
  )}
864
1000
  </div>
865
1001
 
@@ -867,9 +1003,9 @@ function WaitlistPlaceRow({
867
1003
  <div
868
1004
  style={{
869
1005
  marginTop: 12,
870
- padding: "10px 12px",
871
- borderRadius: t.cornerRadius,
872
- border: `1px solid ${t.primary}4d`,
1006
+ padding: "10px 14px",
1007
+ borderLeft: `3px solid ${t.primary}`,
1008
+ background: t.surface,
873
1009
  fontSize: 14,
874
1010
  }}
875
1011
  >
@@ -891,21 +1027,16 @@ function WaitlistPlaceRow({
891
1027
  </div>
892
1028
  )}
893
1029
 
894
- {error && <p style={{ color: "#ef4444", fontSize: 13, marginTop: 12 }}>{error}</p>}
1030
+ {error && (
1031
+ <p role="alert" style={alert}>
1032
+ {error}
1033
+ </p>
1034
+ )}
895
1035
 
896
1036
  <button
897
1037
  onClick={() => void onLeave()}
898
1038
  disabled={leave.isPending}
899
- style={{
900
- marginTop: 12,
901
- padding: "8px 14px",
902
- borderRadius: t.cornerRadius,
903
- border: `1px solid ${t.primary}40`,
904
- background: "transparent",
905
- color: t.text,
906
- cursor: leave.isPending ? "wait" : "pointer",
907
- fontWeight: 600,
908
- }}
1039
+ style={{ ...ghostButton, marginTop: 12, cursor: leave.isPending ? "wait" : "pointer" }}
909
1040
  >
910
1041
  {leave.isPending
911
1042
  ? tr("forge.account_dashboard.waitlist_leaving")
@@ -940,28 +1071,26 @@ function WaitlistPlaceRow({
940
1071
  */
941
1072
  function BookingsTab({ ctx }: { ctx: TabContext }) {
942
1073
  const { user } = usePublicAuth();
943
- const { t, card } = useCardStyles();
1074
+ const { card, list } = useCardStyles();
944
1075
  const tr = useForgeT();
945
- const { data, isLoading } = useMyBookings(user?.id);
1076
+ const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = useMyBookingsPaged(user?.id);
946
1077
 
947
- const bookings = data?.data ?? [];
1078
+ const bookings = data?.pages.flatMap((page) => page.data) ?? [];
948
1079
 
949
1080
  return (
950
1081
  <div style={card}>
951
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
952
- {tr("forge.account_dashboard.sessions_title")}
953
- </h2>
954
1082
  {isLoading ? (
955
1083
  <Loading />
956
1084
  ) : bookings.length > 0 ? (
957
- <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
1085
+ <div style={list}>
958
1086
  {bookings.map((booking) => (
959
1087
  <BookingRow key={booking.id} booking={booking} ctx={ctx} />
960
1088
  ))}
961
1089
  </div>
962
1090
  ) : (
963
- <p style={{ fontSize: 14, opacity: 0.7 }}>{tr("forge.account_dashboard.sessions_empty")}</p>
1091
+ <EmptySection>{tr("forge.account_dashboard.sessions_empty")}</EmptySection>
964
1092
  )}
1093
+ <ShowMore hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} onMore={() => void fetchNextPage()} />
965
1094
  </div>
966
1095
  );
967
1096
  }
@@ -1005,20 +1134,14 @@ function formatSessionWindow(booking: MyBooking, tr: ForgeT): string {
1005
1134
  * apply here: which is the same discipline `canReschedule` follows two blocks
1006
1135
  * down.
1007
1136
  */
1008
- function BookingLocationLine({
1009
- location,
1010
- primary,
1011
- }: {
1012
- location: MyBooking["location"];
1013
- primary: string;
1014
- }) {
1137
+ function BookingLocationLine({ location, primary }: { location: MyBooking["location"]; primary: string }) {
1015
1138
  const tr = useForgeT();
1016
1139
  if (!location) return null;
1017
1140
 
1018
1141
  if (location.type === "online") {
1019
1142
  return (
1020
1143
  <div style={{ fontSize: 13, marginTop: 4 }}>
1021
- <span style={{ opacity: 0.7 }}>{tr("forge.account_dashboard.booking_join_label")}{" "}</span>
1144
+ <span style={{ opacity: 0.7 }}>{tr("forge.account_dashboard.booking_join_label")} </span>
1022
1145
  <a
1023
1146
  href={location.url}
1024
1147
  target="_blank"
@@ -1043,7 +1166,7 @@ function BookingLocationLine({
1043
1166
  if (location.type === "video") {
1044
1167
  return (
1045
1168
  <div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
1046
- <span style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.booking_join_label")}{" "}</span>
1169
+ <span style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.booking_join_label")} </span>
1047
1170
  {tr("forge.account_dashboard.booking_video_call")}
1048
1171
  </div>
1049
1172
  );
@@ -1051,30 +1174,29 @@ function BookingLocationLine({
1051
1174
 
1052
1175
  return (
1053
1176
  <div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
1054
- <span style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.booking_where_label")}{" "}</span>
1177
+ <span style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.booking_where_label")} </span>
1055
1178
  {location.address}
1056
1179
  </div>
1057
1180
  );
1058
1181
  }
1059
1182
 
1060
1183
  function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
1061
- const { t } = useCardStyles();
1184
+ const { t, row, meta, ghostButton, alert } = useCardStyles();
1062
1185
  const tr = useForgeT();
1063
1186
  const { formatAmount, currency } = ctx;
1064
1187
  const cancelBooking = useCancelMyBooking();
1065
1188
  const [picking, setPicking] = useState(false);
1066
1189
  const [error, setError] = useState<string | null>(null);
1067
1190
  const cancelled = booking.status === "canceled" || !!booking.selfCancelledAt;
1191
+ // A session that is over keeps its record, not its way in: no calendar
1192
+ // entry and no "the room has closed" line on something that already happened.
1193
+ const endedAt = new Date(booking.sessionEndTime).getTime();
1194
+ const sessionOver = endedAt < Date.now();
1195
+ // "The room has closed" still helps the day someone missed it; after that it
1196
+ // is noise on a list of history.
1197
+ const callNoteExpired = endedAt < Date.now() - 24 * 3600_000;
1068
1198
 
1069
- const actionButton: React.CSSProperties = {
1070
- padding: "8px 14px",
1071
- borderRadius: t.cornerRadius,
1072
- border: `1px solid ${t.primary}40`,
1073
- background: "transparent",
1074
- color: t.text,
1075
- cursor: "pointer",
1076
- fontWeight: 600,
1077
- };
1199
+ const actionButton = ghostButton;
1078
1200
 
1079
1201
  const onCancel = async () => {
1080
1202
  setError(null);
@@ -1092,31 +1214,22 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
1092
1214
  };
1093
1215
 
1094
1216
  return (
1095
- <div
1096
- style={{
1097
- border: `1px solid ${t.primary}20`,
1098
- borderRadius: t.cornerRadius,
1099
- padding: 16,
1100
- opacity: cancelled ? 0.6 : 1,
1101
- }}
1102
- >
1217
+ <div data-testid="my-booking" style={{ ...row, opacity: cancelled ? 0.6 : 1 }}>
1103
1218
  <div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
1104
1219
  <div>
1105
- <div style={{ fontWeight: 600 }}>{booking.coachingProductTitle}</div>
1106
- <div style={{ fontSize: 13, opacity: 0.7 }}>{formatSessionWindow(booking, tr)}</div>
1220
+ <div style={{ fontWeight: 600, fontSize: 16 }}>{booking.coachingProductTitle}</div>
1221
+ <div style={meta}>{formatSessionWindow(booking, tr)}</div>
1107
1222
  {booking.coachingProductDurationMinutes != null && (
1108
- <div style={{ fontSize: 13, opacity: 0.7 }}>
1223
+ <div style={meta}>
1109
1224
  {tr("forge.account_dashboard.booking_duration", { minutes: booking.coachingProductDurationMinutes })}
1110
1225
  </div>
1111
1226
  )}
1112
1227
  <BookingLocationLine location={booking.location} primary={t.primary} />
1113
1228
  </div>
1114
1229
  <div style={{ textAlign: "right" }}>
1115
- {cancelled && (
1116
- <div style={{ fontSize: 13, color: t.primary }}>{tr("forge.account_dashboard.cancelled")}</div>
1117
- )}
1230
+ {cancelled && <div style={{ fontSize: 13, color: t.muted }}>{tr("forge.account_dashboard.cancelled")}</div>}
1118
1231
  {booking.totalAmount != null && (
1119
- <div style={{ fontWeight: 700, color: t.primary }}>
1232
+ <div style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
1120
1233
  {formatAmount(booking.totalAmount, booking.currency || currency)}
1121
1234
  </div>
1122
1235
  )}
@@ -1125,16 +1238,18 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
1125
1238
 
1126
1239
  {/* Diarise it: including Apple, which needs the platform's own `.ics`.
1127
1240
  Null unless the session is confirmed, and the component draws nothing. */}
1128
- <AddToCalendar
1129
- variant="compact"
1130
- title={booking.coachingProductTitle}
1131
- start={booking.sessionStartTime}
1132
- end={booking.sessionEndTime}
1133
- defaultDurationMinutes={booking.coachingProductDurationMinutes ?? undefined}
1134
- icsUrl={booking.calendarUrl}
1135
- webcalUrl={booking.calendarWebcalUrl}
1136
- style={{ marginTop: 12 }}
1137
- />
1241
+ {!sessionOver && (
1242
+ <AddToCalendar
1243
+ variant="compact"
1244
+ title={booking.coachingProductTitle}
1245
+ start={booking.sessionStartTime}
1246
+ end={booking.sessionEndTime}
1247
+ defaultDurationMinutes={booking.coachingProductDurationMinutes ?? undefined}
1248
+ icsUrl={booking.calendarUrl}
1249
+ webcalUrl={booking.calendarWebcalUrl}
1250
+ style={{ marginTop: 12 }}
1251
+ />
1252
+ )}
1138
1253
 
1139
1254
  {/* The terms they booked under, in the artist's own words where given. */}
1140
1255
  {booking.cancellation.description && (
@@ -1145,9 +1260,11 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
1145
1260
  )}
1146
1261
  {/* A reschedule rule of its own. When it follows the cancellation window
1147
1262
  the sentence above already says it, so nothing is repeated. */}
1148
- {booking.reschedulePolicy && !booking.reschedulePolicy.followsCancellation && booking.reschedulePolicy.description && (
1149
- <div style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>{booking.reschedulePolicy.description}</div>
1150
- )}
1263
+ {booking.reschedulePolicy &&
1264
+ !booking.reschedulePolicy.followsCancellation &&
1265
+ booking.reschedulePolicy.description && (
1266
+ <div style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>{booking.reschedulePolicy.description}</div>
1267
+ )}
1151
1268
 
1152
1269
  {/*
1153
1270
  The action row. Both controls are drawn from the SERVER's flags:
@@ -1155,7 +1272,7 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
1155
1272
  re-derived in the browser, which would give two answers to one question.
1156
1273
  */}
1157
1274
  {error && (
1158
- <p style={{ color: "#ef4444", fontSize: 13, marginTop: 12 }} role="alert">
1275
+ <p style={alert} role="alert">
1159
1276
  {error}
1160
1277
  </p>
1161
1278
  )}
@@ -1183,7 +1300,7 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
1183
1300
 
1184
1301
  {/* The way INTO a video session: a Join control while the room is open,
1185
1302
  the call itself once pressed. Nothing for any other kind of session. */}
1186
- {!cancelled && <BookingCallEntry booking={booking} />}
1303
+ {!cancelled && !callNoteExpired && <BookingCallEntry booking={booking} />}
1187
1304
  </div>
1188
1305
  );
1189
1306
  }
@@ -1322,7 +1439,15 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
1322
1439
  const open = (slots ?? []).filter((slot) => slot.id !== booking.coachingBookingSlotId);
1323
1440
 
1324
1441
  return (
1325
- <div style={{ marginTop: 12, padding: 12, borderRadius: t.cornerRadius, border: `1px solid ${t.primary}4d` }}>
1442
+ <div
1443
+ style={{
1444
+ marginTop: 12,
1445
+ padding: 16,
1446
+ borderRadius: t.cornerRadius,
1447
+ border: `1px solid ${t.border}`,
1448
+ background: t.surface,
1449
+ }}
1450
+ >
1326
1451
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12 }}>
1327
1452
  <strong style={{ fontSize: 14 }}>{tr("forge.account_dashboard.reschedule_title")}</strong>
1328
1453
  <button
@@ -1330,7 +1455,7 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
1330
1455
  style={{
1331
1456
  padding: "4px 10px",
1332
1457
  borderRadius: t.cornerRadius,
1333
- border: `1px solid ${t.primary}40`,
1458
+ border: `1px solid ${t.border}`,
1334
1459
  background: "transparent",
1335
1460
  color: t.text,
1336
1461
  cursor: "pointer",
@@ -1342,7 +1467,7 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
1342
1467
  </div>
1343
1468
 
1344
1469
  {error && (
1345
- <p style={{ color: "#ef4444", fontSize: 13, marginTop: 10 }} role="alert">
1470
+ <p style={{ fontSize: 14, fontWeight: 600, marginTop: 10 }} role="alert">
1346
1471
  {error}
1347
1472
  </p>
1348
1473
  )}
@@ -1362,8 +1487,8 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
1362
1487
  style={{
1363
1488
  padding: "8px 12px",
1364
1489
  borderRadius: t.cornerRadius,
1365
- border: `1px solid ${t.primary}40`,
1366
- background: "transparent",
1490
+ border: `1px solid ${t.border}`,
1491
+ background: t.background,
1367
1492
  color: t.text,
1368
1493
  cursor: reschedule.isPending ? "wait" : "pointer",
1369
1494
  fontSize: 13,
@@ -1384,9 +1509,7 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
1384
1509
  })}
1385
1510
  </div>
1386
1511
  ) : (
1387
- <p style={{ fontSize: 13, opacity: 0.7, marginTop: 12 }}>
1388
- {tr("forge.account_dashboard.reschedule_empty")}
1389
- </p>
1512
+ <p style={{ fontSize: 13, opacity: 0.7, marginTop: 12 }}>{tr("forge.account_dashboard.reschedule_empty")}</p>
1390
1513
  )}
1391
1514
  </div>
1392
1515
  );
@@ -1408,15 +1531,10 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
1408
1531
  * That decision lives in one place, `FilmRentalList`, and both surfaces read it.
1409
1532
  */
1410
1533
  function RentalsTab({ ctx }: { ctx: TabContext }) {
1411
- const { t, card } = useCardStyles();
1412
- const tr = useForgeT();
1413
-
1534
+ const { card } = useCardStyles();
1414
1535
  return (
1415
1536
  <div style={card}>
1416
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
1417
- {tr("forge.account_dashboard.rentals_title")}
1418
- </h2>
1419
- <FilmRentalList navigate={ctx.onNavigateFilm} />
1537
+ <FilmRentalList navigate={ctx.onNavigateFilm} layout="rows" />
1420
1538
  </div>
1421
1539
  );
1422
1540
  }
@@ -1432,46 +1550,50 @@ function RentalsTab({ ctx }: { ctx: TabContext }) {
1432
1550
  * the question this whole tab exists to stop someone asking.
1433
1551
  */
1434
1552
  function CoursesTab({ ctx, courses }: { ctx: TabContext; courses: MyCourse[] }) {
1435
- const { t, card } = useCardStyles();
1553
+ const { card, list, row, meta, button, ghostButton } = useCardStyles();
1436
1554
  const tr = useForgeT();
1437
1555
 
1438
1556
  return (
1439
1557
  <div style={card}>
1440
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
1441
- {tr("forge.account_dashboard.courses_title")}
1442
- </h2>
1443
-
1444
1558
  {courses.length === 0 ? (
1445
- <p style={{ color: t.muted }}>{tr("forge.account_dashboard.courses_empty")}</p>
1559
+ <EmptySection>{tr("forge.account_dashboard.courses_empty")}</EmptySection>
1446
1560
  ) : (
1447
- <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
1561
+ <div style={list}>
1448
1562
  {courses.map((course) => (
1449
- <button
1563
+ <div
1450
1564
  key={course.accessId}
1451
- type="button"
1452
- onClick={() => ctx.onNavigateCourse?.(course)}
1453
- style={{
1454
- textAlign: "left",
1455
- background: t.surface,
1456
- border: `1px solid ${t.border}`,
1457
- borderRadius: t.cornerRadius,
1458
- padding: 16,
1459
- cursor: ctx.onNavigateCourse ? "pointer" : "default",
1460
- color: t.text,
1461
- }}
1565
+ data-testid="my-course"
1566
+ style={{ ...row, display: "flex", alignItems: "center", gap: 16, flexWrap: "wrap" }}
1462
1567
  >
1463
- <div style={{ fontWeight: 600 }}>{course.title}</div>
1464
- {/* No description. It is rich text: as a string it shows its own
1465
- markup, as HTML it drags a sales page's formatting into a list
1466
- row. The row carries the title and the state instead. */}
1467
- <div style={{ color: t.muted, fontSize: 12, marginTop: 8 }}>
1468
- {course.completedAt
1469
- ? tr("forge.account_dashboard.courses_completed")
1470
- : course.currentLessonId
1471
- ? tr("forge.account_dashboard.courses_continue")
1472
- : tr("forge.account_dashboard.courses_start")}
1568
+ <div style={{ flex: "1 1 220px", minWidth: 0 }}>
1569
+ <div style={{ fontWeight: 600, fontSize: 16 }}>{course.title}</div>
1570
+ {/* No description. It is rich text: as a string it shows its own
1571
+ markup, as HTML it drags a sales page's formatting into a list
1572
+ row. The row carries the title and the state instead. */}
1573
+ <div style={meta}>
1574
+ {course.completedAt
1575
+ ? tr("forge.account_dashboard.courses_completed")
1576
+ : course.currentLessonId
1577
+ ? tr("forge.account_dashboard.courses_continue")
1578
+ : tr("forge.account_dashboard.courses_start")}
1579
+ </div>
1473
1580
  </div>
1474
- </button>
1581
+ {ctx.onNavigateCourse && (
1582
+ <button
1583
+ type="button"
1584
+ onClick={() => ctx.onNavigateCourse?.(course)}
1585
+ // Finished courses are history, so only an unfinished one
1586
+ // gets the filled button.
1587
+ style={course.completedAt ? ghostButton : button}
1588
+ >
1589
+ {course.completedAt
1590
+ ? tr("forge.account_dashboard.course_open_review")
1591
+ : course.currentLessonId
1592
+ ? tr("forge.account_dashboard.course_open_continue")
1593
+ : tr("forge.account_dashboard.course_open_start")}
1594
+ </button>
1595
+ )}
1596
+ </div>
1475
1597
  ))}
1476
1598
  </div>
1477
1599
  )}
@@ -1483,38 +1605,61 @@ function CoursesTab({ ctx, courses }: { ctx: TabContext; courses: MyCourse[] })
1483
1605
 
1484
1606
  function OrdersTab({ ctx }: { ctx: TabContext }) {
1485
1607
  const { user } = usePublicAuth();
1486
- const { t, card } = useCardStyles();
1608
+ const { t, card, list, row, meta } = useCardStyles();
1487
1609
  const tr = useForgeT();
1488
1610
  const { formatAmount, formatDate, currency } = ctx;
1489
- const { data: orders, isLoading } = useUserOrders(user?.id);
1611
+ const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = useUserOrdersPaged(user?.id);
1612
+ const orders = data?.pages.flat();
1490
1613
 
1491
1614
  const formatStatus = (s: OrderStatus) => s.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
1492
1615
 
1493
1616
  return (
1494
1617
  <div style={card}>
1495
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
1496
- {tr("forge.account_dashboard.orders_title")}
1497
- </h2>
1498
1618
  {isLoading ? (
1499
1619
  <Loading />
1500
1620
  ) : orders && orders.length > 0 ? (
1501
- <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
1621
+ <div style={list}>
1502
1622
  {orders
1503
1623
  .filter((o) => ![OrderStatus.InitiatedPayment, OrderStatus.PaymentFailed].includes(o.status))
1504
1624
  .map((order) => (
1505
- <div key={order.id} style={{ border: `1px solid ${t.primary}20`, borderRadius: t.cornerRadius, padding: 16 }}>
1506
- <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12 }}>
1625
+ <div key={order.id} data-testid="my-order" style={row}>
1626
+ <div
1627
+ style={{
1628
+ display: "flex",
1629
+ justifyContent: "space-between",
1630
+ alignItems: "flex-start",
1631
+ gap: 12,
1632
+ marginBottom: 8,
1633
+ }}
1634
+ >
1507
1635
  <div>
1508
- <div style={{ fontWeight: 600 }}>
1509
- {tr("forge.account_dashboard.order_number", { order_id: order.id })}
1636
+ {/* The first block of the id is enough to tell orders apart
1637
+ and to quote in an email; the whole uuid is noise. */}
1638
+ <div style={{ fontWeight: 600, fontSize: 16 }} title={order.id}>
1639
+ {tr("forge.account_dashboard.order_number", { order_id: order.id.slice(0, 8).toUpperCase() })}
1640
+ </div>
1641
+ <div className="fg:flex fg:flex-wrap fg:gap-x-3" style={meta}>
1642
+ <span>{formatDate(order.createdAt)}</span>
1643
+ <span>{formatStatus(order.status)}</span>
1510
1644
  </div>
1511
- <div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(order.createdAt)}</div>
1512
1645
  </div>
1513
- <span style={{ fontSize: 13, color: t.primary }}>{formatStatus(order.status)}</span>
1646
+ <span style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
1647
+ {formatAmount(order.totalAmount, order.currency || currency)}
1648
+ </span>
1514
1649
  </div>
1515
1650
  {order.deliveryGroups.map((group) =>
1516
1651
  group.items.map((item, i) => (
1517
- <div key={`${item.productVariantId}-${i}`} style={{ display: "flex", justifyContent: "space-between", fontSize: 14, padding: "4px 0" }}>
1652
+ <div
1653
+ key={`${item.productVariantId}-${i}`}
1654
+ style={{
1655
+ display: "flex",
1656
+ justifyContent: "space-between",
1657
+ gap: 12,
1658
+ fontSize: 14,
1659
+ padding: "2px 0",
1660
+ color: t.muted,
1661
+ }}
1662
+ >
1518
1663
  <span>
1519
1664
  {tr("forge.account_dashboard.order_line", { title: item.title, quantity: item.quantity })}
1520
1665
  {/* Which version. Two versions of one release carry the
@@ -1524,20 +1669,19 @@ function OrdersTab({ ctx }: { ctx: TabContext }) {
1524
1669
  the sale, not re-resolved. */}
1525
1670
  <CartLineOptions item={item} accent={t.primary} size="small" style={{ marginTop: 2 }} />
1526
1671
  </span>
1527
- <span>{formatAmount(item.price * item.quantity, order.currency || currency)}</span>
1672
+ <span style={{ fontVariantNumeric: "tabular-nums" }}>
1673
+ {formatAmount(item.price * item.quantity, order.currency || currency)}
1674
+ </span>
1528
1675
  </div>
1529
1676
  )),
1530
1677
  )}
1531
- <div style={{ display: "flex", justifyContent: "space-between", fontWeight: 700, marginTop: 12, paddingTop: 12, borderTop: `1px solid ${t.primary}20` }}>
1532
- <span>{tr("forge.account_dashboard.total")}</span>
1533
- <span style={{ color: t.primary }}>{formatAmount(order.totalAmount, order.currency || currency)}</span>
1534
- </div>
1535
1678
  </div>
1536
1679
  ))}
1537
1680
  </div>
1538
1681
  ) : (
1539
- <p style={{ opacity: 0.8 }}>{tr("forge.account_dashboard.orders_empty")}</p>
1682
+ <EmptySection>{tr("forge.account_dashboard.orders_empty")}</EmptySection>
1540
1683
  )}
1684
+ <ShowMore hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} onMore={() => void fetchNextPage()} />
1541
1685
  </div>
1542
1686
  );
1543
1687
  }
@@ -1546,31 +1690,28 @@ function OrdersTab({ ctx }: { ctx: TabContext }) {
1546
1690
 
1547
1691
  function SavedTab() {
1548
1692
  const { user } = usePublicAuth();
1549
- const { t, card } = useCardStyles();
1693
+ const { card, list, row, meta } = useCardStyles();
1550
1694
  const tr = useForgeT();
1551
1695
  const { data, isLoading } = useSavedPosts(user?.id);
1552
1696
  const posts = data?.data;
1553
1697
 
1554
1698
  return (
1555
1699
  <div style={card}>
1556
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
1557
- {tr("forge.account_dashboard.saved_title")}
1558
- </h2>
1559
1700
  {isLoading ? (
1560
1701
  <Loading />
1561
1702
  ) : posts && posts.length > 0 ? (
1562
- <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 16 }}>
1703
+ <div style={list}>
1563
1704
  {posts.map((post) => (
1564
- <div key={post.id} style={{ border: `1px solid ${t.primary}20`, borderRadius: t.cornerRadius, padding: 16 }}>
1565
- <p style={{ fontSize: 14, marginBottom: 8 }}>{post.caption}</p>
1566
- <p style={{ fontSize: 12, opacity: 0.6 }}>
1705
+ <div key={post.id} style={row}>
1706
+ <p style={{ fontSize: 15, margin: "0 0 4px" }}>{post.caption}</p>
1707
+ <p style={{ ...meta, margin: 0 }}>
1567
1708
  {tr("forge.account_dashboard.saved_post_stats", { likes: post.likes, comments: post.comments })}
1568
1709
  </p>
1569
1710
  </div>
1570
1711
  ))}
1571
1712
  </div>
1572
1713
  ) : (
1573
- <p style={{ opacity: 0.8 }}>{tr("forge.account_dashboard.saved_empty")}</p>
1714
+ <EmptySection>{tr("forge.account_dashboard.saved_empty")}</EmptySection>
1574
1715
  )}
1575
1716
  </div>
1576
1717
  );
@@ -1580,7 +1721,7 @@ function SavedTab() {
1580
1721
 
1581
1722
  function AccountTab({ ctx }: { ctx: TabContext }) {
1582
1723
  const { user, refetchUser, logout } = usePublicAuth();
1583
- const { t, card, button, ghostButton, input } = useCardStyles();
1724
+ const { t, button, ghostButton, input, meta } = useCardStyles();
1584
1725
  const tr = useForgeT();
1585
1726
  const { formatDate } = ctx;
1586
1727
 
@@ -1643,15 +1784,23 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
1643
1784
  }
1644
1785
  };
1645
1786
 
1646
- const sectionTitle: React.CSSProperties = { fontSize: 16, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily };
1647
- const label: React.CSSProperties = { fontSize: 14, display: "block", marginBottom: 6 };
1787
+ // Each part of the page is a titled block divided by a hairline: the
1788
+ // portal's sections, not four boxes stacked on top of each other.
1789
+ const card: React.CSSProperties = { padding: "28px 0", borderBottom: `1px solid ${t.border}` };
1790
+ const sectionTitle: React.CSSProperties = {
1791
+ fontSize: 18,
1792
+ fontWeight: 600,
1793
+ margin: "0 0 16px",
1794
+ fontFamily: t.headingFontFamily,
1795
+ };
1796
+ const label: React.CSSProperties = { fontSize: 14, fontWeight: 600, display: "block", marginBottom: 6 };
1648
1797
 
1649
1798
  return (
1650
- <div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
1799
+ <div style={{ display: "flex", flexDirection: "column", borderTop: `1px solid ${t.border}`, color: t.text }}>
1651
1800
  {/* Profile */}
1652
1801
  <form onSubmit={onUpdateProfile} style={card}>
1653
1802
  <h2 style={sectionTitle}>{tr("forge.account_dashboard.profile_section_title")}</h2>
1654
- <div style={{ display: "flex", gap: 12, marginBottom: 12 }}>
1803
+ <div className="fg:flex fg:flex-col fg:gap-3 fg:sm:flex-row" style={{ marginBottom: 12 }}>
1655
1804
  <div style={{ flex: 1 }}>
1656
1805
  <label style={label}>{tr("forge.account_dashboard.first_name")}</label>
1657
1806
  <input value={firstName} onChange={(e) => setFirstName(e.target.value)} style={input} />
@@ -1661,7 +1810,7 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
1661
1810
  <input value={lastName} onChange={(e) => setLastName(e.target.value)} style={input} />
1662
1811
  </div>
1663
1812
  </div>
1664
- <p style={{ fontSize: 13, opacity: 0.7, marginBottom: 12 }}>
1813
+ <p style={{ ...meta, margin: "0 0 16px" }}>
1665
1814
  {tr("forge.account_dashboard.email_readonly", { email: user?.email ?? "" })}
1666
1815
  </p>
1667
1816
  {profileMsg && <p style={{ fontSize: 13, marginBottom: 12 }}>{profileMsg}</p>}
@@ -1673,20 +1822,39 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
1673
1822
  {/* Password */}
1674
1823
  <form onSubmit={onChangePassword} style={card}>
1675
1824
  <h2 style={sectionTitle}>{tr("forge.account_dashboard.password_section_title")}</h2>
1676
- {passwordMsg && <p style={{ color: "#ef4444", fontSize: 13, marginBottom: 12 }}>{passwordMsg}</p>}
1825
+ {passwordMsg && (
1826
+ <p role="alert" style={{ fontSize: 14, fontWeight: 600, margin: "0 0 12px" }}>
1827
+ {passwordMsg}
1828
+ </p>
1829
+ )}
1677
1830
  <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
1678
1831
  <div>
1679
1832
  <label style={label}>{tr("forge.account_dashboard.current_password")}</label>
1680
- <input type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} style={input} />
1833
+ <input
1834
+ type="password"
1835
+ value={currentPassword}
1836
+ onChange={(e) => setCurrentPassword(e.target.value)}
1837
+ style={input}
1838
+ />
1681
1839
  </div>
1682
- <div style={{ display: "flex", gap: 12 }}>
1840
+ <div className="fg:flex fg:flex-col fg:gap-3 fg:sm:flex-row">
1683
1841
  <div style={{ flex: 1 }}>
1684
1842
  <label style={label}>{tr("forge.account_dashboard.new_password")}</label>
1685
- <input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} style={input} />
1843
+ <input
1844
+ type="password"
1845
+ value={newPassword}
1846
+ onChange={(e) => setNewPassword(e.target.value)}
1847
+ style={input}
1848
+ />
1686
1849
  </div>
1687
1850
  <div style={{ flex: 1 }}>
1688
1851
  <label style={label}>{tr("forge.account_dashboard.confirm_password")}</label>
1689
- <input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} style={input} />
1852
+ <input
1853
+ type="password"
1854
+ value={confirmPassword}
1855
+ onChange={(e) => setConfirmPassword(e.target.value)}
1856
+ style={input}
1857
+ />
1690
1858
  </div>
1691
1859
  </div>
1692
1860
  </div>
@@ -1698,17 +1866,15 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
1698
1866
  {/* Data rights */}
1699
1867
  <div style={card}>
1700
1868
  <h2 style={sectionTitle}>{tr("forge.account_dashboard.data_section_title")}</h2>
1701
- <p style={{ fontSize: 13, opacity: 0.7, marginBottom: 12 }}>
1702
- {tr("forge.account_dashboard.data_export_description")}
1703
- </p>
1869
+ <p style={{ ...meta, margin: "0 0 12px" }}>{tr("forge.account_dashboard.data_export_description")}</p>
1704
1870
  <button onClick={onExport} style={ghostButton}>
1705
1871
  {tr("forge.account_dashboard.export_data")}
1706
1872
  </button>
1707
1873
  </div>
1708
1874
 
1709
1875
  {/* Delete account */}
1710
- <div style={{ ...card, borderColor: "#ef444533" }}>
1711
- <h2 style={{ ...sectionTitle, color: "#ef4444" }}>{tr("forge.account_dashboard.delete_section_title")}</h2>
1876
+ <div style={{ ...card, borderBottom: "none" }}>
1877
+ <h2 style={sectionTitle}>{tr("forge.account_dashboard.delete_section_title")}</h2>
1712
1878
  {scheduledAt ? (
1713
1879
  <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
1714
1880
  <p style={{ fontSize: 14 }}>
@@ -1722,7 +1888,7 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
1722
1888
  <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
1723
1889
  <p style={{ fontSize: 14 }}>{tr("forge.account_dashboard.deletion_confirm_body")}</p>
1724
1890
  <div style={{ display: "flex", gap: 12 }}>
1725
- <button onClick={() => requestDeletion.mutate()} style={{ ...button, background: "#ef4444" }}>
1891
+ <button onClick={() => requestDeletion.mutate()} style={button}>
1726
1892
  {tr("forge.account_dashboard.deletion_confirm")}
1727
1893
  </button>
1728
1894
  <button onClick={() => setConfirmDelete(false)} style={ghostButton}>
@@ -1731,10 +1897,7 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
1731
1897
  </div>
1732
1898
  </div>
1733
1899
  ) : (
1734
- <button
1735
- onClick={() => setConfirmDelete(true)}
1736
- style={{ padding: "8px 14px", borderRadius: t.cornerRadius, background: "#ef444411", color: "#ef4444", border: "1px solid #ef444433", cursor: "pointer" }}
1737
- >
1900
+ <button onClick={() => setConfirmDelete(true)} style={ghostButton}>
1738
1901
  {tr("forge.account_dashboard.request_deletion")}
1739
1902
  </button>
1740
1903
  )}
@@ -1745,85 +1908,282 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
1745
1908
 
1746
1909
  // ---- Notifications tab ------------------------------------------------------
1747
1910
 
1911
+ /**
1912
+ * Which notifications reach this person, and on which channel.
1913
+ *
1914
+ * The server keeps a matrix of notification type by channel (in-app, push,
1915
+ * email) with defaults per type (`services/notification/matrix.ts`). This draws
1916
+ * it as a few plain groups rather than a grid, and offers only the channels a
1917
+ * type can actually use: a chat message never writes an in-app row, and a
1918
+ * reminder or "live now" never emails, so those switches are not drawn rather
1919
+ * than drawn dead.
1920
+ *
1921
+ * Work notifications (task comments, files a client uploads) go to the
1922
+ * creator's team, never to a client, so they have no place here.
1923
+ */
1924
+ const NOTIFICATION_GROUPS: Array<{ titleKey: string; types: Array<{ type: string; labelKey: string }> }> = [
1925
+ {
1926
+ titleKey: "forge.account_dashboard.notify_group_messages",
1927
+ types: [
1928
+ { type: "dm", labelKey: "forge.account_dashboard.notify_type_dm" },
1929
+ { type: "mention", labelKey: "forge.account_dashboard.notify_type_mention" },
1930
+ { type: "reply", labelKey: "forge.account_dashboard.notify_type_reply" },
1931
+ { type: "chat_message", labelKey: "forge.account_dashboard.notify_type_chat_message" },
1932
+ // Replies from the creator's team in the portal's Messages. Drawn once the
1933
+ // server's matrix knows the type (rows without defaults are skipped).
1934
+ { type: "client_message", labelKey: "forge.account_dashboard.notify_type_client_message" },
1935
+ ],
1936
+ },
1937
+ {
1938
+ titleKey: "forge.account_dashboard.notify_group_community",
1939
+ types: [
1940
+ { type: "new_post", labelKey: "forge.account_dashboard.notify_type_new_post" },
1941
+ { type: "new_community_post", labelKey: "forge.account_dashboard.notify_type_new_community_post" },
1942
+ { type: "reaction", labelKey: "forge.account_dashboard.notify_type_reaction" },
1943
+ { type: "mod", labelKey: "forge.account_dashboard.notify_type_mod" },
1944
+ ],
1945
+ },
1946
+ {
1947
+ titleKey: "forge.account_dashboard.notify_group_events",
1948
+ types: [
1949
+ { type: "event_reminder", labelKey: "forge.account_dashboard.notify_type_event_reminder" },
1950
+ { type: "stream_live", labelKey: "forge.account_dashboard.notify_type_stream_live" },
1951
+ ],
1952
+ },
1953
+ ];
1954
+
1955
+ const NOTIFICATION_CHANNELS: Array<{ channel: NotificationChannel; labelKey: string }> = [
1956
+ { channel: "inapp", labelKey: "forge.account_dashboard.notify_channel_inapp" },
1957
+ { channel: "push", labelKey: "forge.account_dashboard.notify_channel_push" },
1958
+ { channel: "email", labelKey: "forge.account_dashboard.notify_channel_email" },
1959
+ ];
1960
+
1961
+ const DIGEST_OPTIONS: Array<{ value: DigestCadence; labelKey: string }> = [
1962
+ { value: "daily", labelKey: "forge.account_dashboard.notify_digest_daily" },
1963
+ { value: "hourly", labelKey: "forge.account_dashboard.notify_digest_hourly" },
1964
+ { value: "inapp_only", labelKey: "forge.account_dashboard.notify_digest_inapp_only" },
1965
+ { value: "off", labelKey: "forge.account_dashboard.notify_digest_off" },
1966
+ ];
1967
+
1968
+ /** Whether a channel means anything for a type, from the server's own defaults. */
1969
+ function channelApplies(
1970
+ defaults: NotificationMatrix["defaults"] | undefined,
1971
+ type: string,
1972
+ channel: NotificationChannel,
1973
+ ): boolean {
1974
+ const def = defaults?.[type];
1975
+ if (!def) return false;
1976
+ if (channel === "inapp") return def.inapp;
1977
+ if (channel === "email") return def.emailMode !== "none";
1978
+ return true;
1979
+ }
1980
+
1981
+ /**
1982
+ * Push on THIS device: the switch that makes every "Push" choice below mean
1983
+ * anything. A row in the panel's own style rather than `PushOptIn`'s box, and
1984
+ * first, because a member who has not allowed push here receives none of it.
1985
+ * Draws nothing where the browser cannot do push at all.
1986
+ */
1987
+ function PushDeviceRow() {
1988
+ const { t, row, meta, button, ghostButton } = useCardStyles();
1989
+ const tr = useForgeT();
1990
+ const { profileId } = useForge();
1991
+ const { isSupported, isSubscribed, isLoading, subscribe, unsubscribe } = usePushSubscription(profileId ?? "");
1992
+ if (!isSupported || !profileId) return null;
1993
+ return (
1994
+ <section style={{ marginBottom: 36 }} data-testid="notify-push-device">
1995
+ <div
1996
+ style={{
1997
+ ...row,
1998
+ borderTop: `1px solid ${t.border}`,
1999
+ display: "flex",
2000
+ alignItems: "center",
2001
+ gap: 16,
2002
+ flexWrap: "wrap",
2003
+ }}
2004
+ >
2005
+ <div style={{ flex: "1 1 220px" }}>
2006
+ <div style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.notify_push_device_title")}</div>
2007
+ <div style={meta}>
2008
+ {isSubscribed
2009
+ ? tr("forge.account_dashboard.notify_push_device_on")
2010
+ : tr("forge.account_dashboard.notify_push_device_off")}
2011
+ </div>
2012
+ </div>
2013
+ {isSubscribed ? (
2014
+ <button type="button" style={ghostButton} disabled={isLoading} onClick={() => void unsubscribe()}>
2015
+ {tr("forge.account_dashboard.notify_push_device_turn_off")}
2016
+ </button>
2017
+ ) : (
2018
+ <button type="button" style={button} disabled={isLoading} onClick={() => void subscribe()}>
2019
+ {tr("forge.account_dashboard.notify_push_device_turn_on")}
2020
+ </button>
2021
+ )}
2022
+ </div>
2023
+ </section>
2024
+ );
2025
+ }
2026
+
1748
2027
  function NotificationsTab() {
1749
- const { t, card } = useCardStyles();
2028
+ const { t, card, list, row, alert } = useCardStyles();
1750
2029
  const tr = useForgeT();
1751
- const { data } = useNotificationPreferences();
1752
- const updatePreference = useUpdateNotificationPreference();
2030
+ const { data, isLoading } = useNotificationMatrix();
2031
+ const update = useUpdateNotificationMatrix();
2032
+ // Optimistic: the switch moves on click, and moves back if the save fails.
2033
+ const [pending, setPending] = useState<Record<string, boolean>>({});
2034
+ const [cadence, setCadence] = useState<DigestCadence | null>(null);
2035
+ const [error, setError] = useState<string | null>(null);
1753
2036
 
1754
- const [preferences, setPreferences] = useState<NotificationPreferences>({ newPosts: true, chatMessages: true });
2037
+ if (isLoading || !data) return <Loading />;
1755
2038
 
1756
- useEffect(() => {
1757
- if (data?.preferences) {
1758
- setPreferences({
1759
- newPosts: data.preferences.newPosts !== false,
1760
- chatMessages: data.preferences.chatMessages !== false,
2039
+ const isOn = (key: string) => (key in pending ? pending[key]! : !!data.preferences[key]);
2040
+ const currentCadence = cadence ?? data.digestCadence;
2041
+
2042
+ const toggle = async (key: string) => {
2043
+ const value = !isOn(key);
2044
+ setError(null);
2045
+ setPending((prev) => ({ ...prev, [key]: value }));
2046
+ try {
2047
+ await update.mutateAsync({ preferences: { [key]: value } });
2048
+ } catch {
2049
+ setError(tr("forge.account_dashboard.notify_save_error"));
2050
+ } finally {
2051
+ setPending((prev) => {
2052
+ const next = { ...prev };
2053
+ delete next[key];
2054
+ return next;
1761
2055
  });
1762
2056
  }
1763
- }, [data]);
2057
+ };
1764
2058
 
1765
- const toggle = async (key: keyof NotificationPreferences) => {
1766
- const value = !preferences[key];
1767
- setPreferences((prev) => ({ ...prev, [key]: value }));
2059
+ const chooseCadence = async (value: DigestCadence) => {
2060
+ setError(null);
2061
+ setCadence(value);
1768
2062
  try {
1769
- await updatePreference.mutateAsync({ key, value });
2063
+ await update.mutateAsync({ digestCadence: value });
1770
2064
  } catch {
1771
- setPreferences((prev) => ({ ...prev, [key]: !value }));
2065
+ setError(tr("forge.account_dashboard.notify_save_error"));
2066
+ } finally {
2067
+ setCadence(null);
1772
2068
  }
1773
2069
  };
1774
2070
 
1775
- const Row = ({ k, title, desc }: { k: keyof NotificationPreferences; title: string; desc: string }) => (
1776
- <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
1777
- <div>
1778
- <div style={{ fontWeight: 600 }}>{title}</div>
1779
- <div style={{ fontSize: 13, opacity: 0.7 }}>{desc}</div>
1780
- </div>
2071
+ const chip = (key: string, label: string, typeLabel: string) => {
2072
+ const on = isOn(key);
2073
+ return (
1781
2074
  <button
1782
- onClick={() => toggle(k)}
2075
+ key={key}
2076
+ type="button"
2077
+ data-testid={`notify-${key}`}
2078
+ aria-pressed={on}
2079
+ aria-label={tr("forge.account_dashboard.notify_toggle_label", { type: typeLabel, channel: label })}
2080
+ onClick={() => void toggle(key)}
1783
2081
  style={{
1784
- position: "relative",
1785
- width: 44,
1786
- height: 24,
2082
+ display: "inline-flex",
2083
+ alignItems: "center",
2084
+ gap: 6,
2085
+ minHeight: 34,
2086
+ padding: "6px 12px",
1787
2087
  borderRadius: 999,
1788
- border: "none",
2088
+ // On is an outline with a tick, not a filled pill: ten rows of
2089
+ // filled primary would shout, and primary fill is kept for actions.
2090
+ border: `1px solid ${on ? t.primary : t.border}`,
2091
+ background: "transparent",
2092
+ color: on ? t.text : t.muted,
2093
+ fontFamily: t.fontFamily,
2094
+ fontSize: 13,
2095
+ fontWeight: 600,
1789
2096
  cursor: "pointer",
1790
- background: preferences[k] ? t.primary : `${t.text}40`,
1791
2097
  }}
1792
- aria-pressed={preferences[k]}
1793
2098
  >
1794
- <span
1795
- style={{
1796
- position: "absolute",
1797
- top: 4,
1798
- left: preferences[k] ? 24 : 4,
1799
- width: 16,
1800
- height: 16,
1801
- borderRadius: "50%",
1802
- background: "#fff",
1803
- transition: "left 0.15s",
1804
- }}
1805
- />
2099
+ {on ? <Check size={14} aria-hidden style={{ color: t.primary }} /> : null}
2100
+ {label}
1806
2101
  </button>
1807
- </div>
1808
- );
2102
+ );
2103
+ };
1809
2104
 
1810
2105
  return (
1811
- <div style={card}>
1812
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
1813
- {tr("forge.account_dashboard.notifications_title")}
1814
- </h2>
1815
- <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
1816
- <Row
1817
- k="newPosts"
1818
- title={tr("forge.account_dashboard.notify_new_posts_title")}
1819
- desc={tr("forge.account_dashboard.notify_new_posts_description")}
1820
- />
1821
- <Row
1822
- k="chatMessages"
1823
- title={tr("forge.account_dashboard.notify_chat_title")}
1824
- desc={tr("forge.account_dashboard.notify_chat_description")}
1825
- />
1826
- </div>
2106
+ <div style={card} data-testid="notification-settings">
2107
+ <PushDeviceRow />
2108
+ {error && (
2109
+ <p role="alert" style={{ ...alert, marginTop: 0, marginBottom: 16 }}>
2110
+ {error}
2111
+ </p>
2112
+ )}
2113
+
2114
+ {NOTIFICATION_GROUPS.map((group) => {
2115
+ const types = group.types.filter((entry) => data.defaults?.[entry.type]);
2116
+ if (types.length === 0) return null;
2117
+ return (
2118
+ <section key={group.titleKey} style={{ marginBottom: 36 }}>
2119
+ <h2 style={{ fontSize: 18, fontWeight: 600, margin: "0 0 8px", fontFamily: t.headingFontFamily }}>
2120
+ {tr(group.titleKey)}
2121
+ </h2>
2122
+ <div style={list}>
2123
+ {types.map((entry) => {
2124
+ const typeLabel = tr(entry.labelKey);
2125
+ return (
2126
+ <div
2127
+ key={entry.type}
2128
+ data-testid={`notify-row-${entry.type}`}
2129
+ style={{ ...row, padding: "14px 0", display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}
2130
+ >
2131
+ <span style={{ flex: "1 1 180px", fontWeight: 600 }}>{typeLabel}</span>
2132
+ <span style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
2133
+ {NOTIFICATION_CHANNELS.filter(({ channel }) => channelApplies(data.defaults, entry.type, channel)).map(
2134
+ ({ channel, labelKey }) => chip(`${entry.type}.${channel}`, tr(labelKey), typeLabel),
2135
+ )}
2136
+ </span>
2137
+ </div>
2138
+ );
2139
+ })}
2140
+ </div>
2141
+ </section>
2142
+ );
2143
+ })}
2144
+
2145
+ {/* How often the email that is NOT urgent arrives. Direct messages,
2146
+ mentions and reminders always email at once, which is why this
2147
+ explains itself behind the hint rather than in a paragraph. */}
2148
+ <section>
2149
+ <h2
2150
+ style={{ fontSize: 18, fontWeight: 600, margin: "0 0 12px", fontFamily: t.headingFontFamily, display: "flex", alignItems: "center", gap: 6 }}
2151
+ >
2152
+ {tr("forge.account_dashboard.notify_digest_title")}
2153
+ <CallHelpHint label={tr("forge.account_dashboard.notify_digest_help_label")}>
2154
+ {tr("forge.account_dashboard.notify_digest_help")}
2155
+ </CallHelpHint>
2156
+ </h2>
2157
+ <div role="radiogroup" aria-label={tr("forge.account_dashboard.notify_digest_title")} style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
2158
+ {DIGEST_OPTIONS.map((option) => {
2159
+ const active = currentCadence === option.value;
2160
+ return (
2161
+ <button
2162
+ key={option.value}
2163
+ type="button"
2164
+ role="radio"
2165
+ aria-checked={active}
2166
+ data-testid={`notify-digest-${option.value}`}
2167
+ onClick={() => void chooseCadence(option.value)}
2168
+ style={{
2169
+ minHeight: 38,
2170
+ padding: "8px 14px",
2171
+ borderRadius: t.cornerRadius,
2172
+ border: `1px solid ${active ? t.primary : t.border}`,
2173
+ background: active ? t.primary : "transparent",
2174
+ color: active ? t.textPrimary : t.text,
2175
+ fontFamily: t.fontFamily,
2176
+ fontSize: 14,
2177
+ fontWeight: 600,
2178
+ cursor: "pointer",
2179
+ }}
2180
+ >
2181
+ {tr(option.labelKey)}
2182
+ </button>
2183
+ );
2184
+ })}
2185
+ </div>
2186
+ </section>
1827
2187
  </div>
1828
2188
  );
1829
2189
  }