/** * @file Lightweight persistence + frequency-capping for exit-intent triggers. * Backed by localStorage (persistent) or sessionStorage (per-session), with a * safe in-memory fallback when Web Storage is unavailable (SSR, privacy mode). */ /** In-memory fallback so the library never throws when storage is blocked. */ const memoryStore = new Map(); /** * Resolve the requested Storage, degrading to an in-memory shim on failure. * @param {'local'|'session'} kind * @returns {Storage} */ function resolveStorage(kind) { try { const store = kind === 'session' ? window.sessionStorage : window.localStorage; const probe = '__oei_probe__'; store.setItem(probe, '1'); store.removeItem(probe); return store } catch { return /** @type {Storage} */ ({ getItem: (k) => (memoryStore.has(k) ? memoryStore.get(k) : null), setItem: (k, v) => void memoryStore.set(k, String(v)), removeItem: (k) => void memoryStore.delete(k), }) } } /** * Parse a cooldown value into milliseconds. * Accepts a number (ms) or a shorthand string: `500ms`, `30s`, `15m`, `24h`, `7d`, `2w`. * @param {number|string|undefined|null} value * @returns {number} Milliseconds (0 when unset/invalid). */ function parseDuration(value) { if (value == null) return 0 if (typeof value === 'number') return value > 0 ? value : 0 const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)?\s*$/i.exec(value); if (!match) return 0 const amount = parseFloat(match[1]); const unit = (match[2] || 'ms').toLowerCase(); const factors = { ms: 1, s: 1e3, m: 6e4, h: 36e5, d: 864e5, w: 6048e5 }; return Math.round(amount * factors[unit]) } /** * @typedef {Object} ExitState * @property {number} count Number of times the trigger has fired for this key. * @property {number} last Epoch ms of the most recent fire (0 if never). */ /** * Frequency-cap store for a single exit-intent instance. */ class ExitStore { /** * @param {Object} [options] * @param {string} [options.key='oei:default'] Storage key namespace. * @param {'local'|'session'} [options.scope='local'] Persistence scope. */ constructor({ key = 'oei:default', scope = 'local' } = {}) { this.key = key; this.storage = resolveStorage(scope); } /** @returns {ExitState} */ read() { try { const raw = this.storage.getItem(this.key); if (!raw) return { count: 0, last: 0 } const parsed = JSON.parse(raw); return { count: Number(parsed.count) || 0, last: Number(parsed.last) || 0, } } catch { return { count: 0, last: 0 } } } /** * Record a fire: increments count and stamps `last`. * @param {number} now Epoch ms (injectable for testing). * @returns {ExitState} */ record(now) { const state = this.read(); const next = { count: state.count + 1, last: now }; try { this.storage.setItem(this.key, JSON.stringify(next)); } catch { /* storage full / blocked — best effort only */ } return next } /** Clear all stored state for this key. */ reset() { try { this.storage.removeItem(this.key); } catch { /* no-op */ } } /** * Whether the trigger is allowed to fire right now. * @param {Object} rules * @param {number} [rules.maxDisplays=Infinity] Cap on total displays. * @param {number} [rules.cooldownMs=0] Minimum ms between displays. * @param {number} rules.now Epoch ms (injectable for testing). * @returns {boolean} */ canFire({ maxDisplays = Infinity, cooldownMs = 0, now }) { const { count, last } = this.read(); if (count >= maxDisplays) return false if (cooldownMs > 0 && count > 0 && now - last < cooldownMs) return false return true } } /** * @file Framework-agnostic exit-intent detector. Emits `exit` when a visitor * signals they are about to leave, with no UI of its own. Pair it with * {@link ExitModal} or any custom handler. */ /** * @typedef {Object} MobileOptions * @property {boolean} [inactivity=true] Fire after a period of no interaction. * @property {number} [inactivityMs=15000] Idle time before firing (ms). * @property {boolean} [scrollUp=true] Fire on a fast upward scroll near the top. * @property {boolean} [backButton=false] Trap the first Back press via history state. */ /** * @typedef {Object} ExitIntentOptions * @property {number} [threshold=20] Distance (px) from the top edge that counts as leaving. * @property {number} [delay=0] Grace period (ms) before firing; cancelled if the pointer returns. * @property {boolean|MobileOptions} [mobile=false] Enable touch-device heuristics. * @property {number} [maxDisplays=Infinity] Max times to fire per persisted scope. * @property {number|string} [cooldown=0] Minimum time between fires (`'7d'`, `'24h'`, ms). * @property {'local'|'session'} [scope='local'] Persistence scope for frequency caps. * @property {string} [storageKey='oei:default'] Storage namespace. * @property {boolean} [autoStart=true] Attach listeners immediately on construction. * @property {() => number} [now] Clock provider (injectable for testing). * @property {(detail: ExitDetail) => void} [onExit] Convenience callback for the `exit` event. */ /** * @typedef {Object} ExitDetail * @property {'desktop-top'|'mobile-inactivity'|'mobile-scroll'|'mobile-back'|'manual'} reason * @property {number} count Total fires including this one. */ /** Minimal typed event emitter. */ class Emitter { constructor() { /** @type {Map>} */ this._listeners = new Map(); } /** * @param {string} type * @param {Function} handler * @returns {() => void} Unsubscribe function. */ on(type, handler) { if (!this._listeners.has(type)) this._listeners.set(type, new Set()); this._listeners.get(type).add(handler); return () => this.off(type, handler) } /** * @param {string} type * @param {Function} handler */ off(type, handler) { this._listeners.get(type)?.delete(handler); } /** * @param {string} type * @param {*} [detail] */ emit(type, detail) { this._listeners.get(type)?.forEach((fn) => { try { fn(detail); } catch (err) { // eslint-disable-next-line no-console console.error(`[exit-intent] listener for "${type}" threw`, err); } }); } } const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'; /** * Detects exit intent across desktop and (optionally) touch devices. * * @example * const detector = new ExitIntentDetector({ cooldown: '1d', maxDisplays: 3 }) * const off = detector.on('exit', ({ reason }) => console.log('leaving:', reason)) */ class ExitIntentDetector extends Emitter { /** @param {ExitIntentOptions} [options] */ constructor(options = {}) { super(); /** @type {Required> & ExitIntentOptions} */ this.options = { threshold: 20, delay: 0, mobile: false, maxDisplays: Infinity, cooldown: 0, scope: 'local', storageKey: 'oei:default', autoStart: true, ...options, }; this._now = options.now || (() => Date.now()); this._cooldownMs = parseDuration(this.options.cooldown); this._store = new ExitStore({ key: this.options.storageKey, scope: this.options.scope }); this._active = false; this._pending = null; this._idleTimer = null; this._lastScrollY = 0; this._bound = null; if (options.onExit) this.on('exit', options.onExit); if (this.options.autoStart) this.start(); } /** @returns {MobileOptions|null} Normalized mobile config, or null if disabled. */ get _mobileConfig() { const m = this.options.mobile; if (!m) return null const base = { inactivity: true, inactivityMs: 15000, scrollUp: true, backButton: false }; return m === true ? base : { ...base, ...m } } /** @returns {boolean} True on touch/no-hover devices. */ get _isTouch() { if (!isBrowser) return false return ( ('ontouchstart' in window || navigator.maxTouchPoints > 0) && window.matchMedia?.('(hover: none)').matches !== false ) } /** Attach listeners. Idempotent. @returns {this} */ start() { if (!isBrowser || this._active) return this this._active = true; const b = { mouseout: (e) => this._onMouseOut(e), mousemove: () => this._cancelPending(), scroll: () => this._onScroll(), touch: () => this._resetIdle(), popstate: () => this._onPopState(), }; this._bound = b; const touch = this._isTouch; const mobile = this._mobileConfig; if (!touch) { document.addEventListener('mouseout', b.mouseout, { passive: true }); document.addEventListener('mousemove', b.mousemove, { passive: true }); } if (touch && mobile) { if (mobile.scrollUp) { this._lastScrollY = window.scrollY; window.addEventListener('scroll', b.scroll, { passive: true }); } if (mobile.inactivity) { window.addEventListener('touchstart', b.touch, { passive: true }); window.addEventListener('scroll', b.touch, { passive: true }); this._resetIdle(); } if (mobile.backButton) { try { history.pushState({ oei: true }, ''); window.addEventListener('popstate', b.popstate); } catch { /* history unavailable */ } } } return this } /** Detach all listeners and clear timers. Idempotent. @returns {this} */ stop() { if (!isBrowser || !this._active) return this this._active = false; const b = this._bound; if (b) { document.removeEventListener('mouseout', b.mouseout); document.removeEventListener('mousemove', b.mousemove); window.removeEventListener('scroll', b.scroll); window.removeEventListener('touchstart', b.touch); window.removeEventListener('scroll', b.touch); window.removeEventListener('popstate', b.popstate); } this._cancelPending(); if (this._idleTimer) clearTimeout(this._idleTimer); this._idleTimer = null; return this } /** Stop and remove all event subscribers. */ destroy() { this.stop(); this._listeners.clear(); } /** Clear persisted frequency-cap state (counts + cooldown). */ reset() { this._store.reset(); return this } /** * Force the exit flow, honoring frequency caps. * @param {ExitDetail['reason']} [reason='manual'] * @returns {boolean} Whether it actually fired. */ trigger(reason = 'manual') { return this._fire(reason) } /* ---- internal handlers ---- */ _onMouseOut(e) { // Only when leaving the window at the top, not moving into another element. if (e.clientY > this.options.threshold) return const to = e.relatedTarget || e.toElement; if (to) return if (this.options.delay > 0) { if (this._pending) return this._pending = setTimeout(() => { this._pending = null; this._fire('desktop-top'); }, this.options.delay); } else { this._fire('desktop-top'); } } _cancelPending() { if (this._pending) { clearTimeout(this._pending); this._pending = null; } } _onScroll() { const y = window.scrollY; const delta = y - this._lastScrollY; this._lastScrollY = y; // Fast upward fling while near the top of the page. if (delta < -40 && y < 200) this._fire('mobile-scroll'); } _resetIdle() { const mobile = this._mobileConfig; if (!mobile?.inactivity) return if (this._idleTimer) clearTimeout(this._idleTimer); this._idleTimer = setTimeout(() => this._fire('mobile-inactivity'), mobile.inactivityMs); } _onPopState() { this._fire('mobile-back'); } /** * @param {ExitDetail['reason']} reason * @returns {boolean} */ _fire(reason) { const now = this._now(); if ( !this._store.canFire({ maxDisplays: this.options.maxDisplays, cooldownMs: this._cooldownMs, now, }) ) { return false } const { count } = this._store.record(now); if (count >= this.options.maxDisplays) this.stop(); this.emit('exit', { reason, count }); return true } } /** * @file Accessible, Tailwind-styled modal used as the default exit-intent UI. * * Every element carries BOTH Tailwind utility classes (so apps with Tailwind get * styling for free) and a semantic `oei-*` hook class (targeted by the shipped * fallback CSS, so it also looks right without Tailwind). Import the fallback via * `@oblique-code/exit-intent/styles.css` when your app has no Tailwind build. */ const FOCUSABLE = 'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])'; /** * Tailwind utility strings per element. Mirror these in `src/styles.css` so the * two rendering paths stay visually identical. */ const TW = { overlay: 'oei-overlay fixed inset-0 z-[2147483000] flex items-center justify-center bg-slate-950/70 p-4 opacity-0 backdrop-blur-sm transition-opacity duration-200 ease-out', overlayOpen: 'oei-overlay--open opacity-100', dialog: 'oei-dialog relative w-full max-w-md scale-95 rounded-2xl bg-white p-6 text-slate-900 shadow-2xl transition-transform duration-200 ease-out dark:bg-slate-900 dark:text-slate-100 sm:p-8', dialogOpen: 'oei-dialog--open scale-100', // p-0/border-0/bg-transparent are explicit resets: host pages commonly define // a generic `button { padding/border/background }` rule, and since a plain // type selector still wins any property this class doesn't itself declare, // omitting these let a 36px icon button collapse to 0 width under such a rule. close: 'oei-close absolute right-3 top-3 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full border-0 bg-transparent p-0 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white', title: 'oei-title pr-8 text-xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-2xl', body: 'oei-body mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-300', actions: 'oei-actions mt-6 flex flex-col gap-2 sm:flex-row-reverse', // Color-free CTA base; the accent colors come from THEMES below. cta: 'oei-cta inline-flex cursor-pointer items-center justify-center rounded-xl border-0 px-5 py-2.5 text-sm font-medium no-underline transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900', dismiss: 'oei-dismiss inline-flex cursor-pointer items-center justify-center rounded-xl border-0 bg-transparent px-5 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white', }; /** * Accent themes for the primary CTA. Static strings so Tailwind's scanner can * pick them up; each also carries an `oei-cta--*` hook for the fallback CSS. * All pairs meet WCAG AA contrast (≥ 4.5:1) in default and hover states. * @type {Record<'slate'|'indigo'|'emerald'|'rose'|'amber', string>} */ const THEMES = { slate: 'oei-cta--slate bg-slate-900 text-white hover:bg-slate-700 focus-visible:ring-slate-500 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200', indigo: 'oei-cta--indigo bg-indigo-600 text-white hover:bg-indigo-700 focus-visible:ring-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:hover:text-indigo-950', emerald: 'oei-cta--emerald bg-emerald-700 text-white hover:bg-emerald-800 focus-visible:ring-emerald-500 dark:bg-emerald-500 dark:text-emerald-950 dark:hover:bg-emerald-400', rose: 'oei-cta--rose bg-rose-600 text-white hover:bg-rose-700 focus-visible:ring-rose-500 dark:bg-rose-500 dark:hover:bg-rose-400 dark:hover:text-rose-950', amber: 'oei-cta--amber bg-amber-400 text-amber-950 hover:bg-amber-300 focus-visible:ring-amber-500', }; /** * @typedef {Object} CtaConfig * @property {string} label Button text. * @property {string} [href] If set, renders an anchor; otherwise a button. * @property {string} [target] Anchor target (e.g. `_blank`). * @property {() => (void|boolean)} [onClick] Click handler (also runs for anchors). * Return `false` to keep the modal open (e.g. failed validation). */ /** * @typedef {Object} ExitModalOptions * @property {string} [title='Wait — before you go'] Heading text. * @property {string} [html] Trusted HTML for the body. Takes precedence over `text`. * @property {string} [text] Plain-text body (safely escaped). * @property {'slate'|'indigo'|'emerald'|'rose'|'amber'} [theme='slate'] CTA accent theme. * @property {CtaConfig|null} [cta] Primary call-to-action. * @property {string|null} [dismissLabel='No thanks'] Secondary dismiss button; null to hide. * @property {boolean} [closeButton=true] Show the top-right ✕ button. * @property {boolean} [closeOnBackdrop=true] Close when the backdrop is clicked. * @property {boolean} [closeOnEsc=true] Close on the Escape key. * @property {boolean} [lockScroll=true] Prevent background scroll while open. * @property {HTMLElement} [container=document.body] Where the modal is mounted. * @property {(dialog: HTMLElement) => void} [onRender] Called once after the DOM is * built — bind events to custom `html` content here. * @property {(reason: 'cta'|'dismiss'|'backdrop'|'esc'|'close'|'programmatic') => void} [onClose] * @property {() => void} [onOpen] */ /** Escape a string for safe insertion as text content. */ function escapeHtml(str) { return String(str).replace( /[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c], ) } let uid = 0; /** * A self-contained, accessible modal dialog. * * @example * const modal = new ExitModal({ title: 'Leaving already?', text: 'Take 10% off.' }) * modal.open() */ class ExitModal { /** @param {ExitModalOptions} [options] */ constructor(options = {}) { /** @type {ExitModalOptions} */ this.options = { title: 'Wait — before you go', theme: 'slate', dismissLabel: 'No thanks', closeButton: true, closeOnBackdrop: true, closeOnEsc: true, lockScroll: true, ...options, }; this.isOpen = false; this._id = `oei-modal-${++uid}`; this._overlay = null; this._dialog = null; this._lastActive = null; this._prevOverflow = ''; this._onKeydown = (e) => this._handleKeydown(e); } /** Build the DOM (once) and return the overlay element. @returns {HTMLElement} */ render() { if (this._overlay) return this._overlay const o = this.options; const doc = document; const overlay = doc.createElement('div'); overlay.className = TW.overlay; overlay.setAttribute('role', 'presentation'); const dialog = doc.createElement('div'); dialog.className = TW.dialog; dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true'); dialog.id = this._id; // Title let titleEl = null; if (o.title) { titleEl = doc.createElement('h2'); titleEl.className = TW.title; titleEl.id = `${this._id}-title`; titleEl.textContent = o.title; dialog.setAttribute('aria-labelledby', titleEl.id); } // Body let bodyEl = null; if (o.html || o.text) { bodyEl = doc.createElement('div'); bodyEl.className = TW.body; bodyEl.id = `${this._id}-body`; if (o.html) bodyEl.innerHTML = o.html; else bodyEl.textContent = o.text; dialog.setAttribute('aria-describedby', bodyEl.id); } // Close (✕) button let closeEl = null; if (o.closeButton) { closeEl = doc.createElement('button'); closeEl.type = 'button'; closeEl.className = TW.close; closeEl.setAttribute('aria-label', 'Close dialog'); closeEl.innerHTML = ''; closeEl.addEventListener('click', () => this.close('close')); } // Actions let actionsEl = null; if (o.cta || o.dismissLabel) { actionsEl = doc.createElement('div'); actionsEl.className = TW.actions; if (o.cta) { const cta = o.cta.href ? doc.createElement('a') : doc.createElement('button'); cta.className = `${TW.cta} ${THEMES[o.theme] || THEMES.slate}`; cta.textContent = o.cta.label; if (o.cta.href) { cta.href = o.cta.href; if (o.cta.target) cta.target = o.cta.target; } else { cta.type = 'button'; } cta.addEventListener('click', (e) => { if (o.cta.onClick?.() === false) { // Handler vetoed (e.g. validation failed) — keep the modal open. if (o.cta.href) e.preventDefault(); return } this.close('cta'); }); actionsEl.appendChild(cta); } if (o.dismissLabel) { const dismiss = doc.createElement('button'); dismiss.type = 'button'; dismiss.className = TW.dismiss; dismiss.textContent = o.dismissLabel; dismiss.addEventListener('click', () => this.close('dismiss')); actionsEl.appendChild(dismiss); } } [closeEl, titleEl, bodyEl, actionsEl].forEach((el) => el && dialog.appendChild(el)); overlay.appendChild(dialog); if (o.closeOnBackdrop) { overlay.addEventListener('click', (e) => { if (e.target === overlay) this.close('backdrop'); }); } this._overlay = overlay; this._dialog = dialog; o.onRender?.(dialog); return overlay } /** Mount + reveal the modal. @returns {this} */ open() { if (this.isOpen) return this const overlay = this.render(); const container = this.options.container || document.body; this._lastActive = document.activeElement; container.appendChild(overlay); if (this.options.lockScroll) { this._prevOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; } document.addEventListener('keydown', this._onKeydown, true); // Next frame → trigger the transition, then move focus in. const reveal = () => { overlay.classList.add(...TW.overlayOpen.split(' ')); this._dialog.classList.add(...TW.dialogOpen.split(' ')); this._focusFirst(); }; if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reveal); else reveal(); this.isOpen = true; this.options.onOpen?.(); return this } /** * Hide + unmount the modal, restoring focus and scroll. * @param {'cta'|'dismiss'|'backdrop'|'esc'|'close'|'programmatic'} [reason='programmatic'] * @returns {this} */ close(reason = 'programmatic') { if (!this.isOpen) return this this.isOpen = false; document.removeEventListener('keydown', this._onKeydown, true); const overlay = this._overlay; overlay.classList.remove(...TW.overlayOpen.split(' ')); this._dialog.classList.remove(...TW.dialogOpen.split(' ')); if (this.options.lockScroll) document.body.style.overflow = this._prevOverflow; const remove = () => overlay.parentNode && overlay.parentNode.removeChild(overlay); const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; if (reduced) remove(); else setTimeout(remove, 220); // Restore focus to the element that was focused before opening. if (this._lastActive && typeof this._lastActive.focus === 'function') { this._lastActive.focus(); } this.options.onClose?.(reason); return this } /** Remove the modal from the DOM immediately and drop references. */ destroy() { this.close('programmatic'); this._overlay = null; this._dialog = null; } /* ---- internal ---- */ _focusableEls() { return Array.from(this._dialog.querySelectorAll(FOCUSABLE)).filter((el) => { if (el.hasAttribute('disabled') || el.getAttribute('aria-hidden') === 'true') return false if (el.hidden) return false const style = typeof getComputedStyle === 'function' ? getComputedStyle(el) : null; return !style || (style.display !== 'none' && style.visibility !== 'hidden') }) } _focusFirst() { const els = this._focusableEls() ;(els[0] || this._dialog).focus?.(); if (!els.length) this._dialog.setAttribute('tabindex', '-1'); } _handleKeydown(e) { if (e.key === 'Escape' && this.options.closeOnEsc) { e.preventDefault(); this.close('esc'); return } if (e.key !== 'Tab') return // Focus trap. const els = this._focusableEls(); if (!els.length) { e.preventDefault(); return } const first = els[0]; const last = els[els.length - 1]; const active = document.activeElement; if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); } } } /** * @file Ready-made, production-styled modal configurations. * * Each preset returns a plain {@link ExitModalOptions} object, so you can use it * directly or spread-and-override any field: * * ```js * exitIntent({ modal: presets.discount({ code: 'SAVE15', discount: '15%' }) }) * new ExitModal({ ...presets.newsletter({ onSubmit }), title: 'Custom title' }) * ``` * * Like the modal itself, every element carries Tailwind utilities plus an * `oei-*` hook class covered by the fallback stylesheet. */ /** @typedef {import('./modal.js').ExitModalOptions} ExitModalOptions */ /** Static Tailwind strings for preset-only elements (mirrored in styles.css). */ const P = { eyebrow: 'oei-eyebrow mb-2 inline-flex items-center gap-1.5 rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-slate-700 dark:bg-slate-800 dark:text-slate-300', coupon: 'oei-coupon mt-4 flex select-all items-center justify-center rounded-xl border-2 border-dashed border-slate-300 bg-slate-50 px-4 py-3 font-mono text-lg font-semibold tracking-[0.2em] text-slate-900 dark:border-slate-600 dark:bg-slate-800 dark:text-white', couponHint: 'oei-coupon-hint mt-2 text-center text-xs text-slate-500 dark:text-slate-400', field: 'oei-field mt-4', input: 'oei-input w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-900 placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white dark:placeholder:text-slate-400', inputError: 'oei-input--error border-rose-500 focus:ring-rose-500 focus:border-rose-500', errorMsg: 'oei-error mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400', }; let pid = 0; /** * @typedef {Object} DiscountPresetOptions * @property {string} [discount='10%'] Discount amount shown in the copy. * @property {string} [code='SAVE10'] Coupon code displayed in the dashed chip. * @property {string} [title] Heading override. * @property {string} [text] Lead sentence override. * @property {string} [ctaLabel] CTA label override. * @property {string} [href] Where the CTA navigates (optional). * @property {() => (void|boolean)} [onClaim] CTA click handler. * @property {ExitModalOptions['theme']} [theme='indigo'] */ /** * Discount / coupon-code modal with a click-to-copy code chip. * @param {DiscountPresetOptions} [opts] * @returns {ExitModalOptions} */ function discount(opts = {}) { const { discount: amount = '10%', code = 'SAVE10', title = `Wait — take ${amount} off`, text = 'Use this code at checkout before you go. It only takes a second.', ctaLabel = `Claim ${amount} off`, href, onClaim, theme = 'indigo', ...rest } = opts; const id = `oei-coupon-${++pid}`; return { title, theme, html: [ `

${escapeHtml(text)}

`, ``, `

Click the code to copy it

`, ].join(''), cta: { label: ctaLabel, href, onClick: onClaim }, onRender(dialog) { const chip = dialog.querySelector(`#${id}`); const hint = dialog.querySelector(`#${id}-hint`); chip?.addEventListener('click', async () => { try { await navigator.clipboard.writeText(code); if (hint) hint.textContent = 'Copied to clipboard ✓'; } catch { if (hint) hint.textContent = 'Select and copy the code above'; } }); }, ...rest, } } /** * @typedef {Object} NewsletterPresetOptions * @property {(email: string) => void} onSubmit Called with a valid email. * @property {string} [title] Heading override. * @property {string} [text] Lead sentence override. * @property {string} [placeholder='you@example.com'] * @property {string} [ctaLabel='Subscribe'] * @property {ExitModalOptions['theme']} [theme='emerald'] */ /** * Email-capture modal with inline validation (invalid email keeps it open). * @param {NewsletterPresetOptions} opts * @returns {ExitModalOptions} */ function newsletter(opts = {}) { const { onSubmit, title = 'Before you go…', text = 'Get our best tips in your inbox. No spam, unsubscribe anytime.', placeholder = 'you@example.com', ctaLabel = 'Subscribe', theme = 'emerald', ...rest } = opts; const id = `oei-email-${++pid}`; const submit = () => { const input = /** @type {HTMLInputElement|null} */ (document.getElementById(id)); const error = document.getElementById(`${id}-error`); const email = input?.value.trim() ?? ''; const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); if (!valid) { input?.classList.add(...P.inputError.split(' ')); input?.setAttribute('aria-invalid', 'true'); if (error) error.hidden = false; input?.focus(); return false // veto close } onSubmit?.(email); return true }; return { title, theme, html: [ `

${escapeHtml(text)}

`, `
`, ``, ``, ``, `
`, ].join(''), cta: { label: ctaLabel, onClick: submit }, onRender(dialog) { const input = dialog.querySelector(`#${id}`); // Enter submits; typing clears the error state. input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); dialog.querySelector('.oei-cta')?.click(); } }); input?.addEventListener('input', () => { input.classList.remove(...P.inputError.split(' ')); input.removeAttribute('aria-invalid'); const error = dialog.querySelector(`#${id}-error`); if (error) error.hidden = true; }); }, ...rest, } } /** * @typedef {Object} CartSaverPresetOptions * @property {string} [title] Heading override. * @property {string} [text] Lead sentence override. * @property {string} [ctaLabel='Return to cart'] * @property {string} [href] Cart URL for the CTA. * @property {() => (void|boolean)} [onResume] CTA click handler. * @property {string} [eyebrow='Your cart is waiting'] Small badge above the title; '' hides it. * @property {ExitModalOptions['theme']} [theme='rose'] */ /** * Cart-abandonment modal nudging the visitor back to checkout. * @param {CartSaverPresetOptions} [opts] * @returns {ExitModalOptions} */ function cartSaver(opts = {}) { const { title = 'Leaving your cart behind?', text = 'Your items are saved for now, but they’re not reserved — popular items sell out fast.', ctaLabel = 'Return to cart', href, onResume, eyebrow = 'Your cart is waiting', theme = 'rose', ...rest } = opts; return { title, theme, html: [ eyebrow ? `🛒 ${escapeHtml(eyebrow)}` : '', `

${escapeHtml(text)}

`, ].join(''), cta: { label: ctaLabel, href, onClick: onResume }, dismissLabel: 'Continue browsing', ...rest, } } /** All presets, keyed by name. */ const presets = { discount, newsletter, cartSaver }; /** * @file Public entry point for `@oblique-code/exit-intent`. */ /** * @typedef {import('./detector.js').ExitIntentOptions} ExitIntentOptions * @typedef {import('./modal.js').ExitModalOptions} ExitModalOptions */ /** * @typedef {ExitIntentOptions & { * modal?: ExitModalOptions | false, * onExit?: (detail: import('./detector.js').ExitDetail) => void * }} ExitIntentConfig */ /** * @typedef {Object} ExitIntentHandle * @property {ExitIntentDetector} detector The underlying detector. * @property {ExitModal|null} modal The modal instance (null when `modal: false`). * @property {() => ExitIntentHandle} start Re-arm the detector. * @property {() => ExitIntentHandle} stop Detach listeners. * @property {() => void} open Show the modal now (bypasses detection). * @property {() => void} close Hide the modal. * @property {() => void} reset Clear persisted frequency-cap state. * @property {() => void} destroy Tear everything down. */ /** * One-call setup: wires a detector to a Tailwind modal with sensible defaults. * * @param {ExitIntentConfig} [config] * @returns {ExitIntentHandle} * * @example * import { exitIntent } from '@oblique-code/exit-intent' * import '@oblique-code/exit-intent/styles.css' * * exitIntent({ * cooldown: '7d', * maxDisplays: 1, * modal: { * title: 'Wait — before you go', * text: 'Here is 10% off your first order.', * cta: { label: 'Claim 10% off', href: '/offer' }, * }, * }) */ function exitIntent(config = {}) { const { modal: modalConfig, onExit, ...detectorOptions } = config; const modal = modalConfig === false ? null : new ExitModal(modalConfig || {}); const detector = new ExitIntentDetector({ ...detectorOptions, onExit: (detail) => { if (modal) modal.open(); onExit?.(detail); }, }); /** @type {ExitIntentHandle} */ const handle = { detector, modal, start() { detector.start(); return handle }, stop() { detector.stop(); return handle }, open() { modal?.open(); }, close() { modal?.close(); }, reset() { detector.reset(); }, destroy() { detector.destroy(); modal?.destroy(); }, }; return handle } export { ExitIntentDetector, ExitModal, ExitStore, cartSaver, exitIntent as default, discount, exitIntent, newsletter, parseDuration, presets };