Files
dtf-system/web/checkout.js
Cauê Faleiros 5f2be7ea20
Some checks failed
Build and deploy / Validate source (push) Successful in 12s
Build and deploy / Integration suite on a real stack (push) Failing after 2m36s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Has been skipped
feat: accept sheets of up to 5 GB end to end
Sheets of several GB are the normal order. The upload limit is now 5 GB.
ClamAV scans files up to 2 GB; a larger file is released only when its
first bytes match the format its name claims, and a disguised file is
refused. The Site grades a sheet over 150 MB from the pixel size in its
PNG, JPEG or WebP header without decoding it, and reads large PDFs in
ranges. The worker never opens a source over 300 MB: a finished sheet
placed whole becomes its own print file, which the Kanban offers to approve
as the final, and anything else goes to hand preparation. Files start
uploading as they enter the cart, with progress in the summary, and each
part renews the reservation so slow uploads do not expire. Quotas grow to
50 GB per customer and 500 GB in total; the Swarm config for ClamAV is
renamed because a deployed config cannot change in place.

Verified locally with a 386 MB and a 1.8 GB PNG (scanned, paid, original
as print file), a 2.3 GB PNG (format check) and a disguised 2.3 GB file
(refused).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:18:18 -03:00

505 lines
24 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 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;pintaEnvio();},
error=>{e.failed=error;envios.delete(k);pintaEnvio();});
envios.set(k,e);
}
return e.promise;
}
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';
function textoEnvio() {
const files=arquivosDoCarrinho(); if(!files.length) return '';
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++;
}
if (!pendentes) return 'Arquivos enviados.';
if (sent>=total) return 'Arquivos enviados · verificando a segurança…';
return 'Enviando seus arquivos: '+Math.floor(sent/total*100)+'% ('+mb(sent)+' de '+mb(total)+')';
}
function pintaEnvio() {
const el=document.getElementById('envioArq');
const texto=textoEnvio();
if (el) el.textContent=texto;
if (busy && texto) message(texto);
}
// Only what is in the cart: the item on the product page may still change.
window.addEventListener('dtf-cart-changed',()=>{ arquivosDoCarrinho().forEach(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;
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');
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.','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);
const prazo=node('p',null,'prazo');
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));
prazo.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;
}
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']}},
// 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:()=>{},
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)}});
} 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();
})();