All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m40s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 53s
- Buttons: add to cart (bag with plus), go to payment (lock), remove from the cart (bin). - Delivery: a pin for pickup in Franca, a truck for home delivery. - Payment: card and QR code on the methods, copy on the PIX code, a clock on its countdown, a check on the confirmation. - Status: a check when the files are sent, check, alert, redo and cross in the quality list instead of Unicode symbols, and a check on finished steps. - Kanban: a truck or pin for where an order goes, a speech bubble on the WhatsApp link, a download arrow on the file buttons. All Lucide shapes drawn inline, as in the header. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
588 lines
29 KiB
JavaScript
588 lines
29 KiB
JavaScript
/* 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 pixClock = 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/1073741824).toFixed(0)+' GB por arquivo.';
|
|
}).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';
|
|
const prazo = approved.freight.days ? ' · até '+approved.freight.days+' dias úteis' : '';
|
|
rows.push(linha(pickup ? 'Retirada em Franca' : 'Frete'+prazo,
|
|
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 esqueletoPagamento() {
|
|
const linhas=['skel skelLinha','skel skelLinha','skel skelLinha curta'].map(c=>node('div',null,c));
|
|
resumo.hidden=false; resumo.replaceChildren(node('h4','Resumo do pedido'),...linhas);
|
|
actions.replaceChildren(node('div',null,'skel skelLinha curta'),node('div',null,'skel skelBloco'));
|
|
}
|
|
function semPedido() {
|
|
pintaResumo(null);
|
|
actions.replaceChildren();
|
|
message('Nenhum pedido aguardando pagamento.');
|
|
button('Ir para o carrinho', () => vaiPara(CARRINHO));
|
|
}
|
|
// Files start uploading as soon as they are in the cart, so a sheet of
|
|
// several GB is on its way while the customer fills in the order. The
|
|
// checkout waits for whatever is still going.
|
|
const envios=new Map();
|
|
const chaveArquivo=f=>[f.name,f.size,f.lastModified].join('|');
|
|
function enviar(file) {
|
|
const k=chaveArquivo(file);
|
|
let e=envios.get(k);
|
|
if (!e) {
|
|
e={file, sent:0, done:false, failed:null};
|
|
e.promise=(async()=>{
|
|
const session=await ready;
|
|
return window.dtfUpload(file,{api,scope:session.cart_scope,progress:()=>{},
|
|
onBytes:n=>{e.sent=n;pintaEnvio();}});
|
|
})();
|
|
e.promise.then(()=>{e.done=true;falhas.delete(k);pintaEnvio();},
|
|
error=>{envios.delete(k);falhas.set(k,{file,error});pintaEnvio();});
|
|
envios.set(k,e);
|
|
}
|
|
return e.promise;
|
|
}
|
|
// A failed upload is shown and waits for the customer, never retried behind
|
|
// their back: a file the check refused would fail again on every change.
|
|
const falhas=new Map();
|
|
function tentaDeNovo() {
|
|
const arquivos=[...falhas.values()].map(f=>f.file);
|
|
falhas.clear();
|
|
arquivos.forEach(f=>enviar(f).catch(()=>{}));
|
|
pintaEnvio();
|
|
}
|
|
const arquivosDoCarrinho=()=>[...pedido,...(busy&&itemAtual?[itemAtual]:[])].flatMap(it=>it.localFiles||[]);
|
|
const gb=n=>(n/1073741824).toLocaleString('pt-BR',{maximumFractionDigits:1})+' GB';
|
|
const mb=n=>n>=1073741824 ? gb(n) : Math.round(n/1048576)+' MB';
|
|
// Where the uploads stand, and roughly how long is left from the recent speed.
|
|
const amostras=[];
|
|
function estadoEnvio() {
|
|
const files=arquivosDoCarrinho(); if(!files.length) return null;
|
|
let total=0, sent=0, pendentes=0;
|
|
for (const f of files) {
|
|
const e=envios.get(chaveArquivo(f));
|
|
total+=f.size; sent+=e ? Math.min(e.sent,f.size) : 0;
|
|
if (!e || !e.done) pendentes++;
|
|
}
|
|
const agora=Date.now();
|
|
amostras.push([agora,sent]); while(amostras.length>2 && agora-amostras[0][0]>15000) amostras.shift();
|
|
const [t0,s0]=amostras[0], taxa=agora>t0 ? (sent-s0)/((agora-t0)/1000) : 0;
|
|
const fase=!pendentes ? 'pronto' : sent>=total ? 'verificando' : 'enviando';
|
|
return {total, sent, fase, pct:Math.floor(sent/total*100),
|
|
restante: fase==='enviando' && taxa>0 ? (total-sent)/taxa : null};
|
|
}
|
|
const tempo=s=>s<60 ? 'menos de 1 min' : 'cerca de '+Math.ceil(s/60)+' min';
|
|
function textoEnvio(e) {
|
|
if (!e) return '';
|
|
if (e.fase==='pronto') return 'Arquivos enviados';
|
|
if (e.fase==='verificando') return 'Arquivos enviados · verificando a segurança…';
|
|
// Until the speed is measured, the range from the file size.
|
|
return 'Enviando seus arquivos: '+e.pct+'% · '+mb(e.sent)+' de '+mb(e.total)+' · '+
|
|
(e.restante!=null ? tempo(e.restante) : 'estimativa '+faixaEnvio(e.total-e.sent))+
|
|
'. Mantenha a página aberta.';
|
|
}
|
|
function pintaEnvio() {
|
|
const e=estadoEnvio(), texto=textoEnvio(e);
|
|
const el=document.getElementById('envioArq');
|
|
const falhou=arquivosDoCarrinho().map(f=>falhas.get(chaveArquivo(f))).filter(Boolean);
|
|
const topo=document.getElementById('cartEnvio');
|
|
if (falhou.length) {
|
|
if (el) {
|
|
el.replaceChildren(document.createTextNode('Não foi possível enviar '+falhou[0].file.name+': '+
|
|
(falhou[0].error?.message||'erro no envio').replace(/\.$/,'')+(falhou.length>1?' (e mais '+(falhou.length-1)+')':'')+'. '));
|
|
const b=document.createElement('button'); b.type='button'; b.className='envioDeNovo'; b.textContent='Tentar de novo';
|
|
b.onclick=tentaDeNovo; el.append(b);
|
|
el.classList.remove('ok'); el.classList.add('erro');
|
|
}
|
|
if (topo) topo.textContent='· falha no envio';
|
|
return;
|
|
}
|
|
if (el) el.classList.remove('erro');
|
|
if (el) {
|
|
el.replaceChildren();
|
|
if (e && e.fase!=='pronto') {
|
|
const barra=document.createElement('div'); barra.className='envioBarra';
|
|
const i=document.createElement('i'); i.style.width=(e.fase==='verificando'?100:e.pct)+'%'; barra.append(i);
|
|
el.append(barra);
|
|
}
|
|
if (e?.fase==='pronto') el.insertAdjacentHTML('beforeend', icone('okCirculo'));
|
|
if (texto) el.append(document.createTextNode(texto));
|
|
el.classList.toggle('ok', e?.fase==='pronto');
|
|
}
|
|
// Visible on every page while it runs.
|
|
if (topo) topo.textContent = !e || e.fase==='pronto' ? '' : e.fase==='verificando' ? '· verificando' : '· enviando '+e.pct+'%';
|
|
if (busy && texto) {
|
|
message(texto);
|
|
$('bPagarTx').textContent = e && e.fase!=='pronto' ? (e.fase==='verificando' ? 'Verificando os arquivos…' : 'Enviando arquivos… '+e.pct+'%') : $('bPagarTx').textContent;
|
|
}
|
|
}
|
|
// Leaving the page pauses an upload; it resumes, but the customer is warned.
|
|
window.addEventListener('beforeunload',event=>{
|
|
const e=estadoEnvio();
|
|
if (e && e.fase==='enviando') { event.preventDefault(); event.returnValue=''; }
|
|
});
|
|
// Only what is in the cart: the item on the product page may still change.
|
|
window.addEventListener('dtf-cart-changed',()=>{
|
|
arquivosDoCarrinho().forEach(f=>{ if(!falhas.has(chaveArquivo(f))) enviar(f).catch(()=>{}); });
|
|
pintaEnvio();
|
|
});
|
|
async function upload(file) {
|
|
return enviar(file);
|
|
}
|
|
// The cart's package: billed metres and value. The charged freight is
|
|
// quoted again by the server from the approved items.
|
|
const pacote = () => {
|
|
const items=[...pedido,...(itemAtual?[itemAtual]:[])];
|
|
return {metres:items.reduce((t,it)=>t+(it.cob||0),0), cents:Math.round(items.reduce((t,it)=>t+(it.total||0),0)*100)};
|
|
};
|
|
let freightQuoted = null;
|
|
window.dtfFreight = async () => {
|
|
const cep = entrega.cep;
|
|
const service = (await ready).freight_service;
|
|
if (!service) {
|
|
$('cepMsg').textContent = 'A entrega ainda não está disponível. Escolha a retirada em Franca.';
|
|
return;
|
|
}
|
|
const {metres, cents} = pacote();
|
|
if (!metres) { $('cepMsg').textContent = 'Adicione um item ao pedido para cotar o frete.'; return; }
|
|
$('cepMsg').textContent = 'Cotando o frete…';
|
|
try {
|
|
const result = await api('/freight',{service,postal_code:cep,metres:metres.toFixed(2),declared_cents:cents});
|
|
if (entrega.cep !== cep || entrega.tipo !== 'frete') return;
|
|
entrega.valor = result.total_cents/100;
|
|
entrega.dias = result.days || null;
|
|
entrega.cotado = true;
|
|
freightQuoted = JSON.stringify(pacote());
|
|
$('cepMsg').textContent = '';
|
|
pintaEntrega();
|
|
} catch(error) { $('cepMsg').textContent = error.message; }
|
|
};
|
|
// A different package is a different freight: quote it again.
|
|
window.addEventListener('dtf-cart-changed',()=>{
|
|
if (entrega.tipo==='frete' && entrega.cotado && freightQuoted && freightQuoted!==JSON.stringify(pacote())) {
|
|
entrega.cotado=false; freightQuoted=null; window.dtfFreight();
|
|
}
|
|
});
|
|
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=[];
|
|
pintaEnvio();
|
|
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 service = entrega.tipo==='retira' ? 'pickup' : (await ready).freight_service;
|
|
if (!service) throw new Error('A entrega ainda não está disponível. Escolha a retirada em Franca.');
|
|
const content = {customer:{...cliente},items,freight:{service,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;
|
|
if (naPagina() && !actions.children.length) esqueletoPagamento();
|
|
try {
|
|
await ready;
|
|
const quote=await api('/quotes/'+shownId);
|
|
if(version!==refreshVersion || draftId!==shownId) return;
|
|
if (!naPagina()) { message(''); vaiPara(PAGAMENTO); return; }
|
|
shownCart=cartSnapshot();
|
|
clearInterval(pixClock);
|
|
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');
|
|
const titulo=node('h4','Pagamento confirmado');
|
|
titulo.insertAdjacentHTML('afterbegin', icone('okCirculo'));
|
|
box.append(titulo,
|
|
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;
|
|
el.insertAdjacentHTML('afterbegin', icone(key==='pix'?'qrcode':'cartao'));
|
|
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.','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.replaceChildren(); copiar.insertAdjacentHTML('beforeend', icone('ok')+'<span>Código copiado</span>'); }
|
|
catch(_){ code.select(); }
|
|
},linha);
|
|
copiar.insertAdjacentHTML('afterbegin', icone('copiar'));
|
|
const prazo=node('p',null,'prazo');
|
|
prazo.insertAdjacentHTML('afterbegin', icone('relogio'));
|
|
const prazoTx=node('span'); prazo.append(prazoTx);
|
|
box.append(linha,prazo,node('p','Aguardando a confirmação do pagamento…','aguarda'));
|
|
actions.append(box);
|
|
waitForOrder();
|
|
// The code stops working when it expires; a new one is a click away.
|
|
const fim=Date.parse(intent.expires_at);
|
|
if (!Number.isFinite(fim)) { prazo.remove(); return; }
|
|
const tick=()=>{
|
|
const s=Math.max(0,Math.ceil((fim-Date.now())/1000));
|
|
prazoTx.textContent='Pague em '+String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');
|
|
if (s>0) return;
|
|
clearInterval(pixClock);
|
|
const fimBox=node('div',null,'pixBox');
|
|
fimBox.append(node('h4','O código PIX expirou'),
|
|
node('p','Gere um novo código para pagar. O anterior não pode mais ser pago.','pagNota'));
|
|
button('Gerar novo PIX',()=>refresh(),fimBox);
|
|
actions.replaceChildren(fimBox);
|
|
};
|
|
tick();
|
|
pixClock=setInterval(tick,1000);
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
// The cardholder's document as the card form collected it.
|
|
function cardholder(id) {
|
|
const number=String(id?.number||'').replace(/\D/g,'');
|
|
const type=String(id?.type||'').toUpperCase();
|
|
return ['CPF','CNPJ'].includes(type) && /^[0-9]{11,14}$/.test(number)
|
|
? {payer_document_type:type, payer_document:number} : {};
|
|
}
|
|
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';
|
|
const espera=node('div',null,'skel skelBloco');
|
|
into.append(espera,holder);
|
|
try { await loadMercadoPago(); }
|
|
catch(error) { espera.remove(); 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']}},
|
|
// The option above already names the card; the form's own title
|
|
// ("crédito ou débito") would contradict it.
|
|
visual:{hideFormTitle:true,
|
|
style:{customVariables:{baseColor:'#FFA81A',buttonTextColor:'#03060B'}}}},
|
|
callbacks:{
|
|
onReady:()=>{ espera.remove(); },
|
|
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('');
|
|
let result;
|
|
try {
|
|
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),
|
|
...cardholder(data.payer?.identification)}});
|
|
} catch(error) {
|
|
// Rejecting stops the form's spinner; the reason is shown above it.
|
|
message(error.message || 'Não foi possível concluir o pagamento. Tente de novo.');
|
|
status.scrollIntoView({behavior:'smooth',block:'center'});
|
|
throw error;
|
|
}
|
|
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++; clearInterval(pixClock); message(''); actions.replaceChildren(); pintaResumo(null); }
|
|
});
|
|
refresh();
|
|
})();
|