diff options
Diffstat (limited to 'src/index.js')
| -rw-r--r-- | src/index.js | 146 |
1 files changed, 137 insertions, 9 deletions
diff --git a/src/index.js b/src/index.js index d2f531e..77f0dcc 100644 --- a/src/index.js +++ b/src/index.js @@ -10,7 +10,7 @@ */ /** Version of this widget. */ -export const VERSION = "0.1.0"; +export const VERSION = "0.2.0"; const DEFAULT_BASE_URL = "https://cep.is"; const DEFAULT_DEBOUNCE_MS = 250; @@ -18,6 +18,8 @@ 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", @@ -38,12 +40,21 @@ const ADDRESS_FIELDS = [ /** * 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.", @@ -67,12 +78,15 @@ export const MESSAGES = { * | `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, cause?: unknown}} [details] + * @param {{status?: number, retryAfterMs?: number, cep?: string, faixa?: Record<string, any>, cause?: unknown}} [details] */ constructor(code, message, details = {}) { super(message, details.cause === undefined ? undefined : { cause: details.cause }); @@ -81,6 +95,24 @@ export class CepisAutofillError extends Error { 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<string, any>|undefined} + */ + this.faixa = details.faixa; } } @@ -118,6 +150,87 @@ function retryAfterMs(header) { 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<string, any>|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<string, any>|undefined} faixa From {@link parseFaixa}. + * @param {Record<string, string>} 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); @@ -139,6 +252,9 @@ function apiMessage(body, fallback) { * 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<Record<string, any>>} The address. @@ -154,7 +270,7 @@ export async function lookup(cep, options = {}) { if (hit.address) { return hit.address; } - throw new CepisAutofillError(hit.code, hit.message, { cep: digits }); + throw new CepisAutofillError(hit.code, hit.message, { cep: digits, faixa: hit.faixa }); } const controller = new AbortController(); @@ -198,8 +314,12 @@ export async function lookup(cep, options = {}) { } if (response.status === 404) { - cache.set(key, { code: "not_found", message: MESSAGES.not_found }); - throw new CepisAutofillError("not_found", MESSAGES.not_found, { cep: digits }); + // 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 }); @@ -353,7 +473,9 @@ let regionCount = 0; * @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`. + * Called for every failure the shopper sees. Never called for `aborted`. A + * `not_found` may carry `error.faixa`; see {@link CepisAutofillError#faixa} + * for why it must not be filled into the form. * @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}} @@ -425,8 +547,8 @@ export function attach(options) { return node; } - function announce(kind) { - const message = messages[kind] ?? ""; + function announce(kind, text) { + const message = text ?? messages[kind] ?? ""; const node = region(); if (node) { node.setAttribute( @@ -452,6 +574,10 @@ export function attach(options) { } } + // Only ever called with an address the API confirmed exists. A `faixa` from a + // 404 must never reach it: naming the city of a CEP that does not exist is a + // hint for the shopper, and silently writing it into the form would be a + // wrong address. function fill(address) { for (const field of ADDRESS_FIELDS) { if (field === "cep" || !options[field]) { @@ -514,7 +640,9 @@ export function attach(options) { return null; } const code = error instanceof CepisAutofillError ? error.code : "network"; - announce(code); + // No field is touched on this path — a `not_found` only changes the + // wording of the message, never the form. + announce(code, code === "not_found" ? notFoundMessage(error.faixa, messages) : undefined); if (options.onError) { options.onError(error, { cep: digits }); } |