@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 +2 -2
- package/src/contexts/CartContext.tsx +13 -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/useProducts.ts +104 -10
- package/src/data/queries/useSubscriptions.ts +53 -0
- package/src/index.ts +9 -0
- package/src/server/index.ts +20 -0
- package/src/types/models.ts +199 -4
- 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/index.ts +1 -0
- package/src/ui/headless/membership/MembershipGate.tsx +7 -2
- package/src/ui/headless/useVariantSelection.ts +138 -0
- package/src/ui/index.ts +11 -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/ui/styled/ProductBrowseNav.tsx +212 -0
- package/src/ui/styled/ProductDetail.tsx +150 -91
- package/src/ui/styled/ProductGrid.tsx +16 -2
- package/src/utils/membershipAccess.ts +182 -0
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tribe-nest/forge",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.14.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"description": "Forge
|
|
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
|
-
|
|
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
|
-
|
|
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,23 +1,39 @@
|
|
|
1
|
-
import type { IPublicProduct, PaginatedData,
|
|
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
|
|
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
|
|
13
|
-
export function useFeaturedProducts(
|
|
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,
|
|
33
|
+
queryKey: ["featured-products", profileId, productType],
|
|
18
34
|
queryFn: async () => {
|
|
19
35
|
const res = await client.get("/public/products/featured", {
|
|
20
|
-
params: { profileId,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
97
|
-
queryKey: ["product-
|
|
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";
|
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;
|