@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
|
@@ -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";
|
|
@@ -42,7 +43,7 @@ export interface EventTicketsProps {
|
|
|
42
43
|
triggerText?: string;
|
|
43
44
|
/** Format a numeric amount for display. */
|
|
44
45
|
formatAmount?: (amount: number) => string;
|
|
45
|
-
/** Optional payment renderer
|
|
46
|
+
/** Optional payment renderer. Falls back to a registered <ForgePaymentProvider>. */
|
|
46
47
|
renderPayment?: (props: PaymentRenderProps) => ReactNode;
|
|
47
48
|
/** Stripe return URL for a PAID purchase. Default `/i/events/:slug/finalise`. */
|
|
48
49
|
finalisePath?: (slug: string, orderId: string) => string;
|
|
@@ -53,21 +54,25 @@ export interface EventTicketsProps {
|
|
|
53
54
|
* code sites mount theirs under `/i`. The sign-in returns them here.
|
|
54
55
|
*/
|
|
55
56
|
loginPath?: string;
|
|
56
|
-
/** 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. */
|
|
57
58
|
membershipPath?: string;
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
/** Append an alpha to a 6-digit hex color (mirrors frontend-shared addAlphaToHexCode). */
|
|
61
|
-
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");
|
|
62
67
|
|
|
63
68
|
/**
|
|
64
|
-
* Themed event ticket purchase
|
|
69
|
+
* Themed event ticket purchase: a "Buy tickets" trigger + modal running the
|
|
65
70
|
* three-step flow (tickets → details → payment) on `useEventCheckout`. Port of
|
|
66
71
|
* the client app's EventTickets modal, styled from `ForgeThemeProvider`.
|
|
67
72
|
*/
|
|
68
73
|
export function EventTickets({
|
|
69
74
|
slug,
|
|
70
|
-
triggerText
|
|
75
|
+
triggerText,
|
|
71
76
|
formatAmount,
|
|
72
77
|
renderPayment,
|
|
73
78
|
finalisePath,
|
|
@@ -75,6 +80,7 @@ export function EventTickets({
|
|
|
75
80
|
loginPath,
|
|
76
81
|
membershipPath,
|
|
77
82
|
}: EventTicketsProps) {
|
|
83
|
+
const t = useForgeT();
|
|
78
84
|
const theme = useForgeTheme();
|
|
79
85
|
const registered = usePaymentRenderer();
|
|
80
86
|
const fmt = useAmountFormatter(formatAmount);
|
|
@@ -99,7 +105,7 @@ export function EventTickets({
|
|
|
99
105
|
|
|
100
106
|
const tryClose = (next: boolean) => {
|
|
101
107
|
if (!next && c.step !== "tickets" && typeof window !== "undefined") {
|
|
102
|
-
if (!window.confirm("
|
|
108
|
+
if (!window.confirm(t("forge.event_tickets.leave_confirm"))) return;
|
|
103
109
|
}
|
|
104
110
|
setOpen(next);
|
|
105
111
|
};
|
|
@@ -107,7 +113,7 @@ export function EventTickets({
|
|
|
107
113
|
return (
|
|
108
114
|
<DialogRoot open={open} onOpenChange={tryClose}>
|
|
109
115
|
<DialogTrigger asChild>
|
|
110
|
-
<button style={triggerButtonStyle(theme)}>{triggerText}</button>
|
|
116
|
+
<button style={triggerButtonStyle(theme)}>{triggerText ?? t("forge.event_tickets.trigger")}</button>
|
|
111
117
|
</DialogTrigger>
|
|
112
118
|
<DialogPortal>
|
|
113
119
|
<DialogOverlay style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 50 }} />
|
|
@@ -130,16 +136,18 @@ export function EventTickets({
|
|
|
130
136
|
aria-describedby={undefined}
|
|
131
137
|
>
|
|
132
138
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
|
|
133
|
-
<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>
|
|
134
142
|
<DialogClose asChild>
|
|
135
|
-
<button aria-label="
|
|
143
|
+
<button aria-label={t("forge.event_tickets.aria_close")} style={closeButtonStyle(theme)}>
|
|
136
144
|
<X size={18} />
|
|
137
145
|
</button>
|
|
138
146
|
</DialogClose>
|
|
139
147
|
</div>
|
|
140
148
|
|
|
141
149
|
{c.isLoading && <Loading />}
|
|
142
|
-
{!c.isLoading && !c.event && <p style={{ marginTop: 8 }}>
|
|
150
|
+
{!c.isLoading && !c.event && <p style={{ marginTop: 8 }}>{t("forge.event_tickets.not_found")}</p>}
|
|
143
151
|
{c.event && (
|
|
144
152
|
<EventTicketsBody
|
|
145
153
|
c={c}
|
|
@@ -273,6 +281,7 @@ function TicketStep({
|
|
|
273
281
|
loginPath?: string;
|
|
274
282
|
membershipPath?: string;
|
|
275
283
|
}) {
|
|
284
|
+
const t = useForgeT();
|
|
276
285
|
const theme = useForgeTheme();
|
|
277
286
|
// Inclusive stores caption ticket prices as tax-inclusive (display only).
|
|
278
287
|
const pricesIncludeTax = usePricesIncludeTax();
|
|
@@ -286,21 +295,55 @@ function TicketStep({
|
|
|
286
295
|
const urgency = (expiresAt?: string) => {
|
|
287
296
|
if (!expiresAt) return null;
|
|
288
297
|
const diff = new Date(expiresAt).getTime() - now;
|
|
289
|
-
if (diff <= 0)
|
|
298
|
+
if (diff <= 0)
|
|
299
|
+
return { expired: true, color: "#ef4444", alert: true, message: t("forge.event_tickets.expired_badge") };
|
|
290
300
|
const hours = Math.floor(diff / 3_600_000);
|
|
291
301
|
const minutes = Math.floor((diff % 3_600_000) / 60_000);
|
|
292
302
|
const totalMinutes = hours * 60 + minutes;
|
|
293
|
-
if (totalMinutes < 30)
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
+
};
|
|
297
332
|
const days = Math.floor(hours / 24);
|
|
298
|
-
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
|
+
};
|
|
299
342
|
};
|
|
300
343
|
|
|
301
344
|
const setQty = (ticket: ITicket, qty: number) => {
|
|
302
345
|
// `maxAddableTickets` subtracts `held` and returns 0 before a scheduled
|
|
303
|
-
// on-sale
|
|
346
|
+
// on-sale, the same two facts the server sells on. Computed off `now` so
|
|
304
347
|
// the cap opens up on the tick the on-sale time passes, with no reload.
|
|
305
348
|
//
|
|
306
349
|
// 2.6b adds back the seats held for THIS visitor by a waitlist offer.
|
|
@@ -314,14 +357,14 @@ function TicketStep({
|
|
|
314
357
|
|
|
315
358
|
return (
|
|
316
359
|
<div>
|
|
317
|
-
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 16 }}>
|
|
360
|
+
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 16 }}>{t("forge.event_tickets.select_title")}</h2>
|
|
318
361
|
{/* Renders nothing on an event with no coded tier (1.2). Here as well as
|
|
319
|
-
on the page because this modal is reachable on its own
|
|
320
|
-
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.
|
|
321
364
|
Both read the SAME stored code, so unlocking in one unlocks the other. */}
|
|
322
365
|
<PresaleCodeField presale={c.presale} style={{ marginBottom: 16 }} />
|
|
323
366
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
|
324
|
-
{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>}
|
|
325
368
|
{event.tickets.map((ticket) => {
|
|
326
369
|
const selectedQty = c.selectedTickets[ticket.id] ?? 0;
|
|
327
370
|
/**
|
|
@@ -330,7 +373,7 @@ function TicketStep({
|
|
|
330
373
|
* checkouts as available, so a fan in a rush was told "3 left",
|
|
331
374
|
* picked two, filled in the form and was refused at reserve.
|
|
332
375
|
*/
|
|
333
|
-
// 2.6b
|
|
376
|
+
// 2.6b: the offeree's own reservation is not "somebody else's
|
|
334
377
|
// checkout"; it is the seat they were emailed a deadline for.
|
|
335
378
|
const reserved = c.reservedForYou[ticket.id] ?? 0;
|
|
336
379
|
const seatState = ticketSeatState(ticket, reserved);
|
|
@@ -350,8 +393,8 @@ function TicketStep({
|
|
|
350
393
|
/**
|
|
351
394
|
* A members-only tier the buyer cannot have (S.4).
|
|
352
395
|
*
|
|
353
|
-
* The event-level gate applies to EVERY tier
|
|
354
|
-
* 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
|
|
355
398
|
* once at the top: a buyer reading one tier row must be able to tell
|
|
356
399
|
* from that row whether they can buy it.
|
|
357
400
|
*/
|
|
@@ -370,11 +413,13 @@ function TicketStep({
|
|
|
370
413
|
opacity: isSoldOut || isExpired ? 0.5 : 1,
|
|
371
414
|
}}
|
|
372
415
|
>
|
|
373
|
-
<div
|
|
416
|
+
<div
|
|
417
|
+
style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 8 }}
|
|
418
|
+
>
|
|
374
419
|
<div style={{ flex: 1 }}>
|
|
375
420
|
<h3 style={{ fontSize: 17, fontWeight: 600, marginBottom: 6 }}>{ticket.title}</h3>
|
|
376
421
|
{/* A hidden tier is only ever on screen because a code
|
|
377
|
-
revealed it
|
|
422
|
+
revealed it, so say so. Without the badge, a code that
|
|
378
423
|
unlocked a tier the buyer had not seen listed looks like it
|
|
379
424
|
did nothing. */}
|
|
380
425
|
{ticket.isHidden && (
|
|
@@ -396,7 +441,7 @@ function TicketStep({
|
|
|
396
441
|
}}
|
|
397
442
|
>
|
|
398
443
|
<KeyRound size={12} />
|
|
399
|
-
|
|
444
|
+
{t("forge.event_tickets.presale_badge")}
|
|
400
445
|
</div>
|
|
401
446
|
)}
|
|
402
447
|
{/* A tier that is announced but not yet buyable says WHEN,
|
|
@@ -448,23 +493,39 @@ function TicketStep({
|
|
|
448
493
|
)}
|
|
449
494
|
<div style={{ fontSize: 22, fontWeight: 700, color: theme.colors.primary }}>
|
|
450
495
|
{ticket.compareAtPrice != null && Number(ticket.compareAtPrice) > Number(ticket.price) && (
|
|
451
|
-
<span
|
|
496
|
+
<span
|
|
497
|
+
style={{
|
|
498
|
+
textDecoration: "line-through",
|
|
499
|
+
opacity: 0.6,
|
|
500
|
+
marginRight: 8,
|
|
501
|
+
fontWeight: 400,
|
|
502
|
+
fontSize: 16,
|
|
503
|
+
}}
|
|
504
|
+
>
|
|
452
505
|
{fmt(Number(ticket.compareAtPrice))}
|
|
453
506
|
</span>
|
|
454
507
|
)}
|
|
455
508
|
{/* On a PWYW tier `price` is the FLOOR, so it is labelled as
|
|
456
|
-
one
|
|
509
|
+
one. Showing it bare would read as a fixed price the
|
|
457
510
|
buyer is about to be charged. */}
|
|
458
511
|
{isPayWhatYouWant(ticket) && (
|
|
459
|
-
<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>
|
|
460
515
|
)}
|
|
461
|
-
<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
|
+
/>
|
|
462
523
|
</div>
|
|
463
524
|
</div>
|
|
464
525
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
465
526
|
<button
|
|
466
527
|
onClick={() => setQty(ticket, selectedQty - 1)}
|
|
467
|
-
// Never disabled by availability
|
|
528
|
+
// Never disabled by availability, only by there being
|
|
468
529
|
// nothing to remove. A buyer holding two seats on a tier
|
|
469
530
|
// that has just gone unavailable must still be able to put
|
|
470
531
|
// them back; disabling this would trap the cart.
|
|
@@ -481,7 +542,7 @@ function TicketStep({
|
|
|
481
542
|
// A gated tier cannot be added, nor one whose on-sale time
|
|
482
543
|
// has not arrived, nor one whose remaining seats are inside
|
|
483
544
|
// somebody else's checkout. Not because the client is the
|
|
484
|
-
// authority
|
|
545
|
+
// authority (`createOrder` refuses all three regardless)
|
|
485
546
|
// but because letting a buyer build a cart the server will
|
|
486
547
|
// not sell is precisely the "discoverable only by failing"
|
|
487
548
|
// journey this change exists to end.
|
|
@@ -492,8 +553,8 @@ function TicketStep({
|
|
|
492
553
|
</button>
|
|
493
554
|
</div>
|
|
494
555
|
</div>
|
|
495
|
-
{/* The amount box appears only once a seat is actually selected
|
|
496
|
-
|
|
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
|
|
497
558
|
inputs down the tier list reads as a form to fill in rather
|
|
498
559
|
than a choice to make. */}
|
|
499
560
|
{isPayWhatYouWant(ticket) && selectedQty > 0 && !isSoldOut && !isExpired && !isScheduled && (
|
|
@@ -510,13 +571,17 @@ function TicketStep({
|
|
|
510
571
|
<p dangerouslySetInnerHTML={{ __html: ticket.description }} style={{ marginTop: 12, fontSize: 14 }} />
|
|
511
572
|
)}
|
|
512
573
|
{/* Named on the TIER, before the buyer has typed anything. The
|
|
513
|
-
tier stays in the list on purpose
|
|
514
|
-
|
|
574
|
+
tier stays in the list on purpose (hiding it kills the upsell)
|
|
575
|
+
so this is what turns "visible but mysteriously unbuyable"
|
|
515
576
|
into an offer with a way in. */}
|
|
516
577
|
<MembershipGateNotice
|
|
517
578
|
gate={gate}
|
|
518
579
|
variant="badge"
|
|
519
|
-
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
|
+
}
|
|
520
585
|
loginPath={loginPath}
|
|
521
586
|
membershipPath={membershipPath}
|
|
522
587
|
/>
|
|
@@ -555,7 +620,7 @@ function TicketStep({
|
|
|
555
620
|
}}
|
|
556
621
|
>
|
|
557
622
|
<AlertCircle size={16} />
|
|
558
|
-
|
|
623
|
+
{t("forge.event_tickets.expired_notice")}
|
|
559
624
|
</div>
|
|
560
625
|
)}
|
|
561
626
|
</div>
|
|
@@ -576,18 +641,24 @@ function TicketStep({
|
|
|
576
641
|
onNext={() => {
|
|
577
642
|
if (c.addTicketsToCart()) onAddedToCart?.();
|
|
578
643
|
}}
|
|
579
|
-
nextLabel={
|
|
644
|
+
nextLabel={
|
|
645
|
+
c.ticketsInCart ? t("forge.event_tickets.update_cart_addons") : t("forge.event_tickets.continue_addons")
|
|
646
|
+
}
|
|
580
647
|
nextTestId="tickets-add-to-cart"
|
|
581
|
-
secondary={{
|
|
648
|
+
secondary={{
|
|
649
|
+
label: t("forge.event_tickets.checkout_tickets_only"),
|
|
650
|
+
onClick: c.goToDetails,
|
|
651
|
+
testId: "tickets-checkout-only",
|
|
652
|
+
}}
|
|
582
653
|
/>
|
|
583
654
|
) : (
|
|
584
655
|
<ActionBar
|
|
585
656
|
c={c}
|
|
586
657
|
fmt={fmt}
|
|
587
658
|
onNext={c.goToDetails}
|
|
588
|
-
nextLabel="
|
|
659
|
+
nextLabel={t("forge.event_tickets.continue")}
|
|
589
660
|
secondary={{
|
|
590
|
-
label: c.ticketsInCart ? "
|
|
661
|
+
label: c.ticketsInCart ? t("forge.event_tickets.update_cart") : t("forge.event_tickets.add_to_cart"),
|
|
591
662
|
onClick: () => {
|
|
592
663
|
if (c.addTicketsToCart()) onAddedToCart?.();
|
|
593
664
|
},
|
|
@@ -613,6 +684,7 @@ function DetailsStep({
|
|
|
613
684
|
loginPath?: string;
|
|
614
685
|
membershipPath?: string;
|
|
615
686
|
}) {
|
|
687
|
+
const t = useForgeT();
|
|
616
688
|
const theme = useForgeTheme();
|
|
617
689
|
const [confirmEmail, setConfirmEmail] = useState("");
|
|
618
690
|
const [answers, setAnswers] = useState<Record<string, string>>({});
|
|
@@ -628,30 +700,57 @@ function DetailsStep({
|
|
|
628
700
|
|
|
629
701
|
const onContinue = () => {
|
|
630
702
|
setLocalError(null);
|
|
631
|
-
if (!c.firstName.trim() || !c.lastName.trim()) return setLocalError("
|
|
632
|
-
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email)) return setLocalError("
|
|
633
|
-
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"));
|
|
634
706
|
for (const q of questions) {
|
|
635
|
-
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 }));
|
|
636
709
|
}
|
|
637
710
|
c.continueToPayment();
|
|
638
711
|
};
|
|
639
712
|
|
|
640
713
|
return (
|
|
641
714
|
<div>
|
|
642
|
-
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 16 }}>
|
|
715
|
+
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 16 }}>{t("forge.event_tickets.details_title")}</h2>
|
|
643
716
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
644
717
|
<div style={{ display: "flex", gap: 8 }}>
|
|
645
|
-
<input
|
|
646
|
-
|
|
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
|
+
/>
|
|
647
730
|
</div>
|
|
648
731
|
<div>
|
|
649
|
-
<input
|
|
650
|
-
|
|
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
|
+
)}
|
|
651
742
|
</div>
|
|
652
743
|
<div>
|
|
653
|
-
<input
|
|
654
|
-
|
|
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
|
+
)}
|
|
655
754
|
</div>
|
|
656
755
|
|
|
657
756
|
{questions.map((q) => {
|
|
@@ -663,8 +762,12 @@ function DetailsStep({
|
|
|
663
762
|
{!q.optional && <span style={{ color: "#ef4444" }}> *</span>}
|
|
664
763
|
</span>
|
|
665
764
|
{q.options && q.options.length > 0 ? (
|
|
666
|
-
<select
|
|
667
|
-
|
|
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>
|
|
668
771
|
{q.options.map((opt) => (
|
|
669
772
|
<option key={opt} value={opt}>
|
|
670
773
|
{opt}
|
|
@@ -672,15 +775,19 @@ function DetailsStep({
|
|
|
672
775
|
))}
|
|
673
776
|
</select>
|
|
674
777
|
) : (
|
|
675
|
-
<input
|
|
778
|
+
<input
|
|
779
|
+
value={answers[q.id] ?? ""}
|
|
780
|
+
onChange={(e) => onAnswer(q.id, e.target.value)}
|
|
781
|
+
style={inputStyle(theme)}
|
|
782
|
+
/>
|
|
676
783
|
)}
|
|
677
|
-
{missing && <FieldError>
|
|
784
|
+
{missing && <FieldError>{t("forge.event_tickets.field_required")}</FieldError>}
|
|
678
785
|
</label>
|
|
679
786
|
);
|
|
680
787
|
})}
|
|
681
788
|
|
|
682
789
|
{/* Tickets have no apply-coupon endpoint, so the code is carried into
|
|
683
|
-
the order that "Continue" creates
|
|
790
|
+
the order that "Continue" creates, hence `deferred`. Its result is
|
|
684
791
|
what draws the discount line on the payment step. */}
|
|
685
792
|
{c.subTotal > 0 && (
|
|
686
793
|
<div style={{ marginTop: 4, paddingTop: 12, borderTop: `1px solid ${a(theme.colors.primary, 0.13)}` }}>
|
|
@@ -690,14 +797,14 @@ function DetailsStep({
|
|
|
690
797
|
|
|
691
798
|
{/* The refusal path. It should be unreachable now that the tier itself
|
|
692
799
|
is badged, but a membership can lapse mid-checkout and a page can be
|
|
693
|
-
served from cache
|
|
800
|
+
served from cache, and when it happens the buyer gets the tier's
|
|
694
801
|
name and a way in, not a red line reading "Something went wrong."
|
|
695
802
|
`c.error` is deliberately not set for this case, so the two cannot
|
|
696
803
|
both draw. */}
|
|
697
804
|
<MembershipGateNotice
|
|
698
805
|
refusal={c.gateRefusal}
|
|
699
806
|
variant="card"
|
|
700
|
-
itemLabel="
|
|
807
|
+
itemLabel={t("forge.event_tickets.item_label_ticket")}
|
|
701
808
|
loginPath={loginPath}
|
|
702
809
|
membershipPath={membershipPath}
|
|
703
810
|
style={{ marginTop: 4 }}
|
|
@@ -706,10 +813,9 @@ function DetailsStep({
|
|
|
706
813
|
{c.attendeeSlots.length > 0 && (
|
|
707
814
|
<div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 4 }}>
|
|
708
815
|
<div>
|
|
709
|
-
<h3 style={{ fontSize: 16, fontWeight: 700 }}>
|
|
816
|
+
<h3 style={{ fontSize: 16, fontWeight: 700 }}>{t("forge.event_tickets.attendees_title")}</h3>
|
|
710
817
|
<p style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>
|
|
711
|
-
|
|
712
|
-
actually turns up.
|
|
818
|
+
{t("forge.event_tickets.attendees_description")}
|
|
713
819
|
</p>
|
|
714
820
|
</div>
|
|
715
821
|
{c.attendeeSlots.map((slot) => (
|
|
@@ -719,10 +825,10 @@ function DetailsStep({
|
|
|
719
825
|
>
|
|
720
826
|
<span>
|
|
721
827
|
{slot.label}
|
|
722
|
-
{slot.isBuyer && <span style={{ opacity: 0.6 }}> (
|
|
828
|
+
{slot.isBuyer && <span style={{ opacity: 0.6 }}> {t("forge.event_tickets.attendee_is_you")}</span>}
|
|
723
829
|
</span>
|
|
724
830
|
<input
|
|
725
|
-
placeholder="
|
|
831
|
+
placeholder={t("forge.event_tickets.attendee_name_placeholder")}
|
|
726
832
|
value={slot.value}
|
|
727
833
|
onChange={(e) => c.setAttendee(slot.ticketId, slot.index, e.target.value)}
|
|
728
834
|
style={inputStyle(theme)}
|
|
@@ -745,7 +851,13 @@ function DetailsStep({
|
|
|
745
851
|
// fee and any discount: a free ticket with a FLAT booking fee still
|
|
746
852
|
// costs money and still goes to payment, and a 100%-off code on a paid
|
|
747
853
|
// ticket does not.
|
|
748
|
-
nextLabel={
|
|
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
|
+
}
|
|
749
861
|
nextDisabled={c.isProcessing}
|
|
750
862
|
/>
|
|
751
863
|
</div>
|
|
@@ -762,6 +874,7 @@ function PaymentStep({
|
|
|
762
874
|
fmt: (n: number) => string;
|
|
763
875
|
renderPayment?: (props: PaymentRenderProps) => ReactNode;
|
|
764
876
|
}) {
|
|
877
|
+
const t = useForgeT();
|
|
765
878
|
const theme = useForgeTheme();
|
|
766
879
|
// Authoritative tax quote from start-payment: exclusive stores get a "Tax"
|
|
767
880
|
// row (already contained in the charged total below); inclusive stores get a
|
|
@@ -769,10 +882,10 @@ function PaymentStep({
|
|
|
769
882
|
const taxSummary = summarizeTaxQuote(c.taxQuote);
|
|
770
883
|
return (
|
|
771
884
|
<div>
|
|
772
|
-
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>
|
|
773
|
-
<p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>
|
|
774
|
-
{/* The reservation clock. Event ticket holds are taken unconditionally
|
|
775
|
-
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
|
|
776
889
|
fires for every profile, and it was the one that did not render it. */}
|
|
777
890
|
<HoldNotice hold={c.hold} expiredMessage={c.error ?? undefined} style={{ marginBottom: 16 }} />
|
|
778
891
|
<div
|
|
@@ -783,7 +896,7 @@ function PaymentStep({
|
|
|
783
896
|
marginBottom: 16,
|
|
784
897
|
}}
|
|
785
898
|
>
|
|
786
|
-
{/* What the code actually did, from the order response
|
|
899
|
+
{/* What the code actually did, from the order response, never a total
|
|
787
900
|
that silently shrank. Automatic (no-code) discounts land here too. */}
|
|
788
901
|
<DiscountSummaryLines
|
|
789
902
|
subTotal={c.subTotal}
|
|
@@ -808,27 +921,29 @@ function PaymentStep({
|
|
|
808
921
|
fontFamily: theme.fontFamily,
|
|
809
922
|
}}
|
|
810
923
|
>
|
|
811
|
-
|
|
924
|
+
{t("forge.event_tickets.remove_discount")}
|
|
812
925
|
</button>
|
|
813
926
|
</div>
|
|
814
927
|
)}
|
|
815
928
|
{/* Above the tax row, because tax is charged ON the fee: the order total
|
|
816
929
|
the tax quote priced already contained it. By this step the number is
|
|
817
|
-
the SERVER's own
|
|
930
|
+
the SERVER's own (what it wrote to the order), so the itemisation
|
|
818
931
|
adds up to the amount that will actually be charged. */}
|
|
819
932
|
<BookingFeeLine c={c} fmt={fmt} />
|
|
820
933
|
{taxSummary?.mode === "exclusive" && (
|
|
821
934
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
|
|
822
|
-
<span style={{ fontSize: 14, opacity: 0.75 }}>
|
|
935
|
+
<span style={{ fontSize: 14, opacity: 0.75 }}>{t("forge.event_tickets.tax")}</span>
|
|
823
936
|
<span style={{ fontSize: 14, opacity: 0.75 }}>{taxSummary.formattedTax}</span>
|
|
824
937
|
</div>
|
|
825
938
|
)}
|
|
826
939
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
827
|
-
<span style={{ fontSize: 16 }}>
|
|
940
|
+
<span style={{ fontSize: 16 }}>{t("forge.event_tickets.total_amount")}</span>
|
|
828
941
|
<span style={{ fontSize: 22, fontWeight: 700, color: theme.colors.primary, textAlign: "right" }}>
|
|
829
942
|
{fmt(c.totalAmount)}
|
|
830
943
|
{taxSummary?.mode === "inclusive" && (
|
|
831
|
-
<span
|
|
944
|
+
<span
|
|
945
|
+
style={{ display: "block", fontSize: 12, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}
|
|
946
|
+
>
|
|
832
947
|
{taxSummary.label}
|
|
833
948
|
</span>
|
|
834
949
|
)}
|
|
@@ -839,19 +954,22 @@ function PaymentStep({
|
|
|
839
954
|
so it is checked BEFORE the "not configured" branch. */}
|
|
840
955
|
{c.isPaystack ? (
|
|
841
956
|
c.canOpenPaystack ? (
|
|
842
|
-
<PaystackPayButton
|
|
957
|
+
<PaystackPayButton
|
|
958
|
+
onPay={c.openPaystackCheckout}
|
|
959
|
+
label={t("forge.event_tickets.pay", { amount: fmt(c.totalAmount) })}
|
|
960
|
+
/>
|
|
843
961
|
) : (
|
|
844
|
-
<p style={{ opacity: 0.7 }}>
|
|
962
|
+
<p style={{ opacity: 0.7 }}>{t("forge.event_tickets.preparing_payment")}</p>
|
|
845
963
|
)
|
|
846
964
|
) : !renderPayment ? (
|
|
847
|
-
<p style={{ color: "#ef4444", fontSize: 14 }}>
|
|
965
|
+
<p style={{ color: "#ef4444", fontSize: 14 }}>{t("forge.event_tickets.payment_not_configured")}</p>
|
|
848
966
|
) : c.clientSecret ? (
|
|
849
967
|
renderPayment({ clientSecret: c.clientSecret, returnUrl: c.returnUrl, amount: c.totalAmount, mode: "redirect" })
|
|
850
968
|
) : (
|
|
851
|
-
<p style={{ opacity: 0.7 }}>
|
|
969
|
+
<p style={{ opacity: 0.7 }}>{t("forge.event_tickets.preparing_payment")}</p>
|
|
852
970
|
)}
|
|
853
971
|
<button onClick={() => c.setStep("details")} style={{ ...secondaryButtonStyle(theme), marginTop: 16 }}>
|
|
854
|
-
|
|
972
|
+
{t("forge.event_tickets.back")}
|
|
855
973
|
</button>
|
|
856
974
|
</div>
|
|
857
975
|
);
|
|
@@ -866,7 +984,7 @@ function FieldError({ children }: { children: ReactNode }) {
|
|
|
866
984
|
*
|
|
867
985
|
* This is the line that did not exist. The fee was added to `total_amount` on
|
|
868
986
|
* the server and named on no surface, so a buyer choosing £50 of tickets was
|
|
869
|
-
* shown "Total £50" and charged £52.75
|
|
987
|
+
* shown "Total £50" and charged £52.75, and even after a discount, where
|
|
870
988
|
* `Subtotal` and `Discount` were both drawn, the total did not reconcile with
|
|
871
989
|
* them and nothing explained why. A mandatory charge disclosed only on the
|
|
872
990
|
* receipt is what the FTC's junk-fee rule, the CMA's guidance and the EU
|
|
@@ -874,7 +992,7 @@ function FieldError({ children }: { children: ReactNode }) {
|
|
|
874
992
|
*
|
|
875
993
|
* Renders NOTHING when the fee is zero. The payload always arrives (so callers
|
|
876
994
|
* can rely on its shape), which means "this artist charges no fee" comes down as
|
|
877
|
-
* a zero
|
|
995
|
+
* a zero, and a "+£0.00 booking fee" row would invent a charge that does not
|
|
878
996
|
* exist and hint that one might appear later. There is nothing to disclose when
|
|
879
997
|
* nothing is charged.
|
|
880
998
|
*
|
|
@@ -884,6 +1002,7 @@ function FieldError({ children }: { children: ReactNode }) {
|
|
|
884
1002
|
* ON.
|
|
885
1003
|
*/
|
|
886
1004
|
function BookingFeeLine({ c, fmt }: { c: Checkout; fmt: (n: number) => string }) {
|
|
1005
|
+
const t = useForgeT();
|
|
887
1006
|
const theme = useForgeTheme();
|
|
888
1007
|
if (!(c.bookingFeeAmount > 0)) return null;
|
|
889
1008
|
const discountDrewSubtotal = c.coupon.discountAmount > 0;
|
|
@@ -895,15 +1014,12 @@ function BookingFeeLine({ c, fmt }: { c: Checkout; fmt: (n: number) => string })
|
|
|
895
1014
|
>
|
|
896
1015
|
{!discountDrewSubtotal && (
|
|
897
1016
|
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 14 }}>
|
|
898
|
-
<span style={{ opacity: 0.75 }}>
|
|
1017
|
+
<span style={{ opacity: 0.75 }}>{t("forge.event_tickets.subtotal")}</span>
|
|
899
1018
|
<span style={{ opacity: 0.75 }}>{fmt(c.subTotal)}</span>
|
|
900
1019
|
</div>
|
|
901
1020
|
)}
|
|
902
|
-
<div
|
|
903
|
-
|
|
904
|
-
style={{ display: "flex", justifyContent: "space-between", fontSize: 14 }}
|
|
905
|
-
>
|
|
906
|
-
<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>
|
|
907
1023
|
<span style={{ color: theme.colors.text, opacity: 0.75 }}>{fmt(c.bookingFeeAmount)}</span>
|
|
908
1024
|
</div>
|
|
909
1025
|
</div>
|
|
@@ -933,6 +1049,7 @@ function ActionBar({
|
|
|
933
1049
|
/** Extra exit shown next to Back on the selection step. */
|
|
934
1050
|
secondary?: { label: string; onClick: () => void; testId?: string };
|
|
935
1051
|
}) {
|
|
1052
|
+
const t = useForgeT();
|
|
936
1053
|
const theme = useForgeTheme();
|
|
937
1054
|
return (
|
|
938
1055
|
<div style={{ marginTop: 24 }}>
|
|
@@ -951,11 +1068,11 @@ function ActionBar({
|
|
|
951
1068
|
fmt={fmt}
|
|
952
1069
|
/>
|
|
953
1070
|
{/* `Total` below is the figure the buyer reads as final while they are
|
|
954
|
-
still choosing quantities
|
|
1071
|
+
still choosing quantities, so the fee has to be named HERE, not
|
|
955
1072
|
three screens later. */}
|
|
956
1073
|
<BookingFeeLine c={c} fmt={fmt} />
|
|
957
1074
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
958
|
-
<span style={{ fontWeight: 600 }}>
|
|
1075
|
+
<span style={{ fontWeight: 600 }}>{t("forge.event_tickets.total")}</span>
|
|
959
1076
|
<span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>{fmt(c.totalAmount)}</span>
|
|
960
1077
|
</div>
|
|
961
1078
|
</div>
|
|
@@ -965,8 +1082,12 @@ function ActionBar({
|
|
|
965
1082
|
{secondary.label}
|
|
966
1083
|
</button>
|
|
967
1084
|
) : (
|
|
968
|
-
<button
|
|
969
|
-
|
|
1085
|
+
<button
|
|
1086
|
+
onClick={onBack}
|
|
1087
|
+
disabled={!onBack}
|
|
1088
|
+
style={{ ...secondaryButtonStyle(theme), opacity: onBack ? 1 : 0.4 }}
|
|
1089
|
+
>
|
|
1090
|
+
{t("forge.event_tickets.back")}
|
|
970
1091
|
</button>
|
|
971
1092
|
)}
|
|
972
1093
|
<button
|
|
@@ -1007,7 +1128,7 @@ const closeButtonStyle = (t: ForgeTheme): CSSProperties => ({
|
|
|
1007
1128
|
* to the floor on every keystroke), and "1" on the way to "15" must not be
|
|
1008
1129
|
* clamped up to the minimum the instant it is typed.
|
|
1009
1130
|
*
|
|
1010
|
-
* Correction happens on BLUR, when the buyer has finished
|
|
1131
|
+
* Correction happens on BLUR, when the buyer has finished, and the server
|
|
1011
1132
|
* clamps again regardless, so nothing here is load-bearing for money.
|
|
1012
1133
|
*/
|
|
1013
1134
|
function PwywAmountField({
|
|
@@ -1025,12 +1146,13 @@ function PwywAmountField({
|
|
|
1025
1146
|
fmt: (amount: number) => string;
|
|
1026
1147
|
theme: ForgeTheme;
|
|
1027
1148
|
}) {
|
|
1149
|
+
const t = useForgeT();
|
|
1028
1150
|
const floor = Number(ticket.price);
|
|
1029
1151
|
const maximum = pwywMaximum(ticket);
|
|
1030
1152
|
const suggested = pwywDefaultAmount(ticket);
|
|
1031
1153
|
const [draft, setDraft] = useState<string>(String(value ?? suggested));
|
|
1032
1154
|
|
|
1033
|
-
// 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
|
|
1034
1156
|
// operator's suggested amount arriving late (or the selection being cleared
|
|
1035
1157
|
// and remade) should re-seed the box rather than leave a stale figure.
|
|
1036
1158
|
useEffect(() => {
|
|
@@ -1055,15 +1177,13 @@ function PwywAmountField({
|
|
|
1055
1177
|
|
|
1056
1178
|
return (
|
|
1057
1179
|
<div style={{ marginTop: 12 }}>
|
|
1058
|
-
<label
|
|
1059
|
-
|
|
1060
|
-
style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 6 }}
|
|
1061
|
-
>
|
|
1062
|
-
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")}
|
|
1063
1182
|
<span style={{ fontWeight: 400, opacity: 0.7 }}>
|
|
1064
1183
|
{" "}
|
|
1065
|
-
|
|
1066
|
-
|
|
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) })}
|
|
1067
1187
|
</span>
|
|
1068
1188
|
</label>
|
|
1069
1189
|
<input
|
|
@@ -1080,19 +1200,23 @@ function PwywAmountField({
|
|
|
1080
1200
|
/>
|
|
1081
1201
|
<p style={{ fontSize: 12, opacity: 0.7, marginTop: 6 }}>
|
|
1082
1202
|
{belowFloor
|
|
1083
|
-
?
|
|
1203
|
+
? t("forge.event_tickets.pwyw_below_min", { min: fmt(floor) })
|
|
1084
1204
|
: aboveMax
|
|
1085
|
-
?
|
|
1205
|
+
? t("forge.event_tickets.pwyw_above_max", { max: fmt(maximum!) })
|
|
1086
1206
|
: quantity > 1
|
|
1087
|
-
?
|
|
1088
|
-
|
|
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")}
|
|
1089
1213
|
</p>
|
|
1090
1214
|
</div>
|
|
1091
1215
|
);
|
|
1092
1216
|
}
|
|
1093
1217
|
|
|
1094
1218
|
/**
|
|
1095
|
-
* The inline banner under a tier row
|
|
1219
|
+
* The inline banner under a tier row, same shape as the expired one already
|
|
1096
1220
|
* below it, so "why can't I buy this?" always answers itself in one place and
|
|
1097
1221
|
* one visual language, whichever the reason.
|
|
1098
1222
|
*/
|