@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.
- package/package.json +1 -1
- package/src/data/queries/_tests/eventWaitlist.spec.ts +122 -0
- package/src/data/queries/_tests/passTransfers.spec.ts +89 -0
- package/src/data/queries/_tests/walletPass.spec.ts +159 -0
- package/src/data/queries/useCoachingAvailability.ts +14 -3
- package/src/data/queries/useEventSeries.ts +42 -0
- package/src/data/queries/useEventWaitlist.ts +429 -0
- package/src/data/queries/useEvents.ts +10 -1
- package/src/data/queries/useMyBookings.ts +211 -0
- package/src/data/queries/useMyTickets.ts +154 -0
- package/src/data/queries/usePassTransfers.ts +318 -0
- package/src/data/queries/useSubscriptions.ts +53 -0
- package/src/data/queries/useWalletPass.ts +236 -0
- package/src/index.ts +9 -0
- package/src/server/_tests/siteBootstrap.spec.ts +131 -0
- package/src/server/index.ts +142 -6
- package/src/types/diagnostics.ts +49 -0
- package/src/types/models.ts +146 -0
- package/src/ui/format/bookingFee.ts +98 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +19 -0
- package/src/ui/headless/event/useEventCheckout.ts +61 -2
- package/src/ui/headless/membership/MembershipGate.tsx +7 -2
- package/src/ui/index.ts +29 -0
- package/src/ui/shell/PoweredBy.tsx +6 -4
- package/src/ui/shell/PreviewDiagnostics.tsx +80 -0
- package/src/ui/shell/TribeNestApp.tsx +24 -0
- package/src/ui/shell/diagnosticsGating.spec.ts +102 -0
- package/src/ui/shell/diagnosticsGating.ts +90 -0
- package/src/ui/shell/shellGating.spec.ts +40 -1
- package/src/ui/shell/shellGating.ts +33 -0
- package/src/ui/styled/AccountDashboard.tsx +643 -5
- package/src/ui/styled/CancellationTerms.tsx +70 -0
- package/src/ui/styled/CoachingBooking.tsx +10 -0
- package/src/ui/styled/CoachingDetail.tsx +4 -0
- package/src/ui/styled/EventDetail.tsx +18 -0
- package/src/ui/styled/EventSeriesDetail.tsx +222 -0
- package/src/ui/styled/EventTickets.tsx +58 -0
- package/src/ui/styled/EventWaitlist.tsx +448 -0
- package/src/ui/styled/TicketTransfer.tsx +393 -0
- package/src/ui/styled/WalletPassButtons.tsx +208 -0
- package/src/ui/styled/_tests/WalletPassButtons.spec.tsx +223 -0
- package/src/utils/membershipAccess.ts +182 -0
package/src/types/models.ts
CHANGED
|
@@ -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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
32
|
+
// The SERVER's access decision, not a `status === "active"` compare. That
|
|
33
|
+
// compare locked out members in `past_due`/`grace` — states the backend
|
|
34
|
+
// deliberately keeps access through — so a card that bounced cost them the
|
|
35
|
+
// content they were still paying for.
|
|
36
|
+
const membershipGrantsAccess = getMembershipAccess(membership).hasAccess;
|
|
32
37
|
const tierAllows = !tiers?.length || (membership ? tiers.includes(membership.membershipTierId) : false);
|
|
33
38
|
|
|
34
|
-
const hasAccess = access ?? (
|
|
39
|
+
const hasAccess = access ?? (membershipGrantsAccess && tierAllows);
|
|
35
40
|
|
|
36
41
|
if (hasAccess) return <>{children}</>;
|
|
37
42
|
if (typeof fallback === "function") return <>{fallback({ hasAccess, isAuthenticated })}</>;
|
package/src/ui/index.ts
CHANGED
|
@@ -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={
|
|
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
|
+
}
|