@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.
@@ -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;
@@ -559,15 +584,35 @@ export type IMedia = {
559
584
  previewStatus?: string | null;
560
585
  };
561
586
 
562
- export enum ProductCategory {
587
+ /**
588
+ * What a product IS, which decides how it renders: Music shows a track list,
589
+ * Merch shows variants, Digital is a download, Service is fulfilled by the
590
+ * creator.
591
+ *
592
+ * Renamed from `ProductType` in the taxonomy split. It used to be the only
593
+ * taxonomy a product had, pointing into a four-row table shared by every artist
594
+ * on the platform — so no creator could organise their own store. Creator
595
+ * authored categories and collections are separate now (`categories`,
596
+ * `collections` on {@link IPublicProduct}).
597
+ *
598
+ * Coaching and Course are gone: they are separate surfaces with their own
599
+ * tables and never appeared in `products`. The storefront still shows a
600
+ * Coaching tab — see {@link STOREFRONT_COACHING_TAB}.
601
+ */
602
+ export enum ProductType {
563
603
  Music = "Music",
564
604
  Merch = "Merch",
565
605
  Digital = "Digital",
566
606
  Service = "Service",
567
- Coaching = "Coaching",
568
- Course = "Course",
569
607
  }
570
608
 
609
+ /**
610
+ * The storefront's product-type navigation is not purely {@link ProductType}: a
611
+ * creator with coaching products gets a "Coaching" tab alongside them, though
612
+ * no product row is ever of that type.
613
+ */
614
+ export const STOREFRONT_COACHING_TAB = "Coaching" as const;
615
+
571
616
  export type PostType = "image" | "video" | "audio" | "poll";
572
617
 
573
618
  export type IPublicComment = {
@@ -633,11 +678,36 @@ export type IPublicProductVariant = {
633
678
  payWhatYouWant?: boolean;
634
679
  payWhatYouWantMaximum?: number;
635
680
  upcCode: string;
681
+ /**
682
+ * Where this variant sits on its product's axes, in the axes' own order.
683
+ *
684
+ * This is what the picker reads. `color` and `size` below are the two
685
+ * hardcoded axes that predate the option library — they cannot express a
686
+ * third ("Format: MP3 / WAV / Stems"), and one product's `color` may hold a
687
+ * hex while another's holds a name, because three different writers filled
688
+ * it. Prefer `options`.
689
+ */
690
+ options: IPublicVariantOption[];
691
+ /** @deprecated Read `options`. */
636
692
  color: string;
693
+ /** @deprecated Read `options`. */
637
694
  size: string;
695
+ /** Whether a buyer of this version also receives the downloadable files. */
696
+ includesDownload?: boolean;
638
697
  availabilityStatus: "active" | "temporarily_out_of_stock";
639
698
  };
640
699
 
700
+ export type IPublicVariantOption = {
701
+ optionValueId: string;
702
+ optionTypeId: string;
703
+ /** "Colour", "Size", "Format" — as the creator named it. */
704
+ axis: string;
705
+ value: string;
706
+ /** Set only when the value genuinely is a colour; a guessed hex would lie. */
707
+ swatchHex: string | null;
708
+ displayType: string;
709
+ };
710
+
641
711
  // ---- Reviews -------------------------------------------------------------------
642
712
 
643
713
  /** Reviewable entity types (v1 — events deferred). */
@@ -725,8 +795,12 @@ export type IPublicProduct = {
725
795
  slug?: string;
726
796
  title: string;
727
797
  description: string;
728
- category: ProductCategory;
798
+ productType: ProductType;
729
799
  media: IMedia[];
800
+ /** Creator-authored categories this product is filed under. Direct only — a parent category resolves its descendants server-side. */
801
+ categories?: { id: string; title: string; slug: string; parentId: string | null }[];
802
+ /** Curated collections this product belongs to, with its position in each. */
803
+ collections?: { id: string; title: string; slug: string; isFeatured: boolean; position: number }[];
730
804
  variants: IPublicProductVariant[];
731
805
  artist: string;
732
806
  credits: string;
@@ -752,6 +826,31 @@ export type QuestionnaireQuestion = {
752
826
  optional?: boolean;
753
827
  };
754
828
 
829
+ /**
830
+ * S.6 — the cancellation terms a buyer is entitled to read BEFORE they pay.
831
+ *
832
+ * Two endpoints return this shape and they answer different questions:
833
+ * - `GET /public/coaching/products/:id` → the product's LIVE policy, i.e. "what
834
+ * would I be agreeing to". Show it on the storefront.
835
+ * - `POST …/booking/reserve` → the SNAPSHOT written onto the booking, i.e.
836
+ * "what did I agree to". Show it from the details step onwards, because an
837
+ * operator editing the policy mid-checkout must not move the terms under a
838
+ * buyer who already holds the hour.
839
+ *
840
+ * `description` is rendered SERVER-side by the shared `describeCancellationPolicy`
841
+ * so the sentence at checkout is byte-identical to the one in the buyer's portal.
842
+ * It is `null` when the seller configured no policy — render nothing rather than
843
+ * inventing a right, and note that "no policy" is a different fact from `none`.
844
+ */
845
+ export type CancellationTermsView = {
846
+ policy: "none" | "until" | "anytime" | null;
847
+ cutoffHours: number | null;
848
+ /** The seller's own free text, when they wrote any. */
849
+ terms: string | null;
850
+ /** One ready-made sentence, or null when there is no policy at all. */
851
+ description: string | null;
852
+ };
853
+
755
854
  export type CoachingProduct = {
756
855
  id: string;
757
856
  title: string;
@@ -773,6 +872,8 @@ export type CoachingProduct = {
773
872
  questionnaire?: QuestionnaireQuestion[];
774
873
  /** Rating aggregate — present on the detail endpoint, null until first published review. */
775
874
  reviewAggregate?: IReviewAggregate | null;
875
+ /** The seller's CURRENT cancellation terms — present on the detail endpoint only. */
876
+ cancellation?: CancellationTermsView | null;
776
877
  };
777
878
 
778
879
  export type PublicCourseLesson = {
@@ -847,6 +948,29 @@ export type PodcastShow = {
847
948
 
848
949
  // ---- Events ------------------------------------------------------------------
849
950
 
951
+ /**
952
+ * The artist's booking fee, RESOLVED — what `GET /public/events/:id` publishes
953
+ * so a storefront can quote the real price before the buyer commits.
954
+ *
955
+ * The fee is charged per ORDER (`total_amount` goes up by it), and until this
956
+ * shipped no buyer-facing surface mentioned it anywhere.
957
+ *
958
+ * These are NOT the event's raw `ticketFeeCents` / `ticketFeeBps` columns. Those
959
+ * are `null` whenever the event inherits the artist's default — the ordinary
960
+ * case — so reading them shows no fee while one is being charged. The server
961
+ * merges the event's override over the profile default and sends the result.
962
+ */
963
+ export interface IBookingFee {
964
+ /** The flat part per ORDER, in major units of `currency`. */
965
+ feeAmount: number;
966
+ /** The percentage of the discounted subtotal, in basis points. 2.5% = 250. */
967
+ feeBps: number;
968
+ /** The artist's currency — the one ticket prices and `feeAmount` are in. */
969
+ currency: string;
970
+ /** 100, or 1 for a zero-decimal currency (JPY). The percentage rounds to it. */
971
+ minorUnitFactor: number;
972
+ }
973
+
850
974
  export interface IEvent {
851
975
  id: string;
852
976
  profileId: string;
@@ -896,6 +1020,77 @@ export interface IEvent {
896
1020
  tickets: ITicket[];
897
1021
  media: IMedia[];
898
1022
  slug: string;
1023
+ /**
1024
+ * The booking fee this event's buyers will be charged, already resolved.
1025
+ *
1026
+ * Optional because the detail endpoint composes it and the LIST endpoint does
1027
+ * not — no list surface quotes a ticket price, so there is no figure there for
1028
+ * a buyer to mistake for a final one. A page reached through `useEvents()`
1029
+ * must not assume it is present.
1030
+ */
1031
+ bookingFee?: IBookingFee | null;
1032
+ }
1033
+
1034
+ // ---- Event series ------------------------------------------------------------
1035
+
1036
+ /**
1037
+ * One date within a series, as `GET /public/event-series/:slug` returns it.
1038
+ *
1039
+ * DELIBERATELY not `IEvent`. A series page lists dates; it does not sell them —
1040
+ * the buyer follows a date through to `/events/:slug`, which is the page that
1041
+ * carries tickets, the questionnaire, terms and the checkout. Restating the
1042
+ * narrow shape here keeps that boundary visible: if a field is missing from
1043
+ * this type, the series endpoint does not publish it, and adding it is a
1044
+ * decision made on the server first.
1045
+ */
1046
+ export interface IEventSeriesDate {
1047
+ id: string;
1048
+ title: string;
1049
+ /** Links through to the event page — `/events/:slug`. */
1050
+ slug: string;
1051
+ dateTime: string;
1052
+ endDateTime: string | null;
1053
+ doorsOpenAt: string | null;
1054
+ timezone?: string;
1055
+ type: "physical" | "virtual" | "hybrid" | string;
1056
+ /**
1057
+ * A `cancelled` date STAYS in this list — a ticket-holder following their
1058
+ * link must learn why rather than meet a 404 — so any renderer MUST mark it
1059
+ * rather than assume every entry is going ahead.
1060
+ */
1061
+ status: "scheduled" | "on_sale" | "sold_out" | "cancelled" | "completed";
1062
+ cancelledAt: string | null;
1063
+ cancellationReason: string | null;
1064
+ address?: {
1065
+ name?: string;
1066
+ street?: string;
1067
+ city?: string;
1068
+ state?: string;
1069
+ country?: string;
1070
+ zipCode?: string;
1071
+ };
1072
+ }
1073
+
1074
+ /**
1075
+ * A NAMED, multi-date event series.
1076
+ *
1077
+ * Every event on the platform belongs to a series (a one-off show is a series
1078
+ * of one), but only a series that a human NAMED and that holds at least two
1079
+ * publicly-visible dates has a page. Everything else 404s from the API, so this
1080
+ * type never describes a container-of-one and a renderer never has to ask.
1081
+ */
1082
+ export interface IEventSeries {
1083
+ id: string;
1084
+ title: string;
1085
+ slug: string;
1086
+ description: string | null;
1087
+ /** The publicly-visible dates, ordered by `dateTime`, earliest first. */
1088
+ events: IEventSeriesDate[];
1089
+ media: IMedia[];
1090
+ /** `events.length`, derived server-side off the SAME list — they cannot disagree. */
1091
+ dateCount: number;
1092
+ firstDateTime: string | null;
1093
+ lastDateTime: string | null;
899
1094
  }
900
1095
 
901
1096
  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,
@@ -76,3 +76,4 @@ export * from "./work";
76
76
  // Funnel instrumentation — wrap a multi-step flow to record step-through and
77
77
  // drop-off on the first-party analytics feed.
78
78
  export * from "./funnel";
79
+ export * from "./useVariantSelection";
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
3
+ import { getMembershipAccess } from "../../../utils/membershipAccess";
3
4
 
4
5
  export interface MembershipGateState {
5
6
  hasAccess: boolean;
@@ -28,10 +29,14 @@ export interface MembershipGateProps {
28
29
  export function MembershipGate({ tiers, access, children, fallback }: MembershipGateProps) {
29
30
  const { user, isAuthenticated } = usePublicAuth();
30
31
  const membership = user?.membership;
31
- const activeMembership = !!membership && membership.status === "active";
32
+ // The SERVER's access decision, not a `status === "active"` compare. That
33
+ // compare locked out members in `past_due`/`grace` — states the backend
34
+ // deliberately keeps access through — so a card that bounced cost them the
35
+ // content they were still paying for.
36
+ const membershipGrantsAccess = getMembershipAccess(membership).hasAccess;
32
37
  const tierAllows = !tiers?.length || (membership ? tiers.includes(membership.membershipTierId) : false);
33
38
 
34
- const hasAccess = access ?? (activeMembership && tierAllows);
39
+ const hasAccess = access ?? (membershipGrantsAccess && tierAllows);
35
40
 
36
41
  if (hasAccess) return <>{children}</>;
37
42
  if (typeof fallback === "function") return <>{fallback({ hasAccess, isAuthenticated })}</>;