@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.
- package/package.json +6 -3
- package/src/_tests/publishedResolvability.spec.ts +184 -0
- package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
- package/src/_tests/workspaceAliases.ts +40 -0
- package/src/client/_tests/localeHeader.spec.tsx +98 -0
- package/src/client/activeLocale.ts +34 -0
- package/src/client/createForgeClient.ts +13 -0
- package/src/contexts/CartContext.tsx +76 -0
- package/src/contexts/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/CartContext.spec.tsx +210 -0
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useCheckouts.ts +83 -0
- package/src/data/queries/useMyBookings.ts +9 -1
- package/src/data/queries/useWebsite.ts +7 -0
- package/src/i18n/CONVENTION.md +184 -0
- package/src/i18n/_tests/translationKeys.spec.ts +153 -0
- package/src/i18n/de.json +1019 -0
- package/src/i18n/en.json +1019 -0
- package/src/i18n/index.ts +125 -0
- package/src/index.ts +21 -0
- package/src/provider/ForgeAppProvider.tsx +2 -1
- package/src/provider/SiteConfigProvider.tsx +11 -0
- package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
- package/src/ui/format/_tests/pwyw.spec.ts +65 -8
- package/src/ui/format/membershipPwyw.ts +164 -0
- package/src/ui/format/pwyw.ts +37 -0
- package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
- package/src/ui/headless/broadcast/broadcastState.ts +158 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
- package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
- package/src/ui/headless/checkout/useCartResume.ts +340 -0
- package/src/ui/headless/checkout/useCheckout.ts +9 -2
- package/src/ui/headless/event/useEventCheckout.ts +8 -13
- package/src/ui/headless/index.ts +32 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
- package/src/ui/index.ts +38 -0
- package/src/ui/media/CallHelpHint.tsx +87 -0
- package/src/ui/media/CallStage.tsx +542 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
- package/src/ui/media/_tests/callState.spec.ts +452 -0
- package/src/ui/media/_tests/fakeNode.ts +178 -0
- package/src/ui/media/bookingSession.tsx +194 -0
- package/src/ui/media/callState.ts +341 -0
- package/src/ui/media/index.ts +135 -0
- package/src/ui/shell/TribeNestApp.tsx +36 -27
- package/src/ui/styled/AccountDashboard.tsx +289 -96
- package/src/ui/styled/AiAgentWidget.tsx +10 -6
- package/src/ui/styled/AudioPlayer.tsx +14 -6
- package/src/ui/styled/BlogCategory.tsx +4 -2
- package/src/ui/styled/BlogComments.tsx +27 -20
- package/src/ui/styled/BlogList.tsx +5 -3
- package/src/ui/styled/BlogPost.tsx +14 -11
- package/src/ui/styled/BroadcastWatch.tsx +107 -0
- package/src/ui/styled/BundleConfirmation.tsx +32 -23
- package/src/ui/styled/CancellationTerms.tsx +3 -1
- package/src/ui/styled/Cart.tsx +32 -14
- package/src/ui/styled/ChatRoom.tsx +65 -20
- package/src/ui/styled/Checkout.tsx +151 -89
- package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
- package/src/ui/styled/CoachingBooking.tsx +71 -44
- package/src/ui/styled/CoachingConfirmation.tsx +56 -19
- package/src/ui/styled/CoachingDetail.tsx +15 -11
- package/src/ui/styled/CohortPage.tsx +10 -8
- package/src/ui/styled/ConfirmSubscription.tsx +14 -8
- package/src/ui/styled/ContactForm.tsx +31 -12
- package/src/ui/styled/CookieConsent.tsx +19 -15
- package/src/ui/styled/CourseAccess.tsx +54 -47
- package/src/ui/styled/CourseCheckout.tsx +56 -38
- package/src/ui/styled/CourseConfirmation.tsx +39 -17
- package/src/ui/styled/CourseDetail.tsx +21 -15
- package/src/ui/styled/CoursesGrid.tsx +4 -2
- package/src/ui/styled/CurrencySwitcher.tsx +4 -1
- package/src/ui/styled/DiscountCode.tsx +22 -18
- package/src/ui/styled/DonationButton.tsx +52 -17
- package/src/ui/styled/DonationPage.tsx +4 -4
- package/src/ui/styled/EmailListForm.tsx +22 -9
- package/src/ui/styled/EventConfirmation.tsx +37 -14
- package/src/ui/styled/EventCountdown.tsx +10 -6
- package/src/ui/styled/EventDetail.tsx +8 -6
- package/src/ui/styled/EventSeriesDetail.tsx +18 -13
- package/src/ui/styled/EventTickets.tsx +232 -108
- package/src/ui/styled/EventWaitlist.tsx +42 -33
- package/src/ui/styled/EventsList.tsx +15 -13
- package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
- package/src/ui/styled/FormRenderer.tsx +12 -7
- package/src/ui/styled/InstallBanner.tsx +21 -18
- package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
- package/src/ui/styled/InvoicePayment.tsx +37 -16
- package/src/ui/styled/LeadMagnet.tsx +9 -7
- package/src/ui/styled/LiveBroadcastList.tsx +171 -0
- package/src/ui/styled/Loading.tsx +7 -1
- package/src/ui/styled/LoginForm.tsx +32 -14
- package/src/ui/styled/MembershipCheckout.tsx +341 -49
- package/src/ui/styled/MembershipTiers.tsx +34 -18
- package/src/ui/styled/OfferButton.tsx +33 -13
- package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
- package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
- package/src/ui/styled/PodcastEpisode.tsx +20 -9
- package/src/ui/styled/PodcastList.tsx +9 -3
- package/src/ui/styled/PodcastShow.tsx +29 -7
- package/src/ui/styled/PostsFeed.tsx +10 -8
- package/src/ui/styled/PresaleCode.tsx +8 -11
- package/src/ui/styled/ProductBrowseNav.tsx +21 -11
- package/src/ui/styled/ProductDetail.tsx +58 -28
- package/src/ui/styled/ReactionBar.tsx +4 -2
- package/src/ui/styled/ReplayList.tsx +24 -13
- package/src/ui/styled/ResetPasswordForm.tsx +32 -15
- package/src/ui/styled/ResumeCart.tsx +289 -0
- package/src/ui/styled/ReviewForm.tsx +52 -58
- package/src/ui/styled/SignupForm.tsx +34 -16
- package/src/ui/styled/TicketTransfer.tsx +55 -54
- package/src/ui/styled/UserMenu.tsx +16 -14
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
- package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
- package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
- package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
- package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
- package/src/ui/styled/community/CommunityComposer.tsx +182 -3
- package/src/ui/styled/community/CommunityFeed.tsx +36 -51
- package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import type
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
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
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
|
|
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={
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
// a Stripe secret
|
|
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 }}>
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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 }}>
|
|
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 }}>
|
|
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
|
|
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
|
-
|
|
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={
|
|
175
|
-
|
|
176
|
-
|
|
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 }}>
|
|
193
|
-
<p style={{ fontSize: 28, fontWeight: 800 }}>
|
|
194
|
-
|
|
195
|
-
|
|
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={{
|
|
442
|
+
{checkout.error && <p style={{ ...errorText, marginBottom: 16 }}>{checkout.error}</p>}
|
|
200
443
|
|
|
201
|
-
<
|
|
202
|
-
{
|
|
203
|
-
|
|
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
|
|
38
|
+
heading,
|
|
37
39
|
className,
|
|
38
40
|
style,
|
|
39
41
|
}: MembershipTiersProps) {
|
|
40
|
-
const
|
|
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:
|
|
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
|
|
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 (
|
|
55
|
-
if (
|
|
56
|
-
return
|
|
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:
|
|
69
|
-
{
|
|
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:
|
|
79
|
-
border: `1px solid ${
|
|
80
|
-
borderRadius:
|
|
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:
|
|
88
|
-
<div style={{ fontSize: 24, fontWeight: 800, color:
|
|
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) !==
|
|
91
|
-
<span style={{ display: "block", fontSize: 12, fontWeight: 400, color:
|
|
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:
|
|
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
|
-
|
|
124
|
+
{t("forge.membership_tiers.select")}
|
|
109
125
|
</Button>
|
|
110
126
|
</div>
|
|
111
127
|
))}
|