@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.9.0",
3
+ "version": "3.11.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,4 +1,4 @@
1
- import type { BookingSlot, PillarDiscountQuote } from "../../types/models";
1
+ import type { BookingSlot, CancellationTermsView, PillarDiscountQuote } from "../../types/models";
2
2
  import { useForge } from "../../provider/ForgeProvider";
3
3
  import { useMutation, useQuery } from "@tanstack/react-query";
4
4
 
@@ -18,11 +18,22 @@ export function useCoachingAvailability(productId?: string, fromDate?: string, t
18
18
  });
19
19
  }
20
20
 
21
- /** Reserve a coaching booking slot. Returns the created booking id. */
21
+ export type ReserveCoachingBookingResult = {
22
+ bookingId: string;
23
+ totalAmount?: number;
24
+ /**
25
+ * The cancellation terms SNAPSHOTTED onto this booking (S.6). Authoritative
26
+ * from here on — the product's live policy may be edited while the hour is
27
+ * held, and the buyer's rights are governed by this copy, not by that read.
28
+ */
29
+ cancellation?: CancellationTermsView | null;
30
+ };
31
+
32
+ /** Reserve a coaching booking slot. Returns the created booking + its agreed terms. */
22
33
  export function useReserveCoachingBooking(productId?: string) {
23
34
  const { client } = useForge();
24
35
 
25
- return useMutation<{ bookingId: string }, unknown, { slotId: string }>({
36
+ return useMutation<ReserveCoachingBookingResult, unknown, { slotId: string }>({
26
37
  mutationFn: async ({ slotId }) => {
27
38
  const res = await client.post(`/public/coaching/products/${productId}/booking/reserve`, {
28
39
  slotId,
@@ -0,0 +1,42 @@
1
+ import type { IEventSeries } from "../../types/models";
2
+ import { useForge } from "../../provider/ForgeProvider";
3
+ import { useQuery } from "@tanstack/react-query";
4
+
5
+ /**
6
+ * A named, multi-date event series by slug (or id).
7
+ *
8
+ * ## There is deliberately no `useEventSeriesList`
9
+ *
10
+ * Every event belongs to a series, so a list endpoint would be a list of every
11
+ * event on the site wearing a different hat — and the ones the API refuses to
12
+ * publish (unnamed containers, single-date shows) would show up as holes in it.
13
+ * A series is a DESTINATION the operator shares, not a browsable index; the
14
+ * events list is already the browsable index.
15
+ *
16
+ * ## A 404 is the normal answer
17
+ *
18
+ * The API refuses any series that nobody named or that holds fewer than two
19
+ * publicly-visible dates. That is not an error state to surface as "something
20
+ * went wrong" — it means there is no such page — so a renderer should treat
21
+ * `isError` here the same way it treats a missing event.
22
+ *
23
+ * Pass `initialData` (a series fetched server-side in a route loader) to seed
24
+ * the query, so the page server-renders for SEO with no client spinner.
25
+ */
26
+ export function useEventSeries(slugOrId?: string, options?: { initialData?: IEventSeries }) {
27
+ const { client, profileId } = useForge();
28
+
29
+ return useQuery<IEventSeries>({
30
+ queryKey: ["event-series", slugOrId, profileId],
31
+ queryFn: async () => {
32
+ const res = await client.get(`/public/event-series/${encodeURIComponent(slugOrId!)}`, {
33
+ params: { profileId },
34
+ });
35
+ return res.data;
36
+ },
37
+ enabled: !!slugOrId && !!profileId && !!client,
38
+ initialData: options?.initialData,
39
+ // A 404 here means "no such page", which no amount of retrying changes.
40
+ retry: false,
41
+ });
42
+ }
@@ -59,12 +59,21 @@ export type CreateEventOrderInput = {
59
59
  export type CreateEventOrderResult = {
60
60
  orderId: string;
61
61
  isFreeCheckout?: boolean;
62
- /** NET of any discount. */
62
+ /** NET of any discount, and INCLUSIVE of the booking fee below. */
63
63
  totalAmount?: number;
64
64
  // Additive (S.3) — present on every response, discounted or not.
65
65
  /** GROSS, before any discount. */
66
66
  subTotal?: number;
67
67
  discountAmount?: number;
68
+ /**
69
+ * The artist's booking fee this order was ACTUALLY charged, in major units —
70
+ * the server's own snapshot, not a re-derivation.
71
+ *
72
+ * It is what closes the arithmetic: `subTotal - discountAmount + feeAmount`
73
+ * is `totalAmount`. Before it existed the three fields did not reconcile and
74
+ * no line on any checkout explained the gap.
75
+ */
76
+ feeAmount?: number;
68
77
  couponId?: string | null;
69
78
  /** Every discount that applied, entered OR automatic. */
70
79
  appliedCoupons?: AppliedDiscountCoupon[];
@@ -1,5 +1,12 @@
1
1
  import { useForge } from "../../provider/ForgeProvider";
2
2
  import { useMutation } from "@tanstack/react-query";
3
+ import { usePublicAuth } from "../../contexts/PublicAuthContext";
4
+ import {
5
+ getMembershipAccess,
6
+ getMembershipStatusMessage,
7
+ type MembershipAccessSummary,
8
+ type MembershipStatusMessage,
9
+ } from "../../utils/membershipAccess";
3
10
 
4
11
  export type CreateSubscriptionInput = {
5
12
  amount?: number;
@@ -48,6 +55,52 @@ export function useCancelMembership() {
48
55
  });
49
56
  }
50
57
 
58
+ /**
59
+ * The signed-in member's access + billing message, in one call.
60
+ *
61
+ * Every membership-aware surface should use this instead of reading
62
+ * `user.membership.status`. `access` answers "can they see it", `message`
63
+ * answers "what do we tell them" — two different questions, and conflating
64
+ * them is what locked past_due members out while calling them "Cancelled".
65
+ */
66
+ export function useMembershipAccess(): {
67
+ access: MembershipAccessSummary;
68
+ message: MembershipStatusMessage;
69
+ } {
70
+ const { user } = usePublicAuth();
71
+ const access = getMembershipAccess(user?.membership);
72
+ return { access, message: getMembershipStatusMessage(access) };
73
+ }
74
+
75
+ /**
76
+ * Open the payment provider's billing-management page for the signed-in member.
77
+ *
78
+ * This is the other half of keeping access through `past_due`: the grace window
79
+ * is only a recovery window if the member can actually fix their card inside
80
+ * it. Nothing about the caller is sent — the server derives the provider
81
+ * customer from the session, because a customer id in a request body is a
82
+ * cross-tenant leak waiting to happen.
83
+ *
84
+ * `returnUrl` defaults to the current page so the member lands back where they
85
+ * were. The returned `url` is a one-time link — redirect, never render it.
86
+ */
87
+ export function useOpenBillingPortal() {
88
+ const { client, profileId } = useForge();
89
+
90
+ return useMutation<{ url: string; provider: string }, unknown, { returnUrl?: string } | void>({
91
+ mutationFn: async (input) => {
92
+ const returnUrl =
93
+ (input && "returnUrl" in input ? input.returnUrl : undefined) ??
94
+ (typeof window !== "undefined" ? window.location.href : undefined);
95
+ const res = await client.post("/public/payments/subscriptions/billing-portal", {
96
+ profileId,
97
+ ...(returnUrl ? { returnUrl } : {}),
98
+ });
99
+ return res.data;
100
+ },
101
+ });
102
+ }
103
+
51
104
  /**
52
105
  * Reconcile the most recent subscription after a checkout return.
53
106
  * Returns the latest subscription status (e.g. "active").
package/src/index.ts CHANGED
@@ -69,6 +69,7 @@ export * from "./data/queries/useBlog";
69
69
  export * from "./data/queries/usePodcast";
70
70
  export * from "./data/queries/useCollections";
71
71
  export * from "./data/queries/useEvents";
72
+ export * from "./data/queries/useEventSeries";
72
73
  export * from "./data/queries/useInvoice";
73
74
  export * from "./data/queries/usePaymentLink";
74
75
  export * from "./data/queries/useLeadMagnet";
@@ -123,3 +124,6 @@ export * from "./utils/structuredData";
123
124
  // reaches existing sites through the normal Forge publish.
124
125
  export * from "./utils/headMeta";
125
126
  export * from "./utils/formatDateTime";
127
+ // The ONE membership access predicate + the messaging that is deliberately
128
+ // separate from it. Every surface that gates on a membership imports from here.
129
+ export * from "./utils/membershipAccess";
@@ -33,6 +33,7 @@ import type { ApiProbeResult, ForgeSsrDiagnostics, SsrFetchFailure } from "../ty
33
33
  export type { ApiProbeResult, ForgeSsrDiagnostics, SsrFetchFailure };
34
34
  import type {
35
35
  IEvent,
36
+ IEventSeries,
36
37
  IPublicProduct,
37
38
  PublicCourse,
38
39
  CoachingProduct,
@@ -221,6 +222,25 @@ export function fetchEventServer(opts: { apiUrl: string; profileId?: string; idO
221
222
  });
222
223
  }
223
224
 
225
+ /**
226
+ * Fetch a NAMED, multi-date event series by slug (or id) for SSR.
227
+ *
228
+ * Resolves to `null` for a series that does not exist, is not this profile's,
229
+ * was never named by a human, or holds fewer than two publicly-visible dates —
230
+ * the API answers 404 to all four identically, on purpose, so that a caller
231
+ * cannot tell them apart and turn the endpoint into an oracle. A `null` here
232
+ * means "render the not-found page", not "the fetch failed".
233
+ */
234
+ export function fetchEventSeriesServer(opts: {
235
+ apiUrl: string;
236
+ profileId?: string;
237
+ idOrSlug: string;
238
+ }): Promise<IEventSeries | null> {
239
+ return getJson<IEventSeries>(opts.apiUrl, `/public/event-series/${encodeURIComponent(opts.idOrSlug)}`, {
240
+ profileId: opts.profileId,
241
+ });
242
+ }
243
+
224
244
  /** Fetch a single product by id or slug for SSR. */
225
245
  export function fetchProductServer(opts: {
226
246
  apiUrl: string;
@@ -238,13 +238,38 @@ export type MembershipTier = {
238
238
  cancellationMessageContent?: string | null;
239
239
  };
240
240
 
241
+ /**
242
+ * The server's access + billing decision, shipped with every membership.
243
+ *
244
+ * Read this through `getMembershipAccess()` rather than destructuring it — the
245
+ * helper handles a server that predates the block, and is the single place any
246
+ * surface is allowed to answer "does this grant access".
247
+ */
248
+ export interface MembershipAccess {
249
+ hasAccess: boolean;
250
+ billingState: "active" | "ending" | "past_due" | "grace" | "pending" | "ended";
251
+ paymentFailed: boolean;
252
+ graceUntil: string | null;
253
+ pastDueAt: string | null;
254
+ cancelAtPeriodEnd: boolean;
255
+ }
256
+
241
257
  export interface Membership {
242
258
  id: string;
243
259
  membershipTierId: string;
244
260
  profilePaymentSubscriptionId?: string;
245
261
  paymentProviderSubscriptionId?: string;
246
262
  endDate: string;
263
+ /**
264
+ * Raw lifecycle status. **Do not compare this to `"active"` to decide access
265
+ * or to label the membership** — `past_due` and `grace` still grant access.
266
+ * Use `getMembershipAccess(membership)` / `getMembershipStatusMessage()`.
267
+ */
247
268
  status: string;
269
+ access?: MembershipAccess;
270
+ cancelAtPeriodEnd?: boolean;
271
+ graceUntil?: string | null;
272
+ pastDueAt?: string | null;
248
273
  membershipTier: MembershipTier;
249
274
  startDate: string;
250
275
  subscriptionAmount: number;
@@ -752,6 +777,31 @@ export type QuestionnaireQuestion = {
752
777
  optional?: boolean;
753
778
  };
754
779
 
780
+ /**
781
+ * S.6 — the cancellation terms a buyer is entitled to read BEFORE they pay.
782
+ *
783
+ * Two endpoints return this shape and they answer different questions:
784
+ * - `GET /public/coaching/products/:id` → the product's LIVE policy, i.e. "what
785
+ * would I be agreeing to". Show it on the storefront.
786
+ * - `POST …/booking/reserve` → the SNAPSHOT written onto the booking, i.e.
787
+ * "what did I agree to". Show it from the details step onwards, because an
788
+ * operator editing the policy mid-checkout must not move the terms under a
789
+ * buyer who already holds the hour.
790
+ *
791
+ * `description` is rendered SERVER-side by the shared `describeCancellationPolicy`
792
+ * so the sentence at checkout is byte-identical to the one in the buyer's portal.
793
+ * It is `null` when the seller configured no policy — render nothing rather than
794
+ * inventing a right, and note that "no policy" is a different fact from `none`.
795
+ */
796
+ export type CancellationTermsView = {
797
+ policy: "none" | "until" | "anytime" | null;
798
+ cutoffHours: number | null;
799
+ /** The seller's own free text, when they wrote any. */
800
+ terms: string | null;
801
+ /** One ready-made sentence, or null when there is no policy at all. */
802
+ description: string | null;
803
+ };
804
+
755
805
  export type CoachingProduct = {
756
806
  id: string;
757
807
  title: string;
@@ -773,6 +823,8 @@ export type CoachingProduct = {
773
823
  questionnaire?: QuestionnaireQuestion[];
774
824
  /** Rating aggregate — present on the detail endpoint, null until first published review. */
775
825
  reviewAggregate?: IReviewAggregate | null;
826
+ /** The seller's CURRENT cancellation terms — present on the detail endpoint only. */
827
+ cancellation?: CancellationTermsView | null;
776
828
  };
777
829
 
778
830
  export type PublicCourseLesson = {
@@ -847,6 +899,29 @@ export type PodcastShow = {
847
899
 
848
900
  // ---- Events ------------------------------------------------------------------
849
901
 
902
+ /**
903
+ * The artist's booking fee, RESOLVED — what `GET /public/events/:id` publishes
904
+ * so a storefront can quote the real price before the buyer commits.
905
+ *
906
+ * The fee is charged per ORDER (`total_amount` goes up by it), and until this
907
+ * shipped no buyer-facing surface mentioned it anywhere.
908
+ *
909
+ * These are NOT the event's raw `ticketFeeCents` / `ticketFeeBps` columns. Those
910
+ * are `null` whenever the event inherits the artist's default — the ordinary
911
+ * case — so reading them shows no fee while one is being charged. The server
912
+ * merges the event's override over the profile default and sends the result.
913
+ */
914
+ export interface IBookingFee {
915
+ /** The flat part per ORDER, in major units of `currency`. */
916
+ feeAmount: number;
917
+ /** The percentage of the discounted subtotal, in basis points. 2.5% = 250. */
918
+ feeBps: number;
919
+ /** The artist's currency — the one ticket prices and `feeAmount` are in. */
920
+ currency: string;
921
+ /** 100, or 1 for a zero-decimal currency (JPY). The percentage rounds to it. */
922
+ minorUnitFactor: number;
923
+ }
924
+
850
925
  export interface IEvent {
851
926
  id: string;
852
927
  profileId: string;
@@ -896,6 +971,77 @@ export interface IEvent {
896
971
  tickets: ITicket[];
897
972
  media: IMedia[];
898
973
  slug: string;
974
+ /**
975
+ * The booking fee this event's buyers will be charged, already resolved.
976
+ *
977
+ * Optional because the detail endpoint composes it and the LIST endpoint does
978
+ * not — no list surface quotes a ticket price, so there is no figure there for
979
+ * a buyer to mistake for a final one. A page reached through `useEvents()`
980
+ * must not assume it is present.
981
+ */
982
+ bookingFee?: IBookingFee | null;
983
+ }
984
+
985
+ // ---- Event series ------------------------------------------------------------
986
+
987
+ /**
988
+ * One date within a series, as `GET /public/event-series/:slug` returns it.
989
+ *
990
+ * DELIBERATELY not `IEvent`. A series page lists dates; it does not sell them —
991
+ * the buyer follows a date through to `/events/:slug`, which is the page that
992
+ * carries tickets, the questionnaire, terms and the checkout. Restating the
993
+ * narrow shape here keeps that boundary visible: if a field is missing from
994
+ * this type, the series endpoint does not publish it, and adding it is a
995
+ * decision made on the server first.
996
+ */
997
+ export interface IEventSeriesDate {
998
+ id: string;
999
+ title: string;
1000
+ /** Links through to the event page — `/events/:slug`. */
1001
+ slug: string;
1002
+ dateTime: string;
1003
+ endDateTime: string | null;
1004
+ doorsOpenAt: string | null;
1005
+ timezone?: string;
1006
+ type: "physical" | "virtual" | "hybrid" | string;
1007
+ /**
1008
+ * A `cancelled` date STAYS in this list — a ticket-holder following their
1009
+ * link must learn why rather than meet a 404 — so any renderer MUST mark it
1010
+ * rather than assume every entry is going ahead.
1011
+ */
1012
+ status: "scheduled" | "on_sale" | "sold_out" | "cancelled" | "completed";
1013
+ cancelledAt: string | null;
1014
+ cancellationReason: string | null;
1015
+ address?: {
1016
+ name?: string;
1017
+ street?: string;
1018
+ city?: string;
1019
+ state?: string;
1020
+ country?: string;
1021
+ zipCode?: string;
1022
+ };
1023
+ }
1024
+
1025
+ /**
1026
+ * A NAMED, multi-date event series.
1027
+ *
1028
+ * Every event on the platform belongs to a series (a one-off show is a series
1029
+ * of one), but only a series that a human NAMED and that holds at least two
1030
+ * publicly-visible dates has a page. Everything else 404s from the API, so this
1031
+ * type never describes a container-of-one and a renderer never has to ask.
1032
+ */
1033
+ export interface IEventSeries {
1034
+ id: string;
1035
+ title: string;
1036
+ slug: string;
1037
+ description: string | null;
1038
+ /** The publicly-visible dates, ordered by `dateTime`, earliest first. */
1039
+ events: IEventSeriesDate[];
1040
+ media: IMedia[];
1041
+ /** `events.length`, derived server-side off the SAME list — they cannot disagree. */
1042
+ dateCount: number;
1043
+ firstDateTime: string | null;
1044
+ lastDateTime: string | null;
899
1045
  }
900
1046
 
901
1047
  export type ITicket = {
@@ -0,0 +1,98 @@
1
+ import type { IBookingFee } from "../../types/models";
2
+
3
+ /**
4
+ * The artist's booking fee, as a buyer-facing surface has to show it.
5
+ *
6
+ * ## Why this file exists
7
+ *
8
+ * The fee was CHARGED and displayed nowhere. `createOrder` raised the order
9
+ * total by it and no storefront on either rendering stack said a word, so the
10
+ * buyer met it for the first time on the card statement. That is not only
11
+ * unfair, it is regulated — FTC junk-fee rules, CMA guidance and the EU
12
+ * price-indication rules all require a mandatory charge to be visible BEFORE
13
+ * the buyer commits.
14
+ *
15
+ * ## What is safe to compute here, and what is not
16
+ *
17
+ * The RESOLUTION — the event's override merged over the artist's profile
18
+ * default, `null` meaning "inherit" and `0` meaning "deliberately zero" — is NOT
19
+ * done here. It happens once on the server and arrives already resolved on
20
+ * `IEvent.bookingFee`. Reimplementing it on two separate rendering stacks is
21
+ * exactly how a displayed fee drifts away from a charged one, and the raw
22
+ * columns are `null` in the ordinary inheriting case, so a client reading them
23
+ * would show no fee at all.
24
+ *
25
+ * What is left is the arithmetic, and it is deterministic: a flat part plus a
26
+ * percentage of the subtotal, rounded to the currency's minor unit. The factor
27
+ * comes down in the payload precisely so no storefront needs its own copy of
28
+ * the zero-decimal currency list to round the way the server rounds.
29
+ *
30
+ * ## This is still an ESTIMATE
31
+ *
32
+ * It exists to keep the running total honest while the buyer is still choosing
33
+ * quantities, when no order exists and the server has no figure to give. The
34
+ * moment an order is created, `POST /public/events/:id/orders` returns the
35
+ * `feeAmount` it actually wrote, and THAT is what the payment step shows. The
36
+ * two agree by construction; if the artist changes their fee mid-session, the
37
+ * server's number is the one the buyer sees before paying.
38
+ *
39
+ * Mirrors `computeBookingFee` in `apps/backend/src/db/types/bookingFee.ts`.
40
+ */
41
+ export function computeBookingFeeAmount(input: {
42
+ /** The DISCOUNTED subtotal, in major units of the artist's currency. */
43
+ subtotal: number;
44
+ fee: IBookingFee | null | undefined;
45
+ }): number {
46
+ const { subtotal, fee } = input;
47
+ if (!fee) return 0;
48
+
49
+ /**
50
+ * A free order pays nothing — the flat part included.
51
+ *
52
+ * A comp, a members' freebie or a giveaway must not be shown a booking fee,
53
+ * because it is not charged one. The server takes the same branch, so showing
54
+ * one here would be a line the buyer is never billed for.
55
+ */
56
+ if (subtotal <= 0) return 0;
57
+
58
+ const factor = fee.minorUnitFactor > 0 ? fee.minorUnitFactor : 100;
59
+ const subtotalMinor = Math.round(subtotal * factor);
60
+ const percentMinor = Math.round((subtotalMinor * fee.feeBps) / 10_000);
61
+ const flatMinor = Math.round(fee.feeAmount * factor);
62
+
63
+ return Math.max(0, flatMinor + percentMinor) / factor;
64
+ }
65
+
66
+ /**
67
+ * Whether this artist charges a booking fee at all.
68
+ *
69
+ * The payload is always present so a surface can rely on its shape, which means
70
+ * "no fee" arrives as zeros rather than as an absent object. A storefront that
71
+ * rendered those zeros would print a "+$0.00 booking fee" line — inventing a
72
+ * charge that does not exist and implying one might appear later. There is
73
+ * nothing to disclose when nothing is charged, so every surface suppresses the
74
+ * line on this.
75
+ */
76
+ export function hasBookingFee(fee: IBookingFee | null | undefined): boolean {
77
+ return !!fee && (fee.feeAmount > 0 || fee.feeBps > 0);
78
+ }
79
+
80
+ /**
81
+ * The fee rule in words, for a price that is quoted WITHOUT a cart behind it.
82
+ *
83
+ * An event page saying "From $25" is a price a buyer can reasonably read as
84
+ * final, but there is no selection yet, so there is no amount — only the rule.
85
+ * Naming it ("+ $1.50 booking fee", "+ 2.5% booking fee") is what stops the
86
+ * quoted figure from being the whole story. Returns `null` when there is no fee,
87
+ * so the caller renders nothing.
88
+ */
89
+ export function describeBookingFee(
90
+ fee: IBookingFee | null | undefined,
91
+ formatAmount: (amount: number) => string,
92
+ ): string | null {
93
+ if (!hasBookingFee(fee)) return null;
94
+ const parts: string[] = [];
95
+ if (fee!.feeAmount > 0) parts.push(formatAmount(fee!.feeAmount));
96
+ if (fee!.feeBps > 0) parts.push(`${+(fee!.feeBps / 100).toFixed(2)}%`);
97
+ return `+ ${parts.join(" + ")} booking fee`;
98
+ }
@@ -11,6 +11,7 @@ import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
11
11
  import { useCouponField, type CouponQuoteFn } from "../coupon/useCouponField";
12
12
  import { readAttributionRef } from "../../../utils/attribution";
13
13
  import { readLanding } from "../../../utils/landing";
14
+ import type { CancellationTermsView } from "../../../types/models";
14
15
 
15
16
  export type CoachingBookingStep = "slot" | "details" | "payment";
16
17
 
@@ -88,6 +89,13 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
88
89
  const [questionnaire, setQuestionnaire] = useState<unknown>(undefined);
89
90
  const [returnUrl, setReturnUrl] = useState("");
90
91
  const [error, setError] = useState<string | null>(null);
92
+ /**
93
+ * S.6 — the terms this buyer actually agreed to, as snapshotted onto the
94
+ * booking at reserve. Held separately from `product.cancellation` (the
95
+ * seller's live setting) because they can diverge the moment the operator
96
+ * edits the policy while the hour is held, and only this copy governs.
97
+ */
98
+ const [agreedCancellation, setAgreedCancellation] = useState<CancellationTermsView | null>(null);
91
99
  // Epoch ms when the current slot hold expires (null when nothing is held).
92
100
  const [reservationExpiresAt, setReservationExpiresAt] = useState<number | null>(null);
93
101
 
@@ -153,6 +161,7 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
153
161
  if (bookingId) await unreserve.mutateAsync({ bookingId }).catch(() => undefined);
154
162
  const data = await reserve.mutateAsync({ slotId: selectedSlotId });
155
163
  setBookingId(data.bookingId);
164
+ setAgreedCancellation(data.cancellation ?? null);
156
165
  setReservationExpiresAt(Date.now() + RESERVATION_HOLD_MS);
157
166
  setStep("details");
158
167
  } catch (e) {
@@ -171,6 +180,9 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
171
180
  setBookingId(null);
172
181
  setSelectedSlotId(null);
173
182
  setReservationExpiresAt(null);
183
+ // The agreed terms belonged to a booking that no longer exists — the next
184
+ // reserve snapshots afresh, and may snapshot something different.
185
+ setAgreedCancellation(null);
174
186
  // The quote belonged to a booking that no longer exists — keeping it would
175
187
  // show a discount against a price nothing has agreed to.
176
188
  coupon.reset();
@@ -234,6 +246,13 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
234
246
  setStep,
235
247
  selectedSlotId,
236
248
  bookingId,
249
+ /**
250
+ * The cancellation terms in force for THIS checkout (S.6): the booking's
251
+ * snapshot once a slot is held, the product's live policy before that. Never
252
+ * both — a surface rendering `product.cancellation` after the reserve would
253
+ * show the buyer terms other than the ones they are bound by.
254
+ */
255
+ cancellation: agreedCancellation ?? product?.cancellation ?? null,
237
256
  selectSlot,
238
257
  reserveSelected,
239
258
  reservationExpiresAt,
@@ -4,6 +4,7 @@ import { useCart } from "../../../contexts/CartContext";
4
4
  import { useEvent, useCreateEventOrder } from "../../../data/queries/useEvents";
5
5
  import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
6
6
  import { useCouponField } from "../coupon/useCouponField";
7
+ import { computeBookingFeeAmount } from "../../format/bookingFee";
7
8
  import { readAttributionRef } from "../../../utils/attribution";
8
9
  import { readLanding } from "../../../utils/landing";
9
10
 
@@ -47,6 +48,14 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
47
48
  const [questionnaire, setQuestionnaire] = useState<unknown>(undefined);
48
49
  const [returnUrl, setReturnUrl] = useState("");
49
50
  const [error, setError] = useState<string | null>(null);
51
+ /**
52
+ * The booking fee the SERVER charged, once an order exists.
53
+ *
54
+ * Kept separately from the coupon quote because it is not a discount concern:
55
+ * it is the one figure on the payment screen that the client must never
56
+ * compute for itself, since it is the number on the card.
57
+ */
58
+ const [serverFeeAmount, setServerFeeAmount] = useState<number | null>(null);
50
59
 
51
60
  /**
52
61
  * Discount code — STAGED, not quoted.
@@ -71,6 +80,29 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
71
80
  [selectedTickets],
72
81
  );
73
82
 
83
+ /**
84
+ * The artist's booking fee (1.4) — the thing the buyer was charged and never
85
+ * told about, on any surface, on either rendering stack.
86
+ *
87
+ * Resolved by the server (`event.bookingFee`), because the event's own
88
+ * `ticketFeeCents`/`ticketFeeBps` are `null` whenever it inherits the artist's
89
+ * default, and that is the ordinary case.
90
+ */
91
+ const bookingFee = event?.bookingFee ?? null;
92
+
93
+ /**
94
+ * The fee to SHOW, in the artist's currency.
95
+ *
96
+ * Once an order exists the server has told us what it charged, and that wins
97
+ * outright — a locally derived figure on the payment screen is how a displayed
98
+ * total drifts from a billed one. Before then there is no order to ask, so the
99
+ * running total carries an estimate off the resolved rule; it keeps the "Total"
100
+ * honest while the buyer is still choosing quantities, which is precisely when
101
+ * the disclosure has to happen.
102
+ */
103
+ const bookingFeeAmount =
104
+ serverFeeAmount ?? computeBookingFeeAmount({ subtotal: totalAmount, fee: bookingFee });
105
+
74
106
  // Keep only positive quantities in state — a ticket dropped to 0 is removed
75
107
  // entirely, so `items` never carries a 0-quantity entry (the orders endpoint
76
108
  // rejects those with "quantity must be positive").
@@ -151,6 +183,11 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
151
183
  couponId: data.couponId ?? null,
152
184
  appliedCoupons: data.appliedCoupons ?? [],
153
185
  });
186
+ // From here on the fee on screen is the fee on the card. `?? 0` rather
187
+ // than `?? null`: the response always carries it, and a free order
188
+ // legitimately reports 0 — falling back to the estimate there would put a
189
+ // fee on a comp.
190
+ setServerFeeAmount(data.feeAmount ?? 0);
154
191
  const origin = typeof window !== "undefined" ? window.location.origin : "";
155
192
  const ru = `${origin}${finalisePath(slug, data.orderId)}`;
156
193
  setReturnUrl(ru);
@@ -185,6 +222,10 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
185
222
  // The started payment described the DISCOUNTED order; leaving it would keep
186
223
  // the reduced total on screen after the discount is gone.
187
224
  flow.reset();
225
+ // Same reason: the abandoned order's fee was a percentage of the DISCOUNTED
226
+ // subtotal, so keeping it would understate the fee on the fresh, full-price
227
+ // order the next `continueToPayment` creates.
228
+ setServerFeeAmount(null);
188
229
  setStep("details");
189
230
  };
190
231
 
@@ -199,9 +240,27 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
199
240
  // The server's figure wins the moment there is one: start-payment first,
200
241
  // then the order response (which is already net of any discount), and only
201
242
  // the locally summed ticket prices before either exists.
202
- totalAmount: flow.result?.totalAmount ?? coupon.quote?.totalAmount ?? totalAmount,
203
- /** GROSS ticket total, before any discount. */
243
+ //
244
+ // Both server figures already INCLUDE the booking fee — `total_amount` has
245
+ // always been raised by it. The local sum did not, which is what made the
246
+ // pre-payment "Total" a number the buyer would not be charged; the estimated
247
+ // fee is added so the figure on screen means what it says.
248
+ totalAmount:
249
+ flow.result?.totalAmount ?? coupon.quote?.totalAmount ?? totalAmount + bookingFeeAmount,
250
+ /** GROSS ticket total, before any discount OR booking fee. */
204
251
  subTotal: coupon.quote?.subTotal ?? totalAmount,
252
+ /**
253
+ * The resolved fee RULE, for surfaces that quote a price with no cart behind
254
+ * it (an event page's "From $25"). `null` when the artist charges none.
255
+ */
256
+ bookingFee,
257
+ /**
258
+ * The fee for THIS selection — the server's own number once an order exists,
259
+ * an estimate off the rule before that. Zero when nothing is charged, which
260
+ * is what every surface checks before drawing the line: a "+$0.00 booking
261
+ * fee" row invents a charge that does not exist.
262
+ */
263
+ bookingFeeAmount,
205
264
  /** Discount code state + the server's quote. See `useCouponField`. */
206
265
  coupon,
207
266
  clearCoupon,