Compare commits

..

16 Commits

Author SHA1 Message Date
Cauê Faleiros
9bea187ea4 feat: add a read-only Jadlog price probe
All checks were successful
Build and deploy / Validate source (push) Successful in 1m20s
Build and deploy / Integration suite on a real stack (push) Successful in 2m49s
Build and deploy / Secret scan and release gate (push) Successful in 10s
Build and deploy / Publish images (push) Successful in 1m51s
app/jadlog.py prices one package through Jadlog's Simulador de Frete as the
API manual v2.3 describes it; app.jadlog_probe prices test weights to six
regions on the client's account to confirm token, account and contract.
Tested against a fake transport. The roadmap records the Jadlog data and the
Mercado Pago account setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:41:18 -03:00
Cauê Faleiros
bd56170248 feat: let production select Mercado Pago and acknowledge simulated notifications
The production compose hard-coded the fake payment adapter; it now takes
PAYMENT_ADAPTER and the MP_* settings from the stack's environment, so the
sandbox can run with test credentials. A signed notification about a payment
Mercado Pago does not have, such as the panel's "Simular notificação", is
acknowledged instead of answering 500 and being retried; any other lookup
failure still raises.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:41:18 -03:00
Cauê Faleiros
60b7336b37 docs: commit the operator guide's source and record the Week-2 report
The operator guide is now built from docs/guias/operador/ by
docs/guias/imprimir.sh, with the corrections on Tiny and the WhatsApp
notices. The roadmap records the guide, the history fix found while
writing it, and the Week-2 report as sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:07:58 -03:00
Cauê Faleiros
04e4cbc953 fix: hide operators' internal back-move reasons from the customer's order history
A move back undoes an operator's mistake and its reason is internal. The
customer's history now omits back moves and shows a reason only for a
correction; the smoke test checks both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:07:58 -03:00
Cauê Faleiros
aec5b1d054 feat: send order situações to Tiny for the client's WhatsApp notices
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 2m5s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 1m45s
The client already sends WhatsApp notices from Tiny's order situação
(Tiny webhook -> middleware -> n8n). With TINY_STATUS_UPDATES on, a paid
order is set to "Aprovada" once and a finished pickup order to "Pronto
para envio"; pickup orders carry the client's pickup forma de envio
(TINY_FORMA_ENVIO_RETIRADA). The ready event now carries the order and
the Tiny id from the sale's receipt. Off by default until go-live, when
n8n stops sending the DTFIMP designer message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:17:00 -03:00
Cauê Faleiros
8b42e684ca docs: record the Tiny account and product findings
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:40:28 -03:00
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
67 changed files with 3406 additions and 788 deletions

View File

@@ -37,6 +37,11 @@ TINY_ADAPTER=fake
# TINY_PRODUCT_UV_FOLHA=... # TINY_PRODUCT_UV_FOLHA=...
# TINY_PRODUCT_UV_AVULSA=... # TINY_PRODUCT_UV_AVULSA=...
# TINY_ADAPTER=tiny # only once connected and tested: creates real orders # TINY_ADAPTER=tiny # only once connected and tested: creates real orders
# Jadlog price quotes go in jadlog.env, not here (see app/jadlog_probe.py):
# JADLOG_TOKEN=...
# JADLOG_CNPJ=... # the "Usuário" Jadlog issued, the CNPJ that contracts freight
# JADLOG_CONTA=... # conta corrente
# JADLOG_CONTRATO= # only if Jadlog issued a contract number
WHATSAPP_ADAPTER=fake WHATSAPP_ADAPTER=fake
STORAGE_ADAPTER=s3-local STORAGE_ADAPTER=s3-local
MOCK_FREIGHT_CENTS=1500 MOCK_FREIGHT_CENTS=1500

View File

@@ -86,7 +86,7 @@ jobs:
# the generator's geometry. The provider suites use a fake transport: they # the generator's geometry. The provider suites use a fake transport: they
# prove the documented contract, not the integration. # prove the documented contract, not the integration.
- name: Print-file geometry and provider adapters - name: Print-file geometry and provider adapters
run: $COMPOSE exec -T api python -m unittest tests.test_printfile tests.test_mercadopago tests.test_tiny -v run: $COMPOSE exec -T api python -m unittest tests.test_printfile tests.test_mercadopago tests.test_tiny tests.test_jadlog -v
- name: Runtime and retention regressions - name: Runtime and retention regressions
run: | run: |

View File

@@ -146,6 +146,8 @@ class FreightAdapter(Protocol):
def quote(self, service: str, postal_code: str) -> dict: ... def quote(self, service: str, postal_code: str) -> dict: ...
class FakeFreight: class FakeFreight:
name = 'fake'
def quote(self, service: str, postal_code: str) -> dict: def quote(self, service: str, postal_code: str) -> dict:
if service == 'pickup': if service == 'pickup':
return {'provider': 'fake', 'service': 'pickup', 'total_cents': 0, 'postal_code': ''} 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: if not account or not matches:
login_failed(email) login_failed(email)
audit('customer_login_failed', ip=client_ip(request)) 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) previous = current(request)
with db.connect() as c: with db.connect() as c:
if not stored.startswith('scrypt-v2$'): if not stored.startswith('scrypt-v2$'):
@@ -98,7 +98,10 @@ def orders(identity=Depends(owner)):
def detail(oid: UUID, identity=Depends(owner)): def detail(oid: UUID, identity=Depends(owner)):
with db.connect() as c: with db.connect() as c:
row = owned_order(c, oid, identity) row = owned_order(c, oid, identity)
history = c.execute('SELECT from_state,to_state,reason,created_at FROM dtf_local.movements WHERE order_id=%s ORDER BY id', (oid,)).fetchall() # A move back undoes an operator's mistake and its reason is internal;
# only a correction's reason is written for the customer.
history = c.execute('''SELECT from_state,to_state,CASE WHEN to_state='cor' THEN reason ELSE '' END AS reason,
created_at FROM dtf_local.movements WHERE order_id=%s AND NOT back ORDER BY id''', (oid,)).fetchall()
return {'id': row['id'], 'number': row['number'], 'state': row['state'], 'version': row['version'], return {'id': row['id'], 'number': row['number'], 'state': row['state'], 'version': row['version'],
'snapshot': row['snapshot'], 'history': history, 'files': file_rows(c,oid)} 'snapshot': row['snapshot'], 'history': history, 'files': file_rows(c,oid)}

View File

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

View File

@@ -17,7 +17,7 @@ from ..core.models import Move, OperatorLogin, Resolution, Review
from ..core.pricing import price from ..core.pricing import price
from ..printjobs import queue as queue_print_files from ..printjobs import queue as queue_print_files
from .. import tiny 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) enqueue, freight, quote_view, storage, upload_row)
from ..scanning import require_clean from ..scanning import require_clean
@@ -30,14 +30,14 @@ def operator_login(body: OperatorLogin, request: Request, response: Response):
with db.connect() as c: with db.connect() as c:
account = c.execute('SELECT * FROM dtf_local.operators WHERE email=%s', (email,)).fetchone() 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(): 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. # Comparable password work whether or not the account exists or is active.
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
matches = password_matches(body.password, stored) matches = password_matches(body.password, stored)
if not account or not account['active'] or not matches: if not account or not account['active'] or not matches:
login_failed('operator:'+email) login_failed('operator:'+email)
audit('operator_login_failed', ip=client_ip(request), 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) token = secrets.token_urlsafe(32)
with db.connect() as c: with db.connect() as c:
previous = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest() 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 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 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'] WHERE o.id IS NULL AND q.approved IS NOT NULL''').fetchone()['n']
orders = active + list(reversed(finished)) 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. 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}
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 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 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() WHERE p.order_id=ANY(%s) ORDER BY p.item_index''', ([o['id'] for o in orders],)).fetchall()
for order in orders: for order in orders:
order['print_files'] = [row for row in generated if row['order_id'] == order['id']] order['print_files'] = [row for row in generated if row['order_id'] == order['id']]
# A paid notification that did not become an order is money received return orders
# 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 def cursor_pair(first, second):
FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL if (first is None) != (second is None):
ORDER BY received_at LIMIT 100''').fetchall() raise HTTPException(422, 'Both cursor fields are required')
return {'states': STATES, 'transitions': TRANSITIONS, return first is not None
'orders': orders, 'payment_issues': refused, 'tiny': tiny_status(),
'finished_shown': len(finished), 'finished_total': finished_total,
'quotes': [quote_view(c, q) for q in pending + approved], @router.get('/api/operator/orders/finished')
'pending_total': pending_total, 'approved_total': approved_total, def finished_page(before_created_at: datetime | None = None, before_id: UUID | None = None,
'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()} 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') @router.get('/api/operator/quotes')
def quote_page(kind: Literal['pending','approved'], before_created_at: datetime | None = None, 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), before_id: UUID | None = None, offset: int = Query(default=0, ge=0),
user=Depends(operator)): 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): if (before_created_at is None) != (before_id is None):
raise HTTPException(422, 'Both quote cursor fields are required') raise HTTPException(422, 'Both quote cursor fields are required')
approved_filter = 'q.approved IS NULL' if kind == 'pending' else 'q.approved IS NOT NULL' 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 '' 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: with db.connect() as c:
rows = c.execute(f'''SELECT q.* FROM dtf_local.quotes q rows = c.execute(f'''SELECT q.* FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE o.id IS NULL AND {approved_filter} {cursor} 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]], 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') @router.post('/api/operator/quotes/{uid}/approve')
def approve(uid: UUID, body: Review, user=Depends(operator)): def approve(uid: UUID, body: Review, user=Depends(operator)):
@@ -159,28 +230,41 @@ def move(uid: UUID, body: Move, user=Depends(operator)):
if not row: if not row:
raise HTTPException(404, 'Order not found') raise HTTPException(404, 'Order not found')
if body.version != row['version']: 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']: if body.state == row['state']:
return row return row
if body.state not in TRANSITIONS[row['state']]: back = BACK.get(row['state']) == body.state
raise HTTPException(409, 'Move is not allowed from this 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(): 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'): 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() 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']))): 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': if body.state == 'cor':
c.execute("UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind='final'", (uid,)) 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)', 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)) (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() 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'} events = {'imp':'production_started','cor':'correction_needed','fin':'ready'}
if body.state in events: if body.state in events and not back:
for provider in ('tiny','whatsapp'): # "Production started" and "ready" reach the customer once per order,
enqueue(c, f'{uid}:{changed["version"]}:{provider}', provider, # even if a mistaken move is undone and made again. Each correction
{'order_id':str(uid), 'number':row['number'], 'event':events[body.state], 'reason':body.reason, # is a new request, so it keeps one message per movement.
'customer_path': f'/portal.html?order={uid}'}) once = body.state in ('imp','fin')
event = {'order_id':str(uid), 'number':row['number'], 'event':events[body.state], 'reason':body.reason,
'customer_path': f'/portal.html?order={uid}'}
# Tiny needs the order (customer, pickup or delivery) and, once the
# sale reached it, its id, to set the situação without a search.
sale = c.execute("SELECT receipt FROM dtf_local.outbox WHERE event_key=%s AND delivered_at IS NOT NULL",
(f'{uid}:paid:tiny',)).fetchone()
tiny = {**event, 'order': row['snapshot'], 'tiny_id': ((sale or {}).get('receipt') or {}).get('tiny_id')}
for provider, payload in (('tiny', tiny), ('whatsapp', event)):
key = f'{uid}:{events[body.state]}:{provider}' if once else f'{uid}:{changed["version"]}:{provider}'
enqueue(c, key, provider, payload)
return changed return changed
@router.post('/api/operator/orders/{uid}/print-files') @router.post('/api/operator/orders/{uid}/print-files')
@@ -224,13 +308,34 @@ def tiny_connect(user=Depends(operator)):
audit('tiny_connect_started', operator=user) audit('tiny_connect_started', operator=user)
return {'url': tiny.TinyAuth().authorize_url(user)} return {'url': tiny.TinyAuth().authorize_url(user)}
@router.get('/api/operator/tiny/callback') @router.post('/api/operator/tiny/test')
def tiny_callback(code: str = Query(max_length=4096), state: str = Query(max_length=128)): def tiny_test(user=Depends(operator)):
"""Tiny's redirect back. Cross-site, so the operator cookie is absent: the """Read one order and one contact from Tiny. Creates nothing."""
single-use state an operator created is what authorises it."""
if not tiny.configured(): if not tiny.configured():
raise HTTPException(503, 'Tiny application is not configured') raise HTTPException(503, 'Tiny application is not configured')
try: 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) who = tiny.TinyAuth().complete(code, state)
except tiny.TinyError: except tiny.TinyError:
audit('tiny_connect_failed') audit('tiny_connect_failed')

View File

@@ -10,7 +10,7 @@ from psycopg.types.json import Jsonb
from ..core import db from ..core import db
from ..core.auth import owner from ..core.auth import owner
from ..core.models import QuoteRequest 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 from ..scanning import require_clean
router = APIRouter() router = APIRouter()
@@ -20,6 +20,7 @@ def create_quote(body: QuoteRequest, session_id=Depends(owner)):
for item in body.items: for item in body.items:
if abs(item.metres*100-item.production.height_cm) > Decimal('0.02'): if abs(item.metres*100-item.production.height_cm) > Decimal('0.02'):
raise HTTPException(422, 'Quoted metres do not match the submitted layout height') 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'}) draft = body.model_dump(mode='json', exclude={'request_key'})
digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest() digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest()
try: 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['owned']+body.size > int(os.environ.get('OWNER_UPLOAD_QUOTA_BYTES','10737418240')) or
usage['pending'] >= int(os.environ.get('MAX_PENDING_UPLOADS','10'))): usage['pending'] >= int(os.environ.get('MAX_PENDING_UPLOADS','10'))):
audit('upload_quota_rejected') 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) multipart = storage.begin(key)
c.execute('''INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,expires_at) 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')''', VALUES(%s,%s,%s,%s,%s,%s,now()+interval '1 hour')''',

View File

@@ -45,7 +45,7 @@ def session_row(request):
try: try:
sid = UUID(request.cookies.get('dtf_session', '')) sid = UUID(request.cookies.get('dtf_session', ''))
except ValueError: 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: with connect() as c:
row = c.execute('SELECT * FROM dtf_local.sessions WHERE id=%s AND expires_at>now()', (sid,)).fetchone() row = c.execute('SELECT * FROM dtf_local.sessions WHERE id=%s AND expires_at>now()', (sid,)).fetchone()
if not row: if not row:
@@ -92,7 +92,7 @@ def rate_limit(scope, identity, limit, seconds=900):
RETURNING attempts""", (key,seconds,seconds)).fetchone() RETURNING attempts""", (key,seconds,seconds)).fetchone()
if row['attempts'] > limit: if row['attempts'] > limit:
audit('rate_limit', scope=scope) 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 ACCOUNT_FAILURES = 10
@@ -108,7 +108,7 @@ def throttle(email, request):
WHERE key=%s AND started_at >= now()-interval '900 seconds'""", (key,)).fetchone() WHERE key=%s AND started_at >= now()-interval '900 seconds'""", (key,)).fetchone()
if row and row['attempts'] >= ACCOUNT_FAILURES: if row and row['attempts'] >= ACCOUNT_FAILURES:
audit('rate_limit', scope='auth-account') 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): def login_failed(email):
"""Count a failed sign-in (or registration attempt) against the account.""" """Count a failed sign-in (or registration attempt) against the account."""
@@ -117,10 +117,10 @@ def login_failed(email):
def operator(request: Request): def operator(request: Request):
token = request.cookies.get('dtf_operator', '') token = request.cookies.get('dtf_operator', '')
if not token or len(token)>128: 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() digest = hashlib.sha256(token.encode()).hexdigest()
with connect() as c: with connect() as c:
row = c.execute('SELECT username FROM dtf_local.operator_sessions WHERE token_hash=%s AND expires_at>now()', (digest,)).fetchone() row = c.execute('SELECT username FROM dtf_local.operator_sessions WHERE token_hash=%s AND expires_at>now()', (digest,)).fetchone()
if not row: if not row:
raise HTTPException(401, 'Operator session expired') raise HTTPException(401, 'Sua sessão expirou. Entre de novo.')
return row['username'] return row['username']

74
app/jadlog.py Normal file
View File

@@ -0,0 +1,74 @@
"""Jadlog freight quotes (Embarcador API, "Simulador de Frete").
Written from Jadlog's API manual v2.3 (August 2025) and exercised only
against a fake HTTP transport until app.jadlog_probe has run on the client's
account. A quote is read-only: it creates no shipment and costs nothing, so
unlike Tiny it can be tried on the real account as often as needed.
The client's contract: origin CEP 14402-310, service .PACKAGE (modalidade 3),
home delivery. Jadlog prices by weight in kg and expects the larger of the
real and the cubed weight; the weight per metre of film is still to come from
the client, so nothing here decides it.
"""
import os
from decimal import ROUND_HALF_UP, Decimal
import httpx
QUOTE_URL = 'https://www.jadlog.com.br/embarcador/api/frete/valor'
PACKAGE = 3
ORIGIN = '14402310'
class JadlogError(Exception):
pass
def digits(value):
return ''.join(ch for ch in str(value or '') if ch.isdigit())
class JadlogQuotes:
def __init__(self, token=None, cnpj=None, conta=None, contrato=None, origin=None,
modalidade=None, transport=None):
self.token = (token or os.environ.get('JADLOG_TOKEN', '')).strip()
self.cnpj = digits(cnpj or os.environ.get('JADLOG_CNPJ'))
self.conta = digits(conta if conta is not None else os.environ.get('JADLOG_CONTA'))
# Only when Jadlog issued one; the manual says to send null otherwise.
self.contrato = (contrato if contrato is not None else os.environ.get('JADLOG_CONTRATO', '')).strip() or None
self.origin = digits(origin or os.environ.get('JADLOG_ORIGEM_CEP') or ORIGIN)
self.modalidade = int(modalidade or os.environ.get('JADLOG_MODALIDADE') or PACKAGE)
if not self.token or len(self.cnpj) != 14:
raise RuntimeError('Jadlog needs JADLOG_TOKEN and a 14-digit JADLOG_CNPJ')
self.http = httpx.Client(timeout=20, transport=transport)
def item(self, postal_code, weight_kg, declared_cents):
return {'cepori': self.origin, 'cepdes': digits(postal_code), 'frap': 'N',
'peso': float(weight_kg), 'cnpj': self.cnpj, 'conta': self.conta,
'contrato': self.contrato, 'modalidade': self.modalidade,
'tpentrega': 'D', 'tpseguro': 'N',
'vldeclarado': declared_cents / 100, 'vlcoleta': 0}
def quote(self, postal_code, weight_kg, declared_cents):
"""Price in centavos and delivery days for one package to one CEP."""
response = self.http.post(QUOTE_URL, json={'frete': [self.item(postal_code, weight_kg, declared_cents)]},
headers={'Authorization': self.token})
if response.status_code == 401:
raise JadlogError('Jadlog refused the token (HTTP 401)')
try:
data = response.json()
except ValueError:
raise JadlogError(f'HTTP {response.status_code}: the response is not JSON') from None
items = data.get('frete') or []
# The manual names the group "erro" in its tables and "error" in its
# examples, at the top level and per item.
problem = data.get('error') or data.get('erro')
if not problem and items:
problem = items[0].get('erro') or items[0].get('error')
if problem:
raise JadlogError(problem.get('descricao') or str(problem) if isinstance(problem, dict) else str(problem))
if response.status_code >= 400 or not items or items[0].get('vltotal') is None:
raise JadlogError(f'HTTP {response.status_code}: no freight value in the response')
total = Decimal(str(items[0]['vltotal']))
return {'total_cents': int((total * 100).quantize(Decimal('1'), ROUND_HALF_UP)),
'days': items[0].get('prazo'), 'raw': items[0]}

75
app/jadlog_probe.py Normal file
View File

@@ -0,0 +1,75 @@
"""Read-only price check against the client's real Jadlog account, run by hand.
A quote creates no shipment and costs nothing.
python -m app.jadlog_probe [--cep 01310100 ...] [--peso 0.5 ...] [--valor 100]
Without --cep and --peso it prices a few test weights to a few regions. It
answers whether the token, CNPJ and account are accepted, whether a contract
number is required (Jadlog names it in the error), and what the contract
prices and delivery times are. The first failure stops the run: an account
problem would repeat on every line.
Locally, with the credentials in jadlog.env (ignored by git):
docker compose -f compose.local.yaml -f compose.providers.yaml run --rm --no-deps --build \\
--env-from-file jadlog.env worker python -m app.jadlog_probe
"""
import argparse
import sys
from decimal import Decimal
from .core.secrets import load as load_secret_files
from .jadlog import JadlogError, JadlogQuotes
DESTINATIONS = {'14402310': 'Franca (origem)', '01310100': 'São Paulo', '20040002': 'Rio de Janeiro',
'80010000': 'Curitiba', '50030230': 'Recife', '69005010': 'Manaus'}
WEIGHTS = ['0.3', '0.5', '1', '2', '5']
def money(cents):
return f'R$ {cents / 100:.2f}'.replace('.', ',')
def run(quotes, ceps, weights, declared_cents, out):
header = quotes.token
for weight in weights:
for cep in ceps:
try:
result = quotes.quote(cep, Decimal(weight), declared_cents)
except JadlogError as error:
# The manual shows the header as the bare token; some accounts
# are issued one that expects the Bearer scheme.
if '401' in str(error) and not header.lower().startswith('bearer '):
quotes.token = 'Bearer ' + header
header = quotes.token
try:
result = quotes.quote(cep, Decimal(weight), declared_cents)
except JadlogError as retry:
out(f'ERRO {cep} {weight} kg: {retry} (also with "Bearer ")')
return 1
out('Note: the token only works with the "Bearer " prefix.')
else:
out(f'ERRO {cep} {weight} kg: {error}')
return 1
days = result['days']
out(f"{weight} kg\t{cep}\t{DESTINATIONS.get(cep, '')}\t{money(result['total_cents'])}\t"
f"{days if days is not None else '?'} dia(s)")
return 0
def main(argv=None):
parser = argparse.ArgumentParser(prog='python -m app.jadlog_probe')
parser.add_argument('--cep', action='append', help='destination CEP (repeatable)')
parser.add_argument('--peso', action='append', help='weight in kg (repeatable)')
parser.add_argument('--valor', default='100', help='declared value in reais (default 100)')
args = parser.parse_args(argv)
load_secret_files()
quotes = JadlogQuotes()
declared = int(Decimal(args.valor) * 100)
print(f'Origem {quotes.origin}, modalidade {quotes.modalidade}, contrato {quotes.contrato or "-"}, '
f'valor declarado {money(declared)}')
return run(quotes, args.cep or list(DESTINATIONS), args.peso or WEIGHTS, declared, print)
if __name__ == '__main__':
sys.exit(main())

View File

@@ -134,7 +134,15 @@ class MercadoPagoPayment:
payment_id = str((query or {}).get('data.id') or (data.get('data') or {}).get('id') or '') payment_id = str((query or {}).get('data.id') or (data.get('data') or {}).get('id') or '')
if not payment_id.isdigit(): if not payment_id.isdigit():
return None return None
try:
payment = self.lookup(payment_id) payment = self.lookup(payment_id)
except httpx.HTTPStatusError as error:
# Signed by Mercado Pago but about no payment of ours, such as the
# panel's "Simular notificação". Anything else is raised so that a
# real notification is retried.
if error.response.status_code == 404:
return None
raise
return event_from_payment(payment, notification_id=str(data.get('id', ''))) return event_from_payment(payment, notification_id=str(data.get('id', '')))

View File

@@ -46,7 +46,8 @@ Image.MAX_IMAGE_PIXELS = None
class Unsupported(Exception): 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): class Name(str):
@@ -153,14 +154,14 @@ class SourceImage:
self.image = Image.open(path) self.image = Image.open(path)
self.format = self.image.format self.format = self.image.format
except Image.DecompressionBombError as exc: 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: 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: if self.format not in SUPPORTED_FORMATS:
raise Unsupported(f'"{name}" is {self.format or "an unknown format"}; ' raise Unsupported(f'"{name}" está em {self.format or "formato desconhecido"}; '
'only JPEG, PNG, WebP and TIFF are generated automatically') 'só JPEG, PNG, WebP, TIFF e PDF são gerados automaticamente')
if getattr(self.image, 'n_frames', 1) > 1 and self.format != 'TIFF': 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 # Browsers draw a photo upright according to its EXIF orientation, and
# the Site measured it that way, so the print must too. # the Site measured it that way, so the print must too.
try: try:
@@ -181,8 +182,8 @@ class SourceImage:
return self._embed_jpeg(pdf) return self._embed_jpeg(pdf)
width, height = self.image.size width, height = self.image.size
if width * height > MAX_DECODED_PIXELS: if width * height > MAX_DECODED_PIXELS:
raise Unsupported(f'"{self.name}" has {width} x {height} px, more than the ' raise Unsupported(f'"{self.name}" tem {width} × {height} px, mais do que o '
'generator decodes; prepare this item by hand') 'gerador processa; prepare este item à mão')
return self._embed_pixels(pdf) return self._embed_pixels(pdf)
def _colorspace(self, pdf, mode): def _colorspace(self, pdf, mode):
@@ -235,7 +236,7 @@ class SourceImage:
elif mode in ('P', 'PA', 'RGB', 'RGBA', 'RGBa'): elif mode in ('P', 'PA', 'RGB', 'RGBA', 'RGBa'):
color_mode = 'RGB' color_mode = 'RGB'
else: 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: if has_alpha:
image = image.convert('RGBA' if color_mode == 'RGB' else 'LA') image = image.convert('RGBA' if color_mode == 'RGB' else 'LA')
width, height = image.size width, height = image.size
@@ -304,29 +305,29 @@ class PdfPage:
try: try:
self.pdf = pikepdf.open(path) self.pdf = pikepdf.open(path)
except pikepdf.PasswordError as exc: 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: 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: try:
pages = len(self.pdf.pages) pages = len(self.pdf.pages)
if pages != 1: 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.page = self.pdf.pages[0]
self.rotation = int(self.page.rotation) % 360 self.rotation = int(self.page.rotation) % 360
if self.rotation not in (0, 90, 180, 270): 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] box = [float(v) for v in self.page.cropbox]
except Unsupported: except Unsupported:
self.pdf.close() self.pdf.close()
raise raise
except Exception as exc: except Exception as exc:
self.pdf.close() 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.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]) self.w, self.h = abs(box[2] - box[0]), abs(box[3] - box[1])
if self.w <= 0 or self.h <= 0: if self.w <= 0 or self.h <= 0:
self.pdf.close() 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. # 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) 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'])) height = Decimal(str(production['height_cm']))
billed = Decimal(str(item['billed_metres'])) * 100 billed = Decimal(str(item['billed_metres'])) * 100
if height > billed + HEIGHT_TOLERANCE_CM: 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 lowest = None
for placement in production['placements']: for placement in production['placements']:
width_px, height_px = sizes[placement['source_index']] 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) drift = abs((width_px / height_px) / (width_cm / length_cm) - 1)
if drift > ASPECT_TOLERANCE: if drift > ASPECT_TOLERANCE:
source = production['sources'][placement['source_index']] source = production['sources'][placement['source_index']]
raise Unsupported(f'file {placement["source_index"] + 1} has proportions that do not match ' raise Unsupported(f'o arquivo {placement["source_index"] + 1} tem proporções diferentes '
f'the quoted {source["width_cm"]} x {source["length_cm"]} cm') f'das cotadas ({source["width_cm"]} × {source["length_cm"]} cm)')
if placement['source_index'] in vector: if placement['source_index'] in vector:
continue continue
dpi = width_px / (width_cm / 2.54) dpi = width_px / (width_cm / 2.54)
@@ -405,7 +406,7 @@ def render(item, files, out, title):
""" """
production = item['production'] production = item['production']
if production.get('version') != 2: if production.get('version') != 2:
raise Unsupported('item uses an obsolete production layout') raise Unsupported('o item usa uma montagem antiga')
sources = [] sources = []
try: try:
for index in range(len(production['sources'])): 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']: for upload_id in item['uploads']:
row = uploads.get(upload_id) row = uploads.get(upload_id)
if not row or row['scan_state'] != 'clean': 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']: 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'] number = job['number']
name = f'pedido-{number}-item-{job["item_index"] + 1}.pdf' name = f'pedido-{number}-item-{job["item_index"] + 1}.pdf'
with tempfile.TemporaryDirectory(prefix='print-') as scratch: 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'} 'imp': 'Imprimindo', 'cor': 'Correção', 'fin': 'Finalizado'}
TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'], TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'],
'imp': ['fin','cor'], 'cor': ['rec','tra'], 'fin': []} '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): 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(), connected_by text NOT NULL, connected_at timestamptz NOT NULL DEFAULT now(),
updated_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 -- Single-use states for an operator-started OAuth connection. They protect the
-- callback, which arrives cross-site without the operator's cookie. -- callback, which arrives cross-site without the operator's cookie.
CREATE TABLE IF NOT EXISTS dtf_local.oauth_states ( 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) CREATE INDEX IF NOT EXISTS print_files_open ON dtf_local.print_files(created_at)
WHERE status IN ('pending','rendering'); 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 -- The Kanban lists payment events a person must act on (money without an
-- order, or a reversed payment on an existing order) until resolved. -- 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; ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolved_at timestamptz;

View File

@@ -19,6 +19,13 @@ Idempotency: the outbox may deliver the same event more than once. Every order
carries numeroOrdemCompra = "DTF-<order number>", and before creating one the carries numeroOrdemCompra = "DTF-<order number>", and before creating one the
customer's recent orders are searched for that number, so a second delivery customer's recent orders are searched for that number, so a second delivery
finds the first order instead of creating another. finds the first order instead of creating another.
Customer notices: the client already sends WhatsApp messages from Tiny's
order situação (Tiny webhook -> their middleware -> n8n). With
TINY_STATUS_UPDATES on, a paid order is set to "Aprovada" and a finished
pickup order to "Pronto para envio", so those notices reach Site customers
from the same number and templates; the system's own WhatsApp sender stays
off. Pickup orders carry the client's "retirar pessoalmente" forma de envio.
""" """
import os import os
import secrets import secrets
@@ -41,7 +48,24 @@ PRODUCTS = {'file': 'DTF Têxtil 57 cm · folha montada',
'uv': 'DTF UV 28,5 cm · artes avulsas'} 'uv': 'DTF UV 28,5 cm · artes avulsas'}
# How far back to look for an order a previous delivery may already have made. # How far back to look for an order a previous delivery may already have made.
SEARCH_DAYS = 7 SEARCH_DAYS = 7
# A "ready" event can come after corrections; it normally knows the Tiny id.
READY_SEARCH_DAYS = 45
# Tiny v3 order situações (PUT /pedidos/{id}/situacao).
ABERTA, APROVADA, PRONTO_ENVIO, ENVIADA, ENTREGUE, CANCELADA, NAO_ENTREGUE = 0, 3, 7, 5, 6, 2, 9
SITUACOES = {0: 'Aberta', 3: 'Aprovada', 4: 'Preparando envio', 1: 'Faturada', 7: 'Pronto para envio',
5: 'Enviada', 6: 'Entregue', 2: 'Cancelada', 8: 'Dados incompletos', 9: 'Não entregue'}
# The client's customer notices (Tiny webhook -> middleware -> n8n -> WhatsApp)
# react to these situações. Off until go-live: while n8n still sends the
# designer message for DTFIMP products on "Aprovado", setting it would reach
# Site customers.
STATUS_UPDATES_SETTING = 'TINY_STATUS_UPDATES'
# The id of the client's custom "retirar pessoalmente" forma de envio (v2 code
# X), which the pickup notice is keyed on. Find it with the console tool.
PICKUP_SETTING = 'TINY_FORMA_ENVIO_RETIRADA'
STATE_MINUTES = 10 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. # Brazil has had no daylight saving since 2019; the order date is the local day.
BRASILIA = timezone(timedelta(hours=-3)) BRASILIA = timezone(timedelta(hours=-3))
@@ -71,6 +95,10 @@ def required_settings():
return ['TINY_CLIENT_ID', 'TINY_CLIENT_SECRET', 'TINY_REDIRECT_URI'] + list(PRODUCT_SETTINGS.values()) return ['TINY_CLIENT_ID', 'TINY_CLIENT_SECRET', 'TINY_REDIRECT_URI'] + list(PRODUCT_SETTINGS.values())
def status_updates():
return os.environ.get(STATUS_UPDATES_SETTING, '').lower() == 'true'
# OAuth ------------------------------------------------------------------- # OAuth -------------------------------------------------------------------
class TinyAuth: class TinyAuth:
@@ -86,44 +114,78 @@ class TinyAuth:
self.http = httpx.Client(timeout=20, transport=transport) self.http = httpx.Client(timeout=20, transport=transport)
self.clock = clock 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 """Start a connection. The state is single-use, short-lived, and only an
authenticated operator can create one, which is what protects the authenticated operator can create one, which is what protects the
callback: Tiny's redirect back is cross-site, so the operator's 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) state = secrets.token_urlsafe(32)
with self.connect() as c: with self.connect() as c:
c.execute("DELETE FROM dtf_local.oauth_states WHERE expires_at<now()") 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) 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))) 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, 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}) 'state': state})
def complete(self, code, state): def _claim(self, c, 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' row = c.execute('''DELETE FROM dtf_local.oauth_states WHERE state=%s AND provider='tiny'
AND expires_at>now() RETURNING operator''', (state,)).fetchone() AND expires_at>now() RETURNING operator''', (state,)).fetchone()
if not row: if not row:
raise TinyError('Unknown or expired authorisation state') 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:
operator = self._claim(c, state)
tokens = self._token({'grant_type': 'authorization_code', 'code': code, tokens = self._token({'grant_type': 'authorization_code', 'code': code,
'redirect_uri': self.redirect_uri}) 'redirect_uri': self.redirect_uri})
self._store(c, tokens, row['operator']) self._store(c, tokens, operator)
return row['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): def status(self):
with self.connect() as c: with self.connect() as c:
row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at,updated_at,offline,
FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone() refresh_failed_at,refused_at FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone()
if not row: if not row:
return {'connected': False} return {'connected': False}
expired = row['refresh_expires_at'] and row['refresh_expires_at'] <= datetime.now(timezone.utc) now = datetime.now(timezone.utc)
return {'connected': not expired, 'connected_by': row['connected_by'], expires = row['refresh_expires_at']
'connected_at': row['connected_at'], 'expires_at': 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): 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: with self.connect() as c:
row = c.execute('''SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny' row = c.execute('''SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny'
FOR UPDATE''').fetchone() FOR UPDATE''').fetchone()
@@ -132,11 +194,25 @@ class TinyAuth:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
if row['access_expires_at'] > now + timedelta(seconds=60): if row['access_expires_at'] > now + timedelta(seconds=60):
return row['access_token'] 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: if row['refresh_expires_at'] and row['refresh_expires_at'] <= now:
raise TinyNotConnected('The Tiny connection expired; connect again on the Kanban') raise TinyNotConnected('The Tiny connection expired; connect again on the Kanban')
try:
tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']}) 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) self._store(c, tokens, row['connected_by'], refreshed=True)
return tokens['access_token'] 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): def _token(self, form):
response = self.http.post(f'{AUTH}/token', data={**form, 'client_id': self.client_id, response = self.http.post(f'{AUTH}/token', data={**form, 'client_id': self.client_id,
@@ -152,18 +228,21 @@ class TinyAuth:
def _store(self, c, tokens, operator, refreshed=False): def _store(self, c, tokens, operator, refreshed=False):
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
access_expires = now + timedelta(seconds=int(tokens.get('expires_in', 300))) 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_in = tokens.get('refresh_expires_in')
refresh_expires = now + timedelta(seconds=int(refresh_in)) if refresh_in else None 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, 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) access_expires_at,refresh_expires_at,connected_by,connected_at,updated_at,offline)
VALUES('tiny',%s,%s,%s,%s,%s,now(),now()) VALUES('tiny',%s,%s,%s,%s,%s,now(),now(),%s)
ON CONFLICT(provider) DO UPDATE SET access_token=EXCLUDED.access_token, ON CONFLICT(provider) DO UPDATE SET access_token=EXCLUDED.access_token,
refresh_token=EXCLUDED.refresh_token, access_expires_at=EXCLUDED.access_expires_at, 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_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''', 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, (tokens['access_token'], tokens['refresh_token'], access_expires, refresh_expires,
operator, refreshed, refreshed)) operator, offline, refreshed, refreshed))
# Orders ------------------------------------------------------------------ # Orders ------------------------------------------------------------------
@@ -212,6 +291,9 @@ def order_payload(payload, contact_id, today=None):
'valorFrete': money(freight['total_cents']), 'valorFrete': money(freight['total_cents']),
'observacoes': f"Pedido DTF #{payload['number']}" + (' · retirada em Franca' if pickup else ''), 'observacoes': f"Pedido DTF #{payload['number']}" + (' · retirada em Franca' if pickup else ''),
'observacoesInternas': f"Site DTF · pago · {payload['order_id']}"} 'observacoesInternas': f"Site DTF · pago · {payload['order_id']}"}
pickup_method = os.environ.get(PICKUP_SETTING, '')
if pickup and pickup_method.isdigit():
pedido['transportador'] = {'formaEnvio': {'id': int(pickup_method)}}
destination = order.get('destination') destination = order.get('destination')
if destination: if destination:
pedido['enderecoEntrega'] = {**address(destination), 'enderecoNro': destination['number'], pedido['enderecoEntrega'] = {**address(destination), 'enderecoNro': destination['number'],
@@ -223,6 +305,53 @@ def order_payload(payload, contact_id, today=None):
return pedido 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 configured_pickup(orders):
"""The forma de envio the pickup setting points at, or why it cannot be used."""
value = os.environ.get(PICKUP_SETTING, '')
if not value.isdigit():
return None, f'{PICKUP_SETTING} sem id'
return orders.request('GET', f'/formas-envio/{value}'), 'ok'
def check(auth=None, transport=None, orders=None):
"""Read-only proof that the connection and permissions work: one order and
one contact listed, each configured product found active and the pickup
forma de envio found, 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]
reads.append(('forma de envio de retirada', lambda: configured_pickup(orders)[1]))
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: class TinyOrders:
def __init__(self, auth=None, transport=None, today=None): def __init__(self, auth=None, transport=None, today=None):
missing = [name for name in required_settings() if not os.environ.get(name)] missing = [name for name in required_settings() if not os.environ.get(name)]
@@ -249,10 +378,10 @@ class TinyOrders:
return entry['id'] return entry['id']
return self.request('POST', '/contatos', json=contact_payload(order))['id'] return self.request('POST', '/contatos', json=contact_payload(order))['id']
def find(self, payload): def find(self, payload, days=SEARCH_DAYS):
"""An order a previous delivery already created, or None.""" """An order a previous delivery already created, or None."""
wanted = purchase_order(payload['number']) wanted = purchase_order(payload['number'])
since = ((self.today or local_today()) - timedelta(days=SEARCH_DAYS)).isoformat() since = ((self.today or local_today()) - timedelta(days=days)).isoformat()
found = self.request('GET', '/pedidos', params={'cpfCnpj': payload['order']['customer']['cnpj'], found = self.request('GET', '/pedidos', params={'cpfCnpj': payload['order']['customer']['cnpj'],
'dataInicial': since, 'limit': 100}) 'dataInicial': since, 'limit': 100})
for entry in found.get('itens') or []: for entry in found.get('itens') or []:
@@ -261,16 +390,55 @@ class TinyOrders:
return detail return detail
return None return None
def set_situacao(self, tiny_id, situacao):
self.request('PUT', f'/pedidos/{tiny_id}/situacao', json={'situacao': situacao})
def deliver(self, event_key, payload): def deliver(self, event_key, payload):
if payload.get('event') != 'payment_approved': event = payload.get('event')
# Production progress is not written to Tiny; only the sale is. if event == 'payment_approved':
return {'provider': 'tiny', 'event_key': event_key, 'status': 'not-applicable', return self.sale(event_key, payload)
'event': payload.get('event')} if event == 'ready' and status_updates():
return self.ready(event_key, payload)
# Other production events have no Tiny situação and are not written.
return {'provider': 'tiny', 'event_key': event_key, 'status': 'not-applicable', 'event': event}
def sale(self, event_key, payload):
"""Create the order once; with status updates on, approve it once.
An order is created "Aberta" and then set to "Aprovada", so a retry
after a failure between the two finds it still open and finishes the
job, and an order someone has already moved on is left alone."""
existing = self.find(payload) existing = self.find(payload)
if existing: if existing:
return {'provider': 'tiny', 'event_key': event_key, 'status': 'already-created', receipt = {'provider': 'tiny', 'event_key': event_key, 'status': 'already-created',
'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numeroPedido'))} 'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numeroPedido'))}
tiny_id, situacao = existing.get('id'), existing.get('situacao')
else:
contact_id = self.contact(payload['order']) contact_id = self.contact(payload['order'])
created = self.request('POST', '/pedidos', json=order_payload(payload, contact_id, self.today)) created = self.request('POST', '/pedidos', json=order_payload(payload, contact_id, self.today))
return {'provider': 'tiny', 'event_key': event_key, 'status': 'created', receipt = {'provider': 'tiny', 'event_key': event_key, 'status': 'created',
'tiny_id': str(created.get('id')), 'tiny_number': str(created.get('numeroPedido'))} 'tiny_id': str(created.get('id')), 'tiny_number': str(created.get('numeroPedido'))}
tiny_id, situacao = created.get('id'), ABERTA
if status_updates() and situacao == ABERTA:
self.set_situacao(tiny_id, APROVADA)
receipt['situacao'] = SITUACOES[APROVADA]
return receipt
def ready(self, event_key, payload):
"""A pickup order became ready: "Pronto para envio", which the client's
notices turn into the pickup message. Shipped orders get "Enviada"
once freight exists (1.2)."""
base = {'provider': 'tiny', 'event_key': event_key}
order = payload.get('order') or {}
if (order.get('freight') or {}).get('service') != 'pickup':
return {**base, 'status': 'not-applicable', 'event': 'ready'}
tiny_id = payload.get('tiny_id')
detail = self.request('GET', f'/pedidos/{tiny_id}') if tiny_id else self.find(payload, READY_SEARCH_DAYS)
if not detail:
raise TinyError(f"{purchase_order(payload['number'])} is not in Tiny yet; the outbox will retry")
base.update(tiny_id=str(detail.get('id')), tiny_number=str(detail.get('numeroPedido')))
situacao = detail.get('situacao')
if situacao in (PRONTO_ENVIO, ENVIADA, ENTREGUE, CANCELADA, NAO_ENTREGUE):
return {**base, 'status': 'status-unchanged', 'situacao': SITUACOES.get(situacao, str(situacao))}
self.set_situacao(detail['id'], PRONTO_ENVIO)
return {**base, 'status': 'status-updated', 'situacao': SITUACOES[PRONTO_ENVIO]}

153
app/tiny_probe.py Normal file
View File

@@ -0,0 +1,153 @@
"""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 formas-envio [termo] list formas de envio, to find pickup (read-only)
python -m app.tiny_probe conferir connection, the four product ids and pickup (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 formas_envio(orders, termo, out):
params = {'limit': 100}
if termo:
params['nome'] = termo
found = orders.request('GET', '/formas-envio', params=params).get('itens') or []
for item in found:
out(f"{item.get('id')}\t{item.get('tipo')}\t{item.get('nome')}")
out(f'{len(found)} forma(s) de envio' + (f' com "{termo}"' if termo else '') +
'. A retirada é a do tipo 6 (Customizado) que a equipe usa para retirar pessoalmente.')
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())
method, result = tiny.configured_pickup(orders)
ok &= result == 'ok'
out(f"{tiny.PICKUP_SETTING} (retirada): {result} {method.get('nome') if method else ''}".rstrip())
out(f"{tiny.STATUS_UPDATES_SETTING}: {'ligado' if tiny.status_updates() else 'desligado'}")
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='')
methods = commands.add_parser('formas-envio')
methods.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 == 'formas-envio':
formas_envio(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 # 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. # 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 / { location / {
expires -1; expires -1;
try_files $uri $uri/ =404; try_files $uri $uri/ =404;

View File

@@ -27,6 +27,10 @@ POSTGRES_VOLUME=TBD
OPERATOR_EMAIL=TBD OPERATOR_EMAIL=TBD
PAYMENT_ADAPTER=TBD PAYMENT_ADAPTER=TBD
# With PAYMENT_ADAPTER=mercadopago. MP_ACCESS_TOKEN and MP_WEBHOOK_SECRET are
# secrets: enter them in Portainer only, never in this file.
MP_NOTIFICATION_URL=https://<SITE_DOMAIN>/api/payments/webhook
MP_PUBLIC_KEY=
FREIGHT_ADAPTER=TBD FREIGHT_ADAPTER=TBD
TINY_ADAPTER=TBD TINY_ADAPTER=TBD
# Tiny API v3 application (Configurações > Geral > Aplicativos in Tiny). The # Tiny API v3 application (Configurações > Geral > Aplicativos in Tiny). The
@@ -37,6 +41,9 @@ TINY_PRODUCT_TEXTIL_FOLHA=TBD
TINY_PRODUCT_TEXTIL_AVULSA=TBD TINY_PRODUCT_TEXTIL_AVULSA=TBD
TINY_PRODUCT_UV_FOLHA=TBD TINY_PRODUCT_UV_FOLHA=TBD
TINY_PRODUCT_UV_AVULSA=TBD TINY_PRODUCT_UV_AVULSA=TBD
TINY_FORMA_ENVIO_RETIRADA=TBD
# true only at go-live, after the n8n DTFIMP designer branch is removed.
TINY_STATUS_UPDATES=false
WHATSAPP_ADAPTER=TBD WHATSAPP_ADAPTER=TBD
STORAGE_QUOTA_BYTES=TBD STORAGE_QUOTA_BYTES=TBD
OWNER_UPLOAD_QUOTA_BYTES=TBD OWNER_UPLOAD_QUOTA_BYTES=TBD

View File

@@ -19,10 +19,20 @@ x-app-environment: &app-environment
# this value is configured. # this value is configured.
OPERATOR_EMAIL: ${OPERATOR_EMAIL:-} OPERATOR_EMAIL: ${OPERATOR_EMAIL:-}
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD} OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD}
PAYMENT_ADAPTER: fake # fake until Mercado Pago is configured; mercadopago requires MP_ACCESS_TOKEN
# Optional: without it the webhook verifies nothing and therefore accepts # and MP_WEBHOOK_SECRET or the API and worker refuse to start. Test
# nothing, which is the correct state until a provider is connected. Set it # credentials (TEST-...) until the sandbox flows have passed. The card form
# when the provider is configured, never to a value anyone could guess. # appears only with MP_PUBLIC_KEY, and then needs PAYMENT_CSP_SOURCES too.
PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:-fake}
MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN:-}
# The "assinatura secreta" from the webhook settings in Mercado Pago.
MP_WEBHOOK_SECRET: ${MP_WEBHOOK_SECRET:-}
# https://<SITE_DOMAIN>/api/payments/webhook, sent with every payment.
MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-}
MP_PUBLIC_KEY: ${MP_PUBLIC_KEY:-}
# The fake adapter's secret. Optional: without it the webhook verifies
# nothing and therefore accepts nothing, which is the correct state until a
# provider is connected. Never set it to a value anyone could guess.
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-} PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-}
FREIGHT_ADAPTER: fake FREIGHT_ADAPTER: fake
# Order creation in Tiny stays off until it has been tested against the # Order creation in Tiny stays off until it has been tested against the
@@ -37,6 +47,13 @@ x-app-environment: &app-environment
TINY_PRODUCT_TEXTIL_AVULSA: ${TINY_PRODUCT_TEXTIL_AVULSA:-} TINY_PRODUCT_TEXTIL_AVULSA: ${TINY_PRODUCT_TEXTIL_AVULSA:-}
TINY_PRODUCT_UV_FOLHA: ${TINY_PRODUCT_UV_FOLHA:-} TINY_PRODUCT_UV_FOLHA: ${TINY_PRODUCT_UV_FOLHA:-}
TINY_PRODUCT_UV_AVULSA: ${TINY_PRODUCT_UV_AVULSA:-} TINY_PRODUCT_UV_AVULSA: ${TINY_PRODUCT_UV_AVULSA:-}
# The client's "retirar pessoalmente" forma de envio id, on pickup orders.
TINY_FORMA_ENVIO_RETIRADA: ${TINY_FORMA_ENVIO_RETIRADA:-}
# Sets "Aprovada" on paid orders and "Pronto para envio" on finished pickup
# orders, which the client's Tiny -> n8n notices send to customers. Turn on
# only together with TINY_ADAPTER=tiny and after n8n stops sending the
# designer message for DTFIMP products.
TINY_STATUS_UPDATES: ${TINY_STATUS_UPDATES:-false}
WHATSAPP_ADAPTER: fake WHATSAPP_ADAPTER: fake
STORAGE_ADAPTER: s3-r2 STORAGE_ADAPTER: s3-r2
PUBLIC_ORIGIN: https://${SITE_DOMAIN:?set SITE_DOMAIN} PUBLIC_ORIGIN: https://${SITE_DOMAIN:?set SITE_DOMAIN}

View File

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

View File

@@ -7,16 +7,24 @@
> Update the **Current step** line and the item status every time something moves. > 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. > 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 **Current step (2026-09-25, last day of Week 2):** Still waiting on the client
freight were requested on 2026-09-24; Tiny access is already with the client. for Mercado Pago credentials and webhook access (1.1) and freight data (1.2).
Built without them: server-side print-file generation (1.4), the delivery Tiny is connected in production and reads orders, but the connected user
address (3.8), the Kanban's payment-issue and print-file views (1.5), and cannot read contacts (403) and the client's catalogue has no per-metre UV
Mercado Pago and Tiny adapters written from the public API documentation and product; both wait on the client (1.3). Built and deployed without them: server-side print-file
tested against fake transports (1.1, 1.3). None of the provider work is a generation (1.4), the delivery address (3.8), the Kanban's payment-issue and
verified integration. The complete CI integration sequence passed locally on print-file views and its redesign (1.5), the Site redesign (5.17), and Mercado
2026-09-24 (all API suites including the new `print_file_test`, adapter and Pago and Tiny adapters written from the public API documentation and tested
generator unit suites, retention, runtime security, and both browser suites), against fake transports. None of the provider work is a verified integration.
run with Docker Engine in WSL against a fresh build; pending a Gitea runner run. 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:** the Week 2 client report. Waiting on the client: the
Contatos permission and the product decision, then product ids and one
supervised real order (1.3); Mercado Pago credentials for the sandbox PIX and
card payments (1.1); freight data (1.2, the one Week 2 item that slips, on
client inputs).
**Previous step (2026-09-23):** Payment safety fixes 2.13 and 2.14 and **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. the local order-correctness work in 3.6/3.9 have passed integration checks.
@@ -239,9 +247,24 @@ From the report already sent. These are dated promises, not backlog.
quote cannot be charged twice. The Site CSP gains the Mercado Pago origins quote cannot be charged twice. The Site CSP gains the Mercado Pago origins
only through `PAYMENT_CSP_SOURCES`, empty by default. Not yet rendered only through `PAYMENT_CSP_SOURCES`, empty by default. Not yet rendered
against a real public key; sandbox run and refund policy remain. against a real public key; sandbox run and refund policy remain.
**Account setup (2026-09-28):** the client's application is Checkout
Transparente on the Payments API, webhook event "Pagamentos (legacy)" only,
URL `https://dtf.agenciacompor.com.br/api/payments/webhook`. The production
compose now takes `PAYMENT_ADAPTER` and `MP_*` from Portainer (it hard-coded
the fake adapter), so the sandbox runs on production with test credentials;
the Site has no real customers yet. A verified notification whose payment
does not exist (the panel's "Simular notificação") is acknowledged instead
of answering 500, which would have made Mercado Pago retry it.
- `[ ]` 1.2 — Real freight quotation. **Blocked on client inputs** (see - `[ ]` 1.2 — Real freight quotation. **Blocked on client inputs** (see
`PRODUCTION_INPUTS.md`): source platform, credentials, origin CEP, services, `PRODUCTION_INPUTS.md`): source platform, credentials, origin CEP, services,
packaging weight/dimensions per length, subsidy policy. packaging weight/dimensions per length, subsidy policy.
**Received (2026-09-25):** Jadlog, API access (user/CNPJ, client code,
token, account), origin CEP 14402-310, service .PACKAGE (modalidade 3),
home delivery, billed by contract. Still missing: packaging weight and
dimensions per length and how freight is charged to the customer (asked
2026-09-28). `app/jadlog.py` prices one package from the manual (v2.3);
`python -m app.jadlog_probe` prices test weights to six regions, read-only,
to confirm token, account and contract on the client's account. Not yet run.
- `[~]` 1.3 — Idempotent Tiny/Olist order creation with order-number traceability. - `[~]` 1.3 — Idempotent Tiny/Olist order creation with order-number traceability.
Confirm endpoints, tag behaviour and rate limits first. Confirm endpoints, tag behaviour and rate limits first.
**Groundwork (2026-09-24), API v3 by decision:** OAuth2 against Tiny's **Groundwork (2026-09-24), API v3 by decision:** OAuth2 against Tiny's
@@ -261,6 +284,69 @@ From the report already sent. These are dated promises, not backlog.
real orders. Still to confirm on the client's account: plan (Construa+), real orders. Still to confirm on the client's account: plan (Construa+),
product ids, token lifetimes, whether pickup needs a transportador, and product ids, token lifetimes, whether pickup needs a transportador, and
rate limits. 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.
**Client account (2026-09-25):** connected in production with the
developer user the client provided. "Testar conexão": `pedidos` ok,
`contatos` 403, unchanged after reconnecting. The application's permissions
are correct (Contatos, Pedidos: read and include/edit; Produtos: read);
Olist documents that v3 calls also depend on the logged-in user's module
permissions, and that user's Cadastros menu shows only Produtos. Orders
need Contatos (search by CNPJ, create when new; delete never). **Client
to decide:** grant that user Clientes e Fornecedores, or reconnect with a
user that has it (a dedicated integration user is recommended).
**Products (2026-09-25),** from the client's product export: none of the
36 "DTF" products matches the four Site products. `951438842` "IMPRESSÃO
DTF PERSONALIZADO 57X100 (1 METRO)" (MT, R$ 19,90, active) fits Têxtil;
there is no per-metre UV product (the UV one is 27 cm, per 10 cm), and no
separate "artes avulsas" product. **Client to decide:** create four per-metre
products (recommended, copying `951438842`), or share `951438842` between
the two Têxtil modes; UV needs a product either way. The item note already
carries the Site mode and grade. No product ids are set in production yet;
both questions go to the client with the Mercado Pago credentials request.
**Customer notices through Tiny (2026-09-25, Week 3 head start):** the client
already sends WhatsApp notices from Tiny's order situação (Tiny webhook ->
the `api-tiny-n8n` middleware, which reads the order through API v2 -> n8n
-> WhatsApp templates): Aprovado, Pronto para envio with forma de envio `X`
(v2 "Customizada", their pickup), Enviado, Entregue. So the system's own
WhatsApp sender stays off and Tiny drives the notices. With
`TINY_STATUS_UPDATES=true` a paid order is created "Aberta" and set to
"Aprovada" (a retry finishes a half-done approval; an order already moved on
is left alone), and moving a pickup order to Finalizado sets "Pronto para
envio" by the Tiny id from the sale's receipt, searching only when it is
missing and retrying while the sale has not reached Tiny. Pickup orders
carry `TINY_FORMA_ENVIO_RETIRADA` (`tiny_probe formas-envio` lists the ids;
"Testar conexão" now checks it). Delivery orders get "Enviada" with 1.2.
Off by default: while n8n's `isDTFIMP` branch exists, "Aprovado" on a
`DTFIMP` product sends the designer message, and the Site's Têxtil product
code starts with `DTFIMP`. **Go-live together:** `TINY_ADAPTER=tiny`,
`TINY_STATUS_UPDATES=true`, n8n's `isDTFIMP`/"DTF Aprovado - Designer"
removed with "Mapear Whatsapp do Vendedor" connected to `If6`, and the
middleware's `numero_ecommerce` falling back to the purchase order so the
message shows `DTF-<n>`. **Unverified on the account:** that an API status
change fires Tiny's webhook, that Tiny accepts Aprovada -> Pronto para envio
without Faturada, and the v2 field name for the purchase order. Correção
necessária and Produção iniciada have no Tiny situação; client to decide
whether they need messages.
- `[~]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions - `[~]` 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). 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 **Built (2026-09-24):** each paid item gets a PDF the width of the film and
@@ -287,6 +373,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 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 (money without an order, refunds after an order) until resolved. Confirm
with the operation that these are the main states before closing. 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. - `[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'`) `[!]` The production compose currently blocks `dev_paid` (`ENVIRONMENT != 'local'`)
@@ -710,6 +807,22 @@ print-file evidence still need correction before this item can close.
attempt counts against the source address and only failures count against attempt counts against the source address and only failures count against
the account; registration still counts every attempt. The security suite's the account; registration still counts every attempt. The security suite's
lockout check (ten failures, then 429) is unchanged and passes. 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 - `[ ]` 5.13 — Define production recovery: scheduled encrypted offsite database
and object backups, a consistent snapshot boundary, Swarm data placement and and object backups, a consistent snapshot boundary, Swarm data placement and
a restore rehearsal that opens every required live order file. a restore rehearsal that opens every required live order file.
@@ -831,6 +944,28 @@ print-file evidence still need correction before this item can close.
### Reporting ### 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".
- `[x]` Operator guide rebuilt from a committed source (2026-09-25):
`docs/guias/operador/` (HTML and the original screenshots, cropped as
before), printed by `docs/guias/imprimir.sh`. The source of the 09-24 PDF
was never committed. Corrected: stage moves do not update Tiny (only
"Aprovada" and, for pickup, "Pronto para envio", once enabled); WhatsApp
notices go through the client's Tiny -> n8n flow; the correction notice is
not automatic, the reason is in Minha conta; the Tiny card's renewal,
"Verificar" and "Testar conexão" checks. Found while writing it: the
customer's order history showed operators' internal reasons for moving an
order back; it now omits back moves and shows reasons only for corrections.
The Site guide's source is also not in the repository.
- `[x]` Week-2 client report (`Relatorio-Semana-2-DTF.docx`), written 2026-09-25,
sent 2026-09-28. Before sending, the FlexiPRINT import was taken out of
"O que falta" (it is done this week) and the print-file paragraph no longer
names PSD, AI, CDR or multi-page PDFs as hand-preparation cases: generating
those is Week 3 work (1.4). Freight is reported as Jadlog data received,
waiting on packaging weight and dimensions and the charging policy.
- `[x]` Week-1 client report (`Relatorio-Semana-1-DTF.docx`), corrected 2026-09-18 to - `[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" remove the inaccurate "Arquivo por metro permanece separado, com seleção explícita"
claim and the internal commit reference. claim and the internal commit reference.

Binary file not shown.

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

Binary file not shown.

8
docs/guias/imprimir.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/bin/sh
# Print the guide sources in docs/guias/ to the PDFs in docs/ with headless
# Chrome (A4, no browser header or footer). Needs Chrome and the DejaVu fonts.
set -eu
cd "$(dirname "$0")"
CHROME=${CHROME:-$(command -v google-chrome-stable || command -v google-chrome || command -v chromium)}
"$CHROME" --headless --disable-gpu --no-pdf-header-footer --run-all-compositor-stages-before-draw \
--print-to-pdf="$PWD/../guia-operador-kanban.pdf" "file://$PWD/operador/guia-operador-kanban.html"

View File

@@ -0,0 +1,320 @@
<!DOCTYPE html>
<!-- Source of docs/guia-operador-kanban.pdf. Print with docs/guias/imprimir.sh.
Screenshots in img/ come from a throwaway stack with fictional orders. -->
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title>Kanban DTF · Guia do operador</title>
<style>
@page { size: A4; margin: 0; }
:root {
--navy: #0B1320; --orange: #F08A24; --ink: #1B2331; --muted: #6B7280;
--line: #D6DCE4; --head: #EAF0F8; --note: #FFF5E8; --note-line: #F4C58E;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { font-family: 'DejaVu Sans', sans-serif; color: var(--ink); font-size: 9.7pt; line-height: 1.55;
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
.page { width: 210mm; height: 297mm; position: relative; padding: 26mm 18mm 20mm; overflow: hidden;
page-break-after: always; break-after: page; }
.page:last-child { page-break-after: auto; break-after: auto; }
.running { position: absolute; top: 9mm; left: 18mm; right: 18mm; font-size: 7.4pt; color: var(--muted);
border-bottom: 0.6pt solid #E3E7ED; padding-bottom: 2.4mm; }
.folio { position: absolute; bottom: 9mm; right: 18mm; font-size: 7.4pt; color: var(--muted); }
.kicker { color: var(--orange); font-weight: bold; font-size: 7.6pt; letter-spacing: 0.04em; margin: 0 0 1mm; }
h1 { font-size: 20.5pt; line-height: 1.2; margin: 0 0 2mm; color: #101826; }
h1::after { content: ""; display: block; width: 18mm; height: 1.4mm; background: var(--orange); margin-top: 3mm; }
h2 { font-size: 11.5pt; margin: 7mm 0 2.5mm; color: #101826; }
p { margin: 0 0 2.6mm; }
b { font-weight: bold; }
table { width: 100%; border-collapse: collapse; margin: 2mm 0 3mm; font-size: 8.6pt; line-height: 1.45; }
th, td { border: 0.6pt solid var(--line); padding: 2mm 2.4mm; vertical-align: top; text-align: left; }
th { background: var(--head); font-weight: bold; }
td.n { color: var(--orange); font-weight: bold; font-size: 11pt; width: 7mm; text-align: center; }
td.k { font-weight: bold; }
.note { background: var(--note); border: 0.6pt solid var(--note-line); border-radius: 2mm; padding: 3mm 3.6mm;
font-size: 8.6pt; margin: 3mm 0; }
ul { margin: 0 0 3mm; padding-left: 4.5mm; }
ul li { margin-bottom: 1.1mm; }
ul li::marker { color: var(--orange); }
ol { margin: 0 0 3mm; padding-left: 5mm; }
ol li { margin-bottom: 1.3mm; }
ol li::marker { color: var(--orange); font-weight: bold; }
.chip { display: inline-block; border: 0.6pt solid #C9D0DA; background: #F4F6F9; border-radius: 1.2mm;
padding: 0 1.6mm; font-size: 7.6pt; line-height: 1.6; white-space: nowrap; }
.shot { display: block; width: 100%; border-radius: 1.6mm; margin: 2mm 0 1mm; }
.caption { font-size: 7.2pt; color: var(--muted); margin: 0 0 3mm; }
.split { display: flex; gap: 6mm; align-items: flex-start; }
.split > div { flex: 1; }
.split > .side { flex: 0 0 79.5mm; }
.quote { font-style: normal; }
/* Cover */
.cover { background: var(--navy); color: #F3F5F8; padding: 0 18mm; }
.cover .block { position: absolute; left: 18mm; right: 18mm; top: 158mm; }
.cover .kicker { margin-bottom: 4mm; }
.cover .title { font-size: 27pt; font-weight: bold; line-height: 1.25; margin: 0 0 4mm; color: #fff; }
.cover .lead { font-size: 9.6pt; color: #C8CFDA; margin: 0 0 8mm; }
.cover table { font-size: 8pt; margin: 0 0 8mm; }
.cover th, .cover td { border-color: #2A3547; background: transparent; color: #E6EAF0; padding: 3mm 3.6mm; }
.cover th { color: var(--orange); }
.cover .version { font-size: 8pt; color: #A9B2C0; }
</style>
</head>
<body>
<!-- 1 · Capa -->
<section class="page cover">
<div class="block">
<p class="kicker">GUIA DE OPERAÇÃO</p>
<p class="title">Kanban DTF<br>Guia do operador</p>
<p class="lead">Como conferir, produzir e entregar os pedidos que chegam pelo Site DTF.</p>
<table>
<tr><th style="width:50%">Acesso</th><th>Quem usa</th></tr>
<tr><td>dtf.kanban.agenciacompor.com.br<br>e-mail e senha do operador</td>
<td>Equipe da sala de DTF e atendimento</td></tr>
</table>
<p class="version">Versão de 25 de setembro de 2026</p>
</div>
</section>
<!-- 2 · Visão geral -->
<section class="page">
<div class="running">DTF Dropstar - guia do operador</div>
<p class="kicker">VISÃO GERAL</p>
<h1>O caminho de um pedido</h1>
<p>O cliente monta a folha e envia as artes pelo Site DTF. Daí em diante, tudo acontece no Kanban: a equipe
confere a cotação, o cliente paga, e o pedido percorre as etapas de produção até ficar pronto para retirada.</p>
<table>
<tr><td class="n">1</td><td class="k" style="width:31mm">Cliente envia</td>
<td>Monta a folha no Site, vê a nota e o preço e envia o pedido para conferência.</td></tr>
<tr><td class="n">2</td><td class="k">Equipe confere a cotação</td>
<td>Na aba <b>Cotações</b>: confere arquivos, metragem e nota e aprova.</td></tr>
<tr><td class="n">3</td><td class="k">Cliente paga</td>
<td>O total aprovado aparece no Site. O pagamento é pelo Mercado Pago (PIX ou cartão).</td></tr>
<tr><td class="n">4</td><td class="k">Pedido entra na produção</td>
<td>Com o pagamento aprovado, o pedido aparece em <b>Arte recebida</b>, com o PDF de impressão já gerado.</td></tr>
<tr><td class="n">5</td><td class="k">Equipe produz</td>
<td>Aprova o arquivo final, baixa, importa no FlexiPRINT e move o card a cada etapa.</td></tr>
<tr><td class="n">6</td><td class="k">Pedido pronto</td>
<td>Em <b>Finalizado</b>, o pedido fica pronto para retirada e o cliente é avisado (página 7).</td></tr>
</table>
<h2>As quatro abas</h2>
<table>
<tr><th style="width:31mm">Aba</th><th>Para que serve</th></tr>
<tr><td class="k">Produção</td><td>O quadro com os pedidos pagos, uma coluna por etapa.</td></tr>
<tr><td class="k">Cotações</td><td>Pedidos enviados pelo Site esperando conferência. O número ao lado indica quantos faltam revisar.</td></tr>
<tr><td class="k">Pagamentos</td><td>Pagamentos que o sistema não conseguiu ligar a um pedido. Normalmente fica vazia.</td></tr>
<tr><td class="k">Integrações</td><td>Situação do Tiny, Mercado Pago, frete e WhatsApp, e o registro de tudo o que foi enviado a eles.</td></tr>
</table>
<div class="note">O quadro não se atualiza sozinho. Clique em <b>Atualizar</b>, no canto superior direito, para ver pedidos e cotações novos.</div>
<div class="folio">Página 2</div>
</section>
<!-- 3 · Entrar e ler o quadro -->
<section class="page">
<div class="running">DTF Dropstar - guia do operador</div>
<p class="kicker">PRODUÇÃO</p>
<h1>Entrar e ler o quadro</h1>
<div class="split">
<div>
<p>Acesse <b>dtf.kanban.agenciacompor.com.br</b> e entre com seu e-mail e senha. Cada operador tem o próprio
acesso: é o nome dele que fica registrado em cada movimento do pedido.</p>
<p>Depois de várias senhas erradas seguidas, o acesso fica bloqueado por 15 minutos.</p>
</div>
<div class="side"><img class="shot" src="img/login.png" alt="Tela de entrada do Kanban"></div>
</div>
<img class="shot" src="img/quadro.png" alt="Quadro de produção">
<p class="caption">Quadro de produção com três pedidos em etapas diferentes.</p>
<h2>O que cada card mostra</h2>
<ul>
<li><b>#número</b> do pedido e há quanto tempo ele está parado na etapa.</li>
<li>E-mail do cliente, produto e metragem cobrada (ex.: <span class="chip">Têxtil avulsa · 1,00 m</span>).</li>
<li>Situação do arquivo: <span class="chip">PDF pronto</span> (gerado, falta aprovar), <span class="chip">Final aprovado</span>
(pode ir para a fila) ou <span class="chip">Preparar à mão</span> (o sistema não conseguiu gerar; veja a página 6).</li>
<li><span class="chip">Retirada</span> ou <span class="chip">Entrega · UF</span>.</li>
<li>Um aviso <b>Parado há mais de 4 h</b> aparece quando o pedido não anda.</li>
</ul>
<p>Os filtros <b>Têxtil</b>, <b>UV</b> e <b>Preparar à mão</b> mostram só esses pedidos. A busca encontra pedido, cliente ou CNPJ.</p>
<div class="folio">Página 3</div>
</section>
<!-- 4 · Cotações -->
<section class="page">
<div class="running">DTF Dropstar - guia do operador</div>
<p class="kicker">COTAÇÕES</p>
<h1>Conferir e aprovar uma cotação</h1>
<img class="shot" src="img/cotacoes.png" alt="Aba Cotações">
<p class="caption">À esquerda, as cotações a revisar; à direita, a cotação selecionada.</p>
<ol>
<li>Abra a aba <b>Cotações</b> e clique na cotação em <b>A revisar</b>.</li>
<li>Confira a montagem na miniatura. Se precisar, abra os arquivos em <b>Original</b> ou a lista de peças em <b>Manifesto</b>.</li>
<li>Confira <b>Metros conferidos</b> e <b>Nota conferida</b>. Os valores do cliente já vêm preenchidos; mude só se
estiverem errados. A nota define o preço do metro.</li>
<li>Marque <b>Arquivos, metragem e nota conferidos</b> e clique em <b>Aprovar cotação</b>.</li>
</ol>
<p>O cliente vê o total aprovado no Site e tem <b>24 horas</b> para pagar. As aprovadas ficam em
<b>Aprovadas, aguardando pagamento</b> até o pagamento chegar.</p>
<div class="note"><b>Ressalva de resolução aceita pelo cliente</b>: alguma arte tem menos de 300 DPI e o cliente confirmou
que quer imprimir assim. <b>Montagem antiga</b>: a cotação foi feita numa versão anterior do Site; peça ao cliente
para enviar de novo.</div>
<div class="folio">Página 4</div>
</section>
<!-- 5 · Abrir um pedido -->
<section class="page">
<div class="running">DTF Dropstar - guia do operador</div>
<p class="kicker">PRODUÇÃO</p>
<h1>Abrir um pedido</h1>
<img class="shot" src="img/painel.png" alt="Painel do pedido">
<p class="caption">Clique em qualquer card para abrir o painel do pedido.</p>
<table>
<tr><th style="width:36mm">Parte do painel</th><th>O que tem</th></tr>
<tr><td class="k">Topo</td><td>Número, etapa atual, data do pagamento e os botões para mover o pedido.</td></tr>
<tr><td class="k">Cliente e Entrega</td><td>E-mail, CNPJ e WhatsApp do cliente; endereço ou <b>Retirada em Franca</b>.</td></tr>
<tr><td class="k">Itens e arquivos de impressão</td><td>Montagem de cada item, peças, tamanho da folha e nota. <b>Baixar PDF</b>
baixa o arquivo pronto para o FlexiPRINT; <b>Original</b> baixa o arquivo que o cliente enviou; <b>Manifesto</b>
lista as peças e suas posições.</td></tr>
<tr><td class="k">Arquivos finais</td><td>O arquivo que vai ser impresso, aprovado por um operador (próxima página).</td></tr>
<tr><td class="k">Histórico</td><td>Cada movimento do pedido, com hora, operador e motivo.</td></tr>
</table>
<div class="folio">Página 5</div>
</section>
<!-- 6 · Arquivo final -->
<section class="page">
<div class="running">DTF Dropstar - guia do operador</div>
<p class="kicker">PRODUÇÃO</p>
<h1>Aprovar o arquivo final</h1>
<div class="split">
<div>
<p>Antes de ir para a <b>Fila de impressão</b>, todo item precisa de um arquivo final aprovado. O sistema já gera
um PDF com a montagem que o cliente viu e pagou.</p>
<ol>
<li>Baixe o PDF em <b>Baixar PDF</b> e confira.</li>
<li>Deixe marcado <b>Usar o PDF gerado</b>. Se preferir usar outro arquivo, desmarque e envie o seu.</li>
<li>Escreva uma <b>Nota da revisão</b> (ex.: “Conferido, pronto para imprimir”).</li>
<li>Marque <b>Arquivos conferidos</b> e clique em <b>Aprovar arquivos finais</b>.</li>
</ol>
<p>O card passa a mostrar <span class="chip">Final aprovado</span>.</p>
</div>
<div class="side"><img class="shot" src="img/arquivo-final.png" alt="Aprovação do arquivo final"></div>
</div>
<h2>Quando o PDF não é gerado</h2>
<table>
<tr><th style="width:40mm">No card ou no item</th><th>O que fazer</th></tr>
<tr><td><span class="chip">Na fila para gerar</span></td><td>Aguarde alguns segundos e clique em <b>Atualizar</b>.</td></tr>
<tr><td><span class="chip">Preparar à mão</span></td><td>O cliente enviou um formato que o gerador não lê (CDR, AI, PSD, TIFF)
ou pediu correção. Baixe o <b>Original</b>, prepare o arquivo no seu programa e envie-o em <b>Arquivos finais</b>.</td></tr>
<tr><td><span class="chip">Falhou ao gerar</span></td><td>Clique em <b>Gerar novamente</b>. Se falhar de novo, prepare à mão.</td></tr>
</table>
<div class="folio">Página 6</div>
</section>
<!-- 7 · Mover pelas etapas -->
<section class="page">
<div class="running">DTF Dropstar - guia do operador</div>
<p class="kicker">PRODUÇÃO</p>
<h1>Mover o pedido pelas etapas</h1>
<p>Use o botão laranja <b>Mover para…</b> no topo do painel ou arraste o card para a coluna seguinte. Ao arrastar,
só as colunas permitidas ficam destacadas.</p>
<table>
<tr><th style="width:27mm">Etapa</th><th>Significa</th><th style="width:43mm">Aviso ao cliente</th></tr>
<tr><td class="k">Arte recebida</td><td>Pedido pago, arquivo gerado. Ainda não conferido.</td><td>Pedido aprovado (automático)</td></tr>
<tr><td class="k">Arte tratada</td><td>Arquivo conferido e ajustado, se preciso.</td><td>—</td></tr>
<tr><td class="k">Fila de impressão</td><td>Pronto para imprimir. Exige o arquivo final aprovado.</td><td>—</td></tr>
<tr><td class="k">Imprimindo</td><td>Na máquina.</td><td>—</td></tr>
<tr><td class="k">Finalizado</td><td>Impresso e pronto para retirada ou envio.</td><td>Pedido pronto para retirada</td></tr>
<tr><td class="k">Correção</td><td>Algo no arquivo precisa ser resolvido pelo cliente.</td><td>O motivo aparece em Minha conta, no Site (página 8)</td></tr>
</table>
<div class="note">Os avisos por WhatsApp saem pelo número e pelas mensagens que a Dropstar já usa, a partir da situação
do pedido no Tiny: o pedido pago fica <b>Aprovado</b> e, em <b>Finalizado</b>, o pedido de retirada fica
<b>Pronto para envio</b>. Cada aviso sai <b>uma vez só</b>, mesmo que o pedido volte uma etapa e avance de novo.
As outras etapas não mudam o pedido no Tiny. Enquanto essa integração não estiver ativa, avise o cliente como hoje.</div>
<h2>Moveu errado? Volte uma etapa</h2>
<img class="shot" src="img/voltar-etapa.png" alt="Botão Voltar para">
<p class="caption">Botão <b>Voltar para…</b>: pede um motivo interno e não avisa o cliente.</p>
<p>Clique em <b>Voltar para [etapa anterior]</b>, escreva o motivo (ex.: “movi por engano”) e confirme. O retorno fica
no histórico como <b>Voltou para…</b>. O cliente não vê esse retorno nem o motivo.</p>
<div class="folio">Página 7</div>
</section>
<!-- 8 · Correção -->
<section class="page">
<div class="running">DTF Dropstar - guia do operador</div>
<p class="kicker">PRODUÇÃO</p>
<h1>Pedir correção ao cliente</h1>
<img class="shot" src="img/pedir-correcao.png" alt="Botão Pedir correção" style="width:78%">
<p class="caption">Botão <b>Pedir correção</b>: o motivo vai para o cliente.</p>
<ol>
<li>No painel do pedido, clique em <b>Pedir correção</b>.</li>
<li>Escreva o motivo de forma clara para o cliente (ex.: “A arte escudo.png está com fundo branco. Envie com fundo transparente.”).</li>
<li>Clique em <b>Enviar para correção</b>. O pedido vai para a coluna <b>Correção</b>.</li>
</ol>
<p>O cliente vê o motivo em <b>Minha conta</b>, no Site, e envia por lá o arquivo corrigido. Esse aviso ainda não sai
pelo WhatsApp: avise o cliente de que há uma correção pendente. O arquivo corrigido aparece em <b>Arquivos finais</b>
como <b>Correção do cliente</b>. Confira, mova o pedido para <b>Arte recebida</b> ou <b>Arte tratada</b> e siga o fluxo normal.</p>
<div class="note">Correção é para problemas que só o cliente resolve (resolução, fundo, arte errada). Ajustes que a equipe
faz sozinha não precisam de correção: trate o arquivo em <b>Arte tratada</b>.</div>
<h2>Histórico</h2>
<img class="shot" src="img/historico.png" alt="Histórico do pedido" style="width:78%">
<p class="caption">Cada movimento fica registrado com data, hora e operador.</p>
<div class="folio">Página 8</div>
</section>
<!-- 9 · Pagamentos e integrações -->
<section class="page">
<div class="running">DTF Dropstar - guia do operador</div>
<p class="kicker">PAGAMENTOS E INTEGRAÇÕES</p>
<h1>Pagamentos com problema e integrações</h1>
<p>A aba <b>Pagamentos</b> lista os pagamentos que chegaram mas não puderam virar pedido sozinhos. Exemplos:</p>
<ul>
<li>valor pago diferente do total da cotação;</li>
<li>cotação vencida antes do pagamento (passou das 24 h);</li>
<li>pagamento estornado ou cancelado depois que o pedido já existia.</li>
</ul>
<p>Resolva com o cliente ou no Mercado Pago e clique em <b>Registrar resolução</b>, descrevendo o que foi feito.
O item passa para <b>Resolvidos</b>.</p>
<img class="shot" src="img/pagamentos.png" alt="Aba Pagamentos">
<h2>Integrações</h2>
<img class="shot" src="img/integracoes.png" alt="Aba Integrações">
<p class="caption">Imagem de um ambiente sem integrações ativas. Em produção, cada cartão mostra se a integração está conectada.</p>
<ul>
<li><b>Tiny</b>: o cartão mostra quem conectou, quando a conexão foi renovada e até quando vale; o sistema renova
sozinho. Se aparecer <b>Não conectado</b> ou <b>Verificar</b>, clique em <b>Conectar Tiny</b> ou <b>Reconectar</b> e
entre com o usuário do Tiny indicado pela Dropstar. <b>Testar conexão</b> confere pedidos, contatos, os produtos
e a forma de envio de retirada, sem criar nada.</li>
<li><b>Registro de envios</b>: tudo o que o sistema mandou ao Tiny e ao WhatsApp, com filtros por destino, situação,
evento e pedido. <b>Com erro</b> indica um envio que falhou; o sistema tenta de novo sozinho, mas o erro precisa de atenção.</li>
</ul>
<div class="folio">Página 9</div>
</section>
<!-- 10 · Dúvidas -->
<section class="page">
<div class="running">DTF Dropstar - guia do operador</div>
<p class="kicker">DÚVIDAS FREQUENTES</p>
<h1>Mensagens e o que fazer</h1>
<table>
<tr><th style="width:62mm">Mensagem ou situação</th><th>O que fazer</th></tr>
<tr><td>“O pedido mudou. Clique em Atualizar.”</td><td>Outra pessoa mexeu no pedido ao mesmo tempo. Clique em <b>Atualizar</b> e repita a ação.</td></tr>
<tr><td>“Aprove os arquivos finais de todos os itens antes de colocar na fila.”</td><td>Aprove o arquivo final de cada item (página 6) e tente de novo.</td></tr>
<tr><td>“Informe o motivo da correção.” / “Informe por que o pedido está voltando de etapa.”</td><td>Preencha o motivo antes de confirmar.</td></tr>
<tr><td>Cotação com <b>Montagem antiga</b></td><td>Peça ao cliente para enviar o pedido de novo pelo Site.</td></tr>
<tr><td>Card com <b>Parado há mais de 4 h</b></td><td>Veja se o pedido está esperando alguém ou se esqueceram de movê-lo.</td></tr>
<tr><td>Arquivo aparece como <b>expirado</b></td><td>Os arquivos ficam guardados por 30 dias. Depois disso, o cliente precisa enviar de novo.</td></tr>
<tr><td>No topo, <b>Tiny: verificar conexão</b> ou <b>Tiny não conectado</b></td><td>Abra <b>Integrações</b> e siga a mensagem do cartão do Tiny. Se pedir, clique em <b>Reconectar</b>.</td></tr>
<tr><td>A internet da fábrica caiu</td><td>O Site continua recebendo pedidos e pagamentos. Quando a conexão voltar, clique em <b>Atualizar</b>.</td></tr>
</table>
<h2>Boas práticas</h2>
<ul>
<li>Mova o card assim que a etapa mudar: é o que avisa o cliente e mede o tempo de produção.</li>
<li>Escreva motivos que outra pessoa entenda sem perguntar.</li>
<li>Use sempre o seu acesso: o histórico mostra quem fez cada movimento.</li>
<li>Clique em <b>Sair</b> ao deixar o computador.</li>
</ul>
<div class="folio">Página 10</div>
</section>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -19,6 +19,10 @@ server {
proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-For $remote_addr;
client_max_body_size 2m; 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; } location / { try_files $uri $uri/ =404; }
} }
server { server {

View File

@@ -63,6 +63,10 @@ try{
await site.call('Page.reload'); await site.call('Page.reload');
await waitFor(()=>site.eval('typeof window.dtfCheckout === "function"'),'reload after security probe'); await waitFor(()=>site.eval('typeof window.dtfCheckout === "function"'),'reload after security probe');
await site.click('[data-modo="file"]'); 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"]'); await site.click('[data-cam="tabela"]');
const root=await site.call('DOM.getDocument'); const root=await site.call('DOM.getDocument');
const input=await site.call('DOM.querySelector',{nodeId:root.root.nodeId,selector:'#inp'}); const input=await site.call('DOM.querySelector',{nodeId:root.root.nodeId,selector:'#inp'});
@@ -77,6 +81,17 @@ try{
await pause(800); await pause(800);
await site.call('Page.reload'); await site.call('Page.reload');
await waitFor(()=>site.eval('typeof pedido!=="undefined" && pedido.length===1'),'persistent cart recovery'); 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].localFiles[0].name'),'local-test.cdr');
assert.equal(await site.eval('pedido[0].total'),21.89); assert.equal(await site.eval('pedido[0].total'),21.89);
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false); 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('#email',process.env.OPERATOR_EMAIL||'operator@example.test');
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only'); await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
await kanban.eval('document.getElementById("login").requestSubmit()'); 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'); 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('sessionStorage.getItem("dtf-operator")'),null);
assert.equal(await kanban.eval('document.getElementById("password").value'),''); 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`); 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}})()`), 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}); {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']]){ for(const [title,state] of [['Arte tratada','tra'],['Fila de impressão','fil'],['Imprimindo','imp'],['Finalizado','fin']]){
if(state==='fil'){ 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'); await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-final-item]')`),'final upload controls');
const doc=await kanban.call('DOM.getDocument'); 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]`}); 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.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.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 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); 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. // Reload proves the board is persisted on the backend.
await kanban.call('Page.reload'); 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'); 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. # The customer is told once, not once per delivery.
board = Client() 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'] paid = [e for e in events if e['payload'].get('order_id') == order['id']
and e['payload'].get('event') == 'payment_approved'] and e['payload'].get('event') == 'payment_approved']
assert len(paid) == 2, f'expected one tiny and one whatsapp event, got {len(paid)}' 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') 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 = paid_order(client, loose_item(manual, copies=1))
order, row = wait_print(client, order['id'], 'manual') 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) rows = client.call('/operator/orders/' + order['id'] + '/print-files', {}, operator=True)
assert rows[0]['status'] == 'pending' assert rows[0]['status'] == 'pending'
wait_print(client, order['id'], 'manual') wait_print(client, order['id'], 'manual')
@@ -141,17 +141,43 @@ def run():
outcome = deliver({'event_id': event_id, 'reference': quote_id, outcome = deliver({'event_id': event_id, 'reference': quote_id,
'status': 'approved', 'amount_cents': total - 1}) 'status': 'approved', 'amount_cents': total - 1})
assert outcome['outcome'].startswith('refused'), outcome 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) 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'}, client.call('/operator/payment-events/' + issue['id'] + '/resolve', {'note': 'no'},
operator=True, expected=422) operator=True, expected=422)
client.call('/operator/payment-events/' + issue['id'] + '/resolve', client.call('/operator/payment-events/' + issue['id'] + '/resolve',
{'note': 'Local test: refunded the underpayment'}, operator=True) {'note': 'Local test: refunded the underpayment'}, operator=True)
client.call('/operator/payment-events/' + issue['id'] + '/resolve', client.call('/operator/payment-events/' + issue['id'] + '/resolve',
{'note': 'Local test: second resolution'}, operator=True, expected=404) {'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) 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__': if __name__ == '__main__':

View File

@@ -29,6 +29,10 @@ def run():
assert script_src == "script-src 'self'", script_src assert script_src == "script-src 'self'", script_src
assert "object-src 'none'" in policy assert "object-src 'none'" in policy
assert 'cdnjs' not in policy, 'pdf.js is vendored; no CDN belongs in the 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/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':'https://attacker.invalid'},b'{}')
raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'http://localhost:9999'},b'{}') raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'http://localhost:9999'},b'{}')

View File

@@ -185,14 +185,35 @@ def run():
version+=1;assert moved['version']==version version+=1;assert moved['version']==version
client.call('/operator/orders/'+oid+'/move',{'state':'rec','version':0},operator=True,expected=409) 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 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']
# The customer sees the stages and the correction's reason, never the
# internal reason for going back.
seen=client.call('/customer/orders/'+oid)['history']
assert len(seen)==7 and all(h['reason']=='' or h['to_state']=='cor' for h in seen)
assert any(h['to_state']=='cor' and h['reason']=='Local test correction' for h in seen)
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') print('PASS: all modes, authoritative review/prices/freight, tamper rejection, concurrent payment idempotency, transitions and history')
deadline=time.monotonic()+30 deadline=time.monotonic()+30
while time.monotonic()<deadline: 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 if len(events)==8 and all(e['delivered_at'] and e['receipt'] for e in events):break
time.sleep(1) time.sleep(1)
else:raise AssertionError('Mock outbox did not drain') else:raise AssertionError('Mock outbox did not drain')
assert len({e['event_key'] for e in events})==8 assert len({e['event_key'] for e in events})==8
# Tiny sets the pickup situação from the order it is sent; the fake sale
# has no Tiny id, so the real adapter would search for it.
ready={e['provider']:e['payload'] for e in events if e['payload']['event']=='ready'}
assert ready['tiny']['order']['freight']==order['snapshot']['freight'] and 'tiny_id' in ready['tiny']
assert 'order' not in ready['whatsapp']
print(f"PASS: 8 durable fake receipts. Local test order #{order['number']} retained in Finalizado.") print(f"PASS: 8 durable fake receipts. Local test order #{order['number']} retained in Finalizado.")
return oid return oid

100
tests/test_jadlog.py Normal file
View File

@@ -0,0 +1,100 @@
"""The Jadlog quote client against a fake HTTP transport: payload and errors.
This proves the manual's contract only; app.jadlog_probe must still confirm
it on the client's account. Runs where httpx is installed.
"""
import json
import unittest
import httpx
from app.jadlog import QUOTE_URL, JadlogError, JadlogQuotes
from app.jadlog_probe import run
CNPJ = '11.222.333/0001-81'
def client(handler, **settings):
return JadlogQuotes(token=settings.pop('token', 'tok-1'), cnpj=CNPJ, conta='123456',
transport=httpx.MockTransport(handler), **settings)
class JadlogQuoteTest(unittest.TestCase):
def test_payload_follows_the_manual_and_price_becomes_centavos(self):
seen = []
def handler(request):
seen.append(request)
item = json.loads(request.content)['frete'][0]
return httpx.Response(200, json={'frete': [{**item, 'vltotal': 23.455, 'prazo': 4}]})
result = client(handler).quote('01310-100', 1.5, 12990)
request = seen[0]
self.assertEqual(str(request.url), QUOTE_URL)
self.assertEqual(request.headers['authorization'], 'tok-1')
item = json.loads(request.content)['frete'][0]
self.assertEqual(item, {'cepori': '14402310', 'cepdes': '01310100', 'frap': 'N', 'peso': 1.5,
'cnpj': '11222333000181', 'conta': '123456', 'contrato': None,
'modalidade': 3, 'tpentrega': 'D', 'tpseguro': 'N',
'vldeclarado': 129.9, 'vlcoleta': 0})
self.assertEqual((result['total_cents'], result['days']), (2346, 4))
def test_contract_is_sent_only_when_configured(self):
def handler(request):
item = json.loads(request.content)['frete'][0]
self.assertEqual(item['contrato'], '042')
return httpx.Response(200, json={'frete': [{'vltotal': 10, 'prazo': 2}]})
client(handler, contrato='042').quote('01310100', 1, 100)
def test_account_and_item_errors_are_raised_with_jadlog_text(self):
replies = [
httpx.Response(200, json={'frete': [{}], 'error': {'id': -1, 'descricao': 'frete[0].contrato Numero de contrato invalido'}}),
httpx.Response(200, json={'frete': [{'erro': {'id': 2, 'descricao': 'CEP destino invalido'}}]}),
httpx.Response(200, json={'frete': [{'prazo': 3}]}),
httpx.Response(502, text='<html>bad gateway</html>'),
]
messages = ['contrato invalido', 'CEP destino invalido', 'no freight value', 'not JSON']
for reply, message in zip(replies, messages):
with self.subTest(message=message), self.assertRaisesRegex(JadlogError, message):
client(lambda request, reply=reply: reply).quote('01310100', 1, 100)
def test_missing_credentials_refuse_to_start(self):
with self.assertRaises(RuntimeError):
JadlogQuotes(token='', cnpj=CNPJ)
with self.assertRaises(RuntimeError):
JadlogQuotes(token='tok', cnpj='123')
class JadlogProbeTest(unittest.TestCase):
def test_bearer_is_tried_once_after_a_401_and_kept(self):
headers = []
def handler(request):
headers.append(request.headers['authorization'])
if not request.headers['authorization'].startswith('Bearer '):
return httpx.Response(401, json={'descricao': 'TOKEN INVALIDO', 'id': 1})
return httpx.Response(200, json={'frete': [{'vltotal': 12.5, 'prazo': 3}]})
lines = []
status = run(client(handler), ['01310100', '20040002'], ['1'], 10000, lines.append)
self.assertEqual(status, 0)
self.assertEqual(headers, ['tok-1', 'Bearer tok-1', 'Bearer tok-1'])
self.assertIn('Bearer', lines[0])
self.assertIn('R$ 12,50', lines[1])
def test_first_failure_stops_the_run(self):
calls = []
def handler(request):
calls.append(request)
return httpx.Response(200, json={'frete': [{}], 'error': {'id': -1, 'descricao': 'CNPJ invalido'}})
lines = []
self.assertEqual(run(client(handler), ['01310100', '20040002'], ['1', '2'], 10000, lines.append), 1)
self.assertEqual(len(calls), 1)
self.assertIn('CNPJ invalido', lines[0])
if __name__ == '__main__':
unittest.main()

View File

@@ -36,6 +36,10 @@ class MercadoPagoTests(unittest.TestCase):
self.requests.append(request) self.requests.append(request)
if request.method == 'GET': if request.method == 'GET':
payment_id = request.url.path.rsplit('/', 1)[-1] payment_id = request.url.path.rsplit('/', 1)[-1]
if payment_id == '500':
return httpx.Response(500, json={'message': 'internal_error'})
if payment_id not in self.payments:
return httpx.Response(404, json={'message': 'Payment not found'})
return httpx.Response(200, json=self.payments[payment_id]) return httpx.Response(200, json=self.payments[payment_id])
body = json.loads(request.content) body = json.loads(request.content)
payment = {'id': 555, 'status': 'pending', 'status_detail': 'pending_waiting_transfer', payment = {'id': 555, 'status': 'pending', 'status_detail': 'pending_waiting_transfer',
@@ -78,6 +82,15 @@ class MercadoPagoTests(unittest.TestCase):
self.assertEqual(self.requests[-1].headers['authorization'], 'Bearer TEST-token') self.assertEqual(self.requests[-1].headers['authorization'], 'Bearer TEST-token')
self.assertIsNone(self.mp.parse(json.dumps({'type': 'merchant_order', 'data': {'id': '1'}}).encode())) self.assertIsNone(self.mp.parse(json.dumps({'type': 'merchant_order', 'data': {'id': '1'}}).encode()))
def test_notification_for_an_unknown_payment_is_acknowledged(self):
# The panel's "Simular notificação" sends a payment id that does not
# exist. Raising would answer 500 and Mercado Pago would retry for ever.
body = json.dumps({'id': 43, 'type': 'payment', 'data': {'id': '123456'}}).encode()
self.assertIsNone(self.mp.parse(body, {'data.id': '123456', 'type': 'payment'}))
# Any other failure still raises, so a real notification is retried.
with self.assertRaises(httpx.HTTPStatusError):
self.mp.parse(json.dumps({'type': 'payment', 'data': {'id': '500'}}).encode(), {'data.id': '500'})
def test_amounts_outside_brl_centavos_are_not_trusted(self): def test_amounts_outside_brl_centavos_are_not_trusted(self):
for payment in ({'currency_id': 'USD', 'transaction_amount': 10}, for payment in ({'currency_id': 'USD', 'transaction_amount': 10},
{'currency_id': 'BRL', 'transaction_amount': 10.001}, {'currency_id': 'BRL', 'transaction_amount': 10.001},

View File

@@ -115,19 +115,19 @@ class PrintFileTests(unittest.TestCase):
def test_refuses_what_it_cannot_reproduce(self): def test_refuses_what_it_cannot_reproduce(self):
png = self.save(quadrants(), 'a.png') 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]) 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]) 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') fake_pdf = os.path.join(self.dir.name, 'art.pdf')
with open(fake_pdf, 'wb') as handle: with open(fake_pdf, 'wb') as handle:
handle.write(b'%PDF-1.4\n%%EOF\n') 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]) self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [fake_pdf])
cdr = os.path.join(self.dir.name, 'art.cdr') cdr = os.path.join(self.dir.name, 'art.cdr')
with open(cdr, 'wb') as handle: with open(cdr, 'wb') as handle:
handle.write(b'not artwork') 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]) self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [cdr])
def test_long_layouts_scale_user_space_instead_of_splitting(self): 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))) self.assertEqual(detail['min_dpi'], round(80 / (20 / 2.54)))
def test_refuses_pdfs_it_cannot_place(self): 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)]) 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.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10),
[self.pdf('locked.pdf', password='secret')]) [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')]) 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') @unittest.skipIf(fitz is None, 'PyMuPDF is not installed')

View File

@@ -23,7 +23,9 @@ PAID = {'order_id': 'b6f1c0de-0000-4000-8000-000000000001', 'number': 42, 'event
'state': 'SP', 'postal_code': '14400000'}, 'state': 'SP', 'postal_code': '14400000'},
'total_cents': 8472}} 'total_cents': 8472}}
PRODUCTS = {'TINY_PRODUCT_TEXTIL_FOLHA': '101', 'TINY_PRODUCT_TEXTIL_AVULSA': '102', PRODUCTS = {'TINY_PRODUCT_TEXTIL_FOLHA': '101', 'TINY_PRODUCT_TEXTIL_AVULSA': '102',
'TINY_PRODUCT_UV_FOLHA': '103', 'TINY_PRODUCT_UV_AVULSA': '104'} 'TINY_PRODUCT_UV_FOLHA': '103', 'TINY_PRODUCT_UV_AVULSA': '104', 'TINY_FORMA_ENVIO_RETIRADA': '77'}
PICKUP = {**PAID, 'order': {**PAID['order'], 'destination': None, 'freight': {'service': 'pickup', 'total_cents': 0}}}
READY = {'order_id': PAID['order_id'], 'number': 42, 'event': 'ready', 'reason': '', 'order': PICKUP['order']}
class FakeAuth: class FakeAuth:
@@ -31,39 +33,70 @@ class FakeAuth:
return 'access-1' return 'access-1'
@mock.patch.dict(os.environ, PRODUCTS) class FakeTinyCase(unittest.TestCase):
class TinyTests(unittest.TestCase): """A Tiny account in memory: contacts, orders, products and formas de envio."""
def setUp(self): def setUp(self):
self.contacts = [] self.contacts = []
self.orders = [] self.orders = []
self.calls = [] self.calls = []
self.refuse_status = 0
self.methods = {'77': {'id': 77, 'nome': 'Retirar pessoalmente', 'tipo': '6'},
'78': {'id': 78, 'nome': 'Correios', 'tipo': '1'}}
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): def handler(request):
path = request.url.path.removeprefix('/public-api/v3') path = request.url.path.removeprefix('/public-api/v3')
self.calls.append((request.method, path)) self.calls.append((request.method, path))
assert request.headers['authorization'] == 'Bearer access-1' assert request.headers['authorization'] == 'Bearer access-1'
if request.method == 'GET' and path == '/contatos': if request.method == 'GET' and path == '/contatos':
cnpj = request.url.params['cpfCnpj'] cnpj = request.url.params.get('cpfCnpj')
return httpx.Response(200, json={'itens': [c for c in self.contacts if c['cpfCnpj'] == cnpj]}) 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': if request.method == 'POST' and path == '/contatos':
contact = {**json.loads(request.content), 'id': 500 + len(self.contacts)} contact = {**json.loads(request.content), 'id': 500 + len(self.contacts)}
self.contacts.append(contact) self.contacts.append(contact)
return httpx.Response(200, json={'id': contact['id']}) 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.startswith('/formas-envio/'):
wanted = path.rsplit('/', 1)[-1]
if wanted not in self.methods:
return httpx.Response(404, json={'mensagem': 'não encontrado'})
return httpx.Response(200, json=self.methods[wanted])
if request.method == 'GET' and path == '/formas-envio':
return httpx.Response(200, json={'itens': list(self.methods.values())})
if request.method == 'PUT' and path.startswith('/pedidos/') and path.endswith('/situacao'):
if self.refuse_status:
self.refuse_status -= 1
return httpx.Response(503, text='indisponível')
wanted = int(path.split('/')[2])
next(o for o in self.orders if o['id'] == wanted)['situacao'] = json.loads(request.content)['situacao']
return httpx.Response(204)
if request.method == 'GET' and path == '/pedidos': if request.method == 'GET' and path == '/pedidos':
return httpx.Response(200, json={'itens': [{'id': o['id']} for o in self.orders]}) return httpx.Response(200, json={'itens': [{'id': o['id']} for o in self.orders]})
if request.method == 'GET' and path.startswith('/pedidos/'): if request.method == 'GET' and path.startswith('/pedidos/'):
wanted = int(path.rsplit('/', 1)[-1]) wanted = int(path.rsplit('/', 1)[-1])
return httpx.Response(200, json=next(o for o in self.orders if o['id'] == wanted)) return httpx.Response(200, json=next(o for o in self.orders if o['id'] == wanted))
if request.method == 'POST' and path == '/pedidos': if request.method == 'POST' and path == '/pedidos':
order = {**json.loads(request.content), 'id': 9000 + len(self.orders), order = {'situacao': 0, **json.loads(request.content), 'id': 9000 + len(self.orders),
'numeroPedido': str(100 + len(self.orders))} 'numeroPedido': str(100 + len(self.orders))}
self.orders.append(order) self.orders.append(order)
return httpx.Response(200, json={'id': order['id'], 'numeroPedido': order['numeroPedido']}) return httpx.Response(200, json={'id': order['id'], 'numeroPedido': order['numeroPedido']})
return httpx.Response(404) return httpx.Response(404)
self.handler = handler
self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(handler), self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(handler),
today=date(2026, 9, 24)) today=date(2026, 9, 24))
@mock.patch.dict(os.environ, PRODUCTS)
class TinyTests(FakeTinyCase):
def test_payload_carries_contact_products_address_and_freight(self): def test_payload_carries_contact_products_address_and_freight(self):
pedido = order_payload(PAID, 777, date(2026, 9, 24)) pedido = order_payload(PAID, 777, date(2026, 9, 24))
self.assertEqual((pedido['idContato'], pedido['numeroOrdemCompra'], pedido['data']), self.assertEqual((pedido['idContato'], pedido['numeroOrdemCompra'], pedido['data']),
@@ -106,11 +139,158 @@ class TinyTests(unittest.TestCase):
self.tiny.deliver('k3', PAID) self.tiny.deliver('k3', PAID)
self.assertNotIn(('POST', '/pedidos'), self.calls) 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), 7)
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): def test_rate_limit_is_a_retryable_failure(self):
tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(429))) tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(429)))
with self.assertRaisesRegex(TinyError, 'rate limit'): with self.assertRaisesRegex(TinyError, 'rate limit'):
tiny.deliver('k4', PAID) tiny.deliver('k4', PAID)
@mock.patch.dict(os.environ, PRODUCTS)
class TinyStatusTests(FakeTinyCase):
"""Situações that the client's Tiny -> n8n notices turn into WhatsApp messages."""
def puts(self):
return [call for call in self.calls if call[0] == 'PUT']
def test_nothing_changes_while_status_updates_are_off(self):
self.assertEqual(self.tiny.deliver('k1', PICKUP)['status'], 'created')
self.assertEqual(self.orders[0]['situacao'], 0)
self.assertEqual(self.tiny.deliver('k2', READY)['status'], 'not-applicable')
self.assertEqual(self.puts(), [])
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
def test_paid_order_is_approved_once(self):
first = self.tiny.deliver('k1', PICKUP)
self.assertEqual((first['status'], first['situacao']), ('created', 'Aprovada'))
self.assertEqual(self.orders[0]['situacao'], 3)
second = self.tiny.deliver('k1', PICKUP)
self.assertEqual(second['status'], 'already-created')
self.assertNotIn('situacao', second)
self.assertEqual(len(self.puts()), 1)
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
def test_retry_after_a_failed_approval_finishes_it(self):
self.refuse_status = 1
with self.assertRaises(TinyError):
self.tiny.deliver('k1', PICKUP)
self.assertEqual((len(self.orders), self.orders[0]['situacao']), (1, 0))
retry = self.tiny.deliver('k1', PICKUP)
self.assertEqual((retry['status'], retry['situacao']), ('already-created', 'Aprovada'))
self.assertEqual(self.calls.count(('POST', '/pedidos')), 1)
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
def test_an_order_already_moved_on_is_not_approved_again(self):
self.tiny.deliver('k1', PICKUP)
self.orders[0]['situacao'] = 7
self.tiny.deliver('k1', PICKUP)
self.assertEqual(self.orders[0]['situacao'], 7)
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
def test_ready_pickup_order_is_set_ready_once_by_its_known_id(self):
sale = self.tiny.deliver('k1', PICKUP)
self.calls.clear()
ready = self.tiny.deliver('k2', {**READY, 'tiny_id': sale['tiny_id']})
self.assertEqual((ready['status'], ready['situacao'], ready['tiny_number']),
('status-updated', 'Pronto para envio', sale['tiny_number']))
self.assertEqual(self.orders[0]['situacao'], 7)
self.assertNotIn(('GET', '/pedidos'), self.calls, 'a known id needs no search')
again = self.tiny.deliver('k2', {**READY, 'tiny_id': sale['tiny_id']})
self.assertEqual(again['status'], 'status-unchanged')
self.assertEqual(len(self.puts()), 1)
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
def test_ready_without_an_id_finds_the_order(self):
self.tiny.deliver('k1', PICKUP)
self.assertEqual(self.tiny.deliver('k2', READY)['status'], 'status-updated')
self.assertEqual(self.orders[0]['situacao'], 7)
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
def test_ready_before_the_sale_reached_tiny_is_retried(self):
with self.assertRaisesRegex(TinyError, 'not in Tiny yet'):
self.tiny.deliver('k2', READY)
self.assertEqual(self.puts(), [])
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
def test_ready_delivery_order_waits_for_freight(self):
self.tiny.deliver('k1', PAID)
self.calls.clear()
ready = self.tiny.deliver('k2', {**READY, 'order': PAID['order']})
self.assertEqual(ready['status'], 'not-applicable')
self.assertEqual(self.calls, [])
def test_pickup_orders_carry_the_pickup_forma_de_envio(self):
self.assertEqual(order_payload(PICKUP, 1)['transportador'], {'formaEnvio': {'id': 77}})
self.assertNotIn('transportador', order_payload(PAID, 1))
with mock.patch.dict(os.environ, {'TINY_FORMA_ENVIO_RETIRADA': ''}):
self.assertNotIn('transportador', order_payload(PICKUP, 1))
@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.assertIn('TINY_FORMA_ENVIO_RETIRADA (retirada): ok Retirar pessoalmente', text)
code, text = self.probe('formas-envio')
self.assertIn('77\t6\tRetirar pessoalmente', 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__': if __name__ == '__main__':
unittest.main() unittest.main()

View File

@@ -33,8 +33,12 @@ def run():
def exercise(): def exercise():
issued = [] issued = []
server = {'mode': 'session', 'calls': 0}
def token_server(request): 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()} 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') assert (form['client_id'], form['client_secret']) == ('test-client', 'test-secret')
if form['grant_type'] == 'authorization_code': if form['grant_type'] == 'authorization_code':
@@ -44,8 +48,13 @@ def exercise():
return httpx.Response(400, json={'error': 'invalid_grant'}) return httpx.Response(400, json={'error': 'invalid_grant'})
n = len(issued) + 1 n = len(issued) + 1
issued.append(f'refresh-{n}') issued.append(f'refresh-{n}')
if server['mode'] == 'offline':
return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400, 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': 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,
'scope': 'openid profile'})
auth = TinyAuth(transport=httpx.MockTransport(token_server)) auth = TinyAuth(transport=httpx.MockTransport(token_server))
assert auth.status() == {'connected': False} assert auth.status() == {'connected': False}
@@ -59,6 +68,7 @@ def exercise():
query = {k: v[0] for k, v in parse_qs(url.query).items()} 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 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['redirect_uri'].endswith('/api/operator/tiny/callback') and len(query['state']) > 30
assert query['scope'] == 'openid offline_access'
try: try:
auth.complete('good-code', 'forged-state') auth.complete('good-code', 'forged-state')
raise AssertionError('a state no operator created must be refused') raise AssertionError('a state no operator created must be refused')
@@ -72,6 +82,7 @@ def exercise():
pass pass
status = auth.status() status = auth.status()
assert status['connected'] and status['connected_by'] == 'operator@example.test' 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' assert auth.access_token() == 'access-1'
print('PASS: operator-started state is required, single-use, and stores the connection') 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' assert auth.access_token() == 'access-2'
print('PASS: expiring access token refreshed once, refresh token rotated and stored') 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. # A refused refresh (revoked in Tiny) asks for a new connection, not a retry loop.
with connect() as c: with connect() as c:
c.execute("""UPDATE dtf_local.provider_tokens SET access_expires_at=now(), refresh_token='revoked' 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') raise AssertionError('a refused refresh must report the connection as lost')
except TinyNotConnected: except TinyNotConnected:
pass 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: with connect() as c:
c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=%s WHERE provider='tiny'", c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=%s WHERE provider='tiny'",
(datetime.now(timezone.utc) - timedelta(seconds=1),)) (datetime.now(timezone.utc) - timedelta(seconds=1),))
assert not auth.status()['connected'] assert not auth.status()['connected']
print('PASS: revoked or expired connections report that Tiny must be connected again') 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__': if __name__ == '__main__':
run() run()

View File

@@ -3,7 +3,7 @@
let database,scope,timer,restoring=true; let database,scope,timer,restoring=true;
let signedOut=false; let signedOut=false;
window.addEventListener('dtf-private-data-cleared',()=>{signedOut=true;clearTimeout(timer);pedido=[];itemAtual=null;folhas=[];artes=[];}); 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); 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);});} 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(){ async function save(){

View File

@@ -127,7 +127,9 @@
return; return;
} }
if (quote.status==='pending_review') { 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') { } 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.'); 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') { if ((await ready).payment_provider === 'mercadopago') {
@@ -163,8 +165,9 @@
} catch(error) { message(error.message); event.target.disabled=false; } } catch(error) { message(error.message); event.target.disabled=false; }
}); });
} else if (quote.status==='paid') { } else if (quote.status==='paid') {
message('Pedido #'+quote.order.number+' · etapa: '+quote.order.state+'.'); const STAGES={rec:'arte recebida',tra:'arte tratada',fil:'na fila de impressão',imp:'imprimindo',cor:'aguardando correção',fin:'finalizado'};
button('Novo pedido',()=>{clearDraft();location.reload();}); message('Pedido #'+quote.order.number+' · '+(STAGES[quote.order.state]||quote.order.state)+'.');
button('Novo pedido',()=>{clearDraft();location.assign('/');});
} else { } else {
message('Cotação expirada. Envie o carrinho para uma nova revisão.'); message('Cotação expirada. Envie o carrinho para uma nova revisão.');
button('Nova cotação',clearDraft); 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} 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}} @media(prefers-reduced-motion:reduce){*{transition:none!important;scroll-behavior:auto}}
</style> </style>
<link rel="stylesheet" href="/site-v2.css?v=6">
<script src="/site-pages.js"></script>
</head> </head>
<body> <body>
@@ -763,7 +765,7 @@ footer a:hover{color:var(--laranja2)}
</div> </div>
<div class="acts"> <div class="acts">
<a href="/portal.html">Minha conta</a> <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>
</div></header> </div></header>
@@ -784,10 +786,10 @@ footer a:hover{color:var(--laranja2)}
</div> </div>
</div> </div>
<div class="mi on"> <div class="mi on">
<a href="#envio">Impressão DTF</a> <a href="/#envio">Impressão DTF</a>
<div class="sub"> <div class="sub">
<a href="#envio">DTF Têxtil · 57 cm</a> <a href="/#envio">DTF Têxtil · 57 cm</a>
<a href="#envio">DTF UV · 28,5 cm</a> <a href="/#envio">DTF UV · 28,5 cm</a>
</div> </div>
</div> </div>
<div class="mi"> <div class="mi">
@@ -820,171 +822,101 @@ footer a:hover{color:var(--laranja2)}
</div> </div>
</div></nav> </div></nav>
<header class="hero"> <header class="hero2">
<div class="fundo" aria-hidden="true"> <div class="w hero2-grid">
<svg viewBox="0 0 1400 300" preserveAspectRatio="xMidYMid slice"> <div class="hero2-text">
<g fill="#fff"> <div class="chips">
<rect x="20" y="16" width="118" height="140" rx="5"/> <span class="chip2 destaque">Metro a partir de R$ 14,90</span>
<rect x="150" y="16" width="118" height="140" rx="5"/> <span class="chip2">Franca · SP · envio em até 24 h</span>
<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>
<h1>A gente encaixa sua folha e você paga <em>menos metro</em></h1>
<div class="w"> <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="hgrid"> <div class="hero2-cta">
<div> <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>
<div class="selos1"> <span>Sem cadastro · preço em segundos</span>
<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> </div>
</div> </div>
<div class="hero2-folha" aria-hidden="true">
<div class="pfaixa"> <div class="hf-cab"><b>Sua folha montada</b><span>filme 57 cm · prévia</span></div>
<a class="pcard dest" href="#envio"> <div class="hf-area">
<span class="marc">mais vendido</span> <i style="left:3%;top:5%;width:34%;height:40%;background:#FFA81A"></i>
<span class="apartir">com arte boa</span> <i style="left:39%;top:5%;width:34%;height:40%;background:#FFA81A"></i>
<span class="vl">R$ 14,90<small> /m</small></span> <i style="left:75%;top:5%;width:22%;height:55%;background:#2BB6D9"></i>
<span class="nm">Arquivo por metro</span> <i style="left:3%;top:49%;width:22%;height:40%;border-radius:50%;background:#E0508F"></i>
<span class="dsc">têxtil · 57 cm</span> <i style="left:27%;top:49%;width:22%;height:40%;border-radius:50%;background:#E0508F"></i>
</a> <i style="left:51%;top:49%;width:22%;height:30%;background:#E9C23A"></i>
<a class="pcard" href="#envio"> <i style="left:75%;top:63%;width:22%;height:26%;background:#E9C23A"></i>
<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>
</div> </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 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> </div>
</div> </div>
</header> </header>
<section class="etapas2"><div class="w">
<!-- PROVA --> <div><span>1</span><div><b>Envie a arte</b><p>PNG, JPG ou PDF, conforme o produto. Sem cadastro.</p></div></div>
<section class="prova"><div class="w"> <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 class="p1"><b>Quanto melhor sua arte,<br><em>menor o preço do metro</em></b> <div><span>3</span><div><b>Confira e pague</b><p>Conferimos a metragem e liberamos o pagamento.</p></div></div>
<span>Têxtil de R$ 19,90 a R$ 14,90 · UV de R$ 99,90 a R$ 69,90. <div><span>4</span><div><b>Acompanhe</b><p>Aviso no WhatsApp a cada etapa.</p></div></div>
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>
</div></section> </div></section>
<!-- ENVIO --> <!-- 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 --> <!-- 3 cards -->
<div id="cards"> <div id="cards">
<div class="et">Comece por aqui</div> <div class="cards-cab">
<h2>Qual é o seu <em>caso</em>?</h2> <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"> <div class="escolha">
<button class="ec f" data-modo="file"> <button class="ec f" data-modo="file">
<span class="marc">Mais vendido</span> <div class="ec-top"><span class="ec-kind">DTF TÊXTIL · 57 CM</span><span class="marc2">Mais vendido</span></div>
<div class="top"><h3>Arquivo por metro</h3> <h3>Arquivo por metro</h3>
<span class="p"><i>tabela</i>R$ 19,90<small>/m</small></span></div> <p>Você monta a folha e manda pronta. Revisamos, corrigimos e reencaixamos se sobrar espaço.</p>
<div class="chama2">Quer pagar <b>R$ 14,90</b>? Arte em 300 DPI, em arquivo que a gente confere.</div> <div class="ec-pe">
<div class="in"> <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="lg">DTF têxtil · 57 cm</span> <span class="ec-go">Enviar minha folha</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> </div>
</button> </button>
<button class="ec a" data-modo="avulsa"> <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="ec-top"><span class="ec-kind">DTF TÊXTIL · 57 CM</span></div>
<div class="chama2">Quer pagar <b>R$ 24,90</b>? Arte em 300 DPI, em arquivo que a gente confere.</div> <h3>Artes avulsas</h3>
<div class="in"> <p>Cada arte num arquivo. A gente monta a folha com o melhor encaixe. Montagem inclusa no preço.</p>
<span class="lg">DTF têxtil · 57 cm</span> <div class="ec-pe">
<p>Cada arte num arquivo. Montamos a folha com o melhor encaixe.</p> <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>
<ul><li>Sugerimos a largura que rende mais</li><li>Sobreposição não acontece</li> <span class="ec-go">Enviar minhas artes</span>
<li>Montagem inclusa · R$ 10,00 no metro</li></ul>
<span class="go">Enviar minhas artes ›</span>
</div> </div>
</button> </button>
<button class="ec u" data-modo="uvfile"> <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="ec-top"><span class="ec-kind">ADESIVO UV · 28,5 CM</span></div>
<div class="chama2">Quer pagar <b>R$ 69,90</b>? Arte em 300 DPI, em arquivo que a gente confere.</div> <h3>Arquivo por metro · UV</h3>
<div class="in"> <p>Sua folha de UV montada. Para rígidos: caneca, garrafa, case. Não precisa de prensa.</p>
<span class="lg">Adesivo UV · 28,5 cm</span> <div class="ec-pe">
<p>Sua folha de UV montada. Revisamos, corrigimos e reencaixamos.</p> <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>
<ul><li>Arquivo bom paga o mínimo</li><li>Remanejamos e sua folha encolhe</li> <span class="ec-go">Enviar folha de UV</span>
<li>Rígido: caneca, garrafa, case</li></ul>
<span class="go">Enviar minha folha de UV ›</span>
</div> </div>
</button> </button>
<button class="ec u" data-modo="uv"> <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="ec-top"><span class="ec-kind">ADESIVO UV · 28,5 CM</span></div>
<div class="chama2">Quer pagar <b>R$ 83,90</b>? Arte em 300 DPI, em arquivo que a gente confere.</div> <h3>Artes avulsas · UV</h3>
<div class="in"> <p>Cada arte num arquivo. Montamos a folha de UV com o melhor encaixe. Montagem inclusa no preço.</p>
<span class="lg">Adesivo UV · 28,5 cm</span> <div class="ec-pe">
<p>Cada arte num arquivo. Montamos a folha de UV com o melhor encaixe.</p> <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>
<ul><li>Sugerimos a largura que rende mais</li><li>Montagem inclusa</li> <span class="ec-go">Enviar artes de UV</span>
<li>Não precisa de prensa</li></ul>
<span class="go">Enviar minhas artes de UV ›</span>
</div> </div>
</button> </button>
</div> </div>
@@ -992,33 +924,33 @@ footer a:hover{color:var(--laranja2)}
<!-- painel focado --> <!-- painel focado -->
<div class="foco" id="foco"> <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="focoGrid">
<div class="cat" id="catFoco"> <div class="cat" id="catFoco">
<div class="cabfoco" style="padding:20px 22px 14px;margin:0"> <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> <div><h3 id="fTit">—</h3><span class="lg" id="fLg" style="display:block"></span></div>
<span class="pr2" id="fPreco"></span> <span class="pr2" id="fPreco"></span>
<p id="fSub"></p> <p id="fSub"></p>
</div> <div class="caminhos tipoEnvio" id="tipoEnvio" hidden>
<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>
<button type="button" class="cam on" data-tipo="folha"> <button type="button" class="cam on" data-tipo="folha">
<span class="mk2"></span> <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>a imagem ocupa a largura inteira do filme de
<span id="larguraFolhaPronta">57</span> cm</span> <span id="larguraFolhaPronta">57</span> cm</span>
<em id="tipoFolhaPreco">—</em></div> <em id="tipoFolhaPreco">—</em></div>
</button> </button>
<button type="button" class="cam" data-tipo="avulsa"> <button type="button" class="cam" data-tipo="avulsa">
<span class="mk2"></span> <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> <span>você informa a largura de cada uma e nós montamos a folha</span>
<em id="tipoAvulsaPreco">—</em></div> <em id="tipoAvulsaPreco">—</em></div>
</button> </button>
</div> </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="trocouCam" id="avisoTipo" style="display:none"></div>
<div class="caminhos" id="caminhos"> <div class="caminhos" id="caminhos">
<button class="cam on" data-cam="auto"> <button class="cam on" data-cam="auto">
@@ -1044,28 +976,10 @@ footer a:hover{color:var(--laranja2)}
<div id="lista"></div> <div id="lista"></div>
<div class="resumoA" id="rA" style="display:none"></div> <div class="resumoA" id="rA" style="display:none"></div>
<input type="file" id="inp" hidden> <input type="file" id="inp" hidden>
<div class="aoVivo" id="aoVivo"></div>
</div>
<aside class="mont" id="mont">
<div class="montcab"><b id="montcabTit">Montagem ao vivo</b>
<div class="zoomB"><button id="zMenos" title="Diminuir">−</button>
<span id="zTxt">100%</span>
<button id="zMais" title="Aumentar">+</button></div>
<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>
</aside>
</div>
</div>
</div>
</div>
<aside class="req" id="req"> <aside class="req" id="req">
<h3 class="reqcab">O que devo saber<br>sobre a arte</h3> <h3 class="reqcab">Dicas sobre a arte</h3>
<details open> <details>
<summary>Como pagar o menor metro</summary> <summary>Como pagar o menor metro</summary>
<ul> <ul>
<li class="s"><b>300 DPI no tamanho que vai imprimir.</b> É a nota máxima <li class="s"><b>300 DPI no tamanho que vai imprimir.</b> É a nota máxima
@@ -1128,6 +1042,41 @@ footer a:hover{color:var(--laranja2)}
</details> </details>
</aside> </aside>
</div> </div>
<aside class="mont" id="mont">
<div class="montcab"><b id="montcabTit">Montagem ao vivo</b>
<div class="zoomB"><button id="zMenos" title="Diminuir">−</button>
<span id="zTxt">100%</span>
<button id="zMais" title="Aumentar">+</button></div>
<span id="vMt">—</span></div>
<div id="vArea"></div>
<div id="vUso"></div>
<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>
</div>
</div> </div>
<!-- nota --> <!-- nota -->
@@ -1140,16 +1089,9 @@ footer a:hover{color:var(--laranja2)}
</div> </div>
<div class="qbar"><i id="qbi" style="width:0%"></i></div> <div class="qbar"><i id="qbi" style="width:0%"></i></div>
<div class="qitens" id="qitens"></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"> <div class="qbts">
<button class="qb chama" id="qOk">Seguir para o pedido</button> <button class="qb chama" id="qOk">Seguir para o pedido</button>
</div> </div>
<div id="qmsg" style="margin-top:12px;font-size:12.5px;display:none"></div>
</div> </div>
<!-- prévia da montagem --> <!-- prévia da montagem -->
@@ -1166,7 +1108,7 @@ footer a:hover{color:var(--laranja2)}
<div class="pinfo" id="pInfo"></div> <div class="pinfo" id="pInfo"></div>
</div> </div>
<div class="pcta"> <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="pMais">Adicionar mais artes</button>
<button class="pb" id="pVolta">Trocar o arquivo</button> <button class="pb" id="pVolta">Trocar o arquivo</button>
</div> </div>
@@ -1179,7 +1121,8 @@ footer a:hover{color:var(--laranja2)}
<h3>Seu pedido</h3> <h3>Seu pedido</h3>
<div class="cxE"> <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="itens"></div>
<div id="atual"> <div id="atual">
<div class="rot" style="margin-top:12px">Item que você está montando</div> <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="tot"><span>Total</span><b id="carrTot">R$ 0,00</b></div>
<div class="cbts"> <div class="cbts">
<button id="bPagar">Ir para o pagamento</button> <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> </div>
<p class="obs">A nota fiscal sai no CNPJ informado. O arquivo fica guardado por 30 dias; <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 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> <p id="checkoutStatus" class="avisoE" role="status" aria-live="polite"></p>
<div id="checkoutActions"></div> <div id="checkoutActions"></div>
</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> </div></section>
<!-- INFORMAÇÃO EM CARROSSEL --> <!-- PREÇO E VANTAGENS -->
<section id="info"><div class="w"> <section id="info" class="info2"><div class="w info2-grid">
<div class="et">Como funciona</div> <div class="info2-preco">
<h2>Tudo o que você precisa saber, <em>em cinco cartões</em></h2> <div class="et">Preço</div>
<h2>Arte melhor, metro mais barato</h2>
<div class="carro"> <p>A nota aparece na tela antes de você pagar. Preço da folha que você monta:</p>
<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> <table>
<tr><td class="cab">nota</td><td class="cab">têxtil</td><td class="cab">UV</td></tr> <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>
<tr><td>90–100</td><td>R$ 14,90</td><td>R$ 69,90</td></tr> <tbody>
<tr><td>75–89</td><td>R$ 16,20</td><td>R$ 73,90</td></tr> <tr><td>90 a 100</td><td class="melhor">R$ 14,90</td><td class="melhor">R$ 69,90</td></tr>
<tr><td>60–74</td><td>R$ 17,50</td><td>R$ 77,90</td></tr> <tr><td>75 a 89</td><td>R$ 16,20</td><td>R$ 73,90</td></tr>
<tr><td>40–59</td><td>R$ 18,70</td><td>R$ 81,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> <tr><td>abaixo de 40</td><td>R$ 19,90</td><td>R$ 85,90</td></tr>
</tbody>
</table> </table>
<div class="dest">Se a montagem for nossa, some <b>R$ 10,00</b> no têxtil e <p class="nota2">Montagem das artes avulsas: + R$ 10,00 no têxtil e + R$ 14,00 no UV, em qualquer faixa.</p>
<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>
<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>
</div></section> </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"> <footer><div class="w">
<div class="frow"> <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> <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-packing.js"></script>
<script src="/site-cart.js"></script> <script src="/site-cart.js"></script>
<script src="/site-flow.js"></script> <script src="/site-flow.js"></script>
<script src="/site-compra.js"></script>
<script src="/privacy.js"></script> <script src="/privacy.js"></script>
<script src="/upload.js"></script> <script src="/upload.js"></script>
<script src="/checkout.js"></script> <script src="/checkout.js"></script>
<script src="/cart.js"></script> <script src="/cart.js"></script>
<script src="/site-steps.js"></script>
</body> </body>
</html> </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> <!doctype html>
<html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>DTF · Kanban local</title> <title>Kanban DTF</title>
<style> <link rel="preconnect" href="https://fonts.googleapis.com">
:root{--bg:#0B0D10;--card:#15181D;--card2:#1C2026;--linha:#282C34;--tx:#EDEBE6;--fraco:#a1a6af;--ciano:#00B8DA} <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&amp;display=swap">
*{box-sizing:border-box}body{background:var(--bg);color:var(--tx);font:14px/1.5 system-ui,sans-serif;padding:20px;margin:0} <link rel="stylesheet" href="/kanban.css?v=redesign-7">
h1{font-size:24px;margin:0}h2{font-size:18px}header{display:flex;align-items:center;gap:18px;flex-wrap:wrap;margin-bottom:20px} </head><body>
.aviso{background:#152026;border-left:3px solid var(--ciano);padding:12px;color:#b9cbd2;margin:16px 0} <form id="login" class="login" hidden>
button,input,select{font:inherit;border:1px solid #56616d;border-radius:5px;padding:8px;background:var(--card2);color:var(--tx)} <div class="brand"><i aria-hidden="true">D</i>Kanban DTF</div>
button{cursor:pointer}button:hover{border-color:var(--ciano)}button:disabled{opacity:.5}input[type=number]{width:100px} <label>E-mail <input id="email" type="email" autocomplete="username" required></label>
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} <label>Senha <input id="password" type="password" autocomplete="current-password" required></label>
.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)} <button class="btn primary">Entrar</button>
.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)} <p id="login-status" class="hint" role="alert"></p>
</style></head><body> </form>
<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> <div id="app" hidden>
<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> <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> <p id="status" role="status"></p>
<section id="payments"></section><section id="reviews"></section><div id="kan"></div> <main>
<details><summary>Eventos locais de integração</summary><pre id="events"></pre></details> <section id="tab-board">
<script src="/upload.js"></script><script src="/kanban.js?v=pdf-print-1"></script></body></html> <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,671 @@
/* 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); const $ = id=>document.getElementById(id);
// Remove credentials saved by older local builds. Only HttpOnly sessions now. // Remove credentials saved by older local builds. Only HttpOnly sessions now.
sessionStorage.removeItem('dtf-operator'); sessionStorage.removeItem('dtf-operator');
let board;
let extraQuotes={pending:[],approved:[]}; const STAGE_COLORS={rec:'var(--rec)',tra:'var(--tra)',fil:'var(--fil)',imp:'var(--imp)',cor:'var(--cor)',fin:'var(--fin)'};
let moreQuotes={pending:false,approved:false}; 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'}); 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;} const metres=value=>Number(value).toLocaleString('pt-BR',{minimumFractionDigits:2,maximumFractionDigits:2})+' m';
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 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){ 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 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; 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()=>{ function download(uid){return async()=>{
const result=await api('/uploads/'+uid+'/download'); 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(); const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click();
};} };}
function printFiles(card,order){ function manifest(production){
const rows=order.print_files||[]; const url=URL.createObjectURL(new Blob([JSON.stringify(production,null,2)],{type:'application/json'}));
const box=node('div',undefined,'print'); const link=node('a');link.href=url;link.download='dtf-layout-'+production.sources[0].upload_id.slice(0,8)+'.json';
box.append(node('b','Arquivo de impressão')); link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
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);
}
} }
// ---- Loading and tabs ------------------------------------------------------
async function load(){ async function load(){
try{ try{
board=await api('/board'); board=await api('/board');
extraQuotes={pending:[],approved:[]}; const known=new Set(board.orders.map(o=>o.id));
moreQuotes={pending:board.pending_total>board.quotes.filter(q=>!q.approved).length, board.orders.push(...olderFinished.filter(o=>!known.has(o.id)));
approved:board.approved_total>board.quotes.filter(q=>!!q.approved).length}; if(finishedMore===null)finishedMore=board.finished_total>board.finished_shown;
$('login').hidden=true; $('login').hidden=true;$('app').hidden=false;
$('status').textContent='Atualizado às '+new Date().toLocaleTimeString()+tinyNotice; say('Atualizado às '+new Date().toLocaleTimeString('pt-BR')+tinyNotice,false,tinyNotice?6000:2500);
render(); 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();
} }
// The one-time authorisation of this system in the client's Tiny. Shown only function showTab(name){
// when the Tiny application is configured on the server. tab=name;
function tinyStatus(){ for(const b of document.querySelectorAll('[data-tab]'))b.setAttribute('aria-selected',String(b.dataset.tab===name));
const box=$('tiny');box.replaceChildren(); for(const id of ['board','quotes','payments','integrations'])$('tab-'+id).hidden=name!==id;
const tiny=board.tiny||{}; const lists={quotes:loadQuotes,payments:loadIssues,integrations:loadEvents};
if(!tiny.configured)return; if(board&&lists[name])lists[name]().catch(e=>say(e.message,true));
box.append(node('span',tiny.connected }
? 'Tiny conectado'+(tiny.orders_enabled?'':' · envio de pedidos desligado') function render(){
: 'Tiny não conectado')); $('who').textContent=board.operator||'';
box.append(action(tiny.connected?'Reconectar Tiny':'Conectar Tiny',async()=>{ $('count-board').textContent=String(board.orders.filter(o=>o.state!=='fin').length);
const result=await api('/tiny/connect',{});location.href=result.url; $('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'); function card(order){
const tinyNotice=tinyResult?(tinyResult==='connected'?' · Tiny conectado.':' · Não foi possível conectar o Tiny. Tente de novo.'):''; const c=node('button',undefined,'card');c.type='button';c.dataset.card=order.id;c.draggable=true;
if(tinyResult)history.replaceState(null,'',location.pathname); if(order.id===openOrder)c.classList.add('open');
function render(){ const top=node('div',undefined,'row');
tinyStatus(); top.append(node('span','#'+order.number,'num'),node('span',ago(order.updated_at),'age'));
paymentIssues(); const tags=node('div',undefined,'tags');
$('reviews').replaceChildren(); for(const item of order.snapshot.items)tags.append(node('span',SHORT[item.mode]+' · '+metres(item.billed_metres),'tag'));
if(board.pending_total || board.approved_total) const foot=node('div',undefined,'foot');const p=printState(order);
$('reviews').append(node('h2','Cotações · '+board.pending_total+' pendentes · '+ const status=node('span',p.text,p.tone);status.style.display='flex';status.style.alignItems='center';status.style.gap='5px';
board.approved_total+' aprovadas sem pedido')); if(p.tone==='ok')status.prepend(icon(ICON_OK));if(p.tone==='warn')status.prepend(icon(ICON_WARN));
for(const quote of [...board.quotes,...extraQuotes.pending,...extraQuotes.approved]){ const dest=order.snapshot.destination;
const card=node('article',undefined,'review'); foot.append(status,node('span',dest?'Entrega · '+dest.state:'Retirada','where'));
card.append(node('b',quote.id.slice(0,8)+' · '+quote.draft.customer.mail)); c.append(top,node('div',order.snapshot.customer.mail,'cust'),tags,foot);
if(quote.status==='pending_review' && quote.draft.items.some(item=>item.production?.version!==2)){ if(['rec','tra','fil','imp'].includes(order.state)&&Date.now()-new Date(order.updated_at)>LATE_HOURS*3600000)
card.append(node('p','Cotação com montagem antiga. Peça ao cliente para enviar uma nova cotação.')); c.append(node('div','Parado há mais de '+LATE_HOURS+' h','late'));
}else if(quote.status!=='pending_review'){ c.onclick=()=>openPanel(order.id);
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.')); c.ondragstart=e=>{
}else{ e.dataTransfer.setData('text/plain',order.id);
const form=node('form'); const forward=board.transitions[order.state].filter(s=>s!=='cor');
const edits=quote.draft.items.map((item,index)=>{ for(const col of document.querySelectorAll('.col[data-state]'))col.classList.toggle('allowed',forward.includes(col.dataset.state));
const row=node('div');row.append(node('strong',(index+1)+'. '+item.mode+' ')); say(forward.length?'Mover para '+forward.map(s=>board.states[s]).join(' ou '):'Pedido finalizado',false,60000);
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; c.ondragend=()=>{for(const col of document.querySelectorAll('.col'))col.classList.remove('allowed','target');say('');};
const grade=node('input');grade.type='number';grade.min='0';grade.max='100';grade.step='1';grade.value=item.grade;grade.required=true; return c;
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); 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();
} }
for(const [kind,label] of [['pending','Carregar cotações pendentes mais antigas'], async function move(order,state,reason=''){
['approved','Carregar cotações aprovadas mais antigas']]){ await api('/orders/'+order.id+'/move',{state,version:order.version,reason});
if(moreQuotes[kind])$('reviews').append(action(label,()=>loadMoreQuotes(kind))); await load();
} }
$('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.';
}
async function move(order,state){
let reason='';if(state==='cor'){reason=prompt('Motivo da correção:');if(!reason)return;}
await api('/orders/'+order.id+'/move',{state,version:order.version,reason});await load();
}
$('login').onsubmit=async e=>{e.preventDefault();try{await api('/login',{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){ // ---- Order panel ---------------------------------------------------------
container.replaceChildren();
const revisions=await api('/orders/'+order.id+'/files'); function openPanel(id){openOrder=id;const order=board.orders.find(o=>o.id===id);$('drawer').hidden=false;renderPanel(order);renderBoard();}
for(const file of revisions){ function closePanel(){openOrder=null;$('drawer').hidden=true;$('panel').replaceChildren();if(board)renderBoard();}
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'); $('scrim').onclick=closePanel;
row.append(node('p',file.note)); document.addEventListener('keydown',e=>{if(e.key==='Escape'&&openOrder)closePanel();});
if(!file.expired)row.append(action('Baixar '+file.name,download(file.upload_id)));
container.append(row); 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;
} }
if(!['rec','tra','cor'].includes(order.state))return; function renderPanel(order){
const form=node('form'); const token=++openToken, panel=$('panel');panel.dataset.order=order.id;
form.append(node('p','Enviar um conjunto final completo. Pode haver várias partes por item. Um novo conjunto substitui o anterior.')); const color=STAGE_COLORS[order.state];
// A generated print file reproduces the approved layout; offer it first, and const head=node('div',undefined,'panel-head');
// keep the upload for items that need hand preparation. Not after a const row=node('div',undefined,'row');
// correction: the file was made from artwork the customer has replaced. 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);
}
}
// 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 corrected=revisions.some(file=>file.kind==='correction');
const form=node('form');
const inputs=order.snapshot.items.map((item,index)=>{ const inputs=order.snapshot.items.map((item,index)=>{
const label=node('label','Item '+(index+1)+' · '+item.mode);label.style.display='block'; 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;input.style.width='100%'; 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'); const generated=corrected?null:(order.print_files||[]).find(r=>r.item_index===index&&r.status==='ready');
if(generated){ 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;}; 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'; const choice=node('label',undefined,'check');choice.append(use,'Usar o PDF gerado ('+generated.name+')');
label.append(choice);input.generated=()=>use.checked?generated.upload_id:null; slot.append(choice);input.generated=()=>use.checked?generated.upload_id:null;
} }
label.append(input);form.append(label);return input;}); slot.append(input);form.append(slot);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 note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000;
const submit=node('button','Aprovar arquivos finais');submit.type='submit';form.append(submit); 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{ form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{
const refs=[]; const refs=[];
for(const [index,input] of inputs.entries()){ for(const [index,input] of inputs.entries()){
const generated=input.generated?.(); const generated=input.generated?.();
if(generated){refs.push({item_index:index,upload_id:generated});continue;} if(generated){refs.push({item_index:index,upload_id:generated});continue;}
for(const file of input.files){ 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}); 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}); refs.push({item_index:index,upload_id:uid});
}} }
}
await api('/orders/'+order.id+'/final-files',{version:order.version,files:refs,note:note.value}); await api('/orders/'+order.id+'/final-files',{version:order.version,files:refs,note:note.value});
await load(); await load();
}catch(error){$('status').textContent=error.message;submit.disabled=false;}}; }catch(error){say(error.message,true);submit.disabled=false;}};
container.append(form); 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){
const situacao=receipt.situacao?' · '+receipt.situacao:'';
if(receipt.status==='created')return ['Criado no Tiny nº '+receipt.tiny_number+situacao,'good'];
if(receipt.status==='already-created')return ['Já existia no Tiny nº '+receipt.tiny_number+' (reenvio)'+situacao,'good'];
if(receipt.status==='status-updated')return ['Tiny nº '+receipt.tiny_number+situacao,'good'];
if(receipt.status==='status-unchanged')return ['Tiny nº '+receipt.tiny_number+' já estava'+situacao.replace(' · ',' em '),''];
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, os 4 produtos e a retirada 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} *{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> </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> <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"> <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="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> </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> <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 $=id=>document.getElementById(id);
const node=(tag,text)=>{const el=document.createElement(tag);if(text!==undefined)el.textContent=text;return el;}; 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'}); 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;} 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 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);} function forgetCheckout(){for(const k of ['dtf-quote','dtf-request-key','dtf-request-body'])localStorage.removeItem(k);}
async function load(){ async function load(){
try{ 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'); const account=await api('/account/me');
$('auth').hidden=!!account.customer;$('logout').hidden=!account.customer; $('auth').hidden=!!account.customer;$('logout').hidden=!account.customer;
$('account').textContent=account.customer?'Conta: '+account.customer.mail:''; $('account').textContent=account.customer?'Conta: '+account.customer.mail:'';
@@ -17,13 +18,13 @@ async function load(){
for(const quote of data.quotes){ for(const quote of data.quotes){
const card=node('article');card.className='card';card.append(node('h3','Cotação '+quote.id.slice(0,8))); 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.')); 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(); $('orders').replaceChildren();
if(!data.orders.length)$('orders').append(node('p','Nenhum pedido pago nesta conta ou sessão.')); if(!data.orders.length)$('orders').append(node('p','Nenhum pedido pago nesta conta ou sessão.'));
for(const order of data.orders){ for(const order of data.orders){
const card=node('article');card.className='card';card.id='order-'+order.id; 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'))); card.append(node('p',new Date(order.created_at).toLocaleString('pt-BR')));
const content=node('div'); const content=node('div');
card.append(button('Ver detalhes e arquivos',()=>details(order.id,content)),content);$('orders').append(card); 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;}}; $('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;}}; $('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; $('refresh').onclick=load;
load(); load();

View File

@@ -30,10 +30,15 @@ function pintaPedido(){
$('itens').innerHTML = pedido.map((it,i)=> $('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="item"><div class="nm2"><b>'+escapeHTML(it.tit)+'</b><span>'+escapeHTML(it.desc)+'</span></div>'+
'<div class="vl2">'+rs(it.total)+'</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',()=>{ $('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 itens = pedido.reduce((t,it)=>t+it.total,0) + (itemAtual? itemAtual.total : 0);
const frete = entrega.tipo==='frete' && entrega.cotado ? entrega.valor : 0; const frete = entrega.tipo==='frete' && entrega.cotado ? entrega.valor : 0;
const soma = itens + frete; 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)'); '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; } 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 // ── modos
const MODOS={ const MODOS={
file: {tit:'Arquivo por metro', lg:'DTF têxtil · 57 cm', larg:57, 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.', sub:'Sua folha montada. Revisamos, corrigimos e reencaixamos antes de imprimir.',
zt:'Arraste suas folhas montadas', zt:'Arraste suas folhas montadas',
zs:'uma ou várias · PNG, JPG, TIFF ou PDF · PSD, AI e CDR passam por tratamento'}, 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, 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.', sub:'Cada arte num arquivo. Montamos a folha com o melhor encaixe.',
zt:'Arraste suas artes aqui', zt:'Arraste suas artes aqui',
zs:'PNG ou JPG com fundo transparente · informe a largura de cada uma'}, 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, 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.', sub:'Sua folha de UV montada. Revisamos, corrigimos e reencaixamos antes de imprimir.',
zt:'Arraste suas folhas de UV', zt:'Arraste suas folhas de UV',
zs:'uma ou várias · PNG, JPG, TIFF ou PDF · PSD, AI e CDR passam por tratamento'}, 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, 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.', sub:'Cada arte de UV num arquivo. Montamos a folha com o melhor encaixe.',
zt:'Arraste suas artes de UV', zt:'Arraste suas artes de UV',
zs:'PNG ou JPG com fundo transparente · informe a largura de cada arte'} 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 // 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, // 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. // 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',()=>{ $('qOk').addEventListener('click',()=>{
if($('qOk').disabled) return; if($('qOk').disabled) return;
if(!$('prev').classList.contains('on')){ adicionaAoCarrinho(); return; }
const ok=$('ciente').classList.contains('on'); const ok=$('ciente').classList.contains('on');
qm(ok? '<b>Registrado.</b> Você confirmou que viu a ressalva de resolução.' qm(ok? '<b>Registrado.</b> Você confirmou que viu a ressalva de resolução.'
: '<b>Certo.</b> Seu pedido está montado abaixo.','var(--tx)'); : '<b>Certo.</b> Confira a folha montada abaixo.','var(--tx)');
$('carr').scrollIntoView({behavior:'smooth',block:'nearest'}); $('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'})); $('pMais').addEventListener('click',()=>$('foco').scrollIntoView({behavior:'smooth'}));
$('pVolta').addEventListener('click',()=>{ $('pVolta').addEventListener('click',()=>{
folhas=[]; artes=[]; reencaixado=false; pintaFolha(); pintaArtes(); folhas=[]; artes=[]; reencaixado=false; pintaFolha(); pintaArtes();
@@ -165,19 +186,7 @@ $('bCep').addEventListener('click',async()=>{
pintaEntrega(); pintaEntrega();
}); });
$('bMais').addEventListener('click',()=>{ $('bMais').addEventListener('click',()=>{ if(guardaItem()) vaiPara('/#envio'); });
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'});
});
$('bPagar').addEventListener('click',()=>{ $('bPagar').addEventListener('click',()=>{
if(window.dtfCheckout) window.dtfCheckout(); if(window.dtfCheckout) window.dtfCheckout();

View File

@@ -3,7 +3,12 @@
Loaded as classic scripts in the order listed there: they share one global Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */ scope and run top to bottom, exactly as the original did. */
// ── escolha // ── 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){ function abrir(m){
modo=m; artes=[]; folhas=[]; metros=0; nota=0; reencaixado=false; modo=m; artes=[]; folhas=[]; metros=0; nota=0; reencaixado=false;
montagemCm=0; montagemCm=0;
@@ -13,7 +18,7 @@ function abrir(m){
recemChegada=null; caminho='auto'; avisoCam(''); pintaCaminhos(); recemChegada=null; caminho='auto'; avisoCam(''); pintaCaminhos();
$('vMt').textContent='—'; previaAoVivo(); agendaAvaliacao(); $('vMt').textContent='—'; previaAoVivo(); agendaAvaliacao();
limpaPaineis(); // o carrinho só some se o pedido estiver vazio limpaPaineis(); // o carrinho só some se o pedido estiver vazio
$('foco').scrollIntoView({behavior:'smooth',block:'start'}); vaiPara(PAGINAS[m]);
} }
function pintaModo(){ function pintaModo(){
const m=modo; const m=modo;
@@ -29,6 +34,7 @@ function pintaModo(){
$('larguraFolhaPronta').textContent=String(c.larg).replace('.',','); $('larguraFolhaPronta').textContent=String(c.larg).replace('.',',');
$('foco2').classList.remove('sofila'); // a caixa de requisitos vale nos 4 modos $('foco2').classList.remove('sofila'); // a caixa de requisitos vale nos 4 modos
$('montcabTit').textContent = ehFolha(m) ? 'Sua folha' : 'Montagem ao vivo'; $('montcabTit').textContent = ehFolha(m) ? 'Sua folha' : 'Montagem ao vivo';
$('qOk').textContent = ehFolha(m) ? 'Adicionar ao carrinho' : 'Ver a folha montada';
pintaTipoEnvio(); pintaTipoEnvio();
} }
// O par folha montada / artes separadas fica visível e com preço nos dois lados, // 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; if(!destino || modo===destino) return;
folhas=[]; artes=[]; recemChegada=null; metros=0; nota=0; montagemCm=0; folhas=[]; artes=[]; recemChegada=null; metros=0; nota=0; montagemCm=0;
modo=destino; modo=destino;
history.replaceState(history.state,'',PAGINAS[modo]); document.title=tituloPagina();
$('lista').innerHTML=''; $('rA').style.display='none'; recusa([]); $('lista').innerHTML=''; $('rA').style.display='none'; recusa([]);
caminho='auto'; avisoCam(''); caminho='auto'; avisoCam('');
pintaModo(); pintaCaminhos(); limpaPaineis(); agendaAvaliacao(); previaAoVivo(); pintaModo(); pintaCaminhos(); limpaPaineis(); agendaAvaliacao(); previaAoVivo();
} }
$('bVoltar').addEventListener('click',()=>{ $('bVoltar').addEventListener('click',()=>{
itemAtual=null; $('atual').style.display='none'; $('carrLin').innerHTML=''; pintaPedido(); itemAtual=null; modo=null; $('atual').style.display='none'; $('carrLin').innerHTML=''; pintaPedido();
$('foco').classList.remove('on'); $('cards').style.display=''; $('foco').classList.remove('on');
['qual','prev'].forEach(id=>$(id).classList.remove('on')); ['qual','prev'].forEach(id=>$(id).classList.remove('on'));
if(!pedido.length) $('carr').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; return;
} }
tMont=setTimeout(()=>{ 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 prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && a.src);
const W=Math.round(300*ZOOMS[zoomI]); const W=Math.round(300*ZOOMS[zoomI]);
$('vArea').classList.toggle('ampliado', zoomI>0); $('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 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}; 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('.',','); const n1=v=>v.toFixed(1).replace('.',',');
function pintaArtes(){ function pintaArtes(){
@@ -362,17 +332,20 @@ function pintaArtes(){
}else if(a.cm>0 && a.src){ }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))); 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>'; 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>'+ 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); const sug=sugestao(a);
dica = sug // One hint per row, on the row it is about: resolution first, since it
? '<div class="dica"><b>Reduzindo para '+n1(sug.alvo)+' cm você economiza '+ // changes the price; otherwise a width that saves sheet.
n1(sug.cm)+' cm de folha</b>Passam de '+sug.de+' para '+sug.para+ const maxCm=Math.floor(a.px/DPI_AVISA*2.54*10)/10;
' por fileira, e as suas '+a.q+' peças ocupam '+sug.fileiras+ dica = dpi<DPI_AVISA && maxCm>0 && maxCm<a.cm
' fileira'+(sug.fileiras>1?'s':'')+' a menos.<button data-usar="'+i+'" data-cm="'+ ? '<div class="dica av">Pequena para '+n1(a.cm)+' cm. Em '+DPI_AVISA+' DPI ela sai com até '+
sug.alvo+'">Usar sugerido</button></div>' 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>'+ 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">'+ '<span style="color:var(--fraco);font-size:10.5px">'+

View File

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