summaryrefslogtreecommitdiffstats
path: root/assets/js/cepis-woocommerce-faixa.js
blob: b911b034eb11635b8a33983461c045c389f053ea (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
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 };
})();