diff options
Diffstat (limited to 'assets')
| -rw-r--r-- | assets/js/cepis-woocommerce-blocks.js | 39 | ||||
| -rw-r--r-- | assets/js/cepis-woocommerce-classic.js | 92 | ||||
| -rw-r--r-- | assets/js/cepis-woocommerce-faixa.js | 243 |
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 }; +})(); |