@tribe-nest/forge 3.9.0 → 3.11.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.
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
3
+ import { getMembershipAccess } from "../../../utils/membershipAccess";
3
4
 
4
5
  export interface MembershipGateState {
5
6
  hasAccess: boolean;
@@ -28,10 +29,14 @@ export interface MembershipGateProps {
28
29
  export function MembershipGate({ tiers, access, children, fallback }: MembershipGateProps) {
29
30
  const { user, isAuthenticated } = usePublicAuth();
30
31
  const membership = user?.membership;
31
- const activeMembership = !!membership && membership.status === "active";
32
+ // The SERVER's access decision, not a `status === "active"` compare. That
33
+ // compare locked out members in `past_due`/`grace` — states the backend
34
+ // deliberately keeps access through — so a card that bounced cost them the
35
+ // content they were still paying for.
36
+ const membershipGrantsAccess = getMembershipAccess(membership).hasAccess;
32
37
  const tierAllows = !tiers?.length || (membership ? tiers.includes(membership.membershipTierId) : false);
33
38
 
34
- const hasAccess = access ?? (activeMembership && tierAllows);
39
+ const hasAccess = access ?? (membershipGrantsAccess && tierAllows);
35
40
 
36
41
  if (hasAccess) return <>{children}</>;
37
42
  if (typeof fallback === "function") return <>{fallback({ hasAccess, isAuthenticated })}</>;
package/src/ui/index.ts CHANGED
@@ -74,10 +74,16 @@ export { EventTickets, type EventTicketsProps } from "./styled/EventTickets";
74
74
  export { EventCountdown, type EventCountdownProps } from "./styled/EventCountdown";
75
75
  export { EventWaitlist, type EventWaitlistProps, formatCountdown } from "./styled/EventWaitlist";
76
76
  export { EventDetail, type EventDetailProps } from "./styled/EventDetail";
77
+ // Phase 5 — the page for a NAMED, multi-date series. The API refuses anything
78
+ // else, so this component never has to decide whether a series is publishable.
79
+ export { EventSeriesDetail, type EventSeriesDetailProps } from "./styled/EventSeriesDetail";
77
80
  export { AddToCalendar, type AddToCalendarProps } from "./styled/AddToCalendar";
78
81
  export { WalletPassButtons, type WalletPassButtonsProps } from "./styled/WalletPassButtons";
79
82
  export { CoachingBooking, type CoachingBookingProps } from "./styled/CoachingBooking";
80
83
  export { CoachingDetail, type CoachingDetailProps } from "./styled/CoachingDetail";
84
+ // S.6 — the buyer-facing cancellation policy block. Exported so a code site that
85
+ // lays out its own booking page still has ONE renderer for these terms.
86
+ export { CancellationTerms, type CancellationTermsProps } from "./styled/CancellationTerms";
81
87
  export { CourseCheckout, type CourseCheckoutProps } from "./styled/CourseCheckout";
82
88
  export { CourseDetail, type CourseDetailProps } from "./styled/CourseDetail";
83
89
  export { PostsFeed, type PostsFeedProps } from "./styled/PostsFeed";
@@ -142,6 +148,10 @@ export { DonationPage, type DonationPageProps } from "./styled/DonationPage";
142
148
  export { ReviewsSection, StarRow, type ReviewsSectionProps } from "./styled/ReviewsSection";
143
149
  export { ReviewForm, type ReviewFormProps } from "./styled/ReviewForm";
144
150
  export { PriceDisplay, priceTaxCaption, summarizeTaxQuote, type PriceDisplayProps } from "./format/PriceDisplay";
151
+ // The artist's booking fee, for a site rendering its own ticket UI rather than
152
+ // the styled blocks. Resolution stays on the server (`IEvent.bookingFee`); these
153
+ // only do the arithmetic and the wording.
154
+ export { computeBookingFeeAmount, hasBookingFee, describeBookingFee } from "./format/bookingFee";
145
155
  export { usePricesIncludeTax } from "../data/queries/useWebsite";
146
156
 
147
157
  // PWA (installable code sites) — SW registration + install/push UX (Tier 2).
@@ -18,6 +18,8 @@ import {
18
18
  useNotificationPreferences,
19
19
  useUpdateNotificationPreference,
20
20
  useCancelMembership,
21
+ useMembershipAccess,
22
+ useOpenBillingPortal,
21
23
  useUpdateAccount,
22
24
  useChangePassword,
23
25
  useExportAccountData,
@@ -224,7 +226,14 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
224
226
  const [isCancelling, setIsCancelling] = useState(false);
225
227
 
226
228
  const membership = user?.membership;
227
- const isActive = membership?.status === "active";
229
+ // Two separate questions, deliberately answered separately:
230
+ // `access.hasAccess` → are the benefits live (true through past_due/grace)
231
+ // `message` → what the member is told, including a failed payment
232
+ // The old `status === "active"` answered both at once and got both wrong for
233
+ // anyone whose card bounced.
234
+ const { access, message } = useMembershipAccess();
235
+ const openBillingPortal = useOpenBillingPortal();
236
+ const [billingError, setBillingError] = useState<string | null>(null);
228
237
 
229
238
  const onCancel = async () => {
230
239
  if (!membership?.id) return;
@@ -237,6 +246,17 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
237
246
  }
238
247
  };
239
248
 
249
+ const onUpdatePayment = async () => {
250
+ setBillingError(null);
251
+ try {
252
+ const { url } = await openBillingPortal.mutateAsync({});
253
+ window.location.href = url;
254
+ } catch (error) {
255
+ const detail = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
256
+ setBillingError(detail || "We couldn't open the billing page. Please contact us.");
257
+ }
258
+ };
259
+
240
260
  return (
241
261
  <div style={card}>
242
262
  <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>Current Membership</h2>
@@ -244,7 +264,19 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
244
264
  <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
245
265
  <div>
246
266
  <h3 style={{ fontWeight: 700 }}>{membership.membershipTier.name}</h3>
247
- <p style={{ fontSize: 13, opacity: 0.7 }}>{isActive ? "Active" : "Cancelled"}</p>
267
+ <p
268
+ style={{
269
+ fontSize: 13,
270
+ opacity: 0.85,
271
+ fontWeight: message.tone === "warning" ? 700 : 400,
272
+ color: message.tone === "warning" ? t.primary : undefined,
273
+ }}
274
+ >
275
+ {message.label}
276
+ </p>
277
+ {message.detail && (
278
+ <p style={{ fontSize: 13, opacity: 0.8, marginTop: 4, maxWidth: 480 }}>{message.detail}</p>
279
+ )}
248
280
  {!!membership.subscriptionAmount && (
249
281
  <p style={{ fontSize: 14, marginTop: 4 }}>
250
282
  {formatAmount(membership.subscriptionAmount, membership.subscriptionCurrency || currency)} / {membership.billingCycle}
@@ -263,16 +295,25 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
263
295
  )}
264
296
  {membership.endDate && (
265
297
  <p style={{ fontSize: 14, opacity: 0.8 }}>
266
- {isActive ? "Renews" : "Ends"} on {formatDate(membership.endDate)}
298
+ {access.billingState === "active" ? "Renews" : "Ends"} on {formatDate(membership.endDate)}
267
299
  </p>
268
300
  )}
301
+ {billingError && <p style={{ fontSize: 13, opacity: 0.9 }}>{billingError}</p>}
269
302
  </div>
270
303
  ) : (
271
304
  <p style={{ opacity: 0.8 }}>You don&apos;t have an active membership.</p>
272
305
  )}
273
306
 
274
307
  <div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 24 }}>
275
- {isActive && (
308
+ {message.showUpdatePayment && (
309
+ <button onClick={onUpdatePayment} disabled={openBillingPortal.isPending} style={button}>
310
+ {openBillingPortal.isPending ? "Opening…" : "Update payment method"}
311
+ </button>
312
+ )}
313
+ {/* Cancelling stays available for anyone who still HAS access — a
314
+ past_due member must not be trapped in a subscription they can see
315
+ but not leave. */}
316
+ {access.hasAccess && (
276
317
  <button onClick={onCancel} disabled={isCancelling} style={ghostButton}>
277
318
  {isCancelling ? "Cancelling…" : "Cancel"}
278
319
  </button>
@@ -0,0 +1,70 @@
1
+ import { type CSSProperties } from "react";
2
+ import { CalendarX2 } from "lucide-react";
3
+ import type { CancellationTermsView } from "../../types/models";
4
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
+
6
+ export interface CancellationTermsProps {
7
+ /** The terms to show. Renders NOTHING when null or when no policy is set. */
8
+ terms?: CancellationTermsView | null;
9
+ /** `card` for a storefront panel, `inline` for inside a checkout step. */
10
+ variant?: "card" | "inline";
11
+ /** Extra class(es) appended to the root element. */
12
+ className?: string;
13
+ /** Inline style merged LAST into the root element (callers can override). */
14
+ style?: CSSProperties;
15
+ }
16
+
17
+ /**
18
+ * S.6 — the cancellation policy, shown to the buyer BEFORE they pay.
19
+ *
20
+ * ── Why this is DISPLAY and not a tick box ────────────────────────────────
21
+ * The platform already draws this line, on the pillar that has both halves:
22
+ * an event's `terms_text` is tick-boxed and refused without acceptance
23
+ * (`resolveTermsAcceptance`), while an event's cancellation policy is
24
+ * snapshotted with no acceptance step. Entry terms are conditions imposed ON the
25
+ * buyer and enforced later at a door; a cancellation policy is a statement of
26
+ * the SELLER's obligation, and there is nothing for the buyer to promise. A
27
+ * booking has no entry-terms equivalent at all — no door, no age limit, no ID —
28
+ * so display is the whole of what is owed here. Requiring a tick would also make
29
+ * one shared evaluator mean two different things by pillar.
30
+ *
31
+ * ── Why it renders nothing rather than a fallback ─────────────────────────
32
+ * `description === null` means the seller configured NO policy, which is a
33
+ * different fact from `none` ("cannot be cancelled"). Inventing a sentence for
34
+ * the first would promise, or deny, terms nobody wrote.
35
+ *
36
+ * The sentence itself comes from the SERVER (`describeCancellationPolicy`), so
37
+ * the wording here is byte-identical to the wording in the buyer's portal.
38
+ */
39
+ export function CancellationTerms({ terms, variant = "card", className, style }: CancellationTermsProps) {
40
+ const t = useThemeTokens();
41
+ if (!terms?.description) return null;
42
+
43
+ const muted = t.muted;
44
+ const base: CSSProperties =
45
+ variant === "card"
46
+ ? {
47
+ padding: 12,
48
+ border: `1px solid ${t.border}`,
49
+ borderRadius: t.cornerRadius,
50
+ background: t.surface,
51
+ }
52
+ : { padding: "10px 0", borderTop: `1px solid ${t.border}` };
53
+
54
+ return (
55
+ <div
56
+ className={className}
57
+ style={{ display: "flex", gap: 8, alignItems: "flex-start", fontSize: 13, ...base, ...style }}
58
+ >
59
+ <CalendarX2 size={15} style={{ flexShrink: 0, marginTop: 2, color: muted }} aria-hidden />
60
+ <div style={{ minWidth: 0 }}>
61
+ <p style={{ margin: 0, fontWeight: 600 }}>Cancellation policy</p>
62
+ <p style={{ margin: "2px 0 0", color: muted }}>{terms.description}</p>
63
+ {terms.terms && (
64
+ // The seller's own words, preserved as written — line breaks included.
65
+ <p style={{ margin: "6px 0 0", color: muted, whiteSpace: "pre-wrap" }}>{terms.terms}</p>
66
+ )}
67
+ </div>
68
+ </div>
69
+ );
70
+ }
@@ -9,6 +9,7 @@ import { Loading } from "./Loading";
9
9
  import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
10
10
  import { buttonStyle } from "./Button";
11
11
  import { DiscountCodeField, DiscountSummaryLines } from "./DiscountCode";
12
+ import { CancellationTerms } from "./CancellationTerms";
12
13
  import {
13
14
  DialogRoot,
14
15
  DialogTrigger,
@@ -459,6 +460,11 @@ function DetailsStep({ c, product, fmt }: { c: Booking; product: CoachingProduct
459
460
 
460
461
  {(localError || c.error) && <p style={{ color: "#ef4444", fontSize: 14 }}>{localError ?? c.error}</p>}
461
462
 
463
+ {/* S.6 — the terms SNAPSHOTTED onto this booking at reserve, not the
464
+ product's live setting: the hour is already held, and an operator
465
+ editing the policy now cannot move what this buyer is bound by. */}
466
+ <CancellationTerms terms={c.cancellation} />
467
+
462
468
  <p style={{ fontSize: 12, color: a(theme.colors.text, 0.6), marginTop: 4 }}>
463
469
  By continuing, you agree to receive booking confirmations and important updates about your session via email.
464
470
  </p>
@@ -527,6 +533,10 @@ function PaymentStep({
527
533
  </span>
528
534
  </div>
529
535
  </div>
536
+ {/* Repeated on the paying step deliberately: this is the last screen before
537
+ the money moves, and a policy read three clicks earlier is not a policy
538
+ read before committing. */}
539
+ <CancellationTerms terms={c.cancellation} style={{ marginBottom: 16 }} />
530
540
  {!renderPayment ? (
531
541
  <p style={{ color: "#ef4444", fontSize: 14 }}>Payment is not configured.</p>
532
542
  ) : c.clientSecret ? (
@@ -7,6 +7,7 @@ import { PriceDisplay } from "../format/PriceDisplay";
7
7
  import { usePricesIncludeTax } from "../../data/queries/useWebsite";
8
8
  import { Loading } from "./Loading";
9
9
  import { CoachingBooking } from "./CoachingBooking";
10
+ import { CancellationTerms } from "./CancellationTerms";
10
11
  import { ReviewsSection } from "./ReviewsSection";
11
12
 
12
13
  export interface CoachingDetailProps {
@@ -89,6 +90,9 @@ export function CoachingDetail({
89
90
  <PriceDisplay amount={Number(product.price)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
90
91
  </span>
91
92
  <CoachingBooking slug={slug} formatAmount={fmt} finalisePath={finalisePath} onComplete={onComplete} />
93
+ {/* S.6 — next to the price and the button, so the terms are readable
94
+ at the moment the buyer decides, not discovered afterwards. */}
95
+ <CancellationTerms terms={product.cancellation} variant="inline" />
92
96
  </aside>
93
97
  </div>
94
98
 
@@ -4,6 +4,7 @@ import { useEvent } from "../../data/queries/useEvents";
4
4
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
5
  import { useAmountFormatter } from "../format/useFormatCurrency";
6
6
  import { PriceDisplay } from "../format/PriceDisplay";
7
+ import { describeBookingFee } from "../format/bookingFee";
7
8
  import { usePricesIncludeTax } from "../../data/queries/useWebsite";
8
9
  import { Loading } from "./Loading";
9
10
  import { EventTickets } from "./EventTickets";
@@ -65,6 +66,7 @@ export function EventDetail({
65
66
 
66
67
  const cover = event.media?.find((m) => m.type === "image")?.url ?? event.media?.[0]?.url;
67
68
  const minPrice = event.tickets.length ? Math.min(...event.tickets.map((t) => t.price)) : 0;
69
+ const feeNote = describeBookingFee(event.bookingFee, fmt);
68
70
  const locationText =
69
71
  event.type === "virtual" || !event.address
70
72
  ? "Virtual"
@@ -145,6 +147,16 @@ export function EventDetail({
145
147
  From{" "}
146
148
  <PriceDisplay amount={minPrice} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} inlineCaption />
147
149
  </span>
150
+ {/* The cheapest tier is the number a buyer anchors on, and until now
151
+ it was the whole story they were given — the artist's booking fee
152
+ was added at checkout and named nowhere. There is no selection
153
+ here, so there is no amount to quote; the RULE is what makes the
154
+ "From" figure honest. Renders nothing when no fee is charged. */}
155
+ {feeNote && (
156
+ <span data-testid="booking-fee-note" style={{ fontSize: 12, opacity: 0.65 }}>
157
+ {feeNote}
158
+ </span>
159
+ )}
148
160
  <EventTickets
149
161
  slug={slug}
150
162
  triggerText="Buy tickets"
@@ -0,0 +1,222 @@
1
+ import { Calendar, MapPin } from "lucide-react";
2
+ import type { IEventSeries, IEventSeriesDate } from "../../types/models";
3
+ import { useEventSeries } from "../../data/queries/useEventSeries";
4
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
+ import { Loading } from "./Loading";
6
+
7
+ export interface EventSeriesDetailProps {
8
+ /** Series slug (or id) — resolves the series. */
9
+ slug?: string;
10
+ /**
11
+ * Series fetched server-side (e.g. in a route loader). Seeds the query so the
12
+ * page renders with data on first paint — no client spinner — enabling SSR
13
+ * for SEO.
14
+ */
15
+ initialSeries?: IEventSeries;
16
+ /**
17
+ * Where a date links. Defaults to `/events/:slug`, which is where the client
18
+ * app puts event pages; a code site whose events live under `/i/events`
19
+ * passes its own.
20
+ */
21
+ eventHref?: (date: IEventSeriesDate) => string;
22
+ /**
23
+ * Host-owned navigation when a date is clicked. When supplied it REPLACES the
24
+ * browser's own navigation (the click is prevented), which is what a SPA host
25
+ * needs: the client app's `navigate` is preview-aware and keeps the
26
+ * `/template-preview` prefix that a bare anchor would drop.
27
+ *
28
+ * The `href` is still rendered either way — a crawler, a middle-click and a
29
+ * "copy link address" must all keep working, so this is an enhancement over a
30
+ * real link rather than a replacement for one.
31
+ */
32
+ onSelect?: (date: IEventSeriesDate) => void;
33
+ /** Extra class(es) appended to the root element. */
34
+ className?: string;
35
+ /** Inline style merged LAST into the root element (callers can override). */
36
+ style?: React.CSSProperties;
37
+ }
38
+
39
+ const formatDate = (value: string, timezone?: string): string =>
40
+ new Date(value).toLocaleString("en-US", {
41
+ weekday: "short",
42
+ month: "short",
43
+ day: "numeric",
44
+ year: "numeric",
45
+ timeZone: timezone || "UTC",
46
+ });
47
+
48
+ const formatTime = (value: string, timezone?: string): string =>
49
+ new Date(value).toLocaleString("en-US", {
50
+ hour: "numeric",
51
+ minute: "2-digit",
52
+ hour12: true,
53
+ timeZone: timezone || "UTC",
54
+ timeZoneName: "short",
55
+ });
56
+
57
+ const locationText = (date: IEventSeriesDate): string => {
58
+ if (date.type === "virtual" || !date.address) return "Online";
59
+ return [date.address.name, date.address.city, date.address.country].filter(Boolean).join(", ") || "Online";
60
+ };
61
+
62
+ /**
63
+ * The public page for a NAMED, multi-date event series.
64
+ *
65
+ * ## What this component is NOT allowed to assume
66
+ *
67
+ * That every date is going ahead. A `cancelled` date deliberately stays in the
68
+ * list the API returns — a ticket-holder following their link must learn WHY
69
+ * rather than meet a 404 — so it is marked here, with the host's reason, and
70
+ * its link stays live. Silently dropping it, or rendering it identically to a
71
+ * live date, are both wrong for the same person.
72
+ *
73
+ * ## Why there is no ticket UI here
74
+ *
75
+ * A series page lists dates; it does not sell them. Tickets, the questionnaire,
76
+ * terms acceptance and checkout all live on the event page, and duplicating any
77
+ * of that would mean a second checkout path — the exact thing the backend
78
+ * refuses to have. Every date links THROUGH.
79
+ *
80
+ * ## The rendered heading is always a name a human chose
81
+ *
82
+ * The API refuses any series that is still `is_implicit` (a title copied off
83
+ * its one event — "Hello world", "Testing one last time" in the live data) and
84
+ * any that holds fewer than two publicly-visible dates. So this component can
85
+ * render `series.title` as a heading unconditionally; there is no client-side
86
+ * check to forget, because a series that should not be published never arrives.
87
+ */
88
+ export function EventSeriesDetail({
89
+ slug,
90
+ initialSeries,
91
+ eventHref,
92
+ onSelect,
93
+ className,
94
+ style,
95
+ }: EventSeriesDetailProps) {
96
+ const t = useThemeTokens();
97
+ const { data: series, isLoading } = useEventSeries(slug, { initialData: initialSeries });
98
+
99
+ if (isLoading) return <Loading fullPage />;
100
+ if (!series) return <p style={{ color: t.text }}>Series not found.</p>;
101
+
102
+ const cover = series.media?.find((m) => m.type === "image")?.url ?? series.media?.[0]?.url;
103
+ const href = eventHref ?? ((date: IEventSeriesDate) => `/events/${date.slug}`);
104
+
105
+ return (
106
+ <div
107
+ className={className}
108
+ style={{ maxWidth: 1040, margin: "0 auto", color: t.text, fontFamily: t.fontFamily, ...style }}
109
+ >
110
+ {cover && (
111
+ <img
112
+ src={cover}
113
+ alt={series.title}
114
+ style={{
115
+ width: "100%",
116
+ maxHeight: 420,
117
+ objectFit: "cover",
118
+ borderRadius: t.cornerRadius,
119
+ marginBottom: 24,
120
+ }}
121
+ />
122
+ )}
123
+
124
+ <h1 style={{ fontSize: 34, fontWeight: 700, margin: 0 }}>{series.title}</h1>
125
+
126
+ <p style={{ marginTop: 8, opacity: 0.75, display: "flex", alignItems: "center", gap: 8 }}>
127
+ <Calendar size={16} />
128
+ <span>
129
+ {series.dateCount} dates
130
+ {series.firstDateTime && series.lastDateTime
131
+ ? ` · ${formatDate(series.firstDateTime, series.events[0]?.timezone)} – ${formatDate(
132
+ series.lastDateTime,
133
+ series.events[series.events.length - 1]?.timezone,
134
+ )}`
135
+ : ""}
136
+ </span>
137
+ </p>
138
+
139
+ {series.description && (
140
+ <p style={{ marginTop: 16, whiteSpace: "pre-wrap", lineHeight: 1.6 }}>{series.description}</p>
141
+ )}
142
+
143
+ <div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 32 }}>
144
+ {series.events.map((date) => {
145
+ const isCancelled = date.status === "cancelled";
146
+ return (
147
+ <a
148
+ key={date.id}
149
+ href={href(date)}
150
+ onClick={
151
+ onSelect
152
+ ? (e) => {
153
+ // Modifier-clicks are the reader asking the BROWSER for a
154
+ // new tab; hijacking those is the classic SPA-link bug.
155
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
156
+ e.preventDefault();
157
+ onSelect(date);
158
+ }
159
+ : undefined
160
+ }
161
+ style={{
162
+ display: "flex",
163
+ flexWrap: "wrap",
164
+ gap: 16,
165
+ alignItems: "center",
166
+ justifyContent: "space-between",
167
+ background: t.surface,
168
+ border: `1px solid ${t.primary}20`,
169
+ borderRadius: t.cornerRadius,
170
+ padding: 16,
171
+ color: t.text,
172
+ textDecoration: "none",
173
+ // Dimmed, never hidden — see the docblock.
174
+ opacity: isCancelled ? 0.6 : 1,
175
+ }}
176
+ >
177
+ <div style={{ minWidth: 160 }}>
178
+ <div style={{ fontWeight: 600 }}>{formatDate(date.dateTime, date.timezone)}</div>
179
+ <div style={{ fontSize: 13, opacity: 0.75 }}>{formatTime(date.dateTime, date.timezone)}</div>
180
+ </div>
181
+
182
+ <div style={{ flex: 1, minWidth: 200 }}>
183
+ <div style={{ fontWeight: 600 }}>{date.title}</div>
184
+ <div style={{ fontSize: 13, opacity: 0.75, display: "flex", alignItems: "center", gap: 6 }}>
185
+ <MapPin size={14} />
186
+ <span>{locationText(date)}</span>
187
+ </div>
188
+ </div>
189
+
190
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
191
+ {isCancelled && (
192
+ <span
193
+ style={{
194
+ fontSize: 12,
195
+ fontWeight: 700,
196
+ letterSpacing: 0.6,
197
+ textTransform: "uppercase",
198
+ border: `1px solid ${t.text}40`,
199
+ borderRadius: t.cornerRadius,
200
+ padding: "4px 8px",
201
+ }}
202
+ >
203
+ Cancelled
204
+ </span>
205
+ )}
206
+ {date.status === "sold_out" && (
207
+ <span style={{ fontSize: 12, fontWeight: 700, letterSpacing: 0.6, textTransform: "uppercase" }}>
208
+ Sold out
209
+ </span>
210
+ )}
211
+ </div>
212
+
213
+ {isCancelled && date.cancellationReason && (
214
+ <div style={{ flexBasis: "100%", fontSize: 13, opacity: 0.8 }}>{date.cancellationReason}</div>
215
+ )}
216
+ </a>
217
+ );
218
+ })}
219
+ </div>
220
+ </div>
221
+ );
222
+ }
@@ -500,6 +500,11 @@ function PaymentStep({
500
500
  </button>
501
501
  </div>
502
502
  )}
503
+ {/* Above the tax row, because tax is charged ON the fee: the order total
504
+ the tax quote priced already contained it. By this step the number is
505
+ the SERVER's own — what it wrote to the order — so the itemisation
506
+ adds up to the amount that will actually be charged. */}
507
+ <BookingFeeLine c={c} fmt={fmt} />
503
508
  {taxSummary?.mode === "exclusive" && (
504
509
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
505
510
  <span style={{ fontSize: 14, opacity: 0.75 }}>Tax</span>
@@ -536,6 +541,55 @@ function FieldError({ children }: { children: ReactNode }) {
536
541
  return <p style={{ color: "#ef4444", fontSize: 13, marginTop: 4 }}>{children}</p>;
537
542
  }
538
543
 
544
+ /**
545
+ * The artist's booking fee, itemised ABOVE the total (1.4 disclosure).
546
+ *
547
+ * This is the line that did not exist. The fee was added to `total_amount` on
548
+ * the server and named on no surface, so a buyer choosing £50 of tickets was
549
+ * shown "Total £50" and charged £52.75 — and even after a discount, where
550
+ * `Subtotal` and `Discount` were both drawn, the total did not reconcile with
551
+ * them and nothing explained why. A mandatory charge disclosed only on the
552
+ * receipt is what the FTC's junk-fee rule, the CMA's guidance and the EU
553
+ * price-indication rules exist to prevent.
554
+ *
555
+ * Renders NOTHING when the fee is zero. The payload always arrives (so callers
556
+ * can rely on its shape), which means "this artist charges no fee" comes down as
557
+ * a zero — and a "+£0.00 booking fee" row would invent a charge that does not
558
+ * exist and hint that one might appear later. There is nothing to disclose when
559
+ * nothing is charged.
560
+ *
561
+ * The `Subtotal` row is conditional because `DiscountSummaryLines` above already
562
+ * draws one whenever a discount applied; without a discount it draws nothing, and
563
+ * a fee sitting on its own between a heading and a total has nothing to be a fee
564
+ * ON.
565
+ */
566
+ function BookingFeeLine({ c, fmt }: { c: Checkout; fmt: (n: number) => string }) {
567
+ const theme = useForgeTheme();
568
+ if (!(c.bookingFeeAmount > 0)) return null;
569
+ const discountDrewSubtotal = c.coupon.discountAmount > 0;
570
+
571
+ return (
572
+ <div
573
+ data-testid="booking-fee-summary"
574
+ style={{ display: "flex", flexDirection: "column", gap: 4, marginBottom: 8 }}
575
+ >
576
+ {!discountDrewSubtotal && (
577
+ <div style={{ display: "flex", justifyContent: "space-between", fontSize: 14 }}>
578
+ <span style={{ opacity: 0.75 }}>Subtotal</span>
579
+ <span style={{ opacity: 0.75 }}>{fmt(c.subTotal)}</span>
580
+ </div>
581
+ )}
582
+ <div
583
+ data-testid="booking-fee-line"
584
+ style={{ display: "flex", justifyContent: "space-between", fontSize: 14 }}
585
+ >
586
+ <span style={{ color: theme.colors.text, opacity: 0.75 }}>Booking fee</span>
587
+ <span style={{ color: theme.colors.text, opacity: 0.75 }}>{fmt(c.bookingFeeAmount)}</span>
588
+ </div>
589
+ </div>
590
+ );
591
+ }
592
+
539
593
  // ── Shared bits ───────────────────────────────────────────────────────────────
540
594
  function ActionBar({
541
595
  c,
@@ -572,6 +626,10 @@ function ActionBar({
572
626
  appliedCoupons={c.coupon.appliedCoupons}
573
627
  fmt={fmt}
574
628
  />
629
+ {/* `Total` below is the figure the buyer reads as final while they are
630
+ still choosing quantities — so the fee has to be named HERE, not
631
+ three screens later. */}
632
+ <BookingFeeLine c={c} fmt={fmt} />
575
633
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
576
634
  <span style={{ fontWeight: 600 }}>Total</span>
577
635
  <span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>{fmt(c.totalAmount)}</span>