UNPKG

whatsapp-floating

Version:

Dependency-free floating WhatsApp contact button with geolocation-based phone routing, business-hour scheduling, UTM/path rules and analytics integrations. Built for CDN delivery (jsDelivr/unpkg).

1,053 lines (1,043 loc) 45.2 kB
"use strict"; var __defProp = Object.defineProperty; var __defProps = Object.defineProperties; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropDescs = Object.getOwnPropertyDescriptors; var __getOwnPropNames = Object.getOwnPropertyNames; var __getOwnPropSymbols = Object.getOwnPropertySymbols; var __hasOwnProp = Object.prototype.hasOwnProperty; var __propIsEnum = Object.prototype.propertyIsEnumerable; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __spreadValues = (a, b) => { for (var prop in b || (b = {})) if (__hasOwnProp.call(b, prop)) __defNormalProp(a, prop, b[prop]); if (__getOwnPropSymbols) for (var prop of __getOwnPropSymbols(b)) { if (__propIsEnum.call(b, prop)) __defNormalProp(a, prop, b[prop]); } return a; }; var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b)); var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var src_exports = {}; __export(src_exports, { boot: () => boot, default: () => src_default }); module.exports = __toCommonJS(src_exports); // src/utils.ts function isObject(val) { return val !== null && typeof val === "object" && !Array.isArray(val); } function merge(target, source) { const out = __spreadValues({}, target); const src = source; for (const key in src) { if (!Object.prototype.hasOwnProperty.call(src, key)) continue; const sourceVal = src[key]; const targetVal = out[key]; if (isObject(sourceVal) && isObject(targetVal)) { out[key] = merge(targetVal, sourceVal); } else { out[key] = sourceVal; } } return out; } function log(config, level, ...args) { if (!config || !config.debug) return; const prefix = "[WhatsAppFloating]"; if (level === "error") { console.error(prefix, ...args); } else if (level === "warn") { console.warn(prefix, ...args); } else { console.log(prefix, ...args); } } function safeJSONParse(str2, fallback) { try { return JSON.parse(str2); } catch (e) { return fallback; } } function getStorage(type) { try { const storage = window[type]; const testKey = "__wa_floating_test__"; storage.setItem(testKey, "1"); storage.removeItem(testKey); return storage; } catch (e) { return null; } } function getQueryParams() { const params = {}; const search = window.location.search.replace(/^\?/, ""); if (!search) return params; const pairs = search.split("&"); for (const pair of pairs) { const [rawKey, rawValue] = pair.split("="); const key = decodeURIComponent(rawKey || ""); const value = decodeURIComponent((rawValue || "").replace(/\+/g, " ")); if (key) params[key] = value; } return params; } function isMobile(config) { const byWidth = window.innerWidth <= (config.mobileBreakpoint || 768); const byUrl = /\/mobile/i.test(window.location.pathname) || /\/mobile/i.test(window.location.href); return byWidth || byUrl; } function getBrowserLanguage() { return (navigator.language || navigator.userLanguage || "en").toLowerCase(); } var CONTINENT_MAP = { AF: ["DZ", "AO", "BJ", "BW", "BF", "BI", "CM", "CV", "CF", "TD", "KM", "CG", "CD", "DJ", "EG", "GQ", "ER", "SZ", "ET", "GA", "GM", "GH", "GN", "GW", "CI", "KE", "LS", "LR", "LY", "MG", "MW", "ML", "MR", "MU", "MA", "MZ", "NA", "NE", "NG", "RW", "ST", "SN", "SC", "SL", "SO", "ZA", "SS", "SD", "TZ", "TG", "TN", "UG", "ZM", "ZW"], AS: ["AF", "AM", "AZ", "BH", "BD", "BT", "BN", "KH", "CN", "CY", "GE", "IN", "ID", "IR", "IQ", "IL", "JP", "JO", "KZ", "KW", "KG", "LA", "LB", "MY", "MV", "MN", "MM", "NP", "KP", "OM", "PK", "PH", "QA", "SA", "SG", "KR", "LK", "SY", "TW", "TJ", "TH", "TL", "TR", "TM", "AE", "UZ", "VN", "YE"], EU: ["AL", "AD", "AT", "BY", "BE", "BA", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IS", "IE", "IT", "XK", "LV", "LI", "LT", "LU", "MT", "MD", "MC", "ME", "NL", "MK", "NO", "PL", "PT", "RO", "RU", "SM", "RS", "SK", "SI", "ES", "SE", "CH", "UA", "GB", "VA"], NA: ["AG", "BS", "BB", "BZ", "CA", "CR", "CU", "DM", "DO", "SV", "GD", "GT", "HT", "HN", "JM", "MX", "NI", "PA", "KN", "LC", "VC", "TT", "US"], SA: ["AR", "BO", "BR", "CL", "CO", "EC", "GY", "PY", "PE", "SR", "UY", "VE"], OC: ["AU", "FJ", "KI", "MH", "FM", "NR", "NZ", "PW", "PG", "WS", "SB", "TO", "TV", "VU"], AN: ["AQ"] }; function getContinent(countryCode) { var _a; if (!countryCode) return null; for (const continent in CONTINENT_MAP) { if (((_a = CONTINENT_MAP[continent]) == null ? void 0 : _a.indexOf(countryCode)) !== -1) return continent; } return null; } function normalize(str2) { return String(str2 != null ? str2 : "").toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").trim(); } // src/geo.ts var GEO_CACHE_KEY = "__wa_floating_geo_cache__"; var GEO_CACHE_TTL = 1e3 * 60 * 60 * 6; var EMPTY_LOCATION = { country: null, state: null, city: null, ip: null }; function str(val) { return typeof val === "string" && val.length > 0 ? val : null; } var defaultParseIp = (data) => { var _a, _b; return (_b = (_a = str(data.ip)) != null ? _a : str(data.query)) != null ? _b : str(data.ip_address); }; var BUILT_IN_PROVIDERS = [ { name: "ipapi.co", url: "https://ipapi.co/json/", parse: (data) => { var _a, _b; return { country: (_a = str(data.country)) != null ? _a : str(data.country_code), state: (_b = str(data.region_code)) != null ? _b : str(data.region), city: str(data.city) }; } }, { name: "ipwho.is", url: "https://ipwho.is/", parse: (data) => { var _a, _b; return { country: (_a = str(data.country_code)) != null ? _a : str(data.country), state: (_b = str(data.region_code)) != null ? _b : str(data.region), city: str(data.city) }; } }, { name: "ip-api.com", // Note: free tier is HTTP-only; kept as a late fallback (see README). url: "http://ip-api.com/json/", parse: (data) => { var _a, _b; return { country: (_a = str(data.countryCode)) != null ? _a : str(data.country), state: (_b = str(data.region)) != null ? _b : str(data.regionName), city: str(data.city) }; } }, { name: "ipinfo.io", url: "https://ipinfo.io/json", parse: (data) => ({ country: str(data.country), state: str(data.region), city: str(data.city) }) }, { name: "ip.oxylabs.io", url: "https://ip.oxylabs.io/location", // Aggregates several sub-providers (maxmind, dbip, ip2location, ipinfo) // keyed under `providers`; none of them expose a state/region field, // only country + city. `maxmind` is preferred for completeness. parse: (data) => { var _a, _b, _c; const providers = data.providers; const preferred = providers && ((_c = (_b = (_a = providers.maxmind) != null ? _a : providers.dbip) != null ? _b : providers.ip2location) != null ? _c : providers.ipinfo); return { country: str(preferred == null ? void 0 : preferred.country), state: null, city: str(preferred == null ? void 0 : preferred.city) }; } } ]; function resolveProviders(config) { const providers = [...BUILT_IN_PROVIDERS]; if (config.geoApiUrl) { providers.unshift({ name: "custom:geoApiUrl", url: config.geoApiUrl, parse: (data) => { var _a, _b, _c, _d, _e; return { country: (_b = (_a = str(data.country)) != null ? _a : str(data.country_code)) != null ? _b : str(data.countryCode), state: (_e = (_d = (_c = str(data.region)) != null ? _c : str(data.region_code)) != null ? _d : str(data.regionName)) != null ? _e : str(data.state), city: str(data.city) }; } }); } if (config.geoProviders && config.geoProviders.length) { for (const url of config.geoProviders) { providers.push({ name: "custom:" + url, url, parse: (data) => { var _a, _b, _c, _d, _e; return { country: (_b = (_a = str(data.country)) != null ? _a : str(data.country_code)) != null ? _b : str(data.countryCode), state: (_e = (_d = (_c = str(data.region)) != null ? _c : str(data.region_code)) != null ? _d : str(data.regionName)) != null ? _e : str(data.state), city: str(data.city) }; } }); } } return providers; } function isUsable(location) { return !!(location.country || location.state || location.city); } function fetchWithTimeout(url, timeout) { if (typeof AbortController === "undefined") { return fetch(url); } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timer)); } async function tryProvider(provider, timeout, config) { var _a; try { const res = await fetchWithTimeout(provider.url, timeout); if (!res.ok) { log(config, "warn", `Geo provider ${provider.name} responded with status ${res.status}`); return null; } const data = await res.json(); const fields = provider.parse(data); if (!isUsable(fields)) { log(config, "warn", `Geo provider ${provider.name} returned no usable fields`); return null; } const ip = ((_a = provider.parseIp) != null ? _a : defaultParseIp)(data); const location = __spreadProps(__spreadValues({}, fields), { ip }); log(config, "info", `Geo provider ${provider.name} succeeded`, location); return location; } catch (err) { log(config, "warn", `Geo provider ${provider.name} failed`, err); return null; } } function readGeoCache(config) { const storage = getStorage("localStorage"); if (!storage) return null; const raw = storage.getItem(GEO_CACHE_KEY); if (!raw) return null; const parsed = safeJSONParse(raw, null); if (!parsed || !parsed.timestamp || !parsed.data) return null; const ttl = config.geoCacheTTL != null ? config.geoCacheTTL : GEO_CACHE_TTL; if (ttl > 0 && Date.now() - parsed.timestamp > ttl) return null; return parsed; } function writeGeoCache(data) { const storage = getStorage("localStorage"); if (!storage) return; storage.setItem(GEO_CACHE_KEY, JSON.stringify({ timestamp: Date.now(), data })); } async function fetchGeoLocation(config) { var _a, _b, _c, _d; if (config.location && (config.location.country || config.location.city || config.location.state)) { return { country: (_a = config.location.country) != null ? _a : null, state: (_c = (_b = config.location.state) != null ? _b : config.location.region) != null ? _c : null, city: (_d = config.location.city) != null ? _d : null, ip: null }; } const cacheEnabled = config.cacheLocation === true; const cached = cacheEnabled ? readGeoCache(config) : null; const providers = resolveProviders(config); const timeout = config.geoTimeout || 5e3; for (const provider of providers) { const location = await tryProvider(provider, timeout, config); if (location) { if (cached && cached.data.ip && location.ip && cached.data.ip === location.ip) { log(config, "info", "IP unchanged, reusing cached geolocation", cached.data); return cached.data; } if (cacheEnabled) writeGeoCache(location); return location; } } if (cached) { log(config, "warn", "All geolocation providers failed, falling back to stale cache", cached.data); return cached.data; } log(config, "error", "All geolocation providers failed"); return EMPTY_LOCATION; } // src/schedule.ts var WEEKDAY_NAMES = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; function parseTimeToMinutes(str2) { if (!str2 || typeof str2 !== "string") return null; const parts = str2.split(":"); if (parts.length < 2) return null; const h = parseInt(parts[0], 10); const m = parseInt(parts[1], 10); if (isNaN(h) || isNaN(m)) return null; return h * 60 + m; } function nowInTimezone(timezone) { if (!timezone) return /* @__PURE__ */ new Date(); try { const formatter = new Intl.DateTimeFormat("en-US", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }); const parts = formatter.formatToParts(/* @__PURE__ */ new Date()); const get = (type) => { var _a, _b; return (_b = (_a = parts.find((p) => p.type === type)) == null ? void 0 : _a.value) != null ? _b : "0"; }; return new Date( Number(get("year")), Number(get("month")) - 1, Number(get("day")), Number(get("hour")) === 24 ? 0 : Number(get("hour")), Number(get("minute")), Number(get("second")) ); } catch (e) { return /* @__PURE__ */ new Date(); } } function isWithinSchedule(schedule) { if (!schedule) return true; const now = nowInTimezone(schedule.timezone); const day = WEEKDAY_NAMES[now.getDay()]; if (schedule.days && schedule.days.length) { const days = schedule.days.map((d) => String(d).toLowerCase()); if (!day || days.indexOf(day) === -1) return false; } if (schedule.hours) { const current = now.getHours() * 60 + now.getMinutes(); const start = parseTimeToMinutes(schedule.hours.start); const end = parseTimeToMinutes(schedule.hours.end); if (start != null && end != null) { if (start <= end) { if (current < start || current > end) return false; } else { if (current < start && current > end) return false; } } } return true; } // src/rules.ts var roundRobinState = {}; function pickPhoneFromRule(rule, ruleKey) { var _a, _b, _c, _d, _e; if (rule.phone) return rule.phone; const numbers = rule.numbers; if (!numbers || !numbers.length) return null; const strategy = rule.distribution || "random"; if (strategy === "weighted") { let totalWeight = 0; for (const n of numbers) totalWeight += n.weight || 1; let rand = Math.random() * totalWeight; for (const n of numbers) { rand -= n.weight || 1; if (rand <= 0) return n.phone; } return (_b = (_a = numbers[numbers.length - 1]) == null ? void 0 : _a.phone) != null ? _b : null; } if (strategy === "roundrobin" || strategy === "round-robin" || strategy === "round_robin") { const key = "rule_" + ruleKey; const idx = roundRobinState[key] || 0; const entry = numbers[idx % numbers.length]; roundRobinState[key] = idx + 1; return (_c = entry == null ? void 0 : entry.phone) != null ? _c : null; } const randomIndex = Math.floor(Math.random() * numbers.length); return (_e = (_d = numbers[randomIndex]) == null ? void 0 : _d.phone) != null ? _e : null; } function matchesArrayField(ruleValue, actualValue) { if (ruleValue == null) return true; if (!actualValue) return false; const list = Array.isArray(ruleValue) ? ruleValue : [ruleValue]; return list.some((v) => normalize(v) === normalize(actualValue)); } function matchesUTM(rule, utmParams) { if (!rule.utm) return true; for (const key in rule.utm) { if (!Object.prototype.hasOwnProperty.call(rule.utm, key)) continue; const expected = rule.utm[key]; const actual = utmParams["utm_" + key] || utmParams[key]; if (normalize(expected) !== normalize(actual)) return false; } return true; } function matchesContext(rule, ctx) { if (rule.language && normalize(ctx.language).indexOf(normalize(rule.language)) !== 0) return false; if (rule.referrer && ctx.referrer.indexOf(rule.referrer) === -1) return false; if (rule.domain && ctx.domain.indexOf(rule.domain) === -1) return false; if (rule.page && ctx.page.indexOf(rule.page) === -1) return false; if (rule.continent && normalize(rule.continent) !== normalize(ctx.continent)) return false; if (rule.schedule && !isWithinSchedule(rule.schedule)) return false; if (rule.utm && !matchesUTM(rule, ctx.utm)) return false; return true; } function matchesPathRule(rule, ctx) { if (rule.path && ctx.page.indexOf(rule.path) === -1) return false; if (rule.pathRegex) { try { if (!new RegExp(rule.pathRegex).test(ctx.page)) return false; } catch (e) { return false; } } if (!rule.path && !rule.pathRegex) return false; if (rule.schedule && !isWithinSchedule(rule.schedule)) return false; if (rule.utm && !matchesUTM(rule, ctx.utm)) return false; return true; } function resolvePathRule(config, ctx) { const pathRules = config.pathRules || []; for (let i = 0; i < pathRules.length; i++) { const rule = pathRules[i]; if (!matchesPathRule(rule, ctx)) continue; const phone = pickPhoneFromRule(rule, "path_" + i); if (phone || rule.images) { return { phone, matched: "Path", rule, images: rule.images }; } } return null; } var LEVELS = [ { key: "city", label: "Cidade" }, { key: "state", label: "Estado" }, { key: "country", label: "Pa\xEDs" } ]; function ruleMatchesLevel(rule, level, location) { if (level.key === "city") { const cityField = rule.cities || rule.city; if (!cityField) return false; if (!matchesArrayField(cityField, location.city)) return false; if (rule.state && normalize(rule.state) !== normalize(location.state)) return false; if (rule.country && normalize(rule.country) !== normalize(location.country)) return false; } else if (level.key === "state") { if (!rule.state || rule.cities || rule.city) return false; if (normalize(rule.state) !== normalize(location.state)) return false; if (rule.country && normalize(rule.country) !== normalize(location.country)) return false; } else { if (!rule.country || rule.state || rule.cities || rule.city) return false; if (normalize(rule.country) !== normalize(location.country)) return false; } return true; } function resolvePhone(config, location, ctx) { const pathResult = resolvePathRule(config, ctx); if (pathResult && pathResult.phone) { return pathResult; } const imagesOverride = pathResult == null ? void 0 : pathResult.images; const rules = config.rules || []; for (const level of LEVELS) { const actualValue = location[level.key]; if (!actualValue) continue; for (let i = 0; i < rules.length; i++) { const rule = rules[i]; if (!matchesContext(rule, ctx)) continue; if (!ruleMatchesLevel(rule, level, location)) continue; const phone = pickPhoneFromRule(rule, i); if (phone) { return { phone, matched: level.label, rule, images: imagesOverride || rule.images }; } } } let fallbackPhone = null; if (config.fallback) { fallbackPhone = pickPhoneFromRule(config.fallback, "fallback"); } return { phone: fallbackPhone, matched: "Fallback", rule: config.fallback || null, images: imagesOverride }; } function resolveRuleOverride(config, location, ctx, field, topLevelValue) { var _a; const pathRules = config.pathRules || []; for (const rule of pathRules) { if (!matchesPathRule(rule, ctx)) continue; if (rule[field]) return rule[field]; } const rules = config.rules || []; for (const level of LEVELS) { const actualValue = location[level.key]; if (!actualValue) continue; for (const rule of rules) { if (!matchesContext(rule, ctx)) continue; if (!ruleMatchesLevel(rule, level, location)) continue; if (rule[field]) return rule[field]; } } if ((_a = config.fallback) == null ? void 0 : _a[field]) return config.fallback[field]; return topLevelValue || ""; } function resolveMessage(config, location, ctx) { return resolveRuleOverride(config, location, ctx, "message", config.message); } function resolvePillText(config, location, ctx) { var _a; return resolveRuleOverride(config, location, ctx, "pillText", (_a = config.pill) == null ? void 0 : _a.text); } // src/analytics.ts function trackEvent(config, eventName, payload) { try { if (config.analytics === false) return; if (typeof window.gtag === "function") { window.gtag("event", eventName, payload); } if (window.dataLayer && typeof window.dataLayer.push === "function") { window.dataLayer.push(__spreadValues({ event: "whatsapp_floating_" + eventName }, payload)); } if (typeof window.fbq === "function") { window.fbq("trackCustom", "WhatsAppFloating_" + eventName, payload); } } catch (e) { log(config, "warn", "Analytics tracking failed", e); } } // src/styles.ts var STYLE_ID = "wa-floating-styles"; function toCssLength(value, fallback) { if (value == null) return fallback; return typeof value === "number" ? `${value}px` : value; } function resolveCorner(position, offset) { const DEFAULT_GAP = "25px"; const o = offset || {}; if (position === "custom") { let css = ""; ["top", "bottom", "left", "right"].forEach((side) => { if (o[side] != null) css += `${side}:${toCssLength(o[side], DEFAULT_GAP)};`; }); return css || `bottom:${DEFAULT_GAP};right:${DEFAULT_GAP};`; } const horizontal = position.indexOf("left") !== -1 ? "left" : "right"; const verticalValue = toCssLength(o.bottom, DEFAULT_GAP); const horizontalValue = toCssLength(o[horizontal], DEFAULT_GAP); return `bottom:${verticalValue};${horizontal}:${horizontalValue};`; } var DEFAULT_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="100%" height="100%"><path fill="#25D366" d="M16 0C7.164 0 0 7.164 0 16c0 2.82.738 5.566 2.14 7.984L0 32l8.207-2.113A15.9 15.9 0 0 0 16 32c8.836 0 16-7.164 16-16S24.836 0 16 0z"/><path fill="#FFF" d="M25.144 22.63c-.4 1.13-1.99 2.07-3.25 2.34-.87.18-2 .33-5.8-1.24-4.86-2.02-7.99-6.94-8.23-7.26-.24-.32-1.97-2.62-1.97-5 0-2.38 1.24-3.55 1.68-4.03.44-.48.96-.6 1.28-.6.32 0 .64 0 .92.02.3.02.7-.11 1.09.83.4.96 1.36 3.34 1.48 3.58.12.24.2.52.04.84-.16.32-.24.52-.48.8-.24.28-.5.62-.72.84-.24.24-.49.5-.21.98.28.48 1.24 2.05 2.67 3.32 1.83 1.63 3.37 2.14 3.85 2.38.48.24.76.2 1.04-.12.28-.32 1.2-1.4 1.52-1.88.32-.48.64-.4 1.08-.24.44.16 2.8 1.32 3.28 1.56.48.24.8.36.92.56.12.2.12 1.14-.28 2.26z"/></svg>'; function injectStyles(config) { var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j; const theme = config.theme || {}; const position = config.position || "bottom-right"; const corner = resolveCorner(position, config.offset); const zIndex = config.zIndex || 2147483647; const iconSize = toCssLength(theme.iconSize, "64px"); const iconSizeMobile = toCssLength((_a = theme.iconSizeMobile) != null ? _a : theme.iconSize, "56px"); const pulseRaw = theme.pulse; const pulse = pulseRaw === true ? { scale: true } : pulseRaw || {}; const pulseScale = pulseRaw != null && pulse.scale !== false; const pulseDuration = (_b = pulse.duration) != null ? _b : 1800; const pulseScaleAmount = (_c = pulse.scaleAmount) != null ? _c : 1.08; const pulseColor = (_d = pulse.color) != null ? _d : "#25D366"; const pulseOpacity = (_e = pulse.opacity) != null ? _e : 0.55; const pill = config.pill; const pillExpand = (pill == null ? void 0 : pill.expand) || "hover"; const pillExpandMobile = (_f = pill == null ? void 0 : pill.expandMobile) != null ? _f : pillExpand; const pillColor = (pill == null ? void 0 : pill.color) || "#25D366"; const pillTextColor = (pill == null ? void 0 : pill.textColor) || "#ffffff"; const pillFontSize = (_g = pill == null ? void 0 : pill.fontSize) != null ? _g : 14; const pillFontWeight = (_h = pill == null ? void 0 : pill.fontWeight) != null ? _h : 600; const pillPaddingRight = (_i = pill == null ? void 0 : pill.paddingRight) != null ? _i : 18; const pillBorderRadius = (_j = pill == null ? void 0 : pill.borderRadius) != null ? _j : 999; const mobileBreakpoint = config.mobileBreakpoint || 768; const css = `.wa-floating-btn{position:fixed;` + corner + `z-index:${zIndex};display:inline-flex;align-items:center;justify-content:center;cursor:pointer;border:0;background:transparent;line-height:0;text-decoration:none;transition:transform .2s ease,opacity .2s ease;will-change:transform;opacity:0;transform:scale(.85);}.wa-floating-btn.wa-floating-visible{opacity:1;transform:scale(1);}.wa-floating-btn:hover{transform:scale(1.06);}.wa-floating-btn img{max-width:90vw;max-height:90vw;height:${iconSize};width:auto;display:block;}.wa-floating-btn svg{width:${iconSize};height:${iconSize};display:block;flex-shrink:0;}@media(max-width:768px){.wa-floating-btn img{height:${iconSizeMobile};}.wa-floating-btn svg{width:${iconSizeMobile};height:${iconSizeMobile};}}` + (theme.rounded ? `.wa-floating-btn img,.wa-floating-btn svg{border-radius:12px;}` : "") + (theme.shadow ? `.wa-floating-btn img,.wa-floating-btn` : "") + (theme.shadow && theme.dark ? `.wa-floating-btn img,.wa-floating-btn svg{box-shadow:0 4px 20px rgba(0,0,0,.6);}` : "") + // Pulse "scale" animates the image/icon itself (not the // .wa-floating-btn link), so it never fights with the // entrance/hover transform already applied to the link element. (pulseScale ? `.wa-floating-btn img,.wa-floating-btn svg{animation:wa-floating-pulse ${pulseDuration}ms ease-in-out infinite;}@keyframes wa-floating-pulse{0%,100%{transform:scale(1);}50%{transform:scale(${pulseScaleAmount});}}` : "") + // Pulse "ring" is a separate expanding halo on a ::after pseudo // element, so it composes cleanly with "scale" instead of fighting // over the same transform. (pulse.ring ? `.wa-floating-btn::after{content:"";position:absolute;inset:0;border-radius:50%;box-shadow:0 0 0 0 ${pulseColor};opacity:${pulseOpacity};animation:wa-floating-ring ${pulseDuration}ms ease-out infinite;pointer-events:none;}@keyframes wa-floating-ring{0%{box-shadow:0 0 0 0 ${pulseColor};opacity:${pulseOpacity};}100%{box-shadow:0 0 0 24px ${pulseColor};opacity:0;}}` : "") + // Pill mode: icon + text label in a pill shape, collapsed to // icon-only width by default and expanding per `pill.expand`. // Exclusive of the image/icon rules above (widget.ts renders one or // the other, never both), so these selectors only ever match when // `.wa-floating-pill` is actually present on the link. (pill ? `.wa-floating-btn.wa-floating-pill{display:inline-flex;align-items:center;justify-content:flex-start;border-radius:${pillBorderRadius}px;background:${pillColor};overflow:hidden;max-width:${iconSize};transition:max-width .3s ease;white-space:nowrap;}@media(max-width:${mobileBreakpoint}px){.wa-floating-btn.wa-floating-pill{max-width:${iconSizeMobile};}}.wa-floating-pill-icon{flex:none;width:${iconSize};height:${iconSize};display:flex;align-items:center;justify-content:center;}@media(max-width:${mobileBreakpoint}px){.wa-floating-pill-icon{width:${iconSizeMobile};height:${iconSizeMobile};}}.wa-floating-pill-icon svg{width:60%!important;height:60%!important;max-width:none!important;max-height:none!important;display:block!important;}.wa-floating-pill-icon svg>:first-child{fill:transparent;}.wa-floating-pill-icon svg path:last-of-type{fill:${pillTextColor};}` + // The pill's visible "circle" is this wrapper's own // background-color, not the SVG's circle path (made // transparent above) — so the scale pulse must animate the // wrapper itself, not the SVG, or the pill would look static // while only the white glyph inside grows. Cancel the // generic `.wa-floating-btn svg` animation here to avoid // compounding both transforms into a double-scale. (pulseScale ? `.wa-floating-pill-icon{animation:wa-floating-pulse ${pulseDuration}ms ease-in-out infinite;}.wa-floating-pill-icon svg{animation:none!important;}` : "") + `.wa-floating-pill-text{opacity:0;transition:opacity .2s ease .1s;padding-right:${pillPaddingRight}px;color:${pillTextColor};font:${pillFontWeight} ${pillFontSize}px/1.3 -apple-system,BlinkMacSystemFont,sans-serif;}` + // Desktop expand rules (above mobileBreakpoint) (pillExpand === "hover" ? `@media(min-width:${mobileBreakpoint + 1}px){.wa-floating-btn.wa-floating-pill:hover{max-width:320px;}.wa-floating-btn.wa-floating-pill:hover .wa-floating-pill-text{opacity:1;}}` : "") + (pillExpand === "always" ? `@media(min-width:${mobileBreakpoint + 1}px){.wa-floating-btn.wa-floating-pill{max-width:320px;}.wa-floating-pill-text{opacity:1;}}` : "") + (pillExpand === "click" ? `@media(min-width:${mobileBreakpoint + 1}px){.wa-floating-btn.wa-floating-pill.wa-floating-expanded{max-width:320px;}.wa-floating-btn.wa-floating-pill.wa-floating-expanded .wa-floating-pill-text{opacity:1;}}` : "") + // Mobile expand rules (at/below mobileBreakpoint) — only emitted // when expandMobile differs from expand, avoiding duplicate CSS. (pillExpandMobile !== pillExpand || pillExpand === "never" ? `` : ``) + (pillExpandMobile === "hover" ? `@media(max-width:${mobileBreakpoint}px){.wa-floating-btn.wa-floating-pill:hover{max-width:320px;}.wa-floating-btn.wa-floating-pill:hover .wa-floating-pill-text{opacity:1;}}` : "") + (pillExpandMobile === "always" ? `@media(max-width:${mobileBreakpoint}px){.wa-floating-btn.wa-floating-pill{max-width:320px;}.wa-floating-pill-text{opacity:1;}}` : "") + (pillExpandMobile === "click" ? `@media(max-width:${mobileBreakpoint}px){.wa-floating-btn.wa-floating-pill.wa-floating-expanded{max-width:320px;}.wa-floating-btn.wa-floating-pill.wa-floating-expanded .wa-floating-pill-text{opacity:1;}}` : "") : "") + (config.css || ""); let style = document.getElementById(STYLE_ID); if (!style) { style = document.createElement("style"); style.id = STYLE_ID; style.type = "text/css"; document.head.appendChild(style); } style.textContent = css; } // src/icons.ts var GLYPH_PATH = '<path fill="#FFF" d="M25.144 22.63c-.4 1.13-1.99 2.07-3.25 2.34-.87.18-2 .33-5.8-1.24-4.86-2.02-7.99-6.94-8.23-7.26-.24-.32-1.97-2.62-1.97-5 0-2.38 1.24-3.55 1.68-4.03.44-.48.96-.6 1.28-.6.32 0 .64 0 .92.02.3.02.7-.11 1.09.83.4.96 1.36 3.34 1.48 3.58.12.24.2.52.04.84-.16.32-.24.52-.48.8-.24.28-.5.62-.72.84-.24.24-.49.5-.21.98.28.48 1.24 2.05 2.67 3.32 1.83 1.63 3.37 2.14 3.85 2.38.48.24.76.2 1.04-.12.28-.32 1.2-1.4 1.52-1.88.32-.48.64-.4 1.08-.24.44.16 2.8 1.32 3.28 1.56.48.24.8.36.92.56.12.2.12 1.14-.28 2.26z"/>'; var ROUNDED_SQUARE_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="100%" height="100%"><rect x="0" y="0" width="32" height="32" rx="9" fill="#25D366"/>' + GLYPH_PATH + "</svg>"; var FLAT_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="100%" height="100%"><circle cx="16" cy="16" r="16" fill="#25D366"/>' + GLYPH_PATH + "</svg>"; var MONO_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="100%" height="100%"><circle cx="16" cy="16" r="16" fill="#1a1a1a"/>' + GLYPH_PATH + "</svg>"; var ICON_VARIANTS = { solid: DEFAULT_ICON_SVG, roundedSquare: ROUNDED_SQUARE_SVG, flat: FLAT_SVG, mono: MONO_SVG }; // src/widget.ts var VERSION = "1.2.0"; var DEFAULT_DESKTOP_ASSET = "whatsapp-desktop.png"; var DEFAULT_MOBILE_ASSET = "whatsapp-mobile.png"; var WhatsAppFloatingWidget = class { constructor() { this.config = null; this.location = { country: null, state: null, city: null, ip: null }; this.phone = null; this.matched = null; this.el = null; this.ready = false; this.listeners = {}; this.geoPromise = null; /** Whether a `pill` button with `expand: "click"` is currently expanded. */ this.pillExpanded = false; /** Incremented each time init() is called — lets a stale async start() detect it was superseded. */ this.initGen = 0; } emit(name, detail) { const config = this.config; const handlerName = "on" + name.charAt(0).toUpperCase() + name.slice(1); try { const handler = config == null ? void 0 : config[handlerName]; if (typeof handler === "function") { handler(detail); } } catch (e) { log(config, "error", `Error in ${String(handlerName)} handler`, e); } const handlers = this.listeners[name] || []; for (const fn of handlers) { try { fn(detail); } catch (e) { log(config, "error", `Error in listener for ${name}`, e); } } try { document.dispatchEvent(new CustomEvent(`whatsappfloating:${name}`, { detail })); } catch (e) { } } on(name, fn) { var _a; if (!this.listeners[name]) this.listeners[name] = []; (_a = this.listeners[name]) == null ? void 0 : _a.push(fn); return this; } off(name, fn) { const arr = this.listeners[name]; if (!arr) return this; this.listeners[name] = arr.filter((f) => f !== fn); return this; } buildContext() { const qs = getQueryParams(); const utm = {}; for (const key in qs) { if (/^utm_/i.test(key)) utm[key] = qs[key]; } return { language: getBrowserLanguage(), referrer: document.referrer || "", domain: window.location.hostname || "", page: window.location.pathname || "", utm, query: qs, cookies: document.cookie, localStorage: getStorage("localStorage"), sessionStorage: getStorage("sessionStorage"), continent: null }; } async init(userConfig) { const defaultConfig = { debug: false, production: true, mobileBreakpoint: 768, cacheLocation: false, analytics: true, lazyLoad: false }; const gen = ++this.initGen; let config = merge(defaultConfig, userConfig || {}); this.config = config; config = await this.loadRemoteConfig(config); this.config = config; const start = async () => { const ctx = this.buildContext(); const location = await fetchGeoLocation(config); if (gen !== this.initGen) { return { phone: null, matched: "Fallback", rule: null }; } this.location = location; ctx.continent = getContinent(location.country); this.emit("location", location); const result = resolvePhone(config, location, ctx); this.phone = result.phone; this.matched = result.matched; this.ruleImages = result.images; this.ruleMessage = resolveMessage(config, location, ctx); this.rulePillText = resolvePillText(config, location, ctx); this.printConsole(location, result); this.emit("phoneSelected", { phone: result.phone, matched: result.matched }); this.render(); this.ready = true; this.emit("ready", { phone: this.phone, location: this.location, matched: this.matched }); return result; }; if (config.lazyLoad) { this.geoPromise = new Promise((resolve) => { if (document.readyState === "complete") { setTimeout(() => start().then(resolve), 0); } else { window.addEventListener("load", () => start().then(resolve)); } }); return null; } this.geoPromise = start(); return this.geoPromise; } async loadRemoteConfig(config) { if (!config.remoteConfigUrl) return config; try { const res = await fetch(config.remoteConfigUrl); const remote = await res.json(); return merge(config, remote); } catch (err) { log(config, "warn", "Failed to load remote config", err); return config; } } printConsole(location, result) { const config = this.config; if (!config || config.production && !config.debug) return; console.log( `%c[WhatsAppFloating]%c Country: ${location.country || "-"} State: ${location.state || "-"} City: ${location.city || "-"} Matched: ${result.matched} Phone: ${result.phone || "-"}`, "color:#25D366;font-weight:bold;", "color:inherit;" ); } resolveImages() { var _a, _b, _c, _d, _e, _f; const config = this.config; if (!config) return {}; if (config.assetsBaseUrl && /\.(png|jpe?g|gif|webp|svg)$/i.test(config.assetsBaseUrl)) { log( config, "warn", `assetsBaseUrl "${config.assetsBaseUrl}" looks like a file, not a folder \u2014 it will have "/${DEFAULT_DESKTOP_ASSET}" appended to it, which is almost certainly not what you want. Use config.images.desktop/mobile instead to point directly at a specific image file.` ); } const defaultBase = config.assetsBaseUrl ? config.assetsBaseUrl.replace(/\/+$/, "") : ""; const defaults = defaultBase ? { desktop: `${defaultBase}/${DEFAULT_DESKTOP_ASSET}`, mobile: `${defaultBase}/${DEFAULT_MOBILE_ASSET}` } : {}; return { desktop: ((_a = this.manualImages) == null ? void 0 : _a.desktop) || ((_b = this.ruleImages) == null ? void 0 : _b.desktop) || ((_c = config.images) == null ? void 0 : _c.desktop) || defaults.desktop, mobile: ((_d = this.manualImages) == null ? void 0 : _d.mobile) || ((_e = this.ruleImages) == null ? void 0 : _e.mobile) || ((_f = config.images) == null ? void 0 : _f.mobile) || defaults.mobile }; } resolveImage() { const config = this.config; if (!config) return void 0; const images = this.resolveImages(); const mobile = isMobile(config); return mobile ? images.mobile || images.desktop : images.desktop || images.mobile; } applyMessagePlaceholders(message) { var _a, _b, _c, _d, _e; const location = this.location; const ip = (_a = location.ip) != null ? _a : ""; let ipBase64 = ""; if (ip && typeof btoa === "function") { try { ipBase64 = btoa(ip); } catch (e) { ipBase64 = ""; } } const values = { country: (_b = location.country) != null ? _b : "", state: (_c = location.state) != null ? _c : "", region: (_d = location.state) != null ? _d : "", city: (_e = location.city) != null ? _e : "", ip, ip_base64: ipBase64 }; return message.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => { const value = values[key.toLowerCase()]; return value !== void 0 ? value : match; }); } buildLink() { const config = this.config; const phone = (this.phone || "").replace(/\D/g, ""); const message = this.applyMessagePlaceholders(this.ruleMessage || (config == null ? void 0 : config.message) || ""); let utmSuffix = ""; if (config == null ? void 0 : config.appendUtmToMessage) { const ctx = this.buildContext(); const parts = Object.keys(ctx.utm).map((k) => `${k}=${ctx.utm[k]}`); if (parts.length) utmSuffix = ` [${parts.join(", ")}]`; } return `https://wa.me/${phone}?text=${encodeURIComponent(message + utmSuffix)}`; } preloadImage(url) { try { if (document.querySelector(`link[rel="preload"][href="${url}"]`)) return; const link = document.createElement("link"); link.rel = "preload"; link.as = "image"; link.href = url; document.head.appendChild(link); } catch (e) { } } render() { var _a, _b, _c, _d; const config = this.config; if (!config) return; if (!this.phone) { log(config, "warn", "No phone number resolved; button will not be rendered"); return; } this.destroy(true); injectStyles(config); this.pillExpanded = false; const isPill = !!((_a = config.pill) == null ? void 0 : _a.text); const pillText = this.rulePillText || ((_b = config.pill) == null ? void 0 : _b.text) || ""; const imageUrl = isPill ? void 0 : this.resolveImage(); if (imageUrl) this.preloadImage(imageUrl); const link = document.createElement("a"); link.className = isPill ? "wa-floating-btn wa-floating-pill" : "wa-floating-btn"; link.setAttribute("href", this.buildLink()); link.setAttribute("target", "_blank"); link.setAttribute("rel", "noopener noreferrer"); link.setAttribute("aria-label", config.ariaLabel || pillText || "WhatsApp"); if (isPill) { const iconWrap = document.createElement("span"); iconWrap.className = "wa-floating-pill-icon"; iconWrap.innerHTML = ICON_VARIANTS[(_c = config.pill) == null ? void 0 : _c.icon] || ICON_VARIANTS.solid; const svg = iconWrap.querySelector("svg"); if (svg) { svg.removeAttribute("width"); svg.removeAttribute("height"); const bg = svg.firstElementChild; if (bg) bg.setAttribute("fill", "transparent"); const paths = svg.querySelectorAll("path"); const glyph = paths[paths.length - 1]; if (glyph) glyph.setAttribute("fill", ((_d = config.pill) == null ? void 0 : _d.textColor) || "#ffffff"); } const textWrap = document.createElement("span"); textWrap.className = "wa-floating-pill-text"; textWrap.textContent = pillText; link.appendChild(iconWrap); link.appendChild(textWrap); } else if (imageUrl) { const img = document.createElement("img"); img.loading = "eager"; img.decoding = "async"; img.fetchPriority = "high"; img.alt = config.imageAlt || "WhatsApp"; img.src = imageUrl; link.appendChild(img); } else { link.innerHTML = config.icon || ICON_VARIANTS[config.iconVariant] || DEFAULT_ICON_SVG; const svg = link.querySelector("svg"); if (svg) { svg.removeAttribute("width"); svg.removeAttribute("height"); } } link.addEventListener("click", (e) => { var _a2; if (isPill && ((_a2 = config.pill) == null ? void 0 : _a2.expand) === "click" && !this.pillExpanded) { e.preventDefault(); this.pillExpanded = true; link.classList.add("wa-floating-expanded"); return; } this.emit("open", { phone: this.phone }); trackEvent(config, "open", { phone: this.phone, matched: this.matched }); }); document.body.appendChild(link); this.el = link; requestAnimationFrame(() => { requestAnimationFrame(() => { var _a2; (_a2 = this.el) == null ? void 0 : _a2.classList.add("wa-floating-visible"); }); }); trackEvent(config, "render", { phone: this.phone, matched: this.matched }); } reload(newConfig) { const config = newConfig && this.config ? merge(this.config, newConfig) : this.config; return this.init(config); } open() { if (!this.phone) { log(this.config, "warn", "Cannot open: no phone resolved yet"); return this; } const url = this.buildLink(); window.open(url, "_blank", "noopener,noreferrer"); this.emit("open", { phone: this.phone }); if (this.config) trackEvent(this.config, "open", { phone: this.phone, matched: this.matched }); return this; } close() { var _a; (_a = this.el) == null ? void 0 : _a.classList.remove("wa-floating-visible"); this.emit("close", { phone: this.phone }); return this; } destroy(silent = false) { var _a; if ((_a = this.el) == null ? void 0 : _a.parentNode) { this.el.parentNode.removeChild(this.el); } this.el = null; if (!silent) this.ready = false; return this; } setPhone(phone) { this.phone = phone; this.matched = "Manual"; if (this.el) this.render(); this.emit("phoneSelected", { phone, matched: "Manual" }); return this; } /** Programmatically override the desktop/mobile images, taking precedence over config and rules. */ setImages(images) { this.manualImages = __spreadValues(__spreadValues({}, this.manualImages), images); if (this.el) this.render(); return this; } getImages() { return this.resolveImages(); } getPhone() { return this.phone; } getLocation() { return this.location; } getMatched() { return this.matched; } isReady() { return this.ready; } getVersion() { return VERSION; } }; // src/index.ts var instance = new WhatsAppFloatingWidget(); var api = { version: VERSION, init: (config) => instance.init(config), reload: (config) => instance.reload(config), open: () => instance.open(), close: () => instance.close(), destroy: () => instance.destroy(), getPhone: () => instance.getPhone(), getLocation: () => instance.getLocation(), getMatched: () => instance.getMatched(), setPhone: (phone) => instance.setPhone(phone), setImages: (images) => instance.setImages(images), getImages: () => instance.getImages(), isReady: () => instance.isReady(), on: (name, fn) => instance.on(name, fn), off: (name, fn) => instance.off(name, fn) }; function boot() { if (typeof window === "undefined") return; if (window.WhatsAppFloatingConfig) { void instance.init(window.WhatsAppFloatingConfig); } } var src_default = api; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { boot }); //# sourceMappingURL=whatsapp-floating.cjs.js.map