@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.
- package/bin/funnel-kit.js +2 -0
- package/dist/anchors.d.ts +4 -0
- package/dist/anchors.js +94 -0
- package/dist/answers.d.ts +11 -0
- package/dist/answers.js +100 -0
- package/dist/attribution.d.ts +34 -0
- package/dist/attribution.js +237 -0
- package/dist/autotrack.d.ts +4 -0
- package/dist/autotrack.js +13 -0
- package/dist/backnav.d.ts +15 -0
- package/dist/backnav.js +73 -0
- package/dist/blocks/Choice.d.ts +19 -0
- package/dist/blocks/Choice.js +38 -0
- package/dist/blocks/Cta.d.ts +7 -0
- package/dist/blocks/Cta.js +8 -0
- package/dist/blocks/Hero.d.ts +10 -0
- package/dist/blocks/Hero.js +48 -0
- package/dist/blocks/MultiChoice.d.ts +13 -0
- package/dist/blocks/MultiChoice.js +47 -0
- package/dist/blocks/Section.d.ts +6 -0
- package/dist/blocks/Section.js +4 -0
- package/dist/bootstrap.d.ts +33 -0
- package/dist/bootstrap.js +19 -0
- package/dist/bucket.d.ts +13 -0
- package/dist/bucket.js +41 -0
- package/dist/cli/args.d.ts +5 -0
- package/dist/cli/args.js +18 -0
- package/dist/cli/build.d.ts +6 -0
- package/dist/cli/build.js +200 -0
- package/dist/cli/extract.d.ts +32 -0
- package/dist/cli/extract.js +253 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +38 -0
- package/dist/cli/init.d.ts +5 -0
- package/dist/cli/init.js +67 -0
- package/dist/cli/lint.d.ts +25 -0
- package/dist/cli/lint.js +535 -0
- package/dist/cli/upload.d.ts +29 -0
- package/dist/cli/upload.js +90 -0
- package/dist/clicks.d.ts +7 -0
- package/dist/clicks.js +102 -0
- package/dist/content.d.ts +7 -0
- package/dist/content.js +60 -0
- package/dist/context.d.ts +12 -0
- package/dist/context.js +52 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +90 -0
- package/dist/experiments.d.ts +4 -0
- package/dist/experiments.js +24 -0
- package/dist/exposure.d.ts +4 -0
- package/dist/exposure.js +35 -0
- package/dist/functions.d.ts +37 -0
- package/dist/functions.js +24 -0
- package/dist/funnel.d.ts +64 -0
- package/dist/funnel.js +433 -0
- package/dist/handoff.d.ts +8 -0
- package/dist/handoff.js +66 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +17 -0
- package/dist/nav.d.ts +19 -0
- package/dist/nav.js +14 -0
- package/dist/offer.d.ts +3 -0
- package/dist/offer.js +7 -0
- package/dist/pixel.d.ts +36 -0
- package/dist/pixel.js +173 -0
- package/dist/prerender.d.ts +2 -0
- package/dist/prerender.js +16 -0
- package/dist/server.d.ts +12 -0
- package/dist/server.js +88 -0
- package/dist/sha256.d.ts +2 -0
- package/dist/sha256.js +65 -0
- package/dist/signals.d.ts +18 -0
- package/dist/signals.js +300 -0
- package/dist/tracker.d.ts +80 -0
- package/dist/tracker.js +446 -0
- package/dist/uuid.d.ts +1 -0
- package/dist/uuid.js +12 -0
- package/dist/vitals.d.ts +4 -0
- package/dist/vitals.js +104 -0
- package/package.json +29 -4
- package/src/blocks/blocks.css +364 -0
- package/templates/init/CLAUDE.md +10 -0
- package/templates/init/funnels/welcome/index.tsx +129 -0
- package/templates/init/gitignore +5 -0
- package/templates/init/package.json.tmpl +20 -0
- package/templates/init/theme.css +77 -0
- package/templates/init/tsconfig.json +7 -0
- package/README.md +0 -3
package/dist/clicks.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { collapse } from './content.js';
|
|
2
|
+
const TARGET = 'button, a, [role=button], [role=radio], [role=checkbox], [role=option], [data-track]';
|
|
3
|
+
const FIELD = 'input, textarea, select';
|
|
4
|
+
const IGNORE = '[data-track-ignore]';
|
|
5
|
+
const REPEAT_MS = 400;
|
|
6
|
+
const MAX_LABEL = 200;
|
|
7
|
+
const MAX_NAME = 200;
|
|
8
|
+
const SKIP_TEXT = new Set(['SCRIPT', 'STYLE', 'TEMPLATE', 'NOSCRIPT']);
|
|
9
|
+
export function installClicks(root, o) {
|
|
10
|
+
const now = o.now ?? Date.now;
|
|
11
|
+
const last = new Map();
|
|
12
|
+
const onClick = (e) => {
|
|
13
|
+
try {
|
|
14
|
+
const props = describe(root, e.target, o.keyOf);
|
|
15
|
+
if (!props)
|
|
16
|
+
return;
|
|
17
|
+
const step = o.stepId();
|
|
18
|
+
const key = step + '\u0000' + props.target;
|
|
19
|
+
const t = now();
|
|
20
|
+
const prev = last.get(key);
|
|
21
|
+
if (prev !== undefined && t - prev < REPEAT_MS)
|
|
22
|
+
return;
|
|
23
|
+
if (last.size > 200)
|
|
24
|
+
for (const [k, v] of last)
|
|
25
|
+
if (t - v >= REPEAT_MS)
|
|
26
|
+
last.delete(k);
|
|
27
|
+
last.set(key, t);
|
|
28
|
+
o.track('$click', props, step);
|
|
29
|
+
}
|
|
30
|
+
catch { /* analytics must never break a click */ }
|
|
31
|
+
};
|
|
32
|
+
root.addEventListener('click', onClick, { capture: true, passive: true });
|
|
33
|
+
return () => root.removeEventListener('click', onClick, { capture: true });
|
|
34
|
+
}
|
|
35
|
+
function describe(root, from, keyOf) {
|
|
36
|
+
const el = from instanceof Element ? from : from instanceof Node ? from.parentElement : null;
|
|
37
|
+
if (!el || !root.contains(el) || el.closest(FIELD))
|
|
38
|
+
return null;
|
|
39
|
+
const target = el.closest(TARGET);
|
|
40
|
+
if (!target || !root.contains(target) || target.closest(IGNORE))
|
|
41
|
+
return null;
|
|
42
|
+
const text = collapse(visibleText(target)) || collapse(target.getAttribute('aria-label') ?? '') || collapse(imageAlt(target))
|
|
43
|
+
|| collapse(target.getAttribute('title') ?? '');
|
|
44
|
+
const props = { target: nameOf(root, target, text, keyOf), label: Array.from(text).slice(0, MAX_LABEL).join(''), kind: kindOf(target) };
|
|
45
|
+
const host = target.tagName === 'A' ? outsideHost(target.getAttribute('href')) : null;
|
|
46
|
+
if (host)
|
|
47
|
+
props.href_host = host;
|
|
48
|
+
return props;
|
|
49
|
+
}
|
|
50
|
+
function nameOf(root, target, text, keyOf) {
|
|
51
|
+
const own = collapse(target.getAttribute('data-track') ?? '');
|
|
52
|
+
if (own)
|
|
53
|
+
return Array.from(own).slice(0, MAX_NAME).join('');
|
|
54
|
+
const key = text ? keyOf(text) : null;
|
|
55
|
+
if (key)
|
|
56
|
+
return key;
|
|
57
|
+
const all = Array.from(root.querySelectorAll(TARGET)).filter(n => !n.closest(IGNORE));
|
|
58
|
+
return `${target.tagName.toLowerCase()}#${all.indexOf(target) + 1}`;
|
|
59
|
+
}
|
|
60
|
+
function kindOf(el) {
|
|
61
|
+
const role = el.getAttribute('role');
|
|
62
|
+
if (role === 'radio' || role === 'checkbox' || role === 'option')
|
|
63
|
+
return 'option';
|
|
64
|
+
if (role === 'button')
|
|
65
|
+
return 'button';
|
|
66
|
+
return el.tagName === 'A' ? 'link' : 'button';
|
|
67
|
+
}
|
|
68
|
+
const shown = (n) => !SKIP_TEXT.has(n.tagName) && n.getAttribute('aria-hidden') !== 'true' && !n.hasAttribute('hidden');
|
|
69
|
+
// The text a person sees: text nodes, without aria-hidden or hidden parts (an icon's emoji, a check mark). Each child element's
|
|
70
|
+
// text is set off by spaces (<span>Pay</span><span>now</span> → "Pay now"); text nodes next to each other stay joined ("5" "%" →
|
|
71
|
+
// "5%", "Hello, " "Ann" "!" → "Hello, Ann!"). The caller collapses the spaces.
|
|
72
|
+
function visibleText(el) {
|
|
73
|
+
let out = '';
|
|
74
|
+
for (const n of Array.from(el.childNodes)) {
|
|
75
|
+
if (n.nodeType === Node.TEXT_NODE)
|
|
76
|
+
out += n.textContent ?? '';
|
|
77
|
+
else if (n instanceof Element && shown(n))
|
|
78
|
+
out += ' ' + visibleText(n) + ' ';
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
// The alt texts of the images in a button or link that has no text of its own (a logo link, an image button).
|
|
83
|
+
function imageAlt(el) {
|
|
84
|
+
return Array.from(el.querySelectorAll('img[alt]')).filter(img => {
|
|
85
|
+
for (let n = img; n && n !== el; n = n.parentElement)
|
|
86
|
+
if (!shown(n))
|
|
87
|
+
return false;
|
|
88
|
+
return true;
|
|
89
|
+
}).map(img => img.getAttribute('alt')).join(' ');
|
|
90
|
+
}
|
|
91
|
+
// The host of a link that leaves the page's site (http/https only), else null. The path and query are never sent.
|
|
92
|
+
function outsideHost(href) {
|
|
93
|
+
if (!href)
|
|
94
|
+
return null;
|
|
95
|
+
try {
|
|
96
|
+
const u = new URL(href, location.href);
|
|
97
|
+
return (u.protocol === 'http:' || u.protocol === 'https:') && u.host !== location.host ? u.hostname.toLowerCase() : null;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
type Value = string | number | boolean;
|
|
2
|
+
export declare function setContent(c: Record<string, string>, o?: Record<string, Value>): void;
|
|
3
|
+
export declare const collapse: (s: string) => string;
|
|
4
|
+
export declare function t(key: string, def: string, vars?: Record<string, string | number>): string;
|
|
5
|
+
export declare function param<T extends Value>(key: string, def: T): T;
|
|
6
|
+
export declare function keyOfText(text: string): string | null;
|
|
7
|
+
export {};
|
package/dist/content.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
let content = {};
|
|
2
|
+
let overrides = {};
|
|
3
|
+
// The reverse dictionary for $click names (clicks.ts): every text t() has produced on this page, whitespace collapsed → its key.
|
|
4
|
+
// Two keys with one text give the key that comes first in the manifest (keys sorted with localeCompare, cli/extract.ts), so the
|
|
5
|
+
// name does not depend on what was drawn first. Texts with vars ("Hi Ann") count too; past MAX_TEXTS new texts are not added, so a
|
|
6
|
+
// t() with a ticking var cannot grow it for ever.
|
|
7
|
+
let texts = new Map();
|
|
8
|
+
const MAX_TEXTS = 2000;
|
|
9
|
+
export function setContent(c, o = {}) {
|
|
10
|
+
content = c && typeof c === 'object' ? c : {};
|
|
11
|
+
overrides = o && typeof o === 'object' ? o : {};
|
|
12
|
+
texts = new Map();
|
|
13
|
+
}
|
|
14
|
+
const own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
15
|
+
// The value for key: the arm's override, else the admin's edit; undefined for neither.
|
|
16
|
+
function valueOf(key) {
|
|
17
|
+
if (own(overrides, key)) {
|
|
18
|
+
const v = overrides[key];
|
|
19
|
+
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')
|
|
20
|
+
return v;
|
|
21
|
+
}
|
|
22
|
+
if (own(content, key) && typeof content[key] === 'string')
|
|
23
|
+
return content[key];
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
export const collapse = (s) => s.replace(/\s+/g, ' ').trim();
|
|
27
|
+
// t(key, default): the published text for key, else the default written in code. The build collects every
|
|
28
|
+
// t() call with its default into the manifest, so all texts are editable in the admin.
|
|
29
|
+
export function t(key, def, vars) {
|
|
30
|
+
const v = valueOf(key);
|
|
31
|
+
const s = v === undefined ? def : String(v);
|
|
32
|
+
const out = vars ? s.replace(/\{\{(\w+)\}\}/g, (m, k) => (k in vars ? String(vars[k]) : m)) : s;
|
|
33
|
+
remember(collapse(out), key);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
// param(key, default): a number, string or boolean the admin (or an A/B arm) may change, of the default's type. A value that does
|
|
37
|
+
// not read as that type ("abc" for a number) gives the default.
|
|
38
|
+
export function param(key, def) {
|
|
39
|
+
const v = valueOf(key);
|
|
40
|
+
if (v === undefined)
|
|
41
|
+
return def;
|
|
42
|
+
if (typeof def === 'number') {
|
|
43
|
+
const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN;
|
|
44
|
+
return (Number.isFinite(n) ? n : def);
|
|
45
|
+
}
|
|
46
|
+
if (typeof def === 'boolean')
|
|
47
|
+
return (typeof v === 'boolean' ? v : v === 'true' ? true : v === 'false' ? false : def);
|
|
48
|
+
return String(v);
|
|
49
|
+
}
|
|
50
|
+
function remember(text, key) {
|
|
51
|
+
if (!text)
|
|
52
|
+
return;
|
|
53
|
+
const prev = texts.get(text);
|
|
54
|
+
if (prev === undefined ? texts.size < MAX_TEXTS : key.localeCompare(prev) < 0)
|
|
55
|
+
texts.set(text, key);
|
|
56
|
+
}
|
|
57
|
+
// keyOfText: the t() key whose text, as shown on this page, is exactly this one (whitespace collapsed), or null.
|
|
58
|
+
export function keyOfText(text) {
|
|
59
|
+
return texts.get(collapse(text)) ?? null;
|
|
60
|
+
}
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// What the device tells about itself, sent once per batch as `ctx` (spec v2 §2): the language, the time zone, the screen and the
|
|
2
|
+
// viewport in CSS pixels, the device pixel ratio, and (kit 0.13.0) the connection type and data saver. The server stamps it on every event of the batch and drops what does not
|
|
3
|
+
// look right, so each value here is only read and shaped, never trusted. Anything unavailable is left out; reading must never
|
|
4
|
+
// throw (a private mode, an old browser, a test without a screen).
|
|
5
|
+
const NET = new Set(['slow-2g', '2g', '3g', '4g']);
|
|
6
|
+
const LANG = /^[A-Za-z]{2,8}(?:[-_][A-Za-z0-9]{1,8}){0,3}$/;
|
|
7
|
+
const ZONE = /^[A-Za-z][A-Za-z0-9_+/-]{0,63}$/;
|
|
8
|
+
function size(w, h) {
|
|
9
|
+
const a = Math.round(Number(w)), b = Math.round(Number(h));
|
|
10
|
+
return Number.isFinite(a) && Number.isFinite(b) && a >= 1 && b >= 1 && a <= 20000 && b <= 20000 ? `${a}x${b}` : undefined;
|
|
11
|
+
}
|
|
12
|
+
function attempt(read) {
|
|
13
|
+
try {
|
|
14
|
+
return read();
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// The screen of a touch device is its portrait size whatever way it is held (Android reports the current one, iOS the portrait
|
|
21
|
+
// one), so a rotation does not split one visitor into two screens; a desktop keeps width x height as it is.
|
|
22
|
+
function screenSize() {
|
|
23
|
+
const w = screen.width, h = screen.height;
|
|
24
|
+
const touch = attempt(() => matchMedia('(pointer: coarse)').matches) === true;
|
|
25
|
+
return touch && w > h ? size(h, w) : size(w, h);
|
|
26
|
+
}
|
|
27
|
+
export function batchContext(o = {}) {
|
|
28
|
+
const ctx = {};
|
|
29
|
+
const lang = attempt(() => navigator.language);
|
|
30
|
+
if (typeof lang === 'string' && lang.length <= 16 && LANG.test(lang))
|
|
31
|
+
ctx.lang = lang;
|
|
32
|
+
const tz = attempt(() => Intl.DateTimeFormat().resolvedOptions().timeZone);
|
|
33
|
+
if (typeof tz === 'string' && ZONE.test(tz))
|
|
34
|
+
ctx.tz = tz;
|
|
35
|
+
const scr = attempt(screenSize);
|
|
36
|
+
if (scr)
|
|
37
|
+
ctx.screen = scr;
|
|
38
|
+
const vp = attempt(() => size(innerWidth, innerHeight));
|
|
39
|
+
if (vp)
|
|
40
|
+
ctx.viewport = vp;
|
|
41
|
+
const dpr = attempt(() => Number(devicePixelRatio));
|
|
42
|
+
if (dpr !== undefined && Number.isFinite(dpr) && dpr >= 0.5 && dpr <= 10)
|
|
43
|
+
ctx.dpr = Math.round(dpr * 10) / 10;
|
|
44
|
+
if (o.net !== false) {
|
|
45
|
+
const conn = attempt(() => navigator.connection);
|
|
46
|
+
if (conn && typeof conn.effectiveType === 'string' && NET.has(conn.effectiveType))
|
|
47
|
+
ctx.net = conn.effectiveType;
|
|
48
|
+
if (conn && conn.saveData === true)
|
|
49
|
+
ctx.save_data = true;
|
|
50
|
+
}
|
|
51
|
+
return ctx;
|
|
52
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const MAX_ERRORS = 5;
|
|
2
|
+
export declare function scrub(text: string): string;
|
|
3
|
+
export type ErrorOptions = {
|
|
4
|
+
track: (name: string, props: Record<string, unknown>, stepId: string) => void;
|
|
5
|
+
stepId: () => string;
|
|
6
|
+
};
|
|
7
|
+
export declare function installErrors(o: ErrorOptions): () => void;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// $error (kit 0.13.0): an uncaught JS error (window error event) or an unhandled promise rejection on the funnel's page.
|
|
2
|
+
//
|
|
3
|
+
// Props:
|
|
4
|
+
// kind error or rejection
|
|
5
|
+
// message what the browser says, at most 300 characters
|
|
6
|
+
// source the script's address without its query string and fragment (error only), at most 200 characters
|
|
7
|
+
// line, col the place in the script (error only)
|
|
8
|
+
// stack the first 600 characters of the stack (when the error has one)
|
|
9
|
+
// And the step id of the event is the step on screen. Nothing the visitor typed may reach this event, so every text goes through
|
|
10
|
+
// scrub(): a query string or fragment of a URL is cut off, an address with an @ and a run of 9 or more digits are replaced.
|
|
11
|
+
// At most 5 per page load, the same error (message and place) once. Errors of browser extensions and the harmless ResizeObserver
|
|
12
|
+
// notice are not the funnel's and are left out.
|
|
13
|
+
export const MAX_ERRORS = 5;
|
|
14
|
+
const MAX_MESSAGE = 300;
|
|
15
|
+
const MAX_SOURCE = 200;
|
|
16
|
+
const MAX_STACK = 600;
|
|
17
|
+
const URL_RE = /\b(?:https?|file|blob|wss?):\/\/[^\s'"<>()[\]{}]+/gi;
|
|
18
|
+
// An address is word characters, @, then a domain with a dot. "fn@https://…" of a Firefox or Safari stack is no address.
|
|
19
|
+
const EMAIL_RE = /[\w.+%-]+@(?![a-z-]+:)[\w-]+(?:\.[\w-]+)+/g;
|
|
20
|
+
const DIGITS_RE = /\d{9,}/g;
|
|
21
|
+
const EXTENSION_RE = /(?:chrome|moz|safari|safari-web|ms-browser)-extension:\/\/|webkit-masked-url:/i;
|
|
22
|
+
const NOISE_RE = /ResizeObserver loop/i;
|
|
23
|
+
// URL → origin and path, plus the line and column after a query-less script address ("a.js?v=1:10:5" → "a.js:10:5").
|
|
24
|
+
function stripUrl(url) {
|
|
25
|
+
const pos = /(?::\d+){1,2}$/.exec(url)?.[0] ?? '';
|
|
26
|
+
const base = pos ? url.slice(0, -pos.length) : url;
|
|
27
|
+
const cutAt = base.search(/[?#]/);
|
|
28
|
+
return (cutAt < 0 ? base : base.slice(0, cutAt)) + pos;
|
|
29
|
+
}
|
|
30
|
+
export function scrub(text) {
|
|
31
|
+
return text.replace(URL_RE, stripUrl).replace(EMAIL_RE, '[email]').replace(DIGITS_RE, '#');
|
|
32
|
+
}
|
|
33
|
+
const cut = (s, n) => (s.length <= n ? s : Array.from(s).slice(0, n).join(''));
|
|
34
|
+
export function installErrors(o) {
|
|
35
|
+
let sent = 0;
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
const send = (props, key) => {
|
|
38
|
+
if (sent >= MAX_ERRORS || seen.has(key))
|
|
39
|
+
return;
|
|
40
|
+
seen.add(key);
|
|
41
|
+
sent++;
|
|
42
|
+
o.track('$error', props, o.stepId());
|
|
43
|
+
};
|
|
44
|
+
const onError = (e) => {
|
|
45
|
+
try {
|
|
46
|
+
const source = typeof e.filename === 'string' ? e.filename : '';
|
|
47
|
+
const message = typeof e.message === 'string' ? e.message : '';
|
|
48
|
+
if (NOISE_RE.test(message))
|
|
49
|
+
return;
|
|
50
|
+
const stack = e.error && typeof e.error.stack === 'string' ? e.error.stack : '';
|
|
51
|
+
if (EXTENSION_RE.test(source) || EXTENSION_RE.test(stack))
|
|
52
|
+
return;
|
|
53
|
+
const props = { kind: 'error', message: cut(scrub(message), MAX_MESSAGE) };
|
|
54
|
+
if (source)
|
|
55
|
+
props.source = cut(scrub(source), MAX_SOURCE);
|
|
56
|
+
if (Number.isFinite(e.lineno) && e.lineno > 0)
|
|
57
|
+
props.line = e.lineno;
|
|
58
|
+
if (Number.isFinite(e.colno) && e.colno > 0)
|
|
59
|
+
props.col = e.colno;
|
|
60
|
+
if (stack)
|
|
61
|
+
props.stack = cut(scrub(stack), MAX_STACK);
|
|
62
|
+
send(props, `${props.message}|${props.source ?? ''}|${props.line ?? ''}`);
|
|
63
|
+
}
|
|
64
|
+
catch { /* analytics must never break the page */ }
|
|
65
|
+
};
|
|
66
|
+
const onRejection = (e) => {
|
|
67
|
+
try {
|
|
68
|
+
const r = e.reason;
|
|
69
|
+
let message = 'non-error rejection', stack = '';
|
|
70
|
+
if (r instanceof Error || (r && typeof r === 'object' && typeof r.message === 'string')) {
|
|
71
|
+
const err = r;
|
|
72
|
+
message = (typeof err.name === 'string' && err.name ? err.name + ': ' : '') + err.message;
|
|
73
|
+
if (typeof err.stack === 'string')
|
|
74
|
+
stack = err.stack;
|
|
75
|
+
}
|
|
76
|
+
else if (typeof r === 'string')
|
|
77
|
+
message = r;
|
|
78
|
+
if (NOISE_RE.test(message) || EXTENSION_RE.test(stack))
|
|
79
|
+
return;
|
|
80
|
+
const props = { kind: 'rejection', message: cut(scrub(message), MAX_MESSAGE) };
|
|
81
|
+
if (stack)
|
|
82
|
+
props.stack = cut(scrub(stack), MAX_STACK);
|
|
83
|
+
send(props, `${props.message}|r`);
|
|
84
|
+
}
|
|
85
|
+
catch { /* analytics must never break the page */ }
|
|
86
|
+
};
|
|
87
|
+
addEventListener('error', onError);
|
|
88
|
+
addEventListener('unhandledrejection', onRejection);
|
|
89
|
+
return () => { removeEventListener('error', onError); removeEventListener('unhandledrejection', onRejection); };
|
|
90
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// A/B in the funnel's code (kit 0.9.0, spec «A/B» §3): the visitor's arm, as the router assigned it (bootstrap.variants).
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import { readBootstrap } from './bootstrap.js';
|
|
4
|
+
// The arm key of experiment `key` for this visitor, or null: no such experiment running, vite dev, an old router.
|
|
5
|
+
export function experiment(key) {
|
|
6
|
+
const v = readBootstrap().variants;
|
|
7
|
+
if (!v || typeof v !== 'object' || !Object.prototype.hasOwnProperty.call(v, key))
|
|
8
|
+
return null;
|
|
9
|
+
return typeof v[key] === 'string' ? v[key] : null;
|
|
10
|
+
}
|
|
11
|
+
// The same in a component; the bootstrap does not change while the page lives.
|
|
12
|
+
export function useExperiment(key) {
|
|
13
|
+
return useMemo(() => experiment(key), [key]);
|
|
14
|
+
}
|
|
15
|
+
// The targeting rule the router matched for this visitor (v2 track D: «Правила» in the admin), or null: no rule matched, vite dev, an
|
|
16
|
+
// old router. Rules decide the build and the t()/param() values before any experiment does; this is for code that wants to know which
|
|
17
|
+
// one it got. It is the `rule` key of the variants, so experiment('rule') answers the same, but a rule is no experiment: no $exposure.
|
|
18
|
+
export function rule() {
|
|
19
|
+
return experiment('rule');
|
|
20
|
+
}
|
|
21
|
+
// The same in a component; the bootstrap does not change while the page lives.
|
|
22
|
+
export function useRule() {
|
|
23
|
+
return useMemo(() => rule(), []);
|
|
24
|
+
}
|
package/dist/exposure.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// $exposure (kit 0.9.0, spec «A/B» §3): at the funnel's start, one event per experiment of the bootstrap, props {experiment, arm},
|
|
2
|
+
// once per session. The variants key `rule` is the targeting rule the router matched, not an experiment: it has no $exposure: sessionStorage af_exposed:<funnel> keeps the session id (the tracker's, visit.session) and the arms already sent.
|
|
3
|
+
// A new session (30 min idle) or another arm sends it again. Blocked storage: once per page load.
|
|
4
|
+
import { visit } from './server.js';
|
|
5
|
+
// The variants key of the matched rule (v2 track D): it rides on every event as a variant, but a rule is no test and has no exposure.
|
|
6
|
+
export const RULE_KEY = 'rule';
|
|
7
|
+
export function exposeVariants(funnel, variants, track) {
|
|
8
|
+
if (!variants || typeof variants !== 'object')
|
|
9
|
+
return;
|
|
10
|
+
const entries = Object.entries(variants).filter(([experiment, arm]) => experiment !== RULE_KEY && typeof arm === 'string' && arm);
|
|
11
|
+
if (!entries.length)
|
|
12
|
+
return;
|
|
13
|
+
const key = `af_exposed:${funnel}`;
|
|
14
|
+
let sid = '';
|
|
15
|
+
try {
|
|
16
|
+
sid = visit.session();
|
|
17
|
+
}
|
|
18
|
+
catch { /* no session */ }
|
|
19
|
+
let seen = {};
|
|
20
|
+
try {
|
|
21
|
+
seen = JSON.parse(sessionStorage.getItem(key) ?? '{}') ?? {};
|
|
22
|
+
}
|
|
23
|
+
catch { /* blocked or junk */ }
|
|
24
|
+
const arms = seen.sid === sid && seen.arms && typeof seen.arms === 'object' ? { ...seen.arms } : {};
|
|
25
|
+
for (const [experiment, arm] of entries) {
|
|
26
|
+
if (arms[experiment] === arm)
|
|
27
|
+
continue;
|
|
28
|
+
track('$exposure', { experiment, arm });
|
|
29
|
+
arms[experiment] = arm;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
sessionStorage.setItem(key, JSON.stringify({ sid, arms }));
|
|
33
|
+
}
|
|
34
|
+
catch { /* storage unavailable */ }
|
|
35
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export declare const FUNCTION_NAME: RegExp;
|
|
2
|
+
export declare const REPLY: unique symbol;
|
|
3
|
+
export type Reply = {
|
|
4
|
+
readonly [REPLY]: true;
|
|
5
|
+
readonly status: number;
|
|
6
|
+
readonly body: unknown;
|
|
7
|
+
};
|
|
8
|
+
export type FunctionCtx = {
|
|
9
|
+
secrets: Readonly<Record<string, string>>;
|
|
10
|
+
cookies: {
|
|
11
|
+
get(name: string): string | undefined;
|
|
12
|
+
set(name: string, value: string, opts?: {
|
|
13
|
+
maxAgeSec?: number;
|
|
14
|
+
}): void;
|
|
15
|
+
clear(name: string): void;
|
|
16
|
+
};
|
|
17
|
+
ip: string;
|
|
18
|
+
env: 'prod' | 'preview';
|
|
19
|
+
project: string;
|
|
20
|
+
funnel: string;
|
|
21
|
+
visitor: string;
|
|
22
|
+
session: string;
|
|
23
|
+
build: string;
|
|
24
|
+
release: string;
|
|
25
|
+
variants: Readonly<Record<string, string>>;
|
|
26
|
+
params: Readonly<Record<string, string | number | boolean>>;
|
|
27
|
+
rule: string;
|
|
28
|
+
reply(status: number, body?: unknown): Reply;
|
|
29
|
+
track(name: string, props?: Record<string, unknown>): void;
|
|
30
|
+
};
|
|
31
|
+
export type FunctionHandler<P = any, R = unknown> = (payload: P, ctx: FunctionCtx) => R | Reply | Promise<R | Reply>;
|
|
32
|
+
export type FunctionsModule<F extends Record<string, FunctionHandler> = Record<string, FunctionHandler>> = {
|
|
33
|
+
readonly __funnelFunctions: 1;
|
|
34
|
+
readonly functions: F;
|
|
35
|
+
};
|
|
36
|
+
export declare function reply(status: number, body?: unknown): Reply;
|
|
37
|
+
export declare function defineFunctions<F extends Record<string, FunctionHandler>>(fns: F): FunctionsModule<F>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// `@appright/funnelshift/functions` (before 0.15.0 `@appright/funnel-kit/functions`): what functions/index.ts imports. It is bundled into _functions.js and runs in a Worker
|
|
2
|
+
// isolate behind the router (spec §3), so nothing here may touch React, the DOM or node builtins.
|
|
3
|
+
// The same rule as the router and server(): the name is a URL segment and a manifest entry.
|
|
4
|
+
export const FUNCTION_NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
|
5
|
+
// The router's host builds replies with ctx.reply under this registry key; Symbol.for makes the host's symbol and this one the
|
|
6
|
+
// same value although they live in different modules (host.js is router code, not part of the build).
|
|
7
|
+
export const REPLY = Symbol.for('appright.funnel-kit.reply');
|
|
8
|
+
// The same shape ctx.reply returns; for helpers that build a reply outside a handler.
|
|
9
|
+
export function reply(status, body) {
|
|
10
|
+
return { [REPLY]: true, status, body };
|
|
11
|
+
}
|
|
12
|
+
// `export default defineFunctions({ ... })` is the only shape the build and the host accept: the marker tells them apart from
|
|
13
|
+
// a module that forgot it. Bad names fail here, at build time, when funnel-kit imports the bundle to list them.
|
|
14
|
+
export function defineFunctions(fns) {
|
|
15
|
+
if (!fns || typeof fns !== 'object')
|
|
16
|
+
throw new Error('defineFunctions expects an object of functions');
|
|
17
|
+
for (const [name, fn] of Object.entries(fns)) {
|
|
18
|
+
if (!FUNCTION_NAME.test(name))
|
|
19
|
+
throw new Error(`bad function name ${JSON.stringify(name)}: use ${FUNCTION_NAME.source}`);
|
|
20
|
+
if (typeof fn !== 'function')
|
|
21
|
+
throw new Error(`function ${name} is not a function`);
|
|
22
|
+
}
|
|
23
|
+
return { __funnelFunctions: 1, functions: fns };
|
|
24
|
+
}
|
package/dist/funnel.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type Answers, type StepDef } from './nav.js';
|
|
2
|
+
import { type Creator } from './bootstrap.js';
|
|
3
|
+
import { type FinalEvent, type StopScope } from './tracker.js';
|
|
4
|
+
import { type AutoTrack } from './autotrack.js';
|
|
5
|
+
export type FunnelDef = {
|
|
6
|
+
id: string;
|
|
7
|
+
steps: StepDef[];
|
|
8
|
+
prerender?: boolean;
|
|
9
|
+
autoTrack?: AutoTrack;
|
|
10
|
+
browserBack?: boolean;
|
|
11
|
+
};
|
|
12
|
+
export type TrackFn = (name: string, props?: Record<string, unknown>, stepId?: string) => void;
|
|
13
|
+
type Ctx = {
|
|
14
|
+
answers: Answers;
|
|
15
|
+
set: (k: string, v: unknown, label?: string | string[]) => void;
|
|
16
|
+
next: () => void;
|
|
17
|
+
back: () => void;
|
|
18
|
+
skip: () => void;
|
|
19
|
+
stepId: string;
|
|
20
|
+
index: number;
|
|
21
|
+
total: number;
|
|
22
|
+
progress: number;
|
|
23
|
+
track: TrackFn;
|
|
24
|
+
shot: boolean;
|
|
25
|
+
stopTracking: (opts?: StopOptions) => Promise<void>;
|
|
26
|
+
creator: Creator | null;
|
|
27
|
+
visitorId: string;
|
|
28
|
+
funnelId: string;
|
|
29
|
+
userId: string;
|
|
30
|
+
knownUser: boolean;
|
|
31
|
+
};
|
|
32
|
+
export type StopOptions = {
|
|
33
|
+
final?: FinalEvent;
|
|
34
|
+
scope?: StopScope;
|
|
35
|
+
};
|
|
36
|
+
export declare const defineStep: (s: StepDef) => StepDef;
|
|
37
|
+
export declare const defineFunnel: (f: FunnelDef) => FunnelDef;
|
|
38
|
+
export declare const FunnelCtx: import("react").Context<Ctx | null>;
|
|
39
|
+
export declare function useFunnel(): Ctx;
|
|
40
|
+
export type IdentifyOptions = {
|
|
41
|
+
email?: string;
|
|
42
|
+
traits?: Record<string, unknown>;
|
|
43
|
+
sendEmail?: boolean;
|
|
44
|
+
};
|
|
45
|
+
export declare function identify(): void;
|
|
46
|
+
export declare function identify(userId: string, opts?: IdentifyOptions): void;
|
|
47
|
+
export declare function reset(): void;
|
|
48
|
+
export type ShotRequest = {
|
|
49
|
+
step: string;
|
|
50
|
+
};
|
|
51
|
+
export declare function shotRequest(search: string): ShotRequest | null;
|
|
52
|
+
export declare function FunnelApp({ funnel, track, shot, root, browserBack }: {
|
|
53
|
+
funnel: FunnelDef;
|
|
54
|
+
track: TrackFn;
|
|
55
|
+
shot?: ShotRequest | null;
|
|
56
|
+
root?: HTMLElement | null;
|
|
57
|
+
browserBack?: boolean;
|
|
58
|
+
}): import("react").JSX.Element;
|
|
59
|
+
export declare function track(name: string, props?: Record<string, unknown>): void;
|
|
60
|
+
export declare function fieldError(field: string, code: string): void;
|
|
61
|
+
export declare function stopTracking(opts?: StopOptions): Promise<void>;
|
|
62
|
+
export declare function mount(funnel: FunnelDef, el?: HTMLElement): void;
|
|
63
|
+
export declare function landingContext(funnel: FunnelDef): Ctx;
|
|
64
|
+
export {};
|