@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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/contexts/AppAuthContext.tsx +26 -0
  3. package/src/contexts/CartContext.tsx +132 -8
  4. package/src/data/queries/useCheckouts.ts +101 -0
  5. package/src/data/queries/useCollections.ts +24 -4
  6. package/src/data/queries/useFinalize.ts +41 -0
  7. package/src/data/queries/usePageActions.ts +2 -0
  8. package/src/index.ts +6 -1
  9. package/src/server/_tests/appUserPermissions.spec.ts +197 -0
  10. package/src/server/appAuth.ts +37 -2
  11. package/src/server/appUsers.ts +133 -0
  12. package/src/server/index.ts +17 -0
  13. package/src/server/jobs.ts +141 -6
  14. package/src/server/platform.ts +208 -0
  15. package/src/types/models.ts +26 -2
  16. package/src/ui/headless/checkout/useCheckout.ts +56 -9
  17. package/src/ui/headless/event/useEventCheckout.ts +36 -0
  18. package/src/ui/headless/funnel/Funnel.tsx +159 -0
  19. package/src/ui/headless/funnel/funnelSession.spec.ts +108 -0
  20. package/src/ui/headless/funnel/funnelSession.ts +88 -0
  21. package/src/ui/headless/funnel/index.ts +3 -0
  22. package/src/ui/headless/funnel/useFunnelStep.ts +70 -0
  23. package/src/ui/headless/index.ts +3 -0
  24. package/src/ui/index.ts +2 -0
  25. package/src/ui/styled/Addons.tsx +77 -0
  26. package/src/ui/styled/BundleConfirmation.tsx +161 -0
  27. package/src/ui/styled/Cart.tsx +66 -9
  28. package/src/ui/styled/CheckoutConfirmation.tsx +25 -1
  29. package/src/ui/styled/EventTickets.tsx +52 -7
  30. package/src/ui/styled/PageActions.tsx +34 -3
  31. package/src/ui/styled/ProductGrid.tsx +45 -9
  32. package/src/utils/formatDateTime.ts +25 -0
  33. package/src/utils/headMeta.ts +50 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "1.20.2",
3
+ "version": "2.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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
- setCartItems(JSON.parse(savedCart));
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 item;
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, item]);
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 clearCart = () => setCartItems([]);
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={{ cartItems, addToCart, removeFromCart, clearCart, isCartOpen, setCartOpen, isReady: isInitialized }}
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<CollectionAggregateBucket[]>({
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
- return res.data.data;
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<CollectionAggregateBucket[]>({
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
- return res.data;
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
+ }
@@ -6,6 +6,8 @@ export type PageActionType =
6
6
  | "lead_magnet"
7
7
  | "offer"
8
8
  | "product_cards"
9
+ // Specific products sold as attachments to the page's own entity.
10
+ | "addons"
9
11
  | "donation"
10
12
  | "membership"
11
13
  | "button";
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";