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
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
|
# cepis-autofill
Drop-in address autofill for Brazilian checkouts, on top of the
[public CEP API at cep.is](https://cep.is). The shopper types a CEP; the
widget fills the street, neighbourhood, city and state fields you point it
at. Official Correios data (DNE).
One `<script>` tag, no build step, no framework, no dependencies.
**3.7 KB minified and gzipped** (8.8 KB minified, 9042 bytes).
```html
<script src="cepis-autofill.js"></script>
<script>
CepisAutofill.attach({
cep: "#cep",
logradouro: "#endereco",
bairro: "#bairro",
localidade: "#cidade",
uf: "#estado",
numero: "#numero",
});
</script>
```
Nothing about any particular e-commerce platform is baked in: which field
is which is configuration. See
[Mapping your platform's fields](#mapping-your-platforms-fields).
## Install
Copy `dist/cepis-autofill.min.js` next to your theme's other assets and load
it with a `<script>` tag. It defines the global `CepisAutofill` and nothing
else.
Bundling instead? `dist/cepis-autofill.esm.js` is the same code as an ES
module:
```js
import { attach } from "./cepis-autofill.esm.js";
```
Requires a browser with `fetch` and `AbortController` — every browser since 2017. Run `make build` to produce `dist/`.
## Usage
Give `attach` the field the shopper types into, plus the fields to fill.
Every selector accepts a CSS string or an element:
```js
const handle = CepisAutofill.attach({
cep: "#cep",
logradouro: "#endereco",
bairro: "#bairro",
localidade: "#cidade",
uf: "#estado",
numero: "#numero",
focusNumero: true,
});
```
A lookup goes out once the field holds 8 digits and the shopper stops
typing. The `01001-000` mask is accepted. Repeat lookups of the same CEP are
served from memory, so going back to fix the house number costs nothing.
A `<select>` for the state is matched by option value or label, against both
the two-letter UF and the full state name — `SP` and `São Paulo` both work.
The `numero` field is never written to; it exists so `focusNumero` knows
where to send the cursor.
`attach` returns a handle:
```js
handle.lookup(); // run now, skipping the debounce
handle.lookup("01001-000"); // …for a specific CEP
handle.detach(); // remove listeners, abort anything in flight
```
Call `handle.lookup()` when your checkout writes into the CEP field without
firing an `input` event — a "use my saved address" button, for instance.
### Options
| option | default | what it does |
| ----------------------------------------------------------------------------------------------------------------------- | ---------------- | --------------------------------------------------------- |
| `cep` | — | the field the shopper types into (required) |
| `logradouro`, `complemento`, `unidade`, `bairro`, `localidade`, `uf`, `estado`, `regiao`, `ibge`, `gia`, `ddd`, `siafi` | — | fields to fill; map only the ones you have |
| `numero` | — | house-number field, for `focusNumero` |
| `baseUrl` | `https://cep.is` | API root; point it at your own instance |
| `debounceMs` | `250` | quiet period after the last keystroke |
| `timeoutMs` | `8000` | deadline for one request |
| `overwrite` | `"auto"` | what to do with a field that already has a value |
| `focusNumero` | `false` | move focus to `numero` after a successful lookup |
| `status` | auto | where to announce progress |
| `messages` | see below | override any status message |
| `onResult` | — | `(address, { cep }) => void`, after the fields are filled |
| `onError` | — | `(error, { cep }) => void` |
| `onStatus` | — | `(state, message) => void` for a custom UI |
`overwrite` decides what happens to a field that is not empty:
- `"auto"` — fill empty fields, replace values this widget wrote itself, and
leave anything the shopper typed alone. A manual correction survives the
next CEP.
- `false` — only ever fill empty fields.
- `true` — always overwrite.
The widget writes through the native value setter and dispatches `input` and
`change`, so validation in the page — including React's — sees the values.
It never reformats what the shopper typed in the CEP field.
### Status and accessibility
Unless you pass `status`, a `<div role="status" aria-live="polite">` is
inserted right after the CEP field and referenced from its
`aria-describedby`. It is created empty, at a fixed minimum height, so
announcing something never shifts the page. Focus is never taken, except by
`focusNumero`, which moves it only while the CEP field still has focus.
Style it through `.cepis-autofill-status[data-cepis-state="…"]`, where the
state is `idle`, `loading`, `found` or `error`. The message text carries the
meaning on its own — do not use colour as the only signal:
```css
.cepis-autofill-status[data-cepis-state="error"]::before {
content: "! ";
}
```
Pass `status: "#my-node"` to use an element of your own, or `status: false`
to render nothing and drive your own UI from `onStatus`.
### Messages and errors
Every failure has its own message, in Portuguese, and reaches both the
status region and `onError`:
| `error.code` | message | worth retrying? |
| ------------------ | ----------------------------------------------------------------------------------------- | --------------- |
| `invalid` | CEP inválido. Digite os 8 dígitos. | no |
| `not_found` | CEP não encontrado. Confira o número ou preencha o endereço manualmente. | no |
| `not_found` + city | CEP não encontrado. Esse número seria de Areal/RJ, que tem CEPs de 25845-001 a 25849-899. | no |
| `rate_limited` | Muitas consultas em pouco tempo. Aguarde alguns segundos e tente de novo. | later |
| `server` | O serviço de CEP está indisponível. Preencha o endereço manualmente. | later |
| `network` | Não foi possível consultar o CEP. Verifique sua conexão e tente de novo. | later |
| `timeout` | A consulta do CEP demorou demais. Tente de novo ou preencha manualmente. | later |
| `invalid_response` | Resposta inesperada do serviço de CEP. Preencha o endereço manualmente. | probably not |
Override any of them with `messages`:
```js
CepisAutofill.attach({
cep: "#cep",
localidade: "#cidade",
messages: { not_found: "Não achamos esse CEP. Digite o endereço." },
});
```
`rate_limited` carries `retryAfterMs` and `server` carries `status`.
### When the CEP does not exist, but the city does
A CEP that fails is rarely a random number. It is usually someone who knows
the city and guessed the last digits — most often the `-000` that 957
municipalities never received. Areal/RJ, for one, answers from `25845-001`
up, and never at `25845-000`.
So when the API can tell which municipality the number would have belonged
to, the message says it:
> CEP não encontrado. Esse número seria de Areal/RJ, que tem CEPs de
> 25845-001 a 25849-899.
**Nothing is filled in.** This is still a `not_found`: the address does not
exist, so the widget writes no field — not the city, not the state, nothing.
Filling the city from a CEP that does not exist would quietly produce a wrong
address, which is worse than an empty form. Only the wording of the message
changes.
The range is not a list of valid CEPs either. `cep_ini` and `cep_fim` are the
lowest and the highest CEP that really exist in that municipality, and what
lies between them is sparse — Areal has 118 CEPs spread over some 4,900
numbers. Show it as a hint of where to look; never suggest a CEP from inside
it.
Where the API says nothing about a municipality, the plain
`CEP não encontrado…` message stands, exactly as before. The same goes for a
reply this widget cannot make sense of.
Two extra entries in `messages` carry the wording, with `{localidade}`,
`{uf}`, `{cep_ini}` and `{cep_fim}` filled in:
| key | used when |
| ---------------------- | ------------------------------------------- |
| `not_found_city_range` | the municipality and its first and last CEP |
| `not_found_city` | the municipality, but no usable range |
```js
CepisAutofill.attach({
cep: "#cep",
localidade: "#cidade",
messages: {
not_found_city_range:
"Esse CEP não existe. Em {localidade}/{uf} eles vão de {cep_ini} a {cep_fim}.",
},
});
```
`onError` gets the whole thing as `error.faixa` — `localidade`, `uf`, and
whichever of `estado`, `regiao`, `ibge`, `cep_ini`, `cep_fim` and `ceps` the
API sent — for a richer UI of your own. It is absent, never null, when there
is no municipality to name.
### Looking a CEP up yourself
`CepisAutofill.lookup` is the request layer on its own — same cache, same
errors, no DOM:
```js
const address = await CepisAutofill.lookup("01001-000");
address.logradouro; // "Praça da Sé"
address.uf; // "SP"
```
It resolves to the API's own JSON: Portuguese field names, empty strings
rather than nulls, and `geo` present only for the roughly 57% of CEPs that
have a coordinate. It rejects with a `CepisAutofillError`, whose `faixa` —
when the CEP does not exist but its municipality does — is
[described above](#when-the-cep-does-not-exist-but-the-city-does).
## Mapping your platform's fields
Every checkout names its fields differently, and the same platform names
them differently between themes, versions and one-page checkout apps. **No
per-platform preset is shipped here, because none could be verified against
a live store.** Finding yours takes a minute:
1. Open your checkout, right-click the CEP field, Inspect.
2. Read the `id` or `name` on the `<input>`. An `id` gives you `#billing_postcode`;
a `name` gives you `[name="postalCode"]`.
3. Do the same for street, neighbourhood, city, state and number.
4. Paste this in the console to confirm every selector resolves:
```js
["#cep", "#endereco", "#bairro", "#cidade", "#estado", "#numero"].forEach((s) =>
console.log(s, document.querySelector(s)),
);
```
Anything printing `null` is wrong. A selector that resolves to nothing is
skipped silently, which is what makes a wrong one hard to spot.
Two things worth knowing for checkouts that re-render (VTEX, Shopify and
most React-based ones):
- Pass **CSS strings, not elements**. Strings are resolved on every use and
the listener is delegated from `document`, so a form that is destroyed and
rebuilt keeps working. Elements are held directly and stop working when
that node is replaced.
- Load the widget after the checkout has rendered its form once, or call
`attach` from whatever hook the platform gives you for "checkout ready".
Where the CEP field is a component that writes its value without firing
`input`, keep the handle and call `handle.lookup()` from the component's own
change hook.
## Development
```sh
make # list the targets
make test # bun test — drives a real Chromium against a fake API
make lint # prettier --check plus a bundle syntax check
make format # apply prettier
make build # bundle to dist/ and print the gzipped size
make check # lint, then test, then build
```
The tests stand up two `Bun.serve` instances — one serving the checkout
fixture with a freshly built bundle, one impersonating cep.is — and drive
Chromium through Playwright: real keystrokes, real `fetch`, real aborts.
They need a Chromium build; install one with
`bunx playwright install chromium`, or point `CEPIS_CHROMIUM` at an existing
binary.
`demo.html` runs against the real API. Run `make build` and open it.
## Licence
ISC. See [LICENSE](LICENSE).
|