@tribe-nest/forge 3.25.0 → 3.29.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 +1 -1
- package/src/client/_tests/localeHeader.spec.tsx +98 -0
- package/src/client/activeFunnel.ts +35 -0
- package/src/client/activeLocale.ts +34 -0
- package/src/client/createForgeClient.ts +22 -0
- package/src/contexts/CartContext.tsx +84 -1
- package/src/contexts/_tests/CartContext.spec.tsx +210 -0
- package/src/data/queries/useCheckouts.ts +83 -0
- package/src/data/queries/useForms.ts +48 -3
- package/src/data/queries/usePageActions.ts +2 -2
- 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 +960 -0
- package/src/i18n/en.json +960 -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/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 +59 -14
- package/src/ui/headless/event/useEventCheckout.ts +3 -0
- package/src/ui/headless/funnel/Funnel.tsx +13 -0
- package/src/ui/headless/index.ts +18 -0
- package/src/ui/index.ts +2 -0
- package/src/ui/shell/TribeNestApp.tsx +36 -27
- package/src/ui/styled/AccountDashboard.tsx +197 -93
- 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/BundleConfirmation.tsx +86 -46
- 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 +189 -88
- 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 +311 -125
- package/src/ui/styled/EventWaitlist.tsx +42 -33
- package/src/ui/styled/EventsList.tsx +15 -13
- package/src/ui/styled/ForgotPasswordForm.tsx +12 -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/Loading.tsx +7 -1
- package/src/ui/styled/LoginForm.tsx +22 -14
- package/src/ui/styled/MembershipCheckout.tsx +32 -13
- package/src/ui/styled/MembershipTiers.tsx +27 -18
- package/src/ui/styled/OfferButton.tsx +33 -13
- package/src/ui/styled/PageActions.tsx +13 -9
- 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/ProductGrid.tsx +17 -4
- package/src/ui/styled/ReactionBar.tsx +4 -2
- package/src/ui/styled/ReplayList.tsx +24 -13
- package/src/ui/styled/ResetPasswordForm.tsx +27 -15
- package/src/ui/styled/ResumeCart.tsx +289 -0
- package/src/ui/styled/ReviewForm.tsx +52 -58
- package/src/ui/styled/SignupForm.tsx +29 -16
- package/src/ui/styled/TicketTransfer.tsx +55 -54
- package/src/ui/styled/UserMenu.tsx +16 -14
- package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
|
@@ -2,6 +2,7 @@ import { type CSSProperties, type ReactNode, useEffect, useMemo, useState } from
|
|
|
2
2
|
import { AlertCircle, CalendarClock, Clock, KeyRound, X } from "lucide-react";
|
|
3
3
|
import type { IEvent, ITicket } from "../../types/models";
|
|
4
4
|
import { useEventCheckout } from "../headless/event/useEventCheckout";
|
|
5
|
+
import { useForgeT } from "../../i18n";
|
|
5
6
|
import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
|
|
6
7
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
7
8
|
import { PriceDisplay, summarizeTaxQuote } from "../format/PriceDisplay";
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
ticketSeatState,
|
|
17
18
|
} from "../format/ticketAvailability";
|
|
18
19
|
import { usePricesIncludeTax } from "../../data/queries/useWebsite";
|
|
20
|
+
import { usePageActions } from "../../data/queries/usePageActions";
|
|
19
21
|
import { Loading } from "./Loading";
|
|
20
22
|
import { PaystackPayButton } from "./PaystackPayButton";
|
|
21
23
|
import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
|
|
@@ -41,7 +43,7 @@ export interface EventTicketsProps {
|
|
|
41
43
|
triggerText?: string;
|
|
42
44
|
/** Format a numeric amount for display. */
|
|
43
45
|
formatAmount?: (amount: number) => string;
|
|
44
|
-
/** Optional payment renderer
|
|
46
|
+
/** Optional payment renderer. Falls back to a registered <ForgePaymentProvider>. */
|
|
45
47
|
renderPayment?: (props: PaymentRenderProps) => ReactNode;
|
|
46
48
|
/** Stripe return URL for a PAID purchase. Default `/i/events/:slug/finalise`. */
|
|
47
49
|
finalisePath?: (slug: string, orderId: string) => string;
|
|
@@ -52,21 +54,25 @@ export interface EventTicketsProps {
|
|
|
52
54
|
* code sites mount theirs under `/i`. The sign-in returns them here.
|
|
53
55
|
*/
|
|
54
56
|
loginPath?: string;
|
|
55
|
-
/** Where a members-only tier sends a signed-in NON-member
|
|
57
|
+
/** Where a members-only tier sends a signed-in NON-member: the tier listing. */
|
|
56
58
|
membershipPath?: string;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
/** Append an alpha to a 6-digit hex color (mirrors frontend-shared addAlphaToHexCode). */
|
|
60
|
-
const a = (hex: string, alpha: number) =>
|
|
62
|
+
const a = (hex: string, alpha: number) =>
|
|
63
|
+
hex +
|
|
64
|
+
Math.round(alpha * 255)
|
|
65
|
+
.toString(16)
|
|
66
|
+
.padStart(2, "0");
|
|
61
67
|
|
|
62
68
|
/**
|
|
63
|
-
* Themed event ticket purchase
|
|
69
|
+
* Themed event ticket purchase: a "Buy tickets" trigger + modal running the
|
|
64
70
|
* three-step flow (tickets → details → payment) on `useEventCheckout`. Port of
|
|
65
71
|
* the client app's EventTickets modal, styled from `ForgeThemeProvider`.
|
|
66
72
|
*/
|
|
67
73
|
export function EventTickets({
|
|
68
74
|
slug,
|
|
69
|
-
triggerText
|
|
75
|
+
triggerText,
|
|
70
76
|
formatAmount,
|
|
71
77
|
renderPayment,
|
|
72
78
|
finalisePath,
|
|
@@ -74,17 +80,32 @@ export function EventTickets({
|
|
|
74
80
|
loginPath,
|
|
75
81
|
membershipPath,
|
|
76
82
|
}: EventTicketsProps) {
|
|
83
|
+
const t = useForgeT();
|
|
77
84
|
const theme = useForgeTheme();
|
|
78
85
|
const registered = usePaymentRenderer();
|
|
79
86
|
const fmt = useAmountFormatter(formatAmount);
|
|
80
87
|
const c = useEventCheckout(slug, { finalisePath, onComplete });
|
|
81
88
|
const renderPay = renderPayment ?? registered ?? undefined;
|
|
82
89
|
|
|
90
|
+
// Does this page sell add-ons for this event? Same query key as the page's
|
|
91
|
+
// own <PageActions> mount, so this is a cache read, not a second request.
|
|
92
|
+
// When add-ons exist, the PRIMARY exit from ticket selection is the cart
|
|
93
|
+
// (that's what unlocks the add-on grid); direct payment stays the happy
|
|
94
|
+
// path only on pages without add-ons.
|
|
95
|
+
const { data: pageActionsData } = usePageActions("event_detail", c.event?.id);
|
|
96
|
+
const hasAddons = useMemo(
|
|
97
|
+
() =>
|
|
98
|
+
(pageActionsData?.actions ?? []).some(
|
|
99
|
+
(action) => action.type === "product_cards" && Boolean((action.config as Record<string, unknown>)?.isAddon),
|
|
100
|
+
),
|
|
101
|
+
[pageActionsData],
|
|
102
|
+
);
|
|
103
|
+
|
|
83
104
|
const [open, setOpen] = useState(false);
|
|
84
105
|
|
|
85
106
|
const tryClose = (next: boolean) => {
|
|
86
107
|
if (!next && c.step !== "tickets" && typeof window !== "undefined") {
|
|
87
|
-
if (!window.confirm("
|
|
108
|
+
if (!window.confirm(t("forge.event_tickets.leave_confirm"))) return;
|
|
88
109
|
}
|
|
89
110
|
setOpen(next);
|
|
90
111
|
};
|
|
@@ -92,7 +113,7 @@ export function EventTickets({
|
|
|
92
113
|
return (
|
|
93
114
|
<DialogRoot open={open} onOpenChange={tryClose}>
|
|
94
115
|
<DialogTrigger asChild>
|
|
95
|
-
<button style={triggerButtonStyle(theme)}>{triggerText}</button>
|
|
116
|
+
<button style={triggerButtonStyle(theme)}>{triggerText ?? t("forge.event_tickets.trigger")}</button>
|
|
96
117
|
</DialogTrigger>
|
|
97
118
|
<DialogPortal>
|
|
98
119
|
<DialogOverlay style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 50 }} />
|
|
@@ -115,16 +136,18 @@ export function EventTickets({
|
|
|
115
136
|
aria-describedby={undefined}
|
|
116
137
|
>
|
|
117
138
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
|
|
118
|
-
<DialogTitle style={{ fontSize: 18, fontWeight: 700, margin: 0 }}>
|
|
139
|
+
<DialogTitle style={{ fontSize: 18, fontWeight: 700, margin: 0 }}>
|
|
140
|
+
{c.event?.title ?? t("forge.event_tickets.title")}
|
|
141
|
+
</DialogTitle>
|
|
119
142
|
<DialogClose asChild>
|
|
120
|
-
<button aria-label="
|
|
143
|
+
<button aria-label={t("forge.event_tickets.aria_close")} style={closeButtonStyle(theme)}>
|
|
121
144
|
<X size={18} />
|
|
122
145
|
</button>
|
|
123
146
|
</DialogClose>
|
|
124
147
|
</div>
|
|
125
148
|
|
|
126
149
|
{c.isLoading && <Loading />}
|
|
127
|
-
{!c.isLoading && !c.event && <p style={{ marginTop: 8 }}>
|
|
150
|
+
{!c.isLoading && !c.event && <p style={{ marginTop: 8 }}>{t("forge.event_tickets.not_found")}</p>}
|
|
128
151
|
{c.event && (
|
|
129
152
|
<EventTicketsBody
|
|
130
153
|
c={c}
|
|
@@ -133,9 +156,20 @@ export function EventTickets({
|
|
|
133
156
|
renderPayment={renderPay}
|
|
134
157
|
loginPath={loginPath}
|
|
135
158
|
membershipPath={membershipPath}
|
|
136
|
-
|
|
137
|
-
//
|
|
138
|
-
|
|
159
|
+
hasAddons={hasAddons}
|
|
160
|
+
// Selection is safe in the cart now: close, and bring the
|
|
161
|
+
// freshly unlocked add-on grid into view so the buyer lands on
|
|
162
|
+
// the next step instead of wherever they left the page.
|
|
163
|
+
onAddedToCart={() => {
|
|
164
|
+
setOpen(false);
|
|
165
|
+
if (typeof document !== "undefined") {
|
|
166
|
+
setTimeout(() => {
|
|
167
|
+
document
|
|
168
|
+
.querySelector('[data-testid="addons-block"]')
|
|
169
|
+
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
170
|
+
}, 100);
|
|
171
|
+
}
|
|
172
|
+
}}
|
|
139
173
|
/>
|
|
140
174
|
)}
|
|
141
175
|
</DialogContent>
|
|
@@ -152,6 +186,7 @@ function EventTicketsBody({
|
|
|
152
186
|
fmt,
|
|
153
187
|
renderPayment,
|
|
154
188
|
onAddedToCart,
|
|
189
|
+
hasAddons,
|
|
155
190
|
loginPath,
|
|
156
191
|
membershipPath,
|
|
157
192
|
}: {
|
|
@@ -160,6 +195,7 @@ function EventTicketsBody({
|
|
|
160
195
|
fmt: (n: number) => string;
|
|
161
196
|
renderPayment?: (props: PaymentRenderProps) => ReactNode;
|
|
162
197
|
onAddedToCart?: () => void;
|
|
198
|
+
hasAddons?: boolean;
|
|
163
199
|
loginPath?: string;
|
|
164
200
|
membershipPath?: string;
|
|
165
201
|
}) {
|
|
@@ -173,6 +209,7 @@ function EventTicketsBody({
|
|
|
173
209
|
event={event}
|
|
174
210
|
fmt={fmt}
|
|
175
211
|
onAddedToCart={onAddedToCart}
|
|
212
|
+
hasAddons={hasAddons}
|
|
176
213
|
loginPath={loginPath}
|
|
177
214
|
membershipPath={membershipPath}
|
|
178
215
|
/>
|
|
@@ -232,6 +269,7 @@ function TicketStep({
|
|
|
232
269
|
event,
|
|
233
270
|
fmt,
|
|
234
271
|
onAddedToCart,
|
|
272
|
+
hasAddons,
|
|
235
273
|
loginPath,
|
|
236
274
|
membershipPath,
|
|
237
275
|
}: {
|
|
@@ -239,9 +277,11 @@ function TicketStep({
|
|
|
239
277
|
event: IEvent;
|
|
240
278
|
fmt: (n: number) => string;
|
|
241
279
|
onAddedToCart?: () => void;
|
|
280
|
+
hasAddons?: boolean;
|
|
242
281
|
loginPath?: string;
|
|
243
282
|
membershipPath?: string;
|
|
244
283
|
}) {
|
|
284
|
+
const t = useForgeT();
|
|
245
285
|
const theme = useForgeTheme();
|
|
246
286
|
// Inclusive stores caption ticket prices as tax-inclusive (display only).
|
|
247
287
|
const pricesIncludeTax = usePricesIncludeTax();
|
|
@@ -255,21 +295,55 @@ function TicketStep({
|
|
|
255
295
|
const urgency = (expiresAt?: string) => {
|
|
256
296
|
if (!expiresAt) return null;
|
|
257
297
|
const diff = new Date(expiresAt).getTime() - now;
|
|
258
|
-
if (diff <= 0)
|
|
298
|
+
if (diff <= 0)
|
|
299
|
+
return { expired: true, color: "#ef4444", alert: true, message: t("forge.event_tickets.expired_badge") };
|
|
259
300
|
const hours = Math.floor(diff / 3_600_000);
|
|
260
301
|
const minutes = Math.floor((diff % 3_600_000) / 60_000);
|
|
261
302
|
const totalMinutes = hours * 60 + minutes;
|
|
262
|
-
if (totalMinutes < 30)
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
303
|
+
if (totalMinutes < 30)
|
|
304
|
+
return {
|
|
305
|
+
expired: false,
|
|
306
|
+
color: "#dc2626",
|
|
307
|
+
alert: true,
|
|
308
|
+
message:
|
|
309
|
+
minutes !== 1
|
|
310
|
+
? t("forge.event_tickets.minutes_left_other", { count: minutes })
|
|
311
|
+
: t("forge.event_tickets.minutes_left_one", { count: minutes }),
|
|
312
|
+
};
|
|
313
|
+
if (totalMinutes < 60)
|
|
314
|
+
return { expired: false, color: "#f59e0b", alert: true, message: t("forge.event_tickets.less_than_hour") };
|
|
315
|
+
if (hours < 6)
|
|
316
|
+
return {
|
|
317
|
+
expired: false,
|
|
318
|
+
color: "#f97316",
|
|
319
|
+
alert: false,
|
|
320
|
+
message:
|
|
321
|
+
hours !== 1
|
|
322
|
+
? t("forge.event_tickets.expires_hours_soon_other", { count: hours })
|
|
323
|
+
: t("forge.event_tickets.expires_hours_soon_one", { count: hours }),
|
|
324
|
+
};
|
|
325
|
+
if (hours < 24)
|
|
326
|
+
return {
|
|
327
|
+
expired: false,
|
|
328
|
+
color: theme.colors.primary,
|
|
329
|
+
alert: false,
|
|
330
|
+
message: t("forge.event_tickets.expires_hours", { count: hours }),
|
|
331
|
+
};
|
|
266
332
|
const days = Math.floor(hours / 24);
|
|
267
|
-
return {
|
|
333
|
+
return {
|
|
334
|
+
expired: false,
|
|
335
|
+
color: theme.colors.primary,
|
|
336
|
+
alert: false,
|
|
337
|
+
message:
|
|
338
|
+
days !== 1
|
|
339
|
+
? t("forge.event_tickets.expires_days_other", { count: days })
|
|
340
|
+
: t("forge.event_tickets.expires_days_one", { count: days }),
|
|
341
|
+
};
|
|
268
342
|
};
|
|
269
343
|
|
|
270
344
|
const setQty = (ticket: ITicket, qty: number) => {
|
|
271
345
|
// `maxAddableTickets` subtracts `held` and returns 0 before a scheduled
|
|
272
|
-
// on-sale
|
|
346
|
+
// on-sale, the same two facts the server sells on. Computed off `now` so
|
|
273
347
|
// the cap opens up on the tick the on-sale time passes, with no reload.
|
|
274
348
|
//
|
|
275
349
|
// 2.6b adds back the seats held for THIS visitor by a waitlist offer.
|
|
@@ -283,14 +357,14 @@ function TicketStep({
|
|
|
283
357
|
|
|
284
358
|
return (
|
|
285
359
|
<div>
|
|
286
|
-
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 16 }}>
|
|
360
|
+
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 16 }}>{t("forge.event_tickets.select_title")}</h2>
|
|
287
361
|
{/* Renders nothing on an event with no coded tier (1.2). Here as well as
|
|
288
|
-
on the page because this modal is reachable on its own
|
|
289
|
-
and from the broadcast-pass prompt
|
|
362
|
+
on the page because this modal is reachable on its own (from an offer,
|
|
363
|
+
and from the broadcast-pass prompt) where there is no page-level box.
|
|
290
364
|
Both read the SAME stored code, so unlocking in one unlocks the other. */}
|
|
291
365
|
<PresaleCodeField presale={c.presale} style={{ marginBottom: 16 }} />
|
|
292
366
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
|
293
|
-
{event.tickets.length === 0 && <p style={{ opacity: 0.7 }}>
|
|
367
|
+
{event.tickets.length === 0 && <p style={{ opacity: 0.7 }}>{t("forge.event_tickets.no_tickets")}</p>}
|
|
294
368
|
{event.tickets.map((ticket) => {
|
|
295
369
|
const selectedQty = c.selectedTickets[ticket.id] ?? 0;
|
|
296
370
|
/**
|
|
@@ -299,7 +373,7 @@ function TicketStep({
|
|
|
299
373
|
* checkouts as available, so a fan in a rush was told "3 left",
|
|
300
374
|
* picked two, filled in the form and was refused at reserve.
|
|
301
375
|
*/
|
|
302
|
-
// 2.6b
|
|
376
|
+
// 2.6b: the offeree's own reservation is not "somebody else's
|
|
303
377
|
// checkout"; it is the seat they were emailed a deadline for.
|
|
304
378
|
const reserved = c.reservedForYou[ticket.id] ?? 0;
|
|
305
379
|
const seatState = ticketSeatState(ticket, reserved);
|
|
@@ -319,8 +393,8 @@ function TicketStep({
|
|
|
319
393
|
/**
|
|
320
394
|
* A members-only tier the buyer cannot have (S.4).
|
|
321
395
|
*
|
|
322
|
-
* The event-level gate applies to EVERY tier
|
|
323
|
-
* sells to a non-member"
|
|
396
|
+
* The event-level gate applies to EVERY tier ("no ticket to this show
|
|
397
|
+
* sells to a non-member"), so it is folded in here rather than drawn
|
|
324
398
|
* once at the top: a buyer reading one tier row must be able to tell
|
|
325
399
|
* from that row whether they can buy it.
|
|
326
400
|
*/
|
|
@@ -339,11 +413,13 @@ function TicketStep({
|
|
|
339
413
|
opacity: isSoldOut || isExpired ? 0.5 : 1,
|
|
340
414
|
}}
|
|
341
415
|
>
|
|
342
|
-
<div
|
|
416
|
+
<div
|
|
417
|
+
style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 8 }}
|
|
418
|
+
>
|
|
343
419
|
<div style={{ flex: 1 }}>
|
|
344
420
|
<h3 style={{ fontSize: 17, fontWeight: 600, marginBottom: 6 }}>{ticket.title}</h3>
|
|
345
421
|
{/* A hidden tier is only ever on screen because a code
|
|
346
|
-
revealed it
|
|
422
|
+
revealed it, so say so. Without the badge, a code that
|
|
347
423
|
unlocked a tier the buyer had not seen listed looks like it
|
|
348
424
|
did nothing. */}
|
|
349
425
|
{ticket.isHidden && (
|
|
@@ -365,7 +441,7 @@ function TicketStep({
|
|
|
365
441
|
}}
|
|
366
442
|
>
|
|
367
443
|
<KeyRound size={12} />
|
|
368
|
-
|
|
444
|
+
{t("forge.event_tickets.presale_badge")}
|
|
369
445
|
</div>
|
|
370
446
|
)}
|
|
371
447
|
{/* A tier that is announced but not yet buyable says WHEN,
|
|
@@ -417,23 +493,39 @@ function TicketStep({
|
|
|
417
493
|
)}
|
|
418
494
|
<div style={{ fontSize: 22, fontWeight: 700, color: theme.colors.primary }}>
|
|
419
495
|
{ticket.compareAtPrice != null && Number(ticket.compareAtPrice) > Number(ticket.price) && (
|
|
420
|
-
<span
|
|
496
|
+
<span
|
|
497
|
+
style={{
|
|
498
|
+
textDecoration: "line-through",
|
|
499
|
+
opacity: 0.6,
|
|
500
|
+
marginRight: 8,
|
|
501
|
+
fontWeight: 400,
|
|
502
|
+
fontSize: 16,
|
|
503
|
+
}}
|
|
504
|
+
>
|
|
421
505
|
{fmt(Number(ticket.compareAtPrice))}
|
|
422
506
|
</span>
|
|
423
507
|
)}
|
|
424
508
|
{/* On a PWYW tier `price` is the FLOOR, so it is labelled as
|
|
425
|
-
one
|
|
509
|
+
one. Showing it bare would read as a fixed price the
|
|
426
510
|
buyer is about to be charged. */}
|
|
427
511
|
{isPayWhatYouWant(ticket) && (
|
|
428
|
-
<span style={{ fontSize: 14, fontWeight: 500, opacity: 0.7, marginRight: 6 }}>
|
|
512
|
+
<span style={{ fontSize: 14, fontWeight: 500, opacity: 0.7, marginRight: 6 }}>
|
|
513
|
+
{t("forge.event_tickets.price_from")}
|
|
514
|
+
</span>
|
|
429
515
|
)}
|
|
430
|
-
<PriceDisplay
|
|
516
|
+
<PriceDisplay
|
|
517
|
+
amount={Number(ticket.price)}
|
|
518
|
+
pricesIncludeTax={pricesIncludeTax}
|
|
519
|
+
formatAmount={fmt}
|
|
520
|
+
mutedColor={theme.colors.text}
|
|
521
|
+
captionStyle={{ opacity: 0.65 }}
|
|
522
|
+
/>
|
|
431
523
|
</div>
|
|
432
524
|
</div>
|
|
433
525
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
434
526
|
<button
|
|
435
527
|
onClick={() => setQty(ticket, selectedQty - 1)}
|
|
436
|
-
// Never disabled by availability
|
|
528
|
+
// Never disabled by availability, only by there being
|
|
437
529
|
// nothing to remove. A buyer holding two seats on a tier
|
|
438
530
|
// that has just gone unavailable must still be able to put
|
|
439
531
|
// them back; disabling this would trap the cart.
|
|
@@ -450,7 +542,7 @@ function TicketStep({
|
|
|
450
542
|
// A gated tier cannot be added, nor one whose on-sale time
|
|
451
543
|
// has not arrived, nor one whose remaining seats are inside
|
|
452
544
|
// somebody else's checkout. Not because the client is the
|
|
453
|
-
// authority
|
|
545
|
+
// authority (`createOrder` refuses all three regardless)
|
|
454
546
|
// but because letting a buyer build a cart the server will
|
|
455
547
|
// not sell is precisely the "discoverable only by failing"
|
|
456
548
|
// journey this change exists to end.
|
|
@@ -461,8 +553,8 @@ function TicketStep({
|
|
|
461
553
|
</button>
|
|
462
554
|
</div>
|
|
463
555
|
</div>
|
|
464
|
-
{/* The amount box appears only once a seat is actually selected
|
|
465
|
-
|
|
556
|
+
{/* The amount box appears only once a seat is actually selected.
|
|
557
|
+
Before that there is nothing to price, and a row of empty
|
|
466
558
|
inputs down the tier list reads as a form to fill in rather
|
|
467
559
|
than a choice to make. */}
|
|
468
560
|
{isPayWhatYouWant(ticket) && selectedQty > 0 && !isSoldOut && !isExpired && !isScheduled && (
|
|
@@ -479,13 +571,17 @@ function TicketStep({
|
|
|
479
571
|
<p dangerouslySetInnerHTML={{ __html: ticket.description }} style={{ marginTop: 12, fontSize: 14 }} />
|
|
480
572
|
)}
|
|
481
573
|
{/* Named on the TIER, before the buyer has typed anything. The
|
|
482
|
-
tier stays in the list on purpose
|
|
483
|
-
|
|
574
|
+
tier stays in the list on purpose (hiding it kills the upsell)
|
|
575
|
+
so this is what turns "visible but mysteriously unbuyable"
|
|
484
576
|
into an offer with a way in. */}
|
|
485
577
|
<MembershipGateNotice
|
|
486
578
|
gate={gate}
|
|
487
579
|
variant="badge"
|
|
488
|
-
itemLabel={
|
|
580
|
+
itemLabel={
|
|
581
|
+
event.membershipGate && !event.membershipGate.allowed
|
|
582
|
+
? t("forge.event_tickets.item_label_event")
|
|
583
|
+
: t("forge.event_tickets.item_label_ticket")
|
|
584
|
+
}
|
|
489
585
|
loginPath={loginPath}
|
|
490
586
|
membershipPath={membershipPath}
|
|
491
587
|
/>
|
|
@@ -524,7 +620,7 @@ function TicketStep({
|
|
|
524
620
|
}}
|
|
525
621
|
>
|
|
526
622
|
<AlertCircle size={16} />
|
|
527
|
-
|
|
623
|
+
{t("forge.event_tickets.expired_notice")}
|
|
528
624
|
</div>
|
|
529
625
|
)}
|
|
530
626
|
</div>
|
|
@@ -533,21 +629,43 @@ function TicketStep({
|
|
|
533
629
|
</div>
|
|
534
630
|
|
|
535
631
|
{c.error && <p style={{ color: "#ef4444", fontSize: 14, marginTop: 12 }}>{c.error}</p>}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
onClick: () => {
|
|
632
|
+
{/* Two shapes of this bar. With add-ons on the page, the PRIMARY action
|
|
633
|
+
is the cart: that is the only path that unlocks the add-on grid, so
|
|
634
|
+
it has to be the happy path, with a direct tickets-only checkout as
|
|
635
|
+
the secondary exit. Without add-ons the original flow is untouched:
|
|
636
|
+
Continue goes straight to details, the cart is the secondary exit. */}
|
|
637
|
+
{hasAddons ? (
|
|
638
|
+
<ActionBar
|
|
639
|
+
c={c}
|
|
640
|
+
fmt={fmt}
|
|
641
|
+
onNext={() => {
|
|
547
642
|
if (c.addTicketsToCart()) onAddedToCart?.();
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
|
|
643
|
+
}}
|
|
644
|
+
nextLabel={
|
|
645
|
+
c.ticketsInCart ? t("forge.event_tickets.update_cart_addons") : t("forge.event_tickets.continue_addons")
|
|
646
|
+
}
|
|
647
|
+
nextTestId="tickets-add-to-cart"
|
|
648
|
+
secondary={{
|
|
649
|
+
label: t("forge.event_tickets.checkout_tickets_only"),
|
|
650
|
+
onClick: c.goToDetails,
|
|
651
|
+
testId: "tickets-checkout-only",
|
|
652
|
+
}}
|
|
653
|
+
/>
|
|
654
|
+
) : (
|
|
655
|
+
<ActionBar
|
|
656
|
+
c={c}
|
|
657
|
+
fmt={fmt}
|
|
658
|
+
onNext={c.goToDetails}
|
|
659
|
+
nextLabel={t("forge.event_tickets.continue")}
|
|
660
|
+
secondary={{
|
|
661
|
+
label: c.ticketsInCart ? t("forge.event_tickets.update_cart") : t("forge.event_tickets.add_to_cart"),
|
|
662
|
+
onClick: () => {
|
|
663
|
+
if (c.addTicketsToCart()) onAddedToCart?.();
|
|
664
|
+
},
|
|
665
|
+
testId: "tickets-add-to-cart",
|
|
666
|
+
}}
|
|
667
|
+
/>
|
|
668
|
+
)}
|
|
551
669
|
</div>
|
|
552
670
|
);
|
|
553
671
|
}
|
|
@@ -566,6 +684,7 @@ function DetailsStep({
|
|
|
566
684
|
loginPath?: string;
|
|
567
685
|
membershipPath?: string;
|
|
568
686
|
}) {
|
|
687
|
+
const t = useForgeT();
|
|
569
688
|
const theme = useForgeTheme();
|
|
570
689
|
const [confirmEmail, setConfirmEmail] = useState("");
|
|
571
690
|
const [answers, setAnswers] = useState<Record<string, string>>({});
|
|
@@ -581,30 +700,57 @@ function DetailsStep({
|
|
|
581
700
|
|
|
582
701
|
const onContinue = () => {
|
|
583
702
|
setLocalError(null);
|
|
584
|
-
if (!c.firstName.trim() || !c.lastName.trim()) return setLocalError("
|
|
585
|
-
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email)) return setLocalError("
|
|
586
|
-
if (c.email !== confirmEmail) return setLocalError("
|
|
703
|
+
if (!c.firstName.trim() || !c.lastName.trim()) return setLocalError(t("forge.event_tickets.error_name"));
|
|
704
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email)) return setLocalError(t("forge.event_tickets.error_email"));
|
|
705
|
+
if (c.email !== confirmEmail) return setLocalError(t("forge.event_tickets.error_email_mismatch"));
|
|
587
706
|
for (const q of questions) {
|
|
588
|
-
if (!q.optional && !(answers[q.id] ?? "").trim())
|
|
707
|
+
if (!q.optional && !(answers[q.id] ?? "").trim())
|
|
708
|
+
return setLocalError(t("forge.event_tickets.error_answer_required", { question: q.question }));
|
|
589
709
|
}
|
|
590
710
|
c.continueToPayment();
|
|
591
711
|
};
|
|
592
712
|
|
|
593
713
|
return (
|
|
594
714
|
<div>
|
|
595
|
-
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 16 }}>
|
|
715
|
+
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 16 }}>{t("forge.event_tickets.details_title")}</h2>
|
|
596
716
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
597
717
|
<div style={{ display: "flex", gap: 8 }}>
|
|
598
|
-
<input
|
|
599
|
-
|
|
718
|
+
<input
|
|
719
|
+
placeholder={t("forge.event_tickets.first_name_placeholder")}
|
|
720
|
+
value={c.firstName}
|
|
721
|
+
onChange={(e) => c.setFirstName(e.target.value)}
|
|
722
|
+
style={inputStyle(theme)}
|
|
723
|
+
/>
|
|
724
|
+
<input
|
|
725
|
+
placeholder={t("forge.event_tickets.last_name_placeholder")}
|
|
726
|
+
value={c.lastName}
|
|
727
|
+
onChange={(e) => c.setLastName(e.target.value)}
|
|
728
|
+
style={inputStyle(theme)}
|
|
729
|
+
/>
|
|
600
730
|
</div>
|
|
601
731
|
<div>
|
|
602
|
-
<input
|
|
603
|
-
|
|
732
|
+
<input
|
|
733
|
+
placeholder={t("forge.event_tickets.email_placeholder")}
|
|
734
|
+
type="email"
|
|
735
|
+
value={c.email}
|
|
736
|
+
onChange={(e) => c.setEmail(e.target.value)}
|
|
737
|
+
style={inputStyle(theme)}
|
|
738
|
+
/>
|
|
739
|
+
{c.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email) && (
|
|
740
|
+
<FieldError>{t("forge.event_tickets.invalid_email")}</FieldError>
|
|
741
|
+
)}
|
|
604
742
|
</div>
|
|
605
743
|
<div>
|
|
606
|
-
<input
|
|
607
|
-
|
|
744
|
+
<input
|
|
745
|
+
placeholder={t("forge.event_tickets.confirm_email_placeholder")}
|
|
746
|
+
type="email"
|
|
747
|
+
value={confirmEmail}
|
|
748
|
+
onChange={(e) => setConfirmEmail(e.target.value)}
|
|
749
|
+
style={inputStyle(theme)}
|
|
750
|
+
/>
|
|
751
|
+
{confirmEmail && c.email !== confirmEmail && (
|
|
752
|
+
<FieldError>{t("forge.event_tickets.email_mismatch")}</FieldError>
|
|
753
|
+
)}
|
|
608
754
|
</div>
|
|
609
755
|
|
|
610
756
|
{questions.map((q) => {
|
|
@@ -616,8 +762,12 @@ function DetailsStep({
|
|
|
616
762
|
{!q.optional && <span style={{ color: "#ef4444" }}> *</span>}
|
|
617
763
|
</span>
|
|
618
764
|
{q.options && q.options.length > 0 ? (
|
|
619
|
-
<select
|
|
620
|
-
|
|
765
|
+
<select
|
|
766
|
+
value={answers[q.id] ?? ""}
|
|
767
|
+
onChange={(e) => onAnswer(q.id, e.target.value)}
|
|
768
|
+
style={inputStyle(theme)}
|
|
769
|
+
>
|
|
770
|
+
<option value="">{t("forge.event_tickets.select_option")}</option>
|
|
621
771
|
{q.options.map((opt) => (
|
|
622
772
|
<option key={opt} value={opt}>
|
|
623
773
|
{opt}
|
|
@@ -625,15 +775,19 @@ function DetailsStep({
|
|
|
625
775
|
))}
|
|
626
776
|
</select>
|
|
627
777
|
) : (
|
|
628
|
-
<input
|
|
778
|
+
<input
|
|
779
|
+
value={answers[q.id] ?? ""}
|
|
780
|
+
onChange={(e) => onAnswer(q.id, e.target.value)}
|
|
781
|
+
style={inputStyle(theme)}
|
|
782
|
+
/>
|
|
629
783
|
)}
|
|
630
|
-
{missing && <FieldError>
|
|
784
|
+
{missing && <FieldError>{t("forge.event_tickets.field_required")}</FieldError>}
|
|
631
785
|
</label>
|
|
632
786
|
);
|
|
633
787
|
})}
|
|
634
788
|
|
|
635
789
|
{/* Tickets have no apply-coupon endpoint, so the code is carried into
|
|
636
|
-
the order that "Continue" creates
|
|
790
|
+
the order that "Continue" creates, hence `deferred`. Its result is
|
|
637
791
|
what draws the discount line on the payment step. */}
|
|
638
792
|
{c.subTotal > 0 && (
|
|
639
793
|
<div style={{ marginTop: 4, paddingTop: 12, borderTop: `1px solid ${a(theme.colors.primary, 0.13)}` }}>
|
|
@@ -643,14 +797,14 @@ function DetailsStep({
|
|
|
643
797
|
|
|
644
798
|
{/* The refusal path. It should be unreachable now that the tier itself
|
|
645
799
|
is badged, but a membership can lapse mid-checkout and a page can be
|
|
646
|
-
served from cache
|
|
800
|
+
served from cache, and when it happens the buyer gets the tier's
|
|
647
801
|
name and a way in, not a red line reading "Something went wrong."
|
|
648
802
|
`c.error` is deliberately not set for this case, so the two cannot
|
|
649
803
|
both draw. */}
|
|
650
804
|
<MembershipGateNotice
|
|
651
805
|
refusal={c.gateRefusal}
|
|
652
806
|
variant="card"
|
|
653
|
-
itemLabel="
|
|
807
|
+
itemLabel={t("forge.event_tickets.item_label_ticket")}
|
|
654
808
|
loginPath={loginPath}
|
|
655
809
|
membershipPath={membershipPath}
|
|
656
810
|
style={{ marginTop: 4 }}
|
|
@@ -659,10 +813,9 @@ function DetailsStep({
|
|
|
659
813
|
{c.attendeeSlots.length > 0 && (
|
|
660
814
|
<div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 4 }}>
|
|
661
815
|
<div>
|
|
662
|
-
<h3 style={{ fontSize: 16, fontWeight: 700 }}>
|
|
816
|
+
<h3 style={{ fontSize: 16, fontWeight: 700 }}>{t("forge.event_tickets.attendees_title")}</h3>
|
|
663
817
|
<p style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>
|
|
664
|
-
|
|
665
|
-
actually turns up.
|
|
818
|
+
{t("forge.event_tickets.attendees_description")}
|
|
666
819
|
</p>
|
|
667
820
|
</div>
|
|
668
821
|
{c.attendeeSlots.map((slot) => (
|
|
@@ -672,10 +825,10 @@ function DetailsStep({
|
|
|
672
825
|
>
|
|
673
826
|
<span>
|
|
674
827
|
{slot.label}
|
|
675
|
-
{slot.isBuyer && <span style={{ opacity: 0.6 }}> (
|
|
828
|
+
{slot.isBuyer && <span style={{ opacity: 0.6 }}> {t("forge.event_tickets.attendee_is_you")}</span>}
|
|
676
829
|
</span>
|
|
677
830
|
<input
|
|
678
|
-
placeholder="
|
|
831
|
+
placeholder={t("forge.event_tickets.attendee_name_placeholder")}
|
|
679
832
|
value={slot.value}
|
|
680
833
|
onChange={(e) => c.setAttendee(slot.ticketId, slot.index, e.target.value)}
|
|
681
834
|
style={inputStyle(theme)}
|
|
@@ -692,7 +845,19 @@ function DetailsStep({
|
|
|
692
845
|
fmt={fmt}
|
|
693
846
|
onBack={() => c.setStep("tickets")}
|
|
694
847
|
onNext={onContinue}
|
|
695
|
-
|
|
848
|
+
// Nothing to pay means no payment step follows, so promising one is a
|
|
849
|
+
// lie the next screen immediately contradicts. Read off `totalAmount`
|
|
850
|
+
// rather than the ticket prices because it already carries the booking
|
|
851
|
+
// fee and any discount: a free ticket with a FLAT booking fee still
|
|
852
|
+
// costs money and still goes to payment, and a 100%-off code on a paid
|
|
853
|
+
// ticket does not.
|
|
854
|
+
nextLabel={
|
|
855
|
+
c.isProcessing
|
|
856
|
+
? t("forge.event_tickets.processing")
|
|
857
|
+
: c.totalAmount > 0
|
|
858
|
+
? t("forge.event_tickets.continue_payment")
|
|
859
|
+
: t("forge.event_tickets.confirm")
|
|
860
|
+
}
|
|
696
861
|
nextDisabled={c.isProcessing}
|
|
697
862
|
/>
|
|
698
863
|
</div>
|
|
@@ -709,6 +874,7 @@ function PaymentStep({
|
|
|
709
874
|
fmt: (n: number) => string;
|
|
710
875
|
renderPayment?: (props: PaymentRenderProps) => ReactNode;
|
|
711
876
|
}) {
|
|
877
|
+
const t = useForgeT();
|
|
712
878
|
const theme = useForgeTheme();
|
|
713
879
|
// Authoritative tax quote from start-payment: exclusive stores get a "Tax"
|
|
714
880
|
// row (already contained in the charged total below); inclusive stores get a
|
|
@@ -716,10 +882,10 @@ function PaymentStep({
|
|
|
716
882
|
const taxSummary = summarizeTaxQuote(c.taxQuote);
|
|
717
883
|
return (
|
|
718
884
|
<div>
|
|
719
|
-
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>
|
|
720
|
-
<p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>
|
|
721
|
-
{/* The reservation clock. Event ticket holds are taken unconditionally
|
|
722
|
-
no feature switch
|
|
885
|
+
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>{t("forge.event_tickets.payment_title")}</h2>
|
|
886
|
+
<p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>{t("forge.event_tickets.payment_subtitle")}</p>
|
|
887
|
+
{/* The reservation clock. Event ticket holds are taken unconditionally
|
|
888
|
+
(no feature switch), so this is the one checkout where the countdown
|
|
723
889
|
fires for every profile, and it was the one that did not render it. */}
|
|
724
890
|
<HoldNotice hold={c.hold} expiredMessage={c.error ?? undefined} style={{ marginBottom: 16 }} />
|
|
725
891
|
<div
|
|
@@ -730,7 +896,7 @@ function PaymentStep({
|
|
|
730
896
|
marginBottom: 16,
|
|
731
897
|
}}
|
|
732
898
|
>
|
|
733
|
-
{/* What the code actually did, from the order response
|
|
899
|
+
{/* What the code actually did, from the order response, never a total
|
|
734
900
|
that silently shrank. Automatic (no-code) discounts land here too. */}
|
|
735
901
|
<DiscountSummaryLines
|
|
736
902
|
subTotal={c.subTotal}
|
|
@@ -755,27 +921,29 @@ function PaymentStep({
|
|
|
755
921
|
fontFamily: theme.fontFamily,
|
|
756
922
|
}}
|
|
757
923
|
>
|
|
758
|
-
|
|
924
|
+
{t("forge.event_tickets.remove_discount")}
|
|
759
925
|
</button>
|
|
760
926
|
</div>
|
|
761
927
|
)}
|
|
762
928
|
{/* Above the tax row, because tax is charged ON the fee: the order total
|
|
763
929
|
the tax quote priced already contained it. By this step the number is
|
|
764
|
-
the SERVER's own
|
|
930
|
+
the SERVER's own (what it wrote to the order), so the itemisation
|
|
765
931
|
adds up to the amount that will actually be charged. */}
|
|
766
932
|
<BookingFeeLine c={c} fmt={fmt} />
|
|
767
933
|
{taxSummary?.mode === "exclusive" && (
|
|
768
934
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
|
|
769
|
-
<span style={{ fontSize: 14, opacity: 0.75 }}>
|
|
935
|
+
<span style={{ fontSize: 14, opacity: 0.75 }}>{t("forge.event_tickets.tax")}</span>
|
|
770
936
|
<span style={{ fontSize: 14, opacity: 0.75 }}>{taxSummary.formattedTax}</span>
|
|
771
937
|
</div>
|
|
772
938
|
)}
|
|
773
939
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
774
|
-
<span style={{ fontSize: 16 }}>
|
|
940
|
+
<span style={{ fontSize: 16 }}>{t("forge.event_tickets.total_amount")}</span>
|
|
775
941
|
<span style={{ fontSize: 22, fontWeight: 700, color: theme.colors.primary, textAlign: "right" }}>
|
|
776
942
|
{fmt(c.totalAmount)}
|
|
777
943
|
{taxSummary?.mode === "inclusive" && (
|
|
778
|
-
<span
|
|
944
|
+
<span
|
|
945
|
+
style={{ display: "block", fontSize: 12, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}
|
|
946
|
+
>
|
|
779
947
|
{taxSummary.label}
|
|
780
948
|
</span>
|
|
781
949
|
)}
|
|
@@ -786,19 +954,22 @@ function PaymentStep({
|
|
|
786
954
|
so it is checked BEFORE the "not configured" branch. */}
|
|
787
955
|
{c.isPaystack ? (
|
|
788
956
|
c.canOpenPaystack ? (
|
|
789
|
-
<PaystackPayButton
|
|
957
|
+
<PaystackPayButton
|
|
958
|
+
onPay={c.openPaystackCheckout}
|
|
959
|
+
label={t("forge.event_tickets.pay", { amount: fmt(c.totalAmount) })}
|
|
960
|
+
/>
|
|
790
961
|
) : (
|
|
791
|
-
<p style={{ opacity: 0.7 }}>
|
|
962
|
+
<p style={{ opacity: 0.7 }}>{t("forge.event_tickets.preparing_payment")}</p>
|
|
792
963
|
)
|
|
793
964
|
) : !renderPayment ? (
|
|
794
|
-
<p style={{ color: "#ef4444", fontSize: 14 }}>
|
|
965
|
+
<p style={{ color: "#ef4444", fontSize: 14 }}>{t("forge.event_tickets.payment_not_configured")}</p>
|
|
795
966
|
) : c.clientSecret ? (
|
|
796
967
|
renderPayment({ clientSecret: c.clientSecret, returnUrl: c.returnUrl, amount: c.totalAmount, mode: "redirect" })
|
|
797
968
|
) : (
|
|
798
|
-
<p style={{ opacity: 0.7 }}>
|
|
969
|
+
<p style={{ opacity: 0.7 }}>{t("forge.event_tickets.preparing_payment")}</p>
|
|
799
970
|
)}
|
|
800
971
|
<button onClick={() => c.setStep("details")} style={{ ...secondaryButtonStyle(theme), marginTop: 16 }}>
|
|
801
|
-
|
|
972
|
+
{t("forge.event_tickets.back")}
|
|
802
973
|
</button>
|
|
803
974
|
</div>
|
|
804
975
|
);
|
|
@@ -813,7 +984,7 @@ function FieldError({ children }: { children: ReactNode }) {
|
|
|
813
984
|
*
|
|
814
985
|
* This is the line that did not exist. The fee was added to `total_amount` on
|
|
815
986
|
* the server and named on no surface, so a buyer choosing £50 of tickets was
|
|
816
|
-
* shown "Total £50" and charged £52.75
|
|
987
|
+
* shown "Total £50" and charged £52.75, and even after a discount, where
|
|
817
988
|
* `Subtotal` and `Discount` were both drawn, the total did not reconcile with
|
|
818
989
|
* them and nothing explained why. A mandatory charge disclosed only on the
|
|
819
990
|
* receipt is what the FTC's junk-fee rule, the CMA's guidance and the EU
|
|
@@ -821,7 +992,7 @@ function FieldError({ children }: { children: ReactNode }) {
|
|
|
821
992
|
*
|
|
822
993
|
* Renders NOTHING when the fee is zero. The payload always arrives (so callers
|
|
823
994
|
* can rely on its shape), which means "this artist charges no fee" comes down as
|
|
824
|
-
* a zero
|
|
995
|
+
* a zero, and a "+£0.00 booking fee" row would invent a charge that does not
|
|
825
996
|
* exist and hint that one might appear later. There is nothing to disclose when
|
|
826
997
|
* nothing is charged.
|
|
827
998
|
*
|
|
@@ -831,6 +1002,7 @@ function FieldError({ children }: { children: ReactNode }) {
|
|
|
831
1002
|
* ON.
|
|
832
1003
|
*/
|
|
833
1004
|
function BookingFeeLine({ c, fmt }: { c: Checkout; fmt: (n: number) => string }) {
|
|
1005
|
+
const t = useForgeT();
|
|
834
1006
|
const theme = useForgeTheme();
|
|
835
1007
|
if (!(c.bookingFeeAmount > 0)) return null;
|
|
836
1008
|
const discountDrewSubtotal = c.coupon.discountAmount > 0;
|
|
@@ -842,15 +1014,12 @@ function BookingFeeLine({ c, fmt }: { c: Checkout; fmt: (n: number) => string })
|
|
|
842
1014
|
>
|
|
843
1015
|
{!discountDrewSubtotal && (
|
|
844
1016
|
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 14 }}>
|
|
845
|
-
<span style={{ opacity: 0.75 }}>
|
|
1017
|
+
<span style={{ opacity: 0.75 }}>{t("forge.event_tickets.subtotal")}</span>
|
|
846
1018
|
<span style={{ opacity: 0.75 }}>{fmt(c.subTotal)}</span>
|
|
847
1019
|
</div>
|
|
848
1020
|
)}
|
|
849
|
-
<div
|
|
850
|
-
|
|
851
|
-
style={{ display: "flex", justifyContent: "space-between", fontSize: 14 }}
|
|
852
|
-
>
|
|
853
|
-
<span style={{ color: theme.colors.text, opacity: 0.75 }}>Booking fee</span>
|
|
1021
|
+
<div data-testid="booking-fee-line" style={{ display: "flex", justifyContent: "space-between", fontSize: 14 }}>
|
|
1022
|
+
<span style={{ color: theme.colors.text, opacity: 0.75 }}>{t("forge.event_tickets.booking_fee")}</span>
|
|
854
1023
|
<span style={{ color: theme.colors.text, opacity: 0.75 }}>{fmt(c.bookingFeeAmount)}</span>
|
|
855
1024
|
</div>
|
|
856
1025
|
</div>
|
|
@@ -865,6 +1034,7 @@ function ActionBar({
|
|
|
865
1034
|
onNext,
|
|
866
1035
|
nextLabel,
|
|
867
1036
|
nextDisabled,
|
|
1037
|
+
nextTestId,
|
|
868
1038
|
secondary,
|
|
869
1039
|
}: {
|
|
870
1040
|
c: Checkout;
|
|
@@ -873,9 +1043,13 @@ function ActionBar({
|
|
|
873
1043
|
onNext: () => void;
|
|
874
1044
|
nextLabel: string;
|
|
875
1045
|
nextDisabled?: boolean;
|
|
876
|
-
/**
|
|
877
|
-
|
|
1046
|
+
/** Test id for the primary button; the caller says which button is which
|
|
1047
|
+
action, because add-to-cart moves between slots depending on add-ons. */
|
|
1048
|
+
nextTestId?: string;
|
|
1049
|
+
/** Extra exit shown next to Back on the selection step. */
|
|
1050
|
+
secondary?: { label: string; onClick: () => void; testId?: string };
|
|
878
1051
|
}) {
|
|
1052
|
+
const t = useForgeT();
|
|
879
1053
|
const theme = useForgeTheme();
|
|
880
1054
|
return (
|
|
881
1055
|
<div style={{ marginTop: 24 }}>
|
|
@@ -894,25 +1068,34 @@ function ActionBar({
|
|
|
894
1068
|
fmt={fmt}
|
|
895
1069
|
/>
|
|
896
1070
|
{/* `Total` below is the figure the buyer reads as final while they are
|
|
897
|
-
still choosing quantities
|
|
1071
|
+
still choosing quantities, so the fee has to be named HERE, not
|
|
898
1072
|
three screens later. */}
|
|
899
1073
|
<BookingFeeLine c={c} fmt={fmt} />
|
|
900
1074
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
901
|
-
<span style={{ fontWeight: 600 }}>
|
|
1075
|
+
<span style={{ fontWeight: 600 }}>{t("forge.event_tickets.total")}</span>
|
|
902
1076
|
<span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>{fmt(c.totalAmount)}</span>
|
|
903
1077
|
</div>
|
|
904
1078
|
</div>
|
|
905
1079
|
<div style={{ display: "flex", gap: 8, justifyContent: "space-between" }}>
|
|
906
1080
|
{secondary ? (
|
|
907
|
-
<button data-testid=
|
|
1081
|
+
<button data-testid={secondary.testId} onClick={secondary.onClick} style={secondaryButtonStyle(theme)}>
|
|
908
1082
|
{secondary.label}
|
|
909
1083
|
</button>
|
|
910
1084
|
) : (
|
|
911
|
-
<button
|
|
912
|
-
|
|
1085
|
+
<button
|
|
1086
|
+
onClick={onBack}
|
|
1087
|
+
disabled={!onBack}
|
|
1088
|
+
style={{ ...secondaryButtonStyle(theme), opacity: onBack ? 1 : 0.4 }}
|
|
1089
|
+
>
|
|
1090
|
+
{t("forge.event_tickets.back")}
|
|
913
1091
|
</button>
|
|
914
1092
|
)}
|
|
915
|
-
<button
|
|
1093
|
+
<button
|
|
1094
|
+
data-testid={nextTestId}
|
|
1095
|
+
onClick={onNext}
|
|
1096
|
+
disabled={nextDisabled}
|
|
1097
|
+
style={{ ...primaryButtonStyle(theme), opacity: nextDisabled ? 0.6 : 1 }}
|
|
1098
|
+
>
|
|
916
1099
|
{nextLabel}
|
|
917
1100
|
</button>
|
|
918
1101
|
</div>
|
|
@@ -945,7 +1128,7 @@ const closeButtonStyle = (t: ForgeTheme): CSSProperties => ({
|
|
|
945
1128
|
* to the floor on every keystroke), and "1" on the way to "15" must not be
|
|
946
1129
|
* clamped up to the minimum the instant it is typed.
|
|
947
1130
|
*
|
|
948
|
-
* Correction happens on BLUR, when the buyer has finished
|
|
1131
|
+
* Correction happens on BLUR, when the buyer has finished, and the server
|
|
949
1132
|
* clamps again regardless, so nothing here is load-bearing for money.
|
|
950
1133
|
*/
|
|
951
1134
|
function PwywAmountField({
|
|
@@ -963,12 +1146,13 @@ function PwywAmountField({
|
|
|
963
1146
|
fmt: (amount: number) => string;
|
|
964
1147
|
theme: ForgeTheme;
|
|
965
1148
|
}) {
|
|
1149
|
+
const t = useForgeT();
|
|
966
1150
|
const floor = Number(ticket.price);
|
|
967
1151
|
const maximum = pwywMaximum(ticket);
|
|
968
1152
|
const suggested = pwywDefaultAmount(ticket);
|
|
969
1153
|
const [draft, setDraft] = useState<string>(String(value ?? suggested));
|
|
970
1154
|
|
|
971
|
-
// Follow the tier's own default when the buyer hasn't typed anything
|
|
1155
|
+
// Follow the tier's own default when the buyer hasn't typed anything: an
|
|
972
1156
|
// operator's suggested amount arriving late (or the selection being cleared
|
|
973
1157
|
// and remade) should re-seed the box rather than leave a stale figure.
|
|
974
1158
|
useEffect(() => {
|
|
@@ -993,15 +1177,13 @@ function PwywAmountField({
|
|
|
993
1177
|
|
|
994
1178
|
return (
|
|
995
1179
|
<div style={{ marginTop: 12 }}>
|
|
996
|
-
<label
|
|
997
|
-
|
|
998
|
-
style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 6 }}
|
|
999
|
-
>
|
|
1000
|
-
Name your price
|
|
1180
|
+
<label htmlFor={`pwyw-${ticket.id}`} style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 6 }}>
|
|
1181
|
+
{t("forge.event_tickets.pwyw_label")}
|
|
1001
1182
|
<span style={{ fontWeight: 400, opacity: 0.7 }}>
|
|
1002
1183
|
{" "}
|
|
1003
|
-
|
|
1004
|
-
|
|
1184
|
+
{maximum != null
|
|
1185
|
+
? t("forge.event_tickets.pwyw_range_min_max", { min: fmt(floor), max: fmt(maximum) })
|
|
1186
|
+
: t("forge.event_tickets.pwyw_range_min", { min: fmt(floor) })}
|
|
1005
1187
|
</span>
|
|
1006
1188
|
</label>
|
|
1007
1189
|
<input
|
|
@@ -1018,19 +1200,23 @@ function PwywAmountField({
|
|
|
1018
1200
|
/>
|
|
1019
1201
|
<p style={{ fontSize: 12, opacity: 0.7, marginTop: 6 }}>
|
|
1020
1202
|
{belowFloor
|
|
1021
|
-
?
|
|
1203
|
+
? t("forge.event_tickets.pwyw_below_min", { min: fmt(floor) })
|
|
1022
1204
|
: aboveMax
|
|
1023
|
-
?
|
|
1205
|
+
? t("forge.event_tickets.pwyw_above_max", { max: fmt(maximum!) })
|
|
1024
1206
|
: quantity > 1
|
|
1025
|
-
?
|
|
1026
|
-
|
|
1207
|
+
? t("forge.event_tickets.pwyw_each", {
|
|
1208
|
+
amount: fmt(effective),
|
|
1209
|
+
total: fmt(effective * quantity),
|
|
1210
|
+
count: quantity,
|
|
1211
|
+
})
|
|
1212
|
+
: t("forge.event_tickets.pwyw_per_ticket")}
|
|
1027
1213
|
</p>
|
|
1028
1214
|
</div>
|
|
1029
1215
|
);
|
|
1030
1216
|
}
|
|
1031
1217
|
|
|
1032
1218
|
/**
|
|
1033
|
-
* The inline banner under a tier row
|
|
1219
|
+
* The inline banner under a tier row, same shape as the expired one already
|
|
1034
1220
|
* below it, so "why can't I buy this?" always answers itself in one place and
|
|
1035
1221
|
* one visual language, whichever the reason.
|
|
1036
1222
|
*/
|