@tribe-nest/forge 3.9.0 → 3.14.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 +2 -2
- package/src/contexts/CartContext.tsx +13 -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/useProducts.ts +104 -10
- package/src/data/queries/useSubscriptions.ts +53 -0
- package/src/index.ts +9 -0
- package/src/server/index.ts +20 -0
- package/src/types/models.ts +199 -4
- 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/index.ts +1 -0
- package/src/ui/headless/membership/MembershipGate.tsx +7 -2
- package/src/ui/headless/useVariantSelection.ts +138 -0
- package/src/ui/index.ts +11 -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/ui/styled/ProductBrowseNav.tsx +212 -0
- package/src/ui/styled/ProductDetail.tsx +150 -91
- package/src/ui/styled/ProductGrid.tsx +16 -2
- package/src/utils/membershipAccess.ts +182 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import type { IPublicProductVariant } from "../../types/models";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Picking a version of a product, on any number of axes.
|
|
6
|
+
*
|
|
7
|
+
* This replaces a hardcoded colour → size cascade. That cascade could not
|
|
8
|
+
* express a third axis, so an album sold as MP3 / WAV / Stems had nowhere to put
|
|
9
|
+
* "Format", and it read `variant.color` — a column one writer fills with a hex
|
|
10
|
+
* and another with a name.
|
|
11
|
+
*
|
|
12
|
+
* The cascade itself is kept, generalised: each axis is narrowed by the axes
|
|
13
|
+
* BEFORE it. Picking "Black" leaves only the sizes that exist in black, exactly
|
|
14
|
+
* as before, and picking "FLAC" would leave only the editions pressed in FLAC.
|
|
15
|
+
* Axis order comes from the server, which sorts by the axis's own position, so
|
|
16
|
+
* every variant of a product presents its options the same way round.
|
|
17
|
+
*
|
|
18
|
+
* Shared by BOTH rendering surfaces. The Craft themes in `frontend-shared`
|
|
19
|
+
* import it from here, the way they already import the cart and audio player —
|
|
20
|
+
* the dependency runs one way, so the selection rule cannot drift between a
|
|
21
|
+
* code site and a Craft site even though their markup is unrelated.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type VariantAxisValue = {
|
|
25
|
+
optionValueId: string;
|
|
26
|
+
value: string;
|
|
27
|
+
/** Only set when the value genuinely is a colour. Render a swatch if present. */
|
|
28
|
+
swatchHex: string | null;
|
|
29
|
+
/** False when every variant carrying it is out of stock. */
|
|
30
|
+
isAvailable: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type VariantAxis = {
|
|
34
|
+
optionTypeId: string;
|
|
35
|
+
/** "Colour", "Size", "Format" — as the creator named it. */
|
|
36
|
+
axis: string;
|
|
37
|
+
displayType: string;
|
|
38
|
+
values: VariantAxisValue[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type Selection = Record<string, string | undefined>;
|
|
42
|
+
|
|
43
|
+
const optionFor = (variant: IPublicProductVariant, optionTypeId: string) =>
|
|
44
|
+
variant.options?.find((option) => option.optionTypeId === optionTypeId);
|
|
45
|
+
|
|
46
|
+
/** Does this variant match every selection made on the axes listed? */
|
|
47
|
+
const matches = (variant: IPublicProductVariant, selection: Selection, upToAxes: string[]) =>
|
|
48
|
+
upToAxes.every((optionTypeId) => {
|
|
49
|
+
const chosen = selection[optionTypeId];
|
|
50
|
+
if (!chosen) return true;
|
|
51
|
+
return optionFor(variant, optionTypeId)?.optionValueId === chosen;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export function useVariantSelection(variants: IPublicProductVariant[]) {
|
|
55
|
+
const [selection, setSelection] = useState<Selection>({});
|
|
56
|
+
|
|
57
|
+
// The axes this product uses, in server order, deduped. Built from the
|
|
58
|
+
// variants themselves rather than a separate list, so a product can never
|
|
59
|
+
// advertise an axis none of its versions sits on.
|
|
60
|
+
const axisOrder = useMemo(() => {
|
|
61
|
+
const seen: { optionTypeId: string; axis: string; displayType: string }[] = [];
|
|
62
|
+
for (const variant of variants ?? []) {
|
|
63
|
+
for (const option of variant.options ?? []) {
|
|
64
|
+
if (!seen.some((s) => s.optionTypeId === option.optionTypeId)) {
|
|
65
|
+
seen.push({ optionTypeId: option.optionTypeId, axis: option.axis, displayType: option.displayType });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return seen;
|
|
70
|
+
}, [variants]);
|
|
71
|
+
|
|
72
|
+
const axes: VariantAxis[] = useMemo(() => {
|
|
73
|
+
return axisOrder.map((axis, index) => {
|
|
74
|
+
// Narrowed by the axes before it, and only those — an axis must not be
|
|
75
|
+
// constrained by a LATER pick, or choosing a size would start hiding
|
|
76
|
+
// colours and the buyer could reach a state with nothing selectable.
|
|
77
|
+
const earlier = axisOrder.slice(0, index).map((a) => a.optionTypeId);
|
|
78
|
+
const candidates = (variants ?? []).filter((variant) => matches(variant, selection, earlier));
|
|
79
|
+
|
|
80
|
+
const values: VariantAxisValue[] = [];
|
|
81
|
+
for (const variant of candidates) {
|
|
82
|
+
const option = optionFor(variant, axis.optionTypeId);
|
|
83
|
+
if (!option) continue;
|
|
84
|
+
const existing = values.find((v) => v.optionValueId === option.optionValueId);
|
|
85
|
+
const isActive = variant.availabilityStatus === "active";
|
|
86
|
+
if (existing) {
|
|
87
|
+
existing.isAvailable = existing.isAvailable || isActive;
|
|
88
|
+
} else {
|
|
89
|
+
values.push({
|
|
90
|
+
optionValueId: option.optionValueId,
|
|
91
|
+
value: option.value,
|
|
92
|
+
swatchHex: option.swatchHex,
|
|
93
|
+
isAvailable: isActive,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { ...axis, values };
|
|
98
|
+
});
|
|
99
|
+
}, [axisOrder, variants, selection]);
|
|
100
|
+
|
|
101
|
+
const select = useCallback(
|
|
102
|
+
(optionTypeId: string, optionValueId: string) => {
|
|
103
|
+
setSelection((current) => {
|
|
104
|
+
const index = axisOrder.findIndex((a) => a.optionTypeId === optionTypeId);
|
|
105
|
+
const next: Selection = { ...current, [optionTypeId]: optionValueId };
|
|
106
|
+
// Changing an axis invalidates everything narrowed by it. Keeping a
|
|
107
|
+
// stale later pick is how you end up "selecting" a variant that does
|
|
108
|
+
// not exist — the buyer sees Black/XL highlighted and the button dead.
|
|
109
|
+
for (const later of axisOrder.slice(index + 1)) next[later.optionTypeId] = undefined;
|
|
110
|
+
return next;
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
[axisOrder],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const selectedVariant = useMemo(() => {
|
|
117
|
+
// A product with no axes has exactly one version — every digital release in
|
|
118
|
+
// the catalogue before formats existed.
|
|
119
|
+
if (axisOrder.length === 0) {
|
|
120
|
+
return variants?.find((v) => v.isDefault) ?? variants?.[0];
|
|
121
|
+
}
|
|
122
|
+
if (axisOrder.some((a) => !selection[a.optionTypeId])) return undefined;
|
|
123
|
+
return (variants ?? []).find((variant) =>
|
|
124
|
+
axisOrder.every((a) => optionFor(variant, a.optionTypeId)?.optionValueId === selection[a.optionTypeId]),
|
|
125
|
+
);
|
|
126
|
+
}, [axisOrder, selection, variants]);
|
|
127
|
+
|
|
128
|
+
// Walk the axes in order and pre-pick the first value that is actually in
|
|
129
|
+
// stock, falling back to the first at all so the page is never blank.
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
const missing = axes.find((axis) => !selection[axis.optionTypeId] && axis.values.length > 0);
|
|
132
|
+
if (!missing) return;
|
|
133
|
+
const first = missing.values.find((v) => v.isAvailable) ?? missing.values[0];
|
|
134
|
+
setSelection((current) => ({ ...current, [missing.optionTypeId]: first.optionValueId }));
|
|
135
|
+
}, [axes, selection]);
|
|
136
|
+
|
|
137
|
+
return { axes, selection, select, selectedVariant, hasOptions: axisOrder.length > 0 };
|
|
138
|
+
}
|
package/src/ui/index.ts
CHANGED
|
@@ -65,6 +65,7 @@ export { ContactForm, type ContactFormProps } from "./styled/ContactForm";
|
|
|
65
65
|
export { Paywall, type PaywallProps } from "./styled/Paywall";
|
|
66
66
|
export { ReactionBar, type ReactionBarProps } from "./styled/ReactionBar";
|
|
67
67
|
export { ProductGrid, type ProductGridProps } from "./styled/ProductGrid";
|
|
68
|
+
export { ProductBrowseNav, type ProductBrowseNavProps } from "./styled/ProductBrowseNav";
|
|
68
69
|
export { Addons, type AddonsProps } from "./styled/Addons";
|
|
69
70
|
export { BundleConfirmation, type BundleConfirmationProps } from "./styled/BundleConfirmation";
|
|
70
71
|
export { ProductDetail, type ProductDetailProps } from "./styled/ProductDetail";
|
|
@@ -74,10 +75,16 @@ export { EventTickets, type EventTicketsProps } from "./styled/EventTickets";
|
|
|
74
75
|
export { EventCountdown, type EventCountdownProps } from "./styled/EventCountdown";
|
|
75
76
|
export { EventWaitlist, type EventWaitlistProps, formatCountdown } from "./styled/EventWaitlist";
|
|
76
77
|
export { EventDetail, type EventDetailProps } from "./styled/EventDetail";
|
|
78
|
+
// Phase 5 — the page for a NAMED, multi-date series. The API refuses anything
|
|
79
|
+
// else, so this component never has to decide whether a series is publishable.
|
|
80
|
+
export { EventSeriesDetail, type EventSeriesDetailProps } from "./styled/EventSeriesDetail";
|
|
77
81
|
export { AddToCalendar, type AddToCalendarProps } from "./styled/AddToCalendar";
|
|
78
82
|
export { WalletPassButtons, type WalletPassButtonsProps } from "./styled/WalletPassButtons";
|
|
79
83
|
export { CoachingBooking, type CoachingBookingProps } from "./styled/CoachingBooking";
|
|
80
84
|
export { CoachingDetail, type CoachingDetailProps } from "./styled/CoachingDetail";
|
|
85
|
+
// S.6 — the buyer-facing cancellation policy block. Exported so a code site that
|
|
86
|
+
// lays out its own booking page still has ONE renderer for these terms.
|
|
87
|
+
export { CancellationTerms, type CancellationTermsProps } from "./styled/CancellationTerms";
|
|
81
88
|
export { CourseCheckout, type CourseCheckoutProps } from "./styled/CourseCheckout";
|
|
82
89
|
export { CourseDetail, type CourseDetailProps } from "./styled/CourseDetail";
|
|
83
90
|
export { PostsFeed, type PostsFeedProps } from "./styled/PostsFeed";
|
|
@@ -142,6 +149,10 @@ export { DonationPage, type DonationPageProps } from "./styled/DonationPage";
|
|
|
142
149
|
export { ReviewsSection, StarRow, type ReviewsSectionProps } from "./styled/ReviewsSection";
|
|
143
150
|
export { ReviewForm, type ReviewFormProps } from "./styled/ReviewForm";
|
|
144
151
|
export { PriceDisplay, priceTaxCaption, summarizeTaxQuote, type PriceDisplayProps } from "./format/PriceDisplay";
|
|
152
|
+
// The artist's booking fee, for a site rendering its own ticket UI rather than
|
|
153
|
+
// the styled blocks. Resolution stays on the server (`IEvent.bookingFee`); these
|
|
154
|
+
// only do the arithmetic and the wording.
|
|
155
|
+
export { computeBookingFeeAmount, hasBookingFee, describeBookingFee } from "./format/bookingFee";
|
|
145
156
|
export { usePricesIncludeTax } from "../data/queries/useWebsite";
|
|
146
157
|
|
|
147
158
|
// 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
|
+
}
|