/** * Drop-in address autofill for Brazilian checkouts, on top of the public CEP * API at cep.is. * * The shopper types a CEP; the widget fills the street, neighbourhood, city and * state fields you point it at. Field mapping is configuration: nothing about * any particular e-commerce platform is baked in. * * @module */ /** Version of this widget. */ export const VERSION = "0.1.0"; const DEFAULT_BASE_URL = "https://cep.is"; const DEFAULT_DEBOUNCE_MS = 250; const DEFAULT_TIMEOUT_MS = 8000; const NOT_DIGIT = /\D+/g; const DIACRITIC = /[\u0300-\u036f]/g; const ADDRESS_FIELDS = [ "cep", "logradouro", "complemento", "unidade", "bairro", "localidade", "uf", "estado", "regiao", "ibge", "gia", "ddd", "siafi", ]; /** * The message shown for each state, in Portuguese. Override any of them * through the `messages` option of {@link attach}. */ export const MESSAGES = { idle: "", loading: "Buscando endereço…", found: "Endereço encontrado.", not_found: "CEP não encontrado. Confira o número ou preencha o endereço manualmente.", invalid: "CEP inválido. Digite os 8 dígitos.", rate_limited: "Muitas consultas em pouco tempo. Aguarde alguns segundos e tente de novo.", server: "O serviço de CEP está indisponível. Preencha o endereço manualmente.", network: "Não foi possível consultar o CEP. Verifique sua conexão e tente de novo.", timeout: "A consulta do CEP demorou demais. Tente de novo ou preencha manualmente.", invalid_response: "Resposta inesperada do serviço de CEP. Preencha o endereço manualmente.", }; /** * Everything this widget throws or hands to `onError`. * * `code` is what you branch on. It is the decision, not the HTTP status: * * | code | when | worth retrying? | * | --- | --- | --- | * | `invalid` | not 8 digits, or the API rejected the format | no | * | `not_found` | well-formed, absent from the DNE database | no | * | `rate_limited` | 429; `retryAfterMs` carries what the server asked for | later | * | `server` | 5xx; `status` carries the code | later | * | `network` | DNS, refused connection, TLS, offline | later | * | `timeout` | the request outlived `timeoutMs` | later | * | `invalid_response` | a success status with an unreadable body | probably not | * | `aborted` | superseded by a newer keystroke; never surfaced to the shopper | no | */ export class CepisAutofillError extends Error { /** * @param {string} code One of the codes in the table above. * @param {string} message Human message, in Portuguese. * @param {{status?: number, retryAfterMs?: number, cep?: string, cause?: unknown}} [details] */ constructor(code, message, details = {}) { super(message, details.cause === undefined ? undefined : { cause: details.cause }); this.name = "CepisAutofillError"; this.code = code; this.status = details.status; this.retryAfterMs = details.retryAfterMs; this.cep = details.cep; } } /** * Return the CEP's 8 digits, accepting the `01001-000` mask. * * @param {string} value Anything the shopper typed. * @returns {string} The 8 digits. * @throws {CepisAutofillError} code `invalid`, when 8 digits do not remain. */ export function normalizeCep(value) { const digits = String(value == null ? "" : value).replace(NOT_DIGIT, ""); if (digits.length !== 8) { throw new CepisAutofillError("invalid", MESSAGES.invalid, { cep: digits }); } return digits; } const cache = new Map(); /** Drop every cached lookup. Rarely needed: the cache dies with the page. */ export function clearCache() { cache.clear(); } function retryAfterMs(header) { if (!header) { return undefined; } const value = header.trim(); if (/^\d+$/.test(value)) { return Number(value) * 1000; } const target = Date.parse(value); return Number.isNaN(target) ? undefined : Math.max(0, target - Date.now()); } function apiMessage(body, fallback) { try { const data = JSON.parse(body); if (data && typeof data.mensagem === "string" && data.mensagem !== "") { return data.mensagem; } } catch { return fallback; } return fallback; } /** * Look up one CEP on the API. * * Successful answers and "does not exist" are cached in memory for the life of * the page, keyed by CEP and base URL; transient failures are not. Callers get * the address object exactly as the API returns it — Portuguese field names, * empty strings rather than nulls, and `geo` present only for the CEPs that * have a coordinate. * * @param {string} cep Masked or unmasked. * @param {{baseUrl?: string, timeoutMs?: number, signal?: AbortSignal}} [options] * @returns {Promise>} The address. * @throws {CepisAutofillError} See the table on {@link CepisAutofillError}. */ export async function lookup(cep, options = {}) { const digits = normalizeCep(cep); const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); const key = `${baseUrl}|${digits}`; const hit = cache.get(key); if (hit) { if (hit.address) { return hit.address; } throw new CepisAutofillError(hit.code, hit.message, { cep: digits }); } const controller = new AbortController(); const forward = () => controller.abort(); if (options.signal) { if (options.signal.aborted) { throw new CepisAutofillError("aborted", "consulta cancelada", { cep: digits }); } options.signal.addEventListener("abort", forward); } let timedOut = false; const timer = setTimeout(() => { timedOut = true; controller.abort(); }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS); let response; let body; try { response = await fetch(`${baseUrl}/${digits}`, { method: "GET", credentials: "omit", headers: { Accept: "application/json" }, signal: controller.signal, }); body = await response.text(); } catch (error) { if (timedOut) { throw new CepisAutofillError("timeout", MESSAGES.timeout, { cep: digits, cause: error }); } if (controller.signal.aborted) { throw new CepisAutofillError("aborted", "consulta cancelada", { cep: digits }); } throw new CepisAutofillError("network", MESSAGES.network, { cep: digits, cause: error }); } finally { clearTimeout(timer); if (options.signal) { options.signal.removeEventListener("abort", forward); } } if (response.status === 404) { cache.set(key, { code: "not_found", message: MESSAGES.not_found }); throw new CepisAutofillError("not_found", MESSAGES.not_found, { cep: digits }); } if (response.status === 400) { throw new CepisAutofillError("invalid", apiMessage(body, MESSAGES.invalid), { cep: digits }); } if (response.status === 429) { throw new CepisAutofillError("rate_limited", MESSAGES.rate_limited, { cep: digits, retryAfterMs: retryAfterMs(response.headers.get("Retry-After")), }); } if (response.status >= 500) { throw new CepisAutofillError("server", MESSAGES.server, { cep: digits, status: response.status, }); } if (!response.ok) { throw new CepisAutofillError("invalid_response", MESSAGES.invalid_response, { cep: digits, status: response.status, }); } let address; try { address = JSON.parse(body); } catch (error) { throw new CepisAutofillError("invalid_response", MESSAGES.invalid_response, { cep: digits, cause: error, }); } if (!address || typeof address !== "object" || Array.isArray(address)) { throw new CepisAutofillError("invalid_response", MESSAGES.invalid_response, { cep: digits }); } cache.set(key, { address }); return address; } function resolve(target) { if (!target) { return null; } if (typeof target === "string") { return document.querySelector(target); } return target.nodeType === 1 ? target : null; } function fold(value) { return String(value ?? "") .normalize("NFD") .replace(DIACRITIC, "") .trim() .toLowerCase(); } function assign(element, value) { const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), "value"); if (setter && setter.set) { setter.set.call(element, value); } else { element.value = value; } element.dispatchEvent(new Event("input", { bubbles: true })); element.dispatchEvent(new Event("change", { bubbles: true })); } function selectOption(element, candidates) { const wanted = candidates.filter(Boolean).map(fold); for (const option of Array.from(element.options)) { if (wanted.includes(fold(option.value)) || wanted.includes(fold(option.textContent))) { assign(element, option.value); return option.value; } } return null; } function writable(element, mode, written) { const current = String(element.value ?? ""); if (mode === true) { return true; } if (current.trim() === "") { return true; } if (mode === false) { return false; } return written.get(element) === current; } let regionCount = 0; /** * Attach the widget to a form. * * Every selector accepts a CSS string or an element. Strings are resolved on * every use and the CEP listener is delegated from `document`, so a checkout * that re-renders its form keeps working; elements are held directly and stop * working if that node is replaced. * * ```js * const handle = CepisAutofill.attach({ * cep: "#billing_postcode", * logradouro: "#billing_address_1", * bairro: "#billing_neighborhood", * localidade: "#billing_city", * uf: "#billing_state", * numero: "#billing_number", * }); * ``` * * @param {object} options * @param {string|Element} options.cep The field the shopper types into. Required. * @param {string|Element} [options.logradouro] Street field. * @param {string|Element} [options.complemento] Complement field. * @param {string|Element} [options.unidade] Unit field. * @param {string|Element} [options.bairro] Neighbourhood field. * @param {string|Element} [options.localidade] City field. * @param {string|Element} [options.uf] State field. A `