@tribe-nest/forge 3.60.0 → 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/package.json +2 -1
  2. package/src/contexts/CartContext.tsx +34 -1
  3. package/src/contexts/PublicAuthContext.tsx +15 -3
  4. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  5. package/src/data/queries/useAccount.ts +33 -2
  6. package/src/data/queries/useAnalytics.ts +6 -6
  7. package/src/data/queries/useCallChat.ts +2 -2
  8. package/src/data/queries/useChat.ts +10 -1
  9. package/src/data/queries/useCheckouts.ts +2 -1
  10. package/src/data/queries/useCoachingAvailability.ts +10 -1
  11. package/src/data/queries/useCommunities.ts +2 -0
  12. package/src/data/queries/useCommunity.ts +362 -18
  13. package/src/data/queries/useCommunityAgent.ts +247 -0
  14. package/src/data/queries/useCommunityCalendar.ts +150 -0
  15. package/src/data/queries/useCommunityClassroom.ts +75 -0
  16. package/src/data/queries/useCommunityMembers.ts +108 -0
  17. package/src/data/queries/useCommunityModeration.ts +327 -0
  18. package/src/data/queries/useCommunityWiki.ts +137 -0
  19. package/src/data/queries/useCourses.ts +2 -0
  20. package/src/data/queries/useEmailSignIn.ts +79 -0
  21. package/src/data/queries/useEvents.ts +6 -1
  22. package/src/data/queries/useForms.ts +5 -1
  23. package/src/data/queries/useLeadMagnet.ts +6 -2
  24. package/src/data/queries/useMyBookings.ts +24 -1
  25. package/src/data/queries/useMyTickets.ts +24 -1
  26. package/src/data/queries/useNotifications.ts +6 -2
  27. package/src/data/queries/useOrders.ts +2 -1
  28. package/src/data/queries/usePWA.ts +111 -35
  29. package/src/data/queries/useSiteApp.ts +130 -0
  30. package/src/data/queries/useSubscriptions.ts +6 -1
  31. package/src/i18n/de.json +746 -1
  32. package/src/i18n/en.json +746 -1
  33. package/src/index.ts +17 -0
  34. package/src/runtime/pages.spec.ts +5 -0
  35. package/src/runtime/pages.ts +32 -6
  36. package/src/types/models.ts +11 -0
  37. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  38. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  39. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  40. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  41. package/src/ui/analytics/useMetaEvents.ts +78 -0
  42. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  43. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  44. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  45. package/src/ui/headless/_tests/communityHarness.tsx +10 -3
  46. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  47. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  48. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  49. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  50. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  51. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  52. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  53. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
  54. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
  55. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  56. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
  57. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  58. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  59. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  60. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  61. package/src/ui/headless/auth/useSignupForm.ts +20 -1
  62. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  63. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  64. package/src/ui/headless/communities/index.ts +41 -0
  65. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  66. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  67. package/src/ui/headless/communities/useCommunityHome.ts +3 -0
  68. package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
  69. package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
  70. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  71. package/src/ui/headless/community/communityPoll.ts +297 -0
  72. package/src/ui/headless/community/countries.ts +123 -0
  73. package/src/ui/headless/community/index.ts +81 -1
  74. package/src/ui/headless/community/linkPreview.ts +71 -0
  75. package/src/ui/headless/community/richLexical.ts +27 -2
  76. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  77. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  78. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  79. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  80. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  81. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  82. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  83. package/src/ui/headless/downloads/index.ts +8 -0
  84. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  85. package/src/ui/headless/index.ts +13 -0
  86. package/src/ui/headless/memberHome/index.ts +10 -0
  87. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  88. package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
  89. package/src/ui/headless/portal/index.ts +57 -0
  90. package/src/ui/headless/portal/usePortal.ts +274 -0
  91. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  92. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  93. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  94. package/src/ui/headless/siteApp/appBar.ts +102 -0
  95. package/src/ui/headless/siteApp/appHome.ts +344 -0
  96. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  97. package/src/ui/headless/siteApp/index.ts +58 -0
  98. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  99. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  100. package/src/ui/headless/work/index.ts +2 -0
  101. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  102. package/src/ui/index.ts +11 -0
  103. package/src/ui/styled/AccountDashboard.tsx +712 -352
  104. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  105. package/src/ui/styled/ChatRoom.tsx +126 -16
  106. package/src/ui/styled/Checkout.tsx +8 -0
  107. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  108. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  109. package/src/ui/styled/CoachingDetail.tsx +2 -0
  110. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  111. package/src/ui/styled/CourseDetail.tsx +2 -0
  112. package/src/ui/styled/DownloadPage.tsx +263 -0
  113. package/src/ui/styled/EventConfirmation.tsx +10 -0
  114. package/src/ui/styled/EventDetail.tsx +10 -0
  115. package/src/ui/styled/FilmLibrary.tsx +22 -9
  116. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  117. package/src/ui/styled/ProductDetail.tsx +12 -0
  118. package/src/ui/styled/PwaRegistration.tsx +5 -1
  119. package/src/ui/styled/SignupForm.tsx +18 -3
  120. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
  121. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  122. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  123. package/src/ui/styled/app/AppBar.tsx +298 -0
  124. package/src/ui/styled/app/AppHome.tsx +1139 -0
  125. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  126. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  127. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  128. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  129. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  130. package/src/ui/styled/app/index.ts +7 -0
  131. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  132. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  133. package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
  134. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  135. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  136. package/src/ui/styled/communities/CommunityHome.tsx +134 -11
  137. package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
  138. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
  139. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  140. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  141. package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
  142. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  143. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  144. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  145. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  146. package/src/ui/styled/communities/index.ts +9 -0
  147. package/src/ui/styled/communities/shared.tsx +39 -11
  148. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  149. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  150. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  151. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  152. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  153. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  154. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  155. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  156. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  157. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  158. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  159. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  160. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  161. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  162. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  163. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  164. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  165. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  166. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  167. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  168. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  169. package/src/ui/styled/community/index.ts +37 -2
  170. package/src/ui/styled/forge-utilities.css +234 -0
  171. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  172. package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
  173. package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
  174. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  175. package/src/ui/styled/members/index.ts +8 -0
  176. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  177. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  178. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  179. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  180. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  181. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  182. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  183. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  184. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  185. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  186. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  187. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  188. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  189. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  190. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  191. package/src/ui/styled/portal/index.ts +13 -0
  192. package/src/ui/styled/portal/portalNav.ts +260 -0
  193. package/src/ui/styled/portal/shared.tsx +206 -0
  194. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  195. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  196. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  197. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  198. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  199. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  200. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  201. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  202. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  203. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  204. package/src/ui/styled/work/index.ts +1 -1
  205. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  206. package/src/utils/landing.ts +19 -6
  207. package/src/utils/metaEvents.ts +302 -0
@@ -0,0 +1,171 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
3
+ import {
4
+ globeAnglesFor,
5
+ memberGlobeMarkers,
6
+ type MemberGlobePoint,
7
+ } from "../../headless/community/useCommunityMembers";
8
+
9
+ export interface CommunityMemberGlobeProps {
10
+ points: MemberGlobePoint[];
11
+ /** Called when WebGL is unavailable, so the caller can show the country list instead. */
12
+ onUnsupported?: () => void;
13
+ /** Accessible name for the drawing. */
14
+ label: string;
15
+ className?: string;
16
+ }
17
+
18
+ /** A theme colour as cobe's 0..1 RGB triple. Falls back to mid grey. */
19
+ function rgb01(color: string): [number, number, number] {
20
+ const hex = color.trim().replace(/^#/, "");
21
+ const full = hex.length === 3 ? hex.replace(/./g, (c) => c + c) : hex.slice(0, 6);
22
+ const n = Number.parseInt(full, 16);
23
+ if (!/^[0-9a-f]{6}$/i.test(full) || Number.isNaN(n)) return [0.5, 0.5, 0.5];
24
+ return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
25
+ }
26
+
27
+ /** Perceived lightness 0..1, to decide whether the globe sits on a dark page. */
28
+ function lightness([r, g, b]: [number, number, number]): number {
29
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
30
+ }
31
+
32
+ type CobeGlobe = { update: (state: Record<string, unknown>) => void; destroy: () => void };
33
+
34
+ /**
35
+ * A slowly turning dotted globe with one dot per country members are in, sized
36
+ * by how many. Country level only: members choose a country, never a place.
37
+ * Drag to turn it. It starts facing the biggest country, stops turning for
38
+ * visitors who asked for reduced motion, and stops drawing while out of view.
39
+ * Colours come from the theme.
40
+ *
41
+ * `cobe` is loaded with a dynamic `import()` from the effect, so nothing of
42
+ * WebGL is on the server render path. cobe v2 has no render loop of its own;
43
+ * this drives it with `update()`, as the admin analytics globe does.
44
+ */
45
+ export function CommunityMemberGlobe({ points, onUnsupported, label, className }: CommunityMemberGlobeProps) {
46
+ const t = useThemeTokens();
47
+ const canvasRef = useRef<HTMLCanvasElement>(null);
48
+ const globeRef = useRef<CobeGlobe | null>(null);
49
+ const drag = useRef<{ startX: number; startPhi: number } | null>(null);
50
+ const phiRef = useRef(0);
51
+ const thetaRef = useRef(0.3);
52
+ const pointsRef = useRef(points);
53
+ pointsRef.current = points;
54
+ const [ready, setReady] = useState(false);
55
+
56
+ useEffect(() => {
57
+ const canvas = canvasRef.current;
58
+ if (!canvas) return;
59
+ let cancelled = false;
60
+ let cleanup: (() => void) | null = null;
61
+
62
+ (async () => {
63
+ let createGlobe: (canvas: HTMLCanvasElement, opts: Record<string, unknown>) => CobeGlobe;
64
+ try {
65
+ createGlobe = (await import("cobe")).default as unknown as typeof createGlobe;
66
+ } catch {
67
+ if (!cancelled) onUnsupported?.();
68
+ return;
69
+ }
70
+ if (cancelled) return;
71
+
72
+ const background = rgb01(t.background);
73
+ const dark = lightness(background) < 0.5;
74
+ const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
75
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
76
+ let size = canvas.offsetWidth || 400;
77
+
78
+ const biggest = pointsRef.current[0];
79
+ if (biggest) {
80
+ const [phi, theta] = globeAnglesFor(biggest.latitude, biggest.longitude);
81
+ phiRef.current = phi;
82
+ thetaRef.current = Math.max(-0.6, Math.min(0.6, theta));
83
+ }
84
+
85
+ let globe: CobeGlobe;
86
+ try {
87
+ globe = createGlobe(canvas, {
88
+ devicePixelRatio: dpr,
89
+ width: size * dpr,
90
+ height: size * dpr,
91
+ phi: phiRef.current,
92
+ theta: thetaRef.current,
93
+ dark: dark ? 1 : 0,
94
+ diffuse: 1.2,
95
+ mapSamples: 16000,
96
+ mapBrightness: dark ? 6 : 1.8,
97
+ baseColor: dark ? [0.3, 0.3, 0.3] : [1, 1, 1],
98
+ markerColor: rgb01(t.primary),
99
+ glowColor: background,
100
+ markers: memberGlobeMarkers(pointsRef.current),
101
+ markerElevation: 0.02,
102
+ });
103
+ } catch {
104
+ onUnsupported?.();
105
+ return;
106
+ }
107
+ globeRef.current = globe;
108
+ setReady(true);
109
+
110
+ let visible = true;
111
+ let frame = 0;
112
+ const tick = () => {
113
+ if (visible) {
114
+ if (!drag.current && !reduceMotion) phiRef.current += 0.0025;
115
+ globe.update({ phi: phiRef.current, theta: thetaRef.current, width: size * dpr, height: size * dpr });
116
+ }
117
+ frame = requestAnimationFrame(tick);
118
+ };
119
+ frame = requestAnimationFrame(tick);
120
+
121
+ const io =
122
+ typeof IntersectionObserver !== "undefined"
123
+ ? new IntersectionObserver(([entry]) => (visible = entry.isIntersecting))
124
+ : null;
125
+ io?.observe(canvas);
126
+ const ro =
127
+ typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => (size = canvas.offsetWidth || size)) : null;
128
+ ro?.observe(canvas);
129
+
130
+ cleanup = () => {
131
+ cancelAnimationFrame(frame);
132
+ io?.disconnect();
133
+ ro?.disconnect();
134
+ globe.destroy();
135
+ globeRef.current = null;
136
+ };
137
+ })();
138
+
139
+ return () => {
140
+ cancelled = true;
141
+ cleanup?.();
142
+ };
143
+ // Built once per theme; markers are pushed in by the effect below.
144
+ // eslint-disable-next-line react-hooks/exhaustive-deps
145
+ }, [t.background, t.primary]);
146
+
147
+ useEffect(() => {
148
+ globeRef.current?.update({ markers: memberGlobeMarkers(points) });
149
+ }, [points]);
150
+
151
+ return (
152
+ <canvas
153
+ ref={canvasRef}
154
+ role="img"
155
+ aria-label={label}
156
+ data-testid="community-member-globe"
157
+ className={className ?? "fg:aspect-square fg:w-full fg:max-w-[420px] fg:cursor-grab fg:touch-none"}
158
+ style={{ opacity: ready ? 1 : 0, transition: "opacity 0.7s" }}
159
+ onPointerDown={(e) => {
160
+ drag.current = { startX: e.clientX, startPhi: phiRef.current };
161
+ e.currentTarget.setPointerCapture?.(e.pointerId);
162
+ }}
163
+ onPointerMove={(e) => {
164
+ if (!drag.current) return;
165
+ phiRef.current = drag.current.startPhi + (e.clientX - drag.current.startX) / 150;
166
+ }}
167
+ onPointerUp={() => (drag.current = null)}
168
+ onPointerCancel={() => (drag.current = null)}
169
+ />
170
+ );
171
+ }
@@ -1,5 +1,8 @@
1
- import { useRef, useState } from "react";
1
+ import { useMemo, useRef, useState } from "react";
2
2
  import { useThemeTokens } from "../../theme/ForgeThemeProvider";
3
+ import { useForgeLocale, useForgeT } from "../../../i18n";
4
+ import { countryOptions } from "../../headless/community/countries";
5
+ import { CallHelpHint } from "../../media/CallHelpHint";
3
6
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
4
7
  import {
5
8
  communityMemberName,
@@ -29,8 +32,9 @@ export interface CommunityMemberProfileProps {
29
32
  onBack?: () => void;
30
33
  }
31
34
 
32
- /** Member profile: avatar, bio, points/level, recent activity — plus settings
33
- * (display name, bio, avatar, digest cadence) on your own profile. */
35
+ /** Member profile: avatar, bio, points/level, recent activity. On your own
36
+ * profile, settings too: display name, bio, avatar, directory and map
37
+ * visibility, country and city, and digest cadence. */
34
38
  export function CommunityMemberProfile({ accountId, onOpenPost, onBack }: CommunityMemberProfileProps) {
35
39
  const t = useThemeTokens();
36
40
  const { user } = usePublicAuth();
@@ -163,8 +167,13 @@ function ProfileSettings({ profile }: { profile: MemberProfileData }) {
163
167
  const upload = useCommunityAttachmentUpload();
164
168
  const fileInputRef = useRef<HTMLInputElement | null>(null);
165
169
 
170
+ const tr = useForgeT();
171
+ const locale = useForgeLocale();
172
+ const countries = useMemo(() => countryOptions(locale), [locale]);
166
173
  const [displayName, setDisplayName] = useState(profile.displayName ?? "");
167
174
  const [bio, setBio] = useState(profile.bio ?? "");
175
+ const [city, setCity] = useState(profile.city ?? "");
176
+ const [countryCode, setCountryCode] = useState(profile.countryCode ?? "");
168
177
  const [saved, setSaved] = useState(false);
169
178
 
170
179
  const border = `1px solid ${t.border}`;
@@ -180,12 +189,21 @@ function ProfileSettings({ profile }: { profile: MemberProfileData }) {
180
189
  fontSize: 14,
181
190
  };
182
191
 
183
- const save = (extra: Partial<{ avatarUrl: string | null; digestCadence: CommunityDigestCadence }> = {}) => {
192
+ const save = (
193
+ extra: Partial<{
194
+ avatarUrl: string | null;
195
+ digestCadence: CommunityDigestCadence;
196
+ showInDirectory: boolean;
197
+ showOnMap: boolean;
198
+ }> = {},
199
+ ) => {
184
200
  setSaved(false);
185
201
  update.mutate(
186
202
  {
187
203
  displayName: displayName.trim() || null,
188
204
  bio: bio.trim() || null,
205
+ city: city.trim() || null,
206
+ countryCode: countryCode || null,
189
207
  ...extra,
190
208
  },
191
209
  { onSuccess: () => setSaved(true) },
@@ -242,6 +260,59 @@ function ProfileSettings({ profile }: { profile: MemberProfileData }) {
242
260
  {upload.error && <span style={{ fontSize: 12, color: "#dc2626" }}>{upload.error}</span>}
243
261
  </div>
244
262
 
263
+ <fieldset
264
+ data-testid="community-profile-directory-settings"
265
+ style={{ border: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 10 }}
266
+ >
267
+ <legend style={{ fontSize: 13, color: t.muted, padding: 0, marginBottom: 4 }}>
268
+ {tr("forge.community_member_profile.directory_legend")}
269
+ </legend>
270
+ <SettingSwitch
271
+ label={tr("forge.community_member_profile.show_in_directory")}
272
+ checked={profile.showInDirectory !== false}
273
+ disabled={update.isPending}
274
+ onChange={(value) => save({ showInDirectory: value })}
275
+ help={
276
+ <CallHelpHint label={tr("forge.community_member_profile.show_in_directory_help_label")}>
277
+ {tr("forge.community_member_profile.show_in_directory_help")}
278
+ </CallHelpHint>
279
+ }
280
+ />
281
+ <SettingSwitch
282
+ label={tr("forge.community_member_profile.show_on_map")}
283
+ checked={profile.showOnMap === true}
284
+ disabled={update.isPending}
285
+ onChange={(value) => save({ showOnMap: value })}
286
+ help={
287
+ <CallHelpHint label={tr("forge.community_member_profile.show_on_map_help_label")}>
288
+ {tr("forge.community_member_profile.show_on_map_help")}
289
+ </CallHelpHint>
290
+ }
291
+ />
292
+ <div className="fg:flex fg:flex-col fg:gap-3 fg:sm:flex-row">
293
+ <label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13, color: t.muted, flex: 1 }}>
294
+ {tr("forge.community_member_profile.country")}
295
+ <select value={countryCode} onChange={(e) => setCountryCode(e.target.value)} style={inputStyle}>
296
+ <option value="">{tr("forge.community_member_profile.country_none")}</option>
297
+ {countries.map((country) => (
298
+ <option key={country.code} value={country.code}>
299
+ {country.name}
300
+ </option>
301
+ ))}
302
+ </select>
303
+ </label>
304
+ <label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13, color: t.muted, flex: 1 }}>
305
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
306
+ {tr("forge.community_member_profile.city")}
307
+ <CallHelpHint label={tr("forge.community_member_profile.city_help_label")}>
308
+ {tr("forge.community_member_profile.city_help")}
309
+ </CallHelpHint>
310
+ </span>
311
+ <input value={city} onChange={(e) => setCity(e.target.value)} maxLength={80} style={inputStyle} />
312
+ </label>
313
+ </div>
314
+ </fieldset>
315
+
245
316
  <fieldset style={{ border: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 6 }}>
246
317
  <legend style={{ fontSize: 13, color: t.muted, padding: 0, marginBottom: 4 }}>Email digest</legend>
247
318
  {DIGEST_OPTIONS.map((opt) => (
@@ -279,7 +350,69 @@ function ProfileSettings({ profile }: { profile: MemberProfileData }) {
279
350
  {update.isPending ? "Saving…" : "Save"}
280
351
  </button>
281
352
  {saved && !update.isPending && <span style={{ fontSize: 13, color: t.muted }}>Saved ✓</span>}
353
+ {update.isError && (
354
+ <span role="alert" style={{ fontSize: 13, color: t.text, fontWeight: 600 }}>
355
+ {tr("forge.community_member_profile.save_error")}
356
+ </span>
357
+ )}
282
358
  </div>
283
359
  </div>
284
360
  );
285
361
  }
362
+
363
+ /** An on/off setting as an accessible switch, with its explanation behind a `?`. */
364
+ function SettingSwitch({
365
+ label,
366
+ checked,
367
+ disabled,
368
+ onChange,
369
+ help,
370
+ }: {
371
+ label: string;
372
+ checked: boolean;
373
+ disabled?: boolean;
374
+ onChange: (value: boolean) => void;
375
+ help?: React.ReactNode;
376
+ }) {
377
+ const t = useThemeTokens();
378
+ return (
379
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
380
+ <button
381
+ type="button"
382
+ role="switch"
383
+ aria-checked={checked}
384
+ aria-label={label}
385
+ disabled={disabled}
386
+ onClick={() => onChange(!checked)}
387
+ style={{
388
+ position: "relative",
389
+ width: 38,
390
+ height: 22,
391
+ flexShrink: 0,
392
+ borderRadius: 999,
393
+ border: "none",
394
+ padding: 0,
395
+ cursor: disabled ? "wait" : "pointer",
396
+ background: checked ? t.primary : t.border,
397
+ transition: "background 0.15s",
398
+ }}
399
+ >
400
+ <span
401
+ aria-hidden
402
+ style={{
403
+ position: "absolute",
404
+ top: 3,
405
+ left: checked ? 19 : 3,
406
+ width: 16,
407
+ height: 16,
408
+ borderRadius: "50%",
409
+ background: checked ? t.textPrimary : t.background,
410
+ transition: "left 0.15s",
411
+ }}
412
+ />
413
+ </button>
414
+ <span style={{ fontSize: 14, color: t.text }}>{label}</span>
415
+ {help}
416
+ </div>
417
+ );
418
+ }