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
JavaScript
"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;}{.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;} 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;} 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;}{.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;}{.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" ? `{.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" ? `{.wa-floating-btn.wa-floating-pill{max-width:320px;}.wa-floating-pill-text{opacity:1;}}` : "") + (pillExpand === "click" ? `{.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" ? `{.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" ? `{.wa-floating-btn.wa-floating-pill{max-width:320px;}.wa-floating-pill-text{opacity:1;}}` : "") + (pillExpandMobile === "click" ? `{.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