@tribe-nest/forge 3.4.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.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/_tests/eventWaitlist.spec.ts +122 -0
  3. package/src/data/queries/_tests/passTransfers.spec.ts +89 -0
  4. package/src/data/queries/_tests/walletPass.spec.ts +159 -0
  5. package/src/data/queries/useCoachingAvailability.ts +14 -3
  6. package/src/data/queries/useEventSeries.ts +42 -0
  7. package/src/data/queries/useEventWaitlist.ts +429 -0
  8. package/src/data/queries/useEvents.ts +10 -1
  9. package/src/data/queries/useMyBookings.ts +211 -0
  10. package/src/data/queries/useMyTickets.ts +154 -0
  11. package/src/data/queries/usePassTransfers.ts +318 -0
  12. package/src/data/queries/useSubscriptions.ts +53 -0
  13. package/src/data/queries/useWalletPass.ts +236 -0
  14. package/src/index.ts +9 -0
  15. package/src/server/_tests/siteBootstrap.spec.ts +131 -0
  16. package/src/server/index.ts +142 -6
  17. package/src/types/diagnostics.ts +49 -0
  18. package/src/types/models.ts +146 -0
  19. package/src/ui/format/bookingFee.ts +98 -0
  20. package/src/ui/headless/coaching/useCoachingBooking.ts +19 -0
  21. package/src/ui/headless/event/useEventCheckout.ts +61 -2
  22. package/src/ui/headless/membership/MembershipGate.tsx +7 -2
  23. package/src/ui/index.ts +29 -0
  24. package/src/ui/shell/PoweredBy.tsx +6 -4
  25. package/src/ui/shell/PreviewDiagnostics.tsx +80 -0
  26. package/src/ui/shell/TribeNestApp.tsx +24 -0
  27. package/src/ui/shell/diagnosticsGating.spec.ts +102 -0
  28. package/src/ui/shell/diagnosticsGating.ts +90 -0
  29. package/src/ui/shell/shellGating.spec.ts +40 -1
  30. package/src/ui/shell/shellGating.ts +33 -0
  31. package/src/ui/styled/AccountDashboard.tsx +643 -5
  32. package/src/ui/styled/CancellationTerms.tsx +70 -0
  33. package/src/ui/styled/CoachingBooking.tsx +10 -0
  34. package/src/ui/styled/CoachingDetail.tsx +4 -0
  35. package/src/ui/styled/EventDetail.tsx +18 -0
  36. package/src/ui/styled/EventSeriesDetail.tsx +222 -0
  37. package/src/ui/styled/EventTickets.tsx +58 -0
  38. package/src/ui/styled/EventWaitlist.tsx +448 -0
  39. package/src/ui/styled/TicketTransfer.tsx +393 -0
  40. package/src/ui/styled/WalletPassButtons.tsx +208 -0
  41. package/src/ui/styled/_tests/WalletPassButtons.spec.tsx +223 -0
  42. package/src/utils/membershipAccess.ts +182 -0
@@ -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,
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
3
+ import { getMembershipAccess } from "../../../utils/membershipAccess";
3
4
 
4
5
  export interface MembershipGateState {
5
6
  hasAccess: boolean;
@@ -28,10 +29,14 @@ export interface MembershipGateProps {
28
29
  export function MembershipGate({ tiers, access, children, fallback }: MembershipGateProps) {
29
30
  const { user, isAuthenticated } = usePublicAuth();
30
31
  const membership = user?.membership;
31
- const activeMembership = !!membership && membership.status === "active";
32
+ // The SERVER's access decision, not a `status === "active"` compare. That
33
+ // compare locked out members in `past_due`/`grace` — states the backend
34
+ // deliberately keeps access through — so a card that bounced cost them the
35
+ // content they were still paying for.
36
+ const membershipGrantsAccess = getMembershipAccess(membership).hasAccess;
32
37
  const tierAllows = !tiers?.length || (membership ? tiers.includes(membership.membershipTierId) : false);
33
38
 
34
- const hasAccess = access ?? (activeMembership && tierAllows);
39
+ const hasAccess = access ?? (membershipGrantsAccess && tierAllows);
35
40
 
36
41
  if (hasAccess) return <>{children}</>;
37
42
  if (typeof fallback === "function") return <>{fallback({ hasAccess, isAuthenticated })}</>;
package/src/ui/index.ts CHANGED
@@ -72,10 +72,18 @@ export { MembershipTiers, type MembershipTiersProps } from "./styled/MembershipT
72
72
  export { EventsList, type EventsListProps } from "./styled/EventsList";
73
73
  export { EventTickets, type EventTicketsProps } from "./styled/EventTickets";
74
74
  export { EventCountdown, type EventCountdownProps } from "./styled/EventCountdown";
75
+ export { EventWaitlist, type EventWaitlistProps, formatCountdown } from "./styled/EventWaitlist";
75
76
  export { EventDetail, type EventDetailProps } from "./styled/EventDetail";
77
+ // Phase 5 — the page for a NAMED, multi-date series. The API refuses anything
78
+ // else, so this component never has to decide whether a series is publishable.
79
+ export { EventSeriesDetail, type EventSeriesDetailProps } from "./styled/EventSeriesDetail";
76
80
  export { AddToCalendar, type AddToCalendarProps } from "./styled/AddToCalendar";
81
+ export { WalletPassButtons, type WalletPassButtonsProps } from "./styled/WalletPassButtons";
77
82
  export { CoachingBooking, type CoachingBookingProps } from "./styled/CoachingBooking";
78
83
  export { CoachingDetail, type CoachingDetailProps } from "./styled/CoachingDetail";
84
+ // S.6 — the buyer-facing cancellation policy block. Exported so a code site that
85
+ // lays out its own booking page still has ONE renderer for these terms.
86
+ export { CancellationTerms, type CancellationTermsProps } from "./styled/CancellationTerms";
79
87
  export { CourseCheckout, type CourseCheckoutProps } from "./styled/CourseCheckout";
80
88
  export { CourseDetail, type CourseDetailProps } from "./styled/CourseDetail";
81
89
  export { PostsFeed, type PostsFeedProps } from "./styled/PostsFeed";
@@ -85,6 +93,12 @@ export { SignupForm, type SignupFormProps } from "./styled/SignupForm";
85
93
  export { ForgotPasswordForm, type ForgotPasswordFormProps } from "./styled/ForgotPasswordForm";
86
94
  export { ResetPasswordForm, type ResetPasswordFormProps } from "./styled/ResetPasswordForm";
87
95
  export { AccountDashboard, type AccountDashboardProps, type AccountTabKey, ACCOUNT_TABS } from "./styled/AccountDashboard";
96
+ export {
97
+ TicketTransferPanel,
98
+ type TicketTransferPanelProps,
99
+ ClaimTicketTransfer,
100
+ type ClaimTicketTransferProps,
101
+ } from "./styled/TicketTransfer";
88
102
  export { AudioPlayer, type AudioPlayerProps } from "./styled/AudioPlayer";
89
103
  export { ForgeAnalytics, type ForgeAnalyticsProps } from "./analytics/ForgeAnalytics";
90
104
  export { PageMetaPixel, usePageMetaPixel, type PageMetaPixelProps } from "./analytics/PageMetaPixel";
@@ -134,6 +148,10 @@ export { DonationPage, type DonationPageProps } from "./styled/DonationPage";
134
148
  export { ReviewsSection, StarRow, type ReviewsSectionProps } from "./styled/ReviewsSection";
135
149
  export { ReviewForm, type ReviewFormProps } from "./styled/ReviewForm";
136
150
  export { PriceDisplay, priceTaxCaption, summarizeTaxQuote, type PriceDisplayProps } from "./format/PriceDisplay";
151
+ // The artist's booking fee, for a site rendering its own ticket UI rather than
152
+ // the styled blocks. Resolution stays on the server (`IEvent.bookingFee`); these
153
+ // only do the arithmetic and the wording.
154
+ export { computeBookingFeeAmount, hasBookingFee, describeBookingFee } from "./format/bookingFee";
137
155
  export { usePricesIncludeTax } from "../data/queries/useWebsite";
138
156
 
139
157
  // PWA (installable code sites) — SW registration + install/push UX (Tier 2).
@@ -141,6 +159,17 @@ export { PwaRegistration, type PwaRegistrationProps } from "./styled/PwaRegistra
141
159
  export { InstallBanner, type InstallBannerProps } from "./styled/InstallBanner";
142
160
  // The single root component — providers + audio + analytics + PWA + consent in one.
143
161
  export { TribeNestApp, type TribeNestAppProps } from "./shell/TribeNestApp";
162
+ // The SSR-bootstrap failure banner + its gating. Exported so a bespoke shell that
163
+ // doesn't use <TribeNestApp> can still fail loudly instead of silently rendering
164
+ // Forge's defaults when the API is unreachable.
165
+ export { PreviewDiagnostics } from "./shell/PreviewDiagnostics";
166
+ export {
167
+ previewDiagnosticsEnabled,
168
+ resolvePreviewDiagnostics,
169
+ previewDiagnosticsMessage,
170
+ type PreviewDiagnosticsMessage,
171
+ } from "./shell/diagnosticsGating";
172
+ export type { ApiProbeResult, ForgeSsrDiagnostics, SsrFetchFailure } from "../types/diagnostics";
144
173
  export { PushOptIn, type PushOptInProps } from "./styled/PushOptIn";
145
174
 
146
175
  // Community (forum) styled blocks.
@@ -1,7 +1,6 @@
1
+ import { useForge } from "../../provider/ForgeProvider";
1
2
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
2
-
3
- /** Where the badge points. utm params so the referral traffic is attributable. */
4
- const HREF = "https://tribenest.co/?utm_source=powered_by&utm_medium=badge&utm_campaign=creator_site";
3
+ import { poweredByHref } from "./shellGating";
5
4
 
6
5
  export interface PoweredByProps {
7
6
  /** Live released build only — see `shellPoweredByEnabled`. */
@@ -27,7 +26,10 @@ export interface PoweredByProps {
27
26
  */
28
27
  export function PoweredBy({ enabled = true }: PoweredByProps) {
29
28
  const t = useThemeTokens();
29
+ const { subdomain, profileId, appId } = useForge();
30
30
  if (!enabled) return null;
31
+ // Tagged from the identity ForgeProvider was built with — see `poweredByHref`.
32
+ const href = poweredByHref({ subdomain, profileId, appId });
31
33
  return (
32
34
  <div
33
35
  style={{
@@ -39,7 +41,7 @@ export function PoweredBy({ enabled = true }: PoweredByProps) {
39
41
  }}
40
42
  >
41
43
  <a
42
- href={HREF}
44
+ href={href}
43
45
  target="_blank"
44
46
  // nofollow: the same link on every site we host is exactly the pattern
45
47
  // Google reads as a link scheme. Drop it if the SEO value is judged
@@ -0,0 +1,80 @@
1
+ import { useState } from "react";
2
+ import type { ForgeSsrDiagnostics } from "../../types/diagnostics";
3
+ import { previewDiagnosticsMessage } from "./diagnosticsGating";
4
+
5
+ /**
6
+ * The loud failure. Rendered by `<TribeNestApp>` in the builder preview and on
7
+ * review Workers — never on the live published site (see
8
+ * `previewDiagnosticsEnabled`).
9
+ *
10
+ * EVERY COLOR HERE IS A LITERAL, on purpose. This banner announces that the
11
+ * theme could not be fetched, so it cannot itself be themed: `var(--forge-*)`
12
+ * would resolve to the very fallbacks it is warning about, and on a dark-ish
13
+ * default it could render invisible. It is also the one component that must
14
+ * survive a totally broken render, so it takes nothing from context.
15
+ */
16
+ export function PreviewDiagnostics({ diagnostics }: { diagnostics: ForgeSsrDiagnostics }) {
17
+ const [open, setOpen] = useState(false);
18
+ const msg = previewDiagnosticsMessage(diagnostics);
19
+
20
+ return (
21
+ <div
22
+ role="alert"
23
+ style={{
24
+ position: "fixed",
25
+ top: 0,
26
+ left: 0,
27
+ right: 0,
28
+ // Above anything a site can reasonably stack, including sticky navs.
29
+ zIndex: 2147483000,
30
+ background: "#7f1d1d",
31
+ color: "#fef2f2",
32
+ borderBottom: "1px solid #dc2626",
33
+ fontFamily:
34
+ 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
35
+ fontSize: 13,
36
+ lineHeight: 1.45,
37
+ boxShadow: "0 2px 12px rgba(0,0,0,0.35)",
38
+ }}
39
+ >
40
+ <div style={{ maxWidth: 900, margin: "0 auto", padding: "10px 14px" }}>
41
+ <div style={{ display: "flex", alignItems: "flex-start", gap: 10 }}>
42
+ <span aria-hidden style={{ fontSize: 15, lineHeight: 1.3 }}>
43
+ ⚠️
44
+ </span>
45
+ <div style={{ flex: 1, minWidth: 0 }}>
46
+ <strong style={{ fontWeight: 700 }}>{msg.title}</strong>
47
+ <div style={{ marginTop: 3, color: "#fecaca" }}>{msg.consequence}</div>
48
+ </div>
49
+ <button
50
+ type="button"
51
+ onClick={() => setOpen((v) => !v)}
52
+ aria-expanded={open}
53
+ style={{
54
+ flexShrink: 0,
55
+ background: "transparent",
56
+ border: "1px solid #f87171",
57
+ borderRadius: 6,
58
+ color: "#fef2f2",
59
+ cursor: "pointer",
60
+ fontSize: 12,
61
+ padding: "3px 9px",
62
+ }}
63
+ >
64
+ {open ? "Hide" : "Details"}
65
+ </button>
66
+ </div>
67
+
68
+ {open && (
69
+ <ul style={{ margin: "9px 0 0", padding: "0 0 0 26px", color: "#fecaca" }}>
70
+ {msg.details.map((line) => (
71
+ <li key={line} style={{ marginTop: 2, wordBreak: "break-word" }}>
72
+ {line}
73
+ </li>
74
+ ))}
75
+ </ul>
76
+ )}
77
+ </div>
78
+ </div>
79
+ );
80
+ }