@tribe-nest/forge 1.20.2 → 2.1.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.
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Platform access for an app — the creator's BUSINESS data (memberships, orders,
3
+ * courses, contacts), as opposed to the app's own collections.
4
+ *
5
+ * SERVER ONLY. Never import this from a component or anything the browser
6
+ * bundles: it carries the app's platform credential.
7
+ *
8
+ * TWO LANES, AND THEY MUST NOT MERGE:
9
+ * - Anything scoped to a PERSON ("my orders", "my membership") uses the signed-in
10
+ * user's own token — the existing hooks. The platform checks that user's access.
11
+ * - This client acts as the APP, with what the owner granted it. Use it for
12
+ * business-level work and for reacting to events.
13
+ *
14
+ * NEVER answer an end-user request straight from this client. That is what turns
15
+ * "show me my membership" into "show me everyone's". Do your own authorization
16
+ * first, then use it for the part that genuinely needs owner-level access.
17
+ */
18
+
19
+ export type PlatformConfig = {
20
+ /** The TribeNest public API base (VITE_API_URL). */
21
+ apiUrl: string;
22
+ /** The app's platform token, injected as a server-only Worker binding. */
23
+ token: string;
24
+ };
25
+
26
+ export type PlatformAction = {
27
+ id: string;
28
+ domain: string;
29
+ title: string;
30
+ description: string;
31
+ risk: "read" | "write" | "send" | "spend" | "destructive";
32
+ };
33
+
34
+ export class PlatformError extends Error {
35
+ constructor(
36
+ message: string,
37
+ readonly status: number,
38
+ /** Correlates with the platform's audit trail — log it when reporting a failure. */
39
+ readonly requestId?: string,
40
+ ) {
41
+ super(message);
42
+ this.name = "PlatformError";
43
+ }
44
+ }
45
+
46
+ /**
47
+ * A client for the platform actions this app has been granted.
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * const platform = createPlatformClient({ apiUrl: env.API_URL, token: env.TN_APP_TOKEN })
52
+ * const { actions } = await platform.listActions() // what this app may do
53
+ * await platform.run("blog.post.create", { title, content }, { idempotencyKey: myRecordId })
54
+ * ```
55
+ */
56
+ export function createPlatformClient(config: PlatformConfig) {
57
+ const base = config.apiUrl.replace(/\/$/, "");
58
+
59
+ const request = async (path: string, init: RequestInit = {}) => {
60
+ const res = await fetch(`${base}/app-api${path}`, {
61
+ ...init,
62
+ headers: {
63
+ "content-type": "application/json",
64
+ authorization: `Bearer ${config.token}`,
65
+ ...(init.headers ?? {}),
66
+ },
67
+ });
68
+ const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
69
+ if (!res.ok) {
70
+ // The message names the missing permission and how to ask for it — pass it
71
+ // through verbatim rather than flattening it to "request failed".
72
+ throw new PlatformError(
73
+ String(body.message ?? `Platform request failed (${res.status})`),
74
+ res.status,
75
+ body.requestId as string | undefined,
76
+ );
77
+ }
78
+ return body;
79
+ };
80
+
81
+ return {
82
+ /**
83
+ * What this app can actually do, already filtered to its grants.
84
+ *
85
+ * `missing` lists what was withheld and which permission would unlock it —
86
+ * useful in a log line when a feature silently does nothing.
87
+ */
88
+ async listActions(opts: { domain?: string; query?: string } = {}) {
89
+ const params = new URLSearchParams();
90
+ if (opts.domain) params.set("domain", opts.domain);
91
+ if (opts.query) params.set("query", opts.query);
92
+ const qs = params.toString();
93
+ return request(`/actions${qs ? `?${qs}` : ""}`) as Promise<{
94
+ actions: PlatformAction[];
95
+ missing: Array<{ id: string; reason: string; permission?: string }>;
96
+ requestId: string;
97
+ }>;
98
+ },
99
+
100
+ /** An action's input schema and a worked example. */
101
+ async describeAction(id: string) {
102
+ return request(`/actions/${encodeURIComponent(id)}`);
103
+ },
104
+
105
+ /**
106
+ * Run an action.
107
+ *
108
+ * `idempotencyKey` is REQUIRED for anything that writes. Derive it from your
109
+ * own record id: event delivery is at-least-once and jobs retry, so a write
110
+ * without one duplicates the first time anything is redelivered. A replay
111
+ * returns the original result instead of acting again.
112
+ */
113
+ async run<T = unknown>(
114
+ id: string,
115
+ input: unknown,
116
+ opts: { idempotencyKey?: string; dryRun?: boolean } = {},
117
+ ): Promise<T> {
118
+ const body = await request(`/actions/${encodeURIComponent(id)}`, {
119
+ method: "POST",
120
+ headers: opts.idempotencyKey ? { "idempotency-key": opts.idempotencyKey } : {},
121
+ body: JSON.stringify({ input, ...(opts.dryRun ? { dryRun: true } : {}) }),
122
+ });
123
+ return body.result as T;
124
+ },
125
+
126
+ /**
127
+ * Validate a write without performing it. Worth doing before a mutation you
128
+ * cannot undo — it costs one call and catches a bad shape before it lands.
129
+ */
130
+ async dryRun(id: string, input: unknown) {
131
+ return request(`/actions/${encodeURIComponent(id)}`, {
132
+ method: "POST",
133
+ body: JSON.stringify({ input, dryRun: true }),
134
+ });
135
+ },
136
+ };
137
+ }
138
+
139
+ export type PlatformClient = ReturnType<typeof createPlatformClient>;
140
+
141
+ // --------------------------------------------------------------------- events
142
+
143
+ export type PlatformEvent<T = Record<string, unknown>> = {
144
+ event: string;
145
+ /** Dedupe on THIS. The same event arrives again on retry or replay. */
146
+ eventId: string;
147
+ appId: string;
148
+ profileId: string;
149
+ occurredAt: string;
150
+ data: T;
151
+ };
152
+
153
+ /**
154
+ * Verify and parse a platform event delivered to your app.
155
+ *
156
+ * Deliveries are signed; an unverified body is untrusted input from the open
157
+ * internet, so this throws rather than returning something that looks valid.
158
+ *
159
+ * DELIVERY IS AT-LEAST-ONCE. Record `eventId` and ignore one you have already
160
+ * processed — the same event WILL arrive twice after a retry or an owner replay,
161
+ * and a handler that acts twice will double-charge or double-send. That dedupe is
162
+ * also what makes the owner's 48-hour replay safe to use.
163
+ *
164
+ * @example
165
+ * ```ts
166
+ * const event = await verifyPlatformEvent(request, env.TN_EVENT_SECRET)
167
+ * if (await alreadyHandled(event.eventId)) return new Response("ok")
168
+ * await grantAccessFor(event.data)
169
+ * await markHandled(event.eventId)
170
+ * ```
171
+ */
172
+ export async function verifyPlatformEvent<T = Record<string, unknown>>(
173
+ request: Request,
174
+ secret: string,
175
+ ): Promise<PlatformEvent<T>> {
176
+ const signature = request.headers.get("x-tribenest-signature");
177
+ if (!signature) throw new Error("Missing platform event signature.");
178
+
179
+ const body = await request.text();
180
+ const expected = await hmacHex(secret, body);
181
+ if (!timingSafeEqual(signature, expected)) {
182
+ throw new Error("Platform event signature did not verify.");
183
+ }
184
+ return JSON.parse(body) as PlatformEvent<T>;
185
+ }
186
+
187
+ async function hmacHex(secret: string, body: string): Promise<string> {
188
+ const enc = new TextEncoder();
189
+ const key = await crypto.subtle.importKey(
190
+ "raw",
191
+ enc.encode(secret),
192
+ { name: "HMAC", hash: "SHA-256" },
193
+ false,
194
+ ["sign"],
195
+ );
196
+ const sig = await crypto.subtle.sign("HMAC", key, enc.encode(body));
197
+ return Array.from(new Uint8Array(sig))
198
+ .map((b) => b.toString(16).padStart(2, "0"))
199
+ .join("");
200
+ }
201
+
202
+ /** Constant-time compare — a fast-exit compare leaks the signature a byte at a time. */
203
+ function timingSafeEqual(a: string, b: string): boolean {
204
+ if (a.length !== b.length) return false;
205
+ let diff = 0;
206
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
207
+ return diff === 0;
208
+ }
@@ -307,6 +307,7 @@ export type CollectionFieldType =
307
307
  | "number"
308
308
  | "boolean"
309
309
  | "date"
310
+ | "datetime"
310
311
  | "select"
311
312
  | "multiselect"
312
313
  | "tags"
@@ -436,13 +437,36 @@ export type CollectionQuery = {
436
437
 
437
438
  /** Aggregation over a collection (group-by + metrics) — POST /aggregate. */
438
439
  export type CollectionAggregate = {
439
- groupBy: string;
440
+ /**
441
+ * The dimension(s) to group by. Pass several to CROSS-TABULATE in one call —
442
+ * `["campaign", "product"]` returns a bucket per pair, instead of one request
443
+ * per campaign. A bare string means a single dimension.
444
+ */
445
+ groupBy: string | string[];
440
446
  metrics?: Array<{ op: "count" | "sum" | "avg" | "min" | "max"; field?: string; as: string }>;
441
447
  having?: Array<{ metric: string; op: "gt" | "gte" | "lt" | "lte" | "eq" | "ne"; value: number }>;
442
448
  orderBy?: { metric: string; dir?: "asc" | "desc" };
443
449
  limit?: number;
444
450
  };
445
- export type CollectionAggregateBucket = { value: string; count: number } & Record<string, number | string>;
451
+ /**
452
+ * One group. Read `values` (every dimension, keyed by field) when grouping by
453
+ * more than one; `value` is the first dimension, kept for single-dimension code.
454
+ */
455
+ export type CollectionAggregateBucket = {
456
+ value: string;
457
+ values: Record<string, string>;
458
+ count: number;
459
+ } & Record<string, number | string | Record<string, string>>;
460
+
461
+ /**
462
+ * An aggregate's outcome. `truncated` means the groups were CUT OFF at
463
+ * `limit` — check it before summing buckets into a total.
464
+ */
465
+ export type CollectionAggregateResult = {
466
+ buckets: CollectionAggregateBucket[];
467
+ truncated: boolean;
468
+ limit: number;
469
+ };
446
470
 
447
471
  export type MediaType = "image" | "video" | "audio" | "document";
448
472
 
@@ -11,6 +11,7 @@ import {
11
11
  type ShippingCountry,
12
12
  } from "../../../data/queries/useShipping";
13
13
  import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
14
+ import { useCreateCheckout, cartToCheckoutLines } from "../../../data/queries/useCheckouts";
14
15
  import { readAttributionRef } from "../../../utils/attribution";
15
16
  import { readLanding } from "../../../utils/landing";
16
17
  import { ProductDeliveryType, PaymentProviderName, type ApiError, type PublicTaxQuote } from "../../../types/models";
@@ -89,19 +90,34 @@ function messageOf(e: unknown): string | undefined {
89
90
  export function useCheckout(opts: UseCheckoutOptions = {}) {
90
91
  const finalisePath = opts.finalisePath ?? "/checkout/finalise";
91
92
  const { user } = usePublicAuth();
92
- const { cartItems, isReady: isCartReady } = useCart();
93
+ const { cartItems, ticketItems, isBundle, isReady: isCartReady } = useCart();
93
94
  const { data: countries = [] } = useShippingCountries();
94
95
  const shippingRates = useShippingRates();
95
96
  const createOrder = useCreateOrder();
97
+ const createCheckout = useCreateCheckout();
96
98
  const applyCouponMutation = useApplyCoupon();
97
99
 
98
100
  const hasPhysicalProduct = useMemo(
99
101
  () => cartItems.some((item) => item.deliveryType === ProductDeliveryType.Physical),
100
102
  [cartItems],
101
103
  );
104
+ // Tickets count toward the total the buyer is shown and charged; a
105
+ // products-only cart is unchanged because `ticketItems` is then empty.
102
106
  const subTotal = useMemo(
103
- () => round2(cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0)),
104
- [cartItems],
107
+ () =>
108
+ round2(
109
+ cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0) +
110
+ ticketItems.reduce(
111
+ (sum, t) =>
112
+ sum +
113
+ Object.entries(t.tickets).reduce(
114
+ (acc, [id, qty]) => acc + (t.ticketMeta[id]?.price ?? 0) * qty,
115
+ 0,
116
+ ),
117
+ 0,
118
+ ),
119
+ ),
120
+ [cartItems, ticketItems],
105
121
  );
106
122
 
107
123
  const [currentStage, setCurrentStage] = useState<CheckoutStage>("userDetails");
@@ -135,6 +151,8 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
135
151
 
136
152
  const [isFreeCheckoutLoading, setIsFreeCheckoutLoading] = useState(false);
137
153
  const [created, setCreated] = useState<CreateOrderResult | null>(null);
154
+ /** Set instead of `created` when the cart is a bundle. */
155
+ const [checkoutId, setCheckoutId] = useState<string | null>(null);
138
156
  const [startError, setStartError] = useState("");
139
157
  const startedRef = useRef(false);
140
158
 
@@ -161,6 +179,28 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
161
179
  useEffect(() => {
162
180
  if (currentStage !== "payment" || !isPaidCheckout || startedRef.current) return;
163
181
  startedRef.current = true;
182
+
183
+ // A cart spanning tickets AND products goes through the bundle engine so
184
+ // both settle on one payment. Every single-surface cart keeps the flow it
185
+ // has always had, below.
186
+ if (isBundle) {
187
+ createCheckout
188
+ .mutateAsync({
189
+ firstName: guestUserData?.firstName || user?.firstName || "",
190
+ lastName: guestUserData?.lastName || user?.lastName || "",
191
+ email: guestUserData?.email || user?.email || "",
192
+ accountId: user?.id,
193
+ shippingAddress: shippingData,
194
+ selectedShippingRates: selectedShippingRates.length ? selectedShippingRates : undefined,
195
+ attributionRefId: readAttributionRef() ?? undefined,
196
+ ...(readLanding() ?? {}),
197
+ lines: cartToCheckoutLines(cartItems, ticketItems),
198
+ })
199
+ .then((r) => setCheckoutId(r.checkoutId))
200
+ .catch((e) => setStartError(messageOf(e) || "An error occurred"));
201
+ return;
202
+ }
203
+
164
204
  createOrder
165
205
  .mutateAsync({
166
206
  amount: subTotal,
@@ -183,14 +223,17 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
183
223
  // eslint-disable-next-line react-hooks/exhaustive-deps
184
224
  }, [currentStage, isPaidCheckout]);
185
225
 
186
- const returnUrl = created
187
- ? `${typeof window !== "undefined" ? window.location.origin : ""}${finalisePath}?orderId=${created.orderId}`
188
- : "";
226
+ const origin = typeof window !== "undefined" ? window.location.origin : "";
227
+ const returnUrl = checkoutId
228
+ ? `${origin}${finalisePath}?checkoutId=${checkoutId}`
229
+ : created
230
+ ? `${origin}${finalisePath}?orderId=${created.orderId}`
231
+ : "";
189
232
  const flow = usePaymentFlow({
190
- path: "/public/orders/start-payment",
191
- body: { orderId: created?.orderId },
233
+ path: checkoutId ? "/public/checkouts/start-payment" : "/public/orders/start-payment",
234
+ body: checkoutId ? { checkoutId } : { orderId: created?.orderId },
192
235
  returnUrl,
193
- enabled: !!created?.orderId,
236
+ enabled: !!checkoutId || !!created?.orderId,
194
237
  });
195
238
 
196
239
  const orderId = created?.orderId ?? null;
@@ -394,6 +437,10 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
394
437
  confirmRates,
395
438
  // ── Order + payment ────────────────────────────────────────────────────────
396
439
  orderId,
440
+ /** Set instead of `orderId` when the cart spans surfaces. */
441
+ checkoutId,
442
+ isBundle,
443
+ ticketItems,
397
444
  isCreatingOrder: createOrder.isPending || flow.isStarting,
398
445
  startError,
399
446
  provider,
@@ -1,5 +1,6 @@
1
1
  import { useMemo, useState } from "react";
2
2
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
3
+ import { useCart } from "../../../contexts/CartContext";
3
4
  import { useEvent, useCreateEventOrder } from "../../../data/queries/useEvents";
4
5
  import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
5
6
  import { readAttributionRef } from "../../../utils/attribution";
@@ -29,6 +30,7 @@ const errMessage = (e: unknown) =>
29
30
  */
30
31
  export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions = {}) {
31
32
  const { user } = usePublicAuth();
33
+ const { setTickets, hasTicketsFor } = useCart();
32
34
  // The detail is resolved by slug; the order is created against the real event
33
35
  // id (the orders endpoint looks the event up by id, not slug).
34
36
  const { data: event, isLoading } = useEvent(slug);
@@ -66,6 +68,37 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
66
68
  return next;
67
69
  });
68
70
 
71
+ /**
72
+ * Put this selection in the cart instead of paying for it now — the exit
73
+ * that makes add-ons possible, because the buyer has to be able to leave for
74
+ * a product page and come back with the tickets still chosen.
75
+ *
76
+ * A tickets-only purchase still uses `continueToPayment`, the original
77
+ * untouched flow; the cart path only matters once something else is in the
78
+ * cart too.
79
+ */
80
+ const addTicketsToCart = () => {
81
+ if (ticketCount === 0) {
82
+ setError("Select at least one ticket.");
83
+ return false;
84
+ }
85
+ if (!event) return false;
86
+ setError(null);
87
+ setTickets({
88
+ eventId: event.id,
89
+ eventSlug: slug ?? event.id,
90
+ eventTitle: event.title,
91
+ coverImage: event.media?.find((m: { type: string }) => m.type === "image")?.url,
92
+ tickets: { ...selectedTickets },
93
+ ticketMeta: Object.fromEntries(
94
+ event.tickets
95
+ .filter((t) => (selectedTickets[t.id] ?? 0) > 0)
96
+ .map((t) => [t.id, { title: t.title, price: t.price }]),
97
+ ),
98
+ });
99
+ return true;
100
+ };
101
+
69
102
  const goToDetails = () => {
70
103
  if (ticketCount === 0) {
71
104
  setError("Select at least one ticket.");
@@ -132,6 +165,9 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
132
165
  questionnaire,
133
166
  setQuestionnaire,
134
167
  goToDetails,
168
+ addTicketsToCart,
169
+ /** True when this event's tickets are already sitting in the cart. */
170
+ ticketsInCart: event ? hasTicketsFor(event.id) : false,
135
171
  continueToPayment,
136
172
  clientSecret: flow.clientSecret,
137
173
  returnUrl,
@@ -0,0 +1,159 @@
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, type ReactNode } from "react";
2
+ import { useTrackEvent } from "../../../data/queries/useAnalytics";
3
+ import { useCookieConsent } from "../consent/useCookieConsent";
4
+ import { getFunnelSessionId, markOnce, resetFunnelSession } from "./funnelSession";
5
+
6
+ /** Event types this primitive writes to the first-party analytics feed. */
7
+ export const FUNNEL_EVENTS = {
8
+ entered: "funnel_entered",
9
+ stepViewed: "funnel_step_viewed",
10
+ stepCompleted: "funnel_step_completed",
11
+ converted: "funnel_converted",
12
+ } as const;
13
+
14
+ export interface FunnelContextValue {
15
+ funnelId: string;
16
+ funnelName?: string;
17
+ /** The declared step ids, in order. */
18
+ steps: string[];
19
+ /** This visitor's id for this funnel, or null when storage is unavailable. */
20
+ funnelSessionId: string | null;
21
+ /** Position of a step in the declared order, or -1 if it wasn't declared. */
22
+ indexOf(stepId: string): number;
23
+ /** Record that a step became visible. De-duplicated per funnel session. */
24
+ viewStep(stepId: string, data?: Record<string, unknown>): void;
25
+ /** Record that a step's goal was met (form submitted, CTA taken, payment made). */
26
+ completeStep(stepId: string, data?: Record<string, unknown>): void;
27
+ /** Terminal success for the whole funnel. */
28
+ convert(data?: Record<string, unknown>): void;
29
+ /** Start a fresh session for this funnel (a deliberate "start over"). */
30
+ restart(): void;
31
+ }
32
+
33
+ const FunnelContext = createContext<FunnelContextValue | null>(null);
34
+
35
+ export interface FunnelProps {
36
+ /** Stable id — the grouping key in the admin report. Changing it starts a new funnel. */
37
+ id: string;
38
+ /**
39
+ * The ordered step ids.
40
+ *
41
+ * Required, and the reason this component exists rather than a bare `track()`
42
+ * call: a step nobody reaches emits nothing, so a report built only from
43
+ * observed events would end the funnel at the last step someone got to and
44
+ * quietly hide the rest. The declaration ships with the entry event, and the
45
+ * report joins counts onto it — so an untouched step shows as 0, which is the
46
+ * number you actually needed to see.
47
+ */
48
+ steps: string[];
49
+ /** Human label for the report. Defaults to the id. */
50
+ name?: string;
51
+ /** Master switch — pass `false` in previews/editors to record nothing. Default `true`. */
52
+ enabled?: boolean;
53
+ /**
54
+ * Honour the visitor's cookie choice, matching `<ForgeAnalytics />`. Default
55
+ * `true`: nothing is recorded until `performance` consent is given.
56
+ */
57
+ requireConsent?: boolean;
58
+ children: ReactNode;
59
+ }
60
+
61
+ /**
62
+ * Wraps a multi-step flow and records how far each visitor gets.
63
+ *
64
+ * It renders nothing of its own — it supplies the context `useFunnelStep()`
65
+ * reads, and emits one `funnel_entered` event per visitor per funnel carrying
66
+ * the step declaration. Everything rides the existing first-party analytics
67
+ * feed (`POST /public/websites/track-event`), so funnels need no separate
68
+ * pipeline and travel with `website_events` when analytics moves to ClickHouse.
69
+ *
70
+ * Mount it above the steps. When each step is its own page, mount it in the
71
+ * layout those pages share — the session lives in `sessionStorage`, so it
72
+ * survives the navigations between them.
73
+ *
74
+ * <Funnel id="spring-launch" steps={["landing", "quiz", "offer", "checkout"]}>
75
+ * <Outlet />
76
+ * </Funnel>
77
+ *
78
+ * Each step component then declares itself with one hook call:
79
+ *
80
+ * const step = useFunnelStep("optin");
81
+ */
82
+ export function Funnel({ id, steps, name, enabled = true, requireConsent = true, children }: FunnelProps) {
83
+ const { performance: perfConsent } = useCookieConsent();
84
+ const on = enabled && (!requireConsent || perfConsent);
85
+ const { track } = useTrackEvent();
86
+
87
+ // Read once per render pass rather than per event: minting is idempotent, but
88
+ // touching storage on every call for no reason isn't free.
89
+ const funnelSessionId = useMemo(() => (on ? getFunnelSessionId(id) : null), [on, id]);
90
+
91
+ // `steps` is almost always an inline array literal, so identity changes every
92
+ // render. Compare by value or the entry effect re-fires forever. Joined on a
93
+ // character a step id can't contain, so ["a b"] and ["a","b"] can't collide
94
+ // into the same key.
95
+ const stepsKey = steps.join("\u0000");
96
+ const stepList = useMemo(() => steps.slice(), [stepsKey]); // eslint-disable-line react-hooks/exhaustive-deps
97
+
98
+ const emit = useCallback(
99
+ (eventType: string, data: Record<string, unknown>) => {
100
+ if (!on) return;
101
+ track(eventType, {
102
+ funnelId: id,
103
+ funnelName: name ?? id,
104
+ funnelSessionId: funnelSessionId ?? undefined,
105
+ pathname: typeof window !== "undefined" ? window.location.pathname : undefined,
106
+ ...data,
107
+ });
108
+ },
109
+ [on, track, id, name, funnelSessionId],
110
+ );
111
+
112
+ // Keep the latest emitter without making the entry effect depend on it — the
113
+ // entry event must fire once per session, not once per identity change.
114
+ const emitRef = useRef(emit);
115
+ emitRef.current = emit;
116
+
117
+ useEffect(() => {
118
+ if (!on) return;
119
+ if (!markOnce(id, "entered")) return;
120
+ emitRef.current(FUNNEL_EVENTS.entered, { steps: stepList, stepCount: stepList.length });
121
+ }, [on, id, stepsKey]); // eslint-disable-line react-hooks/exhaustive-deps
122
+
123
+ const value = useMemo<FunnelContextValue>(() => {
124
+ const indexOf = (stepId: string) => stepList.indexOf(stepId);
125
+ return {
126
+ funnelId: id,
127
+ funnelName: name,
128
+ steps: stepList,
129
+ funnelSessionId,
130
+ indexOf,
131
+ viewStep: (stepId, data) => {
132
+ // One view per step per session: back-navigation and remounts are not
133
+ // new visitors. Counts are DISTINCT-session anyway, so this is about
134
+ // keeping the event feed honest rather than correcting the numbers.
135
+ if (!markOnce(id, `view_${stepId}`)) return;
136
+ emit(FUNNEL_EVENTS.stepViewed, { stepId, stepIndex: indexOf(stepId), ...data });
137
+ },
138
+ completeStep: (stepId, data) => {
139
+ if (!markOnce(id, `complete_${stepId}`)) return;
140
+ emit(FUNNEL_EVENTS.stepCompleted, { stepId, stepIndex: indexOf(stepId), ...data });
141
+ },
142
+ convert: (data) => {
143
+ if (!markOnce(id, "converted")) return;
144
+ emit(FUNNEL_EVENTS.converted, { ...data });
145
+ },
146
+ restart: () => resetFunnelSession(id),
147
+ };
148
+ }, [id, name, stepList, funnelSessionId, emit]);
149
+
150
+ return <FunnelContext.Provider value={value}>{children}</FunnelContext.Provider>;
151
+ }
152
+
153
+ /**
154
+ * The surrounding funnel, or `null` outside one. Null rather than throwing so a
155
+ * page can be dropped into a funnel or used standalone without branching.
156
+ */
157
+ export function useFunnel(): FunnelContextValue | null {
158
+ return useContext(FunnelContext);
159
+ }