From 43043c361cf5437720ac76fad2405637383e4104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Mon, 28 Sep 2026 11:45:44 -0300 Subject: [PATCH] feat: give payment its own page 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 --- deploy/nginx.conf.template | 2 +- infra/nginx.conf.template | 2 +- tests/browser_test.mjs | 4 ++ web/checkout.js | 99 +++++++++++++++++++++++++++----------- web/index.html | 7 ++- web/portal.js | 2 +- web/site-pages.js | 12 +++-- web/site-steps.js | 9 ++-- web/site-v2.css | 16 +++++- 9 files changed, 113 insertions(+), 40 deletions(-) diff --git a/deploy/nginx.conf.template b/deploy/nginx.conf.template index a911dd2..14e343b 100644 --- a/deploy/nginx.conf.template +++ b/deploy/nginx.conf.template @@ -49,7 +49,7 @@ server { # Always revalidate HTML/JS/CSS after a deployment. Without this, a browser # can pair a new Kanban page with a cached older script after a rollout. # The Site's product pages and cart are addresses of the same page (web/site-pages.js). - location ~ ^/(arquivo-por-metro|artes-avulsas|uv-arquivo-por-metro|uv-artes-avulsas|carrinho)/?$ { + location ~ ^/(arquivo-por-metro|artes-avulsas|uv-arquivo-por-metro|uv-artes-avulsas|carrinho|pagamento)/?$ { expires -1; try_files /index.html =404; } diff --git a/infra/nginx.conf.template b/infra/nginx.conf.template index 17e520e..f3346e0 100644 --- a/infra/nginx.conf.template +++ b/infra/nginx.conf.template @@ -20,7 +20,7 @@ server { client_max_body_size 2m; } # The Site's product pages and cart are addresses of the same page (web/site-pages.js). - location ~ ^/(arquivo-por-metro|artes-avulsas|uv-arquivo-por-metro|uv-artes-avulsas|carrinho)/?$ { + location ~ ^/(arquivo-por-metro|artes-avulsas|uv-arquivo-por-metro|uv-artes-avulsas|carrinho|pagamento)/?$ { try_files /index.html =404; } location / { try_files $uri $uri/ =404; } diff --git a/tests/browser_test.mjs b/tests/browser_test.mjs index ae95dfd..94df84a 100644 --- a/tests/browser_test.mjs +++ b/tests/browser_test.mjs @@ -99,6 +99,10 @@ try{ try{ // A cart the Site can price is approved at once: the customer pays now. await waitFor(async()=> (await site.text()).includes('Total validado no servidor:'),'browser upload and automatic approval',45000); + // Payment is its own page: the cart form is gone and the order summary is shown. + assert.equal(await site.eval('location.pathname'),'/pagamento'); + assert.equal(await site.eval('getComputedStyle(document.getElementById("carr")).display'),'none'); + assert.equal(await site.eval('!document.getElementById("pagResumo").hidden && document.getElementById("pagResumo").textContent.includes("Total")'),true); }catch(error){ console.error('Checkout status:',await site.eval('document.getElementById("checkoutStatus")?.textContent')); throw error; diff --git a/web/checkout.js b/web/checkout.js index a0436f4..e4f9651 100644 --- a/web/checkout.js +++ b/web/checkout.js @@ -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(); })(); diff --git a/web/index.html b/web/index.html index 6453612..4c56777 100644 --- a/web/index.html +++ b/web/index.html @@ -869,7 +869,7 @@ footer a:hover{color:var(--laranja2)} 2Dados e entrega - 3Pagamento + 3Pagamento
@@ -1209,6 +1209,11 @@ footer a:hover{color:var(--laranja2)}
+
+

Pagamento

+ Voltar ao carrinho +
+

diff --git a/web/portal.js b/web/portal.js index 921e5c3..59085b2 100644 --- a/web/portal.js +++ b/web/portal.js @@ -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.')); diff --git a/web/site-pages.js b/web/site-pages.js index 10856cf..ef750af 100644 --- a/web/site-pages.js +++ b/web/site-pages.js @@ -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; diff --git a/web/site-steps.js b/web/site-steps.js index bc4573c..66a03c7 100644 --- a/web/site-steps.js +++ b/web/site-steps.js @@ -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{ - 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'}); diff --git a/web/site-v2.css b/web/site-v2.css index d48bf14..6648bee 100644 --- a/web/site-v2.css +++ b/web/site-v2.css @@ -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}