@thg-agentic/hyper-personalisation-web 3.22.2 → 3.23.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/README.md +143 -1
- package/dist/hp.min.js +3 -3
- package/dist/onsite-answers.min.js +1 -0
- package/dist/onsite-render.min.js +178 -0
- package/dist/onsite-wheel.min.js +1 -0
- package/dist/onsite.min.js +1 -0
- package/dist/snippet.min.js +1 -1
- package/package.json +19 -3
- package/src/bootstrap.js +33 -2
- package/src/context.js +20 -7
- package/src/hp.js +2 -0
- package/src/index.js +96 -1
- package/src/onsite/answers-loader.js +6 -0
- package/src/onsite/answers.js +177 -0
- package/src/onsite/api.js +93 -0
- package/src/onsite/counters.js +144 -0
- package/src/onsite/index.js +446 -0
- package/src/onsite/pow.js +127 -0
- package/src/onsite/preview.js +97 -0
- package/src/onsite/render-loader.js +5 -0
- package/src/onsite/render.js +1234 -0
- package/src/onsite/rich-text.js +73 -0
- package/src/onsite/rules.js +237 -0
- package/src/onsite/styles.js +208 -0
- package/src/onsite/triggers.js +165 -0
- package/src/onsite/wheel-loader.js +5 -0
- package/src/onsite/wheel.js +148 -0
- package/src/onsite-facade.js +99 -0
- package/src/onsite-loader.js +5 -0
- package/src/settings.js +18 -0
- package/src/spec-version.js +1 -1
- package/src/storage-scope.js +5 -0
- package/src/version.js +1 -1
- package/types/bootstrap.d.ts +15 -1
- package/types/events.d.ts +68 -5
- package/types/hp.d.ts +4 -1
- package/types/index.d.ts +10 -0
- package/types/onsite-preview.d.ts +58 -0
- package/types/onsite-wheel.d.ts +11 -0
- package/types/onsite.d.ts +195 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A creative's RichText as DOM, through DOM APIs only: text nodes, `<b>`, `<i>` and `<a>` with an
|
|
3
|
+
* https href. Anything else in the tree is dropped, so no creative can carry markup or script.
|
|
4
|
+
*
|
|
5
|
+
* RichText = string | Array<string | { b: RichText } | { i: RichText } | { a: RichText, href }>
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const MAX_DEPTH = 6;
|
|
9
|
+
|
|
10
|
+
/** The href as an https URL, or null. */
|
|
11
|
+
export function httpsHref(href) {
|
|
12
|
+
if (typeof href !== 'string') return null;
|
|
13
|
+
try {
|
|
14
|
+
const url = new URL(href);
|
|
15
|
+
return url.protocol === 'https:' ? url.href : null;
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** An image or video asset's URL: https, or http on localhost for a local test page; else null. */
|
|
22
|
+
export function assetHref(href) {
|
|
23
|
+
if (typeof href !== 'string') return null;
|
|
24
|
+
try {
|
|
25
|
+
const url = new URL(href);
|
|
26
|
+
return url.protocol === 'https:' ||
|
|
27
|
+
(url.protocol === 'http:' && url.hostname === 'localhost')
|
|
28
|
+
? url.href
|
|
29
|
+
: null;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The characters of a rich text, without its marks. */
|
|
36
|
+
export function plainText(text, depth = 0) {
|
|
37
|
+
if (typeof text === 'string') return text;
|
|
38
|
+
if (depth > MAX_DEPTH || !text || typeof text !== 'object') return '';
|
|
39
|
+
if (Array.isArray(text))
|
|
40
|
+
return text.map((t) => plainText(t, depth + 1)).join('');
|
|
41
|
+
return plainText(text.b ?? text.i ?? text.a, depth + 1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function append(parent, node, doc, depth) {
|
|
45
|
+
if (typeof node === 'string') {
|
|
46
|
+
parent.append(doc.createTextNode(node));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (depth > MAX_DEPTH || node === null) return;
|
|
50
|
+
if (Array.isArray(node)) {
|
|
51
|
+
for (const child of node) append(parent, child, doc, depth + 1);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (typeof node !== 'object') return;
|
|
55
|
+
const tag = 'b' in node ? 'b' : 'i' in node ? 'i' : 'a' in node ? 'a' : null;
|
|
56
|
+
if (!tag) return;
|
|
57
|
+
const href = tag === 'a' ? httpsHref(node.href) : null;
|
|
58
|
+
// A link without an https target is its text alone.
|
|
59
|
+
const el = tag === 'a' && !href ? parent : doc.createElement(tag);
|
|
60
|
+
if (href) {
|
|
61
|
+
el.setAttribute('href', href);
|
|
62
|
+
el.setAttribute('rel', 'noopener');
|
|
63
|
+
}
|
|
64
|
+
append(el, node[tag], doc, depth + 1);
|
|
65
|
+
if (el !== parent) parent.append(el);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `text` as a fragment of text nodes, `<b>`, `<i>` and https `<a>`. */
|
|
69
|
+
export function richText(text, doc = document) {
|
|
70
|
+
const fragment = doc.createDocumentFragment();
|
|
71
|
+
append(fragment, text, doc, 0);
|
|
72
|
+
return fragment;
|
|
73
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which forms may show on this page view, and why not: pure functions of the form, the page and the
|
|
3
|
+
* counters, so the eligibility trace (`hp.onsite.debug()`) says exactly what decided.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const FORMATS = [
|
|
7
|
+
'popup',
|
|
8
|
+
'sheet',
|
|
9
|
+
'bar',
|
|
10
|
+
'teaser',
|
|
11
|
+
'inline',
|
|
12
|
+
'fullscreen',
|
|
13
|
+
];
|
|
14
|
+
export const TRIGGERS = [
|
|
15
|
+
'entry',
|
|
16
|
+
'exit',
|
|
17
|
+
'scroll',
|
|
18
|
+
'time',
|
|
19
|
+
'idle',
|
|
20
|
+
'inline',
|
|
21
|
+
'manual',
|
|
22
|
+
];
|
|
23
|
+
/** The formats that cover the page: one of them per page view, the highest priority first. */
|
|
24
|
+
export const OVERLAY = (format) => format !== 'inline';
|
|
25
|
+
/**
|
|
26
|
+
* The formats that interrupt the shopper, which the property's gap spaces out: not a bar or a
|
|
27
|
+
* teaser, which leave the page usable (Klaviyo's "multiple forms" setting counts the same).
|
|
28
|
+
*/
|
|
29
|
+
export const INTERRUPTS = (format) =>
|
|
30
|
+
format === 'popup' || format === 'sheet' || format === 'fullscreen';
|
|
31
|
+
|
|
32
|
+
const isObject = (v) =>
|
|
33
|
+
v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
34
|
+
const strings = (v) =>
|
|
35
|
+
Array.isArray(v) ? v.filter((s) => typeof s === 'string') : [];
|
|
36
|
+
const count = (v) => (Number.isInteger(v) && v >= 0 ? v : null);
|
|
37
|
+
|
|
38
|
+
/** A form from the settings as the runtime uses it, or null when it cannot be used. */
|
|
39
|
+
export function readForm(raw) {
|
|
40
|
+
if (!isObject(raw) || typeof raw.slot !== 'string' || raw.slot === '')
|
|
41
|
+
return null;
|
|
42
|
+
const trigger = isObject(raw.trigger) ? raw.trigger : {};
|
|
43
|
+
if (!FORMATS.includes(raw.format) || !TRIGGERS.includes(trigger.type))
|
|
44
|
+
return null;
|
|
45
|
+
const caps = isObject(raw.caps) ? raw.caps : {};
|
|
46
|
+
return {
|
|
47
|
+
...raw,
|
|
48
|
+
trigger,
|
|
49
|
+
priority: Number.isFinite(raw.priority) ? raw.priority : 0,
|
|
50
|
+
pages: isObject(raw.pages) ? raw.pages : {},
|
|
51
|
+
devices: strings(raw.devices),
|
|
52
|
+
audiences: strings(raw.audiences),
|
|
53
|
+
caps: {
|
|
54
|
+
per_session: count(caps.per_session),
|
|
55
|
+
lifetime: count(caps.lifetime),
|
|
56
|
+
after_close_days: Number.isFinite(caps.after_close_days)
|
|
57
|
+
? caps.after_close_days
|
|
58
|
+
: null,
|
|
59
|
+
},
|
|
60
|
+
window: isObject(raw.window) ? raw.window : {},
|
|
61
|
+
questions: Array.isArray(raw.questions)
|
|
62
|
+
? raw.questions.filter(isObject)
|
|
63
|
+
: [],
|
|
64
|
+
prizes: Array.isArray(raw.prizes) ? raw.prizes.filter(isObject) : [],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const STEP_ID = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The decision's payload as a creative this runtime can render, or null. Here rather than in the
|
|
72
|
+
* renderer, so a form with no creative never loads ./render.js.
|
|
73
|
+
*/
|
|
74
|
+
export function readCreative(payload) {
|
|
75
|
+
if (!isObject(payload) || payload.v !== 1 || !Array.isArray(payload.steps))
|
|
76
|
+
return null;
|
|
77
|
+
const steps = payload.steps.filter(
|
|
78
|
+
(s) => isObject(s) && STEP_ID.test(s.id) && Array.isArray(s.blocks),
|
|
79
|
+
);
|
|
80
|
+
if (steps.length === 0) return null;
|
|
81
|
+
return {
|
|
82
|
+
format: FORMATS.includes(payload.format) ? payload.format : null,
|
|
83
|
+
theme: isObject(payload.theme) ? payload.theme : {},
|
|
84
|
+
steps,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `*` matches any run of characters, `/` included; everything else is literal, in any case. */
|
|
89
|
+
export function globMatch(glob, path) {
|
|
90
|
+
const body = String(glob)
|
|
91
|
+
.split('*')
|
|
92
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
|
|
93
|
+
.join('.*');
|
|
94
|
+
return new RegExp(`^${body}$`, 'i').test(path);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Included (all pages when the form names none) and not excluded: exclusion wins. */
|
|
98
|
+
export function pageAllowed(pages, path) {
|
|
99
|
+
const include = strings(pages?.include);
|
|
100
|
+
return (
|
|
101
|
+
(!include.length || include.some((g) => globMatch(g, path))) &&
|
|
102
|
+
!strings(pages?.exclude).some((g) => globMatch(g, path))
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `mobile` for a touch-only device (no hover, coarse pointer: a touch laptop with a trackpad is a
|
|
108
|
+
* desktop) or a viewport narrower than 768px, otherwise `desktop`.
|
|
109
|
+
*/
|
|
110
|
+
export function deviceOf(win) {
|
|
111
|
+
const touchOnly =
|
|
112
|
+
win.matchMedia?.('(hover: none) and (pointer: coarse)')?.matches === true;
|
|
113
|
+
return touchOnly || win.innerWidth < 768 ? 'mobile' : 'desktop';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const at = (iso) => (typeof iso === 'string' ? Date.parse(iso) : Number.NaN);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Every reason `form` may not show now, empty when it may. `env` is the page:
|
|
120
|
+
* `{ path, device, now, seen, subscribed, personalisation, preview, audiences }`, where `seen` is
|
|
121
|
+
* the slot's counters and `audiences` the session audiences the host gave, or null.
|
|
122
|
+
*/
|
|
123
|
+
export function blockers(form, env) {
|
|
124
|
+
const reasons = [];
|
|
125
|
+
if (!pageAllowed(form.pages, env.path)) reasons.push('page not included');
|
|
126
|
+
if (form.devices.length && !form.devices.includes(env.device))
|
|
127
|
+
reasons.push(`not on ${env.device}`);
|
|
128
|
+
const starts = at(form.window.starts_at);
|
|
129
|
+
const ends = at(form.window.ends_at);
|
|
130
|
+
if (env.now < starts || env.now >= ends) reasons.push('outside its dates');
|
|
131
|
+
if (form.audiences.length) {
|
|
132
|
+
if (!env.personalisation)
|
|
133
|
+
reasons.push('audiences need personalisation consent');
|
|
134
|
+
else if (
|
|
135
|
+
env.audiences &&
|
|
136
|
+
!form.audiences.some((a) => env.audiences.includes(a))
|
|
137
|
+
)
|
|
138
|
+
reasons.push('not in its audiences');
|
|
139
|
+
}
|
|
140
|
+
// A trader previewing a form sees it whatever the counters say.
|
|
141
|
+
if (env.preview) return reasons;
|
|
142
|
+
const { seen } = env;
|
|
143
|
+
const { caps } = form;
|
|
144
|
+
if (seen.submitted) reasons.push('already submitted');
|
|
145
|
+
if (form.exclude_subscribed === true && env.subscribed)
|
|
146
|
+
reasons.push('already subscribed');
|
|
147
|
+
if (caps.per_session !== null && seen.session >= caps.per_session)
|
|
148
|
+
reasons.push('per_session cap');
|
|
149
|
+
if (caps.lifetime !== null && seen.lifetime >= caps.lifetime)
|
|
150
|
+
reasons.push('lifetime cap');
|
|
151
|
+
if (
|
|
152
|
+
caps.after_close_days !== null &&
|
|
153
|
+
seen.closedAt &&
|
|
154
|
+
env.now - seen.closedAt < caps.after_close_days * 86_400_000
|
|
155
|
+
)
|
|
156
|
+
reasons.push('closed recently');
|
|
157
|
+
return reasons;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Forms whose triggers fired together, in the order to try them: highest priority first. A stable
|
|
162
|
+
* sort keeps the first to fire ahead on a tie.
|
|
163
|
+
*/
|
|
164
|
+
export const byPriority = (a, b) => b.priority - a.priority;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The step after `step`: the button's own `next`, else the first `answers` rule the answers meet
|
|
168
|
+
* (`"question=value"`), else the step's default. Null when there is none: the form is done.
|
|
169
|
+
*/
|
|
170
|
+
export function nextStep(step, answers, explicit) {
|
|
171
|
+
if (typeof explicit === 'string' && explicit) return explicit;
|
|
172
|
+
const rules = isObject(step?.next?.answers) ? step.next.answers : {};
|
|
173
|
+
for (const [rule, target] of Object.entries(rules)) {
|
|
174
|
+
const split = rule.indexOf('=');
|
|
175
|
+
const values = answers[rule.slice(0, split)];
|
|
176
|
+
if (split > 0 && values?.includes(rule.slice(split + 1))) return target;
|
|
177
|
+
}
|
|
178
|
+
return typeof step?.next?.default === 'string' ? step.next.default : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Calling codes for the markets a phone block names, so a national number can be sent in E.164. */
|
|
182
|
+
export const CALLING_CODES = {
|
|
183
|
+
AT: '43',
|
|
184
|
+
AU: '61',
|
|
185
|
+
BE: '32',
|
|
186
|
+
CA: '1',
|
|
187
|
+
CH: '41',
|
|
188
|
+
DE: '49',
|
|
189
|
+
DK: '45',
|
|
190
|
+
ES: '34',
|
|
191
|
+
FI: '358',
|
|
192
|
+
FR: '33',
|
|
193
|
+
GB: '44',
|
|
194
|
+
IE: '353',
|
|
195
|
+
IT: '39',
|
|
196
|
+
NL: '31',
|
|
197
|
+
NO: '47',
|
|
198
|
+
NZ: '64',
|
|
199
|
+
PL: '48',
|
|
200
|
+
PT: '351',
|
|
201
|
+
SE: '46',
|
|
202
|
+
US: '1',
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/** A phone number as E.164 (`+353…`), reading a national one in `country`; null when it is not one. */
|
|
206
|
+
export function e164(raw, country) {
|
|
207
|
+
let n = String(raw ?? '').replace(/[\s.()-]/g, '');
|
|
208
|
+
if (n.startsWith('00')) n = `+${n.slice(2)}`;
|
|
209
|
+
const code = CALLING_CODES[String(country ?? '').toUpperCase()];
|
|
210
|
+
if (!n.startsWith('+') && code) n = `+${code}${n.replace(/^0/, '')}`;
|
|
211
|
+
return /^\+[1-9]\d{7,14}$/.test(n) ? n : null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export const isEmail = (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A `<select>` made with `make(tag, className, text)`: a blank first option reading `blank`, then
|
|
218
|
+
* each `[value, text]`, `chosen` selected. The phone field's country code, a choice's dropdown and a
|
|
219
|
+
* birthday's day and month.
|
|
220
|
+
*/
|
|
221
|
+
export function select(make, label, blank, pairs, chosen) {
|
|
222
|
+
const s = make('select', 'i');
|
|
223
|
+
s.setAttribute('aria-label', label);
|
|
224
|
+
for (const [value, text] of [['', blank], ...pairs]) {
|
|
225
|
+
const o = make('option', '', text);
|
|
226
|
+
o.value = value;
|
|
227
|
+
o.selected = value === chosen;
|
|
228
|
+
s.append(o);
|
|
229
|
+
}
|
|
230
|
+
return s;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Where a wheel must come to rest, in degrees clockwise, for prize `index` to sit under the top pointer. */
|
|
234
|
+
export function restAngle(index, segments, turns = 5) {
|
|
235
|
+
const size = 360 / segments;
|
|
236
|
+
return turns * 360 + (360 - (index + 0.5) * size);
|
|
237
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The forms' only stylesheet, scoped to their shadow root. The creative's theme arrives as custom
|
|
3
|
+
* properties on the host (`--hp-accent`, `--hp-bg`, `--hp-text`, `--hp-radius`, `--hp-font`,
|
|
4
|
+
* `--hp-on-accent`, `--hp-ov`) and classes on the shell: the format, `p-<position>`, `b-<shape>`,
|
|
5
|
+
* `bo` (outline buttons), `uc` (capitals), `mob` (a small screen) and `pv` (a console preview).
|
|
6
|
+
* A button's label is large text (WCAG: 14pt bold), so `--hp-on-accent` needs only 3:1; chips and
|
|
7
|
+
* a teaser's tab use `--hp-on-accent-text`. Fields have a surface of their own (`--hp-field`). The card (`.w`)
|
|
8
|
+
* carries the step's template (`t-*`) and size (`z-*`); type sizes are `x-*`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Theme fonts: system stacks that download nothing, after Modern Font Stacks (CC0), cut to the
|
|
13
|
+
* families that cover macOS/iOS, Windows, Android and Linux.
|
|
14
|
+
*/
|
|
15
|
+
export const FONTS = {
|
|
16
|
+
system: 'system-ui,sans-serif',
|
|
17
|
+
'neo-grotesque': 'Inter,Roboto,"Helvetica Neue",Arial,sans-serif',
|
|
18
|
+
humanist: 'Seravek,"Gill Sans Nova",Ubuntu,Calibri,sans-serif',
|
|
19
|
+
'geometric-humanist': 'Avenir,Montserrat,Corbel,sans-serif',
|
|
20
|
+
'rounded-sans':
|
|
21
|
+
'ui-rounded,Quicksand,Comfortaa,"Arial Rounded MT Bold",sans-serif',
|
|
22
|
+
industrial: 'Bahnschrift,"DIN Alternate","Franklin Gothic Medium",sans-serif',
|
|
23
|
+
transitional: 'Charter,"Sitka Text",Cambria,serif',
|
|
24
|
+
'old-style': '"Iowan Old Style","Palatino Linotype",P052,serif',
|
|
25
|
+
didone: 'Didot,"Bodoni MT","Noto Serif Display",serif',
|
|
26
|
+
'slab-serif': 'Rockwell,"Roboto Slab","DejaVu Serif",serif',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const EASE = 'cubic-bezier(.16,1,.3,1)';
|
|
30
|
+
|
|
31
|
+
export const CSS = `
|
|
32
|
+
:host{all:initial}
|
|
33
|
+
:host([preview]){display:block;position:relative;width:100%;height:100%;overflow:hidden;contain:layout paint}
|
|
34
|
+
.s{--a:var(--hp-accent,#111);--b:var(--hp-bg,#fff);--t:var(--hp-text,#111);--r:var(--hp-radius,16px);--oa:var(--hp-on-accent,#fff);--ot:var(--hp-on-accent-text,var(--oa));--fl:var(--hp-field,color-mix(in srgb,var(--t) 5%,var(--b)));--ft:var(--hp-field-text,var(--t));--mu:color-mix(in srgb,var(--t) 74%,var(--b));--ln:color-mix(in srgb,var(--t) 18%,var(--b));--br:min(12px,var(--r));--ir:min(var(--r),8px);font:16px/1.5 var(--hp-font,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif);color:var(--t);-webkit-font-smoothing:antialiased;box-sizing:border-box;margin:0;padding:0;border:0;background:none;max-width:none;max-height:none;overflow:visible}
|
|
35
|
+
.s *,.s ::before,.s ::after{box-sizing:border-box}
|
|
36
|
+
dialog.s:not([open]){display:none}
|
|
37
|
+
dialog::backdrop{background:none}
|
|
38
|
+
[hidden]{display:none!important}
|
|
39
|
+
.b-pill{--br:999px}
|
|
40
|
+
.b-square{--br:0px;--ir:0px}
|
|
41
|
+
.popup,.fullscreen{position:fixed;inset:0;width:100%;height:100%;display:grid;place-items:center;padding:24px;z-index:2147483646}
|
|
42
|
+
.fullscreen{padding:0}
|
|
43
|
+
.mob.popup{padding:12px}
|
|
44
|
+
.ov{display:none;position:absolute;inset:0;background:var(--hp-ov,rgb(10 10 14/.6));animation:hp-fade .3s ease both}
|
|
45
|
+
.popup>.ov,.fullscreen>.ov{display:block}
|
|
46
|
+
.w{position:relative;display:flex;flex-direction:column;width:min(100%,480px);max-height:100%;min-height:0;overflow:auto;overscroll-behavior:contain;background:var(--b);color:var(--t);border-radius:var(--r);box-shadow:0 24px 48px -24px rgb(0 0 0/.35),0 0 0 1px rgb(0 0 0/.05);animation:hp-pop .44s ${EASE} both}
|
|
47
|
+
.z-sm{width:min(100%,380px)}
|
|
48
|
+
.z-lg{width:min(100%,880px)}
|
|
49
|
+
.z-full,.fullscreen .w{width:100%;height:100%;max-height:none;border-radius:0;box-shadow:none}
|
|
50
|
+
.m{position:relative;flex:none;overflow:hidden;background:color-mix(in srgb,var(--t) 7%,var(--b))}
|
|
51
|
+
.m img,.m video{display:block;width:100%;height:100%;object-fit:cover}
|
|
52
|
+
.t-stack .m{aspect-ratio:var(--ar,16/9);max-height:280px}
|
|
53
|
+
.t-split{flex-direction:row;min-height:min(500px,100%)}
|
|
54
|
+
.t-split.right{flex-direction:row-reverse}
|
|
55
|
+
.t-split .m{flex:0 0 46%}
|
|
56
|
+
.t-split .m>*{position:absolute;inset:0}
|
|
57
|
+
.mob .t-split,.sheet .t-split{flex-direction:column;min-height:0}
|
|
58
|
+
.mob .t-split .m,.sheet .t-split .m{flex:none;height:190px}
|
|
59
|
+
.t-overlay{justify-content:center;min-height:min(520px,100%)}
|
|
60
|
+
.mob .t-overlay{min-height:min(460px,100%)}
|
|
61
|
+
.t-overlay .m{position:absolute;inset:0}
|
|
62
|
+
.t-overlay .m::after{content:"";position:absolute;inset:0;background:linear-gradient(to top,var(--b),color-mix(in srgb,var(--b) 50%,transparent));opacity:var(--sc,.45)}
|
|
63
|
+
.t-overlay .f{position:relative;z-index:1;flex:none;width:min(100%,560px);margin-inline:auto}
|
|
64
|
+
.t-image-only .m{background:none}
|
|
65
|
+
.t-image-only .m img{height:auto;aspect-ratio:var(--ar,auto)}
|
|
66
|
+
.io{display:block;width:100%;padding:0;border:0;background:none;cursor:pointer;color:inherit}
|
|
67
|
+
.f{display:flex;flex-direction:column;gap:14px;padding:44px 40px 36px;flex:1 0 auto;min-width:0}
|
|
68
|
+
.t-split .f{flex:1 1 0;justify-content:center}
|
|
69
|
+
.lk>.f{justify-content:center}
|
|
70
|
+
.z-full .f,.fullscreen .f{justify-content:center;width:min(100%,560px);margin:auto}
|
|
71
|
+
.pd-none{padding:0}
|
|
72
|
+
.pd-sm{padding:24px}
|
|
73
|
+
.pd-lg{padding:60px 56px 52px}
|
|
74
|
+
.mob .pd-md{padding:34px 24px 24px}
|
|
75
|
+
.ta-center .f{text-align:center}
|
|
76
|
+
.a-left{text-align:left}.a-center{text-align:center}.a-right{text-align:right}
|
|
77
|
+
h2{margin:0;font-weight:700;letter-spacing:-.02em;text-wrap:balance}
|
|
78
|
+
h2:focus{outline:none}
|
|
79
|
+
.nm .f>h2:first-child,.t-split:not(.right) .f>h2:first-child{padding-inline-end:36px}
|
|
80
|
+
.nm.ta-center .f>h2:first-child,.nm .f>h2.a-center:first-child{padding-inline:36px}
|
|
81
|
+
p{margin:0;text-wrap:pretty}
|
|
82
|
+
.f>p{color:var(--mu)}
|
|
83
|
+
h2+p{margin-top:-4px}
|
|
84
|
+
.x-xs{font-size:12px;line-height:1.45}
|
|
85
|
+
.x-sm{font-size:14px;line-height:1.5}
|
|
86
|
+
.x-md{font-size:16px;line-height:1.55}
|
|
87
|
+
.x-lg{font-size:20px;line-height:1.35}
|
|
88
|
+
.x-xl{font-size:32px;line-height:1.1}
|
|
89
|
+
.x-display{font-size:54px;line-height:1;letter-spacing:-.035em;font-weight:800}
|
|
90
|
+
.mob .x-xl{font-size:27px}
|
|
91
|
+
.mob .x-display{font-size:40px}
|
|
92
|
+
a{color:inherit;text-underline-offset:3px}
|
|
93
|
+
.l{display:flex;flex-direction:column;gap:6px;font-size:13px;font-weight:600;letter-spacing:.01em;text-align:left}
|
|
94
|
+
.i{font:inherit;font-size:16px;font-weight:400;letter-spacing:0;width:100%;height:52px;padding:0 18px;border:1.5px solid var(--ln);border-radius:var(--ir);background:var(--fl);color:var(--ft);caret-color:var(--a);outline:none}
|
|
95
|
+
.ph{display:flex;gap:8px}.ph .cc{flex:0 0 auto;width:auto;padding:0 10px}
|
|
96
|
+
.i::placeholder{color:color-mix(in srgb,var(--ft) 72%,var(--fl))}
|
|
97
|
+
::selection{background:color-mix(in srgb,var(--a) 30%,var(--b))}
|
|
98
|
+
.i:focus{border-color:var(--a);box-shadow:0 0 0 4px color-mix(in srgb,var(--a) 22%,transparent)}
|
|
99
|
+
.i[aria-invalid=true]{border-color:#d93025;box-shadow:0 0 0 4px rgb(217 48 37/.16)}
|
|
100
|
+
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;font:inherit;font-size:18.67px;font-weight:700;letter-spacing:.01em;line-height:1.2;min-height:52px;padding:14px 24px;border:1.5px solid var(--a);border-radius:var(--br);background:var(--a);color:var(--oa);cursor:pointer;text-decoration:none;-webkit-tap-highlight-color:transparent;transition:transform .15s,filter .2s}
|
|
101
|
+
.btn:hover{filter:brightness(1.08)}
|
|
102
|
+
.btn:active{transform:scale(.98)}
|
|
103
|
+
.bo .btn{background:transparent;color:var(--a)}
|
|
104
|
+
.btn[disabled]{opacity:.55;cursor:progress;filter:none;transform:none}
|
|
105
|
+
.btn.no{align-self:center;min-height:44px;padding:6px 10px;border:0;background:none;color:var(--mu);font-size:14px;font-weight:500;text-decoration:underline;text-decoration-color:color-mix(in srgb,var(--t) 30%,transparent);text-underline-offset:4px;filter:none}
|
|
106
|
+
.btn.no:hover{color:var(--t)}
|
|
107
|
+
:focus-visible,.ch input:focus-visible+span{outline:2px solid var(--a);outline-offset:3px}
|
|
108
|
+
.x{position:absolute;top:12px;right:12px;z-index:2;display:grid;place-items:center;width:44px;height:44px;padding:0;border:0;border-radius:50%;background:var(--b);box-shadow:0 0 0 1px var(--ln);color:var(--t);cursor:pointer}
|
|
109
|
+
.x::before,.x::after{content:"";grid-area:1/1;width:16px;height:2px;border-radius:2px;background:currentColor;transform:rotate(45deg)}
|
|
110
|
+
.x::after{transform:rotate(-45deg)}
|
|
111
|
+
.c{display:flex;gap:10px;align-items:flex-start;font-size:13px;line-height:1.45;color:var(--mu);text-align:left;cursor:pointer}
|
|
112
|
+
.c input{appearance:none;flex:none;display:grid;place-content:center;width:20px;height:20px;margin:0;border:1.5px solid color-mix(in srgb,var(--t) 50%,var(--b));border-radius:min(var(--r),5px);background:var(--b);cursor:pointer}
|
|
113
|
+
.c input:checked{background:var(--a);border-color:var(--a)}
|
|
114
|
+
.c input:checked::after{content:"";width:10px;height:5px;border:solid var(--ot);border-width:0 0 2px 2px;transform:translateY(-1px) rotate(-45deg)}
|
|
115
|
+
.sm{font-size:12px;line-height:1.5;color:var(--mu)}
|
|
116
|
+
fieldset{display:flex;flex-wrap:wrap;gap:8px;min-width:0;margin:0;padding:0;border:0}
|
|
117
|
+
legend{padding:0;margin-bottom:8px;font-size:13px;font-weight:600}
|
|
118
|
+
.ta-center fieldset{justify-content:center}
|
|
119
|
+
.ch{position:relative;display:inline-flex}
|
|
120
|
+
.ch input{position:absolute;inset:0;margin:0;opacity:0}
|
|
121
|
+
.ch span{display:inline-flex;align-items:center;min-height:44px;padding:10px 18px;border:1.5px solid var(--ln);border-radius:999px;font-size:15px}
|
|
122
|
+
.ch input:checked+span{background:var(--a);border-color:var(--a);color:var(--ot)}
|
|
123
|
+
.ls{flex-direction:column}.ls .c{font-size:15px;color:var(--t)}.ls [type=radio]{border-radius:50%}
|
|
124
|
+
.ph .u{align-self:center;color:var(--mu)}
|
|
125
|
+
.bd{flex-wrap:wrap}.bd .i{min-width:0;padding:0 12px}.bd [name=hp_d]{flex:0 0 5.5em}.bd [name=hp_m]{flex:1 1 8.5em}.bd [name=hp_y]{flex:1 1 8em}
|
|
126
|
+
.e.won{color:var(--t);font-size:18px;font-weight:700;text-align:center}
|
|
127
|
+
.e{color:#d93025;font-size:13px;font-weight:500}
|
|
128
|
+
.e:empty{display:none}
|
|
129
|
+
.k{display:flex;flex-direction:column;align-items:stretch;gap:10px}
|
|
130
|
+
.k:empty{display:none}
|
|
131
|
+
.k output{display:block;padding:16px;border:1.5px dashed var(--a);border-radius:min(var(--r),16px);background:color-mix(in srgb,var(--a) 8%,var(--b));font:700 22px/1.2 Menlo,monospace;letter-spacing:.12em;text-align:center}
|
|
132
|
+
.btn.cp,.btn.b2{background:transparent;border-color:var(--ln);color:var(--t)}
|
|
133
|
+
.pg{display:flex;flex-direction:column;gap:8px}
|
|
134
|
+
.pg p{font-weight:600}
|
|
135
|
+
.tr{height:6px;border-radius:99px;background:color-mix(in srgb,var(--t) 18%,var(--b));overflow:hidden}
|
|
136
|
+
.tr i{display:block;height:100%;border-radius:inherit;background:var(--a);transform:scaleX(0);transform-origin:left;transition:transform .6s ${EASE}}
|
|
137
|
+
.tm{font-size:40px;line-height:1;font-weight:800;letter-spacing:.02em;font-variant-numeric:tabular-nums;text-align:center}
|
|
138
|
+
.bi{display:block;width:100%;height:auto}
|
|
139
|
+
.bl{display:block;border-radius:var(--br)}
|
|
140
|
+
hr{width:100%;margin:4px 0;border:0;border-top:1px solid var(--ln)}
|
|
141
|
+
.wh{width:min(100%,320px);align-self:center}
|
|
142
|
+
.wh svg{display:block;width:100%;height:auto;filter:drop-shadow(0 16px 24px rgb(0 0 0/.2))}
|
|
143
|
+
.hd{display:none}
|
|
144
|
+
.uc h2{text-transform:uppercase;font-weight:800;letter-spacing:-.01em;line-height:1.02}
|
|
145
|
+
.uc .btn:not(.no),.uc .tb{text-transform:uppercase;letter-spacing:.06em}
|
|
146
|
+
.sheet,.bar,.teaser{position:fixed;inset:auto;z-index:2147483646;display:flex;flex-direction:column}
|
|
147
|
+
.sheet{right:24px;bottom:24px;width:min(400px,calc(100% - 32px));max-height:calc(100% - 48px)}
|
|
148
|
+
.sheet.p-bottom-left{right:auto;left:24px}
|
|
149
|
+
.sheet.p-bottom{right:auto;left:50%;translate:-50% 0}
|
|
150
|
+
.sheet .w,.bar .w{width:100%}
|
|
151
|
+
.mob.sheet{left:0;right:0;bottom:0;translate:none;width:100%;max-height:92%}
|
|
152
|
+
.mob.sheet .w{border-radius:var(--r) var(--r) 0 0;box-shadow:0 -16px 48px -16px rgb(0 0 0/.35);animation-name:hp-up}
|
|
153
|
+
.mob.sheet .hd{display:block;position:absolute;top:0;left:50%;translate:-50% 0;z-index:3;width:96px;height:28px;cursor:grab;touch-action:none}
|
|
154
|
+
.mob.sheet .hd::before{content:"";display:block;width:40px;height:5px;margin:10px auto 0;border-radius:3px;background:color-mix(in srgb,var(--t) 25%,var(--b))}
|
|
155
|
+
.bar{left:0;right:0;bottom:0;width:100%}
|
|
156
|
+
.bar.p-top{bottom:auto;top:0}
|
|
157
|
+
.bar .w{flex-direction:row;border-radius:0;box-shadow:0 -10px 30px -14px rgb(0 0 0/.3);animation-name:hp-up}
|
|
158
|
+
.bar.p-top .w{animation-name:hp-down}
|
|
159
|
+
.bar .m{display:none}
|
|
160
|
+
.bar .f{flex-direction:row;flex-wrap:wrap;align-items:center;justify-content:center;gap:10px 16px;width:min(100%,1200px);margin:auto;padding:14px 68px 14px 24px}
|
|
161
|
+
.bar h2{font-size:18px;padding:0!important}
|
|
162
|
+
.bar .f>p{font-size:15px}
|
|
163
|
+
.bar .l{flex:0 1 300px}
|
|
164
|
+
.bar .lt{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
|
|
165
|
+
.bar .i,.bar .btn{height:46px;min-height:46px}
|
|
166
|
+
.bar .btn{padding:10px 22px}
|
|
167
|
+
.bar .c,.bar .sm,.bar .e{flex-basis:100%;justify-content:center;text-align:center}
|
|
168
|
+
.bar .pg{flex:0 1 520px;text-align:center}
|
|
169
|
+
.bar .k{flex-direction:row;align-items:center}
|
|
170
|
+
.bar .k output{padding:9px 16px;font-size:16px;border-radius:999px}
|
|
171
|
+
.bar .k .btn{height:44px;min-height:44px;padding:6px 16px;font-size:14px}
|
|
172
|
+
.bar .x{top:50%;right:12px;translate:0 -50%}
|
|
173
|
+
.mob.bar .f{justify-content:flex-start;padding:14px 60px 16px 16px}
|
|
174
|
+
.mob.bar h2,.mob.bar .f>p{flex-basis:100%}
|
|
175
|
+
.mob.bar .l{flex:1 1 160px;min-width:160px}
|
|
176
|
+
.mob.bar .f>.btn:not(.no){flex:1 0 auto}
|
|
177
|
+
.mob.bar .x{top:6px;translate:none}
|
|
178
|
+
.teaser{left:20px;bottom:20px}
|
|
179
|
+
.teaser.p-bottom-right{left:auto;right:20px}
|
|
180
|
+
.teaser.p-left,.teaser.p-right{bottom:auto;top:50%;translate:0 -50%}
|
|
181
|
+
.teaser.p-left{left:0}
|
|
182
|
+
.teaser.p-right{left:auto;right:0}
|
|
183
|
+
.teaser .w{width:auto;overflow:visible;background:none;box-shadow:none;border-radius:0;animation-name:hp-fade}
|
|
184
|
+
.teaser .f{padding:0}
|
|
185
|
+
.teaser .m{overflow:visible}
|
|
186
|
+
.teaser .io img{width:auto;max-width:220px;max-height:220px;border-radius:var(--r);box-shadow:0 16px 40px -12px rgb(0 0 0/.45)}
|
|
187
|
+
.tb{display:inline-flex;align-items:center;gap:10px;padding:15px 24px;border:0;border-radius:var(--br);background:var(--a);color:var(--ot);font:inherit;font-size:15px;font-weight:650;letter-spacing:.01em;cursor:pointer;box-shadow:0 14px 34px -10px rgb(0 0 0/.45)}
|
|
188
|
+
.b-rounded .tb{border-radius:999px}
|
|
189
|
+
.p-left .tb,.p-right .tb{writing-mode:vertical-rl;padding:22px 13px;border-radius:var(--br) 0 0 var(--br)}
|
|
190
|
+
.p-left .tb{transform:rotate(180deg)}
|
|
191
|
+
.b-pill.teaser .tb{border-radius:999px}
|
|
192
|
+
.teaser .x{top:-20px;right:-20px;border:8px solid transparent;background:var(--t) padding-box;color:var(--b)}
|
|
193
|
+
.teaser.p-right .x{right:auto;left:-20px}
|
|
194
|
+
.inline{position:static;display:block;height:100%}
|
|
195
|
+
.inline .w{width:100%;height:100%;box-shadow:none;border:1px solid var(--ln);animation:none}
|
|
196
|
+
.inline .t-stack .m{max-height:none}
|
|
197
|
+
.out .w{animation:hp-out .2s ease-in both}
|
|
198
|
+
.out .ov{animation:hp-fade .2s ease-in reverse both}
|
|
199
|
+
.f>*{animation:hp-rise .5s ${EASE}}
|
|
200
|
+
.pv .w,.pv .ov,.pv .f>*,.inline .f>*{animation:none}
|
|
201
|
+
@keyframes hp-pop{from{opacity:0;transform:translateY(24px) scale(.96)}}
|
|
202
|
+
@keyframes hp-fade{from{opacity:0}}
|
|
203
|
+
@keyframes hp-rise{from{opacity:0;transform:translateY(10px)}}
|
|
204
|
+
@keyframes hp-up{from{transform:translateY(100%)}}
|
|
205
|
+
@keyframes hp-down{from{transform:translateY(-100%)}}
|
|
206
|
+
@keyframes hp-out{to{opacity:0;transform:translateY(12px) scale(.98)}}
|
|
207
|
+
@media (prefers-reduced-motion:reduce){.s .w,.s .ov,.f>*{animation:hp-fade .15s ease both!important}.btn{transition:none}}
|
|
208
|
+
`;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a form's trigger fires. Exit intent follows ouibounce and use-exit-intent (both MIT; the
|
|
3
|
+
* thresholds are ours, docs/design/onsite-capture-research.md §7):
|
|
4
|
+
*
|
|
5
|
+
* - desktop: armed `delay_ms` (5 s) after the start and once the pointer has been below the top
|
|
6
|
+
* tenth of the viewport; fires when it leaves the document through the top edge, moving up, with
|
|
7
|
+
* no field focused.
|
|
8
|
+
* - mobile: after 30% of the page has been scrolled, an upward flick of 120px or more at 0.5 px/ms
|
|
9
|
+
* or faster within 300 ms (reaching for the address bar or back), or a return to the tab after
|
|
10
|
+
* 10 s or more away. Never on hide.
|
|
11
|
+
*
|
|
12
|
+
* Every watcher returns what stops it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const EXIT_ARM_MS = 5000;
|
|
16
|
+
const FLICK_PX = 120;
|
|
17
|
+
const FLICK_PX_PER_MS = 0.5;
|
|
18
|
+
const FLICK_WINDOW_MS = 300;
|
|
19
|
+
const EXIT_DEPTH = 0.3;
|
|
20
|
+
const AWAY_MS = 10_000;
|
|
21
|
+
const INPUTS = [
|
|
22
|
+
'mousemove',
|
|
23
|
+
'pointerdown',
|
|
24
|
+
'keydown',
|
|
25
|
+
'scroll',
|
|
26
|
+
'touchstart',
|
|
27
|
+
'wheel',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function listeners() {
|
|
31
|
+
const off = [];
|
|
32
|
+
const on = (target, type, handler, options = { passive: true }) => {
|
|
33
|
+
target.addEventListener(type, handler, options);
|
|
34
|
+
off.push(() => target.removeEventListener(type, handler, options));
|
|
35
|
+
};
|
|
36
|
+
const later = (fn, ms) => {
|
|
37
|
+
const id = setTimeout(fn, ms);
|
|
38
|
+
off.push(() => clearTimeout(id));
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
on,
|
|
42
|
+
later,
|
|
43
|
+
stop: () => {
|
|
44
|
+
for (const f of off.splice(0)) f();
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const depthOf = (win, doc) => {
|
|
50
|
+
const height = doc.documentElement.scrollHeight || 0;
|
|
51
|
+
return height > 0 ? (win.scrollY + win.innerHeight) / height : 0;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const typing = (doc) => {
|
|
55
|
+
const el = doc.activeElement;
|
|
56
|
+
return Boolean(
|
|
57
|
+
el &&
|
|
58
|
+
(/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable),
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function desktopExit({ on, later }, fire, trigger, win, doc) {
|
|
63
|
+
let armed = false;
|
|
64
|
+
let below = false;
|
|
65
|
+
let lastY = null;
|
|
66
|
+
let dy = 0;
|
|
67
|
+
later(() => {
|
|
68
|
+
armed = true;
|
|
69
|
+
}, trigger.delay_ms ?? EXIT_ARM_MS);
|
|
70
|
+
on(doc, 'mousemove', (e) => {
|
|
71
|
+
if (e.clientY > win.innerHeight * 0.1) below = true;
|
|
72
|
+
if (lastY !== null) dy = e.clientY - lastY;
|
|
73
|
+
lastY = e.clientY;
|
|
74
|
+
});
|
|
75
|
+
on(doc, 'mouseout', (e) => {
|
|
76
|
+
if (
|
|
77
|
+
armed &&
|
|
78
|
+
below &&
|
|
79
|
+
!e.relatedTarget &&
|
|
80
|
+
e.clientY <= 0 &&
|
|
81
|
+
dy < 0 &&
|
|
82
|
+
!typing(doc)
|
|
83
|
+
)
|
|
84
|
+
fire('exit');
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function mobileExit({ on, later }, fire, trigger, win, doc) {
|
|
89
|
+
let armed = false;
|
|
90
|
+
let reached = 0;
|
|
91
|
+
let lastY = win.scrollY;
|
|
92
|
+
let lastT = Date.now();
|
|
93
|
+
let run = null;
|
|
94
|
+
let hiddenAt = null;
|
|
95
|
+
later(() => {
|
|
96
|
+
armed = true;
|
|
97
|
+
}, trigger.delay_ms ?? EXIT_ARM_MS);
|
|
98
|
+
on(win, 'scroll', () => {
|
|
99
|
+
const y = win.scrollY;
|
|
100
|
+
const t = Date.now();
|
|
101
|
+
reached = Math.max(reached, depthOf(win, doc));
|
|
102
|
+
if (y < lastY) {
|
|
103
|
+
if (!run || t - run.t > FLICK_WINDOW_MS) run = { y: lastY, t: lastT };
|
|
104
|
+
const dist = run.y - y;
|
|
105
|
+
if (
|
|
106
|
+
armed &&
|
|
107
|
+
reached >= EXIT_DEPTH &&
|
|
108
|
+
dist >= FLICK_PX &&
|
|
109
|
+
dist / Math.max(1, t - run.t) >= FLICK_PX_PER_MS
|
|
110
|
+
)
|
|
111
|
+
fire('exit_scroll');
|
|
112
|
+
} else run = null;
|
|
113
|
+
lastY = y;
|
|
114
|
+
lastT = t;
|
|
115
|
+
});
|
|
116
|
+
on(doc, 'visibilitychange', () => {
|
|
117
|
+
if (doc.visibilityState === 'hidden') hiddenAt = Date.now();
|
|
118
|
+
else if (hiddenAt !== null && Date.now() - hiddenAt >= AWAY_MS)
|
|
119
|
+
fire('return');
|
|
120
|
+
if (doc.visibilityState !== 'hidden') hiddenAt = null;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Watch `trigger` and call `fire(reason)` when it fires. Inline and manual forms are opened by the
|
|
126
|
+
* runtime itself, so they watch nothing.
|
|
127
|
+
*
|
|
128
|
+
* @param {{ type: string, delay_ms?: number, depth?: number, seconds?: number }} trigger
|
|
129
|
+
* @param {(reason: string) => void} fire
|
|
130
|
+
* @param {{ device: 'desktop'|'mobile', win?: Window, doc?: Document }} env
|
|
131
|
+
*/
|
|
132
|
+
export function watch(trigger, fire, { device, win = window, doc = document }) {
|
|
133
|
+
const l = listeners();
|
|
134
|
+
const seconds = (trigger.seconds ?? 30) * 1000;
|
|
135
|
+
if (trigger.type === 'entry')
|
|
136
|
+
l.later(() => fire('entry'), trigger.delay_ms ?? 0);
|
|
137
|
+
if (trigger.type === 'time') l.later(() => fire('time'), seconds);
|
|
138
|
+
if (trigger.type === 'exit')
|
|
139
|
+
(device === 'mobile' ? mobileExit : desktopExit)(
|
|
140
|
+
l,
|
|
141
|
+
fire,
|
|
142
|
+
trigger,
|
|
143
|
+
win,
|
|
144
|
+
doc,
|
|
145
|
+
);
|
|
146
|
+
if (trigger.type === 'scroll') {
|
|
147
|
+
const depth = trigger.depth ?? 0.5;
|
|
148
|
+
l.on(win, 'scroll', () => depthOf(win, doc) >= depth && fire('scroll'));
|
|
149
|
+
}
|
|
150
|
+
if (trigger.type === 'idle') {
|
|
151
|
+
let id = null;
|
|
152
|
+
const reset = () => {
|
|
153
|
+
clearTimeout(id);
|
|
154
|
+
id = setTimeout(() => fire('idle'), seconds);
|
|
155
|
+
};
|
|
156
|
+
reset();
|
|
157
|
+
for (const type of INPUTS) l.on(type === 'scroll' ? win : doc, type, reset);
|
|
158
|
+
l.on(doc, 'visibilitychange', reset);
|
|
159
|
+
return () => {
|
|
160
|
+
clearTimeout(id);
|
|
161
|
+
l.stop();
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return l.stop;
|
|
165
|
+
}
|