/* 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 = () => ['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'); let requestBody = localStorage.getItem('dtf-request-body'); let quotedCart = localStorage.getItem('dtf-quote-cart'); let refreshVersion = 0; function cartSnapshot() { const items=[...pedido,...(itemAtual?[itemAtual]:[])]; return JSON.stringify({customer:cliente,delivery:entrega,items:items.map(item=>({ mode:item.modo,metres:item.metros,grade:item.nota,production:item.production, quality:item.qualityStatus, acknowledged:item.qualityAcknowledged, files:(item.localFiles||[]).map(file=>({name:file.name,size:file.size,lastModified:file.lastModified})) }))}); } function clearDraft() { draftId=null;quotedCart=null;requestKey=null;requestBody=null; for(const key of ['dtf-quote','dtf-quote-cart','dtf-request-key','dtf-request-body']) localStorage.removeItem(key); actions.replaceChildren(); } const api = async (path, body) => { const response = await fetch('/api'+path, { credentials: 'same-origin', headers: {'Content-Type':'application/json'}, ...(body === undefined ? {} : {method:'POST', body:JSON.stringify(body)}) }); const text = await response.text(); let data; try { data = text ? JSON.parse(text) : {}; } catch (_) { throw new Error(response.ok ? 'Resposta inválida do serviço.' : 'O serviço está indisponível. Tente novamente em instantes.'); } if (!response.ok) { const error=new Error(typeof data.detail === 'string' ? data.detail : 'Confira os dados do pedido ('+response.status+').');error.status=response.status;throw error; } return data; }; const ready = api('/session'); ready.then(session=>{ window.dtfUploadMaxBytes=session.max_upload_bytes; const limit=document.getElementById('zLimite'); if(limit)limit.textContent='Até '+(session.max_upload_bytes/1048576).toFixed(0)+ ' MB por arquivo enquanto a verificação de segurança para arquivos grandes é preparada.'; }).catch(()=>{}); window.dtfSessionReady=ready; window.dtfApi=api; ready.catch(error => { status.textContent = error.message; }); function message(text) { status.textContent = text; } function button(label, handler, into = actions, className = 'pagBtn') { const el = document.createElement('button'); el.type = 'button'; el.className = className; el.textContent = label; 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.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(node('h4','Resumo do pedido'), ...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}); } window.dtfFreight = async () => { const cep = entrega.cep; try { const result = await api('/freight',{service:'mock-standard',postal_code:cep}); if (entrega.cep !== cep || entrega.tipo !== 'frete') return; entrega.valor = result.total_cents/100; entrega.cotado = true; $('cepMsg').textContent = 'Frete estimado: '+rs(entrega.valor)+'.'; pintaEntrega(); } catch(error) { $('cepMsg').textContent = error.message; } }; window.dtfCheckout = async () => { if (busy) 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.'); const cart = [...pedido,...(itemAtual?[itemAtual]:[])]; if (!cart.length) return message('Adicione um item ao pedido.'); const initialCart=cartSnapshot(); busy = true; $('bPagar').disabled = true; try { await ready; if((await api('/session')).cart_scope !== (await ready).cart_scope) throw new Error('Sua conta ou sessão mudou. Recarregue a página antes de enviar o carrinho.'); const items=[]; for (const item of cart) { if (!item.localFiles?.length) throw new Error('Selecione novamente os arquivos deste item.'); const uploads=[]; for (const file of item.localFiles) uploads.push(await upload(file)); if (item.production?.version!==2 || item.production.sources?.length!==uploads.length) throw new Error('A montagem deste item precisa ser refeita antes da cotação.'); items.push({mode:item.modo, metres:String(item.metros), grade:item.nota, uploads, production:{...item.production,sources:item.production.sources.map((source,index)=>({ upload_id:uploads[index],...source}))}, quality_status:item.qualityStatus,quality_acknowledged:item.qualityAcknowledged}); } const content = {customer:{...cliente},items,freight:{service:entrega.tipo==='retira'?'pickup':'mock-standard',postal_code:entrega.tipo==='retira'?'':entrega.cep}}; const destination = destinoApi(); if (destination) content.destination = destination; if (cartSnapshot()!==initialCart) throw new Error('O carrinho mudou durante o envio. Confira os itens e envie de novo.'); const serialized = JSON.stringify(content); if (!requestKey || serialized !== requestBody) { requestKey = crypto.randomUUID(); requestBody = serialized; localStorage.setItem('dtf-request-key',requestKey); localStorage.setItem('dtf-request-body',requestBody); } 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(true); } catch(error) { message(error.message); } finally { busy=false; pintaEntrega(); } }; // `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; } shownCart=cartSnapshot(); unmountCard(); actions.replaceChildren(); message(''); pintaResumo(quote); 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; } if (quote.status==='pending_review') { message((await ready).environment==='local' ? 'Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,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') { if (naPix()) await paginaPix(quote, version); else await escolhaPagamento(quote, version); return; } if ((await ready).environment !== 'local') { message('Cotação revisada. O pagamento online ainda não está disponível.'); return; } // Local stack only: the simulated payment. message('Total validado no servidor: '+rs(quote.approved.total_cents/100)+' · inclui frete. Cotação válida por 24 horas.'); button('Criar pedido de teste',async event=>{ if (!quotedCart || quotedCart!==cartSnapshot()) { await refresh(); return; } event.target.disabled=true; try { const order=await api('/orders/dev-paid',{quote_id:draftId}); pedido=[]; itemAtual=null; limpaPaineis(); await window.dtfClearCart?.(); message('Pedido #'+order.number+' criado e disponível no Kanban.'); await refresh(); } catch(error) { message(error.message); event.target.disabled=false; } }); } else { 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; message(error.message); actions.replaceChildren(); button('Limpar referência e tentar de novo',clearDraft); } } // 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(waitTimer); pedido=[]; itemAtual=null; limpaPaineis(); await window.dtfClearCart?.(); await refresh(); } else if (quote.payment?.status==='rejected') { // Refused after the bank's confirmation or the provider's review. clearInterval(waitTimer); await refresh(); message('Pagamento recusado. Tente de novo ou escolha outra forma de pagamento.'); } } catch(_) {} },3000); } // 3-D Secure: the bank's own page, opened by posting its request in a frame. function showChallenge(challenge, into) { unmountCard(); const frame=document.createElement('iframe'); frame.name='confirmacaoBanco'; frame.title='Confirmação do banco'; frame.className='desafio'; const form=document.createElement('form'); form.method='POST'; form.action=challenge.url; form.target=frame.name; const input=document.createElement('input'); input.type='hidden'; input.name='creq'; input.value=challenge.creq; form.append(input); into.replaceChildren(node('p','Confirme o pagamento na página do seu banco, abaixo.','pagNota'),frame,form); form.submit(); form.remove(); } 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?[['credito','Cartão de crédito'],['debito','Cartão de débito']]:[]),['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!=='pix') { await showCard(quote.approved, area, version, escolhido); return; } area.append(node('p','Clique em Pagar para gerar o QR code do PIX.','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. let sdkLoading=null; function loadMercadoPago() { if (window.MercadoPago) return Promise.resolve(); sdkLoading = sdkLoading || new Promise((resolve,reject)=>{ const script=document.createElement('script'); script.src='https://sdk.mercadopago.com/js/v2'; script.onload=resolve; script.onerror=()=>{sdkLoading=null;reject(new Error('Não foi possível carregar o formulário do Mercado Pago.'));}; document.head.append(script); }); return sdkLoading; } let cardBrick=null; function unmountCard() { if (cardBrick) { try { cardBrick.unmount(); } catch(_) {} cardBrick=null; } } async function showCard(approved, into, version, tipo) { const holder=node('div'); holder.id='cardPaymentBrick'; 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:approved.total_cents/100, payer:{email:approved.customer.mail}}, // Each option takes only its kind of card; debit is paid at once. customization:{paymentMethods:tipo==='debito' ? {maxInstallments:1,types:{excluded:['credit_card']}} : {maxInstallments:12,types:{excluded:['debit_card']}}, 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.challenge) { showChallenge(result.challenge, into); waitForOrder(); return; } if (result.status==='approved' || result.status==='pending') { 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)+'). '+ 'Confira os dados ou use outro cartão.'); throw new Error('rejected'); } } } }); 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); } // 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(); else if (!busy) { refreshVersion++; message(''); actions.replaceChildren(); pintaResumo(null); } }); refresh(); })();