@appright/funnelshift 0.0.0-stage → 0.16.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 (88) hide show
  1. package/bin/funnel-kit.js +2 -0
  2. package/dist/anchors.d.ts +4 -0
  3. package/dist/anchors.js +94 -0
  4. package/dist/answers.d.ts +11 -0
  5. package/dist/answers.js +100 -0
  6. package/dist/attribution.d.ts +34 -0
  7. package/dist/attribution.js +237 -0
  8. package/dist/autotrack.d.ts +4 -0
  9. package/dist/autotrack.js +13 -0
  10. package/dist/backnav.d.ts +15 -0
  11. package/dist/backnav.js +73 -0
  12. package/dist/blocks/Choice.d.ts +19 -0
  13. package/dist/blocks/Choice.js +38 -0
  14. package/dist/blocks/Cta.d.ts +7 -0
  15. package/dist/blocks/Cta.js +8 -0
  16. package/dist/blocks/Hero.d.ts +10 -0
  17. package/dist/blocks/Hero.js +48 -0
  18. package/dist/blocks/MultiChoice.d.ts +13 -0
  19. package/dist/blocks/MultiChoice.js +47 -0
  20. package/dist/blocks/Section.d.ts +6 -0
  21. package/dist/blocks/Section.js +4 -0
  22. package/dist/bootstrap.d.ts +33 -0
  23. package/dist/bootstrap.js +19 -0
  24. package/dist/bucket.d.ts +13 -0
  25. package/dist/bucket.js +41 -0
  26. package/dist/cli/args.d.ts +5 -0
  27. package/dist/cli/args.js +18 -0
  28. package/dist/cli/build.d.ts +6 -0
  29. package/dist/cli/build.js +200 -0
  30. package/dist/cli/extract.d.ts +32 -0
  31. package/dist/cli/extract.js +253 -0
  32. package/dist/cli/index.d.ts +2 -0
  33. package/dist/cli/index.js +40 -0
  34. package/dist/cli/init.d.ts +5 -0
  35. package/dist/cli/init.js +67 -0
  36. package/dist/cli/lint.d.ts +25 -0
  37. package/dist/cli/lint.js +535 -0
  38. package/dist/cli/upload.d.ts +32 -0
  39. package/dist/cli/upload.js +100 -0
  40. package/dist/clicks.d.ts +7 -0
  41. package/dist/clicks.js +102 -0
  42. package/dist/content.d.ts +7 -0
  43. package/dist/content.js +60 -0
  44. package/dist/context.d.ts +12 -0
  45. package/dist/context.js +52 -0
  46. package/dist/errors.d.ts +7 -0
  47. package/dist/errors.js +90 -0
  48. package/dist/experiments.d.ts +4 -0
  49. package/dist/experiments.js +24 -0
  50. package/dist/exposure.d.ts +4 -0
  51. package/dist/exposure.js +35 -0
  52. package/dist/functions.d.ts +37 -0
  53. package/dist/functions.js +24 -0
  54. package/dist/funnel.d.ts +64 -0
  55. package/dist/funnel.js +433 -0
  56. package/dist/handoff.d.ts +8 -0
  57. package/dist/handoff.js +66 -0
  58. package/dist/index.d.ts +17 -0
  59. package/dist/index.js +17 -0
  60. package/dist/nav.d.ts +19 -0
  61. package/dist/nav.js +14 -0
  62. package/dist/offer.d.ts +3 -0
  63. package/dist/offer.js +7 -0
  64. package/dist/pixel.d.ts +36 -0
  65. package/dist/pixel.js +173 -0
  66. package/dist/prerender.d.ts +2 -0
  67. package/dist/prerender.js +16 -0
  68. package/dist/server.d.ts +12 -0
  69. package/dist/server.js +88 -0
  70. package/dist/sha256.d.ts +2 -0
  71. package/dist/sha256.js +65 -0
  72. package/dist/signals.d.ts +18 -0
  73. package/dist/signals.js +300 -0
  74. package/dist/tracker.d.ts +80 -0
  75. package/dist/tracker.js +446 -0
  76. package/dist/uuid.d.ts +1 -0
  77. package/dist/uuid.js +12 -0
  78. package/dist/vitals.d.ts +4 -0
  79. package/dist/vitals.js +104 -0
  80. package/package.json +29 -4
  81. package/src/blocks/blocks.css +364 -0
  82. package/templates/init/CLAUDE.md +10 -0
  83. package/templates/init/funnels/welcome/index.tsx +129 -0
  84. package/templates/init/gitignore +5 -0
  85. package/templates/init/package.json.tmpl +20 -0
  86. package/templates/init/theme.css +77 -0
  87. package/templates/init/tsconfig.json +7 -0
  88. package/README.md +0 -3
package/dist/funnel.js ADDED
@@ -0,0 +1,433 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
+ import { createRoot, hydrateRoot } from 'react-dom/client';
4
+ import { nextIndex, prevIndex } from './nav.js';
5
+ import { readBootstrap, readCreator } from './bootstrap.js';
6
+ import { setContent, keyOfText } from './content.js';
7
+ import { installClicks } from './clicks.js';
8
+ import { Tracker, validUserId } from './tracker.js';
9
+ import { autoOn } from './autotrack.js';
10
+ import { installStepSignals, sectionName } from './signals.js';
11
+ import { installVitals } from './vitals.js';
12
+ import { installBackNav } from './backnav.js';
13
+ import { installAnchors, scrollToInitialHash } from './anchors.js';
14
+ import { installErrors } from './errors.js';
15
+ import { batchContext } from './context.js';
16
+ import { emailHash } from './sha256.js';
17
+ import { visit } from './server.js';
18
+ import { exposeVariants } from './exposure.js';
19
+ import { attribution } from './attribution.js';
20
+ import { encodeAnswer, packAnswers } from './answers.js';
21
+ import { LabelCtx, sameValue } from './offer.js';
22
+ import { funnelSlug, setHandoff, visitorId } from './handoff.js';
23
+ import { installPixel, metaCookies, readPixel, revokePixel, setPixel } from './pixel.js';
24
+ export const defineStep = (s) => s;
25
+ export const defineFunnel = (f) => f;
26
+ export const FunnelCtx = createContext(null);
27
+ export function useFunnel() {
28
+ const c = useContext(FunnelCtx);
29
+ if (!c)
30
+ throw new Error('useFunnel() outside <FunnelApp>');
31
+ return c;
32
+ }
33
+ // The answers of a step: every key that was last set() on this step, except skipAnswers, value and label (answers.ts).
34
+ // More than MAX_PROPS props are split: the first part is the $step_complete props, the rest go as $step_answers.
35
+ // trackAnswers (kit 0.3) and sensitive filter nothing any more.
36
+ // extra (active_ms) is counted in the first event's props like an answer, so it never pushes an answer out of the 128.
37
+ function stepAnswers(step, asked, a, labels, extra) {
38
+ const skip = new Set(step.skipAnswers ?? []);
39
+ const keys = Object.keys(asked).filter(k => asked[k] === step.id && !skip.has(k));
40
+ return packAnswers([...(extra ? [extra] : []), ...keys.map(k => encodeAnswer(k, a[k], labels[k]))]);
41
+ }
42
+ // load/save: a JSON object in sessionStorage. Storage can be blocked or hold junk: the funnel must still work.
43
+ function load(key) {
44
+ try {
45
+ const v = JSON.parse(sessionStorage.getItem(key) ?? '{}');
46
+ return (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
47
+ }
48
+ catch {
49
+ return {};
50
+ }
51
+ }
52
+ function save(key, v) { try {
53
+ sessionStorage.setItem(key, JSON.stringify(v));
54
+ }
55
+ catch { /* storage unavailable */ } }
56
+ // $lead: the visitor left a contact. Sent at most once per session (a sessionStorage flag per funnel; a blocked storage falls back
57
+ // to memory), by identify() or by completing a step with role 'lead'.
58
+ const leadSent = new Set();
59
+ function leadAlreadySent(funnelId) {
60
+ const key = `af_lead:${funnelId}`;
61
+ try {
62
+ if (sessionStorage.getItem(key))
63
+ return true;
64
+ sessionStorage.setItem(key, '1');
65
+ return false;
66
+ }
67
+ catch {
68
+ if (leadSent.has(key))
69
+ return true;
70
+ leadSent.add(key);
71
+ return false;
72
+ }
73
+ }
74
+ // af_known=1, set by the product's app on the parent domain after login (not HttpOnly: the kit reads it).
75
+ function knownCookie() {
76
+ try {
77
+ return /(?:^|;\s*)af_known=1(?:;|$)/.test(document.cookie);
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
83
+ // The funnel that is running now: identify() reports through it. Not set in shot mode, where nothing is ever sent.
84
+ let live = null;
85
+ const TRAIT_KEY = /^[a-z][a-z0-9_]{0,56}$/;
86
+ export function identify(userId, opts = {}) {
87
+ if (userId === undefined) {
88
+ if (live && !leadAlreadySent(live.funnelId))
89
+ live.track('$lead', {}, live.stepId);
90
+ return;
91
+ }
92
+ if (!validUserId(userId)) {
93
+ console.warn('funnel-kit: identify() needs the product\'s user id: up to 128 characters, no "@" (an email is not an id), no control characters; not sent');
94
+ return;
95
+ }
96
+ const tr = current;
97
+ if (!tr || tr.stopped())
98
+ return; // a thumbnail, vite dev, or the visitor must not be tracked
99
+ tr.setUser(userId);
100
+ const props = { user_id: userId };
101
+ const email = typeof opts.email === 'string' ? opts.email.trim() : '';
102
+ const hash = email ? emailHash(email) : '';
103
+ if (hash)
104
+ props.email_hash = hash;
105
+ if (hash && opts.sendEmail === true)
106
+ props.email = email;
107
+ for (const [k, v] of Object.entries(opts.traits ?? {})) {
108
+ const key = k.toLowerCase();
109
+ if (!TRAIT_KEY.test(key) || key.includes('email'))
110
+ continue;
111
+ if (typeof v === 'string' && v.includes('@'))
112
+ continue; // contacts go through email, hashed
113
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')
114
+ props['trait_' + key] = v;
115
+ }
116
+ tr.track('$identify', props, live?.stepId ?? '');
117
+ if (hash && live && !leadAlreadySent(live.funnelId))
118
+ live.track('$lead', {}, live.stepId);
119
+ }
120
+ // reset(): the visitor is nobody's again (the product's logout): later events carry no user id. The visitor id stays.
121
+ export function reset() {
122
+ current?.clearUser();
123
+ if (current)
124
+ return;
125
+ try {
126
+ const b = window.__FUNNEL__;
127
+ if (b?.project)
128
+ localStorage.removeItem('af_user:' + b.project);
129
+ }
130
+ catch { /* storage unavailable */ }
131
+ }
132
+ export function shotRequest(search) {
133
+ const q = new URLSearchParams(search);
134
+ const step = q.get('__step');
135
+ return q.get('__shot') === '1' && step ? { step } : null;
136
+ }
137
+ // One screen, in the same layout as in the funnel, with the sample answers of the step. It is a picture: nothing is tracked,
138
+ // nothing is stored, and the buttons do nothing. `fk-shot` turns the animations off (blocks.css).
139
+ function ShotView({ funnel, stepId }) {
140
+ const found = funnel.steps.findIndex(s => s.id === stepId);
141
+ const index = found < 0 ? 0 : found;
142
+ const step = funnel.steps[index];
143
+ const ctx = useMemo(() => {
144
+ const none = () => { };
145
+ return { answers: { ...step.sample?.answers }, set: none, next: none, back: none, skip: none, track: none,
146
+ stepId: step.id, index, total: funnel.steps.length, progress: 0, shot: true, stopTracking: async () => { }, creator: null,
147
+ visitorId: '', funnelId: funnelSlug() || funnel.id, userId: '', knownUser: false };
148
+ }, [funnel, step, index]);
149
+ const C = step.component;
150
+ return _jsx(FunnelCtx.Provider, { value: ctx, children: _jsx("div", { className: "fk-shot", children: _jsx(C, {}) }) });
151
+ }
152
+ // root: the element the funnel is drawn into. With it, every click on a button, link or option inside goes as $click (clicks.ts);
153
+ // mount() passes its element. A thumbnail never tracks clicks.
154
+ // browserBack: handle the browser's Back with the history (backnav.ts); mount() turns it on for a real visit.
155
+ export function FunnelApp({ funnel, track, shot, root, browserBack }) {
156
+ if (shot)
157
+ return _jsx(ShotView, { funnel: funnel, stepId: shot.step });
158
+ return _jsx(LiveFunnel, { funnel: funnel, track: track, root: root ?? null, browserBack: browserBack === true });
159
+ }
160
+ // Answers, their labels and the step each answer was last set on live in sessionStorage per funnel (af_answers, af_labels,
161
+ // af_asked), so after a reload a step completed again still sends its answers with their texts.
162
+ function LiveFunnel({ funnel, track, root, browserBack }) {
163
+ const store = `af_answers:${funnel.id}`;
164
+ const labelStore = `af_labels:${funnel.id}`;
165
+ const askedStore = `af_asked:${funnel.id}`;
166
+ const [answers, setAnswers] = useState(() => load(store));
167
+ const [index, setIndex] = useState(() => nextIndex(funnel.steps, answers, -1) ?? 0);
168
+ const labels = useRef(load(labelStore));
169
+ const asked = useRef(load(askedStore));
170
+ // The label a Choice/MultiChoice offers while its onChange runs (offer.ts).
171
+ const offered = useRef(null);
172
+ const withLabel = useMemo(() => (value, label, fn) => {
173
+ const prev = offered.current;
174
+ offered.current = { value, label };
175
+ try {
176
+ fn();
177
+ }
178
+ finally {
179
+ offered.current = prev;
180
+ }
181
+ }, []);
182
+ const started = useRef(false);
183
+ // Read once: the bootstrap does not change while the page lives.
184
+ const [creator] = useState(() => readCreator());
185
+ const step = funnel.steps[index];
186
+ // The step on screen and the track function for a click: read when the click happens, before the button's own handler can move
187
+ // on, so one listener serves the whole funnel.
188
+ const onScreen = useRef({ step: step.id, track });
189
+ onScreen.current = { step: step.id, track };
190
+ // server() sends the step on screen. Set while rendering, like onScreen: a step that calls its function in its own effect runs
191
+ // that effect before any effect of the funnel would.
192
+ visit.step = step.id;
193
+ // A layout effect, not a plain one: the listener is on the root before the first paint, so a click right after the screen
194
+ // appears is never missed (a plain effect can run after the browser has already delivered that click).
195
+ useLayoutEffect(() => {
196
+ if (!root)
197
+ return;
198
+ const offClicks = installClicks(root, { stepId: () => onScreen.current.step, track: (n, p, s) => onScreen.current.track(n, p, s), keyOf: keyOfText });
199
+ const offAnchors = installAnchors(root);
200
+ return () => { offClicks(); offAnchors(); };
201
+ }, [root]);
202
+ // A link with a hash in the address (a shared link to a section): scroll to it once the first step is drawn.
203
+ useEffect(() => (root ? scrollToInitialHash() : undefined), [root]);
204
+ // Scroll depth, sections, field errors and the active clock of the step on screen (signals.ts). Without a root they watch the page.
205
+ const signals = useRef(null);
206
+ useLayoutEffect(() => {
207
+ const at = funnel.autoTrack;
208
+ const s = installStepSignals(root ?? document.body, { stepId: () => onScreen.current.step, track: (n, p, st) => onScreen.current.track(n, p, st),
209
+ scroll: autoOn(at, 'scroll'), sections: autoOn(at, 'sections'), fieldErrors: autoOn(at, 'fieldErrors'), activeTime: autoOn(at, 'activeTime') });
210
+ signals.current = s;
211
+ return () => { s.off(); if (signals.current === s)
212
+ signals.current = null; };
213
+ }, [root, funnel]);
214
+ // The browser's Back (backnav.ts): reads the screen and the answers as they are when it happens.
215
+ const now = useRef({ index, answers, reached: index });
216
+ now.current = { index, answers, reached: Math.max(now.current.reached, index) };
217
+ const backNav = useRef(null);
218
+ // The time on the step for the events that leave it (undefined: the clock is off).
219
+ const activeProps = () => {
220
+ const ms = signals.current?.activeMs();
221
+ return ms === undefined ? undefined : { active_ms: String(ms) };
222
+ };
223
+ useEffect(() => {
224
+ if (!browserBack)
225
+ return;
226
+ const b = installBackNav({
227
+ index: () => now.current.index, stepId: i => funnel.steps[i].id,
228
+ prev: () => prevIndex(funnel.steps, now.current.answers, now.current.index),
229
+ next: () => nextIndex(funnel.steps, now.current.answers, now.current.index),
230
+ reached: () => now.current.reached,
231
+ back: p => { onScreen.current.track('$back', { ...activeProps(), via: 'browser' }, funnel.steps[now.current.index].id); setIndex(p); },
232
+ forward: n => setIndex(n),
233
+ });
234
+ backNav.current = b;
235
+ return () => { b.off(); if (backNav.current === b)
236
+ backNav.current = null; };
237
+ }, [browserBack, funnel]);
238
+ useEffect(() => {
239
+ live = { funnelId: funnel.id, track, stepId: step.id };
240
+ return () => { if (live?.track === track)
241
+ live = null; };
242
+ }, [funnel.id, track, step.id]);
243
+ useEffect(() => {
244
+ if (!started.current) {
245
+ started.current = true;
246
+ track('$funnel_start');
247
+ exposeVariants(funnel.id, readBootstrap().variants, track);
248
+ }
249
+ track('$step_view', {}, step.id);
250
+ signals.current?.newView();
251
+ }, [step.id]);
252
+ const ctx = useMemo(() => {
253
+ const set = (k, v, label) => {
254
+ const o = offered.current;
255
+ if (label === undefined && o && sameValue(o.value, v))
256
+ label = o.label;
257
+ // A set() without a label replaces the old one too: a stale text must not describe a new value.
258
+ if (label === undefined)
259
+ delete labels.current[k];
260
+ else
261
+ labels.current[k] = label;
262
+ asked.current[k] = step.id;
263
+ save(labelStore, labels.current);
264
+ save(askedStore, asked.current);
265
+ setAnswers(prev => { const n = { ...prev, [k]: v }; save(store, n); return n; });
266
+ answers[k] = v; // next() called right after set() in one handler must see the fresh answer; React applies setAnswers later
267
+ };
268
+ const active = activeProps;
269
+ const go = (kind) => {
270
+ if (kind === '$step_complete') {
271
+ const [first, ...rest] = stepAnswers(step, asked.current, answers, labels.current, active());
272
+ track(kind, first, step.id);
273
+ for (const more of rest)
274
+ track('$step_answers', more, step.id);
275
+ }
276
+ else
277
+ track(kind, active() ?? {}, step.id);
278
+ if (kind === '$step_complete' && step.role === 'lead' && !leadAlreadySent(funnel.id))
279
+ track('$lead', {}, step.id);
280
+ const n = nextIndex(funnel.steps, answers, index);
281
+ if (n === null)
282
+ track('$funnel_complete', {}, step.id);
283
+ else {
284
+ setIndex(n);
285
+ backNav.current?.push(n);
286
+ }
287
+ };
288
+ const visible = funnel.steps.filter(s => !s.when || s.when(answers)).length;
289
+ const pos = funnel.steps.slice(0, index + 1).filter(s => !s.when || s.when(answers)).length;
290
+ const funnelId = funnelSlug() || funnel.id;
291
+ return {
292
+ answers, set, track, stepId: step.id, index, total: visible, progress: visible ? pos / visible : 0, shot: false, stopTracking, creator,
293
+ // a getter: '' right after stopTracking(), without a new render
294
+ get visitorId() { return visitorId(); }, funnelId,
295
+ // getters too: identify() or the app's login can change them without a new render
296
+ get userId() { return current?.user() ?? ''; }, get knownUser() { return knownCookie(); },
297
+ next: () => go('$step_complete'), skip: () => go('$step_skip'),
298
+ back: () => { const p = prevIndex(funnel.steps, answers, index); if (p !== null) {
299
+ track('$back', { ...active(), via: 'button' }, step.id);
300
+ setIndex(p);
301
+ backNav.current?.syncBack();
302
+ } },
303
+ };
304
+ }, [answers, index, step, funnel, track, creator]);
305
+ const C = step.component;
306
+ return _jsx(LabelCtx.Provider, { value: withLabel, children: _jsx(FunnelCtx.Provider, { value: ctx, children: _jsx(C, {}) }) });
307
+ }
308
+ let current = null;
309
+ export function track(name, props) { current?.track(name, props); }
310
+ // fieldError(field, code) (kit 0.13.0): a field of your own showed a validation error. The kit already reports a field that the browser
311
+ // rejects (a native `invalid`) or that gets aria-invalid="true"; call this when the error shows some other way (a message under
312
+ // a custom input). Sends $field_error with the step on screen. field: the name of the field ([a-z0-9_-], at most 64 characters),
313
+ // code: a short word ([a-z][a-z0-9_]{0,31}: required, email_invalid, too_short, …). Never pass what the person typed.
314
+ export function fieldError(field, code) {
315
+ const f = sectionName(field);
316
+ if (!f || !/^[a-z][a-z0-9_]{0,31}$/.test(code))
317
+ return;
318
+ live?.track('$field_error', { field: f, code }, live.stepId);
319
+ }
320
+ // stopTracking(): the person must not be tracked (under 13 at an age gate, an opt-out). Clears the funnel's queue and sends
321
+ // nothing more from this tab until it closes, reloads included (sessionStorage af_stop:<funnel>): no $funnel_start or
322
+ // $step_view on the next load, and server() stops sending X-Session/X-Step, so function calls are not tied to the visit.
323
+ // scope 'project' keeps the stop for every funnel of the project in this browser (localStorage af_stop_project:<project>).
324
+ // final, given, is sent once, right away, as an event nobody can tie to this person: a fresh random visitor and session, no
325
+ // attribution, no step, no answers, only its own props (say { name: 'age_gate_blocked' }). Outside a mounted funnel: nothing.
326
+ export async function stopTracking(opts) {
327
+ if (current)
328
+ revokePixel();
329
+ await current?.halt(opts?.final, opts?.scope ?? 'tab');
330
+ }
331
+ // mount: read the bootstrap the router injected, set content, start the tracker, render the funnel.
332
+ // In shot mode (see shotRequest) no Tracker is made at all: a thumbnail in the admin must not count as a visit.
333
+ export function mount(funnel, el = document.getElementById('root')) {
334
+ const b = readBootstrap();
335
+ setContent(b.content ?? {}, b.content_overrides ?? {});
336
+ const shot = shotRequest(location.search);
337
+ visit.step = '';
338
+ visit.session = () => '';
339
+ visit.off = () => false;
340
+ if (shot) {
341
+ setPixel(null);
342
+ setHandoff({ visitor: () => '', funnel: b.funnel });
343
+ createRoot(el).render(_jsx(FunnelApp, { funnel: funnel, track: () => { }, shot: shot }));
344
+ return;
345
+ }
346
+ // On a project's own domain (the bootstrap names the functions' prefix) the funnel is mounted at "/<funnel>/" or "/", not at
347
+ // "/<project>/<funnel>/": the link's creator segments are read after the router's <base href>.
348
+ const own = typeof b.fn_base === 'string';
349
+ const mountPath = own ? basePath() : undefined;
350
+ // Meta's cookies first, so the landing read below carries them (pixel.ts).
351
+ if (readPixel(b.meta_pixel) && b.env === 'prod') {
352
+ try {
353
+ metaCookies();
354
+ }
355
+ catch { /* cookies blocked */ }
356
+ }
357
+ current = new Tracker({ ingestUrl: b.ingest_url, fallbackUrl: b.ingest_fallback_url ?? null, project: b.project, key: b.ingest_key,
358
+ attribution: attribution(location, document, sessionStorage, { project: b.project, funnel: b.funnel, mountPath, own }),
359
+ base: { funnel_id: b.funnel, build_id: b.build_id, release_id: b.release_id, env: b.env, visitor_id: b.visitor_id, url_path: location.pathname },
360
+ variants: b.variants ?? {}, geo: typeof b.geo === 'string' ? b.geo : '', context: () => batchContext({ net: autoOn(funnel.autoTrack, 'network') }) });
361
+ const tr = current;
362
+ setHandoff({ visitor: () => tr.stopped() ? '' : b.visitor_id, funnel: b.funnel });
363
+ // After stopTracking() (this page or before a reload) the tracker stays silent, and so does its timer.
364
+ if (!tr.stopped())
365
+ tr.start();
366
+ visit.session = () => tr.session();
367
+ visit.off = () => tr.stopped();
368
+ // JS errors and page performance are the page's, not a step's: errors carry the step on screen (visit.step, set by the funnel).
369
+ if (!tr.stopped()) {
370
+ if (autoOn(funnel.autoTrack, 'errors'))
371
+ installErrors({ track: (n, p, s) => tr.track(n, p, s), stepId: () => visit.step });
372
+ if (autoOn(funnel.autoTrack, 'perf'))
373
+ installVitals({ send: p => { tr.track('$perf', p); void tr.flush(true); } });
374
+ }
375
+ // The page runs: tell ingest which router $request it answers (the share of requests without this is «ушли до загрузки»).
376
+ if (typeof b.request_id === 'string' && b.request_id && !tr.stopped()) {
377
+ tr.track('$page_loaded', { request_id: b.request_id, ms: typeof performance !== 'undefined' ? Math.round(performance.now()) : undefined });
378
+ void tr.flush();
379
+ }
380
+ // The browser's Back moves a step back, except in the admin's preview and inside a frame (the admin's thumbnails and previews).
381
+ let framed = false;
382
+ try {
383
+ framed = window.self !== window.top;
384
+ }
385
+ catch {
386
+ framed = true;
387
+ }
388
+ const browserBack = funnel.browserBack !== false && b.env !== 'preview' && !framed;
389
+ // Meta Pixel (pixel.ts): only on a real visit of a published funnel whose project switched Meta on.
390
+ const px = readPixel(b.meta_pixel);
391
+ let pixel = null;
392
+ if (px && b.env === 'prod' && !tr.stopped()) {
393
+ try {
394
+ pixel = installPixel(px, { funnel: b.funnel, steps: funnel.steps, visitorId: b.visitor_id, session: () => tr.session(), stopped: () => tr.stopped() });
395
+ }
396
+ catch (e) {
397
+ console.warn('funnel-kit: the Meta pixel did not start', e);
398
+ }
399
+ }
400
+ setPixel(pixel);
401
+ const app = _jsx(FunnelApp, { funnel: funnel, track: (n, p, s) => { tr.track(n, p, s ?? ''); pixel?.after(n, s ?? ''); }, root: el, browserBack: browserBack });
402
+ // A landing the build drew into index.html (`prerender: true`, prerender.tsx) is hydrated when this visit starts on it — the
403
+ // first step is the landing and no saved answers move the funnel on; otherwise the page is drawn again from scratch.
404
+ if (el.dataset?.prerendered === 'landing' && el.hasChildNodes() && startsOnFirstStep(funnel)) {
405
+ // A text edited in the admin, an A/B arm or the creator's greeting differ from what the build drew: React then draws that part
406
+ // again on the client (the page is right, the text changes after the script loads). That is expected, not an error to report.
407
+ hydrateRoot(el, app, { onRecoverableError: e => console.debug('funnel-kit: the prerendered landing differs from this visit, drawn again', e) });
408
+ }
409
+ else
410
+ createRoot(el).render(app);
411
+ }
412
+ // The path of the document's <base href>, the funnel's root as the router mounted it.
413
+ function basePath() {
414
+ try {
415
+ return new URL(document.baseURI).pathname;
416
+ }
417
+ catch {
418
+ return undefined;
419
+ }
420
+ }
421
+ function startsOnFirstStep(funnel) {
422
+ return (nextIndex(funnel.steps, load(`af_answers:${funnel.id}`), -1) ?? 0) === 0;
423
+ }
424
+ // What the landing step sees on the server, at build time (prerender.tsx): an anonymous first visit that has answered nothing.
425
+ // It is the context the client builds on a first visit, minus what only a browser knows (the creator, the visitor id).
426
+ export function landingContext(funnel) {
427
+ const none = () => { };
428
+ const step = funnel.steps[0];
429
+ const visible = funnel.steps.filter(s => !s.when || s.when({})).length;
430
+ return { answers: {}, set: none, next: none, back: none, skip: none, track: none, stepId: step.id, index: 0, total: visible,
431
+ progress: visible ? 1 / visible : 0, shot: false, stopTracking: async () => { }, creator: null, visitorId: '', funnelId: funnel.id,
432
+ userId: '', knownUser: false };
433
+ }
@@ -0,0 +1,8 @@
1
+ export declare function setHandoff(m: {
2
+ visitor: () => string;
3
+ funnel: string;
4
+ } | null): void;
5
+ export declare function visitorId(): string;
6
+ export declare function funnelSlug(): string;
7
+ export declare function paywallUrl(base: string, extra?: Record<string, string>): string;
8
+ export declare function appUrl(base: string, extra?: Record<string, string>): string;
@@ -0,0 +1,66 @@
1
+ // Set by mount(): the visitor while the tracker runs ('' once stopped or in shot mode) and the funnel of the bootstrap.
2
+ let mounted = null;
3
+ export function setHandoff(m) { mounted = m; }
4
+ const boot = () => typeof window === 'undefined' ? undefined : window.__FUNNEL__;
5
+ // visitorId(): the visitor_id of this page's events, usable outside React. Before mount() it is read from the router's bootstrap
6
+ // (window.__FUNNEL__); without one (vite dev, no mount) it is ''.
7
+ export function visitorId() {
8
+ if (mounted)
9
+ return mounted.visitor();
10
+ const v = boot()?.visitor_id;
11
+ return typeof v === 'string' ? v : '';
12
+ }
13
+ // The funnel slug of the bootstrap (the funnel_id of the events), '' when unknown.
14
+ export function funnelSlug() {
15
+ if (mounted)
16
+ return mounted.funnel;
17
+ const f = boot()?.funnel;
18
+ return typeof f === 'string' ? f : '';
19
+ }
20
+ // paywallUrl(base, extra?): base with extra and then fv=<visitor>&ff=<funnel slug> set in its query (other params and the hash
21
+ // kept). A Stripe Payment Link (buy.stripe.com) also gets client_reference_id=<visitor>, which Stripe puts on the Checkout
22
+ // Session. A RevenueCat Web Purchase Link (kit 0.10.0) (pay.rev.cat/<token>, or pay.rev.cat/sandbox/<token>) without an app user id gets
23
+ // the visitor as one (…/<token>/<visitor>): RevenueCat drops unknown query params, its webhook brings app_user_id back. With no visitor known (thumbnail, stopped, no bootstrap) or a base that is not a URL, base comes back unchanged.
24
+ export function paywallUrl(base, extra) {
25
+ const u = withVisit(base, extra);
26
+ if (!u)
27
+ return base;
28
+ const visitor = visitorId();
29
+ if (u.hostname === 'buy.stripe.com')
30
+ u.searchParams.set('client_reference_id', visitor);
31
+ if (u.hostname === 'pay.rev.cat') {
32
+ const seg = u.pathname.split('/').filter(Boolean);
33
+ if (seg.length === (seg[0] === 'sandbox' ? 2 : 1))
34
+ u.pathname = `/${seg.join('/')}/${encodeURIComponent(visitor)}`;
35
+ }
36
+ return u.toString();
37
+ }
38
+ // base with extra and then fv=<visitor>&ff=<funnel slug> set in its query (other params and the hash kept); null when there is no
39
+ // visitor to hand over (thumbnail, stopped, no bootstrap) or base is not a URL.
40
+ function withVisit(base, extra) {
41
+ const visitor = visitorId();
42
+ if (!visitor)
43
+ return null;
44
+ let u;
45
+ try {
46
+ u = typeof location === 'undefined' ? new URL(base) : new URL(base, location.href);
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ for (const [k, v] of Object.entries(extra ?? {}))
52
+ u.searchParams.set(k, v);
53
+ u.searchParams.set('fv', visitor);
54
+ const funnel = funnelSlug();
55
+ if (funnel)
56
+ u.searchParams.set('ff', funnel);
57
+ return u;
58
+ }
59
+ // appUrl(base, extra?): the address of the product's app (kit 0.11.0, «Из воронки в кабинет» in the guide) with what the app needs
60
+ // to recognize the person: fv=<visitor> (the value of the af_vid cookie, which the app's backend reads itself on a shared parent
61
+ // domain; fv is the fallback for a browser that lost it) and ff=<funnel slug>, after extra (the one-time token, session_id…).
62
+ // Secrets never belong here beyond the one-time login token the product made. No visitor known: base comes back unchanged.
63
+ export function appUrl(base, extra) {
64
+ const u = withVisit(base, extra);
65
+ return u ? u.toString() : base;
66
+ }
@@ -0,0 +1,17 @@
1
+ export { defineFunnel, defineStep, FunnelApp, useFunnel, mount, track, identify, reset, stopTracking, fieldError, shotRequest, type FunnelDef, type IdentifyOptions, type ShotRequest, type StopOptions } from './funnel.js';
2
+ export { appUrl, paywallUrl, visitorId } from './handoff.js';
3
+ export { purchased, type PurchaseInfo } from './pixel.js';
4
+ export { t, param, setContent } from './content.js';
5
+ export { experiment, useExperiment, rule, useRule } from './experiments.js';
6
+ export { bucketOf, pickArm } from './bucket.js';
7
+ export { readBootstrap, readCreator, type Bootstrap, type Creator } from './bootstrap.js';
8
+ export { server, ServerFnError } from './server.js';
9
+ export { autoOn, SERVICE_SIGNALS, type AutoTrack, type AutoTrackKey } from './autotrack.js';
10
+ export { Tracker, safeProps, type FinalEvent, type StopScope } from './tracker.js';
11
+ export { nextIndex, prevIndex, type StepDef, type StepRole, type Answers } from './nav.js';
12
+ export { uuidv7 } from './uuid.js';
13
+ export { Hero } from './blocks/Hero.js';
14
+ export { Choice } from './blocks/Choice.js';
15
+ export { MultiChoice } from './blocks/MultiChoice.js';
16
+ export { Cta } from './blocks/Cta.js';
17
+ export { Section, type SectionProps } from './blocks/Section.js';
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ export { defineFunnel, defineStep, FunnelApp, useFunnel, mount, track, identify, reset, stopTracking, fieldError, shotRequest } from './funnel.js';
2
+ export { appUrl, paywallUrl, visitorId } from './handoff.js';
3
+ export { purchased } from './pixel.js';
4
+ export { t, param, setContent } from './content.js';
5
+ export { experiment, useExperiment, rule, useRule } from './experiments.js';
6
+ export { bucketOf, pickArm } from './bucket.js';
7
+ export { readBootstrap, readCreator } from './bootstrap.js';
8
+ export { server, ServerFnError } from './server.js';
9
+ export { autoOn, SERVICE_SIGNALS } from './autotrack.js';
10
+ export { Tracker, safeProps } from './tracker.js';
11
+ export { nextIndex, prevIndex } from './nav.js';
12
+ export { uuidv7 } from './uuid.js';
13
+ export { Hero } from './blocks/Hero.js';
14
+ export { Choice } from './blocks/Choice.js';
15
+ export { MultiChoice } from './blocks/MultiChoice.js';
16
+ export { Cta } from './blocks/Cta.js';
17
+ export { Section } from './blocks/Section.js';
package/dist/nav.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { ComponentType } from 'react';
2
+ export type Answers = Record<string, unknown>;
3
+ export type StepRole = 'lead' | 'paywall' | 'landing';
4
+ export declare const STEP_ROLES: readonly StepRole[];
5
+ export type StepDef = {
6
+ id: string;
7
+ component: ComponentType;
8
+ when?: (a: Answers) => boolean;
9
+ role?: StepRole;
10
+ sensitive?: boolean;
11
+ sample?: {
12
+ answers?: Answers;
13
+ };
14
+ skipAnswers?: string[];
15
+ /** @deprecated Since kit 0.4 every answer is sent; this list filters nothing. Use skipAnswers to leave a key out. */
16
+ trackAnswers?: string[];
17
+ };
18
+ export declare function nextIndex(steps: StepDef[], a: Answers, from: number): number | null;
19
+ export declare function prevIndex(steps: StepDef[], a: Answers, from: number): number | null;
package/dist/nav.js ADDED
@@ -0,0 +1,14 @@
1
+ export const STEP_ROLES = ['lead', 'paywall', 'landing'];
2
+ const shown = (s, a) => !s.when || s.when(a);
3
+ export function nextIndex(steps, a, from) {
4
+ for (let i = from + 1; i < steps.length; i++)
5
+ if (shown(steps[i], a))
6
+ return i;
7
+ return null;
8
+ }
9
+ export function prevIndex(steps, a, from) {
10
+ for (let i = from - 1; i >= 0; i--)
11
+ if (shown(steps[i], a))
12
+ return i;
13
+ return null;
14
+ }
@@ -0,0 +1,3 @@
1
+ export type WithLabel = (value: unknown, label: string | string[], fn: () => void) => void;
2
+ export declare const LabelCtx: import("react").Context<WithLabel>;
3
+ export declare function sameValue(a: unknown, b: unknown): boolean;
package/dist/offer.js ADDED
@@ -0,0 +1,7 @@
1
+ import { createContext } from 'react';
2
+ export const LabelCtx = createContext((_v, _l, fn) => fn());
3
+ export function sameValue(a, b) {
4
+ if (Array.isArray(a) && Array.isArray(b))
5
+ return a.length === b.length && a.every((x, i) => x === b[i]);
6
+ return a === b;
7
+ }
@@ -0,0 +1,36 @@
1
+ import type { StepDef } from './nav.js';
2
+ export type MetaPixel = {
3
+ id: string;
4
+ lead_step?: string;
5
+ checkout_step?: string;
6
+ };
7
+ export type PixelDeps = {
8
+ funnel: string;
9
+ steps: StepDef[];
10
+ visitorId: string;
11
+ session: () => string;
12
+ stopped: () => boolean;
13
+ win?: Window;
14
+ doc?: Document;
15
+ now?: () => number;
16
+ };
17
+ export type Pixel = {
18
+ after: (name: string, stepId: string) => void;
19
+ purchased: (p: PurchaseInfo) => void;
20
+ };
21
+ export type PurchaseInfo = {
22
+ value: number;
23
+ currency: string;
24
+ trial?: boolean;
25
+ subscription?: boolean;
26
+ };
27
+ export declare function readPixel(v: unknown): MetaPixel | null;
28
+ export declare function readCookie(doc: Document, name: string): string;
29
+ export declare function setTopCookie(doc: Document, loc: Location, name: string, make: (index: number) => string): string;
30
+ export declare function ensureFbp(doc: Document, loc: Location, now: number): string;
31
+ export declare function ensureFbc(doc: Document, loc: Location, now: number): string;
32
+ export declare function metaCookies(doc?: Document, loc?: Location, now?: number): void;
33
+ export declare function installPixel(cfg: MetaPixel, d: PixelDeps): Pixel;
34
+ export declare function setPixel(p: Pixel | null): void;
35
+ export declare function revokePixel(win?: Window): void;
36
+ export declare function purchased(p: PurchaseInfo): void;