@appright/funnelshift 0.0.0-stage → 0.15.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 +38 -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 +29 -0
  39. package/dist/cli/upload.js +90 -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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../dist/cli/index.js'
@@ -0,0 +1,4 @@
1
+ export declare function anchorTarget(hash: string): Element | 'top' | null;
2
+ export declare function scrollToHash(hash: string, smooth?: boolean): boolean;
3
+ export declare function installAnchors(root: HTMLElement): () => void;
4
+ export declare function scrollToInitialHash(): () => void;
@@ -0,0 +1,94 @@
1
+ // In-page anchors (kit 0.13.0). The router puts <base href="/<project>/<funnel>/"> into the page, so a plain <a href="#pricing"> is
2
+ // resolved against the funnel's root: the browser loads that address (a new page, the creator's path and the utm lost) instead of
3
+ // scrolling. The kit takes such a click over: it scrolls to the element, and puts the hash in the address with replaceState (the
4
+ // path and the query as they are; replace, not push, so the step history of backnav.ts stays as it is).
5
+ //
6
+ // Taken over: a plain left click on an <a> whose href starts with # inside the funnel, that nothing else has handled
7
+ // (defaultPrevented), with no modifier key and no other target. An id that is not on the page still gets no navigation. Not in a frame:
8
+ // the admin's preview must not touch the history.
9
+ const IGNORE_TARGET = /^(?:_blank|_parent|_top)$/i;
10
+ const reduced = () => {
11
+ try {
12
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ };
18
+ // The element a hash names: by id, else by the name of an old-style anchor; null for none. '#' and '#top' name the top of the page.
19
+ export function anchorTarget(hash) {
20
+ if (hash === '' || hash === '#' || hash === '#top')
21
+ return 'top';
22
+ let id = hash.slice(1);
23
+ try {
24
+ id = decodeURIComponent(id);
25
+ }
26
+ catch { /* keep it as it is */ }
27
+ return document.getElementById(id) ?? document.getElementsByName(id)[0] ?? null;
28
+ }
29
+ // Smooth scroll (instant with prefers-reduced-motion) to the element; scroll-margin-top of the element is honoured by the browser.
30
+ export function scrollToHash(hash, smooth = true) {
31
+ const t = anchorTarget(hash);
32
+ if (t === null)
33
+ return false;
34
+ const behavior = smooth && !reduced() ? 'smooth' : 'auto';
35
+ try {
36
+ if (t === 'top')
37
+ window.scrollTo({ top: 0, behavior });
38
+ else
39
+ t.scrollIntoView({ behavior, block: 'start' });
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ return true;
45
+ }
46
+ export function installAnchors(root) {
47
+ let framed = false;
48
+ try {
49
+ framed = window.self !== window.top;
50
+ }
51
+ catch {
52
+ framed = true;
53
+ }
54
+ const onClick = (e) => {
55
+ try {
56
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
57
+ return;
58
+ const from = e.target instanceof Element ? e.target : e.target instanceof Node ? e.target.parentElement : null;
59
+ const a = from?.closest('a');
60
+ if (!a || !root.contains(a) || a.hasAttribute('download'))
61
+ return;
62
+ const href = a.getAttribute('href');
63
+ if (href === null || !href.startsWith('#') || IGNORE_TARGET.test(a.getAttribute('target') ?? ''))
64
+ return;
65
+ e.preventDefault();
66
+ if (!scrollToHash(href))
67
+ return;
68
+ if (!framed && href.length > 1)
69
+ history.replaceState(history.state, '', location.pathname + location.search + href);
70
+ }
71
+ catch { /* a click must never break */ }
72
+ };
73
+ root.addEventListener('click', onClick);
74
+ return () => root.removeEventListener('click', onClick);
75
+ }
76
+ // The address came with a hash (a shared link to a section): once the step is drawn, scroll to it. The content may still be growing
77
+ // (images, a client render after hydration), so it is tried again a moment later.
78
+ export function scrollToInitialHash() {
79
+ let timers = [];
80
+ const hash = (() => { try {
81
+ return location.hash;
82
+ }
83
+ catch {
84
+ return '';
85
+ } })();
86
+ if (hash.length > 1) {
87
+ const go = () => { try {
88
+ scrollToHash(hash, false);
89
+ }
90
+ catch { /* ignore */ } };
91
+ timers = [setTimeout(go, 0), setTimeout(go, 400)];
92
+ }
93
+ return () => timers.forEach(clearTimeout);
94
+ }
@@ -0,0 +1,11 @@
1
+ export declare const PROP_KEY: RegExp;
2
+ export declare const MAX_VALUE = 1000;
3
+ export declare const MAX_PROPS = 128;
4
+ export declare const MAX_LIST = 50;
5
+ export declare const MAX_EVENT_BYTES: number;
6
+ export declare function cut(s: string, n?: number): {
7
+ text: string;
8
+ cut: boolean;
9
+ };
10
+ export declare function encodeAnswer(key: string, value: unknown, label?: unknown): Record<string, string>;
11
+ export declare function packAnswers(answers: Record<string, string>[]): Record<string, string>[];
@@ -0,0 +1,100 @@
1
+ import { readBootstrap } from './bootstrap.js';
2
+ // How an answer becomes event props (spec "all events" §2.1). Every answer set() on a step goes into $step_complete:
3
+ // answer_<key> the value, stable, what filters use;
4
+ // label_<key> the text the person saw (only when set() was given one);
5
+ // answer_<key>_truncated = 'true' when the value was cut.
6
+ // The same limits hold on the server: a prop name matches PROP_KEY, a value is at most MAX_VALUE characters, an event has at
7
+ // most MAX_PROPS props.
8
+ export const PROP_KEY = /^[a-z][a-z0-9_]{0,63}$/;
9
+ export const MAX_VALUE = 1000;
10
+ export const MAX_PROPS = 128;
11
+ export const MAX_LIST = 50;
12
+ // One event's props stay under this many UTF-8 bytes (keys plus values), so the event fits a sendBeacon body (64 KB) and a
13
+ // batch of the server (256 KB) with room to spare. 128 answers of 1000 multi-byte characters would be far bigger.
14
+ export const MAX_EVENT_BYTES = 32 * 1024;
15
+ const enc = new TextEncoder();
16
+ const TRUNCATED = '_truncated';
17
+ // cut keeps at most n characters (code points, the way the server counts them), so an emoji is never split in half.
18
+ export function cut(s, n = MAX_VALUE) {
19
+ if (s.length <= n)
20
+ return { text: s, cut: false }; // UTF-16 length is never smaller than the code point count
21
+ const cps = Array.from(s);
22
+ return cps.length <= n ? { text: s, cut: false } : { text: cps.slice(0, n).join(''), cut: true };
23
+ }
24
+ function scalar(v) {
25
+ if (v === null || v === undefined)
26
+ return '';
27
+ if (typeof v === 'string')
28
+ return v;
29
+ if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'bigint')
30
+ return String(v);
31
+ try {
32
+ return JSON.stringify(v) ?? '';
33
+ }
34
+ catch {
35
+ return '';
36
+ }
37
+ }
38
+ const item = (v) => scalar(v).replaceAll('|', '/');
39
+ // A single answer's | becomes ¦ (broken bar): the analytics split a value on | into list elements, and one answer must
40
+ // stay one value. List elements already turn their | into / (item); labels keep theirs (they are matched by position).
41
+ function encodeValue(v) {
42
+ if (Array.isArray(v)) {
43
+ const r = cut(v.slice(0, MAX_LIST).map(item).join('|'));
44
+ return { text: r.text, cut: r.cut || v.length > MAX_LIST };
45
+ }
46
+ return cut(scalar(v).replaceAll('|', '¦'));
47
+ }
48
+ function encodeLabel(label, value) {
49
+ if (Array.isArray(label)) {
50
+ // Labels go with values by index: the label of value i is element i. A missing label in the middle stays an empty
51
+ // element (the ones after it keep their index); missing labels at the end are left out.
52
+ const n = Array.isArray(value) ? Math.min(value.length, MAX_LIST) : 1;
53
+ const parts = label.slice(0, n).map(l => (typeof l === 'string' ? l.replaceAll('|', '/') : ''));
54
+ while (parts.length && parts[parts.length - 1] === '')
55
+ parts.pop();
56
+ return cut(parts.join('|')).text;
57
+ }
58
+ return typeof label === 'string' ? cut(label).text : '';
59
+ }
60
+ // encodeAnswer: the props of one answer. A key that would give an invalid prop name, or ends in _truncated (that suffix is
61
+ // the flag of a cut answer), gives nothing (a warning in preview and dev; the value is never printed).
62
+ export function encodeAnswer(key, value, label) {
63
+ const name = 'answer_' + key;
64
+ if (!key || !PROP_KEY.test(name) || key.endsWith(TRUNCATED)) {
65
+ if (readBootstrap().env !== 'prod')
66
+ console.warn(`funnel-kit: answer key "${key.slice(0, 80)}" is not allowed (answer_ + key must match ${PROP_KEY} and not end in ${TRUNCATED}), not tracked`);
67
+ return {};
68
+ }
69
+ const v = encodeValue(value);
70
+ const out = { [name]: v.text };
71
+ const l = label === undefined ? '' : encodeLabel(label, value);
72
+ if (l)
73
+ out['label_' + key] = l;
74
+ // answer_<key>_truncated is longer than the answer name: for a key near the limit the flag would be an invalid prop
75
+ // (dropped by the tracker anyway), so it is only added when it fits.
76
+ if (v.cut && PROP_KEY.test(name + TRUNCATED))
77
+ out[name + TRUNCATED] = 'true';
78
+ return out;
79
+ }
80
+ // packAnswers: one event's props per element, at most MAX_PROPS props and MAX_EVENT_BYTES bytes each; the props of one
81
+ // answer are never split between events (an answer bigger than the budget gets an event of its own). The first element goes
82
+ // into $step_complete, the rest into $step_answers of the same step.
83
+ export function packAnswers(answers) {
84
+ const out = [{}];
85
+ let n = 0;
86
+ let bytes = 0;
87
+ for (const a of answers) {
88
+ const size = Object.keys(a).length;
89
+ const b = Object.entries(a).reduce((t, [k, v]) => t + enc.encode(k).length + enc.encode(v).length, 0);
90
+ if (n > 0 && (n + size > MAX_PROPS || bytes + b > MAX_EVENT_BYTES)) {
91
+ out.push({});
92
+ n = 0;
93
+ bytes = 0;
94
+ }
95
+ Object.assign(out[out.length - 1], a);
96
+ n += size;
97
+ bytes += b;
98
+ }
99
+ return out;
100
+ }
@@ -0,0 +1,34 @@
1
+ export type Attribution = {
2
+ utm: Record<string, string>;
3
+ click_id_type: string;
4
+ click_id: string;
5
+ referrer_host: string;
6
+ referrer?: string;
7
+ landing_path: string;
8
+ landing_params: Record<string, string>;
9
+ path_segs: string[];
10
+ click_network: string;
11
+ creator: string;
12
+ channel: string;
13
+ entry_creator: string;
14
+ entry_channel: string;
15
+ };
16
+ export type AttributionOptions = {
17
+ project?: string;
18
+ funnel?: string;
19
+ local?: Storage;
20
+ now?: () => number;
21
+ mountPath?: string;
22
+ own?: boolean;
23
+ };
24
+ export declare const CREATOR_WINDOW_MS: number;
25
+ export declare const MAX_PARAMS = 50;
26
+ export declare const MAX_PARAM_KEY = 64;
27
+ export declare const MAX_PARAM_VALUE = 500;
28
+ export declare const MAX_PARAMS_BYTES = 6144;
29
+ export declare const DROPPED_KEY = "$dropped";
30
+ export declare const CLICK_IDS: readonly (readonly [string, string])[];
31
+ export declare function pathSegments(pathname: string, project: string, funnel: string): string[];
32
+ export declare function segmentsUnder(pathname: string, mountPath: string): string[];
33
+ export declare function capParams(entries: Iterable<[string, string]>): Record<string, string>;
34
+ export declare function attribution(loc?: Location, doc?: Document, store?: Storage, opts?: AttributionOptions): Attribution;
@@ -0,0 +1,237 @@
1
+ // The session's first landing, per project (af_entry:<project>): one tab may open funnels of several projects, and one
2
+ // project must never send another's landing (stage review C1). kit 0.7.0; 0.6 kept a smaller record under af_attr.
3
+ const KEY = 'af_entry:';
4
+ const CREATOR_KEY = 'af_creator:';
5
+ export const CREATOR_WINDOW_MS = 30 * 24 * 3600 * 1000;
6
+ // A path segment: 1–64 of [A-Za-z0-9._-], kept lower-cased; anything else is dropped (spec §1.1).
7
+ const SEG = /^[A-Za-z0-9._-]{1,64}$/;
8
+ const MAX_SEGS = 16;
9
+ export const MAX_PARAMS = 50;
10
+ export const MAX_PARAM_KEY = 64;
11
+ export const MAX_PARAM_VALUE = 500;
12
+ // The params serialized as JSON stay within this many bytes (UTF-8); the rest are dropped, and their number goes as the param
13
+ // $dropped. The server keeps the same cap.
14
+ export const MAX_PARAMS_BYTES = 6144;
15
+ export const DROPPED_KEY = '$dropped';
16
+ // Codes the router serves as files of the build, never a creator (the server refuses them as cards too).
17
+ const RESERVED_CODES = new Set(['.', '..', 'assets', 'index.html', '_functions.js']);
18
+ const UTM_KEYS = ['source', 'medium', 'campaign', 'content', 'term'];
19
+ // The ad networks by their click id (spec §1.4), in this order of preference. click_id_type is the param's name, lower-cased.
20
+ export const CLICK_IDS = [
21
+ ['fbclid', 'meta'], ['gclid', 'google'], ['gbraid', 'google'], ['wbraid', 'google'], ['ttclid', 'tiktok'], ['msclkid', 'microsoft'],
22
+ ['twclid', 'x'], ['li_fat_id', 'linkedin'], ['sccid', 'snapchat'], ['ScCid', 'snapchat'], ['epik', 'pinterest'], ['rdt_cid', 'reddit'],
23
+ ['yclid', 'yandex'],
24
+ ];
25
+ const chars = (s) => [...s];
26
+ const cutTo = (s, n) => (s.length <= n ? s : chars(s).slice(0, n).join(''));
27
+ function get(s, k) { try {
28
+ return s?.getItem(k) ?? null;
29
+ }
30
+ catch {
31
+ return null;
32
+ } }
33
+ function put(s, k, v) { try {
34
+ s?.setItem(k, v);
35
+ }
36
+ catch { /* storage unavailable: this page only */ } }
37
+ // The valid segments of the path after /<project>/<funnel>/, lower-cased; a segment that does not fit is dropped and the rest
38
+ // keep their order. A path outside the funnel (vite dev) has none.
39
+ export function pathSegments(pathname, project, funnel) {
40
+ const prefix = `/${project}/${funnel}`;
41
+ if (!project || !funnel || (pathname !== prefix && !pathname.startsWith(prefix + '/')))
42
+ return [];
43
+ const out = [];
44
+ for (const raw of pathname.slice(prefix.length).split('/')) {
45
+ if (!raw || out.length >= MAX_SEGS)
46
+ continue;
47
+ let s;
48
+ try {
49
+ s = decodeURIComponent(raw);
50
+ }
51
+ catch {
52
+ continue;
53
+ }
54
+ if (!SEG.test(s) || s.toLowerCase() === 'index.html')
55
+ continue;
56
+ out.push(s.toLowerCase());
57
+ }
58
+ return out;
59
+ }
60
+ // The same under any mount: the valid segments of the path after mountPath ("/" or "/<something>/").
61
+ export function segmentsUnder(pathname, mountPath) {
62
+ const prefix = mountPath.replace(/\/+$/, '');
63
+ if (pathname !== prefix && !pathname.startsWith(prefix + '/'))
64
+ return [];
65
+ const out = [];
66
+ for (const raw of pathname.slice(prefix.length).split('/')) {
67
+ if (!raw || out.length >= MAX_SEGS)
68
+ continue;
69
+ let s;
70
+ try {
71
+ s = decodeURIComponent(raw);
72
+ }
73
+ catch {
74
+ continue;
75
+ }
76
+ if (!SEG.test(s) || s.toLowerCase() === 'index.html')
77
+ continue;
78
+ out.push(s.toLowerCase());
79
+ }
80
+ return out;
81
+ }
82
+ const seg = (v) => (v && SEG.test(v) ? v.toLowerCase() : '');
83
+ // The Meta cookies _fbp and _fbc (kit 0.11.0): first-touch fields for a later conversions API call (no CAPI yet). They are kept as
84
+ // the landing params fbp and fbc, unless the link already carries params of those names.
85
+ const META_COOKIES = [['_fbp', 'fbp'], ['_fbc', 'fbc']];
86
+ function metaCookies(doc) {
87
+ let jar = '';
88
+ try {
89
+ jar = doc.cookie;
90
+ }
91
+ catch {
92
+ return [];
93
+ }
94
+ const out = [];
95
+ for (const [cookie, key] of META_COOKIES) {
96
+ const m = new RegExp(`(?:^|;\\s*)${cookie}=([^;]*)`).exec(jar);
97
+ let v = '';
98
+ try {
99
+ v = m ? decodeURIComponent(m[1]) : '';
100
+ }
101
+ catch {
102
+ v = m?.[1] ?? '';
103
+ }
104
+ if (v && !v.includes('@') && chars(v).length <= 128)
105
+ out.push([key, v]);
106
+ }
107
+ return out;
108
+ }
109
+ const bytes = (s) => new TextEncoder().encode(s).length;
110
+ // capParams keeps params in their order while they fit MAX_PARAMS keys and MAX_PARAMS_BYTES serialized; the number of those
111
+ // that did not fit (plus an earlier count in $dropped) goes as $dropped, which takes one of the keys.
112
+ export function capParams(entries) {
113
+ const out = {};
114
+ let size = 2, n = 0, dropped = 0;
115
+ const reserve = bytes(`,"${DROPPED_KEY}":"999999"`);
116
+ for (const [k, v] of entries) {
117
+ if (k === DROPPED_KEY) {
118
+ const d = Number(v);
119
+ if (Number.isInteger(d) && d > 0 && d < 1e6)
120
+ dropped += d;
121
+ continue;
122
+ }
123
+ const add = bytes(JSON.stringify(k)) + bytes(JSON.stringify(v)) + 2;
124
+ if (n >= MAX_PARAMS || size + add > MAX_PARAMS_BYTES - reserve) {
125
+ dropped++;
126
+ continue;
127
+ }
128
+ out[k] = v;
129
+ size += add;
130
+ n++;
131
+ }
132
+ if (dropped > 0) {
133
+ if (n >= MAX_PARAMS) {
134
+ delete out[Object.keys(out)[n - 1]];
135
+ dropped++;
136
+ }
137
+ out[DROPPED_KEY] = String(Math.min(dropped, 999999));
138
+ }
139
+ return out;
140
+ }
141
+ function entryOf(loc, doc, project, funnel, mountPath) {
142
+ const q = new URLSearchParams(loc.search);
143
+ const seen = new Set();
144
+ const fit = [];
145
+ for (const [k, v] of q) {
146
+ // __shot, __step, __build, __exp, __sig: the kit's and the router's own (a preview signature among them), not the link's;
147
+ // $dropped is the kit's count, never taken from a link
148
+ if (!k || k.startsWith('__') || k === DROPPED_KEY || chars(k).length > MAX_PARAM_KEY || seen.has(k))
149
+ continue;
150
+ seen.add(k);
151
+ fit.push([k, cutTo(v, MAX_PARAM_VALUE)]);
152
+ }
153
+ for (const [k, v] of metaCookies(doc))
154
+ if (!seen.has(k))
155
+ fit.push([k, v]);
156
+ const params = capParams(fit);
157
+ const utm = {};
158
+ for (const k of UTM_KEYS) {
159
+ const v = q.get('utm_' + k) || q.get(k);
160
+ if (v)
161
+ utm[k] = cutTo(v, 128);
162
+ }
163
+ const click = CLICK_IDS.find(([p]) => q.get(p));
164
+ let ref = '', referrer = '';
165
+ try {
166
+ const u = new URL(doc.referrer);
167
+ if (u.hostname !== loc.hostname) {
168
+ ref = u.hostname;
169
+ referrer = /^(https?|android-app):$/.test(u.protocol) ? cutTo(`${u.protocol}//${u.hostname}${u.pathname}`, 1024) : '';
170
+ }
171
+ }
172
+ catch { /* no referrer */ }
173
+ const segs = mountPath ? segmentsUnder(loc.pathname, mountPath) : pathSegments(loc.pathname, project, funnel);
174
+ const creator = segs[0] && !RESERVED_CODES.has(segs[0]) ? segs[0] : '';
175
+ // …/creator1/tiktok and …/creator1?source=tiktok say the same: without a second segment the link's source is the channel.
176
+ const channel = creator ? segs[1] ?? seg(utm.source) : '';
177
+ return {
178
+ utm, click_id_type: click ? click[0].toLowerCase() : '', click_id: click ? cutTo(q.get(click[0]), 256) : '', referrer_host: ref, referrer,
179
+ landing_path: cutTo(loc.pathname, 512), landing_params: params, path_segs: segs, click_network: click ? click[1] : '',
180
+ entry_creator: creator, entry_channel: channel,
181
+ };
182
+ }
183
+ // The attribution of this page load. store keeps the session's first landing (sessionStorage), opts.local the project's creator
184
+ // (localStorage). Storage that is blocked or full makes it this page's only; it never throws.
185
+ export function attribution(loc = location, doc = document, store = sessionStorage, opts = {}) {
186
+ const project = opts.project ?? '', funnel = opts.funnel ?? '';
187
+ const now = (opts.now ?? Date.now)();
188
+ let local = opts.local;
189
+ if (local === undefined) {
190
+ try {
191
+ local = localStorage;
192
+ }
193
+ catch {
194
+ local = undefined;
195
+ }
196
+ }
197
+ const here = entryOf(loc, doc, project, funnel, opts.mountPath);
198
+ const key = KEY + project;
199
+ let entry = null;
200
+ try {
201
+ entry = JSON.parse(get(store, key) ?? 'null');
202
+ }
203
+ catch { /* corrupt: a new one */ }
204
+ // A stored landing must be this project's: one under another project's path is never sent (and is replaced).
205
+ const mine = (e) => !project || opts.own || (typeof e.landing_path === 'string' && e.landing_path.startsWith(`/${project}/`));
206
+ if (!entry || typeof entry !== 'object' || !Array.isArray(entry.path_segs) || !mine(entry)) {
207
+ entry = here;
208
+ put(store, key, JSON.stringify(entry));
209
+ }
210
+ // The Meta cookies often appear only after the landing (the pixel sets them): the first landing of the session takes them in
211
+ // when they show up later, so the first touch has them.
212
+ const late = metaCookies(doc).filter(([k]) => !(k in entry.landing_params));
213
+ if (late.length) {
214
+ entry = { ...entry, landing_params: capParams([...Object.entries(entry.landing_params), ...late]) };
215
+ put(store, key, JSON.stringify(entry));
216
+ }
217
+ // The creator: this page's link when it has one (last click), otherwise the project's within the window.
218
+ const ckey = CREATOR_KEY + project;
219
+ let creator = '', channel = '';
220
+ if (here.entry_creator) {
221
+ creator = here.entry_creator;
222
+ channel = here.entry_channel;
223
+ if (project)
224
+ put(local, ckey, JSON.stringify({ c: creator, ch: channel, at: now }));
225
+ }
226
+ else if (project) {
227
+ try {
228
+ const saved = JSON.parse(get(local, ckey) ?? 'null');
229
+ if (saved && typeof saved.c === 'string' && typeof saved.at === 'number' && now - saved.at <= CREATOR_WINDOW_MS) {
230
+ creator = seg(saved.c);
231
+ channel = creator ? seg(typeof saved.ch === 'string' ? saved.ch : '') : '';
232
+ }
233
+ }
234
+ catch { /* corrupt: none */ }
235
+ }
236
+ return { ...entry, creator, channel };
237
+ }
@@ -0,0 +1,4 @@
1
+ export type AutoTrackKey = 'scroll' | 'sections' | 'perf' | 'errors' | 'activeTime' | 'fieldErrors' | 'network';
2
+ export type AutoTrack = boolean | Partial<Record<AutoTrackKey, boolean>>;
3
+ export declare function autoOn(a: AutoTrack | undefined, key: AutoTrackKey): boolean;
4
+ export declare const SERVICE_SIGNALS: readonly ['$scroll', '$section_view', '$perf', '$error', '$field_error'];
@@ -0,0 +1,13 @@
1
+ export function autoOn(a, key) {
2
+ if (a === undefined || a === true)
3
+ return true;
4
+ if (a === false)
5
+ return false;
6
+ return a[key] !== false;
7
+ }
8
+ // The names of the service signals: events the kit sends by itself about how a page behaved, not what a person did in the funnel
9
+ // (kit 0.13.0). They live in the raw events and in their own small rollups; the analytics pre-aggregation (sessions, step and
10
+ // funnel counts) must ignore them, so a new signal never makes the hot path heavier. Their volume is bounded: $scroll at most 4
11
+ // per step view, $section_view once per section and view (20 at most), $perf once per page load, $error 5 per page load,
12
+ // $field_error 20 per step view. The same list in Go: server/internal/event ServiceSignals (a Go test keeps the two equal).
13
+ export const SERVICE_SIGNALS = ['$scroll', '$section_view', '$perf', '$error', '$field_error'];
@@ -0,0 +1,15 @@
1
+ export type BackNavOptions = {
2
+ index: () => number;
3
+ stepId: (index: number) => string;
4
+ prev: () => number | null;
5
+ next: () => number | null;
6
+ reached: () => number;
7
+ back: (p: number) => void;
8
+ forward: (n: number) => void;
9
+ };
10
+ export type BackNav = {
11
+ push: (index: number) => void;
12
+ syncBack: () => void;
13
+ off: () => void;
14
+ };
15
+ export declare function installBackNav(o: BackNavOptions): BackNav;
@@ -0,0 +1,73 @@
1
+ // The browser's Back (kit 0.13.0): a phone's swipe or back button, the browser's own, moves the funnel one step back instead of
2
+ // leaving the page for Instagram or Google. Every step the funnel moves on to (next, skip) gets a history entry of its own
3
+ // (pushState without a URL: the address, its query and hash stay as they are); Back lands on the entry of the step before, and the
4
+ // kit shows that step the way the funnel's own back() does, respecting `when`. The first step gets no entry: Back from it leaves
5
+ // the page as it always did (no trap). Forward goes on only to a step already visited in this page load, else it is undone.
6
+ // A popstate without our state (an anchor's hash, another script's entry) is not ours and is left alone.
7
+ //
8
+ // The state is { funnelStep, idx, pos }: pos counts our entries, so the direction of a move is known whatever the steps say.
9
+ // After a reload the entries of the old page load stay behind the current one with their old pos; Back through them is passed on
10
+ // (history.back()) until it leaves the page, so a reload never makes the funnel a trap either.
11
+ const ours = (s) => !!s && typeof s === 'object' && typeof s.funnelStep === 'string'
12
+ && Number.isInteger(s.pos) && Number.isInteger(s.idx);
13
+ export function installBackNav(o) {
14
+ let pos = 0;
15
+ try {
16
+ const s = history.state;
17
+ if (ours(s))
18
+ pos = s.pos;
19
+ }
20
+ catch { /* no history */ }
21
+ const state = (index) => ({ funnelStep: o.stepId(index), idx: index, pos });
22
+ // The entry the page opened with stands for the step on screen, so a Back that lands on it is ours.
23
+ try {
24
+ history.replaceState(state(o.index()), '');
25
+ }
26
+ catch { /* a sandboxed frame: no history to use */ }
27
+ const onPop = (e) => {
28
+ try {
29
+ const s = e.state;
30
+ if (!ours(s) || s.pos === pos)
31
+ return;
32
+ if (s.pos < pos) {
33
+ const p = o.prev();
34
+ pos = s.pos;
35
+ if (p === null) {
36
+ history.back();
37
+ return;
38
+ } // an entry of an earlier page load behind the first step: on to the exit
39
+ o.back(p);
40
+ }
41
+ else {
42
+ const n = o.next();
43
+ if (n !== null && n <= o.reached()) {
44
+ pos = s.pos;
45
+ o.forward(n);
46
+ }
47
+ else
48
+ history.go(pos - s.pos);
49
+ }
50
+ }
51
+ catch { /* the funnel must work without it */ }
52
+ };
53
+ addEventListener('popstate', onPop);
54
+ return {
55
+ push(index) {
56
+ try {
57
+ pos += 1;
58
+ history.pushState(state(index), '');
59
+ }
60
+ catch { /* no history to use */ }
61
+ },
62
+ syncBack() {
63
+ try {
64
+ if (pos > 0) {
65
+ pos -= 1;
66
+ history.go(-1);
67
+ }
68
+ }
69
+ catch { /* no history to use */ }
70
+ },
71
+ off() { removeEventListener('popstate', onPop); },
72
+ };
73
+ }
@@ -0,0 +1,19 @@
1
+ import { type ReactNode } from 'react';
2
+ export type PickHandlers<V, L> = {
3
+ onChange: (v: V) => void;
4
+ onPick?: (v: V, label: L) => void;
5
+ } | {
6
+ onChange?: (v: V) => void;
7
+ onPick: (v: V, label: L) => void;
8
+ };
9
+ export declare function Choice<T extends string>(p: {
10
+ options: {
11
+ value: T;
12
+ label: string;
13
+ icon?: ReactNode;
14
+ }[];
15
+ value?: T;
16
+ name: string;
17
+ labelledBy?: string;
18
+ label?: string;
19
+ } & PickHandlers<T, string>): import("react").JSX.Element;
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useContext, useRef } from 'react';
3
+ import { LabelCtx } from '../offer.js';
4
+ export function Choice(p) {
5
+ const refs = useRef([]);
6
+ const withLabel = useContext(LabelCtx);
7
+ const pick = (i) => {
8
+ const o = p.options[i];
9
+ p.onPick?.(o.value, o.label);
10
+ withLabel(o.value, o.label, () => p.onChange?.(o.value));
11
+ };
12
+ const chosen = p.options.findIndex(o => o.value === p.value);
13
+ const stop = chosen >= 0 ? chosen : 0;
14
+ function onKeyDown(e, i) {
15
+ const n = p.options.length;
16
+ // Alt+Arrow is "back/forward" in browsers, Cmd/Ctrl+Arrow is an OS or browser shortcut: arrows with them are left alone.
17
+ const plain = !e.altKey && !e.metaKey && !e.ctrlKey;
18
+ let to = -1;
19
+ if (plain && (e.key === 'ArrowDown' || e.key === 'ArrowRight'))
20
+ to = (i + 1) % n;
21
+ else if (plain && (e.key === 'ArrowUp' || e.key === 'ArrowLeft'))
22
+ to = (i - 1 + n) % n;
23
+ else if (e.key === 'Enter' || e.key === ' ') {
24
+ e.preventDefault();
25
+ pick(i);
26
+ return;
27
+ }
28
+ else
29
+ return;
30
+ e.preventDefault();
31
+ pick(to);
32
+ refs.current[to]?.focus();
33
+ }
34
+ return (_jsxs("div", { className: "fk-choice", role: "radiogroup", "aria-labelledby": p.labelledBy, "aria-label": p.label, children: [p.value !== undefined && _jsx("input", { type: "hidden", name: p.name, value: p.value }), p.options.map((o, i) => {
35
+ const on = i === chosen;
36
+ return (_jsxs("button", { ref: el => { refs.current[i] = el; }, type: "button", role: "radio", "aria-checked": on, tabIndex: i === stop ? 0 : -1, className: "fk-choice__option", onClick: () => pick(i), onKeyDown: e => onKeyDown(e, i), children: [o.icon != null && _jsx("span", { className: "fk-choice__icon", "aria-hidden": "true", children: o.icon }), _jsx("span", { className: "fk-choice__label", children: o.label }), _jsx("span", { className: "fk-choice__mark", "aria-hidden": "true" })] }, o.value));
37
+ })] }));
38
+ }
@@ -0,0 +1,7 @@
1
+ import type { ReactNode } from 'react';
2
+ export declare function Cta(p: {
3
+ children: ReactNode;
4
+ onClick: () => void;
5
+ pending?: boolean;
6
+ disabled?: boolean;
7
+ }): import("react").JSX.Element;