diff options
Diffstat (limited to 'assets/js/cepis-woocommerce-faixa.js')
| -rw-r--r-- | assets/js/cepis-woocommerce-faixa.js | 243 |
1 files changed, 243 insertions, 0 deletions
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 }; +})(); |