/** * 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.2.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 EIGHT_DIGITS = /^\d{8}$/; const PLACEHOLDER = /\{(localidade|uf|cep_ini|cep_fim)\}/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}. * * `not_found_city` and `not_found_city_range` are not states of their own: * they are the wording used for the `not_found` state when the API tells us * which municipality the CEP would have belonged to. `{localidade}`, `{uf}`, * `{cep_ini}` and `{cep_fim}` are filled in; the two CEPs are masked. Whenever * that information is missing, plain `not_found` is used instead. */ 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.", not_found_city: "CEP não encontrado. Esse número seria de {localidade}/{uf}.", not_found_city_range: "CEP não encontrado. Esse número seria de {localidade}/{uf}, que tem CEPs de {cep_ini} a {cep_fim}.", 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 | * * A `not_found` may carry `faixa`: the municipality the CEP would have * belonged to. See {@link CepisAutofillError#faixa}. */ 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, faixa?: Record, 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; /** * On a `not_found`, the municipality whose CEP range brackets the number * asked for — `{localidade, uf}` plus whichever of `estado`, `regiao`, * `ibge`, `cep_ini`, `cep_fim` and `ceps` the API sent. Absent whenever * the CEP falls outside every municipality, or the API said nothing * usable. * * **This is context for a failure, not an address.** The CEP asked for * does not exist, so neither does the address it would have had: the * widget never writes any of this into a form field, and neither should * you. `cep_ini` and `cep_fim` are the lowest and highest CEP that really * exist in that municipality, not a contiguous run — Areal/RJ has 118 of * them spread over some 4,900 numbers — so they are worth showing as a * hint of where the right CEP lies, never as a list to pick from. * * @type {Record|undefined} */ this.faixa = details.faixa; } } /** * 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 trimmed(value) { return typeof value === "string" ? value.trim() : ""; } function maskCep(digits) { return `${digits.slice(0, 5)}-${digits.slice(5)}`; } /** * Read the `faixa` a 404 may carry: the municipality the CEP would have * belonged to, had it existed. * * Every field is optional and none is trusted. A body that is not JSON, a * `faixa` that is not an object, a missing `localidade` or `uf` — all of it * yields `undefined`, and the caller falls back to the plain "não encontrado". * This never throws. * * @param {string} body The raw 404 body. * @returns {Record|undefined} */ function parseFaixa(body) { let data; try { data = JSON.parse(body); } catch { return undefined; } const raw = data && typeof data === "object" ? data.faixa : undefined; if (!raw || typeof raw !== "object" || Array.isArray(raw)) { return undefined; } const localidade = trimmed(raw.localidade); const uf = trimmed(raw.uf); if (localidade === "" || uf === "") { return undefined; } const faixa = { localidade, uf }; for (const field of ["estado", "regiao", "ibge"]) { const value = trimmed(raw[field]); if (value !== "") { faixa[field] = value; } } const ini = trimmed(raw.cep_ini); const fim = trimmed(raw.cep_fim); if (EIGHT_DIGITS.test(ini) && EIGHT_DIGITS.test(fim)) { faixa.cep_ini = ini; faixa.cep_fim = fim; } if (typeof raw.ceps === "number" && Number.isFinite(raw.ceps)) { faixa.ceps = raw.ceps; } return faixa; } /** * Word the `not_found` state, naming the municipality when we know it. * * @param {Record|undefined} faixa From {@link parseFaixa}. * @param {Record} messages The message table in force. * @returns {string} */ function notFoundMessage(faixa, messages) { if (!faixa) { return messages.not_found; } const template = faixa.cep_ini ? messages.not_found_city_range : messages.not_found_city; if (typeof template !== "string" || template === "") { return messages.not_found; } return template.replace(PLACEHOLDER, (_, key) => { const value = faixa[key]; if (typeof value !== "string") { return ""; } return key === "cep_ini" || key === "cep_fim" ? maskCep(value) : value; }); } 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. * * A `not_found` may carry a `faixa` naming the municipality the CEP would have * belonged to — context for the failure, never an address to fill in. * * @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, faixa: hit.faixa }); } 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) { // A 404 may name the municipality the CEP would have belonged to. It stays // an error: the address does not exist, so nothing here is ever filled in. const faixa = parseFaixa(body); const message = notFoundMessage(faixa, MESSAGES); cache.set(key, { code: "not_found", message, faixa }); throw new CepisAutofillError("not_found", message, { cep: digits, faixa }); } 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 `