@tribe-nest/forge 1.20.2 → 2.2.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/contexts/AppAuthContext.tsx +26 -0
- package/src/contexts/CartContext.tsx +132 -8
- package/src/data/queries/useCheckouts.ts +101 -0
- package/src/data/queries/useCollections.ts +24 -4
- package/src/data/queries/useFinalize.ts +41 -0
- package/src/data/queries/usePageActions.ts +2 -0
- package/src/index.ts +6 -1
- package/src/server/_tests/appUserPermissions.spec.ts +197 -0
- package/src/server/appAuth.ts +37 -2
- package/src/server/appUsers.ts +133 -0
- package/src/server/index.ts +17 -0
- package/src/server/jobs.ts +141 -6
- package/src/server/platform.ts +208 -0
- package/src/types/models.ts +26 -2
- package/src/ui/headless/checkout/useCheckout.ts +56 -9
- package/src/ui/headless/event/useEventCheckout.ts +36 -0
- package/src/ui/headless/funnel/Funnel.tsx +159 -0
- package/src/ui/headless/funnel/funnelSession.spec.ts +108 -0
- package/src/ui/headless/funnel/funnelSession.ts +88 -0
- package/src/ui/headless/funnel/index.ts +3 -0
- package/src/ui/headless/funnel/useFunnelStep.ts +70 -0
- package/src/ui/headless/index.ts +3 -0
- package/src/ui/index.ts +2 -0
- package/src/ui/styled/Addons.tsx +77 -0
- package/src/ui/styled/BundleConfirmation.tsx +161 -0
- package/src/ui/styled/Cart.tsx +66 -9
- package/src/ui/styled/CheckoutConfirmation.tsx +25 -1
- package/src/ui/styled/EventTickets.tsx +52 -7
- package/src/ui/styled/PageActions.tsx +34 -3
- package/src/ui/styled/ProductGrid.tsx +45 -9
- package/src/utils/formatDateTime.ts +25 -0
- package/src/utils/headMeta.ts +50 -0
package/package.json
CHANGED
|
@@ -20,6 +20,15 @@ export interface AppAuthUser {
|
|
|
20
20
|
emailVerified: boolean;
|
|
21
21
|
status: string;
|
|
22
22
|
appId: string | null;
|
|
23
|
+
/**
|
|
24
|
+
* What YOUR app has decided this user may do. The strings are your own — the
|
|
25
|
+
* platform stores them and never reads them for meaning.
|
|
26
|
+
*
|
|
27
|
+
* Use them to decide what to RENDER. They are not a security boundary on their
|
|
28
|
+
* own: this object lives in the browser, so anything privileged must also be
|
|
29
|
+
* checked on your server with `verifyAppUser`.
|
|
30
|
+
*/
|
|
31
|
+
permissions: string[];
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
export interface AppSignupInput {
|
|
@@ -47,6 +56,19 @@ interface AppAuthContextType {
|
|
|
47
56
|
logout: () => Promise<void>;
|
|
48
57
|
refetch: () => Promise<void>;
|
|
49
58
|
clearError: () => void;
|
|
59
|
+
/**
|
|
60
|
+
* Whether the signed-in user holds a permission your app defined.
|
|
61
|
+
*
|
|
62
|
+
* For DECIDING WHAT TO SHOW — a nav item, a button, a route. A signed-out user
|
|
63
|
+
* is always false. Anything that actually does something privileged must be
|
|
64
|
+
* checked again on your server with `verifyAppUser(request, cfg, { require })`;
|
|
65
|
+
* hiding a button does not protect the route behind it.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* const { hasPermission } = useAppAuth()
|
|
69
|
+
* {hasPermission("bookings.manage") && <CancelButton />}
|
|
70
|
+
*/
|
|
71
|
+
hasPermission: (permission: string) => boolean;
|
|
50
72
|
}
|
|
51
73
|
|
|
52
74
|
const AppAuthContext = createContext<AppAuthContextType | null>(null);
|
|
@@ -165,6 +187,10 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
|
|
|
165
187
|
logout,
|
|
166
188
|
refetch,
|
|
167
189
|
clearError: () => setErrorMessage(null),
|
|
190
|
+
// Defensive `?? []`: a site running against an older API (or a cached
|
|
191
|
+
// response from before permissions existed) has no array here, and a
|
|
192
|
+
// crash in a render guard is worse than a hidden button.
|
|
193
|
+
hasPermission: (permission: string) => (user?.permissions ?? []).includes(permission),
|
|
168
194
|
}),
|
|
169
195
|
[appId, user, isInitialized, isLoading, errorMessage, signup, login, logout, refetch],
|
|
170
196
|
);
|
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import type { ProductDeliveryType } from "../types/models";
|
|
3
|
-
import { createContext, useCallback, useContext, useState } from "react";
|
|
3
|
+
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
|
4
4
|
import type { ReactNode } from "react";
|
|
5
5
|
import { useEffect } from "react";
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* What a line is attached to. A product carrying this is an ADD-ON: it was
|
|
9
|
+
* added from another entity's page (an event's add-on block) and is only
|
|
10
|
+
* sellable alongside that entity's own line. The server enforces it —
|
|
11
|
+
* `POST /public/checkouts` rejects a bundle whose add-on has no base.
|
|
12
|
+
*
|
|
13
|
+
* Attachment describes how the item was added to THIS cart, never what the
|
|
14
|
+
* product is: the same product added from the shop carries nothing and sells
|
|
15
|
+
* on its own as always.
|
|
16
|
+
*/
|
|
17
|
+
export type AttachedTo = { type: "event"; entityId: string };
|
|
18
|
+
|
|
7
19
|
export type CartItem = {
|
|
8
20
|
productId: string;
|
|
9
21
|
productVariantId: string;
|
|
@@ -20,6 +32,30 @@ export type CartItem = {
|
|
|
20
32
|
color?: string;
|
|
21
33
|
size?: string;
|
|
22
34
|
deliveryType?: ProductDeliveryType;
|
|
35
|
+
/** Set automatically from `?addonFor=` — see `addToCart`. */
|
|
36
|
+
attachedTo?: AttachedTo;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A ticket selection for one event: the tier→quantity map exactly as the ticket
|
|
41
|
+
* modal produces it.
|
|
42
|
+
*
|
|
43
|
+
* Tickets live in their own collection rather than as a member of a `CartItem`
|
|
44
|
+
* union, deliberately. A dozen components read `cartItems` — four of the header
|
|
45
|
+
* layouts only want a count — and every one of them would need type narrowing
|
|
46
|
+
* for a shape they never render. Keeping products' shape untouched means the
|
|
47
|
+
* whole existing shop, on both rendering stacks, is unaffected; the two places
|
|
48
|
+
* that genuinely need both (the cart panel and checkout) read both lists.
|
|
49
|
+
*/
|
|
50
|
+
export type TicketCartItem = {
|
|
51
|
+
eventId: string;
|
|
52
|
+
eventSlug: string;
|
|
53
|
+
eventTitle: string;
|
|
54
|
+
coverImage?: string;
|
|
55
|
+
/** ticketId → quantity, matching `useEventCheckout`'s `selectedTickets`. */
|
|
56
|
+
tickets: Record<string, number>;
|
|
57
|
+
/** Display data per ticket id, so the cart can render lines without refetching. */
|
|
58
|
+
ticketMeta: Record<string, { title: string; price: number }>;
|
|
23
59
|
};
|
|
24
60
|
|
|
25
61
|
interface CartContextType {
|
|
@@ -27,6 +63,17 @@ interface CartContextType {
|
|
|
27
63
|
addToCart: (item: CartItem) => boolean;
|
|
28
64
|
removeFromCart: (productId: string, isGift: boolean, recipientEmail?: string) => void;
|
|
29
65
|
clearCart: () => void;
|
|
66
|
+
/** Ticket selections, one entry per event. */
|
|
67
|
+
ticketItems: TicketCartItem[];
|
|
68
|
+
/** Add or replace the selection for an event (the modal always sends the whole map). */
|
|
69
|
+
setTickets: (item: TicketCartItem) => void;
|
|
70
|
+
/** Drops the selection AND every add-on attached to that event. */
|
|
71
|
+
removeTickets: (eventId: string) => void;
|
|
72
|
+
hasTicketsFor: (eventId: string) => boolean;
|
|
73
|
+
/** Products + tickets, for the header badge. */
|
|
74
|
+
itemCount: number;
|
|
75
|
+
/** True when the cart spans more than one surface — i.e. the bundle path. */
|
|
76
|
+
isBundle: boolean;
|
|
30
77
|
isCartOpen: boolean;
|
|
31
78
|
setCartOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
|
32
79
|
/** False until the cart has been read from storage after mount. Gate any
|
|
@@ -38,8 +85,26 @@ const CartContext = createContext<CartContextType | undefined>(undefined);
|
|
|
38
85
|
|
|
39
86
|
const CART_STORAGE_KEY = "tribenest-cart";
|
|
40
87
|
|
|
88
|
+
/**
|
|
89
|
+
* The event this page is selling add-ons for, if any. The add-on card links to
|
|
90
|
+
* the ordinary product page with `?addonFor=<eventId>`, and stamping happens
|
|
91
|
+
* HERE rather than in the product page so that Forge's `ProductDetail` and the
|
|
92
|
+
* Craft themes' `ProductDetails` / `MusicItemDetails` need no changes at all —
|
|
93
|
+
* they don't know add-ons exist.
|
|
94
|
+
*/
|
|
95
|
+
function readAddonContext(): AttachedTo | undefined {
|
|
96
|
+
if (typeof window === "undefined") return undefined;
|
|
97
|
+
try {
|
|
98
|
+
const eventId = new URLSearchParams(window.location.search).get("addonFor");
|
|
99
|
+
return eventId ? { type: "event", entityId: eventId } : undefined;
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
41
105
|
export function CartProvider({ children }: { children: ReactNode }) {
|
|
42
106
|
const [cartItems, setCartItems] = useState<CartItem[]>([]);
|
|
107
|
+
const [ticketItems, setTicketItems] = useState<TicketCartItem[]>([]);
|
|
43
108
|
const [isInitialized, setIsInitialized] = useState(false);
|
|
44
109
|
const [isCartOpen, setCartOpen] = useState(false);
|
|
45
110
|
|
|
@@ -49,7 +114,14 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
|
|
49
114
|
try {
|
|
50
115
|
const savedCart = localStorage.getItem(CART_STORAGE_KEY);
|
|
51
116
|
if (savedCart) {
|
|
52
|
-
|
|
117
|
+
const parsed = JSON.parse(savedCart);
|
|
118
|
+
// Carts saved before tickets existed are a bare array — keep them.
|
|
119
|
+
if (Array.isArray(parsed)) {
|
|
120
|
+
setCartItems(parsed);
|
|
121
|
+
} else {
|
|
122
|
+
setCartItems(parsed.items ?? []);
|
|
123
|
+
setTicketItems(parsed.tickets ?? []);
|
|
124
|
+
}
|
|
53
125
|
}
|
|
54
126
|
} catch (error) {
|
|
55
127
|
console.error("Failed to load cart from localStorage:", error);
|
|
@@ -62,15 +134,19 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
|
|
62
134
|
useEffect(() => {
|
|
63
135
|
if (isInitialized) {
|
|
64
136
|
try {
|
|
65
|
-
localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(cartItems));
|
|
137
|
+
localStorage.setItem(CART_STORAGE_KEY, JSON.stringify({ items: cartItems, tickets: ticketItems }));
|
|
66
138
|
} catch (error) {
|
|
67
139
|
console.error("Failed to save cart to localStorage:", error);
|
|
68
140
|
}
|
|
69
141
|
}
|
|
70
|
-
}, [cartItems, isInitialized]);
|
|
142
|
+
}, [cartItems, ticketItems, isInitialized]);
|
|
71
143
|
|
|
72
144
|
const addToCart = useCallback(
|
|
73
145
|
(item: CartItem): boolean => {
|
|
146
|
+
// An add-on reached this page via `?addonFor=`; bind the line to it.
|
|
147
|
+
const attachedTo = item.attachedTo ?? readAddonContext();
|
|
148
|
+
const next = attachedTo ? { ...item, attachedTo } : item;
|
|
149
|
+
|
|
74
150
|
const exists = cartItems.some(
|
|
75
151
|
(i) =>
|
|
76
152
|
i.productId === item.productId &&
|
|
@@ -88,7 +164,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
|
|
88
164
|
i.isGift === item.isGift &&
|
|
89
165
|
i.recipientEmail === item.recipientEmail
|
|
90
166
|
) {
|
|
91
|
-
return
|
|
167
|
+
return next;
|
|
92
168
|
} else {
|
|
93
169
|
return i;
|
|
94
170
|
}
|
|
@@ -98,7 +174,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
|
|
98
174
|
// decides whether to toast. (Was `toast.info("Item already in cart")`.)
|
|
99
175
|
return false;
|
|
100
176
|
}
|
|
101
|
-
setCartItems((prev) => [...prev,
|
|
177
|
+
setCartItems((prev) => [...prev, next]);
|
|
102
178
|
setCartOpen(true);
|
|
103
179
|
return true;
|
|
104
180
|
},
|
|
@@ -113,11 +189,59 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
|
|
113
189
|
);
|
|
114
190
|
};
|
|
115
191
|
|
|
116
|
-
const
|
|
192
|
+
const setTickets = useCallback((item: TicketCartItem) => {
|
|
193
|
+
setTicketItems((prev) => [...prev.filter((t) => t.eventId !== item.eventId), item]);
|
|
194
|
+
setCartOpen(true);
|
|
195
|
+
}, []);
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Removing the base removes what hangs off it. Leaving orphaned add-ons in
|
|
199
|
+
* the cart would produce a checkout the server refuses, with nothing on
|
|
200
|
+
* screen explaining why — so they go, and the caller toasts.
|
|
201
|
+
*/
|
|
202
|
+
const removeTickets = useCallback((eventId: string) => {
|
|
203
|
+
setTicketItems((prev) => prev.filter((t) => t.eventId !== eventId));
|
|
204
|
+
setCartItems((prev) => prev.filter((i) => i.attachedTo?.entityId !== eventId));
|
|
205
|
+
}, []);
|
|
206
|
+
|
|
207
|
+
const hasTicketsFor = useCallback(
|
|
208
|
+
(eventId: string) => ticketItems.some((t) => t.eventId === eventId),
|
|
209
|
+
[ticketItems],
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const clearCart = () => {
|
|
213
|
+
setCartItems([]);
|
|
214
|
+
setTicketItems([]);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const itemCount = useMemo(
|
|
218
|
+
() =>
|
|
219
|
+
cartItems.reduce((acc, i) => acc + i.quantity, 0) +
|
|
220
|
+
ticketItems.reduce((acc, t) => acc + Object.values(t.tickets).reduce((a, b) => a + b, 0), 0),
|
|
221
|
+
[cartItems, ticketItems],
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// Only a cart spanning both surfaces takes the bundle engine; a products-only
|
|
225
|
+
// or tickets-only cart keeps its existing single-surface flow untouched.
|
|
226
|
+
const isBundle = cartItems.length > 0 && ticketItems.length > 0;
|
|
117
227
|
|
|
118
228
|
return (
|
|
119
229
|
<CartContext.Provider
|
|
120
|
-
value={{
|
|
230
|
+
value={{
|
|
231
|
+
cartItems,
|
|
232
|
+
addToCart,
|
|
233
|
+
removeFromCart,
|
|
234
|
+
clearCart,
|
|
235
|
+
ticketItems,
|
|
236
|
+
setTickets,
|
|
237
|
+
removeTickets,
|
|
238
|
+
hasTicketsFor,
|
|
239
|
+
itemCount,
|
|
240
|
+
isBundle,
|
|
241
|
+
isCartOpen,
|
|
242
|
+
setCartOpen,
|
|
243
|
+
isReady: isInitialized,
|
|
244
|
+
}}
|
|
121
245
|
>
|
|
122
246
|
{children}
|
|
123
247
|
</CartContext.Provider>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useMutation } from "@tanstack/react-query";
|
|
2
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
3
|
+
import type { CartItem, TicketCartItem } from "../../contexts/CartContext";
|
|
4
|
+
|
|
5
|
+
/** One line of a bundle, in the shape `POST /public/checkouts` expects. */
|
|
6
|
+
export type CheckoutLineInput =
|
|
7
|
+
| {
|
|
8
|
+
type: "product";
|
|
9
|
+
productId: string;
|
|
10
|
+
productVariantId: string;
|
|
11
|
+
quantity: number;
|
|
12
|
+
price: number;
|
|
13
|
+
title: string;
|
|
14
|
+
coverImage?: string;
|
|
15
|
+
isGift?: boolean;
|
|
16
|
+
recipientName?: string;
|
|
17
|
+
recipientEmail?: string;
|
|
18
|
+
recipientMessage?: string;
|
|
19
|
+
payWhatYouWant?: boolean;
|
|
20
|
+
attachedTo?: { type: "event"; entityId: string };
|
|
21
|
+
}
|
|
22
|
+
| {
|
|
23
|
+
type: "event_ticket";
|
|
24
|
+
eventId: string;
|
|
25
|
+
ticketId: string;
|
|
26
|
+
quantity: number;
|
|
27
|
+
price: number;
|
|
28
|
+
title: string;
|
|
29
|
+
coverImage?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Flatten the two cart collections into the flat line list the bundle endpoint
|
|
34
|
+
* takes. A ticket selection expands to one line per tier, because the server
|
|
35
|
+
* groups by event itself.
|
|
36
|
+
*/
|
|
37
|
+
export function cartToCheckoutLines(cartItems: CartItem[], ticketItems: TicketCartItem[]): CheckoutLineInput[] {
|
|
38
|
+
const ticketLines: CheckoutLineInput[] = ticketItems.flatMap((t) =>
|
|
39
|
+
Object.entries(t.tickets)
|
|
40
|
+
.filter(([, qty]) => qty > 0)
|
|
41
|
+
.map(([ticketId, qty]) => ({
|
|
42
|
+
type: "event_ticket" as const,
|
|
43
|
+
eventId: t.eventId,
|
|
44
|
+
ticketId,
|
|
45
|
+
quantity: qty,
|
|
46
|
+
price: t.ticketMeta[ticketId]?.price ?? 0,
|
|
47
|
+
title: t.ticketMeta[ticketId]?.title ?? t.eventTitle,
|
|
48
|
+
coverImage: t.coverImage,
|
|
49
|
+
})),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const productLines: CheckoutLineInput[] = cartItems.map((i) => ({
|
|
53
|
+
type: "product" as const,
|
|
54
|
+
productId: i.productId,
|
|
55
|
+
productVariantId: i.productVariantId,
|
|
56
|
+
quantity: i.quantity,
|
|
57
|
+
price: i.price,
|
|
58
|
+
title: i.title,
|
|
59
|
+
coverImage: i.coverImage,
|
|
60
|
+
isGift: i.isGift,
|
|
61
|
+
recipientName: i.recipientName,
|
|
62
|
+
recipientEmail: i.recipientEmail,
|
|
63
|
+
recipientMessage: i.recipientMessage,
|
|
64
|
+
payWhatYouWant: i.payWhatYouWant,
|
|
65
|
+
attachedTo: i.attachedTo,
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
// Tickets first so the server's own ordering matches how the cart reads.
|
|
69
|
+
return [...ticketLines, ...productLines];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type CreateCheckoutResult = { checkoutId: string; currency: string; subtotalCents: number };
|
|
73
|
+
|
|
74
|
+
export function useCreateCheckout() {
|
|
75
|
+
const { client, profileId } = useForge();
|
|
76
|
+
|
|
77
|
+
return useMutation<CreateCheckoutResult, unknown, Record<string, unknown>>({
|
|
78
|
+
mutationFn: async (body) => {
|
|
79
|
+
const res = await client.post("/public/checkouts", { profileId, ...body });
|
|
80
|
+
return res.data;
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type FinalizeCheckoutResult = {
|
|
86
|
+
checkoutId: string;
|
|
87
|
+
status: string;
|
|
88
|
+
fulfillmentStatus: string;
|
|
89
|
+
children?: { sourceType: string; sourceId: string }[];
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export function useFinalizeCheckout() {
|
|
93
|
+
const { client, profileId } = useForge();
|
|
94
|
+
|
|
95
|
+
return useMutation<FinalizeCheckoutResult, unknown, { checkoutId: string }>({
|
|
96
|
+
mutationFn: async ({ checkoutId }) => {
|
|
97
|
+
const res = await client.post("/public/checkouts/finalize", { profileId, checkoutId });
|
|
98
|
+
return res.data;
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
CollectionQuery,
|
|
6
6
|
CollectionAggregate,
|
|
7
7
|
CollectionAggregateBucket,
|
|
8
|
+
CollectionAggregateResult,
|
|
8
9
|
CollectionSearchResult,
|
|
9
10
|
} from "../../types/models";
|
|
10
11
|
import { useForge } from "../../provider/ForgeProvider";
|
|
@@ -96,6 +97,12 @@ export function useCollectionQuery(
|
|
|
96
97
|
* Aggregate a collection (group-by + count/sum/avg/min/max) via POST /aggregate —
|
|
97
98
|
* totals, leaderboards, "washes remaining". `scope: "mine"` restricts to the
|
|
98
99
|
* member's own rows.
|
|
100
|
+
*
|
|
101
|
+
* Returns `{ buckets, truncated, limit }`. **Check `truncated` before summing
|
|
102
|
+
* buckets into a total** — a capped set and a complete one are otherwise
|
|
103
|
+
* indistinguishable, and a partial revenue figure looks exactly like a real one.
|
|
104
|
+
* You only get `truncated: true` if you passed a `limit`; without one, a
|
|
105
|
+
* grouping that overflows the cap fails outright rather than answering partially.
|
|
99
106
|
*/
|
|
100
107
|
export function useCollectionAggregate(
|
|
101
108
|
slug?: string,
|
|
@@ -104,7 +111,7 @@ export function useCollectionAggregate(
|
|
|
104
111
|
) {
|
|
105
112
|
const { client, profileId } = useForge();
|
|
106
113
|
|
|
107
|
-
return useQuery<
|
|
114
|
+
return useQuery<CollectionAggregateResult>({
|
|
108
115
|
queryKey: ["collection-aggregate", profileId, slug, JSON.stringify({ aggregate, ...opts })],
|
|
109
116
|
queryFn: async () => {
|
|
110
117
|
const res = await client.post(`/public/collections/${slug}/aggregate`, {
|
|
@@ -114,12 +121,19 @@ export function useCollectionAggregate(
|
|
|
114
121
|
search: opts?.search,
|
|
115
122
|
aggregate,
|
|
116
123
|
});
|
|
117
|
-
|
|
124
|
+
// The BODY still puts the array under `data` — that wire shape is frozen
|
|
125
|
+
// for sites already deployed against it. Only this hook's shape changed.
|
|
126
|
+
return {
|
|
127
|
+
buckets: res.data.data ?? [],
|
|
128
|
+
truncated: res.data.truncated === true,
|
|
129
|
+
limit: Number(res.data.groupLimit ?? 0),
|
|
130
|
+
};
|
|
118
131
|
},
|
|
119
132
|
enabled: (opts?.enabled ?? true) && !!profileId && !!client && !!slug && !!aggregate,
|
|
120
133
|
});
|
|
121
134
|
}
|
|
122
135
|
|
|
136
|
+
|
|
123
137
|
// Prefer the app-user token (useAppAuth) when the viewer is an app user;
|
|
124
138
|
// otherwise fall back to the Forge client's own token (an app-admin's TribeNest
|
|
125
139
|
// session). Both are accepted by the app read endpoint. Returns a per-request
|
|
@@ -163,7 +177,7 @@ export function useAppCollectionAggregate(
|
|
|
163
177
|
) {
|
|
164
178
|
const { client, appId } = useForge();
|
|
165
179
|
|
|
166
|
-
return useQuery<
|
|
180
|
+
return useQuery<CollectionAggregateResult>({
|
|
167
181
|
queryKey: ["app-collection-aggregate", appId, slug, JSON.stringify({ aggregate, ...opts })],
|
|
168
182
|
queryFn: async () => {
|
|
169
183
|
const res = await client.post(
|
|
@@ -176,7 +190,13 @@ export function useAppCollectionAggregate(
|
|
|
176
190
|
// `{ data }`. Unwrapping here resolved undefined, which React Query
|
|
177
191
|
// rejects outright, so every call errored before reaching the caller.
|
|
178
192
|
// Matches useAppCollectionQuery above; keep the two in step.
|
|
179
|
-
|
|
193
|
+
//
|
|
194
|
+
// Because the body has no room for it, truncation rides in headers here.
|
|
195
|
+
return {
|
|
196
|
+
buckets: res.data ?? [],
|
|
197
|
+
truncated: String(res.headers?.["x-tn-aggregate-truncated"] ?? "") === "true",
|
|
198
|
+
limit: Number(res.headers?.["x-tn-aggregate-limit"] ?? 0),
|
|
199
|
+
};
|
|
180
200
|
},
|
|
181
201
|
enabled: (opts?.enabled ?? true) && !!appId && !!client && !!slug && !!aggregate,
|
|
182
202
|
});
|
|
@@ -107,3 +107,44 @@ export function useCourseBookingFinalize(courseId?: string, bookingId?: string)
|
|
|
107
107
|
enabled: !!profileId && !!client && !!bookingId,
|
|
108
108
|
});
|
|
109
109
|
}
|
|
110
|
+
|
|
111
|
+
/** Statuses a bundle can still move on from while the gateway settles. */
|
|
112
|
+
const PENDING_BUNDLE_STATES = new Set(["unpaid", "paid"]);
|
|
113
|
+
|
|
114
|
+
export type BundleFinalizeResult = {
|
|
115
|
+
checkoutId: string;
|
|
116
|
+
status: string;
|
|
117
|
+
fulfillmentStatus: string;
|
|
118
|
+
children?: { sourceType: string; sourceId: string }[];
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Finalize a bundle checkout (`/public/checkouts/finalize`). One call settles
|
|
123
|
+
* every surface in the bundle; the backend polls the single shared intent and
|
|
124
|
+
* fans out to each child's own fulfill.
|
|
125
|
+
*
|
|
126
|
+
* Idempotent, so the return page can poll: a checkout already `complete`
|
|
127
|
+
* short-circuits, and a `partial` one resumes only the children still owed
|
|
128
|
+
* (the reconciliation cron does the same thing on a schedule).
|
|
129
|
+
*/
|
|
130
|
+
export function useCheckoutFinalize(args: { checkoutId?: string }, opts?: { pollWhilePending?: boolean }) {
|
|
131
|
+
const { client, profileId } = useForge();
|
|
132
|
+
const { checkoutId } = args;
|
|
133
|
+
|
|
134
|
+
return useQuery<BundleFinalizeResult>({
|
|
135
|
+
queryKey: ["bundlePaymentStatus", profileId, checkoutId],
|
|
136
|
+
queryFn: async () => {
|
|
137
|
+
const res = await client.post("/public/checkouts/finalize", { profileId, checkoutId });
|
|
138
|
+
return res.data;
|
|
139
|
+
},
|
|
140
|
+
enabled: !!profileId && !!client && !!checkoutId,
|
|
141
|
+
refetchInterval: opts?.pollWhilePending
|
|
142
|
+
? (query) =>
|
|
143
|
+
query.state.data &&
|
|
144
|
+
query.state.data.fulfillmentStatus !== "complete" &&
|
|
145
|
+
PENDING_BUNDLE_STATES.has(query.state.data.status)
|
|
146
|
+
? 2500
|
|
147
|
+
: false
|
|
148
|
+
: false,
|
|
149
|
+
});
|
|
150
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -21,7 +21,7 @@ export * from "./types";
|
|
|
21
21
|
export { AudioPlayerProvider, useAudioPlayer } from "./contexts/AudioPlayerContext";
|
|
22
22
|
export type { AudioTrack } from "./contexts/AudioPlayerContext";
|
|
23
23
|
export { CartProvider, useCart } from "./contexts/CartContext";
|
|
24
|
-
export type { CartItem } from "./contexts/CartContext";
|
|
24
|
+
export type { CartItem, TicketCartItem, AttachedTo } from "./contexts/CartContext";
|
|
25
25
|
export {
|
|
26
26
|
ACCESS_TOKEN_KEY,
|
|
27
27
|
PUBLIC_ACCESS_TOKEN_KEY,
|
|
@@ -96,3 +96,8 @@ export * from "./utils/landing";
|
|
|
96
96
|
export * from "./utils/metaPixel";
|
|
97
97
|
export * from "./utils/cookieConsent";
|
|
98
98
|
export * from "./utils/structuredData";
|
|
99
|
+
// Shared by BOTH starters. Kept here rather than in a starter's `/i/-lib` so an
|
|
100
|
+
// app can use them without inheriting the fan-site route tree, and so a fix
|
|
101
|
+
// reaches existing sites through the normal Forge publish.
|
|
102
|
+
export * from "./utils/headMeta";
|
|
103
|
+
export * from "./utils/formatDateTime";
|