first commit
This commit is contained in:
47
local/static/cart.js
Normal file
47
local/static/cart.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/* Browser-local cart recovery. Files are stored only for an unfinished cart, for 24h. */
|
||||
(() => {
|
||||
let database,scope,timer,restoring=true;
|
||||
let signedOut=false;
|
||||
window.addEventListener('dtf-private-data-cleared',()=>{signedOut=true;clearTimeout(timer);pedido=[];itemAtual=null;folhas=[];artes=[];});
|
||||
const notice=document.createElement('p');notice.style.cssText='font:13px system-ui;color:#56616d;padding:8px 20px';
|
||||
document.getElementById('carr').prepend(notice);
|
||||
function transaction(mode,fn){return new Promise((resolve,reject)=>{const tx=database.transaction('cart',mode);const request=fn(tx.objectStore('cart'));let result;request.onsuccess=()=>result=request.result;tx.oncomplete=()=>resolve(result);tx.onerror=()=>reject(tx.error);tx.onabort=()=>reject(tx.error);});}
|
||||
async function save(){
|
||||
if(signedOut||restoring||!database||!scope)return;
|
||||
const items=[...pedido,...(itemAtual?[itemAtual]:[])];
|
||||
try{
|
||||
if(!items.length){await transaction('readwrite',store=>store.delete(scope));return;}
|
||||
await transaction('readwrite',store=>store.put({items,customer:{...cliente},delivery:{...entrega},expires:Date.now()+86400000},scope));
|
||||
notice.textContent='Carrinho salvo neste navegador por 24 horas. Você pode remover itens e adicionar outros após recarregar.';
|
||||
}catch(error){notice.textContent='Não foi possível salvar o carrinho neste navegador. Mantenha esta página aberta até enviar o pedido.';}
|
||||
}
|
||||
window.dtfClearCart=async()=>{clearTimeout(timer);if(database&&scope)await transaction('readwrite',store=>store.delete(scope));notice.textContent='';};
|
||||
window.addEventListener('dtf-cart-changed',()=>{clearTimeout(timer);timer=setTimeout(save,400);});
|
||||
window.addEventListener('pagehide',()=>{clearTimeout(timer);save();});
|
||||
setInterval(async()=>{
|
||||
if(!database||signedOut)return;
|
||||
try{const keys=await transaction('readonly',s=>s.getAllKeys());for(const key of keys){const value=await transaction('readonly',s=>s.get(key));if(value&&value.expires<Date.now())await transaction('readwrite',s=>s.delete(key));}}catch{/* Browser may close storage during navigation. */}
|
||||
},60000);
|
||||
(async()=>{
|
||||
try{
|
||||
scope=(await window.dtfSessionReady).cart_scope;
|
||||
database=await new Promise((resolve,reject)=>{const req=indexedDB.open('dtf-local-cart',1);req.onupgradeneeded=()=>req.result.createObjectStore('cart');req.onsuccess=()=>resolve(req.result);req.onerror=()=>reject(req.error);});
|
||||
const records=await transaction('readonly',store=>store.getAllKeys());
|
||||
for(const key of records){const value=await transaction('readonly',store=>store.get(key));if(value.expires<Date.now())await transaction('readwrite',store=>store.delete(key));}
|
||||
const saved=await transaction('readonly',store=>store.get(scope));
|
||||
if(saved && !pedido.length && !itemAtual){
|
||||
pedido=saved.items;cliente=saved.customer;entrega=saved.delivery;
|
||||
// Freight must be quoted again; restored browser values are never final.
|
||||
if(entrega.tipo==='frete'){entrega.cotado=false;entrega.valor=0;}
|
||||
for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];
|
||||
$('cepIn').value=entrega.cep;
|
||||
$('atual').style.display='none';pintaEntrega();
|
||||
notice.textContent='Carrinho recuperado. Remova e adicione novamente um item se precisar alterar sua montagem.';
|
||||
}else{
|
||||
const account=await window.dtfApi('/account/me');
|
||||
if(account.customer && !cliente.mail){cliente={...account.customer};for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];pintaEntrega();}
|
||||
}
|
||||
}catch(error){notice.textContent='Recuperação de carrinho indisponível; o pedido ainda pode ser feito nesta sessão.';}
|
||||
finally{restoring=false;}
|
||||
})();
|
||||
})();
|
||||
126
local/static/checkout.js
Normal file
126
local/static/checkout.js
Normal file
@@ -0,0 +1,126 @@
|
||||
/* Checkout bridge only: approved commercial functions in dtf-site.html stay intact. */
|
||||
(() => {
|
||||
const box = document.createElement('section');
|
||||
box.style.cssText = 'position:relative;z-index:20;background:#152026;color:#e6f4f7;padding:12px 24px;font:14px system-ui;border-bottom:2px solid #00b8da';
|
||||
box.innerHTML = '<b>Ambiente local · pagamento simulado</b> <span id="local-status" role="status"></span><div id="local-actions"></div>';
|
||||
document.body.prepend(box);
|
||||
const status = document.getElementById('local-status');
|
||||
const actions = document.getElementById('local-actions');
|
||||
let busy = false;
|
||||
let draftId = localStorage.getItem('dtf-quote');
|
||||
let requestKey = localStorage.getItem('dtf-request-key');
|
||||
let requestBody = localStorage.getItem('dtf-request-body');
|
||||
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 data = await response.json();
|
||||
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');
|
||||
window.dtfSessionReady=ready;
|
||||
window.dtfApi=api;
|
||||
ready.catch(error => { status.textContent = error.message; });
|
||||
function message(text) { status.textContent = ' · '+text; }
|
||||
function button(label, handler) {
|
||||
const el = document.createElement('button');
|
||||
el.textContent = label;
|
||||
el.style.cssText = 'margin:8px 8px 0 0;padding:8px 14px;cursor:pointer';
|
||||
el.onclick = handler;
|
||||
actions.append(el);
|
||||
return el;
|
||||
}
|
||||
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 simulado local: '+rs(entrega.valor)+'. Nenhuma transportadora foi consultada.';
|
||||
pintaEntrega();
|
||||
} catch(error) { $('cepMsg').textContent = error.message; }
|
||||
};
|
||||
window.dtfCheckout = async () => {
|
||||
if (busy) return;
|
||||
if (draftId) { await refresh(); box.scrollIntoView(); return; }
|
||||
if (!clienteOk() || !entrega.cotado) return;
|
||||
const cart = [...pedido,...(itemAtual?[itemAtual]:[])];
|
||||
if (!cart.length) return message('Adicione um item ao pedido.');
|
||||
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));
|
||||
items.push({mode:item.modo, metres:String(item.metros), grade:item.nota, uploads});
|
||||
}
|
||||
const content = {customer:{...cliente},items,freight:{service:entrega.tipo==='retira'?'pickup':'mock-standard',postal_code:entrega.tipo==='retira'?'':entrega.cep}};
|
||||
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});
|
||||
draftId=quote.id; localStorage.setItem('dtf-quote',draftId);
|
||||
await refresh();
|
||||
box.scrollIntoView({behavior:'smooth'});
|
||||
} catch(error) { message(error.message); }
|
||||
finally { busy=false; pintaEntrega(); }
|
||||
};
|
||||
async function refresh() {
|
||||
if (!draftId) return;
|
||||
try {
|
||||
await ready;
|
||||
const quote=await api('/quotes/'+draftId);
|
||||
actions.replaceChildren();
|
||||
if (quote.status==='pending_review') {
|
||||
message('Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.');
|
||||
} else if (quote.status==='approved') {
|
||||
message('Total validado no servidor: '+rs(quote.approved.total_cents/100)+' · inclui frete. Cotação válida por 24 horas.');
|
||||
button('Criar pedido pago local',async event=>{
|
||||
event.target.disabled=true;
|
||||
try {
|
||||
const order=await api('/orders/dev-paid',{quote_id:draftId});
|
||||
pedido=[]; itemAtual=null; limpaPaineis();
|
||||
await window.dtfClearCart?.();
|
||||
message('Pedido local #'+order.number+' pago e disponível no Kanban.');
|
||||
await refresh();
|
||||
} catch(error) { message(error.message); event.target.disabled=false; }
|
||||
});
|
||||
} else if (quote.status==='paid') {
|
||||
message('Pedido local #'+quote.order.number+' pago · etapa: '+quote.order.state+'. Nenhuma cobrança real.');
|
||||
button('Novo pedido local',()=>{localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');localStorage.removeItem('dtf-request-body');location.reload();});
|
||||
} else {
|
||||
message('Cotação expirada. Envie o carrinho para uma nova revisão.');
|
||||
button('Nova cotação',()=>{draftId=null;requestKey=null;localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');actions.replaceChildren();});
|
||||
}
|
||||
} catch(error) {
|
||||
message(error.message);
|
||||
actions.replaceChildren();
|
||||
button('Limpar referência local e tentar de novo',()=>{draftId=null;requestKey=null;localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');actions.replaceChildren();});
|
||||
}
|
||||
}
|
||||
// Explicit refresh avoids replacing focused payment controls during interaction.
|
||||
const refreshButton = document.createElement('button');
|
||||
refreshButton.textContent='Atualizar pedido local';
|
||||
refreshButton.style.cssText='margin-left:12px;padding:6px;cursor:pointer';
|
||||
refreshButton.onclick=refresh;
|
||||
box.append(refreshButton);
|
||||
const portalLink=document.createElement('a');portalLink.href='/portal.html';portalLink.textContent='Minha conta e pedidos';
|
||||
portalLink.style.cssText='color:#e6f4f7;margin-left:16px;text-decoration:underline';box.append(portalLink);
|
||||
const quoteFromPortal=new URLSearchParams(location.search).get('quote');
|
||||
if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId);}
|
||||
refresh();
|
||||
})();
|
||||
21
local/static/kanban.html
Normal file
21
local/static/kanban.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>DTF · Kanban local</title>
|
||||
<style>
|
||||
:root{--bg:#0B0D10;--card:#15181D;--card2:#1C2026;--linha:#282C34;--tx:#EDEBE6;--fraco:#a1a6af;--ciano:#00B8DA}
|
||||
*{box-sizing:border-box}body{background:var(--bg);color:var(--tx);font:14px/1.5 system-ui,sans-serif;padding:20px;margin:0}
|
||||
h1{font-size:24px;margin:0}h2{font-size:18px}header{display:flex;align-items:center;gap:18px;flex-wrap:wrap;margin-bottom:20px}
|
||||
.aviso{background:#152026;border-left:3px solid var(--ciano);padding:12px;color:#b9cbd2;margin:16px 0}
|
||||
button,input,select{font:inherit;border:1px solid #56616d;border-radius:5px;padding:8px;background:var(--card2);color:var(--tx)}
|
||||
button{cursor:pointer}button:hover{border-color:var(--ciano)}button:disabled{opacity:.5}input[type=number]{width:100px}
|
||||
label{display:inline-flex;gap:8px;align-items:center;margin:5px}#kan{display:grid;grid-template-columns:repeat(6,minmax(220px,1fr));gap:10px;overflow-x:auto;padding-bottom:16px}
|
||||
.col{background:var(--card);border:1px solid var(--linha);border-radius:9px;min-height:250px;padding:10px}.col h2{font-size:14px;border-bottom:2px solid var(--cc);padding-bottom:10px}.col.alvo{border-color:var(--ciano)}
|
||||
.cd,.review{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc,var(--ciano));border-radius:6px;padding:12px;margin-bottom:10px}.cd{cursor:grab}.cd p{overflow-wrap:anywhere}.meta{color:var(--fraco);font-size:12px}.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.error{color:#ffad83}#status{min-height:24px}details{margin:16px 0}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--ciano)}
|
||||
</style></head><body>
|
||||
<header><h1>Kanban DTF</h1><span class="meta">Desenvolvimento local</span><button id="refresh">Atualizar</button><button id="logout">Sair</button></header>
|
||||
<div class="aviso">Pagamentos, Tiny/Olist, WhatsApp e frete são simulados. Confira a cotação manualmente. Em Arquivos de produção, envie e aprove os arquivos finais de todos os itens antes de colocar o pedido na fila. Não há validação automática de arte.</div>
|
||||
<form id="login"><label>Usuário <input id="user" autocomplete="username" required></label><label>Senha local <input id="password" type="password" autocomplete="current-password" required></label><button>Entrar</button></form>
|
||||
<p id="status" role="status"></p>
|
||||
<section id="reviews"></section><div id="kan"></div>
|
||||
<details><summary>Eventos locais de integração</summary><pre id="events"></pre></details>
|
||||
<script src="/upload.js"></script><script src="/kanban.js"></script></body></html>
|
||||
107
local/static/kanban.js
Normal file
107
local/static/kanban.js
Normal file
@@ -0,0 +1,107 @@
|
||||
/* Reuses the prototype palette, column names and drag/drop interaction; no machine controls. */
|
||||
const $ = id=>document.getElementById(id);
|
||||
// Remove credentials saved by older local builds. Only HttpOnly sessions now.
|
||||
sessionStorage.removeItem('dtf-operator');
|
||||
let board;
|
||||
const money=cents=>(cents/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'});
|
||||
function node(tag,text,className){const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(className)e.className=className;return e;}
|
||||
function action(text,fn){const b=node('button',text);b.onclick=async()=>{b.disabled=true;try{await fn();}catch(e){$('status').textContent=e.message;}finally{b.disabled=false;}};return b;}
|
||||
async function api(path,body){
|
||||
const r=await fetch('/api/operator'+path,{headers:{'Content-Type':'application/json'},...(body===undefined?{}:{method:'POST',body:JSON.stringify(body)})});
|
||||
const d=await r.json();if(!r.ok){const error=new Error(typeof d.detail==='string'?d.detail:'Confira os campos ('+r.status+').');error.status=r.status;throw error;}return d;
|
||||
}
|
||||
function files(container,items){
|
||||
for(const uid of [...new Set(items.flatMap(i=>i.uploads))])container.append(action('Baixar original '+uid.slice(0,6),async()=>{
|
||||
const result=await api('/uploads/'+uid+'/download');
|
||||
const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click();
|
||||
}));
|
||||
}
|
||||
async function load(){
|
||||
try{
|
||||
board=await api('/board');$('login').hidden=true;
|
||||
$('status').textContent='Atualizado às '+new Date().toLocaleTimeString();
|
||||
render();
|
||||
}catch(e){$('status').textContent=e.message;$('login').hidden=false;}
|
||||
}
|
||||
function render(){
|
||||
$('reviews').replaceChildren();
|
||||
if(board.quotes.length)$('reviews').append(node('h2','Cotações · conferência comercial'));
|
||||
for(const quote of board.quotes){
|
||||
const card=node('article',undefined,'review');
|
||||
card.append(node('b',quote.id.slice(0,8)+' · '+quote.draft.customer.mail));
|
||||
if(quote.status!=='pending_review'){
|
||||
card.append(node('p',quote.status==='expired'?'Cotação expirada. O cliente precisa solicitar outra.':'Aprovada: '+money(quote.approved.total_cents)+'. Volte ao Site, atualize o pedido e confirme o pagamento local.'));
|
||||
}else{
|
||||
const form=node('form');
|
||||
const edits=quote.draft.items.map((item,index)=>{
|
||||
const row=node('div');row.append(node('strong',(index+1)+'. '+item.mode+' '));
|
||||
const metres=node('input');metres.type='number';metres.min='0.001';metres.max='12000';metres.step='any';metres.value=item.metres;metres.required=true;
|
||||
const grade=node('input');grade.type='number';grade.min='0';grade.max='100';grade.step='1';grade.value=item.grade;grade.required=true;
|
||||
const ml=node('label','Metros conferidos');ml.append(metres);const gl=node('label','Nota conferida');gl.append(grade);row.append(ml,gl);form.append(row);
|
||||
return ()=>({...item,metres:metres.value,grade:Number(grade.value)});
|
||||
});
|
||||
const check=node('input');check.type='checkbox';check.required=true;const label=node('label','Conferi os arquivos, a metragem total (incluindo repetições/montagem) e a nota.');label.prepend(check);form.append(label);
|
||||
const submit=node('button','Aprovar cotação');submit.type='submit';form.append(submit);
|
||||
form.onsubmit=async e=>{e.preventDefault();submit.disabled=true;try{await api('/quotes/'+quote.id+'/approve',{items:edits.map(fn=>fn())});await load();}catch(error){$('status').textContent=error.message;submit.disabled=false;}};
|
||||
card.append(form);
|
||||
}
|
||||
const downloads=node('div',undefined,'actions');files(downloads,quote.draft.items);card.append(downloads);$('reviews').append(card);
|
||||
}
|
||||
$('kan').replaceChildren();
|
||||
const colors=['#00b8da','#e0357c','#efb700','#edebe6','#e0642a','#48b072'];
|
||||
Object.entries(board.states).forEach(([state,title],index)=>{
|
||||
const column=node('section',undefined,'col');column.dataset.state=state;column.style.setProperty('--cc',colors[index]);
|
||||
const orders=board.orders.filter(o=>o.state===state);column.append(node('h2',title+' · '+orders.length));
|
||||
for(const order of orders){
|
||||
const card=node('article',undefined,'cd');card.draggable=true;card.dataset.order=order.id;
|
||||
card.append(node('b','#'+order.number+' · Pago local'),node('p',order.snapshot.customer.mail,'meta'),node('p',money(order.snapshot.total_cents)));
|
||||
for(const item of order.snapshot.items)card.append(node('p',item.mode+' · '+item.billed_metres+' m · nota '+item.grade,'meta'));
|
||||
const actions=node('div',undefined,'actions');files(actions,order.snapshot.items);
|
||||
const artwork=node('div');
|
||||
actions.append(action('Arquivos de produção',()=>artworkPanel(order,artwork)));
|
||||
actions.append(action('Histórico',async()=>{const rows=await api('/orders/'+order.id+'/history');alert(rows.map(r=>board.states[r.from_state]+' → '+board.states[r.to_state]+' · '+r.operator+(r.reason?' · '+r.reason:'')).join('\n')||'Pedido recebido.');}));
|
||||
for(const next of board.transitions[state])actions.append(action('→ '+board.states[next],()=>move(order,next)));
|
||||
card.append(actions,artwork);card.ondragstart=e=>e.dataTransfer.setData('text/plain',order.id);column.append(card);
|
||||
}
|
||||
column.ondragover=e=>{e.preventDefault();column.classList.add('alvo');};column.ondragleave=()=>column.classList.remove('alvo');
|
||||
column.ondrop=async e=>{e.preventDefault();column.classList.remove('alvo');const order=board.orders.find(o=>o.id===e.dataTransfer.getData('text/plain'));if(order)try{await move(order,state);}catch(error){$('status').textContent=error.message;}};
|
||||
$('kan').append(column);
|
||||
});
|
||||
$('events').textContent=board.events.map(e=>e.provider+' · '+e.payload.event+' · pedido #'+e.payload.number+' · '+(e.delivered_at?'registrado localmente':'pendente')+' · tentativas '+e.attempts).join('\n')||'Nenhum evento.';
|
||||
}
|
||||
async function move(order,state){
|
||||
let reason='';if(state==='cor'){reason=prompt('Motivo da correção:');if(!reason)return;}
|
||||
await api('/orders/'+order.id+'/move',{state,version:order.version,reason});await load();
|
||||
}
|
||||
$('login').onsubmit=async e=>{e.preventDefault();try{await api('/login',{username:$('user').value,password:$('password').value});await load();}catch(error){$('status').textContent=error.message;}finally{$('password').value='';}};
|
||||
$('logout').onclick=async()=>{await api('/logout',{});for(const key of Object.keys(localStorage))if(key.startsWith('dtf-'))localStorage.removeItem(key);location.reload();};
|
||||
$('refresh').onclick=load;
|
||||
load();
|
||||
|
||||
async function artworkPanel(order,container){
|
||||
container.replaceChildren();
|
||||
const revisions=await api('/orders/'+order.id+'/files');
|
||||
for(const file of revisions){
|
||||
const row=node('p',(file.kind==='final'?'Final':'Correção do cliente')+' · item '+(file.item_index+1)+' · '+file.name+' · '+(file.expired?'expirado':file.active?'atual':'substituído'),'meta');
|
||||
row.append(node('p',file.note));
|
||||
if(!file.expired)row.append(action('Baixar '+file.name,async()=>{const result=await api('/uploads/'+file.upload_id+'/download');const link=node('a');link.href=result.url;link.download=result.name;link.referrerPolicy='no-referrer';link.click();}));
|
||||
container.append(row);
|
||||
}
|
||||
if(!['rec','tra','cor'].includes(order.state))return;
|
||||
const form=node('form');
|
||||
form.append(node('p','Enviar um conjunto final completo. Pode haver várias partes por item. Um novo conjunto substitui o anterior.'));
|
||||
const inputs=order.snapshot.items.map((item,index)=>{const label=node('label','Item '+(index+1)+' · '+item.mode);label.style.display='block';const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index;input.style.width='100%';label.append(input);form.append(label);return input;});
|
||||
const note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000;note.style.width='100%';form.append(note);
|
||||
const check=node('input');check.type='checkbox';check.required=true;const label=node('label','Conferi estes arquivos finais para impressão manual.');label.prepend(check);form.append(label);
|
||||
const submit=node('button','Aprovar arquivos finais');submit.type='submit';form.append(submit);
|
||||
form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{
|
||||
const refs=[];
|
||||
for(const [index,input] of inputs.entries())for(const file of input.files){
|
||||
const uid=await dtfUpload(file,{api,startPath:'/orders/'+order.id+'/uploads',scope:'operator:'+order.id+':'+order.version,progress:text=>$('status').textContent=text});
|
||||
refs.push({item_index:index,upload_id:uid});
|
||||
}
|
||||
await api('/orders/'+order.id+'/final-files',{version:order.version,files:refs,note:note.value});
|
||||
await load();
|
||||
}catch(error){$('status').textContent=error.message;submit.disabled=false;}};
|
||||
container.append(form);
|
||||
}
|
||||
11
local/static/portal.html
Normal file
11
local/static/portal.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Dropstar · Meus pedidos DTF</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}body{margin:0;background:#f6f7f9;color:#20252d;font:15px/1.5 system-ui,sans-serif}header{background:white;border-bottom:1px solid #ddd;padding:20px max(20px,calc((100% - 1100px)/2));display:flex;gap:24px;align-items:center;flex-wrap:wrap}.brand{color:#ef8500;font-size:24px;font-style:italic;font-weight:900}a{color:#8a4e00}main{max-width:1100px;margin:24px auto;padding:0 20px}h1{font-size:28px}h2{font-size:21px}h3{font-size:17px}.notice{background:#fff1d4;border-left:4px solid #ffa900;padding:12px 16px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:20px}.card{background:white;border:1px solid #ddd;border-radius:10px;padding:20px;margin:16px 0}.card p{overflow-wrap:anywhere}.muted{color:#596471}label{display:block;margin:10px 0}input,textarea,button{font:inherit;border:1px solid #9aa0a8;border-radius:6px;padding:10px}input:not([type=file]),textarea{width:100%}button{background:#ffa900;border-color:#ffa900;cursor:pointer}button:disabled{opacity:.6}form{margin:10px 0}.actions{display:flex;gap:10px;flex-wrap:wrap;align-items:center}#message{min-height:24px;color:#9b3800}li{margin:8px 0}.tag{background:#eef2f6;padding:5px 10px;border-radius:6px}details{margin:12px 0}[hidden]{display:none!important}
|
||||
</style></head><body>
|
||||
<header><a class="brand" href="/">DROPSTAR</a><a href="/">Enviar arte</a><span>Minha conta · DTF</span><button id="logout" hidden>Sair</button></header>
|
||||
<main><h1>Meus pedidos DTF</h1><p class="notice">Ambiente de desenvolvimento local. Pagamentos e notificações são simulados. Use apenas dados de teste.</p><p id="message" role="status"></p>
|
||||
<div id="account"></div><section id="auth" class="grid">
|
||||
<form id="login" class="card"><h2>Entrar</h2><label>E-mail <input id="email" type="email" autocomplete="username" required></label><label>Senha <input id="password" type="password" autocomplete="current-password" required></label><button>Entrar</button></form>
|
||||
<form id="register" class="card"><h2>Criar conta local</h2><label>CNPJ <input id="cnpj" required></label><label>WhatsApp <input id="phone" required></label><label>E-mail <input id="register-email" type="email" autocomplete="email" required></label><label>Senha (12 caracteres ou mais) <input id="register-password" type="password" minlength="12" maxlength="128" autocomplete="new-password" required></label><button>Criar conta</button><p class="muted">Pedidos desta sessão serão associados à sua conta. Não há envio de e-mail nem recuperação de senha nesta versão local.</p></form>
|
||||
</section><div class="actions"><h2>Acompanhamento</h2><button id="refresh">Atualizar pedidos</button></div><p id="guest" class="muted"></p><section id="quotes"></section><section id="orders"></section></main>
|
||||
<script src="/privacy.js"></script><script src="/upload.js"></script><script src="/portal.js"></script></body></html>
|
||||
76
local/static/portal.js
Normal file
76
local/static/portal.js
Normal file
@@ -0,0 +1,76 @@
|
||||
const $=id=>document.getElementById(id);
|
||||
const node=(tag,text)=>{const el=document.createElement(tag);if(text!==undefined)el.textContent=text;return el;};
|
||||
const money=c=>(c/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'});
|
||||
let scope,states={};
|
||||
async function api(path,body){const r=await fetch('/api'+path,{headers:{'Content-Type':'application/json'},...(body===undefined?{}:{method:'POST',body:JSON.stringify(body)})});const d=await r.json();if(!r.ok){const error=new Error(typeof d.detail==='string'?d.detail:'Confira os campos informados.');error.status=r.status;throw error;}return d;}
|
||||
function button(text,fn){const b=node('button',text);b.onclick=async()=>{b.disabled=true;try{await fn();}catch(e){$('message').textContent=e.message;}finally{b.disabled=false;}};return b;}
|
||||
function forgetCheckout(){for(const k of ['dtf-quote','dtf-request-key','dtf-request-body'])localStorage.removeItem(k);}
|
||||
async function load(){
|
||||
try{
|
||||
scope=(await api('/session')).cart_scope;
|
||||
const account=await api('/account/me');
|
||||
$('auth').hidden=!!account.customer;$('logout').hidden=!account.customer;
|
||||
$('account').textContent=account.customer?'Conta: '+account.customer.mail:'';
|
||||
$('guest').textContent=account.customer?'':'Você está vendo apenas os pedidos desta sessão. Crie uma conta para acessar seus pedidos em outro navegador.';
|
||||
const data=await api('/customer/orders');states=data.states;
|
||||
$('quotes').replaceChildren();
|
||||
for(const quote of data.quotes){
|
||||
const card=node('article');card.className='card';card.append(node('h3','Cotação '+quote.id.slice(0,8)));
|
||||
card.append(node('p',quote.approved?'Total aprovado: '+money(quote.approved.total_cents):'Aguardando conferência de metragem e nota.'));
|
||||
const link=node('a','Abrir cotação no Site');link.href='/?quote='+quote.id;card.append(link);$('quotes').append(card);
|
||||
}
|
||||
$('orders').replaceChildren();
|
||||
if(!data.orders.length)$('orders').append(node('p','Nenhum pedido pago nesta conta ou sessão.'));
|
||||
for(const order of data.orders){
|
||||
const card=node('article');card.className='card';card.id='order-'+order.id;
|
||||
card.append(node('h3','Pedido #'+order.number),node('p',states[order.state]+' · '+money(order.snapshot.total_cents)+' · pagamento local'));
|
||||
card.append(node('p',new Date(order.created_at).toLocaleString('pt-BR')));
|
||||
const content=node('div');
|
||||
card.append(button('Ver detalhes e arquivos',()=>details(order.id,content)),content);$('orders').append(card);
|
||||
if(new URLSearchParams(location.search).get('order')===order.id)await details(order.id,content);
|
||||
}
|
||||
}catch(error){$('message').textContent=error.message;}
|
||||
}
|
||||
async function details(id,container){
|
||||
const order=await api('/customer/orders/'+id);container.replaceChildren();
|
||||
for(const [i,item] of order.snapshot.items.entries())container.append(node('p',(i+1)+'. '+item.mode+' · '+item.billed_metres+' m · nota '+item.grade+' · '+money(item.total_cents)));
|
||||
const history=node('ol');
|
||||
for(const h of order.history)history.append(node('li',new Date(h.created_at).toLocaleString('pt-BR')+' · '+states[h.to_state]+(h.reason?' — '+h.reason:'')));
|
||||
container.append(history);
|
||||
for(const file of order.files){
|
||||
const row=node('p',(file.kind==='final'?'Arquivo final':'Correção')+' · item '+(file.item_index+1)+' · '+file.name+' · '+(file.expired?'expirado':!file.active?'substituído':'disponível até '+new Date(file.expires_at).toLocaleDateString('pt-BR')));
|
||||
if(file.active&&!file.expired)row.append(button('Baixar',async()=>{const d=await api('/customer/orders/'+id+'/files/'+file.id+'/download');const link=node('a');link.href=d.url;link.download=d.name;link.referrerPolicy='no-referrer';link.click();}));
|
||||
container.append(row);
|
||||
}
|
||||
if(order.state==='cor'){
|
||||
const form=node('form');form.append(node('h3','Enviar arte corrigida'));
|
||||
const inputs=order.snapshot.items.map((item,index)=>{const label=node('label','Item '+(index+1)+' · '+item.mode);const input=node('input');input.type='file';input.multiple=true;input.dataset.item=index;label.append(input);form.append(label);return input;});
|
||||
const note=node('textarea');note.placeholder='Descreva a correção';note.required=true;note.maxLength=1000;form.append(note);
|
||||
const submit=node('button','Enviar correção');submit.type='submit';form.append(submit);
|
||||
form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{
|
||||
const files=[];for(const [index,input] of inputs.entries())for(const file of input.files)files.push({item_index:index,upload_id:await dtfUpload(file,{api,scope:scope+':correction:'+id+':'+order.version,progress:text=>$('message').textContent=text})});
|
||||
if(!files.length)throw new Error('Selecione pelo menos um arquivo corrigido.');
|
||||
await api('/customer/orders/'+id+'/corrections',{version:order.version,files,note:note.value});
|
||||
$('message').textContent='Correção enviada. Aguarde a conferência da equipe.';await details(id,container);
|
||||
}catch(error){$('message').textContent=error.message;}finally{submit.disabled=false;}};
|
||||
container.append(form);
|
||||
}
|
||||
}
|
||||
async function migrateGuestCart(previous,next){
|
||||
if(previous===next)return;
|
||||
const database=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onupgradeneeded=()=>r.result.createObjectStore('cart');r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});
|
||||
await new Promise((resolve,reject)=>{const tx=database.transaction('cart','readwrite'),store=tx.objectStore('cart'),req=store.get(previous);req.onsuccess=()=>{if(req.result){store.put(req.result,next);store.delete(previous);}};tx.oncomplete=resolve;tx.onerror=()=>reject(tx.error);});
|
||||
database.close();
|
||||
}
|
||||
async function authenticate(path,body){
|
||||
const previous=scope,guest=!(await api('/account/me')).customer;
|
||||
await api(path,body);forgetCheckout();$('message').textContent='Acesso confirmado.';
|
||||
const next=(await api('/session')).cart_scope;
|
||||
if(guest)try{await migrateGuestCart(previous,next);}catch{$('message').textContent='Acesso confirmado. Não foi possível transferir o carrinho salvo neste navegador.';}
|
||||
await load();$('password').value='';$('register-password').value='';
|
||||
}
|
||||
$('login').onsubmit=async event=>{event.preventDefault();try{await authenticate('/account/login',{email:$('email').value,password:$('password').value});}catch(e){$('message').textContent=e.message;}};
|
||||
$('register').onsubmit=async event=>{event.preventDefault();try{await authenticate('/account/register',{customer:{cnpj:$('cnpj').value,zap:$('phone').value,mail:$('register-email').value},password:$('register-password').value});}catch(e){$('message').textContent=e.message;}};
|
||||
$('logout').onclick=async()=>{try{await window.dtfForgetPrivateData();await api('/account/logout',{});location.reload();}catch(error){$('message').textContent='Não foi possível concluir a limpeza local. Feche as abas do Site e limpe os dados deste site no navegador.';}};
|
||||
$('refresh').onclick=load;
|
||||
load();
|
||||
15
local/static/privacy.js
Normal file
15
local/static/privacy.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/* Clear draft files and metadata across Site tabs when signing out. */
|
||||
(() => {
|
||||
const signal='dtf-privacy-reset';
|
||||
function notify(){window.dispatchEvent(new Event('dtf-private-data-cleared'));}
|
||||
window.dtfForgetPrivateData=async()=>{
|
||||
notify();
|
||||
for(const key of Object.keys(localStorage))if(key.startsWith('dtf-'))localStorage.removeItem(key);
|
||||
localStorage.setItem(signal,crypto.randomUUID());
|
||||
const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onupgradeneeded=()=>r.result.createObjectStore('cart');r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});
|
||||
try{await new Promise((resolve,reject)=>{const tx=db.transaction('cart','readwrite');tx.objectStore('cart').clear();tx.oncomplete=resolve;tx.onerror=()=>reject(tx.error);});}finally{db.close();}
|
||||
};
|
||||
window.addEventListener('storage',event=>{
|
||||
if(event.key===signal && event.newValue){notify();location.reload();}
|
||||
});
|
||||
})();
|
||||
30
local/static/upload.js
Normal file
30
local/static/upload.js
Normal file
@@ -0,0 +1,30 @@
|
||||
/* Shared direct multipart transport for customer originals/corrections and operator finals. */
|
||||
window.dtfUpload = async (file, {api, prefix='/uploads', startPath=prefix, progress=()=>{}, scope='guest', resume=true}) => {
|
||||
const samples=new Blob([file.slice(0,65536),file.slice(Math.max(0,file.size-65536))]);
|
||||
const hash=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',await samples.arrayBuffer())),b=>b.toString(16).padStart(2,'0')).join('');
|
||||
const key='dtf-upload:'+JSON.stringify([scope,file.name,file.size,file.lastModified,hash]);
|
||||
let id=resume?localStorage.getItem(key):null,state;
|
||||
if(id){try{state=await api(prefix+'/'+id);}catch(error){if(![404,410].includes(error.status))throw error;id=null;}}
|
||||
if(!id){state=await api(startPath,{name:file.name,size:file.size});id=state.id;if(resume)localStorage.setItem(key,id);}
|
||||
async function waitForScan(){
|
||||
for(let attempt=0;attempt<150;attempt++){
|
||||
const checked=await api(prefix+'/'+id);
|
||||
if(checked.scan_state==='clean')return id;
|
||||
if(['rejected','error'].includes(checked.scan_state))throw new Error(checked.scan_reason||'Arquivo bloqueado pela verificação de segurança.');
|
||||
progress('Verificando segurança de '+file.name+'…');
|
||||
await new Promise(resolve=>setTimeout(resolve,1000));
|
||||
}
|
||||
throw new Error('Verificação de segurança pendente. Tente novamente para consultar o resultado.');
|
||||
}
|
||||
if(state.complete)return waitForScan();
|
||||
const done=new Set(state.parts||[]),size=state.part_bytes;
|
||||
for(let offset=0,part=1;offset<file.size;offset+=size,part++){
|
||||
if(done.has(part))continue;
|
||||
progress('Enviando '+file.name+' · parte '+part+'/'+Math.ceil(file.size/size));
|
||||
const signed=await api(prefix+'/'+id+'/parts/'+part,{});
|
||||
const response=await fetch(signed.url,{method:'PUT',body:file.slice(offset,offset+size)});
|
||||
if(!response.ok)throw new Error('Upload interrompido. Tente novamente para retomar.');
|
||||
}
|
||||
await api(prefix+'/'+id+'/complete',{});
|
||||
return waitForScan();
|
||||
};
|
||||
Reference in New Issue
Block a user