@tribe-nest/forge 3.9.0 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.9.0",
3
+ "version": "3.14.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "description": "Forge the headless React SDK for building custom TribeNest creator sites (the Hydrogen of TribeNest). Exposes the backend (memberships, commerce, gated content, ticketing, courses, booking, auth) as data + behavior primitives.",
7
+ "description": "Forge \u2014 the headless React SDK for building custom TribeNest creator sites (the Hydrogen of TribeNest). Exposes the backend (memberships, commerce, gated content, ticketing, courses, booking, auth) as data + behavior primitives.",
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
10
  "./ui": "./src/ui/index.ts",
@@ -31,7 +31,19 @@ export type CartItem = {
31
31
  payWhatYouWant: boolean;
32
32
  color?: string;
33
33
  size?: string;
34
- deliveryType?: ProductDeliveryType;
34
+ /**
35
+ * REQUIRED, and deliberately so.
36
+ *
37
+ * Checkout decides whether to ask for a shipping address from this field on
38
+ * the cart LINE. While it was optional, a caller that forgot it produced an
39
+ * item that silently read as non-physical — the Craft music page did exactly
40
+ * that, and a vinyl reached checkout with the buyer never asked where to send
41
+ * the record. A missing address is not recoverable after payment.
42
+ *
43
+ * Making it required turns that into a compile error instead of a defect
44
+ * nobody sees until an order arrives with nowhere to ship it.
45
+ */
46
+ deliveryType: ProductDeliveryType;
35
47
  /** Set automatically from `?addonFor=` — see `addToCart`. */
36
48
  attachedTo?: AttachedTo;
37
49
  };
@@ -1,4 +1,4 @@
1
- import type { BookingSlot, PillarDiscountQuote } from "../../types/models";
1
+ import type { BookingSlot, CancellationTermsView, PillarDiscountQuote } from "../../types/models";
2
2
  import { useForge } from "../../provider/ForgeProvider";
3
3
  import { useMutation, useQuery } from "@tanstack/react-query";
4
4
 
@@ -18,11 +18,22 @@ export function useCoachingAvailability(productId?: string, fromDate?: string, t
18
18
  });
19
19
  }
20
20
 
21
- /** Reserve a coaching booking slot. Returns the created booking id. */
21
+ export type ReserveCoachingBookingResult = {
22
+ bookingId: string;
23
+ totalAmount?: number;
24
+ /**
25
+ * The cancellation terms SNAPSHOTTED onto this booking (S.6). Authoritative
26
+ * from here on — the product's live policy may be edited while the hour is
27
+ * held, and the buyer's rights are governed by this copy, not by that read.
28
+ */
29
+ cancellation?: CancellationTermsView | null;
30
+ };
31
+
32
+ /** Reserve a coaching booking slot. Returns the created booking + its agreed terms. */
22
33
  export function useReserveCoachingBooking(productId?: string) {
23
34
  const { client } = useForge();
24
35
 
25
- return useMutation<{ bookingId: string }, unknown, { slotId: string }>({
36
+ return useMutation<ReserveCoachingBookingResult, unknown, { slotId: string }>({
26
37
  mutationFn: async ({ slotId }) => {
27
38
  const res = await client.post(`/public/coaching/products/${productId}/booking/reserve`, {
28
39
  slotId,
@@ -0,0 +1,42 @@
1
+ import type { IEventSeries } from "../../types/models";
2
+ import { useForge } from "../../provider/ForgeProvider";
3
+ import { useQuery } from "@tanstack/react-query";
4
+
5
+ /**
6
+ * A named, multi-date event series by slug (or id).
7
+ *
8
+ * ## There is deliberately no `useEventSeriesList`
9
+ *
10
+ * Every event belongs to a series, so a list endpoint would be a list of every
11
+ * event on the site wearing a different hat — and the ones the API refuses to
12
+ * publish (unnamed containers, single-date shows) would show up as holes in it.
13
+ * A series is a DESTINATION the operator shares, not a browsable index; the
14
+ * events list is already the browsable index.
15
+ *
16
+ * ## A 404 is the normal answer
17
+ *
18
+ * The API refuses any series that nobody named or that holds fewer than two
19
+ * publicly-visible dates. That is not an error state to surface as "something
20
+ * went wrong" — it means there is no such page — so a renderer should treat
21
+ * `isError` here the same way it treats a missing event.
22
+ *
23
+ * Pass `initialData` (a series fetched server-side in a route loader) to seed
24
+ * the query, so the page server-renders for SEO with no client spinner.
25
+ */
26
+ export function useEventSeries(slugOrId?: string, options?: { initialData?: IEventSeries }) {
27
+ const { client, profileId } = useForge();
28
+
29
+ return useQuery<IEventSeries>({
30
+ queryKey: ["event-series", slugOrId, profileId],
31
+ queryFn: async () => {
32
+ const res = await client.get(`/public/event-series/${encodeURIComponent(slugOrId!)}`, {
33
+ params: { profileId },
34
+ });
35
+ return res.data;
36
+ },
37
+ enabled: !!slugOrId && !!profileId && !!client,
38
+ initialData: options?.initialData,
39
+ // A 404 here means "no such page", which no amount of retrying changes.
40
+ retry: false,
41
+ });
42
+ }
@@ -59,12 +59,21 @@ export type CreateEventOrderInput = {
59
59
  export type CreateEventOrderResult = {
60
60
  orderId: string;
61
61
  isFreeCheckout?: boolean;
62
- /** NET of any discount. */
62
+ /** NET of any discount, and INCLUSIVE of the booking fee below. */
63
63
  totalAmount?: number;
64
64
  // Additive (S.3) — present on every response, discounted or not.
65
65
  /** GROSS, before any discount. */
66
66
  subTotal?: number;
67
67
  discountAmount?: number;
68
+ /**
69
+ * The artist's booking fee this order was ACTUALLY charged, in major units —
70
+ * the server's own snapshot, not a re-derivation.
71
+ *
72
+ * It is what closes the arithmetic: `subTotal - discountAmount + feeAmount`
73
+ * is `totalAmount`. Before it existed the three fields did not reconcile and
74
+ * no line on any checkout explained the gap.
75
+ */
76
+ feeAmount?: number;
68
77
  couponId?: string | null;
69
78
  /** Every discount that applied, entered OR automatic. */
70
79
  appliedCoupons?: AppliedDiscountCoupon[];
@@ -1,23 +1,39 @@
1
- import type { IPublicProduct, PaginatedData, ProductCategory } from "../../types/models";
1
+ import type { IPublicProduct, PaginatedData, ProductType } from "../../types/models";
2
2
  import { useForge } from "../../provider/ForgeProvider";
3
3
  import { useQuery } from "@tanstack/react-query";
4
4
 
5
5
  export interface GetProductsParams {
6
6
  query?: string;
7
- category?: ProductCategory[];
7
+ /** What the products ARE. Renamed from `category` in the taxonomy split. */
8
+ productType?: ProductType[];
9
+ /**
10
+ * @deprecated The old name for `productType`. Still honoured, and honoured
11
+ * deliberately: callers usually build this object in a `useMemo` and pass it
12
+ * as a VARIABLE, which turns off TypeScript's excess-property check — so a
13
+ * caller left on the old name compiles clean and silently loses its filter,
14
+ * listing the whole catalogue on a page meant to show one type. Dropping the
15
+ * key would make that failure mode look like working code.
16
+ */
17
+ category?: ProductType[];
18
+ /**
19
+ * A creator-authored category. Descendant-inclusive: filtering on "Apparel"
20
+ * returns everything filed beneath it, at any depth.
21
+ */
22
+ categoryId?: string;
23
+ collectionId?: string;
8
24
  page?: number;
9
25
  releaseType?: string;
10
26
  }
11
27
 
12
- /** Featured products for the profile, optionally filtered by category. */
13
- export function useFeaturedProducts(category?: ProductCategory) {
28
+ /** Featured products for the profile, optionally filtered by product type. */
29
+ export function useFeaturedProducts(productType?: ProductType) {
14
30
  const { client, profileId } = useForge();
15
31
 
16
32
  return useQuery<IPublicProduct[]>({
17
- queryKey: ["featured-products", profileId, category],
33
+ queryKey: ["featured-products", profileId, productType],
18
34
  queryFn: async () => {
19
35
  const res = await client.get("/public/products/featured", {
20
- params: { profileId, category },
36
+ params: { profileId, productType },
21
37
  });
22
38
  return res.data;
23
39
  },
@@ -34,7 +50,9 @@ export function useGetProducts(params?: GetProductsParams, enabled = true) {
34
50
  const res = await client.get("/public/products", {
35
51
  params: {
36
52
  profileId: profileId,
37
- category: params?.category,
53
+ productType: params?.productType ?? params?.category,
54
+ categoryId: params?.categoryId,
55
+ collectionId: params?.collectionId,
38
56
  page: params?.page || 1,
39
57
  limit: 10,
40
58
  filter: {
@@ -90,11 +108,21 @@ export function useGetProductsByIds(productIds: string[]) {
90
108
  });
91
109
  }
92
110
 
93
- export function useGetProductCategories() {
111
+ /**
112
+ * The product TYPES this creator has products in, plus a synthetic "Coaching"
113
+ * entry when they sell coaching. This is what the storefront's top-level tabs
114
+ * are built from.
115
+ *
116
+ * The endpoint keeps its `/categories` path: every code-site published before
117
+ * the taxonomy split calls it and reads this exact payload, and the hook name
118
+ * is what changed. For the creator's OWN categories use
119
+ * {@link useProductCategories}.
120
+ */
121
+ export function useGetProductTypes() {
94
122
  const { client, profileId } = useForge();
95
123
 
96
- return useQuery<{ title: ProductCategory; description: string }[]>({
97
- queryKey: ["product-categories", profileId],
124
+ return useQuery<{ title: string; description: string }[]>({
125
+ queryKey: ["product-types", profileId],
98
126
  queryFn: async () => {
99
127
  const res = await client.get("/public/products/categories", {
100
128
  params: {
@@ -106,3 +134,69 @@ export function useGetProductCategories() {
106
134
  enabled: !!profileId && !!client,
107
135
  });
108
136
  }
137
+
138
+ /**
139
+ * @deprecated Renamed to {@link useGetProductTypes}, which is what it always
140
+ * returned. Kept so a site built against an earlier Forge keeps compiling; it
141
+ * will go in a future major.
142
+ */
143
+ export const useGetProductCategories = useGetProductTypes;
144
+
145
+ /** A node in the creator's category tree. Children are nested, never repeated at the root. */
146
+ export interface IPublicProductCategory {
147
+ id: string;
148
+ title: string;
149
+ slug: string;
150
+ description: string | null;
151
+ parentId: string | null;
152
+ position: number;
153
+ productCount: number;
154
+ children: IPublicProductCategory[];
155
+ }
156
+
157
+ export interface IPublicProductCollection {
158
+ id: string;
159
+ title: string;
160
+ slug: string;
161
+ description: string | null;
162
+ isFeatured: boolean;
163
+ position: number;
164
+ productCount: number;
165
+ }
166
+
167
+ /**
168
+ * The creator's OWN category tree — what they organise their store by, as
169
+ * opposed to {@link useGetProductTypes} which is what their products ARE.
170
+ *
171
+ * Counts are descendant-inclusive, so "Apparel" reports everything beneath it.
172
+ */
173
+ export function useProductCategories() {
174
+ const { client, profileId } = useForge();
175
+
176
+ return useQuery<IPublicProductCategory[]>({
177
+ queryKey: ["public-product-categories", profileId],
178
+ queryFn: async () => {
179
+ const res = await client.get("/public/products/product-categories", {
180
+ params: { profileId },
181
+ });
182
+ return res.data;
183
+ },
184
+ enabled: !!profileId && !!client,
185
+ });
186
+ }
187
+
188
+ /** The creator's curated collections — drops, seasons, hand-picked edits. */
189
+ export function useProductCollections() {
190
+ const { client, profileId } = useForge();
191
+
192
+ return useQuery<IPublicProductCollection[]>({
193
+ queryKey: ["public-product-collections", profileId],
194
+ queryFn: async () => {
195
+ const res = await client.get("/public/products/product-collections", {
196
+ params: { profileId },
197
+ });
198
+ return res.data;
199
+ },
200
+ enabled: !!profileId && !!client,
201
+ });
202
+ }
@@ -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
@@ -26,6 +26,11 @@ export * from "./types";
26
26
  export { AudioPlayerProvider, useAudioPlayer } from "./contexts/AudioPlayerContext";
27
27
  export type { AudioTrack } from "./contexts/AudioPlayerContext";
28
28
  export { CartProvider, useCart } from "./contexts/CartContext";
29
+ // Variant selection, shared with the Craft themes in frontend-shared the same
30
+ // way the cart and audio player are — so a code site and a Craft site cannot
31
+ // disagree about which version a buyer picked.
32
+ export { useVariantSelection } from "./ui/headless/useVariantSelection";
33
+ export type { VariantAxis, VariantAxisValue } from "./ui/headless/useVariantSelection";
29
34
  export type { CartItem, TicketCartItem, AttachedTo } from "./contexts/CartContext";
30
35
  export {
31
36
  ACCESS_TOKEN_KEY,
@@ -69,6 +74,7 @@ export * from "./data/queries/useBlog";
69
74
  export * from "./data/queries/usePodcast";
70
75
  export * from "./data/queries/useCollections";
71
76
  export * from "./data/queries/useEvents";
77
+ export * from "./data/queries/useEventSeries";
72
78
  export * from "./data/queries/useInvoice";
73
79
  export * from "./data/queries/usePaymentLink";
74
80
  export * from "./data/queries/useLeadMagnet";
@@ -123,3 +129,6 @@ export * from "./utils/structuredData";
123
129
  // reaches existing sites through the normal Forge publish.
124
130
  export * from "./utils/headMeta";
125
131
  export * from "./utils/formatDateTime";
132
+ // The ONE membership access predicate + the messaging that is deliberately
133
+ // separate from it. Every surface that gates on a membership imports from here.
134
+ export * from "./utils/membershipAccess";
@@ -33,6 +33,7 @@ import type { ApiProbeResult, ForgeSsrDiagnostics, SsrFetchFailure } from "../ty
33
33
  export type { ApiProbeResult, ForgeSsrDiagnostics, SsrFetchFailure };
34
34
  import type {
35
35
  IEvent,
36
+ IEventSeries,
36
37
  IPublicProduct,
37
38
  PublicCourse,
38
39
  CoachingProduct,
@@ -221,6 +222,25 @@ export function fetchEventServer(opts: { apiUrl: string; profileId?: string; idO
221
222
  });
222
223
  }
223
224
 
225
+ /**
226
+ * Fetch a NAMED, multi-date event series by slug (or id) for SSR.
227
+ *
228
+ * Resolves to `null` for a series that does not exist, is not this profile's,
229
+ * was never named by a human, or holds fewer than two publicly-visible dates —
230
+ * the API answers 404 to all four identically, on purpose, so that a caller
231
+ * cannot tell them apart and turn the endpoint into an oracle. A `null` here
232
+ * means "render the not-found page", not "the fetch failed".
233
+ */
234
+ export function fetchEventSeriesServer(opts: {
235
+ apiUrl: string;
236
+ profileId?: string;
237
+ idOrSlug: string;
238
+ }): Promise<IEventSeries | null> {
239
+ return getJson<IEventSeries>(opts.apiUrl, `/public/event-series/${encodeURIComponent(opts.idOrSlug)}`, {
240
+ profileId: opts.profileId,
241
+ });
242
+ }
243
+
224
244
  /** Fetch a single product by id or slug for SSR. */
225
245
  export function fetchProductServer(opts: {
226
246
  apiUrl: string;