@tribe-nest/forge 3.54.0 → 3.58.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 +4 -2
- package/src/_tests/workspaceAliases.ts +1 -0
- package/src/contexts/CartContext.tsx +88 -5
- package/src/data/queries/useCallBreakouts.ts +112 -0
- package/src/data/queries/useCheckouts.ts +15 -2
- package/src/data/queries/useCoachingProducts.ts +15 -1
- package/src/data/queries/useCourses.ts +15 -1
- package/src/data/queries/useEvents.ts +80 -1
- package/src/data/queries/useMembership.ts +176 -4
- package/src/data/queries/useMyBookings.ts +16 -0
- package/src/data/queries/useNotifications.ts +1 -1
- package/src/data/queries/useProducts.ts +15 -1
- package/src/data/queries/useSubscriptions.ts +21 -1
- package/src/i18n/de.json +153 -0
- package/src/i18n/en.json +153 -0
- package/src/index.ts +1 -0
- package/src/server/_tests/buildPwaHead.spec.ts +1 -1
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
- package/src/server/index.ts +75 -1
- package/src/server/platformEvents.generated.ts +60 -0
- package/src/server/pwa.ts +1 -1
- package/src/types/models.ts +137 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
- package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
- package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
- package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
- package/src/ui/headless/checkout/seatConflict.ts +117 -0
- package/src/ui/headless/checkout/useCheckout.ts +178 -55
- package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
- package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
- package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
- package/src/ui/headless/event/useEventCheckout.ts +362 -22
- package/src/ui/headless/event/useEventSeating.ts +233 -0
- package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
- package/src/ui/headless/forms/useEmailListForm.ts +37 -6
- package/src/ui/headless/forms/usePhoneInput.ts +294 -0
- package/src/ui/headless/forms/useSectionedForm.ts +2 -2
- package/src/ui/headless/index.ts +48 -1
- package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
- package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
- package/src/ui/headless/membership/membershipApplication.ts +109 -0
- package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
- package/src/ui/headless/membership/membershipTrial.ts +53 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
- package/src/ui/index.ts +16 -0
- package/src/ui/media/CallStage.tsx +107 -10
- package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
- package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
- package/src/ui/media/_tests/callState.spec.ts +103 -1
- package/src/ui/media/callBreakouts.ts +57 -0
- package/src/ui/media/callState.ts +83 -23
- package/src/ui/media/index.ts +11 -0
- package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
- package/src/ui/media/panels/CallSidePanel.tsx +37 -4
- package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
- package/src/ui/media/stage/CallControlsBar.tsx +46 -0
- package/src/ui/media/stage/CallTopBar.tsx +1 -1
- package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
- package/src/ui/payment/ForgeStripePayment.tsx +78 -7
- package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
- package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
- package/src/ui/payment/stripeIntentKind.ts +32 -0
- package/src/ui/shell/TribeNestApp.tsx +1 -1
- package/src/ui/styled/AccountDashboard.tsx +165 -2
- package/src/ui/styled/BundleConfirmation.tsx +47 -2
- package/src/ui/styled/Cart.tsx +211 -135
- package/src/ui/styled/Checkout.tsx +163 -32
- package/src/ui/styled/EmailListForm.tsx +7 -9
- package/src/ui/styled/EventTickets.tsx +143 -4
- package/src/ui/styled/LoginForm.tsx +5 -3
- package/src/ui/styled/MembershipCheckout.tsx +596 -256
- package/src/ui/styled/MembershipTierCallout.tsx +17 -3
- package/src/ui/styled/MembershipTiers.tsx +148 -6
- package/src/ui/styled/PhoneInput.tsx +127 -0
- package/src/ui/styled/ProductGrid.tsx +14 -2
- package/src/ui/styled/SignupForm.tsx +4 -2
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
- package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
- package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
- package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
- package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
- package/src/ui/styled/forge-utilities.css +67 -0
- package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
- package/src/ui/styled/seating/SeatLegend.tsx +73 -0
- package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
- package/src/ui/styled/seating/paint.ts +79 -0
- package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
- package/src/ui/styled/seating/viewport.ts +86 -0
- package/src/utils/_tests/engagementTracker.spec.ts +111 -0
- package/src/utils/engagementTracker.ts +73 -0
- package/src/utils/landing.ts +1 -1
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { useEffect, useState, type CSSProperties } from "react";
|
|
2
2
|
import { useMembershipCheckout } from "../headless/membership/useMembershipCheckout";
|
|
3
|
-
import { useForgeT } from "../../i18n";
|
|
3
|
+
import { useForgeLocale, useForgeT } from "../../i18n";
|
|
4
4
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
5
5
|
import { useAmountFormatter, useFormatCurrency, getCurrencySymbol } from "../format/useFormatCurrency";
|
|
6
6
|
import { priceTaxCaption } from "../format/PriceDisplay";
|
|
7
7
|
import { usePricesIncludeTax, useSiteConfig } from "../../data/queries/useWebsite";
|
|
8
8
|
import { usePublicAuth } from "../../contexts/PublicAuthContext";
|
|
9
9
|
import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
|
|
10
|
+
import { isMembershipInviteDead, useMembershipInviteInfo } from "../../data/queries/useMembership";
|
|
11
|
+
import {
|
|
12
|
+
tierCardAction,
|
|
13
|
+
type MembershipApplicationOutcome,
|
|
14
|
+
} from "../headless/membership/membershipApplication";
|
|
15
|
+
import { formatTrialDate, tierTrialDays } from "../headless/membership/membershipTrial";
|
|
10
16
|
import { readableTextOn } from "../theme/contrast";
|
|
11
17
|
import { cycleFloor, cycleIsFree, offeredCycles, type BillingCycle } from "../format/membershipPwyw";
|
|
12
18
|
import type { MembershipTier } from "../../types/models";
|
|
@@ -31,8 +37,20 @@ export interface MembershipCheckoutProps {
|
|
|
31
37
|
successPath?: string;
|
|
32
38
|
/** Where the "View memberships" link points when the artist sells no tiers. Default `/i/membership`. */
|
|
33
39
|
membershipsPath?: string;
|
|
40
|
+
/**
|
|
41
|
+
* The fan's account page, where their applications are listed. The link
|
|
42
|
+
* after applying points here. Default `/i/account`.
|
|
43
|
+
*/
|
|
44
|
+
accountPath?: string;
|
|
34
45
|
/** Format a numeric amount for display. Defaults to the runtime currency formatter. */
|
|
35
46
|
formatAmount?: (amount: number) => string;
|
|
47
|
+
/**
|
|
48
|
+
* A membership invite token. Omitted, the `invite` search param of the current
|
|
49
|
+
* url is used, so the invite email's link works with no wiring. The page then
|
|
50
|
+
* names the tier and the invited email before anything else, or says plainly
|
|
51
|
+
* that the invite is no longer valid.
|
|
52
|
+
*/
|
|
53
|
+
inviteToken?: string | null;
|
|
36
54
|
className?: string;
|
|
37
55
|
style?: CSSProperties;
|
|
38
56
|
}
|
|
@@ -67,18 +85,21 @@ export function MembershipCheckout({
|
|
|
67
85
|
onComplete,
|
|
68
86
|
successPath = "/i/account?tab=membership&confirmSubscription=true",
|
|
69
87
|
membershipsPath = "/i/membership",
|
|
88
|
+
accountPath = "/i/account",
|
|
70
89
|
formatAmount,
|
|
90
|
+
inviteToken,
|
|
71
91
|
className,
|
|
72
92
|
style,
|
|
73
93
|
}: MembershipCheckoutProps) {
|
|
74
94
|
const t = useForgeT();
|
|
95
|
+
const locale = useForgeLocale();
|
|
75
96
|
const theme = useForgeTheme();
|
|
76
97
|
const fmt = useAmountFormatter(formatAmount);
|
|
77
98
|
const { data: siteConfig } = useSiteConfig();
|
|
78
99
|
/** Settlement currency: the currency every tier number is already in. */
|
|
79
100
|
const settlementCurrency = siteConfig?.currency;
|
|
80
101
|
const { convertCurrency } = useFormatCurrency();
|
|
81
|
-
const { userSelectedCurrency } = usePublicAuth();
|
|
102
|
+
const { userSelectedCurrency, user } = usePublicAuth();
|
|
82
103
|
/** What the visitor is reading prices in. Falls back to settlement when they
|
|
83
104
|
* have not picked one, in which case both conversions below are identities. */
|
|
84
105
|
const displayCurrency = (userSelectedCurrency as string | undefined) ?? settlementCurrency;
|
|
@@ -124,7 +145,9 @@ export function MembershipCheckout({
|
|
|
124
145
|
returnPath: successPath,
|
|
125
146
|
freeReturnPath: successPath,
|
|
126
147
|
onComplete,
|
|
148
|
+
inviteToken,
|
|
127
149
|
});
|
|
150
|
+
const inviteInfo = useMembershipInviteInfo(checkout.inviteToken);
|
|
128
151
|
|
|
129
152
|
/**
|
|
130
153
|
* The digits in the box, in the visitor's currency.
|
|
@@ -170,292 +193,609 @@ export function MembershipCheckout({
|
|
|
170
193
|
|
|
171
194
|
if (checkout.isLoading) return <Loading fullPage />;
|
|
172
195
|
|
|
173
|
-
|
|
196
|
+
// ---- Invite banner: the tier and the invited email, before anything else ---
|
|
197
|
+
// State the fan acts on (who the invite is for, whether it still works), so it
|
|
198
|
+
// is visible content rather than a hint.
|
|
199
|
+
const inviteDead = !!checkout.inviteToken && isMembershipInviteDead(inviteInfo.error);
|
|
200
|
+
const invite = inviteInfo.data;
|
|
201
|
+
const emailMismatch =
|
|
202
|
+
!!invite && !!user?.email && user.email.trim().toLowerCase() !== invite.email.trim().toLowerCase();
|
|
203
|
+
const banner =
|
|
204
|
+
inviteDead || invite ? (
|
|
205
|
+
<div
|
|
206
|
+
role={inviteDead || emailMismatch ? "alert" : "status"}
|
|
207
|
+
data-testid="membership-invite-banner"
|
|
208
|
+
style={{ ...box, maxWidth: 560, margin: "0 auto 24px", color: theme.colors.text }}
|
|
209
|
+
>
|
|
210
|
+
{inviteDead ? (
|
|
211
|
+
<>
|
|
212
|
+
<p style={{ fontWeight: 700 }}>{t("forge.membership_checkout.invite_dead_title")}</p>
|
|
213
|
+
<p style={{ fontSize: 14, opacity: 0.85, marginTop: 4 }}>{t("forge.membership_checkout.invite_dead_body")}</p>
|
|
214
|
+
</>
|
|
215
|
+
) : (
|
|
216
|
+
invite && (
|
|
217
|
+
<>
|
|
218
|
+
<p style={{ fontWeight: 700 }}>
|
|
219
|
+
{t("forge.membership_checkout.invite_title", { tier_name: invite.membershipTierName })}
|
|
220
|
+
</p>
|
|
221
|
+
<p style={{ fontSize: 14, opacity: 0.85, marginTop: 4 }}>
|
|
222
|
+
{t("forge.membership_checkout.invite_for_email", { email: invite.email })}
|
|
223
|
+
</p>
|
|
224
|
+
{emailMismatch && (
|
|
225
|
+
<p style={{ fontSize: 14, marginTop: 8, fontWeight: 600 }}>
|
|
226
|
+
{t("forge.membership_checkout.invite_email_mismatch", {
|
|
227
|
+
signed_in_email: user?.email ?? "",
|
|
228
|
+
email: invite.email,
|
|
229
|
+
})}
|
|
230
|
+
</p>
|
|
231
|
+
)}
|
|
232
|
+
</>
|
|
233
|
+
)
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
) : null;
|
|
237
|
+
|
|
238
|
+
const content = renderStage();
|
|
239
|
+
return banner ? (
|
|
240
|
+
<>
|
|
241
|
+
{banner}
|
|
242
|
+
{content}
|
|
243
|
+
</>
|
|
244
|
+
) : (
|
|
245
|
+
content
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
function renderStage() {
|
|
249
|
+
const tier = checkout.selectedTier;
|
|
174
250
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const tiers = checkout.tiers ?? [];
|
|
178
|
-
if (!tiers.length) {
|
|
251
|
+
// ---- Application submitted --------------------------------------------------
|
|
252
|
+
if (tier && checkout.applicationOutcome) {
|
|
179
253
|
return (
|
|
180
|
-
<div className={className} style={card}>
|
|
181
|
-
<
|
|
182
|
-
{
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
254
|
+
<div className={className} style={card} data-testid="membership-application-outcome">
|
|
255
|
+
<div style={box}>
|
|
256
|
+
<h1 style={{ fontSize: 24, fontWeight: 800, marginBottom: 8 }}>
|
|
257
|
+
{t(APPLICATION_OUTCOME_TITLE[checkout.applicationOutcome], { tier_name: tier.name })}
|
|
258
|
+
</h1>
|
|
259
|
+
<p style={{ opacity: 0.85 }}>
|
|
260
|
+
{checkout.applicationOutcome === "received" && checkout.application?.amount
|
|
261
|
+
? t("forge.membership_checkout.application_received_paid_body", {
|
|
262
|
+
tier_name: tier.name,
|
|
263
|
+
amount: fmt(checkout.application.amount),
|
|
264
|
+
cycle:
|
|
265
|
+
checkout.application.billingCycle === "year"
|
|
266
|
+
? t("forge.membership_checkout.per_year")
|
|
267
|
+
: t("forge.membership_checkout.per_month"),
|
|
268
|
+
})
|
|
269
|
+
: t(APPLICATION_OUTCOME_BODY[checkout.applicationOutcome], { tier_name: tier.name })}
|
|
270
|
+
</p>
|
|
271
|
+
</div>
|
|
272
|
+
{/* A lodged or approved application is followed on the account page.
|
|
273
|
+
One that closed leaves the fan choosing again, so that goes back
|
|
274
|
+
to the tiers. */}
|
|
275
|
+
{checkout.applicationOutcome === "received" || checkout.applicationOutcome === "approved" ? (
|
|
276
|
+
<a
|
|
277
|
+
href={accountPath}
|
|
278
|
+
data-testid="membership-application-account-link"
|
|
279
|
+
style={{ ...ghostButton, display: "block", textAlign: "center", textDecoration: "none", boxSizing: "border-box" }}
|
|
280
|
+
>
|
|
281
|
+
{t("forge.membership_checkout.go_to_account")}
|
|
282
|
+
</a>
|
|
283
|
+
) : (
|
|
284
|
+
<a
|
|
285
|
+
href={membershipsPath}
|
|
286
|
+
style={{ ...ghostButton, display: "block", textAlign: "center", textDecoration: "none", boxSizing: "border-box" }}
|
|
287
|
+
>
|
|
288
|
+
{t("forge.membership_checkout.view_memberships")}
|
|
289
|
+
</a>
|
|
290
|
+
)}
|
|
191
291
|
</div>
|
|
192
292
|
);
|
|
193
293
|
}
|
|
194
294
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
295
|
+
// ---- Stage 1: pick a tier -------------------------------------------------
|
|
296
|
+
if (!tier) {
|
|
297
|
+
const tiers = checkout.tiers ?? [];
|
|
298
|
+
if (!tiers.length) {
|
|
299
|
+
return (
|
|
300
|
+
<div className={className} style={card}>
|
|
301
|
+
<h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 8 }}>
|
|
302
|
+
{t("forge.membership_checkout.empty_title")}
|
|
303
|
+
</h1>
|
|
304
|
+
<p>{t("forge.membership_checkout.no_tier_body")}</p>
|
|
305
|
+
<a
|
|
306
|
+
href={membershipsPath}
|
|
307
|
+
style={{ ...button, marginTop: 16, display: "inline-block", textAlign: "center", textDecoration: "none" }}
|
|
308
|
+
>
|
|
309
|
+
{t("forge.membership_checkout.view_memberships")}
|
|
310
|
+
</a>
|
|
311
|
+
</div>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<div className={className} style={wide}>
|
|
317
|
+
<h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 24, textAlign: "center" }}>
|
|
318
|
+
{t("forge.membership_checkout.choose_title")}
|
|
319
|
+
</h1>
|
|
320
|
+
<div style={{ display: "flex", flexWrap: "wrap", gap: 20, justifyContent: "center" }}>
|
|
321
|
+
{tiers.map((option) => {
|
|
322
|
+
const isCurrent = option.id === checkout.currentTierId;
|
|
323
|
+
return (
|
|
324
|
+
<div
|
|
325
|
+
key={option.id}
|
|
326
|
+
style={{
|
|
327
|
+
...box,
|
|
328
|
+
flex: "1 1 260px",
|
|
329
|
+
maxWidth: 340,
|
|
330
|
+
width: "100%",
|
|
331
|
+
display: "flex",
|
|
332
|
+
flexDirection: "column",
|
|
333
|
+
gap: 12,
|
|
334
|
+
opacity: isCurrent ? 0.6 : 1,
|
|
335
|
+
borderColor: isCurrent ? `${theme.colors.primary}99` : `${theme.colors.primary}30`,
|
|
336
|
+
}}
|
|
337
|
+
>
|
|
338
|
+
{isCurrent && (
|
|
339
|
+
<span
|
|
340
|
+
style={{
|
|
341
|
+
alignSelf: "center",
|
|
342
|
+
padding: "2px 10px",
|
|
343
|
+
borderRadius: 999,
|
|
344
|
+
fontSize: 12,
|
|
345
|
+
fontWeight: 700,
|
|
346
|
+
background: theme.colors.primary,
|
|
347
|
+
color: readableTextOn(theme.colors.primary),
|
|
348
|
+
}}
|
|
349
|
+
>
|
|
350
|
+
{t("forge.membership_checkout.current_plan")}
|
|
351
|
+
</span>
|
|
352
|
+
)}
|
|
353
|
+
<h2 style={{ fontSize: 20, fontWeight: 700, textAlign: "center" }}>{option.name}</h2>
|
|
354
|
+
<p style={{ fontSize: 18, fontWeight: 700, textAlign: "center", color: theme.colors.primary }}>
|
|
355
|
+
{tierPriceLabel(option, t, fmt)}
|
|
356
|
+
</p>
|
|
357
|
+
{tierTrialDays(option, user?.membership) > 0 && (
|
|
358
|
+
<span
|
|
359
|
+
data-testid="membership-tier-trial-badge"
|
|
360
|
+
style={{
|
|
361
|
+
alignSelf: "center",
|
|
362
|
+
padding: "2px 10px",
|
|
363
|
+
borderRadius: 999,
|
|
364
|
+
fontSize: 12,
|
|
365
|
+
fontWeight: 700,
|
|
366
|
+
border: `1px solid ${theme.colors.primary}`,
|
|
367
|
+
color: theme.colors.primary,
|
|
368
|
+
}}
|
|
369
|
+
>
|
|
370
|
+
{t("forge.membership_tiers.trial_badge", { days: tierTrialDays(option, user?.membership) })}
|
|
371
|
+
</span>
|
|
263
372
|
)}
|
|
373
|
+
{!!option.benefits?.length && (
|
|
374
|
+
<ul
|
|
375
|
+
style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 6 }}
|
|
376
|
+
>
|
|
377
|
+
{option.benefits.slice(0, 3).map((b) => (
|
|
378
|
+
<li key={b.id} style={{ display: "flex", gap: 8, fontSize: 14 }}>
|
|
379
|
+
<span style={{ color: theme.colors.primary }}>✓</span>
|
|
380
|
+
{b.title}
|
|
381
|
+
</li>
|
|
382
|
+
))}
|
|
383
|
+
{option.benefits.length > 3 && (
|
|
384
|
+
<li style={{ fontSize: 12, opacity: 0.75 }}>
|
|
385
|
+
{t("forge.membership_checkout.more_benefits", { count: option.benefits.length - 3 })}
|
|
386
|
+
</li>
|
|
387
|
+
)}
|
|
388
|
+
</ul>
|
|
389
|
+
)}
|
|
390
|
+
<div style={{ marginTop: "auto" }}>
|
|
391
|
+
{isCurrent ? (
|
|
392
|
+
<p style={{ fontSize: 13, opacity: 0.75, textAlign: "center" }}>
|
|
393
|
+
{t("forge.membership_checkout.on_this_plan")}
|
|
394
|
+
</p>
|
|
395
|
+
) : (
|
|
396
|
+
<GridTierAction
|
|
397
|
+
tier={option}
|
|
398
|
+
trialDays={tierTrialDays(option, user?.membership)}
|
|
399
|
+
onSelect={() => checkout.selectTier(option)}
|
|
400
|
+
buttonStyle={button}
|
|
401
|
+
t={t}
|
|
402
|
+
/>
|
|
403
|
+
)}
|
|
404
|
+
</div>
|
|
264
405
|
</div>
|
|
265
|
-
|
|
266
|
-
)
|
|
267
|
-
|
|
406
|
+
);
|
|
407
|
+
})}
|
|
408
|
+
</div>
|
|
268
409
|
</div>
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---- Stage 3: payment -----------------------------------------------------
|
|
414
|
+
// The registered payment UI (Stripe) in manual mode, or the Paystack modal
|
|
415
|
+
// behind a pay button. The Paystack branch is also what a member who closed
|
|
416
|
+
// the popup comes back to, so it must render whether or not a Stripe secret
|
|
417
|
+
// was ever issued.
|
|
418
|
+
if (checkout.step === "payment" && (checkout.clientSecret || checkout.canOpenPaystack)) {
|
|
419
|
+
return (
|
|
420
|
+
<div className={className} style={card}>
|
|
421
|
+
<h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 8 }}>
|
|
422
|
+
{checkout.setupPurpose === "application"
|
|
423
|
+
? t("forge.membership_checkout.application_card_title")
|
|
424
|
+
: checkout.setupPurpose === "trial"
|
|
425
|
+
? t("forge.membership_checkout.trial_card_title")
|
|
426
|
+
: t("forge.membership_checkout.payment_title")}
|
|
427
|
+
</h1>
|
|
428
|
+
<p style={{ opacity: 0.75, marginBottom: 24 }}>
|
|
429
|
+
{t("forge.membership_checkout.payment_summary", {
|
|
430
|
+
tier_name: tier.name,
|
|
431
|
+
amount: fmt(checkout.amount),
|
|
432
|
+
cycle: checkout.billingCycle,
|
|
433
|
+
})}
|
|
434
|
+
</p>
|
|
435
|
+
<div style={box}>
|
|
436
|
+
{checkout.isPaystack ? (
|
|
437
|
+
<PaystackPayButton
|
|
438
|
+
onPay={checkout.openPaystackCheckout}
|
|
439
|
+
label={t("forge.membership_checkout.pay", { amount: fmt(checkout.amount) })}
|
|
440
|
+
/>
|
|
441
|
+
) : (
|
|
442
|
+
checkout.clientSecret &&
|
|
443
|
+
renderPayment({
|
|
444
|
+
clientSecret: checkout.clientSecret,
|
|
445
|
+
returnUrl:
|
|
446
|
+
checkout.setupPurpose === "application" && checkout.applicationReturnUrl
|
|
447
|
+
? checkout.applicationReturnUrl
|
|
448
|
+
: `${typeof window !== "undefined" ? window.location.origin : ""}${successPath}`,
|
|
449
|
+
amount: checkout.amount,
|
|
450
|
+
currency: settlementCurrency,
|
|
451
|
+
mode: "manual",
|
|
452
|
+
// A trial saves the card (a SetupIntent) for a subscription that
|
|
453
|
+
// starts now: the renderer says what is charged and when.
|
|
454
|
+
trial:
|
|
455
|
+
checkout.setupPurpose === "trial" && checkout.trialEndsAt
|
|
456
|
+
? { endsAt: checkout.trialEndsAt, billingCycle: checkout.billingCycle }
|
|
457
|
+
: null,
|
|
458
|
+
// An application's card was SAVED, not charged: tell the server,
|
|
459
|
+
// which moves it in front of the business. Otherwise (a charged
|
|
460
|
+
// card, or a trial's saved card) the account page still has to
|
|
461
|
+
// reconcile the subscription, so this completion asks for
|
|
462
|
+
// confirmation.
|
|
463
|
+
onSucceeded: () => void checkout.onPaymentSucceeded(),
|
|
464
|
+
onFailed: () => {},
|
|
465
|
+
})
|
|
466
|
+
)}
|
|
467
|
+
</div>
|
|
468
|
+
{checkout.error && <p style={errorText}>{checkout.error}</p>}
|
|
469
|
+
<button onClick={() => checkout.setStep("select")} style={ghostButton}>
|
|
470
|
+
{t("forge.membership_checkout.back")}
|
|
471
|
+
</button>
|
|
472
|
+
</div>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ---- Stage 2: tier detail, billing cycle, pay-what-you-want ---------------
|
|
477
|
+
const cycles = checkout.cycles;
|
|
478
|
+
const showBillingToggle = cycles.month && cycles.year;
|
|
479
|
+
const tierAction = tierCardAction(tier);
|
|
480
|
+
/** An application already waiting, approved or declined: nothing to submit. */
|
|
481
|
+
const applicationBlocked = tierAction === "pending" || tierAction === "approved" || tierAction === "declined";
|
|
482
|
+
const applicationStateLine =
|
|
483
|
+
tierAction === "pending"
|
|
484
|
+
? t("forge.membership_checkout.application_state_pending")
|
|
485
|
+
: tierAction === "approved"
|
|
486
|
+
? t("forge.membership_checkout.application_state_approved")
|
|
487
|
+
: tierAction === "declined"
|
|
488
|
+
? t("forge.membership_checkout.application_state_declined")
|
|
489
|
+
: tierAction === "complete"
|
|
490
|
+
? t("forge.membership_checkout.application_state_complete")
|
|
491
|
+
: null;
|
|
492
|
+
const submitLabel = checkout.isApplication
|
|
493
|
+
? t("forge.membership_checkout.apply")
|
|
494
|
+
: tierAction === "complete"
|
|
495
|
+
? t("forge.membership_checkout.complete_membership")
|
|
496
|
+
: checkout.isChange
|
|
497
|
+
? t("forge.membership_checkout.confirm_change")
|
|
498
|
+
: tierAction === "accept_invite"
|
|
499
|
+
? t("forge.membership_checkout.accept_invite")
|
|
500
|
+
: checkout.trialDays > 0
|
|
501
|
+
? t("forge.membership_checkout.start_free_trial")
|
|
502
|
+
: t("forge.membership_checkout.submit");
|
|
272
503
|
|
|
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.
|
|
278
|
-
if (checkout.step === "payment" && (checkout.clientSecret || checkout.canOpenPaystack)) {
|
|
279
504
|
return (
|
|
280
505
|
<div className={className} style={card}>
|
|
281
|
-
<h1 style={{ fontSize: 26, fontWeight: 800, marginBottom:
|
|
282
|
-
{t("forge.membership_checkout.
|
|
506
|
+
<h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 24 }}>
|
|
507
|
+
{t("forge.membership_checkout.title", { tier_name: tier.name })}
|
|
283
508
|
</h1>
|
|
284
|
-
|
|
285
|
-
{t("forge.membership_checkout.payment_summary", {
|
|
286
|
-
tier_name: tier.name,
|
|
287
|
-
amount: fmt(checkout.amount),
|
|
288
|
-
cycle: checkout.billingCycle,
|
|
289
|
-
})}
|
|
290
|
-
</p>
|
|
509
|
+
|
|
291
510
|
<div style={box}>
|
|
292
|
-
{
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
amount: checkout.amount,
|
|
303
|
-
currency: settlementCurrency,
|
|
304
|
-
mode: "manual",
|
|
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),
|
|
309
|
-
onFailed: () => {},
|
|
310
|
-
})
|
|
511
|
+
{tier.description && <p style={{ marginBottom: 16 }}>{tier.description}</p>}
|
|
512
|
+
{!!tier.benefits?.length && (
|
|
513
|
+
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 8 }}>
|
|
514
|
+
{tier.benefits.map((b) => (
|
|
515
|
+
<li key={b.id} style={{ display: "flex", gap: 8 }}>
|
|
516
|
+
<span style={{ color: theme.colors.primary }}>✓</span>
|
|
517
|
+
{b.title}
|
|
518
|
+
</li>
|
|
519
|
+
))}
|
|
520
|
+
</ul>
|
|
311
521
|
)}
|
|
312
522
|
</div>
|
|
313
|
-
{checkout.error && <p style={errorText}>{checkout.error}</p>}
|
|
314
|
-
<button onClick={() => checkout.setStep("select")} style={ghostButton}>
|
|
315
|
-
{t("forge.membership_checkout.back")}
|
|
316
|
-
</button>
|
|
317
|
-
</div>
|
|
318
|
-
);
|
|
319
|
-
}
|
|
320
523
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
</ul>
|
|
342
|
-
)}
|
|
343
|
-
</div>
|
|
524
|
+
<div style={box}>
|
|
525
|
+
{showBillingToggle && (
|
|
526
|
+
<div style={{ marginBottom: 16 }}>
|
|
527
|
+
<p style={{ fontSize: 14, marginBottom: 8 }}>{t("forge.membership_checkout.billing_cycle_label")}</p>
|
|
528
|
+
<div style={{ display: "flex", gap: 16, flexWrap: "wrap" }}>
|
|
529
|
+
{(["month", "year"] as const).map((cycle) => (
|
|
530
|
+
<label key={cycle} style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}>
|
|
531
|
+
<input
|
|
532
|
+
type="radio"
|
|
533
|
+
name="billingCycle"
|
|
534
|
+
checked={checkout.billingCycle === cycle}
|
|
535
|
+
onChange={() => checkout.setBillingCycle(cycle)}
|
|
536
|
+
style={{ accentColor: theme.colors.primary }}
|
|
537
|
+
/>
|
|
538
|
+
{cycleLabel(tier, cycle, t, fmt)}
|
|
539
|
+
</label>
|
|
540
|
+
))}
|
|
541
|
+
</div>
|
|
542
|
+
</div>
|
|
543
|
+
)}
|
|
344
544
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
545
|
+
{tier.payWhatYouWant && (
|
|
546
|
+
<div style={{ marginBottom: 16 }}>
|
|
547
|
+
{/* The floor is printed in the SETTLEMENT currency because that is
|
|
548
|
+
the currency of the number this box sends. A unit that changes
|
|
549
|
+
what you type belongs in the label. */}
|
|
550
|
+
<label htmlFor="forge-pwyw-amount" style={{ fontSize: 14, display: "block", marginBottom: 8 }}>
|
|
551
|
+
{t("forge.membership_checkout.pwyw_label")}{" "}
|
|
552
|
+
{t("forge.membership_checkout.pwyw_min", { amount: fmt(checkout.minimumAmount) })}
|
|
553
|
+
</label>
|
|
554
|
+
<input
|
|
555
|
+
id="forge-pwyw-amount"
|
|
556
|
+
type="number"
|
|
557
|
+
min={Number(toDisplay(checkout.minimumAmount).toFixed(2))}
|
|
558
|
+
step="any"
|
|
559
|
+
value={typedAmount}
|
|
560
|
+
onChange={(e) => {
|
|
561
|
+
setTypedAmount(e.target.value);
|
|
562
|
+
// Converted back on the way out. The floor is checked against
|
|
563
|
+
// THIS number, not the typed one, so a rate that rounds down
|
|
564
|
+
// cannot slip a fan under the artist's minimum.
|
|
565
|
+
checkout.setCustomAmount(toSettlement(Number(e.target.value)));
|
|
566
|
+
}}
|
|
567
|
+
aria-invalid={!!checkout.amountRefusal}
|
|
568
|
+
style={{
|
|
569
|
+
width: "100%",
|
|
570
|
+
height: 40,
|
|
571
|
+
padding: "8px 12px",
|
|
572
|
+
borderRadius: theme.cornerRadius,
|
|
573
|
+
border: `1px solid ${checkout.amountRefusal ? "#ef4444" : `${theme.colors.primary}40`}`,
|
|
574
|
+
background: "transparent",
|
|
575
|
+
color: theme.colors.text,
|
|
576
|
+
outline: "none",
|
|
577
|
+
}}
|
|
578
|
+
/>
|
|
579
|
+
{/* What will ACTUALLY be taken, when rounding to a whole settlement
|
|
580
|
+
unit means it is not quite what was typed. Shown only when the
|
|
581
|
+
two differ, because a line that always appears is a line nobody
|
|
582
|
+
reads. This is state the fan has to act on, not an explanation,
|
|
583
|
+
so it stays visible rather than going behind a `?`. */}
|
|
584
|
+
{!checkout.amountRefusal && Number(typedAmount) > 0 && (() => {
|
|
585
|
+
const settled = toSettlement(Number(typedAmount));
|
|
586
|
+
const backInDisplay = Number(toDisplay(settled).toFixed(2));
|
|
587
|
+
return Math.abs(backInDisplay - Number(typedAmount)) >= 0.01 ? (
|
|
588
|
+
<p style={{ fontSize: 13, opacity: 0.75, marginTop: 6 }}>
|
|
589
|
+
{t("forge.membership_checkout.pwyw_charged_as", { amount: fmt(settled) })}
|
|
590
|
+
</p>
|
|
591
|
+
) : null;
|
|
592
|
+
})()}
|
|
593
|
+
{checkout.amountRefusal && (
|
|
594
|
+
<p role="alert" style={errorText}>
|
|
595
|
+
{checkout.amountRefusal.reason === "below_minimum"
|
|
596
|
+
? t("forge.membership_checkout.amount_below_minimum", {
|
|
597
|
+
minimum: fmt(checkout.amountRefusal.minimum),
|
|
598
|
+
})
|
|
599
|
+
: t("forge.membership_checkout.amount_not_positive")}
|
|
600
|
+
</p>
|
|
601
|
+
)}
|
|
362
602
|
</div>
|
|
603
|
+
)}
|
|
604
|
+
|
|
605
|
+
<div style={{ textAlign: "center", paddingTop: 16, borderTop: `1px solid ${theme.colors.primary}20` }}>
|
|
606
|
+
<p style={{ fontSize: 14, opacity: 0.75 }}>{t("forge.membership_checkout.total")}</p>
|
|
607
|
+
<p style={{ fontSize: 28, fontWeight: 800 }}>
|
|
608
|
+
{checkout.isFreeCycle ? t("forge.membership_checkout.free") : fmt(checkout.amount)}
|
|
609
|
+
</p>
|
|
610
|
+
<p style={{ fontSize: 14, opacity: 0.75 }}>
|
|
611
|
+
{checkout.billingCycle === "month"
|
|
612
|
+
? t("forge.membership_checkout.per_month")
|
|
613
|
+
: t("forge.membership_checkout.per_year")}
|
|
614
|
+
</p>
|
|
615
|
+
{taxCaption && !checkout.isFreeCycle && (
|
|
616
|
+
<p style={{ fontSize: 12, opacity: 0.65, marginTop: 2 }}>{taxCaption}</p>
|
|
617
|
+
)}
|
|
618
|
+
{/* When the first charge falls is what the fan is agreeing to, so it
|
|
619
|
+
is content, not a hint. */}
|
|
620
|
+
{checkout.trialDays > 0 && checkout.trialEndsAt && (
|
|
621
|
+
<p data-testid="membership-checkout-trial-line" style={{ fontSize: 14, fontWeight: 600, marginTop: 8 }}>
|
|
622
|
+
{t("forge.membership_checkout.trial_line", {
|
|
623
|
+
days: checkout.trialDays,
|
|
624
|
+
date: formatTrialDate(checkout.trialEndsAt, locale),
|
|
625
|
+
})}
|
|
626
|
+
</p>
|
|
627
|
+
)}
|
|
363
628
|
</div>
|
|
364
|
-
|
|
629
|
+
</div>
|
|
365
630
|
|
|
366
|
-
{tier.
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
<
|
|
372
|
-
{t("forge.membership_checkout.
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
631
|
+
{/* The tier's questions, on every join mode. Answering them is part of
|
|
632
|
+
joining, and a refusal is content the fan acts on, so both stay
|
|
633
|
+
visible. */}
|
|
634
|
+
{checkout.questionnaire.length > 0 && !applicationBlocked && (
|
|
635
|
+
<div style={{ ...box, marginBottom: 16 }} data-testid="membership-questionnaire">
|
|
636
|
+
<h2 style={{ fontSize: 17, fontWeight: 700, marginBottom: 12 }}>
|
|
637
|
+
{t("forge.membership_checkout.questions_title")}
|
|
638
|
+
</h2>
|
|
639
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
640
|
+
{checkout.questionnaire.map((q) => {
|
|
641
|
+
const value = checkout.questionnaireAnswers[q.id] ?? "";
|
|
642
|
+
const missing = !!checkout.questionnaireRefusal && !q.optional && !value.trim();
|
|
643
|
+
const fieldId = `forge-membership-question-${q.id}`;
|
|
644
|
+
const fieldStyle: CSSProperties = {
|
|
645
|
+
width: "100%",
|
|
646
|
+
padding: "8px 12px",
|
|
647
|
+
borderRadius: theme.cornerRadius,
|
|
648
|
+
border: `1px solid ${missing ? "#ef4444" : `${theme.colors.primary}40`}`,
|
|
649
|
+
background: "transparent",
|
|
650
|
+
color: theme.colors.text,
|
|
651
|
+
outline: "none",
|
|
652
|
+
boxSizing: "border-box",
|
|
653
|
+
};
|
|
654
|
+
return (
|
|
655
|
+
<div key={q.id} style={{ display: "flex", flexDirection: "column", gap: 6, fontSize: 14 }}>
|
|
656
|
+
<label htmlFor={fieldId}>
|
|
657
|
+
{q.question}
|
|
658
|
+
{!q.optional && (
|
|
659
|
+
<span aria-hidden="true" style={{ color: "#ef4444" }}>
|
|
660
|
+
{" "}
|
|
661
|
+
*
|
|
662
|
+
</span>
|
|
663
|
+
)}
|
|
664
|
+
</label>
|
|
665
|
+
{q.type === "select" && q.options && q.options.length > 0 ? (
|
|
666
|
+
<select
|
|
667
|
+
id={fieldId}
|
|
668
|
+
value={value}
|
|
669
|
+
required={!q.optional}
|
|
670
|
+
aria-invalid={missing}
|
|
671
|
+
onChange={(e) => checkout.setQuestionnaireAnswer(q.id, e.target.value)}
|
|
672
|
+
style={fieldStyle}
|
|
673
|
+
>
|
|
674
|
+
<option value="">{t("forge.membership_checkout.select_placeholder")}</option>
|
|
675
|
+
{q.options.map((opt) => (
|
|
676
|
+
<option key={opt} value={opt}>
|
|
677
|
+
{opt}
|
|
678
|
+
</option>
|
|
679
|
+
))}
|
|
680
|
+
</select>
|
|
681
|
+
) : q.type === "textarea" ? (
|
|
682
|
+
<textarea
|
|
683
|
+
id={fieldId}
|
|
684
|
+
value={value}
|
|
685
|
+
rows={3}
|
|
686
|
+
required={!q.optional}
|
|
687
|
+
aria-invalid={missing}
|
|
688
|
+
onChange={(e) => checkout.setQuestionnaireAnswer(q.id, e.target.value)}
|
|
689
|
+
style={{ ...fieldStyle, resize: "vertical" }}
|
|
690
|
+
/>
|
|
691
|
+
) : (
|
|
692
|
+
<input
|
|
693
|
+
id={fieldId}
|
|
694
|
+
value={value}
|
|
695
|
+
required={!q.optional}
|
|
696
|
+
aria-invalid={missing}
|
|
697
|
+
onChange={(e) => checkout.setQuestionnaireAnswer(q.id, e.target.value)}
|
|
698
|
+
style={{ ...fieldStyle, height: 40 }}
|
|
699
|
+
/>
|
|
700
|
+
)}
|
|
701
|
+
{missing && (
|
|
702
|
+
<p style={{ color: "#ef4444", fontSize: 13 }}>{t("forge.membership_checkout.field_required")}</p>
|
|
703
|
+
)}
|
|
704
|
+
</div>
|
|
705
|
+
);
|
|
706
|
+
})}
|
|
707
|
+
</div>
|
|
708
|
+
{checkout.questionnaireRefusal && (
|
|
709
|
+
<p role="alert" data-testid="membership-questionnaire-refusal" style={errorText}>
|
|
710
|
+
{t("forge.membership_checkout.answer_required", {
|
|
711
|
+
question: checkout.questionnaireRefusal.question,
|
|
712
|
+
})}
|
|
421
713
|
</p>
|
|
422
714
|
)}
|
|
423
715
|
</div>
|
|
424
716
|
)}
|
|
425
717
|
|
|
426
|
-
|
|
427
|
-
<p style={{
|
|
428
|
-
|
|
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")}
|
|
718
|
+
{applicationStateLine && (
|
|
719
|
+
<p role="status" data-testid="membership-application-state" style={{ fontWeight: 600, marginBottom: 16 }}>
|
|
720
|
+
{applicationStateLine}
|
|
435
721
|
</p>
|
|
436
|
-
|
|
437
|
-
|
|
722
|
+
)}
|
|
723
|
+
|
|
724
|
+
{checkout.error && <p style={{ ...errorText, marginBottom: 16 }}>{checkout.error}</p>}
|
|
725
|
+
|
|
726
|
+
<div style={{ display: "flex", gap: 12 }}>
|
|
727
|
+
{!initialTierId && (
|
|
728
|
+
<button onClick={checkout.backToTierList} disabled={checkout.isProcessing} style={ghostButton}>
|
|
729
|
+
{t("forge.membership_checkout.back")}
|
|
730
|
+
</button>
|
|
731
|
+
)}
|
|
732
|
+
{!applicationBlocked && (
|
|
733
|
+
<button onClick={() => checkout.subscribe()} disabled={checkout.isProcessing} style={button}>
|
|
734
|
+
{checkout.isProcessing
|
|
735
|
+
? t("forge.membership_checkout.submitting")
|
|
736
|
+
: submitLabel}
|
|
737
|
+
</button>
|
|
438
738
|
)}
|
|
439
739
|
</div>
|
|
440
740
|
</div>
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
441
744
|
|
|
442
|
-
|
|
745
|
+
const APPLICATION_OUTCOME_TITLE: Record<MembershipApplicationOutcome, string> = {
|
|
746
|
+
received: "forge.membership_checkout.application_received_title",
|
|
747
|
+
approved: "forge.membership_checkout.application_approved_title",
|
|
748
|
+
tier_full: "forge.membership_checkout.application_tier_full_title",
|
|
749
|
+
declined: "forge.membership_checkout.application_declined_title",
|
|
750
|
+
closed: "forge.membership_checkout.application_closed_title",
|
|
751
|
+
};
|
|
443
752
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
753
|
+
const APPLICATION_OUTCOME_BODY: Record<MembershipApplicationOutcome, string> = {
|
|
754
|
+
received: "forge.membership_checkout.application_received_body",
|
|
755
|
+
approved: "forge.membership_checkout.application_approved_body",
|
|
756
|
+
tier_full: "forge.membership_checkout.application_tier_full_body",
|
|
757
|
+
declined: "forge.membership_checkout.application_declined_body",
|
|
758
|
+
closed: "forge.membership_checkout.application_closed_body",
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
/** A tier card's action in the grid: a select button, or where the fan's application stands. */
|
|
762
|
+
function GridTierAction({
|
|
763
|
+
tier,
|
|
764
|
+
trialDays,
|
|
765
|
+
onSelect,
|
|
766
|
+
buttonStyle,
|
|
767
|
+
t,
|
|
768
|
+
}: {
|
|
769
|
+
tier: MembershipTier;
|
|
770
|
+
trialDays: number;
|
|
771
|
+
onSelect: () => void;
|
|
772
|
+
buttonStyle: CSSProperties;
|
|
773
|
+
t: (key: string, vars?: Record<string, string | number>) => string;
|
|
774
|
+
}) {
|
|
775
|
+
const action = tierCardAction(tier);
|
|
776
|
+
if (action === "pending" || action === "approved" || action === "declined") {
|
|
777
|
+
return (
|
|
778
|
+
<p style={{ fontSize: 13, fontWeight: 600, textAlign: "center" }}>
|
|
779
|
+
{action === "pending"
|
|
780
|
+
? t("forge.membership_tiers.application_pending")
|
|
781
|
+
: action === "approved"
|
|
782
|
+
? t("forge.membership_tiers.application_approved")
|
|
783
|
+
: t("forge.membership_tiers.application_declined")}
|
|
784
|
+
</p>
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
return (
|
|
788
|
+
<button onClick={onSelect} style={buttonStyle}>
|
|
789
|
+
{action === "accept_invite"
|
|
790
|
+
? t("forge.membership_tiers.accept_invite")
|
|
791
|
+
: action === "apply"
|
|
792
|
+
? t("forge.membership_tiers.apply")
|
|
793
|
+
: action === "complete"
|
|
794
|
+
? t("forge.membership_tiers.complete_membership")
|
|
795
|
+
: trialDays > 0
|
|
796
|
+
? t("forge.membership_tiers.start_free_trial")
|
|
797
|
+
: t("forge.membership_checkout.select_tier")}
|
|
798
|
+
</button>
|
|
459
799
|
);
|
|
460
800
|
}
|
|
461
801
|
|