Compare commits

...

10 Commits

Author SHA1 Message Date
Cauê Faleiros
e768dcb489 feat: keep the Tiny connection alive and show when it is not
All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 2m29s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m49s
Connecting now asks for offline_access, retrying once without it if Tiny
refuses the scope. Renewal failures are stored: a refused refresh token
marks the connection lost and is not sent again (the Kanban previously
still said "conectado"), a transient failure shows as a warning until the
next renewal, and a session grant with under 12 hours left is flagged.
Tiny errors on the callback return to the Kanban instead of a 422.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:04:23 -03:00
Cauê Faleiros
122c645f72 fix: stop Site timers from running after a product is closed
The grade check and the layout preview run on short timers. When the
item went to the cart inside that window, no product was open and both
threw in the customer's browser, which also failed the browser tests
intermittently. Each now returns when no product is open. The cart test
waits for the empty state, which is painted on the next animation frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:04:23 -03:00
Cauê Faleiros
988b252f9d feat: check Tiny products and add a supervised order test
All checks were successful
Build and deploy / Validate source (push) Successful in 11s
Build and deploy / Integration suite on a real stack (push) Successful in 2m39s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m43s
"Testar conexão" now also reads the four configured Tiny products and
requires each to be active. app/tiny_probe.py runs from the worker console
to list products, confirm the configured ids, and create one marked test
order through the worker's own delivery path, proving the duplicate guard
by search before a second delivery. Nothing is sent without --confirmar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:23:07 -03:00
Cauê Faleiros
56021d8451 docs: add the operator and Site guides and bring the roadmap up to date
All checks were successful
Build and deploy / Validate source (push) Successful in 1m39s
Build and deploy / Integration suite on a real stack (push) Successful in 2m32s
Build and deploy / Secret scan and release gate (push) Successful in 10s
Build and deploy / Publish images (push) Successful in 1m58s
The operator guide covers the Kanban flow from quote review to finished
order; the Site guide walks through the customer journey, prices and the
current state of each integration. The roadmap records the Kanban and Site
redesigns, the guides, and what is left for the last day of Week 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 09:07:50 -03:00
Cauê Faleiros
b6a90411f1 feat: let customers remove items and empty the cart, with undo
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 2m2s
Build and deploy / Secret scan and release gate (push) Successful in 4s
Build and deploy / Publish images (push) Successful in 1m33s
Each cart item has a visible "Remover" button instead of a faint ×, and
carts with two or more items get "Esvaziar carrinho". Both show a
"Desfazer" notice for 8 seconds, so a wrong click costs nothing. The
browser test covers remove and undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:18:10 -03:00
Cauê Faleiros
cbbbdb351a feat: rebuild the Site product page around a buy box
All checks were successful
Build and deploy / Validate source (push) Successful in 10s
Build and deploy / Integration suite on a real stack (push) Successful in 2m46s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m46s
Artworks on the left; on the right a box that stays in view with the live
sheet, the grade, the price per metre, the metres charged, the total, the
resolution note and "Adicionar ao carrinho". The separate quality and
preview panels, the second sheet preview, the per-row mini sheets, the
summary box and the repeated findings list are gone: each piece of
information now appears once.

Each artwork row carries at most one hint (resolution first, otherwise a
width that saves film), the ready-sheet/loose-artwork choice is a toggle
beside the title, the upload area is one bar and the tips are collapsed.
The box only shows the item the page already priced, so it always matches
the cart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:02:45 -03:00
Cauê Faleiros
b81ff8d03d feat: give each Site product and the cart its own page
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 2m28s
Build and deploy / Secret scan and release gate (push) Successful in 8s
Build and deploy / Publish images (push) Successful in 1m52s
The home, each product's Montagem and the cart now have their own
addresses (/artes-avulsas, /arquivo-por-metro, /uv-artes-avulsas,
/uv-arquivo-por-metro, /carrinho) and show only their own content, with
Back, Forward, reload and direct links working as in any store. They stay
one document so uploaded artworks survive moving between pages; nginx
serves index.html for these addresses.

"Adicionar ao carrinho" puts the item in the cart and opens it, and an
empty cart says so. Portal quote links open in the cart. Also fixes the
"57 cm" line break on the ready-sheet option, returns "Novo pedido" to
the home, and says PDF depends on the product.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 16:56:58 -03:00
Cauê Faleiros
177882e77b feat: redesign the Site order flow and fix the cart layout
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 2m23s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m48s
New landing (hero with a sheet preview, four steps, product cards priced
from the checkout table, price table and benefits), a progress bar that
follows the order through Montagem, Dados e entrega and Pagamento, the
live sheet beside the artworks, and a running total bar on phones.

The cart's saved-in-browser note no longer takes a grid column, which had
pushed the order into a narrow strip and the summary below it. The
previous look is kept in tag ui-v1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 16:33:06 -03:00
Cauê Faleiros
2e03b0362b feat: undo mistaken moves, numbered pagination, and quieter Kanban messages
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m33s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m48s
Moves: an order can go back one stage (BACK in app/runtime.py) with an
internal reason, flagged in the history as movements.back. The customer is
not notified and approved finals stay; "production started" and "ready" are
now enqueued once per order, so undoing and redoing a move sends nothing
twice. Dragging only goes forward and highlights the allowed column. Move
errors are in Portuguese.

Lists: the send log, payments (open, resolved as history, all) and quotes
are paged on the server with a total, 20 rows by default (10/20/50/100),
first/previous/page/next/last. The send log filters by destination, status,
event and order. Older finished orders load on demand. The board no longer
carries the send log or payment rows, only the open-payment count.

Kanban: Pagamentos and Integrações are separate tabs; messages are brief,
bottom notifications that clear themselves; wording is shorter.

Full CI integration sequence passes locally, with new checks for undo, paging
and filters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 16:01:31 -03:00
Cauê Faleiros
99a558ddd9 feat: redesign the Kanban and keep test wording out of production
All checks were successful
Build and deploy / Validate source (push) Successful in 11s
Build and deploy / Integration suite on a real stack (push) Successful in 3m3s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m51s
Kanban: tabs for production, quote review and payments/integrations; compact
cards with products, metres, print-file status, delivery and time in stage;
an order panel with stage progress, one main action, a correction reason in
place, items with a preview drawn from the approved layout, final-file
approval and the history as a timeline. Quote review gets a list and a pane;
payment issues resolve in place; integrations show their real state, Tiny's
connection with a read-only "Testar conexão", and a readable send log. The
previous Kanban is kept in git tag ui-v1 and is no longer served.

Production wording: the customer portal no longer says it is a local test
environment outside the local stack; the checkout no longer tells customers
to use the Kanban or shows internal stage codes; sign-in, session, quota and
print-file messages are Portuguese and never say "local". A simulated freight
price is refused outside the local stack until a real freight provider
exists, so production only offers pickup.

The browser suite drives the new tabs and panel and still checks the whole
upload, quote, payment and production journey. Full CI sequence passes locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:28:04 -03:00
46 changed files with 2461 additions and 764 deletions

View File

@@ -146,6 +146,8 @@ class FreightAdapter(Protocol):
def quote(self, service: str, postal_code: str) -> dict: ...
class FakeFreight:
name = 'fake'
def quote(self, service: str, postal_code: str) -> dict:
if service == 'pickup':
return {'provider': 'fake', 'service': 'pickup', 'total_cents': 0, 'postal_code': ''}

View File

@@ -57,7 +57,7 @@ def login(body: Login, request: Request, response: Response):
if not account or not matches:
login_failed(email)
audit('customer_login_failed', ip=client_ip(request))
raise HTTPException(401, 'Invalid email or password')
raise HTTPException(401, 'E-mail ou senha inválidos.')
previous = current(request)
with db.connect() as c:
if not stored.startswith('scrypt-v2$'):

View File

@@ -7,7 +7,8 @@ from ..core import db
from ..core.auth import client_ip, owner, new_session, rate_limit
from ..core.limits import upload_limit_bytes
from ..core.models import Freight
from ..runtime import ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, payment, storage
from ..runtime import (ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, payment,
require_delivery_available, storage)
router = APIRouter()
@@ -40,6 +41,7 @@ def session(request: Request, response: Response):
@router.post('/api/freight')
def quote_freight(body: Freight):
require_delivery_available(body.service)
try:
return freight.quote(body.service, body.postal_code)
except ValueError as exc:

View File

@@ -17,7 +17,7 @@ from ..core.models import Move, OperatorLogin, Resolution, Review
from ..core.pricing import price
from ..printjobs import queue as queue_print_files
from .. import tiny
from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, STATES, TRANSITIONS,
from ..runtime import (BACK, BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, ENVIRONMENT, STATES, TRANSITIONS, payment,
enqueue, freight, quote_view, storage, upload_row)
from ..scanning import require_clean
@@ -30,14 +30,14 @@ def operator_login(body: OperatorLogin, request: Request, response: Response):
with db.connect() as c:
account = c.execute('SELECT * FROM dtf_local.operators WHERE email=%s', (email,)).fetchone()
if not c.execute('SELECT 1 FROM dtf_local.operators WHERE active LIMIT 1').fetchone():
raise HTTPException(503, 'No Kanban operator account is configured')
raise HTTPException(503, 'Nenhuma conta de operador configurada.')
# Comparable password work whether or not the account exists or is active.
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
matches = password_matches(body.password, stored)
if not account or not account['active'] or not matches:
login_failed('operator:'+email)
audit('operator_login_failed', ip=client_ip(request), operator=email)
raise HTTPException(401, 'Invalid operator login')
raise HTTPException(401, 'E-mail ou senha inválidos.')
token = secrets.token_urlsafe(32)
with db.connect() as c:
previous = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
@@ -84,41 +84,112 @@ def board(user=Depends(operator)):
approved_total = c.execute('''SELECT count(*) AS n FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE o.id IS NULL AND q.approved IS NOT NULL''').fetchone()['n']
orders = active + list(reversed(finished))
generated = c.execute('''SELECT p.order_id,p.item_index,p.status,p.upload_id,p.detail,u.name
FROM dtf_local.print_files p LEFT JOIN dtf_local.uploads u ON u.id=p.upload_id
WHERE p.order_id=ANY(%s) ORDER BY p.item_index''', ([o['id'] for o in orders],)).fetchall()
for order in orders:
order['print_files'] = [row for row in generated if row['order_id'] == order['id']]
orders = with_print_files(c, active + list(reversed(finished)))
# A paid notification that did not become an order is money received
# for nothing the factory will make. It stays on the board until a
# person records what was done about it.
refused = c.execute('''SELECT id,provider,event_id,reference,status,amount_cents,received_at,outcome
FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL
ORDER BY received_at LIMIT 100''').fetchall()
return {'states': STATES, 'transitions': TRANSITIONS,
'orders': orders, 'payment_issues': refused, 'tiny': tiny_status(),
# for nothing the factory will make. The board carries the count; the
# Pagamentos tab pages through them until someone records a resolution.
issues_total = c.execute('''SELECT count(*) AS n FROM dtf_local.payment_events
WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL''').fetchone()['n']
return {'states': STATES, 'transitions': TRANSITIONS, 'back': BACK,
'orders': orders, 'payment_issues_total': issues_total, 'tiny': tiny_status(), 'operator': user,
'providers': {'payment': payment.name}, 'environment': ENVIRONMENT,
'finished_shown': len(finished), 'finished_total': finished_total,
'quotes': [quote_view(c, q) for q in pending + approved],
'pending_total': pending_total, 'approved_total': approved_total,
'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()}
'pending_total': pending_total, 'approved_total': approved_total}
def with_print_files(c, orders):
generated = c.execute('''SELECT p.order_id,p.item_index,p.status,p.upload_id,p.detail,u.name
FROM dtf_local.print_files p LEFT JOIN dtf_local.uploads u ON u.id=p.upload_id
WHERE p.order_id=ANY(%s) ORDER BY p.item_index''', ([o['id'] for o in orders],)).fetchall()
for order in orders:
order['print_files'] = [row for row in generated if row['order_id'] == order['id']]
return orders
def cursor_pair(first, second):
if (first is None) != (second is None):
raise HTTPException(422, 'Both cursor fields are required')
return first is not None
@router.get('/api/operator/orders/finished')
def finished_page(before_created_at: datetime | None = None, before_id: UUID | None = None,
limit: int = Query(default=50, ge=1, le=100), user=Depends(operator)):
"""Older finished orders, newest first, beyond the board's recent window."""
paged = cursor_pair(before_created_at, before_id)
with db.connect() as c:
rows = c.execute('''SELECT * FROM dtf_local.orders WHERE state='fin'
''' + ('AND (created_at,id)<(%s,%s) ' if paged else '') + '''
ORDER BY created_at DESC,id DESC LIMIT %s''',
((before_created_at, before_id) if paged else ()) + (limit + 1,)).fetchall()
return {'orders': with_print_files(c, rows[:limit]), 'has_more': len(rows) > limit}
@router.get('/api/operator/payment-events')
def payment_events(state: Literal['open','resolved','all'] = 'open',
offset: int = Query(default=0, ge=0),
limit: int = Query(default=20, ge=1, le=100), user=Depends(operator)):
"""Payments that needed a person, newest first: open ones to act on, resolved ones as history."""
where = {'open': 'AND resolved_at IS NULL', 'resolved': 'AND resolved_at IS NOT NULL', 'all': ''}[state]
with db.connect() as c:
rows = c.execute('''SELECT id,provider,event_id,reference,status,amount_cents,received_at,outcome,
resolved_at,resolved_by,resolution
FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%%' OR outcome LIKE 'attention%%') ''' + where + '''
ORDER BY received_at DESC,id DESC LIMIT %s OFFSET %s''', (limit, offset)).fetchall()
total = c.execute('''SELECT count(*) AS n FROM dtf_local.payment_events
WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') ''' + where).fetchone()['n']
return {'issues': rows, 'total': total}
@router.get('/api/operator/events')
def events(provider: Literal['tiny','whatsapp'] | None = None,
status: Literal['delivered','queued','failing'] | None = None,
event: Literal['payment_approved','production_started','correction_needed','ready'] | None = None,
order: int | None = Query(default=None, ge=1), offset: int = Query(default=0, ge=0),
limit: int = Query(default=20, ge=1, le=100), user=Depends(operator)):
"""The integration send log, newest first, filtered, by page with a total."""
clauses, params = [], []
if provider:
clauses.append('provider=%s'); params.append(provider)
if status == 'delivered':
clauses.append('delivered_at IS NOT NULL')
elif status == 'queued':
clauses.append('delivered_at IS NULL AND last_error IS NULL')
elif status == 'failing':
clauses.append('delivered_at IS NULL AND last_error IS NOT NULL')
if event:
clauses.append("payload->>'event'=%s"); params.append(event)
if order:
clauses.append("payload->>'number'=%s"); params.append(str(order))
where = ('WHERE ' + ' AND '.join(clauses)) if clauses else ''
with db.connect() as c:
rows = c.execute(f'SELECT * FROM dtf_local.outbox {where} ORDER BY id DESC LIMIT %s OFFSET %s',
(*params, limit, offset)).fetchall()
total = c.execute(f'SELECT count(*) AS n FROM dtf_local.outbox {where}', params).fetchone()['n']
return {'events': rows, 'total': total}
@router.get('/api/operator/quotes')
def quote_page(kind: Literal['pending','approved'], before_created_at: datetime | None = None,
before_id: UUID | None = None, limit: int = Query(default=50, ge=1, le=100),
user=Depends(operator)):
before_id: UUID | None = None, offset: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=100), user=Depends(operator)):
"""Unpaid quotes, newest first: by cursor, or by page (offset) with a total."""
if (before_created_at is None) != (before_id is None):
raise HTTPException(422, 'Both quote cursor fields are required')
approved_filter = 'q.approved IS NULL' if kind == 'pending' else 'q.approved IS NOT NULL'
cursor = 'AND (q.created_at,q.id)<(%s,%s)' if before_created_at else ''
params = ((before_created_at,before_id) if before_created_at else ()) + (limit+1,)
skip = 0 if before_created_at else offset
params = ((before_created_at,before_id) if before_created_at else ()) + (limit+1, skip)
with db.connect() as c:
rows = c.execute(f'''SELECT q.* FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE o.id IS NULL AND {approved_filter} {cursor}
ORDER BY q.created_at DESC,q.id DESC LIMIT %s''', params).fetchall()
ORDER BY q.created_at DESC,q.id DESC LIMIT %s OFFSET %s''', params).fetchall()
total = c.execute(f'''SELECT count(*) AS n FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE o.id IS NULL AND {approved_filter}''').fetchone()['n']
return {'quotes':[quote_view(c,row) for row in rows[:limit]],
'has_more':len(rows)>limit}
'has_more':len(rows)>limit, 'total': total}
@router.post('/api/operator/quotes/{uid}/approve')
def approve(uid: UUID, body: Review, user=Depends(operator)):
@@ -159,26 +230,34 @@ def move(uid: UUID, body: Move, user=Depends(operator)):
if not row:
raise HTTPException(404, 'Order not found')
if body.version != row['version']:
raise HTTPException(409, 'Order changed; refresh the board')
raise HTTPException(409, 'O pedido mudou. Clique em Atualizar.')
if body.state == row['state']:
return row
if body.state not in TRANSITIONS[row['state']]:
raise HTTPException(409, 'Move is not allowed from this state')
back = BACK.get(row['state']) == body.state
if body.state not in TRANSITIONS[row['state']] and not back:
raise HTTPException(409, f"Não dá para ir de {STATES[row['state']]} para {STATES[body.state]}.")
if body.state == 'cor' and not body.reason.strip():
raise HTTPException(422, 'Correction requires a reason')
raise HTTPException(422, 'Informe o motivo da correção.')
if back and not body.reason.strip():
raise HTTPException(422, 'Informe por que o pedido está voltando de etapa.')
if body.state in ('fil','imp'):
coverage = c.execute('SELECT DISTINCT f.item_index FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id WHERE f.order_id=%s AND f.kind=\'final\' AND f.active AND u.expires_at>now() AND u.purged_at IS NULL AND u.scan_state=\'clean\'', (uid,)).fetchall()
if {r['item_index'] for r in coverage} != set(range(len(row['snapshot']['items']))):
raise HTTPException(409, 'Approve a complete final-file set for every item before queueing')
raise HTTPException(409, 'Aprove os arquivos finais de todos os itens antes de colocar na fila.')
if body.state == 'cor':
c.execute("UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind='final'", (uid,))
c.execute('INSERT INTO dtf_local.movements(order_id,from_state,to_state,operator,reason) VALUES(%s,%s,%s,%s,%s)',
(uid,row['state'],body.state,user,body.reason))
c.execute('INSERT INTO dtf_local.movements(order_id,from_state,to_state,operator,reason,back) VALUES(%s,%s,%s,%s,%s,%s)',
(uid,row['state'],body.state,user,body.reason,back))
changed = c.execute('UPDATE dtf_local.orders SET state=%s, version=version+1, updated_at=now() WHERE id=%s RETURNING *', (body.state,uid)).fetchone()
events = {'imp':'production_started','cor':'correction_needed','fin':'ready'}
if body.state in events:
if body.state in events and not back:
# "Production started" and "ready" reach the customer once per order,
# even if a mistaken move is undone and made again. Each correction
# is a new request, so it keeps one message per movement.
once = body.state in ('imp','fin')
for provider in ('tiny','whatsapp'):
enqueue(c, f'{uid}:{changed["version"]}:{provider}', provider,
key = f'{uid}:{events[body.state]}:{provider}' if once else f'{uid}:{changed["version"]}:{provider}'
enqueue(c, key, provider,
{'order_id':str(uid), 'number':row['number'], 'event':events[body.state], 'reason':body.reason,
'customer_path': f'/portal.html?order={uid}'})
return changed
@@ -224,13 +303,34 @@ def tiny_connect(user=Depends(operator)):
audit('tiny_connect_started', operator=user)
return {'url': tiny.TinyAuth().authorize_url(user)}
@router.get('/api/operator/tiny/callback')
def tiny_callback(code: str = Query(max_length=4096), state: str = Query(max_length=128)):
"""Tiny's redirect back. Cross-site, so the operator cookie is absent: the
single-use state an operator created is what authorises it."""
@router.post('/api/operator/tiny/test')
def tiny_test(user=Depends(operator)):
"""Read one order and one contact from Tiny. Creates nothing."""
if not tiny.configured():
raise HTTPException(503, 'Tiny application is not configured')
try:
results = tiny.check()
except tiny.TinyNotConnected as exc:
raise HTTPException(409, str(exc))
audit('tiny_tested', operator=user, ok=all(v == 'ok' for v in results.values()))
return {'ok': all(v == 'ok' for v in results.values()), 'results': results}
@router.get('/api/operator/tiny/callback')
def tiny_callback(code: str = Query('', max_length=4096), state: str = Query('', max_length=128),
error: str = Query('', max_length=128)):
"""Tiny's redirect back. Cross-site, so the operator cookie is absent: the
single-use state an operator created is what authorises it. Tiny reports a
refusal (the operator declined, or offline access is not allowed for this
application) with `error` instead of a code."""
if not tiny.configured():
raise HTTPException(503, 'Tiny application is not configured')
try:
if error == 'invalid_scope':
retry = tiny.TinyAuth().without_offline(state)
audit('tiny_offline_refused')
return RedirectResponse(retry, status_code=303)
if error or not code:
raise tiny.TinyError(f'Tiny returned {error or "no code"}')
who = tiny.TinyAuth().complete(code, state)
except tiny.TinyError:
audit('tiny_connect_failed')

View File

@@ -10,7 +10,7 @@ from psycopg.types.json import Jsonb
from ..core import db
from ..core.auth import owner
from ..core.models import QuoteRequest
from ..runtime import freight, quote_view, upload_row
from ..runtime import freight, quote_view, require_delivery_available, upload_row
from ..scanning import require_clean
router = APIRouter()
@@ -20,6 +20,7 @@ def create_quote(body: QuoteRequest, session_id=Depends(owner)):
for item in body.items:
if abs(item.metres*100-item.production.height_cm) > Decimal('0.02'):
raise HTTPException(422, 'Quoted metres do not match the submitted layout height')
require_delivery_available(body.freight.service)
draft = body.model_dump(mode='json', exclude={'request_key'})
digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest()
try:

View File

@@ -37,7 +37,7 @@ def begin_upload(body: UploadStart, session_id=Depends(owner)):
usage['owned']+body.size > int(os.environ.get('OWNER_UPLOAD_QUOTA_BYTES','10737418240')) or
usage['pending'] >= int(os.environ.get('MAX_PENDING_UPLOADS','10'))):
audit('upload_quota_rejected')
raise HTTPException(429, 'Local storage quota or pending upload limit reached')
raise HTTPException(429, 'Limite de armazenamento ou de envios pendentes atingido. Tente de novo mais tarde.')
multipart = storage.begin(key)
c.execute('''INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,expires_at)
VALUES(%s,%s,%s,%s,%s,%s,now()+interval '1 hour')''',

View File

@@ -45,7 +45,7 @@ def session_row(request):
try:
sid = UUID(request.cookies.get('dtf_session', ''))
except ValueError:
raise HTTPException(401, 'Start a local session first')
raise HTTPException(401, 'Sessão não iniciada. Recarregue a página.')
with connect() as c:
row = c.execute('SELECT * FROM dtf_local.sessions WHERE id=%s AND expires_at>now()', (sid,)).fetchone()
if not row:
@@ -92,7 +92,7 @@ def rate_limit(scope, identity, limit, seconds=900):
RETURNING attempts""", (key,seconds,seconds)).fetchone()
if row['attempts'] > limit:
audit('rate_limit', scope=scope)
raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After':str(seconds)})
raise HTTPException(429, 'Muitas tentativas. Tente de novo mais tarde.', headers={'Retry-After':str(seconds)})
ACCOUNT_FAILURES = 10
@@ -108,7 +108,7 @@ def throttle(email, request):
WHERE key=%s AND started_at >= now()-interval '900 seconds'""", (key,)).fetchone()
if row and row['attempts'] >= ACCOUNT_FAILURES:
audit('rate_limit', scope='auth-account')
raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After': '900'})
raise HTTPException(429, 'Muitas tentativas. Tente de novo mais tarde.', headers={'Retry-After': '900'})
def login_failed(email):
"""Count a failed sign-in (or registration attempt) against the account."""
@@ -117,10 +117,10 @@ def login_failed(email):
def operator(request: Request):
token = request.cookies.get('dtf_operator', '')
if not token or len(token)>128:
raise HTTPException(401, 'Sign in to the local Kanban')
raise HTTPException(401, 'Entre no Kanban.')
digest = hashlib.sha256(token.encode()).hexdigest()
with connect() as c:
row = c.execute('SELECT username FROM dtf_local.operator_sessions WHERE token_hash=%s AND expires_at>now()', (digest,)).fetchone()
if not row:
raise HTTPException(401, 'Operator session expired')
raise HTTPException(401, 'Sua sessão expirou. Entre de novo.')
return row['username']

View File

@@ -46,7 +46,8 @@ Image.MAX_IMAGE_PIXELS = None
class Unsupported(Exception):
"""This item cannot be generated automatically; the reason is for the operator."""
"""This item cannot be generated automatically. The reason is shown to the
operator on the Kanban, so it is written in Portuguese."""
class Name(str):
@@ -153,14 +154,14 @@ class SourceImage:
self.image = Image.open(path)
self.format = self.image.format
except Image.DecompressionBombError as exc:
raise Unsupported(f'"{name}" is too large to generate automatically') from exc
raise Unsupported(f'"{name}" é grande demais para gerar automaticamente') from exc
except Exception as exc:
raise Unsupported(f'"{name}" is not an image this generator can read') from exc
raise Unsupported(f'"{name}" não é uma imagem que o gerador consiga ler') from exc
if self.format not in SUPPORTED_FORMATS:
raise Unsupported(f'"{name}" is {self.format or "an unknown format"}; '
'only JPEG, PNG, WebP and TIFF are generated automatically')
raise Unsupported(f'"{name}" está em {self.format or "formato desconhecido"}; '
'só JPEG, PNG, WebP, TIFF e PDF são gerados automaticamente')
if getattr(self.image, 'n_frames', 1) > 1 and self.format != 'TIFF':
raise Unsupported(f'"{name}" is animated or has several frames')
raise Unsupported(f'"{name}" é animado ou tem vários quadros')
# Browsers draw a photo upright according to its EXIF orientation, and
# the Site measured it that way, so the print must too.
try:
@@ -181,8 +182,8 @@ class SourceImage:
return self._embed_jpeg(pdf)
width, height = self.image.size
if width * height > MAX_DECODED_PIXELS:
raise Unsupported(f'"{self.name}" has {width} x {height} px, more than the '
'generator decodes; prepare this item by hand')
raise Unsupported(f'"{self.name}" tem {width} × {height} px, mais do que o '
'gerador processa; prepare este item à mão')
return self._embed_pixels(pdf)
def _colorspace(self, pdf, mode):
@@ -235,7 +236,7 @@ class SourceImage:
elif mode in ('P', 'PA', 'RGB', 'RGBA', 'RGBa'):
color_mode = 'RGB'
else:
raise Unsupported(f'"{self.name}" uses the {mode} colour mode, which is not generated automatically')
raise Unsupported(f'"{self.name}" usa o modo de cor {mode}, que não é gerado automaticamente')
if has_alpha:
image = image.convert('RGBA' if color_mode == 'RGB' else 'LA')
width, height = image.size
@@ -304,29 +305,29 @@ class PdfPage:
try:
self.pdf = pikepdf.open(path)
except pikepdf.PasswordError as exc:
raise Unsupported(f'"{name}" is password-protected') from exc
raise Unsupported(f'"{name}" está protegido por senha') from exc
except Exception as exc:
raise Unsupported(f'"{name}" is not a PDF this generator can read') from exc
raise Unsupported(f'"{name}" não é um PDF que o gerador consiga ler') from exc
try:
pages = len(self.pdf.pages)
if pages != 1:
raise Unsupported(f'"{name}" has {pages} pages; only single-page PDFs are generated automatically')
raise Unsupported(f'"{name}" tem {pages} páginas; só PDFs de uma página são gerados automaticamente')
self.page = self.pdf.pages[0]
self.rotation = int(self.page.rotation) % 360
if self.rotation not in (0, 90, 180, 270):
raise Unsupported(f'"{name}" has an unsupported page rotation')
raise Unsupported(f'"{name}" tem uma rotação de página não suportada')
box = [float(v) for v in self.page.cropbox]
except Unsupported:
self.pdf.close()
raise
except Exception as exc:
self.pdf.close()
raise Unsupported(f'"{name}" has a page this generator cannot read') from exc
raise Unsupported(f'"{name}" tem uma página que o gerador não consegue ler') from exc
self.x0, self.y0 = min(box[0], box[2]), min(box[1], box[3])
self.w, self.h = abs(box[2] - box[0]), abs(box[3] - box[1])
if self.w <= 0 or self.h <= 0:
self.pdf.close()
raise Unsupported(f'"{name}" has an empty page')
raise Unsupported(f'"{name}" tem uma página vazia')
# As displayed, which is what the proportions are checked against.
self.size = (self.h, self.w) if self.rotation in (90, 270) else (self.w, self.h)
@@ -373,7 +374,7 @@ def check_layout(item, sizes, vector=()):
height = Decimal(str(production['height_cm']))
billed = Decimal(str(item['billed_metres'])) * 100
if height > billed + HEIGHT_TOLERANCE_CM:
raise Unsupported(f'layout is {height} cm long but only {billed} cm were billed')
raise Unsupported(f'a montagem tem {height} cm, mas só {billed} cm foram cobrados')
lowest = None
for placement in production['placements']:
width_px, height_px = sizes[placement['source_index']]
@@ -384,8 +385,8 @@ def check_layout(item, sizes, vector=()):
drift = abs((width_px / height_px) / (width_cm / length_cm) - 1)
if drift > ASPECT_TOLERANCE:
source = production['sources'][placement['source_index']]
raise Unsupported(f'file {placement["source_index"] + 1} has proportions that do not match '
f'the quoted {source["width_cm"]} x {source["length_cm"]} cm')
raise Unsupported(f'o arquivo {placement["source_index"] + 1} tem proporções diferentes '
f'das cotadas ({source["width_cm"]} × {source["length_cm"]} cm)')
if placement['source_index'] in vector:
continue
dpi = width_px / (width_cm / 2.54)
@@ -405,7 +406,7 @@ def render(item, files, out, title):
"""
production = item['production']
if production.get('version') != 2:
raise Unsupported('item uses an obsolete production layout')
raise Unsupported('o item usa uma montagem antiga')
sources = []
try:
for index in range(len(production['sources'])):

View File

@@ -101,9 +101,9 @@ def produce(storage, job, item, uploads):
for upload_id in item['uploads']:
row = uploads.get(upload_id)
if not row or row['scan_state'] != 'clean':
raise Unsupported('an original file is missing or not cleared by the malware scan')
raise Unsupported('um arquivo original está ausente ou não foi liberado pelo antivírus')
if row['purged_at'] or row['expired']:
raise Unsupported('an original file has passed its retention period')
raise Unsupported('um arquivo original passou do prazo de guarda')
number = job['number']
name = f'pedido-{number}-item-{job["item_index"] + 1}.pdf'
with tempfile.TemporaryDirectory(prefix='print-') as scratch:

View File

@@ -46,6 +46,16 @@ STATES = {'rec': 'Arte recebida', 'tra': 'Arte tratada', 'fil': 'Fila de impress
'imp': 'Imprimindo', 'cor': 'Correção', 'fin': 'Finalizado'}
TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'],
'imp': ['fin','cor'], 'cor': ['rec','tra'], 'fin': []}
# Undoing a move made by mistake: one stage back, with an internal reason. It
# is not a correction: the customer is not told and approved finals stay.
BACK = {'tra': 'rec', 'fil': 'tra', 'imp': 'fil', 'fin': 'imp'}
def require_delivery_available(service):
"""Outside the local stack, a simulated freight price must never reach a
customer: until a real freight provider exists, only pickup is offered."""
if service != 'pickup' and ENVIRONMENT != 'local' and getattr(freight, 'name', '') == 'fake':
raise HTTPException(503, 'A entrega ainda não está disponível. Escolha a retirada em Franca.')
def upload_row(c, upload_id, session_id, lock=False):

View File

@@ -107,6 +107,13 @@ CREATE TABLE IF NOT EXISTS dtf_local.provider_tokens (
connected_by text NOT NULL, connected_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Renewal health, cleared by the next successful renewal or connection.
-- refused_at: the provider rejected the refresh token, so only a new
-- connection helps. offline: the grant is not bound to a login session.
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refresh_failed_at timestamptz;
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refresh_error text;
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refused_at timestamptz;
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS offline boolean NOT NULL DEFAULT false;
-- Single-use states for an operator-started OAuth connection. They protect the
-- callback, which arrives cross-site without the operator's cookie.
CREATE TABLE IF NOT EXISTS dtf_local.oauth_states (
@@ -173,6 +180,9 @@ CREATE INDEX IF NOT EXISTS payment_events_unprocessed ON dtf_local.payment_event
CREATE INDEX IF NOT EXISTS print_files_open ON dtf_local.print_files(created_at)
WHERE status IN ('pending','rendering');
-- A movement that undid an earlier one (a mistaken move), shown as such.
ALTER TABLE dtf_local.movements ADD COLUMN IF NOT EXISTS back boolean NOT NULL DEFAULT false;
-- The Kanban lists payment events a person must act on (money without an
-- order, or a reversed payment on an existing order) until resolved.
ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolved_at timestamptz;

View File

@@ -42,6 +42,9 @@ PRODUCTS = {'file': 'DTF Têxtil 57 cm · folha montada',
# How far back to look for an order a previous delivery may already have made.
SEARCH_DAYS = 7
STATE_MINUTES = 10
# Renewal runs about every four hours against a one-day refresh token, so
# less than this left means renewals have been failing for hours.
EXPIRY_WARNING = timedelta(hours=12)
# Brazil has had no daylight saving since 2019; the order date is the local day.
BRASILIA = timezone(timedelta(hours=-3))
@@ -86,44 +89,78 @@ class TinyAuth:
self.http = httpx.Client(timeout=20, transport=transport)
self.clock = clock
def authorize_url(self, operator):
def authorize_url(self, operator, offline=True):
"""Start a connection. The state is single-use, short-lived, and only an
authenticated operator can create one, which is what protects the
callback: Tiny's redirect back is cross-site, so the operator's
SameSite=Strict cookie does not travel with it."""
SameSite=Strict cookie does not travel with it.
offline_access asks for a grant that is not bound to a login session,
so the connection lasts as long as it keeps being renewed. Tiny's
documented refresh token otherwise lasts one day."""
state = secrets.token_urlsafe(32)
with self.connect() as c:
c.execute("DELETE FROM dtf_local.oauth_states WHERE expires_at<now()")
c.execute('''INSERT INTO dtf_local.oauth_states(state,provider,operator,expires_at)
VALUES(%s,'tiny',%s,now()+%s)''', (state, operator, timedelta(minutes=STATE_MINUTES)))
return f'{AUTH}/auth?' + urlencode({'response_type': 'code', 'client_id': self.client_id,
'redirect_uri': self.redirect_uri, 'scope': 'openid',
'redirect_uri': self.redirect_uri,
'scope': 'openid offline_access' if offline else 'openid',
'state': state})
def _claim(self, c, state):
row = c.execute('''DELETE FROM dtf_local.oauth_states WHERE state=%s AND provider='tiny'
AND expires_at>now() RETURNING operator''', (state,)).fetchone()
if not row:
raise TinyError('Unknown or expired authorisation state')
return row['operator']
def complete(self, code, state):
"""Exchange the authorisation code; returns the operator who started it."""
with self.connect() as c:
row = c.execute('''DELETE FROM dtf_local.oauth_states WHERE state=%s AND provider='tiny'
AND expires_at>now() RETURNING operator''', (state,)).fetchone()
if not row:
raise TinyError('Unknown or expired authorisation state')
operator = self._claim(c, state)
tokens = self._token({'grant_type': 'authorization_code', 'code': code,
'redirect_uri': self.redirect_uri})
self._store(c, tokens, row['operator'])
return row['operator']
self._store(c, tokens, operator)
return operator
def without_offline(self, state):
"""Tiny refused the offline_access scope for this application: spend the
operator's state and start again with a session-bound grant."""
with self.connect() as c:
operator = self._claim(c, state)
return self.authorize_url(operator, offline=False)
def status(self):
with self.connect() as c:
row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at
FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone()
row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at,updated_at,offline,
refresh_failed_at,refused_at FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone()
if not row:
return {'connected': False}
expired = row['refresh_expires_at'] and row['refresh_expires_at'] <= datetime.now(timezone.utc)
return {'connected': not expired, 'connected_by': row['connected_by'],
'connected_at': row['connected_at'], 'expires_at': row['refresh_expires_at']}
now = datetime.now(timezone.utc)
expires = row['refresh_expires_at']
expired = bool(expires and expires <= now)
if row['refused_at']:
problem = 'refused'
elif expired:
problem = 'expired'
elif row['refresh_failed_at']:
problem = 'renewal-failing'
elif expires and expires - now < EXPIRY_WARNING:
problem = 'expiring'
else:
problem = None
return {'connected': not expired and not row['refused_at'], 'problem': problem,
'connected_by': row['connected_by'], 'connected_at': row['connected_at'],
'renewed_at': row['updated_at'], 'expires_at': expires, 'offline': row['offline'],
'failed_at': row['refresh_failed_at']}
def access_token(self):
"""A valid access token, refreshing (and rotating) under a row lock."""
"""A valid access token, refreshing (and rotating) under a row lock.
A failed renewal is recorded after the lock is released, so the Kanban
can show it. A refused refresh token is never tried again: only a new
connection helps, and retrying it would only repeat the refusal."""
with self.connect() as c:
row = c.execute('''SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny'
FOR UPDATE''').fetchone()
@@ -132,11 +169,25 @@ class TinyAuth:
now = datetime.now(timezone.utc)
if row['access_expires_at'] > now + timedelta(seconds=60):
return row['access_token']
if row['refused_at']:
raise TinyNotConnected('Tiny refused the stored refresh token; connect again on the Kanban')
if row['refresh_expires_at'] and row['refresh_expires_at'] <= now:
raise TinyNotConnected('The Tiny connection expired; connect again on the Kanban')
tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']})
self._store(c, tokens, row['connected_by'], refreshed=True)
return tokens['access_token']
try:
tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']})
except (TinyError, httpx.HTTPError, ValueError) as exc:
failure = exc
else:
self._store(c, tokens, row['connected_by'], refreshed=True)
return tokens['access_token']
# Only against the token that failed: another worker may have renewed since.
with self.connect() as c:
c.execute('''UPDATE dtf_local.provider_tokens SET refresh_failed_at=now(), refresh_error=%s,
refused_at=CASE WHEN %s THEN now() ELSE refused_at END
WHERE provider='tiny' AND refresh_token=%s''',
(str(failure)[:300] or type(failure).__name__, isinstance(failure, TinyNotConnected),
row['refresh_token']))
raise failure
def _token(self, form):
response = self.http.post(f'{AUTH}/token', data={**form, 'client_id': self.client_id,
@@ -152,18 +203,21 @@ class TinyAuth:
def _store(self, c, tokens, operator, refreshed=False):
now = datetime.now(timezone.utc)
access_expires = now + timedelta(seconds=int(tokens.get('expires_in', 300)))
# An offline grant reports refresh_expires_in 0: no fixed end.
refresh_in = tokens.get('refresh_expires_in')
refresh_expires = now + timedelta(seconds=int(refresh_in)) if refresh_in else None
offline = 'offline_access' in str(tokens.get('scope') or '').split()
c.execute('''INSERT INTO dtf_local.provider_tokens(provider,access_token,refresh_token,
access_expires_at,refresh_expires_at,connected_by,connected_at,updated_at)
VALUES('tiny',%s,%s,%s,%s,%s,now(),now())
access_expires_at,refresh_expires_at,connected_by,connected_at,updated_at,offline)
VALUES('tiny',%s,%s,%s,%s,%s,now(),now(),%s)
ON CONFLICT(provider) DO UPDATE SET access_token=EXCLUDED.access_token,
refresh_token=EXCLUDED.refresh_token, access_expires_at=EXCLUDED.access_expires_at,
refresh_expires_at=EXCLUDED.refresh_expires_at, updated_at=now(),
refresh_expires_at=EXCLUDED.refresh_expires_at, updated_at=now(), offline=EXCLUDED.offline,
refresh_failed_at=NULL, refresh_error=NULL, refused_at=NULL,
connected_by=CASE WHEN %s THEN dtf_local.provider_tokens.connected_by ELSE EXCLUDED.connected_by END,
connected_at=CASE WHEN %s THEN dtf_local.provider_tokens.connected_at ELSE now() END''',
(tokens['access_token'], tokens['refresh_token'], access_expires, refresh_expires,
operator, refreshed, refreshed))
operator, offline, refreshed, refreshed))
# Orders ------------------------------------------------------------------
@@ -223,6 +277,44 @@ def order_payload(payload, contact_id, today=None):
return pedido
def configured_product(orders, mode):
"""The Tiny product a mode's setting points at, or a reason it cannot be used."""
setting = PRODUCT_SETTINGS[mode]
value = os.environ.get(setting, '')
if not value.isdigit():
return None, f'{setting} sem id'
found = orders.request('GET', f'/produtos/{value}')
if found.get('situacao') != 'A':
return found, f"produto {value} não está ativo no Tiny ({found.get('situacao')})"
return found, 'ok'
def check(auth=None, transport=None, orders=None):
"""Read-only proof that the connection and permissions work: one order and
one contact listed and each configured product found active, nothing
created. Raises TinyNotConnected when there is no usable connection;
otherwise reports each read separately."""
orders = orders or TinyOrders(auth=auth or TinyAuth(), transport=transport)
def listed(path):
orders.request('GET', path, params={'limit': 1})
return 'ok'
reads = [('pedidos', lambda: listed('/pedidos')), ('contatos', lambda: listed('/contatos'))]
reads += [(PRODUCTS[mode], lambda mode=mode: configured_product(orders, mode)[1]) for mode in PRODUCT_SETTINGS]
results = {}
for name, read in reads:
try:
results[name] = read()
except TinyNotConnected:
raise
except TinyError as exc:
results[name] = str(exc)[:200]
except httpx.HTTPError:
results[name] = 'sem resposta do Tiny'
return results
class TinyOrders:
def __init__(self, auth=None, transport=None, today=None):
missing = [name for name in required_settings() if not os.environ.get(name)]

132
app/tiny_probe.py Normal file
View File

@@ -0,0 +1,132 @@
"""Supervised checks against the client's real Tiny, run by hand from a
container console (docker exec, or Portainer > Containers > worker > Console).
Tiny has no sandbox, so this is how the adapter is proven on the real account.
python -m app.tiny_probe produtos [termo] list active products (read-only)
python -m app.tiny_probe conferir connection and the four product ids (read-only)
python -m app.tiny_probe pedido --cnpj ... --email ... --celular ... [--modo file]
show the test order; --confirmar creates it
The test order goes through TinyOrders.deliver, exactly as the worker sends a
paid order. The duplicate guard is then proven read-only first (the order must
be found by its purchase-order number) and only then by a second delivery,
which must return the existing order. If the search cannot find the order, the
second delivery is not attempted: it would create a duplicate. Cancel the test
order in Olist afterwards.
"""
import argparse
import json
import sys
import time
from datetime import datetime
from .core.pricing import TIERS
from .core.secrets import load as load_secret_files
from . import tiny
FIND_ATTEMPTS = 4
FIND_WAIT_SECONDS = 5
def test_order(cnpj, email, celular, mode, now=None):
number = 'TESTE-' + (now or datetime.now(tiny.BRASILIA)).strftime('%Y%m%d%H%M')
unit = TIERS[mode][0][1]
return {'order_id': 'teste-integracao', 'number': number, 'event': 'payment_approved',
'order': {'customer': {'cnpj': cnpj, 'mail': email, 'zap': celular},
'items': [{'mode': mode, 'grade': 100, 'billed_metres': '1', 'unit_cents': unit}],
'freight': {'service': 'pickup', 'total_cents': 0},
'destination': None, 'total_cents': unit}}
def produtos(orders, termo, out):
params = {'situacao': 'A', 'limit': 100}
if termo:
params['nome'] = termo
found = orders.request('GET', '/produtos', params=params).get('itens') or []
for item in found:
preco = (item.get('precos') or {}).get('preco')
out(f"{item['id']}\t{item.get('sku') or '-'}\t{item.get('descricao')}\tR$ {preco}")
out(f'{len(found)} produto(s) ativo(s)' + (f' com "{termo}"' if termo else '') + '.')
def conferir(orders, out):
ok = True
for mode, setting in tiny.PRODUCT_SETTINGS.items():
product, result = tiny.configured_product(orders, mode)
ok &= result == 'ok'
described = f"{product.get('sku') or '-'} · {product.get('descricao')}" if product else ''
out(f'{setting} ({tiny.PRODUCTS[mode]}): {result} {described}'.rstrip())
for name, result in tiny.check(orders=orders).items():
if name in ('pedidos', 'contatos'):
ok &= result == 'ok'
out(f'{name}: {result}')
out('Tudo conferido.' if ok else 'Há pendências acima.')
return ok
def pedido(orders, payload, confirm, out, wait=time.sleep):
cnpj = payload['order']['customer']['cnpj']
contacts = orders.request('GET', '/contatos', params={'cpfCnpj': cnpj, 'limit': 5}).get('itens') or []
known = [c for c in contacts if ''.join(ch for ch in str(c.get('cpfCnpj') or '') if ch.isdigit()) == cnpj]
out(f"Contato {cnpj}: " + (f"já existe no Tiny (id {known[0]['id']})" if known
else 'não existe; será criado com o e-mail como nome'))
out('Pedido que será enviado:')
out(json.dumps(tiny.order_payload(payload, known[0]['id'] if known else 0), ensure_ascii=False, indent=2))
if not confirm:
out('Nada foi criado. Repita com --confirmar para criar este pedido no Tiny.')
return None
first = orders.deliver('teste-integracao', payload)
out(f"1º envio: {first['status']} · Tiny id {first['tiny_id']} · nº {first['tiny_number']}")
for attempt in range(FIND_ATTEMPTS):
found = orders.find(payload)
if found:
break
if attempt < FIND_ATTEMPTS - 1:
wait(FIND_WAIT_SECONDS)
else:
out(f'FALHA: a busca por {tiny.purchase_order(payload["number"])} não encontrou o pedido. '
'Um reenvio criaria um duplicado; o 2º envio não foi feito. Cancele o pedido no Olist.')
return False
second = orders.deliver('teste-integracao', payload)
out(f"2º envio: {second['status']} · Tiny id {second['tiny_id']}")
passed = second['status'] == 'already-created' and second['tiny_id'] == first['tiny_id']
out(('OK: o reenvio encontrou o mesmo pedido.' if passed else 'FALHA: o reenvio não devolveu o mesmo pedido.')
+ f" Cancele o pedido nº {first['tiny_number']} no Olist.")
return passed
def main(argv=None, orders=None, out=print):
parser = argparse.ArgumentParser(prog='python -m app.tiny_probe')
commands = parser.add_subparsers(dest='command', required=True)
listing = commands.add_parser('produtos')
listing.add_argument('termo', nargs='?', default='')
commands.add_parser('conferir')
order = commands.add_parser('pedido')
order.add_argument('--cnpj', required=True)
order.add_argument('--email', required=True)
order.add_argument('--celular', required=True)
order.add_argument('--modo', choices=sorted(tiny.PRODUCT_SETTINGS), default='file')
order.add_argument('--confirmar', action='store_true')
args = parser.parse_args(argv)
if orders is None:
load_secret_files()
orders = tiny.TinyOrders(auth=tiny.TinyAuth())
try:
if args.command == 'produtos':
produtos(orders, args.termo, out)
return 0
if args.command == 'conferir':
return 0 if conferir(orders, out) else 1
cnpj = ''.join(ch for ch in args.cnpj if ch.isdigit())
if len(cnpj) != 14:
out('Informe um CNPJ com 14 dígitos.')
return 2
payload = test_order(cnpj, args.email, args.celular, args.modo)
return 0 if pedido(orders, payload, args.confirmar, out) is not False else 1
except tiny.TinyError as exc:
out(f'Tiny: {exc}')
return 1
if __name__ == '__main__':
sys.exit(main())

View File

@@ -48,6 +48,11 @@ server {
}
# Always revalidate HTML/JS/CSS after a deployment. Without this, a browser
# can pair a new Kanban page with a cached older script after a rollout.
# The Site's product pages and cart are addresses of the same page (web/site-pages.js).
location ~ ^/(arquivo-por-metro|artes-avulsas|uv-arquivo-por-metro|uv-artes-avulsas|carrinho)/?$ {
expires -1;
try_files /index.html =404;
}
location / {
expires -1;
try_files $uri $uri/ =404;

View File

@@ -62,25 +62,35 @@ operator interfaces.
4. Click the Site checkout button. Files upload directly to MinIO, remain
quarantined until ClamAV returns `clean`, and then become eligible for a quote.
The local banner displays a quote ID awaiting commercial review.
5. Open Kanban and log in. In **Cotações**, download the original if needed,
confirm/correct total metres and grade, tick the manual confirmation, and
click **Aprovar cotação**. For the fixture keep 1.01 m and grade 0.
5. Open Kanban and log in. Open the **Cotações** tab, pick the quote, download
the original if needed, confirm/correct metres and grade, tick the
confirmation at the bottom and click **Aprovar cotação**. For the fixture
keep 1.01 m and grade 0.
6. Return to the Site and click its checkout action again. Inspect the
authoritative server total, then click **Criar pedido de teste**. No real
payment occurs.
7. Refresh Kanban. The paid order starts in **Arte recebida**. Move it using
the buttons or drag and drop to **Arte tratada**. Open **Arquivos de produção**,
select the manually prepared final files for every item, enter a review note,
tick the confirmation and click **Aprovar arquivos finais**. Use the harmless
fixture again only for this local test. The text fixture is not an image, so
its print file shows **preparar à mão**; with a PNG or JPEG artwork the
generated PDF is preselected instead (see "Print files").
Continue through **Fila de impressão →
Imprimindo → Finalizado**. A move to **Correção** requires a reason; it can
return to **Arte recebida** or **Arte tratada**. Finalizado is terminal locally.
8. Inspect **Histórico** and **Eventos locais de integração**. Worker receipts
should say recorded locally. Download links expire after five minutes;
request another link from Kanban when needed.
7. Click **Atualizar** on the Kanban. The paid order appears in **Arte
recebida** on the **Produção** tab. Click its card to open the order panel,
then **Mover para Arte tratada** (dragging the card to a column also works).
In the panel's **Arquivos finais**, select the manually prepared final file
for every item, enter a review note, tick the confirmation and click
**Aprovar arquivos finais**. Use the harmless fixture again only for this
local test. The text fixture is not an image, so its print file shows
**Preparar à mão**; with a PNG, JPEG or PDF artwork the generated file is
preselected instead (see "Print files"). Continue with the panel's main
button through **Fila de impressão → Imprimindo → Finalizado**. **Pedir
correção** asks for the reason the customer will see; from **Correção** the
order returns to **Arte recebida** or **Arte tratada**. A move made by
mistake is undone with **Voltar para …**: one stage back, with an internal
reason recorded in the history. The customer is not notified, approved
finals stay, and "produção iniciada"/"pedido pronto" are sent only once per
order even if the stage is entered again. Dragging a card only goes forward;
the columns it can be dropped on are highlighted.
8. The panel's **Histórico** lists every move. The **Pagamentos** tab lists
payments that need a person; **Integrações** shows the Tiny connection and
the send log; with the fake adapters every send reads "Registrado
(simulado)". Download links expire after five minutes; click again for a new
one. The previous Kanban is kept in git tag `ui-v1`.
The manual quote step is necessary to enforce the backend trust boundary while
automatic pre-flight is deferred. Browser measurements and grade are proposals.

View File

@@ -7,16 +7,23 @@
> Update the **Current step** line and the item status every time something moves.
> Add new findings at the bottom of the relevant block rather than rewriting history.
**Current step (2026-09-24, Week 2):** Client inputs for Mercado Pago and
freight were requested on 2026-09-24; Tiny access is already with the client.
Built without them: server-side print-file generation (1.4), the delivery
address (3.8), the Kanban's payment-issue and print-file views (1.5), and
Mercado Pago and Tiny adapters written from the public API documentation and
tested against fake transports (1.1, 1.3). None of the provider work is a
verified integration. The complete CI integration sequence passed locally on
2026-09-24 (all API suites including the new `print_file_test`, adapter and
generator unit suites, retention, runtime security, and both browser suites),
run with Docker Engine in WSL against a fresh build; pending a Gitea runner run.
**Current step (2026-09-25, last day of Week 2):** Still waiting on the client
for Mercado Pago credentials and webhook access (1.1) and freight data (1.2);
Tiny's app credentials are in production and the connection starts from the
Kanban (1.3). Built and deployed without them: server-side print-file
generation (1.4), the delivery address (3.8), the Kanban's payment-issue and
print-file views and its redesign (1.5), the Site redesign (5.17), and Mercado
Pago and Tiny adapters written from the public API documentation and tested
against fake transports. None of the provider work is a verified integration.
Every change passed the full integration sequence locally (Docker Engine in
WSL) and on the Gitea runner, which publishes images on green pushes to `main`;
the user verified each release in production after redeploying in Portainer.
Operator and Site guides are in `docs/` (see Reporting).
**Left for today:** product ids for the four products from the client's Tiny and
one supervised real order (1.3); a Mercado Pago sandbox payment by PIX and card
if the credentials arrive (1.1); the freight connector if the data arrives
(1.2, otherwise the one Week 2 item that slips, on client inputs); the Week 2
client report.
**Previous step (2026-09-23):** Payment safety fixes 2.13 and 2.14 and
the local order-correctness work in 3.6/3.9 have passed integration checks.
@@ -261,6 +268,27 @@ From the report already sent. These are dated promises, not backlog.
real orders. Still to confirm on the client's account: plan (Construa+),
product ids, token lifetimes, whether pickup needs a transportador, and
rate limits.
**Supervised run (2026-09-25):** the payload and query parameters were
checked against Tiny's published v3 OpenAPI spec (`GET /pedidos` accepts
`cpfCnpj` and `dataInicial`; `GET /pedidos/{id}` returns
`numeroOrdemCompra`). "Testar conexão" on the Kanban now also reads the four
configured products and requires each to be active. `python -m
app.tiny_probe` runs from the worker console: `produtos` and `conferir` are
read-only; `pedido` shows the test order and creates it only with
`--confirmar`, through the worker's own `deliver`, then proves the duplicate
guard by search first and only then by a second delivery. Not yet run
against the client's account.
**Staying connected (2026-09-25):** Tiny documents a 4-hour access token and
a 1-day refresh token; the worker renews about every 4 hours. The connection
now asks for `offline_access` (listed by Tiny's Keycloak; if refused for this
application the callback retries once without it). Renewal failures are
stored: a refused refresh token marks the connection lost and is not retried,
a transient failure shows as a warning until the next success, and a
session grant with under 12 hours left is flagged. Previously a refused
refresh still showed "Tiny conectado". Whether Tiny grants offline access,
and whether a session grant has an undocumented maximum, is only known on
the client's account. There is no alert channel yet (no e-mail; WhatsApp
is fake): problems show on the Kanban only.
- `[~]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions
must survive checkout before an output engine can reproduce the approved job).
**Built (2026-09-24):** each paid item gets a PDF the width of the film and
@@ -287,6 +315,17 @@ download, approve as final, queue; hand-preparation routing and retry).
print-file status per item, and a panel lists payments that need a person
(money without an order, refunds after an order) until resolved. Confirm
with the operation that these are the main states before closing.
**2026-09-24:** a mistaken move can be undone one stage back (`BACK` in
`app/runtime.py`) with an internal reason, flagged in the history
(`movements.back`), without notifying the customer; "production started" and
"ready" are enqueued once per order. Previously the only way back was
Correção, which messages the customer and invalidates approved finals.
**Redesign (2026-09-24):** tabs for Produção, Cotações, Pagamentos and
Integrações; an order panel with stages, items, print files, final files and
history; numbered pagination (20 per page) on quotes, payment issues and the
integration log, which also has filters; messages dismiss themselves. The
operator guide (`docs/guia-operador-kanban.pdf`) documents the flow for the
operation to confirm.
- `[x]` 1.6 — **Block 0.2 + 0.3** were completed and verified on 2026-09-18.
`[!]` The production compose currently blocks `dev_paid` (`ENVIRONMENT != 'local'`)
@@ -710,6 +749,22 @@ print-file evidence still need correction before this item can close.
attempt counts against the source address and only failures count against
the account; registration still counts every attempt. The security suite's
lockout check (ten failures, then 429) is unchanged and passes.
- `[x]` 5.17 — Site redesign (2026-09-24), Dropstar brand kept; the previous
look is tag `ui-v1`. The home, one page per product (`/arquivo-por-metro`,
`/artes-avulsas`, `/uv-arquivo-por-metro`, `/uv-artes-avulsas`) and
`/carrinho` have their own addresses but stay one document, so artwork held in
the browser survives moving between them (`web/site-pages.js`,
`web/site-steps.js`; nginx serves `index.html` for those paths). The product
page is built around a buy box (`web/site-compra.js`) that shows the item the
flow already priced (grade, price per metre, metres charged, total,
resolution acknowledgement and "Adicionar ao carrinho"); the duplicated
quality and preview panels are hidden. The cart has "Remover" per item,
"Esvaziar carrinho" and an 8-second undo. Fixed on the way, all already in
production before: the saved-cart note took a grid column and pushed the
order into a narrow strip, the panels outside `.w` ran edge to edge, and
"57 cm" wrapped on the ready-sheet option. The browser suite covers product
and cart addresses, reload on a product page, and remove/undo; the security
suite checks the new addresses carry the same CSP.
- `[ ]` 5.13 — Define production recovery: scheduled encrypted offsite database
and object backups, a consistent snapshot boundary, Swarm data placement and
a restore rehearsal that opens every required live order file.
@@ -831,6 +886,12 @@ print-file evidence still need correction before this item can close.
### Reporting
- `[x]` Operator guide (`docs/guia-operador-kanban.pdf`) and Site guide
(`docs/guia-site-dtf.pdf`), 2026-09-24, with screenshots of a throwaway stack
and fictional orders. The payment step has no screenshot until Mercado Pago
is configured, and the guides name the provisional Kanban domain; regenerate
them when either changes. Early work toward Week 3's "orientação à operação".
- `[ ]` Week-2 client report, due end of day 2026-09-25.
- `[x]` Week-1 client report (`Relatorio-Semana-1-DTF.docx`), corrected 2026-09-18 to
remove the inaccurate "Arquivo por metro permanece separado, com seleção explícita"
claim and the internal commit reference.

Binary file not shown.

BIN
docs/guia-site-dtf.pdf Normal file

Binary file not shown.

View File

@@ -19,6 +19,10 @@ server {
proxy_set_header X-Forwarded-For $remote_addr;
client_max_body_size 2m;
}
# The Site's product pages and cart are addresses of the same page (web/site-pages.js).
location ~ ^/(arquivo-por-metro|artes-avulsas|uv-arquivo-por-metro|uv-artes-avulsas|carrinho)/?$ {
try_files /index.html =404;
}
location / { try_files $uri $uri/ =404; }
}
server {

View File

@@ -63,6 +63,10 @@ try{
await site.call('Page.reload');
await waitFor(()=>site.eval('typeof window.dtfCheckout === "function"'),'reload after security probe');
await site.click('[data-modo="file"]');
// Each product has its own page; the home's parts are not on it.
assert.deepEqual(await site.eval(`({path:location.pathname,page:document.documentElement.dataset.rota,
cards:getComputedStyle($('cards')).display,cart:getComputedStyle($('carr')).display})`),
{path:'/arquivo-por-metro',page:'produto',cards:'none',cart:'none'});
await site.click('[data-cam="tabela"]');
const root=await site.call('DOM.getDocument');
const input=await site.call('DOM.querySelector',{nodeId:root.root.nodeId,selector:'#inp'});
@@ -77,6 +81,17 @@ try{
await pause(800);
await site.call('Page.reload');
await waitFor(()=>site.eval('typeof pedido!=="undefined" && pedido.length===1'),'persistent cart recovery');
// Reloading a product page reopens that product; the cart is its own page.
assert.equal(await site.eval('modo'),'file');
await site.click('#cartLink');
await waitFor(()=>site.eval(`location.pathname==='/carrinho' && getComputedStyle($('carr')).display!=='none' && getComputedStyle($('foco')).display==='none'`),'cart page');
// Removing an item is one click and can be undone.
await site.click('[data-rmi="0"]');
// The empty state is painted on the next animation frame (site-steps.js).
await waitFor(()=>site.eval(`!$('carrVazio').hidden||document.documentElement.hasAttribute('data-sem-itens')`),'empty cart state');
assert.deepEqual(await site.eval(`({items:pedido.length,empty:!$('carrVazio').hidden||document.documentElement.hasAttribute('data-sem-itens'),undo:!$('desfazer').hidden})`),{items:0,empty:true,undo:true});
await site.click('#desfazerBtn');
assert.equal(await site.eval('pedido.length===1 && pedido[0].total===21.89 && $("desfazer").hidden'),true);
assert.equal(await site.eval('pedido[0].localFiles[0].name'),'local-test.cdr');
assert.equal(await site.eval('pedido[0].total'),21.89);
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false);
@@ -92,6 +107,11 @@ try{
await kanban.fill('#email',process.env.OPERATOR_EMAIL||'operator@example.test');
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
await kanban.eval('document.getElementById("login").requestSubmit()');
// Quotes live in their own tab; the review pane shows the one picked.
await waitFor(()=>kanban.eval('!document.getElementById("app").hidden'),'Kanban sign-in');
await kanban.click('[data-tab="quotes"]');
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-quote-pick="${qid}"]')`),'quote listed on Kanban');
await kanban.click(`[data-quote-pick="${qid}"]`);
await waitFor(async()=> (await kanban.text()).includes(qid.slice(0,8)),'quote on Kanban');
assert.equal(await kanban.eval('sessionStorage.getItem("dtf-operator")'),null);
assert.equal(await kanban.eval('document.getElementById("password").value'),'');
@@ -114,21 +134,34 @@ try{
const oid=await kanban.eval(`board.orders.find(o=>o.quote_id===${JSON.stringify(qid)}).id`);
assert.deepEqual(await kanban.eval(`(()=>{const spec=board.orders.find(o=>o.id===${JSON.stringify(oid)}).snapshot.items[0].production;const source=spec.sources[0];return {kind:source.kind,copies:source.copies,length:Number(source.length_cm),height:Number(spec.height_cm),placed:spec.placements.length}})()`),
{kind:'sheet',copies:1,length:101,height:101,placed:1});
// Click real transition buttons, including rerender after each move.
// Open the order's panel from its card, then click the real transition
// buttons there, including the rerender after each move.
await kanban.click('[data-tab="board"]');
await kanban.click(`[data-card="${oid}"]`);
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-move]')`),'order panel');
for(const [title,state] of [['Arte tratada','tra'],['Fila de impressão','fil'],['Imprimindo','imp'],['Finalizado','fin']]){
if(state==='fil'){
await kanban.eval(`(()=>{const card=document.querySelector('[data-order="${oid}"]');[...card.querySelectorAll('button')].find(x=>x.textContent==='Arquivos de produção').click();})()`);
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-final-item]')`),'final upload controls');
const doc=await kanban.call('DOM.getDocument');
const input=await kanban.call('DOM.querySelector',{nodeId:doc.root.nodeId,selector:`[data-order="${oid}"] [data-final-item]`});
await kanban.call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve('tests/fixtures/local-test.cdr')]});
await kanban.fill(`[data-order="${oid}"] input[placeholder="Nota da revisão"]`,'Browser test final file');
await kanban.eval(`(()=>{const form=document.querySelector('[data-order="${oid}"] form');form.querySelector('[type=checkbox]').click();form.requestSubmit();})()`);
await kanban.eval(`(()=>{const check=document.querySelector('[data-order="${oid}"] [data-confirm]');check.click();check.closest('form').requestSubmit();})()`);
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').version===2`),'final file approval');
}
await kanban.eval(`(()=>{const card=document.querySelector('[data-order="${oid}"]');[...card.querySelectorAll('button')].find(x=>x.textContent===${JSON.stringify('→ '+title)}).click();})()`);
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-move="${state}"]')`),'move to '+title);
await kanban.click(`[data-order="${oid}"] [data-move="${state}"]`);
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='${state}'`),'transition '+state);
}
// Undo a mistaken move from the panel: one stage back with an internal reason.
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-back="imp"]')`),'back button');
await kanban.click(`[data-order="${oid}"] [data-back="imp"]`);
await kanban.fill(`[data-order="${oid}"] form.reason input`,'Movido por engano (teste)');
await kanban.eval(`document.querySelector('[data-order="${oid}"] form.reason input').closest('form').requestSubmit()`);
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='imp'`),'undo to Imprimindo');
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-move="fin"]')`),'move to Finalizado again');
await kanban.click(`[data-order="${oid}"] [data-move="fin"]`);
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='fin'`),'finished again');
// Reload proves the board is persisted on the backend.
await kanban.call('Page.reload');
await waitFor(()=>kanban.eval(`typeof board!=='undefined' && !!board && board.orders.some(o=>o.id==='${oid}'&&o.state==='fin')`),'persisted board');

View File

@@ -109,7 +109,7 @@ def run():
# The customer is told once, not once per delivery.
board = Client()
events = board.call('/operator/board', operator=True)['events']
events = board.call('/operator/events?order=' + str(order['number']), operator=True)['events']
paid = [e for e in events if e['payload'].get('order_id') == order['id']
and e['payload'].get('event') == 'payment_approved']
assert len(paid) == 2, f'expected one tiny and one whatsapp event, got {len(paid)}'

View File

@@ -126,7 +126,7 @@ def run():
manual = upload_bytes(client, b'LOCAL PRINT TEST - NOT AN IMAGE', name='LOCAL-PRINT-TEST.cdr')
order = paid_order(client, loose_item(manual, copies=1))
order, row = wait_print(client, order['id'], 'manual')
assert 'not an image' in row['detail']['reason'], row
assert 'não é uma imagem' in row['detail']['reason'], row
rows = client.call('/operator/orders/' + order['id'] + '/print-files', {}, operator=True)
assert rows[0]['status'] == 'pending'
wait_print(client, order['id'], 'manual')
@@ -141,17 +141,43 @@ def run():
outcome = deliver({'event_id': event_id, 'reference': quote_id,
'status': 'approved', 'amount_cents': total - 1})
assert outcome['outcome'].startswith('refused'), outcome
issues = client.call('/operator/board', operator=True)['payment_issues']
issues = client.call('/operator/payment-events?state=open&limit=100', operator=True)['issues']
issue = next(i for i in issues if i['event_id'] == event_id)
assert client.call('/operator/board', operator=True)['payment_issues_total'] >= 1
client.call('/operator/payment-events/' + issue['id'] + '/resolve', {'note': 'no'},
operator=True, expected=422)
client.call('/operator/payment-events/' + issue['id'] + '/resolve',
{'note': 'Local test: refunded the underpayment'}, operator=True)
client.call('/operator/payment-events/' + issue['id'] + '/resolve',
{'note': 'Local test: second resolution'}, operator=True, expected=404)
issues = client.call('/operator/board', operator=True)['payment_issues']
issues = client.call('/operator/payment-events?state=open&limit=100', operator=True)['issues']
assert not any(i['event_id'] == event_id for i in issues)
print('PASS: refused paid notification is listed until an operator resolves it')
resolved_page = client.call('/operator/payment-events?state=resolved&limit=100', operator=True)
assert resolved_page['total'] >= 1
resolved = resolved_page['issues']
done = next(i for i in resolved if i['event_id'] == event_id)
assert done['resolution'] == 'Local test: refunded the underpayment' and done['resolved_by']
print('PASS: refused paid notification is listed until an operator resolves it, then kept as history')
# The send log pages by id and filters by destination, status, event and order.
page = client.call('/operator/events?limit=2', operator=True)
assert len(page['events']) == 2 and page['total'] > 2
second = client.call('/operator/events?limit=2&offset=2', operator=True)
assert all(e['id'] < page['events'][-1]['id'] for e in second['events'])
assert second['total'] == page['total']
tiny = client.call('/operator/events?provider=tiny&event=payment_approved&status=delivered&limit=100', operator=True)
assert tiny['events'] and all(e['provider'] == 'tiny' and e['payload']['event'] == 'payment_approved'
and e['delivered_at'] for e in tiny['events'])
client.call('/operator/events?provider=email', operator=True, expected=422)
client.call('/operator/events?limit=500', operator=True, expected=422)
quotes = client.call('/operator/quotes?kind=approved&limit=1&offset=0', operator=True)
assert 'total' in quotes and len(quotes['quotes']) <= 1
finished = client.call('/operator/orders/finished?limit=1', operator=True)
if finished['orders']:
last = finished['orders'][-1]
client.call('/operator/orders/finished?before_created_at=' + last['created_at'].replace('+', '%2B')
+ '&before_id=' + last['id'], operator=True)
print('PASS: send log, payment history and finished orders page and filter')
if __name__ == '__main__':

View File

@@ -29,6 +29,10 @@ def run():
assert script_src == "script-src 'self'", script_src
assert "object-src 'none'" in policy
assert 'cdnjs' not in policy, 'pdf.js is vendored; no CDN belongs in the policy'
# Product pages and the cart are addresses of the Site's page, under the same policy.
for page in ('/arquivo-por-metro','/artes-avulsas','/uv-arquivo-por-metro','/uv-artes-avulsas','/carrinho'):
assert raw(page,200)['Content-Security-Policy']==policy,page
raw('/carrinho/outra-coisa',404)
raw('/api/health',400,{'Host':'attacker.invalid'})
raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'https://attacker.invalid'},b'{}')
raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'http://localhost:9999'},b'{}')

View File

@@ -185,10 +185,21 @@ def run():
version+=1;assert moved['version']==version
client.call('/operator/orders/'+oid+'/move',{'state':'rec','version':0},operator=True,expected=409)
assert len(client.call('/operator/orders/'+oid+'/history',operator=True))==6
# A mistaken move can be undone one stage at a time, with an internal
# reason. The customer is not told again: going back and forward once more
# adds no messages (the outbox count below stays 8).
client.call('/operator/orders/'+oid+'/move',{'state':'imp','version':version},operator=True,expected=422)
client.call('/operator/orders/'+oid+'/move',{'state':'tra','version':version},operator=True,expected=409)
back=client.call('/operator/orders/'+oid+'/move',{'state':'imp','version':version,'reason':'Movido por engano'},operator=True)
assert back['state']=='imp';version+=1
client.call('/operator/orders/'+oid+'/move',{'state':'fin','version':version},operator=True);version+=1
history=client.call('/operator/orders/'+oid+'/history',operator=True)
assert len(history)==8 and history[-2]['back'] and history[-2]['reason']=='Movido por engano' and not history[-1]['back']
print('PASS: a mistaken move is undone one stage back with a reason, without messaging the customer again')
print('PASS: all modes, authoritative review/prices/freight, tamper rejection, concurrent payment idempotency, transitions and history')
deadline=time.monotonic()+30
while time.monotonic()<deadline:
events=[e for e in client.call('/operator/board',operator=True)['events'] if e['payload']['order_id']==oid]
events=[e for e in client.call('/operator/events?order='+str(order['number']),operator=True)['events'] if e['payload']['order_id']==oid]
if len(events)==8 and all(e['delivered_at'] and e['receipt'] for e in events):break
time.sleep(1)
else:raise AssertionError('Mock outbox did not drain')

View File

@@ -115,19 +115,19 @@ class PrintFileTests(unittest.TestCase):
def test_refuses_what_it_cannot_reproduce(self):
png = self.save(quadrants(), 'a.png')
with self.assertRaisesRegex(Unsupported, 'proportions'):
with self.assertRaisesRegex(Unsupported, 'proporções'):
self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [png])
with self.assertRaisesRegex(Unsupported, 'billed'):
with self.assertRaisesRegex(Unsupported, 'cobrados'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 150, billed='1.0'), [png])
fake_pdf = os.path.join(self.dir.name, 'art.pdf')
with open(fake_pdf, 'wb') as handle:
handle.write(b'%PDF-1.4\n%%EOF\n')
with self.assertRaisesRegex(Unsupported, 'not a PDF'):
with self.assertRaisesRegex(Unsupported, 'não é um PDF'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [fake_pdf])
cdr = os.path.join(self.dir.name, 'art.cdr')
with open(cdr, 'wb') as handle:
handle.write(b'not artwork')
with self.assertRaisesRegex(Unsupported, 'not an image'):
with self.assertRaisesRegex(Unsupported, 'não é uma imagem'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [cdr])
def test_long_layouts_scale_user_space_instead_of_splitting(self):
@@ -247,12 +247,12 @@ class PdfSourceTests(unittest.TestCase):
self.assertEqual(detail['min_dpi'], round(80 / (20 / 2.54)))
def test_refuses_pdfs_it_cannot_place(self):
with self.assertRaisesRegex(Unsupported, '2 pages'):
with self.assertRaisesRegex(Unsupported, '2 páginas'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [self.pdf('two.pdf', pages=2)])
with self.assertRaisesRegex(Unsupported, 'password'):
with self.assertRaisesRegex(Unsupported, 'senha'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10),
[self.pdf('locked.pdf', password='secret')])
with self.assertRaisesRegex(Unsupported, 'proportions'):
with self.assertRaisesRegex(Unsupported, 'proporções'):
self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [self.pdf('square.pdf')])
@unittest.skipIf(fitz is None, 'PyMuPDF is not installed')

View File

@@ -31,24 +31,34 @@ class FakeAuth:
return 'access-1'
@mock.patch.dict(os.environ, PRODUCTS)
class TinyTests(unittest.TestCase):
class FakeTinyCase(unittest.TestCase):
"""A Tiny account in memory: contacts, orders and the four products."""
def setUp(self):
self.contacts = []
self.orders = []
self.calls = []
self.products = {value: {'id': int(value), 'sku': f'DTF-{value}', 'descricao': f'Produto {value}',
'situacao': 'A', 'precos': {'preco': 14.9}} for value in PRODUCTS.values()}
def handler(request):
path = request.url.path.removeprefix('/public-api/v3')
self.calls.append((request.method, path))
assert request.headers['authorization'] == 'Bearer access-1'
if request.method == 'GET' and path == '/contatos':
cnpj = request.url.params['cpfCnpj']
return httpx.Response(200, json={'itens': [c for c in self.contacts if c['cpfCnpj'] == cnpj]})
cnpj = request.url.params.get('cpfCnpj')
return httpx.Response(200, json={'itens': [c for c in self.contacts if cnpj in (None, c['cpfCnpj'])]})
if request.method == 'POST' and path == '/contatos':
contact = {**json.loads(request.content), 'id': 500 + len(self.contacts)}
self.contacts.append(contact)
return httpx.Response(200, json={'id': contact['id']})
if request.method == 'GET' and path.startswith('/produtos/'):
wanted = path.rsplit('/', 1)[-1]
if wanted not in self.products:
return httpx.Response(404, json={'mensagem': 'não encontrado'})
return httpx.Response(200, json=self.products[wanted])
if request.method == 'GET' and path == '/produtos':
return httpx.Response(200, json={'itens': list(self.products.values())})
if request.method == 'GET' and path == '/pedidos':
return httpx.Response(200, json={'itens': [{'id': o['id']} for o in self.orders]})
if request.method == 'GET' and path.startswith('/pedidos/'):
@@ -61,9 +71,13 @@ class TinyTests(unittest.TestCase):
return httpx.Response(200, json={'id': order['id'], 'numeroPedido': order['numeroPedido']})
return httpx.Response(404)
self.handler = handler
self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(handler),
today=date(2026, 9, 24))
@mock.patch.dict(os.environ, PRODUCTS)
class TinyTests(FakeTinyCase):
def test_payload_carries_contact_products_address_and_freight(self):
pedido = order_payload(PAID, 777, date(2026, 9, 24))
self.assertEqual((pedido['idContato'], pedido['numeroOrdemCompra'], pedido['data']),
@@ -106,11 +120,75 @@ class TinyTests(unittest.TestCase):
self.tiny.deliver('k3', PAID)
self.assertNotIn(('POST', '/pedidos'), self.calls)
def test_connection_check_only_reads(self):
from app.tiny import check
results = check(auth=FakeAuth(), transport=httpx.MockTransport(self.handler))
self.assertEqual(set(results.values()), {'ok'})
self.assertEqual(len(results), 6)
self.assertTrue(all(method == 'GET' for method, _ in self.calls))
self.products['103']['situacao'] = 'I'
with mock.patch.dict(os.environ, {'TINY_PRODUCT_UV_AVULSA': ''}):
results = check(auth=FakeAuth(), transport=httpx.MockTransport(self.handler))
self.assertIn('não está ativo', results['DTF UV 28,5 cm · folha montada'])
self.assertEqual(results['DTF UV 28,5 cm · artes avulsas'], 'TINY_PRODUCT_UV_AVULSA sem id')
self.assertEqual(results['pedidos'], 'ok')
denied = check(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(403, text='forbidden')))
self.assertTrue(denied['pedidos'].startswith('GET /pedidos: 403'))
def test_rate_limit_is_a_retryable_failure(self):
tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(429)))
with self.assertRaisesRegex(TinyError, 'rate limit'):
tiny.deliver('k4', PAID)
@mock.patch.dict(os.environ, PRODUCTS)
class TinyProbeTests(FakeTinyCase):
"""The supervised console tool run against the fake Tiny."""
def probe(self, *argv):
from app import tiny_probe
lines = []
code = tiny_probe.main(list(argv), orders=self.tiny, out=lines.append)
return code, '\n'.join(lines)
def test_products_and_settings_are_listed_without_writing(self):
code, text = self.probe('produtos', 'DTF')
self.assertEqual(code, 0)
self.assertIn('101\tDTF-101\tProduto 101', text)
code, text = self.probe('conferir')
self.assertEqual(code, 0)
self.assertIn('TINY_PRODUCT_UV_AVULSA (DTF UV 28,5 cm · artes avulsas): ok DTF-104', text)
self.assertTrue(all(method == 'GET' for method, _ in self.calls))
def test_order_is_shown_and_not_created_without_confirmation(self):
code, text = self.probe('pedido', '--cnpj', '11.222.333/0001-81', '--email', 'a@example.test',
'--celular', '16999999999')
self.assertEqual(code, 0)
self.assertIn('"numeroOrdemCompra": "DTF-TESTE-', text)
self.assertIn('Nada foi criado', text)
self.assertEqual(self.orders, [])
self.assertTrue(all(method == 'GET' for method, _ in self.calls))
def test_confirmed_order_is_created_once_and_found_on_resend(self):
code, text = self.probe('pedido', '--cnpj', '11222333000181', '--email', 'a@example.test',
'--celular', '16999999999', '--modo', 'uv', '--confirmar')
self.assertEqual(code, 0, text)
self.assertEqual(len(self.orders), 1)
self.assertEqual(self.orders[0]['itens'][0]['produto'], {'id': 104})
self.assertIn('2º envio: already-created', text)
def test_resend_is_skipped_when_the_search_cannot_find_the_order(self):
from app import tiny_probe
blind = lambda request: (httpx.Response(200, json={'itens': []})
if request.method == 'GET' and request.url.path.endswith('/pedidos')
else self.handler(request))
self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(blind), today=date(2026, 9, 24))
payload = tiny_probe.test_order('11222333000181', 'a@example.test', '16999999999', 'file')
lines = []
self.assertFalse(tiny_probe.pedido(self.tiny, payload, True, lines.append, wait=lambda s: None))
self.assertEqual(len(self.orders), 1)
self.assertIn('o 2º envio não foi feito', lines[-1])
if __name__ == '__main__':
unittest.main()

View File

@@ -33,8 +33,12 @@ def run():
def exercise():
issued = []
server = {'mode': 'session', 'calls': 0}
def token_server(request):
server['calls'] += 1
if server['mode'] == 'down':
return httpx.Response(503, text='unavailable')
form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
assert (form['client_id'], form['client_secret']) == ('test-client', 'test-secret')
if form['grant_type'] == 'authorization_code':
@@ -44,8 +48,13 @@ def exercise():
return httpx.Response(400, json={'error': 'invalid_grant'})
n = len(issued) + 1
issued.append(f'refresh-{n}')
if server['mode'] == 'offline':
return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400,
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 0,
'scope': 'openid offline_access profile'})
return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400,
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 86400})
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 86400,
'scope': 'openid profile'})
auth = TinyAuth(transport=httpx.MockTransport(token_server))
assert auth.status() == {'connected': False}
@@ -59,6 +68,7 @@ def exercise():
query = {k: v[0] for k, v in parse_qs(url.query).items()}
assert url.netloc == 'accounts.tiny.com.br' and query['client_id'] == 'test-client'
assert query['redirect_uri'].endswith('/api/operator/tiny/callback') and len(query['state']) > 30
assert query['scope'] == 'openid offline_access'
try:
auth.complete('good-code', 'forged-state')
raise AssertionError('a state no operator created must be refused')
@@ -72,6 +82,7 @@ def exercise():
pass
status = auth.status()
assert status['connected'] and status['connected_by'] == 'operator@example.test'
assert status['problem'] is None and not status['offline'] and status['expires_at']
assert auth.access_token() == 'access-1'
print('PASS: operator-started state is required, single-use, and stores the connection')
@@ -85,6 +96,23 @@ def exercise():
assert auth.access_token() == 'access-2'
print('PASS: expiring access token refreshed once, refresh token rotated and stored')
# Tiny unreachable: the failure is shown, the connection kept, and the next
# renewal clears it.
with connect() as c:
c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'")
server['mode'] = 'down'
try:
auth.access_token()
raise AssertionError('an unreachable token server must fail the renewal')
except httpx.HTTPError:
pass
status = auth.status()
assert status['connected'] and status['problem'] == 'renewal-failing' and status['failed_at']
server['mode'] = 'session'
assert auth.access_token() == 'access-3'
assert auth.status()['problem'] is None
print('PASS: a failed renewal is shown and cleared by the next successful one')
# A refused refresh (revoked in Tiny) asks for a new connection, not a retry loop.
with connect() as c:
c.execute("""UPDATE dtf_local.provider_tokens SET access_expires_at=now(), refresh_token='revoked'
@@ -94,12 +122,63 @@ def exercise():
raise AssertionError('a refused refresh must report the connection as lost')
except TinyNotConnected:
pass
status = auth.status()
assert not status['connected'] and status['problem'] == 'refused'
calls = server['calls']
try:
auth.access_token()
raise AssertionError('a refused connection must stay refused')
except TinyNotConnected:
pass
assert server['calls'] == calls, 'a refused refresh token must not be sent again'
with connect() as c:
c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=%s WHERE provider='tiny'",
(datetime.now(timezone.utc) - timedelta(seconds=1),))
assert not auth.status()['connected']
print('PASS: revoked or expired connections report that Tiny must be connected again')
# Reconnecting with an offline grant: no daily end, and the refusal is cleared.
server['mode'] = 'offline'
state = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
auth.complete('good-code', state)
status = auth.status()
assert status['connected'] and status['offline'] and status['expires_at'] is None
assert status['problem'] is None
with connect() as c:
c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'")
auth.access_token()
assert auth.status()['offline'] and auth.status()['expires_at'] is None
print('PASS: an offline grant is stored without a daily expiry and survives renewal')
# Tiny refusing offline_access restarts the authorisation without it, once.
state = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
retry = parse_qs(urlparse(auth.without_offline(state)).query)
assert retry['scope'] == ['openid'] and retry['state'][0] != state
try:
auth.without_offline(state)
raise AssertionError('the refused state must be spent')
except TinyError:
pass
# A session grant close to its end is flagged while renewals are not happening.
server['mode'] = 'session'
auth.complete('good-code', retry['state'][0])
with connect() as c:
c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=now()+interval '2 hours' WHERE provider='tiny'")
status = auth.status()
assert status['connected'] and status['problem'] == 'expiring' and not status['offline']
print('PASS: a refused offline scope falls back once; a connection near its end is flagged')
# Tiny's redirect back with an error instead of a code.
from app.api.operator import tiny_callback
declined = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
assert tiny_callback('', declined, 'access_denied').headers['location'] == '/?tiny=failed'
assert tiny_callback('', '', '').headers['location'] == '/?tiny=failed'
scoped = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
location = urlparse(tiny_callback('', scoped, 'invalid_scope').headers['location'])
assert location.netloc == 'accounts.tiny.com.br' and parse_qs(location.query)['scope'] == ['openid']
assert tiny_callback('', scoped, 'invalid_scope').headers['location'] == '/?tiny=failed'
print('PASS: a declined or malformed callback returns to the Kanban; a refused scope retries without it')
if __name__ == '__main__':
run()

View File

@@ -3,7 +3,7 @@
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';
const notice=document.createElement('p');notice.className='carrAviso';
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(){

View File

@@ -127,7 +127,9 @@
return;
}
if (quote.status==='pending_review') {
message('Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.');
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') {
message('Total validado no servidor: '+rs(quote.approved.total_cents/100)+' · inclui frete. Cotação válida por 24 horas.');
if ((await ready).payment_provider === 'mercadopago') {
@@ -163,8 +165,9 @@
} catch(error) { message(error.message); event.target.disabled=false; }
});
} else if (quote.status==='paid') {
message('Pedido #'+quote.order.number+' · etapa: '+quote.order.state+'.');
button('Novo pedido',()=>{clearDraft();location.reload();});
const STAGES={rec:'arte recebida',tra:'arte tratada',fil:'na fila de impressão',imp:'imprimindo',cor:'aguardando correção',fin:'finalizado'};
message('Pedido #'+quote.order.number+' · '+(STAGES[quote.order.state]||quote.order.state)+'.');
button('Novo pedido',()=>{clearDraft();location.assign('/');});
} else {
message('Cotação expirada. Envie o carrinho para uma nova revisão.');
button('Nova cotação',clearDraft);

View File

@@ -748,6 +748,8 @@ footer a:hover{color:var(--laranja2)}
justify-content:space-between;gap:12px;flex-wrap:wrap;font-size:11px;color:#5A6570}
@media(prefers-reduced-motion:reduce){*{transition:none!important;scroll-behavior:auto}}
</style>
<link rel="stylesheet" href="/site-v2.css?v=6">
<script src="/site-pages.js"></script>
</head>
<body>
@@ -763,7 +765,7 @@ footer a:hover{color:var(--laranja2)}
</div>
<div class="acts">
<a href="/portal.html">Minha conta</a>
<a href="#carr" id="cartLink">Carrinho (0)</a>
<a href="/carrinho" id="cartLink">Carrinho (0)</a>
</div>
</div></header>
@@ -784,10 +786,10 @@ footer a:hover{color:var(--laranja2)}
</div>
</div>
<div class="mi on">
<a href="#envio">Impressão DTF</a>
<a href="/#envio">Impressão DTF</a>
<div class="sub">
<a href="#envio">DTF Têxtil · 57 cm</a>
<a href="#envio">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">
@@ -820,171 +822,101 @@ footer a:hover{color:var(--laranja2)}
</div>
</div></nav>
<header class="hero">
<div class="fundo" aria-hidden="true">
<svg viewBox="0 0 1400 300" preserveAspectRatio="xMidYMid slice">
<g fill="#fff">
<rect x="20" y="16" width="118" height="140" rx="5"/>
<rect x="150" y="16" width="118" height="140" rx="5"/>
<rect x="280" y="16" width="76" height="66" rx="5"/>
<rect x="368" y="16" width="76" height="66" rx="5"/>
<rect x="280" y="94" width="164" height="62" rx="5"/>
<rect x="456" y="16" width="96" height="96" rx="5"/>
<rect x="564" y="16" width="96" height="96" rx="5"/>
<rect x="456" y="124" width="204" height="32" rx="5"/>
<rect x="672" y="16" width="118" height="140" rx="5"/>
<rect x="802" y="16" width="112" height="66" rx="5"/>
<rect x="802" y="94" width="112" height="62" rx="5"/>
<rect x="926" y="16" width="150" height="140" rx="5"/>
<rect x="1088" y="16" width="86" height="86" rx="5"/>
<rect x="1186" y="16" width="86" height="86" rx="5"/>
<rect x="1088" y="114" width="184" height="42" rx="5"/>
<rect x="1284" y="16" width="96" height="140" rx="5"/>
<rect x="20" y="168" width="76" height="76" rx="5"/>
<rect x="108" y="168" width="76" height="76" rx="5"/>
<rect x="196" y="168" width="76" height="76" rx="5"/>
<rect x="284" y="168" width="160" height="76" rx="5"/>
<rect x="456" y="168" width="104" height="118" rx="5"/>
<rect x="572" y="168" width="104" height="118" rx="5"/>
<rect x="688" y="168" width="226" height="54" rx="5"/>
<rect x="688" y="234" width="108" height="52" rx="5"/>
<rect x="808" y="234" width="106" height="52" rx="5"/>
<rect x="926" y="168" width="150" height="118" rx="5"/>
<rect x="1088" y="168" width="184" height="56" rx="5"/>
<rect x="1088" y="236" width="86" height="50" rx="5"/>
<rect x="1186" y="236" width="86" height="50" rx="5"/>
<rect x="1284" y="168" width="96" height="118" rx="5"/>
<rect x="20" y="256" width="424" height="30" rx="5"/>
</g>
</svg>
</div>
<div class="w">
<div class="hgrid">
<div>
<div class="selos1">
<span class="sel top">Metro a partir de R$ 14,90</span>
<span class="sel novo">Revisão inclusa</span>
<span class="sel pos">Franca · SP</span>
</div>
<h1>A gente encaixa sua folha<br><em>e você paga menos metro</em></h1>
<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">Enviar minha arte</a>
<span class="mini">sem cadastro · resposta em segundos</span>
</div>
<header class="hero2">
<div class="w hero2-grid">
<div class="hero2-text">
<div class="chips">
<span class="chip2 destaque">Metro a partir de R$ 14,90</span>
<span class="chip2">Franca · SP · envio em até 24 h</span>
</div>
<div class="pfaixa">
<a class="pcard dest" href="#envio">
<span class="marc">mais vendido</span>
<span class="apartir">com arte boa</span>
<span class="vl">R$ 14,90<small> /m</small></span>
<span class="nm">Arquivo por metro</span>
<span class="dsc">têxtil · 57 cm</span>
</a>
<a class="pcard" href="#envio">
<span class="apartir">com arte boa</span>
<span class="vl">R$ 24,90<small> /m</small></span>
<span class="nm">Artes avulsas</span>
<span class="dsc">têxtil · montamos a folha</span>
</a>
<a class="pcard" href="#envio">
<span class="apartir">com arte boa</span>
<span class="vl">R$ 69,90<small> /m</small></span>
<span class="nm">Arquivo UV</span>
<span class="dsc">rígido · 28,5 cm</span>
</a>
<a class="pcard" href="#envio">
<span class="apartir">com arte boa</span>
<span class="vl">R$ 83,90<small> /m</small></span>
<span class="nm">Artes avulsas UV</span>
<span class="dsc">rígido · montamos a folha</span>
</a>
<h1>A gente encaixa sua folha e você paga <em>menos metro</em></h1>
<p>Mande a arte, veja a nota e a folha montada antes de pagar. Revisão de 5 pontos e reencaixe por nossa conta.</p>
<div class="hero2-cta">
<a href="/#envio" class="btn-laranja">Enviar minha arte <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a>
<span>Sem cadastro · preço em segundos</span>
</div>
</div>
<div class="barrah">
<span><i>◆</i> Revisão de <b>5 pontos</b> sem custo</span>
<span><i>◆</i> Cobrança por <b>proporção</b>, a cada 10 cm</span>
<span><i>◆</i> Filme de <b>57 cm</b>, o dobro do padrão</span>
<span><i>◆</i> Prévia da folha <b>antes de pagar</b></span>
<div class="hero2-folha" aria-hidden="true">
<div class="hf-cab"><b>Sua folha montada</b><span>filme 57 cm · prévia</span></div>
<div class="hf-area">
<i style="left:3%;top:5%;width:34%;height:40%;background:#FFA81A"></i>
<i style="left:39%;top:5%;width:34%;height:40%;background:#FFA81A"></i>
<i style="left:75%;top:5%;width:22%;height:55%;background:#2BB6D9"></i>
<i style="left:3%;top:49%;width:22%;height:40%;border-radius:50%;background:#E0508F"></i>
<i style="left:27%;top:49%;width:22%;height:40%;border-radius:50%;background:#E0508F"></i>
<i style="left:51%;top:49%;width:22%;height:30%;background:#E9C23A"></i>
<i style="left:75%;top:63%;width:22%;height:26%;background:#E9C23A"></i>
</div>
<div class="hf-num">
<div><span>Metragem</span><b>0,60 m</b></div>
<div><span>Nota da arte</span><b class="verde">92</b></div>
<div><span>Preço do metro</span><b class="laranja">R$ 24,90</b></div>
</div>
</div>
</div>
</header>
<!-- PROVA -->
<section class="prova"><div class="w">
<div class="p1"><b>Quanto melhor sua arte,<br><em>menor o preço do metro</em></b>
<span>Têxtil de R$ 19,90 a R$ 14,90 · UV de R$ 99,90 a R$ 69,90.
A nota aparece na tela antes de você pagar.</span></div>
<div class="p1"><b>Filme de 57 cm,<br><em>o dobro do padrão</em></b>
<span>Cabe o dobro de arte por metro. E reencaixamos sua folha sem cobrar.</span></div>
<div class="p1"><b>Você paga só<br><em>o que usa</em></b>
<span>Cobrança a cada 10 cm. Folha de 2,75 m custa 2,80 — não 3.</span></div>
<div class="p1"><b>Erro pego<br><em>antes de imprimir</em></b>
<span>5 pontos conferidos e a nota na tela. Sem custo.</span></div>
<div class="p1"><b>Envio em<br><em>até 24 horas</em></b>
<span>E avisamos por WhatsApp a cada etapa do pedido.</span></div>
<section class="etapas2"><div class="w">
<div><span>1</span><div><b>Envie a arte</b><p>PNG, JPG ou PDF, conforme o produto. Sem cadastro.</p></div></div>
<div><span>2</span><div><b>Veja nota e montagem</b><p>A folha e o preço aparecem na hora.</p></div></div>
<div><span>3</span><div><b>Confira e pague</b><p>Conferimos a metragem e liberamos o pagamento.</p></div></div>
<div><span>4</span><div><b>Acompanhe</b><p>Aviso no WhatsApp a cada etapa.</p></div></div>
</div></section>
<!-- ENVIO -->
<section id="envio"><div class="w">
<section id="envio">
<nav class="passos" id="passos" aria-label="Etapas do pedido" hidden>
<a href="/#envio" data-passo="1"><span>1</span>Montagem</a>
<i aria-hidden="true"></i>
<a href="/carrinho" data-passo="2"><span>2</span>Dados e entrega</a>
<i aria-hidden="true"></i>
<a href="/carrinho#checkoutStatus" data-passo="3"><span>3</span>Pagamento</a>
</nav>
<div class="w">
<!-- 3 cards -->
<div id="cards">
<div class="et">Comece por aqui</div>
<h2>Qual é o seu <em>caso</em>?</h2>
<div class="cards-cab">
<div><div class="et">Escolha o produto</div>
<h2>Qual é o seu caso?</h2></div>
<p>O preço do metro depende da nota da arte: quanto melhor o arquivo, menor o preço.</p>
</div>
<div class="escolha">
<button class="ec f" data-modo="file">
<span class="marc">Mais vendido</span>
<div class="top"><h3>Arquivo por metro</h3>
<span class="p"><i>tabela</i>R$ 19,90<small>/m</small></span></div>
<div class="chama2">Quer pagar <b>R$ 14,90</b>? Arte em 300 DPI, em arquivo que a gente confere.</div>
<div class="in">
<span class="lg">DTF têxtil · 57 cm</span>
<p>Você monta a folha e manda pronta. Revisamos, corrigimos e reencaixamos.</p>
<ul><li>Remanejamos e sua folha encolhe</li><li>Arquivo bom paga o mínimo</li>
<li>Você continua no controle do layout</li></ul>
<span class="go">Enviar minha folha ›</span>
<div class="ec-top"><span class="ec-kind">DTF TÊXTIL · 57 CM</span><span class="marc2">Mais vendido</span></div>
<h3>Arquivo por metro</h3>
<p>Você monta a folha e manda pronta. Revisamos, corrigimos e reencaixamos se sobrar espaço.</p>
<div class="ec-pe">
<div class="ec-preco"><span>de</span><b data-de="file">—</b><span>a <em data-ate="file">—</em> /m conforme a nota da arte</span></div>
<span class="ec-go">Enviar minha folha</span>
</div>
</button>
<button class="ec a" data-modo="avulsa">
<div class="top"><h3>Artes avulsas</h3><span class="p"><i>tabela</i>R$ 29,90<small>/m</small></span></div>
<div class="chama2">Quer pagar <b>R$ 24,90</b>? Arte em 300 DPI, em arquivo que a gente confere.</div>
<div class="in">
<span class="lg">DTF têxtil · 57 cm</span>
<p>Cada arte num arquivo. Montamos a folha com o melhor encaixe.</p>
<ul><li>Sugerimos a largura que rende mais</li><li>Sobreposição não acontece</li>
<li>Montagem inclusa · R$ 10,00 no metro</li></ul>
<span class="go">Enviar minhas artes ›</span>
<div class="ec-top"><span class="ec-kind">DTF TÊXTIL · 57 CM</span></div>
<h3>Artes avulsas</h3>
<p>Cada arte num arquivo. A gente monta a folha com o melhor encaixe. Montagem inclusa no preço.</p>
<div class="ec-pe">
<div class="ec-preco"><span>de</span><b data-de="avulsa">—</b><span>a <em data-ate="avulsa">—</em> /m conforme a nota da arte</span></div>
<span class="ec-go">Enviar minhas artes</span>
</div>
</button>
<button class="ec u" data-modo="uvfile">
<div class="top"><h3>Arquivo por metro · UV</h3><span class="p"><i>tabela</i>R$ 85,90<small>/m</small></span></div>
<div class="chama2">Quer pagar <b>R$ 69,90</b>? Arte em 300 DPI, em arquivo que a gente confere.</div>
<div class="in">
<span class="lg">Adesivo UV · 28,5 cm</span>
<p>Sua folha de UV montada. Revisamos, corrigimos e reencaixamos.</p>
<ul><li>Arquivo bom paga o mínimo</li><li>Remanejamos e sua folha encolhe</li>
<li>Rígido: caneca, garrafa, case</li></ul>
<span class="go">Enviar minha folha de UV ›</span>
<div class="ec-top"><span class="ec-kind">ADESIVO UV · 28,5 CM</span></div>
<h3>Arquivo por metro · UV</h3>
<p>Sua folha de UV montada. Para rígidos: caneca, garrafa, case. Não precisa de prensa.</p>
<div class="ec-pe">
<div class="ec-preco"><span>de</span><b data-de="uvfile">—</b><span>a <em data-ate="uvfile">—</em> /m conforme a nota da arte</span></div>
<span class="ec-go">Enviar folha de UV</span>
</div>
</button>
<button class="ec u" data-modo="uv">
<div class="top"><h3>Artes avulsas · UV</h3><span class="p"><i>tabela</i>R$ 99,90<small>/m</small></span></div>
<div class="chama2">Quer pagar <b>R$ 83,90</b>? Arte em 300 DPI, em arquivo que a gente confere.</div>
<div class="in">
<span class="lg">Adesivo UV · 28,5 cm</span>
<p>Cada arte num arquivo. Montamos a folha de UV com o melhor encaixe.</p>
<ul><li>Sugerimos a largura que rende mais</li><li>Montagem inclusa</li>
<li>Não precisa de prensa</li></ul>
<span class="go">Enviar minhas artes de UV ›</span>
<div class="ec-top"><span class="ec-kind">ADESIVO UV · 28,5 CM</span></div>
<h3>Artes avulsas · UV</h3>
<p>Cada arte num arquivo. Montamos a folha de UV com o melhor encaixe. Montagem inclusa no preço.</p>
<div class="ec-pe">
<div class="ec-preco"><span>de</span><b data-de="uv">—</b><span>a <em data-ate="uv">—</em> /m conforme a nota da arte</span></div>
<span class="ec-go">Enviar artes de UV</span>
</div>
</button>
</div>
@@ -992,33 +924,33 @@ footer a:hover{color:var(--laranja2)}
<!-- painel focado -->
<div class="foco" id="foco">
<button class="voltar" id="bVoltar"><span>‹</span>Trocar de produto</button>
<button class="voltar" id="bVoltar"><span>‹</span>Todos os produtos</button>
<div class="focoGrid">
<div class="cat" id="catFoco">
<div class="cabfoco" style="padding:20px 22px 14px;margin:0">
<div><h3 id="fTit">—</h3><span class="lg" id="fLg" style="display:block"></span></div>
<span class="pr2" id="fPreco"></span>
<p id="fSub"></p>
</div>
<div class="in" style="padding:0 22px 22px">
<div class="foco2" id="foco2">
<div class="fila">
<div class="trocouCam" id="trocouCam" style="display:none"></div>
<div class="caminhos" id="tipoEnvio" hidden>
<div class="caminhos tipoEnvio" id="tipoEnvio" hidden>
<button type="button" class="cam on" data-tipo="folha">
<span class="mk2"></span>
<div><b>Já é uma folha montada</b>
<div><b>Folha já 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>
<div><b>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>
<div class="in" style="padding:0 22px 22px">
<div class="foco2" id="foco2">
<div class="fila">
<div class="trocouCam" id="trocouCam" style="display:none"></div>
<div class="trocouCam" id="avisoTipo" style="display:none"></div>
<div class="caminhos" id="caminhos">
<button class="cam on" data-cam="auto">
@@ -1044,7 +976,71 @@ footer a:hover{color:var(--laranja2)}
<div id="lista"></div>
<div class="resumoA" id="rA" style="display:none"></div>
<input type="file" id="inp" hidden>
<div class="aoVivo" id="aoVivo"></div>
<aside class="req" id="req">
<h3 class="reqcab">Dicas sobre a arte</h3>
<details>
<summary>Como pagar o menor metro</summary>
<ul>
<li class="s"><b>300 DPI no tamanho que vai imprimir.</b> É a nota máxima
e o menor preço da tabela</li>
<li class="s"><b>O tamanho manda mais que o arquivo.</b> A mesma arte passa
em 20 cm e não passa em 40. Reduzir um pouco costuma pular uma faixa
de preço inteira</li>
<li class="s"><b>Larguras parecidas rendem mais.</b> Se as suas artes têm
medidas próximas, cabem mais por fileira e a folha encolhe</li>
</ul>
<p class="obs2">Resolução é a única coisa que mexe no preço — porque é a
única que a gente não consegue melhorar por você.</p>
</details>
<details>
<summary>O que arrumamos sem cobrar</summary>
<ul>
<li class="s"><b>Artes coladas.</b> Separamos com 5 mm, sem você refazer nada</li>
<li class="s"><b>Encaixe mal aproveitado.</b> Remanejamos e a sua folha
encolhe — você paga menos metro do que enviou</li>
<li class="s"><b>Peça passando da largura.</b> Remanejamos em vez de cortar</li>
<li class="s"><b>Margem.</b> Não precisa deixar. Só respeitar a largura do filme</li>
</ul>
</details>
<details>
<summary>Manda em PNG e sai na hora</summary>
<ul>
<li class="s"><b>PNG ou JPG</b> com fundo transparente entram direto, sem passar
por ninguém, e não têm limite de comprimento. Só que a gente lê a resolução
<b>do arquivo</b>: se a arte foi ampliada antes de exportar, isso não aparece
para nós — e sai serrilhado no filme do mesmo jeito</li>
<li class="s"><b>PDF é o que conferimos melhor.</b> Nele medimos a resolução de
cada imagem dentro da folha, uma por uma. Se a sua arte é boa, é onde a nota
aparece com mais precisão</li>
<li class="a"><b>PDF grande.</b> Sem UserUnit, a página passa do limite de 508 cm
do formato; divida a folha ou mande PNG. Envie um PDF de uma página por folha.</li>
<li class="a"><b>TIFF, PSD, AI e CDR</b> a gente aceita, mas ninguém consegue
conferir sozinho: alguém abre na mão, sai mais devagar e o metro vai pela
tabela, sem desconto de nota</li>
<li class="a">Em <b>artes avulsas</b> só PNG e JPG: a montagem precisa
enxergar a sua arte para encaixar pela forma</li>
</ul>
</details>
<details>
<summary>O que só você resolve</summary>
<ul>
<li class="n"><b>Fundo branco ou colorido.</b> Precisa ser transparente de
verdade — branco parecendo transparente sai branco no filme</li>
<li class="n"><b>Pontinho de recorte.</b> A impressora enxerga qualquer
resíduo e imprime. Não existe pequeno demais</li>
<li class="n"><b>Cópias sobrepostas.</b> Duas iguais com 1 mm de diferença
saem duplicadas</li>
<li class="n"><b>Menos de 96 DPI.</b> Abaixo disso não imprimimos: sai
serrilhado a ponto de não valer o filme</li>
<li class="n"><b>Degradê de transparência</b>, comum em arte de IA. O branco
por baixo sai irregular e sai assim mesmo</li>
</ul>
</details>
</aside>
</div>
<aside class="mont" id="mont">
<div class="montcab"><b id="montcabTit">Montagem ao vivo</b>
@@ -1054,79 +1050,32 @@ footer a:hover{color:var(--laranja2)}
<span id="vMt">—</span></div>
<div id="vArea"></div>
<div id="vUso"></div>
<p class="montpe">Prévia aproximada. No servidor o encaixe é refeito do zero —
o resultado sai igual ou melhor, nunca pior.</p>
<p class="montpe">Prévia. O encaixe final é refeito no servidor e nunca sai pior.</p>
<div class="compra" id="compra">
<div class="aoVivo" id="aoVivo"></div>
<div id="compraVals" hidden>
<div class="cNota"><span>Nota da arte</span><i><i id="cNotaBar"></i></i><b id="cNota">—</b></div>
<div class="cLin"><span>Preço do metro</span><span><s id="cTabela"></s><b id="cMetro">—</b></span></div>
<div class="cLin"><span>Metragem cobrada</span><b id="cMetragem">—</b></div>
<p class="cObs" id="cObs"></p>
<div class="cTot"><span>Total</span><b id="cTotal">—</b></div>
<div id="qmsg" style="display:none"></div>
<div class="ciente" id="ciente">
<h4 id="cienteTit">Ressalva de resolução</h4>
<div id="cienteTxt"></div>
<label><input type="checkbox" id="cienteOk">
<span>Li e entendi. Quero imprimir assim mesmo.</span></label>
</div>
</div>
<button type="button" class="cAdd" id="bAdd" disabled>Adicionar ao carrinho</button>
<p class="cPe">Revisão de 5 pontos e reencaixe grátis</p>
</div>
</aside>
</div>
</div>
</div>
</div>
<aside class="req" id="req">
<h3 class="reqcab">O que devo saber<br>sobre a arte</h3>
<details open>
<summary>Como pagar o menor metro</summary>
<ul>
<li class="s"><b>300 DPI no tamanho que vai imprimir.</b> É a nota máxima
e o menor preço da tabela</li>
<li class="s"><b>O tamanho manda mais que o arquivo.</b> A mesma arte passa
em 20 cm e não passa em 40. Reduzir um pouco costuma pular uma faixa
de preço inteira</li>
<li class="s"><b>Larguras parecidas rendem mais.</b> Se as suas artes têm
medidas próximas, cabem mais por fileira e a folha encolhe</li>
</ul>
<p class="obs2">Resolução é a única coisa que mexe no preço — porque é a
única que a gente não consegue melhorar por você.</p>
</details>
<details>
<summary>O que arrumamos sem cobrar</summary>
<ul>
<li class="s"><b>Artes coladas.</b> Separamos com 5 mm, sem você refazer nada</li>
<li class="s"><b>Encaixe mal aproveitado.</b> Remanejamos e a sua folha
encolhe — você paga menos metro do que enviou</li>
<li class="s"><b>Peça passando da largura.</b> Remanejamos em vez de cortar</li>
<li class="s"><b>Margem.</b> Não precisa deixar. Só respeitar a largura do filme</li>
</ul>
</details>
<details>
<summary>Manda em PNG e sai na hora</summary>
<ul>
<li class="s"><b>PNG ou JPG</b> com fundo transparente entram direto, sem passar
por ninguém, e não têm limite de comprimento. Só que a gente lê a resolução
<b>do arquivo</b>: se a arte foi ampliada antes de exportar, isso não aparece
para nós — e sai serrilhado no filme do mesmo jeito</li>
<li class="s"><b>PDF é o que conferimos melhor.</b> Nele medimos a resolução de
cada imagem dentro da folha, uma por uma. Se a sua arte é boa, é onde a nota
aparece com mais precisão</li>
<li class="a"><b>PDF grande.</b> Sem UserUnit, a página passa do limite de 508 cm
do formato; divida a folha ou mande PNG. Envie um PDF de uma página por folha.</li>
<li class="a"><b>TIFF, PSD, AI e CDR</b> a gente aceita, mas ninguém consegue
conferir sozinho: alguém abre na mão, sai mais devagar e o metro vai pela
tabela, sem desconto de nota</li>
<li class="a">Em <b>artes avulsas</b> só PNG e JPG: a montagem precisa
enxergar a sua arte para encaixar pela forma</li>
</ul>
</details>
<details>
<summary>O que só você resolve</summary>
<ul>
<li class="n"><b>Fundo branco ou colorido.</b> Precisa ser transparente de
verdade — branco parecendo transparente sai branco no filme</li>
<li class="n"><b>Pontinho de recorte.</b> A impressora enxerga qualquer
resíduo e imprime. Não existe pequeno demais</li>
<li class="n"><b>Cópias sobrepostas.</b> Duas iguais com 1 mm de diferença
saem duplicadas</li>
<li class="n"><b>Menos de 96 DPI.</b> Abaixo disso não imprimimos: sai
serrilhado a ponto de não valer o filme</li>
<li class="n"><b>Degradê de transparência</b>, comum em arte de IA. O branco
por baixo sai irregular e sai assim mesmo</li>
</ul>
</details>
</aside>
</div>
</div>
@@ -1140,16 +1089,9 @@ footer a:hover{color:var(--laranja2)}
</div>
<div class="qbar"><i id="qbi" style="width:0%"></i></div>
<div class="qitens" id="qitens"></div>
<div class="ciente" id="ciente">
<h4 id="cienteTit">Ressalva de resolução</h4>
<div id="cienteTxt"></div>
<label><input type="checkbox" id="cienteOk">
<span>Li e entendi. Quero imprimir assim mesmo.</span></label>
</div>
<div class="qbts">
<button class="qb chama" id="qOk">Seguir para o pedido</button>
</div>
<div id="qmsg" style="margin-top:12px;font-size:12.5px;display:none"></div>
</div>
<!-- prévia da montagem -->
@@ -1166,7 +1108,7 @@ footer a:hover{color:var(--laranja2)}
<div class="pinfo" id="pInfo"></div>
</div>
<div class="pcta">
<button class="pb p" id="pOk">Está certo, fechar o pedido</button>
<button class="pb p" id="pOk">Adicionar ao carrinho</button>
<button class="pb" id="pMais">Adicionar mais artes</button>
<button class="pb" id="pVolta">Trocar o arquivo</button>
</div>
@@ -1179,7 +1121,8 @@ footer a:hover{color:var(--laranja2)}
<h3>Seu pedido</h3>
<div class="cxE">
<h4>Itens</h4>
<div class="itensCab"><h4>Itens</h4>
<button type="button" class="esvaziar" id="bEsvaziar">Esvaziar carrinho</button></div>
<div id="itens"></div>
<div id="atual">
<div class="rot" style="margin-top:12px">Item que você está montando</div>
@@ -1260,7 +1203,7 @@ footer a:hover{color:var(--laranja2)}
<div class="tot"><span>Total</span><b id="carrTot">R$ 0,00</b></div>
<div class="cbts">
<button id="bPagar">Ir para o pagamento</button>
<button id="bMais" class="sec">Adicionar outro produto</button>
<button id="bMais" class="sec">Continuar comprando</button>
</div>
<p class="obs">A nota fiscal sai no CNPJ informado. O arquivo fica guardado por 30 dias;
depois disso, um novo pedido precisa do arquivo de novo. O histórico do pedido
@@ -1274,101 +1217,47 @@ footer a:hover{color:var(--laranja2)}
<p id="checkoutStatus" class="avisoE" role="status" aria-live="polite"></p>
<div id="checkoutActions"></div>
</div>
</div></section>
<!-- INFORMAÇÃO EM CARROSSEL -->
<section id="info"><div class="w">
<div class="et">Como funciona</div>
<h2>Tudo o que você precisa saber, <em>em cinco cartões</em></h2>
<div class="carro">
<div class="trilho" id="trilho">
<div class="slide">
<div class="stop">
<div class="sinfo"><span class="et2">Preço</span><h3>Arte melhor,<br>metro mais barato</h3></div>
<div class="selo"><i></i><b>−25<em>%</em></b><span>no metro</span></div>
</div>
<p>Vale nos quatro produtos. Preço da folha que você monta:</p>
<table>
<tr><td class="cab">nota</td><td class="cab">têxtil</td><td class="cab">UV</td></tr>
<tr><td>90–100</td><td>R$ 14,90</td><td>R$ 69,90</td></tr>
<tr><td>75–89</td><td>R$ 16,20</td><td>R$ 73,90</td></tr>
<tr><td>60–74</td><td>R$ 17,50</td><td>R$ 77,90</td></tr>
<tr><td>40–59</td><td>R$ 18,70</td><td>R$ 81,90</td></tr>
<tr><td>abaixo de 40</td><td>R$ 19,90</td><td>R$ 85,90</td></tr>
</table>
<div class="dest">Se a montagem for nossa, some <b>R$ 10,00</b> no têxtil e
<b>R$ 14,00</b> no UV — mesma escada, em qualquer faixa</div>
</div>
<div class="slide">
<div class="stop">
<div class="sinfo"><span class="et2">Largura</span><h3>O dobro do<br>filme padrão</h3></div>
<div class="selo"><i></i><b>57<em>cm</em></b><span>de filme</span></div>
</div>
<p>A maioria trabalha com 28,5 cm. Na nossa largura cabe o dobro de arte
por metro corrido — e o metro sai mais barato por peça.</p>
<ul>
<li>Duas artes de 28 cm lado a lado</li>
<li>Menos emenda, menos corte</li>
<li>DTF UV também disponível, em 28,5 cm</li>
</ul>
</div>
<div class="slide">
<div class="stop">
<div class="sinfo"><span class="et2">Encaixe</span><h3>Reencaixamos<br>sua folha de graça</h3></div>
<div class="selo"><i></i><b>grátis</b><span>reencaixe</span></div>
</div>
<p>Se a sua montagem deixa espaço sobrando, refazemos o encaixe com o mesmo
motor que usamos nas nossas montagens.</p>
<ul>
<li>Artes que se tocam, separadas com 5 mm</li>
<li>Giramos as peças para aproveitar a largura</li>
<li>A folha encolhe e a sua conta diminui</li>
</ul>
<div class="dest">Refazemos o encaixe <b>antes de imprimir</b>, sem cobrar nada</div>
</div>
<div class="slide">
<div class="stop">
<div class="sinfo"><span class="et2">Cobrança</span><h3>Você paga<br>o que usa</h3></div>
<div class="selo"><i></i><b>10<em>cm</em></b><span>de cada vez</span></div>
</div>
<p>Arredondamos a cada 10 cm. Uma folha de 2,75 m custa 2,80 m — quem cobra
metro cheio cobraria 3.</p>
<ul>
<li>Pedido mínimo de 1 metro</li>
<li>A identificação do pedido é por nossa conta</li>
<li>Sem taxa de arquivo, sem taxa de revisão</li>
</ul>
</div>
<div class="slide">
<div class="stop">
<div class="sinfo"><span class="et2">Sem burocracia</span><h3>Conta só na<br>hora de pagar</h3></div>
<div class="selo"><i></i><b>0</b><span>conta e senha</span></div>
</div>
<p>Sobe a arte e vê o preço sem conta nenhuma. A conta nasce no pagamento, junto com o CNPJ da nota — e o próximo pedido já vem com suas artes anteriores. A revisão de 5 pontos vem junto —
há quem cobre R$ 19,90 por pedido só por isso.</p>
<ul>
<li>Sem cadastro para subir e ver o preço</li>
<li>Envio em até 24 horas</li>
<li>WhatsApp a cada etapa do pedido</li>
<li>Arquivo guardado 30 dias · histórico do pedido na conta</li>
</ul>
</div>
</div>
<div class="navc">
<button id="cAnt" aria-label="Anterior">‹</button>
<button id="cPro" aria-label="Próximo">›</button>
<div class="pontos" id="cPontos"></div>
</div>
<div class="desfazer" id="desfazer" role="status" hidden>
<span id="desfazerTxt"></span><button type="button" id="desfazerBtn">Desfazer</button>
</div>
<div class="carrVazio" id="carrVazio" hidden>
<h2>Seu carrinho está vazio</h2>
<p>Escolha um produto, envie a arte e veja o preço na hora.</p>
<a href="/#envio" class="btn-laranja">Escolher produto</a>
</div>
</div></section>
<!-- PREÇO E VANTAGENS -->
<section id="info" class="info2"><div class="w info2-grid">
<div class="info2-preco">
<div class="et">Preço</div>
<h2>Arte melhor, metro mais barato</h2>
<p>A nota aparece na tela antes de você pagar. Preço da folha que você monta:</p>
<table>
<thead><tr><th scope="col">Nota da arte</th><th scope="col">Têxtil 57 cm</th><th scope="col">UV 28,5 cm</th></tr></thead>
<tbody>
<tr><td>90 a 100</td><td class="melhor">R$ 14,90</td><td class="melhor">R$ 69,90</td></tr>
<tr><td>75 a 89</td><td>R$ 16,20</td><td>R$ 73,90</td></tr>
<tr><td>60 a 74</td><td>R$ 17,50</td><td>R$ 77,90</td></tr>
<tr><td>40 a 59</td><td>R$ 18,70</td><td>R$ 81,90</td></tr>
<tr><td>abaixo de 40</td><td>R$ 19,90</td><td>R$ 85,90</td></tr>
</tbody>
</table>
<p class="nota2">Montagem das artes avulsas: + R$ 10,00 no têxtil e + R$ 14,00 no UV, em qualquer faixa.</p>
</div>
<div class="info2-vant">
<div><b class="n">57 cm</b><b>O dobro do filme padrão</b><span>Cabe o dobro de arte por metro corrido.</span></div>
<div><b class="n">10 cm</b><b>Você paga o que usa</b><span>Folha de 2,75 m custa 2,80 m, não 3. Pedido mínimo de 1 metro.</span></div>
<div><b class="n">Grátis</b><b>Reencaixe da folha</b><span>Sobrou espaço? Refazemos o encaixe e a conta diminui.</span></div>
<div><b class="n">5 pontos</b><b>Revisão sem custo</b><span>Erro pego antes de imprimir. Arquivo guardado por 30 dias.</span></div>
</div>
</div></section>
<div class="barraM" id="barraM" hidden>
<div><span id="barraMSub">Seu pedido</span><b id="barraMTot">R$ 0,00</b></div>
<a href="/carrinho" id="barraMBtn">Continuar</a>
</div>
<footer><div class="w">
<div class="frow">
<span class="fmarca"><img src="https://cdn.vnda.com.br/dropstar/2025/12/17/logo-header-15125700-16129080.png?v=1766009286" alt="Dropstar">DTF</span>
@@ -1404,10 +1293,12 @@ footer a:hover{color:var(--laranja2)}
<script src="/site-packing.js"></script>
<script src="/site-cart.js"></script>
<script src="/site-flow.js"></script>
<script src="/site-compra.js"></script>
<script src="/privacy.js"></script>
<script src="/upload.js"></script>
<script src="/checkout.js"></script>
<script src="/cart.js"></script>
<script src="/site-steps.js"></script>
</body>
</html>

203
web/kanban.css Normal file
View File

@@ -0,0 +1,203 @@
/* Kanban DTF. The previous look is kept in git tag ui-v1. */
:root{
--bg:#0F1113;--bar:#13161A;--col:#13161A;--card:#1A1D21;--card2:#23282D;--panel:#15181B;
--line:#262A2F;--line2:#2A2F35;--line3:#3A4047;
--tx:#ECE9E4;--tx2:#D5D2CC;--muted:#A3A9B0;--faint:#8B929A;
--accent:#FF8B2B;--accent-tx:#FFB575;--ok:#6FD19C;--warn:#F2B33D;--bad:#F4968B;--bad-line:#5A3530;
--rec:#2BB6D9;--tra:#E0508F;--fil:#E9C23A;--imp:#D9D6D0;--cor:#F06A5B;--fin:#3FB97A;
--mono:Manrope,system-ui,sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--tx)}
body{font:14px/1.45 Manrope,system-ui,sans-serif;font-variant-numeric:tabular-nums}
button,input,textarea{font:inherit;color:inherit}
button{cursor:pointer}
button:disabled{opacity:.5;cursor:default}
a{color:var(--accent-tx)}
[hidden]{display:none!important}
.mono{font-family:var(--mono)}
.muted{color:var(--muted)}
.faint{color:var(--faint)}
.btn{height:36px;padding:0 12px;border-radius:8px;border:1px solid var(--line2);background:var(--card2);color:var(--tx);font-size:13px;font-weight:600;white-space:nowrap}
.btn:hover{border-color:var(--line3)}
.btn.ghost{background:transparent;color:var(--muted)}
.btn.primary{height:44px;border:0;background:var(--accent);color:#0F1113;font-weight:800;font-size:14px;padding:0 18px}
.btn.primary:hover{filter:brightness(1.08)}
.btn.danger{height:44px;background:transparent;border-color:var(--bad-line);color:var(--bad);font-weight:700;font-size:14px}
.btn.warn{border:0;background:var(--warn);color:#0F1113;font-weight:800}
.chip{height:34px;padding:0 12px;border-radius:8px;border:1px solid var(--line2);background:transparent;color:var(--muted);font-size:13px;font-weight:600}
.chip[aria-pressed=true]{border-color:var(--accent);background:rgba(255,139,43,.12);color:var(--accent-tx)}
.badge{font-size:12px;font-weight:700;border-radius:999px;padding:1px 8px;background:var(--accent);color:#0F1113}
.badge.warn{background:var(--warn);color:#0F1113}
.badge:empty{display:none}
.eyebrow{font-size:12px;color:var(--faint);font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0 0 8px}
input[type=text],input[type=email],input[type=password],input[type=number],input:not([type]),textarea{height:40px;padding:0 12px;border-radius:8px;border:1px solid var(--line3);background:var(--bg);color:var(--tx)}
textarea{height:auto;padding:10px 12px;min-height:72px;width:100%;resize:vertical}
input[type=checkbox]{width:18px;height:18px;accent-color:var(--accent)}
input[type=file]{font-size:13px;color:var(--muted);max-width:100%}
.check{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--tx2)}
.ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}
/* Sign-in */
.login{max-width:380px;margin:12vh auto 0;padding:32px;border:1px solid var(--line2);border-radius:14px;background:var(--panel);display:flex;flex-direction:column;gap:14px}
.login label{display:flex;flex-direction:column;gap:6px;font-size:13px;font-weight:600;color:var(--muted)}
.login .hint{font-size:12px;color:var(--faint);margin:0}
/* Top bar */
.top{height:64px;padding:0 24px;display:flex;align-items:center;gap:22px;border-bottom:1px solid var(--line);background:var(--bar);position:sticky;top:0;z-index:5}
.brand{display:flex;align-items:center;gap:10px;font-weight:800;font-size:16px;letter-spacing:-.01em;white-space:nowrap}
.brand i{width:28px;height:28px;border-radius:7px;background:var(--accent);color:#0F1113;font-style:normal;font-weight:800;font-size:15px;display:flex;align-items:center;justify-content:center}
.tabs{display:flex;gap:4px;align-self:stretch}
.tabs button{display:flex;align-items:center;gap:8px;padding:0 14px;border:0;border-bottom:2px solid transparent;background:none;color:var(--muted);font-weight:600;font-size:14px}
.tabs button[aria-selected=true]{color:var(--tx);font-weight:700;border-bottom-color:var(--accent)}
.tabs .count{font-family:var(--mono);font-size:12px;color:var(--muted)}
.search{display:flex;align-items:center;gap:8px;width:300px;height:38px;padding:0 12px;border:1px solid var(--line2);border-radius:9px;background:var(--card);color:var(--faint);margin-left:auto}
.search input{flex:1;min-width:0;border:0;background:transparent;height:auto;padding:0;outline:none}
.tinydot{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted);white-space:nowrap}
.tinydot::before{content:"";width:8px;height:8px;border-radius:999px;background:var(--dot,#5B6168)}
.tinydot:empty{display:none}
.who{font-size:13px;color:var(--muted);white-space:nowrap;max-width:220px;overflow:hidden;text-overflow:ellipsis}
#status{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:20;margin:0;max-width:min(640px,calc(100vw - 32px));padding:10px 16px;border-radius:10px;border:1px solid var(--line3);background:#1E2226;color:var(--tx);font-size:13px;box-shadow:0 10px 30px rgba(0,0,0,.45)}
#status:empty,#login-status:empty{display:none}
#status.error{border-color:var(--bad-line);color:var(--bad)}
#login-status.error{color:var(--bad)}
/* Board */
main{padding:0 24px 24px}
.bar{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:18px 0 12px}
.bar h1,.split h1,.page h1{margin:0;font-size:22px;font-weight:800;letter-spacing:-.02em}
.chips{display:flex;gap:8px;flex-wrap:wrap}
.bar .chips{margin-left:auto}
.kan{display:grid;grid-template-columns:repeat(6,minmax(220px,1fr));gap:12px;overflow-x:auto;padding-bottom:8px;align-items:start}
.col{display:flex;flex-direction:column;gap:10px;background:var(--col);border:1px solid #22262B;border-radius:12px;padding:12px;min-height:320px}
.col.allowed{border-color:rgba(255,139,43,.45);background:#17140F}
.col.target{border-color:var(--accent)}
.col-head{display:flex;flex-direction:column;gap:6px;padding:2px 2px 8px;border-bottom:1px solid #22262B}
.col-head div{display:flex;align-items:center;gap:8px}
.col-head h2{margin:0;font-size:14px;font-weight:700}
.col-head .swatch{width:10px;height:10px;border-radius:3px;background:var(--c)}
.col-head .n{margin-left:auto;font-family:var(--mono);font-size:12px;color:var(--muted)}
.col-head small{font-size:12px;color:var(--faint)}
.col .empty{font-size:12px;color:var(--faint);padding:8px 2px}
.card{text-align:left;display:flex;flex-direction:column;gap:8px;padding:12px;border-radius:10px;border:1px solid var(--line2);background:var(--card);width:100%}
.card:hover,.card.open{border-color:var(--line3);background:#1E2226}
.card .row{display:flex;align-items:baseline;gap:8px;width:100%}
.card .num{font-size:14px;font-weight:800}
.card .age{margin-left:auto;font-size:12px;color:var(--faint)}
.card .cust{font-size:13px;font-weight:600;color:var(--tx);width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tags{display:flex;flex-wrap:wrap;gap:6px}
.tag{font-size:12px;padding:3px 8px;border-radius:6px;background:var(--card2);color:var(--tx2)}
.card .foot{display:flex;align-items:center;gap:6px;font-size:12px}
.card .foot .where{margin-left:auto;color:var(--muted)}
.late{font-size:12px;color:#F06A5B;font-weight:600}
/* Order panel */
.drawer{position:fixed;inset:0;z-index:10;display:flex;justify-content:flex-end}
.scrim{position:absolute;inset:0;background:rgba(8,9,11,.6)}
.panel{position:relative;width:min(600px,100vw);height:100%;background:var(--panel);border-left:1px solid var(--line2);display:flex;flex-direction:column}
.panel-head{padding:20px 24px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:14px}
.panel-head .row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.panel-head .num{font-size:22px;font-weight:800}
.stage{font-size:12px;font-weight:700;padding:4px 10px;border-radius:999px;background:color-mix(in srgb,var(--c) 18%,transparent);color:var(--c)}
.close{margin-left:auto;width:40px;height:40px;border-radius:8px;border:1px solid var(--line2);background:transparent;color:var(--muted);display:flex;align-items:center;justify-content:center}
.steps{margin:0;padding:0;list-style:none;display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:6px}
.steps li{display:flex;flex-direction:column;gap:6px;font-size:11px;color:var(--faint)}
.steps li::before{content:"";height:4px;border-radius:4px;background:var(--line2)}
.steps li.done{color:var(--muted)}.steps li.done::before{background:var(--c)}
.steps li.now{color:var(--tx);font-weight:700}.steps li.now::before{background:var(--c)}
.moves{display:flex;gap:10px;flex-wrap:wrap}
.moves .primary{flex:1}
.reason{display:flex;flex-direction:column;gap:8px}
.panel-body{flex:1;overflow:auto;padding:20px 24px 32px;display:flex;flex-direction:column;gap:24px}
.facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
.facts b{display:block;font-size:14px}
.facts span{font-size:13px;color:var(--muted);overflow-wrap:anywhere}
.item{display:flex;gap:14px;padding:14px;border:1px solid var(--line2);border-radius:12px;background:var(--card)}
.item.attention{border-color:#4A3A1E;background:#1C1A15}
.item .preview{width:96px;flex-shrink:0;border-radius:8px;background:var(--card2);display:flex;align-items:flex-start;justify-content:center;padding:6px}
.item .preview svg{width:100%;height:auto;display:block}
.item .body{flex:1;min-width:0;display:flex;flex-direction:column;gap:8px}
.item .title{display:flex;align-items:baseline;gap:8px}
.item .title b{font-size:14px}
.item .title .mono{margin-left:auto;font-size:13px}
.item .line{font-size:12px;color:var(--muted)}
.item .file{display:flex;align-items:center;gap:8px;font-size:13px}
.buttons{display:flex;gap:8px;flex-wrap:wrap}
.finals{display:flex;flex-direction:column;gap:12px}
.finals .rev{font-size:13px;display:flex;flex-direction:column;gap:4px;padding:10px 12px;border:1px solid var(--line2);border-radius:10px}
.finals form{display:flex;flex-direction:column;gap:12px;padding:14px;border:1px solid var(--line2);border-radius:12px}
.finals .slot{display:flex;flex-direction:column;gap:6px;font-size:13px;font-weight:600}
.timeline{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:10px}
.timeline li{display:flex;gap:12px;font-size:13px}
.timeline li::before{content:"";width:8px;height:8px;margin-top:6px;border-radius:999px;background:var(--c,var(--muted));flex-shrink:0}
.timeline small{display:block;color:var(--faint);font-size:12px}
/* Quotes */
#tab-quotes{padding-top:18px}
.split{display:flex;gap:0;border:1px solid var(--line);border-radius:12px;overflow:hidden;min-height:calc(100vh - 140px)}
.split .list{width:460px;flex-shrink:0;border-right:1px solid var(--line);padding:16px;display:flex;flex-direction:column;gap:12px;background:#111316}
.qitem{text-align:left;display:flex;justify-content:space-between;gap:12px;padding:14px;border-radius:10px;border:1px solid var(--line2);background:var(--card);width:100%}
.qitem[aria-current=true]{border-color:var(--accent);background:#1F1A15}
.qitem b{display:block;font-size:14px;overflow-wrap:anywhere}
.qitem span{font-size:12px;color:var(--muted)}
.qitem .right{text-align:right;flex-shrink:0}
.qitem .flag{display:block;color:var(--warn);font-weight:600;margin-top:4px}
.pane{flex:1;min-width:0;display:flex;flex-direction:column}
.pane .head{padding:18px 24px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;flex-wrap:wrap}
.pane .head b{font-size:18px;overflow-wrap:anywhere}
.pane .content{flex:1;padding:20px 24px;display:flex;flex-direction:column;gap:16px}
.pane .fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}
.pane .fields label{display:flex;flex-direction:column;gap:6px;font-size:12px;font-weight:600;color:var(--muted)}
.pane .fields input[readonly]{color:var(--faint);background:var(--panel);border-color:var(--line2)}
.pane .foot{padding:16px 24px;border-top:1px solid var(--line);background:var(--bar);display:flex;align-items:center;gap:16px;flex-wrap:wrap}
.pane .foot .total{margin-left:auto;font-family:var(--mono);color:var(--muted)}
.pane .foot .total b{color:var(--tx)}
.pane .note{padding:24px;color:var(--muted)}
/* Payments and integrations */
.page{display:flex;flex-direction:column;gap:22px;padding-top:18px}
.page h2{margin:0 0 10px;font-size:15px;font-weight:800}
.issue{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:16px 18px;border-radius:12px;border:1px solid #4A3A1E;background:#1C1A15;margin-bottom:8px}
.issue .text{flex:1;min-width:260px;display:flex;flex-direction:column;gap:4px}
.issue .text b{font-size:14px}
.issue .text span{font-size:13px;color:var(--muted);overflow-wrap:anywhere}
.issue form{display:flex;gap:8px;align-items:center}
.issue.done{border-color:var(--line2);background:var(--card)}
.pager{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:14px 4px;border-top:1px solid var(--line);margin-top:8px;font-size:13px;color:var(--muted)}
.pager-size{display:flex;align-items:center;gap:8px}
.pager-size select{height:34px;padding:0 8px;border-radius:8px;border:1px solid var(--line3);background:var(--bg);color:var(--tx)}
.pager-range{margin-left:auto}
.pager-nav{display:flex;align-items:center;gap:6px}
.pager .pg{width:34px;height:34px;border-radius:8px;border:1px solid var(--line2);background:transparent;color:var(--tx);font-size:16px;line-height:1}
.pager .pg:disabled{color:var(--faint);opacity:.45}
.pager-page{display:flex;align-items:center;gap:6px;margin:0 4px}
.pager-page input{width:56px;height:34px;text-align:center;padding:0 4px}
.pager.compact{gap:10px;padding:12px 0 0}
.pager.compact .pager-range{margin-left:0}
.pager.compact .pager-nav{margin-left:auto}
.filters{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end;margin-bottom:12px}
.field{display:flex;flex-direction:column;gap:6px;font-size:12px;font-weight:600;color:var(--muted)}
.field select,.field input{height:38px;min-width:160px;padding:0 10px;border-radius:8px;border:1px solid var(--line3);background:var(--bg);color:var(--tx)}
.field input{min-width:110px;width:110px}
.issue input{width:320px}
.grid4{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px}
.integration{display:flex;flex-direction:column;gap:10px;padding:16px;border-radius:12px;border:1px solid var(--line2);background:var(--card)}
.integration .head{display:flex;align-items:center;gap:8px}
.integration .head b{font-size:15px}
.integration .head .dot{width:9px;height:9px;border-radius:999px;background:var(--dot,#5B6168)}
.integration .head em{margin-left:auto;font-style:normal;font-size:12px;font-weight:700;color:var(--dotx,var(--muted))}
.integration p{margin:0;font-size:13px;color:var(--muted)}
.integration .buttons{margin-top:auto}
table.log{width:100%;border-collapse:collapse;font-size:13px}
table.log th{text-align:left;color:var(--faint);font-weight:600;padding:8px 12px;border-bottom:1px solid var(--line)}
table.log td{padding:10px 12px;border-bottom:1px solid #1E2226;vertical-align:top}
.good{color:var(--ok);font-weight:600}.retry{color:var(--warn);font-weight:600}
@media (max-width:900px){
.top{flex-wrap:wrap;height:auto;padding:10px 16px;gap:10px}
.search{order:5;width:100%;margin-left:0}
main{padding:0 16px 16px}
.split{flex-direction:column}
.split .list{width:auto;border-right:0;border-bottom:1px solid var(--line)}
.facts,.pane .fields{grid-template-columns:1fr}
}

View File

@@ -1,21 +1,70 @@
<!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}.print{margin-top:8px;padding-top:8px;border-top:1px solid var(--linha)}.print button{margin-left:6px}#tiny{font-size:12px;color:var(--fraco);margin-right:8px}#tiny button{margin-left:6px}#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 id="tiny"></span><button id="refresh">Atualizar</button><button id="logout">Sair</button></header>
<div class="aviso">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>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>
<p id="status" role="status"></p>
<section id="payments"></section><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?v=pdf-print-1"></script></body></html>
<title>Kanban DTF</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&amp;display=swap">
<link rel="stylesheet" href="/kanban.css?v=redesign-7">
</head><body>
<form id="login" class="login" hidden>
<div class="brand"><i aria-hidden="true">D</i>Kanban DTF</div>
<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 class="btn primary">Entrar</button>
<p id="login-status" class="hint" role="alert"></p>
</form>
<div id="app" hidden>
<header class="top">
<div class="brand"><i aria-hidden="true">D</i>Kanban DTF</div>
<nav class="tabs" aria-label="Seções">
<button type="button" data-tab="board" aria-selected="true">Produção <span class="count" id="count-board"></span></button>
<button type="button" data-tab="quotes" aria-selected="false">Cotações <span class="badge" id="badge-quotes"></span></button>
<button type="button" data-tab="payments" aria-selected="false">Pagamentos <span class="badge warn" id="badge-payments"></span></button>
<button type="button" data-tab="integrations" aria-selected="false">Integrações</button>
</nav>
<label class="search">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="M20 20l-3.5-3.5"></path></svg>
<input id="search" type="search" placeholder="Buscar pedido, cliente ou CNPJ" aria-label="Buscar pedido, cliente ou CNPJ">
</label>
<span id="tiny" class="tinydot"></span>
<span id="who" class="who"></span>
<button class="btn ghost" id="refresh" type="button">Atualizar</button>
<button class="btn ghost" id="logout" type="button">Sair</button>
</header>
<p id="status" role="status"></p>
<main>
<section id="tab-board">
<div class="bar"><h1>Produção</h1><span class="muted" id="board-summary"></span><div class="chips" id="filters"></div></div>
<div class="kan" id="kan"></div>
</section>
<section id="tab-quotes" hidden>
<div class="split">
<div class="list">
<h1>Cotações</h1>
<div class="chips" id="quote-filters"></div>
<div id="quote-list" style="display:flex;flex-direction:column;gap:8px"></div>
</div>
<div class="pane" id="reviews"></div>
</div>
</section>
<section id="tab-payments" class="page" hidden>
<h1>Pagamentos</h1>
<section id="payments"></section>
</section>
<section id="tab-integrations" class="page" hidden>
<h1>Integrações</h1>
<section><div class="grid4" id="integrations"></div></section>
<section><h2>Registro de envios</h2>
<div class="filters" id="event-filters"></div>
<table class="log"><thead><tr><th scope="col">Quando</th><th scope="col">Pedido</th><th scope="col">Destino</th><th scope="col">Evento</th><th scope="col">Resultado</th></tr></thead><tbody id="events"></tbody></table>
<div id="events-more"></div>
</section>
</section>
</main>
</div>
<div class="drawer" id="drawer" hidden>
<div class="scrim" id="scrim"></div>
<aside class="panel" id="panel" aria-label="Pedido"></aside>
</div>
<script src="/upload.js"></script><script src="/kanban.js?v=redesign-8"></script></body></html>

View File

@@ -1,249 +1,668 @@
/* Reuses the prototype palette, column names and drag/drop interaction; no machine controls. */
/* Kanban DTF: production board, quote review, payments and integrations.
Everything is built with textContent; nothing from an order or a customer is
ever parsed as HTML. The previous screen is in git tag ui-v1. */
const $ = id=>document.getElementById(id);
// Remove credentials saved by older local builds. Only HttpOnly sessions now.
sessionStorage.removeItem('dtf-operator');
let board;
let extraQuotes={pending:[],approved:[]};
let moreQuotes={pending:false,approved:false};
const STAGE_COLORS={rec:'var(--rec)',tra:'var(--tra)',fil:'var(--fil)',imp:'var(--imp)',cor:'var(--cor)',fin:'var(--fin)'};
const FLOW=['rec','tra','fil','imp','fin'];
const PRODUCT={file:'DTF Têxtil · folha montada',avulsa:'DTF Têxtil · artes avulsas',uvfile:'DTF UV · folha montada',uv:'DTF UV · artes avulsas'};
const SHORT={file:'Têxtil folha',avulsa:'Têxtil avulsa',uvfile:'UV folha',uv:'UV avulsa'};
const EVENTS={payment_approved:'Pagamento aprovado',production_started:'Produção iniciada',correction_needed:'Correção necessária',ready:'Pedido pronto'};
const LATE_HOURS=4;
// Payment outcomes are recorded by the API in English; operators read Portuguese.
function outcomeText(outcome){
const rules=[
[/^refused: paid (\S+) but quote total is (\d+)/,m=>'valor pago ('+(m[1]==='None'?'não informado':money(Number(m[1])))+') diferente do total da cotação ('+money(Number(m[2]))+')'],
[/^refused: quote expired/,()=>'a cotação venceu antes do pagamento'],
[/^refused: quote not found/,()=>'cotação não encontrada'],
[/^refused: reference is not a quote id/,()=>'a referência do pagamento não é uma cotação'],
[/^refused: quote was never reviewed/,()=>'a cotação ainda não tinha sido revisada'],
[/^refused: quote uses an obsolete/,()=>'a cotação usa uma montagem antiga'],
[/^attention: payment (\w+) for order (\d+)/,m=>'pagamento '+(m[1]==='refunded'?'estornado':'cancelado')+' depois do pedido #'+m[2]],
];
for(const [pattern,text] of rules){const m=outcome.match(pattern);if(m)return text(m);}
return outcome;
}
let board=null;
let tab='board', filter='all', search='', openOrder=null, openToken=0;
let quoteKind='pending', pickedQuote=null;
let olderFinished=[], finishedMore=null;
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;}
const metres=value=>Number(value).toLocaleString('pt-BR',{minimumFractionDigits:2,maximumFractionDigits:2})+' m';
const when=value=>new Date(value).toLocaleString('pt-BR',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});
function ago(value){
const minutes=Math.max(0,Math.round((Date.now()-new Date(value))/60000));
if(minutes<60)return 'há '+minutes+' min';
const hours=Math.round(minutes/60);
return hours<48?'há '+hours+' h':'há '+Math.round(hours/24)+' dias';
}
function node(tag,text,className){const e=document.createElement(tag);if(text!==undefined&&text!==null)e.textContent=text;if(className)e.className=className;return e;}
function button(text,fn,className='btn'){
const b=node('button',text,className);b.type='button';
b.onclick=async event=>{event.stopPropagation();b.disabled=true;try{await fn();}catch(e){say(e.message,true);}finally{b.disabled=false;}};
return b;
}
// Messages are notifications, not state: they clear themselves. Errors stay
// longer so they can be read; a caller can hold one for as long as it needs.
let sayTimer=null;
function say(text,error=false,ms){
clearTimeout(sayTimer);
for(const id of ['status','login-status']){$(id).textContent=text||'';$(id).classList.toggle('error',!!error);}
if(text)sayTimer=setTimeout(()=>say(''),ms??(error?8000:4000));
}
function icon(path){
const ns='http://www.w3.org/2000/svg';const svg=document.createElementNS(ns,'svg');
svg.setAttribute('width','14');svg.setAttribute('height','14');svg.setAttribute('viewBox','0 0 24 24');
svg.setAttribute('fill','none');svg.setAttribute('stroke','currentColor');svg.setAttribute('stroke-width','2.2');svg.setAttribute('aria-hidden','true');
for(const d of path){const p=document.createElementNS(ns,d.startsWith('circle')?'circle':'path');
if(d.startsWith('circle')){const [cx,cy,r]=d.split(' ').slice(1);p.setAttribute('cx',cx);p.setAttribute('cy',cy);p.setAttribute('r',r);}else p.setAttribute('d',d);svg.append(p);}
return svg;
}
const ICON_OK=['M5 12l4 4 10-10'], ICON_WARN=['M12 8v5M12 17h.01','circle 12 12 9'];
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();
}));
}
function productionLines(container,item){
if(!item.production){container.append(node('p','Instruções de produção ausentes; não produzir este item.','meta'));return;}
container.append(node('p','Especificação v'+item.production.version+' · qualidade '+item.quality_status+
(item.quality_status==='warning'?' · ressalva '+(item.quality_acknowledged?'aceita':'não aceita'):''),'meta'));
if(item.production.placements){
container.append(node('p',item.production.placements.length+' peças posicionadas em '+
item.production.film_width_cm+' × '+item.production.height_cm+' cm de filme','meta'));
const download=node('button','Baixar manifesto da montagem');
download.type='button';
download.onclick=()=>{
const url=URL.createObjectURL(new Blob([JSON.stringify(item.production,null,2)],{type:'application/json'}));
const link=node('a');link.href=url;
link.download='dtf-layout-'+item.production.sources[0].upload_id.slice(0,8)+'.json';
link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
};
container.append(download);
}
item.production.sources.forEach((source,index)=>container.append(node('p',
(index+1)+'. '+source.kind+' · '+source.copies+' × '+source.width_cm+' × '+source.length_cm+
' cm · giro '+source.rotation_degrees+'°'+(source.mirrored?' · espelhada':'')+
' · medida '+source.measurement+' · arquivo '+source.upload_id.slice(0,8),'meta')));
}
const PRINT_STATUS={pending:'na fila para gerar',rendering:'gerando…',ready:'pronto',
manual:'preparar à mão',failed:'falhou ao gerar'};
function download(uid){return 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();
};}
function printFiles(card,order){
const rows=order.print_files||[];
const box=node('div',undefined,'print');
box.append(node('b','Arquivo de impressão'));
order.snapshot.items.forEach((item,index)=>{
const row=rows.find(r=>r.item_index===index);
const line=node('p','Item '+(index+1)+' · '+(row?PRINT_STATUS[row.status]:'não gerado'),'meta');
if(row?.status==='ready'){
line.textContent+=' · '+row.detail.film_width_cm+' × '+row.detail.height_cm+' cm'+
(row.detail.min_dpi?' · menor resolução '+row.detail.min_dpi+' DPI':'')+
(row.detail.vector_sources?' · PDF vetorial':'');
line.append(action('Baixar PDF',download(row.upload_id)));
}
if(row?.status==='manual')line.append(node('span',' · '+row.detail.reason));
box.append(line);
});
const retry=order.snapshot.items.some((_,index)=>{const row=rows.find(r=>r.item_index===index);
return !row||row.status==='manual'||row.status==='failed';});
if(retry&&['rec','tra'].includes(order.state))
box.append(action('Gerar arquivos de impressão',async()=>{await api('/orders/'+order.id+'/print-files',{});await load();}));
card.append(box);
}
function paymentIssues(){
$('payments').replaceChildren();
if(!board.payment_issues?.length)return;
$('payments').append(node('h2','Pagamentos que precisam de atenção · '+board.payment_issues.length));
$('payments').append(node('p','Um pagamento chegou sem virar pedido (valor diferente, cotação vencida) ou foi '+
'estornado depois do pedido. Confira no painel do provedor, resolva com o cliente e registre o que foi feito.','meta'));
for(const issue of board.payment_issues){
const card=node('article',undefined,'review');
card.append(node('b',issue.provider+' · evento '+issue.event_id),
node('p',(issue.amount_cents==null?'valor não informado':money(issue.amount_cents))+' · cotação '+
(issue.reference||'—')+' · '+new Date(issue.received_at).toLocaleString('pt-BR'),'meta'),
node('p',issue.outcome));
card.append(action('Registrar resolução',async()=>{
const note=prompt('O que foi feito? (ex.: estornado no Mercado Pago em 25/09)');if(!note)return;
await api('/payment-events/'+issue.id+'/resolve',{note});await load();
}));
$('payments').append(card);
}
function manifest(production){
const url=URL.createObjectURL(new Blob([JSON.stringify(production,null,2)],{type:'application/json'}));
const link=node('a');link.href=url;link.download='dtf-layout-'+production.sources[0].upload_id.slice(0,8)+'.json';
link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
}
// ---- Loading and tabs ------------------------------------------------------
async function load(){
try{
board=await api('/board');
extraQuotes={pending:[],approved:[]};
moreQuotes={pending:board.pending_total>board.quotes.filter(q=>!q.approved).length,
approved:board.approved_total>board.quotes.filter(q=>!!q.approved).length};
$('login').hidden=true;
$('status').textContent='Atualizado às '+new Date().toLocaleTimeString()+tinyNotice;
const known=new Set(board.orders.map(o=>o.id));
board.orders.push(...olderFinished.filter(o=>!known.has(o.id)));
if(finishedMore===null)finishedMore=board.finished_total>board.finished_shown;
$('login').hidden=true;$('app').hidden=false;
say('Atualizado às '+new Date().toLocaleTimeString('pt-BR')+tinyNotice,false,tinyNotice?6000:2500);
render();
}catch(e){$('status').textContent=e.message;$('login').hidden=false;}
}catch(e){
if(e.status===401||e.status===503){$('app').hidden=true;$('login').hidden=false;$('drawer').hidden=true;}
// Not being signed in yet is the normal start, not an error.
if(e.status===401){say('');return;}
say(e.message,true);
}
}
async function loadMoreQuotes(kind){
const shown=[...board.quotes.filter(q=>kind==='pending'?!q.approved:!!q.approved),...extraQuotes[kind]];
const last=shown.at(-1);
const params=new URLSearchParams({kind,limit:'50'});
if(last){params.set('before_created_at',last.created_at);params.set('before_id',last.id);}
const page=await api('/quotes?'+params);
const known=new Set(shown.map(q=>q.id));
extraQuotes[kind].push(...page.quotes.filter(q=>!known.has(q.id)));
moreQuotes[kind]=page.has_more;
render();
function showTab(name){
tab=name;
for(const b of document.querySelectorAll('[data-tab]'))b.setAttribute('aria-selected',String(b.dataset.tab===name));
for(const id of ['board','quotes','payments','integrations'])$('tab-'+id).hidden=name!==id;
const lists={quotes:loadQuotes,payments:loadIssues,integrations:loadEvents};
if(board&&lists[name])lists[name]().catch(e=>say(e.message,true));
}
// The one-time authorisation of this system in the client's Tiny. Shown only
// when the Tiny application is configured on the server.
function tinyStatus(){
const box=$('tiny');box.replaceChildren();
const tiny=board.tiny||{};
if(!tiny.configured)return;
box.append(node('span',tiny.connected
? 'Tiny conectado'+(tiny.orders_enabled?'':' · envio de pedidos desligado')
: 'Tiny não conectado'));
box.append(action(tiny.connected?'Reconectar Tiny':'Conectar Tiny',async()=>{
const result=await api('/tiny/connect',{});location.href=result.url;
function render(){
$('who').textContent=board.operator||'';
$('count-board').textContent=String(board.orders.filter(o=>o.state!=='fin').length);
$('badge-quotes').textContent=board.pending_total?String(board.pending_total):'';
$('badge-payments').textContent=board.payment_issues_total?String(board.payment_issues_total):'';
tinyHeader();renderBoard();renderIntegrations();
// Lists live on their own pages of the API; refresh the one being looked at.
const lists={quotes:loadQuotes,payments:loadIssues,integrations:loadEvents};
if(lists[tab])lists[tab]().catch(e=>say(e.message,true));
if(openOrder){const order=board.orders.find(o=>o.id===openOrder);if(order)renderPanel(order);else closePanel();}
}
// ---- Production board ----------------------------------------------------
function printState(order){
if(order.state==='fil'||order.state==='imp')return {text:'Final aprovado',tone:'ok'};
if(order.state==='fin')return {text:'Concluído',tone:'ok'};
if(order.state==='cor')return {text:'Aguardando o cliente',tone:''};
const rows=order.print_files||[], items=order.snapshot.items.length;
const ready=rows.filter(r=>r.status==='ready').length;
if(rows.some(r=>r.status==='pending'||r.status==='rendering'))return {text:'Gerando arquivo…',tone:''};
if(ready===items)return {text:'PDF pronto',tone:'ok'};
if(rows.some(r=>r.status==='manual'||r.status==='failed'))
return {text:items>1&&ready?ready+' de '+items+' pronto':'Preparar à mão',tone:'warn'};
return {text:'Sem arquivo gerado',tone:'warn'};
}
function matches(order){
const modes=order.snapshot.items.map(i=>i.mode);
if(filter==='textil'&&!modes.some(m=>m==='file'||m==='avulsa'))return false;
if(filter==='uv'&&!modes.some(m=>m==='uvfile'||m==='uv'))return false;
if(filter==='manual'&&!(order.print_files||[]).some(r=>r.status==='manual'||r.status==='failed'))return false;
if(!search)return true;
const c=order.snapshot.customer, d=order.snapshot.destination||{};
const hay=[String(order.number),c.mail,c.cnpj,d.recipient,d.city].join(' ').toLowerCase();
const digits=search.replace(/\D/g,'');
return hay.includes(search)||(digits.length>=3&&hay.replace(/\D/g,'').includes(digits));
}
function renderBoard(){
const filters=[['all','Todos'],['textil','Têxtil'],['uv','UV'],['manual','Preparar à mão']];
$('filters').replaceChildren(...filters.map(([key,label])=>{
const b=node('button',label,'chip');b.type='button';b.setAttribute('aria-pressed',String(filter===key));
b.onclick=()=>{filter=key;renderBoard();};return b;}));
const active=board.orders.filter(o=>['rec','tra','fil','imp'].includes(o.state));
const queued=active.reduce((s,o)=>s+o.snapshot.items.reduce((t,i)=>t+Number(i.billed_metres),0),0);
$('board-summary').textContent=metres(queued)+' em produção';
$('kan').replaceChildren(...Object.entries(board.states).map(([state,title])=>{
const column=node('section',undefined,'col');column.dataset.state=state;
const orders=board.orders.filter(o=>o.state===state&&matches(o));
const total=orders.reduce((s,o)=>s+o.snapshot.items.reduce((t,i)=>t+Number(i.billed_metres),0),0);
const head=node('div',undefined,'col-head');const line=node('div');
const swatch=node('span',undefined,'swatch');swatch.style.setProperty('--c',STAGE_COLORS[state]);
// Finished orders are a recent window, not the whole history: say so.
const count=state==='fin'&&board.finished_total>orders.length?orders.length+' de '+board.finished_total:String(orders.length);
line.append(swatch,node('h2',title),node('span',count,'n'));
head.append(line,node('small',orders.length?metres(total)+' no total':'vazio'));
column.append(head);
for(const order of orders)column.append(card(order));
if(state==='fin'&&finishedMore&&!search&&filter==='all')moreButton(column,true,loadOlderFinished);
// Dragging only goes forward to the next stage. Going back or asking for a
// correction needs a reason, so those happen in the order panel.
column.ondragover=e=>{if(!column.classList.contains('allowed'))return;e.preventDefault();column.classList.add('target');};
column.ondragleave=()=>column.classList.remove('target');
column.ondrop=async e=>{e.preventDefault();column.classList.remove('target');
const order=board.orders.find(o=>o.id===e.dataTransfer.getData('text/plain'));
if(order&&order.state!==state)try{await move(order,state);}catch(error){say(error.message,true);}};
return column;
}));
}
const tinyResult=new URLSearchParams(location.search).get('tiny');
const tinyNotice=tinyResult?(tinyResult==='connected'?' · Tiny conectado.':' · Não foi possível conectar o Tiny. Tente de novo.'):'';
if(tinyResult)history.replaceState(null,'',location.pathname);
function render(){
tinyStatus();
paymentIssues();
$('reviews').replaceChildren();
if(board.pending_total || board.approved_total)
$('reviews').append(node('h2','Cotações · '+board.pending_total+' pendentes · '+
board.approved_total+' aprovadas sem pedido'));
for(const quote of [...board.quotes,...extraQuotes.pending,...extraQuotes.approved]){
const card=node('article',undefined,'review');
card.append(node('b',quote.id.slice(0,8)+' · '+quote.draft.customer.mail));
if(quote.status==='pending_review' && quote.draft.items.some(item=>item.production?.version!==2)){
card.append(node('p','Cotação com montagem antiga. Peça ao cliente para enviar uma nova cotação.'));
}else 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+' '));
productionLines(row,item);
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);
}
for(const [kind,label] of [['pending','Carregar cotações pendentes mais antigas'],
['approved','Carregar cotações aprovadas mais antigas']]){
if(moreQuotes[kind])$('reviews').append(action(label,()=>loadMoreQuotes(kind)));
}
$('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);
// Finished orders are a recent window, not the whole history: say so rather
// than let the count read as an all-time total.
const count=state==='fin'&&board.finished_total>orders.length
? orders.length+' de '+board.finished_total : String(orders.length);
column.append(node('h2',title+' · '+count));
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+' · '+(order.payment?.provider==='fake'?'Pago local':'Pago')),node('p',order.snapshot.customer.mail,'meta'),node('p',money(order.snapshot.total_cents)));
const to=order.snapshot.destination;
card.append(node('p',to?'Entrega: '+to.recipient+' · '+to.street+', '+to.number+(to.complement?' '+to.complement:'')+
' · '+to.district+' · '+to.city+'/'+to.state+' · CEP '+to.postal_code:'Retirada em Franca','meta'));
for(const item of order.snapshot.items){
card.append(node('p',item.mode+' · '+item.billed_metres+' m · nota '+item.grade,'meta'));
productionLines(card,item);
}
printFiles(card,order);
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.';
function card(order){
const c=node('button',undefined,'card');c.type='button';c.dataset.card=order.id;c.draggable=true;
if(order.id===openOrder)c.classList.add('open');
const top=node('div',undefined,'row');
top.append(node('span','#'+order.number,'num'),node('span',ago(order.updated_at),'age'));
const tags=node('div',undefined,'tags');
for(const item of order.snapshot.items)tags.append(node('span',SHORT[item.mode]+' · '+metres(item.billed_metres),'tag'));
const foot=node('div',undefined,'foot');const p=printState(order);
const status=node('span',p.text,p.tone);status.style.display='flex';status.style.alignItems='center';status.style.gap='5px';
if(p.tone==='ok')status.prepend(icon(ICON_OK));if(p.tone==='warn')status.prepend(icon(ICON_WARN));
const dest=order.snapshot.destination;
foot.append(status,node('span',dest?'Entrega · '+dest.state:'Retirada','where'));
c.append(top,node('div',order.snapshot.customer.mail,'cust'),tags,foot);
if(['rec','tra','fil','imp'].includes(order.state)&&Date.now()-new Date(order.updated_at)>LATE_HOURS*3600000)
c.append(node('div','Parado há mais de '+LATE_HOURS+' h','late'));
c.onclick=()=>openPanel(order.id);
c.ondragstart=e=>{
e.dataTransfer.setData('text/plain',order.id);
const forward=board.transitions[order.state].filter(s=>s!=='cor');
for(const col of document.querySelectorAll('.col[data-state]'))col.classList.toggle('allowed',forward.includes(col.dataset.state));
say(forward.length?'Mover para '+forward.map(s=>board.states[s]).join(' ou '):'Pedido finalizado',false,60000);
};
c.ondragend=()=>{for(const col of document.querySelectorAll('.col'))col.classList.remove('allowed','target');say('');};
return c;
}
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();
async function loadOlderFinished(){
const shown=board.orders.filter(o=>o.state==='fin').sort((a,b)=>a.created_at<b.created_at?-1:1);
const oldest=shown[0];const params=new URLSearchParams({limit:'50'});
if(oldest){params.set('before_created_at',oldest.created_at);params.set('before_id',oldest.id);}
const page=await api('/orders/finished?'+params);
const known=new Set(board.orders.map(o=>o.id));
const fresh=page.orders.filter(o=>!known.has(o.id));
olderFinished.push(...fresh);board.orders.push(...fresh);finishedMore=page.has_more;renderBoard();
}
async function move(order,state,reason=''){
await api('/orders/'+order.id+'/move',{state,version:order.version,reason});
await load();
}
$('login').onsubmit=async e=>{e.preventDefault();try{await api('/login',{email:$('email').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,download(file.upload_id)));
container.append(row);
// ---- Order panel ---------------------------------------------------------
function openPanel(id){openOrder=id;const order=board.orders.find(o=>o.id===id);$('drawer').hidden=false;renderPanel(order);renderBoard();}
function closePanel(){openOrder=null;$('drawer').hidden=true;$('panel').replaceChildren();if(board)renderBoard();}
$('scrim').onclick=closePanel;
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&openOrder)closePanel();});
function layoutPreview(production){
const ns='http://www.w3.org/2000/svg', w=Number(production.film_width_cm), h=Math.max(1,Number(production.height_cm));
const svg=document.createElementNS(ns,'svg');svg.setAttribute('viewBox','0 0 '+w+' '+h);svg.setAttribute('role','img');
svg.setAttribute('aria-label','Montagem: '+production.placements.length+' peças em '+w+' × '+h+' cm');
const film=document.createElementNS(ns,'rect');film.setAttribute('width',w);film.setAttribute('height',h);film.setAttribute('fill','#0F1113');svg.append(film);
const colors=['#2BB6D9','#E0508F','#E9C23A','#6FD19C','#FF8B2B','#A78BFA'];
for(const p of production.placements){const r=document.createElementNS(ns,'rect');
r.setAttribute('x',p.x_cm);r.setAttribute('y',p.y_cm);r.setAttribute('width',p.width_cm);r.setAttribute('height',p.length_cm);
r.setAttribute('fill',colors[p.source_index%colors.length]);r.setAttribute('fill-opacity','.55');svg.append(r);}
return svg;
}
function renderPanel(order){
const token=++openToken, panel=$('panel');panel.dataset.order=order.id;
const color=STAGE_COLORS[order.state];
const head=node('div',undefined,'panel-head');
const row=node('div',undefined,'row');
const stage=node('span',board.states[order.state],'stage');stage.style.setProperty('--c',color);
const close=node('button',undefined,'close');close.type='button';close.setAttribute('aria-label','Fechar');close.append(icon(['M6 6l12 12M18 6L6 18']));close.onclick=closePanel;
row.append(node('span','#'+order.number,'num'),stage,node('span','pago '+when(order.created_at),'faint'),close);
const steps=node('ol',undefined,'steps');steps.setAttribute('aria-label','Etapas');
const at=FLOW.indexOf(order.state);
for(const [i,s] of FLOW.entries()){const li=node('li',board.states[s]);li.style.setProperty('--c',STAGE_COLORS[s]);
if(at>=0&&i<at)li.classList.add('done');if(i===at)li.classList.add('now');steps.append(li);}
head.append(row,steps,moves(order));
const body=node('div',undefined,'panel-body');
const s=order.snapshot, dest=s.destination;
const facts=node('section',undefined,'facts');
const who=node('div');who.append(node('p','Cliente','eyebrow'),node('b',s.customer.mail),node('span','CNPJ '+s.customer.cnpj+' · '+s.customer.zap));
const where=node('div');where.append(node('p','Entrega','eyebrow'),node('b',dest?dest.recipient:'Retirada em Franca'),
node('span',dest?dest.street+', '+dest.number+(dest.complement?' '+dest.complement:'')+' · '+dest.district+' · '+dest.city+'/'+dest.state+' · CEP '+dest.postal_code:'Cliente retira na fábrica'));
facts.append(who,where);
const items=node('section');items.append(node('p','Itens e arquivos de impressão','eyebrow'));
const itemsList=node('div');itemsList.style.display='flex';itemsList.style.flexDirection='column';itemsList.style.gap='10px';
s.items.forEach((item,index)=>itemsList.append(itemCard(order,item,index)));
items.append(itemsList);
const total=node('p',undefined,'muted');total.append('Total pago: ',node('b',money(s.total_cents)));items.append(total);
const finals=node('section',undefined,'finals');finals.append(node('p','Arquivos finais','eyebrow'),node('p','Carregando…','faint'));
const history=node('section');history.append(node('p','Histórico','eyebrow'),node('p','Carregando…','faint'));
body.append(facts,items,finals,history);
panel.replaceChildren(head,body);
api('/orders/'+order.id+'/files').then(rows=>{if(token===openToken)finalsSection(order,rows,finals);}).catch(e=>say(e.message,true));
api('/orders/'+order.id+'/history').then(rows=>{if(token===openToken)historySection(order,rows,history);}).catch(e=>say(e.message,true));
}
function moves(order){
const box=node('div');box.style.display='flex';box.style.flexDirection='column';box.style.gap='10px';
const row=node('div',undefined,'moves');
const reason=node('form',undefined,'reason');reason.hidden=true;
for(const next of board.transitions[order.state]){
if(next==='cor'){
const b=node('button','Pedir correção','btn danger');b.type='button';b.dataset.move='cor';
b.onclick=()=>{reason.hidden=!reason.hidden;backForm.hidden=true;if(!reason.hidden)reason.querySelector('textarea').focus();};row.append(b);
}else{
const b=button('Mover para '+board.states[next],()=>move(order,next),'btn primary');b.dataset.move=next;row.append(b);
}
}
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.'));
// A generated print file reproduces the approved layout; offer it first, and
// keep the upload for items that need hand preparation. Not after a
// correction: the file was made from artwork the customer has replaced.
// Undo a mistaken move: one stage back, internal reason, no customer message.
const previous=board.back?.[order.state];
const backForm=node('form',undefined,'reason');backForm.hidden=true;
if(previous){
const b=node('button','Voltar para '+board.states[previous],'btn ghost');b.type='button';b.dataset.back=previous;
b.onclick=()=>{backForm.hidden=!backForm.hidden;reason.hidden=true;if(!backForm.hidden)backForm.querySelector('input').focus();};
row.append(b);
const why=node('input');why.placeholder='Motivo';why.setAttribute('aria-label','Motivo do retorno');why.required=true;why.maxLength=1000;
const go=node('button','Voltar para '+board.states[previous],'btn');
backForm.append(why,go);
backForm.onsubmit=async e=>{e.preventDefault();go.disabled=true;try{await move(order,previous,why.value.trim());}catch(error){say(error.message,true);go.disabled=false;}};
}
if(!row.children.length)row.append(node('span','Pedido concluído.','muted'));
const text=node('textarea');text.placeholder='Motivo da correção';text.setAttribute('aria-label','Motivo da correção');text.maxLength=1000;text.required=true;
const confirm=node('button','Enviar para correção','btn danger');
reason.append(text,confirm);
reason.onsubmit=async e=>{e.preventDefault();confirm.disabled=true;try{await move(order,'cor',text.value.trim());}catch(error){say(error.message,true);confirm.disabled=false;}};
box.append(row,reason,backForm);return box;
}
const PRINT_TEXT={pending:'Na fila para gerar',rendering:'Gerando arquivo…',ready:'PDF gerado',manual:'Preparar à mão',failed:'Falhou ao gerar'};
function itemCard(order,item,index){
const row=(order.print_files||[]).find(r=>r.item_index===index);
const box=node('div',undefined,'item');
if(row&&(row.status==='manual'||row.status==='failed'))box.classList.add('attention');
const preview=node('div',undefined,'preview');
if(item.production?.placements)preview.append(layoutPreview(item.production));
const body=node('div',undefined,'body');
const title=node('div',undefined,'title');title.append(node('b',PRODUCT[item.mode]),node('span',metres(item.billed_metres),'mono'));
const spec=item.production;
const line=spec?(spec.placements.length+' peças em '+spec.film_width_cm+' × '+spec.height_cm+' cm · nota '+item.grade):('nota '+item.grade);
body.append(title,node('div',line,'line'));
const file=node('div',undefined,'file');
if(row){
const tone=row.status==='ready'?'ok':row.status==='manual'||row.status==='failed'?'warn':'muted';
const label=node('span',PRINT_TEXT[row.status]+(row.status==='ready'&&row.name?' · '+row.name:'')+(row.status==='manual'&&row.detail.reason?': '+row.detail.reason:''),tone);
file.append(tone==='ok'?icon(ICON_OK):tone==='warn'?icon(ICON_WARN):'',label);
if(row.status==='ready'&&row.detail){body.append(file,node('div',[row.detail.film_width_cm+' × '+row.detail.height_cm+' cm',
row.detail.min_dpi?'menor resolução '+row.detail.min_dpi+' DPI':'',row.detail.vector_sources?'PDF vetorial':''].filter(Boolean).join(' · '),'line'));}
else body.append(file);
}else body.append(node('div','Arquivo de impressão ainda não gerado','line'));
const buttons=node('div',undefined,'buttons');
if(row?.status==='ready')buttons.append(button('Baixar PDF',download(row.upload_id)));
item.uploads.forEach((uid,i)=>buttons.append(button(item.uploads.length>1?'Original '+(i+1):'Baixar original',download(uid),'btn ghost')));
if(spec?.placements)buttons.append(button('Manifesto',()=>manifest(spec),'btn ghost'));
if(['rec','tra'].includes(order.state)&&(!row||row.status==='manual'||row.status==='failed'))
buttons.append(button('Gerar novamente',async()=>{await api('/orders/'+order.id+'/print-files',{});await load();},'btn ghost'));
body.append(buttons);
box.append(preview,body);return box;
}
function finalsSection(order,revisions,section){
section.replaceChildren(node('p','Arquivos finais','eyebrow'));
for(const file of revisions){
const rev=node('div',undefined,'rev');
rev.append(node('b',(file.kind==='final'?'Final':'Correção do cliente')+' · item '+(file.item_index+1)+' · '+file.name),
node('span',(file.expired?'expirado':file.active?'atual':'substituído')+' · '+when(file.created_at)+' · '+file.note,'faint'));
if(!file.expired){const b=button('Baixar',download(file.upload_id),'btn ghost');b.style.alignSelf='flex-start';rev.append(b);}
section.append(rev);
}
if(!['rec','tra','cor'].includes(order.state)){if(!revisions.length)section.append(node('p','Nenhum arquivo final registrado.','faint'));return;}
// A generated print file reproduces the approved layout: offer it first. Not
// after a customer correction, which replaced the artwork it was made from.
const corrected=revisions.some(file=>file.kind==='correction');
const form=node('form');
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%';
const slot=node('label',undefined,'slot');slot.append('Item '+(index+1)+' · '+PRODUCT[item.mode]);
const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index;
const generated=corrected?null:(order.print_files||[]).find(r=>r.item_index===index&&r.status==='ready');
if(generated){
const use=node('input');use.type='checkbox';use.checked=true;input.required=false;input.hidden=true;
const use=node('input');use.type='checkbox';use.checked=true;use.dataset.useGenerated=index;input.required=false;input.hidden=true;
use.onchange=()=>{input.hidden=use.checked;input.required=!use.checked;};
const choice=node('label',' Usar o arquivo gerado ('+generated.name+')');choice.prepend(use);choice.style.display='block';
label.append(choice);input.generated=()=>use.checked?generated.upload_id:null;
const choice=node('label',undefined,'check');choice.append(use,'Usar o PDF gerado ('+generated.name+')');
slot.append(choice);input.generated=()=>use.checked?generated.upload_id:null;
}
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);
slot.append(input);form.append(slot);return input;
});
const note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000;
const check=node('input');check.type='checkbox';check.required=true;check.dataset.confirm='';
const confirm=node('label',undefined,'check');confirm.append(check,'Arquivos conferidos');
const submit=node('button','Aprovar arquivos finais','btn primary');
form.append(note,confirm,submit);
form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{
const refs=[];
for(const [index,input] of inputs.entries()){
const generated=input.generated?.();
if(generated){refs.push({item_index:index,upload_id:generated});continue;}
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});
}}
const uid=await dtfUpload(file,{api,startPath:'/orders/'+order.id+'/uploads',scope:'operator:'+order.id+':'+order.version,progress:text=>say(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);
}catch(error){say(error.message,true);submit.disabled=false;}};
section.append(form);
}
function historySection(order,rows,section){
const list=node('ol',undefined,'timeline');
for(const r of [...rows].reverse()){
const li=node('li');li.style.setProperty('--c',STAGE_COLORS[r.to_state]);
const text=node('div');text.append(node('b',r.back?'Voltou para '+board.states[r.to_state]:board.states[r.from_state]+' → '+board.states[r.to_state]));
if(r.reason)text.append(node('span',' · '+r.reason,'muted'));
text.append(node('small',when(r.created_at)+' · '+r.operator));li.append(text);list.append(li);
}
const first=node('li');first.style.setProperty('--c','var(--rec)');
const t=node('div');t.append(node('b','Pedido recebido e pago'),node('small',when(order.created_at)+' · '+(board.environment==='local'&&order.payment?.provider==='fake'?'pagamento de teste':'Site')));
first.append(t);list.append(first);
section.replaceChildren(node('p','Histórico','eyebrow'),list);
}
// ---- Quotes --------------------------------------------------------------
function quoteMetres(q){return (q.approved||q.draft).items.reduce((s,i)=>s+Number(i.billed_metres||i.metres),0);}
let quoteRows=[], quoteTotal=0, quotePage=1, quoteSize=20, currentQuote=null, quoteLoading=0;
async function loadQuotes(){
const token=++quoteLoading;
const page=await api('/quotes?'+new URLSearchParams({kind:quoteKind,limit:String(quoteSize),offset:String((quotePage-1)*quoteSize)}));
if(token!==quoteLoading)return;
quoteRows=page.quotes;quoteTotal=page.total;
if(quotePage>1&&!quoteRows.length&&quoteTotal){quotePage=Math.ceil(quoteTotal/quoteSize);return loadQuotes();}
renderQuotes();
}
function renderQuotes(){
const kinds=[['pending','A revisar',board.pending_total],['approved','Aprovadas, aguardando pagamento',board.approved_total]];
$('quote-filters').replaceChildren(...kinds.map(([key,label,count])=>{
const b=node('button',label+' · '+count,'chip');b.type='button';b.setAttribute('aria-pressed',String(quoteKind===key));
b.onclick=()=>{quoteKind=key;quotePage=1;currentQuote=null;loadQuotes().catch(e=>say(e.message,true));};return b;}));
if(!currentQuote||(!quoteRows.some(q=>q.id===currentQuote.id)&&currentQuote.status==='pending_review'))currentQuote=quoteRows[0]||null;
else currentQuote=quoteRows.find(q=>q.id===currentQuote.id)||currentQuote;
const list=quoteRows.map(q=>{
const b=node('button',undefined,'qitem');b.type='button';b.dataset.quotePick=q.id;b.setAttribute('aria-current',String(q.id===currentQuote?.id));
const left=node('div');left.append(node('b',q.draft.customer.mail),
node('span',q.draft.items.map(i=>SHORT[i.mode]).join(' + ')+' · '+metres(quoteMetres(q))));
if(q.draft.items.some(i=>i.production?.version!==2))left.append(node('span','Montagem antiga: peça nova cotação','flag'));
else if(q.draft.items.some(i=>i.quality_status==='warning'))left.append(node('span','Ressalva de resolução aceita pelo cliente','flag'));
const right=node('div',undefined,'right');
right.append(node('b',q.approved?money(q.approved.total_cents):q.id.slice(0,8),'mono'),node('span',ago(q.created_at)));
b.append(left,right);b.onclick=()=>{currentQuote=q;renderQuotes();};return b;
});
if(!list.length)list.push(node('p',quoteKind==='pending'?'Nenhuma cotação para revisar.':'Nenhuma cotação aprovada aguardando pagamento.','faint'));
if(quoteTotal>quoteSize||quotePage>1)list.push(pager({page:quotePage,size:quoteSize,total:quoteTotal,noun:'cotações',compact:true,
onPage:n=>{quotePage=n;loadQuotes().catch(e=>say(e.message,true));},
onSize:n=>{quoteSize=n;quotePage=1;loadQuotes().catch(e=>say(e.message,true));}}));
$('quote-list').replaceChildren(...list);
$('reviews').replaceChildren(currentQuote?review(currentQuote):node('p','Selecione uma cotação.','note'));
}
function review(quote){
const card=node('article',undefined,'review');card.style.display='contents';card.dataset.quote=quote.id;
const head=node('div',undefined,'head');const title=node('div');
title.append(node('b',quote.draft.customer.mail),node('div','Cotação '+quote.id.slice(0,8)+' · enviada '+ago(quote.created_at)+' · '+
(quote.draft.destination?'entrega em '+quote.draft.destination.city+'/'+quote.draft.destination.state:'retirada em Franca'),'muted'));
head.append(title);
const content=node('div',undefined,'content');
const foot=node('div',undefined,'foot');
if(quote.status==='pending_review'&&quote.draft.items.some(item=>item.production?.version!==2)){
content.append(node('p','Montagem antiga. Solicite uma nova cotação ao cliente.','muted'));
card.append(head,content);return card;
}
if(quote.status!=='pending_review'){
content.append(node('p',quote.status==='expired'?'Cotação expirada. O cliente precisa solicitar outra.':
'Aprovada: '+money(quote.approved.total_cents)+' · aguardando pagamento','muted'));
for(const item of quote.draft.items)content.append(quoteItem(item,null));
card.append(head,content);return card;
}
const form=node('form');form.style.display='contents';
const edits=quote.draft.items.map(item=>{const fields={};content.append(quoteItem(item,fields));
return ()=>({...item,metres:fields.metres.value,grade:Number(fields.grade.value)});});
const check=node('input');check.type='checkbox';check.required=true;
const confirm=node('label',undefined,'check');confirm.append(check,'Arquivos, metragem e nota conferidos');
const submit=node('button','Aprovar cotação','btn primary');
foot.append(confirm,submit);
form.append(content,foot);
form.onsubmit=async e=>{e.preventDefault();submit.disabled=true;
try{const approved=await api('/quotes/'+quote.id+'/approve',{items:edits.map(fn=>fn())});
// Keep showing the quote just approved while its list refreshes.
currentQuote={...quote,approved,status:'approved'};say('Cotação aprovada.');await load();}
catch(error){say(error.message,true);submit.disabled=false;}};
card.append(head,form);return card;
}
function quoteItem(item,fields){
const box=node('div',undefined,'item');
const preview=node('div',undefined,'preview');preview.style.width='160px';
if(item.production?.placements)preview.append(layoutPreview(item.production));
const body=node('div',undefined,'body');
const title=node('div',undefined,'title');
const quality=item.quality_status==='ok'?'qualidade ok':item.quality_status==='warning'?'ressalva '+(item.quality_acknowledged?'aceita':'não aceita'):'qualidade não verificada';
title.append(node('b',PRODUCT[item.mode]),node('span',quality+' · nota '+item.grade,item.quality_status==='ok'?'ok':'warn'));
body.append(title);
if(item.production)body.append(node('div',item.production.sources.length+' arquivo(s) · '+item.production.placements.length+' peças · '+
item.production.film_width_cm+' × '+item.production.height_cm+' cm','line'));
if(fields){
const grid=node('div',undefined,'fields');
const asked=node('label','Metros do cliente');const a=node('input');a.value=item.metres;a.readOnly=true;asked.append(a);
const m=node('label','Metros conferidos');fields.metres=node('input');Object.assign(fields.metres,{type:'number',min:'0.001',max:'12000',step:'any',value:item.metres,required:true});m.append(fields.metres);
const g=node('label','Nota conferida');fields.grade=node('input');Object.assign(fields.grade,{type:'number',min:'0',max:'100',step:'1',value:item.grade,required:true});g.append(fields.grade);
grid.append(asked,m,g);body.append(grid);
}
const buttons=node('div',undefined,'buttons');
item.uploads.forEach((uid,i)=>buttons.append(button(item.uploads.length>1?'Original '+(i+1):'Baixar original',download(uid),'btn ghost')));
if(item.production?.placements)buttons.append(button('Manifesto',()=>manifest(item.production),'btn ghost'));
body.append(buttons);box.append(preview,body);return box;
}
// ---- Payments and integrations ------------------------------------------
const tinyResult=new URLSearchParams(location.search).get('tiny');
const tinyNotice=tinyResult?(tinyResult==='connected'?' · Tiny conectado.':' · Não foi possível conectar o Tiny. Tente de novo.'):'';
if(tinyResult)history.replaceState(null,'',location.pathname);
function tinyHeader(){
const t=board.tiny||{};const el=$('tiny');
if(!t.configured){el.textContent='';return;}
const trouble=!t.connected||t.problem;
el.textContent=!t.connected?'Tiny não conectado':t.problem?'Tiny: verificar conexão':'Tiny conectado';
el.style.setProperty('--dot',trouble?'var(--warn)':'var(--fin)');
}
const TINY_PROBLEMS={refused:'O Tiny recusou a renovação da conexão. Conecte de novo.',
expired:'A conexão com o Tiny expirou. Conecte de novo.',
'renewal-failing':'A última renovação falhou; o sistema tenta de novo sozinho.',
expiring:'A conexão expira em breve e não está sendo renovada.'};
function tinyDetail(t){
if(!t.connected_by)return 'Conecte com uma conta do Tiny.';
const parts=[t.connected_by+' · conectado em '+when(t.connected_at),'renovado em '+when(t.renewed_at),
t.offline?'sem expiração diária':t.expires_at?'válido até '+when(t.expires_at):'',
'envio de pedidos '+(t.orders_enabled?'ativo':'desativado')].filter(Boolean);
const problem=TINY_PROBLEMS[t.problem];
return (problem?problem+(t.failed_at&&t.problem!=='expired'?' (falha em '+when(t.failed_at)+')':'')+' · ':'')+parts.join(' · ');
}
function integration(name,state,tone,text,actions=[]){
const box=node('div',undefined,'integration');
const head=node('div',undefined,'head');const dot=node('span',undefined,'dot');
const colors={ok:['var(--fin)','var(--ok)'],warn:['var(--warn)','var(--warn)'],off:['#5B6168','var(--muted)']}[tone];
dot.style.setProperty('--dot',colors[0]);const em=node('em',state);em.style.setProperty('--dotx',colors[1]);
head.append(dot,node('b',name),em);box.append(head,node('p',text));
if(actions.length){const b=node('div',undefined,'buttons');b.append(...actions);box.append(b);}
return box;
}
// The page bar under every list: page size, range, first/previous/page/next/last.
const PAGE_SIZES=[10,20,50,100];
function pager({page,size,total,noun,onPage,onSize,compact=false}){
const pages=Math.max(1,Math.ceil(total/size));
const bar=node('div',undefined,'pager'+(compact?' compact':''));
const sizeLabel=node('label',undefined,'pager-size');
const select=node('select');select.setAttribute('aria-label','Itens por página');
for(const n of PAGE_SIZES){const o=node('option',String(n));o.value=String(n);o.selected=n===size;select.append(o);}
select.onchange=()=>onSize(Number(select.value));
sizeLabel.append('Mostrar',select,'por página');
const nav=node('div',undefined,'pager-nav');
const from=total?(page-1)*size+1:0, to=Math.min(total,page*size);
const go=(label,aria,target,disabled)=>{const b=node('button',label,'pg');b.type='button';b.setAttribute('aria-label',aria);b.disabled=disabled;b.onclick=()=>onPage(target);return b;};
const pageLabel=node('label',undefined,'pager-page');
const input=node('input');input.type='number';input.min='1';input.max=String(pages);input.value=String(page);input.setAttribute('aria-label','Página');
input.onchange=()=>onPage(Math.min(pages,Math.max(1,Math.round(Number(input.value))||1)));
pageLabel.append(compact?'':'Página',input,'de '+pages);
nav.append(go('«','Primeira página',1,page<=1),go('‹','Página anterior',page-1,page<=1),pageLabel,
go('›','Próxima página',page+1,page>=pages),go('»','Última página',pages,page>=pages));
bar.append(sizeLabel,node('span',(compact?'':'Mostrando ')+from+' a '+to+' de '+total+(compact?'':' '+noun),'pager-range'),nav);
return bar;
}
function chips(container,options,current,pick){
container.replaceChildren(...options.map(([key,label])=>{
const b=node('button',label,'chip');b.type='button';b.setAttribute('aria-pressed',String(current===key));
b.onclick=()=>pick(key);return b;}));
}
function moreButton(container,has,fn){
if(!has)return;
const b=button('Carregar mais',fn,'btn ghost');b.style.alignSelf='flex-start';b.style.marginTop='8px';container.append(b);
}
// Payments needing a person: paged, open or resolved (history).
let issueState='open', issueRows=[], issueTotal=0, issuePage=1, issueSize=20, issueLoading=0;
async function loadIssues(){
const token=++issueLoading;
const page=await api('/payment-events?'+new URLSearchParams({state:issueState,limit:String(issueSize),offset:String((issuePage-1)*issueSize)}));
if(token!==issueLoading)return;
issueRows=page.issues;issueTotal=page.total;
if(issuePage>1&&!issueRows.length&&issueTotal){issuePage=Math.ceil(issueTotal/issueSize);return loadIssues();}
renderIssues();
}
function renderIssues(){
const filters=node('div',undefined,'chips');filters.style.marginBottom='12px';
chips(filters,[['open','Abertos'],['resolved','Resolvidos'],['all','Todos']],issueState,key=>{issueState=key;issuePage=1;loadIssues().catch(e=>say(e.message,true));});
const list=node('div');
if(!issueRows.length)list.append(node('p',issueState==='resolved'?'Nenhum pagamento resolvido.':'Nenhum pagamento pendente.','faint'));
for(const issue of issueRows){
const box=node('div',undefined,'issue');
if(issue.resolved_at)box.classList.add('done');
box.append(issue.resolved_at?icon(ICON_OK):icon(ICON_WARN));box.firstChild.style.color=issue.resolved_at?'var(--ok)':'var(--warn)';
const text=node('div',undefined,'text');
text.append(node('b',(issue.amount_cents==null?'Pagamento sem valor informado':'Pagamento de '+money(issue.amount_cents))+': '+outcomeText(issue.outcome)),
node('span',issue.provider+' · evento '+issue.event_id+' · cotação '+(issue.reference||'—')+' · '+when(issue.received_at)));
if(issue.resolved_at){
text.append(node('span','Resolvido por '+issue.resolved_by+' em '+when(issue.resolved_at)+': '+issue.resolution));
box.append(text);
}else{
const form=node('form');const note=node('input');note.placeholder='Resolução';note.setAttribute('aria-label','Resolução');note.required=true;note.minLength=3;note.maxLength=1000;
const save=node('button','Registrar resolução','btn warn');form.append(note,save);
form.onsubmit=async e=>{e.preventDefault();save.disabled=true;
try{await api('/payment-events/'+issue.id+'/resolve',{note:note.value});say('Resolução registrada.');await load();}
catch(error){say(error.message,true);save.disabled=false;}};
box.append(text,form);
}
list.append(box);
}
const bar=pager({page:issuePage,size:issueSize,total:issueTotal,noun:'pagamentos',
onPage:n=>{issuePage=n;loadIssues().catch(e=>say(e.message,true));},
onSize:n=>{issueSize=n;issuePage=1;loadIssues().catch(e=>say(e.message,true));}});
$('payments').replaceChildren(filters,list,bar);
}
// The integration send log: filtered and paged on the server.
let eventFilter={provider:'',status:'',event:'',order:''}, eventRows=[], eventTotal=0, eventPage=1, eventSize=20, eventLoading=0, eventTimer=null;
async function loadEvents(){
const token=++eventLoading;
const params=new URLSearchParams({limit:String(eventSize),offset:String((eventPage-1)*eventSize)});
for(const [key,value] of Object.entries(eventFilter))if(value)params.set(key,value);
const page=await api('/events?'+params);
if(token!==eventLoading)return;
eventRows=page.events;eventTotal=page.total;
if(eventPage>1&&!eventRows.length&&eventTotal){eventPage=Math.ceil(eventTotal/eventSize);return loadEvents();}
renderEvents();
}
function eventFilters(){
const bar=$('event-filters');if(bar.dataset.ready)return;bar.dataset.ready='1';
const select=(label,key,options)=>{
const field=node('label',undefined,'field');field.append(label);
const s=node('select');for(const [value,text] of options){const o=node('option',text);o.value=value;s.append(o);}
s.onchange=()=>{eventFilter[key]=s.value;eventPage=1;loadEvents().catch(e=>say(e.message,true));};
field.append(s);return field;
};
const order=node('label',undefined,'field');order.append('Pedido');
const input=node('input');input.type='number';input.min='1';input.placeholder='Nº';input.setAttribute('inputmode','numeric');
input.oninput=()=>{clearTimeout(eventTimer);eventTimer=setTimeout(()=>{eventFilter.order=input.value.trim();eventPage=1;loadEvents().catch(e=>say(e.message,true));},350);};
order.append(input);
bar.append(
select('Destino','provider',[['','Todos'],['tiny','Tiny'],['whatsapp','WhatsApp']]),
select('Situação','status',[['','Todas'],['delivered','Enviados'],['queued','Na fila'],['failing','Com erro']]),
select('Evento','event',[['','Todos'],...Object.entries(EVENTS)]),
order);
}
function eventResult(e){
const receipt=e.receipt||{};
if(e.delivered_at){
if(receipt.status==='created')return ['Criado no Tiny nº '+receipt.tiny_number,'good'];
if(receipt.status==='already-created')return ['Já existia no Tiny nº '+receipt.tiny_number+' (reenvio)','good'];
if(receipt.status==='not-applicable')return ['Não se aplica ao Tiny',''];
return ['Registrado, sem envio',''];
}
if(e.last_error)return ['Nova tentativa · '+e.last_error+' (tentativa '+e.attempts+')','retry'];
return ['Na fila',''];
}
function renderEvents(){
const rows=eventRows.map(e=>{
const tr=node('tr');const [result,cls]=eventResult(e);
tr.append(node('td',when(e.delivered_at||e.available_at),'muted'),node('td','#'+e.payload.number,'mono'),
node('td',{tiny:'Tiny',whatsapp:'WhatsApp'}[e.provider]||e.provider),
node('td',EVENTS[e.payload.event]||e.payload.event),node('td',result,cls));
return tr;
});
if(!rows.length){const tr=node('tr');const td=node('td','Nenhum envio encontrado.','faint');td.colSpan=5;tr.append(td);rows.push(tr);}
$('events').replaceChildren(...rows);
$('events-more').replaceChildren(pager({page:eventPage,size:eventSize,total:eventTotal,noun:'envios',
onPage:n=>{eventPage=n;loadEvents().catch(e=>say(e.message,true));},
onSize:n=>{eventSize=n;eventPage=1;loadEvents().catch(e=>say(e.message,true));}}));
}
function renderIntegrations(){
const t=board.tiny||{};const cards=[];
if(!t.configured)cards.push(integration('Tiny','Não configurado','off','Credenciais ausentes no servidor.'));
else{
const connect=button(t.connected?'Reconectar':'Conectar Tiny',async()=>{const r=await api('/tiny/connect',{});location.href=r.url;},t.connected?'btn ghost':'btn');
const actions=[connect];
if(t.connected){
actions.unshift(button('Testar conexão',async()=>{const r=await api('/tiny/test',{});
say(r.ok?'Conexão com o Tiny ok: pedidos, contatos e os 4 produtos conferidos.':'Tiny: '+Object.entries(r.results).filter(([,v])=>v!=='ok').map(([k,v])=>k+': '+v).join(' · '),!r.ok);}));}
cards.push(integration('Tiny',!t.connected?'Não conectado':t.problem?'Verificar':'Conectado',
t.connected&&!t.problem?'ok':'warn',tinyDetail(t),actions));
}
const mp=board.providers?.payment;
cards.push(integration('Mercado Pago',mp==='mercadopago'?'Ativo':'Não configurado',mp==='mercadopago'?'ok':'off',
mp==='mercadopago'?'PIX e cartão ativos.':'Aguardando credenciais.'));
cards.push(integration('Frete','Não configurado','off','Somente retirada.'));
cards.push(integration('WhatsApp','Não configurado','off','Mensagens registradas, sem envio.'));
$('integrations').replaceChildren(...cards);
eventFilters();
}
// ---- Wiring --------------------------------------------------------------
for(const b of document.querySelectorAll('[data-tab]'))b.onclick=()=>showTab(b.dataset.tab);
$('search').oninput=e=>{search=e.target.value.trim().toLowerCase();renderBoard();if(search)showTab('board');};
$('login').onsubmit=async e=>{e.preventDefault();try{await api('/login',{email:$('email').value,password:$('password').value});await load();}catch(error){say(error.message,true);}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();

View File

@@ -3,9 +3,9 @@
*{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>
<main><h1>Meus pedidos DTF</h1><p class="notice" id="dev-notice" hidden>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>
<form id="register" class="card"><h2>Criar conta</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. Guarde sua senha: a recuperação por e-mail ainda não está disponível.</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>

View File

@@ -1,13 +1,14 @@
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={};
let scope,states={},environment='';
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 session=await api('/session');scope=session.cart_scope;environment=session.environment;
$('dev-notice').hidden=environment!=='local';
const account=await api('/account/me');
$('auth').hidden=!!account.customer;$('logout').hidden=!account.customer;
$('account').textContent=account.customer?'Conta: '+account.customer.mail:'';
@@ -17,13 +18,13 @@ async function load(){
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);
const link=node('a','Abrir cotação no Site');link.href='/carrinho?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('h3','Pedido #'+order.number),node('p',states[order.state]+' · '+money(order.snapshot.total_cents)+(environment==='local'?' · pagamento de teste':'')));
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);
@@ -71,6 +72,6 @@ async function authenticate(path,body){
}
$('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.';}};
$('logout').onclick=async()=>{try{await window.dtfForgetPrivateData();await api('/account/logout',{});location.reload();}catch(error){$('message').textContent='Não foi possível limpar os dados deste navegador. Feche as abas do Site e limpe os dados do site nas configurações do navegador.';}};
$('refresh').onclick=load;
load();

View File

@@ -30,10 +30,15 @@ function pintaPedido(){
$('itens').innerHTML = pedido.map((it,i)=>
'<div class="item"><div class="nm2"><b>'+escapeHTML(it.tit)+'</b><span>'+escapeHTML(it.desc)+'</span></div>'+
'<div class="vl2">'+rs(it.total)+'</div>'+
'<button class="x" data-rmi="'+i+'" aria-label="Remover">×</button></div>').join('');
'<button type="button" class="x" data-rmi="'+i+'" aria-label="Remover '+escapeHTML(it.tit)+'">Remover</button></div>').join('');
$('itens').querySelectorAll('[data-rmi]').forEach(b=>b.addEventListener('click',()=>{
pedido.splice(+b.dataset.rmi,1); pintaPedido();
const i=+b.dataset.rmi, [item]=pedido.splice(i,1);
pintaPedido();
avisaDesfazer(item.tit+' saiu do carrinho.',()=>{
pedido.splice(Math.min(i,pedido.length),0,item); pintaPedido();
});
}));
$('bEsvaziar').hidden = pedido.length+(itemAtual?1:0) < 2;
const itens = pedido.reduce((t,it)=>t+it.total,0) + (itemAtual? itemAtual.total : 0);
const frete = entrega.tipo==='frete' && entrega.cotado ? entrega.valor : 0;
const soma = itens + frete;
@@ -118,4 +123,19 @@ function carrinho(){
'o metro cai para '+rs(piso)+' — são '+rs(ganho)+' a menos neste pedido.','var(--verde)');
}
}
// Removing is one click, and a wrong click is undone from the notice that follows.
let desfazerAcao=null, desfazerTimer=null;
function avisaDesfazer(texto,acao){
desfazerAcao=acao; clearTimeout(desfazerTimer);
$('desfazerTxt').textContent=texto; $('desfazer').hidden=false;
desfazerTimer=setTimeout(fechaDesfazer,8000);
}
function fechaDesfazer(){ clearTimeout(desfazerTimer); desfazerAcao=null; $('desfazer').hidden=true; }
addEventListener('dtf-page-changed',fechaDesfazer);
$('desfazerBtn').addEventListener('click',()=>{ const acao=desfazerAcao; fechaDesfazer(); if(acao) acao(); });
$('bEsvaziar').addEventListener('click',()=>{
const antes=pedido.slice();
pedido=[]; invalidaItemAtual(); pintaPedido();
avisaDesfazer('Carrinho esvaziado.',()=>{ pedido=antes; pintaPedido(); });
});
function qm(t,c){ const m=$('qmsg'); m.style.display='block'; m.style.color=c||'var(--verde)'; m.innerHTML=t; }

30
web/site-compra.js Normal file
View File

@@ -0,0 +1,30 @@
/* Site DTF — the product page's buy box: grade, price per metre, billed length,
total and the add-to-cart button, beside the live sheet. It shows the item
the other site-*.js files already priced (itemAtual) and never computes a
price of its own, so the box and the cart always agree. */
(function(){
const vals=$('compraVals'), add=$('bAdd');
function pintaCompra(){
const it=itemAtual;
vals.hidden=!it;
add.disabled=!it || !itemPodeEnviar(it);
if(!it) return;
const semNota=!it.nota;
$('cNota').textContent = semNota ? 'sem nota' : it.nota;
$('cNotaBar').style.width=(semNota?0:it.nota)+'%';
$('cNotaBar').className = semNota ? '' : it.nota>=85 ? 'ok' : it.nota>=50 ? 'av' : 'er';
$('cTabela').textContent = TABELA[it.modo]-it.unit>0.001 ? rs(TABELA[it.modo]) : '';
$('cMetro').textContent=rs(it.unit);
$('cMetragem').textContent=fmtM(it.cob)+' m';
const sobraCm=Math.round((it.cob-it.metros)*100);
$('cObs').textContent =
it.cob===MINIMO_M && it.metros<MINIMO_M
? (ehFolha(it.modo) ? 'Pedido mínimo de 1 m.'
: 'Pedido mínimo de 1 m: ainda cabem '+sobraCm+' cm de artes sem custo.')
: sobraCm>0 ? fmtM(it.metros)+' m de folha, arredondado a cada 10 cm.' : '';
$('cTotal').textContent=rs(it.total);
}
add.addEventListener('click',()=>adicionaAoCarrinho());
addEventListener('dtf-cart-changed',pintaCompra);
pintaCompra();
})();

View File

@@ -10,22 +10,22 @@ const fmt=b=>b<1048576?(b/1024).toFixed(0)+' KB':(b/1048576).toFixed(1).replace(
// ── modos
const MODOS={
file: {tit:'Arquivo por metro', lg:'DTF têxtil · 57 cm', larg:57,
preco:'tabela R$ 19,90/m · até R$ 14,90', multi:true, folha:true,
preco:'R$ 14,90 a R$ 19,90 /m conforme a nota', multi:true, folha:true,
sub:'Sua folha montada. Revisamos, corrigimos e reencaixamos antes de imprimir.',
zt:'Arraste suas folhas montadas',
zs:'uma ou várias · PNG, JPG, TIFF ou PDF · PSD, AI e CDR passam por tratamento'},
avulsa:{tit:'Artes avulsas', lg:'DTF têxtil · 57 cm', larg:57,
preco:'tabela R$ 29,90/m · até R$ 24,90', multi:true,
preco:'R$ 24,90 a R$ 29,90 /m conforme a nota', multi:true,
sub:'Cada arte num arquivo. Montamos a folha com o melhor encaixe.',
zt:'Arraste suas artes aqui',
zs:'PNG ou JPG com fundo transparente · informe a largura de cada uma'},
uvfile:{tit:'Arquivo por metro · UV', lg:'Adesivo UV · 28,5 cm', larg:28.5,
preco:'tabela R$ 85,90/m · até R$ 69,90', multi:true, folha:true,
preco:'R$ 69,90 a R$ 85,90 /m conforme a nota', multi:true, folha:true,
sub:'Sua folha de UV montada. Revisamos, corrigimos e reencaixamos antes de imprimir.',
zt:'Arraste suas folhas de UV',
zs:'uma ou várias · PNG, JPG, TIFF ou PDF · PSD, AI e CDR passam por tratamento'},
uv: {tit:'Artes avulsas · UV', lg:'Adesivo UV · 28,5 cm', larg:28.5,
preco:'tabela R$ 99,90/m · até R$ 83,90', multi:true,
preco:'R$ 83,90 a R$ 99,90 /m conforme a nota', multi:true,
sub:'Cada arte de UV num arquivo. Montamos a folha com o melhor encaixe.',
zt:'Arraste suas artes de UV',
zs:'PNG ou JPG com fundo transparente · informe a largura de cada arte'}

View File

@@ -7,15 +7,36 @@
// Por isso a nota NÃO muda aqui. E a metragem nova só existe depois que o motor
// roda na folha de verdade, no servidor: o navegador recebe a folha fechada,
// não as artes separadas, então não há como recalcular aqui sem inventar número.
// Loose artworks still show the mounted sheet below; a ready sheet goes
// straight to the cart.
$('qOk').addEventListener('click',()=>{
if($('qOk').disabled) return;
if(!$('prev').classList.contains('on')){ adicionaAoCarrinho(); return; }
const ok=$('ciente').classList.contains('on');
qm(ok? '<b>Registrado.</b> Você confirmou que viu a ressalva de resolução.'
: '<b>Certo.</b> Seu pedido está montado abaixo.','var(--tx)');
$('carr').scrollIntoView({behavior:'smooth',block:'nearest'});
: '<b>Certo.</b> Confira a folha montada abaixo.','var(--tx)');
$('prev').scrollIntoView({behavior:'smooth',block:'start'});
});
$('pOk').addEventListener('click',()=>$('carr').scrollIntoView({behavior:'smooth'}));
// The finished item joins the order and the product page starts over empty.
function guardaItem(){
if(itemAtual && !itemPodeEnviar(itemAtual)){
$('ciente').scrollIntoView({behavior:'smooth',block:'center'});
return false;
}
if(itemAtual){ pedido.push(itemAtual); itemAtual=null; }
$('atual').style.display='none'; $('carrLin').innerHTML='';
$('foco').classList.remove('on');
['qual','prev'].forEach(id=>$(id).classList.remove('on'));
modo=null; artes=[]; folhas=[]; metros=0; nota=0; montagemCm=0;
pintaPedido();
return true;
}
function adicionaAoCarrinho(){
if(!itemAtual){ $('compra').scrollIntoView({behavior:'smooth',block:'nearest'}); return; }
if(guardaItem()) vaiPara(CARRINHO);
}
$('pOk').addEventListener('click',adicionaAoCarrinho);
$('pMais').addEventListener('click',()=>$('foco').scrollIntoView({behavior:'smooth'}));
$('pVolta').addEventListener('click',()=>{
folhas=[]; artes=[]; reencaixado=false; pintaFolha(); pintaArtes();
@@ -165,19 +186,7 @@ $('bCep').addEventListener('click',async()=>{
pintaEntrega();
});
$('bMais').addEventListener('click',()=>{
if(itemAtual && !itemPodeEnviar(itemAtual)){
$('qual').scrollIntoView({behavior:'smooth',block:'nearest'});
return;
}
if(itemAtual){ pedido.push(itemAtual); itemAtual=null; }
$('atual').style.display='none'; $('carrLin').innerHTML='';
$('foco').classList.remove('on'); $('cards').style.display='';
['qual','prev'].forEach(id=>$(id).classList.remove('on'));
modo=null; artes=[]; folhas=[]; metros=0; nota=0; montagemCm=0;
pintaPedido();
document.getElementById('envio').scrollIntoView({behavior:'smooth'});
});
$('bMais').addEventListener('click',()=>{ if(guardaItem()) vaiPara('/#envio'); });
$('bPagar').addEventListener('click',()=>{
if(window.dtfCheckout) window.dtfCheckout();

View File

@@ -3,7 +3,12 @@
Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */
// ── escolha
document.querySelectorAll('.ec').forEach(b=>b.addEventListener('click',()=>abrir(b.dataset.modo)));
document.querySelectorAll('.ec').forEach(b=>b.addEventListener('click',()=>escolheProduto(b.dataset.modo)));
// Back on the home and into the same product: the artworks sent are still there.
function escolheProduto(m){
if(modo===m && $('foco').classList.contains('on')) vaiPara(PAGINAS[m]);
else abrir(m);
}
function abrir(m){
modo=m; artes=[]; folhas=[]; metros=0; nota=0; reencaixado=false;
montagemCm=0;
@@ -13,7 +18,7 @@ function abrir(m){
recemChegada=null; caminho='auto'; avisoCam(''); pintaCaminhos();
$('vMt').textContent='—'; previaAoVivo(); agendaAvaliacao();
limpaPaineis(); // o carrinho só some se o pedido estiver vazio
$('foco').scrollIntoView({behavior:'smooth',block:'start'});
vaiPara(PAGINAS[m]);
}
function pintaModo(){
const m=modo;
@@ -29,6 +34,7 @@ function pintaModo(){
$('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';
$('qOk').textContent = ehFolha(m) ? 'Adicionar ao carrinho' : 'Ver a folha montada';
pintaTipoEnvio();
}
// O par folha montada / artes separadas fica visível e com preço nos dois lados,
@@ -81,15 +87,16 @@ function trocaTipo(destino){
if(!destino || modo===destino) return;
folhas=[]; artes=[]; recemChegada=null; metros=0; nota=0; montagemCm=0;
modo=destino;
history.replaceState(history.state,'',PAGINAS[modo]); document.title=tituloPagina();
$('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();
$('foco').classList.remove('on'); $('cards').style.display='';
itemAtual=null; modo=null; $('atual').style.display='none'; $('carrLin').innerHTML=''; pintaPedido();
$('foco').classList.remove('on');
['qual','prev'].forEach(id=>$(id).classList.remove('on'));
if(!pedido.length) $('carr').classList.remove('on');
document.getElementById('envio').scrollIntoView({behavior:'smooth'});
vaiPara('/#envio');
});

View File

@@ -302,6 +302,8 @@ function previaAoVivo(){
return;
}
tMont=setTimeout(()=>{
// o produto pode ter sido fechado nesses 140 ms (item foi para o carrinho)
if(!modo) return;
const prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && a.src);
const W=Math.round(300*ZOOMS[zoomI]);
$('vArea').classList.toggle('ampliado', zoomI>0);

19
web/site-pages.js Normal file
View File

@@ -0,0 +1,19 @@
/* Site DTF — the Site's pages: the home, one page per product and the cart.
They share this document, so artworks already loaded in the browser survive
moving between them; each page has its own address, so Back, Forward, reload
and shared links behave as in any store. nginx serves index.html for these
addresses. Loaded in <head> so the right page is the first one painted;
site-steps.js does the navigation. */
const PAGINAS={file:'/arquivo-por-metro', avulsa:'/artes-avulsas',
uvfile:'/uv-arquivo-por-metro', uv:'/uv-artes-avulsas'};
const CARRINHO='/carrinho';
function paginaDe(caminho){
caminho=caminho.replace(/\/+$/,'')||'/';
if(caminho===CARRINHO) return {pagina:'carrinho'};
const m=Object.keys(PAGINAS).find(k=>PAGINAS[k]===caminho);
return m ? {pagina:'produto',modo:m} : {pagina:'inicio'};
}
// Older portal links open a quote on the home address; quotes live in the cart.
if(location.pathname==='/' && new URLSearchParams(location.search).has('quote'))
history.replaceState(null,'',CARRINHO+location.search);
document.documentElement.dataset.rota=paginaDe(location.pathname).pagina;

View File

@@ -319,36 +319,6 @@ function sugestao(a){
if(economiaCm<0.5) return null; // menos de meio centímetro não é notícia
return {alvo, de:n, para:n+1, fileiras:fa-fd, cm:economiaCm};
}
// A cota em escala: o cliente ve a peca dentro da largura do filme, quantas cabem
// lado a lado e quanto sobra. Numero em texto ninguem le; retangulo todo mundo entende.
function vizPeca(a){
const BASE=300, L=larguraFilme();
const e0=BASE/L, alt0=a.cm*propDe(a)*e0;
const k = alt0>132 ? 132/alt0 : 1; // tira nunca fica alta demais
const e = e0*k;
const n=cabemNaLinha(a.cm), pw=a.cm*e, ph=a.cm*propDe(a)*e, gp=GAP_CM*e;
const sobra=L-(n*a.cm+(n-1)*GAP_CM);
const ext=escapeHTML(a.f.name.split('.').pop().toUpperCase());
let pecas='';
for(let i=0;i<n;i++) pecas+='<div class="pc" style="width:'+pw.toFixed(1)+'px;height:'+
ph.toFixed(1)+'px">'+(a.src?'<img src="'+a.src+'" alt="" style="transform:'+
(a.giro?'rotate(90deg) ':'')+(a.esp?'scaleX(-1)':'')+'">':'<span>'+ext+'</span>')+'</div>';
const Wt=(L*e+6).toFixed(1); // +6 = borda e respiro da tira
return '<div class="viz">'+
'<div class="vizRow">'+
'<div class="vizH" style="height:'+ph.toFixed(1)+'px"><span>'+n1(a.cm*propDe(a))+' cm</span></div>'+
'<div class="vizTira" style="width:'+Wt+'px">'+
'<div class="vizL"><i></i><b>'+n1(L)+' cm de filme</b><i></i></div>'+
'<div class="tira" style="height:'+ph.toFixed(1)+'px;gap:'+gp.toFixed(1)+'px">'+pecas+
(sobra>0.25?'<div class="folga"><span>sobra '+n1(sobra)+' cm</span></div>':'')+
'</div>'+
'</div>'+
'</div>'+
'<div class="vizPe"><b>'+n1(a.cm)+' cm</b> de largura · <b>'+n+
(n>1?' peças':' peça')+'</b> por fileira'+
(sobra>0.25?' · sobram <b>'+n1(sobra)+' cm</b>':' · largura toda aproveitada')+'</div>'+
'</div>';
}
const n1=v=>v.toFixed(1).replace('.',',');
function pintaArtes(){
@@ -362,17 +332,20 @@ function pintaArtes(){
}else if(a.cm>0 && a.src){
const dpi=Math.round(dpiDe(a)), n=Math.max(6,Math.min(100,Math.round(dpi/300*100)));
barra='<div class="qb2"><i class="'+cls(n)+'" style="width:'+n+'%"></i></div>';
const fila=cabemNaLinha(a.cm);
calc='<span class="'+cls(n)+'"><b>'+n+'%</b> · '+dpi+' DPI</span><span>'+
a.q+' × '+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</span>';
a.q+' × '+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm · '+fila+' por fileira</span>';
const sug=sugestao(a);
dica = sug
? '<div class="dica"><b>Reduzindo para '+n1(sug.alvo)+' cm você economiza '+
n1(sug.cm)+' cm de folha</b>Passam de '+sug.de+' para '+sug.para+
' por fileira, e as suas '+a.q+' peças ocupam '+sug.fileiras+
' fileira'+(sug.fileiras>1?'s':'')+' a menos.<button data-usar="'+i+'" data-cm="'+
sug.alvo+'">Usar sugerido</button></div>'
// One hint per row, on the row it is about: resolution first, since it
// changes the price; otherwise a width that saves sheet.
const maxCm=Math.floor(a.px/DPI_AVISA*2.54*10)/10;
dica = dpi<DPI_AVISA && maxCm>0 && maxCm<a.cm
? '<div class="dica av">Pequena para '+n1(a.cm)+' cm. Em '+DPI_AVISA+' DPI ela sai com até '+
n1(maxCm)+' cm, ou envie um arquivo maior.</div>'
: sug
? '<div class="dica">Com '+n1(sug.alvo)+' cm cabem '+sug.para+' por fileira e a folha encolhe '+
n1(sug.cm)+' cm.<button data-usar="'+i+'" data-cm="'+sug.alvo+'">Usar '+n1(sug.alvo)+' cm</button></div>'
: '';
dica = vizPeca(a) + dica;
}
return '<div class="art'+(a===recemChegada?' nova':'')+'"><div class="l1"><b>'+escapeHTML(a.f.name)+'</b>'+
'<span style="color:var(--fraco);font-size:10.5px">'+

View File

@@ -88,6 +88,8 @@ let tAval=null;
function agendaAvaliacao(){
clearTimeout(tAval);
tAval=setTimeout(()=>{
// o produto pode ter sido fechado nesses 220 ms (item foi para o carrinho)
if(!modo) return;
const r=avaliar();
const el=$('aoVivo');
if(!el) return;

136
web/site-steps.js Normal file
View File

@@ -0,0 +1,136 @@
/* Site DTF — moving between the Site's pages (addresses in site-pages.js), the
order progress bar, the product card prices and the phone total bar. It only
switches which page is shown and reads the page's state (the open product,
the cart, the checkout status); orders, prices and files stay with the other
site-*.js files. */
// Card prices come from the same table the checkout uses, so they cannot drift.
document.querySelectorAll('[data-de]').forEach(el=>{ el.textContent=rs(pisoEscada(el.dataset.de)); });
document.querySelectorAll('[data-ate]').forEach(el=>{ el.textContent=rs(TABELA[el.dataset.ate]); });
const TITULO_INICIO=document.title;
function tituloPagina(){
const p=paginaDe(location.pathname);
if(p.pagina==='carrinho') return 'Carrinho · DTF Dropstar';
if(p.pagina==='produto') return MODOS[p.modo].tit+' · DTF Dropstar';
return TITULO_INICIO;
}
// Shows the page for the current address; `ancora` scrolls to a section,
// otherwise the page opens at `y` (the saved position on Back and Forward).
function mostraPagina(ancora,y,suave){
const p=paginaDe(location.pathname);
if(p.pagina==='produto' && modo!==p.modo) abrir(p.modo);
document.documentElement.dataset.rota=p.pagina;
$('cards').style.display='';
document.title=tituloPagina();
window.dispatchEvent(new Event('dtf-page-changed'));
const el=ancora && document.getElementById(ancora);
if(el) el.scrollIntoView({behavior:suave?'smooth':'auto',block:'start'});
else window.scrollTo(0,y||0);
}
function vaiPara(url){
const u=new URL(url,location.href);
const ancora=u.hash.slice(1)||null;
if(u.pathname+u.search===location.pathname+location.search){
mostraPagina(ancora,0,true);
return;
}
try{ history.replaceState({...(history.state||{}),y:scrollY},''); }catch(e){}
history.pushState({y:0},'',u.pathname+u.search+u.hash);
mostraPagina(ancora,0,false);
}
(function(){
if('scrollRestoration' in history) history.scrollRestoration='manual';
addEventListener('popstate',e=>mostraPagina(e.state?null:location.hash.slice(1)||null,e.state?.y));
// Links to the Site's own pages move inside this document; everything else
// (the account portal, the store, e-mail) is a normal link.
document.addEventListener('click',e=>{
const a=e.target.closest && e.target.closest('a[href]');
if(!a || e.defaultPrevented || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || a.target) return;
const u=new URL(a.href,location.href);
if(u.origin!==location.origin) return;
const caminho=u.pathname.replace(/\/+$/,'')||'/';
if(caminho!=='/' && caminho!=='/index.html' && paginaDe(caminho).pagina==='inicio') return;
e.preventDefault(); vaiPara(u.pathname+u.search+u.hash);
});
const passos=$('passos'), barra=$('barraM'), vazio=$('carrVazio');
const foco=$('foco'), carr=$('carr'), status=$('checkoutStatus'), acoes=$('checkoutActions');
const rota=()=>document.documentElement.dataset.rota;
const temCheckout=()=>!!(status.textContent||'').trim() || acoes.children.length>0;
// The store header is sticky; the progress bar and the side panels sit below it.
function alturas(){
const topo=document.querySelector('.topo')?.offsetHeight||0;
document.documentElement.style.setProperty('--topo',topo+'px');
document.documentElement.style.setProperty('--topo-passos',(topo+(passos.hidden?0:passos.offsetHeight)+16)+'px');
}
function naTela(el){
const r=el.getBoundingClientRect();
return r.height>0 && r.top<innerHeight && r.bottom>0;
}
// Where the phone bar's button leads: the add-to-cart button on a product
// page, the order summary in the cart.
function alvoBarra(){
if(rota()==='produto' && itemAtual) return $('bAdd');
if(rota()==='carrinho' && (pedido.length || itemAtual) && !temCheckout())
return document.querySelector('.resumo');
return null;
}
let pendente=false;
function pinta(){
pendente=false;
const r=rota();
// With nothing in it, the cart page says so instead of showing an empty form.
const semItens = !pedido.length && !itemAtual;
const carrinhoVazio = r==='carrinho' && semItens && !temCheckout();
document.documentElement.toggleAttribute('data-sem-itens', semItens);
vazio.hidden = !carrinhoVazio;
passos.hidden = r==='inicio' || carrinhoVazio;
alturas();
if(!passos.hidden){
const atual = r==='produto' ? 1 : temCheckout() ? 3 : 2;
passos.querySelectorAll('[data-passo]').forEach(a=>{
const n=Number(a.dataset.passo);
a.classList.toggle('atual',n===atual); a.classList.toggle('feito',n<atual);
if(n===atual) a.setAttribute('aria-current','step'); else a.removeAttribute('aria-current');
});
passos.querySelector('[data-passo="1"]').href =
modo && foco.classList.contains('on') ? PAGINAS[modo] : '/#envio';
}
const alvo=alvoBarra();
const mostrar=!!alvo && !naTela(alvo);
barra.hidden=!mostrar;
if(mostrar){
$('barraMTot').textContent=$('carrTot').textContent;
$('barraMSub').textContent= r==='produto' ? 'Total até agora' : 'Seu pedido';
}
}
function agenda(){ if(!pendente){ pendente=true; requestAnimationFrame(pinta); } }
const obs=new MutationObserver(agenda);
obs.observe(foco,{attributes:true,attributeFilter:['class']});
obs.observe(carr,{attributes:true,attributeFilter:['class']});
obs.observe($('prev'),{attributes:true,attributeFilter:['class']});
obs.observe(status,{childList:true,characterData:true,subtree:true});
obs.observe(acoes,{childList:true});
obs.observe($('carrTot'),{childList:true,characterData:true,subtree:true});
addEventListener('scroll',agenda,{passive:true});
addEventListener('resize',agenda);
addEventListener('dtf-cart-changed',agenda);
addEventListener('dtf-page-changed',agenda);
// Payment is a step only once the order has been sent.
passos.querySelector('[data-passo="3"]').addEventListener('click',e=>{
if(!temCheckout()) e.preventDefault();
});
$('barraMBtn').addEventListener('click',e=>{
e.preventDefault();
alvoBarra()?.scrollIntoView({behavior:'smooth',block:'center'});
});
try{ history.replaceState({...(history.state||{}),y:0},''); }catch(e){}
mostraPagina(location.hash.slice(1)||null,0);
})();

272
web/site-v2.css Normal file
View File

@@ -0,0 +1,272 @@
/* Site DTF — redesign (2026-09). Loaded after the page's own styles and only
restyles: every element and class the scripts use keeps its meaning. The
previous look is in git tag ui-v1. */
:root{
--laranja:#FFA81A; --laranja2:#FF7F0A; --laranja-escuro:#C25E00;
--preto:#03060B; --texto2:#45484D; --fraco:#5C5F64; --linha:#E6E6E6; --cinza:#F9F9F9;
--verde:#1E7A45; --verde-ml:#1E7A45; --verde-fundo:#E3F5EA; --aviso-fundo:#FFF8EC; --aviso-linha:#F6D9A6;
--raio:14px; --raio2:10px;
}
body{background:#fff}
.w{max-width:1200px;padding:0 24px}
h1,h2,h3,h4{font-family:"Inter",sans-serif}
.et{font-family:"Inter",sans-serif;font-weight:800;font-size:12.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--laranja-escuro)}
/* Pages (site-pages.js): the home, a product page and the cart show only their own parts. */
html[data-rota="inicio"] :is(#passos,#foco,#qual,#prev,#carr,.checkout,#carrVazio),
html[data-rota="produto"] :is(.hero2,.etapas2,#cards,#carr,.checkout,#carrVazio,#info),
html[data-rota="carrinho"] :is(.hero2,.etapas2,#cards,#foco,#qual,#prev,#info){display:none!important}
html[data-sem-itens] #carr{display:none!important}
html:not([data-rota="inicio"]) #envio{padding-top:0;padding-bottom:64px;min-height:70vh}
#envio,#foco,#qual,#prev,#checkoutStatus{scroll-margin-top:var(--topo-passos,128px)}
.carrVazio{max-width:520px;margin:48px auto 0;padding:0 16px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:14px}
.carrVazio[hidden]{display:none}
.carrVazio h2{font-size:28px;font-weight:800;letter-spacing:-.02em}
.carrVazio p{color:var(--fraco);font-size:16px}
/* Hero */
.hero2{background:var(--preto);color:#fff;padding:64px 0}
.hero2-grid{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(0,1fr);gap:56px;align-items:center}
.hero2-text{display:flex;flex-direction:column;gap:24px}
.chips{display:flex;gap:10px;flex-wrap:wrap}
.chip2{font-family:"Inter",sans-serif;font-weight:700;font-size:12px;letter-spacing:.04em;text-transform:uppercase;padding:6px 12px;border-radius:6px;border:1px solid #3A3D42;color:#E4E4E4}
.chip2.destaque{background:var(--laranja);border-color:var(--laranja);color:var(--preto);font-weight:800}
.hero2 h1{color:#fff;font-size:clamp(34px,4.6vw,58px);line-height:1.04;font-weight:900;letter-spacing:-.03em}
.hero2 h1 em{font-style:normal;color:var(--laranja)}
.hero2-text>p{font-size:18px;line-height:1.55;color:#C9CCD1;max-width:560px}
.hero2-cta{display:flex;align-items:center;gap:18px;flex-wrap:wrap}
.hero2-cta span{font-size:14px;color:#9EA3AA}
.btn-laranja{height:56px;padding:0 28px;border-radius:var(--raio2);background:var(--laranja);color:var(--preto);font-family:"Inter",sans-serif;font-weight:800;font-size:17px;display:inline-flex;align-items:center;gap:10px;text-decoration:none}
.btn-laranja:hover{background:#FFB53D}
.hero2-folha{background:#14171B;border:1px solid #262A2F;border-radius:18px;padding:22px;display:flex;flex-direction:column;gap:14px}
.hf-cab{display:flex;align-items:baseline;gap:10px}
.hf-cab b{font-family:"Inter",sans-serif;font-size:15px}
.hf-cab span{margin-left:auto;font-size:13px;color:#9EA3AA}
.hf-area{position:relative;height:260px;border-radius:10px;background:#0B0D10;border:1px dashed #3A3D42;overflow:hidden}
.hf-area i{position:absolute;border-radius:6px;opacity:.88}
.hf-num{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
.hf-num span{display:block;font-size:12px;color:#9EA3AA}
.hf-num b{font-family:"Inter",sans-serif;font-size:18px}
.hf-num .verde{color:#57D68A}.hf-num .laranja{color:var(--laranja)}
/* Steps strip */
.etapas2{padding:32px 0;border-bottom:1px solid var(--linha)}
.etapas2 .w{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:24px}
.etapas2 .w>div{display:flex;gap:14px;align-items:flex-start}
.etapas2 span{width:36px;height:36px;flex-shrink:0;border-radius:999px;background:#FFF1DB;color:var(--laranja-escuro);font-family:"Inter",sans-serif;font-weight:800;display:flex;align-items:center;justify-content:center}
.etapas2 b{font-family:"Inter",sans-serif;font-size:15.5px}
.etapas2 p{margin-top:3px;font-size:14px;color:var(--fraco)}
section+section{border-top:0}
/* Product chooser */
#envio{padding-top:56px}
.cards-cab{display:flex;align-items:flex-end;gap:24px;flex-wrap:wrap}
.cards-cab h2{margin-top:8px;font-size:clamp(28px,3vw,38px);font-weight:800;letter-spacing:-.02em}
.cards-cab p{margin-left:auto;max-width:440px;font-size:15px;color:var(--fraco)}
.escolha{grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;margin-top:28px}
.ec{text-align:left;padding:26px;gap:14px;border:1px solid var(--linha);border-radius:16px;box-shadow:none;cursor:pointer;font:inherit;color:inherit}
.ec::before,.ec::after{display:none}
.ec:hover{border-color:var(--laranja);box-shadow:0 10px 30px rgba(3,6,11,.08);transform:translateY(-2px)}
.ec.f,.ec.f:hover{border-color:var(--linha)}
.ec.f:hover{border-color:var(--laranja)}
.ec-top{display:flex;align-items:center;gap:10px}
.ec-kind{font-family:"Inter",sans-serif;font-weight:700;font-size:12px;letter-spacing:.05em;color:#707070}
.marc2{margin-left:auto;font-family:"Inter",sans-serif;font-weight:800;font-size:11px;letter-spacing:.05em;text-transform:uppercase;padding:4px 10px;border-radius:999px;background:var(--preto);color:var(--laranja)}
.ec h3{font-size:25px;font-weight:800;letter-spacing:-.01em;color:var(--preto)}
.ec>p{font-size:15px;line-height:1.5;color:var(--texto2);flex:1}
.ec-pe{display:flex;align-items:flex-end;gap:12px;padding-top:14px;border-top:1px solid #EFEFEF}
.ec-preco{display:flex;flex-direction:column;gap:2px}
.ec-preco span{font-size:13px;color:#707070}
.ec-preco em{font-style:normal}
.ec-preco b{font-family:"Inter",sans-serif;font-size:29px;font-weight:900;letter-spacing:-.02em;color:var(--preto)}
.ec-go{margin-left:auto;height:46px;padding:0 18px;border-radius:var(--raio2);background:var(--preto);color:#fff;font-family:"Inter",sans-serif;font-weight:700;font-size:15px;display:flex;align-items:center;white-space:nowrap}
.ec:hover .ec-go{background:var(--laranja);color:var(--preto)}
/* Progress bar */
.passos{position:sticky;top:var(--topo,64px);z-index:30;display:flex;align-items:center;justify-content:center;gap:14px;margin:0 0 22px;padding:14px 16px;background:rgba(255,255,255,.96);backdrop-filter:blur(6px);border-bottom:1px solid var(--linha);font-family:"Inter",sans-serif;font-size:14px}
.passos[hidden]{display:none}
.passos a{display:flex;align-items:center;gap:8px;color:#707070;font-weight:600;text-decoration:none}
.passos a span{width:26px;height:26px;border-radius:999px;border:2px solid #D0D0D0;display:flex;align-items:center;justify-content:center;font-size:13px}
.passos a.atual{color:var(--preto);font-weight:800}
.passos a.atual span{background:var(--laranja);border-color:var(--laranja)}
.passos a.feito{color:var(--verde)}
.passos a.feito span{background:var(--verde-fundo);border-color:var(--verde-fundo);color:var(--verde)}
.passos i{width:48px;height:2px;background:#E0E0E0}
/* Product page (Montagem): artworks on the left; on the right a buy box that
stays in view with the live sheet, the grade, the price, the total and the
add-to-cart button. The old quality and preview panels are not shown: their
parts live in the buy box, and the rest repeated it (the findings list too:
each row already says what was checked). */
#qual,#prev,#rA,.qb2,#qitens{display:none!important}
.voltar{height:auto;padding:0;border:0;background:none;box-shadow:none;font-size:14px;font-weight:500;color:var(--fraco);margin:24px 0 14px}
.voltar:hover{color:var(--laranja-escuro)}
.focoGrid{grid-template-columns:minmax(0,1fr);gap:20px}
.cat,.cat.file{border:0;background:transparent;box-shadow:none;overflow:visible}
.cabfoco,.cat.file .cabfoco{padding:0 0 20px!important;background:transparent;border:0;display:grid;
grid-template-columns:minmax(0,1fr) auto;grid-template-areas:"t e" "s e" "p e";column-gap:24px;align-items:end}
.cabfoco>div:first-child{grid-area:t;display:flex;flex-direction:column-reverse;gap:4px}
.cabfoco .lg{font-size:12px;color:#707070;letter-spacing:.05em}
.cabfoco h3{font-size:32px;font-weight:800;letter-spacing:-.02em;line-height:1.1}
.cabfoco p{grid-area:s;margin-top:6px;font-size:15px;color:var(--texto2)}
.cabfoco .pr2{grid-area:p;margin-top:4px;font-size:14px;font-weight:600;color:var(--fraco);white-space:normal}
.tipoEnvio{grid-area:e;margin:0}
.tipoEnvio:not([hidden]){display:inline-flex;gap:4px;padding:4px;border-radius:10px;background:#EFEFEF}
.tipoEnvio .cam,.tipoEnvio .cam.on{border:0;box-shadow:none;background:transparent;padding:0 14px;min-height:40px;border-radius:8px;align-items:center}
.tipoEnvio .cam.on{background:#fff;box-shadow:0 1px 2px rgba(3,6,11,.14)}
.tipoEnvio .cam .mk2,.tipoEnvio .cam span,.tipoEnvio .cam em{display:none}
.tipoEnvio .cam b{margin:0;font-size:13.5px;font-weight:600;white-space:nowrap}
.tipoEnvio .cam.on b{font-weight:800}
.tipoEnvio .cam:disabled{cursor:default}
.tipoEnvio .cam:disabled:not(.on){opacity:.5}
.cat .in{padding:0!important}
.foco2{grid-template-columns:minmax(0,1fr) 420px;gap:32px}
.fila{display:flex;flex-direction:column;gap:12px}
.fila>*{margin:0}
.cam{border-radius:var(--raio2)}
.cam.on{border-color:var(--laranja);background:var(--aviso-fundo)}
#larguraFolhaPronta{display:inline}
.zona{display:grid;grid-template-columns:44px minmax(0,1fr);column-gap:16px;row-gap:2px;align-items:center;text-align:left;padding:16px 20px;margin:0;border:2px dashed #F2B25A;background:var(--aviso-fundo);border-radius:var(--raio)}
.zona .ico{grid-row:1/4;width:44px;height:44px;line-height:44px;margin:0;border-radius:10px;background:var(--laranja);color:var(--preto);box-shadow:none}
.zona b{font-size:15px;margin:0}
.zona span{max-width:none;margin:0;font-size:13px;color:var(--fraco)}
.zona .bt{display:none}
.zona:hover,.zona.sobre{border-color:var(--laranja2);box-shadow:none}
#lista{display:flex;flex-direction:column;gap:10px}
#lista:empty{display:none}
.art{margin:0;padding:14px 16px;border-radius:12px;background:#fff;border:1px solid var(--linha)}
.art.nova{background:#fff;box-shadow:0 0 0 2px rgba(255,168,26,.45)}
.art .l1 b{font-size:15px;font-weight:700}
.art label{font-size:12px}
.art input{height:42px;font-size:15px;font-weight:600;border-radius:8px}
.art .cps{grid-template-columns:repeat(2,minmax(0,160px))}
.rep{margin:0;padding:14px 16px;border-radius:12px;background:#fff;border:1px solid var(--linha)}
.calc{font-size:13px;margin-top:8px}
.dica{display:flex;align-items:center;gap:12px;margin-top:10px;padding:10px 12px;border:0;border-radius:8px;background:var(--verde-fundo);font-size:13px;color:#14532D}
.dica.av{background:var(--aviso-fundo);color:#5A3A00}
.dica button{display:inline-flex;width:auto;margin:0 0 0 auto;flex:none;height:34px;align-items:center;padding:0 12px;border-radius:7px;border:1px solid var(--verde);background:#fff;color:var(--verde);box-shadow:none;font-size:13px}
.dica button:hover{background:var(--verde);color:#fff;box-shadow:none}
.req{position:static;padding:16px 20px 6px;border-radius:12px;box-shadow:none}
.reqcab{font-size:15px;margin-bottom:6px}
/* The buy box */
.mont{position:sticky;top:var(--topo-passos,128px);padding:20px;border-radius:16px;background:#fff;box-shadow:none;display:flex;flex-direction:column;gap:12px}
.mont>*{margin:0}
.montcab b{font-size:17px;font-weight:800}
.montpe{font-size:12px;text-align:center}
.compra{display:flex;flex-direction:column;gap:12px;padding-top:16px;border-top:1px solid #EFEFEF}
.compra .aoVivo{margin:0}
.compra .aoVivo.pronto{display:none}
#compraVals{display:flex;flex-direction:column;gap:10px;font-size:15px}
#compraVals[hidden]{display:none}
.cNota{display:flex;align-items:center;gap:12px}
.cNota span,.cLin>span:first-child{color:var(--texto2)}
.cNota>i{flex:1;height:6px;border-radius:999px;background:#EFEFEF;overflow:hidden}
.cNota>i i{display:block;height:100%;border-radius:999px;background:#C9CCD1}
.cNota>i i.ok{background:var(--verde)}.cNota>i i.av{background:var(--laranja)}.cNota>i i.er{background:var(--vermelho)}
.cNota b{font-family:"Inter",sans-serif;font-weight:800}
.cLin{display:flex;align-items:baseline;gap:8px}
.cLin>span:last-child,.cLin>b{margin-left:auto;display:flex;gap:8px;align-items:baseline}
.cLin s{font-size:13px;color:#707070}
.cObs{margin:-4px 0 0;font-size:13px;color:var(--verde)}
.cObs:empty{display:none}
.cTot{display:flex;align-items:baseline;padding-top:12px;border-top:1px solid #EFEFEF}
.cTot span{font-family:"Inter",sans-serif;font-weight:700}
.cTot b{margin-left:auto;font-family:"Inter",sans-serif;font-size:30px;font-weight:900;letter-spacing:-.01em}
#qmsg{font-size:13px;line-height:1.45}
.compra .ciente{margin:0;padding:12px 14px}
.compra .ciente h4{font-size:13.5px}
.compra .ciente p,.compra .ciente .lista{font-size:13px}
.cAdd{height:54px;border-radius:var(--raio2);border:0;background:var(--laranja);color:var(--preto);font-family:"Inter",sans-serif;font-weight:800;font-size:16px;cursor:pointer}
.cAdd:hover:not(:disabled){background:#FFB53D}
.cAdd:disabled{background:#EDEDED;color:#8A8D92;cursor:not-allowed}
.cPe{margin:0;font-size:13px;color:var(--fraco);text-align:center}
@media(max-width:1100px){.foco2{grid-template-columns:1fr}.mont{position:static}}
@media(max-width:620px){.montcab b{font-size:15px}
.cabfoco,.cat.file .cabfoco{grid-template-columns:1fr;grid-template-areas:"t" "s" "p" "e"}
.tipoEnvio:not([hidden]){display:grid;grid-template-columns:1fr 1fr;margin-top:14px}}
/* The quality, preview, cart and checkout panels sit directly in #envio,
outside .w; they share the page column so nothing runs edge to edge. */
#envio>.qual,#envio>.prev,#envio>.carr,#envio>.checkout{width:min(1152px,calc(100% - 48px));margin-left:auto;margin-right:auto}
@media(max-width:820px){#envio>.qual,#envio>.prev,#envio>.carr,#envio>.checkout{width:calc(100% - 32px)}}
/* Cart */
.itensCab{display:flex;align-items:baseline;gap:12px;margin-bottom:11px}
.itensCab h4{margin:0}
.esvaziar{margin-left:auto;border:0;background:none;padding:6px 0;font-size:13.5px;font-weight:600;color:var(--fraco);cursor:pointer;text-decoration:underline;text-underline-offset:3px}
.esvaziar:hover{color:#B8391C}
.esvaziar[hidden]{display:none}
.item{align-items:center}
.item .x{flex:none;height:36px;padding:0 12px;border-radius:8px;border:1px solid #D6D6D6;background:#fff;color:var(--texto2);font-size:13px;font-weight:600;font-family:inherit;cursor:pointer}
.item .x:hover{border-color:#B8391C;color:#B8391C}
.desfazer{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:80;display:flex;align-items:center;gap:16px;padding:12px 14px 12px 18px;border-radius:12px;background:var(--preto);color:#fff;font-size:14px;box-shadow:0 10px 30px rgba(3,6,11,.25);max-width:calc(100% - 32px);box-sizing:border-box}
.desfazer[hidden]{display:none}
.desfazer button{flex:none;height:36px;padding:0 14px;border-radius:8px;border:0;background:var(--laranja);color:var(--preto);font-family:"Inter",sans-serif;font-weight:800;font-size:14px;cursor:pointer}
@media(max-width:820px){.desfazer{bottom:100px}}
.carrAviso{grid-column:1/-1;font-size:13px;color:var(--fraco)}
.carrAviso:empty{display:none}
.carr{border-radius:16px}
.carr h3{font-size:24px;font-weight:800;letter-spacing:-.02em}
.cxE{border-radius:var(--raio);border-color:var(--linha)}
.cxE h4,.resumo h4{font-family:"Inter",sans-serif;font-weight:800;font-size:17px}
.cp input,.cepL input{height:46px;border-radius:var(--raio2)}
.cp input:focus,.cepL input:focus{border-color:var(--laranja2);box-shadow:0 0 0 3px rgba(255,168,26,.18)}
.opE{border-radius:12px}
.opE.on{border:2px solid var(--laranja);background:var(--aviso-fundo)}
.resumo{position:sticky;top:var(--topo-passos,128px);border-radius:16px}
.tot b{font-family:"Inter",sans-serif;font-weight:900}
#bPagar{height:54px;border-radius:var(--raio2);background:var(--laranja);color:var(--preto);border:0;font-family:"Inter",sans-serif;font-weight:800;font-size:16px}
#bPagar:hover:not(:disabled){background:#FFB53D}
#bPagar:disabled{background:#EDEDED;color:#8A8D92}
#bMais{height:46px;border-radius:var(--raio2);font-family:"Inter",sans-serif;font-weight:600}
.checkout{max-width:760px;margin:20px auto 0}
.checkout #checkoutStatus:not(:empty){padding:16px 18px;border-radius:var(--raio);background:var(--verde-fundo);border:1px solid #BFE6CE;color:#14532D;font-size:15px}
#checkoutActions{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px}
#checkoutActions button{height:48px!important;padding:0 20px!important;margin:0!important;border-radius:var(--raio2);border:0;background:var(--laranja);color:var(--preto);font-family:"Inter",sans-serif;font-weight:800;font-size:15px}
#checkoutActions input{height:44px;border-radius:var(--raio2);border:1px solid #D6D6D6;padding:0 12px}
/* Price and benefits */
.info2{background:var(--cinza);padding:64px 0}
.info2-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:56px;align-items:start}
.info2-preco{display:flex;flex-direction:column;gap:16px}
.info2 h2{font-size:clamp(26px,3vw,34px);font-weight:800;letter-spacing:-.02em}
.info2-preco>p{font-size:15px;color:var(--texto2)}
.info2 table{border-collapse:collapse;background:#fff;border:1px solid var(--linha);border-radius:12px;overflow:hidden;font-size:15px}
.info2 th{text-align:left;background:var(--preto);color:#fff;font-family:"Inter",sans-serif;font-size:13px;padding:12px 18px}
.info2 td{padding:11px 18px;border-top:1px solid #EFEFEF}
.info2 td.melhor{font-weight:700;color:var(--verde)}
.info2 .nota2{font-size:13px;color:#707070}
.info2-vant{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
.info2-vant>div{padding:22px;border-radius:var(--raio);background:#fff;border:1px solid var(--linha);display:flex;flex-direction:column;gap:6px}
.info2-vant b{font-family:"Inter",sans-serif;font-size:16px}
.info2-vant b.n{font-size:32px;font-weight:900;color:var(--laranja2)}
.info2-vant span{font-size:14px;color:var(--fraco)}
/* Phone total bar */
.barraM{display:none}
@media (max-width:820px){
.barraM:not([hidden]){display:flex;position:fixed;left:0;right:0;bottom:0;z-index:40;align-items:center;gap:12px;padding:12px 16px calc(12px + env(safe-area-inset-bottom));background:#fff;border-top:1px solid var(--linha);box-shadow:0 -6px 20px rgba(3,6,11,.08)}
.barraM div{display:flex;flex-direction:column}
.barraM span{font-size:12px;color:#707070}
.barraM b{font-family:"Inter",sans-serif;font-size:21px;font-weight:900}
.barraM a{margin-left:auto;height:50px;padding:0 22px;border-radius:var(--raio2);background:var(--laranja);color:var(--preto);font-family:"Inter",sans-serif;font-weight:800;font-size:16px;display:flex;align-items:center;text-decoration:none}
.w{padding:0 16px}
.hero2{padding:40px 0}
.hero2-grid,.info2-grid{grid-template-columns:1fr;gap:28px}
.etapas2 .w{grid-template-columns:repeat(2,minmax(0,1fr))}
.escolha{grid-template-columns:1fr}
.cards-cab p{margin-left:0}
.ec-pe{flex-wrap:wrap}
.ec-go{margin-left:0;width:100%;justify-content:center}
.focoGrid{grid-template-columns:1fr}
.mont,.resumo{position:static}
.passos{gap:8px;font-size:12.5px;margin:0 0 16px;padding:10px 16px}
.passos i{width:14px}
.passos a{flex-direction:column;gap:4px;text-align:center}
.info2-vant{grid-template-columns:1fr}
footer{padding-bottom:84px}
}
@media (max-width:480px){
.etapas2 .w{grid-template-columns:1fr}
}