@tribe-nest/forge 3.29.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 (51) 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/contexts/PublicAuthContext.tsx +34 -5
  6. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  7. package/src/data/queries/useBroadcasts.ts +151 -0
  8. package/src/data/queries/useMyBookings.ts +9 -1
  9. package/src/i18n/de.json +59 -0
  10. package/src/i18n/en.json +59 -0
  11. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  12. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  13. package/src/ui/format/membershipPwyw.ts +164 -0
  14. package/src/ui/format/pwyw.ts +37 -0
  15. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  16. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  17. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  18. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  19. package/src/ui/headless/index.ts +14 -0
  20. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  21. package/src/ui/index.ts +36 -0
  22. package/src/ui/media/CallHelpHint.tsx +87 -0
  23. package/src/ui/media/CallStage.tsx +542 -0
  24. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  25. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  26. package/src/ui/media/_tests/callState.spec.ts +452 -0
  27. package/src/ui/media/_tests/fakeNode.ts +178 -0
  28. package/src/ui/media/bookingSession.tsx +194 -0
  29. package/src/ui/media/callState.ts +341 -0
  30. package/src/ui/media/index.ts +135 -0
  31. package/src/ui/styled/AccountDashboard.tsx +92 -3
  32. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  33. package/src/ui/styled/ForgotPasswordForm.tsx +5 -0
  34. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  35. package/src/ui/styled/LoginForm.tsx +10 -0
  36. package/src/ui/styled/MembershipCheckout.tsx +318 -45
  37. package/src/ui/styled/MembershipTiers.tsx +10 -3
  38. package/src/ui/styled/ResetPasswordForm.tsx +5 -0
  39. package/src/ui/styled/SignupForm.tsx +5 -0
  40. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  41. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  42. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  43. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  44. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  45. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  46. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  47. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  48. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  49. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  50. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  51. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
@@ -1,12 +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
3
  import { useForgeT } from "../../i18n";
4
4
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
5
- import { useAmountFormatter } from "../format/useFormatCurrency";
5
+ import { useAmountFormatter, useFormatCurrency, getCurrencySymbol } from "../format/useFormatCurrency";
6
6
  import { priceTaxCaption } from "../format/PriceDisplay";
7
- import { usePricesIncludeTax } from "../../data/queries/useWebsite";
7
+ import { usePricesIncludeTax, useSiteConfig } from "../../data/queries/useWebsite";
8
+ import { usePublicAuth } from "../../contexts/PublicAuthContext";
8
9
  import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
9
10
  import { readableTextOn } from "../theme/contrast";
11
+ import { cycleFloor, cycleIsFree, offeredCycles, type BillingCycle } from "../format/membershipPwyw";
12
+ import type { MembershipTier } from "../../types/models";
10
13
  import { Loading } from "./Loading";
11
14
  import { PaystackPayButton } from "./PaystackPayButton";
12
15
 
@@ -14,15 +17,19 @@ export interface MembershipCheckoutProps {
14
17
  /** Pre-selected tier (e.g. from a `?membershipTierId=` param). */
15
18
  initialTierId?: string;
16
19
  /**
17
- * Called on a successful subscription (free activation, or after a paid
18
- * payment succeeds) the host navigates however it wants (e.g. to the account
19
- * 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.
20
27
  */
21
- onComplete?: () => void;
28
+ onComplete?: (result: { requiresConfirmation: boolean }) => void;
22
29
  /** The URL Stripe returns to for a PAID subscription (its `return_url`, and the
23
30
  * free/paid fallback redirect). Default `/i/account?tab=membership&confirmSubscription=true`. */
24
31
  successPath?: string;
25
- /** 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`. */
26
33
  membershipsPath?: string;
27
34
  /** Format a numeric amount for display. Defaults to the runtime currency formatter. */
28
35
  formatAmount?: (amount: number) => string;
@@ -31,10 +38,29 @@ export interface MembershipCheckoutProps {
31
38
  }
32
39
 
33
40
  /**
34
- * Themed membership subscribe the full flow (tier summary billing cycle /
35
- * pay-what-you-want payment) on `useMembershipCheckout`. Free tiers activate
36
- * immediately; paid tiers render the registered payment UI (Stripe, manual mode).
37
- * 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.
38
64
  */
39
65
  export function MembershipCheckout({
40
66
  initialTierId,
@@ -48,10 +74,51 @@ export function MembershipCheckout({
48
74
  const t = useForgeT();
49
75
  const theme = useForgeTheme();
50
76
  const fmt = useAmountFormatter(formatAmount);
51
- // 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
52
118
  // stores still caption the displayed price as tax-inclusive (display only).
53
119
  const taxCaption = priceTaxCaption({ pricesIncludeTax: usePricesIncludeTax() });
54
120
  const renderPayment = usePaymentRenderer();
121
+
55
122
  const checkout = useMembershipCheckout({
56
123
  initialTierId,
57
124
  returnPath: successPath,
@@ -59,7 +126,24 @@ export function MembershipCheckout({
59
126
  onComplete,
60
127
  });
61
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
+
62
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 };
63
147
  const box: CSSProperties = {
64
148
  border: `1px solid ${theme.colors.primary}30`,
65
149
  borderRadius: theme.cornerRadius,
@@ -76,29 +160,127 @@ export function MembershipCheckout({
76
160
  fontWeight: 600,
77
161
  cursor: "pointer",
78
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 };
79
170
 
80
171
  if (checkout.isLoading) return <Loading fullPage />;
81
172
 
82
173
  const tier = checkout.selectedTier;
174
+
175
+ // ---- Stage 1: pick a tier -------------------------------------------------
83
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
+
84
195
  return (
85
- <div className={className} style={card}>
86
- <p>{t("forge.membership_checkout.no_tier_body")}</p>
87
- <a href={membershipsPath} style={{ ...button, marginTop: 16, display: "inline-block", textAlign: "center", textDecoration: "none" }}>
88
- {t("forge.membership_checkout.view_memberships")}
89
- </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>
90
269
  </div>
91
270
  );
92
271
  }
93
272
 
94
- // Payment step: the registered payment UI (Stripe) in manual mode, or the
95
- // Paystack modal behind a pay button. The Paystack branch is also what a
96
- // member who closed the popup comes back to, so it must render whether or not
97
- // 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.
98
278
  if (checkout.step === "payment" && (checkout.clientSecret || checkout.canOpenPaystack)) {
99
279
  return (
100
280
  <div className={className} style={card}>
101
- <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 8 }}>{t("forge.membership_checkout.payment_title")}</h1>
281
+ <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 8 }}>
282
+ {t("forge.membership_checkout.payment_title")}
283
+ </h1>
102
284
  <p style={{ opacity: 0.75, marginBottom: 24 }}>
103
285
  {t("forge.membership_checkout.payment_summary", {
104
286
  tier_name: tier.name,
@@ -118,23 +300,27 @@ export function MembershipCheckout({
118
300
  clientSecret: checkout.clientSecret,
119
301
  returnUrl: `${typeof window !== "undefined" ? window.location.origin : ""}${successPath}`,
120
302
  amount: checkout.amount,
303
+ currency: settlementCurrency,
121
304
  mode: "manual",
122
- 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),
123
309
  onFailed: () => {},
124
310
  })
125
311
  )}
126
312
  </div>
127
- <button
128
- onClick={() => checkout.setStep("select")}
129
- style={{ ...button, background: "transparent", color: theme.colors.text, border: `1px solid ${theme.colors.primary}40` }}
130
- >
313
+ {checkout.error && <p style={errorText}>{checkout.error}</p>}
314
+ <button onClick={() => checkout.setStep("select")} style={ghostButton}>
131
315
  {t("forge.membership_checkout.back")}
132
316
  </button>
133
317
  </div>
134
318
  );
135
319
  }
136
320
 
137
- 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;
138
324
 
139
325
  return (
140
326
  <div className={className} style={card}>
@@ -160,7 +346,7 @@ export function MembershipCheckout({
160
346
  {showBillingToggle && (
161
347
  <div style={{ marginBottom: 16 }}>
162
348
  <p style={{ fontSize: 14, marginBottom: 8 }}>{t("forge.membership_checkout.billing_cycle_label")}</p>
163
- <div style={{ display: "flex", gap: 16 }}>
349
+ <div style={{ display: "flex", gap: 16, flexWrap: "wrap" }}>
164
350
  {(["month", "year"] as const).map((cycle) => (
165
351
  <label key={cycle} style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}>
166
352
  <input
@@ -170,9 +356,7 @@ export function MembershipCheckout({
170
356
  onChange={() => checkout.setBillingCycle(cycle)}
171
357
  style={{ accentColor: theme.colors.primary }}
172
358
  />
173
- {cycle === "month"
174
- ? t("forge.membership_checkout.billing_monthly")
175
- : t("forge.membership_checkout.billing_yearly")}
359
+ {cycleLabel(tier, cycle, t, fmt)}
176
360
  </label>
177
361
  ))}
178
362
  </div>
@@ -181,45 +365,134 @@ export function MembershipCheckout({
181
365
 
182
366
  {tier.payWhatYouWant && (
183
367
  <div style={{ marginBottom: 16 }}>
184
- <label style={{ fontSize: 14, display: "block", marginBottom: 8 }}>
185
- {t("forge.membership_checkout.pwyw_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) })}
186
374
  </label>
187
375
  <input
376
+ id="forge-pwyw-amount"
188
377
  type="number"
189
- min={tier.payWhatYouWantMinimum ?? 0}
190
- value={checkout.customAmount || ""}
191
- 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}
192
389
  style={{
193
390
  width: "100%",
194
391
  height: 40,
195
392
  padding: "8px 12px",
196
393
  borderRadius: theme.cornerRadius,
197
- border: `1px solid ${theme.colors.primary}40`,
394
+ border: `1px solid ${checkout.amountRefusal ? "#ef4444" : `${theme.colors.primary}40`}`,
198
395
  background: "transparent",
199
396
  color: theme.colors.text,
200
397
  outline: "none",
201
398
  }}
202
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
+ )}
203
423
  </div>
204
424
  )}
205
425
 
206
426
  <div style={{ textAlign: "center", paddingTop: 16, borderTop: `1px solid ${theme.colors.primary}20` }}>
207
427
  <p style={{ fontSize: 14, opacity: 0.75 }}>{t("forge.membership_checkout.total")}</p>
208
- <p style={{ fontSize: 28, fontWeight: 800 }}>{fmt(checkout.amount)}</p>
428
+ <p style={{ fontSize: 28, fontWeight: 800 }}>
429
+ {checkout.isFreeCycle ? t("forge.membership_checkout.free") : fmt(checkout.amount)}
430
+ </p>
209
431
  <p style={{ fontSize: 14, opacity: 0.75 }}>
210
432
  {checkout.billingCycle === "month"
211
433
  ? t("forge.membership_checkout.per_month")
212
434
  : t("forge.membership_checkout.per_year")}
213
435
  </p>
214
- {taxCaption && <p style={{ fontSize: 12, opacity: 0.65, marginTop: 2 }}>{taxCaption}</p>}
436
+ {taxCaption && !checkout.isFreeCycle && (
437
+ <p style={{ fontSize: 12, opacity: 0.65, marginTop: 2 }}>{taxCaption}</p>
438
+ )}
215
439
  </div>
216
440
  </div>
217
441
 
218
- {checkout.error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{checkout.error}</p>}
442
+ {checkout.error && <p style={{ ...errorText, marginBottom: 16 }}>{checkout.error}</p>}
219
443
 
220
- <button onClick={() => checkout.subscribe()} disabled={checkout.isProcessing} style={button}>
221
- {checkout.isProcessing ? t("forge.membership_checkout.submitting") : t("forge.membership_checkout.submit")}
222
- </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>
223
458
  </div>
224
459
  );
225
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
+ }
@@ -5,6 +5,7 @@ import { useAmountFormatter } from "../format/useFormatCurrency";
5
5
  import { priceTaxCaption } from "../format/PriceDisplay";
6
6
  import { usePricesIncludeTax } from "../../data/queries/useWebsite";
7
7
  import { useForgeT } from "../../i18n";
8
+ import { cycleFloor, offeredCycles } from "../format/membershipPwyw";
8
9
  import { Button } from "./Button";
9
10
  import { Loading } from "./Loading";
10
11
 
@@ -52,12 +53,18 @@ export function MembershipTiers({
52
53
  if (isLoading) return <Loading fullPage />;
53
54
  if (!tiers?.length) return <p style={{ color: tokens.text }}>{t("forge.membership_tiers.empty_title")}</p>;
54
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.
55
59
  const priceLabel = (tier: MembershipTier) => {
60
+ const cycles = offeredCycles(tier);
56
61
  if (tier.payWhatYouWant) {
57
- return t("forge.membership_tiers.price_pwyw", { amount: fmt(tier.payWhatYouWantMinimum ?? 0) });
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")) });
58
65
  }
59
- if (tier.priceMonthly) return t("forge.membership_tiers.price_monthly", { amount: fmt(tier.priceMonthly) });
60
- if (tier.priceYearly) return t("forge.membership_tiers.price_yearly", { amount: fmt(tier.priceYearly) });
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")) });
61
68
  return freeLabel;
62
69
  };
63
70
 
@@ -26,6 +26,11 @@ export function ResetPasswordForm({ token, loginHref, forgotHref }: ResetPasswor
26
26
  const [isSuccess, setIsSuccess] = useState(false);
27
27
 
28
28
  const card: React.CSSProperties = {
29
+ // See `LoginForm`. The host layout is a COLUMN flex container, and an auto
30
+ // cross-axis margin disables `align-items: stretch`, so `maxWidth` alone
31
+ // leaves the card shrink-to-fit. The cap is unchanged: this fills the
32
+ // container UP TO 420 and no further.
33
+ width: "100%",
29
34
  maxWidth: 420,
30
35
  margin: "40px auto 0",
31
36
  padding: 24,
@@ -23,6 +23,11 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
23
23
  const codeSentParts = t("forge.signup_form.code_sent").split("{email}");
24
24
 
25
25
  const card: React.CSSProperties = {
26
+ // See `LoginForm`. The host layout is a COLUMN flex container, and an auto
27
+ // cross-axis margin disables `align-items: stretch`, so `maxWidth` alone
28
+ // leaves the card shrink-to-fit. The cap is unchanged: this fills the
29
+ // container UP TO 420 and no further.
30
+ width: "100%",
26
31
  maxWidth: 420,
27
32
  margin: "40px auto 0",
28
33
  padding: 24,