feat: give payment its own page
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m12s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m35s

The PIX and card choices appeared under the cart, on the same page as the
customer's details. "Ir para o pagamento" now sends the order and opens
/pagamento, step 3 of the progress bar: the server's order summary, then PIX
or card, each opening below. The cart keeps only the sending progress and its
errors; a changed cart is sent again instead of offering the old quote.
Portal links open the payment page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-28 11:45:44 -03:00
parent 536510b148
commit 43043c361c
9 changed files with 113 additions and 40 deletions

View File

@@ -1,7 +1,11 @@
/* Checkout bridge only: approved commercial functions in web/index.html stay intact. */
/* Checkout bridge only: approved commercial functions in web/index.html stay intact.
The cart sends the order; the quote is paid on its own page (/pagamento). */
(() => {
const status = document.getElementById('checkoutStatus');
const actions = document.getElementById('checkoutActions');
const resumo = document.getElementById('pagResumo');
const naPagina = () => document.documentElement.dataset.rota === 'pagamento';
let area = null;
let busy = false;
let draftId = localStorage.getItem('dtf-quote');
let requestKey = localStorage.getItem('dtf-request-key');
@@ -46,14 +50,39 @@
window.dtfApi=api;
ready.catch(error => { status.textContent = error.message; });
function message(text) { status.textContent = text; }
function button(label, handler) {
function button(label, handler, into = actions) {
const el = document.createElement('button');
el.textContent = label;
el.style.cssText = 'margin:8px 8px 0 0;padding:8px 14px;cursor:pointer';
el.onclick = handler;
actions.append(el);
into.append(el);
return el;
}
// What the customer is paying for, from the server's approval.
function pintaResumo(quote) {
const approved = quote && quote.status !== 'paid' ? quote.approved : null;
resumo.hidden = !approved;
if (!approved) { resumo.replaceChildren(); return; }
const linha = (label, value, className='l') => {
const row=document.createElement('div'); row.className=className;
const name=document.createElement('span'); name.textContent=label;
const amount=document.createElement('b'); amount.textContent=value;
row.append(name,amount); return row;
};
const rows = approved.items.map(item => linha(
(MODOS[item.mode]?.tit || item.mode)+' · '+fmtM(Number(item.billed_metres))+' m', rs(item.total_cents/100)));
const pickup = approved.freight.service === 'pickup';
rows.push(linha(pickup ? 'Retirada em Franca' : 'Frete',
approved.freight.total_cents ? rs(approved.freight.total_cents/100) : 'Grátis'));
rows.push(linha('Total', rs(approved.total_cents/100), 'tot'));
resumo.replaceChildren(...rows);
}
function semPedido() {
pintaResumo(null);
actions.replaceChildren();
message('Nenhum pedido aguardando pagamento.');
button('Ir para o carrinho', () => vaiPara(CARRINHO));
}
async function upload(file) {
const session=await ready;
return window.dtfUpload(file,{api,progress:message,scope:session.cart_scope});
@@ -71,7 +100,9 @@
};
window.dtfCheckout = async () => {
if (busy) return;
if (draftId) { await refresh(); status.scrollIntoView({behavior:'smooth',block:'nearest'}); return; }
// The cart that was sent goes straight to its payment; a changed cart is sent again.
if (draftId && quotedCart === cartSnapshot()) { await refresh(true); return; }
if (draftId) clearDraft();
if (!clienteOk() || !entrega.cotado) return;
if (!enderecoOk()) return message('Preencha o endereço de entrega.');
if (!cartPodeEnviar()) return message('Revise a qualidade e confirme a ressalva de cada item antes de enviar o pedido.');
@@ -108,19 +139,22 @@
const quote = await api('/quotes',{request_key:requestKey,...content});
quotedCart=initialCart;localStorage.setItem('dtf-quote-cart',quotedCart);
draftId=quote.id; localStorage.setItem('dtf-quote',draftId);
await refresh();
status.scrollIntoView({behavior:'smooth',block:'nearest'});
await refresh(true);
} catch(error) { message(error.message); }
finally { busy=false; pintaEntrega(); }
};
async function refresh() {
if (!draftId) return;
// `go`: the customer asked to pay, so the cart page moves to the payment page.
async function refresh(go) {
if (!naPagina()) { if (!go || !draftId) return; }
else if (!draftId) { semPedido(); return; }
const version=++refreshVersion, shownId=draftId;
try {
await ready;
const quote=await api('/quotes/'+shownId);
if(version!==refreshVersion || draftId!==shownId) return;
if (!naPagina()) { message(''); vaiPara(PAGAMENTO); return; }
actions.replaceChildren();
pintaResumo(quote);
if (quote.status!=='paid' && (!quotedCart || quotedCart!==cartSnapshot())) {
message('O carrinho mudou ou não está disponível neste navegador. A cotação anterior continua separada; envie o carrinho atual para uma nova revisão.');
button('Enviar carrinho atual',()=>{clearDraft();window.dtfCheckout();});
@@ -133,20 +167,26 @@
} else if (quote.status==='approved') {
message('Total validado no servidor: '+rs(quote.approved.total_cents/100)+' · inclui frete. Cotação válida por 24 horas.');
if ((await ready).payment_provider === 'mercadopago') {
button('Pagar com PIX',async event=>{
const metodos=[];
const escolhe=async(el,show)=>{
if (!quotedCart || quotedCart!==cartSnapshot()) { await refresh(); return; }
event.target.disabled=true;
try { showPix(await api('/payments/intent',{quote_id:draftId,method:{type:'pix'}})); }
catch(error) { message(error.message); event.target.disabled=false; }
});
metodos.forEach(b=>b.setAttribute('aria-pressed',String(b===el)));
el.disabled=true;
try { await show(); }
catch(error) { message(error.message); }
finally { el.disabled=false; }
};
const pix=button('PIX',()=>escolhe(pix,async()=>
showPix(await api('/payments/intent',{quote_id:draftId,method:{type:'pix'}}))));
metodos.push(pix);
if ((await ready).payment_public_key) {
button('Pagar com cartão',async event=>{
if (!quotedCart || quotedCart!==cartSnapshot()) { await refresh(); return; }
event.target.disabled=true;
try { await showCard(quote.approved.total_cents); }
catch(error) { message(error.message); event.target.disabled=false; }
});
const card=button('Cartão de crédito',()=>escolhe(card,()=>showCard(quote.approved.total_cents)));
metodos.push(card);
}
metodos.forEach(b=>{b.className='metodo';b.setAttribute('aria-pressed','false');});
area=document.createElement('div'); area.className='area';
actions.append(area);
message('Total validado no servidor: '+rs(quote.approved.total_cents/100)+' · inclui frete. Escolha como pagar.');
return;
}
if ((await ready).environment !== 'local') {
@@ -169,8 +209,8 @@
message('Pedido #'+quote.order.number+' · '+(STAGES[quote.order.state]||quote.order.state)+'.');
button('Novo pedido',()=>{clearDraft();location.assign('/');});
} else {
message('Cotação expirada. Envie o carrinho para uma nova revisão.');
button('Nova cotação',clearDraft);
message('Cotação expirada. Envie o carrinho de novo para pagar.');
button('Voltar ao carrinho',()=>{clearDraft();vaiPara(CARRINHO);});
}
} catch(error) {
if(version!==refreshVersion || draftId!==shownId) return;
@@ -183,19 +223,19 @@
// by the provider's notification, not by this page, so the page only waits.
let pixTimer=null;
function showPix(intent) {
actions.replaceChildren();
area.replaceChildren();
if (!intent.pix_qr_code) { message('Não foi possível gerar o PIX. Tente de novo em instantes.'); return; }
message('Pague o PIX de '+rs(intent.total_cents/100)+'. O pedido entra na produção assim que o pagamento for confirmado.');
if (intent.pix_qr_code_base64) {
const img=document.createElement('img');
img.src='data:image/png;base64,'+intent.pix_qr_code_base64;
img.alt='QR code do PIX';img.width=220;img.height=220;img.style.display='block';
actions.append(img);
area.append(img);
}
const code=document.createElement('input');
code.readOnly=true;code.value=intent.pix_qr_code;code.style.cssText='width:100%;margin-top:8px;padding:8px';
actions.append(code);
button('Copiar código PIX',async()=>{ try{ await navigator.clipboard.writeText(intent.pix_qr_code); message('Código copiado.'); }catch(_){ code.select(); } });
area.append(code);
button('Copiar código PIX',async()=>{ try{ await navigator.clipboard.writeText(intent.pix_qr_code); message('Código copiado.'); }catch(_){ code.select(); } },area);
clearInterval(pixTimer);
pixTimer=setInterval(async()=>{
try {
@@ -229,11 +269,11 @@
async function showCard(totalCents) {
await loadMercadoPago();
const session=await ready;
actions.replaceChildren();
area.replaceChildren();
const holder=document.createElement('div');
holder.id='cardPaymentBrick';
holder.style.cssText='max-width:520px;margin-top:8px';
actions.append(holder);
area.append(holder);
message('Pagamento com cartão: '+rs(totalCents/100)+'.');
if (cardBrick) { try { await cardBrick.unmount(); } catch(_) {} }
const mp=new window.MercadoPago(session.payment_public_key,{locale:'pt-BR'});
@@ -282,5 +322,10 @@
draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId);
}
window.addEventListener('dtf-cart-changed',()=>{if(draftId) refresh();});
// The cart page keeps only the sending progress and errors, never a payment.
window.addEventListener('dtf-page-changed',()=>{
if (naPagina()) refresh();
else if (!busy) { refreshVersion++; message(''); actions.replaceChildren(); pintaResumo(null); }
});
refresh();
})();