diff --git a/deploy/nginx.conf.template b/deploy/nginx.conf.template index 14e343b..97c8a2d 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|pagamento)/?$ { + location ~ ^/(arquivo-por-metro|artes-avulsas|uv-arquivo-por-metro|uv-artes-avulsas|carrinho|pagamento|pagamento/pix)/?$ { expires -1; try_files /index.html =404; } diff --git a/infra/nginx.conf.template b/infra/nginx.conf.template index f3346e0..b49d5ec 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|pagamento)/?$ { + location ~ ^/(arquivo-por-metro|artes-avulsas|uv-arquivo-por-metro|uv-artes-avulsas|carrinho|pagamento|pagamento/pix)/?$ { try_files /index.html =404; } location / { try_files $uri $uri/ =404; } diff --git a/web/checkout.js b/web/checkout.js index ac98f2d..80a1049 100644 --- a/web/checkout.js +++ b/web/checkout.js @@ -4,8 +4,9 @@ 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; + const naPagina = () => ['pagamento','pix'].includes(document.documentElement.dataset.rota); + const naPix = () => document.documentElement.dataset.rota === 'pix'; + let shownCart = null; let busy = false; let draftId = localStorage.getItem('dtf-quote'); let requestKey = localStorage.getItem('dtf-request-key'); @@ -50,17 +51,24 @@ window.dtfApi=api; ready.catch(error => { status.textContent = error.message; }); function message(text) { status.textContent = text; } - function button(label, handler, into = actions) { + function button(label, handler, into = actions, className = 'pagBtn') { const el = document.createElement('button'); + el.type = 'button'; + el.className = className; el.textContent = label; - el.style.cssText = 'margin:8px 8px 0 0;padding:8px 14px;cursor:pointer'; el.onclick = handler; into.append(el); return el; } + function node(tag, text, className) { + const el=document.createElement(tag); + if (text!=null) el.textContent=text; + if (className) el.className=className; + 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; + const approved = quote ? quote.approved : null; resumo.hidden = !approved; if (!approved) { resumo.replaceChildren(); return; } const linha = (label, value, className='l') => { @@ -75,7 +83,7 @@ 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); + resumo.replaceChildren(node('h4','Resumo do pedido'), ...rows); } function semPedido() { pintaResumo(null); @@ -153,9 +161,15 @@ const quote=await api('/quotes/'+shownId); if(version!==refreshVersion || draftId!==shownId) return; if (!naPagina()) { message(''); vaiPara(PAGAMENTO); return; } + shownCart=cartSnapshot(); + unmountCard(); actions.replaceChildren(); + message(''); pintaResumo(quote); - if (quote.status!=='paid' && (!quotedCart || quotedCart!==cartSnapshot())) { + document.querySelector('.pagVolta').hidden = quote.status==='paid'; + if (quote.status==='paid') { confirmado(quote); return; } + if (naPix() && quote.status!=='approved') { vaiPara(PAGAMENTO); return; } + if (!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();}); return; @@ -166,26 +180,8 @@ : 'Arquivos enviados. Nossa equipe está conferindo a cotação '+draftId.slice(0,8)+'; o valor final aparece aqui em seguida.'); } else if (quote.status==='approved') { if ((await ready).payment_provider === 'mercadopago') { - const metodos=[]; - const escolhe=async(el,show)=>{ - if (!quotedCart || quotedCart!==cartSnapshot()) { await refresh(); return; } - 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) { - 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(''); + if (naPix()) await paginaPix(quote, version); + else await escolhaPagamento(quote, version); return; } if ((await ready).environment !== 'local') { @@ -205,10 +201,6 @@ await refresh(); } catch(error) { message(error.message); event.target.disabled=false; } }); - } else if (quote.status==='paid') { - const STAGES={rec:'arte recebida',tra:'arte tratada',fil:'na fila de impressão',imp:'imprimindo',cor:'aguardando correção',fin:'finalizado'}; - 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 de novo para pagar.'); button('Voltar ao carrinho',()=>{clearDraft();vaiPara(CARRINHO);}); @@ -220,40 +212,103 @@ button('Limpar referência e tentar de novo',clearDraft); } } - // PIX: the provider's QR code and copy-and-paste code. The order is created - // by the provider's notification, not by this page, so the page only waits. - let pixTimer=null; - function showPix(intent) { - 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'; - 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'; - 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()=>{ + // The order is created by Mercado Pago's notification, never by this page, + // so after paying the page only waits for the order to exist. + let waitTimer=null; + function waitForOrder() { + clearInterval(waitTimer); + waitTimer=setInterval(async()=>{ try { const quote=await api('/quotes/'+draftId); if (quote.status==='paid') { - clearInterval(pixTimer); + clearInterval(waitTimer); pedido=[]; itemAtual=null; limpaPaineis(); await window.dtfClearCart?.(); await refresh(); } } catch(_) {} - },5000); + },3000); } + function confirmado(quote) { + clearInterval(waitTimer); + const box=node('div',null,'confirmado'); + box.append(node('h4','Pagamento confirmado'), + node('p','Pedido #'+quote.order.number+' recebido e enviado para a produção. Você acompanha cada etapa em Meus pedidos.')); + button('Ver meus pedidos',()=>location.assign('/portal.html?order='+quote.order.id),box); + actions.append(box); + } + const cartOk=()=>!!quotedCart && quotedCart===cartSnapshot(); + + // Paying: card (the default, paid on this page) or PIX (paid on its own page). + async function escolhaPagamento(quote, version) { + const session=await ready; + const opcoes=[...(session.payment_public_key?[['card','Cartão de crédito']]:[]),['pix','PIX']]; + let escolhido=opcoes[0][0]; + const grupo=node('div',null,'metodos'); + grupo.setAttribute('role','radiogroup'); grupo.setAttribute('aria-label','Forma de pagamento'); + const area=node('div',null,'area'); + const radios=opcoes.map(([key,label])=>{ + const el=button(label,()=>{ if(escolhido!==key){ escolhido=key; pinta(); } },grupo,'metodo'); + el.setAttribute('role','radio'); el.dataset.metodo=key; + return el; + }); + actions.append(node('h4','Forma de pagamento'),grupo,area); + async function pinta() { + radios.forEach(el=>el.setAttribute('aria-checked',String(el.dataset.metodo===escolhido))); + unmountCard(); area.replaceChildren(); message(''); + if (escolhido==='card') { await showCard(quote.approved, area, version); return; } + area.append(node('p','Ao pagar, mostramos o QR code e o código copia e cola. O pedido vai para a produção assim que o PIX é confirmado.','pagNota')); + button('Pagar',async event=>{ + if (!cartOk()) { await refresh(); return; } + event.target.disabled=true; + try { + await api('/payments/intent',{quote_id:draftId,method:{type:'pix'}}); + vaiPara(PAGAMENTO_PIX); + } catch(error) { message(error.message); event.target.disabled=false; } + },area); + } + await pinta(); + } + + // The PIX page. The intent is idempotent on the quote, so reopening this + // page shows the same QR code instead of creating another charge. + async function paginaPix(quote, version) { + let intent; + try { intent=await api('/payments/intent',{quote_id:draftId,method:{type:'pix'}}); } + catch(error) { + if (version!==refreshVersion) return; + message(error.message); + button('Escolher outra forma de pagamento',()=>vaiPara(PAGAMENTO),actions,'pagBtn sec'); + return; + } + if (version!==refreshVersion) return; + if (!intent.pix_qr_code) { message('Não foi possível gerar o PIX. Tente de novo em instantes.'); return; } + const box=node('div',null,'pixBox'); + box.append(node('h4','Pague com PIX'), + node('p','Abra o app do seu banco, escolha pagar com PIX e leia o QR code ou cole o código abaixo. Valor: '+rs(intent.total_cents/100)+'.','pagNota')); + 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=240;img.height=240; + box.append(img); + } + const linha=node('div',null,'codigo'); + const code=document.createElement('input'); + code.readOnly=true;code.value=intent.pix_qr_code;code.setAttribute('aria-label','Código PIX copia e cola'); + linha.append(code); + const copiar=button('Copiar código',async()=>{ + try{ await navigator.clipboard.writeText(intent.pix_qr_code); copiar.textContent='Código copiado'; } + catch(_){ code.select(); } + },linha); + box.append(linha,node('p','Aguardando a confirmação do pagamento…','aguarda')); + button('Escolher outra forma de pagamento',()=>vaiPara(PAGAMENTO),box,'pagBtn sec'); + actions.append(box); + waitForOrder(); + } + // Card: Mercado Pago's own form (Card Payment Brick). The card is typed into // Mercado Pago's secure fields and becomes a one-time token; the number never - // reaches this page's code or our server. As with PIX, the order is created - // by the provider's notification, so the page only waits for it. + // reaches this page's code or our server. let sdkLoading=null; function loadMercadoPago() { if (window.MercadoPago) return Promise.resolve(); @@ -267,32 +322,39 @@ return sdkLoading; } let cardBrick=null; - async function showCard(totalCents) { - await loadMercadoPago(); - const session=await ready; - area.replaceChildren(); - const holder=document.createElement('div'); + function unmountCard() { + if (cardBrick) { try { cardBrick.unmount(); } catch(_) {} cardBrick=null; } + } + async function showCard(approved, into, version) { + const holder=node('div'); holder.id='cardPaymentBrick'; - holder.style.cssText='max-width:520px;margin-top:8px'; - area.append(holder); - message('Pagamento com cartão: '+rs(totalCents/100)+'.'); - if (cardBrick) { try { await cardBrick.unmount(); } catch(_) {} } + into.append(holder); + try { await loadMercadoPago(); } + catch(error) { message(error.message); return; } + const session=await ready; + // The customer may have switched to PIX, or the page re-rendered, meanwhile. + if (!holder.isConnected || version!==refreshVersion) return; + unmountCard(); const mp=new window.MercadoPago(session.payment_public_key,{locale:'pt-BR'}); cardBrick=await mp.bricks().create('cardPayment','cardPaymentBrick',{ - initialization:{amount:totalCents/100, payer:{email:cliente.mail}}, - customization:{paymentMethods:{maxInstallments:12}}, + initialization:{amount:approved.total_cents/100, payer:{email:approved.customer.mail}}, + customization:{paymentMethods:{maxInstallments:12}, + visual:{style:{customVariables:{baseColor:'#FFA81A',buttonTextColor:'#03060B'}}}}, callbacks:{ onReady:()=>{}, onError:error=>{ console.error(error); message('Erro no formulário do cartão. Confira os dados e tente de novo.'); }, onSubmit:async data=>{ + if (!cartOk()) { await refresh(); throw new Error('cart changed'); } + message(''); const result=await api('/payments/intent',{quote_id:draftId,method:{ type:'card', token:data.token, payment_method_id:data.payment_method_id, installments:Number(data.installments)||1, issuer_id:data.issuer_id==null?null:String(data.issuer_id)}}); if (result.status==='approved' || result.status==='pending') { - message(result.status==='approved' - ? 'Pagamento aprovado. Seu pedido entra na produção em instantes.' - : 'Pagamento em análise pelo Mercado Pago. Avisamos assim que for confirmado.'); + unmountCard(); + into.replaceChildren(node('p',result.status==='approved' + ? 'Pagamento aprovado. Confirmando seu pedido…' + : 'Pagamento em análise pelo Mercado Pago. Esta página atualiza assim que ele for confirmado.','pagNota')); waitForOrder(); } else { message('Pagamento recusado pelo Mercado Pago ('+(result.status_detail||result.status)+'). '+ @@ -302,27 +364,16 @@ } } }); - } - function waitForOrder() { - clearInterval(pixTimer); - pixTimer=setInterval(async()=>{ - try { - const quote=await api('/quotes/'+draftId); - if (quote.status==='paid') { - clearInterval(pixTimer); - pedido=[]; itemAtual=null; limpaPaineis(); - await window.dtfClearCart?.(); - await refresh(); - } - } catch(_) {} - },5000); + if (!holder.isConnected || version!==refreshVersion) unmountCard(); } const quoteFromPortal=new URLSearchParams(location.search).get('quote'); if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){ if(draftId!==quoteFromPortal){quotedCart=null;localStorage.removeItem('dtf-quote-cart');} draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId); } - window.addEventListener('dtf-cart-changed',()=>{if(draftId) refresh();}); + // Re-render the payment page only when the cart really changed: re-rendering + // would remount the card form under the customer's typing. + window.addEventListener('dtf-cart-changed',()=>{if(draftId && naPagina() && shownCart!==cartSnapshot()) refresh();}); // The cart page keeps only the sending progress and errors, never a payment. window.addEventListener('dtf-page-changed',()=>{ if (naPagina()) refresh(); diff --git a/web/index.html b/web/index.html index 4c56777..20ed7c4 100644 --- a/web/index.html +++ b/web/index.html @@ -1213,9 +1213,13 @@ footer a:hover{color:var(--laranja2)}

Pagamento

Voltar ao carrinho - -

-
+
+
+

+
+
+ +