summaryrefslogtreecommitdiffstats
path: root/assets/js
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-27 20:52:15 -0300
committerMurilo Ijanc <[email protected]>2026-07-27 20:52:15 -0300
commit511a0ab33b5b12a4cd8a1d4dca6c5dce50cdb6c1 (patch)
tree462412b439743c2d04f1acf24f6557251d3a8265 /assets/js
parent535dbda6b48171f8115b455e54790de94d97d42a (diff)
downloadwoocommerce-main.tar.gz
Name the municipality behind a CEP that does not existHEADv0.2.0main
A CEP that fails at the checkout is almost never a random number. It is a shopper who knows perfectly well which town they live in and got a digit wrong, most often by reaching for the -000 that 957 municipalities never received. The API now names the municipality such a number would have belonged to, in the body of its own 404, so the message can say "that would be in Areal/RJ, which has CEPs from 25845-001 to 25849-899" instead of leaving the shopper at a dead end with nothing to act on. It stays an error. Nothing from it is written into a checkout field, on either checkout and in either lookup mode: a CEP that does not exist has no address, and filling one in from the municipality behind it means a parcel delivered to the wrong town and a chargeback behind that. The one module that reads the 404 body hands its callers a finished sentence and never the data itself, so there is no shape in which it could leak into a field later. The two CEPs quoted are the lowest and the highest that really exist there, never a list and never a suggestion, because the range is sparse enough that a number out of the middle of it would most likely not exist either. The widget correctly throws its 404 away without reading it, so the body is fetched once more, on the miss path only and once per CEP. In proxy mode PHP passes the municipality through with the 404 and caches it with the miss, which is what keeps the two modes indistinguishable from the browser. Anything missing or misshapen falls back to the message the plugin has always shown.
Diffstat (limited to 'assets/js')
-rw-r--r--assets/js/cepis-woocommerce-blocks.js39
-rw-r--r--assets/js/cepis-woocommerce-classic.js92
-rw-r--r--assets/js/cepis-woocommerce-faixa.js243
3 files changed, 373 insertions, 1 deletions
diff --git a/assets/js/cepis-woocommerce-blocks.js b/assets/js/cepis-woocommerce-blocks.js
index bcbab93..4f4e2e3 100644
--- a/assets/js/cepis-woocommerce-blocks.js
+++ b/assets/js/cepis-woocommerce-blocks.js
@@ -131,6 +131,37 @@
}
/**
+ * Say which municipality a CEP that does not exist would have been in.
+ *
+ * Replaces the notice already on screen — same id, so the block swaps the
+ * text rather than stacking a second error. The failure is reported
+ * immediately and refined a round trip later, because making the shopper wait
+ * on a second request before being told anything at all is worse than the
+ * message improving under them.
+ *
+ * Nothing here goes near `apply()`. A CEP the Correios do not have has no
+ * address, and the municipality in a 404 is a guess about which digit went
+ * wrong, not a place to ship to.
+ *
+ * @param {string} name Section name, `shipping` or `billing`.
+ * @param {string} digits The CEP that was not found.
+ * @param {string} shown The message already in the notice area.
+ */
+ function explain(name, digits, shown) {
+ var helper = window.cepisWooCommerceFaixa;
+
+ if (!helper) {
+ return;
+ }
+
+ helper.message(digits, shown, function (message) {
+ if (message !== shown && progress[name].cep === digits) {
+ announce(message);
+ }
+ });
+ }
+
+ /**
* Write a resolved address into the store.
*
* @param {string} name Section name, `shipping` or `billing`.
@@ -185,7 +216,13 @@
if (progress[name].cep !== digits || error.code === "aborted") {
return;
}
- announce(config.messages[error.code] || error.message);
+
+ var shown = config.messages[error.code] || error.message;
+ announce(shown);
+
+ if (error.code === "not_found") {
+ explain(name, digits, shown);
+ }
},
);
}
diff --git a/assets/js/cepis-woocommerce-classic.js b/assets/js/cepis-woocommerce-classic.js
index 69d8068..481104c 100644
--- a/assets/js/cepis-woocommerce-classic.js
+++ b/assets/js/cepis-woocommerce-classic.js
@@ -23,6 +23,9 @@
var DIACRITIC = /[\u0300-\u036f]/g;
+ /** Class the widget gives the region it announces through. */
+ var STATUS = "cepis-autofill-status";
+
/**
* Compare labels the way a person would: accents and case do not count.
*
@@ -123,6 +126,90 @@
}
/**
+ * The `aria-live` region the widget announces through.
+ *
+ * The widget inserts it immediately after the CEP field and points the
+ * field's `aria-describedby` at it, but only once, when it attaches.
+ * WooCommerce re-renders the address fields afterwards — that is what
+ * `country_to_state_changed` does — and the attribute does not survive it
+ * while the region itself does. So the sibling is what is actually reliable
+ * here, and the surrounding form row is the fallback for a theme that wraps
+ * the input in something of its own.
+ *
+ * Never widened to a document-wide search: billing and shipping each have a
+ * region, and picking the wrong one would tell a shopper about the address
+ * they are not editing.
+ *
+ * @param {string} selector CSS selector for the CEP field.
+ * @returns {HTMLElement|null}
+ */
+ function statusRegion(selector) {
+ var field = document.querySelector(selector);
+
+ if (!field) {
+ return null;
+ }
+
+ var sibling = field.nextElementSibling;
+
+ if (sibling && sibling.classList && sibling.classList.contains(STATUS)) {
+ return sibling;
+ }
+
+ var node = field.parentElement;
+
+ for (var depth = 0; node && depth < 2; depth += 1) {
+ var region = node.querySelector("." + STATUS);
+
+ if (region) {
+ return region;
+ }
+
+ node = node.parentElement;
+ }
+
+ return null;
+ }
+
+ /**
+ * Say which municipality a CEP that does not exist would have been in.
+ *
+ * Rewrites the line the widget just wrote, and nothing else — no field is
+ * touched here, on this path, ever. The CEP was not found; there is no
+ * address to fill anything with, and inventing one from the municipality
+ * would send the parcel to the wrong town.
+ *
+ * Both guards before the write matter, because the answer arrives one round
+ * trip late: the shopper may have typed on, and the widget may have replaced
+ * the line with a "found" of its own in the meantime.
+ *
+ * @param {string} selector CSS selector for the CEP field.
+ * @param {string} cep The CEP that was not found.
+ */
+ function explain(selector, cep) {
+ var helper = window.cepisWooCommerceFaixa;
+ var region = statusRegion(selector);
+
+ if (!helper || !region) {
+ return;
+ }
+
+ var shown = region.textContent;
+
+ helper.message(cep, shown, function (message) {
+ var current = statusRegion(selector);
+ var field = document.querySelector(selector);
+ var typed = field ? String(field.value == null ? "" : field.value).replace(/\D+/g, "") : "";
+
+ if (message === shown || !current || current.textContent !== shown || typed !== cep) {
+ return;
+ }
+
+ current.textContent = message;
+ });
+ }
+
+ /**
* Attach one address section — billing or shipping.
*
* @param {Record<string, string>} map Selectors, keyed by the API's field names.
@@ -143,6 +230,11 @@
onResult: function (address) {
fillState(map.uf, address.uf, address.estado);
},
+ onError: function (error, context) {
+ if (error && "not_found" === error.code) {
+ explain(map.cep, context.cep);
+ }
+ },
};
Object.keys(map).forEach(function (field) {
diff --git a/assets/js/cepis-woocommerce-faixa.js b/assets/js/cepis-woocommerce-faixa.js
new file mode 100644
index 0000000..b911b03
--- /dev/null
+++ b/assets/js/cepis-woocommerce-faixa.js
@@ -0,0 +1,243 @@
+/**
+ * Naming the municipality a CEP that does not exist would have belonged to.
+ *
+ * A CEP that fails at the checkout is rarely a random number. It is almost
+ * always a shopper who knows perfectly well which town they live in and got a
+ * digit wrong — most often by trying the `-000`, which 957 municipalities never
+ * received. Areal/RJ, for instance, answers from 25845-001 upwards and never at
+ * 25845-000. The API says so in the body of its own 404, under `faixa`, and
+ * telling the shopper "that number would be in Areal/RJ" turns a dead end into
+ * something they can fix.
+ *
+ * **What comes back from here is a sentence, and only a sentence.** The `faixa`
+ * object never leaves this module, because a CEP that does not exist has no
+ * address: the municipality named in a 404 is a guess about a typo, not a place
+ * to ship to. Filling a city or a state from it would produce a parcel sent to
+ * the wrong town and a chargeback with it. Both checkout integrations therefore
+ * get a string they can only display.
+ *
+ * The body costs one extra request, because the widget — correctly — throws its
+ * 404 away without reading it. That request happens only on the miss path, only
+ * once per CEP however many sections ask for it, and never at all when the CEP
+ * resolves, which is the overwhelming majority of lookups.
+ *
+ * @module
+ */
+
+(function () {
+ /** Eight digits, split where the mask goes. */
+ var CEP = /^(\d{5})(\d{3})$/;
+
+ /** Anything that is not a digit. */
+ var NOT_DIGITS = /\D+/g;
+
+ /** A `%1$s`-style placeholder, as WordPress writes them. */
+ var PLACEHOLDER = /%(\d)\$s/g;
+
+ /**
+ * One promise of a message per CEP, so retyping the same wrong number and the
+ * billing and shipping sections asking at once all cost a single request.
+ *
+ * @type {Record<string, Promise<string>>}
+ */
+ var asked = {};
+
+ /**
+ * Whatever the page was configured with, read late: this file is a dependency
+ * of the integrations and loads before the inline configuration does.
+ *
+ * @returns {Record<string, any>}
+ */
+ function settings() {
+ return window.cepisWooCommerce || {};
+ }
+
+ /**
+ * The digits of a CEP, however it was typed.
+ *
+ * @param {unknown} value Anything printable.
+ * @returns {string}
+ */
+ function digits(value) {
+ return String(value == null ? "" : value).replace(NOT_DIGITS, "");
+ }
+
+ /**
+ * A CEP with its mask, or an empty string when it is not eight digits.
+ *
+ * @param {unknown} value Anything printable.
+ * @returns {string}
+ */
+ function mask(value) {
+ var match = CEP.exec(String(value == null ? "" : value));
+
+ return match ? match[1] + "-" + match[2] : "";
+ }
+
+ /**
+ * Fill a translated template in.
+ *
+ * A placeholder with no value is left as it stands, so a translation with the
+ * wrong number of arguments degrades into visible nonsense rather than
+ * `undefined`.
+ *
+ * @param {string} template Message with `%1$s` placeholders.
+ * @param {string[]} values Values, in order.
+ * @returns {string}
+ */
+ function fill(template, values) {
+ return template.replace(PLACEHOLDER, function (whole, index) {
+ var value = values[Number(index) - 1];
+
+ return "string" === typeof value ? value : whole;
+ });
+ }
+
+ /**
+ * A non-empty trimmed string, or "".
+ *
+ * @param {unknown} value Anything.
+ * @returns {string}
+ */
+ function text(value) {
+ return "string" === typeof value ? value.trim() : "";
+ }
+
+ /**
+ * Turn a 404 body into the sentence the shopper gets, or "" when it does not
+ * name a municipality.
+ *
+ * Every step is a guard: `faixa` is absent for a CEP in no municipality's
+ * range at all, and the plugin must behave exactly as it did before whenever
+ * anything is missing or the wrong shape.
+ *
+ * The range is deliberately described as the lowest and the highest CEP that
+ * exist there, never as a list and never as a suggestion. It is sparse — about
+ * 118 real CEPs across nearly five thousand numbers in Areal's case — so a
+ * number picked out of the middle of it would most likely not exist either.
+ *
+ * @param {unknown} body The decoded 404 body.
+ * @returns {string}
+ */
+ function describe(body) {
+ var messages = settings().messages || {};
+ var faixa = body && "object" === typeof body && !Array.isArray(body) ? body.faixa : null;
+
+ if (!faixa || "object" !== typeof faixa || Array.isArray(faixa)) {
+ return "";
+ }
+
+ var city = text(faixa.localidade);
+ var uf = text(faixa.uf);
+
+ if ("" === city || "" === uf) {
+ return "";
+ }
+
+ var first = mask(faixa.cep_ini);
+ var last = mask(faixa.cep_fim);
+ var ranged = text(messages.not_found_range);
+
+ if ("" !== first && "" !== last && "" !== ranged) {
+ return fill(ranged, [city, uf, first, last]);
+ }
+
+ var plain = text(messages.not_found_city);
+
+ return "" === plain ? "" : fill(plain, [city, uf]);
+ }
+
+ /**
+ * Fetch the 404 body for one CEP.
+ *
+ * Resolves with `null` for anything that is not a 404 carrying JSON —
+ * including a CEP that has meanwhile started to exist, a network failure, or
+ * a store whose API answers something else entirely. Never rejects.
+ *
+ * @param {string} cep Eight digits.
+ * @returns {Promise<unknown>}
+ */
+ function body(cep) {
+ var config = settings();
+ var base = String(config.baseUrl || "").replace(/\/+$/, "");
+
+ if ("" === base || "function" !== typeof window.fetch) {
+ return Promise.resolve(null);
+ }
+
+ var controller =
+ "function" === typeof window.AbortController ? new window.AbortController() : null;
+ var timer = controller
+ ? window.setTimeout(function () {
+ controller.abort();
+ }, config.timeoutMs || 8000)
+ : 0;
+
+ return window
+ .fetch(base + "/" + cep, {
+ method: "GET",
+ credentials: "omit",
+ headers: { Accept: "application/json" },
+ signal: controller ? controller.signal : undefined,
+ })
+ .then(function (response) {
+ return 404 === response.status ? response.text() : "";
+ })
+ .then(function (payload) {
+ try {
+ return JSON.parse(payload);
+ } catch (error) {
+ return null;
+ }
+ })
+ .catch(function () {
+ return null;
+ })
+ .then(function (decoded) {
+ window.clearTimeout(timer);
+
+ return decoded;
+ });
+ }
+
+ /**
+ * Ask what a CEP that does not exist would have been.
+ *
+ * `done` is always called exactly once, with either a better message or the
+ * fallback unchanged. It is never called with anything but a string, and the
+ * caller is given no way to reach the underlying data.
+ *
+ * @param {string} cep The CEP that was not found.
+ * @param {string} fallback The message already on screen.
+ * @param {(message: string) => void} done Called with what to show.
+ */
+ function message(cep, fallback, done) {
+ if ("function" !== typeof done) {
+ return;
+ }
+
+ var number = digits(cep);
+
+ if (8 !== number.length) {
+ done(fallback);
+ return;
+ }
+
+ if (!asked[number]) {
+ asked[number] = body(number).then(describe, function () {
+ return "";
+ });
+ }
+
+ asked[number].then(
+ function (better) {
+ done("" === better ? fallback : better);
+ },
+ function () {
+ done(fallback);
+ },
+ );
+ }
+
+ window.cepisWooCommerceFaixa = { message: message };
+})();