Compare commits
10 Commits
ui-v1
...
e768dcb489
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e768dcb489 | ||
|
|
122c645f72 | ||
|
|
988b252f9d | ||
|
|
56021d8451 | ||
|
|
b6a90411f1 | ||
|
|
cbbbdb351a | ||
|
|
b81ff8d03d | ||
|
|
177882e77b | ||
|
|
2e03b0362b | ||
|
|
99a558ddd9 |
@@ -146,6 +146,8 @@ class FreightAdapter(Protocol):
|
||||
def quote(self, service: str, postal_code: str) -> dict: ...
|
||||
|
||||
class FakeFreight:
|
||||
name = 'fake'
|
||||
|
||||
def quote(self, service: str, postal_code: str) -> dict:
|
||||
if service == 'pickup':
|
||||
return {'provider': 'fake', 'service': 'pickup', 'total_cents': 0, 'postal_code': ''}
|
||||
|
||||
@@ -57,7 +57,7 @@ def login(body: Login, request: Request, response: Response):
|
||||
if not account or not matches:
|
||||
login_failed(email)
|
||||
audit('customer_login_failed', ip=client_ip(request))
|
||||
raise HTTPException(401, 'Invalid email or password')
|
||||
raise HTTPException(401, 'E-mail ou senha inválidos.')
|
||||
previous = current(request)
|
||||
with db.connect() as c:
|
||||
if not stored.startswith('scrypt-v2$'):
|
||||
|
||||
@@ -7,7 +7,8 @@ from ..core import db
|
||||
from ..core.auth import client_ip, owner, new_session, rate_limit
|
||||
from ..core.limits import upload_limit_bytes
|
||||
from ..core.models import Freight
|
||||
from ..runtime import ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, payment, storage
|
||||
from ..runtime import (ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, payment,
|
||||
require_delivery_available, storage)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -40,6 +41,7 @@ def session(request: Request, response: Response):
|
||||
|
||||
@router.post('/api/freight')
|
||||
def quote_freight(body: Freight):
|
||||
require_delivery_available(body.service)
|
||||
try:
|
||||
return freight.quote(body.service, body.postal_code)
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -17,7 +17,7 @@ from ..core.models import Move, OperatorLogin, Resolution, Review
|
||||
from ..core.pricing import price
|
||||
from ..printjobs import queue as queue_print_files
|
||||
from .. import tiny
|
||||
from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, STATES, TRANSITIONS,
|
||||
from ..runtime import (BACK, BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, ENVIRONMENT, STATES, TRANSITIONS, payment,
|
||||
enqueue, freight, quote_view, storage, upload_row)
|
||||
from ..scanning import require_clean
|
||||
|
||||
@@ -30,14 +30,14 @@ def operator_login(body: OperatorLogin, request: Request, response: Response):
|
||||
with db.connect() as c:
|
||||
account = c.execute('SELECT * FROM dtf_local.operators WHERE email=%s', (email,)).fetchone()
|
||||
if not c.execute('SELECT 1 FROM dtf_local.operators WHERE active LIMIT 1').fetchone():
|
||||
raise HTTPException(503, 'No Kanban operator account is configured')
|
||||
raise HTTPException(503, 'Nenhuma conta de operador configurada.')
|
||||
# Comparable password work whether or not the account exists or is active.
|
||||
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
|
||||
matches = password_matches(body.password, stored)
|
||||
if not account or not account['active'] or not matches:
|
||||
login_failed('operator:'+email)
|
||||
audit('operator_login_failed', ip=client_ip(request), operator=email)
|
||||
raise HTTPException(401, 'Invalid operator login')
|
||||
raise HTTPException(401, 'E-mail ou senha inválidos.')
|
||||
token = secrets.token_urlsafe(32)
|
||||
with db.connect() as c:
|
||||
previous = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
|
||||
@@ -84,41 +84,112 @@ def board(user=Depends(operator)):
|
||||
approved_total = c.execute('''SELECT count(*) AS n FROM dtf_local.quotes q
|
||||
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
|
||||
WHERE o.id IS NULL AND q.approved IS NOT NULL''').fetchone()['n']
|
||||
orders = active + list(reversed(finished))
|
||||
generated = c.execute('''SELECT p.order_id,p.item_index,p.status,p.upload_id,p.detail,u.name
|
||||
FROM dtf_local.print_files p LEFT JOIN dtf_local.uploads u ON u.id=p.upload_id
|
||||
WHERE p.order_id=ANY(%s) ORDER BY p.item_index''', ([o['id'] for o in orders],)).fetchall()
|
||||
for order in orders:
|
||||
order['print_files'] = [row for row in generated if row['order_id'] == order['id']]
|
||||
orders = with_print_files(c, active + list(reversed(finished)))
|
||||
# A paid notification that did not become an order is money received
|
||||
# for nothing the factory will make. It stays on the board until a
|
||||
# person records what was done about it.
|
||||
refused = c.execute('''SELECT id,provider,event_id,reference,status,amount_cents,received_at,outcome
|
||||
FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL
|
||||
ORDER BY received_at LIMIT 100''').fetchall()
|
||||
return {'states': STATES, 'transitions': TRANSITIONS,
|
||||
'orders': orders, 'payment_issues': refused, 'tiny': tiny_status(),
|
||||
# for nothing the factory will make. The board carries the count; the
|
||||
# Pagamentos tab pages through them until someone records a resolution.
|
||||
issues_total = c.execute('''SELECT count(*) AS n FROM dtf_local.payment_events
|
||||
WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL''').fetchone()['n']
|
||||
return {'states': STATES, 'transitions': TRANSITIONS, 'back': BACK,
|
||||
'orders': orders, 'payment_issues_total': issues_total, 'tiny': tiny_status(), 'operator': user,
|
||||
'providers': {'payment': payment.name}, 'environment': ENVIRONMENT,
|
||||
'finished_shown': len(finished), 'finished_total': finished_total,
|
||||
'quotes': [quote_view(c, q) for q in pending + approved],
|
||||
'pending_total': pending_total, 'approved_total': approved_total,
|
||||
'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()}
|
||||
'pending_total': pending_total, 'approved_total': approved_total}
|
||||
|
||||
|
||||
def with_print_files(c, orders):
|
||||
generated = c.execute('''SELECT p.order_id,p.item_index,p.status,p.upload_id,p.detail,u.name
|
||||
FROM dtf_local.print_files p LEFT JOIN dtf_local.uploads u ON u.id=p.upload_id
|
||||
WHERE p.order_id=ANY(%s) ORDER BY p.item_index''', ([o['id'] for o in orders],)).fetchall()
|
||||
for order in orders:
|
||||
order['print_files'] = [row for row in generated if row['order_id'] == order['id']]
|
||||
return orders
|
||||
|
||||
|
||||
def cursor_pair(first, second):
|
||||
if (first is None) != (second is None):
|
||||
raise HTTPException(422, 'Both cursor fields are required')
|
||||
return first is not None
|
||||
|
||||
|
||||
@router.get('/api/operator/orders/finished')
|
||||
def finished_page(before_created_at: datetime | None = None, before_id: UUID | None = None,
|
||||
limit: int = Query(default=50, ge=1, le=100), user=Depends(operator)):
|
||||
"""Older finished orders, newest first, beyond the board's recent window."""
|
||||
paged = cursor_pair(before_created_at, before_id)
|
||||
with db.connect() as c:
|
||||
rows = c.execute('''SELECT * FROM dtf_local.orders WHERE state='fin'
|
||||
''' + ('AND (created_at,id)<(%s,%s) ' if paged else '') + '''
|
||||
ORDER BY created_at DESC,id DESC LIMIT %s''',
|
||||
((before_created_at, before_id) if paged else ()) + (limit + 1,)).fetchall()
|
||||
return {'orders': with_print_files(c, rows[:limit]), 'has_more': len(rows) > limit}
|
||||
|
||||
|
||||
@router.get('/api/operator/payment-events')
|
||||
def payment_events(state: Literal['open','resolved','all'] = 'open',
|
||||
offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=20, ge=1, le=100), user=Depends(operator)):
|
||||
"""Payments that needed a person, newest first: open ones to act on, resolved ones as history."""
|
||||
where = {'open': 'AND resolved_at IS NULL', 'resolved': 'AND resolved_at IS NOT NULL', 'all': ''}[state]
|
||||
with db.connect() as c:
|
||||
rows = c.execute('''SELECT id,provider,event_id,reference,status,amount_cents,received_at,outcome,
|
||||
resolved_at,resolved_by,resolution
|
||||
FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%%' OR outcome LIKE 'attention%%') ''' + where + '''
|
||||
ORDER BY received_at DESC,id DESC LIMIT %s OFFSET %s''', (limit, offset)).fetchall()
|
||||
total = c.execute('''SELECT count(*) AS n FROM dtf_local.payment_events
|
||||
WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') ''' + where).fetchone()['n']
|
||||
return {'issues': rows, 'total': total}
|
||||
|
||||
|
||||
@router.get('/api/operator/events')
|
||||
def events(provider: Literal['tiny','whatsapp'] | None = None,
|
||||
status: Literal['delivered','queued','failing'] | None = None,
|
||||
event: Literal['payment_approved','production_started','correction_needed','ready'] | None = None,
|
||||
order: int | None = Query(default=None, ge=1), offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=20, ge=1, le=100), user=Depends(operator)):
|
||||
"""The integration send log, newest first, filtered, by page with a total."""
|
||||
clauses, params = [], []
|
||||
if provider:
|
||||
clauses.append('provider=%s'); params.append(provider)
|
||||
if status == 'delivered':
|
||||
clauses.append('delivered_at IS NOT NULL')
|
||||
elif status == 'queued':
|
||||
clauses.append('delivered_at IS NULL AND last_error IS NULL')
|
||||
elif status == 'failing':
|
||||
clauses.append('delivered_at IS NULL AND last_error IS NOT NULL')
|
||||
if event:
|
||||
clauses.append("payload->>'event'=%s"); params.append(event)
|
||||
if order:
|
||||
clauses.append("payload->>'number'=%s"); params.append(str(order))
|
||||
where = ('WHERE ' + ' AND '.join(clauses)) if clauses else ''
|
||||
with db.connect() as c:
|
||||
rows = c.execute(f'SELECT * FROM dtf_local.outbox {where} ORDER BY id DESC LIMIT %s OFFSET %s',
|
||||
(*params, limit, offset)).fetchall()
|
||||
total = c.execute(f'SELECT count(*) AS n FROM dtf_local.outbox {where}', params).fetchone()['n']
|
||||
return {'events': rows, 'total': total}
|
||||
|
||||
@router.get('/api/operator/quotes')
|
||||
def quote_page(kind: Literal['pending','approved'], before_created_at: datetime | None = None,
|
||||
before_id: UUID | None = None, limit: int = Query(default=50, ge=1, le=100),
|
||||
user=Depends(operator)):
|
||||
before_id: UUID | None = None, offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=100), user=Depends(operator)):
|
||||
"""Unpaid quotes, newest first: by cursor, or by page (offset) with a total."""
|
||||
if (before_created_at is None) != (before_id is None):
|
||||
raise HTTPException(422, 'Both quote cursor fields are required')
|
||||
approved_filter = 'q.approved IS NULL' if kind == 'pending' else 'q.approved IS NOT NULL'
|
||||
cursor = 'AND (q.created_at,q.id)<(%s,%s)' if before_created_at else ''
|
||||
params = ((before_created_at,before_id) if before_created_at else ()) + (limit+1,)
|
||||
skip = 0 if before_created_at else offset
|
||||
params = ((before_created_at,before_id) if before_created_at else ()) + (limit+1, skip)
|
||||
with db.connect() as c:
|
||||
rows = c.execute(f'''SELECT q.* FROM dtf_local.quotes q
|
||||
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
|
||||
WHERE o.id IS NULL AND {approved_filter} {cursor}
|
||||
ORDER BY q.created_at DESC,q.id DESC LIMIT %s''', params).fetchall()
|
||||
ORDER BY q.created_at DESC,q.id DESC LIMIT %s OFFSET %s''', params).fetchall()
|
||||
total = c.execute(f'''SELECT count(*) AS n FROM dtf_local.quotes q
|
||||
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
|
||||
WHERE o.id IS NULL AND {approved_filter}''').fetchone()['n']
|
||||
return {'quotes':[quote_view(c,row) for row in rows[:limit]],
|
||||
'has_more':len(rows)>limit}
|
||||
'has_more':len(rows)>limit, 'total': total}
|
||||
|
||||
@router.post('/api/operator/quotes/{uid}/approve')
|
||||
def approve(uid: UUID, body: Review, user=Depends(operator)):
|
||||
@@ -159,26 +230,34 @@ def move(uid: UUID, body: Move, user=Depends(operator)):
|
||||
if not row:
|
||||
raise HTTPException(404, 'Order not found')
|
||||
if body.version != row['version']:
|
||||
raise HTTPException(409, 'Order changed; refresh the board')
|
||||
raise HTTPException(409, 'O pedido mudou. Clique em Atualizar.')
|
||||
if body.state == row['state']:
|
||||
return row
|
||||
if body.state not in TRANSITIONS[row['state']]:
|
||||
raise HTTPException(409, 'Move is not allowed from this state')
|
||||
back = BACK.get(row['state']) == body.state
|
||||
if body.state not in TRANSITIONS[row['state']] and not back:
|
||||
raise HTTPException(409, f"Não dá para ir de {STATES[row['state']]} para {STATES[body.state]}.")
|
||||
if body.state == 'cor' and not body.reason.strip():
|
||||
raise HTTPException(422, 'Correction requires a reason')
|
||||
raise HTTPException(422, 'Informe o motivo da correção.')
|
||||
if back and not body.reason.strip():
|
||||
raise HTTPException(422, 'Informe por que o pedido está voltando de etapa.')
|
||||
if body.state in ('fil','imp'):
|
||||
coverage = c.execute('SELECT DISTINCT f.item_index FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id WHERE f.order_id=%s AND f.kind=\'final\' AND f.active AND u.expires_at>now() AND u.purged_at IS NULL AND u.scan_state=\'clean\'', (uid,)).fetchall()
|
||||
if {r['item_index'] for r in coverage} != set(range(len(row['snapshot']['items']))):
|
||||
raise HTTPException(409, 'Approve a complete final-file set for every item before queueing')
|
||||
raise HTTPException(409, 'Aprove os arquivos finais de todos os itens antes de colocar na fila.')
|
||||
if body.state == 'cor':
|
||||
c.execute("UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind='final'", (uid,))
|
||||
c.execute('INSERT INTO dtf_local.movements(order_id,from_state,to_state,operator,reason) VALUES(%s,%s,%s,%s,%s)',
|
||||
(uid,row['state'],body.state,user,body.reason))
|
||||
c.execute('INSERT INTO dtf_local.movements(order_id,from_state,to_state,operator,reason,back) VALUES(%s,%s,%s,%s,%s,%s)',
|
||||
(uid,row['state'],body.state,user,body.reason,back))
|
||||
changed = c.execute('UPDATE dtf_local.orders SET state=%s, version=version+1, updated_at=now() WHERE id=%s RETURNING *', (body.state,uid)).fetchone()
|
||||
events = {'imp':'production_started','cor':'correction_needed','fin':'ready'}
|
||||
if body.state in events:
|
||||
if body.state in events and not back:
|
||||
# "Production started" and "ready" reach the customer once per order,
|
||||
# even if a mistaken move is undone and made again. Each correction
|
||||
# is a new request, so it keeps one message per movement.
|
||||
once = body.state in ('imp','fin')
|
||||
for provider in ('tiny','whatsapp'):
|
||||
enqueue(c, f'{uid}:{changed["version"]}:{provider}', provider,
|
||||
key = f'{uid}:{events[body.state]}:{provider}' if once else f'{uid}:{changed["version"]}:{provider}'
|
||||
enqueue(c, key, provider,
|
||||
{'order_id':str(uid), 'number':row['number'], 'event':events[body.state], 'reason':body.reason,
|
||||
'customer_path': f'/portal.html?order={uid}'})
|
||||
return changed
|
||||
@@ -224,13 +303,34 @@ def tiny_connect(user=Depends(operator)):
|
||||
audit('tiny_connect_started', operator=user)
|
||||
return {'url': tiny.TinyAuth().authorize_url(user)}
|
||||
|
||||
@router.get('/api/operator/tiny/callback')
|
||||
def tiny_callback(code: str = Query(max_length=4096), state: str = Query(max_length=128)):
|
||||
"""Tiny's redirect back. Cross-site, so the operator cookie is absent: the
|
||||
single-use state an operator created is what authorises it."""
|
||||
@router.post('/api/operator/tiny/test')
|
||||
def tiny_test(user=Depends(operator)):
|
||||
"""Read one order and one contact from Tiny. Creates nothing."""
|
||||
if not tiny.configured():
|
||||
raise HTTPException(503, 'Tiny application is not configured')
|
||||
try:
|
||||
results = tiny.check()
|
||||
except tiny.TinyNotConnected as exc:
|
||||
raise HTTPException(409, str(exc))
|
||||
audit('tiny_tested', operator=user, ok=all(v == 'ok' for v in results.values()))
|
||||
return {'ok': all(v == 'ok' for v in results.values()), 'results': results}
|
||||
|
||||
@router.get('/api/operator/tiny/callback')
|
||||
def tiny_callback(code: str = Query('', max_length=4096), state: str = Query('', max_length=128),
|
||||
error: str = Query('', max_length=128)):
|
||||
"""Tiny's redirect back. Cross-site, so the operator cookie is absent: the
|
||||
single-use state an operator created is what authorises it. Tiny reports a
|
||||
refusal (the operator declined, or offline access is not allowed for this
|
||||
application) with `error` instead of a code."""
|
||||
if not tiny.configured():
|
||||
raise HTTPException(503, 'Tiny application is not configured')
|
||||
try:
|
||||
if error == 'invalid_scope':
|
||||
retry = tiny.TinyAuth().without_offline(state)
|
||||
audit('tiny_offline_refused')
|
||||
return RedirectResponse(retry, status_code=303)
|
||||
if error or not code:
|
||||
raise tiny.TinyError(f'Tiny returned {error or "no code"}')
|
||||
who = tiny.TinyAuth().complete(code, state)
|
||||
except tiny.TinyError:
|
||||
audit('tiny_connect_failed')
|
||||
|
||||
@@ -10,7 +10,7 @@ from psycopg.types.json import Jsonb
|
||||
from ..core import db
|
||||
from ..core.auth import owner
|
||||
from ..core.models import QuoteRequest
|
||||
from ..runtime import freight, quote_view, upload_row
|
||||
from ..runtime import freight, quote_view, require_delivery_available, upload_row
|
||||
from ..scanning import require_clean
|
||||
|
||||
router = APIRouter()
|
||||
@@ -20,6 +20,7 @@ def create_quote(body: QuoteRequest, session_id=Depends(owner)):
|
||||
for item in body.items:
|
||||
if abs(item.metres*100-item.production.height_cm) > Decimal('0.02'):
|
||||
raise HTTPException(422, 'Quoted metres do not match the submitted layout height')
|
||||
require_delivery_available(body.freight.service)
|
||||
draft = body.model_dump(mode='json', exclude={'request_key'})
|
||||
digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest()
|
||||
try:
|
||||
|
||||
@@ -37,7 +37,7 @@ def begin_upload(body: UploadStart, session_id=Depends(owner)):
|
||||
usage['owned']+body.size > int(os.environ.get('OWNER_UPLOAD_QUOTA_BYTES','10737418240')) or
|
||||
usage['pending'] >= int(os.environ.get('MAX_PENDING_UPLOADS','10'))):
|
||||
audit('upload_quota_rejected')
|
||||
raise HTTPException(429, 'Local storage quota or pending upload limit reached')
|
||||
raise HTTPException(429, 'Limite de armazenamento ou de envios pendentes atingido. Tente de novo mais tarde.')
|
||||
multipart = storage.begin(key)
|
||||
c.execute('''INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,expires_at)
|
||||
VALUES(%s,%s,%s,%s,%s,%s,now()+interval '1 hour')''',
|
||||
|
||||
@@ -45,7 +45,7 @@ def session_row(request):
|
||||
try:
|
||||
sid = UUID(request.cookies.get('dtf_session', ''))
|
||||
except ValueError:
|
||||
raise HTTPException(401, 'Start a local session first')
|
||||
raise HTTPException(401, 'Sessão não iniciada. Recarregue a página.')
|
||||
with connect() as c:
|
||||
row = c.execute('SELECT * FROM dtf_local.sessions WHERE id=%s AND expires_at>now()', (sid,)).fetchone()
|
||||
if not row:
|
||||
@@ -92,7 +92,7 @@ def rate_limit(scope, identity, limit, seconds=900):
|
||||
RETURNING attempts""", (key,seconds,seconds)).fetchone()
|
||||
if row['attempts'] > limit:
|
||||
audit('rate_limit', scope=scope)
|
||||
raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After':str(seconds)})
|
||||
raise HTTPException(429, 'Muitas tentativas. Tente de novo mais tarde.', headers={'Retry-After':str(seconds)})
|
||||
|
||||
ACCOUNT_FAILURES = 10
|
||||
|
||||
@@ -108,7 +108,7 @@ def throttle(email, request):
|
||||
WHERE key=%s AND started_at >= now()-interval '900 seconds'""", (key,)).fetchone()
|
||||
if row and row['attempts'] >= ACCOUNT_FAILURES:
|
||||
audit('rate_limit', scope='auth-account')
|
||||
raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After': '900'})
|
||||
raise HTTPException(429, 'Muitas tentativas. Tente de novo mais tarde.', headers={'Retry-After': '900'})
|
||||
|
||||
def login_failed(email):
|
||||
"""Count a failed sign-in (or registration attempt) against the account."""
|
||||
@@ -117,10 +117,10 @@ def login_failed(email):
|
||||
def operator(request: Request):
|
||||
token = request.cookies.get('dtf_operator', '')
|
||||
if not token or len(token)>128:
|
||||
raise HTTPException(401, 'Sign in to the local Kanban')
|
||||
raise HTTPException(401, 'Entre no Kanban.')
|
||||
digest = hashlib.sha256(token.encode()).hexdigest()
|
||||
with connect() as c:
|
||||
row = c.execute('SELECT username FROM dtf_local.operator_sessions WHERE token_hash=%s AND expires_at>now()', (digest,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(401, 'Operator session expired')
|
||||
raise HTTPException(401, 'Sua sessão expirou. Entre de novo.')
|
||||
return row['username']
|
||||
|
||||
@@ -46,7 +46,8 @@ Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
|
||||
class Unsupported(Exception):
|
||||
"""This item cannot be generated automatically; the reason is for the operator."""
|
||||
"""This item cannot be generated automatically. The reason is shown to the
|
||||
operator on the Kanban, so it is written in Portuguese."""
|
||||
|
||||
|
||||
class Name(str):
|
||||
@@ -153,14 +154,14 @@ class SourceImage:
|
||||
self.image = Image.open(path)
|
||||
self.format = self.image.format
|
||||
except Image.DecompressionBombError as exc:
|
||||
raise Unsupported(f'"{name}" is too large to generate automatically') from exc
|
||||
raise Unsupported(f'"{name}" é grande demais para gerar automaticamente') from exc
|
||||
except Exception as exc:
|
||||
raise Unsupported(f'"{name}" is not an image this generator can read') from exc
|
||||
raise Unsupported(f'"{name}" não é uma imagem que o gerador consiga ler') from exc
|
||||
if self.format not in SUPPORTED_FORMATS:
|
||||
raise Unsupported(f'"{name}" is {self.format or "an unknown format"}; '
|
||||
'only JPEG, PNG, WebP and TIFF are generated automatically')
|
||||
raise Unsupported(f'"{name}" está em {self.format or "formato desconhecido"}; '
|
||||
'só JPEG, PNG, WebP, TIFF e PDF são gerados automaticamente')
|
||||
if getattr(self.image, 'n_frames', 1) > 1 and self.format != 'TIFF':
|
||||
raise Unsupported(f'"{name}" is animated or has several frames')
|
||||
raise Unsupported(f'"{name}" é animado ou tem vários quadros')
|
||||
# Browsers draw a photo upright according to its EXIF orientation, and
|
||||
# the Site measured it that way, so the print must too.
|
||||
try:
|
||||
@@ -181,8 +182,8 @@ class SourceImage:
|
||||
return self._embed_jpeg(pdf)
|
||||
width, height = self.image.size
|
||||
if width * height > MAX_DECODED_PIXELS:
|
||||
raise Unsupported(f'"{self.name}" has {width} x {height} px, more than the '
|
||||
'generator decodes; prepare this item by hand')
|
||||
raise Unsupported(f'"{self.name}" tem {width} × {height} px, mais do que o '
|
||||
'gerador processa; prepare este item à mão')
|
||||
return self._embed_pixels(pdf)
|
||||
|
||||
def _colorspace(self, pdf, mode):
|
||||
@@ -235,7 +236,7 @@ class SourceImage:
|
||||
elif mode in ('P', 'PA', 'RGB', 'RGBA', 'RGBa'):
|
||||
color_mode = 'RGB'
|
||||
else:
|
||||
raise Unsupported(f'"{self.name}" uses the {mode} colour mode, which is not generated automatically')
|
||||
raise Unsupported(f'"{self.name}" usa o modo de cor {mode}, que não é gerado automaticamente')
|
||||
if has_alpha:
|
||||
image = image.convert('RGBA' if color_mode == 'RGB' else 'LA')
|
||||
width, height = image.size
|
||||
@@ -304,29 +305,29 @@ class PdfPage:
|
||||
try:
|
||||
self.pdf = pikepdf.open(path)
|
||||
except pikepdf.PasswordError as exc:
|
||||
raise Unsupported(f'"{name}" is password-protected') from exc
|
||||
raise Unsupported(f'"{name}" está protegido por senha') from exc
|
||||
except Exception as exc:
|
||||
raise Unsupported(f'"{name}" is not a PDF this generator can read') from exc
|
||||
raise Unsupported(f'"{name}" não é um PDF que o gerador consiga ler') from exc
|
||||
try:
|
||||
pages = len(self.pdf.pages)
|
||||
if pages != 1:
|
||||
raise Unsupported(f'"{name}" has {pages} pages; only single-page PDFs are generated automatically')
|
||||
raise Unsupported(f'"{name}" tem {pages} páginas; só PDFs de uma página são gerados automaticamente')
|
||||
self.page = self.pdf.pages[0]
|
||||
self.rotation = int(self.page.rotation) % 360
|
||||
if self.rotation not in (0, 90, 180, 270):
|
||||
raise Unsupported(f'"{name}" has an unsupported page rotation')
|
||||
raise Unsupported(f'"{name}" tem uma rotação de página não suportada')
|
||||
box = [float(v) for v in self.page.cropbox]
|
||||
except Unsupported:
|
||||
self.pdf.close()
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.pdf.close()
|
||||
raise Unsupported(f'"{name}" has a page this generator cannot read') from exc
|
||||
raise Unsupported(f'"{name}" tem uma página que o gerador não consegue ler') from exc
|
||||
self.x0, self.y0 = min(box[0], box[2]), min(box[1], box[3])
|
||||
self.w, self.h = abs(box[2] - box[0]), abs(box[3] - box[1])
|
||||
if self.w <= 0 or self.h <= 0:
|
||||
self.pdf.close()
|
||||
raise Unsupported(f'"{name}" has an empty page')
|
||||
raise Unsupported(f'"{name}" tem uma página vazia')
|
||||
# As displayed, which is what the proportions are checked against.
|
||||
self.size = (self.h, self.w) if self.rotation in (90, 270) else (self.w, self.h)
|
||||
|
||||
@@ -373,7 +374,7 @@ def check_layout(item, sizes, vector=()):
|
||||
height = Decimal(str(production['height_cm']))
|
||||
billed = Decimal(str(item['billed_metres'])) * 100
|
||||
if height > billed + HEIGHT_TOLERANCE_CM:
|
||||
raise Unsupported(f'layout is {height} cm long but only {billed} cm were billed')
|
||||
raise Unsupported(f'a montagem tem {height} cm, mas só {billed} cm foram cobrados')
|
||||
lowest = None
|
||||
for placement in production['placements']:
|
||||
width_px, height_px = sizes[placement['source_index']]
|
||||
@@ -384,8 +385,8 @@ def check_layout(item, sizes, vector=()):
|
||||
drift = abs((width_px / height_px) / (width_cm / length_cm) - 1)
|
||||
if drift > ASPECT_TOLERANCE:
|
||||
source = production['sources'][placement['source_index']]
|
||||
raise Unsupported(f'file {placement["source_index"] + 1} has proportions that do not match '
|
||||
f'the quoted {source["width_cm"]} x {source["length_cm"]} cm')
|
||||
raise Unsupported(f'o arquivo {placement["source_index"] + 1} tem proporções diferentes '
|
||||
f'das cotadas ({source["width_cm"]} × {source["length_cm"]} cm)')
|
||||
if placement['source_index'] in vector:
|
||||
continue
|
||||
dpi = width_px / (width_cm / 2.54)
|
||||
@@ -405,7 +406,7 @@ def render(item, files, out, title):
|
||||
"""
|
||||
production = item['production']
|
||||
if production.get('version') != 2:
|
||||
raise Unsupported('item uses an obsolete production layout')
|
||||
raise Unsupported('o item usa uma montagem antiga')
|
||||
sources = []
|
||||
try:
|
||||
for index in range(len(production['sources'])):
|
||||
|
||||
@@ -101,9 +101,9 @@ def produce(storage, job, item, uploads):
|
||||
for upload_id in item['uploads']:
|
||||
row = uploads.get(upload_id)
|
||||
if not row or row['scan_state'] != 'clean':
|
||||
raise Unsupported('an original file is missing or not cleared by the malware scan')
|
||||
raise Unsupported('um arquivo original está ausente ou não foi liberado pelo antivírus')
|
||||
if row['purged_at'] or row['expired']:
|
||||
raise Unsupported('an original file has passed its retention period')
|
||||
raise Unsupported('um arquivo original passou do prazo de guarda')
|
||||
number = job['number']
|
||||
name = f'pedido-{number}-item-{job["item_index"] + 1}.pdf'
|
||||
with tempfile.TemporaryDirectory(prefix='print-') as scratch:
|
||||
|
||||
@@ -46,6 +46,16 @@ STATES = {'rec': 'Arte recebida', 'tra': 'Arte tratada', 'fil': 'Fila de impress
|
||||
'imp': 'Imprimindo', 'cor': 'Correção', 'fin': 'Finalizado'}
|
||||
TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'],
|
||||
'imp': ['fin','cor'], 'cor': ['rec','tra'], 'fin': []}
|
||||
# Undoing a move made by mistake: one stage back, with an internal reason. It
|
||||
# is not a correction: the customer is not told and approved finals stay.
|
||||
BACK = {'tra': 'rec', 'fil': 'tra', 'imp': 'fil', 'fin': 'imp'}
|
||||
|
||||
|
||||
def require_delivery_available(service):
|
||||
"""Outside the local stack, a simulated freight price must never reach a
|
||||
customer: until a real freight provider exists, only pickup is offered."""
|
||||
if service != 'pickup' and ENVIRONMENT != 'local' and getattr(freight, 'name', '') == 'fake':
|
||||
raise HTTPException(503, 'A entrega ainda não está disponível. Escolha a retirada em Franca.')
|
||||
|
||||
|
||||
def upload_row(c, upload_id, session_id, lock=False):
|
||||
|
||||
@@ -107,6 +107,13 @@ CREATE TABLE IF NOT EXISTS dtf_local.provider_tokens (
|
||||
connected_by text NOT NULL, connected_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
-- Renewal health, cleared by the next successful renewal or connection.
|
||||
-- refused_at: the provider rejected the refresh token, so only a new
|
||||
-- connection helps. offline: the grant is not bound to a login session.
|
||||
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refresh_failed_at timestamptz;
|
||||
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refresh_error text;
|
||||
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refused_at timestamptz;
|
||||
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS offline boolean NOT NULL DEFAULT false;
|
||||
-- Single-use states for an operator-started OAuth connection. They protect the
|
||||
-- callback, which arrives cross-site without the operator's cookie.
|
||||
CREATE TABLE IF NOT EXISTS dtf_local.oauth_states (
|
||||
@@ -173,6 +180,9 @@ CREATE INDEX IF NOT EXISTS payment_events_unprocessed ON dtf_local.payment_event
|
||||
CREATE INDEX IF NOT EXISTS print_files_open ON dtf_local.print_files(created_at)
|
||||
WHERE status IN ('pending','rendering');
|
||||
|
||||
-- A movement that undid an earlier one (a mistaken move), shown as such.
|
||||
ALTER TABLE dtf_local.movements ADD COLUMN IF NOT EXISTS back boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- The Kanban lists payment events a person must act on (money without an
|
||||
-- order, or a reversed payment on an existing order) until resolved.
|
||||
ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolved_at timestamptz;
|
||||
|
||||
136
app/tiny.py
136
app/tiny.py
@@ -42,6 +42,9 @@ PRODUCTS = {'file': 'DTF Têxtil 57 cm · folha montada',
|
||||
# How far back to look for an order a previous delivery may already have made.
|
||||
SEARCH_DAYS = 7
|
||||
STATE_MINUTES = 10
|
||||
# Renewal runs about every four hours against a one-day refresh token, so
|
||||
# less than this left means renewals have been failing for hours.
|
||||
EXPIRY_WARNING = timedelta(hours=12)
|
||||
# Brazil has had no daylight saving since 2019; the order date is the local day.
|
||||
BRASILIA = timezone(timedelta(hours=-3))
|
||||
|
||||
@@ -86,44 +89,78 @@ class TinyAuth:
|
||||
self.http = httpx.Client(timeout=20, transport=transport)
|
||||
self.clock = clock
|
||||
|
||||
def authorize_url(self, operator):
|
||||
def authorize_url(self, operator, offline=True):
|
||||
"""Start a connection. The state is single-use, short-lived, and only an
|
||||
authenticated operator can create one, which is what protects the
|
||||
callback: Tiny's redirect back is cross-site, so the operator's
|
||||
SameSite=Strict cookie does not travel with it."""
|
||||
SameSite=Strict cookie does not travel with it.
|
||||
|
||||
offline_access asks for a grant that is not bound to a login session,
|
||||
so the connection lasts as long as it keeps being renewed. Tiny's
|
||||
documented refresh token otherwise lasts one day."""
|
||||
state = secrets.token_urlsafe(32)
|
||||
with self.connect() as c:
|
||||
c.execute("DELETE FROM dtf_local.oauth_states WHERE expires_at<now()")
|
||||
c.execute('''INSERT INTO dtf_local.oauth_states(state,provider,operator,expires_at)
|
||||
VALUES(%s,'tiny',%s,now()+%s)''', (state, operator, timedelta(minutes=STATE_MINUTES)))
|
||||
return f'{AUTH}/auth?' + urlencode({'response_type': 'code', 'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri, 'scope': 'openid',
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'scope': 'openid offline_access' if offline else 'openid',
|
||||
'state': state})
|
||||
|
||||
def _claim(self, c, state):
|
||||
row = c.execute('''DELETE FROM dtf_local.oauth_states WHERE state=%s AND provider='tiny'
|
||||
AND expires_at>now() RETURNING operator''', (state,)).fetchone()
|
||||
if not row:
|
||||
raise TinyError('Unknown or expired authorisation state')
|
||||
return row['operator']
|
||||
|
||||
def complete(self, code, state):
|
||||
"""Exchange the authorisation code; returns the operator who started it."""
|
||||
with self.connect() as c:
|
||||
row = c.execute('''DELETE FROM dtf_local.oauth_states WHERE state=%s AND provider='tiny'
|
||||
AND expires_at>now() RETURNING operator''', (state,)).fetchone()
|
||||
if not row:
|
||||
raise TinyError('Unknown or expired authorisation state')
|
||||
operator = self._claim(c, state)
|
||||
tokens = self._token({'grant_type': 'authorization_code', 'code': code,
|
||||
'redirect_uri': self.redirect_uri})
|
||||
self._store(c, tokens, row['operator'])
|
||||
return row['operator']
|
||||
self._store(c, tokens, operator)
|
||||
return operator
|
||||
|
||||
def without_offline(self, state):
|
||||
"""Tiny refused the offline_access scope for this application: spend the
|
||||
operator's state and start again with a session-bound grant."""
|
||||
with self.connect() as c:
|
||||
operator = self._claim(c, state)
|
||||
return self.authorize_url(operator, offline=False)
|
||||
|
||||
def status(self):
|
||||
with self.connect() as c:
|
||||
row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at
|
||||
FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone()
|
||||
row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at,updated_at,offline,
|
||||
refresh_failed_at,refused_at FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone()
|
||||
if not row:
|
||||
return {'connected': False}
|
||||
expired = row['refresh_expires_at'] and row['refresh_expires_at'] <= datetime.now(timezone.utc)
|
||||
return {'connected': not expired, 'connected_by': row['connected_by'],
|
||||
'connected_at': row['connected_at'], 'expires_at': row['refresh_expires_at']}
|
||||
now = datetime.now(timezone.utc)
|
||||
expires = row['refresh_expires_at']
|
||||
expired = bool(expires and expires <= now)
|
||||
if row['refused_at']:
|
||||
problem = 'refused'
|
||||
elif expired:
|
||||
problem = 'expired'
|
||||
elif row['refresh_failed_at']:
|
||||
problem = 'renewal-failing'
|
||||
elif expires and expires - now < EXPIRY_WARNING:
|
||||
problem = 'expiring'
|
||||
else:
|
||||
problem = None
|
||||
return {'connected': not expired and not row['refused_at'], 'problem': problem,
|
||||
'connected_by': row['connected_by'], 'connected_at': row['connected_at'],
|
||||
'renewed_at': row['updated_at'], 'expires_at': expires, 'offline': row['offline'],
|
||||
'failed_at': row['refresh_failed_at']}
|
||||
|
||||
def access_token(self):
|
||||
"""A valid access token, refreshing (and rotating) under a row lock."""
|
||||
"""A valid access token, refreshing (and rotating) under a row lock.
|
||||
|
||||
A failed renewal is recorded after the lock is released, so the Kanban
|
||||
can show it. A refused refresh token is never tried again: only a new
|
||||
connection helps, and retrying it would only repeat the refusal."""
|
||||
with self.connect() as c:
|
||||
row = c.execute('''SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny'
|
||||
FOR UPDATE''').fetchone()
|
||||
@@ -132,11 +169,25 @@ class TinyAuth:
|
||||
now = datetime.now(timezone.utc)
|
||||
if row['access_expires_at'] > now + timedelta(seconds=60):
|
||||
return row['access_token']
|
||||
if row['refused_at']:
|
||||
raise TinyNotConnected('Tiny refused the stored refresh token; connect again on the Kanban')
|
||||
if row['refresh_expires_at'] and row['refresh_expires_at'] <= now:
|
||||
raise TinyNotConnected('The Tiny connection expired; connect again on the Kanban')
|
||||
tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']})
|
||||
self._store(c, tokens, row['connected_by'], refreshed=True)
|
||||
return tokens['access_token']
|
||||
try:
|
||||
tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']})
|
||||
except (TinyError, httpx.HTTPError, ValueError) as exc:
|
||||
failure = exc
|
||||
else:
|
||||
self._store(c, tokens, row['connected_by'], refreshed=True)
|
||||
return tokens['access_token']
|
||||
# Only against the token that failed: another worker may have renewed since.
|
||||
with self.connect() as c:
|
||||
c.execute('''UPDATE dtf_local.provider_tokens SET refresh_failed_at=now(), refresh_error=%s,
|
||||
refused_at=CASE WHEN %s THEN now() ELSE refused_at END
|
||||
WHERE provider='tiny' AND refresh_token=%s''',
|
||||
(str(failure)[:300] or type(failure).__name__, isinstance(failure, TinyNotConnected),
|
||||
row['refresh_token']))
|
||||
raise failure
|
||||
|
||||
def _token(self, form):
|
||||
response = self.http.post(f'{AUTH}/token', data={**form, 'client_id': self.client_id,
|
||||
@@ -152,18 +203,21 @@ class TinyAuth:
|
||||
def _store(self, c, tokens, operator, refreshed=False):
|
||||
now = datetime.now(timezone.utc)
|
||||
access_expires = now + timedelta(seconds=int(tokens.get('expires_in', 300)))
|
||||
# An offline grant reports refresh_expires_in 0: no fixed end.
|
||||
refresh_in = tokens.get('refresh_expires_in')
|
||||
refresh_expires = now + timedelta(seconds=int(refresh_in)) if refresh_in else None
|
||||
offline = 'offline_access' in str(tokens.get('scope') or '').split()
|
||||
c.execute('''INSERT INTO dtf_local.provider_tokens(provider,access_token,refresh_token,
|
||||
access_expires_at,refresh_expires_at,connected_by,connected_at,updated_at)
|
||||
VALUES('tiny',%s,%s,%s,%s,%s,now(),now())
|
||||
access_expires_at,refresh_expires_at,connected_by,connected_at,updated_at,offline)
|
||||
VALUES('tiny',%s,%s,%s,%s,%s,now(),now(),%s)
|
||||
ON CONFLICT(provider) DO UPDATE SET access_token=EXCLUDED.access_token,
|
||||
refresh_token=EXCLUDED.refresh_token, access_expires_at=EXCLUDED.access_expires_at,
|
||||
refresh_expires_at=EXCLUDED.refresh_expires_at, updated_at=now(),
|
||||
refresh_expires_at=EXCLUDED.refresh_expires_at, updated_at=now(), offline=EXCLUDED.offline,
|
||||
refresh_failed_at=NULL, refresh_error=NULL, refused_at=NULL,
|
||||
connected_by=CASE WHEN %s THEN dtf_local.provider_tokens.connected_by ELSE EXCLUDED.connected_by END,
|
||||
connected_at=CASE WHEN %s THEN dtf_local.provider_tokens.connected_at ELSE now() END''',
|
||||
(tokens['access_token'], tokens['refresh_token'], access_expires, refresh_expires,
|
||||
operator, refreshed, refreshed))
|
||||
operator, offline, refreshed, refreshed))
|
||||
|
||||
|
||||
# Orders ------------------------------------------------------------------
|
||||
@@ -223,6 +277,44 @@ def order_payload(payload, contact_id, today=None):
|
||||
return pedido
|
||||
|
||||
|
||||
def configured_product(orders, mode):
|
||||
"""The Tiny product a mode's setting points at, or a reason it cannot be used."""
|
||||
setting = PRODUCT_SETTINGS[mode]
|
||||
value = os.environ.get(setting, '')
|
||||
if not value.isdigit():
|
||||
return None, f'{setting} sem id'
|
||||
found = orders.request('GET', f'/produtos/{value}')
|
||||
if found.get('situacao') != 'A':
|
||||
return found, f"produto {value} não está ativo no Tiny ({found.get('situacao')})"
|
||||
return found, 'ok'
|
||||
|
||||
|
||||
def check(auth=None, transport=None, orders=None):
|
||||
"""Read-only proof that the connection and permissions work: one order and
|
||||
one contact listed and each configured product found active, nothing
|
||||
created. Raises TinyNotConnected when there is no usable connection;
|
||||
otherwise reports each read separately."""
|
||||
orders = orders or TinyOrders(auth=auth or TinyAuth(), transport=transport)
|
||||
|
||||
def listed(path):
|
||||
orders.request('GET', path, params={'limit': 1})
|
||||
return 'ok'
|
||||
|
||||
reads = [('pedidos', lambda: listed('/pedidos')), ('contatos', lambda: listed('/contatos'))]
|
||||
reads += [(PRODUCTS[mode], lambda mode=mode: configured_product(orders, mode)[1]) for mode in PRODUCT_SETTINGS]
|
||||
results = {}
|
||||
for name, read in reads:
|
||||
try:
|
||||
results[name] = read()
|
||||
except TinyNotConnected:
|
||||
raise
|
||||
except TinyError as exc:
|
||||
results[name] = str(exc)[:200]
|
||||
except httpx.HTTPError:
|
||||
results[name] = 'sem resposta do Tiny'
|
||||
return results
|
||||
|
||||
|
||||
class TinyOrders:
|
||||
def __init__(self, auth=None, transport=None, today=None):
|
||||
missing = [name for name in required_settings() if not os.environ.get(name)]
|
||||
|
||||
132
app/tiny_probe.py
Normal file
132
app/tiny_probe.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Supervised checks against the client's real Tiny, run by hand from a
|
||||
container console (docker exec, or Portainer > Containers > worker > Console).
|
||||
Tiny has no sandbox, so this is how the adapter is proven on the real account.
|
||||
|
||||
python -m app.tiny_probe produtos [termo] list active products (read-only)
|
||||
python -m app.tiny_probe conferir connection and the four product ids (read-only)
|
||||
python -m app.tiny_probe pedido --cnpj ... --email ... --celular ... [--modo file]
|
||||
show the test order; --confirmar creates it
|
||||
|
||||
The test order goes through TinyOrders.deliver, exactly as the worker sends a
|
||||
paid order. The duplicate guard is then proven read-only first (the order must
|
||||
be found by its purchase-order number) and only then by a second delivery,
|
||||
which must return the existing order. If the search cannot find the order, the
|
||||
second delivery is not attempted: it would create a duplicate. Cancel the test
|
||||
order in Olist afterwards.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from .core.pricing import TIERS
|
||||
from .core.secrets import load as load_secret_files
|
||||
from . import tiny
|
||||
|
||||
FIND_ATTEMPTS = 4
|
||||
FIND_WAIT_SECONDS = 5
|
||||
|
||||
|
||||
def test_order(cnpj, email, celular, mode, now=None):
|
||||
number = 'TESTE-' + (now or datetime.now(tiny.BRASILIA)).strftime('%Y%m%d%H%M')
|
||||
unit = TIERS[mode][0][1]
|
||||
return {'order_id': 'teste-integracao', 'number': number, 'event': 'payment_approved',
|
||||
'order': {'customer': {'cnpj': cnpj, 'mail': email, 'zap': celular},
|
||||
'items': [{'mode': mode, 'grade': 100, 'billed_metres': '1', 'unit_cents': unit}],
|
||||
'freight': {'service': 'pickup', 'total_cents': 0},
|
||||
'destination': None, 'total_cents': unit}}
|
||||
|
||||
|
||||
def produtos(orders, termo, out):
|
||||
params = {'situacao': 'A', 'limit': 100}
|
||||
if termo:
|
||||
params['nome'] = termo
|
||||
found = orders.request('GET', '/produtos', params=params).get('itens') or []
|
||||
for item in found:
|
||||
preco = (item.get('precos') or {}).get('preco')
|
||||
out(f"{item['id']}\t{item.get('sku') or '-'}\t{item.get('descricao')}\tR$ {preco}")
|
||||
out(f'{len(found)} produto(s) ativo(s)' + (f' com "{termo}"' if termo else '') + '.')
|
||||
|
||||
|
||||
def conferir(orders, out):
|
||||
ok = True
|
||||
for mode, setting in tiny.PRODUCT_SETTINGS.items():
|
||||
product, result = tiny.configured_product(orders, mode)
|
||||
ok &= result == 'ok'
|
||||
described = f"{product.get('sku') or '-'} · {product.get('descricao')}" if product else ''
|
||||
out(f'{setting} ({tiny.PRODUCTS[mode]}): {result} {described}'.rstrip())
|
||||
for name, result in tiny.check(orders=orders).items():
|
||||
if name in ('pedidos', 'contatos'):
|
||||
ok &= result == 'ok'
|
||||
out(f'{name}: {result}')
|
||||
out('Tudo conferido.' if ok else 'Há pendências acima.')
|
||||
return ok
|
||||
|
||||
|
||||
def pedido(orders, payload, confirm, out, wait=time.sleep):
|
||||
cnpj = payload['order']['customer']['cnpj']
|
||||
contacts = orders.request('GET', '/contatos', params={'cpfCnpj': cnpj, 'limit': 5}).get('itens') or []
|
||||
known = [c for c in contacts if ''.join(ch for ch in str(c.get('cpfCnpj') or '') if ch.isdigit()) == cnpj]
|
||||
out(f"Contato {cnpj}: " + (f"já existe no Tiny (id {known[0]['id']})" if known
|
||||
else 'não existe; será criado com o e-mail como nome'))
|
||||
out('Pedido que será enviado:')
|
||||
out(json.dumps(tiny.order_payload(payload, known[0]['id'] if known else 0), ensure_ascii=False, indent=2))
|
||||
if not confirm:
|
||||
out('Nada foi criado. Repita com --confirmar para criar este pedido no Tiny.')
|
||||
return None
|
||||
first = orders.deliver('teste-integracao', payload)
|
||||
out(f"1º envio: {first['status']} · Tiny id {first['tiny_id']} · nº {first['tiny_number']}")
|
||||
for attempt in range(FIND_ATTEMPTS):
|
||||
found = orders.find(payload)
|
||||
if found:
|
||||
break
|
||||
if attempt < FIND_ATTEMPTS - 1:
|
||||
wait(FIND_WAIT_SECONDS)
|
||||
else:
|
||||
out(f'FALHA: a busca por {tiny.purchase_order(payload["number"])} não encontrou o pedido. '
|
||||
'Um reenvio criaria um duplicado; o 2º envio não foi feito. Cancele o pedido no Olist.')
|
||||
return False
|
||||
second = orders.deliver('teste-integracao', payload)
|
||||
out(f"2º envio: {second['status']} · Tiny id {second['tiny_id']}")
|
||||
passed = second['status'] == 'already-created' and second['tiny_id'] == first['tiny_id']
|
||||
out(('OK: o reenvio encontrou o mesmo pedido.' if passed else 'FALHA: o reenvio não devolveu o mesmo pedido.')
|
||||
+ f" Cancele o pedido nº {first['tiny_number']} no Olist.")
|
||||
return passed
|
||||
|
||||
|
||||
def main(argv=None, orders=None, out=print):
|
||||
parser = argparse.ArgumentParser(prog='python -m app.tiny_probe')
|
||||
commands = parser.add_subparsers(dest='command', required=True)
|
||||
listing = commands.add_parser('produtos')
|
||||
listing.add_argument('termo', nargs='?', default='')
|
||||
commands.add_parser('conferir')
|
||||
order = commands.add_parser('pedido')
|
||||
order.add_argument('--cnpj', required=True)
|
||||
order.add_argument('--email', required=True)
|
||||
order.add_argument('--celular', required=True)
|
||||
order.add_argument('--modo', choices=sorted(tiny.PRODUCT_SETTINGS), default='file')
|
||||
order.add_argument('--confirmar', action='store_true')
|
||||
args = parser.parse_args(argv)
|
||||
if orders is None:
|
||||
load_secret_files()
|
||||
orders = tiny.TinyOrders(auth=tiny.TinyAuth())
|
||||
try:
|
||||
if args.command == 'produtos':
|
||||
produtos(orders, args.termo, out)
|
||||
return 0
|
||||
if args.command == 'conferir':
|
||||
return 0 if conferir(orders, out) else 1
|
||||
cnpj = ''.join(ch for ch in args.cnpj if ch.isdigit())
|
||||
if len(cnpj) != 14:
|
||||
out('Informe um CNPJ com 14 dígitos.')
|
||||
return 2
|
||||
payload = test_order(cnpj, args.email, args.celular, args.modo)
|
||||
return 0 if pedido(orders, payload, args.confirmar, out) is not False else 1
|
||||
except tiny.TinyError as exc:
|
||||
out(f'Tiny: {exc}')
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -48,6 +48,11 @@ server {
|
||||
}
|
||||
# Always revalidate HTML/JS/CSS after a deployment. Without this, a browser
|
||||
# can pair a new Kanban page with a cached older script after a rollout.
|
||||
# The Site's product pages and cart are addresses of the same page (web/site-pages.js).
|
||||
location ~ ^/(arquivo-por-metro|artes-avulsas|uv-arquivo-por-metro|uv-artes-avulsas|carrinho)/?$ {
|
||||
expires -1;
|
||||
try_files /index.html =404;
|
||||
}
|
||||
location / {
|
||||
expires -1;
|
||||
try_files $uri $uri/ =404;
|
||||
|
||||
@@ -62,25 +62,35 @@ operator interfaces.
|
||||
4. Click the Site checkout button. Files upload directly to MinIO, remain
|
||||
quarantined until ClamAV returns `clean`, and then become eligible for a quote.
|
||||
The local banner displays a quote ID awaiting commercial review.
|
||||
5. Open Kanban and log in. In **Cotações**, download the original if needed,
|
||||
confirm/correct total metres and grade, tick the manual confirmation, and
|
||||
click **Aprovar cotação**. For the fixture keep 1.01 m and grade 0.
|
||||
5. Open Kanban and log in. Open the **Cotações** tab, pick the quote, download
|
||||
the original if needed, confirm/correct metres and grade, tick the
|
||||
confirmation at the bottom and click **Aprovar cotação**. For the fixture
|
||||
keep 1.01 m and grade 0.
|
||||
6. Return to the Site and click its checkout action again. Inspect the
|
||||
authoritative server total, then click **Criar pedido de teste**. No real
|
||||
payment occurs.
|
||||
7. Refresh Kanban. The paid order starts in **Arte recebida**. Move it using
|
||||
the buttons or drag and drop to **Arte tratada**. Open **Arquivos de produção**,
|
||||
select the manually prepared final files for every item, enter a review note,
|
||||
tick the confirmation and click **Aprovar arquivos finais**. Use the harmless
|
||||
fixture again only for this local test. The text fixture is not an image, so
|
||||
its print file shows **preparar à mão**; with a PNG or JPEG artwork the
|
||||
generated PDF is preselected instead (see "Print files").
|
||||
Continue through **Fila de impressão →
|
||||
Imprimindo → Finalizado**. A move to **Correção** requires a reason; it can
|
||||
return to **Arte recebida** or **Arte tratada**. Finalizado is terminal locally.
|
||||
8. Inspect **Histórico** and **Eventos locais de integração**. Worker receipts
|
||||
should say recorded locally. Download links expire after five minutes;
|
||||
request another link from Kanban when needed.
|
||||
7. Click **Atualizar** on the Kanban. The paid order appears in **Arte
|
||||
recebida** on the **Produção** tab. Click its card to open the order panel,
|
||||
then **Mover para Arte tratada** (dragging the card to a column also works).
|
||||
In the panel's **Arquivos finais**, select the manually prepared final file
|
||||
for every item, enter a review note, tick the confirmation and click
|
||||
**Aprovar arquivos finais**. Use the harmless fixture again only for this
|
||||
local test. The text fixture is not an image, so its print file shows
|
||||
**Preparar à mão**; with a PNG, JPEG or PDF artwork the generated file is
|
||||
preselected instead (see "Print files"). Continue with the panel's main
|
||||
button through **Fila de impressão → Imprimindo → Finalizado**. **Pedir
|
||||
correção** asks for the reason the customer will see; from **Correção** the
|
||||
order returns to **Arte recebida** or **Arte tratada**. A move made by
|
||||
mistake is undone with **Voltar para …**: one stage back, with an internal
|
||||
reason recorded in the history. The customer is not notified, approved
|
||||
finals stay, and "produção iniciada"/"pedido pronto" are sent only once per
|
||||
order even if the stage is entered again. Dragging a card only goes forward;
|
||||
the columns it can be dropped on are highlighted.
|
||||
8. The panel's **Histórico** lists every move. The **Pagamentos** tab lists
|
||||
payments that need a person; **Integrações** shows the Tiny connection and
|
||||
the send log; with the fake adapters every send reads "Registrado
|
||||
(simulado)". Download links expire after five minutes; click again for a new
|
||||
one. The previous Kanban is kept in git tag `ui-v1`.
|
||||
|
||||
The manual quote step is necessary to enforce the backend trust boundary while
|
||||
automatic pre-flight is deferred. Browser measurements and grade are proposals.
|
||||
|
||||
@@ -7,16 +7,23 @@
|
||||
> Update the **Current step** line and the item status every time something moves.
|
||||
> Add new findings at the bottom of the relevant block rather than rewriting history.
|
||||
|
||||
**Current step (2026-09-24, Week 2):** Client inputs for Mercado Pago and
|
||||
freight were requested on 2026-09-24; Tiny access is already with the client.
|
||||
Built without them: server-side print-file generation (1.4), the delivery
|
||||
address (3.8), the Kanban's payment-issue and print-file views (1.5), and
|
||||
Mercado Pago and Tiny adapters written from the public API documentation and
|
||||
tested against fake transports (1.1, 1.3). None of the provider work is a
|
||||
verified integration. The complete CI integration sequence passed locally on
|
||||
2026-09-24 (all API suites including the new `print_file_test`, adapter and
|
||||
generator unit suites, retention, runtime security, and both browser suites),
|
||||
run with Docker Engine in WSL against a fresh build; pending a Gitea runner run.
|
||||
**Current step (2026-09-25, last day of Week 2):** Still waiting on the client
|
||||
for Mercado Pago credentials and webhook access (1.1) and freight data (1.2);
|
||||
Tiny's app credentials are in production and the connection starts from the
|
||||
Kanban (1.3). Built and deployed without them: server-side print-file
|
||||
generation (1.4), the delivery address (3.8), the Kanban's payment-issue and
|
||||
print-file views and its redesign (1.5), the Site redesign (5.17), and Mercado
|
||||
Pago and Tiny adapters written from the public API documentation and tested
|
||||
against fake transports. None of the provider work is a verified integration.
|
||||
Every change passed the full integration sequence locally (Docker Engine in
|
||||
WSL) and on the Gitea runner, which publishes images on green pushes to `main`;
|
||||
the user verified each release in production after redeploying in Portainer.
|
||||
Operator and Site guides are in `docs/` (see Reporting).
|
||||
**Left for today:** product ids for the four products from the client's Tiny and
|
||||
one supervised real order (1.3); a Mercado Pago sandbox payment by PIX and card
|
||||
if the credentials arrive (1.1); the freight connector if the data arrives
|
||||
(1.2, otherwise the one Week 2 item that slips, on client inputs); the Week 2
|
||||
client report.
|
||||
|
||||
**Previous step (2026-09-23):** Payment safety fixes 2.13 and 2.14 and
|
||||
the local order-correctness work in 3.6/3.9 have passed integration checks.
|
||||
@@ -261,6 +268,27 @@ From the report already sent. These are dated promises, not backlog.
|
||||
real orders. Still to confirm on the client's account: plan (Construa+),
|
||||
product ids, token lifetimes, whether pickup needs a transportador, and
|
||||
rate limits.
|
||||
**Supervised run (2026-09-25):** the payload and query parameters were
|
||||
checked against Tiny's published v3 OpenAPI spec (`GET /pedidos` accepts
|
||||
`cpfCnpj` and `dataInicial`; `GET /pedidos/{id}` returns
|
||||
`numeroOrdemCompra`). "Testar conexão" on the Kanban now also reads the four
|
||||
configured products and requires each to be active. `python -m
|
||||
app.tiny_probe` runs from the worker console: `produtos` and `conferir` are
|
||||
read-only; `pedido` shows the test order and creates it only with
|
||||
`--confirmar`, through the worker's own `deliver`, then proves the duplicate
|
||||
guard by search first and only then by a second delivery. Not yet run
|
||||
against the client's account.
|
||||
**Staying connected (2026-09-25):** Tiny documents a 4-hour access token and
|
||||
a 1-day refresh token; the worker renews about every 4 hours. The connection
|
||||
now asks for `offline_access` (listed by Tiny's Keycloak; if refused for this
|
||||
application the callback retries once without it). Renewal failures are
|
||||
stored: a refused refresh token marks the connection lost and is not retried,
|
||||
a transient failure shows as a warning until the next success, and a
|
||||
session grant with under 12 hours left is flagged. Previously a refused
|
||||
refresh still showed "Tiny conectado". Whether Tiny grants offline access,
|
||||
and whether a session grant has an undocumented maximum, is only known on
|
||||
the client's account. There is no alert channel yet (no e-mail; WhatsApp
|
||||
is fake): problems show on the Kanban only.
|
||||
- `[~]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions
|
||||
must survive checkout before an output engine can reproduce the approved job).
|
||||
**Built (2026-09-24):** each paid item gets a PDF the width of the film and
|
||||
@@ -287,6 +315,17 @@ download, approve as final, queue; hand-preparation routing and retry).
|
||||
print-file status per item, and a panel lists payments that need a person
|
||||
(money without an order, refunds after an order) until resolved. Confirm
|
||||
with the operation that these are the main states before closing.
|
||||
**2026-09-24:** a mistaken move can be undone one stage back (`BACK` in
|
||||
`app/runtime.py`) with an internal reason, flagged in the history
|
||||
(`movements.back`), without notifying the customer; "production started" and
|
||||
"ready" are enqueued once per order. Previously the only way back was
|
||||
Correção, which messages the customer and invalidates approved finals.
|
||||
**Redesign (2026-09-24):** tabs for Produção, Cotações, Pagamentos and
|
||||
Integrações; an order panel with stages, items, print files, final files and
|
||||
history; numbered pagination (20 per page) on quotes, payment issues and the
|
||||
integration log, which also has filters; messages dismiss themselves. The
|
||||
operator guide (`docs/guia-operador-kanban.pdf`) documents the flow for the
|
||||
operation to confirm.
|
||||
- `[x]` 1.6 — **Block 0.2 + 0.3** were completed and verified on 2026-09-18.
|
||||
|
||||
`[!]` The production compose currently blocks `dev_paid` (`ENVIRONMENT != 'local'`)
|
||||
@@ -710,6 +749,22 @@ print-file evidence still need correction before this item can close.
|
||||
attempt counts against the source address and only failures count against
|
||||
the account; registration still counts every attempt. The security suite's
|
||||
lockout check (ten failures, then 429) is unchanged and passes.
|
||||
- `[x]` 5.17 — Site redesign (2026-09-24), Dropstar brand kept; the previous
|
||||
look is tag `ui-v1`. The home, one page per product (`/arquivo-por-metro`,
|
||||
`/artes-avulsas`, `/uv-arquivo-por-metro`, `/uv-artes-avulsas`) and
|
||||
`/carrinho` have their own addresses but stay one document, so artwork held in
|
||||
the browser survives moving between them (`web/site-pages.js`,
|
||||
`web/site-steps.js`; nginx serves `index.html` for those paths). The product
|
||||
page is built around a buy box (`web/site-compra.js`) that shows the item the
|
||||
flow already priced (grade, price per metre, metres charged, total,
|
||||
resolution acknowledgement and "Adicionar ao carrinho"); the duplicated
|
||||
quality and preview panels are hidden. The cart has "Remover" per item,
|
||||
"Esvaziar carrinho" and an 8-second undo. Fixed on the way, all already in
|
||||
production before: the saved-cart note took a grid column and pushed the
|
||||
order into a narrow strip, the panels outside `.w` ran edge to edge, and
|
||||
"57 cm" wrapped on the ready-sheet option. The browser suite covers product
|
||||
and cart addresses, reload on a product page, and remove/undo; the security
|
||||
suite checks the new addresses carry the same CSP.
|
||||
- `[ ]` 5.13 — Define production recovery: scheduled encrypted offsite database
|
||||
and object backups, a consistent snapshot boundary, Swarm data placement and
|
||||
a restore rehearsal that opens every required live order file.
|
||||
@@ -831,6 +886,12 @@ print-file evidence still need correction before this item can close.
|
||||
|
||||
### Reporting
|
||||
|
||||
- `[x]` Operator guide (`docs/guia-operador-kanban.pdf`) and Site guide
|
||||
(`docs/guia-site-dtf.pdf`), 2026-09-24, with screenshots of a throwaway stack
|
||||
and fictional orders. The payment step has no screenshot until Mercado Pago
|
||||
is configured, and the guides name the provisional Kanban domain; regenerate
|
||||
them when either changes. Early work toward Week 3's "orientação à operação".
|
||||
- `[ ]` Week-2 client report, due end of day 2026-09-25.
|
||||
- `[x]` Week-1 client report (`Relatorio-Semana-1-DTF.docx`), corrected 2026-09-18 to
|
||||
remove the inaccurate "Arquivo por metro permanece separado, com seleção explícita"
|
||||
claim and the internal commit reference.
|
||||
|
||||
BIN
docs/guia-operador-kanban.pdf
Normal file
BIN
docs/guia-operador-kanban.pdf
Normal file
Binary file not shown.
BIN
docs/guia-site-dtf.pdf
Normal file
BIN
docs/guia-site-dtf.pdf
Normal file
Binary file not shown.
@@ -19,6 +19,10 @@ server {
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
client_max_body_size 2m;
|
||||
}
|
||||
# The Site's product pages and cart are addresses of the same page (web/site-pages.js).
|
||||
location ~ ^/(arquivo-por-metro|artes-avulsas|uv-arquivo-por-metro|uv-artes-avulsas|carrinho)/?$ {
|
||||
try_files /index.html =404;
|
||||
}
|
||||
location / { try_files $uri $uri/ =404; }
|
||||
}
|
||||
server {
|
||||
|
||||
@@ -63,6 +63,10 @@ try{
|
||||
await site.call('Page.reload');
|
||||
await waitFor(()=>site.eval('typeof window.dtfCheckout === "function"'),'reload after security probe');
|
||||
await site.click('[data-modo="file"]');
|
||||
// Each product has its own page; the home's parts are not on it.
|
||||
assert.deepEqual(await site.eval(`({path:location.pathname,page:document.documentElement.dataset.rota,
|
||||
cards:getComputedStyle($('cards')).display,cart:getComputedStyle($('carr')).display})`),
|
||||
{path:'/arquivo-por-metro',page:'produto',cards:'none',cart:'none'});
|
||||
await site.click('[data-cam="tabela"]');
|
||||
const root=await site.call('DOM.getDocument');
|
||||
const input=await site.call('DOM.querySelector',{nodeId:root.root.nodeId,selector:'#inp'});
|
||||
@@ -77,6 +81,17 @@ try{
|
||||
await pause(800);
|
||||
await site.call('Page.reload');
|
||||
await waitFor(()=>site.eval('typeof pedido!=="undefined" && pedido.length===1'),'persistent cart recovery');
|
||||
// Reloading a product page reopens that product; the cart is its own page.
|
||||
assert.equal(await site.eval('modo'),'file');
|
||||
await site.click('#cartLink');
|
||||
await waitFor(()=>site.eval(`location.pathname==='/carrinho' && getComputedStyle($('carr')).display!=='none' && getComputedStyle($('foco')).display==='none'`),'cart page');
|
||||
// Removing an item is one click and can be undone.
|
||||
await site.click('[data-rmi="0"]');
|
||||
// The empty state is painted on the next animation frame (site-steps.js).
|
||||
await waitFor(()=>site.eval(`!$('carrVazio').hidden||document.documentElement.hasAttribute('data-sem-itens')`),'empty cart state');
|
||||
assert.deepEqual(await site.eval(`({items:pedido.length,empty:!$('carrVazio').hidden||document.documentElement.hasAttribute('data-sem-itens'),undo:!$('desfazer').hidden})`),{items:0,empty:true,undo:true});
|
||||
await site.click('#desfazerBtn');
|
||||
assert.equal(await site.eval('pedido.length===1 && pedido[0].total===21.89 && $("desfazer").hidden'),true);
|
||||
assert.equal(await site.eval('pedido[0].localFiles[0].name'),'local-test.cdr');
|
||||
assert.equal(await site.eval('pedido[0].total'),21.89);
|
||||
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false);
|
||||
@@ -92,6 +107,11 @@ try{
|
||||
await kanban.fill('#email',process.env.OPERATOR_EMAIL||'operator@example.test');
|
||||
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
|
||||
await kanban.eval('document.getElementById("login").requestSubmit()');
|
||||
// Quotes live in their own tab; the review pane shows the one picked.
|
||||
await waitFor(()=>kanban.eval('!document.getElementById("app").hidden'),'Kanban sign-in');
|
||||
await kanban.click('[data-tab="quotes"]');
|
||||
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-quote-pick="${qid}"]')`),'quote listed on Kanban');
|
||||
await kanban.click(`[data-quote-pick="${qid}"]`);
|
||||
await waitFor(async()=> (await kanban.text()).includes(qid.slice(0,8)),'quote on Kanban');
|
||||
assert.equal(await kanban.eval('sessionStorage.getItem("dtf-operator")'),null);
|
||||
assert.equal(await kanban.eval('document.getElementById("password").value'),'');
|
||||
@@ -114,21 +134,34 @@ try{
|
||||
const oid=await kanban.eval(`board.orders.find(o=>o.quote_id===${JSON.stringify(qid)}).id`);
|
||||
assert.deepEqual(await kanban.eval(`(()=>{const spec=board.orders.find(o=>o.id===${JSON.stringify(oid)}).snapshot.items[0].production;const source=spec.sources[0];return {kind:source.kind,copies:source.copies,length:Number(source.length_cm),height:Number(spec.height_cm),placed:spec.placements.length}})()`),
|
||||
{kind:'sheet',copies:1,length:101,height:101,placed:1});
|
||||
// Click real transition buttons, including rerender after each move.
|
||||
// Open the order's panel from its card, then click the real transition
|
||||
// buttons there, including the rerender after each move.
|
||||
await kanban.click('[data-tab="board"]');
|
||||
await kanban.click(`[data-card="${oid}"]`);
|
||||
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-move]')`),'order panel');
|
||||
for(const [title,state] of [['Arte tratada','tra'],['Fila de impressão','fil'],['Imprimindo','imp'],['Finalizado','fin']]){
|
||||
if(state==='fil'){
|
||||
await kanban.eval(`(()=>{const card=document.querySelector('[data-order="${oid}"]');[...card.querySelectorAll('button')].find(x=>x.textContent==='Arquivos de produção').click();})()`);
|
||||
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-final-item]')`),'final upload controls');
|
||||
const doc=await kanban.call('DOM.getDocument');
|
||||
const input=await kanban.call('DOM.querySelector',{nodeId:doc.root.nodeId,selector:`[data-order="${oid}"] [data-final-item]`});
|
||||
await kanban.call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve('tests/fixtures/local-test.cdr')]});
|
||||
await kanban.fill(`[data-order="${oid}"] input[placeholder="Nota da revisão"]`,'Browser test final file');
|
||||
await kanban.eval(`(()=>{const form=document.querySelector('[data-order="${oid}"] form');form.querySelector('[type=checkbox]').click();form.requestSubmit();})()`);
|
||||
await kanban.eval(`(()=>{const check=document.querySelector('[data-order="${oid}"] [data-confirm]');check.click();check.closest('form').requestSubmit();})()`);
|
||||
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').version===2`),'final file approval');
|
||||
}
|
||||
await kanban.eval(`(()=>{const card=document.querySelector('[data-order="${oid}"]');[...card.querySelectorAll('button')].find(x=>x.textContent===${JSON.stringify('→ '+title)}).click();})()`);
|
||||
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-move="${state}"]')`),'move to '+title);
|
||||
await kanban.click(`[data-order="${oid}"] [data-move="${state}"]`);
|
||||
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='${state}'`),'transition '+state);
|
||||
}
|
||||
// Undo a mistaken move from the panel: one stage back with an internal reason.
|
||||
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-back="imp"]')`),'back button');
|
||||
await kanban.click(`[data-order="${oid}"] [data-back="imp"]`);
|
||||
await kanban.fill(`[data-order="${oid}"] form.reason input`,'Movido por engano (teste)');
|
||||
await kanban.eval(`document.querySelector('[data-order="${oid}"] form.reason input').closest('form').requestSubmit()`);
|
||||
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='imp'`),'undo to Imprimindo');
|
||||
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-move="fin"]')`),'move to Finalizado again');
|
||||
await kanban.click(`[data-order="${oid}"] [data-move="fin"]`);
|
||||
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='fin'`),'finished again');
|
||||
// Reload proves the board is persisted on the backend.
|
||||
await kanban.call('Page.reload');
|
||||
await waitFor(()=>kanban.eval(`typeof board!=='undefined' && !!board && board.orders.some(o=>o.id==='${oid}'&&o.state==='fin')`),'persisted board');
|
||||
|
||||
@@ -109,7 +109,7 @@ def run():
|
||||
|
||||
# The customer is told once, not once per delivery.
|
||||
board = Client()
|
||||
events = board.call('/operator/board', operator=True)['events']
|
||||
events = board.call('/operator/events?order=' + str(order['number']), operator=True)['events']
|
||||
paid = [e for e in events if e['payload'].get('order_id') == order['id']
|
||||
and e['payload'].get('event') == 'payment_approved']
|
||||
assert len(paid) == 2, f'expected one tiny and one whatsapp event, got {len(paid)}'
|
||||
|
||||
@@ -126,7 +126,7 @@ def run():
|
||||
manual = upload_bytes(client, b'LOCAL PRINT TEST - NOT AN IMAGE', name='LOCAL-PRINT-TEST.cdr')
|
||||
order = paid_order(client, loose_item(manual, copies=1))
|
||||
order, row = wait_print(client, order['id'], 'manual')
|
||||
assert 'not an image' in row['detail']['reason'], row
|
||||
assert 'não é uma imagem' in row['detail']['reason'], row
|
||||
rows = client.call('/operator/orders/' + order['id'] + '/print-files', {}, operator=True)
|
||||
assert rows[0]['status'] == 'pending'
|
||||
wait_print(client, order['id'], 'manual')
|
||||
@@ -141,17 +141,43 @@ def run():
|
||||
outcome = deliver({'event_id': event_id, 'reference': quote_id,
|
||||
'status': 'approved', 'amount_cents': total - 1})
|
||||
assert outcome['outcome'].startswith('refused'), outcome
|
||||
issues = client.call('/operator/board', operator=True)['payment_issues']
|
||||
issues = client.call('/operator/payment-events?state=open&limit=100', operator=True)['issues']
|
||||
issue = next(i for i in issues if i['event_id'] == event_id)
|
||||
assert client.call('/operator/board', operator=True)['payment_issues_total'] >= 1
|
||||
client.call('/operator/payment-events/' + issue['id'] + '/resolve', {'note': 'no'},
|
||||
operator=True, expected=422)
|
||||
client.call('/operator/payment-events/' + issue['id'] + '/resolve',
|
||||
{'note': 'Local test: refunded the underpayment'}, operator=True)
|
||||
client.call('/operator/payment-events/' + issue['id'] + '/resolve',
|
||||
{'note': 'Local test: second resolution'}, operator=True, expected=404)
|
||||
issues = client.call('/operator/board', operator=True)['payment_issues']
|
||||
issues = client.call('/operator/payment-events?state=open&limit=100', operator=True)['issues']
|
||||
assert not any(i['event_id'] == event_id for i in issues)
|
||||
print('PASS: refused paid notification is listed until an operator resolves it')
|
||||
resolved_page = client.call('/operator/payment-events?state=resolved&limit=100', operator=True)
|
||||
assert resolved_page['total'] >= 1
|
||||
resolved = resolved_page['issues']
|
||||
done = next(i for i in resolved if i['event_id'] == event_id)
|
||||
assert done['resolution'] == 'Local test: refunded the underpayment' and done['resolved_by']
|
||||
print('PASS: refused paid notification is listed until an operator resolves it, then kept as history')
|
||||
|
||||
# The send log pages by id and filters by destination, status, event and order.
|
||||
page = client.call('/operator/events?limit=2', operator=True)
|
||||
assert len(page['events']) == 2 and page['total'] > 2
|
||||
second = client.call('/operator/events?limit=2&offset=2', operator=True)
|
||||
assert all(e['id'] < page['events'][-1]['id'] for e in second['events'])
|
||||
assert second['total'] == page['total']
|
||||
tiny = client.call('/operator/events?provider=tiny&event=payment_approved&status=delivered&limit=100', operator=True)
|
||||
assert tiny['events'] and all(e['provider'] == 'tiny' and e['payload']['event'] == 'payment_approved'
|
||||
and e['delivered_at'] for e in tiny['events'])
|
||||
client.call('/operator/events?provider=email', operator=True, expected=422)
|
||||
client.call('/operator/events?limit=500', operator=True, expected=422)
|
||||
quotes = client.call('/operator/quotes?kind=approved&limit=1&offset=0', operator=True)
|
||||
assert 'total' in quotes and len(quotes['quotes']) <= 1
|
||||
finished = client.call('/operator/orders/finished?limit=1', operator=True)
|
||||
if finished['orders']:
|
||||
last = finished['orders'][-1]
|
||||
client.call('/operator/orders/finished?before_created_at=' + last['created_at'].replace('+', '%2B')
|
||||
+ '&before_id=' + last['id'], operator=True)
|
||||
print('PASS: send log, payment history and finished orders page and filter')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -29,6 +29,10 @@ def run():
|
||||
assert script_src == "script-src 'self'", script_src
|
||||
assert "object-src 'none'" in policy
|
||||
assert 'cdnjs' not in policy, 'pdf.js is vendored; no CDN belongs in the policy'
|
||||
# Product pages and the cart are addresses of the Site's page, under the same policy.
|
||||
for page in ('/arquivo-por-metro','/artes-avulsas','/uv-arquivo-por-metro','/uv-artes-avulsas','/carrinho'):
|
||||
assert raw(page,200)['Content-Security-Policy']==policy,page
|
||||
raw('/carrinho/outra-coisa',404)
|
||||
raw('/api/health',400,{'Host':'attacker.invalid'})
|
||||
raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'https://attacker.invalid'},b'{}')
|
||||
raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'http://localhost:9999'},b'{}')
|
||||
|
||||
@@ -185,10 +185,21 @@ def run():
|
||||
version+=1;assert moved['version']==version
|
||||
client.call('/operator/orders/'+oid+'/move',{'state':'rec','version':0},operator=True,expected=409)
|
||||
assert len(client.call('/operator/orders/'+oid+'/history',operator=True))==6
|
||||
# A mistaken move can be undone one stage at a time, with an internal
|
||||
# reason. The customer is not told again: going back and forward once more
|
||||
# adds no messages (the outbox count below stays 8).
|
||||
client.call('/operator/orders/'+oid+'/move',{'state':'imp','version':version},operator=True,expected=422)
|
||||
client.call('/operator/orders/'+oid+'/move',{'state':'tra','version':version},operator=True,expected=409)
|
||||
back=client.call('/operator/orders/'+oid+'/move',{'state':'imp','version':version,'reason':'Movido por engano'},operator=True)
|
||||
assert back['state']=='imp';version+=1
|
||||
client.call('/operator/orders/'+oid+'/move',{'state':'fin','version':version},operator=True);version+=1
|
||||
history=client.call('/operator/orders/'+oid+'/history',operator=True)
|
||||
assert len(history)==8 and history[-2]['back'] and history[-2]['reason']=='Movido por engano' and not history[-1]['back']
|
||||
print('PASS: a mistaken move is undone one stage back with a reason, without messaging the customer again')
|
||||
print('PASS: all modes, authoritative review/prices/freight, tamper rejection, concurrent payment idempotency, transitions and history')
|
||||
deadline=time.monotonic()+30
|
||||
while time.monotonic()<deadline:
|
||||
events=[e for e in client.call('/operator/board',operator=True)['events'] if e['payload']['order_id']==oid]
|
||||
events=[e for e in client.call('/operator/events?order='+str(order['number']),operator=True)['events'] if e['payload']['order_id']==oid]
|
||||
if len(events)==8 and all(e['delivered_at'] and e['receipt'] for e in events):break
|
||||
time.sleep(1)
|
||||
else:raise AssertionError('Mock outbox did not drain')
|
||||
|
||||
@@ -115,19 +115,19 @@ class PrintFileTests(unittest.TestCase):
|
||||
|
||||
def test_refuses_what_it_cannot_reproduce(self):
|
||||
png = self.save(quadrants(), 'a.png')
|
||||
with self.assertRaisesRegex(Unsupported, 'proportions'):
|
||||
with self.assertRaisesRegex(Unsupported, 'proporções'):
|
||||
self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [png])
|
||||
with self.assertRaisesRegex(Unsupported, 'billed'):
|
||||
with self.assertRaisesRegex(Unsupported, 'cobrados'):
|
||||
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 150, billed='1.0'), [png])
|
||||
fake_pdf = os.path.join(self.dir.name, 'art.pdf')
|
||||
with open(fake_pdf, 'wb') as handle:
|
||||
handle.write(b'%PDF-1.4\n%%EOF\n')
|
||||
with self.assertRaisesRegex(Unsupported, 'not a PDF'):
|
||||
with self.assertRaisesRegex(Unsupported, 'não é um PDF'):
|
||||
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [fake_pdf])
|
||||
cdr = os.path.join(self.dir.name, 'art.cdr')
|
||||
with open(cdr, 'wb') as handle:
|
||||
handle.write(b'not artwork')
|
||||
with self.assertRaisesRegex(Unsupported, 'not an image'):
|
||||
with self.assertRaisesRegex(Unsupported, 'não é uma imagem'):
|
||||
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [cdr])
|
||||
|
||||
def test_long_layouts_scale_user_space_instead_of_splitting(self):
|
||||
@@ -247,12 +247,12 @@ class PdfSourceTests(unittest.TestCase):
|
||||
self.assertEqual(detail['min_dpi'], round(80 / (20 / 2.54)))
|
||||
|
||||
def test_refuses_pdfs_it_cannot_place(self):
|
||||
with self.assertRaisesRegex(Unsupported, '2 pages'):
|
||||
with self.assertRaisesRegex(Unsupported, '2 páginas'):
|
||||
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [self.pdf('two.pdf', pages=2)])
|
||||
with self.assertRaisesRegex(Unsupported, 'password'):
|
||||
with self.assertRaisesRegex(Unsupported, 'senha'):
|
||||
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10),
|
||||
[self.pdf('locked.pdf', password='secret')])
|
||||
with self.assertRaisesRegex(Unsupported, 'proportions'):
|
||||
with self.assertRaisesRegex(Unsupported, 'proporções'):
|
||||
self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [self.pdf('square.pdf')])
|
||||
|
||||
@unittest.skipIf(fitz is None, 'PyMuPDF is not installed')
|
||||
|
||||
@@ -31,24 +31,34 @@ class FakeAuth:
|
||||
return 'access-1'
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PRODUCTS)
|
||||
class TinyTests(unittest.TestCase):
|
||||
class FakeTinyCase(unittest.TestCase):
|
||||
"""A Tiny account in memory: contacts, orders and the four products."""
|
||||
|
||||
def setUp(self):
|
||||
self.contacts = []
|
||||
self.orders = []
|
||||
self.calls = []
|
||||
self.products = {value: {'id': int(value), 'sku': f'DTF-{value}', 'descricao': f'Produto {value}',
|
||||
'situacao': 'A', 'precos': {'preco': 14.9}} for value in PRODUCTS.values()}
|
||||
|
||||
def handler(request):
|
||||
path = request.url.path.removeprefix('/public-api/v3')
|
||||
self.calls.append((request.method, path))
|
||||
assert request.headers['authorization'] == 'Bearer access-1'
|
||||
if request.method == 'GET' and path == '/contatos':
|
||||
cnpj = request.url.params['cpfCnpj']
|
||||
return httpx.Response(200, json={'itens': [c for c in self.contacts if c['cpfCnpj'] == cnpj]})
|
||||
cnpj = request.url.params.get('cpfCnpj')
|
||||
return httpx.Response(200, json={'itens': [c for c in self.contacts if cnpj in (None, c['cpfCnpj'])]})
|
||||
if request.method == 'POST' and path == '/contatos':
|
||||
contact = {**json.loads(request.content), 'id': 500 + len(self.contacts)}
|
||||
self.contacts.append(contact)
|
||||
return httpx.Response(200, json={'id': contact['id']})
|
||||
if request.method == 'GET' and path.startswith('/produtos/'):
|
||||
wanted = path.rsplit('/', 1)[-1]
|
||||
if wanted not in self.products:
|
||||
return httpx.Response(404, json={'mensagem': 'não encontrado'})
|
||||
return httpx.Response(200, json=self.products[wanted])
|
||||
if request.method == 'GET' and path == '/produtos':
|
||||
return httpx.Response(200, json={'itens': list(self.products.values())})
|
||||
if request.method == 'GET' and path == '/pedidos':
|
||||
return httpx.Response(200, json={'itens': [{'id': o['id']} for o in self.orders]})
|
||||
if request.method == 'GET' and path.startswith('/pedidos/'):
|
||||
@@ -61,9 +71,13 @@ class TinyTests(unittest.TestCase):
|
||||
return httpx.Response(200, json={'id': order['id'], 'numeroPedido': order['numeroPedido']})
|
||||
return httpx.Response(404)
|
||||
|
||||
self.handler = handler
|
||||
self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(handler),
|
||||
today=date(2026, 9, 24))
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PRODUCTS)
|
||||
class TinyTests(FakeTinyCase):
|
||||
def test_payload_carries_contact_products_address_and_freight(self):
|
||||
pedido = order_payload(PAID, 777, date(2026, 9, 24))
|
||||
self.assertEqual((pedido['idContato'], pedido['numeroOrdemCompra'], pedido['data']),
|
||||
@@ -106,11 +120,75 @@ class TinyTests(unittest.TestCase):
|
||||
self.tiny.deliver('k3', PAID)
|
||||
self.assertNotIn(('POST', '/pedidos'), self.calls)
|
||||
|
||||
def test_connection_check_only_reads(self):
|
||||
from app.tiny import check
|
||||
results = check(auth=FakeAuth(), transport=httpx.MockTransport(self.handler))
|
||||
self.assertEqual(set(results.values()), {'ok'})
|
||||
self.assertEqual(len(results), 6)
|
||||
self.assertTrue(all(method == 'GET' for method, _ in self.calls))
|
||||
self.products['103']['situacao'] = 'I'
|
||||
with mock.patch.dict(os.environ, {'TINY_PRODUCT_UV_AVULSA': ''}):
|
||||
results = check(auth=FakeAuth(), transport=httpx.MockTransport(self.handler))
|
||||
self.assertIn('não está ativo', results['DTF UV 28,5 cm · folha montada'])
|
||||
self.assertEqual(results['DTF UV 28,5 cm · artes avulsas'], 'TINY_PRODUCT_UV_AVULSA sem id')
|
||||
self.assertEqual(results['pedidos'], 'ok')
|
||||
denied = check(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(403, text='forbidden')))
|
||||
self.assertTrue(denied['pedidos'].startswith('GET /pedidos: 403'))
|
||||
|
||||
def test_rate_limit_is_a_retryable_failure(self):
|
||||
tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(429)))
|
||||
with self.assertRaisesRegex(TinyError, 'rate limit'):
|
||||
tiny.deliver('k4', PAID)
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, PRODUCTS)
|
||||
class TinyProbeTests(FakeTinyCase):
|
||||
"""The supervised console tool run against the fake Tiny."""
|
||||
|
||||
def probe(self, *argv):
|
||||
from app import tiny_probe
|
||||
lines = []
|
||||
code = tiny_probe.main(list(argv), orders=self.tiny, out=lines.append)
|
||||
return code, '\n'.join(lines)
|
||||
|
||||
def test_products_and_settings_are_listed_without_writing(self):
|
||||
code, text = self.probe('produtos', 'DTF')
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn('101\tDTF-101\tProduto 101', text)
|
||||
code, text = self.probe('conferir')
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn('TINY_PRODUCT_UV_AVULSA (DTF UV 28,5 cm · artes avulsas): ok DTF-104', text)
|
||||
self.assertTrue(all(method == 'GET' for method, _ in self.calls))
|
||||
|
||||
def test_order_is_shown_and_not_created_without_confirmation(self):
|
||||
code, text = self.probe('pedido', '--cnpj', '11.222.333/0001-81', '--email', 'a@example.test',
|
||||
'--celular', '16999999999')
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn('"numeroOrdemCompra": "DTF-TESTE-', text)
|
||||
self.assertIn('Nada foi criado', text)
|
||||
self.assertEqual(self.orders, [])
|
||||
self.assertTrue(all(method == 'GET' for method, _ in self.calls))
|
||||
|
||||
def test_confirmed_order_is_created_once_and_found_on_resend(self):
|
||||
code, text = self.probe('pedido', '--cnpj', '11222333000181', '--email', 'a@example.test',
|
||||
'--celular', '16999999999', '--modo', 'uv', '--confirmar')
|
||||
self.assertEqual(code, 0, text)
|
||||
self.assertEqual(len(self.orders), 1)
|
||||
self.assertEqual(self.orders[0]['itens'][0]['produto'], {'id': 104})
|
||||
self.assertIn('2º envio: already-created', text)
|
||||
|
||||
def test_resend_is_skipped_when_the_search_cannot_find_the_order(self):
|
||||
from app import tiny_probe
|
||||
blind = lambda request: (httpx.Response(200, json={'itens': []})
|
||||
if request.method == 'GET' and request.url.path.endswith('/pedidos')
|
||||
else self.handler(request))
|
||||
self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(blind), today=date(2026, 9, 24))
|
||||
payload = tiny_probe.test_order('11222333000181', 'a@example.test', '16999999999', 'file')
|
||||
lines = []
|
||||
self.assertFalse(tiny_probe.pedido(self.tiny, payload, True, lines.append, wait=lambda s: None))
|
||||
self.assertEqual(len(self.orders), 1)
|
||||
self.assertIn('o 2º envio não foi feito', lines[-1])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -33,8 +33,12 @@ def run():
|
||||
|
||||
def exercise():
|
||||
issued = []
|
||||
server = {'mode': 'session', 'calls': 0}
|
||||
|
||||
def token_server(request):
|
||||
server['calls'] += 1
|
||||
if server['mode'] == 'down':
|
||||
return httpx.Response(503, text='unavailable')
|
||||
form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
|
||||
assert (form['client_id'], form['client_secret']) == ('test-client', 'test-secret')
|
||||
if form['grant_type'] == 'authorization_code':
|
||||
@@ -44,8 +48,13 @@ def exercise():
|
||||
return httpx.Response(400, json={'error': 'invalid_grant'})
|
||||
n = len(issued) + 1
|
||||
issued.append(f'refresh-{n}')
|
||||
if server['mode'] == 'offline':
|
||||
return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400,
|
||||
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 0,
|
||||
'scope': 'openid offline_access profile'})
|
||||
return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400,
|
||||
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 86400})
|
||||
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 86400,
|
||||
'scope': 'openid profile'})
|
||||
|
||||
auth = TinyAuth(transport=httpx.MockTransport(token_server))
|
||||
assert auth.status() == {'connected': False}
|
||||
@@ -59,6 +68,7 @@ def exercise():
|
||||
query = {k: v[0] for k, v in parse_qs(url.query).items()}
|
||||
assert url.netloc == 'accounts.tiny.com.br' and query['client_id'] == 'test-client'
|
||||
assert query['redirect_uri'].endswith('/api/operator/tiny/callback') and len(query['state']) > 30
|
||||
assert query['scope'] == 'openid offline_access'
|
||||
try:
|
||||
auth.complete('good-code', 'forged-state')
|
||||
raise AssertionError('a state no operator created must be refused')
|
||||
@@ -72,6 +82,7 @@ def exercise():
|
||||
pass
|
||||
status = auth.status()
|
||||
assert status['connected'] and status['connected_by'] == 'operator@example.test'
|
||||
assert status['problem'] is None and not status['offline'] and status['expires_at']
|
||||
assert auth.access_token() == 'access-1'
|
||||
print('PASS: operator-started state is required, single-use, and stores the connection')
|
||||
|
||||
@@ -85,6 +96,23 @@ def exercise():
|
||||
assert auth.access_token() == 'access-2'
|
||||
print('PASS: expiring access token refreshed once, refresh token rotated and stored')
|
||||
|
||||
# Tiny unreachable: the failure is shown, the connection kept, and the next
|
||||
# renewal clears it.
|
||||
with connect() as c:
|
||||
c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'")
|
||||
server['mode'] = 'down'
|
||||
try:
|
||||
auth.access_token()
|
||||
raise AssertionError('an unreachable token server must fail the renewal')
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
status = auth.status()
|
||||
assert status['connected'] and status['problem'] == 'renewal-failing' and status['failed_at']
|
||||
server['mode'] = 'session'
|
||||
assert auth.access_token() == 'access-3'
|
||||
assert auth.status()['problem'] is None
|
||||
print('PASS: a failed renewal is shown and cleared by the next successful one')
|
||||
|
||||
# A refused refresh (revoked in Tiny) asks for a new connection, not a retry loop.
|
||||
with connect() as c:
|
||||
c.execute("""UPDATE dtf_local.provider_tokens SET access_expires_at=now(), refresh_token='revoked'
|
||||
@@ -94,12 +122,63 @@ def exercise():
|
||||
raise AssertionError('a refused refresh must report the connection as lost')
|
||||
except TinyNotConnected:
|
||||
pass
|
||||
status = auth.status()
|
||||
assert not status['connected'] and status['problem'] == 'refused'
|
||||
calls = server['calls']
|
||||
try:
|
||||
auth.access_token()
|
||||
raise AssertionError('a refused connection must stay refused')
|
||||
except TinyNotConnected:
|
||||
pass
|
||||
assert server['calls'] == calls, 'a refused refresh token must not be sent again'
|
||||
with connect() as c:
|
||||
c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=%s WHERE provider='tiny'",
|
||||
(datetime.now(timezone.utc) - timedelta(seconds=1),))
|
||||
assert not auth.status()['connected']
|
||||
print('PASS: revoked or expired connections report that Tiny must be connected again')
|
||||
|
||||
# Reconnecting with an offline grant: no daily end, and the refusal is cleared.
|
||||
server['mode'] = 'offline'
|
||||
state = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
|
||||
auth.complete('good-code', state)
|
||||
status = auth.status()
|
||||
assert status['connected'] and status['offline'] and status['expires_at'] is None
|
||||
assert status['problem'] is None
|
||||
with connect() as c:
|
||||
c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'")
|
||||
auth.access_token()
|
||||
assert auth.status()['offline'] and auth.status()['expires_at'] is None
|
||||
print('PASS: an offline grant is stored without a daily expiry and survives renewal')
|
||||
|
||||
# Tiny refusing offline_access restarts the authorisation without it, once.
|
||||
state = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
|
||||
retry = parse_qs(urlparse(auth.without_offline(state)).query)
|
||||
assert retry['scope'] == ['openid'] and retry['state'][0] != state
|
||||
try:
|
||||
auth.without_offline(state)
|
||||
raise AssertionError('the refused state must be spent')
|
||||
except TinyError:
|
||||
pass
|
||||
# A session grant close to its end is flagged while renewals are not happening.
|
||||
server['mode'] = 'session'
|
||||
auth.complete('good-code', retry['state'][0])
|
||||
with connect() as c:
|
||||
c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=now()+interval '2 hours' WHERE provider='tiny'")
|
||||
status = auth.status()
|
||||
assert status['connected'] and status['problem'] == 'expiring' and not status['offline']
|
||||
print('PASS: a refused offline scope falls back once; a connection near its end is flagged')
|
||||
|
||||
# Tiny's redirect back with an error instead of a code.
|
||||
from app.api.operator import tiny_callback
|
||||
declined = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
|
||||
assert tiny_callback('', declined, 'access_denied').headers['location'] == '/?tiny=failed'
|
||||
assert tiny_callback('', '', '').headers['location'] == '/?tiny=failed'
|
||||
scoped = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
|
||||
location = urlparse(tiny_callback('', scoped, 'invalid_scope').headers['location'])
|
||||
assert location.netloc == 'accounts.tiny.com.br' and parse_qs(location.query)['scope'] == ['openid']
|
||||
assert tiny_callback('', scoped, 'invalid_scope').headers['location'] == '/?tiny=failed'
|
||||
print('PASS: a declined or malformed callback returns to the Kanban; a refused scope retries without it')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
let database,scope,timer,restoring=true;
|
||||
let signedOut=false;
|
||||
window.addEventListener('dtf-private-data-cleared',()=>{signedOut=true;clearTimeout(timer);pedido=[];itemAtual=null;folhas=[];artes=[];});
|
||||
const notice=document.createElement('p');notice.style.cssText='font:13px system-ui;color:#56616d;padding:8px 20px';
|
||||
const notice=document.createElement('p');notice.className='carrAviso';
|
||||
document.getElementById('carr').prepend(notice);
|
||||
function transaction(mode,fn){return new Promise((resolve,reject)=>{const tx=database.transaction('cart',mode);const request=fn(tx.objectStore('cart'));let result;request.onsuccess=()=>result=request.result;tx.oncomplete=()=>resolve(result);tx.onerror=()=>reject(tx.error);tx.onabort=()=>reject(tx.error);});}
|
||||
async function save(){
|
||||
|
||||
@@ -127,7 +127,9 @@
|
||||
return;
|
||||
}
|
||||
if (quote.status==='pending_review') {
|
||||
message('Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.');
|
||||
message((await ready).environment==='local'
|
||||
? 'Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.'
|
||||
: 'Arquivos enviados. Nossa equipe está conferindo a cotação '+draftId.slice(0,8)+'; o valor final aparece aqui em seguida.');
|
||||
} else if (quote.status==='approved') {
|
||||
message('Total validado no servidor: '+rs(quote.approved.total_cents/100)+' · inclui frete. Cotação válida por 24 horas.');
|
||||
if ((await ready).payment_provider === 'mercadopago') {
|
||||
@@ -163,8 +165,9 @@
|
||||
} catch(error) { message(error.message); event.target.disabled=false; }
|
||||
});
|
||||
} else if (quote.status==='paid') {
|
||||
message('Pedido #'+quote.order.number+' · etapa: '+quote.order.state+'.');
|
||||
button('Novo pedido',()=>{clearDraft();location.reload();});
|
||||
const STAGES={rec:'arte recebida',tra:'arte tratada',fil:'na fila de impressão',imp:'imprimindo',cor:'aguardando correção',fin:'finalizado'};
|
||||
message('Pedido #'+quote.order.number+' · '+(STAGES[quote.order.state]||quote.order.state)+'.');
|
||||
button('Novo pedido',()=>{clearDraft();location.assign('/');});
|
||||
} else {
|
||||
message('Cotação expirada. Envie o carrinho para uma nova revisão.');
|
||||
button('Nova cotação',clearDraft);
|
||||
|
||||
539
web/index.html
539
web/index.html
@@ -748,6 +748,8 @@ footer a:hover{color:var(--laranja2)}
|
||||
justify-content:space-between;gap:12px;flex-wrap:wrap;font-size:11px;color:#5A6570}
|
||||
@media(prefers-reduced-motion:reduce){*{transition:none!important;scroll-behavior:auto}}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/site-v2.css?v=6">
|
||||
<script src="/site-pages.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -763,7 +765,7 @@ footer a:hover{color:var(--laranja2)}
|
||||
</div>
|
||||
<div class="acts">
|
||||
<a href="/portal.html">Minha conta</a>
|
||||
<a href="#carr" id="cartLink">Carrinho (0)</a>
|
||||
<a href="/carrinho" id="cartLink">Carrinho (0)</a>
|
||||
</div>
|
||||
</div></header>
|
||||
|
||||
@@ -784,10 +786,10 @@ footer a:hover{color:var(--laranja2)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mi on">
|
||||
<a href="#envio">Impressão DTF</a>
|
||||
<a href="/#envio">Impressão DTF</a>
|
||||
<div class="sub">
|
||||
<a href="#envio">DTF Têxtil · 57 cm</a>
|
||||
<a href="#envio">DTF UV · 28,5 cm</a>
|
||||
<a href="/#envio">DTF Têxtil · 57 cm</a>
|
||||
<a href="/#envio">DTF UV · 28,5 cm</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mi">
|
||||
@@ -820,171 +822,101 @@ footer a:hover{color:var(--laranja2)}
|
||||
</div>
|
||||
</div></nav>
|
||||
|
||||
<header class="hero">
|
||||
<div class="fundo" aria-hidden="true">
|
||||
<svg viewBox="0 0 1400 300" preserveAspectRatio="xMidYMid slice">
|
||||
<g fill="#fff">
|
||||
<rect x="20" y="16" width="118" height="140" rx="5"/>
|
||||
<rect x="150" y="16" width="118" height="140" rx="5"/>
|
||||
<rect x="280" y="16" width="76" height="66" rx="5"/>
|
||||
<rect x="368" y="16" width="76" height="66" rx="5"/>
|
||||
<rect x="280" y="94" width="164" height="62" rx="5"/>
|
||||
<rect x="456" y="16" width="96" height="96" rx="5"/>
|
||||
<rect x="564" y="16" width="96" height="96" rx="5"/>
|
||||
<rect x="456" y="124" width="204" height="32" rx="5"/>
|
||||
<rect x="672" y="16" width="118" height="140" rx="5"/>
|
||||
<rect x="802" y="16" width="112" height="66" rx="5"/>
|
||||
<rect x="802" y="94" width="112" height="62" rx="5"/>
|
||||
<rect x="926" y="16" width="150" height="140" rx="5"/>
|
||||
<rect x="1088" y="16" width="86" height="86" rx="5"/>
|
||||
<rect x="1186" y="16" width="86" height="86" rx="5"/>
|
||||
<rect x="1088" y="114" width="184" height="42" rx="5"/>
|
||||
<rect x="1284" y="16" width="96" height="140" rx="5"/>
|
||||
<rect x="20" y="168" width="76" height="76" rx="5"/>
|
||||
<rect x="108" y="168" width="76" height="76" rx="5"/>
|
||||
<rect x="196" y="168" width="76" height="76" rx="5"/>
|
||||
<rect x="284" y="168" width="160" height="76" rx="5"/>
|
||||
<rect x="456" y="168" width="104" height="118" rx="5"/>
|
||||
<rect x="572" y="168" width="104" height="118" rx="5"/>
|
||||
<rect x="688" y="168" width="226" height="54" rx="5"/>
|
||||
<rect x="688" y="234" width="108" height="52" rx="5"/>
|
||||
<rect x="808" y="234" width="106" height="52" rx="5"/>
|
||||
<rect x="926" y="168" width="150" height="118" rx="5"/>
|
||||
<rect x="1088" y="168" width="184" height="56" rx="5"/>
|
||||
<rect x="1088" y="236" width="86" height="50" rx="5"/>
|
||||
<rect x="1186" y="236" width="86" height="50" rx="5"/>
|
||||
<rect x="1284" y="168" width="96" height="118" rx="5"/>
|
||||
<rect x="20" y="256" width="424" height="30" rx="5"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="w">
|
||||
<div class="hgrid">
|
||||
<div>
|
||||
<div class="selos1">
|
||||
<span class="sel top">Metro a partir de R$ 14,90</span>
|
||||
<span class="sel novo">Revisão inclusa</span>
|
||||
<span class="sel pos">Franca · SP</span>
|
||||
</div>
|
||||
<h1>A gente encaixa sua folha<br><em>e você paga menos metro</em></h1>
|
||||
<p class="sub2">Mande a arte, veja a nota em segundos e a folha montada antes de fechar.
|
||||
<b>Revisão e remanejamento por nossa conta.</b></p>
|
||||
<div class="hcta">
|
||||
<a href="#envio" class="btn1">Enviar minha arte</a>
|
||||
<span class="mini">sem cadastro · resposta em segundos</span>
|
||||
</div>
|
||||
<header class="hero2">
|
||||
<div class="w hero2-grid">
|
||||
<div class="hero2-text">
|
||||
<div class="chips">
|
||||
<span class="chip2 destaque">Metro a partir de R$ 14,90</span>
|
||||
<span class="chip2">Franca · SP · envio em até 24 h</span>
|
||||
</div>
|
||||
|
||||
<div class="pfaixa">
|
||||
<a class="pcard dest" href="#envio">
|
||||
<span class="marc">mais vendido</span>
|
||||
<span class="apartir">com arte boa</span>
|
||||
<span class="vl">R$ 14,90<small> /m</small></span>
|
||||
<span class="nm">Arquivo por metro</span>
|
||||
<span class="dsc">têxtil · 57 cm</span>
|
||||
</a>
|
||||
<a class="pcard" href="#envio">
|
||||
<span class="apartir">com arte boa</span>
|
||||
<span class="vl">R$ 24,90<small> /m</small></span>
|
||||
<span class="nm">Artes avulsas</span>
|
||||
<span class="dsc">têxtil · montamos a folha</span>
|
||||
</a>
|
||||
<a class="pcard" href="#envio">
|
||||
<span class="apartir">com arte boa</span>
|
||||
<span class="vl">R$ 69,90<small> /m</small></span>
|
||||
<span class="nm">Arquivo UV</span>
|
||||
<span class="dsc">rígido · 28,5 cm</span>
|
||||
</a>
|
||||
<a class="pcard" href="#envio">
|
||||
<span class="apartir">com arte boa</span>
|
||||
<span class="vl">R$ 83,90<small> /m</small></span>
|
||||
<span class="nm">Artes avulsas UV</span>
|
||||
<span class="dsc">rígido · montamos a folha</span>
|
||||
</a>
|
||||
<h1>A gente encaixa sua folha e você paga <em>menos metro</em></h1>
|
||||
<p>Mande a arte, veja a nota e a folha montada antes de pagar. Revisão de 5 pontos e reencaixe por nossa conta.</p>
|
||||
<div class="hero2-cta">
|
||||
<a href="/#envio" class="btn-laranja">Enviar minha arte <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a>
|
||||
<span>Sem cadastro · preço em segundos</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="barrah">
|
||||
<span><i>◆</i> Revisão de <b>5 pontos</b> sem custo</span>
|
||||
<span><i>◆</i> Cobrança por <b>proporção</b>, a cada 10 cm</span>
|
||||
<span><i>◆</i> Filme de <b>57 cm</b>, o dobro do padrão</span>
|
||||
<span><i>◆</i> Prévia da folha <b>antes de pagar</b></span>
|
||||
<div class="hero2-folha" aria-hidden="true">
|
||||
<div class="hf-cab"><b>Sua folha montada</b><span>filme 57 cm · prévia</span></div>
|
||||
<div class="hf-area">
|
||||
<i style="left:3%;top:5%;width:34%;height:40%;background:#FFA81A"></i>
|
||||
<i style="left:39%;top:5%;width:34%;height:40%;background:#FFA81A"></i>
|
||||
<i style="left:75%;top:5%;width:22%;height:55%;background:#2BB6D9"></i>
|
||||
<i style="left:3%;top:49%;width:22%;height:40%;border-radius:50%;background:#E0508F"></i>
|
||||
<i style="left:27%;top:49%;width:22%;height:40%;border-radius:50%;background:#E0508F"></i>
|
||||
<i style="left:51%;top:49%;width:22%;height:30%;background:#E9C23A"></i>
|
||||
<i style="left:75%;top:63%;width:22%;height:26%;background:#E9C23A"></i>
|
||||
</div>
|
||||
<div class="hf-num">
|
||||
<div><span>Metragem</span><b>0,60 m</b></div>
|
||||
<div><span>Nota da arte</span><b class="verde">92</b></div>
|
||||
<div><span>Preço do metro</span><b class="laranja">R$ 24,90</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
<!-- PROVA -->
|
||||
<section class="prova"><div class="w">
|
||||
<div class="p1"><b>Quanto melhor sua arte,<br><em>menor o preço do metro</em></b>
|
||||
<span>Têxtil de R$ 19,90 a R$ 14,90 · UV de R$ 99,90 a R$ 69,90.
|
||||
A nota aparece na tela antes de você pagar.</span></div>
|
||||
<div class="p1"><b>Filme de 57 cm,<br><em>o dobro do padrão</em></b>
|
||||
<span>Cabe o dobro de arte por metro. E reencaixamos sua folha sem cobrar.</span></div>
|
||||
<div class="p1"><b>Você paga só<br><em>o que usa</em></b>
|
||||
<span>Cobrança a cada 10 cm. Folha de 2,75 m custa 2,80 — não 3.</span></div>
|
||||
<div class="p1"><b>Erro pego<br><em>antes de imprimir</em></b>
|
||||
<span>5 pontos conferidos e a nota na tela. Sem custo.</span></div>
|
||||
<div class="p1"><b>Envio em<br><em>até 24 horas</em></b>
|
||||
<span>E avisamos por WhatsApp a cada etapa do pedido.</span></div>
|
||||
<section class="etapas2"><div class="w">
|
||||
<div><span>1</span><div><b>Envie a arte</b><p>PNG, JPG ou PDF, conforme o produto. Sem cadastro.</p></div></div>
|
||||
<div><span>2</span><div><b>Veja nota e montagem</b><p>A folha e o preço aparecem na hora.</p></div></div>
|
||||
<div><span>3</span><div><b>Confira e pague</b><p>Conferimos a metragem e liberamos o pagamento.</p></div></div>
|
||||
<div><span>4</span><div><b>Acompanhe</b><p>Aviso no WhatsApp a cada etapa.</p></div></div>
|
||||
</div></section>
|
||||
|
||||
<!-- ENVIO -->
|
||||
<section id="envio"><div class="w">
|
||||
<section id="envio">
|
||||
<nav class="passos" id="passos" aria-label="Etapas do pedido" hidden>
|
||||
<a href="/#envio" data-passo="1"><span>1</span>Montagem</a>
|
||||
<i aria-hidden="true"></i>
|
||||
<a href="/carrinho" data-passo="2"><span>2</span>Dados e entrega</a>
|
||||
<i aria-hidden="true"></i>
|
||||
<a href="/carrinho#checkoutStatus" data-passo="3"><span>3</span>Pagamento</a>
|
||||
</nav>
|
||||
<div class="w">
|
||||
|
||||
|
||||
<!-- 3 cards -->
|
||||
<div id="cards">
|
||||
<div class="et">Comece por aqui</div>
|
||||
<h2>Qual é o seu <em>caso</em>?</h2>
|
||||
<div class="cards-cab">
|
||||
<div><div class="et">Escolha o produto</div>
|
||||
<h2>Qual é o seu caso?</h2></div>
|
||||
<p>O preço do metro depende da nota da arte: quanto melhor o arquivo, menor o preço.</p>
|
||||
</div>
|
||||
<div class="escolha">
|
||||
<button class="ec f" data-modo="file">
|
||||
<span class="marc">Mais vendido</span>
|
||||
<div class="top"><h3>Arquivo por metro</h3>
|
||||
<span class="p"><i>tabela</i>R$ 19,90<small>/m</small></span></div>
|
||||
<div class="chama2">Quer pagar <b>R$ 14,90</b>? Arte em 300 DPI, em arquivo que a gente confere.</div>
|
||||
<div class="in">
|
||||
<span class="lg">DTF têxtil · 57 cm</span>
|
||||
<p>Você monta a folha e manda pronta. Revisamos, corrigimos e reencaixamos.</p>
|
||||
<ul><li>Remanejamos e sua folha encolhe</li><li>Arquivo bom paga o mínimo</li>
|
||||
<li>Você continua no controle do layout</li></ul>
|
||||
<span class="go">Enviar minha folha ›</span>
|
||||
<div class="ec-top"><span class="ec-kind">DTF TÊXTIL · 57 CM</span><span class="marc2">Mais vendido</span></div>
|
||||
<h3>Arquivo por metro</h3>
|
||||
<p>Você monta a folha e manda pronta. Revisamos, corrigimos e reencaixamos se sobrar espaço.</p>
|
||||
<div class="ec-pe">
|
||||
<div class="ec-preco"><span>de</span><b data-de="file">—</b><span>a <em data-ate="file">—</em> /m conforme a nota da arte</span></div>
|
||||
<span class="ec-go">Enviar minha folha</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button class="ec a" data-modo="avulsa">
|
||||
<div class="top"><h3>Artes avulsas</h3><span class="p"><i>tabela</i>R$ 29,90<small>/m</small></span></div>
|
||||
<div class="chama2">Quer pagar <b>R$ 24,90</b>? Arte em 300 DPI, em arquivo que a gente confere.</div>
|
||||
<div class="in">
|
||||
<span class="lg">DTF têxtil · 57 cm</span>
|
||||
<p>Cada arte num arquivo. Montamos a folha com o melhor encaixe.</p>
|
||||
<ul><li>Sugerimos a largura que rende mais</li><li>Sobreposição não acontece</li>
|
||||
<li>Montagem inclusa · R$ 10,00 no metro</li></ul>
|
||||
<span class="go">Enviar minhas artes ›</span>
|
||||
<div class="ec-top"><span class="ec-kind">DTF TÊXTIL · 57 CM</span></div>
|
||||
<h3>Artes avulsas</h3>
|
||||
<p>Cada arte num arquivo. A gente monta a folha com o melhor encaixe. Montagem inclusa no preço.</p>
|
||||
<div class="ec-pe">
|
||||
<div class="ec-preco"><span>de</span><b data-de="avulsa">—</b><span>a <em data-ate="avulsa">—</em> /m conforme a nota da arte</span></div>
|
||||
<span class="ec-go">Enviar minhas artes</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button class="ec u" data-modo="uvfile">
|
||||
<div class="top"><h3>Arquivo por metro · UV</h3><span class="p"><i>tabela</i>R$ 85,90<small>/m</small></span></div>
|
||||
<div class="chama2">Quer pagar <b>R$ 69,90</b>? Arte em 300 DPI, em arquivo que a gente confere.</div>
|
||||
<div class="in">
|
||||
<span class="lg">Adesivo UV · 28,5 cm</span>
|
||||
<p>Sua folha de UV montada. Revisamos, corrigimos e reencaixamos.</p>
|
||||
<ul><li>Arquivo bom paga o mínimo</li><li>Remanejamos e sua folha encolhe</li>
|
||||
<li>Rígido: caneca, garrafa, case</li></ul>
|
||||
<span class="go">Enviar minha folha de UV ›</span>
|
||||
<div class="ec-top"><span class="ec-kind">ADESIVO UV · 28,5 CM</span></div>
|
||||
<h3>Arquivo por metro · UV</h3>
|
||||
<p>Sua folha de UV montada. Para rígidos: caneca, garrafa, case. Não precisa de prensa.</p>
|
||||
<div class="ec-pe">
|
||||
<div class="ec-preco"><span>de</span><b data-de="uvfile">—</b><span>a <em data-ate="uvfile">—</em> /m conforme a nota da arte</span></div>
|
||||
<span class="ec-go">Enviar folha de UV</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button class="ec u" data-modo="uv">
|
||||
<div class="top"><h3>Artes avulsas · UV</h3><span class="p"><i>tabela</i>R$ 99,90<small>/m</small></span></div>
|
||||
<div class="chama2">Quer pagar <b>R$ 83,90</b>? Arte em 300 DPI, em arquivo que a gente confere.</div>
|
||||
<div class="in">
|
||||
<span class="lg">Adesivo UV · 28,5 cm</span>
|
||||
<p>Cada arte num arquivo. Montamos a folha de UV com o melhor encaixe.</p>
|
||||
<ul><li>Sugerimos a largura que rende mais</li><li>Montagem inclusa</li>
|
||||
<li>Não precisa de prensa</li></ul>
|
||||
<span class="go">Enviar minhas artes de UV ›</span>
|
||||
<div class="ec-top"><span class="ec-kind">ADESIVO UV · 28,5 CM</span></div>
|
||||
<h3>Artes avulsas · UV</h3>
|
||||
<p>Cada arte num arquivo. Montamos a folha de UV com o melhor encaixe. Montagem inclusa no preço.</p>
|
||||
<div class="ec-pe">
|
||||
<div class="ec-preco"><span>de</span><b data-de="uv">—</b><span>a <em data-ate="uv">—</em> /m conforme a nota da arte</span></div>
|
||||
<span class="ec-go">Enviar artes de UV</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -992,33 +924,33 @@ footer a:hover{color:var(--laranja2)}
|
||||
|
||||
<!-- painel focado -->
|
||||
<div class="foco" id="foco">
|
||||
<button class="voltar" id="bVoltar"><span>‹</span>Trocar de produto</button>
|
||||
<button class="voltar" id="bVoltar"><span>‹</span>Todos os produtos</button>
|
||||
<div class="focoGrid">
|
||||
<div class="cat" id="catFoco">
|
||||
<div class="cabfoco" style="padding:20px 22px 14px;margin:0">
|
||||
<div><h3 id="fTit">—</h3><span class="lg" id="fLg" style="display:block"></span></div>
|
||||
<span class="pr2" id="fPreco"></span>
|
||||
<p id="fSub"></p>
|
||||
</div>
|
||||
<div class="in" style="padding:0 22px 22px">
|
||||
<div class="foco2" id="foco2">
|
||||
<div class="fila">
|
||||
<div class="trocouCam" id="trocouCam" style="display:none"></div>
|
||||
<div class="caminhos" id="tipoEnvio" hidden>
|
||||
<div class="caminhos tipoEnvio" id="tipoEnvio" hidden>
|
||||
<button type="button" class="cam on" data-tipo="folha">
|
||||
<span class="mk2"></span>
|
||||
<div><b>Já é uma folha montada</b>
|
||||
<div><b>Folha já montada</b>
|
||||
<span>a imagem ocupa a largura inteira do filme de
|
||||
<span id="larguraFolhaPronta">57</span> cm</span>
|
||||
<em id="tipoFolhaPreco">—</em></div>
|
||||
</button>
|
||||
<button type="button" class="cam" data-tipo="avulsa">
|
||||
<span class="mk2"></span>
|
||||
<div><b>São artes separadas</b>
|
||||
<div><b>Artes separadas</b>
|
||||
<span>você informa a largura de cada uma e nós montamos a folha</span>
|
||||
<em id="tipoAvulsaPreco">—</em></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="in" style="padding:0 22px 22px">
|
||||
<div class="foco2" id="foco2">
|
||||
<div class="fila">
|
||||
<div class="trocouCam" id="trocouCam" style="display:none"></div>
|
||||
<div class="trocouCam" id="avisoTipo" style="display:none"></div>
|
||||
<div class="caminhos" id="caminhos">
|
||||
<button class="cam on" data-cam="auto">
|
||||
@@ -1044,7 +976,71 @@ footer a:hover{color:var(--laranja2)}
|
||||
<div id="lista"></div>
|
||||
<div class="resumoA" id="rA" style="display:none"></div>
|
||||
<input type="file" id="inp" hidden>
|
||||
<div class="aoVivo" id="aoVivo"></div>
|
||||
<aside class="req" id="req">
|
||||
<h3 class="reqcab">Dicas sobre a arte</h3>
|
||||
|
||||
<details>
|
||||
<summary>Como pagar o menor metro</summary>
|
||||
<ul>
|
||||
<li class="s"><b>300 DPI no tamanho que vai imprimir.</b> É a nota máxima
|
||||
e o menor preço da tabela</li>
|
||||
<li class="s"><b>O tamanho manda mais que o arquivo.</b> A mesma arte passa
|
||||
em 20 cm e não passa em 40. Reduzir um pouco costuma pular uma faixa
|
||||
de preço inteira</li>
|
||||
<li class="s"><b>Larguras parecidas rendem mais.</b> Se as suas artes têm
|
||||
medidas próximas, cabem mais por fileira e a folha encolhe</li>
|
||||
</ul>
|
||||
<p class="obs2">Resolução é a única coisa que mexe no preço — porque é a
|
||||
única que a gente não consegue melhorar por você.</p>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>O que arrumamos sem cobrar</summary>
|
||||
<ul>
|
||||
<li class="s"><b>Artes coladas.</b> Separamos com 5 mm, sem você refazer nada</li>
|
||||
<li class="s"><b>Encaixe mal aproveitado.</b> Remanejamos e a sua folha
|
||||
encolhe — você paga menos metro do que enviou</li>
|
||||
<li class="s"><b>Peça passando da largura.</b> Remanejamos em vez de cortar</li>
|
||||
<li class="s"><b>Margem.</b> Não precisa deixar. Só respeitar a largura do filme</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Manda em PNG e sai na hora</summary>
|
||||
<ul>
|
||||
<li class="s"><b>PNG ou JPG</b> com fundo transparente entram direto, sem passar
|
||||
por ninguém, e não têm limite de comprimento. Só que a gente lê a resolução
|
||||
<b>do arquivo</b>: se a arte foi ampliada antes de exportar, isso não aparece
|
||||
para nós — e sai serrilhado no filme do mesmo jeito</li>
|
||||
<li class="s"><b>PDF é o que conferimos melhor.</b> Nele medimos a resolução de
|
||||
cada imagem dentro da folha, uma por uma. Se a sua arte é boa, é onde a nota
|
||||
aparece com mais precisão</li>
|
||||
<li class="a"><b>PDF grande.</b> Sem UserUnit, a página passa do limite de 508 cm
|
||||
do formato; divida a folha ou mande PNG. Envie um PDF de uma página por folha.</li>
|
||||
<li class="a"><b>TIFF, PSD, AI e CDR</b> a gente aceita, mas ninguém consegue
|
||||
conferir sozinho: alguém abre na mão, sai mais devagar e o metro vai pela
|
||||
tabela, sem desconto de nota</li>
|
||||
<li class="a">Em <b>artes avulsas</b> só PNG e JPG: a montagem precisa
|
||||
enxergar a sua arte para encaixar pela forma</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>O que só você resolve</summary>
|
||||
<ul>
|
||||
<li class="n"><b>Fundo branco ou colorido.</b> Precisa ser transparente de
|
||||
verdade — branco parecendo transparente sai branco no filme</li>
|
||||
<li class="n"><b>Pontinho de recorte.</b> A impressora enxerga qualquer
|
||||
resíduo e imprime. Não existe pequeno demais</li>
|
||||
<li class="n"><b>Cópias sobrepostas.</b> Duas iguais com 1 mm de diferença
|
||||
saem duplicadas</li>
|
||||
<li class="n"><b>Menos de 96 DPI.</b> Abaixo disso não imprimimos: sai
|
||||
serrilhado a ponto de não valer o filme</li>
|
||||
<li class="n"><b>Degradê de transparência</b>, comum em arte de IA. O branco
|
||||
por baixo sai irregular e sai assim mesmo</li>
|
||||
</ul>
|
||||
</details>
|
||||
</aside>
|
||||
</div>
|
||||
<aside class="mont" id="mont">
|
||||
<div class="montcab"><b id="montcabTit">Montagem ao vivo</b>
|
||||
@@ -1054,79 +1050,32 @@ footer a:hover{color:var(--laranja2)}
|
||||
<span id="vMt">—</span></div>
|
||||
<div id="vArea"></div>
|
||||
<div id="vUso"></div>
|
||||
<p class="montpe">Prévia aproximada. No servidor o encaixe é refeito do zero —
|
||||
o resultado sai igual ou melhor, nunca pior.</p>
|
||||
<p class="montpe">Prévia. O encaixe final é refeito no servidor e nunca sai pior.</p>
|
||||
<div class="compra" id="compra">
|
||||
<div class="aoVivo" id="aoVivo"></div>
|
||||
<div id="compraVals" hidden>
|
||||
<div class="cNota"><span>Nota da arte</span><i><i id="cNotaBar"></i></i><b id="cNota">—</b></div>
|
||||
<div class="cLin"><span>Preço do metro</span><span><s id="cTabela"></s><b id="cMetro">—</b></span></div>
|
||||
<div class="cLin"><span>Metragem cobrada</span><b id="cMetragem">—</b></div>
|
||||
<p class="cObs" id="cObs"></p>
|
||||
<div class="cTot"><span>Total</span><b id="cTotal">—</b></div>
|
||||
<div id="qmsg" style="display:none"></div>
|
||||
<div class="ciente" id="ciente">
|
||||
<h4 id="cienteTit">Ressalva de resolução</h4>
|
||||
<div id="cienteTxt"></div>
|
||||
<label><input type="checkbox" id="cienteOk">
|
||||
<span>Li e entendi. Quero imprimir assim mesmo.</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="cAdd" id="bAdd" disabled>Adicionar ao carrinho</button>
|
||||
<p class="cPe">Revisão de 5 pontos e reencaixe grátis</p>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="req" id="req">
|
||||
<h3 class="reqcab">O que devo saber<br>sobre a arte</h3>
|
||||
|
||||
<details open>
|
||||
<summary>Como pagar o menor metro</summary>
|
||||
<ul>
|
||||
<li class="s"><b>300 DPI no tamanho que vai imprimir.</b> É a nota máxima
|
||||
e o menor preço da tabela</li>
|
||||
<li class="s"><b>O tamanho manda mais que o arquivo.</b> A mesma arte passa
|
||||
em 20 cm e não passa em 40. Reduzir um pouco costuma pular uma faixa
|
||||
de preço inteira</li>
|
||||
<li class="s"><b>Larguras parecidas rendem mais.</b> Se as suas artes têm
|
||||
medidas próximas, cabem mais por fileira e a folha encolhe</li>
|
||||
</ul>
|
||||
<p class="obs2">Resolução é a única coisa que mexe no preço — porque é a
|
||||
única que a gente não consegue melhorar por você.</p>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>O que arrumamos sem cobrar</summary>
|
||||
<ul>
|
||||
<li class="s"><b>Artes coladas.</b> Separamos com 5 mm, sem você refazer nada</li>
|
||||
<li class="s"><b>Encaixe mal aproveitado.</b> Remanejamos e a sua folha
|
||||
encolhe — você paga menos metro do que enviou</li>
|
||||
<li class="s"><b>Peça passando da largura.</b> Remanejamos em vez de cortar</li>
|
||||
<li class="s"><b>Margem.</b> Não precisa deixar. Só respeitar a largura do filme</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Manda em PNG e sai na hora</summary>
|
||||
<ul>
|
||||
<li class="s"><b>PNG ou JPG</b> com fundo transparente entram direto, sem passar
|
||||
por ninguém, e não têm limite de comprimento. Só que a gente lê a resolução
|
||||
<b>do arquivo</b>: se a arte foi ampliada antes de exportar, isso não aparece
|
||||
para nós — e sai serrilhado no filme do mesmo jeito</li>
|
||||
<li class="s"><b>PDF é o que conferimos melhor.</b> Nele medimos a resolução de
|
||||
cada imagem dentro da folha, uma por uma. Se a sua arte é boa, é onde a nota
|
||||
aparece com mais precisão</li>
|
||||
<li class="a"><b>PDF grande.</b> Sem UserUnit, a página passa do limite de 508 cm
|
||||
do formato; divida a folha ou mande PNG. Envie um PDF de uma página por folha.</li>
|
||||
<li class="a"><b>TIFF, PSD, AI e CDR</b> a gente aceita, mas ninguém consegue
|
||||
conferir sozinho: alguém abre na mão, sai mais devagar e o metro vai pela
|
||||
tabela, sem desconto de nota</li>
|
||||
<li class="a">Em <b>artes avulsas</b> só PNG e JPG: a montagem precisa
|
||||
enxergar a sua arte para encaixar pela forma</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>O que só você resolve</summary>
|
||||
<ul>
|
||||
<li class="n"><b>Fundo branco ou colorido.</b> Precisa ser transparente de
|
||||
verdade — branco parecendo transparente sai branco no filme</li>
|
||||
<li class="n"><b>Pontinho de recorte.</b> A impressora enxerga qualquer
|
||||
resíduo e imprime. Não existe pequeno demais</li>
|
||||
<li class="n"><b>Cópias sobrepostas.</b> Duas iguais com 1 mm de diferença
|
||||
saem duplicadas</li>
|
||||
<li class="n"><b>Menos de 96 DPI.</b> Abaixo disso não imprimimos: sai
|
||||
serrilhado a ponto de não valer o filme</li>
|
||||
<li class="n"><b>Degradê de transparência</b>, comum em arte de IA. O branco
|
||||
por baixo sai irregular e sai assim mesmo</li>
|
||||
</ul>
|
||||
</details>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1140,16 +1089,9 @@ footer a:hover{color:var(--laranja2)}
|
||||
</div>
|
||||
<div class="qbar"><i id="qbi" style="width:0%"></i></div>
|
||||
<div class="qitens" id="qitens"></div>
|
||||
<div class="ciente" id="ciente">
|
||||
<h4 id="cienteTit">Ressalva de resolução</h4>
|
||||
<div id="cienteTxt"></div>
|
||||
<label><input type="checkbox" id="cienteOk">
|
||||
<span>Li e entendi. Quero imprimir assim mesmo.</span></label>
|
||||
</div>
|
||||
<div class="qbts">
|
||||
<button class="qb chama" id="qOk">Seguir para o pedido</button>
|
||||
</div>
|
||||
<div id="qmsg" style="margin-top:12px;font-size:12.5px;display:none"></div>
|
||||
</div>
|
||||
|
||||
<!-- prévia da montagem -->
|
||||
@@ -1166,7 +1108,7 @@ footer a:hover{color:var(--laranja2)}
|
||||
<div class="pinfo" id="pInfo"></div>
|
||||
</div>
|
||||
<div class="pcta">
|
||||
<button class="pb p" id="pOk">Está certo, fechar o pedido</button>
|
||||
<button class="pb p" id="pOk">Adicionar ao carrinho</button>
|
||||
<button class="pb" id="pMais">Adicionar mais artes</button>
|
||||
<button class="pb" id="pVolta">Trocar o arquivo</button>
|
||||
</div>
|
||||
@@ -1179,7 +1121,8 @@ footer a:hover{color:var(--laranja2)}
|
||||
<h3>Seu pedido</h3>
|
||||
|
||||
<div class="cxE">
|
||||
<h4>Itens</h4>
|
||||
<div class="itensCab"><h4>Itens</h4>
|
||||
<button type="button" class="esvaziar" id="bEsvaziar">Esvaziar carrinho</button></div>
|
||||
<div id="itens"></div>
|
||||
<div id="atual">
|
||||
<div class="rot" style="margin-top:12px">Item que você está montando</div>
|
||||
@@ -1260,7 +1203,7 @@ footer a:hover{color:var(--laranja2)}
|
||||
<div class="tot"><span>Total</span><b id="carrTot">R$ 0,00</b></div>
|
||||
<div class="cbts">
|
||||
<button id="bPagar">Ir para o pagamento</button>
|
||||
<button id="bMais" class="sec">Adicionar outro produto</button>
|
||||
<button id="bMais" class="sec">Continuar comprando</button>
|
||||
</div>
|
||||
<p class="obs">A nota fiscal sai no CNPJ informado. O arquivo fica guardado por 30 dias;
|
||||
depois disso, um novo pedido precisa do arquivo de novo. O histórico do pedido
|
||||
@@ -1274,101 +1217,47 @@ footer a:hover{color:var(--laranja2)}
|
||||
<p id="checkoutStatus" class="avisoE" role="status" aria-live="polite"></p>
|
||||
<div id="checkoutActions"></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- INFORMAÇÃO EM CARROSSEL -->
|
||||
<section id="info"><div class="w">
|
||||
<div class="et">Como funciona</div>
|
||||
<h2>Tudo o que você precisa saber, <em>em cinco cartões</em></h2>
|
||||
|
||||
<div class="carro">
|
||||
<div class="trilho" id="trilho">
|
||||
|
||||
<div class="slide">
|
||||
<div class="stop">
|
||||
<div class="sinfo"><span class="et2">Preço</span><h3>Arte melhor,<br>metro mais barato</h3></div>
|
||||
<div class="selo"><i></i><b>−25<em>%</em></b><span>no metro</span></div>
|
||||
</div>
|
||||
<p>Vale nos quatro produtos. Preço da folha que você monta:</p>
|
||||
<table>
|
||||
<tr><td class="cab">nota</td><td class="cab">têxtil</td><td class="cab">UV</td></tr>
|
||||
<tr><td>90–100</td><td>R$ 14,90</td><td>R$ 69,90</td></tr>
|
||||
<tr><td>75–89</td><td>R$ 16,20</td><td>R$ 73,90</td></tr>
|
||||
<tr><td>60–74</td><td>R$ 17,50</td><td>R$ 77,90</td></tr>
|
||||
<tr><td>40–59</td><td>R$ 18,70</td><td>R$ 81,90</td></tr>
|
||||
<tr><td>abaixo de 40</td><td>R$ 19,90</td><td>R$ 85,90</td></tr>
|
||||
</table>
|
||||
<div class="dest">Se a montagem for nossa, some <b>R$ 10,00</b> no têxtil e
|
||||
<b>R$ 14,00</b> no UV — mesma escada, em qualquer faixa</div>
|
||||
</div>
|
||||
|
||||
<div class="slide">
|
||||
<div class="stop">
|
||||
<div class="sinfo"><span class="et2">Largura</span><h3>O dobro do<br>filme padrão</h3></div>
|
||||
<div class="selo"><i></i><b>57<em>cm</em></b><span>de filme</span></div>
|
||||
</div>
|
||||
<p>A maioria trabalha com 28,5 cm. Na nossa largura cabe o dobro de arte
|
||||
por metro corrido — e o metro sai mais barato por peça.</p>
|
||||
<ul>
|
||||
<li>Duas artes de 28 cm lado a lado</li>
|
||||
<li>Menos emenda, menos corte</li>
|
||||
<li>DTF UV também disponível, em 28,5 cm</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="slide">
|
||||
<div class="stop">
|
||||
<div class="sinfo"><span class="et2">Encaixe</span><h3>Reencaixamos<br>sua folha de graça</h3></div>
|
||||
<div class="selo"><i></i><b>grátis</b><span>reencaixe</span></div>
|
||||
</div>
|
||||
<p>Se a sua montagem deixa espaço sobrando, refazemos o encaixe com o mesmo
|
||||
motor que usamos nas nossas montagens.</p>
|
||||
<ul>
|
||||
<li>Artes que se tocam, separadas com 5 mm</li>
|
||||
<li>Giramos as peças para aproveitar a largura</li>
|
||||
<li>A folha encolhe e a sua conta diminui</li>
|
||||
</ul>
|
||||
<div class="dest">Refazemos o encaixe <b>antes de imprimir</b>, sem cobrar nada</div>
|
||||
</div>
|
||||
|
||||
<div class="slide">
|
||||
<div class="stop">
|
||||
<div class="sinfo"><span class="et2">Cobrança</span><h3>Você paga<br>o que usa</h3></div>
|
||||
<div class="selo"><i></i><b>10<em>cm</em></b><span>de cada vez</span></div>
|
||||
</div>
|
||||
<p>Arredondamos a cada 10 cm. Uma folha de 2,75 m custa 2,80 m — quem cobra
|
||||
metro cheio cobraria 3.</p>
|
||||
<ul>
|
||||
<li>Pedido mínimo de 1 metro</li>
|
||||
<li>A identificação do pedido é por nossa conta</li>
|
||||
<li>Sem taxa de arquivo, sem taxa de revisão</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="slide">
|
||||
<div class="stop">
|
||||
<div class="sinfo"><span class="et2">Sem burocracia</span><h3>Conta só na<br>hora de pagar</h3></div>
|
||||
<div class="selo"><i></i><b>0</b><span>conta e senha</span></div>
|
||||
</div>
|
||||
<p>Sobe a arte e vê o preço sem conta nenhuma. A conta nasce no pagamento, junto com o CNPJ da nota — e o próximo pedido já vem com suas artes anteriores. A revisão de 5 pontos vem junto —
|
||||
há quem cobre R$ 19,90 por pedido só por isso.</p>
|
||||
<ul>
|
||||
<li>Sem cadastro para subir e ver o preço</li>
|
||||
<li>Envio em até 24 horas</li>
|
||||
<li>WhatsApp a cada etapa do pedido</li>
|
||||
<li>Arquivo guardado 30 dias · histórico do pedido na conta</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="navc">
|
||||
<button id="cAnt" aria-label="Anterior">‹</button>
|
||||
<button id="cPro" aria-label="Próximo">›</button>
|
||||
<div class="pontos" id="cPontos"></div>
|
||||
</div>
|
||||
<div class="desfazer" id="desfazer" role="status" hidden>
|
||||
<span id="desfazerTxt"></span><button type="button" id="desfazerBtn">Desfazer</button>
|
||||
</div>
|
||||
<div class="carrVazio" id="carrVazio" hidden>
|
||||
<h2>Seu carrinho está vazio</h2>
|
||||
<p>Escolha um produto, envie a arte e veja o preço na hora.</p>
|
||||
<a href="/#envio" class="btn-laranja">Escolher produto</a>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- PREÇO E VANTAGENS -->
|
||||
<section id="info" class="info2"><div class="w info2-grid">
|
||||
<div class="info2-preco">
|
||||
<div class="et">Preço</div>
|
||||
<h2>Arte melhor, metro mais barato</h2>
|
||||
<p>A nota aparece na tela antes de você pagar. Preço da folha que você monta:</p>
|
||||
<table>
|
||||
<thead><tr><th scope="col">Nota da arte</th><th scope="col">Têxtil 57 cm</th><th scope="col">UV 28,5 cm</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>90 a 100</td><td class="melhor">R$ 14,90</td><td class="melhor">R$ 69,90</td></tr>
|
||||
<tr><td>75 a 89</td><td>R$ 16,20</td><td>R$ 73,90</td></tr>
|
||||
<tr><td>60 a 74</td><td>R$ 17,50</td><td>R$ 77,90</td></tr>
|
||||
<tr><td>40 a 59</td><td>R$ 18,70</td><td>R$ 81,90</td></tr>
|
||||
<tr><td>abaixo de 40</td><td>R$ 19,90</td><td>R$ 85,90</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="nota2">Montagem das artes avulsas: + R$ 10,00 no têxtil e + R$ 14,00 no UV, em qualquer faixa.</p>
|
||||
</div>
|
||||
<div class="info2-vant">
|
||||
<div><b class="n">57 cm</b><b>O dobro do filme padrão</b><span>Cabe o dobro de arte por metro corrido.</span></div>
|
||||
<div><b class="n">10 cm</b><b>Você paga o que usa</b><span>Folha de 2,75 m custa 2,80 m, não 3. Pedido mínimo de 1 metro.</span></div>
|
||||
<div><b class="n">Grátis</b><b>Reencaixe da folha</b><span>Sobrou espaço? Refazemos o encaixe e a conta diminui.</span></div>
|
||||
<div><b class="n">5 pontos</b><b>Revisão sem custo</b><span>Erro pego antes de imprimir. Arquivo guardado por 30 dias.</span></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<div class="barraM" id="barraM" hidden>
|
||||
<div><span id="barraMSub">Seu pedido</span><b id="barraMTot">R$ 0,00</b></div>
|
||||
<a href="/carrinho" id="barraMBtn">Continuar</a>
|
||||
</div>
|
||||
|
||||
<footer><div class="w">
|
||||
<div class="frow">
|
||||
<span class="fmarca"><img src="https://cdn.vnda.com.br/dropstar/2025/12/17/logo-header-15125700-16129080.png?v=1766009286" alt="Dropstar">DTF</span>
|
||||
@@ -1404,10 +1293,12 @@ footer a:hover{color:var(--laranja2)}
|
||||
<script src="/site-packing.js"></script>
|
||||
<script src="/site-cart.js"></script>
|
||||
<script src="/site-flow.js"></script>
|
||||
<script src="/site-compra.js"></script>
|
||||
<script src="/privacy.js"></script>
|
||||
<script src="/upload.js"></script>
|
||||
<script src="/checkout.js"></script>
|
||||
<script src="/cart.js"></script>
|
||||
<script src="/site-steps.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
203
web/kanban.css
Normal file
203
web/kanban.css
Normal 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}
|
||||
}
|
||||
@@ -1,21 +1,70 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>DTF · Kanban local</title>
|
||||
<style>
|
||||
:root{--bg:#0B0D10;--card:#15181D;--card2:#1C2026;--linha:#282C34;--tx:#EDEBE6;--fraco:#a1a6af;--ciano:#00B8DA}
|
||||
*{box-sizing:border-box}body{background:var(--bg);color:var(--tx);font:14px/1.5 system-ui,sans-serif;padding:20px;margin:0}
|
||||
h1{font-size:24px;margin:0}h2{font-size:18px}header{display:flex;align-items:center;gap:18px;flex-wrap:wrap;margin-bottom:20px}
|
||||
.aviso{background:#152026;border-left:3px solid var(--ciano);padding:12px;color:#b9cbd2;margin:16px 0}
|
||||
button,input,select{font:inherit;border:1px solid #56616d;border-radius:5px;padding:8px;background:var(--card2);color:var(--tx)}
|
||||
button{cursor:pointer}button:hover{border-color:var(--ciano)}button:disabled{opacity:.5}input[type=number]{width:100px}
|
||||
label{display:inline-flex;gap:8px;align-items:center;margin:5px}#kan{display:grid;grid-template-columns:repeat(6,minmax(220px,1fr));gap:10px;overflow-x:auto;padding-bottom:16px}
|
||||
.col{background:var(--card);border:1px solid var(--linha);border-radius:9px;min-height:250px;padding:10px}.col h2{font-size:14px;border-bottom:2px solid var(--cc);padding-bottom:10px}.col.alvo{border-color:var(--ciano)}
|
||||
.cd,.review{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc,var(--ciano));border-radius:6px;padding:12px;margin-bottom:10px}.cd{cursor:grab}.cd p{overflow-wrap:anywhere}.meta{color:var(--fraco);font-size:12px}.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.error{color:#ffad83}.print{margin-top:8px;padding-top:8px;border-top:1px solid var(--linha)}.print button{margin-left:6px}#tiny{font-size:12px;color:var(--fraco);margin-right:8px}#tiny button{margin-left:6px}#status{min-height:24px}details{margin:16px 0}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--ciano)}
|
||||
</style></head><body>
|
||||
<header><h1>Kanban DTF</h1><span id="tiny"></span><button id="refresh">Atualizar</button><button id="logout">Sair</button></header>
|
||||
<div class="aviso">Confira a cotação manualmente. Em Arquivos de produção, envie e aprove os arquivos finais de todos os itens antes de colocar o pedido na fila. Não há validação automática de arte.</div>
|
||||
<form id="login"><label>E-mail <input id="email" type="email" autocomplete="username" required></label><label>Senha <input id="password" type="password" autocomplete="current-password" required></label><button>Entrar</button></form>
|
||||
<p id="status" role="status"></p>
|
||||
<section id="payments"></section><section id="reviews"></section><div id="kan"></div>
|
||||
<details><summary>Eventos locais de integração</summary><pre id="events"></pre></details>
|
||||
<script src="/upload.js"></script><script src="/kanban.js?v=pdf-print-1"></script></body></html>
|
||||
<title>Kanban DTF</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<link rel="stylesheet" href="/kanban.css?v=redesign-7">
|
||||
</head><body>
|
||||
<form id="login" class="login" hidden>
|
||||
<div class="brand"><i aria-hidden="true">D</i>Kanban DTF</div>
|
||||
<label>E-mail <input id="email" type="email" autocomplete="username" required></label>
|
||||
<label>Senha <input id="password" type="password" autocomplete="current-password" required></label>
|
||||
<button class="btn primary">Entrar</button>
|
||||
<p id="login-status" class="hint" role="alert"></p>
|
||||
</form>
|
||||
|
||||
<div id="app" hidden>
|
||||
<header class="top">
|
||||
<div class="brand"><i aria-hidden="true">D</i>Kanban DTF</div>
|
||||
<nav class="tabs" aria-label="Seções">
|
||||
<button type="button" data-tab="board" aria-selected="true">Produção <span class="count" id="count-board"></span></button>
|
||||
<button type="button" data-tab="quotes" aria-selected="false">Cotações <span class="badge" id="badge-quotes"></span></button>
|
||||
<button type="button" data-tab="payments" aria-selected="false">Pagamentos <span class="badge warn" id="badge-payments"></span></button>
|
||||
<button type="button" data-tab="integrations" aria-selected="false">Integrações</button>
|
||||
</nav>
|
||||
<label class="search">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="M20 20l-3.5-3.5"></path></svg>
|
||||
<input id="search" type="search" placeholder="Buscar pedido, cliente ou CNPJ" aria-label="Buscar pedido, cliente ou CNPJ">
|
||||
</label>
|
||||
<span id="tiny" class="tinydot"></span>
|
||||
<span id="who" class="who"></span>
|
||||
<button class="btn ghost" id="refresh" type="button">Atualizar</button>
|
||||
<button class="btn ghost" id="logout" type="button">Sair</button>
|
||||
</header>
|
||||
<p id="status" role="status"></p>
|
||||
<main>
|
||||
<section id="tab-board">
|
||||
<div class="bar"><h1>Produção</h1><span class="muted" id="board-summary"></span><div class="chips" id="filters"></div></div>
|
||||
<div class="kan" id="kan"></div>
|
||||
</section>
|
||||
<section id="tab-quotes" hidden>
|
||||
<div class="split">
|
||||
<div class="list">
|
||||
<h1>Cotações</h1>
|
||||
<div class="chips" id="quote-filters"></div>
|
||||
<div id="quote-list" style="display:flex;flex-direction:column;gap:8px"></div>
|
||||
</div>
|
||||
<div class="pane" id="reviews"></div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="tab-payments" class="page" hidden>
|
||||
<h1>Pagamentos</h1>
|
||||
<section id="payments"></section>
|
||||
</section>
|
||||
<section id="tab-integrations" class="page" hidden>
|
||||
<h1>Integrações</h1>
|
||||
<section><div class="grid4" id="integrations"></div></section>
|
||||
<section><h2>Registro de envios</h2>
|
||||
<div class="filters" id="event-filters"></div>
|
||||
<table class="log"><thead><tr><th scope="col">Quando</th><th scope="col">Pedido</th><th scope="col">Destino</th><th scope="col">Evento</th><th scope="col">Resultado</th></tr></thead><tbody id="events"></tbody></table>
|
||||
<div id="events-more"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="drawer" id="drawer" hidden>
|
||||
<div class="scrim" id="scrim"></div>
|
||||
<aside class="panel" id="panel" aria-label="Pedido"></aside>
|
||||
</div>
|
||||
<script src="/upload.js"></script><script src="/kanban.js?v=redesign-8"></script></body></html>
|
||||
|
||||
837
web/kanban.js
837
web/kanban.js
@@ -1,249 +1,668 @@
|
||||
/* Reuses the prototype palette, column names and drag/drop interaction; no machine controls. */
|
||||
/* Kanban DTF: production board, quote review, payments and integrations.
|
||||
Everything is built with textContent; nothing from an order or a customer is
|
||||
ever parsed as HTML. The previous screen is in git tag ui-v1. */
|
||||
const $ = id=>document.getElementById(id);
|
||||
// Remove credentials saved by older local builds. Only HttpOnly sessions now.
|
||||
sessionStorage.removeItem('dtf-operator');
|
||||
let board;
|
||||
let extraQuotes={pending:[],approved:[]};
|
||||
let moreQuotes={pending:false,approved:false};
|
||||
|
||||
const STAGE_COLORS={rec:'var(--rec)',tra:'var(--tra)',fil:'var(--fil)',imp:'var(--imp)',cor:'var(--cor)',fin:'var(--fin)'};
|
||||
const FLOW=['rec','tra','fil','imp','fin'];
|
||||
const PRODUCT={file:'DTF Têxtil · folha montada',avulsa:'DTF Têxtil · artes avulsas',uvfile:'DTF UV · folha montada',uv:'DTF UV · artes avulsas'};
|
||||
const SHORT={file:'Têxtil folha',avulsa:'Têxtil avulsa',uvfile:'UV folha',uv:'UV avulsa'};
|
||||
const EVENTS={payment_approved:'Pagamento aprovado',production_started:'Produção iniciada',correction_needed:'Correção necessária',ready:'Pedido pronto'};
|
||||
const LATE_HOURS=4;
|
||||
// Payment outcomes are recorded by the API in English; operators read Portuguese.
|
||||
function outcomeText(outcome){
|
||||
const rules=[
|
||||
[/^refused: paid (\S+) but quote total is (\d+)/,m=>'valor pago ('+(m[1]==='None'?'não informado':money(Number(m[1])))+') diferente do total da cotação ('+money(Number(m[2]))+')'],
|
||||
[/^refused: quote expired/,()=>'a cotação venceu antes do pagamento'],
|
||||
[/^refused: quote not found/,()=>'cotação não encontrada'],
|
||||
[/^refused: reference is not a quote id/,()=>'a referência do pagamento não é uma cotação'],
|
||||
[/^refused: quote was never reviewed/,()=>'a cotação ainda não tinha sido revisada'],
|
||||
[/^refused: quote uses an obsolete/,()=>'a cotação usa uma montagem antiga'],
|
||||
[/^attention: payment (\w+) for order (\d+)/,m=>'pagamento '+(m[1]==='refunded'?'estornado':'cancelado')+' depois do pedido #'+m[2]],
|
||||
];
|
||||
for(const [pattern,text] of rules){const m=outcome.match(pattern);if(m)return text(m);}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
let board=null;
|
||||
let tab='board', filter='all', search='', openOrder=null, openToken=0;
|
||||
let quoteKind='pending', pickedQuote=null;
|
||||
let olderFinished=[], finishedMore=null;
|
||||
|
||||
const money=cents=>(cents/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'});
|
||||
function node(tag,text,className){const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(className)e.className=className;return e;}
|
||||
function action(text,fn){const b=node('button',text);b.onclick=async()=>{b.disabled=true;try{await fn();}catch(e){$('status').textContent=e.message;}finally{b.disabled=false;}};return b;}
|
||||
const metres=value=>Number(value).toLocaleString('pt-BR',{minimumFractionDigits:2,maximumFractionDigits:2})+' m';
|
||||
const when=value=>new Date(value).toLocaleString('pt-BR',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});
|
||||
function ago(value){
|
||||
const minutes=Math.max(0,Math.round((Date.now()-new Date(value))/60000));
|
||||
if(minutes<60)return 'há '+minutes+' min';
|
||||
const hours=Math.round(minutes/60);
|
||||
return hours<48?'há '+hours+' h':'há '+Math.round(hours/24)+' dias';
|
||||
}
|
||||
function node(tag,text,className){const e=document.createElement(tag);if(text!==undefined&&text!==null)e.textContent=text;if(className)e.className=className;return e;}
|
||||
function button(text,fn,className='btn'){
|
||||
const b=node('button',text,className);b.type='button';
|
||||
b.onclick=async event=>{event.stopPropagation();b.disabled=true;try{await fn();}catch(e){say(e.message,true);}finally{b.disabled=false;}};
|
||||
return b;
|
||||
}
|
||||
// Messages are notifications, not state: they clear themselves. Errors stay
|
||||
// longer so they can be read; a caller can hold one for as long as it needs.
|
||||
let sayTimer=null;
|
||||
function say(text,error=false,ms){
|
||||
clearTimeout(sayTimer);
|
||||
for(const id of ['status','login-status']){$(id).textContent=text||'';$(id).classList.toggle('error',!!error);}
|
||||
if(text)sayTimer=setTimeout(()=>say(''),ms??(error?8000:4000));
|
||||
}
|
||||
function icon(path){
|
||||
const ns='http://www.w3.org/2000/svg';const svg=document.createElementNS(ns,'svg');
|
||||
svg.setAttribute('width','14');svg.setAttribute('height','14');svg.setAttribute('viewBox','0 0 24 24');
|
||||
svg.setAttribute('fill','none');svg.setAttribute('stroke','currentColor');svg.setAttribute('stroke-width','2.2');svg.setAttribute('aria-hidden','true');
|
||||
for(const d of path){const p=document.createElementNS(ns,d.startsWith('circle')?'circle':'path');
|
||||
if(d.startsWith('circle')){const [cx,cy,r]=d.split(' ').slice(1);p.setAttribute('cx',cx);p.setAttribute('cy',cy);p.setAttribute('r',r);}else p.setAttribute('d',d);svg.append(p);}
|
||||
return svg;
|
||||
}
|
||||
const ICON_OK=['M5 12l4 4 10-10'], ICON_WARN=['M12 8v5M12 17h.01','circle 12 12 9'];
|
||||
async function api(path,body){
|
||||
const r=await fetch('/api/operator'+path,{headers:{'Content-Type':'application/json'},...(body===undefined?{}:{method:'POST',body:JSON.stringify(body)})});
|
||||
const d=await r.json();if(!r.ok){const error=new Error(typeof d.detail==='string'?d.detail:'Confira os campos ('+r.status+').');error.status=r.status;throw error;}return d;
|
||||
}
|
||||
function files(container,items){
|
||||
for(const uid of [...new Set(items.flatMap(i=>i.uploads))])container.append(action('Baixar original '+uid.slice(0,6),async()=>{
|
||||
const result=await api('/uploads/'+uid+'/download');
|
||||
const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click();
|
||||
}));
|
||||
}
|
||||
function productionLines(container,item){
|
||||
if(!item.production){container.append(node('p','Instruções de produção ausentes; não produzir este item.','meta'));return;}
|
||||
container.append(node('p','Especificação v'+item.production.version+' · qualidade '+item.quality_status+
|
||||
(item.quality_status==='warning'?' · ressalva '+(item.quality_acknowledged?'aceita':'não aceita'):''),'meta'));
|
||||
if(item.production.placements){
|
||||
container.append(node('p',item.production.placements.length+' peças posicionadas em '+
|
||||
item.production.film_width_cm+' × '+item.production.height_cm+' cm de filme','meta'));
|
||||
const download=node('button','Baixar manifesto da montagem');
|
||||
download.type='button';
|
||||
download.onclick=()=>{
|
||||
const url=URL.createObjectURL(new Blob([JSON.stringify(item.production,null,2)],{type:'application/json'}));
|
||||
const link=node('a');link.href=url;
|
||||
link.download='dtf-layout-'+item.production.sources[0].upload_id.slice(0,8)+'.json';
|
||||
link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||||
};
|
||||
container.append(download);
|
||||
}
|
||||
item.production.sources.forEach((source,index)=>container.append(node('p',
|
||||
(index+1)+'. '+source.kind+' · '+source.copies+' × '+source.width_cm+' × '+source.length_cm+
|
||||
' cm · giro '+source.rotation_degrees+'°'+(source.mirrored?' · espelhada':'')+
|
||||
' · medida '+source.measurement+' · arquivo '+source.upload_id.slice(0,8),'meta')));
|
||||
}
|
||||
const PRINT_STATUS={pending:'na fila para gerar',rendering:'gerando…',ready:'pronto',
|
||||
manual:'preparar à mão',failed:'falhou ao gerar'};
|
||||
function download(uid){return async()=>{
|
||||
const result=await api('/uploads/'+uid+'/download');
|
||||
const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click();
|
||||
};}
|
||||
function printFiles(card,order){
|
||||
const rows=order.print_files||[];
|
||||
const box=node('div',undefined,'print');
|
||||
box.append(node('b','Arquivo de impressão'));
|
||||
order.snapshot.items.forEach((item,index)=>{
|
||||
const row=rows.find(r=>r.item_index===index);
|
||||
const line=node('p','Item '+(index+1)+' · '+(row?PRINT_STATUS[row.status]:'não gerado'),'meta');
|
||||
if(row?.status==='ready'){
|
||||
line.textContent+=' · '+row.detail.film_width_cm+' × '+row.detail.height_cm+' cm'+
|
||||
(row.detail.min_dpi?' · menor resolução '+row.detail.min_dpi+' DPI':'')+
|
||||
(row.detail.vector_sources?' · PDF vetorial':'');
|
||||
line.append(action('Baixar PDF',download(row.upload_id)));
|
||||
}
|
||||
if(row?.status==='manual')line.append(node('span',' · '+row.detail.reason));
|
||||
box.append(line);
|
||||
});
|
||||
const retry=order.snapshot.items.some((_,index)=>{const row=rows.find(r=>r.item_index===index);
|
||||
return !row||row.status==='manual'||row.status==='failed';});
|
||||
if(retry&&['rec','tra'].includes(order.state))
|
||||
box.append(action('Gerar arquivos de impressão',async()=>{await api('/orders/'+order.id+'/print-files',{});await load();}));
|
||||
card.append(box);
|
||||
}
|
||||
function paymentIssues(){
|
||||
$('payments').replaceChildren();
|
||||
if(!board.payment_issues?.length)return;
|
||||
$('payments').append(node('h2','Pagamentos que precisam de atenção · '+board.payment_issues.length));
|
||||
$('payments').append(node('p','Um pagamento chegou sem virar pedido (valor diferente, cotação vencida) ou foi '+
|
||||
'estornado depois do pedido. Confira no painel do provedor, resolva com o cliente e registre o que foi feito.','meta'));
|
||||
for(const issue of board.payment_issues){
|
||||
const card=node('article',undefined,'review');
|
||||
card.append(node('b',issue.provider+' · evento '+issue.event_id),
|
||||
node('p',(issue.amount_cents==null?'valor não informado':money(issue.amount_cents))+' · cotação '+
|
||||
(issue.reference||'—')+' · '+new Date(issue.received_at).toLocaleString('pt-BR'),'meta'),
|
||||
node('p',issue.outcome));
|
||||
card.append(action('Registrar resolução',async()=>{
|
||||
const note=prompt('O que foi feito? (ex.: estornado no Mercado Pago em 25/09)');if(!note)return;
|
||||
await api('/payment-events/'+issue.id+'/resolve',{note});await load();
|
||||
}));
|
||||
$('payments').append(card);
|
||||
}
|
||||
function manifest(production){
|
||||
const url=URL.createObjectURL(new Blob([JSON.stringify(production,null,2)],{type:'application/json'}));
|
||||
const link=node('a');link.href=url;link.download='dtf-layout-'+production.sources[0].upload_id.slice(0,8)+'.json';
|
||||
link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||||
}
|
||||
|
||||
// ---- Loading and tabs ------------------------------------------------------
|
||||
|
||||
async function load(){
|
||||
try{
|
||||
board=await api('/board');
|
||||
extraQuotes={pending:[],approved:[]};
|
||||
moreQuotes={pending:board.pending_total>board.quotes.filter(q=>!q.approved).length,
|
||||
approved:board.approved_total>board.quotes.filter(q=>!!q.approved).length};
|
||||
$('login').hidden=true;
|
||||
$('status').textContent='Atualizado às '+new Date().toLocaleTimeString()+tinyNotice;
|
||||
const known=new Set(board.orders.map(o=>o.id));
|
||||
board.orders.push(...olderFinished.filter(o=>!known.has(o.id)));
|
||||
if(finishedMore===null)finishedMore=board.finished_total>board.finished_shown;
|
||||
$('login').hidden=true;$('app').hidden=false;
|
||||
say('Atualizado às '+new Date().toLocaleTimeString('pt-BR')+tinyNotice,false,tinyNotice?6000:2500);
|
||||
render();
|
||||
}catch(e){$('status').textContent=e.message;$('login').hidden=false;}
|
||||
}catch(e){
|
||||
if(e.status===401||e.status===503){$('app').hidden=true;$('login').hidden=false;$('drawer').hidden=true;}
|
||||
// Not being signed in yet is the normal start, not an error.
|
||||
if(e.status===401){say('');return;}
|
||||
say(e.message,true);
|
||||
}
|
||||
}
|
||||
async function loadMoreQuotes(kind){
|
||||
const shown=[...board.quotes.filter(q=>kind==='pending'?!q.approved:!!q.approved),...extraQuotes[kind]];
|
||||
const last=shown.at(-1);
|
||||
const params=new URLSearchParams({kind,limit:'50'});
|
||||
if(last){params.set('before_created_at',last.created_at);params.set('before_id',last.id);}
|
||||
const page=await api('/quotes?'+params);
|
||||
const known=new Set(shown.map(q=>q.id));
|
||||
extraQuotes[kind].push(...page.quotes.filter(q=>!known.has(q.id)));
|
||||
moreQuotes[kind]=page.has_more;
|
||||
render();
|
||||
function showTab(name){
|
||||
tab=name;
|
||||
for(const b of document.querySelectorAll('[data-tab]'))b.setAttribute('aria-selected',String(b.dataset.tab===name));
|
||||
for(const id of ['board','quotes','payments','integrations'])$('tab-'+id).hidden=name!==id;
|
||||
const lists={quotes:loadQuotes,payments:loadIssues,integrations:loadEvents};
|
||||
if(board&&lists[name])lists[name]().catch(e=>say(e.message,true));
|
||||
}
|
||||
// The one-time authorisation of this system in the client's Tiny. Shown only
|
||||
// when the Tiny application is configured on the server.
|
||||
function tinyStatus(){
|
||||
const box=$('tiny');box.replaceChildren();
|
||||
const tiny=board.tiny||{};
|
||||
if(!tiny.configured)return;
|
||||
box.append(node('span',tiny.connected
|
||||
? 'Tiny conectado'+(tiny.orders_enabled?'':' · envio de pedidos desligado')
|
||||
: 'Tiny não conectado'));
|
||||
box.append(action(tiny.connected?'Reconectar Tiny':'Conectar Tiny',async()=>{
|
||||
const result=await api('/tiny/connect',{});location.href=result.url;
|
||||
function render(){
|
||||
$('who').textContent=board.operator||'';
|
||||
$('count-board').textContent=String(board.orders.filter(o=>o.state!=='fin').length);
|
||||
$('badge-quotes').textContent=board.pending_total?String(board.pending_total):'';
|
||||
$('badge-payments').textContent=board.payment_issues_total?String(board.payment_issues_total):'';
|
||||
tinyHeader();renderBoard();renderIntegrations();
|
||||
// Lists live on their own pages of the API; refresh the one being looked at.
|
||||
const lists={quotes:loadQuotes,payments:loadIssues,integrations:loadEvents};
|
||||
if(lists[tab])lists[tab]().catch(e=>say(e.message,true));
|
||||
if(openOrder){const order=board.orders.find(o=>o.id===openOrder);if(order)renderPanel(order);else closePanel();}
|
||||
}
|
||||
|
||||
// ---- Production board ----------------------------------------------------
|
||||
|
||||
function printState(order){
|
||||
if(order.state==='fil'||order.state==='imp')return {text:'Final aprovado',tone:'ok'};
|
||||
if(order.state==='fin')return {text:'Concluído',tone:'ok'};
|
||||
if(order.state==='cor')return {text:'Aguardando o cliente',tone:''};
|
||||
const rows=order.print_files||[], items=order.snapshot.items.length;
|
||||
const ready=rows.filter(r=>r.status==='ready').length;
|
||||
if(rows.some(r=>r.status==='pending'||r.status==='rendering'))return {text:'Gerando arquivo…',tone:''};
|
||||
if(ready===items)return {text:'PDF pronto',tone:'ok'};
|
||||
if(rows.some(r=>r.status==='manual'||r.status==='failed'))
|
||||
return {text:items>1&&ready?ready+' de '+items+' pronto':'Preparar à mão',tone:'warn'};
|
||||
return {text:'Sem arquivo gerado',tone:'warn'};
|
||||
}
|
||||
function matches(order){
|
||||
const modes=order.snapshot.items.map(i=>i.mode);
|
||||
if(filter==='textil'&&!modes.some(m=>m==='file'||m==='avulsa'))return false;
|
||||
if(filter==='uv'&&!modes.some(m=>m==='uvfile'||m==='uv'))return false;
|
||||
if(filter==='manual'&&!(order.print_files||[]).some(r=>r.status==='manual'||r.status==='failed'))return false;
|
||||
if(!search)return true;
|
||||
const c=order.snapshot.customer, d=order.snapshot.destination||{};
|
||||
const hay=[String(order.number),c.mail,c.cnpj,d.recipient,d.city].join(' ').toLowerCase();
|
||||
const digits=search.replace(/\D/g,'');
|
||||
return hay.includes(search)||(digits.length>=3&&hay.replace(/\D/g,'').includes(digits));
|
||||
}
|
||||
function renderBoard(){
|
||||
const filters=[['all','Todos'],['textil','Têxtil'],['uv','UV'],['manual','Preparar à mão']];
|
||||
$('filters').replaceChildren(...filters.map(([key,label])=>{
|
||||
const b=node('button',label,'chip');b.type='button';b.setAttribute('aria-pressed',String(filter===key));
|
||||
b.onclick=()=>{filter=key;renderBoard();};return b;}));
|
||||
const active=board.orders.filter(o=>['rec','tra','fil','imp'].includes(o.state));
|
||||
const queued=active.reduce((s,o)=>s+o.snapshot.items.reduce((t,i)=>t+Number(i.billed_metres),0),0);
|
||||
$('board-summary').textContent=metres(queued)+' em produção';
|
||||
$('kan').replaceChildren(...Object.entries(board.states).map(([state,title])=>{
|
||||
const column=node('section',undefined,'col');column.dataset.state=state;
|
||||
const orders=board.orders.filter(o=>o.state===state&&matches(o));
|
||||
const total=orders.reduce((s,o)=>s+o.snapshot.items.reduce((t,i)=>t+Number(i.billed_metres),0),0);
|
||||
const head=node('div',undefined,'col-head');const line=node('div');
|
||||
const swatch=node('span',undefined,'swatch');swatch.style.setProperty('--c',STAGE_COLORS[state]);
|
||||
// Finished orders are a recent window, not the whole history: say so.
|
||||
const count=state==='fin'&&board.finished_total>orders.length?orders.length+' de '+board.finished_total:String(orders.length);
|
||||
line.append(swatch,node('h2',title),node('span',count,'n'));
|
||||
head.append(line,node('small',orders.length?metres(total)+' no total':'vazio'));
|
||||
column.append(head);
|
||||
for(const order of orders)column.append(card(order));
|
||||
if(state==='fin'&&finishedMore&&!search&&filter==='all')moreButton(column,true,loadOlderFinished);
|
||||
// Dragging only goes forward to the next stage. Going back or asking for a
|
||||
// correction needs a reason, so those happen in the order panel.
|
||||
column.ondragover=e=>{if(!column.classList.contains('allowed'))return;e.preventDefault();column.classList.add('target');};
|
||||
column.ondragleave=()=>column.classList.remove('target');
|
||||
column.ondrop=async e=>{e.preventDefault();column.classList.remove('target');
|
||||
const order=board.orders.find(o=>o.id===e.dataTransfer.getData('text/plain'));
|
||||
if(order&&order.state!==state)try{await move(order,state);}catch(error){say(error.message,true);}};
|
||||
return column;
|
||||
}));
|
||||
}
|
||||
const tinyResult=new URLSearchParams(location.search).get('tiny');
|
||||
const tinyNotice=tinyResult?(tinyResult==='connected'?' · Tiny conectado.':' · Não foi possível conectar o Tiny. Tente de novo.'):'';
|
||||
if(tinyResult)history.replaceState(null,'',location.pathname);
|
||||
function render(){
|
||||
tinyStatus();
|
||||
paymentIssues();
|
||||
$('reviews').replaceChildren();
|
||||
if(board.pending_total || board.approved_total)
|
||||
$('reviews').append(node('h2','Cotações · '+board.pending_total+' pendentes · '+
|
||||
board.approved_total+' aprovadas sem pedido'));
|
||||
for(const quote of [...board.quotes,...extraQuotes.pending,...extraQuotes.approved]){
|
||||
const card=node('article',undefined,'review');
|
||||
card.append(node('b',quote.id.slice(0,8)+' · '+quote.draft.customer.mail));
|
||||
if(quote.status==='pending_review' && quote.draft.items.some(item=>item.production?.version!==2)){
|
||||
card.append(node('p','Cotação com montagem antiga. Peça ao cliente para enviar uma nova cotação.'));
|
||||
}else if(quote.status!=='pending_review'){
|
||||
card.append(node('p',quote.status==='expired'?'Cotação expirada. O cliente precisa solicitar outra.':'Aprovada: '+money(quote.approved.total_cents)+'. Volte ao Site, atualize o pedido e confirme o pagamento local.'));
|
||||
}else{
|
||||
const form=node('form');
|
||||
const edits=quote.draft.items.map((item,index)=>{
|
||||
const row=node('div');row.append(node('strong',(index+1)+'. '+item.mode+' '));
|
||||
productionLines(row,item);
|
||||
const metres=node('input');metres.type='number';metres.min='0.001';metres.max='12000';metres.step='any';metres.value=item.metres;metres.required=true;
|
||||
const grade=node('input');grade.type='number';grade.min='0';grade.max='100';grade.step='1';grade.value=item.grade;grade.required=true;
|
||||
const ml=node('label','Metros conferidos');ml.append(metres);const gl=node('label','Nota conferida');gl.append(grade);row.append(ml,gl);form.append(row);
|
||||
return ()=>({...item,metres:metres.value,grade:Number(grade.value)});
|
||||
});
|
||||
const check=node('input');check.type='checkbox';check.required=true;const label=node('label','Conferi os arquivos, a metragem total (incluindo repetições/montagem) e a nota.');label.prepend(check);form.append(label);
|
||||
const submit=node('button','Aprovar cotação');submit.type='submit';form.append(submit);
|
||||
form.onsubmit=async e=>{e.preventDefault();submit.disabled=true;try{await api('/quotes/'+quote.id+'/approve',{items:edits.map(fn=>fn())});await load();}catch(error){$('status').textContent=error.message;submit.disabled=false;}};
|
||||
card.append(form);
|
||||
}
|
||||
const downloads=node('div',undefined,'actions');files(downloads,quote.draft.items);card.append(downloads);$('reviews').append(card);
|
||||
}
|
||||
for(const [kind,label] of [['pending','Carregar cotações pendentes mais antigas'],
|
||||
['approved','Carregar cotações aprovadas mais antigas']]){
|
||||
if(moreQuotes[kind])$('reviews').append(action(label,()=>loadMoreQuotes(kind)));
|
||||
}
|
||||
$('kan').replaceChildren();
|
||||
const colors=['#00b8da','#e0357c','#efb700','#edebe6','#e0642a','#48b072'];
|
||||
Object.entries(board.states).forEach(([state,title],index)=>{
|
||||
const column=node('section',undefined,'col');column.dataset.state=state;column.style.setProperty('--cc',colors[index]);
|
||||
const orders=board.orders.filter(o=>o.state===state);
|
||||
// Finished orders are a recent window, not the whole history: say so rather
|
||||
// than let the count read as an all-time total.
|
||||
const count=state==='fin'&&board.finished_total>orders.length
|
||||
? orders.length+' de '+board.finished_total : String(orders.length);
|
||||
column.append(node('h2',title+' · '+count));
|
||||
for(const order of orders){
|
||||
const card=node('article',undefined,'cd');card.draggable=true;card.dataset.order=order.id;
|
||||
card.append(node('b','#'+order.number+' · '+(order.payment?.provider==='fake'?'Pago local':'Pago')),node('p',order.snapshot.customer.mail,'meta'),node('p',money(order.snapshot.total_cents)));
|
||||
const to=order.snapshot.destination;
|
||||
card.append(node('p',to?'Entrega: '+to.recipient+' · '+to.street+', '+to.number+(to.complement?' '+to.complement:'')+
|
||||
' · '+to.district+' · '+to.city+'/'+to.state+' · CEP '+to.postal_code:'Retirada em Franca','meta'));
|
||||
for(const item of order.snapshot.items){
|
||||
card.append(node('p',item.mode+' · '+item.billed_metres+' m · nota '+item.grade,'meta'));
|
||||
productionLines(card,item);
|
||||
}
|
||||
printFiles(card,order);
|
||||
const actions=node('div',undefined,'actions');files(actions,order.snapshot.items);
|
||||
const artwork=node('div');
|
||||
actions.append(action('Arquivos de produção',()=>artworkPanel(order,artwork)));
|
||||
actions.append(action('Histórico',async()=>{const rows=await api('/orders/'+order.id+'/history');alert(rows.map(r=>board.states[r.from_state]+' → '+board.states[r.to_state]+' · '+r.operator+(r.reason?' · '+r.reason:'')).join('\n')||'Pedido recebido.');}));
|
||||
for(const next of board.transitions[state])actions.append(action('→ '+board.states[next],()=>move(order,next)));
|
||||
card.append(actions,artwork);card.ondragstart=e=>e.dataTransfer.setData('text/plain',order.id);column.append(card);
|
||||
}
|
||||
column.ondragover=e=>{e.preventDefault();column.classList.add('alvo');};column.ondragleave=()=>column.classList.remove('alvo');
|
||||
column.ondrop=async e=>{e.preventDefault();column.classList.remove('alvo');const order=board.orders.find(o=>o.id===e.dataTransfer.getData('text/plain'));if(order)try{await move(order,state);}catch(error){$('status').textContent=error.message;}};
|
||||
$('kan').append(column);
|
||||
});
|
||||
$('events').textContent=board.events.map(e=>e.provider+' · '+e.payload.event+' · pedido #'+e.payload.number+' · '+(e.delivered_at?'registrado localmente':'pendente')+' · tentativas '+e.attempts).join('\n')||'Nenhum evento.';
|
||||
function card(order){
|
||||
const c=node('button',undefined,'card');c.type='button';c.dataset.card=order.id;c.draggable=true;
|
||||
if(order.id===openOrder)c.classList.add('open');
|
||||
const top=node('div',undefined,'row');
|
||||
top.append(node('span','#'+order.number,'num'),node('span',ago(order.updated_at),'age'));
|
||||
const tags=node('div',undefined,'tags');
|
||||
for(const item of order.snapshot.items)tags.append(node('span',SHORT[item.mode]+' · '+metres(item.billed_metres),'tag'));
|
||||
const foot=node('div',undefined,'foot');const p=printState(order);
|
||||
const status=node('span',p.text,p.tone);status.style.display='flex';status.style.alignItems='center';status.style.gap='5px';
|
||||
if(p.tone==='ok')status.prepend(icon(ICON_OK));if(p.tone==='warn')status.prepend(icon(ICON_WARN));
|
||||
const dest=order.snapshot.destination;
|
||||
foot.append(status,node('span',dest?'Entrega · '+dest.state:'Retirada','where'));
|
||||
c.append(top,node('div',order.snapshot.customer.mail,'cust'),tags,foot);
|
||||
if(['rec','tra','fil','imp'].includes(order.state)&&Date.now()-new Date(order.updated_at)>LATE_HOURS*3600000)
|
||||
c.append(node('div','Parado há mais de '+LATE_HOURS+' h','late'));
|
||||
c.onclick=()=>openPanel(order.id);
|
||||
c.ondragstart=e=>{
|
||||
e.dataTransfer.setData('text/plain',order.id);
|
||||
const forward=board.transitions[order.state].filter(s=>s!=='cor');
|
||||
for(const col of document.querySelectorAll('.col[data-state]'))col.classList.toggle('allowed',forward.includes(col.dataset.state));
|
||||
say(forward.length?'Mover para '+forward.map(s=>board.states[s]).join(' ou '):'Pedido finalizado',false,60000);
|
||||
};
|
||||
c.ondragend=()=>{for(const col of document.querySelectorAll('.col'))col.classList.remove('allowed','target');say('');};
|
||||
return c;
|
||||
}
|
||||
async function move(order,state){
|
||||
let reason='';if(state==='cor'){reason=prompt('Motivo da correção:');if(!reason)return;}
|
||||
await api('/orders/'+order.id+'/move',{state,version:order.version,reason});await load();
|
||||
async function loadOlderFinished(){
|
||||
const shown=board.orders.filter(o=>o.state==='fin').sort((a,b)=>a.created_at<b.created_at?-1:1);
|
||||
const oldest=shown[0];const params=new URLSearchParams({limit:'50'});
|
||||
if(oldest){params.set('before_created_at',oldest.created_at);params.set('before_id',oldest.id);}
|
||||
const page=await api('/orders/finished?'+params);
|
||||
const known=new Set(board.orders.map(o=>o.id));
|
||||
const fresh=page.orders.filter(o=>!known.has(o.id));
|
||||
olderFinished.push(...fresh);board.orders.push(...fresh);finishedMore=page.has_more;renderBoard();
|
||||
}
|
||||
async function move(order,state,reason=''){
|
||||
await api('/orders/'+order.id+'/move',{state,version:order.version,reason});
|
||||
await load();
|
||||
}
|
||||
$('login').onsubmit=async e=>{e.preventDefault();try{await api('/login',{email:$('email').value,password:$('password').value});await load();}catch(error){$('status').textContent=error.message;}finally{$('password').value='';}};
|
||||
$('logout').onclick=async()=>{await api('/logout',{});for(const key of Object.keys(localStorage))if(key.startsWith('dtf-'))localStorage.removeItem(key);location.reload();};
|
||||
$('refresh').onclick=load;
|
||||
load();
|
||||
|
||||
async function artworkPanel(order,container){
|
||||
container.replaceChildren();
|
||||
const revisions=await api('/orders/'+order.id+'/files');
|
||||
for(const file of revisions){
|
||||
const row=node('p',(file.kind==='final'?'Final':'Correção do cliente')+' · item '+(file.item_index+1)+' · '+file.name+' · '+(file.expired?'expirado':file.active?'atual':'substituído'),'meta');
|
||||
row.append(node('p',file.note));
|
||||
if(!file.expired)row.append(action('Baixar '+file.name,download(file.upload_id)));
|
||||
container.append(row);
|
||||
// ---- Order panel ---------------------------------------------------------
|
||||
|
||||
function openPanel(id){openOrder=id;const order=board.orders.find(o=>o.id===id);$('drawer').hidden=false;renderPanel(order);renderBoard();}
|
||||
function closePanel(){openOrder=null;$('drawer').hidden=true;$('panel').replaceChildren();if(board)renderBoard();}
|
||||
$('scrim').onclick=closePanel;
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&openOrder)closePanel();});
|
||||
|
||||
function layoutPreview(production){
|
||||
const ns='http://www.w3.org/2000/svg', w=Number(production.film_width_cm), h=Math.max(1,Number(production.height_cm));
|
||||
const svg=document.createElementNS(ns,'svg');svg.setAttribute('viewBox','0 0 '+w+' '+h);svg.setAttribute('role','img');
|
||||
svg.setAttribute('aria-label','Montagem: '+production.placements.length+' peças em '+w+' × '+h+' cm');
|
||||
const film=document.createElementNS(ns,'rect');film.setAttribute('width',w);film.setAttribute('height',h);film.setAttribute('fill','#0F1113');svg.append(film);
|
||||
const colors=['#2BB6D9','#E0508F','#E9C23A','#6FD19C','#FF8B2B','#A78BFA'];
|
||||
for(const p of production.placements){const r=document.createElementNS(ns,'rect');
|
||||
r.setAttribute('x',p.x_cm);r.setAttribute('y',p.y_cm);r.setAttribute('width',p.width_cm);r.setAttribute('height',p.length_cm);
|
||||
r.setAttribute('fill',colors[p.source_index%colors.length]);r.setAttribute('fill-opacity','.55');svg.append(r);}
|
||||
return svg;
|
||||
}
|
||||
function renderPanel(order){
|
||||
const token=++openToken, panel=$('panel');panel.dataset.order=order.id;
|
||||
const color=STAGE_COLORS[order.state];
|
||||
const head=node('div',undefined,'panel-head');
|
||||
const row=node('div',undefined,'row');
|
||||
const stage=node('span',board.states[order.state],'stage');stage.style.setProperty('--c',color);
|
||||
const close=node('button',undefined,'close');close.type='button';close.setAttribute('aria-label','Fechar');close.append(icon(['M6 6l12 12M18 6L6 18']));close.onclick=closePanel;
|
||||
row.append(node('span','#'+order.number,'num'),stage,node('span','pago '+when(order.created_at),'faint'),close);
|
||||
const steps=node('ol',undefined,'steps');steps.setAttribute('aria-label','Etapas');
|
||||
const at=FLOW.indexOf(order.state);
|
||||
for(const [i,s] of FLOW.entries()){const li=node('li',board.states[s]);li.style.setProperty('--c',STAGE_COLORS[s]);
|
||||
if(at>=0&&i<at)li.classList.add('done');if(i===at)li.classList.add('now');steps.append(li);}
|
||||
head.append(row,steps,moves(order));
|
||||
|
||||
const body=node('div',undefined,'panel-body');
|
||||
const s=order.snapshot, dest=s.destination;
|
||||
const facts=node('section',undefined,'facts');
|
||||
const who=node('div');who.append(node('p','Cliente','eyebrow'),node('b',s.customer.mail),node('span','CNPJ '+s.customer.cnpj+' · '+s.customer.zap));
|
||||
const where=node('div');where.append(node('p','Entrega','eyebrow'),node('b',dest?dest.recipient:'Retirada em Franca'),
|
||||
node('span',dest?dest.street+', '+dest.number+(dest.complement?' '+dest.complement:'')+' · '+dest.district+' · '+dest.city+'/'+dest.state+' · CEP '+dest.postal_code:'Cliente retira na fábrica'));
|
||||
facts.append(who,where);
|
||||
const items=node('section');items.append(node('p','Itens e arquivos de impressão','eyebrow'));
|
||||
const itemsList=node('div');itemsList.style.display='flex';itemsList.style.flexDirection='column';itemsList.style.gap='10px';
|
||||
s.items.forEach((item,index)=>itemsList.append(itemCard(order,item,index)));
|
||||
items.append(itemsList);
|
||||
const total=node('p',undefined,'muted');total.append('Total pago: ',node('b',money(s.total_cents)));items.append(total);
|
||||
const finals=node('section',undefined,'finals');finals.append(node('p','Arquivos finais','eyebrow'),node('p','Carregando…','faint'));
|
||||
const history=node('section');history.append(node('p','Histórico','eyebrow'),node('p','Carregando…','faint'));
|
||||
body.append(facts,items,finals,history);
|
||||
panel.replaceChildren(head,body);
|
||||
|
||||
api('/orders/'+order.id+'/files').then(rows=>{if(token===openToken)finalsSection(order,rows,finals);}).catch(e=>say(e.message,true));
|
||||
api('/orders/'+order.id+'/history').then(rows=>{if(token===openToken)historySection(order,rows,history);}).catch(e=>say(e.message,true));
|
||||
}
|
||||
function moves(order){
|
||||
const box=node('div');box.style.display='flex';box.style.flexDirection='column';box.style.gap='10px';
|
||||
const row=node('div',undefined,'moves');
|
||||
const reason=node('form',undefined,'reason');reason.hidden=true;
|
||||
for(const next of board.transitions[order.state]){
|
||||
if(next==='cor'){
|
||||
const b=node('button','Pedir correção','btn danger');b.type='button';b.dataset.move='cor';
|
||||
b.onclick=()=>{reason.hidden=!reason.hidden;backForm.hidden=true;if(!reason.hidden)reason.querySelector('textarea').focus();};row.append(b);
|
||||
}else{
|
||||
const b=button('Mover para '+board.states[next],()=>move(order,next),'btn primary');b.dataset.move=next;row.append(b);
|
||||
}
|
||||
}
|
||||
if(!['rec','tra','cor'].includes(order.state))return;
|
||||
const form=node('form');
|
||||
form.append(node('p','Enviar um conjunto final completo. Pode haver várias partes por item. Um novo conjunto substitui o anterior.'));
|
||||
// A generated print file reproduces the approved layout; offer it first, and
|
||||
// keep the upload for items that need hand preparation. Not after a
|
||||
// correction: the file was made from artwork the customer has replaced.
|
||||
// Undo a mistaken move: one stage back, internal reason, no customer message.
|
||||
const previous=board.back?.[order.state];
|
||||
const backForm=node('form',undefined,'reason');backForm.hidden=true;
|
||||
if(previous){
|
||||
const b=node('button','Voltar para '+board.states[previous],'btn ghost');b.type='button';b.dataset.back=previous;
|
||||
b.onclick=()=>{backForm.hidden=!backForm.hidden;reason.hidden=true;if(!backForm.hidden)backForm.querySelector('input').focus();};
|
||||
row.append(b);
|
||||
const why=node('input');why.placeholder='Motivo';why.setAttribute('aria-label','Motivo do retorno');why.required=true;why.maxLength=1000;
|
||||
const go=node('button','Voltar para '+board.states[previous],'btn');
|
||||
backForm.append(why,go);
|
||||
backForm.onsubmit=async e=>{e.preventDefault();go.disabled=true;try{await move(order,previous,why.value.trim());}catch(error){say(error.message,true);go.disabled=false;}};
|
||||
}
|
||||
if(!row.children.length)row.append(node('span','Pedido concluído.','muted'));
|
||||
const text=node('textarea');text.placeholder='Motivo da correção';text.setAttribute('aria-label','Motivo da correção');text.maxLength=1000;text.required=true;
|
||||
const confirm=node('button','Enviar para correção','btn danger');
|
||||
reason.append(text,confirm);
|
||||
reason.onsubmit=async e=>{e.preventDefault();confirm.disabled=true;try{await move(order,'cor',text.value.trim());}catch(error){say(error.message,true);confirm.disabled=false;}};
|
||||
box.append(row,reason,backForm);return box;
|
||||
}
|
||||
const PRINT_TEXT={pending:'Na fila para gerar',rendering:'Gerando arquivo…',ready:'PDF gerado',manual:'Preparar à mão',failed:'Falhou ao gerar'};
|
||||
function itemCard(order,item,index){
|
||||
const row=(order.print_files||[]).find(r=>r.item_index===index);
|
||||
const box=node('div',undefined,'item');
|
||||
if(row&&(row.status==='manual'||row.status==='failed'))box.classList.add('attention');
|
||||
const preview=node('div',undefined,'preview');
|
||||
if(item.production?.placements)preview.append(layoutPreview(item.production));
|
||||
const body=node('div',undefined,'body');
|
||||
const title=node('div',undefined,'title');title.append(node('b',PRODUCT[item.mode]),node('span',metres(item.billed_metres),'mono'));
|
||||
const spec=item.production;
|
||||
const line=spec?(spec.placements.length+' peças em '+spec.film_width_cm+' × '+spec.height_cm+' cm · nota '+item.grade):('nota '+item.grade);
|
||||
body.append(title,node('div',line,'line'));
|
||||
const file=node('div',undefined,'file');
|
||||
if(row){
|
||||
const tone=row.status==='ready'?'ok':row.status==='manual'||row.status==='failed'?'warn':'muted';
|
||||
const label=node('span',PRINT_TEXT[row.status]+(row.status==='ready'&&row.name?' · '+row.name:'')+(row.status==='manual'&&row.detail.reason?': '+row.detail.reason:''),tone);
|
||||
file.append(tone==='ok'?icon(ICON_OK):tone==='warn'?icon(ICON_WARN):'',label);
|
||||
if(row.status==='ready'&&row.detail){body.append(file,node('div',[row.detail.film_width_cm+' × '+row.detail.height_cm+' cm',
|
||||
row.detail.min_dpi?'menor resolução '+row.detail.min_dpi+' DPI':'',row.detail.vector_sources?'PDF vetorial':''].filter(Boolean).join(' · '),'line'));}
|
||||
else body.append(file);
|
||||
}else body.append(node('div','Arquivo de impressão ainda não gerado','line'));
|
||||
const buttons=node('div',undefined,'buttons');
|
||||
if(row?.status==='ready')buttons.append(button('Baixar PDF',download(row.upload_id)));
|
||||
item.uploads.forEach((uid,i)=>buttons.append(button(item.uploads.length>1?'Original '+(i+1):'Baixar original',download(uid),'btn ghost')));
|
||||
if(spec?.placements)buttons.append(button('Manifesto',()=>manifest(spec),'btn ghost'));
|
||||
if(['rec','tra'].includes(order.state)&&(!row||row.status==='manual'||row.status==='failed'))
|
||||
buttons.append(button('Gerar novamente',async()=>{await api('/orders/'+order.id+'/print-files',{});await load();},'btn ghost'));
|
||||
body.append(buttons);
|
||||
box.append(preview,body);return box;
|
||||
}
|
||||
function finalsSection(order,revisions,section){
|
||||
section.replaceChildren(node('p','Arquivos finais','eyebrow'));
|
||||
for(const file of revisions){
|
||||
const rev=node('div',undefined,'rev');
|
||||
rev.append(node('b',(file.kind==='final'?'Final':'Correção do cliente')+' · item '+(file.item_index+1)+' · '+file.name),
|
||||
node('span',(file.expired?'expirado':file.active?'atual':'substituído')+' · '+when(file.created_at)+' · '+file.note,'faint'));
|
||||
if(!file.expired){const b=button('Baixar',download(file.upload_id),'btn ghost');b.style.alignSelf='flex-start';rev.append(b);}
|
||||
section.append(rev);
|
||||
}
|
||||
if(!['rec','tra','cor'].includes(order.state)){if(!revisions.length)section.append(node('p','Nenhum arquivo final registrado.','faint'));return;}
|
||||
// A generated print file reproduces the approved layout: offer it first. Not
|
||||
// after a customer correction, which replaced the artwork it was made from.
|
||||
const corrected=revisions.some(file=>file.kind==='correction');
|
||||
const form=node('form');
|
||||
const inputs=order.snapshot.items.map((item,index)=>{
|
||||
const label=node('label','Item '+(index+1)+' · '+item.mode);label.style.display='block';
|
||||
const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index;input.style.width='100%';
|
||||
const slot=node('label',undefined,'slot');slot.append('Item '+(index+1)+' · '+PRODUCT[item.mode]);
|
||||
const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index;
|
||||
const generated=corrected?null:(order.print_files||[]).find(r=>r.item_index===index&&r.status==='ready');
|
||||
if(generated){
|
||||
const use=node('input');use.type='checkbox';use.checked=true;input.required=false;input.hidden=true;
|
||||
const use=node('input');use.type='checkbox';use.checked=true;use.dataset.useGenerated=index;input.required=false;input.hidden=true;
|
||||
use.onchange=()=>{input.hidden=use.checked;input.required=!use.checked;};
|
||||
const choice=node('label',' Usar o arquivo gerado ('+generated.name+')');choice.prepend(use);choice.style.display='block';
|
||||
label.append(choice);input.generated=()=>use.checked?generated.upload_id:null;
|
||||
const choice=node('label',undefined,'check');choice.append(use,'Usar o PDF gerado ('+generated.name+')');
|
||||
slot.append(choice);input.generated=()=>use.checked?generated.upload_id:null;
|
||||
}
|
||||
label.append(input);form.append(label);return input;});
|
||||
const note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000;note.style.width='100%';form.append(note);
|
||||
const check=node('input');check.type='checkbox';check.required=true;const label=node('label','Conferi estes arquivos finais para impressão manual.');label.prepend(check);form.append(label);
|
||||
const submit=node('button','Aprovar arquivos finais');submit.type='submit';form.append(submit);
|
||||
slot.append(input);form.append(slot);return input;
|
||||
});
|
||||
const note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000;
|
||||
const check=node('input');check.type='checkbox';check.required=true;check.dataset.confirm='';
|
||||
const confirm=node('label',undefined,'check');confirm.append(check,'Arquivos conferidos');
|
||||
const submit=node('button','Aprovar arquivos finais','btn primary');
|
||||
form.append(note,confirm,submit);
|
||||
form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{
|
||||
const refs=[];
|
||||
for(const [index,input] of inputs.entries()){
|
||||
const generated=input.generated?.();
|
||||
if(generated){refs.push({item_index:index,upload_id:generated});continue;}
|
||||
for(const file of input.files){
|
||||
const uid=await dtfUpload(file,{api,startPath:'/orders/'+order.id+'/uploads',scope:'operator:'+order.id+':'+order.version,progress:text=>$('status').textContent=text});
|
||||
refs.push({item_index:index,upload_id:uid});
|
||||
}}
|
||||
const uid=await dtfUpload(file,{api,startPath:'/orders/'+order.id+'/uploads',scope:'operator:'+order.id+':'+order.version,progress:text=>say(text)});
|
||||
refs.push({item_index:index,upload_id:uid});
|
||||
}
|
||||
}
|
||||
await api('/orders/'+order.id+'/final-files',{version:order.version,files:refs,note:note.value});
|
||||
await load();
|
||||
}catch(error){$('status').textContent=error.message;submit.disabled=false;}};
|
||||
container.append(form);
|
||||
}catch(error){say(error.message,true);submit.disabled=false;}};
|
||||
section.append(form);
|
||||
}
|
||||
function historySection(order,rows,section){
|
||||
const list=node('ol',undefined,'timeline');
|
||||
for(const r of [...rows].reverse()){
|
||||
const li=node('li');li.style.setProperty('--c',STAGE_COLORS[r.to_state]);
|
||||
const text=node('div');text.append(node('b',r.back?'Voltou para '+board.states[r.to_state]:board.states[r.from_state]+' → '+board.states[r.to_state]));
|
||||
if(r.reason)text.append(node('span',' · '+r.reason,'muted'));
|
||||
text.append(node('small',when(r.created_at)+' · '+r.operator));li.append(text);list.append(li);
|
||||
}
|
||||
const first=node('li');first.style.setProperty('--c','var(--rec)');
|
||||
const t=node('div');t.append(node('b','Pedido recebido e pago'),node('small',when(order.created_at)+' · '+(board.environment==='local'&&order.payment?.provider==='fake'?'pagamento de teste':'Site')));
|
||||
first.append(t);list.append(first);
|
||||
section.replaceChildren(node('p','Histórico','eyebrow'),list);
|
||||
}
|
||||
|
||||
// ---- Quotes --------------------------------------------------------------
|
||||
|
||||
function quoteMetres(q){return (q.approved||q.draft).items.reduce((s,i)=>s+Number(i.billed_metres||i.metres),0);}
|
||||
let quoteRows=[], quoteTotal=0, quotePage=1, quoteSize=20, currentQuote=null, quoteLoading=0;
|
||||
async function loadQuotes(){
|
||||
const token=++quoteLoading;
|
||||
const page=await api('/quotes?'+new URLSearchParams({kind:quoteKind,limit:String(quoteSize),offset:String((quotePage-1)*quoteSize)}));
|
||||
if(token!==quoteLoading)return;
|
||||
quoteRows=page.quotes;quoteTotal=page.total;
|
||||
if(quotePage>1&&!quoteRows.length&"eTotal){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)&¤tQuote.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'&"e.draft.items.some(item=>item.production?.version!==2)){
|
||||
content.append(node('p','Montagem antiga. Solicite uma nova cotação ao cliente.','muted'));
|
||||
card.append(head,content);return card;
|
||||
}
|
||||
if(quote.status!=='pending_review'){
|
||||
content.append(node('p',quote.status==='expired'?'Cotação expirada. O cliente precisa solicitar outra.':
|
||||
'Aprovada: '+money(quote.approved.total_cents)+' · aguardando pagamento','muted'));
|
||||
for(const item of quote.draft.items)content.append(quoteItem(item,null));
|
||||
card.append(head,content);return card;
|
||||
}
|
||||
const form=node('form');form.style.display='contents';
|
||||
const edits=quote.draft.items.map(item=>{const fields={};content.append(quoteItem(item,fields));
|
||||
return ()=>({...item,metres:fields.metres.value,grade:Number(fields.grade.value)});});
|
||||
const check=node('input');check.type='checkbox';check.required=true;
|
||||
const confirm=node('label',undefined,'check');confirm.append(check,'Arquivos, metragem e nota conferidos');
|
||||
const submit=node('button','Aprovar cotação','btn primary');
|
||||
foot.append(confirm,submit);
|
||||
form.append(content,foot);
|
||||
form.onsubmit=async e=>{e.preventDefault();submit.disabled=true;
|
||||
try{const approved=await api('/quotes/'+quote.id+'/approve',{items:edits.map(fn=>fn())});
|
||||
// Keep showing the quote just approved while its list refreshes.
|
||||
currentQuote={...quote,approved,status:'approved'};say('Cotação aprovada.');await load();}
|
||||
catch(error){say(error.message,true);submit.disabled=false;}};
|
||||
card.append(head,form);return card;
|
||||
}
|
||||
function quoteItem(item,fields){
|
||||
const box=node('div',undefined,'item');
|
||||
const preview=node('div',undefined,'preview');preview.style.width='160px';
|
||||
if(item.production?.placements)preview.append(layoutPreview(item.production));
|
||||
const body=node('div',undefined,'body');
|
||||
const title=node('div',undefined,'title');
|
||||
const quality=item.quality_status==='ok'?'qualidade ok':item.quality_status==='warning'?'ressalva '+(item.quality_acknowledged?'aceita':'não aceita'):'qualidade não verificada';
|
||||
title.append(node('b',PRODUCT[item.mode]),node('span',quality+' · nota '+item.grade,item.quality_status==='ok'?'ok':'warn'));
|
||||
body.append(title);
|
||||
if(item.production)body.append(node('div',item.production.sources.length+' arquivo(s) · '+item.production.placements.length+' peças · '+
|
||||
item.production.film_width_cm+' × '+item.production.height_cm+' cm','line'));
|
||||
if(fields){
|
||||
const grid=node('div',undefined,'fields');
|
||||
const asked=node('label','Metros do cliente');const a=node('input');a.value=item.metres;a.readOnly=true;asked.append(a);
|
||||
const m=node('label','Metros conferidos');fields.metres=node('input');Object.assign(fields.metres,{type:'number',min:'0.001',max:'12000',step:'any',value:item.metres,required:true});m.append(fields.metres);
|
||||
const g=node('label','Nota conferida');fields.grade=node('input');Object.assign(fields.grade,{type:'number',min:'0',max:'100',step:'1',value:item.grade,required:true});g.append(fields.grade);
|
||||
grid.append(asked,m,g);body.append(grid);
|
||||
}
|
||||
const buttons=node('div',undefined,'buttons');
|
||||
item.uploads.forEach((uid,i)=>buttons.append(button(item.uploads.length>1?'Original '+(i+1):'Baixar original',download(uid),'btn ghost')));
|
||||
if(item.production?.placements)buttons.append(button('Manifesto',()=>manifest(item.production),'btn ghost'));
|
||||
body.append(buttons);box.append(preview,body);return box;
|
||||
}
|
||||
|
||||
// ---- Payments and integrations ------------------------------------------
|
||||
|
||||
const tinyResult=new URLSearchParams(location.search).get('tiny');
|
||||
const tinyNotice=tinyResult?(tinyResult==='connected'?' · Tiny conectado.':' · Não foi possível conectar o Tiny. Tente de novo.'):'';
|
||||
if(tinyResult)history.replaceState(null,'',location.pathname);
|
||||
function tinyHeader(){
|
||||
const t=board.tiny||{};const el=$('tiny');
|
||||
if(!t.configured){el.textContent='';return;}
|
||||
const trouble=!t.connected||t.problem;
|
||||
el.textContent=!t.connected?'Tiny não conectado':t.problem?'Tiny: verificar conexão':'Tiny conectado';
|
||||
el.style.setProperty('--dot',trouble?'var(--warn)':'var(--fin)');
|
||||
}
|
||||
const TINY_PROBLEMS={refused:'O Tiny recusou a renovação da conexão. Conecte de novo.',
|
||||
expired:'A conexão com o Tiny expirou. Conecte de novo.',
|
||||
'renewal-failing':'A última renovação falhou; o sistema tenta de novo sozinho.',
|
||||
expiring:'A conexão expira em breve e não está sendo renovada.'};
|
||||
function tinyDetail(t){
|
||||
if(!t.connected_by)return 'Conecte com uma conta do Tiny.';
|
||||
const parts=[t.connected_by+' · conectado em '+when(t.connected_at),'renovado em '+when(t.renewed_at),
|
||||
t.offline?'sem expiração diária':t.expires_at?'válido até '+when(t.expires_at):'',
|
||||
'envio de pedidos '+(t.orders_enabled?'ativo':'desativado')].filter(Boolean);
|
||||
const problem=TINY_PROBLEMS[t.problem];
|
||||
return (problem?problem+(t.failed_at&&t.problem!=='expired'?' (falha em '+when(t.failed_at)+')':'')+' · ':'')+parts.join(' · ');
|
||||
}
|
||||
function integration(name,state,tone,text,actions=[]){
|
||||
const box=node('div',undefined,'integration');
|
||||
const head=node('div',undefined,'head');const dot=node('span',undefined,'dot');
|
||||
const colors={ok:['var(--fin)','var(--ok)'],warn:['var(--warn)','var(--warn)'],off:['#5B6168','var(--muted)']}[tone];
|
||||
dot.style.setProperty('--dot',colors[0]);const em=node('em',state);em.style.setProperty('--dotx',colors[1]);
|
||||
head.append(dot,node('b',name),em);box.append(head,node('p',text));
|
||||
if(actions.length){const b=node('div',undefined,'buttons');b.append(...actions);box.append(b);}
|
||||
return box;
|
||||
}
|
||||
// The page bar under every list: page size, range, first/previous/page/next/last.
|
||||
const PAGE_SIZES=[10,20,50,100];
|
||||
function pager({page,size,total,noun,onPage,onSize,compact=false}){
|
||||
const pages=Math.max(1,Math.ceil(total/size));
|
||||
const bar=node('div',undefined,'pager'+(compact?' compact':''));
|
||||
const sizeLabel=node('label',undefined,'pager-size');
|
||||
const select=node('select');select.setAttribute('aria-label','Itens por página');
|
||||
for(const n of PAGE_SIZES){const o=node('option',String(n));o.value=String(n);o.selected=n===size;select.append(o);}
|
||||
select.onchange=()=>onSize(Number(select.value));
|
||||
sizeLabel.append('Mostrar',select,'por página');
|
||||
const nav=node('div',undefined,'pager-nav');
|
||||
const from=total?(page-1)*size+1:0, to=Math.min(total,page*size);
|
||||
const go=(label,aria,target,disabled)=>{const b=node('button',label,'pg');b.type='button';b.setAttribute('aria-label',aria);b.disabled=disabled;b.onclick=()=>onPage(target);return b;};
|
||||
const pageLabel=node('label',undefined,'pager-page');
|
||||
const input=node('input');input.type='number';input.min='1';input.max=String(pages);input.value=String(page);input.setAttribute('aria-label','Página');
|
||||
input.onchange=()=>onPage(Math.min(pages,Math.max(1,Math.round(Number(input.value))||1)));
|
||||
pageLabel.append(compact?'':'Página',input,'de '+pages);
|
||||
nav.append(go('«','Primeira página',1,page<=1),go('‹','Página anterior',page-1,page<=1),pageLabel,
|
||||
go('›','Próxima página',page+1,page>=pages),go('»','Última página',pages,page>=pages));
|
||||
bar.append(sizeLabel,node('span',(compact?'':'Mostrando ')+from+' a '+to+' de '+total+(compact?'':' '+noun),'pager-range'),nav);
|
||||
return bar;
|
||||
}
|
||||
function chips(container,options,current,pick){
|
||||
container.replaceChildren(...options.map(([key,label])=>{
|
||||
const b=node('button',label,'chip');b.type='button';b.setAttribute('aria-pressed',String(current===key));
|
||||
b.onclick=()=>pick(key);return b;}));
|
||||
}
|
||||
function moreButton(container,has,fn){
|
||||
if(!has)return;
|
||||
const b=button('Carregar mais',fn,'btn ghost');b.style.alignSelf='flex-start';b.style.marginTop='8px';container.append(b);
|
||||
}
|
||||
|
||||
// Payments needing a person: paged, open or resolved (history).
|
||||
let issueState='open', issueRows=[], issueTotal=0, issuePage=1, issueSize=20, issueLoading=0;
|
||||
async function loadIssues(){
|
||||
const token=++issueLoading;
|
||||
const page=await api('/payment-events?'+new URLSearchParams({state:issueState,limit:String(issueSize),offset:String((issuePage-1)*issueSize)}));
|
||||
if(token!==issueLoading)return;
|
||||
issueRows=page.issues;issueTotal=page.total;
|
||||
if(issuePage>1&&!issueRows.length&&issueTotal){issuePage=Math.ceil(issueTotal/issueSize);return loadIssues();}
|
||||
renderIssues();
|
||||
}
|
||||
function renderIssues(){
|
||||
const filters=node('div',undefined,'chips');filters.style.marginBottom='12px';
|
||||
chips(filters,[['open','Abertos'],['resolved','Resolvidos'],['all','Todos']],issueState,key=>{issueState=key;issuePage=1;loadIssues().catch(e=>say(e.message,true));});
|
||||
const list=node('div');
|
||||
if(!issueRows.length)list.append(node('p',issueState==='resolved'?'Nenhum pagamento resolvido.':'Nenhum pagamento pendente.','faint'));
|
||||
for(const issue of issueRows){
|
||||
const box=node('div',undefined,'issue');
|
||||
if(issue.resolved_at)box.classList.add('done');
|
||||
box.append(issue.resolved_at?icon(ICON_OK):icon(ICON_WARN));box.firstChild.style.color=issue.resolved_at?'var(--ok)':'var(--warn)';
|
||||
const text=node('div',undefined,'text');
|
||||
text.append(node('b',(issue.amount_cents==null?'Pagamento sem valor informado':'Pagamento de '+money(issue.amount_cents))+': '+outcomeText(issue.outcome)),
|
||||
node('span',issue.provider+' · evento '+issue.event_id+' · cotação '+(issue.reference||'—')+' · '+when(issue.received_at)));
|
||||
if(issue.resolved_at){
|
||||
text.append(node('span','Resolvido por '+issue.resolved_by+' em '+when(issue.resolved_at)+': '+issue.resolution));
|
||||
box.append(text);
|
||||
}else{
|
||||
const form=node('form');const note=node('input');note.placeholder='Resolução';note.setAttribute('aria-label','Resolução');note.required=true;note.minLength=3;note.maxLength=1000;
|
||||
const save=node('button','Registrar resolução','btn warn');form.append(note,save);
|
||||
form.onsubmit=async e=>{e.preventDefault();save.disabled=true;
|
||||
try{await api('/payment-events/'+issue.id+'/resolve',{note:note.value});say('Resolução registrada.');await load();}
|
||||
catch(error){say(error.message,true);save.disabled=false;}};
|
||||
box.append(text,form);
|
||||
}
|
||||
list.append(box);
|
||||
}
|
||||
const bar=pager({page:issuePage,size:issueSize,total:issueTotal,noun:'pagamentos',
|
||||
onPage:n=>{issuePage=n;loadIssues().catch(e=>say(e.message,true));},
|
||||
onSize:n=>{issueSize=n;issuePage=1;loadIssues().catch(e=>say(e.message,true));}});
|
||||
$('payments').replaceChildren(filters,list,bar);
|
||||
}
|
||||
|
||||
// The integration send log: filtered and paged on the server.
|
||||
let eventFilter={provider:'',status:'',event:'',order:''}, eventRows=[], eventTotal=0, eventPage=1, eventSize=20, eventLoading=0, eventTimer=null;
|
||||
async function loadEvents(){
|
||||
const token=++eventLoading;
|
||||
const params=new URLSearchParams({limit:String(eventSize),offset:String((eventPage-1)*eventSize)});
|
||||
for(const [key,value] of Object.entries(eventFilter))if(value)params.set(key,value);
|
||||
const page=await api('/events?'+params);
|
||||
if(token!==eventLoading)return;
|
||||
eventRows=page.events;eventTotal=page.total;
|
||||
if(eventPage>1&&!eventRows.length&&eventTotal){eventPage=Math.ceil(eventTotal/eventSize);return loadEvents();}
|
||||
renderEvents();
|
||||
}
|
||||
function eventFilters(){
|
||||
const bar=$('event-filters');if(bar.dataset.ready)return;bar.dataset.ready='1';
|
||||
const select=(label,key,options)=>{
|
||||
const field=node('label',undefined,'field');field.append(label);
|
||||
const s=node('select');for(const [value,text] of options){const o=node('option',text);o.value=value;s.append(o);}
|
||||
s.onchange=()=>{eventFilter[key]=s.value;eventPage=1;loadEvents().catch(e=>say(e.message,true));};
|
||||
field.append(s);return field;
|
||||
};
|
||||
const order=node('label',undefined,'field');order.append('Pedido');
|
||||
const input=node('input');input.type='number';input.min='1';input.placeholder='Nº';input.setAttribute('inputmode','numeric');
|
||||
input.oninput=()=>{clearTimeout(eventTimer);eventTimer=setTimeout(()=>{eventFilter.order=input.value.trim();eventPage=1;loadEvents().catch(e=>say(e.message,true));},350);};
|
||||
order.append(input);
|
||||
bar.append(
|
||||
select('Destino','provider',[['','Todos'],['tiny','Tiny'],['whatsapp','WhatsApp']]),
|
||||
select('Situação','status',[['','Todas'],['delivered','Enviados'],['queued','Na fila'],['failing','Com erro']]),
|
||||
select('Evento','event',[['','Todos'],...Object.entries(EVENTS)]),
|
||||
order);
|
||||
}
|
||||
function eventResult(e){
|
||||
const receipt=e.receipt||{};
|
||||
if(e.delivered_at){
|
||||
if(receipt.status==='created')return ['Criado no Tiny nº '+receipt.tiny_number,'good'];
|
||||
if(receipt.status==='already-created')return ['Já existia no Tiny nº '+receipt.tiny_number+' (reenvio)','good'];
|
||||
if(receipt.status==='not-applicable')return ['Não se aplica ao Tiny',''];
|
||||
return ['Registrado, sem envio',''];
|
||||
}
|
||||
if(e.last_error)return ['Nova tentativa · '+e.last_error+' (tentativa '+e.attempts+')','retry'];
|
||||
return ['Na fila',''];
|
||||
}
|
||||
function renderEvents(){
|
||||
const rows=eventRows.map(e=>{
|
||||
const tr=node('tr');const [result,cls]=eventResult(e);
|
||||
tr.append(node('td',when(e.delivered_at||e.available_at),'muted'),node('td','#'+e.payload.number,'mono'),
|
||||
node('td',{tiny:'Tiny',whatsapp:'WhatsApp'}[e.provider]||e.provider),
|
||||
node('td',EVENTS[e.payload.event]||e.payload.event),node('td',result,cls));
|
||||
return tr;
|
||||
});
|
||||
if(!rows.length){const tr=node('tr');const td=node('td','Nenhum envio encontrado.','faint');td.colSpan=5;tr.append(td);rows.push(tr);}
|
||||
$('events').replaceChildren(...rows);
|
||||
$('events-more').replaceChildren(pager({page:eventPage,size:eventSize,total:eventTotal,noun:'envios',
|
||||
onPage:n=>{eventPage=n;loadEvents().catch(e=>say(e.message,true));},
|
||||
onSize:n=>{eventSize=n;eventPage=1;loadEvents().catch(e=>say(e.message,true));}}));
|
||||
}
|
||||
|
||||
function renderIntegrations(){
|
||||
const t=board.tiny||{};const cards=[];
|
||||
if(!t.configured)cards.push(integration('Tiny','Não configurado','off','Credenciais ausentes no servidor.'));
|
||||
else{
|
||||
const connect=button(t.connected?'Reconectar':'Conectar Tiny',async()=>{const r=await api('/tiny/connect',{});location.href=r.url;},t.connected?'btn ghost':'btn');
|
||||
const actions=[connect];
|
||||
if(t.connected){
|
||||
actions.unshift(button('Testar conexão',async()=>{const r=await api('/tiny/test',{});
|
||||
say(r.ok?'Conexão com o Tiny ok: pedidos, contatos e os 4 produtos conferidos.':'Tiny: '+Object.entries(r.results).filter(([,v])=>v!=='ok').map(([k,v])=>k+': '+v).join(' · '),!r.ok);}));}
|
||||
cards.push(integration('Tiny',!t.connected?'Não conectado':t.problem?'Verificar':'Conectado',
|
||||
t.connected&&!t.problem?'ok':'warn',tinyDetail(t),actions));
|
||||
}
|
||||
const mp=board.providers?.payment;
|
||||
cards.push(integration('Mercado Pago',mp==='mercadopago'?'Ativo':'Não configurado',mp==='mercadopago'?'ok':'off',
|
||||
mp==='mercadopago'?'PIX e cartão ativos.':'Aguardando credenciais.'));
|
||||
cards.push(integration('Frete','Não configurado','off','Somente retirada.'));
|
||||
cards.push(integration('WhatsApp','Não configurado','off','Mensagens registradas, sem envio.'));
|
||||
$('integrations').replaceChildren(...cards);
|
||||
eventFilters();
|
||||
}
|
||||
|
||||
// ---- Wiring --------------------------------------------------------------
|
||||
|
||||
for(const b of document.querySelectorAll('[data-tab]'))b.onclick=()=>showTab(b.dataset.tab);
|
||||
$('search').oninput=e=>{search=e.target.value.trim().toLowerCase();renderBoard();if(search)showTab('board');};
|
||||
$('login').onsubmit=async e=>{e.preventDefault();try{await api('/login',{email:$('email').value,password:$('password').value});await load();}catch(error){say(error.message,true);}finally{$('password').value='';}};
|
||||
$('logout').onclick=async()=>{await api('/logout',{});for(const key of Object.keys(localStorage))if(key.startsWith('dtf-'))localStorage.removeItem(key);location.reload();};
|
||||
$('refresh').onclick=load;
|
||||
load();
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
*{box-sizing:border-box}body{margin:0;background:#f6f7f9;color:#20252d;font:15px/1.5 system-ui,sans-serif}header{background:white;border-bottom:1px solid #ddd;padding:20px max(20px,calc((100% - 1100px)/2));display:flex;gap:24px;align-items:center;flex-wrap:wrap}.brand{color:#ef8500;font-size:24px;font-style:italic;font-weight:900}a{color:#8a4e00}main{max-width:1100px;margin:24px auto;padding:0 20px}h1{font-size:28px}h2{font-size:21px}h3{font-size:17px}.notice{background:#fff1d4;border-left:4px solid #ffa900;padding:12px 16px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:20px}.card{background:white;border:1px solid #ddd;border-radius:10px;padding:20px;margin:16px 0}.card p{overflow-wrap:anywhere}.muted{color:#596471}label{display:block;margin:10px 0}input,textarea,button{font:inherit;border:1px solid #9aa0a8;border-radius:6px;padding:10px}input:not([type=file]),textarea{width:100%}button{background:#ffa900;border-color:#ffa900;cursor:pointer}button:disabled{opacity:.6}form{margin:10px 0}.actions{display:flex;gap:10px;flex-wrap:wrap;align-items:center}#message{min-height:24px;color:#9b3800}li{margin:8px 0}.tag{background:#eef2f6;padding:5px 10px;border-radius:6px}details{margin:12px 0}[hidden]{display:none!important}
|
||||
</style></head><body>
|
||||
<header><a class="brand" href="/">DROPSTAR</a><a href="/">Enviar arte</a><span>Minha conta · DTF</span><button id="logout" hidden>Sair</button></header>
|
||||
<main><h1>Meus pedidos DTF</h1><p class="notice">Ambiente de desenvolvimento local. Pagamentos e notificações são simulados. Use apenas dados de teste.</p><p id="message" role="status"></p>
|
||||
<main><h1>Meus pedidos DTF</h1><p class="notice" id="dev-notice" hidden>Ambiente de desenvolvimento local. Pagamentos e notificações são simulados. Use apenas dados de teste.</p><p id="message" role="status"></p>
|
||||
<div id="account"></div><section id="auth" class="grid">
|
||||
<form id="login" class="card"><h2>Entrar</h2><label>E-mail <input id="email" type="email" autocomplete="username" required></label><label>Senha <input id="password" type="password" autocomplete="current-password" required></label><button>Entrar</button></form>
|
||||
<form id="register" class="card"><h2>Criar conta local</h2><label>CNPJ <input id="cnpj" required></label><label>WhatsApp <input id="phone" required></label><label>E-mail <input id="register-email" type="email" autocomplete="email" required></label><label>Senha (12 caracteres ou mais) <input id="register-password" type="password" minlength="12" maxlength="128" autocomplete="new-password" required></label><button>Criar conta</button><p class="muted">Pedidos desta sessão serão associados à sua conta. Não há envio de e-mail nem recuperação de senha nesta versão local.</p></form>
|
||||
<form id="register" class="card"><h2>Criar conta</h2><label>CNPJ <input id="cnpj" required></label><label>WhatsApp <input id="phone" required></label><label>E-mail <input id="register-email" type="email" autocomplete="email" required></label><label>Senha (12 caracteres ou mais) <input id="register-password" type="password" minlength="12" maxlength="128" autocomplete="new-password" required></label><button>Criar conta</button><p class="muted">Pedidos desta sessão serão associados à sua conta. Guarde sua senha: a recuperação por e-mail ainda não está disponível.</p></form>
|
||||
</section><div class="actions"><h2>Acompanhamento</h2><button id="refresh">Atualizar pedidos</button></div><p id="guest" class="muted"></p><section id="quotes"></section><section id="orders"></section></main>
|
||||
<script src="/privacy.js"></script><script src="/upload.js"></script><script src="/portal.js"></script></body></html>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
const $=id=>document.getElementById(id);
|
||||
const node=(tag,text)=>{const el=document.createElement(tag);if(text!==undefined)el.textContent=text;return el;};
|
||||
const money=c=>(c/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'});
|
||||
let scope,states={};
|
||||
let scope,states={},environment='';
|
||||
async function api(path,body){const r=await fetch('/api'+path,{headers:{'Content-Type':'application/json'},...(body===undefined?{}:{method:'POST',body:JSON.stringify(body)})});const d=await r.json();if(!r.ok){const error=new Error(typeof d.detail==='string'?d.detail:'Confira os campos informados.');error.status=r.status;throw error;}return d;}
|
||||
function button(text,fn){const b=node('button',text);b.onclick=async()=>{b.disabled=true;try{await fn();}catch(e){$('message').textContent=e.message;}finally{b.disabled=false;}};return b;}
|
||||
function forgetCheckout(){for(const k of ['dtf-quote','dtf-request-key','dtf-request-body'])localStorage.removeItem(k);}
|
||||
async function load(){
|
||||
try{
|
||||
scope=(await api('/session')).cart_scope;
|
||||
const session=await api('/session');scope=session.cart_scope;environment=session.environment;
|
||||
$('dev-notice').hidden=environment!=='local';
|
||||
const account=await api('/account/me');
|
||||
$('auth').hidden=!!account.customer;$('logout').hidden=!account.customer;
|
||||
$('account').textContent=account.customer?'Conta: '+account.customer.mail:'';
|
||||
@@ -17,13 +18,13 @@ async function load(){
|
||||
for(const quote of data.quotes){
|
||||
const card=node('article');card.className='card';card.append(node('h3','Cotação '+quote.id.slice(0,8)));
|
||||
card.append(node('p',quote.approved?'Total aprovado: '+money(quote.approved.total_cents):'Aguardando conferência de metragem e nota.'));
|
||||
const link=node('a','Abrir cotação no Site');link.href='/?quote='+quote.id;card.append(link);$('quotes').append(card);
|
||||
const link=node('a','Abrir cotação no Site');link.href='/carrinho?quote='+quote.id;card.append(link);$('quotes').append(card);
|
||||
}
|
||||
$('orders').replaceChildren();
|
||||
if(!data.orders.length)$('orders').append(node('p','Nenhum pedido pago nesta conta ou sessão.'));
|
||||
for(const order of data.orders){
|
||||
const card=node('article');card.className='card';card.id='order-'+order.id;
|
||||
card.append(node('h3','Pedido #'+order.number),node('p',states[order.state]+' · '+money(order.snapshot.total_cents)+' · pagamento local'));
|
||||
card.append(node('h3','Pedido #'+order.number),node('p',states[order.state]+' · '+money(order.snapshot.total_cents)+(environment==='local'?' · pagamento de teste':'')));
|
||||
card.append(node('p',new Date(order.created_at).toLocaleString('pt-BR')));
|
||||
const content=node('div');
|
||||
card.append(button('Ver detalhes e arquivos',()=>details(order.id,content)),content);$('orders').append(card);
|
||||
@@ -71,6 +72,6 @@ async function authenticate(path,body){
|
||||
}
|
||||
$('login').onsubmit=async event=>{event.preventDefault();try{await authenticate('/account/login',{email:$('email').value,password:$('password').value});}catch(e){$('message').textContent=e.message;}};
|
||||
$('register').onsubmit=async event=>{event.preventDefault();try{await authenticate('/account/register',{customer:{cnpj:$('cnpj').value,zap:$('phone').value,mail:$('register-email').value},password:$('register-password').value});}catch(e){$('message').textContent=e.message;}};
|
||||
$('logout').onclick=async()=>{try{await window.dtfForgetPrivateData();await api('/account/logout',{});location.reload();}catch(error){$('message').textContent='Não foi possível concluir a limpeza local. Feche as abas do Site e limpe os dados deste site no navegador.';}};
|
||||
$('logout').onclick=async()=>{try{await window.dtfForgetPrivateData();await api('/account/logout',{});location.reload();}catch(error){$('message').textContent='Não foi possível limpar os dados deste navegador. Feche as abas do Site e limpe os dados do site nas configurações do navegador.';}};
|
||||
$('refresh').onclick=load;
|
||||
load();
|
||||
|
||||
@@ -30,10 +30,15 @@ function pintaPedido(){
|
||||
$('itens').innerHTML = pedido.map((it,i)=>
|
||||
'<div class="item"><div class="nm2"><b>'+escapeHTML(it.tit)+'</b><span>'+escapeHTML(it.desc)+'</span></div>'+
|
||||
'<div class="vl2">'+rs(it.total)+'</div>'+
|
||||
'<button class="x" data-rmi="'+i+'" aria-label="Remover">×</button></div>').join('');
|
||||
'<button type="button" class="x" data-rmi="'+i+'" aria-label="Remover '+escapeHTML(it.tit)+'">Remover</button></div>').join('');
|
||||
$('itens').querySelectorAll('[data-rmi]').forEach(b=>b.addEventListener('click',()=>{
|
||||
pedido.splice(+b.dataset.rmi,1); pintaPedido();
|
||||
const i=+b.dataset.rmi, [item]=pedido.splice(i,1);
|
||||
pintaPedido();
|
||||
avisaDesfazer(item.tit+' saiu do carrinho.',()=>{
|
||||
pedido.splice(Math.min(i,pedido.length),0,item); pintaPedido();
|
||||
});
|
||||
}));
|
||||
$('bEsvaziar').hidden = pedido.length+(itemAtual?1:0) < 2;
|
||||
const itens = pedido.reduce((t,it)=>t+it.total,0) + (itemAtual? itemAtual.total : 0);
|
||||
const frete = entrega.tipo==='frete' && entrega.cotado ? entrega.valor : 0;
|
||||
const soma = itens + frete;
|
||||
@@ -118,4 +123,19 @@ function carrinho(){
|
||||
'o metro cai para '+rs(piso)+' — são '+rs(ganho)+' a menos neste pedido.','var(--verde)');
|
||||
}
|
||||
}
|
||||
// Removing is one click, and a wrong click is undone from the notice that follows.
|
||||
let desfazerAcao=null, desfazerTimer=null;
|
||||
function avisaDesfazer(texto,acao){
|
||||
desfazerAcao=acao; clearTimeout(desfazerTimer);
|
||||
$('desfazerTxt').textContent=texto; $('desfazer').hidden=false;
|
||||
desfazerTimer=setTimeout(fechaDesfazer,8000);
|
||||
}
|
||||
function fechaDesfazer(){ clearTimeout(desfazerTimer); desfazerAcao=null; $('desfazer').hidden=true; }
|
||||
addEventListener('dtf-page-changed',fechaDesfazer);
|
||||
$('desfazerBtn').addEventListener('click',()=>{ const acao=desfazerAcao; fechaDesfazer(); if(acao) acao(); });
|
||||
$('bEsvaziar').addEventListener('click',()=>{
|
||||
const antes=pedido.slice();
|
||||
pedido=[]; invalidaItemAtual(); pintaPedido();
|
||||
avisaDesfazer('Carrinho esvaziado.',()=>{ pedido=antes; pintaPedido(); });
|
||||
});
|
||||
function qm(t,c){ const m=$('qmsg'); m.style.display='block'; m.style.color=c||'var(--verde)'; m.innerHTML=t; }
|
||||
|
||||
30
web/site-compra.js
Normal file
30
web/site-compra.js
Normal 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();
|
||||
})();
|
||||
@@ -10,22 +10,22 @@ const fmt=b=>b<1048576?(b/1024).toFixed(0)+' KB':(b/1048576).toFixed(1).replace(
|
||||
// ── modos
|
||||
const MODOS={
|
||||
file: {tit:'Arquivo por metro', lg:'DTF têxtil · 57 cm', larg:57,
|
||||
preco:'tabela R$ 19,90/m · até R$ 14,90', multi:true, folha:true,
|
||||
preco:'R$ 14,90 a R$ 19,90 /m conforme a nota', multi:true, folha:true,
|
||||
sub:'Sua folha montada. Revisamos, corrigimos e reencaixamos antes de imprimir.',
|
||||
zt:'Arraste suas folhas montadas',
|
||||
zs:'uma ou várias · PNG, JPG, TIFF ou PDF · PSD, AI e CDR passam por tratamento'},
|
||||
avulsa:{tit:'Artes avulsas', lg:'DTF têxtil · 57 cm', larg:57,
|
||||
preco:'tabela R$ 29,90/m · até R$ 24,90', multi:true,
|
||||
preco:'R$ 24,90 a R$ 29,90 /m conforme a nota', multi:true,
|
||||
sub:'Cada arte num arquivo. Montamos a folha com o melhor encaixe.',
|
||||
zt:'Arraste suas artes aqui',
|
||||
zs:'PNG ou JPG com fundo transparente · informe a largura de cada uma'},
|
||||
uvfile:{tit:'Arquivo por metro · UV', lg:'Adesivo UV · 28,5 cm', larg:28.5,
|
||||
preco:'tabela R$ 85,90/m · até R$ 69,90', multi:true, folha:true,
|
||||
preco:'R$ 69,90 a R$ 85,90 /m conforme a nota', multi:true, folha:true,
|
||||
sub:'Sua folha de UV montada. Revisamos, corrigimos e reencaixamos antes de imprimir.',
|
||||
zt:'Arraste suas folhas de UV',
|
||||
zs:'uma ou várias · PNG, JPG, TIFF ou PDF · PSD, AI e CDR passam por tratamento'},
|
||||
uv: {tit:'Artes avulsas · UV', lg:'Adesivo UV · 28,5 cm', larg:28.5,
|
||||
preco:'tabela R$ 99,90/m · até R$ 83,90', multi:true,
|
||||
preco:'R$ 83,90 a R$ 99,90 /m conforme a nota', multi:true,
|
||||
sub:'Cada arte de UV num arquivo. Montamos a folha com o melhor encaixe.',
|
||||
zt:'Arraste suas artes de UV',
|
||||
zs:'PNG ou JPG com fundo transparente · informe a largura de cada arte'}
|
||||
|
||||
@@ -7,15 +7,36 @@
|
||||
// Por isso a nota NÃO muda aqui. E a metragem nova só existe depois que o motor
|
||||
// roda na folha de verdade, no servidor: o navegador recebe a folha fechada,
|
||||
// não as artes separadas, então não há como recalcular aqui sem inventar número.
|
||||
// Loose artworks still show the mounted sheet below; a ready sheet goes
|
||||
// straight to the cart.
|
||||
$('qOk').addEventListener('click',()=>{
|
||||
if($('qOk').disabled) return;
|
||||
if(!$('prev').classList.contains('on')){ adicionaAoCarrinho(); return; }
|
||||
const ok=$('ciente').classList.contains('on');
|
||||
qm(ok? '<b>Registrado.</b> Você confirmou que viu a ressalva de resolução.'
|
||||
: '<b>Certo.</b> Seu pedido está montado abaixo.','var(--tx)');
|
||||
$('carr').scrollIntoView({behavior:'smooth',block:'nearest'});
|
||||
: '<b>Certo.</b> Confira a folha montada abaixo.','var(--tx)');
|
||||
$('prev').scrollIntoView({behavior:'smooth',block:'start'});
|
||||
});
|
||||
|
||||
$('pOk').addEventListener('click',()=>$('carr').scrollIntoView({behavior:'smooth'}));
|
||||
// The finished item joins the order and the product page starts over empty.
|
||||
function guardaItem(){
|
||||
if(itemAtual && !itemPodeEnviar(itemAtual)){
|
||||
$('ciente').scrollIntoView({behavior:'smooth',block:'center'});
|
||||
return false;
|
||||
}
|
||||
if(itemAtual){ pedido.push(itemAtual); itemAtual=null; }
|
||||
$('atual').style.display='none'; $('carrLin').innerHTML='';
|
||||
$('foco').classList.remove('on');
|
||||
['qual','prev'].forEach(id=>$(id).classList.remove('on'));
|
||||
modo=null; artes=[]; folhas=[]; metros=0; nota=0; montagemCm=0;
|
||||
pintaPedido();
|
||||
return true;
|
||||
}
|
||||
function adicionaAoCarrinho(){
|
||||
if(!itemAtual){ $('compra').scrollIntoView({behavior:'smooth',block:'nearest'}); return; }
|
||||
if(guardaItem()) vaiPara(CARRINHO);
|
||||
}
|
||||
$('pOk').addEventListener('click',adicionaAoCarrinho);
|
||||
$('pMais').addEventListener('click',()=>$('foco').scrollIntoView({behavior:'smooth'}));
|
||||
$('pVolta').addEventListener('click',()=>{
|
||||
folhas=[]; artes=[]; reencaixado=false; pintaFolha(); pintaArtes();
|
||||
@@ -165,19 +186,7 @@ $('bCep').addEventListener('click',async()=>{
|
||||
pintaEntrega();
|
||||
});
|
||||
|
||||
$('bMais').addEventListener('click',()=>{
|
||||
if(itemAtual && !itemPodeEnviar(itemAtual)){
|
||||
$('qual').scrollIntoView({behavior:'smooth',block:'nearest'});
|
||||
return;
|
||||
}
|
||||
if(itemAtual){ pedido.push(itemAtual); itemAtual=null; }
|
||||
$('atual').style.display='none'; $('carrLin').innerHTML='';
|
||||
$('foco').classList.remove('on'); $('cards').style.display='';
|
||||
['qual','prev'].forEach(id=>$(id).classList.remove('on'));
|
||||
modo=null; artes=[]; folhas=[]; metros=0; nota=0; montagemCm=0;
|
||||
pintaPedido();
|
||||
document.getElementById('envio').scrollIntoView({behavior:'smooth'});
|
||||
});
|
||||
$('bMais').addEventListener('click',()=>{ if(guardaItem()) vaiPara('/#envio'); });
|
||||
|
||||
$('bPagar').addEventListener('click',()=>{
|
||||
if(window.dtfCheckout) window.dtfCheckout();
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
Loaded as classic scripts in the order listed there: they share one global
|
||||
scope and run top to bottom, exactly as the original did. */
|
||||
// ── escolha
|
||||
document.querySelectorAll('.ec').forEach(b=>b.addEventListener('click',()=>abrir(b.dataset.modo)));
|
||||
document.querySelectorAll('.ec').forEach(b=>b.addEventListener('click',()=>escolheProduto(b.dataset.modo)));
|
||||
// Back on the home and into the same product: the artworks sent are still there.
|
||||
function escolheProduto(m){
|
||||
if(modo===m && $('foco').classList.contains('on')) vaiPara(PAGINAS[m]);
|
||||
else abrir(m);
|
||||
}
|
||||
function abrir(m){
|
||||
modo=m; artes=[]; folhas=[]; metros=0; nota=0; reencaixado=false;
|
||||
montagemCm=0;
|
||||
@@ -13,7 +18,7 @@ function abrir(m){
|
||||
recemChegada=null; caminho='auto'; avisoCam(''); pintaCaminhos();
|
||||
$('vMt').textContent='—'; previaAoVivo(); agendaAvaliacao();
|
||||
limpaPaineis(); // o carrinho só some se o pedido estiver vazio
|
||||
$('foco').scrollIntoView({behavior:'smooth',block:'start'});
|
||||
vaiPara(PAGINAS[m]);
|
||||
}
|
||||
function pintaModo(){
|
||||
const m=modo;
|
||||
@@ -29,6 +34,7 @@ function pintaModo(){
|
||||
$('larguraFolhaPronta').textContent=String(c.larg).replace('.',',');
|
||||
$('foco2').classList.remove('sofila'); // a caixa de requisitos vale nos 4 modos
|
||||
$('montcabTit').textContent = ehFolha(m) ? 'Sua folha' : 'Montagem ao vivo';
|
||||
$('qOk').textContent = ehFolha(m) ? 'Adicionar ao carrinho' : 'Ver a folha montada';
|
||||
pintaTipoEnvio();
|
||||
}
|
||||
// O par folha montada / artes separadas fica visível e com preço nos dois lados,
|
||||
@@ -81,15 +87,16 @@ function trocaTipo(destino){
|
||||
if(!destino || modo===destino) return;
|
||||
folhas=[]; artes=[]; recemChegada=null; metros=0; nota=0; montagemCm=0;
|
||||
modo=destino;
|
||||
history.replaceState(history.state,'',PAGINAS[modo]); document.title=tituloPagina();
|
||||
$('lista').innerHTML=''; $('rA').style.display='none'; recusa([]);
|
||||
caminho='auto'; avisoCam('');
|
||||
pintaModo(); pintaCaminhos(); limpaPaineis(); agendaAvaliacao(); previaAoVivo();
|
||||
}
|
||||
$('bVoltar').addEventListener('click',()=>{
|
||||
itemAtual=null; $('atual').style.display='none'; $('carrLin').innerHTML=''; pintaPedido();
|
||||
$('foco').classList.remove('on'); $('cards').style.display='';
|
||||
itemAtual=null; modo=null; $('atual').style.display='none'; $('carrLin').innerHTML=''; pintaPedido();
|
||||
$('foco').classList.remove('on');
|
||||
['qual','prev'].forEach(id=>$(id).classList.remove('on'));
|
||||
if(!pedido.length) $('carr').classList.remove('on');
|
||||
document.getElementById('envio').scrollIntoView({behavior:'smooth'});
|
||||
vaiPara('/#envio');
|
||||
});
|
||||
|
||||
|
||||
@@ -302,6 +302,8 @@ function previaAoVivo(){
|
||||
return;
|
||||
}
|
||||
tMont=setTimeout(()=>{
|
||||
// o produto pode ter sido fechado nesses 140 ms (item foi para o carrinho)
|
||||
if(!modo) return;
|
||||
const prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && a.src);
|
||||
const W=Math.round(300*ZOOMS[zoomI]);
|
||||
$('vArea').classList.toggle('ampliado', zoomI>0);
|
||||
|
||||
19
web/site-pages.js
Normal file
19
web/site-pages.js
Normal 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;
|
||||
@@ -319,36 +319,6 @@ function sugestao(a){
|
||||
if(economiaCm<0.5) return null; // menos de meio centímetro não é notícia
|
||||
return {alvo, de:n, para:n+1, fileiras:fa-fd, cm:economiaCm};
|
||||
}
|
||||
// A cota em escala: o cliente ve a peca dentro da largura do filme, quantas cabem
|
||||
// lado a lado e quanto sobra. Numero em texto ninguem le; retangulo todo mundo entende.
|
||||
function vizPeca(a){
|
||||
const BASE=300, L=larguraFilme();
|
||||
const e0=BASE/L, alt0=a.cm*propDe(a)*e0;
|
||||
const k = alt0>132 ? 132/alt0 : 1; // tira nunca fica alta demais
|
||||
const e = e0*k;
|
||||
const n=cabemNaLinha(a.cm), pw=a.cm*e, ph=a.cm*propDe(a)*e, gp=GAP_CM*e;
|
||||
const sobra=L-(n*a.cm+(n-1)*GAP_CM);
|
||||
const ext=escapeHTML(a.f.name.split('.').pop().toUpperCase());
|
||||
let pecas='';
|
||||
for(let i=0;i<n;i++) pecas+='<div class="pc" style="width:'+pw.toFixed(1)+'px;height:'+
|
||||
ph.toFixed(1)+'px">'+(a.src?'<img src="'+a.src+'" alt="" style="transform:'+
|
||||
(a.giro?'rotate(90deg) ':'')+(a.esp?'scaleX(-1)':'')+'">':'<span>'+ext+'</span>')+'</div>';
|
||||
const Wt=(L*e+6).toFixed(1); // +6 = borda e respiro da tira
|
||||
return '<div class="viz">'+
|
||||
'<div class="vizRow">'+
|
||||
'<div class="vizH" style="height:'+ph.toFixed(1)+'px"><span>'+n1(a.cm*propDe(a))+' cm</span></div>'+
|
||||
'<div class="vizTira" style="width:'+Wt+'px">'+
|
||||
'<div class="vizL"><i></i><b>'+n1(L)+' cm de filme</b><i></i></div>'+
|
||||
'<div class="tira" style="height:'+ph.toFixed(1)+'px;gap:'+gp.toFixed(1)+'px">'+pecas+
|
||||
(sobra>0.25?'<div class="folga"><span>sobra '+n1(sobra)+' cm</span></div>':'')+
|
||||
'</div>'+
|
||||
'</div>'+
|
||||
'</div>'+
|
||||
'<div class="vizPe"><b>'+n1(a.cm)+' cm</b> de largura · <b>'+n+
|
||||
(n>1?' peças':' peça')+'</b> por fileira'+
|
||||
(sobra>0.25?' · sobram <b>'+n1(sobra)+' cm</b>':' · largura toda aproveitada')+'</div>'+
|
||||
'</div>';
|
||||
}
|
||||
const n1=v=>v.toFixed(1).replace('.',',');
|
||||
|
||||
function pintaArtes(){
|
||||
@@ -362,17 +332,20 @@ function pintaArtes(){
|
||||
}else if(a.cm>0 && a.src){
|
||||
const dpi=Math.round(dpiDe(a)), n=Math.max(6,Math.min(100,Math.round(dpi/300*100)));
|
||||
barra='<div class="qb2"><i class="'+cls(n)+'" style="width:'+n+'%"></i></div>';
|
||||
const fila=cabemNaLinha(a.cm);
|
||||
calc='<span class="'+cls(n)+'"><b>'+n+'%</b> · '+dpi+' DPI</span><span>'+
|
||||
a.q+' × '+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</span>';
|
||||
a.q+' × '+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm · '+fila+' por fileira</span>';
|
||||
const sug=sugestao(a);
|
||||
dica = sug
|
||||
? '<div class="dica"><b>Reduzindo para '+n1(sug.alvo)+' cm você economiza '+
|
||||
n1(sug.cm)+' cm de folha</b>Passam de '+sug.de+' para '+sug.para+
|
||||
' por fileira, e as suas '+a.q+' peças ocupam '+sug.fileiras+
|
||||
' fileira'+(sug.fileiras>1?'s':'')+' a menos.<button data-usar="'+i+'" data-cm="'+
|
||||
sug.alvo+'">Usar sugerido</button></div>'
|
||||
// One hint per row, on the row it is about: resolution first, since it
|
||||
// changes the price; otherwise a width that saves sheet.
|
||||
const maxCm=Math.floor(a.px/DPI_AVISA*2.54*10)/10;
|
||||
dica = dpi<DPI_AVISA && maxCm>0 && maxCm<a.cm
|
||||
? '<div class="dica av">Pequena para '+n1(a.cm)+' cm. Em '+DPI_AVISA+' DPI ela sai com até '+
|
||||
n1(maxCm)+' cm, ou envie um arquivo maior.</div>'
|
||||
: sug
|
||||
? '<div class="dica">Com '+n1(sug.alvo)+' cm cabem '+sug.para+' por fileira e a folha encolhe '+
|
||||
n1(sug.cm)+' cm.<button data-usar="'+i+'" data-cm="'+sug.alvo+'">Usar '+n1(sug.alvo)+' cm</button></div>'
|
||||
: '';
|
||||
dica = vizPeca(a) + dica;
|
||||
}
|
||||
return '<div class="art'+(a===recemChegada?' nova':'')+'"><div class="l1"><b>'+escapeHTML(a.f.name)+'</b>'+
|
||||
'<span style="color:var(--fraco);font-size:10.5px">'+
|
||||
|
||||
@@ -88,6 +88,8 @@ let tAval=null;
|
||||
function agendaAvaliacao(){
|
||||
clearTimeout(tAval);
|
||||
tAval=setTimeout(()=>{
|
||||
// o produto pode ter sido fechado nesses 220 ms (item foi para o carrinho)
|
||||
if(!modo) return;
|
||||
const r=avaliar();
|
||||
const el=$('aoVivo');
|
||||
if(!el) return;
|
||||
|
||||
136
web/site-steps.js
Normal file
136
web/site-steps.js
Normal 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
272
web/site-v2.css
Normal 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}
|
||||
}
|
||||
Reference in New Issue
Block a user