@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
|
@@ -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
|
+
}
|
package/src/types/models.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
() =>
|
|
104
|
-
|
|
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
|
|
187
|
-
|
|
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
|
+
}
|