diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 15:01:02 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 15:01:02 -0300 |
| commit | 36486007ccada8c31d30e8f528d21233cabce32c (patch) | |
| tree | d14a108845e4c984ec922ba98d153c8d68da6ebb /src/index.js | |
| download | autofill-36486007ccada8c31d30e8f528d21233cabce32c.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'src/index.js')
| -rw-r--r-- | src/index.js | 614 |
1 files changed, 614 insertions, 0 deletions
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<Record<string, any>>} 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 `<select>` is matched by + * option value or label, against both the two-letter UF and the state name. + * @param {string|Element} [options.estado] State-name field. + * @param {string|Element} [options.regiao] Region field. + * @param {string|Element} [options.ibge] IBGE code field. + * @param {string|Element} [options.gia] GIA code field. + * @param {string|Element} [options.ddd] Area code field. + * @param {string|Element} [options.siafi] SIAFI code field. + * @param {string|Element} [options.numero] House-number field. Never written + * to — only used by `focusNumero`. + * @param {string} [options.baseUrl] API root. Default `https://cep.is`; point + * it at a local instance to test. + * @param {number} [options.debounceMs] Quiet period after the last keystroke + * before a request goes out. Default `250`. + * @param {number} [options.timeoutMs] Deadline for one request. Default `8000`. + * @param {boolean|"auto"} [options.overwrite] What to do with a field that + * already has a value. `"auto"` (default) replaces empty fields and values + * this widget wrote itself, and leaves anything the shopper typed alone; + * `false` only ever fills empty fields; `true` always overwrites. + * @param {boolean} [options.focusNumero] Move focus to the `numero` field + * after a successful lookup, and only while the CEP field still has focus. + * Default `false`. + * @param {string|Element|false} [options.status] Where to announce progress. + * Omit to have an `aria-live` region created right after the CEP field, pass + * a selector or element to use your own, or `false` to render nothing and + * drive your own UI from `onStatus`. + * @param {Record<string, string>} [options.messages] Overrides for + * {@link MESSAGES}. + * @param {(address: Record<string, any>, context: {cep: string}) => void} [options.onResult] + * Called after a successful lookup, once the fields are filled. + * @param {(error: CepisAutofillError, context: {cep: string}) => void} [options.onError] + * Called for every failure the shopper sees. Never called for `aborted`. + * @param {(state: string, message: string) => void} [options.onStatus] Called + * on every state change: `idle`, `loading`, `found`, or an error code. + * @returns {{detach: () => void, lookup: (cep?: string) => Promise<Record<string, any>|null>, element: () => Element|null}} + * `detach()` removes every listener, aborts anything in flight and removes a + * status region it created. `lookup()` runs the whole pipeline immediately, + * skipping the debounce, for checkouts that fill the CEP field without + * firing events. + */ +export function attach(options) { + if (!options || !options.cep) { + throw new TypeError("attach() needs a `cep` selector or element"); + } + + const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); + const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const overwrite = options.overwrite ?? "auto"; + const messages = { ...MESSAGES, ...(options.messages ?? {}) }; + const written = new WeakMap(); + + const state = { + seq: 0, + timer: 0, + controller: null, + region: null, + describedBy: false, + detached: false, + }; + + const source = () => resolve(options.cep); + + function region() { + if (options.status === false) { + return null; + } + if (state.region && state.region.isConnected) { + return state.region; + } + if (options.status) { + state.region = resolve(options.status); + } else { + const anchor = source(); + if (!anchor) { + return null; + } + const node = document.createElement("div"); + node.className = "cepis-autofill-status"; + node.style.minHeight = "1.25em"; + anchor.insertAdjacentElement("afterend", node); + state.region = node; + } + const node = state.region; + if (!node) { + return null; + } + node.setAttribute("role", "status"); + node.setAttribute("aria-live", "polite"); + node.setAttribute("aria-atomic", "true"); + if (!node.id) { + regionCount += 1; + node.id = `cepis-autofill-status-${regionCount}`; + } + const anchor = source(); + if (anchor && !(anchor.getAttribute("aria-describedby") ?? "").includes(node.id)) { + const previous = anchor.getAttribute("aria-describedby"); + anchor.setAttribute("aria-describedby", previous ? `${previous} ${node.id}` : node.id); + state.describedBy = true; + } + return node; + } + + function announce(kind) { + const message = messages[kind] ?? ""; + const node = region(); + if (node) { + node.setAttribute( + "data-cepis-state", + kind === "found" || kind === "loading" ? kind : kind === "idle" ? "idle" : "error", + ); + node.textContent = message; + } + const anchor = source(); + if (anchor) { + if (kind === "loading") { + anchor.setAttribute("aria-busy", "true"); + } else { + anchor.removeAttribute("aria-busy"); + } + anchor.setAttribute( + "aria-invalid", + kind === "invalid" || kind === "not_found" ? "true" : "false", + ); + } + if (options.onStatus) { + options.onStatus(kind, message); + } + } + + function fill(address) { + for (const field of ADDRESS_FIELDS) { + if (field === "cep" || !options[field]) { + continue; + } + const element = resolve(options[field]); + const value = address[field]; + if (!element || typeof value !== "string" || value === "") { + continue; + } + if (!writable(element, overwrite, written)) { + continue; + } + if (element.tagName === "SELECT") { + const chosen = selectOption( + element, + field === "uf" + ? [value, address.estado] + : field === "estado" + ? [value, address.uf] + : [value], + ); + if (chosen !== null) { + written.set(element, element.value); + } + continue; + } + assign(element, value); + written.set(element, value); + } + } + + async function run(digits) { + state.seq += 1; + const seq = state.seq; + if (state.controller) { + state.controller.abort(); + } + const controller = new AbortController(); + state.controller = controller; + announce("loading"); + + try { + const address = await lookup(digits, { baseUrl, timeoutMs, signal: controller.signal }); + if (seq !== state.seq || state.detached) { + return null; + } + fill(address); + announce("found"); + if (options.onResult) { + options.onResult(address, { cep: digits }); + } + const numero = resolve(options.numero); + if (options.focusNumero && numero && document.activeElement === source()) { + numero.focus(); + } + return address; + } catch (error) { + if (seq !== state.seq || state.detached || error.code === "aborted") { + return null; + } + const code = error instanceof CepisAutofillError ? error.code : "network"; + announce(code); + if (options.onError) { + options.onError(error, { cep: digits }); + } + return null; + } finally { + if (state.controller === controller) { + state.controller = null; + } + } + } + + function handle(event) { + const target = event.target; + const matches = + typeof options.cep === "string" + ? target && target.nodeType === 1 && target.matches(options.cep) + : target === options.cep; + if (!matches) { + return; + } + schedule(String(target.value ?? "")); + } + + function schedule(value) { + clearTimeout(state.timer); + const digits = value.replace(NOT_DIGIT, ""); + if (digits.length !== 8) { + state.seq += 1; + if (state.controller) { + state.controller.abort(); + state.controller = null; + } + announce(digits.length > 8 ? "invalid" : "idle"); + return; + } + state.timer = setTimeout(() => run(digits), debounceMs); + } + + announce("idle"); + document.addEventListener("input", handle, true); + document.addEventListener("change", handle, true); + + return { + detach() { + state.detached = true; + state.seq += 1; + clearTimeout(state.timer); + if (state.controller) { + state.controller.abort(); + state.controller = null; + } + document.removeEventListener("input", handle, true); + document.removeEventListener("change", handle, true); + const anchor = source(); + if (anchor) { + anchor.removeAttribute("aria-busy"); + anchor.removeAttribute("aria-invalid"); + } + if (state.region && state.describedBy && anchor) { + const remaining = (anchor.getAttribute("aria-describedby") ?? "") + .split(/\s+/) + .filter((id) => id && id !== state.region.id) + .join(" "); + if (remaining) { + anchor.setAttribute("aria-describedby", remaining); + } else { + anchor.removeAttribute("aria-describedby"); + } + } + if (state.region && !options.status) { + state.region.remove(); + } + state.region = null; + }, + + lookup(cep) { + clearTimeout(state.timer); + const anchor = source(); + const value = cep ?? (anchor ? anchor.value : ""); + let digits; + try { + digits = normalizeCep(value); + } catch (error) { + announce("invalid"); + if (options.onError) { + options.onError(error, { cep: String(value) }); + } + return Promise.resolve(null); + } + return run(digits); + }, + + element: source, + }; +} + +export default { attach, lookup, normalizeCep, clearCache, CepisAutofillError, MESSAGES, VERSION }; |