summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/index.js146
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 });
}