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
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:
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -869,7 +869,7 @@ footer a:hover{color:var(--laranja2)}
|
||||
<i aria-hidden="true"></i>
|
||||
<a href="/carrinho" data-passo="2"><span>2</span>Dados e entrega</a>
|
||||
<i aria-hidden="true"></i>
|
||||
<a href="/carrinho#checkoutStatus" data-passo="3"><span>3</span>Pagamento</a>
|
||||
<a href="/pagamento" data-passo="3"><span>3</span>Pagamento</a>
|
||||
</nav>
|
||||
<div class="w">
|
||||
|
||||
@@ -1209,6 +1209,11 @@ footer a:hover{color:var(--laranja2)}
|
||||
<!-- Fora de #carr de propósito: o pedido pago esvazia o carrinho, e a
|
||||
confirmação não pode sumir junto com o painel. -->
|
||||
<div class="checkout">
|
||||
<div class="pagCab">
|
||||
<h3>Pagamento</h3>
|
||||
<a href="/carrinho" class="pagVolta">Voltar ao carrinho</a>
|
||||
</div>
|
||||
<div class="pagResumo" id="pagResumo" hidden></div>
|
||||
<p id="checkoutStatus" class="avisoE" role="status" aria-live="polite"></p>
|
||||
<div id="checkoutActions"></div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ async function load(){
|
||||
for(const quote of data.quotes){
|
||||
const card=node('article');card.className='card';card.append(node('h3','Cotação '+quote.id.slice(0,8)));
|
||||
card.append(node('p',quote.approved?'Total aprovado: '+money(quote.approved.total_cents):'Aguardando conferência de metragem e nota.'));
|
||||
const link=node('a','Abrir cotação no Site');link.href='/carrinho?quote='+quote.id;card.append(link);$('quotes').append(card);
|
||||
const link=node('a','Abrir cotação no Site');link.href='/pagamento?quote='+quote.id;card.append(link);$('quotes').append(card);
|
||||
}
|
||||
$('orders').replaceChildren();
|
||||
if(!data.orders.length)$('orders').append(node('p','Nenhum pedido pago nesta conta ou sessão.'));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* Site DTF — the Site's pages: the home, one page per product and the cart.
|
||||
/* Site DTF — the Site's pages: the home, one page per product, the cart and
|
||||
the payment page.
|
||||
They share this document, so artworks already loaded in the browser survive
|
||||
moving between them; each page has its own address, so Back, Forward, reload
|
||||
and shared links behave as in any store. nginx serves index.html for these
|
||||
@@ -6,14 +7,15 @@
|
||||
site-steps.js does the navigation. */
|
||||
const PAGINAS={file:'/arquivo-por-metro', avulsa:'/artes-avulsas',
|
||||
uvfile:'/uv-arquivo-por-metro', uv:'/uv-artes-avulsas'};
|
||||
const CARRINHO='/carrinho';
|
||||
const CARRINHO='/carrinho', PAGAMENTO='/pagamento';
|
||||
function paginaDe(caminho){
|
||||
caminho=caminho.replace(/\/+$/,'')||'/';
|
||||
if(caminho===CARRINHO) return {pagina:'carrinho'};
|
||||
if(caminho===PAGAMENTO) return {pagina:'pagamento'};
|
||||
const m=Object.keys(PAGINAS).find(k=>PAGINAS[k]===caminho);
|
||||
return m ? {pagina:'produto',modo:m} : {pagina:'inicio'};
|
||||
}
|
||||
// Older portal links open a quote on the home address; quotes live in the cart.
|
||||
if(location.pathname==='/' && new URLSearchParams(location.search).has('quote'))
|
||||
history.replaceState(null,'',CARRINHO+location.search);
|
||||
// Portal links, old and new, open a quote on the payment page.
|
||||
if(['/',CARRINHO].includes(location.pathname.replace(/\/+$/,'')||'/') && new URLSearchParams(location.search).has('quote'))
|
||||
history.replaceState(null,'',PAGAMENTO+location.search);
|
||||
document.documentElement.dataset.rota=paginaDe(location.pathname).pagina;
|
||||
|
||||
@@ -11,6 +11,7 @@ const TITULO_INICIO=document.title;
|
||||
function tituloPagina(){
|
||||
const p=paginaDe(location.pathname);
|
||||
if(p.pagina==='carrinho') return 'Carrinho · DTF Dropstar';
|
||||
if(p.pagina==='pagamento') return 'Pagamento · DTF Dropstar';
|
||||
if(p.pagina==='produto') return MODOS[p.modo].tit+' · DTF Dropstar';
|
||||
return TITULO_INICIO;
|
||||
}
|
||||
@@ -91,7 +92,7 @@ function vaiPara(url){
|
||||
passos.hidden = r==='inicio' || carrinhoVazio;
|
||||
alturas();
|
||||
if(!passos.hidden){
|
||||
const atual = r==='produto' ? 1 : temCheckout() ? 3 : 2;
|
||||
const atual = r==='produto' ? 1 : r==='pagamento' ? 3 : 2;
|
||||
passos.querySelectorAll('[data-passo]').forEach(a=>{
|
||||
const n=Number(a.dataset.passo);
|
||||
a.classList.toggle('atual',n===atual); a.classList.toggle('feito',n<atual);
|
||||
@@ -124,8 +125,10 @@ function vaiPara(url){
|
||||
|
||||
// Payment is a step only once the order has been sent.
|
||||
passos.querySelector('[data-passo="3"]').addEventListener('click',e=>{
|
||||
if(!temCheckout()) e.preventDefault();
|
||||
});
|
||||
let enviado=false;
|
||||
try{ enviado=!!localStorage.getItem('dtf-quote'); }catch(_){}
|
||||
if(!enviado){ e.preventDefault(); e.stopPropagation(); }
|
||||
},true);
|
||||
$('barraMBtn').addEventListener('click',e=>{
|
||||
e.preventDefault();
|
||||
alvoBarra()?.scrollIntoView({behavior:'smooth',block:'center'});
|
||||
|
||||
@@ -15,7 +15,8 @@ h1,h2,h3,h4{font-family:"Inter",sans-serif}
|
||||
/* Pages (site-pages.js): the home, a product page and the cart show only their own parts. */
|
||||
html[data-rota="inicio"] :is(#passos,#foco,#qual,#prev,#carr,.checkout,#carrVazio),
|
||||
html[data-rota="produto"] :is(.hero2,.etapas2,#cards,#carr,.checkout,#carrVazio,#info),
|
||||
html[data-rota="carrinho"] :is(.hero2,.etapas2,#cards,#foco,#qual,#prev,#info){display:none!important}
|
||||
html[data-rota="carrinho"] :is(.hero2,.etapas2,#cards,#foco,#qual,#prev,#info,.pagCab,.pagResumo){display:none!important}
|
||||
html[data-rota="pagamento"] :is(.hero2,.etapas2,#cards,#foco,#qual,#prev,#info,#carr,#carrVazio,#desfazer){display:none!important}
|
||||
html[data-sem-itens] #carr{display:none!important}
|
||||
html:not([data-rota="inicio"]) #envio{padding-top:0;padding-bottom:64px;min-height:70vh}
|
||||
#envio,#foco,#qual,#prev,#checkoutStatus{scroll-margin-top:var(--topo-passos,128px)}
|
||||
@@ -225,6 +226,19 @@ section+section{border-top:0}
|
||||
#checkoutActions{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px}
|
||||
#checkoutActions button{height:48px!important;padding:0 20px!important;margin:0!important;border-radius:var(--raio2);border:0;background:var(--laranja);color:var(--preto);font-family:"Inter",sans-serif;font-weight:800;font-size:15px}
|
||||
#checkoutActions input{height:44px;border-radius:var(--raio2);border:1px solid #D6D6D6;padding:0 12px}
|
||||
/* The payment page */
|
||||
html[data-rota="pagamento"] .checkout{margin-top:8px}
|
||||
.pagCab{display:flex;align-items:baseline;justify-content:space-between;gap:16px;margin-bottom:16px}
|
||||
.pagCab h3{font-size:26px;font-weight:800;letter-spacing:-.02em}
|
||||
.pagVolta{font-size:14px;color:var(--texto2)}
|
||||
.pagResumo{border:1px solid var(--linha);border-radius:16px;background:#fff;padding:18px 20px;margin-bottom:16px}
|
||||
.pagResumo .l{display:flex;justify-content:space-between;gap:12px;font-size:14px;padding:5px 0}
|
||||
.pagResumo .l span{color:var(--texto2)}
|
||||
.pagResumo .tot{display:flex;justify-content:space-between;align-items:baseline;border-top:1px solid #EFEFEF;margin-top:10px;padding-top:12px}
|
||||
.pagResumo .tot b{font-size:26px}
|
||||
#checkoutActions .metodo{background:#fff;border:2px solid var(--linha);color:var(--preto);min-width:180px}
|
||||
#checkoutActions .metodo[aria-pressed="true"]{border-color:var(--laranja);background:var(--aviso-fundo)}
|
||||
#checkoutActions .area{flex-basis:100%}
|
||||
|
||||
/* Price and benefits */
|
||||
.info2{background:var(--cinza);padding:64px 0}
|
||||
|
||||
Reference in New Issue
Block a user