@tribe-nest/forge 3.59.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
package/src/ui/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Forge UI — Tier-2 styled blocks (theme-aware, opinionated) + the theme
1
+ // Forge UI: Tier-2 styled blocks (theme-aware, opinionated) + the theme
2
2
  // contract. Built on the Tier-1 headless primitives in `forge/ui/headless`.
3
3
 
4
4
  // Compiled Tailwind utilities for every className in Forge's source, generated
@@ -50,7 +50,7 @@ export * from "./headless";
50
50
  // Currency formatting primitive (presentational helper).
51
51
  export { useFormatCurrency, useAmountFormatter, formatCurrency, getCurrencySymbol } from "./format/useFormatCurrency";
52
52
 
53
- // Content layer (Component 7) — editable field wrappers.
53
+ // Content layer (Component 7): editable field wrappers.
54
54
  export {
55
55
  EditableText,
56
56
  EditableRichText,
@@ -96,7 +96,7 @@ export { Cart, type CartProps } from "./styled/Cart";
96
96
  export { ResumeCart, type ResumeCartProps } from "./styled/ResumeCart";
97
97
  // The resolver is exported alongside the component because the Craft themes
98
98
  // are a separate rendering stack (Tailwind, not inline styles) and cannot use
99
- // the component — but the legacy `color`/`size` fallback must not diverge.
99
+ // the component: but the legacy `color`/`size` fallback must not diverge.
100
100
  export { CartLineOptions, resolveCartLineOptions } from "./styled/CartLineOptions";
101
101
  export { CurrencySwitcher, type CurrencySwitcherProps } from "./styled/CurrencySwitcher";
102
102
  export { UserMenu, type UserMenuProps } from "./styled/UserMenu";
@@ -128,14 +128,14 @@ export { tierColorMap, TIER_PALETTE } from "./styled/seating/paint";
128
128
  export { EventCountdown, type EventCountdownProps } from "./styled/EventCountdown";
129
129
  export { EventWaitlist, type EventWaitlistProps, formatCountdown } from "./styled/EventWaitlist";
130
130
  export { EventDetail, type EventDetailProps } from "./styled/EventDetail";
131
- // Phase 5 — the page for a NAMED, multi-date series. The API refuses anything
131
+ // Phase 5: the page for a NAMED, multi-date series. The API refuses anything
132
132
  // else, so this component never has to decide whether a series is publishable.
133
133
  export { EventSeriesDetail, type EventSeriesDetailProps } from "./styled/EventSeriesDetail";
134
134
  export { AddToCalendar, type AddToCalendarProps } from "./styled/AddToCalendar";
135
135
  export { WalletPassButtons, type WalletPassButtonsProps } from "./styled/WalletPassButtons";
136
136
  export { CoachingBooking, type CoachingBookingProps } from "./styled/CoachingBooking";
137
137
  export { CoachingDetail, type CoachingDetailProps } from "./styled/CoachingDetail";
138
- // S.6 — the buyer-facing cancellation policy block. Exported so a code site that
138
+ // S.6: the buyer-facing cancellation policy block. Exported so a code site that
139
139
  // lays out its own booking page still has ONE renderer for these terms.
140
140
  export { CancellationTerms, type CancellationTermsProps } from "./styled/CancellationTerms";
141
141
  export { CourseCheckout, type CourseCheckoutProps } from "./styled/CourseCheckout";
@@ -255,7 +255,7 @@ export { PriceDisplay, priceTaxCaption, summarizeTaxQuote, type PriceDisplayProp
255
255
  export { computeBookingFeeAmount, hasBookingFee, describeBookingFee } from "./format/bookingFee";
256
256
  // Pay-what-you-want, for a site rendering its own ticket UI. `price` is the
257
257
  // FLOOR on a PWYW tier, so an existing "from $X" is already correct; these only
258
- // resolve what a buyer's chosen amount costs. The clamp here is UX — the server
258
+ // resolve what a buyer's chosen amount costs. The clamp here is UX: the server
259
259
  // clamps again and is the one that decides what is charged.
260
260
  export {
261
261
  isPayWhatYouWant,
@@ -288,7 +288,7 @@ export {
288
288
  // subtracts `held` the way the server does, so a fan is never shown seats that
289
289
  // are already in someone else's checkout; `ticketSaleWindow` surfaces a
290
290
  // scheduled on-sale, which shipped enforced and drawn nowhere. Presentation
291
- // only — `createOrder` re-checks both.
291
+ // only: `createOrder` re-checks both.
292
292
  export {
293
293
  ticketSeatsAvailable,
294
294
  ticketSeatsUnsold,
@@ -305,7 +305,7 @@ export {
305
305
  type TicketSaleWindow,
306
306
  } from "./format/ticketAvailability";
307
307
  // Per-attendee names at checkout. Exported for a site rendering its own ticket
308
- // UI — and used by `apps/client`, which has its own event checkout: the request
308
+ // UI: and used by `apps/client`, which has its own event checkout: the request
309
309
  // shape is positional per tier and a length mismatch is refused server-side, so
310
310
  // neither stack derives it for itself.
311
311
  export {
@@ -323,7 +323,7 @@ export {
323
323
  type AttendeeProblem,
324
324
  } from "./format/attendees";
325
325
  // Members-only gates (S.4). The wording and the resolving ACTION are decided
326
- // purely, in one place, because BOTH rendering stacks draw this — `apps/client`
326
+ // purely, in one place, because BOTH rendering stacks draw this: `apps/client`
327
327
  // imports these directly, exactly as it does the PWYW arithmetic. A members-only
328
328
  // badge that says one thing on an artist's PWA and another on their website is
329
329
  // the same bug twice.
@@ -345,10 +345,10 @@ export {
345
345
  export { MembershipGateNotice, type MembershipGateNoticeProps } from "./styled/MembershipGateNotice";
346
346
  export { usePricesIncludeTax } from "../data/queries/useWebsite";
347
347
 
348
- // PWA (installable code sites) — SW registration + install/push UX (Tier 2).
348
+ // PWA (installable code sites): SW registration + install/push UX (Tier 2).
349
349
  export { PwaRegistration, type PwaRegistrationProps } from "./styled/PwaRegistration";
350
350
  export { InstallBanner, type InstallBannerProps } from "./styled/InstallBanner";
351
- // The single root component — providers + audio + analytics + PWA + consent in one.
351
+ // The single root component: providers + audio + analytics + PWA + consent in one.
352
352
  export { TribeNestApp, type TribeNestAppProps } from "./shell/TribeNestApp";
353
353
  // The SSR-bootstrap failure banner + its gating. Exported so a bespoke shell that
354
354
  // doesn't use <TribeNestApp> can still fail loudly instead of silently rendering
@@ -366,10 +366,15 @@ export { PushOptIn, type PushOptInProps } from "./styled/PushOptIn";
366
366
  // Community (forum) styled blocks.
367
367
  export * from "./styled/community";
368
368
 
369
+ // Communities: the list, About page with the in-place join sheet, the pending
370
+ // screen, the community home shell, the upgrade sheet, the membership menu,
371
+ // and the account page's list of memberships.
372
+ export * from "./styled/communities";
373
+
369
374
  // Work (project management) client-portal styled blocks.
370
375
  export * from "./styled/work";
371
376
 
372
- // Unified member home (`/i/members`) styled blocks.
377
+ // A community's member home (`/i/communities/<slug>/home`) styled blocks.
373
378
  export * from "./styled/members";
374
379
 
375
380
  // The review page a "how did we do?" email lands on. Here rather than in a
@@ -23,14 +23,12 @@ import {
23
23
  useMyCourses,
24
24
  useMyFilmRentals,
25
25
  type MyCourse,
26
- useCancelMembership,
27
- useMembershipAccess,
28
26
  useMyMembershipApplications,
29
27
  useWithdrawMembershipApplication,
30
28
  useConfirmMembershipApplication,
31
29
  type MembershipApplication,
32
30
  type MembershipApplicationStatus,
33
- useOpenBillingPortal,
31
+ usePublicCommunities,
34
32
  useUpdateAccount,
35
33
  useChangePassword,
36
34
  useExportAccountData,
@@ -53,7 +51,8 @@ import { TicketTransferPanel } from "./TicketTransfer";
53
51
  import { CartLineOptions } from "./CartLineOptions";
54
52
  import { FilmRentalList } from "./FilmLibrary";
55
53
  import { canWithdrawMembershipApplication } from "../headless/membership/membershipApplication";
56
- import { isMembershipInTrial } from "../headless/membership/membershipTrial";
54
+ import { communityAboutPath, communitiesListPath } from "../headless/communities/paths";
55
+ import { MyMembershipsList } from "./communities/MyMembershipsList";
57
56
 
58
57
  export const ACCOUNT_TABS = [
59
58
  "membership",
@@ -63,7 +62,7 @@ export const ACCOUNT_TABS = [
63
62
  // holding for this artist's shows? (2.6)
64
63
  "waitlist",
65
64
  // Sessions bought from this artist (S.6 / bookings § 1.4). A booking is not an
66
- // "order" — the Orders tab covers product orders only — so it needs its own
65
+ // "order" (the Orders tab covers product orders only) so it needs its own
67
66
  // tab or it is unreachable, which is what it was.
68
67
  "bookings",
69
68
  // Films and series this fan has rented. Next to the other purchase surfaces
@@ -109,21 +108,10 @@ export interface AccountDashboardProps {
109
108
  /** Called when the visitor selects a different tab. Host maps this to navigation. */
110
109
  onTabChange?: (tab: AccountTabKey) => void;
111
110
  /**
112
- * Navigate to the membership page (the "Change" / "Join" action). Host wires
113
- * this to its router (e.g. `/i/membership`).
111
+ * SPA navigation adapter for the Memberships tab (each membership opens its
112
+ * community's home). Defaults to a full-page visit.
114
113
  */
115
- onNavigateMembership?: () => void;
116
- /**
117
- * Navigate to the member area, the member home (host maps this to its router,
118
- * e.g. `/i/members`).
119
- *
120
- * Optional, and drawn for every signed-in visitor, membership or none. It used
121
- * to be gated on `/public/community/spaces` returning a space they could open,
122
- * which hid the member area from exactly the people who had not joined yet:
123
- * the member home itself shows the locked posts and the way to buy access, so
124
- * the door is never a dead end.
125
- */
126
- onNavigateCommunity?: () => void;
114
+ navigate?: (href: string) => void;
127
115
  /**
128
116
  * Navigate to a film route from the Rentals tab (watch, resume, rent again).
129
117
  *
@@ -208,8 +196,7 @@ interface TabContext {
208
196
  formatAmount: (amount: number, currencyCode?: string) => string;
209
197
  formatDate: (value: string | number | Date) => string;
210
198
  currency?: string;
211
- onNavigateMembership?: () => void;
212
- onNavigateCommunity?: () => void;
199
+ navigate?: (href: string) => void;
213
200
  onNavigateFilm?: (href: string) => void;
214
201
  /**
215
202
  * Open a course the fan owns. Host-supplied, like every other navigation
@@ -232,8 +219,7 @@ interface TabContext {
232
219
  export function AccountDashboard({
233
220
  tab,
234
221
  onTabChange,
235
- onNavigateMembership,
236
- onNavigateCommunity,
222
+ navigate,
237
223
  onNavigateFilm,
238
224
  onNavigateCourse,
239
225
  formatAmount,
@@ -271,11 +257,10 @@ export function AccountDashboard({
271
257
  const ctx: TabContext = {
272
258
  formatAmount: fmtAmount,
273
259
  formatDate: fmtDate,
274
- // Fallback for records with no explicit currency — the tenant's runtime
260
+ // Fallback for records with no explicit currency: the tenant's runtime
275
261
  // settlement currency (single source), unless the host overrides it.
276
262
  currency: currency ?? siteConfig?.currency,
277
- onNavigateMembership,
278
- onNavigateCommunity,
263
+ navigate,
279
264
  onNavigateFilm,
280
265
  onNavigateCourse,
281
266
  };
@@ -350,222 +335,22 @@ export function AccountDashboard({
350
335
 
351
336
  // ---- Membership tab ---------------------------------------------------------
352
337
 
338
+ /**
339
+ * "My memberships": one card per community the fan belongs to, each linking
340
+ * inside its community, where billing, cancel and downgrade live. A fan can
341
+ * hold a membership in several communities of one creator, so there is no
342
+ * single "your membership" any more. Nothing in a join or upgrade flow sends a
343
+ * fan here; this is for someone who wants everything in one place.
344
+ */
353
345
  function MembershipTab({ ctx }: { ctx: TabContext }) {
354
- const { user } = usePublicAuth();
355
- const { t, card, button, ghostButton } = useCardStyles();
346
+ const { t, card } = useCardStyles();
356
347
  const tr = useForgeT();
357
- const { formatAmount, formatDate, currency, onNavigateMembership, onNavigateCommunity } = ctx;
358
- const cancelMembership = useCancelMembership();
359
- // The way into the member area. Being signed in is the whole gate: a fan with
360
- // no membership needs this door most, because the member home is where the
361
- // locked posts and the offer to join actually live. The old gate asked
362
- // `/public/community/spaces` for an openable space first, so a fan who had not
363
- // joined saw no way in at all.
364
- const canOpenCommunity = !!onNavigateCommunity && !!user;
365
- const [isCancelling, setIsCancelling] = useState(false);
366
- /**
367
- * Cancelling is destructive and irreversible from here, so it asks first.
368
- * CLAUDE.md puts a destructive confirmation in the category that must stay
369
- * VISIBLE rather than behind a `?`, and until now one click on Cancel simply
370
- * ended the membership. The client app has always asked (`EditorModal` in
371
- * `MembershipTab.tsx`); this surface was ported without it.
372
- */
373
- const [confirmingCancel, setConfirmingCancel] = useState(false);
374
- const [cancelError, setCancelError] = useState<string | null>(null);
375
-
376
- const membership = user?.membership;
377
- /**
378
- * Inside a free trial: nothing has been charged yet. The date the first
379
- * charge falls and what it will be are state the member acts on (cancel
380
- * before then, or not), so they are visible, not behind a hint.
381
- */
382
- const inTrial = isMembershipInTrial(membership);
383
- const cycleWord = (cycle: string | undefined) =>
384
- cycle === "year" ? tr("forge.account_dashboard.cycle_year") : tr("forge.account_dashboard.cycle_month");
385
- // Two separate questions, deliberately answered separately:
386
- // `access.hasAccess` → are the benefits live (true through past_due/grace)
387
- // `message` → what the member is told, including a failed payment
388
- // The old `status === "active"` answered both at once and got both wrong for
389
- // anyone whose card bounced.
390
- const { access, message } = useMembershipAccess();
391
- const openBillingPortal = useOpenBillingPortal();
392
- const [billingError, setBillingError] = useState<string | null>(null);
393
-
394
- // A failure here used to be swallowed whole: the button said "Cancelling…",
395
- // then said "Cancel" again, and the member was never told anything. That is
396
- // how a free-tier member — whose cancel the server rejected outright — could
397
- // press this forever with no effect and no explanation.
398
- const onCancel = async () => {
399
- if (!membership?.id) return;
400
- setCancelError(null);
401
- setIsCancelling(true);
402
- try {
403
- await cancelMembership.mutateAsync({ membershipId: membership.id });
404
- window.location.reload();
405
- } catch (error) {
406
- const detail = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
407
- setCancelError(detail || tr("forge.account_dashboard.membership_cancel_error"));
408
- } finally {
409
- setIsCancelling(false);
410
- }
411
- };
412
-
413
- const onUpdatePayment = async () => {
414
- setBillingError(null);
415
- try {
416
- const { url } = await openBillingPortal.mutateAsync({});
417
- window.location.href = url;
418
- } catch (error) {
419
- const detail = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
420
- setBillingError(detail || tr("forge.account_dashboard.billing_portal_error"));
421
- }
422
- };
423
-
424
348
  return (
425
349
  <div style={card}>
426
350
  <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
427
351
  {tr("forge.account_dashboard.membership_title")}
428
352
  </h2>
429
- {membership ? (
430
- <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
431
- <div>
432
- <h3 style={{ fontWeight: 700 }}>{membership.membershipTier.name}</h3>
433
- <p
434
- style={{
435
- fontSize: 13,
436
- opacity: 0.85,
437
- fontWeight: message.tone === "warning" ? 700 : 400,
438
- color: message.tone === "warning" ? t.primary : undefined,
439
- }}
440
- >
441
- {message.label}
442
- </p>
443
- {message.detail && (
444
- <p style={{ fontSize: 13, opacity: 0.8, marginTop: 4, maxWidth: 480 }}>{message.detail}</p>
445
- )}
446
- {!!membership.subscriptionAmount && (
447
- <p style={{ fontSize: 14, marginTop: 4 }}>
448
- {tr("forge.account_dashboard.membership_price", {
449
- amount: formatAmount(membership.subscriptionAmount, membership.subscriptionCurrency || currency),
450
- cycle: membership.billingCycle,
451
- })}
452
- </p>
453
- )}
454
- </div>
455
- {!!membership.membershipTier.benefits?.length && (
456
- <ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 6 }}>
457
- {membership.membershipTier.benefits.map((b) => (
458
- <li key={b.id} style={{ display: "flex", gap: 8 }}>
459
- <span style={{ color: t.primary }}>✓</span>
460
- {b.title}
461
- </li>
462
- ))}
463
- </ul>
464
- )}
465
- {inTrial && membership.trialEndsAt && (
466
- <div data-testid="membership-trial-state">
467
- <p style={{ fontSize: 14, fontWeight: 600 }}>
468
- {tr("forge.account_dashboard.membership_trial_until", { date: formatDate(membership.trialEndsAt) })}
469
- </p>
470
- <p style={{ fontSize: 14, opacity: 0.85, marginTop: 2 }}>
471
- {access.cancelAtPeriodEnd
472
- ? tr("forge.account_dashboard.membership_trial_cancelled")
473
- : membership.subscriptionAmount
474
- ? tr("forge.account_dashboard.membership_trial_then", {
475
- amount: formatAmount(membership.subscriptionAmount, membership.subscriptionCurrency || currency),
476
- cycle: cycleWord(membership.billingCycle),
477
- date: formatDate(membership.trialEndsAt),
478
- })
479
- : null}
480
- </p>
481
- </div>
482
- )}
483
- {membership.endDate && !inTrial && (
484
- <p style={{ fontSize: 14, opacity: 0.8 }}>
485
- {access.billingState === "active"
486
- ? tr("forge.account_dashboard.membership_renews_on", { date: formatDate(membership.endDate) })
487
- : tr("forge.account_dashboard.membership_ends_on", { date: formatDate(membership.endDate) })}
488
- </p>
489
- )}
490
- {billingError && <p style={{ fontSize: 13, opacity: 0.9 }}>{billingError}</p>}
491
- {cancelError && (
492
- <p role="alert" style={{ fontSize: 13, opacity: 0.9, color: t.primary }}>
493
- {cancelError}
494
- </p>
495
- )}
496
- </div>
497
- ) : (
498
- <p style={{ opacity: 0.8 }}>{tr("forge.account_dashboard.membership_empty")}</p>
499
- )}
500
-
501
- <div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 24, flexWrap: "wrap" }}>
502
- {/* Kept hard left, apart from the billing actions: this is the door to
503
- what the membership is FOR, not another thing to do to the plan. */}
504
- {canOpenCommunity && (
505
- <button onClick={() => onNavigateCommunity?.()} style={{ ...ghostButton, marginRight: "auto" }}>
506
- {tr("forge.account_dashboard.member_area")}
507
- </button>
508
- )}
509
- {message.showUpdatePayment && (
510
- <button onClick={onUpdatePayment} disabled={openBillingPortal.isPending} style={button}>
511
- {openBillingPortal.isPending
512
- ? tr("forge.account_dashboard.billing_portal_opening")
513
- : tr("forge.account_dashboard.update_payment_method")}
514
- </button>
515
- )}
516
- {/* Cancelling stays available for anyone who still HAS access — a
517
- past_due member must not be trapped in a subscription they can see
518
- but not leave. Hidden once it is already scheduled ("Ending"), where
519
- the only honest label would be "Cancel again". */}
520
- {access.hasAccess && !access.cancelAtPeriodEnd && (
521
- <button onClick={() => setConfirmingCancel(true)} disabled={isCancelling} style={ghostButton}>
522
- {isCancelling ? tr("forge.account_dashboard.cancelling") : tr("forge.account_dashboard.cancel")}
523
- </button>
524
- )}
525
- <button onClick={() => onNavigateMembership?.()} style={button}>
526
- {membership
527
- ? tr("forge.account_dashboard.membership_change")
528
- : tr("forge.account_dashboard.membership_join")}
529
- </button>
530
- </div>
531
-
532
- {/* Says WHICH of the two cancellations this is. A paid membership runs to
533
- the end of the period already paid for; a free one stops now. A flat
534
- "are you sure" tells a member who paid this month that they have lost
535
- the rest of it, which is not true and reads as a threat. */}
536
- {confirmingCancel && (
537
- <div role="alertdialog" aria-modal="true" style={{ ...card, marginTop: 16 }}>
538
- <p style={{ fontWeight: 700 }}>{tr("forge.account_dashboard.cancel_confirm_title")}</p>
539
- <p style={{ fontSize: 14, opacity: 0.85, marginTop: 8 }}>
540
- {inTrial && membership?.trialEndsAt
541
- ? tr("forge.account_dashboard.cancel_confirm_trial", { date: formatDate(membership.trialEndsAt) })
542
- : membership?.subscriptionAmount
543
- ? membership.endDate
544
- ? tr("forge.account_dashboard.cancel_confirm_paid_until", { date: formatDate(membership.endDate) })
545
- : tr("forge.account_dashboard.cancel_confirm_paid")
546
- : tr("forge.account_dashboard.cancel_confirm_free")}
547
- </p>
548
- <div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 16 }}>
549
- <button onClick={() => setConfirmingCancel(false)} disabled={isCancelling} style={ghostButton}>
550
- {tr("forge.account_dashboard.cancel_confirm_keep")}
551
- </button>
552
- <button
553
- onClick={() => {
554
- // Closed FIRST, so the dialog cannot be double-submitted while
555
- // the request is in flight. The row behind it already renders
556
- // "Cancelling" from `isCancelling`.
557
- setConfirmingCancel(false);
558
- void onCancel();
559
- }}
560
- disabled={isCancelling}
561
- style={button}
562
- >
563
- {tr("forge.account_dashboard.cancel_confirm_confirm")}
564
- </button>
565
- </div>
566
- </div>
567
- )}
568
-
353
+ <MyMembershipsList navigate={ctx.navigate} formatAmount={ctx.formatAmount} />
569
354
  <MembershipApplicationsSection ctx={ctx} />
570
355
  </div>
571
356
  );
@@ -597,6 +382,13 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
597
382
  const { t, ghostButton, button } = useCardStyles();
598
383
  const { formatAmount, formatDate, currency } = ctx;
599
384
  const { data: applications } = useMyMembershipApplications(user?.id);
385
+ const { data: communities } = usePublicCommunities({ enabled: !!applications?.some((a) => a.status === "payment_failed") });
386
+ /** An approved application whose card failed is finished on its community's About page, on that tier. */
387
+ const completeHref = (application: MembershipApplication) => {
388
+ const communityId = (application as MembershipApplication & { communityId?: string }).communityId;
389
+ const slug = communities?.find((community) => community.id === communityId)?.slug;
390
+ return slug ? communityAboutPath(slug, { tier: application.membershipTierId }) : communitiesListPath();
391
+ };
600
392
  const withdraw = useWithdrawMembershipApplication();
601
393
  const confirm = useConfirmMembershipApplication();
602
394
  const [error, setError] = useState<string | null>(null);
@@ -674,7 +466,7 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
674
466
  )}
675
467
  {application.status === "payment_failed" && (
676
468
  <a
677
- href={`/i/membership/checkout?membershipTierId=${encodeURIComponent(application.membershipTierId)}`}
469
+ href={completeHref(application)}
678
470
  style={{ ...button, textDecoration: "none" }}
679
471
  >
680
472
  {tr("forge.account_dashboard.application_complete")}
@@ -698,7 +490,7 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
698
490
  * The buyer's event tickets, and cancelling one inside the policy they were
699
491
  * sold under (S.6).
700
492
  *
701
- * Before this, a ticket buyer had no purchase surface at all — the confirmation
493
+ * Before this, a ticket buyer had no purchase surface at all: the confirmation
702
494
  * email and a one-shot finalise page were the only artefacts of the sale.
703
495
  *
704
496
  * Eligibility is NOT recomputed here. `cancellation.canCancel` and its reason
@@ -709,7 +501,7 @@ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
709
501
  * ## Two kinds of row
710
502
  *
711
503
  * `role` says whether the caller PAID for this order or merely HOLDS a pass out
712
- * of it — a ticket somebody transferred to them, which until now reached them
504
+ * of it: a ticket somebody transferred to them, which until now reached them
713
505
  * nowhere at all. A holder's row is scrubbed server-side (no totals, no line
714
506
  * prices, no other seats, no cancel), and this renders that difference rather
715
507
  * than printing "$0.00" over somebody else's purchase.
@@ -821,7 +613,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
821
613
  })}
822
614
  </span>
823
615
  {/* A holder is not shown a price, because the server did
824
- not send one — `Number(null)` is 0, and a confident
616
+ not send one: `Number(null)` is 0, and a confident
825
617
  "$0.00" on somebody else's purchase is a lie. */}
826
618
  {!gifted && (
827
619
  <span>{formatAmount(Number(item.price) * item.quantity, ticket.currency || currency)}</span>
@@ -866,7 +658,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
866
658
  </div>
867
659
  )}
868
660
 
869
- {/* Diarise it — including the Apple option, which needs the
661
+ {/* Diarise it: including the Apple option, which needs the
870
662
  platform's own `.ics` and so could not exist before. Null
871
663
  for a called-off show, and the component draws nothing. */}
872
664
  {/* Not offered on a request: the fan has not been admitted to
@@ -885,7 +677,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
885
677
 
886
678
  {/*
887
679
  Wallet passes (2.3). Renders NOTHING unless the API offers a
888
- real pass for a real pass id — which, with no signing
680
+ real pass for a real pass id: which, with no signing
889
681
  credentials configured, is every ticket today. Deliberately
890
682
  not wrapped in a condition of its own: the component owns that
891
683
  decision, and a second copy of the rule here is how one of the
@@ -899,20 +691,20 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
899
691
  {/*
900
692
  Transfer (2.2). The email a transfer sends points at
901
693
  `/i/tickets/claim`, so this affordance and that route ship
902
- together — a send with nowhere to land is undeliverable in
694
+ together: a send with nowhere to land is undeliverable in
903
695
  practice.
904
696
 
905
697
  Renders nothing when the payload carries no `TN-…` pass ids
906
698
  (an older API build), because every pass-scoped endpoint would
907
699
  404. Whether THIS pass may be handed on is the server's call,
908
- answered on submit in its own words — not hidden behind a
700
+ answered on submit in its own words: not hidden behind a
909
701
  guess here.
910
702
 
911
703
  HELD passes only: the endpoints resolve the current holder, so
912
704
  a control on a named guest's seat or on a ticket already
913
705
  claimed by somebody else could answer nothing but 404. The
914
706
  pass objects go through too, because each carries the caller's
915
- own `pendingTransfer` — which is what lets a sender withdraw
707
+ own `pendingTransfer`: which is what lets a sender withdraw
916
708
  from a device that is not the one they sent from.
917
709
  */}
918
710
  <TicketTransferPanel
@@ -967,8 +759,8 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
967
759
  *
968
760
  * Two states, and they are genuinely different things:
969
761
  *
970
- * WAITING — a 1-based `position` in the queue.
971
- * NOTIFIED — an OFFER with a deadline, and `position: null`. An offer is not a
762
+ * WAITING: a 1-based `position` in the queue.
763
+ * NOTIFIED: an OFFER with a deadline, and `position: null`. An offer is not a
972
764
  * queue place, so it is never rendered as one; the deadline is the
973
765
  * whole point and gets the emphasis.
974
766
  *
@@ -1131,13 +923,13 @@ function WaitlistPlaceRow({
1131
923
  * Session buyers had no purchase surface at all: `GET /public/coaching/bookings/mine`
1132
924
  * and the reschedule action shipped with nothing that could reach them.
1133
925
  *
1134
- * A booking is deliberately NOT folded into the Orders tab — that tab reads
926
+ * A booking is deliberately NOT folded into the Orders tab: that tab reads
1135
927
  * `/public/orders` and covers product orders only, so a session would never
1136
928
  * appear there however hard one looked.
1137
929
  *
1138
930
  * Nothing is recomputed here. `canReschedule` and `rescheduleReason` arrive on
1139
931
  * every row, and the slot grid the picker offers is the server's own
1140
- * availability read — which already excludes taken slots and hours the coach is
932
+ * availability read: which already excludes taken slots and hours the coach is
1141
933
  * busy on any of their services.
1142
934
  *
1143
935
  * `cancellation` comes from the POLICY SNAPSHOT taken on the booking at
@@ -1194,15 +986,15 @@ function formatSessionWindow(booking: MyBooking, tr: ForgeT): string {
1194
986
  }
1195
987
 
1196
988
  /**
1197
- * WHERE the session is — the line this portal existed without.
989
+ * WHERE the session is: the line this portal existed without.
1198
990
  *
1199
991
  * A client who bought a coaching call could see the time, the price and an
1200
992
  * add-to-calendar button, and nothing at all about how to attend; the answer
1201
993
  * arrived, if it arrived, in a separate email from the coach.
1202
994
  *
1203
995
  * The two kinds are not interchangeable, so they do not render the same. An
1204
- * online session's link is an anchor — at 9:59 for a 10:00 call it is the only
1205
- * thing on this row anyone wants — shown in full rather than as the word "Join",
996
+ * online session's link is an anchor: at 9:59 for a 10:00 call it is the only
997
+ * thing on this row anyone wants: shown in full rather than as the word "Join",
1206
998
  * because the client may be reading this on a phone and about to type it into
1207
999
  * a laptop. An in-person address is plain text: it is the coach's own words,
1208
1000
  * often "Studio B, ring the buzzer" rather than anything a maps provider could
@@ -1210,7 +1002,7 @@ function formatSessionWindow(booking: MyBooking, tr: ForgeT): string {
1210
1002
  *
1211
1003
  * `location` is already resolved server-side (booking override over product
1212
1004
  * default) and is null unless the session is confirmed, so there is no rule to
1213
- * apply here — which is the same discipline `canReschedule` follows two blocks
1005
+ * apply here: which is the same discipline `canReschedule` follows two blocks
1214
1006
  * down.
1215
1007
  */
1216
1008
  function BookingLocationLine({
@@ -1331,7 +1123,7 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
1331
1123
  </div>
1332
1124
  </div>
1333
1125
 
1334
- {/* Diarise it — including Apple, which needs the platform's own `.ics`.
1126
+ {/* Diarise it: including Apple, which needs the platform's own `.ics`.
1335
1127
  Null unless the session is confirmed, and the component draws nothing. */}
1336
1128
  <AddToCalendar
1337
1129
  variant="compact"
@@ -1358,8 +1150,8 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
1358
1150
  )}
1359
1151
 
1360
1152
  {/*
1361
- The action row. Both controls are drawn from the SERVER's flags —
1362
- `canReschedule` and `cancellation.canCancel` — never from eligibility
1153
+ The action row. Both controls are drawn from the SERVER's flags:
1154
+ `canReschedule` and `cancellation.canCancel`: never from eligibility
1363
1155
  re-derived in the browser, which would give two answers to one question.
1364
1156
  */}
1365
1157
  {error && (
@@ -1487,7 +1279,7 @@ function BookingCallEntry({ booking }: { booking: MyBooking }) {
1487
1279
  * Pick another time for a session already bought.
1488
1280
  *
1489
1281
  * The grid is `GET /public/coaching/products/:id/availability` over the next
1490
- * fortnight — the same read the original purchase used, which already drops
1282
+ * fortnight: the same read the original purchase used, which already drops
1491
1283
  * taken slots and hours the coach is busy on any of their services. It is not
1492
1284
  * filtered further here: a slot this list offers and the server then refuses
1493
1285
  * (two buyers racing for one hour, decided by a database EXCLUDE constraint)