@tribe-nest/forge 3.58.0 → 3.60.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 (94) hide show
  1. package/package.json +1 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CommunityContext.tsx +192 -0
  4. package/src/data/queries/useChat.ts +42 -39
  5. package/src/data/queries/useCommunities.ts +343 -0
  6. package/src/data/queries/useCommunity.ts +93 -89
  7. package/src/data/queries/useMembership.ts +32 -7
  8. package/src/data/queries/useMembershipCalls.ts +116 -7
  9. package/src/data/queries/usePostCollections.ts +28 -16
  10. package/src/data/queries/usePosts.ts +16 -14
  11. package/src/data/queries/useSubscriptions.ts +22 -8
  12. package/src/i18n/de.json +145 -1
  13. package/src/i18n/en.json +145 -1
  14. package/src/index.ts +19 -7
  15. package/src/runtime/pages.spec.ts +31 -0
  16. package/src/runtime/pages.ts +21 -22
  17. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  18. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  19. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  20. package/src/ui/format/membershipGate.ts +19 -15
  21. package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
  22. package/src/ui/headless/_tests/communityHarness.tsx +205 -0
  23. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  24. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  25. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +382 -0
  26. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +151 -0
  27. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +286 -0
  28. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  29. package/src/ui/headless/auth/useSignupForm.ts +10 -2
  30. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  31. package/src/ui/headless/communities/index.ts +55 -0
  32. package/src/ui/headless/communities/paths.ts +36 -0
  33. package/src/ui/headless/communities/useCommunity.ts +140 -0
  34. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  35. package/src/ui/headless/communities/useCommunityHome.ts +88 -0
  36. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  37. package/src/ui/headless/communities/useCommunityJoin.ts +444 -0
  38. package/src/ui/headless/communities/useCommunityMembership.ts +136 -0
  39. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  40. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  41. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  42. package/src/ui/headless/index.ts +8 -4
  43. package/src/ui/headless/memberHome/index.ts +1 -1
  44. package/src/ui/headless/memberHome/useMemberHomeNav.ts +44 -42
  45. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  46. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  47. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  48. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  49. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  50. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  51. package/src/ui/headless/posts/upsell.ts +54 -8
  52. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  53. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  54. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  55. package/src/ui/headless/posts/usePostItem.ts +6 -5
  56. package/src/ui/index.ts +17 -12
  57. package/src/ui/styled/AccountDashboard.tsx +48 -256
  58. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  59. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  60. package/src/ui/styled/MembershipTiers.tsx +49 -11
  61. package/src/ui/styled/Paywall.tsx +1 -1
  62. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +1 -1
  63. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  64. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  65. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  66. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  67. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  68. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  69. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  70. package/src/ui/styled/communities/CommunityAbout.tsx +140 -0
  71. package/src/ui/styled/communities/CommunityHome.tsx +175 -0
  72. package/src/ui/styled/communities/CommunityJoinSheet.tsx +444 -0
  73. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +133 -0
  74. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  75. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  76. package/src/ui/styled/communities/MyMembershipsList.tsx +89 -0
  77. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  78. package/src/ui/styled/communities/index.ts +10 -0
  79. package/src/ui/styled/communities/shared.tsx +149 -0
  80. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  81. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  82. package/src/ui/styled/forge-utilities.css +19 -0
  83. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  84. package/src/ui/styled/members/MemberCallsCalendar.tsx +142 -10
  85. package/src/ui/styled/members/MemberHomeLayout.tsx +35 -17
  86. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  87. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  88. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  89. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  90. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  91. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  92. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  93. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  94. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
@@ -0,0 +1,444 @@
1
+ import type { CSSProperties } from "react";
2
+ import { useCommunityJoin, type UseCommunityJoinOptions } from "../../headless/communities/useCommunityJoin";
3
+ import type { PublicTier } from "../../../data/queries/useCommunities";
4
+ import type { CommunityInviteProblem } from "../../headless/communities/useCommunityInvite";
5
+ import { useSiteConfig } from "../../../data/queries/useWebsite";
6
+ import { useForgeLocale, useForgeT, type ForgeT } from "../../../i18n";
7
+ import { usePaymentRenderer } from "../../payment/ForgePaymentProvider";
8
+ import { formatCurrency } from "../../format/useFormatCurrency";
9
+ import { cycleFloor, cycleIsFree, type BillingCycle } from "../../format/membershipPwyw";
10
+ import { membershipPriceLabel } from "../../format/membershipPriceLabel";
11
+ import { formatTrialDate } from "../../headless/membership/membershipTrial";
12
+ import { CallHelpHint } from "../../media/CallHelpHint";
13
+ import { Loading } from "../Loading";
14
+ import { PaystackPayButton } from "../PaystackPayButton";
15
+ import { CommunitySheet, useCommunityStyles } from "./shared";
16
+
17
+ export interface CommunityJoinSheetProps extends Omit<UseCommunityJoinOptions, "tierId"> {
18
+ open: boolean;
19
+ onOpenChange: (open: boolean) => void;
20
+ /** The tier the sheet opens on (`?tier=`, or a tier card's own Join). An invite's tier wins. */
21
+ tierId?: string | null;
22
+ }
23
+
24
+ /**
25
+ * The join sheet: the About page's Join, in place. Every step is
26
+ * `useCommunityJoin` plus markup; the step order, what is skipped, invites and
27
+ * provider differences all live in the hook.
28
+ */
29
+ export function CommunityJoinSheet({ open, onOpenChange, ...options }: CommunityJoinSheetProps) {
30
+ const tr = useForgeT();
31
+ // The flow lives HERE, outside the dialog content, so a Paystack stand-down
32
+ // that remounts the content does not reset it.
33
+ const join = useCommunityJoin(options);
34
+ return (
35
+ <CommunitySheet
36
+ open={open}
37
+ onOpenChange={onOpenChange}
38
+ testId="community-join-sheet"
39
+ title={join.community ? tr("forge.community_join_sheet.title", { community_name: join.community.name }) : tr("forge.community_join_sheet.title_plain")}
40
+ closeLabel={tr("forge.community_join_sheet.aria_close")}
41
+ >
42
+ <JoinBody join={join} />
43
+ </CommunitySheet>
44
+ );
45
+ }
46
+
47
+ type Join = ReturnType<typeof useCommunityJoin>;
48
+
49
+ /** Why an invite link cannot be used, said plainly. The normal Join still works underneath. */
50
+ const INVITE_PROBLEM_KEYS: Record<CommunityInviteProblem, string> = {
51
+ expired: "forge.community_join_sheet.invite_expired",
52
+ revoked: "forge.community_join_sheet.invite_revoked",
53
+ used: "forge.community_join_sheet.invite_used",
54
+ used_up: "forge.community_join_sheet.invite_used_up",
55
+ not_found: "forge.community_join_sheet.invite_not_found",
56
+ };
57
+
58
+ function JoinBody({ join }: { join: Join }) {
59
+ const tr = useForgeT();
60
+ const { t, button, ghostButton, input, muted, errorText } = useCommunityStyles();
61
+ const locale = useForgeLocale();
62
+ const renderPayment = usePaymentRenderer();
63
+ const currency = useSiteConfig().data?.currency;
64
+ const money = (amount: number) => formatCurrency(amount, currency);
65
+ const field: CSSProperties = { display: "flex", flexDirection: "column", gap: 6, fontSize: 14 };
66
+
67
+ if (join.isLoading) return <Loading />;
68
+
69
+ const problem = join.inviteProblem && (
70
+ <p role="alert" data-testid="community-join-invite-problem" style={{ ...muted, fontWeight: 600, marginBottom: 12 }}>
71
+ {tr(INVITE_PROBLEM_KEYS[join.inviteProblem])}
72
+ </p>
73
+ );
74
+
75
+ switch (join.step) {
76
+ case "email": {
77
+ if (join.emailMismatch) {
78
+ return (
79
+ <div data-testid="community-join-email-mismatch" className="fg:flex fg:flex-col fg:gap-3">
80
+ <p role="alert" style={{ fontWeight: 600 }}>
81
+ {tr("forge.community_join_sheet.email_mismatch", {
82
+ email: join.emailMismatch.invitedEmail,
83
+ signed_in_email: join.emailMismatch.signedInEmail,
84
+ })}
85
+ </p>
86
+ <button type="button" style={button} onClick={() => void join.signInAsInvited()}>
87
+ {tr("forge.community_join_sheet.sign_in_as", { email: join.emailMismatch.invitedEmail })}
88
+ </button>
89
+ </div>
90
+ );
91
+ }
92
+ const signup = join.mode === "signup";
93
+ return (
94
+ <form
95
+ className="fg:flex fg:flex-col fg:gap-3"
96
+ onSubmit={(e) => {
97
+ e.preventDefault();
98
+ void join.next();
99
+ }}
100
+ >
101
+ {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
+ {signup && (
117
+ <div className="fg:flex fg:gap-2">
118
+ <label style={{ ...field, flex: 1 }}>
119
+ {tr("forge.community_join_sheet.first_name")}
120
+ <input style={input} value={join.signup.firstName} onChange={(e) => join.signup.setFirstName(e.target.value)} autoComplete="given-name" />
121
+ </label>
122
+ <label style={{ ...field, flex: 1 }}>
123
+ {tr("forge.community_join_sheet.last_name")}
124
+ <input style={input} value={join.signup.lastName} onChange={(e) => join.signup.setLastName(e.target.value)} autoComplete="family-name" />
125
+ </label>
126
+ </div>
127
+ )}
128
+ <label style={field}>
129
+ {tr("forge.community_join_sheet.email")}
130
+ <input
131
+ style={{ ...input, opacity: join.emailLocked ? 0.75 : 1 }}
132
+ type="email"
133
+ value={signup ? join.signup.email : join.login.email}
134
+ readOnly={join.emailLocked}
135
+ aria-readonly={join.emailLocked}
136
+ onChange={(e) => (signup ? join.signup.setEmail(e.target.value) : join.login.setEmail(e.target.value))}
137
+ autoComplete="email"
138
+ />
139
+ </label>
140
+ <label style={field}>
141
+ {tr("forge.community_join_sheet.password")}
142
+ <input
143
+ style={input}
144
+ type="password"
145
+ value={signup ? join.signup.password : join.login.password}
146
+ onChange={(e) => (signup ? join.signup.setPassword(e.target.value) : join.login.setPassword(e.target.value))}
147
+ autoComplete={signup ? "new-password" : "current-password"}
148
+ />
149
+ </label>
150
+ {signup && (
151
+ <label className="fg:flex fg:items-center fg:gap-2" style={{ fontSize: 13 }}>
152
+ <input
153
+ type="checkbox"
154
+ checked={join.signup.acceptedTerms}
155
+ onChange={(e) => join.signup.setAcceptedTerms(e.target.checked)}
156
+ style={{ accentColor: t.primary }}
157
+ />
158
+ {tr("forge.community_join_sheet.accept_terms")}
159
+ </label>
160
+ )}
161
+ {join.error && <p role="alert" style={errorText}>{join.error}</p>}
162
+ <button type="submit" disabled={join.isSubmitting} style={button}>
163
+ {join.isSubmitting ? tr("forge.community_join_sheet.working") : tr("forge.community_join_sheet.continue")}
164
+ </button>
165
+ </form>
166
+ );
167
+ }
168
+
169
+ case "code":
170
+ return (
171
+ <form
172
+ className="fg:flex fg:flex-col fg:gap-3"
173
+ onSubmit={(e) => {
174
+ e.preventDefault();
175
+ void join.next();
176
+ }}
177
+ >
178
+ <p>{tr("forge.community_join_sheet.code_sent", { email: join.mode === "signup" ? join.signup.email : join.login.email })}</p>
179
+ <label style={field}>
180
+ {tr("forge.community_join_sheet.code")}
181
+ <input
182
+ style={{ ...input, letterSpacing: 4 }}
183
+ inputMode="numeric"
184
+ autoComplete="one-time-code"
185
+ maxLength={6}
186
+ value={join.code}
187
+ onChange={(e) => join.setCode(e.target.value.replace(/\D/g, ""))}
188
+ />
189
+ </label>
190
+ {join.error && <p role="alert" style={errorText}>{join.error}</p>}
191
+ <button type="submit" disabled={join.isSubmitting} style={button}>
192
+ {join.isSubmitting ? tr("forge.community_join_sheet.working") : tr("forge.community_join_sheet.verify")}
193
+ </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")}
197
+ </button>
198
+ <button type="button" style={ghostButton} onClick={() => void join.resendCode()}>
199
+ {tr("forge.community_join_sheet.resend")}
200
+ </button>
201
+ </div>
202
+ </form>
203
+ );
204
+
205
+ case "tier":
206
+ return (
207
+ <div className="fg:flex fg:flex-col fg:gap-4" data-testid="community-join-tier">
208
+ {problem}
209
+ {join.hasTierChoice && (
210
+ <div className="fg:flex fg:flex-col fg:gap-2" role="radiogroup" aria-label={tr("forge.community_join_sheet.choose_tier")}>
211
+ {join.tiers.map((tier) => (
212
+ <TierOption key={tier.id} tier={tier} selected={join.selectedTier?.id === tier.id} onSelect={() => join.selectTier(tier)} t={tr} money={money} />
213
+ ))}
214
+ </div>
215
+ )}
216
+ {join.selectedTier && (
217
+ <div className="fg:flex fg:flex-col fg:gap-3">
218
+ {!join.hasTierChoice && (
219
+ <div>
220
+ <p style={{ fontWeight: 700 }}>{join.selectedTier.name}</p>
221
+ <p style={muted}>{membershipPriceLabel(join.selectedTier, tr, money)}</p>
222
+ </div>
223
+ )}
224
+ {join.cycles.month && join.cycles.year && (
225
+ <div className="fg:flex fg:flex-wrap fg:gap-4" role="radiogroup" aria-label={tr("forge.community_join_sheet.billing_cycle")}>
226
+ {(["month", "year"] as const).map((cycle) => (
227
+ <label key={cycle} className="fg:flex fg:items-center fg:gap-2" style={{ cursor: "pointer", fontSize: 14 }}>
228
+ <input
229
+ type="radio"
230
+ checked={join.billingCycle === cycle}
231
+ onChange={() => join.setBillingCycle(cycle)}
232
+ style={{ accentColor: t.primary }}
233
+ />
234
+ {cycleLabel(join.selectedTier!, cycle, tr, money)}
235
+ </label>
236
+ ))}
237
+ </div>
238
+ )}
239
+ {join.selectedTier.payWhatYouWant && (
240
+ <label style={field}>
241
+ {tr("forge.community_join_sheet.amount_label", { minimum: money(join.minimumAmount) })}
242
+ <input
243
+ style={{ ...input, borderColor: join.amountRefusal ? "#ef4444" : undefined }}
244
+ type="number"
245
+ min={join.minimumAmount}
246
+ step="any"
247
+ value={join.customAmount || ""}
248
+ aria-invalid={!!join.amountRefusal}
249
+ onChange={(e) => join.setCustomAmount(Number(e.target.value))}
250
+ />
251
+ </label>
252
+ )}
253
+ {join.amountRefusal && (
254
+ <p role="alert" style={errorText}>
255
+ {join.amountRefusal.reason === "below_minimum"
256
+ ? tr("forge.community_join_sheet.amount_below_minimum", { minimum: money(join.amountRefusal.minimum) })
257
+ : tr("forge.community_join_sheet.amount_not_positive")}
258
+ </p>
259
+ )}
260
+ {join.trialDays > 0 && join.trialEndsAt && (
261
+ <p data-testid="community-join-trial" style={{ fontSize: 14, fontWeight: 600 }}>
262
+ {tr("forge.community_join_sheet.trial_line", { days: join.trialDays, date: formatTrialDate(join.trialEndsAt, locale) })}
263
+ </p>
264
+ )}
265
+ </div>
266
+ )}
267
+ {join.error && <p role="alert" style={errorText}>{join.error}</p>}
268
+ <button type="button" disabled={!join.selectedTier || join.isSubmitting} style={button} onClick={() => void join.next()}>
269
+ {submitLabel(join, tr, money)}
270
+ </button>
271
+ </div>
272
+ );
273
+
274
+ case "questions":
275
+ return (
276
+ <form
277
+ className="fg:flex fg:flex-col fg:gap-3"
278
+ data-testid="community-join-questions"
279
+ onSubmit={(e) => {
280
+ e.preventDefault();
281
+ void join.next();
282
+ }}
283
+ >
284
+ {join.questionnaire.map((q) => {
285
+ const value = join.questionnaireAnswers[q.id] ?? "";
286
+ const missing = !!join.questionnaireRefusal && !q.optional && !value.trim();
287
+ const style = { ...input, borderColor: missing ? "#ef4444" : undefined };
288
+ return (
289
+ <label key={q.id} style={field}>
290
+ <span>
291
+ {q.question}
292
+ {!q.optional && <span aria-hidden="true" style={{ color: "#ef4444" }}> *</span>}
293
+ </span>
294
+ {q.type === "select" && q.options?.length ? (
295
+ <select style={style} value={value} aria-invalid={missing} onChange={(e) => join.setQuestionnaireAnswer(q.id, e.target.value)}>
296
+ <option value="">{tr("forge.community_join_sheet.select_placeholder")}</option>
297
+ {q.options.map((option) => (
298
+ <option key={option} value={option}>
299
+ {option}
300
+ </option>
301
+ ))}
302
+ </select>
303
+ ) : q.type === "textarea" ? (
304
+ <textarea
305
+ style={{ ...style, height: "auto", resize: "vertical" }}
306
+ rows={3}
307
+ value={value}
308
+ aria-invalid={missing}
309
+ onChange={(e) => join.setQuestionnaireAnswer(q.id, e.target.value)}
310
+ />
311
+ ) : (
312
+ <input style={style} value={value} aria-invalid={missing} onChange={(e) => join.setQuestionnaireAnswer(q.id, e.target.value)} />
313
+ )}
314
+ </label>
315
+ );
316
+ })}
317
+ {join.questionnaireRefusal && (
318
+ <p role="alert" style={errorText}>
319
+ {tr("forge.community_join_sheet.answer_required", { question: join.questionnaireRefusal.question })}
320
+ </p>
321
+ )}
322
+ {join.error && <p role="alert" style={errorText}>{join.error}</p>}
323
+ <div className="fg:flex fg:gap-2">
324
+ <button type="button" style={ghostButton} onClick={join.back}>
325
+ {tr("forge.community_join_sheet.back")}
326
+ </button>
327
+ <button type="submit" disabled={join.isSubmitting} style={{ ...button, flex: 1 }}>
328
+ {submitLabel(join, tr, money)}
329
+ </button>
330
+ </div>
331
+ </form>
332
+ );
333
+
334
+ case "payment":
335
+ return (
336
+ <div className="fg:flex fg:flex-col fg:gap-3" data-testid="community-join-payment">
337
+ <p style={{ fontWeight: 700 }}>
338
+ {join.setupPurpose === "application"
339
+ ? tr("forge.community_join_sheet.card_application")
340
+ : join.setupPurpose === "trial"
341
+ ? tr("forge.community_join_sheet.card_trial")
342
+ : tr("forge.community_join_sheet.card_payment", { amount: money(join.amount) })}
343
+ {join.setupPurpose === "application" && (
344
+ <CallHelpHint label={tr("forge.community_join_sheet.card_application_help_label")}>
345
+ {tr("forge.community_join_sheet.card_application_help")}
346
+ </CallHelpHint>
347
+ )}
348
+ </p>
349
+ {join.isPaystack ? (
350
+ <PaystackPayButton onPay={join.openPaystackCheckout} label={tr("forge.community_join_sheet.pay", { amount: money(join.amount) })} />
351
+ ) : (
352
+ join.clientSecret &&
353
+ renderPayment({
354
+ clientSecret: join.clientSecret,
355
+ returnUrl: join.returnUrl,
356
+ amount: join.amount,
357
+ currency,
358
+ mode: "manual",
359
+ trial: join.trial,
360
+ onSucceeded: () => void join.onPaymentSucceeded(),
361
+ onFailed: () => {},
362
+ })
363
+ )}
364
+ {join.error && <p role="alert" style={errorText}>{join.error}</p>}
365
+ <button type="button" style={ghostButton} onClick={join.back}>
366
+ {tr("forge.community_join_sheet.back")}
367
+ </button>
368
+ </div>
369
+ );
370
+
371
+ case "submitting":
372
+ return (
373
+ <div className="fg:flex fg:flex-col fg:items-center fg:gap-3" style={{ padding: 16 }}>
374
+ <Loading />
375
+ <p style={muted}>{tr("forge.community_join_sheet.joining")}</p>
376
+ </div>
377
+ );
378
+
379
+ case "pending":
380
+ return (
381
+ <div data-testid="community-join-pending" className="fg:flex fg:flex-col fg:gap-2">
382
+ <p style={{ fontWeight: 700 }}>{tr("forge.community_join_sheet.pending_title")}</p>
383
+ <p style={muted}>{tr("forge.community_join_sheet.pending_body")}</p>
384
+ </div>
385
+ );
386
+
387
+ case "joined":
388
+ return (
389
+ <div data-testid="community-join-joined" className="fg:flex fg:flex-col fg:gap-2">
390
+ <p style={{ fontWeight: 700 }}>
391
+ {join.alreadyMember ? tr("forge.community_join_sheet.already_member") : tr("forge.community_join_sheet.joined_title")}
392
+ </p>
393
+ <p style={muted}>{tr("forge.community_join_sheet.taking_you_inside")}</p>
394
+ </div>
395
+ );
396
+ }
397
+ }
398
+
399
+ function TierOption({
400
+ tier,
401
+ selected,
402
+ onSelect,
403
+ t,
404
+ money,
405
+ }: {
406
+ tier: PublicTier;
407
+ selected: boolean;
408
+ onSelect: () => void;
409
+ t: ForgeT;
410
+ money: (amount: number) => string;
411
+ }) {
412
+ const { t: tokens, card } = useCommunityStyles();
413
+ return (
414
+ <button
415
+ type="button"
416
+ role="radio"
417
+ aria-checked={selected}
418
+ onClick={onSelect}
419
+ className="fg:flex fg:flex-col fg:gap-1 fg:text-left"
420
+ style={{ ...card, padding: 14, cursor: "pointer", font: "inherit", borderColor: selected ? tokens.primary : tokens.border }}
421
+ >
422
+ <span style={{ fontWeight: 700 }}>{tier.name}</span>
423
+ <span style={{ fontSize: 13, color: tokens.muted }}>{membershipPriceLabel(tier, t, money)}</span>
424
+ {!!tier.benefits?.length && (
425
+ <span style={{ fontSize: 13 }}>{tier.benefits.slice(0, 3).map((benefit) => benefit.title).join(", ")}</span>
426
+ )}
427
+ </button>
428
+ );
429
+ }
430
+
431
+ function cycleLabel(tier: PublicTier, cycle: BillingCycle, t: ForgeT, money: (amount: number) => string): string {
432
+ const amount = money(cycleFloor(tier, cycle));
433
+ if (cycleIsFree(tier, cycle)) return t(cycle === "month" ? "forge.community_join_sheet.monthly" : "forge.community_join_sheet.yearly");
434
+ return t(cycle === "month" ? "forge.community_join_sheet.monthly_price" : "forge.community_join_sheet.yearly_price", { amount });
435
+ }
436
+
437
+ function submitLabel(join: Join, t: ForgeT, money: (amount: number) => string): string {
438
+ if (join.isSubmitting) return t("forge.community_join_sheet.working");
439
+ if (join.isApplication) return t("forge.community_join_sheet.apply");
440
+ if (join.isChange) return t("forge.community_join_sheet.change_tier");
441
+ if (join.isFreeCycle) return t("forge.community_join_sheet.join_free");
442
+ if (join.trialDays > 0) return t("forge.community_join_sheet.start_trial");
443
+ return t("forge.community_join_sheet.join_for", { amount: money(join.amount) });
444
+ }
@@ -0,0 +1,133 @@
1
+ import { useState, type CSSProperties } from "react";
2
+ import { useCommunityMembership } from "../../headless/communities/useCommunityMembership";
3
+ import { useCommunityUpgradeOpener } from "../../../contexts/CommunityContext";
4
+ import { useForgeLocale, useForgeT } from "../../../i18n";
5
+ import { CommunitySheet, Pill, shortDate, useCommunityStyles } from "./shared";
6
+
7
+ export interface CommunityMembershipMenuProps {
8
+ /** Controlled open state. Omitted, the menu renders its own trigger button. */
9
+ open?: boolean;
10
+ onOpenChange?: (open: boolean) => void;
11
+ className?: string;
12
+ style?: CSSProperties;
13
+ }
14
+
15
+ /**
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
+ */
21
+ export function CommunityMembershipMenu({ open: openProp, onOpenChange, className, style }: CommunityMembershipMenuProps) {
22
+ const tr = useForgeT();
23
+ const locale = useForgeLocale();
24
+ const { t, button, ghostButton, muted, errorText, card } = useCommunityStyles();
25
+ const membership = useCommunityMembership();
26
+ const opener = useCommunityUpgradeOpener();
27
+ const [ownOpen, setOwnOpen] = useState(false);
28
+ const open = openProp ?? ownOpen;
29
+ const setOpen = onOpenChange ?? setOwnOpen;
30
+
31
+ if (!membership.hasMembership) return null;
32
+ const periodEnd = shortDate(membership.periodEnd, locale);
33
+
34
+ return (
35
+ <>
36
+ {openProp === undefined && (
37
+ <button type="button" data-testid="community-membership-menu-trigger" className={className} style={{ ...ghostButton, ...style }} onClick={() => setOpen(true)}>
38
+ {tr("forge.community_membership_menu.trigger")}
39
+ </button>
40
+ )}
41
+ <CommunitySheet
42
+ open={open}
43
+ onOpenChange={(next) => {
44
+ if (!next) membership.abortCancel();
45
+ setOpen(next);
46
+ }}
47
+ testId="community-membership-menu"
48
+ title={tr("forge.community_membership_menu.title")}
49
+ closeLabel={tr("forge.community_membership_menu.aria_close")}
50
+ >
51
+ <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>}
56
+ </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>
63
+ )}
64
+
65
+ {membership.pendingDowngradeTier && (
66
+ <div role="status" data-testid="community-membership-pending-downgrade" style={card}>
67
+ <p style={{ fontSize: 14 }}>
68
+ {tr("forge.community_membership_menu.pending_downgrade", {
69
+ tier_name: membership.pendingDowngradeTier.name,
70
+ date: periodEnd,
71
+ })}
72
+ </p>
73
+ <button type="button" style={{ ...ghostButton, marginTop: 8 }} disabled={membership.isWorking} onClick={() => void membership.undoDowngrade()}>
74
+ {tr("forge.community_membership_menu.undo_downgrade")}
75
+ </button>
76
+ </div>
77
+ )}
78
+
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
+ )}
107
+ </div>
108
+
109
+ {/* A destructive confirmation is read every time, so it is content, not a hint. */}
110
+ {membership.cancelRequested && (
111
+ <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>
113
+ <p style={{ fontSize: 14, marginTop: 6 }}>
114
+ {periodEnd
115
+ ? tr("forge.community_membership_menu.cancel_confirm_until", { date: periodEnd })
116
+ : tr("forge.community_membership_menu.cancel_confirm_now")}
117
+ </p>
118
+ <div className="fg:flex fg:justify-end fg:gap-2" style={{ marginTop: 12 }}>
119
+ <button type="button" style={ghostButton} onClick={membership.abortCancel}>
120
+ {tr("forge.community_membership_menu.cancel_keep")}
121
+ </button>
122
+ <button type="button" style={button} disabled={membership.isWorking} onClick={() => void membership.confirmCancel()}>
123
+ {tr("forge.community_membership_menu.cancel_confirm")}
124
+ </button>
125
+ </div>
126
+ </div>
127
+ )}
128
+ {membership.error && <p role="alert" style={errorText}>{membership.error}</p>}
129
+ </div>
130
+ </CommunitySheet>
131
+ </>
132
+ );
133
+ }
@@ -0,0 +1,49 @@
1
+ import type { CSSProperties } from "react";
2
+ import { Clock } from "lucide-react";
3
+ import { useCommunity } from "../../headless/communities/useCommunity";
4
+ import { useForgeLocale, useForgeT } from "../../../i18n";
5
+ import { Loading } from "../Loading";
6
+ import { shortDate, useCommunityStyles } from "./shared";
7
+
8
+ export interface CommunityPendingProps {
9
+ className?: string;
10
+ style?: CSSProperties;
11
+ }
12
+
13
+ /**
14
+ * The community home for a fan whose application is being reviewed: what the
15
+ * community is, and that the application is waiting. No member content.
16
+ * Approval turns the same URL into the full home without a new link. Must sit
17
+ * inside `<CommunityProvider>`. Built on `useCommunity` (`isPending`,
18
+ * `pendingSince`).
19
+ */
20
+ export function CommunityPending({ className, style }: CommunityPendingProps) {
21
+ const tr = useForgeT();
22
+ const locale = useForgeLocale();
23
+ const { t, card, muted } = useCommunityStyles();
24
+ const community = useCommunity();
25
+
26
+ if (community.isLoading && !community.community) return <Loading fullPage />;
27
+
28
+ return (
29
+ <div className={className} data-testid="community-pending" style={{ color: t.text, fontFamily: t.fontFamily, ...style }}>
30
+ {community.community && (
31
+ <div className="fg:flex fg:flex-col fg:gap-2" style={{ marginBottom: 24 }}>
32
+ <h1 style={{ fontSize: 28, fontWeight: 800, margin: 0, fontFamily: t.headingFontFamily }}>{community.community.name}</h1>
33
+ {community.community.description && <p style={{ whiteSpace: "pre-line", opacity: 0.85 }}>{community.community.description}</p>}
34
+ </div>
35
+ )}
36
+ <div role="status" className="fg:flex fg:items-start fg:gap-3" style={card}>
37
+ <Clock size={22} style={{ color: t.primary, flexShrink: 0, marginTop: 2 }} />
38
+ <div>
39
+ <p style={{ fontWeight: 700 }}>{tr("forge.community_pending.title")}</p>
40
+ <p style={muted}>
41
+ {community.pendingSince
42
+ ? tr("forge.community_pending.body_since", { date: shortDate(community.pendingSince, locale) })
43
+ : tr("forge.community_pending.body")}
44
+ </p>
45
+ </div>
46
+ </div>
47
+ </div>
48
+ );
49
+ }