@tribe-nest/forge 3.59.0 → 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/package.json +2 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CartContext.tsx +34 -1
  4. package/src/contexts/CommunityContext.tsx +192 -0
  5. package/src/contexts/PublicAuthContext.tsx +15 -3
  6. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  7. package/src/data/queries/useAccount.ts +33 -2
  8. package/src/data/queries/useAnalytics.ts +6 -6
  9. package/src/data/queries/useCallChat.ts +2 -2
  10. package/src/data/queries/useChat.ts +52 -40
  11. package/src/data/queries/useCheckouts.ts +2 -1
  12. package/src/data/queries/useCoachingAvailability.ts +10 -1
  13. package/src/data/queries/useCommunities.ts +345 -0
  14. package/src/data/queries/useCommunity.ts +450 -102
  15. package/src/data/queries/useCommunityAgent.ts +247 -0
  16. package/src/data/queries/useCommunityCalendar.ts +150 -0
  17. package/src/data/queries/useCommunityClassroom.ts +75 -0
  18. package/src/data/queries/useCommunityMembers.ts +108 -0
  19. package/src/data/queries/useCommunityModeration.ts +327 -0
  20. package/src/data/queries/useCommunityWiki.ts +137 -0
  21. package/src/data/queries/useCourses.ts +2 -0
  22. package/src/data/queries/useEmailSignIn.ts +79 -0
  23. package/src/data/queries/useEvents.ts +6 -1
  24. package/src/data/queries/useForms.ts +5 -1
  25. package/src/data/queries/useLeadMagnet.ts +6 -2
  26. package/src/data/queries/useMembership.ts +32 -7
  27. package/src/data/queries/useMembershipCalls.ts +116 -7
  28. package/src/data/queries/useMyBookings.ts +24 -1
  29. package/src/data/queries/useMyTickets.ts +24 -1
  30. package/src/data/queries/useNotifications.ts +6 -2
  31. package/src/data/queries/useOrders.ts +2 -1
  32. package/src/data/queries/usePWA.ts +111 -35
  33. package/src/data/queries/usePostCollections.ts +28 -16
  34. package/src/data/queries/usePosts.ts +16 -14
  35. package/src/data/queries/useSiteApp.ts +130 -0
  36. package/src/data/queries/useSubscriptions.ts +28 -9
  37. package/src/i18n/de.json +891 -2
  38. package/src/i18n/en.json +891 -2
  39. package/src/index.ts +36 -7
  40. package/src/runtime/pages.spec.ts +36 -0
  41. package/src/runtime/pages.ts +52 -27
  42. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  43. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  44. package/src/types/models.ts +11 -0
  45. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  46. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  47. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  48. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  49. package/src/ui/analytics/useMetaEvents.ts +78 -0
  50. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  51. package/src/ui/format/membershipGate.ts +19 -15
  52. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  53. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  54. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  55. package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
  56. package/src/ui/headless/_tests/communityHarness.tsx +212 -0
  57. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  58. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  59. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  60. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  61. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  62. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  63. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  64. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  65. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  66. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +596 -0
  67. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +179 -0
  68. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  69. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +295 -0
  70. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  71. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  72. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  73. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  74. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  75. package/src/ui/headless/auth/useSignupForm.ts +30 -3
  76. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  77. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  78. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  79. package/src/ui/headless/communities/index.ts +96 -0
  80. package/src/ui/headless/communities/paths.ts +36 -0
  81. package/src/ui/headless/communities/useCommunity.ts +140 -0
  82. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  83. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  84. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  85. package/src/ui/headless/communities/useCommunityHome.ts +91 -0
  86. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  87. package/src/ui/headless/communities/useCommunityJoin.ts +494 -0
  88. package/src/ui/headless/communities/useCommunityMembership.ts +182 -0
  89. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  90. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  91. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  92. package/src/ui/headless/community/communityPoll.ts +297 -0
  93. package/src/ui/headless/community/countries.ts +123 -0
  94. package/src/ui/headless/community/index.ts +81 -1
  95. package/src/ui/headless/community/linkPreview.ts +71 -0
  96. package/src/ui/headless/community/richLexical.ts +27 -2
  97. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  98. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  99. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  100. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  101. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  102. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  103. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  104. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  105. package/src/ui/headless/downloads/index.ts +8 -0
  106. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  107. package/src/ui/headless/index.ts +21 -4
  108. package/src/ui/headless/memberHome/index.ts +11 -1
  109. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  110. package/src/ui/headless/memberHome/useMemberHomeNav.ts +84 -44
  111. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  112. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  113. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  114. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  115. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  116. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  117. package/src/ui/headless/portal/index.ts +57 -0
  118. package/src/ui/headless/portal/usePortal.ts +274 -0
  119. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  120. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  121. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  122. package/src/ui/headless/posts/upsell.ts +54 -8
  123. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  124. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  125. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  126. package/src/ui/headless/posts/usePostItem.ts +6 -5
  127. package/src/ui/headless/siteApp/appBar.ts +102 -0
  128. package/src/ui/headless/siteApp/appHome.ts +344 -0
  129. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  130. package/src/ui/headless/siteApp/index.ts +58 -0
  131. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  132. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  133. package/src/ui/headless/work/index.ts +2 -0
  134. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  135. package/src/ui/index.ts +28 -12
  136. package/src/ui/styled/AccountDashboard.tsx +754 -602
  137. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  138. package/src/ui/styled/ChatRoom.tsx +126 -16
  139. package/src/ui/styled/Checkout.tsx +8 -0
  140. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  141. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  142. package/src/ui/styled/CoachingDetail.tsx +2 -0
  143. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  144. package/src/ui/styled/CourseDetail.tsx +2 -0
  145. package/src/ui/styled/DownloadPage.tsx +263 -0
  146. package/src/ui/styled/EventConfirmation.tsx +10 -0
  147. package/src/ui/styled/EventDetail.tsx +10 -0
  148. package/src/ui/styled/FilmLibrary.tsx +22 -9
  149. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  150. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  151. package/src/ui/styled/MembershipTiers.tsx +49 -11
  152. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  153. package/src/ui/styled/Paywall.tsx +1 -1
  154. package/src/ui/styled/ProductDetail.tsx +12 -0
  155. package/src/ui/styled/PwaRegistration.tsx +5 -1
  156. package/src/ui/styled/SignupForm.tsx +18 -3
  157. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +6 -6
  158. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  159. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  160. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  161. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  162. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  163. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  164. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  165. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  166. package/src/ui/styled/app/AppBar.tsx +298 -0
  167. package/src/ui/styled/app/AppHome.tsx +1139 -0
  168. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  169. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  170. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  171. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  172. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  173. package/src/ui/styled/app/index.ts +7 -0
  174. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  175. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  176. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  177. package/src/ui/styled/communities/CommunityAbout.tsx +145 -0
  178. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  179. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  180. package/src/ui/styled/communities/CommunityHome.tsx +298 -0
  181. package/src/ui/styled/communities/CommunityJoinSheet.tsx +544 -0
  182. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +249 -0
  183. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  184. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  185. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  186. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  187. package/src/ui/styled/communities/MyMembershipsList.tsx +115 -0
  188. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  189. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  190. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  191. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  192. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  193. package/src/ui/styled/communities/index.ts +19 -0
  194. package/src/ui/styled/communities/shared.tsx +177 -0
  195. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  196. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  197. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  198. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  199. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  200. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  201. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  202. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  203. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  204. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  205. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  206. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  207. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  208. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  209. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  210. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  211. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  212. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  213. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  214. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  215. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  216. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  217. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  218. package/src/ui/styled/community/index.ts +37 -2
  219. package/src/ui/styled/forge-utilities.css +253 -0
  220. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  221. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  222. package/src/ui/styled/members/MemberCallsCalendar.tsx +41 -54
  223. package/src/ui/styled/members/MemberHomeLayout.tsx +327 -29
  224. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  225. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  226. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  227. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  228. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  229. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  230. package/src/ui/styled/members/index.ts +8 -0
  231. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  232. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  233. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  234. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  235. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  236. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  237. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  238. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  239. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  240. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  241. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  242. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  243. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  244. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  245. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  246. package/src/ui/styled/portal/index.ts +13 -0
  247. package/src/ui/styled/portal/portalNav.ts +260 -0
  248. package/src/ui/styled/portal/shared.tsx +206 -0
  249. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  250. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  251. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  252. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  253. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  254. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  255. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  256. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  257. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  258. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  259. package/src/ui/styled/work/index.ts +1 -1
  260. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  261. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  262. package/src/utils/landing.ts +19 -6
  263. package/src/utils/metaEvents.ts +302 -0
  264. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  265. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  266. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
@@ -0,0 +1,229 @@
1
+ /**
2
+ * When the site app asks to be installed, and when it offers notifications.
3
+ * Contract: docs/proposals/site-app-and-website-menu.md, section 8.
4
+ *
5
+ * Install is asked at a moment the person just got something worth coming back
6
+ * to (they joined, bought, booked, accepted an invite), worded for that moment.
7
+ * Notifications are offered once, on the first launch of the installed app.
8
+ *
9
+ * The decisions are pure functions; the storage around them is guarded,
10
+ * because private mode and blocked storage throw on access.
11
+ */
12
+
13
+ import { shareRuntimeValue } from "../../../runtime/registry";
14
+
15
+ // ---- Install requests --------------------------------------------------------
16
+
17
+ export const APP_INSTALL_CONTEXTS = ["joined_community", "purchase", "booking", "invite_accepted"] as const;
18
+
19
+ export type AppInstallContext = (typeof APP_INSTALL_CONTEXTS)[number];
20
+
21
+ export interface AppInstallRequest {
22
+ context: AppInstallContext;
23
+ /** Epoch ms. */
24
+ at: number;
25
+ }
26
+
27
+ /** Remembered "Not now": the prompt stays away this long. */
28
+ export const APP_INSTALL_SNOOZE_MS = 14 * 24 * 60 * 60 * 1000;
29
+
30
+ /**
31
+ * How long a request waits for a page that can show it. A success usually
32
+ * navigates (into the community, back from a payment), so the request rides
33
+ * the session to the next page; a stale one is not shown out of nowhere.
34
+ */
35
+ export const APP_INSTALL_REQUEST_TTL_MS = 30 * 60 * 1000;
36
+
37
+ export const APP_INSTALL_DISMISSED_KEY = "forge-app-install-dismissed-at";
38
+ export const APP_INSTALL_PENDING_KEY = "forge-app-install-pending";
39
+ export const APP_PUSH_ANSWER_KEY = "forge-app-push-answer";
40
+
41
+ /**
42
+ * The waiting request and who is listening, shared through the runtime
43
+ * registry: the success that asks (a hosted page, or the site's own checkout)
44
+ * and the prompt that shows it may sit in different copies of Forge.
45
+ */
46
+ const shared = () =>
47
+ shareRuntimeValue<{ pending: AppInstallRequest | null; listeners: Set<() => void> }>("state:app-install-request", () => ({
48
+ pending: null,
49
+ listeners: new Set(),
50
+ }));
51
+
52
+ function isContext(value: unknown): value is AppInstallContext {
53
+ return typeof value === "string" && (APP_INSTALL_CONTEXTS as readonly string[]).includes(value);
54
+ }
55
+
56
+ function readStorage(kind: "local" | "session", key: string): string | null {
57
+ try {
58
+ const store = kind === "local" ? window.localStorage : window.sessionStorage;
59
+ return store.getItem(key);
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function writeStorage(kind: "local" | "session", key: string, value: string | null): void {
66
+ try {
67
+ const store = kind === "local" ? window.localStorage : window.sessionStorage;
68
+ if (value === null) store.removeItem(key);
69
+ else store.setItem(key, value);
70
+ } catch {
71
+ /* private mode or blocked storage: the prompt is best effort */
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Ask the site app to offer itself, worded for `context`. Call it from a
77
+ * success: a community joined, a purchase confirmed, a booking made, an invite
78
+ * accepted. Safe to call anywhere, any number of times; the app decides whether
79
+ * a prompt is shown (never when installed, never when the creator turned it
80
+ * off, never within 14 days of "Not now").
81
+ */
82
+ export function requestAppInstall(context: AppInstallContext): void {
83
+ if (typeof window === "undefined" || !isContext(context)) return;
84
+ const request: AppInstallRequest = { context, at: Date.now() };
85
+ shared().pending = request;
86
+ writeStorage("session", APP_INSTALL_PENDING_KEY, JSON.stringify(request));
87
+ shared().listeners.forEach((listener) => listener());
88
+ }
89
+
90
+ /** The request waiting to be shown, if one is fresh. */
91
+ export function readPendingAppInstall(now: number = Date.now()): AppInstallRequest | null {
92
+ let request = shared().pending;
93
+ if (!request && typeof window !== "undefined") {
94
+ const raw = readStorage("session", APP_INSTALL_PENDING_KEY);
95
+ if (raw) {
96
+ try {
97
+ const parsed = JSON.parse(raw) as Partial<AppInstallRequest>;
98
+ if (isContext(parsed.context) && typeof parsed.at === "number") request = { context: parsed.context, at: parsed.at };
99
+ } catch {
100
+ request = null;
101
+ }
102
+ }
103
+ }
104
+ if (!request || now - request.at > APP_INSTALL_REQUEST_TTL_MS || request.at > now + 60_000) return null;
105
+ return request;
106
+ }
107
+
108
+ /** The request was shown or turned down: forget it. */
109
+ export function clearPendingAppInstall(): void {
110
+ shared().pending = null;
111
+ if (typeof window !== "undefined") writeStorage("session", APP_INSTALL_PENDING_KEY, null);
112
+ shared().listeners.forEach((listener) => listener());
113
+ }
114
+
115
+ /** Be told when a request arrives or is cleared. */
116
+ export function subscribeAppInstallRequests(listener: () => void): () => void {
117
+ shared().listeners.add(listener);
118
+ return () => {
119
+ shared().listeners.delete(listener);
120
+ };
121
+ }
122
+
123
+ /** When "Not now" was last pressed, or null. */
124
+ export function readInstallDismissedAt(): number | null {
125
+ if (typeof window === "undefined") return null;
126
+ const raw = readStorage("local", APP_INSTALL_DISMISSED_KEY);
127
+ const value = raw ? Number(raw) : NaN;
128
+ return Number.isFinite(value) ? value : null;
129
+ }
130
+
131
+ export function rememberInstallDismissed(now: number = Date.now()): void {
132
+ if (typeof window !== "undefined") writeStorage("local", APP_INSTALL_DISMISSED_KEY, String(now));
133
+ }
134
+
135
+ export type AppInstallMode = "native" | "ios";
136
+
137
+ /**
138
+ * Whether to offer the install, and how. Pure.
139
+ *
140
+ * - `installPrompt` is the website's setting; anything but `true` (off, or not
141
+ * known yet) is no.
142
+ * - Running installed is no: there is nothing to install.
143
+ * - "Not now" within the last 14 days is no.
144
+ * - A held native prompt (Android, desktop Chromium) is `native`; iPhone and
145
+ * iPad Safari, which have none, get the two `ios` steps. Anything else is no.
146
+ */
147
+ export function decideAppInstall(input: {
148
+ installPrompt: boolean | null | undefined;
149
+ isStandalone: boolean;
150
+ isIOS: boolean;
151
+ hasNativePrompt: boolean;
152
+ dismissedAt: number | null;
153
+ now: number;
154
+ }): AppInstallMode | null {
155
+ if (input.installPrompt !== true) return null;
156
+ if (input.isStandalone) return null;
157
+ if (input.dismissedAt !== null && input.now - input.dismissedAt < APP_INSTALL_SNOOZE_MS) return null;
158
+ if (input.hasNativePrompt) return "native";
159
+ if (input.isIOS) return "ios";
160
+ return null;
161
+ }
162
+
163
+ // ---- Notifications on first launch ------------------------------------------------
164
+
165
+ export type AppPushAnswer = "accepted" | "declined";
166
+
167
+ export function readPushAnswer(): AppPushAnswer | null {
168
+ if (typeof window === "undefined") return null;
169
+ const raw = readStorage("local", APP_PUSH_ANSWER_KEY);
170
+ return raw === "accepted" || raw === "declined" ? raw : null;
171
+ }
172
+
173
+ export function rememberPushAnswer(answer: AppPushAnswer): void {
174
+ if (typeof window !== "undefined") writeStorage("local", APP_PUSH_ANSWER_KEY, answer);
175
+ }
176
+
177
+ /**
178
+ * Whether to offer notifications now. Pure.
179
+ *
180
+ * Only in the installed app (standalone), only when the website's `pushPrompt`
181
+ * is on, only to someone signed in (a subscription belongs to an account), only
182
+ * where the browser can do push, only while the browser has not been asked
183
+ * (`default`: granted needs no asking, denied cannot be asked again), and only
184
+ * once: any earlier answer here ends it.
185
+ */
186
+ export function decideAppPush(input: {
187
+ pushPrompt: boolean | null | undefined;
188
+ isStandalone: boolean;
189
+ isSignedIn: boolean;
190
+ isSupported: boolean;
191
+ permission: NotificationPermission | null;
192
+ answer: AppPushAnswer | null;
193
+ }): boolean {
194
+ return (
195
+ input.pushPrompt === true &&
196
+ input.isStandalone &&
197
+ input.isSignedIn &&
198
+ input.isSupported &&
199
+ input.permission === "default" &&
200
+ input.answer === null
201
+ );
202
+ }
203
+
204
+ /**
205
+ * The push categories offered on first launch, each the notification types it
206
+ * switches (`<type>.push` in the member's preference matrix). A category is
207
+ * offered only when the server's matrix knows at least one of its types.
208
+ */
209
+ export const APP_PUSH_CATEGORIES = [
210
+ { key: "messages", types: ["dm", "mention", "reply", "chat_message", "client_message"] },
211
+ { key: "community", types: ["new_post", "new_community_post", "reaction", "mod"] },
212
+ { key: "events", types: ["event_reminder", "stream_live"] },
213
+ ] as const;
214
+
215
+ export type AppPushCategoryKey = (typeof APP_PUSH_CATEGORIES)[number]["key"];
216
+
217
+ /** The preference patch for the chosen categories: every known type of each offered category, on or off. */
218
+ export function pushPreferencePatch(
219
+ chosen: Readonly<Record<AppPushCategoryKey, boolean>>,
220
+ knownTypes: ReadonlySet<string>,
221
+ ): Record<string, boolean> {
222
+ const patch: Record<string, boolean> = {};
223
+ for (const category of APP_PUSH_CATEGORIES) {
224
+ for (const type of category.types) {
225
+ if (knownTypes.has(type)) patch[`${type}.push`] = chosen[category.key];
226
+ }
227
+ }
228
+ return patch;
229
+ }
@@ -0,0 +1,58 @@
1
+ // The site app (one installable app per website): the bar model, the home
2
+ // sections, and the install and notification prompts.
3
+ export {
4
+ APP_BAR_HREFS,
5
+ buildAppBar,
6
+ isAppBarItemActive,
7
+ normaliseBarItems,
8
+ type AppBarItem,
9
+ type AppBarKey,
10
+ type AppBarModel,
11
+ } from "./appBar";
12
+ export {
13
+ APP_HOME_LIST_LIMIT,
14
+ APP_PORTAL_TILES,
15
+ attentionAction,
16
+ buildAppHomeSections,
17
+ buildPortalTiles,
18
+ courseProgress,
19
+ greetingPart,
20
+ isSafeLinkHref,
21
+ pickLearningCourse,
22
+ pickNextCall,
23
+ pickNextTicket,
24
+ upcomingEvents,
25
+ type AppAttentionAction,
26
+ type AppCommunityCard,
27
+ type AppCourseProgress,
28
+ type AppHomeData,
29
+ type AppHomeSectionView,
30
+ type AppLearningCourse,
31
+ type AppNextCall,
32
+ type AppNextTicket,
33
+ type AppPortalTile,
34
+ type AppPortalTileKey,
35
+ } from "./appHome";
36
+ export { useAppHome, useAppCommunityUnread, type UseAppHomeResult } from "./useAppHome";
37
+ export { useAppInstallRequest } from "./useAppInstallRequest";
38
+ export {
39
+ APP_INSTALL_CONTEXTS,
40
+ APP_INSTALL_SNOOZE_MS,
41
+ APP_PUSH_CATEGORIES,
42
+ clearPendingAppInstall,
43
+ decideAppInstall,
44
+ decideAppPush,
45
+ pushPreferencePatch,
46
+ readInstallDismissedAt,
47
+ readPendingAppInstall,
48
+ readPushAnswer,
49
+ rememberInstallDismissed,
50
+ rememberPushAnswer,
51
+ requestAppInstall,
52
+ subscribeAppInstallRequests,
53
+ type AppInstallContext,
54
+ type AppInstallMode,
55
+ type AppInstallRequest,
56
+ type AppPushAnswer,
57
+ type AppPushCategoryKey,
58
+ } from "./appPrompts";
@@ -0,0 +1,210 @@
1
+ import { useContext, useMemo } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { useForge } from "../../../provider/ForgeProvider";
4
+ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
5
+ import {
6
+ SITE_APP_DEFAULT_SECTIONS,
7
+ useSiteApp,
8
+ type SiteAppHomeSection,
9
+ type SiteAppSectionKey,
10
+ } from "../../../data/queries/useSiteApp";
11
+ import { useMyCommunityMemberships, usePublicCommunities } from "../../../data/queries/useCommunities";
12
+ import { communityKeys } from "../../../data/queries/useCommunity";
13
+ import { useMyBookings } from "../../../data/queries/useMyBookings";
14
+ import { useMyTickets } from "../../../data/queries/useMyTickets";
15
+ import { useMyCourses } from "../../../data/queries/useMyCourses";
16
+ import { useCourseAccess } from "../../../data/queries/useCourseAccess";
17
+ import { useGetCourses } from "../../../data/queries/useCourses";
18
+ import { useGetProducts } from "../../../data/queries/useProducts";
19
+ import { membershipCallKeys, type MyMembershipCall } from "../../../data/queries/useMembershipCalls";
20
+ import type { IEvent } from "../../../types/models";
21
+ import { internalPath } from "../../../runtime/pages";
22
+ import { getMembershipAccess } from "../../../utils/membershipAccess";
23
+ import { usePortalSummary } from "../portal/usePortal";
24
+ import { communityAboutPath, communityHomePath } from "../communities/paths";
25
+ import {
26
+ buildAppHomeSections,
27
+ buildPortalTiles,
28
+ courseProgress,
29
+ pickLearningCourse,
30
+ pickNextCall,
31
+ pickNextTicket,
32
+ upcomingEvents,
33
+ type AppCommunityCard,
34
+ type AppHomeSectionView,
35
+ type AppLearningCourse,
36
+ } from "./appHome";
37
+
38
+ export interface UseAppHomeResult {
39
+ /** The settings or the account are still being read: draw a loading state. */
40
+ isLoading: boolean;
41
+ /** Signed out: the home is the sign-in screen. */
42
+ isSignedIn: boolean;
43
+ /** The app's name (the website's, unless the creator renamed the app). */
44
+ appName: string | null;
45
+ iconUrl: string | null;
46
+ /** The member's first name, for the greeting. */
47
+ firstName: string | null;
48
+ /** The sections to draw, in order. Empty while loading. */
49
+ sections: AppHomeSectionView[];
50
+ }
51
+
52
+ /**
53
+ * The data behind the site app's home (`/i/app`).
54
+ *
55
+ * Reads the website's app settings, then only what the configured sections
56
+ * need: a section the creator hid costs no request. Signed out, nothing
57
+ * member-shaped is fetched at all. Built on the same public hooks the portal
58
+ * and the communities use, so the home never disagrees with the page a tile
59
+ * opens. `buildAppHomeSections` decides order, visibility and the empty-skip.
60
+ */
61
+ export function useAppHome(opts: { now?: Date } = {}): UseAppHomeResult {
62
+ const { client, profileId } = useForge();
63
+ const auth = useContext(PublicAuthContext);
64
+ const user = auth?.user ?? null;
65
+ const signedIn = !!user;
66
+ const now = opts.now ?? new Date();
67
+
68
+ const app = useSiteApp();
69
+ // An unknown host (a preview) answers 404: the contract's defaults stand in.
70
+ const config: readonly SiteAppHomeSection[] | undefined =
71
+ app.data?.homeSections ?? (app.isError ? SITE_APP_DEFAULT_SECTIONS : undefined);
72
+ const wants = (key: SiteAppSectionKey) =>
73
+ !!config?.some((entry) => entry.type === "builtin" && entry.key === key && entry.visible !== false);
74
+
75
+ const summary = usePortalSummary();
76
+ const sectionsCount = summary.data?.sections;
77
+
78
+ const wantsCommunities = wants("communities") || wants("explore_communities");
79
+ const mine = useMyCommunityMemberships({ enabled: signedIn && wantsCommunities });
80
+ const everyCommunity = usePublicCommunities({ enabled: wantsCommunities });
81
+
82
+ const comingUp = signedIn && wants("coming_up");
83
+ const calls = useQuery<MyMembershipCall[]>({
84
+ // The member's whole call calendar, across communities and courses.
85
+ queryKey: [...membershipCallKeys.mine(profileId), "now", "open", "app-home", user?.id ?? null],
86
+ queryFn: async () =>
87
+ (await client.get("/public/scheduled-calls/mine", { params: { profileId, limit: 5 } })).data as MyMembershipCall[],
88
+ enabled: comingUp && !!profileId && !!client,
89
+ refetchInterval: 60_000,
90
+ });
91
+ const bookings = useMyBookings(comingUp && (sectionsCount?.bookings ?? 0) > 0 ? user?.id : undefined, 1, 20);
92
+ const tickets = useMyTickets(comingUp && (sectionsCount?.tickets ?? 0) > 0 ? user?.id : undefined, 1, 20);
93
+
94
+ const myCourses = useMyCourses({ accountId: user?.id, enabled: signedIn && wants("learning") });
95
+ const learningCourse = pickLearningCourse(myCourses.data);
96
+ const access = useCourseAccess(learningCourse?.accessId);
97
+
98
+ const wantsEvents = wants("events");
99
+ const events = useQuery<IEvent[]>({
100
+ queryKey: ["events", profileId],
101
+ queryFn: async () => (await client.get("/public/events", { params: { profileId } })).data as IEvent[],
102
+ enabled: wantsEvents && !!profileId && !!client,
103
+ });
104
+ const courses = useGetCourses(undefined, wants("courses"));
105
+ const products = useGetProducts(undefined, wants("shop"));
106
+
107
+ const sections = useMemo(() => {
108
+ if (!config) return [];
109
+ const byId = new Map((everyCommunity.data ?? []).map((community) => [community.id, community]));
110
+ const memberships = (mine.data ?? []).filter(
111
+ (row) => getMembershipAccess({ status: row.status, cancelAtPeriodEnd: !!row.cancelAtPeriodEnd }).hasAccess,
112
+ );
113
+ const joined = new Set(memberships.map((row) => row.community.id));
114
+ const communities: AppCommunityCard[] = memberships.map((row) => {
115
+ const full = byId.get(row.community.id);
116
+ return {
117
+ id: row.community.id,
118
+ name: row.community.name,
119
+ slug: row.community.slug,
120
+ logoUrl: full?.logoUrl ?? null,
121
+ coverUrl: full?.coverUrl ?? null,
122
+ tierName: row.tierName ?? row.membershipTier?.name ?? null,
123
+ memberCount: full?.memberCount ?? null,
124
+ href: communityHomePath(row.community.slug),
125
+ };
126
+ });
127
+ // Signed out, or a membership read that failed, lists nothing as "yours";
128
+ // explore then shows every community rather than none.
129
+ const explore: AppCommunityCard[] = (everyCommunity.data ?? [])
130
+ .filter((community) => !joined.has(community.id) && !(mine.data ?? []).some((row) => row.community.id === community.id))
131
+ .map((community) => ({
132
+ id: community.id,
133
+ name: community.name,
134
+ slug: community.slug,
135
+ logoUrl: community.logoUrl,
136
+ coverUrl: community.coverUrl,
137
+ tierName: null,
138
+ memberCount: community.memberCount,
139
+ href: communityAboutPath(community.slug),
140
+ joinMode: community.joinMode ?? "open",
141
+ }));
142
+ const learning: AppLearningCourse | null = learningCourse
143
+ ? {
144
+ accessId: learningCourse.accessId,
145
+ title: learningCourse.title,
146
+ href: internalPath("course-access/$accessId", { accessId: learningCourse.accessId }),
147
+ progress: courseProgress(access.data),
148
+ }
149
+ : null;
150
+ return buildAppHomeSections(config, {
151
+ attention: summary.data?.attention ?? [],
152
+ portalTiles: buildPortalTiles(summary.data),
153
+ communities,
154
+ nextCall: pickNextCall(calls.data, bookings.data?.data, now),
155
+ nextTicket: pickNextTicket(tickets.data?.data, now),
156
+ learning,
157
+ explore,
158
+ events: upcomingEvents(events.data, now),
159
+ courses: courses.data?.data ?? [],
160
+ products: products.data?.data ?? [],
161
+ });
162
+ // `now` is read per render on purpose; the inputs below are what change the answer.
163
+ // eslint-disable-next-line react-hooks/exhaustive-deps
164
+ }, [
165
+ config,
166
+ everyCommunity.data,
167
+ mine.data,
168
+ learningCourse,
169
+ access.data,
170
+ summary.data,
171
+ calls.data,
172
+ bookings.data,
173
+ tickets.data,
174
+ events.data,
175
+ courses.data,
176
+ products.data,
177
+ ]);
178
+
179
+ const authReady = auth ? auth.isInitialized !== false : true;
180
+ const isLoading = !config || !authReady || (signedIn && summary.isLoading);
181
+
182
+ return {
183
+ isLoading,
184
+ isSignedIn: signedIn,
185
+ appName: app.data?.appName ?? null,
186
+ iconUrl: app.data?.iconUrl ?? null,
187
+ firstName: user?.firstName?.trim() || null,
188
+ sections: isLoading ? [] : sections,
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Unread notifications in one community, for its card on the app home. The
194
+ * same request and cache entry as the community's own bell, so opening the
195
+ * community and coming back shows the same number.
196
+ */
197
+ export function useAppCommunityUnread(communityId: string | undefined, enabled = true) {
198
+ const { client, profileId } = useForge();
199
+ return useQuery<number>({
200
+ queryKey: communityKeys.unread(communityId),
201
+ queryFn: async () => {
202
+ const res = await client.get("/public/community/notifications", {
203
+ params: { profileId, communityId, page: 1, limit: 1, unreadOnly: true },
204
+ });
205
+ return res.data.unreadCount as number;
206
+ },
207
+ enabled: enabled && !!profileId && !!communityId && !!client,
208
+ refetchInterval: 30_000,
209
+ });
210
+ }
@@ -0,0 +1,16 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { requestAppInstall, type AppInstallContext } from "./appPrompts";
3
+
4
+ /**
5
+ * Ask for the app install once, the first time `ready` is true (a payment
6
+ * confirmed, a booking confirmed). For a success that is a callback rather
7
+ * than a state, call `requestAppInstall` directly.
8
+ */
9
+ export function useAppInstallRequest(ready: boolean, context: AppInstallContext): void {
10
+ const asked = useRef(false);
11
+ useEffect(() => {
12
+ if (!ready || asked.current) return;
13
+ asked.current = true;
14
+ requestAppInstall(context);
15
+ }, [ready, context]);
16
+ }
@@ -7,6 +7,8 @@ export {
7
7
  useWorkTaskAttachments,
8
8
  useWorkInvite,
9
9
  useAcceptWorkInvite,
10
+ rememberSignupEmail,
11
+ SIGNUP_EMAIL_PREFILL_KEY,
10
12
  useWorkProjectInvoices,
11
13
  useWorkSharedDocuments,
12
14
  useWorkOpenSharedDocument,
@@ -129,25 +129,19 @@ export interface WorkInviteContext {
129
129
  email: string;
130
130
  firstName: string | null;
131
131
  /**
132
- * The invited address already has an account, so the invite grants portal
133
- * access but cannot set a credential: render "sign in", not a password
134
- * form (C10).
132
+ * The invited address has no account yet. The invite never creates one: the
133
+ * page sends the person through the normal signup (which verifies the
134
+ * address with an emailed code) and back.
135
135
  */
136
+ needsAccount: boolean;
137
+ /** The complement of `needsAccount`: sign in, then come back. */
136
138
  requiresExistingLogin?: boolean;
137
139
  }
138
140
 
141
+ /** POST /public/work/invite/accept, made as the signed-in invited account. */
139
142
  export interface WorkInviteAcceptResult {
140
- /**
141
- * True when the invited address already has an account: the invite granted
142
- * portal access but issued NO session, and the person signs in normally
143
- * (C10). `token` is absent in that case.
144
- */
145
- requiresLogin?: boolean;
146
- token?: string;
147
- /** Rotating refresh token for the portal session (backend §7). Persist it. */
148
- refreshToken?: string;
143
+ accepted: true;
149
144
  projectId: string;
150
- account: { id: string; email: string; firstName: string | null; lastName: string | null };
151
145
  }
152
146
 
153
147
  // ── Query keys ───────────────────────────────────────────────────────────────
@@ -410,22 +404,23 @@ export function useUploadWorkDocument(projectId: string) {
410
404
  });
411
405
  }
412
406
 
413
- // ── Client invite accept (public, no auth) ───────────────────────────────────
407
+ // ── Client invite accept ─────────────────────────────────────────────────────
414
408
 
415
409
  /**
416
410
  * Fetch the branded context for an invite link (GET /public/work/invite).
417
- * Public: no bearer token needed; the link's token IS the credential.
418
- * `null`/errored when the token or project is missing/invalid/expired.
411
+ * Public: no bearer token needed. Sends the site's `profileId`, so a token for
412
+ * another tenant's project is refused here. Errors when the token or project is
413
+ * missing, invalid, expired or already used.
419
414
  */
420
415
  export function useWorkInvite(
421
416
  token: string | undefined,
422
417
  projectId: string | undefined,
423
418
  ): UseQueryResult<WorkInviteContext> {
424
- const { client } = useForge();
419
+ const { client, profileId } = useForge();
425
420
  return useQuery<WorkInviteContext>({
426
- queryKey: ["work-invite", token, projectId],
421
+ queryKey: ["work-invite", token, projectId, profileId],
427
422
  queryFn: async () => {
428
- const res = await client.get(`/public/work/invite`, { params: { token, projectId } });
423
+ const res = await client.get(`/public/work/invite`, { params: { token, projectId, profileId } });
429
424
  return res.data as WorkInviteContext;
430
425
  },
431
426
  enabled: !!client && !!token && !!projectId,
@@ -434,20 +429,36 @@ export function useWorkInvite(
434
429
  }
435
430
 
436
431
  /**
437
- * Accept an invite (POST /public/work/invite/accept): set a password and
438
- * receive a fresh portal session token. The caller persists the token +
439
- * navigates. Public: no bearer token needed.
432
+ * Accept an invite (POST /public/work/invite/accept) as the SIGNED-IN account.
433
+ * The account's email must be the invited address; nothing is created and no
434
+ * password is involved.
440
435
  */
441
436
  export function useAcceptWorkInvite() {
442
- const { client } = useForge();
443
- return useMutation<WorkInviteAcceptResult, unknown, { token: string; projectId: string; password: string }>({
444
- mutationFn: async ({ token, projectId, password }) => {
445
- const res = await client.post(`/public/work/invite/accept`, { token, projectId, password });
437
+ const { client, profileId } = useForge();
438
+ return useMutation<WorkInviteAcceptResult, unknown, { token: string; projectId: string }>({
439
+ mutationFn: async ({ token, projectId }) => {
440
+ const res = await client.post(`/public/work/invite/accept`, { token, projectId, profileId });
446
441
  return res.data as WorkInviteAcceptResult;
447
442
  },
448
443
  });
449
444
  }
450
445
 
446
+ /** Session-storage key the signup form reads its prefilled email from. */
447
+ export const SIGNUP_EMAIL_PREFILL_KEY = "forge:signup-email";
448
+
449
+ /**
450
+ * Hand an email to the next signup form in this tab, without putting it in a
451
+ * URL (where analytics would record it). Read once by `useSignupForm`.
452
+ */
453
+ export function rememberSignupEmail(email: string | null | undefined) {
454
+ if (!email || typeof window === "undefined") return;
455
+ try {
456
+ window.sessionStorage.setItem(SIGNUP_EMAIL_PREFILL_KEY, email);
457
+ } catch {
458
+ // Storage unavailable: the person types it.
459
+ }
460
+ }
461
+
451
462
  // ── Presentational helpers ───────────────────────────────────────────────────
452
463
 
453
464
  /** Major-unit amount → display string using the project currency symbol. */