From 36486007ccada8c31d30e8f528d21233cabce32c Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Sun, 26 Jul 2026 15:01:02 -0300 Subject: Initial import --- src/index.js | 614 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 614 insertions(+) create mode 100644 src/index.js (limited to 'src/index.js') diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..d2f531e --- /dev/null +++ b/src/index.js @@ -0,0 +1,614 @@ +/** + * 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 `