@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.
- package/package.json +1 -1
- package/src/data/queries/useCoachingAvailability.ts +14 -3
- package/src/data/queries/useEventSeries.ts +42 -0
- package/src/data/queries/useEvents.ts +10 -1
- package/src/data/queries/useSubscriptions.ts +53 -0
- package/src/index.ts +4 -0
- package/src/server/index.ts +20 -0
- package/src/types/models.ts +146 -0
- package/src/ui/format/bookingFee.ts +98 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +19 -0
- package/src/ui/headless/event/useEventCheckout.ts +61 -2
- package/src/ui/headless/membership/MembershipGate.tsx +7 -2
- package/src/ui/index.ts +10 -0
- package/src/ui/styled/AccountDashboard.tsx +45 -4
- package/src/ui/styled/CancellationTerms.tsx +70 -0
- package/src/ui/styled/CoachingBooking.tsx +10 -0
- package/src/ui/styled/CoachingDetail.tsx +4 -0
- package/src/ui/styled/EventDetail.tsx +12 -0
- package/src/ui/styled/EventSeriesDetail.tsx +222 -0
- package/src/ui/styled/EventTickets.tsx +58 -0
- package/src/utils/membershipAccess.ts +182 -0
|
@@ -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
|
-
|
|
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 ?? (
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
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't have an active membership.</p>
|
|
272
305
|
)}
|
|
273
306
|
|
|
274
307
|
<div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 24 }}>
|
|
275
|
-
{
|
|
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>
|