summaryrefslogtreecommitdiffstats
path: root/assets
diff options
context:
space:
mode:
Diffstat (limited to 'assets')
-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 };
+})();