@tribe-nest/forge 3.59.0 → 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/package.json +2 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CartContext.tsx +34 -1
  4. package/src/contexts/CommunityContext.tsx +192 -0
  5. package/src/contexts/PublicAuthContext.tsx +15 -3
  6. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  7. package/src/data/queries/useAccount.ts +33 -2
  8. package/src/data/queries/useAnalytics.ts +6 -6
  9. package/src/data/queries/useCallChat.ts +2 -2
  10. package/src/data/queries/useChat.ts +52 -40
  11. package/src/data/queries/useCheckouts.ts +2 -1
  12. package/src/data/queries/useCoachingAvailability.ts +10 -1
  13. package/src/data/queries/useCommunities.ts +345 -0
  14. package/src/data/queries/useCommunity.ts +450 -102
  15. package/src/data/queries/useCommunityAgent.ts +247 -0
  16. package/src/data/queries/useCommunityCalendar.ts +150 -0
  17. package/src/data/queries/useCommunityClassroom.ts +75 -0
  18. package/src/data/queries/useCommunityMembers.ts +108 -0
  19. package/src/data/queries/useCommunityModeration.ts +327 -0
  20. package/src/data/queries/useCommunityWiki.ts +137 -0
  21. package/src/data/queries/useCourses.ts +2 -0
  22. package/src/data/queries/useEmailSignIn.ts +79 -0
  23. package/src/data/queries/useEvents.ts +6 -1
  24. package/src/data/queries/useForms.ts +5 -1
  25. package/src/data/queries/useLeadMagnet.ts +6 -2
  26. package/src/data/queries/useMembership.ts +32 -7
  27. package/src/data/queries/useMembershipCalls.ts +116 -7
  28. package/src/data/queries/useMyBookings.ts +24 -1
  29. package/src/data/queries/useMyTickets.ts +24 -1
  30. package/src/data/queries/useNotifications.ts +6 -2
  31. package/src/data/queries/useOrders.ts +2 -1
  32. package/src/data/queries/usePWA.ts +111 -35
  33. package/src/data/queries/usePostCollections.ts +28 -16
  34. package/src/data/queries/usePosts.ts +16 -14
  35. package/src/data/queries/useSiteApp.ts +130 -0
  36. package/src/data/queries/useSubscriptions.ts +28 -9
  37. package/src/i18n/de.json +891 -2
  38. package/src/i18n/en.json +891 -2
  39. package/src/index.ts +36 -7
  40. package/src/runtime/pages.spec.ts +36 -0
  41. package/src/runtime/pages.ts +52 -27
  42. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  43. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  44. package/src/types/models.ts +11 -0
  45. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  46. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  47. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  48. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  49. package/src/ui/analytics/useMetaEvents.ts +78 -0
  50. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  51. package/src/ui/format/membershipGate.ts +19 -15
  52. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  53. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  54. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  55. package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
  56. package/src/ui/headless/_tests/communityHarness.tsx +212 -0
  57. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  58. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  59. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  60. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  61. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  62. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  63. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  64. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  65. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  66. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +596 -0
  67. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +179 -0
  68. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  69. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +295 -0
  70. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  71. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  72. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  73. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  74. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  75. package/src/ui/headless/auth/useSignupForm.ts +30 -3
  76. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  77. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  78. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  79. package/src/ui/headless/communities/index.ts +96 -0
  80. package/src/ui/headless/communities/paths.ts +36 -0
  81. package/src/ui/headless/communities/useCommunity.ts +140 -0
  82. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  83. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  84. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  85. package/src/ui/headless/communities/useCommunityHome.ts +91 -0
  86. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  87. package/src/ui/headless/communities/useCommunityJoin.ts +494 -0
  88. package/src/ui/headless/communities/useCommunityMembership.ts +182 -0
  89. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  90. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  91. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  92. package/src/ui/headless/community/communityPoll.ts +297 -0
  93. package/src/ui/headless/community/countries.ts +123 -0
  94. package/src/ui/headless/community/index.ts +81 -1
  95. package/src/ui/headless/community/linkPreview.ts +71 -0
  96. package/src/ui/headless/community/richLexical.ts +27 -2
  97. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  98. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  99. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  100. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  101. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  102. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  103. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  104. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  105. package/src/ui/headless/downloads/index.ts +8 -0
  106. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  107. package/src/ui/headless/index.ts +21 -4
  108. package/src/ui/headless/memberHome/index.ts +11 -1
  109. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  110. package/src/ui/headless/memberHome/useMemberHomeNav.ts +84 -44
  111. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  112. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  113. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  114. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  115. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  116. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  117. package/src/ui/headless/portal/index.ts +57 -0
  118. package/src/ui/headless/portal/usePortal.ts +274 -0
  119. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  120. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  121. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  122. package/src/ui/headless/posts/upsell.ts +54 -8
  123. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  124. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  125. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  126. package/src/ui/headless/posts/usePostItem.ts +6 -5
  127. package/src/ui/headless/siteApp/appBar.ts +102 -0
  128. package/src/ui/headless/siteApp/appHome.ts +344 -0
  129. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  130. package/src/ui/headless/siteApp/index.ts +58 -0
  131. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  132. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  133. package/src/ui/headless/work/index.ts +2 -0
  134. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  135. package/src/ui/index.ts +28 -12
  136. package/src/ui/styled/AccountDashboard.tsx +754 -602
  137. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  138. package/src/ui/styled/ChatRoom.tsx +126 -16
  139. package/src/ui/styled/Checkout.tsx +8 -0
  140. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  141. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  142. package/src/ui/styled/CoachingDetail.tsx +2 -0
  143. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  144. package/src/ui/styled/CourseDetail.tsx +2 -0
  145. package/src/ui/styled/DownloadPage.tsx +263 -0
  146. package/src/ui/styled/EventConfirmation.tsx +10 -0
  147. package/src/ui/styled/EventDetail.tsx +10 -0
  148. package/src/ui/styled/FilmLibrary.tsx +22 -9
  149. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  150. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  151. package/src/ui/styled/MembershipTiers.tsx +49 -11
  152. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  153. package/src/ui/styled/Paywall.tsx +1 -1
  154. package/src/ui/styled/ProductDetail.tsx +12 -0
  155. package/src/ui/styled/PwaRegistration.tsx +5 -1
  156. package/src/ui/styled/SignupForm.tsx +18 -3
  157. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +6 -6
  158. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  159. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  160. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  161. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  162. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  163. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  164. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  165. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  166. package/src/ui/styled/app/AppBar.tsx +298 -0
  167. package/src/ui/styled/app/AppHome.tsx +1139 -0
  168. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  169. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  170. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  171. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  172. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  173. package/src/ui/styled/app/index.ts +7 -0
  174. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  175. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  176. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  177. package/src/ui/styled/communities/CommunityAbout.tsx +145 -0
  178. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  179. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  180. package/src/ui/styled/communities/CommunityHome.tsx +298 -0
  181. package/src/ui/styled/communities/CommunityJoinSheet.tsx +544 -0
  182. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +249 -0
  183. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  184. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  185. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  186. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  187. package/src/ui/styled/communities/MyMembershipsList.tsx +115 -0
  188. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  189. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  190. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  191. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  192. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  193. package/src/ui/styled/communities/index.ts +19 -0
  194. package/src/ui/styled/communities/shared.tsx +177 -0
  195. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  196. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  197. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  198. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  199. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  200. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  201. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  202. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  203. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  204. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  205. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  206. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  207. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  208. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  209. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  210. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  211. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  212. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  213. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  214. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  215. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  216. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  217. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  218. package/src/ui/styled/community/index.ts +37 -2
  219. package/src/ui/styled/forge-utilities.css +253 -0
  220. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  221. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  222. package/src/ui/styled/members/MemberCallsCalendar.tsx +41 -54
  223. package/src/ui/styled/members/MemberHomeLayout.tsx +327 -29
  224. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  225. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  226. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  227. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  228. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  229. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  230. package/src/ui/styled/members/index.ts +8 -0
  231. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  232. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  233. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  234. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  235. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  236. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  237. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  238. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  239. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  240. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  241. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  242. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  243. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  244. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  245. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  246. package/src/ui/styled/portal/index.ts +13 -0
  247. package/src/ui/styled/portal/portalNav.ts +260 -0
  248. package/src/ui/styled/portal/shared.tsx +206 -0
  249. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  250. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  251. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  252. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  253. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  254. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  255. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  256. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  257. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  258. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  259. package/src/ui/styled/work/index.ts +1 -1
  260. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  261. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  262. package/src/utils/landing.ts +19 -6
  263. package/src/utils/metaEvents.ts +302 -0
  264. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  265. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  266. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
@@ -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,25 +13,28 @@ 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,
26
- useCancelMembership,
27
- useMembershipAccess,
28
32
  useMyMembershipApplications,
29
33
  useWithdrawMembershipApplication,
30
34
  useConfirmMembershipApplication,
31
35
  type MembershipApplication,
32
36
  type MembershipApplicationStatus,
33
- useOpenBillingPortal,
37
+ usePublicCommunities,
34
38
  useUpdateAccount,
35
39
  useChangePassword,
36
40
  useExportAccountData,
@@ -38,13 +42,13 @@ import {
38
42
  useCancelAccountDeletion,
39
43
  useAccountDeletionStatus,
40
44
  OrderStatus,
41
- type NotificationPreferences,
42
45
  } from "../../index";
43
46
  import { useForgeT, type ForgeT } from "../../i18n";
44
47
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
45
48
  import { useSiteConfig } from "../../data/queries/useWebsite";
46
49
  import { useAmountFormatter } from "../format/useFormatCurrency";
47
50
  import { Loading } from "./Loading";
51
+ import { CallHelpHint } from "../media/CallHelpHint";
48
52
  import { bookingCallWindow } from "../media/bookingWindow";
49
53
  import { formatCountdown } from "./EventWaitlist";
50
54
  import { AddToCalendar } from "./AddToCalendar";
@@ -53,7 +57,8 @@ import { TicketTransferPanel } from "./TicketTransfer";
53
57
  import { CartLineOptions } from "./CartLineOptions";
54
58
  import { FilmRentalList } from "./FilmLibrary";
55
59
  import { canWithdrawMembershipApplication } from "../headless/membership/membershipApplication";
56
- import { isMembershipInTrial } from "../headless/membership/membershipTrial";
60
+ import { communityAboutPath, communitiesListPath } from "../headless/communities/paths";
61
+ import { MyMembershipsList } from "./communities/MyMembershipsList";
57
62
 
58
63
  export const ACCOUNT_TABS = [
59
64
  "membership",
@@ -63,7 +68,7 @@ export const ACCOUNT_TABS = [
63
68
  // holding for this artist's shows? (2.6)
64
69
  "waitlist",
65
70
  // Sessions bought from this artist (S.6 / bookings § 1.4). A booking is not an
66
- // "order" — the Orders tab covers product orders only — so it needs its own
71
+ // "order" (the Orders tab covers product orders only) so it needs its own
67
72
  // tab or it is unreachable, which is what it was.
68
73
  "bookings",
69
74
  // Films and series this fan has rented. Next to the other purchase surfaces
@@ -109,21 +114,10 @@ export interface AccountDashboardProps {
109
114
  /** Called when the visitor selects a different tab. Host maps this to navigation. */
110
115
  onTabChange?: (tab: AccountTabKey) => void;
111
116
  /**
112
- * Navigate to the membership page (the "Change" / "Join" action). Host wires
113
- * this to its router (e.g. `/i/membership`).
117
+ * SPA navigation adapter for the Memberships tab (each membership opens its
118
+ * community's home). Defaults to a full-page visit.
114
119
  */
115
- onNavigateMembership?: () => void;
116
- /**
117
- * Navigate to the member area, the member home (host maps this to its router,
118
- * e.g. `/i/members`).
119
- *
120
- * Optional, and drawn for every signed-in visitor, membership or none. It used
121
- * to be gated on `/public/community/spaces` returning a space they could open,
122
- * which hid the member area from exactly the people who had not joined yet:
123
- * the member home itself shows the locked posts and the way to buy access, so
124
- * the door is never a dead end.
125
- */
126
- onNavigateCommunity?: () => void;
120
+ navigate?: (href: string) => void;
127
121
  /**
128
122
  * Navigate to a film route from the Rentals tab (watch, resume, rent again).
129
123
  *
@@ -161,55 +155,123 @@ const defaultFormatDate = (value: string | number | Date): string =>
161
155
 
162
156
  // ---- shared styling helpers -------------------------------------------------
163
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
+ */
164
163
  function useCardStyles() {
165
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
+ };
166
180
  return {
167
181
  t,
168
- card: {
169
- background: t.surface,
170
- border: `1px solid ${t.primary}20`,
171
- borderRadius: t.cornerRadius,
172
- padding: 24,
173
- color: t.text,
174
- } 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,
175
190
  button: {
176
- padding: "8px 14px",
177
- borderRadius: t.cornerRadius,
191
+ ...control,
178
192
  background: t.primary,
179
- color: t.background,
180
- border: "none",
181
- fontWeight: 600,
182
- cursor: "pointer",
193
+ color: t.textPrimary,
194
+ border: `1px solid ${t.primary}`,
183
195
  } as React.CSSProperties,
184
196
  ghostButton: {
185
- padding: "8px 14px",
186
- borderRadius: t.cornerRadius,
197
+ ...control,
187
198
  background: "transparent",
188
199
  color: t.text,
189
- border: `1px solid ${t.primary}40`,
190
- cursor: "pointer",
200
+ border: `1px solid ${t.border}`,
191
201
  } as React.CSSProperties,
192
202
  input: {
193
203
  width: "100%",
194
204
  height: 40,
195
205
  padding: "8px 12px",
196
206
  borderRadius: t.cornerRadius,
197
- border: `1px solid ${t.primary}40`,
207
+ border: `1px solid ${t.border}`,
198
208
  background: "transparent",
199
209
  color: t.text,
210
+ fontFamily: t.fontFamily,
211
+ fontSize: 15,
200
212
  outline: "none",
213
+ boxSizing: "border-box",
201
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,
202
217
  };
203
218
  }
204
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
+
205
268
  // ---- shared context passed down to tabs ------------------------------------
206
269
 
207
270
  interface TabContext {
208
271
  formatAmount: (amount: number, currencyCode?: string) => string;
209
272
  formatDate: (value: string | number | Date) => string;
210
273
  currency?: string;
211
- onNavigateMembership?: () => void;
212
- onNavigateCommunity?: () => void;
274
+ navigate?: (href: string) => void;
213
275
  onNavigateFilm?: (href: string) => void;
214
276
  /**
215
277
  * Open a course the fan owns. Host-supplied, like every other navigation
@@ -232,8 +294,7 @@ interface TabContext {
232
294
  export function AccountDashboard({
233
295
  tab,
234
296
  onTabChange,
235
- onNavigateMembership,
236
- onNavigateCommunity,
297
+ navigate,
237
298
  onNavigateFilm,
238
299
  onNavigateCourse,
239
300
  formatAmount,
@@ -271,11 +332,10 @@ export function AccountDashboard({
271
332
  const ctx: TabContext = {
272
333
  formatAmount: fmtAmount,
273
334
  formatDate: fmtDate,
274
- // Fallback for records with no explicit currency — the tenant's runtime
335
+ // Fallback for records with no explicit currency: the tenant's runtime
275
336
  // settlement currency (single source), unless the host overrides it.
276
337
  currency: currency ?? siteConfig?.currency,
277
- onNavigateMembership,
278
- onNavigateCommunity,
338
+ navigate,
279
339
  onNavigateFilm,
280
340
  onNavigateCourse,
281
341
  };
@@ -290,15 +350,16 @@ export function AccountDashboard({
290
350
  const hasCourses = (courses?.length ?? 0) > 0;
291
351
  const visibleTabs = ACCOUNT_TABS.filter(
292
352
  (key) =>
293
- (key !== "rentals" || hasRentals || tab === "rentals") &&
294
- (key !== "courses" || hasCourses || tab === "courses"),
353
+ (key !== "rentals" || hasRentals || tab === "rentals") && (key !== "courses" || hasCourses || tab === "courses"),
295
354
  );
296
355
 
297
356
  if (!user) return <Loading fullPage />;
298
357
 
299
358
  return (
300
359
  <div className={className} style={{ maxWidth: 900, margin: "0 auto", color: t.text, ...style }}>
301
- <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
+ >
302
363
  <div>
303
364
  <h1 style={{ fontSize: 28, fontWeight: 800, fontFamily: t.headingFontFamily }}>
304
365
  {tr("forge.account_dashboard.title")}
@@ -307,7 +368,14 @@ export function AccountDashboard({
307
368
  </div>
308
369
  <button
309
370
  onClick={() => logout()}
310
- 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
+ }}
311
379
  >
312
380
  {tr("forge.account_dashboard.log_out")}
313
381
  </button>
@@ -348,224 +416,83 @@ export function AccountDashboard({
348
416
  );
349
417
  }
350
418
 
351
- // ---- Membership tab ---------------------------------------------------------
419
+ // ---- one section, outside the dashboard -----------------------------------
352
420
 
353
- function MembershipTab({ ctx }: { ctx: TabContext }) {
354
- const { user } = usePublicAuth();
355
- const { t, card, button, ghostButton } = useCardStyles();
356
- const tr = useForgeT();
357
- const { formatAmount, formatDate, currency, onNavigateMembership, onNavigateCommunity } = ctx;
358
- const cancelMembership = useCancelMembership();
359
- // The way into the member area. Being signed in is the whole gate: a fan with
360
- // no membership needs this door most, because the member home is where the
361
- // locked posts and the offer to join actually live. The old gate asked
362
- // `/public/community/spaces` for an openable space first, so a fan who had not
363
- // joined saw no way in at all.
364
- const canOpenCommunity = !!onNavigateCommunity && !!user;
365
- const [isCancelling, setIsCancelling] = useState(false);
366
- /**
367
- * Cancelling is destructive and irreversible from here, so it asks first.
368
- * CLAUDE.md puts a destructive confirmation in the category that must stay
369
- * VISIBLE rather than behind a `?`, and until now one click on Cancel simply
370
- * ended the membership. The client app has always asked (`EditorModal` in
371
- * `MembershipTab.tsx`); this surface was ported without it.
372
- */
373
- const [confirmingCancel, setConfirmingCancel] = useState(false);
374
- const [cancelError, setCancelError] = useState<string | null>(null);
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
+ };
375
425
 
376
- const membership = user?.membership;
377
- /**
378
- * Inside a free trial: nothing has been charged yet. The date the first
379
- * charge falls and what it will be are state the member acts on (cancel
380
- * before then, or not), so they are visible, not behind a hint.
381
- */
382
- const inTrial = isMembershipInTrial(membership);
383
- const cycleWord = (cycle: string | undefined) =>
384
- cycle === "year" ? tr("forge.account_dashboard.cycle_year") : tr("forge.account_dashboard.cycle_month");
385
- // Two separate questions, deliberately answered separately:
386
- // `access.hasAccess` → are the benefits live (true through past_due/grace)
387
- // `message` → what the member is told, including a failed payment
388
- // The old `status === "active"` answered both at once and got both wrong for
389
- // anyone whose card bounced.
390
- const { access, message } = useMembershipAccess();
391
- const openBillingPortal = useOpenBillingPortal();
392
- const [billingError, setBillingError] = useState<string | null>(null);
393
-
394
- // A failure here used to be swallowed whole: the button said "Cancelling…",
395
- // then said "Cancel" again, and the member was never told anything. That is
396
- // how a free-tier member — whose cancel the server rejected outright — could
397
- // press this forever with no effect and no explanation.
398
- const onCancel = async () => {
399
- if (!membership?.id) return;
400
- setCancelError(null);
401
- setIsCancelling(true);
402
- try {
403
- await cancelMembership.mutateAsync({ membershipId: membership.id });
404
- window.location.reload();
405
- } catch (error) {
406
- const detail = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
407
- setCancelError(detail || tr("forge.account_dashboard.membership_cancel_error"));
408
- } finally {
409
- setIsCancelling(false);
410
- }
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,
411
454
  };
412
455
 
413
- const onUpdatePayment = async () => {
414
- setBillingError(null);
415
- try {
416
- const { url } = await openBillingPortal.mutateAsync({});
417
- window.location.href = url;
418
- } catch (error) {
419
- const detail = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
420
- setBillingError(detail || tr("forge.account_dashboard.billing_portal_error"));
421
- }
422
- };
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
+
482
+ // ---- Membership tab ---------------------------------------------------------
423
483
 
484
+ /**
485
+ * "My memberships": one card per community the fan belongs to, each linking
486
+ * inside its community, where billing, cancel and downgrade live. A fan can
487
+ * hold a membership in several communities of one creator, so there is no
488
+ * single "your membership" any more. Nothing in a join or upgrade flow sends a
489
+ * fan here; this is for someone who wants everything in one place.
490
+ */
491
+ function MembershipTab({ ctx }: { ctx: TabContext }) {
492
+ const { card } = useCardStyles();
424
493
  return (
425
494
  <div style={card}>
426
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
427
- {tr("forge.account_dashboard.membership_title")}
428
- </h2>
429
- {membership ? (
430
- <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
431
- <div>
432
- <h3 style={{ fontWeight: 700 }}>{membership.membershipTier.name}</h3>
433
- <p
434
- style={{
435
- fontSize: 13,
436
- opacity: 0.85,
437
- fontWeight: message.tone === "warning" ? 700 : 400,
438
- color: message.tone === "warning" ? t.primary : undefined,
439
- }}
440
- >
441
- {message.label}
442
- </p>
443
- {message.detail && (
444
- <p style={{ fontSize: 13, opacity: 0.8, marginTop: 4, maxWidth: 480 }}>{message.detail}</p>
445
- )}
446
- {!!membership.subscriptionAmount && (
447
- <p style={{ fontSize: 14, marginTop: 4 }}>
448
- {tr("forge.account_dashboard.membership_price", {
449
- amount: formatAmount(membership.subscriptionAmount, membership.subscriptionCurrency || currency),
450
- cycle: membership.billingCycle,
451
- })}
452
- </p>
453
- )}
454
- </div>
455
- {!!membership.membershipTier.benefits?.length && (
456
- <ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 6 }}>
457
- {membership.membershipTier.benefits.map((b) => (
458
- <li key={b.id} style={{ display: "flex", gap: 8 }}>
459
- <span style={{ color: t.primary }}>✓</span>
460
- {b.title}
461
- </li>
462
- ))}
463
- </ul>
464
- )}
465
- {inTrial && membership.trialEndsAt && (
466
- <div data-testid="membership-trial-state">
467
- <p style={{ fontSize: 14, fontWeight: 600 }}>
468
- {tr("forge.account_dashboard.membership_trial_until", { date: formatDate(membership.trialEndsAt) })}
469
- </p>
470
- <p style={{ fontSize: 14, opacity: 0.85, marginTop: 2 }}>
471
- {access.cancelAtPeriodEnd
472
- ? tr("forge.account_dashboard.membership_trial_cancelled")
473
- : membership.subscriptionAmount
474
- ? tr("forge.account_dashboard.membership_trial_then", {
475
- amount: formatAmount(membership.subscriptionAmount, membership.subscriptionCurrency || currency),
476
- cycle: cycleWord(membership.billingCycle),
477
- date: formatDate(membership.trialEndsAt),
478
- })
479
- : null}
480
- </p>
481
- </div>
482
- )}
483
- {membership.endDate && !inTrial && (
484
- <p style={{ fontSize: 14, opacity: 0.8 }}>
485
- {access.billingState === "active"
486
- ? tr("forge.account_dashboard.membership_renews_on", { date: formatDate(membership.endDate) })
487
- : tr("forge.account_dashboard.membership_ends_on", { date: formatDate(membership.endDate) })}
488
- </p>
489
- )}
490
- {billingError && <p style={{ fontSize: 13, opacity: 0.9 }}>{billingError}</p>}
491
- {cancelError && (
492
- <p role="alert" style={{ fontSize: 13, opacity: 0.9, color: t.primary }}>
493
- {cancelError}
494
- </p>
495
- )}
496
- </div>
497
- ) : (
498
- <p style={{ opacity: 0.8 }}>{tr("forge.account_dashboard.membership_empty")}</p>
499
- )}
500
-
501
- <div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 24, flexWrap: "wrap" }}>
502
- {/* Kept hard left, apart from the billing actions: this is the door to
503
- what the membership is FOR, not another thing to do to the plan. */}
504
- {canOpenCommunity && (
505
- <button onClick={() => onNavigateCommunity?.()} style={{ ...ghostButton, marginRight: "auto" }}>
506
- {tr("forge.account_dashboard.member_area")}
507
- </button>
508
- )}
509
- {message.showUpdatePayment && (
510
- <button onClick={onUpdatePayment} disabled={openBillingPortal.isPending} style={button}>
511
- {openBillingPortal.isPending
512
- ? tr("forge.account_dashboard.billing_portal_opening")
513
- : tr("forge.account_dashboard.update_payment_method")}
514
- </button>
515
- )}
516
- {/* Cancelling stays available for anyone who still HAS access — a
517
- past_due member must not be trapped in a subscription they can see
518
- but not leave. Hidden once it is already scheduled ("Ending"), where
519
- the only honest label would be "Cancel again". */}
520
- {access.hasAccess && !access.cancelAtPeriodEnd && (
521
- <button onClick={() => setConfirmingCancel(true)} disabled={isCancelling} style={ghostButton}>
522
- {isCancelling ? tr("forge.account_dashboard.cancelling") : tr("forge.account_dashboard.cancel")}
523
- </button>
524
- )}
525
- <button onClick={() => onNavigateMembership?.()} style={button}>
526
- {membership
527
- ? tr("forge.account_dashboard.membership_change")
528
- : tr("forge.account_dashboard.membership_join")}
529
- </button>
530
- </div>
531
-
532
- {/* Says WHICH of the two cancellations this is. A paid membership runs to
533
- the end of the period already paid for; a free one stops now. A flat
534
- "are you sure" tells a member who paid this month that they have lost
535
- the rest of it, which is not true and reads as a threat. */}
536
- {confirmingCancel && (
537
- <div role="alertdialog" aria-modal="true" style={{ ...card, marginTop: 16 }}>
538
- <p style={{ fontWeight: 700 }}>{tr("forge.account_dashboard.cancel_confirm_title")}</p>
539
- <p style={{ fontSize: 14, opacity: 0.85, marginTop: 8 }}>
540
- {inTrial && membership?.trialEndsAt
541
- ? tr("forge.account_dashboard.cancel_confirm_trial", { date: formatDate(membership.trialEndsAt) })
542
- : membership?.subscriptionAmount
543
- ? membership.endDate
544
- ? tr("forge.account_dashboard.cancel_confirm_paid_until", { date: formatDate(membership.endDate) })
545
- : tr("forge.account_dashboard.cancel_confirm_paid")
546
- : tr("forge.account_dashboard.cancel_confirm_free")}
547
- </p>
548
- <div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 16 }}>
549
- <button onClick={() => setConfirmingCancel(false)} disabled={isCancelling} style={ghostButton}>
550
- {tr("forge.account_dashboard.cancel_confirm_keep")}
551
- </button>
552
- <button
553
- onClick={() => {
554
- // Closed FIRST, so the dialog cannot be double-submitted while
555
- // the request is in flight. The row behind it already renders
556
- // "Cancelling" from `isCancelling`.
557
- setConfirmingCancel(false);
558
- void onCancel();
559
- }}
560
- disabled={isCancelling}
561
- style={button}
562
- >
563
- {tr("forge.account_dashboard.cancel_confirm_confirm")}
564
- </button>
565
- </div>
566
- </div>
567
- )}
568
-
495
+ <MyMembershipsList navigate={ctx.navigate} formatAmount={ctx.formatAmount} layout="rows" />
569
496
  <MembershipApplicationsSection ctx={ctx} />
570
497
  </div>
571
498
  );
@@ -594,9 +521,18 @@ const APPLICATION_STATUS_LABEL: Record<MembershipApplicationStatus, string> = {
594
521
  function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
595
522
  const { user } = usePublicAuth();
596
523
  const tr = useForgeT();
597
- const { t, ghostButton, button } = useCardStyles();
524
+ const { t, ghostButton, button, list, row, meta, alert } = useCardStyles();
598
525
  const { formatAmount, formatDate, currency } = ctx;
599
526
  const { data: applications } = useMyMembershipApplications(user?.id);
527
+ const { data: communities } = usePublicCommunities({
528
+ enabled: !!applications?.some((a) => a.status === "payment_failed"),
529
+ });
530
+ /** An approved application whose card failed is finished on its community's About page, on that tier. */
531
+ const completeHref = (application: MembershipApplication) => {
532
+ const communityId = (application as MembershipApplication & { communityId?: string }).communityId;
533
+ const slug = communities?.find((community) => community.id === communityId)?.slug;
534
+ return slug ? communityAboutPath(slug, { tier: application.membershipTierId }) : communitiesListPath();
535
+ };
600
536
  const withdraw = useWithdrawMembershipApplication();
601
537
  const confirm = useConfirmMembershipApplication();
602
538
  const [error, setError] = useState<string | null>(null);
@@ -623,24 +559,28 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
623
559
  };
624
560
 
625
561
  return (
626
- <div style={{ marginTop: 24, paddingTop: 16, borderTop: `1px solid ${t.primary}20` }}>
627
- <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 }}>
628
564
  {tr("forge.account_dashboard.applications_title")}
629
- </h3>
630
- <ul
631
- data-testid="membership-applications"
632
- style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 12 }}
633
- >
565
+ </h2>
566
+ <ul data-testid="membership-applications" style={{ ...list, listStyle: "none", padding: 0, margin: 0 }}>
634
567
  {applications.map((application) => (
635
568
  <li
636
569
  key={application.id}
637
- 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
+ }}
638
578
  >
639
579
  <div style={{ minWidth: 0 }}>
640
- <p style={{ fontWeight: 600 }}>{application.membershipTierName ?? ""}</p>
641
- <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>
642
582
  {application.amount != null && application.billingCycle && (
643
- <p style={{ fontSize: 13, opacity: 0.8 }}>
583
+ <p style={{ ...meta, margin: 0 }}>
644
584
  {tr("forge.account_dashboard.membership_price", {
645
585
  amount: formatAmount(application.amount, application.currency || currency),
646
586
  cycle: application.billingCycle,
@@ -648,35 +588,28 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
648
588
  </p>
649
589
  )}
650
590
  {application.status === "payment_failed" && application.paymentWindowExpiresAt && (
651
- <p style={{ fontSize: 13, fontWeight: 600 }}>
591
+ <p style={{ fontSize: 13, fontWeight: 600, margin: 0 }}>
652
592
  {tr("forge.account_dashboard.application_complete_by", {
653
593
  date: formatDate(application.paymentWindowExpiresAt),
654
594
  })}
655
595
  </p>
656
596
  )}
657
597
  {application.status === "declined" && application.decisionNote && (
658
- <p style={{ fontSize: 13, opacity: 0.8, maxWidth: 480 }}>{application.decisionNote}</p>
598
+ <p style={{ ...meta, margin: 0, maxWidth: 480 }}>{application.decisionNote}</p>
659
599
  )}
660
- <p style={{ fontSize: 12, opacity: 0.65 }}>
600
+ <p style={{ ...meta, margin: 0 }}>
661
601
  {tr("forge.account_dashboard.application_submitted_on", { date: formatDate(application.createdAt) })}
662
602
  </p>
663
603
  </div>
664
604
  {canWithdrawMembershipApplication(application.status) && (
665
- <button
666
- onClick={() => void onWithdraw(application)}
667
- disabled={withdraw.isPending}
668
- style={ghostButton}
669
- >
605
+ <button onClick={() => void onWithdraw(application)} disabled={withdraw.isPending} style={ghostButton}>
670
606
  {withdraw.isPending && withdraw.variables?.applicationId === application.id
671
607
  ? tr("forge.account_dashboard.application_withdrawing")
672
608
  : tr("forge.account_dashboard.application_withdraw")}
673
609
  </button>
674
610
  )}
675
611
  {application.status === "payment_failed" && (
676
- <a
677
- href={`/i/membership/checkout?membershipTierId=${encodeURIComponent(application.membershipTierId)}`}
678
- style={{ ...button, textDecoration: "none" }}
679
- >
612
+ <a href={completeHref(application)} style={button}>
680
613
  {tr("forge.account_dashboard.application_complete")}
681
614
  </a>
682
615
  )}
@@ -684,7 +617,7 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
684
617
  ))}
685
618
  </ul>
686
619
  {error && (
687
- <p role="alert" style={{ fontSize: 13, marginTop: 8, color: t.primary }}>
620
+ <p role="alert" style={alert}>
688
621
  {error}
689
622
  </p>
690
623
  )}
@@ -698,7 +631,7 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
698
631
  * The buyer's event tickets, and cancelling one inside the policy they were
699
632
  * sold under (S.6).
700
633
  *
701
- * Before this, a ticket buyer had no purchase surface at all — the confirmation
634
+ * Before this, a ticket buyer had no purchase surface at all: the confirmation
702
635
  * email and a one-shot finalise page were the only artefacts of the sale.
703
636
  *
704
637
  * Eligibility is NOT recomputed here. `cancellation.canCancel` and its reason
@@ -709,22 +642,22 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
709
642
  * ## Two kinds of row
710
643
  *
711
644
  * `role` says whether the caller PAID for this order or merely HOLDS a pass out
712
- * of it — a ticket somebody transferred to them, which until now reached them
645
+ * of it: a ticket somebody transferred to them, which until now reached them
713
646
  * nowhere at all. A holder's row is scrubbed server-side (no totals, no line
714
647
  * prices, no other seats, no cancel), and this renders that difference rather
715
648
  * than printing "$0.00" over somebody else's purchase.
716
649
  */
717
650
  function TicketsTab({ ctx }: { ctx: TabContext }) {
718
651
  const { user } = usePublicAuth();
719
- const { t, card } = useCardStyles();
652
+ const { t, card, list, row, meta, ghostButton } = useCardStyles();
720
653
  const tr = useForgeT();
721
654
  const { formatAmount, formatDate, currency } = ctx;
722
- const { data, isLoading } = useMyTickets(user?.id);
655
+ const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = useMyTicketsPaged(user?.id);
723
656
  const cancelTicket = useCancelMyTicket();
724
657
  const [pendingId, setPendingId] = useState<string | null>(null);
725
658
  const [error, setError] = useState<string | null>(null);
726
659
 
727
- const tickets = data?.data ?? [];
660
+ const tickets = data?.pages.flatMap((page) => page.data) ?? [];
728
661
 
729
662
  const onCancel = async (ticket: MyTicket) => {
730
663
  setError(null);
@@ -743,18 +676,15 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
743
676
 
744
677
  return (
745
678
  <div style={card}>
746
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
747
- {tr("forge.account_dashboard.tickets_title")}
748
- </h2>
749
679
  {error && (
750
- <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" }}>
751
681
  {error}
752
- </div>
682
+ </p>
753
683
  )}
754
684
  {isLoading ? (
755
685
  <Loading />
756
686
  ) : tickets.length > 0 ? (
757
- <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
687
+ <div style={list}>
758
688
  {tickets.map((ticket) => {
759
689
  const cancelled = !!ticket.selfCancelledAt;
760
690
  // Absent on an older API build, where every row WAS a purchase.
@@ -770,24 +700,23 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
770
700
  const requestState = myTicketRequestState(ticket);
771
701
  const isRequestRow = isMyTicketRequestOpenOrEnded(ticket);
772
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);
773
707
  return (
774
708
  <div
775
709
  key={ticket.id}
776
- data-testid={requestState ? `my-ticket-request-${requestState}` : undefined}
777
- style={{
778
- border: `1px solid ${t.primary}20`,
779
- borderRadius: t.cornerRadius,
780
- padding: 16,
781
- opacity: cancelled || requestEnded ? 0.6 : 1,
782
- }}
710
+ data-testid={requestState ? `my-ticket-request-${requestState}` : "my-ticket"}
711
+ style={{ ...row, opacity: cancelled || requestEnded ? 0.6 : 1 }}
783
712
  >
784
- <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12, gap: 12 }}>
713
+ <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8, gap: 12 }}>
785
714
  <div>
786
- <div style={{ fontWeight: 600 }}>{ticket.eventTitle}</div>
787
- <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>
788
717
  </div>
789
718
  {requestState && (
790
- <span style={{ fontSize: 13, color: t.primary, textAlign: "right" }}>
719
+ <span style={{ fontSize: 13, fontWeight: 600, textAlign: "right" }}>
791
720
  {requestState === "pending"
792
721
  ? tr("forge.account_dashboard.request_pending")
793
722
  : requestState === "approved"
@@ -800,20 +729,29 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
800
729
  </span>
801
730
  )}
802
731
  {cancelled && !requestState && (
803
- <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>
804
733
  )}
805
734
  {/* Says WHY this is in the list at all. Without it a
806
735
  transferred ticket looks like a purchase the buyer cannot
807
736
  remember making, with no prices on it. */}
808
737
  {!cancelled && gifted && (
809
- <span style={{ fontSize: 13, color: t.primary }}>
738
+ <span style={{ fontSize: 13, color: t.muted }}>
810
739
  {tr("forge.account_dashboard.ticket_transferred")}
811
740
  </span>
812
741
  )}
813
742
  </div>
814
743
 
815
744
  {ticket.items.map((item) => (
816
- <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
+ >
817
755
  <span>
818
756
  {tr("forge.account_dashboard.ticket_line", {
819
757
  title: item.ticketTitle ?? tr("forge.account_dashboard.ticket_fallback_title"),
@@ -821,7 +759,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
821
759
  })}
822
760
  </span>
823
761
  {/* A holder is not shown a price, because the server did
824
- not send one — `Number(null)` is 0, and a confident
762
+ not send one: `Number(null)` is 0, and a confident
825
763
  "$0.00" on somebody else's purchase is a lie. */}
826
764
  {!gifted && (
827
765
  <span>{formatAmount(Number(item.price) * item.quantity, ticket.currency || currency)}</span>
@@ -830,7 +768,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
830
768
  ))}
831
769
 
832
770
  {!gifted && (
833
- <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 }}>
834
772
  {/* Nothing has been taken on a pending request, and never
835
773
  will be on one that ended: the label says which. */}
836
774
  <span>
@@ -838,7 +776,9 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
838
776
  ? tr("forge.account_dashboard.total_on_approval")
839
777
  : tr("forge.account_dashboard.total")}
840
778
  </span>
841
- <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>
842
782
  </div>
843
783
  )}
844
784
 
@@ -866,12 +806,12 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
866
806
  </div>
867
807
  )}
868
808
 
869
- {/* Diarise it — including the Apple option, which needs the
809
+ {/* Diarise it: including the Apple option, which needs the
870
810
  platform's own `.ics` and so could not exist before. Null
871
811
  for a called-off show, and the component draws nothing. */}
872
812
  {/* Not offered on a request: the fan has not been admitted to
873
813
  anything yet, and a calendar entry says they have. */}
874
- {!isRequestRow && (
814
+ {!isRequestRow && !eventOver && (
875
815
  <AddToCalendar
876
816
  variant="compact"
877
817
  title={ticket.eventTitle}
@@ -885,7 +825,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
885
825
 
886
826
  {/*
887
827
  Wallet passes (2.3). Renders NOTHING unless the API offers a
888
- real pass for a real pass id — which, with no signing
828
+ real pass for a real pass id: which, with no signing
889
829
  credentials configured, is every ticket today. Deliberately
890
830
  not wrapped in a condition of its own: the component owns that
891
831
  decision, and a second copy of the rule here is how one of the
@@ -894,32 +834,41 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
894
834
  Eligibility (cancelled order, rotating QR) is the server's
895
835
  call, exactly as it is for cancellation above.
896
836
  */}
897
- <WalletPassButtons passIds={myTicketHeldPassIds(ticket)} variant="compact" style={{ marginTop: 12 }} />
837
+ {!eventOver && <WalletPassButtons passIds={heldPassIds} variant="compact" style={{ marginTop: 12 }} />}
898
838
 
899
839
  {/*
900
840
  Transfer (2.2). The email a transfer sends points at
901
841
  `/i/tickets/claim`, so this affordance and that route ship
902
- together — a send with nowhere to land is undeliverable in
842
+ together: a send with nowhere to land is undeliverable in
903
843
  practice.
904
844
 
905
845
  Renders nothing when the payload carries no `TN-…` pass ids
906
846
  (an older API build), because every pass-scoped endpoint would
907
847
  404. Whether THIS pass may be handed on is the server's call,
908
- answered on submit in its own words — not hidden behind a
848
+ answered on submit in its own words: not hidden behind a
909
849
  guess here.
910
850
 
911
851
  HELD passes only: the endpoints resolve the current holder, so
912
852
  a control on a named guest's seat or on a ticket already
913
853
  claimed by somebody else could answer nothing but 404. The
914
854
  pass objects go through too, because each carries the caller's
915
- own `pendingTransfer` — which is what lets a sender withdraw
855
+ own `pendingTransfer`: which is what lets a sender withdraw
916
856
  from a device that is not the one they sent from.
917
857
  */}
918
- <TicketTransferPanel
919
- passIds={myTicketHeldPassIds(ticket)}
920
- passes={myTicketPasses(ticket)}
921
- style={{ marginTop: 12 }}
922
- />
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
+ )}
923
872
 
924
873
  {/* The terms they agreed to, in the artist's own words where given. */}
925
874
  {ticket.cancellation.description && (
@@ -933,16 +882,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
933
882
  <button
934
883
  onClick={() => void onCancel(ticket)}
935
884
  disabled={pendingId === ticket.id}
936
- style={{
937
- marginTop: 12,
938
- padding: "8px 14px",
939
- borderRadius: t.cornerRadius,
940
- border: `1px solid ${t.primary}40`,
941
- background: "transparent",
942
- color: t.text,
943
- cursor: pendingId === ticket.id ? "wait" : "pointer",
944
- fontWeight: 600,
945
- }}
885
+ style={{ ...ghostButton, marginTop: 12, cursor: pendingId === ticket.id ? "wait" : "pointer" }}
946
886
  >
947
887
  {pendingId === ticket.id
948
888
  ? tr("forge.account_dashboard.cancelling")
@@ -954,8 +894,9 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
954
894
  })}
955
895
  </div>
956
896
  ) : (
957
- <p style={{ fontSize: 14, opacity: 0.7 }}>{tr("forge.account_dashboard.tickets_empty")}</p>
897
+ <EmptySection>{tr("forge.account_dashboard.tickets_empty")}</EmptySection>
958
898
  )}
899
+ <ShowMore hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} onMore={() => void fetchNextPage()} />
959
900
  </div>
960
901
  );
961
902
  }
@@ -967,8 +908,8 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
967
908
  *
968
909
  * Two states, and they are genuinely different things:
969
910
  *
970
- * WAITING — a 1-based `position` in the queue.
971
- * NOTIFIED — an OFFER with a deadline, and `position: null`. An offer is not a
911
+ * WAITING: a 1-based `position` in the queue.
912
+ * NOTIFIED: an OFFER with a deadline, and `position: null`. An offer is not a
972
913
  * queue place, so it is never rendered as one; the deadline is the
973
914
  * whole point and gets the emphasis.
974
915
  *
@@ -978,26 +919,23 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
978
919
  */
979
920
  function WaitlistTab({ ctx }: { ctx: TabContext }) {
980
921
  const { user } = usePublicAuth();
981
- const { t, card } = useCardStyles();
922
+ const { card, list } = useCardStyles();
982
923
  const tr = useForgeT();
983
924
  const { formatDate } = ctx;
984
925
  const { places, isLoading } = useMyWaitlistPlaces(user?.id);
985
926
 
986
927
  return (
987
928
  <div style={card}>
988
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
989
- {tr("forge.account_dashboard.waitlists_title")}
990
- </h2>
991
929
  {isLoading ? (
992
930
  <Loading />
993
931
  ) : places.length > 0 ? (
994
- <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
932
+ <div style={list}>
995
933
  {places.map((place) => (
996
934
  <WaitlistPlaceRow key={place.id} place={place} formatDate={formatDate} />
997
935
  ))}
998
936
  </div>
999
937
  ) : (
1000
- <p style={{ fontSize: 14, opacity: 0.7 }}>{tr("forge.account_dashboard.waitlists_empty")}</p>
938
+ <EmptySection>{tr("forge.account_dashboard.waitlists_empty")}</EmptySection>
1001
939
  )}
1002
940
  </div>
1003
941
  );
@@ -1010,7 +948,7 @@ function WaitlistPlaceRow({
1010
948
  place: WaitlistPlace;
1011
949
  formatDate: (value: string | number | Date) => string;
1012
950
  }) {
1013
- const { t } = useCardStyles();
951
+ const { t, row, meta, ghostButton, alert } = useCardStyles();
1014
952
  const tr = useForgeT();
1015
953
  const leave = useLeaveEventWaitlist(place.eventId);
1016
954
  const [error, setError] = useState<string | null>(null);
@@ -1030,19 +968,13 @@ function WaitlistPlaceRow({
1030
968
  const deadline = place.offer.claimExpiresAt ? new Date(place.offer.claimExpiresAt) : null;
1031
969
 
1032
970
  return (
1033
- <div
1034
- style={{
1035
- border: `1px solid ${t.primary}${place.offer.offerOpen ? "80" : "20"}`,
1036
- borderRadius: t.cornerRadius,
1037
- padding: 16,
1038
- }}
1039
- >
971
+ <div style={row}>
1040
972
  <div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
1041
973
  <div>
1042
- <div style={{ fontWeight: 600 }}>
974
+ <div style={{ fontWeight: 600, fontSize: 16 }}>
1043
975
  {place.event?.title ?? tr("forge.account_dashboard.waitlist_event_fallback")}
1044
976
  </div>
1045
- <div style={{ fontSize: 13, opacity: 0.7 }}>
977
+ <div style={meta}>
1046
978
  {tr(
1047
979
  place.quantity === 1
1048
980
  ? "forge.account_dashboard.waitlist_ticket_line_one"
@@ -1053,21 +985,17 @@ function WaitlistPlaceRow({
1053
985
  },
1054
986
  )}
1055
987
  </div>
1056
- {place.event?.dateTime && (
1057
- <div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(place.event.dateTime)}</div>
1058
- )}
988
+ {place.event?.dateTime && <div style={meta}>{formatDate(place.event.dateTime)}</div>}
1059
989
  </div>
1060
990
  {place.offer.offerOpen ? (
1061
- <span style={{ fontWeight: 700, color: t.primary }}>
1062
- {tr("forge.account_dashboard.waitlist_offer_open")}
1063
- </span>
991
+ <span style={{ fontWeight: 700, color: t.primary }}>{tr("forge.account_dashboard.waitlist_offer_open")}</span>
1064
992
  ) : place.position != null ? (
1065
- <span style={{ fontSize: 14, opacity: 0.85 }}>
993
+ <span style={{ fontSize: 14, color: t.muted }}>
1066
994
  {tr("forge.account_dashboard.waitlist_position_label")}{" "}
1067
- <strong style={{ color: t.primary }}>#{place.position}</strong>
995
+ <strong style={{ color: t.text }}>#{place.position}</strong>
1068
996
  </span>
1069
997
  ) : (
1070
- <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>
1071
999
  )}
1072
1000
  </div>
1073
1001
 
@@ -1075,9 +1003,9 @@ function WaitlistPlaceRow({
1075
1003
  <div
1076
1004
  style={{
1077
1005
  marginTop: 12,
1078
- padding: "10px 12px",
1079
- borderRadius: t.cornerRadius,
1080
- border: `1px solid ${t.primary}4d`,
1006
+ padding: "10px 14px",
1007
+ borderLeft: `3px solid ${t.primary}`,
1008
+ background: t.surface,
1081
1009
  fontSize: 14,
1082
1010
  }}
1083
1011
  >
@@ -1099,21 +1027,16 @@ function WaitlistPlaceRow({
1099
1027
  </div>
1100
1028
  )}
1101
1029
 
1102
- {error && <p style={{ color: "#ef4444", fontSize: 13, marginTop: 12 }}>{error}</p>}
1030
+ {error && (
1031
+ <p role="alert" style={alert}>
1032
+ {error}
1033
+ </p>
1034
+ )}
1103
1035
 
1104
1036
  <button
1105
1037
  onClick={() => void onLeave()}
1106
1038
  disabled={leave.isPending}
1107
- style={{
1108
- marginTop: 12,
1109
- padding: "8px 14px",
1110
- borderRadius: t.cornerRadius,
1111
- border: `1px solid ${t.primary}40`,
1112
- background: "transparent",
1113
- color: t.text,
1114
- cursor: leave.isPending ? "wait" : "pointer",
1115
- fontWeight: 600,
1116
- }}
1039
+ style={{ ...ghostButton, marginTop: 12, cursor: leave.isPending ? "wait" : "pointer" }}
1117
1040
  >
1118
1041
  {leave.isPending
1119
1042
  ? tr("forge.account_dashboard.waitlist_leaving")
@@ -1131,13 +1054,13 @@ function WaitlistPlaceRow({
1131
1054
  * Session buyers had no purchase surface at all: `GET /public/coaching/bookings/mine`
1132
1055
  * and the reschedule action shipped with nothing that could reach them.
1133
1056
  *
1134
- * A booking is deliberately NOT folded into the Orders tab — that tab reads
1057
+ * A booking is deliberately NOT folded into the Orders tab: that tab reads
1135
1058
  * `/public/orders` and covers product orders only, so a session would never
1136
1059
  * appear there however hard one looked.
1137
1060
  *
1138
1061
  * Nothing is recomputed here. `canReschedule` and `rescheduleReason` arrive on
1139
1062
  * every row, and the slot grid the picker offers is the server's own
1140
- * availability read — which already excludes taken slots and hours the coach is
1063
+ * availability read: which already excludes taken slots and hours the coach is
1141
1064
  * busy on any of their services.
1142
1065
  *
1143
1066
  * `cancellation` comes from the POLICY SNAPSHOT taken on the booking at
@@ -1148,28 +1071,26 @@ function WaitlistPlaceRow({
1148
1071
  */
1149
1072
  function BookingsTab({ ctx }: { ctx: TabContext }) {
1150
1073
  const { user } = usePublicAuth();
1151
- const { t, card } = useCardStyles();
1074
+ const { card, list } = useCardStyles();
1152
1075
  const tr = useForgeT();
1153
- const { data, isLoading } = useMyBookings(user?.id);
1076
+ const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = useMyBookingsPaged(user?.id);
1154
1077
 
1155
- const bookings = data?.data ?? [];
1078
+ const bookings = data?.pages.flatMap((page) => page.data) ?? [];
1156
1079
 
1157
1080
  return (
1158
1081
  <div style={card}>
1159
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
1160
- {tr("forge.account_dashboard.sessions_title")}
1161
- </h2>
1162
1082
  {isLoading ? (
1163
1083
  <Loading />
1164
1084
  ) : bookings.length > 0 ? (
1165
- <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
1085
+ <div style={list}>
1166
1086
  {bookings.map((booking) => (
1167
1087
  <BookingRow key={booking.id} booking={booking} ctx={ctx} />
1168
1088
  ))}
1169
1089
  </div>
1170
1090
  ) : (
1171
- <p style={{ fontSize: 14, opacity: 0.7 }}>{tr("forge.account_dashboard.sessions_empty")}</p>
1091
+ <EmptySection>{tr("forge.account_dashboard.sessions_empty")}</EmptySection>
1172
1092
  )}
1093
+ <ShowMore hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} onMore={() => void fetchNextPage()} />
1173
1094
  </div>
1174
1095
  );
1175
1096
  }
@@ -1194,15 +1115,15 @@ function formatSessionWindow(booking: MyBooking, tr: ForgeT): string {
1194
1115
  }
1195
1116
 
1196
1117
  /**
1197
- * WHERE the session is — the line this portal existed without.
1118
+ * WHERE the session is: the line this portal existed without.
1198
1119
  *
1199
1120
  * A client who bought a coaching call could see the time, the price and an
1200
1121
  * add-to-calendar button, and nothing at all about how to attend; the answer
1201
1122
  * arrived, if it arrived, in a separate email from the coach.
1202
1123
  *
1203
1124
  * The two kinds are not interchangeable, so they do not render the same. An
1204
- * online session's link is an anchor — at 9:59 for a 10:00 call it is the only
1205
- * thing on this row anyone wants — shown in full rather than as the word "Join",
1125
+ * online session's link is an anchor: at 9:59 for a 10:00 call it is the only
1126
+ * thing on this row anyone wants: shown in full rather than as the word "Join",
1206
1127
  * because the client may be reading this on a phone and about to type it into
1207
1128
  * a laptop. An in-person address is plain text: it is the coach's own words,
1208
1129
  * often "Studio B, ring the buzzer" rather than anything a maps provider could
@@ -1210,23 +1131,17 @@ function formatSessionWindow(booking: MyBooking, tr: ForgeT): string {
1210
1131
  *
1211
1132
  * `location` is already resolved server-side (booking override over product
1212
1133
  * default) and is null unless the session is confirmed, so there is no rule to
1213
- * apply here — which is the same discipline `canReschedule` follows two blocks
1134
+ * apply here: which is the same discipline `canReschedule` follows two blocks
1214
1135
  * down.
1215
1136
  */
1216
- function BookingLocationLine({
1217
- location,
1218
- primary,
1219
- }: {
1220
- location: MyBooking["location"];
1221
- primary: string;
1222
- }) {
1137
+ function BookingLocationLine({ location, primary }: { location: MyBooking["location"]; primary: string }) {
1223
1138
  const tr = useForgeT();
1224
1139
  if (!location) return null;
1225
1140
 
1226
1141
  if (location.type === "online") {
1227
1142
  return (
1228
1143
  <div style={{ fontSize: 13, marginTop: 4 }}>
1229
- <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>
1230
1145
  <a
1231
1146
  href={location.url}
1232
1147
  target="_blank"
@@ -1251,7 +1166,7 @@ function BookingLocationLine({
1251
1166
  if (location.type === "video") {
1252
1167
  return (
1253
1168
  <div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
1254
- <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>
1255
1170
  {tr("forge.account_dashboard.booking_video_call")}
1256
1171
  </div>
1257
1172
  );
@@ -1259,30 +1174,29 @@ function BookingLocationLine({
1259
1174
 
1260
1175
  return (
1261
1176
  <div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
1262
- <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>
1263
1178
  {location.address}
1264
1179
  </div>
1265
1180
  );
1266
1181
  }
1267
1182
 
1268
1183
  function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
1269
- const { t } = useCardStyles();
1184
+ const { t, row, meta, ghostButton, alert } = useCardStyles();
1270
1185
  const tr = useForgeT();
1271
1186
  const { formatAmount, currency } = ctx;
1272
1187
  const cancelBooking = useCancelMyBooking();
1273
1188
  const [picking, setPicking] = useState(false);
1274
1189
  const [error, setError] = useState<string | null>(null);
1275
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;
1276
1198
 
1277
- const actionButton: React.CSSProperties = {
1278
- padding: "8px 14px",
1279
- borderRadius: t.cornerRadius,
1280
- border: `1px solid ${t.primary}40`,
1281
- background: "transparent",
1282
- color: t.text,
1283
- cursor: "pointer",
1284
- fontWeight: 600,
1285
- };
1199
+ const actionButton = ghostButton;
1286
1200
 
1287
1201
  const onCancel = async () => {
1288
1202
  setError(null);
@@ -1300,49 +1214,42 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
1300
1214
  };
1301
1215
 
1302
1216
  return (
1303
- <div
1304
- style={{
1305
- border: `1px solid ${t.primary}20`,
1306
- borderRadius: t.cornerRadius,
1307
- padding: 16,
1308
- opacity: cancelled ? 0.6 : 1,
1309
- }}
1310
- >
1217
+ <div data-testid="my-booking" style={{ ...row, opacity: cancelled ? 0.6 : 1 }}>
1311
1218
  <div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
1312
1219
  <div>
1313
- <div style={{ fontWeight: 600 }}>{booking.coachingProductTitle}</div>
1314
- <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>
1315
1222
  {booking.coachingProductDurationMinutes != null && (
1316
- <div style={{ fontSize: 13, opacity: 0.7 }}>
1223
+ <div style={meta}>
1317
1224
  {tr("forge.account_dashboard.booking_duration", { minutes: booking.coachingProductDurationMinutes })}
1318
1225
  </div>
1319
1226
  )}
1320
1227
  <BookingLocationLine location={booking.location} primary={t.primary} />
1321
1228
  </div>
1322
1229
  <div style={{ textAlign: "right" }}>
1323
- {cancelled && (
1324
- <div style={{ fontSize: 13, color: t.primary }}>{tr("forge.account_dashboard.cancelled")}</div>
1325
- )}
1230
+ {cancelled && <div style={{ fontSize: 13, color: t.muted }}>{tr("forge.account_dashboard.cancelled")}</div>}
1326
1231
  {booking.totalAmount != null && (
1327
- <div style={{ fontWeight: 700, color: t.primary }}>
1232
+ <div style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
1328
1233
  {formatAmount(booking.totalAmount, booking.currency || currency)}
1329
1234
  </div>
1330
1235
  )}
1331
1236
  </div>
1332
1237
  </div>
1333
1238
 
1334
- {/* Diarise it — including Apple, which needs the platform's own `.ics`.
1239
+ {/* Diarise it: including Apple, which needs the platform's own `.ics`.
1335
1240
  Null unless the session is confirmed, and the component draws nothing. */}
1336
- <AddToCalendar
1337
- variant="compact"
1338
- title={booking.coachingProductTitle}
1339
- start={booking.sessionStartTime}
1340
- end={booking.sessionEndTime}
1341
- defaultDurationMinutes={booking.coachingProductDurationMinutes ?? undefined}
1342
- icsUrl={booking.calendarUrl}
1343
- webcalUrl={booking.calendarWebcalUrl}
1344
- style={{ marginTop: 12 }}
1345
- />
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
+ )}
1346
1253
 
1347
1254
  {/* The terms they booked under, in the artist's own words where given. */}
1348
1255
  {booking.cancellation.description && (
@@ -1353,17 +1260,19 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
1353
1260
  )}
1354
1261
  {/* A reschedule rule of its own. When it follows the cancellation window
1355
1262
  the sentence above already says it, so nothing is repeated. */}
1356
- {booking.reschedulePolicy && !booking.reschedulePolicy.followsCancellation && booking.reschedulePolicy.description && (
1357
- <div style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>{booking.reschedulePolicy.description}</div>
1358
- )}
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
+ )}
1359
1268
 
1360
1269
  {/*
1361
- The action row. Both controls are drawn from the SERVER's flags —
1362
- `canReschedule` and `cancellation.canCancel` — never from eligibility
1270
+ The action row. Both controls are drawn from the SERVER's flags:
1271
+ `canReschedule` and `cancellation.canCancel`: never from eligibility
1363
1272
  re-derived in the browser, which would give two answers to one question.
1364
1273
  */}
1365
1274
  {error && (
1366
- <p style={{ color: "#ef4444", fontSize: 13, marginTop: 12 }} role="alert">
1275
+ <p style={alert} role="alert">
1367
1276
  {error}
1368
1277
  </p>
1369
1278
  )}
@@ -1391,7 +1300,7 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
1391
1300
 
1392
1301
  {/* The way INTO a video session: a Join control while the room is open,
1393
1302
  the call itself once pressed. Nothing for any other kind of session. */}
1394
- {!cancelled && <BookingCallEntry booking={booking} />}
1303
+ {!cancelled && !callNoteExpired && <BookingCallEntry booking={booking} />}
1395
1304
  </div>
1396
1305
  );
1397
1306
  }
@@ -1487,7 +1396,7 @@ function BookingCallEntry({ booking }: { booking: MyBooking }) {
1487
1396
  * Pick another time for a session already bought.
1488
1397
  *
1489
1398
  * The grid is `GET /public/coaching/products/:id/availability` over the next
1490
- * fortnight — the same read the original purchase used, which already drops
1399
+ * fortnight: the same read the original purchase used, which already drops
1491
1400
  * taken slots and hours the coach is busy on any of their services. It is not
1492
1401
  * filtered further here: a slot this list offers and the server then refuses
1493
1402
  * (two buyers racing for one hour, decided by a database EXCLUDE constraint)
@@ -1530,7 +1439,15 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
1530
1439
  const open = (slots ?? []).filter((slot) => slot.id !== booking.coachingBookingSlotId);
1531
1440
 
1532
1441
  return (
1533
- <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
+ >
1534
1451
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12 }}>
1535
1452
  <strong style={{ fontSize: 14 }}>{tr("forge.account_dashboard.reschedule_title")}</strong>
1536
1453
  <button
@@ -1538,7 +1455,7 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
1538
1455
  style={{
1539
1456
  padding: "4px 10px",
1540
1457
  borderRadius: t.cornerRadius,
1541
- border: `1px solid ${t.primary}40`,
1458
+ border: `1px solid ${t.border}`,
1542
1459
  background: "transparent",
1543
1460
  color: t.text,
1544
1461
  cursor: "pointer",
@@ -1550,7 +1467,7 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
1550
1467
  </div>
1551
1468
 
1552
1469
  {error && (
1553
- <p style={{ color: "#ef4444", fontSize: 13, marginTop: 10 }} role="alert">
1470
+ <p style={{ fontSize: 14, fontWeight: 600, marginTop: 10 }} role="alert">
1554
1471
  {error}
1555
1472
  </p>
1556
1473
  )}
@@ -1570,8 +1487,8 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
1570
1487
  style={{
1571
1488
  padding: "8px 12px",
1572
1489
  borderRadius: t.cornerRadius,
1573
- border: `1px solid ${t.primary}40`,
1574
- background: "transparent",
1490
+ border: `1px solid ${t.border}`,
1491
+ background: t.background,
1575
1492
  color: t.text,
1576
1493
  cursor: reschedule.isPending ? "wait" : "pointer",
1577
1494
  fontSize: 13,
@@ -1592,9 +1509,7 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
1592
1509
  })}
1593
1510
  </div>
1594
1511
  ) : (
1595
- <p style={{ fontSize: 13, opacity: 0.7, marginTop: 12 }}>
1596
- {tr("forge.account_dashboard.reschedule_empty")}
1597
- </p>
1512
+ <p style={{ fontSize: 13, opacity: 0.7, marginTop: 12 }}>{tr("forge.account_dashboard.reschedule_empty")}</p>
1598
1513
  )}
1599
1514
  </div>
1600
1515
  );
@@ -1616,15 +1531,10 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
1616
1531
  * That decision lives in one place, `FilmRentalList`, and both surfaces read it.
1617
1532
  */
1618
1533
  function RentalsTab({ ctx }: { ctx: TabContext }) {
1619
- const { t, card } = useCardStyles();
1620
- const tr = useForgeT();
1621
-
1534
+ const { card } = useCardStyles();
1622
1535
  return (
1623
1536
  <div style={card}>
1624
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
1625
- {tr("forge.account_dashboard.rentals_title")}
1626
- </h2>
1627
- <FilmRentalList navigate={ctx.onNavigateFilm} />
1537
+ <FilmRentalList navigate={ctx.onNavigateFilm} layout="rows" />
1628
1538
  </div>
1629
1539
  );
1630
1540
  }
@@ -1640,46 +1550,50 @@ function RentalsTab({ ctx }: { ctx: TabContext }) {
1640
1550
  * the question this whole tab exists to stop someone asking.
1641
1551
  */
1642
1552
  function CoursesTab({ ctx, courses }: { ctx: TabContext; courses: MyCourse[] }) {
1643
- const { t, card } = useCardStyles();
1553
+ const { card, list, row, meta, button, ghostButton } = useCardStyles();
1644
1554
  const tr = useForgeT();
1645
1555
 
1646
1556
  return (
1647
1557
  <div style={card}>
1648
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
1649
- {tr("forge.account_dashboard.courses_title")}
1650
- </h2>
1651
-
1652
1558
  {courses.length === 0 ? (
1653
- <p style={{ color: t.muted }}>{tr("forge.account_dashboard.courses_empty")}</p>
1559
+ <EmptySection>{tr("forge.account_dashboard.courses_empty")}</EmptySection>
1654
1560
  ) : (
1655
- <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
1561
+ <div style={list}>
1656
1562
  {courses.map((course) => (
1657
- <button
1563
+ <div
1658
1564
  key={course.accessId}
1659
- type="button"
1660
- onClick={() => ctx.onNavigateCourse?.(course)}
1661
- style={{
1662
- textAlign: "left",
1663
- background: t.surface,
1664
- border: `1px solid ${t.border}`,
1665
- borderRadius: t.cornerRadius,
1666
- padding: 16,
1667
- cursor: ctx.onNavigateCourse ? "pointer" : "default",
1668
- color: t.text,
1669
- }}
1565
+ data-testid="my-course"
1566
+ style={{ ...row, display: "flex", alignItems: "center", gap: 16, flexWrap: "wrap" }}
1670
1567
  >
1671
- <div style={{ fontWeight: 600 }}>{course.title}</div>
1672
- {/* No description. It is rich text: as a string it shows its own
1673
- markup, as HTML it drags a sales page's formatting into a list
1674
- row. The row carries the title and the state instead. */}
1675
- <div style={{ color: t.muted, fontSize: 12, marginTop: 8 }}>
1676
- {course.completedAt
1677
- ? tr("forge.account_dashboard.courses_completed")
1678
- : course.currentLessonId
1679
- ? tr("forge.account_dashboard.courses_continue")
1680
- : 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>
1681
1580
  </div>
1682
- </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>
1683
1597
  ))}
1684
1598
  </div>
1685
1599
  )}
@@ -1691,38 +1605,61 @@ function CoursesTab({ ctx, courses }: { ctx: TabContext; courses: MyCourse[] })
1691
1605
 
1692
1606
  function OrdersTab({ ctx }: { ctx: TabContext }) {
1693
1607
  const { user } = usePublicAuth();
1694
- const { t, card } = useCardStyles();
1608
+ const { t, card, list, row, meta } = useCardStyles();
1695
1609
  const tr = useForgeT();
1696
1610
  const { formatAmount, formatDate, currency } = ctx;
1697
- const { data: orders, isLoading } = useUserOrders(user?.id);
1611
+ const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = useUserOrdersPaged(user?.id);
1612
+ const orders = data?.pages.flat();
1698
1613
 
1699
1614
  const formatStatus = (s: OrderStatus) => s.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
1700
1615
 
1701
1616
  return (
1702
1617
  <div style={card}>
1703
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
1704
- {tr("forge.account_dashboard.orders_title")}
1705
- </h2>
1706
1618
  {isLoading ? (
1707
1619
  <Loading />
1708
1620
  ) : orders && orders.length > 0 ? (
1709
- <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
1621
+ <div style={list}>
1710
1622
  {orders
1711
1623
  .filter((o) => ![OrderStatus.InitiatedPayment, OrderStatus.PaymentFailed].includes(o.status))
1712
1624
  .map((order) => (
1713
- <div key={order.id} style={{ border: `1px solid ${t.primary}20`, borderRadius: t.cornerRadius, padding: 16 }}>
1714
- <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
+ >
1715
1635
  <div>
1716
- <div style={{ fontWeight: 600 }}>
1717
- {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>
1718
1644
  </div>
1719
- <div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(order.createdAt)}</div>
1720
1645
  </div>
1721
- <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>
1722
1649
  </div>
1723
1650
  {order.deliveryGroups.map((group) =>
1724
1651
  group.items.map((item, i) => (
1725
- <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
+ >
1726
1663
  <span>
1727
1664
  {tr("forge.account_dashboard.order_line", { title: item.title, quantity: item.quantity })}
1728
1665
  {/* Which version. Two versions of one release carry the
@@ -1732,20 +1669,19 @@ function OrdersTab({ ctx }: { ctx: TabContext }) {
1732
1669
  the sale, not re-resolved. */}
1733
1670
  <CartLineOptions item={item} accent={t.primary} size="small" style={{ marginTop: 2 }} />
1734
1671
  </span>
1735
- <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>
1736
1675
  </div>
1737
1676
  )),
1738
1677
  )}
1739
- <div style={{ display: "flex", justifyContent: "space-between", fontWeight: 700, marginTop: 12, paddingTop: 12, borderTop: `1px solid ${t.primary}20` }}>
1740
- <span>{tr("forge.account_dashboard.total")}</span>
1741
- <span style={{ color: t.primary }}>{formatAmount(order.totalAmount, order.currency || currency)}</span>
1742
- </div>
1743
1678
  </div>
1744
1679
  ))}
1745
1680
  </div>
1746
1681
  ) : (
1747
- <p style={{ opacity: 0.8 }}>{tr("forge.account_dashboard.orders_empty")}</p>
1682
+ <EmptySection>{tr("forge.account_dashboard.orders_empty")}</EmptySection>
1748
1683
  )}
1684
+ <ShowMore hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} onMore={() => void fetchNextPage()} />
1749
1685
  </div>
1750
1686
  );
1751
1687
  }
@@ -1754,31 +1690,28 @@ function OrdersTab({ ctx }: { ctx: TabContext }) {
1754
1690
 
1755
1691
  function SavedTab() {
1756
1692
  const { user } = usePublicAuth();
1757
- const { t, card } = useCardStyles();
1693
+ const { card, list, row, meta } = useCardStyles();
1758
1694
  const tr = useForgeT();
1759
1695
  const { data, isLoading } = useSavedPosts(user?.id);
1760
1696
  const posts = data?.data;
1761
1697
 
1762
1698
  return (
1763
1699
  <div style={card}>
1764
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
1765
- {tr("forge.account_dashboard.saved_title")}
1766
- </h2>
1767
1700
  {isLoading ? (
1768
1701
  <Loading />
1769
1702
  ) : posts && posts.length > 0 ? (
1770
- <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 16 }}>
1703
+ <div style={list}>
1771
1704
  {posts.map((post) => (
1772
- <div key={post.id} style={{ border: `1px solid ${t.primary}20`, borderRadius: t.cornerRadius, padding: 16 }}>
1773
- <p style={{ fontSize: 14, marginBottom: 8 }}>{post.caption}</p>
1774
- <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 }}>
1775
1708
  {tr("forge.account_dashboard.saved_post_stats", { likes: post.likes, comments: post.comments })}
1776
1709
  </p>
1777
1710
  </div>
1778
1711
  ))}
1779
1712
  </div>
1780
1713
  ) : (
1781
- <p style={{ opacity: 0.8 }}>{tr("forge.account_dashboard.saved_empty")}</p>
1714
+ <EmptySection>{tr("forge.account_dashboard.saved_empty")}</EmptySection>
1782
1715
  )}
1783
1716
  </div>
1784
1717
  );
@@ -1788,7 +1721,7 @@ function SavedTab() {
1788
1721
 
1789
1722
  function AccountTab({ ctx }: { ctx: TabContext }) {
1790
1723
  const { user, refetchUser, logout } = usePublicAuth();
1791
- const { t, card, button, ghostButton, input } = useCardStyles();
1724
+ const { t, button, ghostButton, input, meta } = useCardStyles();
1792
1725
  const tr = useForgeT();
1793
1726
  const { formatDate } = ctx;
1794
1727
 
@@ -1851,15 +1784,23 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
1851
1784
  }
1852
1785
  };
1853
1786
 
1854
- const sectionTitle: React.CSSProperties = { fontSize: 16, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily };
1855
- 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 };
1856
1797
 
1857
1798
  return (
1858
- <div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
1799
+ <div style={{ display: "flex", flexDirection: "column", borderTop: `1px solid ${t.border}`, color: t.text }}>
1859
1800
  {/* Profile */}
1860
1801
  <form onSubmit={onUpdateProfile} style={card}>
1861
1802
  <h2 style={sectionTitle}>{tr("forge.account_dashboard.profile_section_title")}</h2>
1862
- <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 }}>
1863
1804
  <div style={{ flex: 1 }}>
1864
1805
  <label style={label}>{tr("forge.account_dashboard.first_name")}</label>
1865
1806
  <input value={firstName} onChange={(e) => setFirstName(e.target.value)} style={input} />
@@ -1869,7 +1810,7 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
1869
1810
  <input value={lastName} onChange={(e) => setLastName(e.target.value)} style={input} />
1870
1811
  </div>
1871
1812
  </div>
1872
- <p style={{ fontSize: 13, opacity: 0.7, marginBottom: 12 }}>
1813
+ <p style={{ ...meta, margin: "0 0 16px" }}>
1873
1814
  {tr("forge.account_dashboard.email_readonly", { email: user?.email ?? "" })}
1874
1815
  </p>
1875
1816
  {profileMsg && <p style={{ fontSize: 13, marginBottom: 12 }}>{profileMsg}</p>}
@@ -1881,20 +1822,39 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
1881
1822
  {/* Password */}
1882
1823
  <form onSubmit={onChangePassword} style={card}>
1883
1824
  <h2 style={sectionTitle}>{tr("forge.account_dashboard.password_section_title")}</h2>
1884
- {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
+ )}
1885
1830
  <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
1886
1831
  <div>
1887
1832
  <label style={label}>{tr("forge.account_dashboard.current_password")}</label>
1888
- <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
+ />
1889
1839
  </div>
1890
- <div style={{ display: "flex", gap: 12 }}>
1840
+ <div className="fg:flex fg:flex-col fg:gap-3 fg:sm:flex-row">
1891
1841
  <div style={{ flex: 1 }}>
1892
1842
  <label style={label}>{tr("forge.account_dashboard.new_password")}</label>
1893
- <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
+ />
1894
1849
  </div>
1895
1850
  <div style={{ flex: 1 }}>
1896
1851
  <label style={label}>{tr("forge.account_dashboard.confirm_password")}</label>
1897
- <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
+ />
1898
1858
  </div>
1899
1859
  </div>
1900
1860
  </div>
@@ -1906,17 +1866,15 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
1906
1866
  {/* Data rights */}
1907
1867
  <div style={card}>
1908
1868
  <h2 style={sectionTitle}>{tr("forge.account_dashboard.data_section_title")}</h2>
1909
- <p style={{ fontSize: 13, opacity: 0.7, marginBottom: 12 }}>
1910
- {tr("forge.account_dashboard.data_export_description")}
1911
- </p>
1869
+ <p style={{ ...meta, margin: "0 0 12px" }}>{tr("forge.account_dashboard.data_export_description")}</p>
1912
1870
  <button onClick={onExport} style={ghostButton}>
1913
1871
  {tr("forge.account_dashboard.export_data")}
1914
1872
  </button>
1915
1873
  </div>
1916
1874
 
1917
1875
  {/* Delete account */}
1918
- <div style={{ ...card, borderColor: "#ef444533" }}>
1919
- <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>
1920
1878
  {scheduledAt ? (
1921
1879
  <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
1922
1880
  <p style={{ fontSize: 14 }}>
@@ -1930,7 +1888,7 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
1930
1888
  <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
1931
1889
  <p style={{ fontSize: 14 }}>{tr("forge.account_dashboard.deletion_confirm_body")}</p>
1932
1890
  <div style={{ display: "flex", gap: 12 }}>
1933
- <button onClick={() => requestDeletion.mutate()} style={{ ...button, background: "#ef4444" }}>
1891
+ <button onClick={() => requestDeletion.mutate()} style={button}>
1934
1892
  {tr("forge.account_dashboard.deletion_confirm")}
1935
1893
  </button>
1936
1894
  <button onClick={() => setConfirmDelete(false)} style={ghostButton}>
@@ -1939,10 +1897,7 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
1939
1897
  </div>
1940
1898
  </div>
1941
1899
  ) : (
1942
- <button
1943
- onClick={() => setConfirmDelete(true)}
1944
- style={{ padding: "8px 14px", borderRadius: t.cornerRadius, background: "#ef444411", color: "#ef4444", border: "1px solid #ef444433", cursor: "pointer" }}
1945
- >
1900
+ <button onClick={() => setConfirmDelete(true)} style={ghostButton}>
1946
1901
  {tr("forge.account_dashboard.request_deletion")}
1947
1902
  </button>
1948
1903
  )}
@@ -1953,85 +1908,282 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
1953
1908
 
1954
1909
  // ---- Notifications tab ------------------------------------------------------
1955
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
+
1956
2027
  function NotificationsTab() {
1957
- const { t, card } = useCardStyles();
2028
+ const { t, card, list, row, alert } = useCardStyles();
1958
2029
  const tr = useForgeT();
1959
- const { data } = useNotificationPreferences();
1960
- 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);
1961
2036
 
1962
- const [preferences, setPreferences] = useState<NotificationPreferences>({ newPosts: true, chatMessages: true });
2037
+ if (isLoading || !data) return <Loading />;
1963
2038
 
1964
- useEffect(() => {
1965
- if (data?.preferences) {
1966
- setPreferences({
1967
- newPosts: data.preferences.newPosts !== false,
1968
- 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;
1969
2055
  });
1970
2056
  }
1971
- }, [data]);
2057
+ };
1972
2058
 
1973
- const toggle = async (key: keyof NotificationPreferences) => {
1974
- const value = !preferences[key];
1975
- setPreferences((prev) => ({ ...prev, [key]: value }));
2059
+ const chooseCadence = async (value: DigestCadence) => {
2060
+ setError(null);
2061
+ setCadence(value);
1976
2062
  try {
1977
- await updatePreference.mutateAsync({ key, value });
2063
+ await update.mutateAsync({ digestCadence: value });
1978
2064
  } catch {
1979
- setPreferences((prev) => ({ ...prev, [key]: !value }));
2065
+ setError(tr("forge.account_dashboard.notify_save_error"));
2066
+ } finally {
2067
+ setCadence(null);
1980
2068
  }
1981
2069
  };
1982
2070
 
1983
- const Row = ({ k, title, desc }: { k: keyof NotificationPreferences; title: string; desc: string }) => (
1984
- <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
1985
- <div>
1986
- <div style={{ fontWeight: 600 }}>{title}</div>
1987
- <div style={{ fontSize: 13, opacity: 0.7 }}>{desc}</div>
1988
- </div>
2071
+ const chip = (key: string, label: string, typeLabel: string) => {
2072
+ const on = isOn(key);
2073
+ return (
1989
2074
  <button
1990
- 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)}
1991
2081
  style={{
1992
- position: "relative",
1993
- width: 44,
1994
- height: 24,
2082
+ display: "inline-flex",
2083
+ alignItems: "center",
2084
+ gap: 6,
2085
+ minHeight: 34,
2086
+ padding: "6px 12px",
1995
2087
  borderRadius: 999,
1996
- 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,
1997
2096
  cursor: "pointer",
1998
- background: preferences[k] ? t.primary : `${t.text}40`,
1999
2097
  }}
2000
- aria-pressed={preferences[k]}
2001
2098
  >
2002
- <span
2003
- style={{
2004
- position: "absolute",
2005
- top: 4,
2006
- left: preferences[k] ? 24 : 4,
2007
- width: 16,
2008
- height: 16,
2009
- borderRadius: "50%",
2010
- background: "#fff",
2011
- transition: "left 0.15s",
2012
- }}
2013
- />
2099
+ {on ? <Check size={14} aria-hidden style={{ color: t.primary }} /> : null}
2100
+ {label}
2014
2101
  </button>
2015
- </div>
2016
- );
2102
+ );
2103
+ };
2017
2104
 
2018
2105
  return (
2019
- <div style={card}>
2020
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
2021
- {tr("forge.account_dashboard.notifications_title")}
2022
- </h2>
2023
- <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
2024
- <Row
2025
- k="newPosts"
2026
- title={tr("forge.account_dashboard.notify_new_posts_title")}
2027
- desc={tr("forge.account_dashboard.notify_new_posts_description")}
2028
- />
2029
- <Row
2030
- k="chatMessages"
2031
- title={tr("forge.account_dashboard.notify_chat_title")}
2032
- desc={tr("forge.account_dashboard.notify_chat_description")}
2033
- />
2034
- </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>
2035
2187
  </div>
2036
2188
  );
2037
2189
  }