@tribe-nest/forge 3.26.0 → 3.31.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 (129) hide show
  1. package/package.json +6 -3
  2. package/src/_tests/publishedResolvability.spec.ts +184 -0
  3. package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
  4. package/src/_tests/workspaceAliases.ts +40 -0
  5. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  6. package/src/client/activeLocale.ts +34 -0
  7. package/src/client/createForgeClient.ts +13 -0
  8. package/src/contexts/CartContext.tsx +76 -0
  9. package/src/contexts/PublicAuthContext.tsx +34 -5
  10. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  11. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  12. package/src/data/queries/useBroadcasts.ts +151 -0
  13. package/src/data/queries/useCheckouts.ts +83 -0
  14. package/src/data/queries/useMyBookings.ts +9 -1
  15. package/src/data/queries/useWebsite.ts +7 -0
  16. package/src/i18n/CONVENTION.md +184 -0
  17. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  18. package/src/i18n/de.json +1019 -0
  19. package/src/i18n/en.json +1019 -0
  20. package/src/i18n/index.ts +125 -0
  21. package/src/index.ts +21 -0
  22. package/src/provider/ForgeAppProvider.tsx +2 -1
  23. package/src/provider/SiteConfigProvider.tsx +11 -0
  24. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  25. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  26. package/src/ui/format/membershipPwyw.ts +164 -0
  27. package/src/ui/format/pwyw.ts +37 -0
  28. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  29. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  30. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  31. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  32. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  33. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  34. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  35. package/src/ui/headless/checkout/useCheckout.ts +9 -2
  36. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  37. package/src/ui/headless/index.ts +32 -0
  38. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  39. package/src/ui/index.ts +38 -0
  40. package/src/ui/media/CallHelpHint.tsx +87 -0
  41. package/src/ui/media/CallStage.tsx +542 -0
  42. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  43. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  44. package/src/ui/media/_tests/callState.spec.ts +452 -0
  45. package/src/ui/media/_tests/fakeNode.ts +178 -0
  46. package/src/ui/media/bookingSession.tsx +194 -0
  47. package/src/ui/media/callState.ts +341 -0
  48. package/src/ui/media/index.ts +135 -0
  49. package/src/ui/shell/TribeNestApp.tsx +36 -27
  50. package/src/ui/styled/AccountDashboard.tsx +289 -96
  51. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  52. package/src/ui/styled/AudioPlayer.tsx +14 -6
  53. package/src/ui/styled/BlogCategory.tsx +4 -2
  54. package/src/ui/styled/BlogComments.tsx +27 -20
  55. package/src/ui/styled/BlogList.tsx +5 -3
  56. package/src/ui/styled/BlogPost.tsx +14 -11
  57. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  58. package/src/ui/styled/BundleConfirmation.tsx +32 -23
  59. package/src/ui/styled/CancellationTerms.tsx +3 -1
  60. package/src/ui/styled/Cart.tsx +32 -14
  61. package/src/ui/styled/ChatRoom.tsx +65 -20
  62. package/src/ui/styled/Checkout.tsx +151 -89
  63. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  64. package/src/ui/styled/CoachingBooking.tsx +71 -44
  65. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  66. package/src/ui/styled/CoachingDetail.tsx +15 -11
  67. package/src/ui/styled/CohortPage.tsx +10 -8
  68. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  69. package/src/ui/styled/ContactForm.tsx +31 -12
  70. package/src/ui/styled/CookieConsent.tsx +19 -15
  71. package/src/ui/styled/CourseAccess.tsx +54 -47
  72. package/src/ui/styled/CourseCheckout.tsx +56 -38
  73. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  74. package/src/ui/styled/CourseDetail.tsx +21 -15
  75. package/src/ui/styled/CoursesGrid.tsx +4 -2
  76. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  77. package/src/ui/styled/DiscountCode.tsx +22 -18
  78. package/src/ui/styled/DonationButton.tsx +52 -17
  79. package/src/ui/styled/DonationPage.tsx +4 -4
  80. package/src/ui/styled/EmailListForm.tsx +22 -9
  81. package/src/ui/styled/EventConfirmation.tsx +37 -14
  82. package/src/ui/styled/EventCountdown.tsx +10 -6
  83. package/src/ui/styled/EventDetail.tsx +8 -6
  84. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  85. package/src/ui/styled/EventTickets.tsx +232 -108
  86. package/src/ui/styled/EventWaitlist.tsx +42 -33
  87. package/src/ui/styled/EventsList.tsx +15 -13
  88. package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
  89. package/src/ui/styled/FormRenderer.tsx +12 -7
  90. package/src/ui/styled/InstallBanner.tsx +21 -18
  91. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  92. package/src/ui/styled/InvoicePayment.tsx +37 -16
  93. package/src/ui/styled/LeadMagnet.tsx +9 -7
  94. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  95. package/src/ui/styled/Loading.tsx +7 -1
  96. package/src/ui/styled/LoginForm.tsx +32 -14
  97. package/src/ui/styled/MembershipCheckout.tsx +341 -49
  98. package/src/ui/styled/MembershipTiers.tsx +34 -18
  99. package/src/ui/styled/OfferButton.tsx +33 -13
  100. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  101. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  102. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  103. package/src/ui/styled/PodcastList.tsx +9 -3
  104. package/src/ui/styled/PodcastShow.tsx +29 -7
  105. package/src/ui/styled/PostsFeed.tsx +10 -8
  106. package/src/ui/styled/PresaleCode.tsx +8 -11
  107. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  108. package/src/ui/styled/ProductDetail.tsx +58 -28
  109. package/src/ui/styled/ReactionBar.tsx +4 -2
  110. package/src/ui/styled/ReplayList.tsx +24 -13
  111. package/src/ui/styled/ResetPasswordForm.tsx +32 -15
  112. package/src/ui/styled/ResumeCart.tsx +289 -0
  113. package/src/ui/styled/ReviewForm.tsx +52 -58
  114. package/src/ui/styled/SignupForm.tsx +34 -16
  115. package/src/ui/styled/TicketTransfer.tsx +55 -54
  116. package/src/ui/styled/UserMenu.tsx +16 -14
  117. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  118. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  119. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  120. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
  121. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  122. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  123. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  124. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  125. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  126. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  127. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  128. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  129. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
@@ -1,11 +1,15 @@
1
- import type { CSSProperties } from "react";
1
+ import { useEffect, useState, type CSSProperties } from "react";
2
2
  import { useMembershipCheckout } from "../headless/membership/useMembershipCheckout";
3
+ import { useForgeT } from "../../i18n";
3
4
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
- import { useAmountFormatter } from "../format/useFormatCurrency";
5
+ import { useAmountFormatter, useFormatCurrency, getCurrencySymbol } from "../format/useFormatCurrency";
5
6
  import { priceTaxCaption } from "../format/PriceDisplay";
6
- import { usePricesIncludeTax } from "../../data/queries/useWebsite";
7
+ import { usePricesIncludeTax, useSiteConfig } from "../../data/queries/useWebsite";
8
+ import { usePublicAuth } from "../../contexts/PublicAuthContext";
7
9
  import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
8
10
  import { readableTextOn } from "../theme/contrast";
11
+ import { cycleFloor, cycleIsFree, offeredCycles, type BillingCycle } from "../format/membershipPwyw";
12
+ import type { MembershipTier } from "../../types/models";
9
13
  import { Loading } from "./Loading";
10
14
  import { PaystackPayButton } from "./PaystackPayButton";
11
15
 
@@ -13,15 +17,19 @@ export interface MembershipCheckoutProps {
13
17
  /** Pre-selected tier (e.g. from a `?membershipTierId=` param). */
14
18
  initialTierId?: string;
15
19
  /**
16
- * Called on a successful subscription (free activation, or after a paid
17
- * payment succeeds) the host navigates however it wants (e.g. to the account
18
- * page). Omit to fall back to Forge's default `window.location` redirect.
20
+ * Called when the subscription is finished in-app: a free activation, a tier
21
+ * change settled by proration, or a paid card payment that succeeded. The
22
+ * host navigates however it wants (e.g. to the account page). Omit to fall
23
+ * back to Forge's default `window.location` redirect.
24
+ *
25
+ * `requiresConfirmation` is true only for a real provider payment, which is
26
+ * the only case the account page has anything to reconcile.
19
27
  */
20
- onComplete?: () => void;
28
+ onComplete?: (result: { requiresConfirmation: boolean }) => void;
21
29
  /** The URL Stripe returns to for a PAID subscription (its `return_url`, and the
22
30
  * free/paid fallback redirect). Default `/i/account?tab=membership&confirmSubscription=true`. */
23
31
  successPath?: string;
24
- /** Where the "View memberships" link points when no tier is selected. Default `/i/membership`. */
32
+ /** Where the "View memberships" link points when the artist sells no tiers. Default `/i/membership`. */
25
33
  membershipsPath?: string;
26
34
  /** Format a numeric amount for display. Defaults to the runtime currency formatter. */
27
35
  formatAmount?: (amount: number) => string;
@@ -30,10 +38,29 @@ export interface MembershipCheckoutProps {
30
38
  }
31
39
 
32
40
  /**
33
- * Themed membership subscribe the full flow (tier summary billing cycle /
34
- * pay-what-you-want payment) on `useMembershipCheckout`. Free tiers activate
35
- * immediately; paid tiers render the registered payment UI (Stripe, manual mode).
36
- * A single drop-in reused by every site so the flow is fixed in one place.
41
+ * Themed membership subscribe: the full flow, ported from the client app's
42
+ * three stages (tier grid, tier detail with billing cycle and
43
+ * pay-what-you-want, payment) onto `useMembershipCheckout`. Free tiers activate
44
+ * immediately; paid tiers render the registered payment UI (Stripe) or the
45
+ * Paystack modal. A single drop-in reused by every site so the flow is fixed in
46
+ * one place.
47
+ *
48
+ * ## The pay-what-you-want box is in the VISITOR's currency
49
+ *
50
+ * Everything on this screen is shown converted into whatever currency the
51
+ * visitor picked. The amount box follows, because a page that prices a tier in
52
+ * dollars and then asks for euros is asking somebody to do arithmetic to buy
53
+ * something, and they will get it wrong.
54
+ *
55
+ * The API still takes the tenant's SETTLEMENT currency, so the typed figure is
56
+ * converted back before it is sent, and the floor is enforced on the settled
57
+ * number rather than the typed one. Both halves are needed: label the box in the
58
+ * visitor's currency and send it unconverted, and somebody types 5000 meaning
59
+ * naira and is charged 5000 dollars.
60
+ *
61
+ * The typed text is held as its own state rather than derived from the settled
62
+ * amount. Round-tripping through the rate on every keystroke would rewrite the
63
+ * digits under the cursor.
37
64
  */
38
65
  export function MembershipCheckout({
39
66
  initialTierId,
@@ -44,12 +71,54 @@ export function MembershipCheckout({
44
71
  className,
45
72
  style,
46
73
  }: MembershipCheckoutProps) {
74
+ const t = useForgeT();
47
75
  const theme = useForgeTheme();
48
76
  const fmt = useAmountFormatter(formatAmount);
49
- // Memberships have no checkout tax quote (subscription rail) — inclusive
77
+ const { data: siteConfig } = useSiteConfig();
78
+ /** Settlement currency: the currency every tier number is already in. */
79
+ const settlementCurrency = siteConfig?.currency;
80
+ const { convertCurrency } = useFormatCurrency();
81
+ const { userSelectedCurrency } = usePublicAuth();
82
+ /** What the visitor is reading prices in. Falls back to settlement when they
83
+ * have not picked one, in which case both conversions below are identities. */
84
+ const displayCurrency = (userSelectedCurrency as string | undefined) ?? settlementCurrency;
85
+ const toDisplay = (value: number) => convertCurrency(value, settlementCurrency, displayCurrency);
86
+ /**
87
+ * The forward rate, derived rather than looked up backwards.
88
+ *
89
+ * `convertCurrency(x, display, settlement)` needs `exchangeRates[NGN][USD]`,
90
+ * and a tenant's table only has to carry the directions it QUOTES, which is
91
+ * settlement to everything else. When the reverse is missing that call returns
92
+ * the amount UNCHANGED, so a fan typing 75,000 meaning naira would be charged
93
+ * 75,000 dollars. Silently, because nothing throws.
94
+ *
95
+ * Dividing by the same rate the display multiplied by cannot go missing: if
96
+ * the screen converted, this un-converts it exactly.
97
+ */
98
+ const displayRate = toDisplay(1) || 1;
99
+ /**
100
+ * ROUNDED, because every membership price column is an `integer`.
101
+ *
102
+ * `profile_payment_prices.amount`, `price_monthly`, `pay_what_you_want_minimum`
103
+ * are all integers: a membership is priced in whole settlement units and there
104
+ * is no such thing as a fractional one. Sending the raw quotient reached
105
+ * `ProfilePaymentPrice.findOne({ amount })` and Postgres answered
106
+ * `invalid input syntax for type integer: "426.76681461249575"` - a 500 on the
107
+ * subscribe button, from a figure the fan never saw.
108
+ *
109
+ * Rounding means most typed figures are not exactly representable, so
110
+ * `settledCharge` below shows the fan what will actually be taken rather than
111
+ * letting them find out on the statement.
112
+ */
113
+ const toSettlement = (value: number) => {
114
+ const settled = Math.round(value / displayRate);
115
+ return Number.isFinite(settled) ? settled : 0;
116
+ };
117
+ // Memberships have no checkout tax quote (subscription rail). Inclusive
50
118
  // stores still caption the displayed price as tax-inclusive (display only).
51
119
  const taxCaption = priceTaxCaption({ pricesIncludeTax: usePricesIncludeTax() });
52
120
  const renderPayment = usePaymentRenderer();
121
+
53
122
  const checkout = useMembershipCheckout({
54
123
  initialTierId,
55
124
  returnPath: successPath,
@@ -57,7 +126,24 @@ export function MembershipCheckout({
57
126
  onComplete,
58
127
  });
59
128
 
129
+ /**
130
+ * The digits in the box, in the visitor's currency.
131
+ *
132
+ * Held separately from `checkout.customAmount` (which stays in settlement
133
+ * currency, because that is what the API takes). Deriving it would round-trip
134
+ * through the exchange rate on every keystroke and rewrite what somebody is
135
+ * halfway through typing.
136
+ */
137
+ const [typedAmount, setTypedAmount] = useState<string>("");
138
+ // Re-seeded when the floor moves, which is what happens on a tier or cycle
139
+ // change. Left alone otherwise, so it never fights the person typing.
140
+ useEffect(() => {
141
+ setTypedAmount(checkout.minimumAmount ? String(Number(toDisplay(checkout.minimumAmount).toFixed(2))) : "");
142
+ // eslint-disable-next-line react-hooks/exhaustive-deps
143
+ }, [checkout.minimumAmount, displayCurrency]);
144
+
60
145
  const card: CSSProperties = { maxWidth: 560, margin: "0 auto", color: theme.colors.text, ...style };
146
+ const wide: CSSProperties = { maxWidth: 960, margin: "0 auto", color: theme.colors.text, ...style };
61
147
  const box: CSSProperties = {
62
148
  border: `1px solid ${theme.colors.primary}30`,
63
149
  borderRadius: theme.cornerRadius,
@@ -74,62 +160,173 @@ export function MembershipCheckout({
74
160
  fontWeight: 600,
75
161
  cursor: "pointer",
76
162
  };
163
+ const ghostButton: CSSProperties = {
164
+ ...button,
165
+ background: "transparent",
166
+ color: theme.colors.text,
167
+ border: `1px solid ${theme.colors.primary}40`,
168
+ };
169
+ const errorText: CSSProperties = { color: "#ef4444", fontSize: 14, marginTop: 8 };
77
170
 
78
171
  if (checkout.isLoading) return <Loading fullPage />;
79
172
 
80
173
  const tier = checkout.selectedTier;
174
+
175
+ // ---- Stage 1: pick a tier -------------------------------------------------
81
176
  if (!tier) {
177
+ const tiers = checkout.tiers ?? [];
178
+ if (!tiers.length) {
179
+ return (
180
+ <div className={className} style={card}>
181
+ <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 8 }}>
182
+ {t("forge.membership_checkout.empty_title")}
183
+ </h1>
184
+ <p>{t("forge.membership_checkout.no_tier_body")}</p>
185
+ <a
186
+ href={membershipsPath}
187
+ style={{ ...button, marginTop: 16, display: "inline-block", textAlign: "center", textDecoration: "none" }}
188
+ >
189
+ {t("forge.membership_checkout.view_memberships")}
190
+ </a>
191
+ </div>
192
+ );
193
+ }
194
+
82
195
  return (
83
- <div className={className} style={card}>
84
- <p>Select a membership tier to continue.</p>
85
- <a href={membershipsPath} style={{ ...button, marginTop: 16, display: "inline-block", textAlign: "center", textDecoration: "none" }}>
86
- View memberships
87
- </a>
196
+ <div className={className} style={wide}>
197
+ <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 24, textAlign: "center" }}>
198
+ {t("forge.membership_checkout.choose_title")}
199
+ </h1>
200
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 20, justifyContent: "center" }}>
201
+ {tiers.map((option) => {
202
+ const isCurrent = option.id === checkout.currentTierId;
203
+ return (
204
+ <div
205
+ key={option.id}
206
+ style={{
207
+ ...box,
208
+ flex: "1 1 260px",
209
+ maxWidth: 340,
210
+ width: "100%",
211
+ display: "flex",
212
+ flexDirection: "column",
213
+ gap: 12,
214
+ opacity: isCurrent ? 0.6 : 1,
215
+ borderColor: isCurrent ? `${theme.colors.primary}99` : `${theme.colors.primary}30`,
216
+ }}
217
+ >
218
+ {isCurrent && (
219
+ <span
220
+ style={{
221
+ alignSelf: "center",
222
+ padding: "2px 10px",
223
+ borderRadius: 999,
224
+ fontSize: 12,
225
+ fontWeight: 700,
226
+ background: theme.colors.primary,
227
+ color: readableTextOn(theme.colors.primary),
228
+ }}
229
+ >
230
+ {t("forge.membership_checkout.current_plan")}
231
+ </span>
232
+ )}
233
+ <h2 style={{ fontSize: 20, fontWeight: 700, textAlign: "center" }}>{option.name}</h2>
234
+ <p style={{ fontSize: 18, fontWeight: 700, textAlign: "center", color: theme.colors.primary }}>
235
+ {tierPriceLabel(option, t, fmt)}
236
+ </p>
237
+ {!!option.benefits?.length && (
238
+ <ul
239
+ style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 6 }}
240
+ >
241
+ {option.benefits.slice(0, 3).map((b) => (
242
+ <li key={b.id} style={{ display: "flex", gap: 8, fontSize: 14 }}>
243
+ <span style={{ color: theme.colors.primary }}>✓</span>
244
+ {b.title}
245
+ </li>
246
+ ))}
247
+ {option.benefits.length > 3 && (
248
+ <li style={{ fontSize: 12, opacity: 0.75 }}>
249
+ {t("forge.membership_checkout.more_benefits", { count: option.benefits.length - 3 })}
250
+ </li>
251
+ )}
252
+ </ul>
253
+ )}
254
+ <div style={{ marginTop: "auto" }}>
255
+ {isCurrent ? (
256
+ <p style={{ fontSize: 13, opacity: 0.75, textAlign: "center" }}>
257
+ {t("forge.membership_checkout.on_this_plan")}
258
+ </p>
259
+ ) : (
260
+ <button onClick={() => checkout.selectTier(option)} style={button}>
261
+ {t("forge.membership_checkout.select_tier")}
262
+ </button>
263
+ )}
264
+ </div>
265
+ </div>
266
+ );
267
+ })}
268
+ </div>
88
269
  </div>
89
270
  );
90
271
  }
91
272
 
92
- // Payment step: the registered payment UI (Stripe) in manual mode, or the
93
- // Paystack modal behind a pay button. The Paystack branch is also what a
94
- // member who closed the popup comes back to, so it must render whether or not
95
- // a Stripe secret was ever issued.
273
+ // ---- Stage 3: payment -----------------------------------------------------
274
+ // The registered payment UI (Stripe) in manual mode, or the Paystack modal
275
+ // behind a pay button. The Paystack branch is also what a member who closed
276
+ // the popup comes back to, so it must render whether or not a Stripe secret
277
+ // was ever issued.
96
278
  if (checkout.step === "payment" && (checkout.clientSecret || checkout.canOpenPaystack)) {
97
279
  return (
98
280
  <div className={className} style={card}>
99
- <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 8 }}>Payment</h1>
281
+ <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 8 }}>
282
+ {t("forge.membership_checkout.payment_title")}
283
+ </h1>
100
284
  <p style={{ opacity: 0.75, marginBottom: 24 }}>
101
- {tier.name} · {fmt(checkout.amount)} / {checkout.billingCycle}
285
+ {t("forge.membership_checkout.payment_summary", {
286
+ tier_name: tier.name,
287
+ amount: fmt(checkout.amount),
288
+ cycle: checkout.billingCycle,
289
+ })}
102
290
  </p>
103
291
  <div style={box}>
104
292
  {checkout.isPaystack ? (
105
- <PaystackPayButton onPay={checkout.openPaystackCheckout} label={`Pay ${fmt(checkout.amount)}`} />
293
+ <PaystackPayButton
294
+ onPay={checkout.openPaystackCheckout}
295
+ label={t("forge.membership_checkout.pay", { amount: fmt(checkout.amount) })}
296
+ />
106
297
  ) : (
107
298
  checkout.clientSecret &&
108
299
  renderPayment({
109
300
  clientSecret: checkout.clientSecret,
110
301
  returnUrl: `${typeof window !== "undefined" ? window.location.origin : ""}${successPath}`,
111
302
  amount: checkout.amount,
303
+ currency: settlementCurrency,
112
304
  mode: "manual",
113
- onSucceeded: () => onComplete?.(),
305
+ // A card that has just been charged still needs the account page
306
+ // to reconcile the subscription, so this is the one completion
307
+ // that asks for confirmation.
308
+ onSucceeded: () => checkout.finish(true),
114
309
  onFailed: () => {},
115
310
  })
116
311
  )}
117
312
  </div>
118
- <button
119
- onClick={() => checkout.setStep("select")}
120
- style={{ ...button, background: "transparent", color: theme.colors.text, border: `1px solid ${theme.colors.primary}40` }}
121
- >
122
- Back
313
+ {checkout.error && <p style={errorText}>{checkout.error}</p>}
314
+ <button onClick={() => checkout.setStep("select")} style={ghostButton}>
315
+ {t("forge.membership_checkout.back")}
123
316
  </button>
124
317
  </div>
125
318
  );
126
319
  }
127
320
 
128
- const showBillingToggle = !tier.payWhatYouWant && !!tier.priceMonthly && !!tier.priceYearly;
321
+ // ---- Stage 2: tier detail, billing cycle, pay-what-you-want ---------------
322
+ const cycles = checkout.cycles;
323
+ const showBillingToggle = cycles.month && cycles.year;
129
324
 
130
325
  return (
131
326
  <div className={className} style={card}>
132
- <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 24 }}>Join {tier.name}</h1>
327
+ <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 24 }}>
328
+ {t("forge.membership_checkout.title", { tier_name: tier.name })}
329
+ </h1>
133
330
 
134
331
  <div style={box}>
135
332
  {tier.description && <p style={{ marginBottom: 16 }}>{tier.description}</p>}
@@ -148,8 +345,8 @@ export function MembershipCheckout({
148
345
  <div style={box}>
149
346
  {showBillingToggle && (
150
347
  <div style={{ marginBottom: 16 }}>
151
- <p style={{ fontSize: 14, marginBottom: 8 }}>Billing cycle</p>
152
- <div style={{ display: "flex", gap: 16 }}>
348
+ <p style={{ fontSize: 14, marginBottom: 8 }}>{t("forge.membership_checkout.billing_cycle_label")}</p>
349
+ <div style={{ display: "flex", gap: 16, flexWrap: "wrap" }}>
153
350
  {(["month", "year"] as const).map((cycle) => (
154
351
  <label key={cycle} style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}>
155
352
  <input
@@ -159,7 +356,7 @@ export function MembershipCheckout({
159
356
  onChange={() => checkout.setBillingCycle(cycle)}
160
357
  style={{ accentColor: theme.colors.primary }}
161
358
  />
162
- {cycle === "month" ? "Monthly" : "Yearly"}
359
+ {cycleLabel(tier, cycle, t, fmt)}
163
360
  </label>
164
361
  ))}
165
362
  </div>
@@ -168,39 +365,134 @@ export function MembershipCheckout({
168
365
 
169
366
  {tier.payWhatYouWant && (
170
367
  <div style={{ marginBottom: 16 }}>
171
- <label style={{ fontSize: 14, display: "block", marginBottom: 8 }}>How much would you like to pay?</label>
368
+ {/* The floor is printed in the SETTLEMENT currency because that is
369
+ the currency of the number this box sends. A unit that changes
370
+ what you type belongs in the label. */}
371
+ <label htmlFor="forge-pwyw-amount" style={{ fontSize: 14, display: "block", marginBottom: 8 }}>
372
+ {t("forge.membership_checkout.pwyw_label")}{" "}
373
+ {t("forge.membership_checkout.pwyw_min", { amount: fmt(checkout.minimumAmount) })}
374
+ </label>
172
375
  <input
376
+ id="forge-pwyw-amount"
173
377
  type="number"
174
- min={tier.payWhatYouWantMinimum ?? 0}
175
- value={checkout.customAmount || ""}
176
- onChange={(e) => checkout.setCustomAmount(Number(e.target.value))}
378
+ min={Number(toDisplay(checkout.minimumAmount).toFixed(2))}
379
+ step="any"
380
+ value={typedAmount}
381
+ onChange={(e) => {
382
+ setTypedAmount(e.target.value);
383
+ // Converted back on the way out. The floor is checked against
384
+ // THIS number, not the typed one, so a rate that rounds down
385
+ // cannot slip a fan under the artist's minimum.
386
+ checkout.setCustomAmount(toSettlement(Number(e.target.value)));
387
+ }}
388
+ aria-invalid={!!checkout.amountRefusal}
177
389
  style={{
178
390
  width: "100%",
179
391
  height: 40,
180
392
  padding: "8px 12px",
181
393
  borderRadius: theme.cornerRadius,
182
- border: `1px solid ${theme.colors.primary}40`,
394
+ border: `1px solid ${checkout.amountRefusal ? "#ef4444" : `${theme.colors.primary}40`}`,
183
395
  background: "transparent",
184
396
  color: theme.colors.text,
185
397
  outline: "none",
186
398
  }}
187
399
  />
400
+ {/* What will ACTUALLY be taken, when rounding to a whole settlement
401
+ unit means it is not quite what was typed. Shown only when the
402
+ two differ, because a line that always appears is a line nobody
403
+ reads. This is state the fan has to act on, not an explanation,
404
+ so it stays visible rather than going behind a `?`. */}
405
+ {!checkout.amountRefusal && Number(typedAmount) > 0 && (() => {
406
+ const settled = toSettlement(Number(typedAmount));
407
+ const backInDisplay = Number(toDisplay(settled).toFixed(2));
408
+ return Math.abs(backInDisplay - Number(typedAmount)) >= 0.01 ? (
409
+ <p style={{ fontSize: 13, opacity: 0.75, marginTop: 6 }}>
410
+ {t("forge.membership_checkout.pwyw_charged_as", { amount: fmt(settled) })}
411
+ </p>
412
+ ) : null;
413
+ })()}
414
+ {checkout.amountRefusal && (
415
+ <p role="alert" style={errorText}>
416
+ {checkout.amountRefusal.reason === "below_minimum"
417
+ ? t("forge.membership_checkout.amount_below_minimum", {
418
+ minimum: fmt(checkout.amountRefusal.minimum),
419
+ })
420
+ : t("forge.membership_checkout.amount_not_positive")}
421
+ </p>
422
+ )}
188
423
  </div>
189
424
  )}
190
425
 
191
426
  <div style={{ textAlign: "center", paddingTop: 16, borderTop: `1px solid ${theme.colors.primary}20` }}>
192
- <p style={{ fontSize: 14, opacity: 0.75 }}>Total</p>
193
- <p style={{ fontSize: 28, fontWeight: 800 }}>{fmt(checkout.amount)}</p>
194
- <p style={{ fontSize: 14, opacity: 0.75 }}>{checkout.billingCycle === "month" ? "per month" : "per year"}</p>
195
- {taxCaption && <p style={{ fontSize: 12, opacity: 0.65, marginTop: 2 }}>{taxCaption}</p>}
427
+ <p style={{ fontSize: 14, opacity: 0.75 }}>{t("forge.membership_checkout.total")}</p>
428
+ <p style={{ fontSize: 28, fontWeight: 800 }}>
429
+ {checkout.isFreeCycle ? t("forge.membership_checkout.free") : fmt(checkout.amount)}
430
+ </p>
431
+ <p style={{ fontSize: 14, opacity: 0.75 }}>
432
+ {checkout.billingCycle === "month"
433
+ ? t("forge.membership_checkout.per_month")
434
+ : t("forge.membership_checkout.per_year")}
435
+ </p>
436
+ {taxCaption && !checkout.isFreeCycle && (
437
+ <p style={{ fontSize: 12, opacity: 0.65, marginTop: 2 }}>{taxCaption}</p>
438
+ )}
196
439
  </div>
197
440
  </div>
198
441
 
199
- {checkout.error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{checkout.error}</p>}
442
+ {checkout.error && <p style={{ ...errorText, marginBottom: 16 }}>{checkout.error}</p>}
200
443
 
201
- <button onClick={() => checkout.subscribe()} disabled={checkout.isProcessing} style={button}>
202
- {checkout.isProcessing ? "Processing…" : "Subscribe"}
203
- </button>
444
+ <div style={{ display: "flex", gap: 12 }}>
445
+ {!initialTierId && (
446
+ <button onClick={checkout.backToTierList} disabled={checkout.isProcessing} style={ghostButton}>
447
+ {t("forge.membership_checkout.back")}
448
+ </button>
449
+ )}
450
+ <button onClick={() => checkout.subscribe()} disabled={checkout.isProcessing} style={button}>
451
+ {checkout.isProcessing
452
+ ? t("forge.membership_checkout.submitting")
453
+ : checkout.isChange
454
+ ? t("forge.membership_checkout.confirm_change")
455
+ : t("forge.membership_checkout.submit")}
456
+ </button>
457
+ </div>
204
458
  </div>
205
459
  );
206
460
  }
461
+
462
+ /** The price line on a tier card in the grid. Read-only, so it converts. */
463
+ function tierPriceLabel(
464
+ tier: MembershipTier,
465
+ t: (key: string, vars?: Record<string, string | number>) => string,
466
+ fmt: (amount: number) => string,
467
+ ): string {
468
+ const cycles = offeredCycles(tier);
469
+ if (tier.payWhatYouWant) {
470
+ return cycles.month
471
+ ? t("forge.membership_tiers.price_pwyw", { amount: fmt(cycleFloor(tier, "month")) })
472
+ : t("forge.membership_tiers.price_pwyw_yearly", { amount: fmt(cycleFloor(tier, "year")) });
473
+ }
474
+ if (cycles.month) return t("forge.membership_tiers.price_monthly", { amount: fmt(cycleFloor(tier, "month")) });
475
+ if (cycles.year) return t("forge.membership_tiers.price_yearly", { amount: fmt(cycleFloor(tier, "year")) });
476
+ return t("forge.membership_checkout.free");
477
+ }
478
+
479
+ /** A billing-cycle radio's label. Read-only, so it converts. */
480
+ function cycleLabel(
481
+ tier: MembershipTier,
482
+ cycle: BillingCycle,
483
+ t: (key: string, vars?: Record<string, string | number>) => string,
484
+ fmt: (amount: number) => string,
485
+ ): string {
486
+ const amount = fmt(cycleFloor(tier, cycle));
487
+ if (cycleIsFree(tier, cycle)) {
488
+ return cycle === "month" ? t("forge.membership_checkout.billing_monthly") : t("forge.membership_checkout.billing_yearly");
489
+ }
490
+ if (tier.payWhatYouWant) {
491
+ return cycle === "month"
492
+ ? t("forge.membership_checkout.billing_monthly_pwyw", { amount })
493
+ : t("forge.membership_checkout.billing_yearly_pwyw", { amount });
494
+ }
495
+ return cycle === "month"
496
+ ? t("forge.membership_checkout.billing_monthly_price", { amount })
497
+ : t("forge.membership_checkout.billing_yearly_price", { amount });
498
+ }
@@ -4,6 +4,8 @@ import { useThemeTokens } from "../theme/ForgeThemeProvider";
4
4
  import { useAmountFormatter } from "../format/useFormatCurrency";
5
5
  import { priceTaxCaption } from "../format/PriceDisplay";
6
6
  import { usePricesIncludeTax } from "../../data/queries/useWebsite";
7
+ import { useForgeT } from "../../i18n";
8
+ import { cycleFloor, offeredCycles } from "../format/membershipPwyw";
7
9
  import { Button } from "./Button";
8
10
  import { Loading } from "./Loading";
9
11
 
@@ -33,27 +35,37 @@ export function MembershipTiers({
33
35
  formatAmount,
34
36
  checkoutPath = "/i/membership/checkout",
35
37
  onSelect,
36
- heading = "Membership",
38
+ heading,
37
39
  className,
38
40
  style,
39
41
  }: MembershipTiersProps) {
40
- const t = useThemeTokens();
42
+ const tokens = useThemeTokens();
43
+ const t = useForgeT();
41
44
  const fmt = useAmountFormatter(formatAmount);
42
45
  // Display-only: inclusive stores caption tier prices as tax-inclusive.
43
46
  const pricesIncludeTax = usePricesIncludeTax();
44
47
  const taxCaption = priceTaxCaption({ pricesIncludeTax });
45
48
  const { data: tiers, isLoading } = useGetMembershipTiers();
49
+ // `null` still means "no heading"; anything else falls back to the default.
50
+ const resolvedHeading = heading === null ? null : (heading ?? t("forge.membership_tiers.heading"));
51
+ const freeLabel = t("forge.membership_tiers.price_free");
46
52
 
47
53
  if (isLoading) return <Loading fullPage />;
48
- if (!tiers?.length) return <p style={{ color: t.text }}>No membership tiers available.</p>;
54
+ if (!tiers?.length) return <p style={{ color: tokens.text }}>{t("forge.membership_tiers.empty_title")}</p>;
49
55
 
56
+ // A tier priced on ONE cycle must be advertised on that cycle. Reading the
57
+ // monthly minimum unconditionally printed "Pay what you want from $0/mo" on a
58
+ // yearly-only tier, which reads as free and is the opposite of the truth.
50
59
  const priceLabel = (tier: MembershipTier) => {
60
+ const cycles = offeredCycles(tier);
51
61
  if (tier.payWhatYouWant) {
52
- return `Pay what you want from ${fmt(tier.payWhatYouWantMinimum ?? 0)}/mo`;
62
+ return cycles.month
63
+ ? t("forge.membership_tiers.price_pwyw", { amount: fmt(cycleFloor(tier, "month")) })
64
+ : t("forge.membership_tiers.price_pwyw_yearly", { amount: fmt(cycleFloor(tier, "year")) });
53
65
  }
54
- if (tier.priceMonthly) return `${fmt(tier.priceMonthly)}/mo`;
55
- if (tier.priceYearly) return `${fmt(tier.priceYearly)}/yr`;
56
- return "Free";
66
+ if (cycles.month) return t("forge.membership_tiers.price_monthly", { amount: fmt(cycleFloor(tier, "month")) });
67
+ if (cycles.year) return t("forge.membership_tiers.price_yearly", { amount: fmt(cycleFloor(tier, "year")) });
68
+ return freeLabel;
57
69
  };
58
70
 
59
71
  const select = (tier: MembershipTier) => {
@@ -65,8 +77,12 @@ export function MembershipTiers({
65
77
  };
66
78
 
67
79
  return (
68
- <div className={className} style={{ color: t.text, fontFamily: t.fontFamily, ...style }}>
69
- {heading && <h1 style={{ fontSize: 32, fontWeight: 800, marginBottom: 24, fontFamily: t.headingFontFamily }}>{heading}</h1>}
80
+ <div className={className} style={{ color: tokens.text, fontFamily: tokens.fontFamily, ...style }}>
81
+ {resolvedHeading && (
82
+ <h1 style={{ fontSize: 32, fontWeight: 800, marginBottom: 24, fontFamily: tokens.headingFontFamily }}>
83
+ {resolvedHeading}
84
+ </h1>
85
+ )}
70
86
  <div style={{ display: "flex", flexWrap: "wrap", gap: 20, justifyContent: "center" }}>
71
87
  {tiers.map((tier) => (
72
88
  <div
@@ -75,20 +91,20 @@ export function MembershipTiers({
75
91
  flex: "1 1 260px",
76
92
  maxWidth: 340,
77
93
  width: "100%",
78
- background: t.surface,
79
- border: `1px solid ${t.primary}30`,
80
- borderRadius: t.cornerRadius,
94
+ background: tokens.surface,
95
+ border: `1px solid ${tokens.primary}30`,
96
+ borderRadius: tokens.cornerRadius,
81
97
  padding: 24,
82
98
  display: "flex",
83
99
  flexDirection: "column",
84
100
  gap: 12,
85
101
  }}
86
102
  >
87
- <h2 style={{ fontSize: 22, fontWeight: 700, fontFamily: t.headingFontFamily }}>{tier.name}</h2>
88
- <div style={{ fontSize: 24, fontWeight: 800, color: t.primary }}>
103
+ <h2 style={{ fontSize: 22, fontWeight: 700, fontFamily: tokens.headingFontFamily }}>{tier.name}</h2>
104
+ <div style={{ fontSize: 24, fontWeight: 800, color: tokens.primary }}>
89
105
  {priceLabel(tier)}
90
- {taxCaption && priceLabel(tier) !== "Free" && (
91
- <span style={{ display: "block", fontSize: 12, fontWeight: 400, color: t.text, opacity: 0.65 }}>
106
+ {taxCaption && priceLabel(tier) !== freeLabel && (
107
+ <span style={{ display: "block", fontSize: 12, fontWeight: 400, color: tokens.text, opacity: 0.65 }}>
92
108
  {taxCaption}
93
109
  </span>
94
110
  )}
@@ -98,14 +114,14 @@ export function MembershipTiers({
98
114
  <ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 6 }}>
99
115
  {tier.benefits.map((b) => (
100
116
  <li key={b.id} style={{ display: "flex", gap: 8, opacity: 0.85 }}>
101
- <span style={{ color: t.primary }}>✓</span>
117
+ <span style={{ color: tokens.primary }}>✓</span>
102
118
  {b.title}
103
119
  </li>
104
120
  ))}
105
121
  </ul>
106
122
  )}
107
123
  <Button fullWidth style={{ marginTop: "auto" }} onClick={() => select(tier)}>
108
- Select
124
+ {t("forge.membership_tiers.select")}
109
125
  </Button>
110
126
  </div>
111
127
  ))}