feat: fill the delivery address from the CEP and quote freight as it is typed
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m21s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m47s

A complete CEP now looks up its address on the server (ViaCEP, rate
limited, keeping the Site's CSP to its own origin) and fills street,
district, city and state, moving the cursor to the first field left, and
quotes the freight without pressing Calcular. The CEP keeps its mask when
the cart is restored. Freight and its delivery time appear in the order
summary instead of a line under the CEP, the delivery option says it ships
with Jadlog, and the address reminder is a hint rather than an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-29 10:31:41 -03:00
parent e20c8673bb
commit 690b15ece1
7 changed files with 58 additions and 10 deletions

View File

@@ -1,6 +1,8 @@
"""Health, session bootstrap and freight quoting.""" """Health, session bootstrap, freight quoting and the address of a CEP."""
import os import os
import re
import httpx
from fastapi import APIRouter, HTTPException, Request, Response from fastapi import APIRouter, HTTPException, Request, Response
from ..core import db from ..core import db
@@ -48,3 +50,23 @@ def quote_freight(body: FreightEstimate):
return freight.quote(body.service, body.postal_code, body.metres, body.declared_cents) return freight.quote(body.service, body.postal_code, body.metres, body.declared_cents)
except ValueError as exc: except ValueError as exc:
raise HTTPException(422, str(exc)) raise HTTPException(422, str(exc))
# The address of a CEP, so the cart fills it in. Looked up here rather than in
# the browser, which keeps the Site's CSP to its own origin.
VIACEP = 'https://viacep.com.br/ws/{}/json/'
CEP_LIMIT = 120
@router.get('/api/cep/{cep}')
def cep_address(cep: str, request: Request):
if not re.fullmatch(r'[0-9]{8}', cep):
raise HTTPException(422, 'CEP must have eight digits')
rate_limit('cep-lookup', client_ip(request), CEP_LIMIT, 900)
try:
response = httpx.get(VIACEP.format(cep), timeout=5)
data = response.json() if response.status_code == 200 else {}
except (httpx.HTTPError, ValueError):
raise HTTPException(503, 'Consulta de CEP indisponível')
if not data or data.get('erro'):
raise HTTPException(404, 'CEP não encontrado')
return {'street': data.get('logradouro') or '', 'district': data.get('bairro') or '',
'city': data.get('localidade') or '', 'state': data.get('uf') or ''}

View File

@@ -34,7 +34,7 @@
// Freight must be quoted again; restored browser values are never final. // Freight must be quoted again; restored browser values are never final.
if(entrega.tipo==='frete'){entrega.cotado=false;entrega.valor=0;} if(entrega.tipo==='frete'){entrega.cotado=false;entrega.valor=0;}
for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key]; for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];
$('cepIn').value=entrega.cep; $('cepIn').value=fmtCep(entrega.cep);
entrega.end=entrega.end||{}; entrega.end=entrega.end||{};
for(const [id,key] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp','comp'], for(const [id,key] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp','comp'],
['eBairro','bairro'],['eCidade','cidade'],['eUf','uf']])$(id).value=entrega.end[key]||''; ['eBairro','bairro'],['eCidade','cidade'],['eUf','uf']])$(id).value=entrega.end[key]||'';

View File

@@ -118,10 +118,10 @@
const result = await api('/freight',{service,postal_code:cep,metres:metres.toFixed(2),declared_cents:cents}); const result = await api('/freight',{service,postal_code:cep,metres:metres.toFixed(2),declared_cents:cents});
if (entrega.cep !== cep || entrega.tipo !== 'frete') return; if (entrega.cep !== cep || entrega.tipo !== 'frete') return;
entrega.valor = result.total_cents/100; entrega.valor = result.total_cents/100;
entrega.dias = result.days || null;
entrega.cotado = true; entrega.cotado = true;
freightQuoted = JSON.stringify(pacote()); freightQuoted = JSON.stringify(pacote());
$('cepMsg').textContent = 'Frete: '+rs(entrega.valor)+ $('cepMsg').textContent = '';
(result.days ? ' · entrega em até '+result.days+' dias úteis.' : '.');
pintaEntrega(); pintaEntrega();
} catch(error) { $('cepMsg').textContent = error.message; } } catch(error) { $('cepMsg').textContent = error.message; }
}; };

View File

@@ -644,6 +644,7 @@ h1 em{font-style:normal;color:var(--laranja)}
font-family:"Inter",sans-serif;font-weight:600;font-size:12.5px} font-family:"Inter",sans-serif;font-weight:600;font-size:12.5px}
.cepL button:hover{border-color:var(--laranja);color:var(--laranja)} .cepL button:hover{border-color:var(--laranja);color:var(--laranja)}
.cep p{font-size:11.5px;color:var(--fraco);margin-top:9px;line-height:1.5} .cep p{font-size:11.5px;color:var(--fraco);margin-top:9px;line-height:1.5}
.cep p:empty{display:none}
.cep .endereco{margin-top:12px} .cep .endereco{margin-top:12px}
.cep .err{color:var(--vermelho)} .cep .err{color:var(--vermelho)}
.avisoE:empty{display:none} .avisoE:empty{display:none}
@@ -1162,7 +1163,7 @@ footer a:hover{color:var(--laranja2)}
<span class="mk"></span> <span class="mk"></span>
<div> <div>
<b>Receber em casa</b> <b>Receber em casa</b>
<span>Cotamos com a transportadora pelo seu CEP</span> <span>Entrega pela Jadlog em todo o Brasil. Informe o CEP para ver o valor e o prazo.</span>
</div> </div>
<em id="vFrete">—</em> <em id="vFrete">—</em>
</button> </button>
@@ -1189,7 +1190,7 @@ footer a:hover{color:var(--laranja2)}
<div class="cp"><label>UF</label> <div class="cp"><label>UF</label>
<input id="eUf" maxlength="2" autocomplete="address-level1" placeholder="SP"></div> <input id="eUf" maxlength="2" autocomplete="address-level1" placeholder="SP"></div>
</div> </div>
<p class="err" id="eEnd"></p> <p class="dicaEnd" id="eEnd"></p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -49,7 +49,8 @@ function pintaPedido(){
'<div class="l"><span>'+n+(n===1?' item':' itens')+'</span><b>'+rs(itens+eco)+'</b></div>'+ '<div class="l"><span>'+n+(n===1?' item':' itens')+'</span><b>'+rs(itens+eco)+'</b></div>'+
(eco>0.01? '<div class="l" style="color:var(--verde-ml)"><span>Desconto pela nota</span>'+ (eco>0.01? '<div class="l" style="color:var(--verde-ml)"><span>Desconto pela nota</span>'+
'<b style="color:var(--verde-ml)">− '+rs(eco)+'</b></div>' : '')+ '<b style="color:var(--verde-ml)">− '+rs(eco)+'</b></div>' : '')+
'<div class="l"><span>Frete</span><b>'+ '<div class="l"><span>Frete'+(entrega.tipo==='frete' && entrega.cotado && entrega.dias
? ' · até '+entrega.dias+' dias úteis' : '')+'</span><b>'+
(entrega.tipo==='retira' ? '<span style="color:var(--verde-ml)">Grátis</span>' (entrega.tipo==='retira' ? '<span style="color:var(--verde-ml)">Grátis</span>'
: entrega.cotado ? rs(frete) : 'a cotar')+'</b></div>'+ : entrega.cotado ? rs(frete) : 'a cotar')+'</b></div>'+
'<div class="l"><span>Revisão e reencaixe</span>'+ '<div class="l"><span>Revisão e reencaixe</span>'+

View File

@@ -106,7 +106,7 @@ function pintaEntrega(){
: entrega.cotado ? rs(entrega.valor) : 'a cotar'; : entrega.cotado ? rs(entrega.valor) : 'a cotar';
$('bPagar').disabled = !entrega.cotado || !clienteOk() || !enderecoOk() || !cartPodeEnviar(); $('bPagar').disabled = !entrega.cotado || !clienteOk() || !enderecoOk() || !cartPodeEnviar();
$('eEnd').textContent = entrega.tipo==='frete' && entrega.cotado && !enderecoOk() $('eEnd').textContent = entrega.tipo==='frete' && entrega.cotado && !enderecoOk()
? 'Preencha o endereço de entrega para seguir.' : ''; ? 'Complete o endereço de entrega para ir ao pagamento.' : '';
pintaPedido(); pintaPedido();
} }
@@ -158,10 +158,31 @@ document.querySelectorAll('.opE').forEach(b=>b.addEventListener('click',()=>{
if(entrega.tipo==='frete') $('cepMsg').innerHTML=''; if(entrega.tipo==='frete') $('cepMsg').innerHTML='';
pintaEntrega(); pintaEntrega();
})); }));
function fmtCep(v){ v=String(v||'').replace(/\D/g,'').slice(0,8); return v.length>5 ? v.slice(0,5)+'-'+v.slice(5) : v; }
// The address of a complete CEP fills the form; only the number is left to type.
let cepBuscado='';
async function buscaEndereco(cep){
if(cep===cepBuscado || !window.dtfApi) return;
cepBuscado=cep;
try{
const a=await window.dtfApi('/cep/'+cep);
if(entrega.cep!==cep) return;
for(const [id,chave,valor] of [['eRua','rua',a.street],['eBairro','bairro',a.district],
['eCidade','cidade',a.city],['eUf','uf',a.state]]){
if(!valor) continue;
$(id).value=valor; entrega.end={...(entrega.end||{}),[chave]:valor};
}
pintaEntrega();
const vazio=['eNome','eRua','eNum'].map($).find(el=>!el.value.trim());
if(vazio && document.activeElement===$('cepIn')) vazio.focus();
}catch(e){ /* sem consulta: o cliente preenche o endereço */ }
}
$('cepIn').addEventListener('input',e=>{ $('cepIn').addEventListener('input',e=>{
const v=e.target.value.replace(/\D/g,'').slice(0,8); const v=e.target.value.replace(/\D/g,'').slice(0,8);
e.target.value = v.length>5 ? v.slice(0,5)+'-'+v.slice(5) : v; e.target.value = fmtCep(v);
entrega.cep=v; entrega.cotado=false; entrega.valor=0; pintaEntrega(); if(v===entrega.cep) return;
entrega.cep=v; entrega.cotado=false; entrega.valor=0; entrega.dias=null; pintaEntrega();
if(v.length===8){ buscaEndereco(v); if(window.dtfFreight) window.dtfFreight(); }
}); });
for(const [id,chave] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp','comp'], for(const [id,chave] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp','comp'],
['eBairro','bairro'],['eCidade','cidade'],['eUf','uf']]){ ['eBairro','bairro'],['eCidade','cidade'],['eUf','uf']]){
@@ -173,6 +194,7 @@ for(const [id,chave] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp'
} }
$('bCep').addEventListener('click',async()=>{ $('bCep').addEventListener('click',async()=>{
if(entrega.cep.length!==8){ $('cepMsg').innerHTML='CEP incompleto.'; return; } if(entrega.cep.length!==8){ $('cepMsg').innerHTML='CEP incompleto.'; return; }
buscaEndereco(entrega.cep);
if(window.dtfFreight){ await window.dtfFreight(); return; } if(window.dtfFreight){ await window.dtfFreight(); return; }
// A cotação vem da transportadora. Enquanto a integração não existir, o pedido não // A cotação vem da transportadora. Enquanto a integração não existir, o pedido não
// fecha por frete: o valor precisa estar fechado antes do Mercado Pago. // fecha por frete: o valor precisa estar fechado antes do Mercado Pago.

View File

@@ -228,6 +228,8 @@ section+section{border-top:0}
/* The payment pages: paying on the left, the order summary on the right */ /* The payment pages: paying on the left, the order summary on the right */
html:is([data-rota="pagamento"],[data-rota="pix"]) .checkout{max-width:1040px;margin-top:8px} html:is([data-rota="pagamento"],[data-rota="pix"]) .checkout{max-width:1040px;margin-top:8px}
html:is([data-rota="pagamento"],[data-rota="pix"]) .pagGrid{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:32px;align-items:start} html:is([data-rota="pagamento"],[data-rota="pix"]) .pagGrid{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:32px;align-items:start}
.dicaEnd{font-size:13px;color:var(--texto2);margin-top:12px}
.dicaEnd:empty{display:none}
.pagCab{display:flex;align-items:baseline;justify-content:space-between;gap:16px;margin-bottom:20px} .pagCab{display:flex;align-items:baseline;justify-content:space-between;gap:16px;margin-bottom:20px}
.pagCab h3{font-size:26px;font-weight:800;letter-spacing:-.02em} .pagCab h3{font-size:26px;font-weight:800;letter-spacing:-.02em}
.pagVolta{font-size:14px;color:var(--texto2)} .pagVolta{font-size:14px;color:var(--texto2)}