@tribe-nest/forge 3.60.0 → 3.63.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 (220) hide show
  1. package/package.json +5 -2
  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 +20 -0
  34. package/src/runtime/pages.spec.ts +5 -0
  35. package/src/runtime/pages.ts +32 -6
  36. package/src/seo/_tests/seo.spec.ts +326 -0
  37. package/src/seo/applySeoToHead.ts +98 -0
  38. package/src/seo/index.ts +9 -0
  39. package/src/seo/resolve.ts +211 -0
  40. package/src/seo/seoHead.ts +61 -0
  41. package/src/seo/siteSeoHead.ts +97 -0
  42. package/src/seo/types.ts +99 -0
  43. package/src/server/_tests/siteBootstrap.spec.ts +65 -0
  44. package/src/server/index.ts +33 -4
  45. package/src/types/models.ts +11 -0
  46. package/src/ui/analytics/ForgeAnalytics.tsx +134 -34
  47. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  48. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  49. package/src/ui/analytics/_tests/ForgeAnalytics.webVitals.spec.tsx +129 -0
  50. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  51. package/src/ui/analytics/useMetaEvents.ts +78 -0
  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/communityHarness.tsx +10 -3
  56. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  57. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  58. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  59. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  60. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  61. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  62. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  63. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
  64. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
  65. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  66. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
  67. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  68. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  69. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  70. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  71. package/src/ui/headless/auth/useSignupForm.ts +20 -1
  72. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  73. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  74. package/src/ui/headless/communities/index.ts +41 -0
  75. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  76. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  77. package/src/ui/headless/communities/useCommunityHome.ts +3 -0
  78. package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
  79. package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
  80. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  81. package/src/ui/headless/community/communityPoll.ts +297 -0
  82. package/src/ui/headless/community/countries.ts +123 -0
  83. package/src/ui/headless/community/index.ts +81 -1
  84. package/src/ui/headless/community/linkPreview.ts +71 -0
  85. package/src/ui/headless/community/richLexical.ts +27 -2
  86. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  87. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  88. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  89. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  90. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  91. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  92. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  93. package/src/ui/headless/downloads/index.ts +8 -0
  94. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  95. package/src/ui/headless/index.ts +13 -0
  96. package/src/ui/headless/memberHome/index.ts +10 -0
  97. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  98. package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
  99. package/src/ui/headless/portal/index.ts +57 -0
  100. package/src/ui/headless/portal/usePortal.ts +274 -0
  101. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  102. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  103. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  104. package/src/ui/headless/siteApp/appBar.ts +102 -0
  105. package/src/ui/headless/siteApp/appHome.ts +344 -0
  106. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  107. package/src/ui/headless/siteApp/index.ts +58 -0
  108. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  109. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  110. package/src/ui/headless/work/index.ts +2 -0
  111. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  112. package/src/ui/index.ts +11 -0
  113. package/src/ui/styled/AccountDashboard.tsx +712 -352
  114. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  115. package/src/ui/styled/ChatRoom.tsx +126 -16
  116. package/src/ui/styled/Checkout.tsx +8 -0
  117. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  118. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  119. package/src/ui/styled/CoachingDetail.tsx +2 -0
  120. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  121. package/src/ui/styled/CourseDetail.tsx +2 -0
  122. package/src/ui/styled/DownloadPage.tsx +263 -0
  123. package/src/ui/styled/EventConfirmation.tsx +10 -0
  124. package/src/ui/styled/EventDetail.tsx +10 -0
  125. package/src/ui/styled/FilmLibrary.tsx +22 -9
  126. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  127. package/src/ui/styled/ProductDetail.tsx +12 -0
  128. package/src/ui/styled/PwaRegistration.tsx +5 -1
  129. package/src/ui/styled/SignupForm.tsx +18 -3
  130. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
  131. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  132. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  133. package/src/ui/styled/app/AppBar.tsx +298 -0
  134. package/src/ui/styled/app/AppHome.tsx +1139 -0
  135. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  136. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  137. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  138. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  139. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  140. package/src/ui/styled/app/index.ts +7 -0
  141. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  142. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  143. package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
  144. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  145. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  146. package/src/ui/styled/communities/CommunityHome.tsx +134 -11
  147. package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
  148. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
  149. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  150. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  151. package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
  152. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  153. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  154. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  155. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  156. package/src/ui/styled/communities/index.ts +9 -0
  157. package/src/ui/styled/communities/shared.tsx +39 -11
  158. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  159. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  160. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  161. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  162. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  163. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  164. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  165. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  166. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  167. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  168. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  169. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  170. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  171. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  172. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  173. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  174. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  175. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  176. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  177. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  178. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  179. package/src/ui/styled/community/index.ts +37 -2
  180. package/src/ui/styled/forge-utilities.css +234 -0
  181. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  182. package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
  183. package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
  184. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  185. package/src/ui/styled/members/index.ts +8 -0
  186. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  187. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  188. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  189. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  190. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  191. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  192. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  193. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  194. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  195. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  196. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  197. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  198. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  199. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  200. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  201. package/src/ui/styled/portal/index.ts +13 -0
  202. package/src/ui/styled/portal/portalNav.ts +260 -0
  203. package/src/ui/styled/portal/shared.tsx +206 -0
  204. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  205. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  206. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  207. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  208. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  209. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  210. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  211. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  212. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  213. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  214. package/src/ui/styled/work/index.ts +1 -1
  215. package/src/utils/_tests/landing.spec.ts +39 -0
  216. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  217. package/src/utils/_tests/structuredDataSeo.spec.ts +247 -0
  218. package/src/utils/landing.ts +36 -10
  219. package/src/utils/metaEvents.ts +302 -0
  220. package/src/utils/structuredData.ts +339 -1
@@ -63,6 +63,17 @@ function JoinBody({ join }: { join: Join }) {
63
63
  const currency = useSiteConfig().data?.currency;
64
64
  const money = (amount: number) => formatCurrency(amount, currency);
65
65
  const field: CSSProperties = { display: "flex", flexDirection: "column", gap: 6, fontSize: 14 };
66
+ const linkButton: CSSProperties = {
67
+ alignSelf: "center",
68
+ background: "transparent",
69
+ border: "none",
70
+ padding: 4,
71
+ color: t.primary,
72
+ fontFamily: t.fontFamily,
73
+ fontSize: 14,
74
+ fontWeight: 600,
75
+ cursor: "pointer",
76
+ };
66
77
 
67
78
  if (join.isLoading) return <Loading />;
68
79
 
@@ -89,6 +100,41 @@ function JoinBody({ join }: { join: Join }) {
89
100
  </div>
90
101
  );
91
102
  }
103
+ if (join.mode === "email") {
104
+ return (
105
+ <form
106
+ data-testid="community-join-email"
107
+ className="fg:flex fg:flex-col fg:gap-3"
108
+ onSubmit={(e) => {
109
+ e.preventDefault();
110
+ void join.next();
111
+ }}
112
+ >
113
+ {problem}
114
+ <label style={field}>
115
+ {tr("forge.community_join_sheet.email")}
116
+ <input
117
+ style={{ ...input, opacity: join.emailLocked ? 0.75 : 1 }}
118
+ type="email"
119
+ inputMode="email"
120
+ value={join.emailFlow.email}
121
+ readOnly={join.emailLocked}
122
+ aria-readonly={join.emailLocked}
123
+ onChange={(e) => join.emailFlow.setEmail(e.target.value)}
124
+ autoComplete="email"
125
+ autoFocus={!join.emailLocked}
126
+ />
127
+ </label>
128
+ {join.error && <p role="alert" style={errorText}>{join.error}</p>}
129
+ <button type="submit" disabled={join.isSubmitting} style={button}>
130
+ {join.isSubmitting ? tr("forge.community_join_sheet.working") : tr("forge.community_join_sheet.continue")}
131
+ </button>
132
+ <button type="button" data-testid="community-join-use-password" style={linkButton} onClick={join.switchToPassword}>
133
+ {tr("forge.community_join_sheet.use_password")}
134
+ </button>
135
+ </form>
136
+ );
137
+ }
92
138
  const signup = join.mode === "signup";
93
139
  return (
94
140
  <form
@@ -99,20 +145,6 @@ function JoinBody({ join }: { join: Join }) {
99
145
  }}
100
146
  >
101
147
  {problem}
102
- <div className="fg:flex fg:gap-2" role="tablist">
103
- {(["signup", "signin"] as const).map((mode) => (
104
- <button
105
- key={mode}
106
- type="button"
107
- role="tab"
108
- aria-selected={join.mode === mode}
109
- onClick={() => join.setMode(mode)}
110
- style={join.mode === mode ? { ...button, flex: 1 } : { ...ghostButton, flex: 1 }}
111
- >
112
- {tr(mode === "signup" ? "forge.community_join_sheet.mode_signup" : "forge.community_join_sheet.mode_signin")}
113
- </button>
114
- ))}
115
- </div>
116
148
  {signup && (
117
149
  <div className="fg:flex fg:gap-2">
118
150
  <label style={{ ...field, flex: 1 }}>
@@ -162,6 +194,9 @@ function JoinBody({ join }: { join: Join }) {
162
194
  <button type="submit" disabled={join.isSubmitting} style={button}>
163
195
  {join.isSubmitting ? tr("forge.community_join_sheet.working") : tr("forge.community_join_sheet.continue")}
164
196
  </button>
197
+ <button type="button" data-testid="community-join-use-code" style={linkButton} onClick={join.switchToEmailCode}>
198
+ {tr("forge.community_join_sheet.use_code")}
199
+ </button>
165
200
  </form>
166
201
  );
167
202
  }
@@ -175,7 +210,7 @@ function JoinBody({ join }: { join: Join }) {
175
210
  void join.next();
176
211
  }}
177
212
  >
178
- <p>{tr("forge.community_join_sheet.code_sent", { email: join.mode === "signup" ? join.signup.email : join.login.email })}</p>
213
+ <p>{tr("forge.community_join_sheet.code_sent", { email: join.codeEmail })}</p>
179
214
  <label style={field}>
180
215
  {tr("forge.community_join_sheet.code")}
181
216
  <input
@@ -185,23 +220,88 @@ function JoinBody({ join }: { join: Join }) {
185
220
  maxLength={6}
186
221
  value={join.code}
187
222
  onChange={(e) => join.setCode(e.target.value.replace(/\D/g, ""))}
223
+ autoFocus
188
224
  />
189
225
  </label>
190
226
  {join.error && <p role="alert" style={errorText}>{join.error}</p>}
227
+ {join.mode === "email" && join.emailFlow.resent && !join.error && (
228
+ <p role="status" data-testid="community-join-code-resent" style={muted}>
229
+ {tr("forge.community_join_sheet.code_resent")}
230
+ </p>
231
+ )}
191
232
  <button type="submit" disabled={join.isSubmitting} style={button}>
192
233
  {join.isSubmitting ? tr("forge.community_join_sheet.working") : tr("forge.community_join_sheet.verify")}
193
234
  </button>
194
- <div className="fg:flex fg:justify-between fg:gap-2">
195
- <button type="button" style={ghostButton} onClick={join.back}>
196
- {tr("forge.community_join_sheet.back")}
235
+ <div className="fg:flex fg:flex-wrap fg:justify-between fg:gap-2">
236
+ <button type="button" data-testid="community-join-change-email" style={linkButton} onClick={join.back}>
237
+ {join.mode === "email" ? tr("forge.community_join_sheet.different_email") : tr("forge.community_join_sheet.back")}
197
238
  </button>
198
- <button type="button" style={ghostButton} onClick={() => void join.resendCode()}>
239
+ <button
240
+ type="button"
241
+ data-testid="community-join-resend"
242
+ style={linkButton}
243
+ disabled={join.isSubmitting}
244
+ onClick={() => void join.resendCode()}
245
+ >
199
246
  {tr("forge.community_join_sheet.resend")}
200
247
  </button>
201
248
  </div>
202
249
  </form>
203
250
  );
204
251
 
252
+ case "profile":
253
+ return (
254
+ <form
255
+ data-testid="community-join-profile"
256
+ className="fg:flex fg:flex-col fg:gap-3"
257
+ onSubmit={(e) => {
258
+ e.preventDefault();
259
+ void join.next();
260
+ }}
261
+ >
262
+ <p style={{ fontWeight: 600 }}>{tr("forge.community_join_sheet.new_here", { email: join.emailFlow.email })}</p>
263
+ <div className="fg:flex fg:gap-2">
264
+ <label style={{ ...field, flex: 1, minWidth: 0 }}>
265
+ {tr("forge.community_join_sheet.first_name")}
266
+ <input
267
+ style={input}
268
+ value={join.emailFlow.firstName}
269
+ maxLength={100}
270
+ onChange={(e) => join.emailFlow.setFirstName(e.target.value)}
271
+ autoComplete="given-name"
272
+ autoFocus
273
+ />
274
+ </label>
275
+ <label style={{ ...field, flex: 1, minWidth: 0 }}>
276
+ {tr("forge.community_join_sheet.last_name")}
277
+ <input
278
+ style={input}
279
+ value={join.emailFlow.lastName}
280
+ maxLength={100}
281
+ onChange={(e) => join.emailFlow.setLastName(e.target.value)}
282
+ autoComplete="family-name"
283
+ />
284
+ </label>
285
+ </div>
286
+ <label className="fg:flex fg:items-center fg:gap-2" style={{ fontSize: 13 }}>
287
+ <input
288
+ type="checkbox"
289
+ checked={join.emailFlow.acceptedTerms}
290
+ onChange={(e) => join.emailFlow.setAcceptedTerms(e.target.checked)}
291
+ style={{ accentColor: t.primary }}
292
+ />
293
+ {tr("forge.community_join_sheet.accept_terms")}
294
+ </label>
295
+ {join.error && <p role="alert" style={errorText}>{join.error}</p>}
296
+ <button type="submit" disabled={join.isSubmitting} style={button}>
297
+ {join.isSubmitting ? tr("forge.community_join_sheet.working") : tr("forge.community_join_sheet.continue")}
298
+ </button>
299
+ <button type="button" data-testid="community-join-start-again" style={linkButton} onClick={join.back}>
300
+ {tr("forge.community_join_sheet.start_again")}
301
+ </button>
302
+ </form>
303
+ );
304
+
205
305
  case "tier":
206
306
  return (
207
307
  <div className="fg:flex fg:flex-col fg:gap-4" data-testid="community-join-tier">
@@ -1,27 +1,43 @@
1
- import { useState, type CSSProperties } from "react";
1
+ import { useState, type CSSProperties, type ReactNode } from "react";
2
+ import { ArrowDownCircle, ChevronRight, CreditCard, LogOut } from "lucide-react";
2
3
  import { useCommunityMembership } from "../../headless/communities/useCommunityMembership";
3
4
  import { useCommunityUpgradeOpener } from "../../../contexts/CommunityContext";
4
5
  import { useForgeLocale, useForgeT } from "../../../i18n";
6
+ import { useAmountFormatter } from "../../format/useFormatCurrency";
5
7
  import { CommunitySheet, Pill, shortDate, useCommunityStyles } from "./shared";
6
8
 
7
9
  export interface CommunityMembershipMenuProps {
8
10
  /** Controlled open state. Omitted, the menu renders its own trigger button. */
9
11
  open?: boolean;
10
12
  onOpenChange?: (open: boolean) => void;
13
+ /** Format an amount in a currency. Defaults to the runtime currency formatter. */
14
+ formatAmount?: (amount: number, currencyCode?: string) => string;
11
15
  className?: string;
12
16
  style?: CSSProperties;
13
17
  }
14
18
 
15
19
  /**
16
- * The Membership menu inside a community: the current tier, when it renews,
17
- * a pending downgrade (visible and cancellable), and billing, downgrade and
18
- * cancel. Cancel keeps the paid period and asks first; a downgrade takes effect
19
- * at period end. Built on `useCommunityMembership`.
20
+ * The Membership menu inside a community.
21
+ *
22
+ * At the top, a summary card: the tier, what it costs per period (or Free),
23
+ * when it renews or ends, and any state to act on (Ending, Payment failed).
24
+ * Under it, Upgrade when a higher tier exists. Then the other actions as rows:
25
+ * update the card (paid memberships only: a free one has no card), switch to
26
+ * a lower tier, and last, in the destructive style, Cancel (paid) or Leave
27
+ * (free), which asks first. Cancel keeps the paid period; a downgrade takes
28
+ * effect at period end. Built on `useCommunityMembership`.
20
29
  */
21
- export function CommunityMembershipMenu({ open: openProp, onOpenChange, className, style }: CommunityMembershipMenuProps) {
30
+ export function CommunityMembershipMenu({
31
+ open: openProp,
32
+ onOpenChange,
33
+ formatAmount,
34
+ className,
35
+ style,
36
+ }: CommunityMembershipMenuProps) {
22
37
  const tr = useForgeT();
23
38
  const locale = useForgeLocale();
24
39
  const { t, button, ghostButton, muted, errorText, card } = useCommunityStyles();
40
+ const fmt = useAmountFormatter(formatAmount);
25
41
  const membership = useCommunityMembership();
26
42
  const opener = useCommunityUpgradeOpener();
27
43
  const [ownOpen, setOwnOpen] = useState(false);
@@ -30,11 +46,68 @@ export function CommunityMembershipMenu({ open: openProp, onOpenChange, classNam
30
46
 
31
47
  if (!membership.hasMembership) return null;
32
48
  const periodEnd = shortDate(membership.periodEnd, locale);
49
+ const { billing, isPaid } = membership;
50
+ const danger = errorText.color as string;
51
+
52
+ const price =
53
+ billing.amount !== null
54
+ ? tr(billing.cycle === "year" ? "forge.community_membership_menu.price_year" : "forge.community_membership_menu.price_month", {
55
+ amount: fmt(billing.amount, billing.currency ?? undefined),
56
+ })
57
+ : isPaid
58
+ ? null
59
+ : tr("forge.community_membership_menu.free");
60
+
61
+ const row = (opts: {
62
+ key: string;
63
+ icon: ReactNode;
64
+ label: string;
65
+ onPress: () => void;
66
+ destructive?: boolean;
67
+ disabled?: boolean;
68
+ }) => (
69
+ <button
70
+ key={opts.key}
71
+ type="button"
72
+ data-testid={`community-membership-row-${opts.key}`}
73
+ disabled={opts.disabled}
74
+ onClick={opts.onPress}
75
+ className="fg:flex fg:w-full fg:items-center fg:gap-3"
76
+ style={{
77
+ padding: "14px 4px",
78
+ background: "transparent",
79
+ border: "none",
80
+ borderBottom: `1px solid ${t.border}`,
81
+ color: opts.destructive ? danger : t.text,
82
+ fontFamily: t.fontFamily,
83
+ fontSize: 15,
84
+ fontWeight: 500,
85
+ textAlign: "left",
86
+ cursor: opts.disabled ? "default" : "pointer",
87
+ opacity: opts.disabled ? 0.6 : 1,
88
+ }}
89
+ >
90
+ <span aria-hidden className="fg:flex fg:shrink-0" style={{ color: opts.destructive ? danger : t.muted }}>
91
+ {opts.icon}
92
+ </span>
93
+ <span style={{ flex: 1 }}>{opts.label}</span>
94
+ {!opts.destructive && <ChevronRight size={16} aria-hidden style={{ color: t.muted }} />}
95
+ </button>
96
+ );
97
+
98
+ const canDowngrade = !membership.pendingDowngradeTier && !membership.isCancelling;
99
+ const canCancel = !membership.isCancelling && !membership.cancelRequested;
33
100
 
34
101
  return (
35
102
  <>
36
103
  {openProp === undefined && (
37
- <button type="button" data-testid="community-membership-menu-trigger" className={className} style={{ ...ghostButton, ...style }} onClick={() => setOpen(true)}>
104
+ <button
105
+ type="button"
106
+ data-testid="community-membership-menu-trigger"
107
+ className={className}
108
+ style={{ ...ghostButton, ...style }}
109
+ onClick={() => setOpen(true)}
110
+ >
38
111
  {tr("forge.community_membership_menu.trigger")}
39
112
  </button>
40
113
  )}
@@ -49,17 +122,38 @@ export function CommunityMembershipMenu({ open: openProp, onOpenChange, classNam
49
122
  closeLabel={tr("forge.community_membership_menu.aria_close")}
50
123
  >
51
124
  <div className="fg:flex fg:flex-col fg:gap-4">
52
- <div className="fg:flex fg:flex-wrap fg:items-center fg:gap-2">
53
- <span style={{ fontWeight: 700 }}>{membership.membership?.tierName}</span>
54
- {membership.isCancelling && <Pill>{tr("forge.community_membership_menu.ending")}</Pill>}
55
- {membership.isPastDue && <Pill>{tr("forge.community_membership_menu.payment_failed")}</Pill>}
125
+ <div data-testid="community-membership-summary" style={{ ...card, padding: 16 }}>
126
+ <div className="fg:flex fg:flex-wrap fg:items-center fg:gap-2">
127
+ <span style={{ fontWeight: 700, fontSize: 17 }}>{membership.membership?.tierName}</span>
128
+ {membership.isCancelling && <Pill>{tr("forge.community_membership_menu.ending")}</Pill>}
129
+ {membership.isPastDue && <Pill>{tr("forge.community_membership_menu.payment_failed")}</Pill>}
130
+ </div>
131
+ {price && (
132
+ <p data-testid="community-membership-price" style={{ fontSize: 15, fontWeight: 600, margin: "6px 0 0" }}>
133
+ {price}
134
+ </p>
135
+ )}
136
+ {periodEnd && (
137
+ <p data-testid="community-membership-period" style={{ ...muted, margin: "4px 0 0" }}>
138
+ {membership.isCancelling
139
+ ? tr("forge.community_membership_menu.ends_on", { date: periodEnd })
140
+ : tr("forge.community_membership_menu.renews_on", { date: periodEnd })}
141
+ </p>
142
+ )}
56
143
  </div>
57
- {periodEnd && (
58
- <p style={muted}>
59
- {membership.isCancelling
60
- ? tr("forge.community_membership_menu.ends_on", { date: periodEnd })
61
- : tr("forge.community_membership_menu.renews_on", { date: periodEnd })}
62
- </p>
144
+
145
+ {opener && membership.hasUpgrade && (
146
+ <button
147
+ type="button"
148
+ data-testid="community-membership-upgrade"
149
+ style={button}
150
+ onClick={() => {
151
+ setOpen(false);
152
+ opener.openUpgrade(null);
153
+ }}
154
+ >
155
+ {tr("forge.community_membership_menu.upgrade")}
156
+ </button>
63
157
  )}
64
158
 
65
159
  {membership.pendingDowngradeTier && (
@@ -70,62 +164,84 @@ export function CommunityMembershipMenu({ open: openProp, onOpenChange, classNam
70
164
  date: periodEnd,
71
165
  })}
72
166
  </p>
73
- <button type="button" style={{ ...ghostButton, marginTop: 8 }} disabled={membership.isWorking} onClick={() => void membership.undoDowngrade()}>
167
+ <button
168
+ type="button"
169
+ style={{ ...ghostButton, marginTop: 8 }}
170
+ disabled={membership.isWorking}
171
+ onClick={() => void membership.undoDowngrade()}
172
+ >
74
173
  {tr("forge.community_membership_menu.undo_downgrade")}
75
174
  </button>
76
175
  </div>
77
176
  )}
78
177
 
79
- <div className="fg:flex fg:flex-col fg:gap-2">
80
- {opener && (
81
- <button
82
- type="button"
83
- style={button}
84
- onClick={() => {
85
- setOpen(false);
86
- opener.openUpgrade(null);
87
- }}
88
- >
89
- {tr("forge.community_membership_menu.upgrade")}
90
- </button>
91
- )}
92
- <button type="button" style={ghostButton} disabled={membership.isWorking} onClick={() => void membership.updateCard()}>
93
- {tr("forge.community_membership_menu.update_card")}
94
- </button>
95
- {!membership.pendingDowngradeTier &&
96
- !membership.isCancelling &&
97
- membership.downgradeTiers.map((tier) => (
98
- <button key={tier.id} type="button" style={ghostButton} disabled={membership.isWorking} onClick={() => void membership.downgrade(tier.id)}>
99
- {tr("forge.community_membership_menu.downgrade_to", { tier_name: tier.name })}
100
- </button>
101
- ))}
102
- {!membership.isCancelling && !membership.cancelRequested && (
103
- <button type="button" style={{ ...ghostButton, color: t.text }} onClick={membership.requestCancel}>
104
- {tr("forge.community_membership_menu.cancel")}
105
- </button>
106
- )}
178
+ <div style={{ borderTop: `1px solid ${t.border}` }}>
179
+ {isPaid &&
180
+ row({
181
+ key: "update-card",
182
+ icon: <CreditCard size={18} />,
183
+ label: tr("forge.community_membership_menu.update_card"),
184
+ disabled: membership.isWorking,
185
+ onPress: () => void membership.updateCard(),
186
+ })}
187
+ {canDowngrade &&
188
+ membership.downgradeTiers.map((tier) =>
189
+ row({
190
+ key: `downgrade-${tier.id}`,
191
+ icon: <ArrowDownCircle size={18} />,
192
+ label: tr("forge.community_membership_menu.downgrade_to", { tier_name: tier.name }),
193
+ disabled: membership.isWorking,
194
+ onPress: () => void membership.downgrade(tier.id),
195
+ }),
196
+ )}
197
+ {canCancel &&
198
+ row({
199
+ key: "cancel",
200
+ icon: <LogOut size={18} />,
201
+ label: isPaid ? tr("forge.community_membership_menu.cancel") : tr("forge.community_membership_menu.leave"),
202
+ destructive: true,
203
+ onPress: membership.requestCancel,
204
+ })}
107
205
  </div>
108
206
 
109
207
  {/* A destructive confirmation is read every time, so it is content, not a hint. */}
110
208
  {membership.cancelRequested && (
111
209
  <div role="alertdialog" aria-modal="false" data-testid="community-membership-cancel-confirm" style={card}>
112
- <p style={{ fontWeight: 700 }}>{tr("forge.community_membership_menu.cancel_confirm_title")}</p>
210
+ <p style={{ fontWeight: 700 }}>
211
+ {isPaid
212
+ ? tr("forge.community_membership_menu.cancel_confirm_title")
213
+ : tr("forge.community_membership_menu.leave_confirm_title")}
214
+ </p>
113
215
  <p style={{ fontSize: 14, marginTop: 6 }}>
114
216
  {periodEnd
115
217
  ? tr("forge.community_membership_menu.cancel_confirm_until", { date: periodEnd })
116
- : tr("forge.community_membership_menu.cancel_confirm_now")}
218
+ : isPaid
219
+ ? tr("forge.community_membership_menu.cancel_confirm_now")
220
+ : tr("forge.community_membership_menu.leave_confirm_now")}
117
221
  </p>
118
222
  <div className="fg:flex fg:justify-end fg:gap-2" style={{ marginTop: 12 }}>
119
223
  <button type="button" style={ghostButton} onClick={membership.abortCancel}>
120
- {tr("forge.community_membership_menu.cancel_keep")}
224
+ {isPaid ? tr("forge.community_membership_menu.cancel_keep") : tr("forge.community_membership_menu.leave_keep")}
121
225
  </button>
122
- <button type="button" style={button} disabled={membership.isWorking} onClick={() => void membership.confirmCancel()}>
123
- {tr("forge.community_membership_menu.cancel_confirm")}
226
+ <button
227
+ type="button"
228
+ data-testid="community-membership-cancel-confirm-button"
229
+ style={button}
230
+ disabled={membership.isWorking}
231
+ onClick={() => void membership.confirmCancel()}
232
+ >
233
+ {isPaid
234
+ ? tr("forge.community_membership_menu.cancel_confirm")
235
+ : tr("forge.community_membership_menu.leave_confirm")}
124
236
  </button>
125
237
  </div>
126
238
  </div>
127
239
  )}
128
- {membership.error && <p role="alert" style={errorText}>{membership.error}</p>}
240
+ {membership.error && (
241
+ <p role="alert" style={errorText}>
242
+ {membership.error}
243
+ </p>
244
+ )}
129
245
  </div>
130
246
  </CommunitySheet>
131
247
  </>
@@ -0,0 +1,75 @@
1
+ import { useState, type CSSProperties } from "react";
2
+ import { ChevronDown, ChevronRight } from "lucide-react";
3
+ import { useForgeT } from "../../../i18n";
4
+ import { useCommunityWiki } from "../../headless/communities/useCommunityWiki";
5
+ import { CommunityWikiPageView } from "./CommunityWiki";
6
+ import { useCommunityStyles } from "./shared";
7
+
8
+ export interface CommunityPublicWikiProps {
9
+ className?: string;
10
+ style?: CSSProperties;
11
+ }
12
+
13
+ /**
14
+ * The community's PUBLIC wiki pages (the guidelines, by default) on its About
15
+ * page, so a visitor can read them before joining. Each page is a section that
16
+ * opens in place and loads its content only then. Renders nothing when the
17
+ * community has no public pages. Must sit inside `<CommunityProvider>`. Built
18
+ * on `useCommunityWiki({ publicOnly: true })`.
19
+ */
20
+ export function CommunityPublicWiki({ className, style }: CommunityPublicWikiProps) {
21
+ const tr = useForgeT();
22
+ const { t, card } = useCommunityStyles();
23
+ const wiki = useCommunityWiki({ publicOnly: true });
24
+ const [openSlug, setOpenSlug] = useState<string | null>(null);
25
+
26
+ if (wiki.isLoading || wiki.readablePages.length === 0) return null;
27
+
28
+ return (
29
+ <section
30
+ data-testid="community-public-wiki"
31
+ className={className}
32
+ style={{ marginTop: 32, color: t.text, ...style }}
33
+ >
34
+ <h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
35
+ {tr("forge.community_public_wiki.title")}
36
+ </h2>
37
+ <div className="fg:flex fg:flex-col fg:gap-3">
38
+ {wiki.readablePages.map((page) => {
39
+ const open = openSlug === page.slug;
40
+ const panelId = `community-public-wiki-${page.id}`;
41
+ return (
42
+ <div key={page.id} data-testid="community-public-wiki-item" style={{ ...card, padding: 0 }}>
43
+ <button
44
+ type="button"
45
+ aria-expanded={open}
46
+ aria-controls={panelId}
47
+ onClick={() => setOpenSlug(open ? null : page.slug)}
48
+ className="fg:flex fg:w-full fg:items-center fg:gap-2"
49
+ style={{
50
+ background: "transparent",
51
+ border: "none",
52
+ padding: "14px 18px",
53
+ cursor: "pointer",
54
+ color: t.text,
55
+ fontSize: 16,
56
+ fontWeight: 600,
57
+ textAlign: "left",
58
+ fontFamily: "inherit",
59
+ }}
60
+ >
61
+ {open ? <ChevronDown size={16} aria-hidden /> : <ChevronRight size={16} aria-hidden />}
62
+ {page.title}
63
+ </button>
64
+ {open && (
65
+ <div id={panelId} style={{ padding: "0 18px 18px" }}>
66
+ <CommunityWikiPageView pageSlug={page.slug} hideTitle />
67
+ </div>
68
+ )}
69
+ </div>
70
+ );
71
+ })}
72
+ </div>
73
+ </section>
74
+ );
75
+ }