fix: make the by-metre product reachable and its declaration verified

Navigation links carried data-modo-cta and opened loose artwork directly, so
every entry point except the price card bypassed "Arquivo por metro". Dropping
a PNG or JPG from the by-metre editor then switched the order to loose artwork
and repriced it, while the same screen advertised PNG/JPG in its drop zone and
marked that path as the cheaper one.

Replace the guessing with a declaration: the product is the choice. #tipoEnvio
shows ready sheet and loose artwork side by side with both prices, in all four
modes, reversible until a file is attached and locked afterwards. sel() no
longer reassigns modo, so a file can never change product or price on its own.

Verify the declaration instead of trusting it. medirFolha returns dpiFolha, and
an image without the pixels to span the film width at DPI_RECUSA is refused as
a sheet, with the numbers shown and one click to send it as loose artwork.
Previously a small image declared as a sheet was billed by the metre.

Scope pintaCaminhos to #caminhos .cam: its global selector was clearing the new
control. Move the checkout status out of #carr, which the success path hides,
so a paid order still confirms itself to the customer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-18 18:32:38 -03:00
parent 9046ec6db0
commit 67aa6c970c
2 changed files with 137 additions and 45 deletions

View File

@@ -577,6 +577,8 @@ h1 em{font-style:normal;color:var(--laranja)}
/* ── Carrinho no padrão de checkout: entrega à esquerda, resumo fixo à direita ── */
.carr{display:none;margin-top:18px}
.checkout{margin-top:14px}
.checkout #checkoutStatus:empty{display:none}
.carr.on{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:18px;align-items:start}
@media(max-width:900px){.carr.on{grid-template-columns:1fr}}
.carr h3{font-family:"Inter",sans-serif;font-size:19px;font-weight:700;margin-bottom:12px}
@@ -780,10 +782,10 @@ footer a:hover{color:var(--laranja2)}
</div>
</div>
<div class="mi on">
<a href="#envio" data-modo-cta="avulsa">Impressão DTF</a>
<a href="#envio">Impressão DTF</a>
<div class="sub">
<a href="#envio" data-modo-cta="avulsa">DTF Têxtil · 57 cm</a>
<a href="#envio" data-modo-cta="uv">DTF UV · 28,5 cm</a>
<a href="#envio">DTF Têxtil · 57 cm</a>
<a href="#envio">DTF UV · 28,5 cm</a>
</div>
</div>
<div class="mi">
@@ -867,7 +869,7 @@ footer a:hover{color:var(--laranja2)}
<p class="sub2">Mande a arte, veja a nota em segundos e a folha montada antes de fechar.
<b>Revisão e remanejamento por nossa conta.</b></p>
<div class="hcta">
<a href="#envio" class="btn1" data-modo-cta="avulsa">Enviar minha arte</a>
<a href="#envio" class="btn1">Enviar minha arte</a>
<span class="mini">sem cadastro · resposta em segundos</span>
</div>
</div>
@@ -1000,13 +1002,22 @@ footer a:hover{color:var(--laranja2)}
<div class="foco2" id="foco2">
<div class="fila">
<div class="trocouCam" id="trocouCam" style="display:none"></div>
<div class="trocarModo" id="trocarParaAvulsa" hidden>
<b>PNG e JPG entram como artes avulsas.</b> Informe largura e quantidade;
montamos a folha, com o preço de artes avulsas.
<label><input type="checkbox" id="imagemComoFolha">
Minha imagem já é uma folha montada de <span id="larguraFolhaPronta">57</span> cm
· usar o preço de arquivo por metro</label>
<div class="caminhos" id="tipoEnvio" hidden>
<button type="button" class="cam on" data-tipo="folha">
<span class="mk2"></span>
<div><b>Já é uma folha montada</b>
<span>a imagem ocupa a largura inteira do filme de
<span id="larguraFolhaPronta">57</span> cm</span>
<em id="tipoFolhaPreco">—</em></div>
</button>
<button type="button" class="cam" data-tipo="avulsa">
<span class="mk2"></span>
<div><b>São artes separadas</b>
<span>você informa a largura de cada uma e nós montamos a folha</span>
<em id="tipoAvulsaPreco">—</em></div>
</button>
</div>
<div class="trocouCam" id="avisoTipo" style="display:none"></div>
<div class="caminhos" id="caminhos">
<button class="cam on" data-cam="auto">
<span class="mk2"></span>
@@ -1232,13 +1243,17 @@ footer a:hover{color:var(--laranja2)}
<button id="bPagar">Ir para o pagamento</button>
<button id="bMais" class="sec">Adicionar outro produto</button>
</div>
<p id="checkoutStatus" class="avisoE" role="status" aria-live="polite"></p>
<div id="checkoutActions"></div>
<p class="obs">A nota fiscal sai no CNPJ informado. O arquivo fica guardado por 90 dias e o histórico
do pedido por 12 meses, para você repetir sem subir de novo.</p>
</aside>
</div>
<!-- Fora de #carr de propósito: o pedido pago esvazia o carrinho, e a
confirmação não pode sumir junto com o painel. -->
<div class="checkout">
<p id="checkoutStatus" class="avisoE" role="status" aria-live="polite"></p>
<div id="checkoutActions"></div>
</div>
</div></section>
<!-- INFORMAÇÃO EM CARROSSEL -->
@@ -1418,6 +1433,11 @@ let CLIENTE='SEU NOME AQUI', PEDIDO='000000';
let modo=null, artes=[], recemChegada=null, metros=0, nota=0, reencaixado=false;
let folhas=[]; // [{f, med, rep, m}] · o cliente pode subir várias
const folhaTotalM=()=>folhas.reduce((t,x)=>t+(x.m||0)*(x.rep||1),0);
// O cliente declara o que está mandando escolhendo o produto, em vez de o site
// adivinhar pelo formato do arquivo. A declaração de folha montada é conferida na
// medição: uma imagem sem pixel para ocupar a largura do filme não é uma folha.
// Cada modo por metro tem o seu par de artes avulsas, e o seletor anda nos dois sentidos.
const PAR={file:'avulsa', avulsa:'file', uvfile:'uv', uv:'uvfile'};
let montagemCm=0; // altura da folha montada pelo motor, em cm
const ZOOMS=[1,1.5,2,3,4]; let zoomI=0; // ampliação da montagem ao vivo
let pedido=[]; // itens já fechados · o pagamento é um só
@@ -1458,14 +1478,10 @@ const px=f=>Math.round(Math.min(6000,Math.max(600,Math.sqrt(f.size/1024)*95)));
// ── escolha
document.querySelectorAll('.ec').forEach(b=>b.addEventListener('click',()=>abrir(b.dataset.modo)));
document.querySelectorAll('[data-modo-cta]').forEach(b=>b.addEventListener('click',e=>{
e.preventDefault(); abrir(b.dataset.modoCta);
}));
function abrir(m){
modo=m; artes=[]; folhas=[]; metros=0; nota=0; reencaixado=false;
montagemCm=0;
$('imagemComoFolha').checked=false;
$('imagemComoFolha').disabled=false;
avisoTipo('');
pintaModo();
$('lista').innerHTML=''; $('rA').style.display='none'; recusa([]);
recemChegada=null; caminho='auto'; avisoCam(''); pintaCaminhos();
@@ -1484,10 +1500,64 @@ function pintaModo(){
$('inp').accept = ehFolha(m) ? '.png,.jpg,.jpeg,.pdf'
: '.png,.jpg,.jpeg,.webp';
$('catFoco').className='cat '+(ehFolha(m)?'file':'av');
$('trocarParaAvulsa').hidden=!ehFolha(m);
$('larguraFolhaPronta').textContent=String(c.larg).replace('.',',');
$('foco2').classList.remove('sofila'); // a caixa de requisitos vale nos 4 modos
$('montcabTit').textContent = ehFolha(m) ? 'Sua folha' : 'Montagem ao vivo';
pintaTipoEnvio();
}
// O par folha montada / artes separadas fica visível e com preço nos dois lados,
// nos dois modos: nenhuma troca acontece sem o cliente ver o que muda, e sempre
// dá para voltar atrás sem recomeçar o pedido.
function pintaTipoEnvio(){
const el=$('tipoEnvio'); if(!el) return;
el.hidden=!modo;
if(el.hidden) return;
const folha = ehFolha() ? modo : PAR[modo];
const avulsa = ehFolha() ? PAR[modo] : modo;
$('tipoFolhaPreco').textContent='a partir de '+rs(pisoEscada(folha));
$('tipoAvulsaPreco').textContent='a partir de '+rs(pisoEscada(avulsa))+' · montagem inclusa';
$('larguraFolhaPronta').textContent=String(MODOS[folha].larg).replace('.',',');
const atual = ehFolha() ? 'folha' : 'avulsa';
el.querySelectorAll('.cam').forEach(b=>{
b.classList.toggle('on', b.dataset.tipo===atual);
b.disabled=folhas.length>0 || artes.length>0; // já tem arquivo: a escolha está feita
});
}
function avisoTipo(txt){
const el=$('avisoTipo'); if(!el) return;
el.innerHTML=txt; el.style.display = txt? '' : 'none';
}
// A imagem foi declarada folha montada mas não tem resolução para isso. Devolve o
// arquivo com o motivo e o caminho certo — sem trocar preço por conta própria.
function recusaFolha(x, md){
const arquivo=x.f, destino = modo==='uvfile' ? 'uv' : 'avulsa';
folhas=folhas.filter(y=>y!==x);
pintaFolha();
avisoTipo('<b>'+escapeHTML(arquivo.name)+' não é uma folha montada.</b> '+
'Para ocupar os '+n1(larguraFilme())+' cm do filme ela teria só '+md.dpiFolha+
' DPI, e o mínimo para imprimir é '+DPI_RECUSA+'. '+
'Se for uma arte para montarmos, use <b>'+MODOS[destino].tit+'</b>, '+
'a partir de '+rs(pisoEscada(destino))+'/m. '+
'<button type="button" id="usarAvulsa">Enviar como '+MODOS[destino].tit.toLowerCase()+'</button>');
const botao=$('usarAvulsa');
if(botao) botao.addEventListener('click',()=>{
avisoTipo(''); trocaTipo(PAR[modo]); sel([arquivo]);
});
}
document.querySelectorAll('#tipoEnvio .cam').forEach(b=>b.addEventListener('click',()=>{
if(b.disabled) return;
const querFolha=b.dataset.tipo==='folha';
if(querFolha===ehFolha()) return;
avisoTipo(''); trocaTipo(PAR[modo]); // escolha explícita do cliente
}));
// Troca declarada, nunca silenciosa: o cliente já viu os dois preços no seletor.
function trocaTipo(destino){
if(!destino || modo===destino) return;
folhas=[]; artes=[]; recemChegada=null; metros=0; nota=0; montagemCm=0;
modo=destino;
$('lista').innerHTML=''; $('rA').style.display='none'; recusa([]);
caminho='auto'; avisoCam('');
pintaModo(); pintaCaminhos(); limpaPaineis(); agendaAvaliacao(); previaAoVivo();
}
$('bVoltar').addEventListener('click',()=>{
itemAtual=null; $('atual').style.display='none'; $('carrLin').innerHTML=''; pintaPedido();
@@ -1525,18 +1595,9 @@ Z.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){I.click();e.pr
I.addEventListener('change',()=>sel([...I.files]));
function sel(fs){
if(!fs.length) return;
// Pixels cannot tell a loose artwork from a finished sheet. Images default
// to the artwork model unless the customer explicitly declares a ready sheet.
// Update this same editor in place; do not reopen it or discard uploaded files.
if(ehFolha() && !$('imagemComoFolha').checked && fs.some(f=>ACEITA.avulsa.test(f.name))){
if(fs.some(f=>!ACEITA.avulsa.test(f.name))){
$('recusa').style.display='block';
$('recusa').textContent='Envie artes avulsas e folhas prontas separadamente. Para uma imagem já montada, marque a opção de folha pronta.';
return;
}
modo=modo==='uvfile'?'uv':'avulsa';
pintaModo(); pintaCaminhos();
}
// Pixels cannot tell a loose artwork from a finished sheet, so the customer
// declares it by choosing the product in the selector above. The declaration is
// verified in medirFolha; the file never changes product or price on its own.
const regra = ehFolha() ? ACEITA.folha : ACEITA.avulsa;
const fora = fs.filter(f=>!regra.test(f.name));
fs = fs.filter(f=>regra.test(f.name));
@@ -1559,12 +1620,12 @@ function sel(fs){
pintaCaminhos();
}
const novas=fs.map(f=>({f, med:null, rep:1, m:0, semAnalise:null}));
$('imagemComoFolha').checked=true;
folhas=folhas.concat(novas); // soma, não substitui
pintaFolha();
novas.forEach(x=>{
x.pct=5; pintaFolha();
medirFolha(x.f).then(md=>{
if(md && md.dpiFolha!=null && md.dpiFolha<DPI_RECUSA) return recusaFolha(x, md);
x.med=md; if(md) x.m=md.alt/100; x.pct=55; pintaFolha();
if(md && RENDERIZA.test(x.f.name)){
carregarImagem(x.f).then(img=>{
@@ -1846,7 +1907,11 @@ function medirFolha(file){
carregarImagem(file).then(img=>{
if(!img || !img.width) return res(null);
const L=larguraFilme();
res({larg:L, alt:+(img.height/img.width*L).toFixed(1), fonte:'proporção da imagem'});
// Uma imagem só é folha montada se tiver pixel para ocupar a largura do
// filme. Sem esta conferência, qualquer arte pequena vira "folha" e leva
// o preço por metro sem nunca ter sido montada.
res({larg:L, alt:+(img.height/img.width*L).toFixed(1), fonte:'proporção da imagem',
dpiFolha:Math.round(img.width/(L/2.54)), px:img.width});
});
return;
}
@@ -1882,8 +1947,7 @@ function medirFolha(file){
function pintaFolha(){
const L=larguraFilme();
$('imagemComoFolha').disabled=folhas.length>0;
if(!folhas.length) $('imagemComoFolha').checked=false;
pintaTipoEnvio(); // trava o seletor enquanto houver folha
if(!folhas.length){ $('lista').innerHTML=''; agendaAvaliacao(); previaAoVivo(); return; }
$('lista').innerHTML = folhas.map((x,i)=>{
@@ -2769,7 +2833,7 @@ function pintaCaminhos(){
const ehF=modo&&ehFolha();
$('caminhos').style.display = ehF? '' : 'none';
if(!ehF) return;
document.querySelectorAll('.cam').forEach(b=>b.classList.toggle('on', b.dataset.cam===caminho));
document.querySelectorAll('#caminhos .cam').forEach(b=>b.classList.toggle('on', b.dataset.cam===caminho));
$('camA').textContent='a partir de '+rs(pisoEscada());
$('camB').textContent=rs(TABELA[modo])+' fixo';
$('inp').accept = caminho==='auto' ? '.png,.jpg,.jpeg,.pdf'
@@ -2778,7 +2842,7 @@ function pintaCaminhos(){
? 'PNG, JPG ou PDF · conferência automática, nota na tela'
: 'CDR, AI, PSD ou TIFF · sem conferência, metro pela tabela';
}
document.querySelectorAll('.cam').forEach(b=>b.addEventListener('click',()=>{
document.querySelectorAll('#caminhos .cam').forEach(b=>b.addEventListener('click',()=>{
caminho=b.dataset.cam; avisoCam(''); pintaCaminhos();
}));
function avisoCam(txt){

View File

@@ -103,9 +103,28 @@ try{
if(drop)$('zona').dispatchEvent(new DragEvent('drop',{dataTransfer:dt,bubbles:true,cancelable:true}));
else{$('inp').files=dt.files;$('inp').dispatchEvent(new Event('change',{bubbles:true}));}
};
// A genuine mounted sheet spans the film width with printable resolution.
window.sendSheet=async()=>{
const cm=larguraFilme(), px=Math.ceil(cm/2.54*150);
const c=document.createElement('canvas');c.width=px;c.height=Math.round(px*0.6);
const ctx=c.getContext('2d');ctx.fillStyle='white';ctx.fillRect(0,0,c.width,c.height);
ctx.fillStyle='#222';ctx.fillRect(40,40,px/4,px/4);
const blob=await new Promise(r=>c.toBlob(r,'image/png'));
const dt=new DataTransfer();dt.items.add(new File([blob],'folha-montada.png',{type:'image/png'}));
$('inp').files=dt.files;$('inp').dispatchEvent(new Event('change',{bubbles:true}));
};
window.pickTipo=t=>document.querySelector('#tipoEnvio .cam[data-tipo="'+t+'"]').click();
})()`);
// Reproduce the reported entry path, with no navigation workaround.
// Navigation links must reach the chooser, never preselect a product.
assert.equal(await evaluate(`document.querySelectorAll('[data-modo-cta]').length`),0);
await click('[data-modo="file"]');
// By-metre opens declaring a mounted sheet; switching is explicit and priced.
assert.deepEqual(await evaluate(`({shown:!$('tipoEnvio').hidden,
on:[...document.querySelectorAll('#tipoEnvio .cam.on')].map(b=>b.dataset.tipo)})`),
{shown:true,on:['folha']});
await evaluate(`pickTipo('avulsa')`);
assert.equal(await evaluate('modo'),'avulsa');
await evaluate('sendImage()');
await waitFor(()=>evaluate(`artes.length===1 && !!artes[0].src`),'JPG artwork model');
assert.deepEqual(await evaluate(`({mode:modo,sheets:folhas.length,width:artes[0].cm,quantity:artes[0].q})`),{mode:'avulsa',sheets:0,width:0,quantity:1});
@@ -134,26 +153,35 @@ try{
const m=result.pos[0].m;
return !m.bits[2*m.cw+2] && !m.bits[2*m.cw+30] && !!m.bits[15*m.cw+30];
})()`),true);
// Explicit finished-sheet images retain their separate dimensions and price.
// A real finished sheet keeps its own dimensions and by-metre price.
await click('#bVoltar');await click('[data-modo="file"]');
await click('#imagemComoFolha');await evaluate('sendImage()');
await waitFor(()=>evaluate('folhas.length===1 && !!folhas[0].previewSrc && metros===1.14'),'ready-sheet image');
await evaluate('sendSheet()');
await waitFor(()=>evaluate('folhas.length===1 && !!folhas[0].previewSrc && metros===0.342'),'ready-sheet image');
assert.deepEqual(await evaluate(`({mode:modo,art:artes.length,title:$('montcabTit').textContent,rate:precoBase(100)})`),{mode:'file',art:0,title:'Sua folha',rate:14.9});
assert.equal(await evaluate(`document.querySelectorAll('[data-cm]').length`),0);
// With a sheet loaded the declaration is locked; it cannot flip under the customer.
assert.equal(await evaluate(`document.querySelector('#tipoEnvio .cam[data-tipo="avulsa"]').disabled`),true);
await evaluate(`(()=>{const el=$('lista').querySelector('[data-repf]');el.value=2;el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await waitFor(()=>evaluate('metros===2.28'),'ready-sheet repetitions');
await waitFor(()=>evaluate('metros===0.684'),'ready-sheet repetitions');
assert.equal(await evaluate(`document.querySelectorAll('.folhaPrevia').length`),1);
// Keep manual ready-sheet uploads intact and refuse ambiguous mixed batches.
// An image too small to span the film is refused, never silently repriced.
await click('#bVoltar');await click('[data-modo="file"]');
await evaluate('sendImage()');
await waitFor(()=>evaluate(`!!document.getElementById('usarAvulsa')`),'ready-sheet refusal');
assert.deepEqual(await evaluate(`({mode:modo,sheets:folhas.length,art:artes.length})`),{mode:'file',sheets:0,art:0});
await click('#usarAvulsa');
await waitFor(()=>evaluate(`modo==='avulsa' && artes.length===1`),'one-click recovery into loose artwork');
// Keep manual ready-sheet uploads intact.
await click('#bVoltar');await click('[data-modo="file"]');
await evaluate(`sel([new File(['sheet'],'sheet.cdr'),new File(['image'],'art.png')])`);
assert.deepEqual(await evaluate('[folhas.length,artes.length]'),[0,0]);
await evaluate(`sel([new File(['sheet'],'sheet.cdr')])`);
await waitFor(()=>evaluate(`!!$('lista').querySelector('[data-comp]')`),'manual ready sheet');
await evaluate(`(()=>{const el=$('lista').querySelector('[data-comp]');el.value=1.01;el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await waitFor(()=>evaluate('itemAtual?.total===21.89'),'unchanged manual pricing');
// PNG drag/drop follows exactly the same artwork path; UV stays UV.
for(const [mode,expected] of [['file','avulsa'],['avulsa','avulsa'],['uvfile','uv']]){
// PNG drag/drop follows exactly the same artwork path; UV stays UV. Reaching it
// from a by-metre product is one declared click, and it is reversible.
for(const [mode,expected] of [['file','avulsa'],['avulsa','avulsa'],['uvfile','uv'],['uv','uv']]){
await click('#bVoltar');await click('[data-modo="'+mode+'"]');
if(await evaluate(`ehFolha()`))await evaluate(`pickTipo('avulsa')`);
await evaluate(`sendImage('image/png',true)`);
await waitFor(()=>evaluate('artes.length===1 && !!artes[0].src'),'PNG drop');
assert.equal(await evaluate('modo'),expected);