summaryrefslogtreecommitdiffstats
path: root/assets/js/cepis-woocommerce-faixa.js
diff options
context:
space:
mode:
Diffstat (limited to 'assets/js/cepis-woocommerce-faixa.js')
-rw-r--r--assets/js/cepis-woocommerce-faixa.js243
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 };
+})();