@tribe-nest/forge 3.9.0 → 3.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/data/queries/useCoachingAvailability.ts +14 -3
- package/src/data/queries/useEventSeries.ts +42 -0
- package/src/data/queries/useEvents.ts +10 -1
- package/src/data/queries/useSubscriptions.ts +53 -0
- package/src/index.ts +4 -0
- package/src/server/index.ts +20 -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 +10 -0
- package/src/ui/styled/AccountDashboard.tsx +45 -4
- package/src/ui/styled/CancellationTerms.tsx +70 -0
- package/src/ui/styled/CoachingBooking.tsx +10 -0
- package/src/ui/styled/CoachingDetail.tsx +4 -0
- package/src/ui/styled/EventDetail.tsx +12 -0
- package/src/ui/styled/EventSeriesDetail.tsx +222 -0
- package/src/ui/styled/EventTickets.tsx +58 -0
- package/src/utils/membershipAccess.ts +182 -0
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BookingSlot, PillarDiscountQuote } from "../../types/models";
|
|
1
|
+
import type { BookingSlot, CancellationTermsView, PillarDiscountQuote } from "../../types/models";
|
|
2
2
|
import { useForge } from "../../provider/ForgeProvider";
|
|
3
3
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
4
4
|
|
|
@@ -18,11 +18,22 @@ export function useCoachingAvailability(productId?: string, fromDate?: string, t
|
|
|
18
18
|
});
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
export type ReserveCoachingBookingResult = {
|
|
22
|
+
bookingId: string;
|
|
23
|
+
totalAmount?: number;
|
|
24
|
+
/**
|
|
25
|
+
* The cancellation terms SNAPSHOTTED onto this booking (S.6). Authoritative
|
|
26
|
+
* from here on — the product's live policy may be edited while the hour is
|
|
27
|
+
* held, and the buyer's rights are governed by this copy, not by that read.
|
|
28
|
+
*/
|
|
29
|
+
cancellation?: CancellationTermsView | null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Reserve a coaching booking slot. Returns the created booking + its agreed terms. */
|
|
22
33
|
export function useReserveCoachingBooking(productId?: string) {
|
|
23
34
|
const { client } = useForge();
|
|
24
35
|
|
|
25
|
-
return useMutation<
|
|
36
|
+
return useMutation<ReserveCoachingBookingResult, unknown, { slotId: string }>({
|
|
26
37
|
mutationFn: async ({ slotId }) => {
|
|
27
38
|
const res = await client.post(`/public/coaching/products/${productId}/booking/reserve`, {
|
|
28
39
|
slotId,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { IEventSeries } from "../../types/models";
|
|
2
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A named, multi-date event series by slug (or id).
|
|
7
|
+
*
|
|
8
|
+
* ## There is deliberately no `useEventSeriesList`
|
|
9
|
+
*
|
|
10
|
+
* Every event belongs to a series, so a list endpoint would be a list of every
|
|
11
|
+
* event on the site wearing a different hat — and the ones the API refuses to
|
|
12
|
+
* publish (unnamed containers, single-date shows) would show up as holes in it.
|
|
13
|
+
* A series is a DESTINATION the operator shares, not a browsable index; the
|
|
14
|
+
* events list is already the browsable index.
|
|
15
|
+
*
|
|
16
|
+
* ## A 404 is the normal answer
|
|
17
|
+
*
|
|
18
|
+
* The API refuses any series that nobody named or that holds fewer than two
|
|
19
|
+
* publicly-visible dates. That is not an error state to surface as "something
|
|
20
|
+
* went wrong" — it means there is no such page — so a renderer should treat
|
|
21
|
+
* `isError` here the same way it treats a missing event.
|
|
22
|
+
*
|
|
23
|
+
* Pass `initialData` (a series fetched server-side in a route loader) to seed
|
|
24
|
+
* the query, so the page server-renders for SEO with no client spinner.
|
|
25
|
+
*/
|
|
26
|
+
export function useEventSeries(slugOrId?: string, options?: { initialData?: IEventSeries }) {
|
|
27
|
+
const { client, profileId } = useForge();
|
|
28
|
+
|
|
29
|
+
return useQuery<IEventSeries>({
|
|
30
|
+
queryKey: ["event-series", slugOrId, profileId],
|
|
31
|
+
queryFn: async () => {
|
|
32
|
+
const res = await client.get(`/public/event-series/${encodeURIComponent(slugOrId!)}`, {
|
|
33
|
+
params: { profileId },
|
|
34
|
+
});
|
|
35
|
+
return res.data;
|
|
36
|
+
},
|
|
37
|
+
enabled: !!slugOrId && !!profileId && !!client,
|
|
38
|
+
initialData: options?.initialData,
|
|
39
|
+
// A 404 here means "no such page", which no amount of retrying changes.
|
|
40
|
+
retry: false,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -59,12 +59,21 @@ export type CreateEventOrderInput = {
|
|
|
59
59
|
export type CreateEventOrderResult = {
|
|
60
60
|
orderId: string;
|
|
61
61
|
isFreeCheckout?: boolean;
|
|
62
|
-
/** NET of any discount. */
|
|
62
|
+
/** NET of any discount, and INCLUSIVE of the booking fee below. */
|
|
63
63
|
totalAmount?: number;
|
|
64
64
|
// Additive (S.3) — present on every response, discounted or not.
|
|
65
65
|
/** GROSS, before any discount. */
|
|
66
66
|
subTotal?: number;
|
|
67
67
|
discountAmount?: number;
|
|
68
|
+
/**
|
|
69
|
+
* The artist's booking fee this order was ACTUALLY charged, in major units —
|
|
70
|
+
* the server's own snapshot, not a re-derivation.
|
|
71
|
+
*
|
|
72
|
+
* It is what closes the arithmetic: `subTotal - discountAmount + feeAmount`
|
|
73
|
+
* is `totalAmount`. Before it existed the three fields did not reconcile and
|
|
74
|
+
* no line on any checkout explained the gap.
|
|
75
|
+
*/
|
|
76
|
+
feeAmount?: number;
|
|
68
77
|
couponId?: string | null;
|
|
69
78
|
/** Every discount that applied, entered OR automatic. */
|
|
70
79
|
appliedCoupons?: AppliedDiscountCoupon[];
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { useForge } from "../../provider/ForgeProvider";
|
|
2
2
|
import { useMutation } from "@tanstack/react-query";
|
|
3
|
+
import { usePublicAuth } from "../../contexts/PublicAuthContext";
|
|
4
|
+
import {
|
|
5
|
+
getMembershipAccess,
|
|
6
|
+
getMembershipStatusMessage,
|
|
7
|
+
type MembershipAccessSummary,
|
|
8
|
+
type MembershipStatusMessage,
|
|
9
|
+
} from "../../utils/membershipAccess";
|
|
3
10
|
|
|
4
11
|
export type CreateSubscriptionInput = {
|
|
5
12
|
amount?: number;
|
|
@@ -48,6 +55,52 @@ export function useCancelMembership() {
|
|
|
48
55
|
});
|
|
49
56
|
}
|
|
50
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The signed-in member's access + billing message, in one call.
|
|
60
|
+
*
|
|
61
|
+
* Every membership-aware surface should use this instead of reading
|
|
62
|
+
* `user.membership.status`. `access` answers "can they see it", `message`
|
|
63
|
+
* answers "what do we tell them" — two different questions, and conflating
|
|
64
|
+
* them is what locked past_due members out while calling them "Cancelled".
|
|
65
|
+
*/
|
|
66
|
+
export function useMembershipAccess(): {
|
|
67
|
+
access: MembershipAccessSummary;
|
|
68
|
+
message: MembershipStatusMessage;
|
|
69
|
+
} {
|
|
70
|
+
const { user } = usePublicAuth();
|
|
71
|
+
const access = getMembershipAccess(user?.membership);
|
|
72
|
+
return { access, message: getMembershipStatusMessage(access) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Open the payment provider's billing-management page for the signed-in member.
|
|
77
|
+
*
|
|
78
|
+
* This is the other half of keeping access through `past_due`: the grace window
|
|
79
|
+
* is only a recovery window if the member can actually fix their card inside
|
|
80
|
+
* it. Nothing about the caller is sent — the server derives the provider
|
|
81
|
+
* customer from the session, because a customer id in a request body is a
|
|
82
|
+
* cross-tenant leak waiting to happen.
|
|
83
|
+
*
|
|
84
|
+
* `returnUrl` defaults to the current page so the member lands back where they
|
|
85
|
+
* were. The returned `url` is a one-time link — redirect, never render it.
|
|
86
|
+
*/
|
|
87
|
+
export function useOpenBillingPortal() {
|
|
88
|
+
const { client, profileId } = useForge();
|
|
89
|
+
|
|
90
|
+
return useMutation<{ url: string; provider: string }, unknown, { returnUrl?: string } | void>({
|
|
91
|
+
mutationFn: async (input) => {
|
|
92
|
+
const returnUrl =
|
|
93
|
+
(input && "returnUrl" in input ? input.returnUrl : undefined) ??
|
|
94
|
+
(typeof window !== "undefined" ? window.location.href : undefined);
|
|
95
|
+
const res = await client.post("/public/payments/subscriptions/billing-portal", {
|
|
96
|
+
profileId,
|
|
97
|
+
...(returnUrl ? { returnUrl } : {}),
|
|
98
|
+
});
|
|
99
|
+
return res.data;
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
51
104
|
/**
|
|
52
105
|
* Reconcile the most recent subscription after a checkout return.
|
|
53
106
|
* Returns the latest subscription status (e.g. "active").
|
package/src/index.ts
CHANGED
|
@@ -69,6 +69,7 @@ export * from "./data/queries/useBlog";
|
|
|
69
69
|
export * from "./data/queries/usePodcast";
|
|
70
70
|
export * from "./data/queries/useCollections";
|
|
71
71
|
export * from "./data/queries/useEvents";
|
|
72
|
+
export * from "./data/queries/useEventSeries";
|
|
72
73
|
export * from "./data/queries/useInvoice";
|
|
73
74
|
export * from "./data/queries/usePaymentLink";
|
|
74
75
|
export * from "./data/queries/useLeadMagnet";
|
|
@@ -123,3 +124,6 @@ export * from "./utils/structuredData";
|
|
|
123
124
|
// reaches existing sites through the normal Forge publish.
|
|
124
125
|
export * from "./utils/headMeta";
|
|
125
126
|
export * from "./utils/formatDateTime";
|
|
127
|
+
// The ONE membership access predicate + the messaging that is deliberately
|
|
128
|
+
// separate from it. Every surface that gates on a membership imports from here.
|
|
129
|
+
export * from "./utils/membershipAccess";
|
package/src/server/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ import type { ApiProbeResult, ForgeSsrDiagnostics, SsrFetchFailure } from "../ty
|
|
|
33
33
|
export type { ApiProbeResult, ForgeSsrDiagnostics, SsrFetchFailure };
|
|
34
34
|
import type {
|
|
35
35
|
IEvent,
|
|
36
|
+
IEventSeries,
|
|
36
37
|
IPublicProduct,
|
|
37
38
|
PublicCourse,
|
|
38
39
|
CoachingProduct,
|
|
@@ -221,6 +222,25 @@ export function fetchEventServer(opts: { apiUrl: string; profileId?: string; idO
|
|
|
221
222
|
});
|
|
222
223
|
}
|
|
223
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Fetch a NAMED, multi-date event series by slug (or id) for SSR.
|
|
227
|
+
*
|
|
228
|
+
* Resolves to `null` for a series that does not exist, is not this profile's,
|
|
229
|
+
* was never named by a human, or holds fewer than two publicly-visible dates —
|
|
230
|
+
* the API answers 404 to all four identically, on purpose, so that a caller
|
|
231
|
+
* cannot tell them apart and turn the endpoint into an oracle. A `null` here
|
|
232
|
+
* means "render the not-found page", not "the fetch failed".
|
|
233
|
+
*/
|
|
234
|
+
export function fetchEventSeriesServer(opts: {
|
|
235
|
+
apiUrl: string;
|
|
236
|
+
profileId?: string;
|
|
237
|
+
idOrSlug: string;
|
|
238
|
+
}): Promise<IEventSeries | null> {
|
|
239
|
+
return getJson<IEventSeries>(opts.apiUrl, `/public/event-series/${encodeURIComponent(opts.idOrSlug)}`, {
|
|
240
|
+
profileId: opts.profileId,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
224
244
|
/** Fetch a single product by id or slug for SSR. */
|
|
225
245
|
export function fetchProductServer(opts: {
|
|
226
246
|
apiUrl: string;
|
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,
|