Ce guide construit l'étape adresse d'un tunnel de commande : l'utilisateur tape, choisit une ville, et les champs pays, région et code postal se remplissent tout seuls et se valident. Environ 60 lignes de JavaScript.
Trois endpoints font le travail :
/v1/places/autocomplete — des suggestions pendant la saisie/v1/places/details — la fiche complète de ce qui a été choisi/v1/places/validate — vérification finale avant d'enregistrerUne autocomplétion envoie une requête par frappe. Pour garder un coût prévisible, générez un sessiontoken quand le champ prend le focus et réutilisez-le pour toutes les requêtes de ce champ, y compris l'appel final à details. Le champ entier coûte 1 jeton, quelle que soit la quantité de texte saisie.
let session = crypto.randomUUID();
input.addEventListener('focus', () => {
session = crypto.randomUUID(); // une session par adresse saisie par l'utilisateur
});
Les sessions durent 3 minutes. Si vous omettez le jeton, chaque requête coûte 1 jeton.
<input id="city" placeholder="Commencez à saisir votre ville..." autocomplete="off" />
<ul id="suggestions"></ul>
<input id="country" readonly />
<input id="state" readonly />
<input id="zip" placeholder="Code postal" />
<p id="zip-error"></p>
const API_KEY = 'votre-cle-api';
const BASE = 'https://api.countrydataapi.com/v1/places';
const input = document.getElementById('city');
const list = document.getElementById('suggestions');
let session = crypto.randomUUID();
let timer;
let postal = null;
input.addEventListener('focus', () => { session = crypto.randomUUID(); });
// Debounce : une requête par pause, pas par frappe.
input.addEventListener('input', () => {
clearTimeout(timer);
const q = input.value.trim();
if (q.length < 2) { list.innerHTML = ''; return; }
timer = setTimeout(() => search(q), 150);
});
async function search(q) {
const params = new URLSearchParams({
apikey: API_KEY,
q,
types: 'city',
limit: '5',
lang: 'fr',
sessiontoken: session,
});
const response = await fetch(`${BASE}/autocomplete?${params}`);
const { suggestions } = await response.json();
list.innerHTML = '';
suggestions.forEach((suggestion) => {
const item = document.createElement('li');
item.textContent = suggestion.description; // "Madrid, Communauté de Madrid, Espagne"
item.onclick = () => choose(suggestion);
list.appendChild(item);
});
}
async function choose(suggestion) {
list.innerHTML = '';
input.value = suggestion.name;
const params = new URLSearchParams({
apikey: API_KEY,
id: suggestion.id,
type: suggestion.type,
lang: 'fr',
sessiontoken: session, // clôt la session : cet appel est gratuit
});
const response = await fetch(`${BASE}/details?${params}`);
const { place } = await response.json();
document.getElementById('country').value = place.components.country?.name ?? '';
document.getElementById('state').value = place.components.state?.name ?? '';
// Les règles de code postal du pays, pour valider le champ suivant dans le navigateur.
postal = place.postal;
document.getElementById('zip').placeholder = postal.example ?? 'Code postal';
}
document.getElementById('zip').addEventListener('blur', (event) => {
const error = document.getElementById('zip-error');
if (!postal?.regex) { error.textContent = ''; return; }
const ok = new RegExp(postal.regex).test(event.target.value.trim());
error.textContent = ok ? '' : `Format attendu : ${postal.example ?? postal.format}`;
});
Voilà toute l'interaction. Le champ description est déjà formaté pour une liste déroulante, il n'y a donc aucune chaîne à assembler.
import { useEffect, useRef, useState } from 'react';
const API_KEY = 'votre-cle-api';
const BASE = 'https://api.countrydataapi.com/v1/places';
export function useAddressAutocomplete() {
const [query, setQuery] = useState('');
const [suggestions, setSuggestions] = useState([]);
const [address, setAddress] = useState(null);
const session = useRef(crypto.randomUUID());
useEffect(() => {
if (query.trim().length < 2) {
setSuggestions([]);
return;
}
// Annule les requêtes en cours pour qu'une réponse lente n'écrase pas une plus récente.
const controller = new AbortController();
const timer = setTimeout(async () => {
const params = new URLSearchParams({
apikey: API_KEY,
q: query.trim(),
types: 'city',
limit: '5',
lang: 'fr',
sessiontoken: session.current,
});
try {
const response = await fetch(`${BASE}/autocomplete?${params}`, {
signal: controller.signal,
});
const data = await response.json();
setSuggestions(data.suggestions ?? []);
} catch (error) {
if (error.name !== 'AbortError') throw error;
}
}, 150);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]);
async function select(suggestion) {
const params = new URLSearchParams({
apikey: API_KEY,
id: suggestion.id,
type: suggestion.type,
lang: 'fr',
sessiontoken: session.current,
});
const response = await fetch(`${BASE}/details?${params}`);
const { place } = await response.json();
setAddress(place);
setSuggestions([]);
setQuery(place.name);
session.current = crypto.randomUUID(); // adresse suivante, session suivante
}
return { query, setQuery, suggestions, address, select };
}
function AddressField() {
const { query, setQuery, suggestions, address, select } = useAddressAutocomplete();
return (
<div>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Commencez à saisir votre ville..."
autoComplete="off"
/>
{suggestions.length > 0 && (
<ul>
{suggestions.map((suggestion) => (
<li key={suggestion.id} onClick={() => select(suggestion)}>
{suggestion.description}
</li>
))}
</ul>
)}
{address && (
<>
<input readOnly value={address.components.country?.name ?? ''} />
<input readOnly value={address.components.state?.name ?? ''} />
<input placeholder={address.postal.example ?? 'Code postal'} />
</>
)}
</div>
);
}
La validation dans le navigateur est un confort pour l'utilisateur. Avant d'enregistrer ou d'expédier quoi que ce soit, confirmez la combinaison complète côté serveur :
const params = new URLSearchParams({
apikey: process.env.COUNTRY_DATA_API_KEY,
country: form.country,
state: form.state,
city: form.city,
zipcode: form.zip,
lang: 'fr',
});
const response = await fetch(
`https://api.countrydataapi.com/v1/places/validate?${params}`
);
const { result } = await response.json();
if (!result.valid && result.has_corrections) {
// Proposez la correction au lieu de rejeter le formulaire d'emblée.
return { needsConfirmation: result.corrections };
}
// Enregistrez la forme canonique, pas ce que l'utilisateur a tapé.
await orders.save({
country: result.normalized.country?.name,
state: result.normalized.state?.name,
city: result.normalized.city?.name,
zipcode: result.normalized.zipcode,
});
Enregistrer result.normalized plutôt que la saisie brute, c'est ce qui rend vos données d'adresses interrogeables par la suite : pas de Cataluna à côté d'un Cataluña, pas de MADRID à côté d'un Madrid.
Si votre formulaire ne comporte qu'un code postal et une ville, vous n'avez pas besoin du champ province : le code postal le résout.
const { result } = await api.places.validate({ country: 'ES', zipcode: '28001' });
result.normalized.state?.name; // "Communauté de Madrid"
Un champ de moins améliore de façon mesurable la conversion d'un tunnel de commande.
| Action | Jetons |
|---|---|
| Un champ d'adresse complet (autant de frappes que nécessaire + details) | 1 |
| Validation côté serveur du formulaire soumis | 1 |
| Formats postaux de tous les pays (à mettre en cache) | 1, une fois |
Une adresse de commande complétée coûte donc 2 jetons, et peu importe la vitesse de frappe de l'utilisateur.
Le jeu de données est administratif : pays, états, villes et codes postaux. Il n'y a pas de données au niveau de la rue, pas de coordonnées en dessous du niveau pays, et pas de points d'intérêt — ni commerces, ni horaires, ni avis, ni photos. Si vous devez géocoder une adresse postale complète ou rechercher un restaurant, ce n'est pas le bon outil.