Compare commits

..

20 Commits

Author SHA1 Message Date
Cauê Faleiros
ef95dcc327 fix: the page's centred blocks keep their full width in the column layout
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m44s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m2s
Making the body a column to keep the footer at the bottom shrank every
block centred with auto margins (the account area, and two of the Site's
sections) to the width of its content. They take the full width again, up
to their max-width, exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:34:09 -03:00
Cauê Faleiros
0b86fe79fc fix: the footer stays at the bottom of the screen on short pages
All checks were successful
Build and deploy / Validate source (push) Successful in 7s
Build and deploy / Integration suite on a real stack (push) Successful in 2m51s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images (push) Successful in 56s
On a short page (sign-in, an empty cart) the footer followed the content
and the rest of the screen was left blank below it, more so with the Site
drawn at 80%. The body is now a column at least the screen's height with
the footer pushed to its end; under the 80% zoom that height is 125vh,
because the zoom scales viewport units too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:12:55 -03:00
Cauê Faleiros
24ee4e9eab feat: Minha conta with sign-in, overview, orders and account details
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 3m7s
Build and deploy / Secret scan and release gate (push) Successful in 10s
Build and deploy / Publish images (push) Successful in 1m6s
The order page becomes the customer's area, one page at four addresses:

- /conta/entrar: sign in or create an account, side by side. "Esqueci
  minha senha" points to the Dropstar WhatsApp until e-mail can be sent.
- /conta: the counts of orders waiting for payment, in production, in
  correction and finished, and the latest one.
- /conta/pedidos: every order and the cart waiting for payment in one list,
  newest first, filtered by group, order number and period, ten per page.
  The cart shows "Aguardando pagamento" and its "Pagar" goes to the PIX
  page when a PIX code is open. An order opens in place with its progress,
  items, delivery, history, files and the correction form. A guest sees
  the orders paid in this browser.
- /conta/dados: WhatsApp and a saved delivery address (the CNPJ is locked),
  e-mail and password changes, both confirmed with the current password;
  a password change signs the other devices out.

The cart fills in the account's details and saved address. New API routes
for the details, and the order list takes filters and pages and returns
the counts; only the newest unpaid quote whose files still exist is listed.
The Site's "Minha conta" and "Ver meus pedidos" point to the new addresses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:57:37 -03:00
Cauê Faleiros
223854e392 feat: the order page in the Site's look, and no search box
All checks were successful
Build and deploy / Validate source (push) Successful in 4s
Build and deploy / Integration suite on a real stack (push) Successful in 2m37s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 59s
"Meus pedidos" was still the prototype's plain page. It now has the Site's
stripe, header and footer, and its type, colours and cards: each order shows
its status, a progress line through production (or the correction the team
asked for), the items with metres and price, where it goes, and on request
the history, the files to download and the correction form. Carts waiting
for payment are listed with a button to pay. Sign-in and account creation
are side by side.

The header, stripe, menu, footer and tokens moved from index.html's inline
style to site-shell.css, which both pages load. The header's search box,
which did nothing, is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:13:35 -03:00
Cauê Faleiros
0b55ebce7f style: the Site at 80% on computers, and seconds instead of "quase pronto"
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m43s
Build and deploy / Secret scan and release gate (push) Successful in 4s
Build and deploy / Publish images (push) Successful in 58s
The text sizes were chosen looking at the Site with the browser at 80%; at
100% everything felt too big. On screens 1100 px and wider the Site is now
drawn at 80% (CSS zoom); phones and tablets keep their sizes. The time left
while reading a large file shows the seconds under 10 s ("faltam 7 s")
instead of "quase pronto".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:39:50 -03:00
Cauê Faleiros
bee7f8f245 feat: the preview of a large file says how long is left, not a percentage
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 3m2s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m0s
Reading a large PNG in the browser for its preview showed "gerando a
prévia… 87%". It now shows the time left, worked out from how long the
reading has taken so far: "faltam cerca de 25 s", "faltam cerca de 3 min",
"quase pronto". The bar still fills as it goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:18:21 -03:00
Cauê Faleiros
c5db1375de test: reload only after the cart is saved, instead of after 800 ms
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m50s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images (push) Successful in 1m2s
The browser suite reloaded the page a fixed 800 ms after the last field
was typed. The cart save waits for typing to stop and writes the files to
the browser's storage, so under load the reload came first and the cart was
not recovered ("persistent cart recovery", and once "Inspected target
navigated or closed"). It now waits until the cart is in storage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:55:19 -03:00
Cauê Faleiros
3d8bca3af7 feat: the upload bar says how long is left, not a notice before it
All checks were successful
Build and deploy / Validate source (push) Successful in 4s
Build and deploy / Integration suite on a real stack (push) Successful in 2m42s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 1m2s
The product page's notice about the upload time is gone. The cart's upload
bar now reads "Enviando seus arquivos · faltam cerca de 6 min", measured
from the upload's own speed (a range from the size for the first seconds),
with a line saying the time depends on the internet and to keep the page
open. The header and the pay button show the same time instead of a
percentage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:43:30 -03:00
Cauê Faleiros
f3fc733352 fix: the upload-time notice leaves with the files, and says it in one line
All checks were successful
Build and deploy / Integration suite on a real stack (push) Successful in 2m37s
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 56s
The notice stayed on the product page after the item went to the cart,
showing the last file's size on the next order. It is now redrawn wherever
the files are cleared and hidden when no product is open. The text is one
line: "Envio: até 8 min (501,2 MB), conforme sua internet. Mantenha a
página aberta."

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:25:50 -03:00
Cauê Faleiros
4ce44feb70 fix: mark the required fields instead of a message about the address
Some checks failed
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m46s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Has been cancelled
Removes "Complete o endereço de entrega para ir ao pagamento." The required
fields (CNPJ, WhatsApp, e-mail, and the delivery address except the
complement) carry a red asterisk, and aria-required for screen readers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:22:13 -03:00
Cauê Faleiros
01b94da36d feat: choose which cart items to pay, with a thumbnail of each
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 3m22s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m13s
Each cart item has a checkbox and a thumbnail of its first file (or its
extension when there is none). Only checked items are priced, quoted,
uploaded and paid; an unchecked item stays in the cart, dimmed, for a later
order, and stays there after the others are paid. The header count is the
whole cart; the summary counts what is being paid.

Also removes the "A conta nasce aqui…" and "Carrinho salvo neste
navegador…" texts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:57:16 -03:00
Cauê Faleiros
25fa890e92 style: one more step up for the text and icons
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m36s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m6s
Every Site text size up to 18 px grows another pixel, the Kanban's up to
14 px half a pixel, and the icons one pixel. The pay button grows to two
lines when "Pagar os N itens de uma vez" no longer fits on one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:37:20 -03:00
Cauê Faleiros
4386fc0737 style: larger text across the Site and the Kanban, and icons to match
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m47s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m5s
Most of the text was 11 to 13 px and hard to read. Every size up to 16 px
grows: by 1.5 px up to 12.5, 1 px up to 14 and 0.5 px up to 16 on the Site,
and a little less on the denser Kanban. Headings and prices keep their size.
Icons grow by 2 px so they stay in proportion with the text beside them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:26:00 -03:00
Cauê Faleiros
70a76feb23 feat: icons on the buttons, delivery, payment, status and Kanban
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m40s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 53s
- Buttons: add to cart (bag with plus), go to payment (lock), remove from
  the cart (bin).
- Delivery: a pin for pickup in Franca, a truck for home delivery.
- Payment: card and QR code on the methods, copy on the PIX code, a clock on
  its countdown, a check on the confirmation.
- Status: a check when the files are sent, check, alert, redo and cross in
  the quality list instead of Unicode symbols, and a check on finished steps.
- Kanban: a truck or pin for where an order goes, a speech bubble on the
  WhatsApp link, a download arrow on the file buttons.

All Lucide shapes drawn inline, as in the header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:13:45 -03:00
Cauê Faleiros
20982a945f feat: icons in the header and on the Montagem controls
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m50s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m18s
The header's search, account and cart get icons, with the item count on the
cart instead of "Carrinho (N)"; on phones only the icons show. The upload
zone, remove, rotate and mirror buttons used Unicode symbols that look
different, or are missing, depending on the system's fonts; they are now
Lucide icons (ISC licence) drawn inline, so nothing new is loaded and the
CSP is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:01 -03:00
Cauê Faleiros
37715ef223 feat: the server checks the grade against the files before approving
All checks were successful
Build and deploy / Validate source (push) Successful in 1m23s
Build and deploy / Integration suite on a real stack (push) Successful in 3m13s
Build and deploy / Secret scan and release gate (push) Successful in 10s
Build and deploy / Publish images (push) Successful in 1m25s
The price depends on the grade, which the browser worked out and the API
took on trust. Before a cart is approved at checkout the API now recomputes
it from the uploaded files by the Site's own rules: the pixel size in a
PNG, JPG or WebP header across the printed width (rotation included), and
the area-weighted DPI of the images placed in a PDF of up to 150 MB, 300
for vectors. Sheets take the worst grade, artworks the average. A claim more
than 2 points above the file's grade, or a discount on a file the server
cannot grade, waits for an operator, with the reason on the Kanban.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:24:37 -03:00
Cauê Faleiros
b7e2ea12d0 feat: accept large JPG and WebP artworks instead of refusing them
Above 150 MB the browser cannot read JPG or WebP in parts, so a large
artwork was refused. It is now measured from its header, graded and priced
with the discount like any other, and placed on the sheet as a full box,
which is exact for JPG. The card says there is no preview and that the team
checks the art before printing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:14:04 -03:00
Cauê Faleiros
933bd30cbd feat: an unpaid cart's files are kept 2 days, a paid order's 30
Files are uploaded before payment so the price and the security check use
the file itself, but an abandoned cart kept them for 30 days. Now a finished
upload is held 2 days, a quote waiting for review 7, an approved quote 2 more
to be paid, and the paid order keeps its originals for 30 days from upload.
A payment never starts for files that are gone; one under way holds them a
day. Files attached to an order take the order's window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:09:02 -03:00
Cauê Faleiros
eb254da501 feat: tell the customer how long the upload takes before it starts
When files are picked, the product page shows the range the upload takes
between a slow (10 Mbps) and a fast (150 Mbps) connection, and asks to keep
the page open. The cart shows the same range until the real speed is
measured, then the measured time left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:09:02 -03:00
Cauê Faleiros
8786a33c8d fix: the cart keeps only its items, never the customer's details
The browser put the CNPJ, WhatsApp, e-mail, CEP and address back after a
reload without telling the page, which then showed them but could not pay
until they were typed again. The fields are now emptied on load and marked
autocomplete="off", and the saved cart holds only the items: the details and
address are typed again after a reload or a closed tab. The payment page
already takes them from the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:23:18 -03:00
40 changed files with 1894 additions and 597 deletions

View File

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

View File

@@ -2,7 +2,9 @@
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from psycopg.errors import UniqueViolation
from psycopg.types.json import Jsonb
@@ -10,7 +12,7 @@ from ..core import db
from ..artwork import submit_files
from ..core.auth import (DUMMY_PASSWORD_HASH, audit, client_ip, new_session, owner,
login_failed, password_hash, password_matches, session_row, throttle, transfer_guest)
from ..core.models import ArtworkSubmission, Login, Register
from ..core.models import ArtworkSubmission, EmailChange, Login, PasswordChange, ProfileUpdate, Register
from ..runtime import STATES, file_rows, owned_order, storage
from ..scanning import require_clean
@@ -86,13 +88,112 @@ def me(identity=Depends(owner)):
row = c.execute('SELECT profile FROM dtf_local.accounts WHERE id=%s', (identity,)).fetchone()
return {'customer': row['profile'] if row else None}
@router.get('/api/customer/orders')
def orders(identity=Depends(owner)):
def account_row(c, identity, lock=False):
row = c.execute('SELECT * FROM dtf_local.accounts WHERE id=%s' + (' FOR UPDATE' if lock else ''), (identity,)).fetchone()
if not row:
raise HTTPException(401, 'Entre na sua conta.')
return row
@router.post('/api/account/profile')
def update_profile(body: ProfileUpdate, identity=Depends(owner)):
"""WhatsApp and the saved delivery address. The CNPJ is the account's and
does not change; the e-mail is the login and changes on its own route."""
with db.connect() as c:
rows = c.execute('SELECT id,number,state,version,snapshot,created_at,updated_at FROM dtf_local.orders WHERE owner=%s ORDER BY created_at DESC', (identity,)).fetchall()
quotes = c.execute('''SELECT q.id,q.approved,q.approved_at,q.created_at FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id WHERE q.owner=%s AND o.id IS NULL ORDER BY q.created_at DESC''', (identity,)).fetchall()
return {'orders': rows, 'quotes': quotes, 'states': STATES}
row = account_row(c, identity, lock=True)
profile = {**row['profile'], 'zap': body.zap,
'address': body.address.model_dump() if body.address else None}
c.execute('UPDATE dtf_local.accounts SET profile=%s WHERE id=%s', (Jsonb(profile), identity))
audit('account_profile_updated', account=str(identity))
return {'customer': profile}
@router.post('/api/account/email')
def change_email(body: EmailChange, request: Request, identity=Depends(owner)):
with db.connect() as c:
row = account_row(c, identity)
throttle(row['email'], request)
if not password_matches(body.password, row['password_hash']):
login_failed(row['email'])
raise HTTPException(401, 'Senha incorreta.')
profile = {**row['profile'], 'mail': body.email}
try:
with db.connect() as c:
c.execute('UPDATE dtf_local.accounts SET email=%s, profile=%s WHERE id=%s', (body.email, Jsonb(profile), identity))
except UniqueViolation:
raise HTTPException(409, 'Este e-mail já é de outra conta.')
audit('account_email_changed', account=str(identity))
return {'customer': profile}
@router.post('/api/account/password')
def change_password(body: PasswordChange, request: Request, identity=Depends(owner)):
with db.connect() as c:
row = account_row(c, identity)
throttle(row['email'], request)
if not password_matches(body.current, row['password_hash']):
login_failed(row['email'])
raise HTTPException(401, 'Senha atual incorreta.')
session = current(request)
with db.connect() as c:
c.execute('UPDATE dtf_local.accounts SET password_hash=%s WHERE id=%s', (password_hash(body.new), identity))
# Other devices signed in with the old password are signed out.
c.execute('DELETE FROM dtf_local.sessions WHERE owner=%s AND id<>%s', (identity, session['id']))
audit('account_password_changed', account=str(identity))
return {'ok': True}
# The order list's groups, as the customer filters them.
GROUPS = {'pay': 'Aguardando pagamento', 'prod': 'Em produção', 'cor': 'Correção', 'fin': 'Finalizados'}
ENTRIES = '''WITH live_quote AS (
-- The cart waiting for payment: the newest unpaid quote whose files still
-- exist. An older one was replaced when the cart changed, and one whose
-- files are gone can no longer be paid.
SELECT q.id, q.created_at, q.approved FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE q.owner=%(me)s AND o.id IS NULL
AND NOT EXISTS (SELECT 1 FROM jsonb_array_elements(q.draft->'items') i
CROSS JOIN LATERAL jsonb_array_elements_text(i->'uploads') u(uid)
JOIN dtf_local.uploads up ON up.id=u.uid::uuid
WHERE up.purged_at IS NOT NULL OR up.expires_at<=now())
ORDER BY q.created_at DESC LIMIT 1
), entries AS (
SELECT 'order' AS kind, o.id, o.number, o.state,
CASE WHEN o.state='fin' THEN 'fin' WHEN o.state='cor' THEN 'cor' ELSE 'prod' END AS grp,
(o.snapshot->>'total_cents')::bigint AS total_cents, o.created_at, o.snapshot->'items' AS items
FROM dtf_local.orders o WHERE o.owner=%(me)s
UNION ALL
SELECT 'quote', q.id, NULL, CASE WHEN q.approved IS NULL THEN 'review' ELSE 'pay' END, 'pay',
(q.approved->>'total_cents')::bigint, q.created_at, COALESCE(q.approved->'items','[]'::jsonb)
FROM live_quote q
)'''
@router.get('/api/customer/orders')
def orders(identity=Depends(owner), status: Literal['all','pay','prod','cor','fin'] = 'all',
number: int | None = Query(None, ge=1), days: int = Query(0, ge=0, le=3650),
page: int = Query(1, ge=1, le=10000), size: int = Query(10, ge=1, le=50)):
"""The customer's orders and the cart waiting for payment, newest first,
filtered and a page at a time, with how many there are in each group."""
params = {'me': identity, 'grp': None if status == 'all' else status, 'number': number,
'since': datetime.now(timezone.utc) - timedelta(days=days) if days else None,
'size': size, 'offset': (page - 1) * size}
where = '''WHERE (%(grp)s::text IS NULL OR grp=%(grp)s) AND (%(number)s::int IS NULL OR number=%(number)s)
AND (%(since)s::timestamptz IS NULL OR created_at>=%(since)s)'''
with db.connect() as c:
counts = {r['grp']: r['n'] for r in c.execute(ENTRIES + ' SELECT grp, count(*) AS n FROM entries GROUP BY grp', params).fetchall()}
total = c.execute(ENTRIES + ' SELECT count(*) AS n FROM entries ' + where, params).fetchone()['n']
rows = c.execute(ENTRIES + ' SELECT * FROM entries ' + where +
' ORDER BY created_at DESC LIMIT %(size)s OFFSET %(offset)s', params).fetchall()
items = []
for r in rows:
entry = {'kind': r['kind'], 'id': r['id'], 'number': r['number'], 'state': r['state'],
'group': r['grp'], 'total_cents': r['total_cents'], 'created_at': r['created_at'],
'items': [{'mode': i['mode'], 'billed_metres': i.get('billed_metres')} for i in r['items']]}
if r['kind'] == 'quote':
# A PIX code still open is paid on its own page.
pix = c.execute('''SELECT 1 FROM dtf_local.payment_intents WHERE quote_id=%s AND method='pix'
AND status='pending' AND COALESCE((response->>'expires_at')::timestamptz > now(), false)''',
(r['id'],)).fetchone()
entry['pay_with'] = 'pix' if pix else 'page'
items.append(entry)
return {'items': items, 'total': total, 'page': page, 'size': size,
'counts': {g: counts.get(g, 0) for g in GROUPS}, 'groups': GROUPS, 'states': STATES}
@router.get('/api/customer/orders/{oid}')
def detail(oid: UUID, identity=Depends(owner)):
@@ -103,7 +204,8 @@ def detail(oid: UUID, identity=Depends(owner)):
history = c.execute('''SELECT from_state,to_state,CASE WHEN to_state='cor' THEN reason ELSE '' END AS reason,
created_at FROM dtf_local.movements WHERE order_id=%s AND NOT back ORDER BY id''', (oid,)).fetchall()
return {'id': row['id'], 'number': row['number'], 'state': row['state'], 'version': row['version'],
'snapshot': row['snapshot'], 'history': history, 'files': file_rows(c,oid)}
'created_at': row['created_at'], 'snapshot': row['snapshot'], 'history': history,
'files': file_rows(c,oid)}
@router.post('/api/customer/orders/{oid}/corrections')
def correction(oid: UUID, body: ArtworkSubmission, identity=Depends(owner)):

View File

@@ -85,6 +85,16 @@ def intent(body: PaymentIntent, session_id=Depends(owner)):
raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal))
if c.execute('SELECT 1 FROM dtf_local.orders WHERE quote_id=%s', (body.quote_id,)).fetchone():
raise HTTPException(409, 'Quote is already paid')
# Never charge for files that are gone: an unpaid cart's files are
# removed after a while. A payment under way keeps them a day longer,
# time enough for the provider's notice to become the order.
uploads = payments.quote_uploads(quote['approved'])
live = c.execute('''SELECT count(*) AS n FROM dtf_local.uploads WHERE id=ANY(%s)
AND purged_at IS NULL AND expires_at>now()''', (uploads,)).fetchone()['n']
if live != len(set(uploads)):
raise HTTPException(410, 'Os arquivos deste pedido expiraram porque ele não foi pago a tempo. '
'Monte o pedido de novo para pagar.')
payments.hold_uploads(c, uploads, '1 day')
# Never a second charge: an approved payment is waiting for its
# notification to become the order, and a card in review may still be.
# A card waiting for the bank's confirmation (3-D Secure) blocks only

View File

@@ -7,12 +7,14 @@ from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException
from psycopg.types.json import Jsonb
from .. import payments
from ..core import db
from ..core.auth import owner
from ..core.models import QuoteRequest
from ..core.pricing import price
from .. import quote_review
from ..runtime import freight, quote_view, require_delivery_available, upload_row
from ..grade_check import Files, mismatch
from ..runtime import freight, quote_view, require_delivery_available, storage, upload_row
from ..scanning import require_clean
router = APIRouter()
@@ -35,21 +37,30 @@ def create_quote(body: QuoteRequest, session_id=Depends(owner)):
except ValueError as exc:
raise HTTPException(422, str(exc))
with db.connect() as c:
rows = {}
for item in body.items:
for uid in item.uploads:
row = upload_row(c, uid, session_id)
if not row['complete']:
raise HTTPException(409, 'Complete every upload before requesting a quote')
require_clean(row)
rows[str(uid)] = row
# The grade sets the price and came from the browser: before a cart is
# approved at checkout, the server works it out from the files.
note = mismatch(Files(storage), draft['items'], rows) if reason is None else None
reason = reason or note
uid = uuid4()
c.execute('INSERT INTO dtf_local.quotes(id,owner,request_key,request_hash,draft) VALUES(%s,%s,%s,%s,%s) ON CONFLICT(owner,request_key) DO NOTHING',
(uid, session_id, body.request_key, digest, Jsonb(draft)))
c.execute('INSERT INTO dtf_local.quotes(id,owner,request_key,request_hash,draft,review_note) VALUES(%s,%s,%s,%s,%s,%s) ON CONFLICT(owner,request_key) DO NOTHING',
(uid, session_id, body.request_key, digest, Jsonb(draft), note))
row = c.execute('SELECT * FROM dtf_local.quotes WHERE owner=%s AND request_key=%s FOR UPDATE', (session_id, body.request_key)).fetchone()
if row['request_hash'] != digest:
raise HTTPException(409, 'Request key already used for a different cart')
if row['id'] == uid and reason is None:
quote_review.approve(c, row, body.items, quote_review.AUTO)
return {'id': row['id'], 'status': 'approved'}
if row['id'] == uid:
# An operator reviews it first; the files wait for that review.
payments.hold_uploads(c, payments.quote_uploads(draft), payments.REVIEW_HOLD)
return {'id': row['id'], 'status': 'approved' if row['approved'] else 'pending_review'}
@router.get('/api/quotes/{uid}')

View File

@@ -14,6 +14,7 @@ from ..core import db
from ..core.auth import audit, owner, rate_limit
from ..core.limits import upload_limit_bytes
from ..core.models import UploadStart
from ..payments import UNPAID_HOLD
from ..runtime import PART_BYTES, storage, upload_row
router = APIRouter()
@@ -87,7 +88,10 @@ def complete_upload(uid: UUID, session_id=Depends(owner)):
existing_size = storage.size(row['object_key'])
if existing_size != row['size']:
raise HTTPException(409, 'Stored size differs from declared size')
c.execute("UPDATE dtf_local.uploads SET complete=true,expires_at=now()+interval '30 days' WHERE id=%s", (uid,))
# Held while the cart is unpaid: an abandoned cart's files go after
# UNPAID_HOLD; a paid order keeps them for its 30 days (app/payments.py).
c.execute('UPDATE dtf_local.uploads SET complete=true,expires_at=now()+%s::interval WHERE id=%s',
(UNPAID_HOLD, uid))
return {'id': uid, 'complete': True}
@router.delete('/api/uploads/{uid}')

View File

@@ -67,7 +67,8 @@ def submit_files(c, order, body, identity, kind, actor):
for ref in body.files:
c.execute('INSERT INTO dtf_local.order_files(id,order_id,upload_id,item_index,kind,note,created_by) VALUES(%s,%s,%s,%s,%s,%s,%s)',
(uuid4(),order['id'],ref.upload_id,ref.item_index,kind,body.note,actor))
c.execute('UPDATE dtf_local.uploads SET expires_at=LEAST(expires_at,%s) WHERE id=%s', (expiry,ref.upload_id))
# The order's window, whatever the upload's own hold was.
c.execute('UPDATE dtf_local.uploads SET expires_at=%s WHERE id=%s', (expiry,ref.upload_id))
c.execute('UPDATE dtf_local.orders SET version=version+1,updated_at=now() WHERE id=%s', (order['id'],))
if kind == 'final':
# Artwork approval, not commercial quote approval, starts original cleanup.

View File

@@ -219,6 +219,35 @@ class Register(StrictModel):
customer: Customer
password: str = Field(min_length=12, max_length=128)
class ProfileUpdate(StrictModel):
"""What a customer may change about their account; the CNPJ is not among it."""
zap: str
address: Destination | None = None
@field_validator('zap')
@classmethod
def phone_valid(cls, value):
digits = re.sub(r'\D', '', value)
if len(digits) not in (10,11) or not digits.isascii():
raise ValueError('Invalid phone')
return digits
class EmailChange(StrictModel):
email: str = Field(max_length=254)
password: str = Field(min_length=1, max_length=128)
@field_validator('email')
@classmethod
def email_valid(cls, value):
value = value.strip().lower()
if not re.fullmatch(r'[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}', value):
raise ValueError('Invalid email')
return value
class PasswordChange(StrictModel):
current: str = Field(min_length=1, max_length=128)
new: str = Field(min_length=12, max_length=128)
class Login(StrictModel):
email: str = Field(min_length=3, max_length=254)
password: str = Field(min_length=1, max_length=128)

189
app/grade_check.py Normal file
View File

@@ -0,0 +1,189 @@
"""The grade (the resolution discount) recomputed from the uploaded files.
The Site grades the art in the customer's browser and sends the grade with the
cart, and the price depends on it. A customer who edits the page could claim a
better grade, so before a cart is approved at checkout the server reads the
files it already has and works the grade out again, by the Site's own rules:
- a finished sheet: its pixel width over the sheet width is its DPI, and the
item's grade comes from the worst sheet (web/site-quality.js);
- artworks: each one's DPI along the width it is printed at, rotation
included; the item's grade is the average of the artworks' grades;
- a PDF: the area-weighted DPI of the images drawn on its page, 300 when it
is only vectors (web/site-pdf.js, dpiDasImagens and resumoDpi).
Images are measured from their first bytes, never decoded. A PDF is opened
only up to PDF_MAX_BYTES, the size the Site itself analyses. A grade that
cannot be recomputed is None; the caller decides what that means.
"""
import math
import os
import struct
import tempfile
DPI_IDEAL = 300
# The Site analyses PDFs up to 150 MB (GRANDE_BYTES); above that it does not
# grade them, so neither does this.
PDF_MAX_BYTES = 150 * 1048576
HEAD_BYTES = 1048576
# Rounding in the browser and here can differ by a point on the same file.
TOLERANCE = 2
def js_round(value):
"""Math.round, which rounds halves up; Python's round() does not."""
return math.floor(value + 0.5)
def grade_of(dpi):
return max(6, min(100, js_round(dpi / DPI_IDEAL * 100)))
def image_size(head):
"""(width, height) in pixels from a PNG, JPEG or WebP's first bytes."""
if head[:8] == b'\x89PNG\r\n\x1a\n' and head[12:16] == b'IHDR':
return struct.unpack('>II', head[16:24])
if head[:2] == b'\xff\xd8':
i = 2
while i + 9 < len(head):
if head[i] != 0xFF:
i += 1
continue
marker = head[i + 1]
if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7 or marker == 0xFF:
i += 1 if marker == 0xFF else 2
continue
length = struct.unpack('>H', head[i + 2:i + 4])[0]
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
h, w = struct.unpack('>HH', head[i + 5:i + 9])
return w, h
i += 2 + length
return None
if head[:4] == b'RIFF' and head[8:12] == b'WEBP':
chunk = head[12:16]
if chunk == b'VP8X':
return (int.from_bytes(head[24:27], 'little') + 1, int.from_bytes(head[27:30], 'little') + 1)
if chunk == b'VP8L' and head[20] == 0x2F:
bits = int.from_bytes(head[21:25], 'little')
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
if chunk == b'VP8 ' and head[23:26] == b'\x9d\x01\x2a':
w, h = struct.unpack('<HH', head[26:30])
return w & 0x3FFF, h & 0x3FFF
return None
def _multiply(a, b):
return [a[0] * b[0] + a[2] * b[1], a[1] * b[0] + a[3] * b[1],
a[0] * b[2] + a[2] * b[3], a[1] * b[2] + a[3] * b[3],
a[0] * b[4] + a[2] * b[5] + a[4], a[1] * b[4] + a[3] * b[5] + a[5]]
def pdf_dpi(path):
"""The page's area-weighted image DPI, 300 for vectors only, None if the
file is not a one-page PDF this can read."""
import pikepdf
try:
with pikepdf.open(path) as pdf:
if len(pdf.pages) != 1:
return None
page = pdf.pages[0]
unit = float(page.obj.get('/UserUnit', 1))
found = []
def walk(resources, instructions, ctm, depth):
stack = []
xobjects = resources.get('/XObject', {}) if resources is not None else {}
for operands, operator in instructions:
op = str(operator)
if op == 'q':
stack.append(ctm)
elif op == 'Q':
ctm = stack.pop() if stack else [1, 0, 0, 1, 0, 0]
elif op == 'cm':
ctm = _multiply(ctm, [float(v) for v in operands])
elif op == 'Do' and operands:
xobject = xobjects.get(str(operands[0]))
if xobject is None:
continue
subtype = xobject.get('/Subtype')
if subtype == '/Image':
width_pt = math.hypot(ctm[0], ctm[1])
if width_pt >= 1:
found.append((int(xobject.get('/Width', 0)) / (width_pt * unit / 72),
abs(ctm[0] * ctm[3] - ctm[1] * ctm[2])))
elif subtype == '/Form' and depth < 8:
matrix = [float(v) for v in xobject.get('/Matrix', [1, 0, 0, 1, 0, 0])]
walk(xobject.get('/Resources', resources),
pikepdf.parse_content_stream(xobject), _multiply(ctm, matrix), depth + 1)
walk(page.obj.get('/Resources'), pikepdf.parse_content_stream(page), [1, 0, 0, 1, 0, 0], 0)
except Exception:
return None
found = [(dpi, area) for dpi, area in found if dpi > 0]
if not found:
return DPI_IDEAL
total = sum(area for _, area in found) or 1
return js_round(sum(dpi * area for dpi, area in found) / total)
class Files:
"""The uploaded files, read from storage as little as needed."""
def __init__(self, storage):
self.storage = storage
def head(self, row):
return self.storage.client.get_object(Bucket=self.storage.bucket, Key=row['object_key'],
Range=f'bytes=0-{HEAD_BYTES - 1}')['Body'].read()
def pdf_dpi(self, row):
if row['size'] > PDF_MAX_BYTES:
return None
with tempfile.NamedTemporaryFile(suffix='.pdf') as handle:
self.storage.client.download_fileobj(self.storage.bucket, row['object_key'], handle)
handle.flush()
return pdf_dpi(handle.name)
def source_dpi(files, row, source):
"""One source's DPI across the width it is printed at, or None."""
name = row['name'].lower()
width_in = float(source['width_cm']) / 2.54
if name.endswith('.pdf'):
return files.pdf_dpi(row) if source['kind'] == 'sheet' else None
if not name.endswith(('.png', '.jpg', '.jpeg', '.webp')):
return None
size = image_size(files.head(row))
if not size or not all(size):
return None
across = size[1] if source['rotation_degrees'] in (90, 270) else size[0]
return across / width_in
def item_grade(files, item, rows):
"""The grade the Site gives this item, recomputed; None if it cannot be."""
sources = item['production']['sources']
dpis = []
for source in sources:
row = rows.get(str(source['upload_id']))
dpi = source_dpi(files, row, source) if row else None
if dpi is None:
return None
dpis.append(dpi)
if all(source['kind'] == 'sheet' for source in sources):
# A sheet's DPI is shown and compared as a whole number.
return grade_of(min(js_round(d) for d in dpis))
return js_round(sum(grade_of(d) for d in dpis) / len(dpis))
def mismatch(files, items, rows):
"""Why a cart's grades cannot be approved at checkout, or None."""
for item in items:
if item['grade'] == 0:
continue # full price: nothing to verify
verified = item_grade(files, item, rows)
if verified is None:
return 'Nota não conferida no servidor'
if item['grade'] > verified + TOLERANCE:
return f"Nota {item['grade']} maior que a do arquivo ({verified})"
return None

View File

@@ -49,6 +49,26 @@ def is_test(order):
return (order.get('payment') or {}).get('provider') == TEST_PROVIDER
# How long a file is kept while nothing has been paid for it. A cart's files
# are uploaded before payment, so the price and the security check are done on
# the file itself; a cart that is never paid must not keep them for 30 days.
UNPAID_HOLD = '2 days'
# A quote waiting for an operator's review keeps its files this long.
REVIEW_HOLD = '7 days'
# Once paid, the order keeps its originals for this long from the upload.
ORDER_RETENTION = '30 days'
def quote_uploads(quote_or_draft):
return [uid for item in quote_or_draft['items'] for uid in item['uploads']]
def hold_uploads(c, upload_ids, interval):
"""Keep these files at least `interval` from now; never shortens a hold."""
c.execute('''UPDATE dtf_local.uploads SET expires_at=GREATEST(expires_at, now()+%s::interval)
WHERE id=ANY(%s) AND purged_at IS NULL''', (interval, [str(u) for u in upload_ids]))
def create_order(c, quote, payment):
"""Create the order for a reviewed quote, or return the one already there.
@@ -68,6 +88,9 @@ def create_order(c, quote, payment):
order = c.execute(
'INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) VALUES(%s,%s,%s,%s,%s) RETURNING *',
(uuid4(), quote['id'], quote['owner'], Jsonb(approved), Jsonb(payment))).fetchone()
# Paid: the files are kept for the order's retention, counted from upload.
c.execute('''UPDATE dtf_local.uploads SET expires_at=GREATEST(expires_at, created_at+%s::interval)
WHERE id=ANY(%s) AND purged_at IS NULL''', (ORDER_RETENTION, quote_uploads(approved)))
queue_print_files(c, order['id'], len(approved['items']))
if is_test(order):
return order, True

View File

@@ -6,11 +6,11 @@ moment it was created. The Site is a shop: a customer who can price the order
should be able to pay for it straight away, at any hour, so only orders a
person must look at before charging wait for the Kanban (review_reason).
Known limit: the grade (and so the discount) and the layout are still worked
out in the customer's browser (roadmap 3.2, 3.9). The API checks the layout's
geometry and that an unanalysed item carries no discount, but a customer who
edits the page can claim a better grade. Operators see every order's grade
and artwork at Arte recebida.
The grade (and so the discount) is worked out in the customer's browser and
checked against the files before an automatic approval (app/grade_check.py);
a grade the files do not support waits for review. The layout is still the
browser's (roadmap 3.9): the API checks its geometry. Operators see every
order's grade and artwork at Arte recebida.
"""
import os
from decimal import Decimal
@@ -18,6 +18,7 @@ from decimal import Decimal
from fastapi import HTTPException
from psycopg.types.json import Jsonb
from . import payments
from .core.pricing import price
from .runtime import freight, upload_row
from .scanning import require_clean
@@ -83,4 +84,6 @@ def approve(c, row, items, reviewer):
'total_cents': sum(i['total_cents'] for i in priced) + quoted_freight['total_cents']}
c.execute('UPDATE dtf_local.quotes SET approved=%s, reviewed_by=%s, approved_at=now() WHERE id=%s',
(Jsonb(approved), reviewer, row['id']))
# Approved and payable now: the files wait for the payment, not for ever.
payments.hold_uploads(c, payments.quote_uploads(approved), payments.UNPAID_HOLD)
return approved

View File

@@ -85,7 +85,7 @@ def quote_view(c, row):
'draft': row['draft'], 'approved': row['approved'],
'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
'auto_approved': row.get('reviewed_by') == 'auto',
'review_reason': None if row['approved'] else review_reason(row['draft']),
'review_reason': None if row['approved'] else (row.get('review_note') or review_reason(row['draft'])),
'payment': attempt,
'order': order}

View File

@@ -142,6 +142,10 @@ CREATE TABLE IF NOT EXISTS dtf_local.backups (
);
CREATE INDEX IF NOT EXISTS backups_finished ON dtf_local.backups(finished_at DESC);
-- Why a quote waits for review, when the reason came from its files (the grade
-- the server recomputed) and cannot be worked out again from the draft alone.
ALTER TABLE dtf_local.quotes ADD COLUMN IF NOT EXISTS review_note text;
CREATE INDEX IF NOT EXISTS uploads_owner ON dtf_local.uploads(owner);
-- Indexes follow the queries the application actually issues. Only these; every

View File

@@ -53,6 +53,11 @@ server {
expires -1;
try_files /index.html =404;
}
# The customer's area: one page, which shows the right part for each address.
location ~ ^/conta(/(entrar|pedidos|dados))?/?$ {
expires -1;
try_files /portal.html =404;
}
location / {
expires -1;
try_files $uri $uri/ =404;

View File

@@ -627,6 +627,13 @@ the site already did.
generator, with the browser as preview only. This is the single largest gap between
what was promised in the meeting and what exists.
**2026-09-30:** the grade (the resolution discount) is no longer taken on trust.
Before an automatic approval the API recomputes it from the uploaded files by the
Site's rules (`app/grade_check.py`): image headers for PNG/JPG/WebP, the placed
images of a PDF up to 150 MB. A claim more than 2 points above the file's grade,
or a discount on a file the server cannot grade, waits for review with the reason
on the Kanban. The metres (the packing) are still the browser's.
### `[~]` 3.3 — The 5 GB problem is unsolved `(F19)`
Transport accepts 5 GiB; `SCAN_MAX_BYTES` / ClamAV `StreamMaxLength` release only

View File

@@ -23,6 +23,10 @@ server {
location ~ ^/(arquivo-por-metro|artes-avulsas|uv-arquivo-por-metro|uv-artes-avulsas|carrinho|pagamento|pagamento/pix)/?$ {
try_files /index.html =404;
}
# The customer's area: one page, which shows the right part for each address.
location ~ ^/conta(/(entrar|pedidos|dados))?/?$ {
try_files /portal.html =404;
}
location / { try_files $uri $uri/ =404; }
}
server {

View File

@@ -170,6 +170,17 @@ try{
await evaluate(`folhas=[];pintaFolha()`);
await evaluate(`pickTipo('avulsa')`);
assert.equal(await evaluate('modo'),'avulsa');
// A large JPG artwork cannot be read in parts: it is measured from its
// header and placed as a full box, with the discount, instead of refused.
await evaluate(`(()=>{const h=new Uint8Array(20);h.set([0xFF,0xD8,0xFF,0xC0,0,17,8,0x09,0x3A,0x09,0x3A,3]);
const f=new File([h],'arte-grande.jpg');Object.defineProperty(f,'size',{value:400*1048576});sel([f]);})()`);
await waitFor(()=>evaluate(`artes.length===1 && artes[0].semPrevia===true`),'large JPG artwork measured from its header');
await fill('[data-cm]',20);
await waitFor(()=>evaluate(`!!itemAtual?.production?.sources?.length`),'large JPG artwork packed');
assert.deepEqual(await evaluate(`({dpi:Math.round(dpiDe(artes[0])),src:!!artes[0].src,
measurement:itemAtual.production.sources[0].measurement,note:$('lista').textContent.includes('Sem prévia')})`),
{dpi:300,src:false,measurement:'file',note:true});
await evaluate(`artes=[];pintaArtes()`);
await evaluate('sendImage()');
await waitFor(()=>evaluate(`artes.length===1 && !!artes[0].src`),'JPG artwork model');
assert.deepEqual(await evaluate(`({mode:modo,sheets:folhas.length,width:artes[0].cm,quantity:artes[0].q})`),{mode:'avulsa',sheets:0,width:0,quantity:1});

View File

@@ -79,7 +79,12 @@ try{
await site.fill('#fCnpj','11222333000181');
await site.fill('#fZap','11999999999');
await site.fill('#fMail','local-browser@example.test');
await pause(800);
// Reload only once the cart is in the browser's storage: the save waits for
// typing to stop and writes the files, so a fixed pause raced it.
await waitFor(()=>site.eval(`new Promise(done=>{const open=indexedDB.open('dtf-local-cart',1);
open.onerror=()=>done(false);
open.onsuccess=()=>{const all=open.result.transaction('cart').objectStore('cart').getAll();
all.onsuccess=()=>done(all.result.some(v=>v.items?.length===1));all.onerror=()=>done(false);};})`),'cart saved in the browser');
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.
@@ -93,7 +98,19 @@ try{
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);
// The items come back after a reload; the customer's details do not.
assert.deepEqual(await site.eval(`['fCnpj','fZap','fMail'].map(id=>$(id).value)`),['','','']);
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),true);
await site.fill('#fCnpj','11222333000181');
await site.fill('#fZap','11999999999');
await site.fill('#fMail','local-browser@example.test');
assert.equal(await site.eval('pedido[0].localFiles[0].name'),'local-test.cdr');
// An unchecked item stays in the cart but is not paid for now.
await site.click('[data-sel="0"]');
assert.deepEqual(await site.eval(`({marcado:pedido[0].marcado,pagar:itensAPagar().length,zero:$('carrTot').textContent.includes('0,00'),row:document.querySelector('.item').classList.contains('fora')})`),
{marcado:false,pagar:0,zero:true,row:true});
await site.click('[data-sel="0"]');
assert.equal(await site.eval('pedido[0].marcado===true && itensAPagar().length===1'),true);
assert.equal(await site.eval('pedido[0].total'),21.89);
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false);
await site.click('#bPagar');
@@ -175,14 +192,20 @@ try{
await waitFor(()=>site.eval('window.scrollY===0'),'screenshot scroll position');
await site.screenshot('output/local/site.png');
await kanban.screenshot('output/local/kanban.png');
const portal=await page(siteOrigin+'/portal.html?order='+oid);
// A guest sees the order paid in this browser; creating an account keeps it.
const portal=await page(siteOrigin+'/conta/pedidos?order='+oid);
await waitFor(async()=> (await portal.text()).includes('Finalizado'),'customer order tracking');
await waitFor(()=>portal.eval('document.querySelector("#order-'+oid+' .detalhe")?.hidden===false'),'order from the link opened');
await portal.click('#navEntrar');
await waitFor(()=>portal.eval('location.pathname==="/conta/entrar" && !document.getElementById("auth").hidden'),'sign-in page');
await portal.fill('#cnpj','11222333000181');await portal.fill('#phone','11999999999');
await portal.fill('#register-email','browser-'+Date.now()+'@example.test');
await portal.fill('#register-password','local-browser-password-123');
await portal.eval('document.getElementById("register").requestSubmit()');
await waitFor(()=>portal.eval('document.getElementById("auth").hidden'),'customer registration');
assert.ok((await portal.text()).includes('Finalizado'));
await waitFor(()=>portal.eval('document.getElementById("auth").hidden && location.pathname==="/conta"'),'customer registration');
await waitFor(async()=> (await portal.text()).includes('Finalizado'),'overview shows the last order');
await portal.click('[data-view="dados"]');
await waitFor(()=>portal.eval('location.pathname==="/conta/dados" && document.getElementById("dCnpj").value==="11.222.333/0001-81"'),'account details');
await portal.screenshot('output/local/portal.png');
// A logout must clear draft file blobs and metadata, including other open Site tabs.
await portal.eval(`(async()=>{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onsuccess=()=>resolve(r.result);r.onerror=reject;});await new Promise((resolve,reject)=>{const tx=db.transaction('cart','readwrite');tx.objectStore('cart').put({items:[new File(['private'],'private.cdr')],expires:Date.now()+86400000},'security-fixture');tx.oncomplete=resolve;tx.onerror=reject;});db.close();})()`);

View File

@@ -155,6 +155,30 @@ def run():
assert queued == 0 and jobs == 1, (queued, jobs)
print('PASS: an operator test order reaches the board and the print queue, never Tiny or WhatsApp')
# Files are uploaded before payment. An unpaid cart keeps them briefly; a
# paid order keeps them for its 30 days; a payment never starts for files
# that are gone.
def files_of(qid):
with db.connect() as c:
q = c.execute('SELECT approved,draft FROM dtf_local.quotes WHERE id=%s', (qid,)).fetchone()
return [u for item in (q['approved'] or q['draft'])['items'] for u in item['uploads']]
def days(ids, since='now()'):
with db.connect() as c:
return [float(r['d']) for r in c.execute(
f'SELECT extract(epoch FROM expires_at-{since})/86400 AS d FROM dtf_local.uploads WHERE id=ANY(%s)',
(ids,)).fetchall()]
fresh = upload_bytes(customer, b'UNPAID HOLD TEST')
assert all(1.99 < d <= 2.0 for d in days([fresh])), days([fresh])
assert all(abs(d - 30) < 0.01 for d in days(files_of(quote_id), 'created_at')), days(files_of(quote_id), 'created_at')
late, late_quote, _ = reviewed_quote()
assert all(d > 1.99 for d in days(files_of(late_quote))), days(files_of(late_quote))
with db.connect() as c:
c.execute("UPDATE dtf_local.uploads SET expires_at=now()-interval '1 second' WHERE id=ANY(%s)",
(files_of(late_quote),))
late.call('/payments/intent', {'quote_id': late_quote, 'method': {'type': 'pix'}}, expected=410)
print('PASS: unpaid files are held 2 days, paid ones 30 days, and expired files are never charged for')
if __name__ == '__main__':
run()

View File

@@ -175,7 +175,11 @@ def run():
assert not client.call('/quotes/'+qid)['auto_approved']
# A cart the Site priced is approved at once and can be paid straight away,
# at the server's own prices; no operator can then change it.
small={**draft,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',90,uid)]}
# The server works the grade out from the file: this PNG is 6059 px across
# 57 cm, 270 DPI, which is grade 90.
png=b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR'+(6059).to_bytes(4,'big')+(2000).to_bytes(4,'big')+b'\x08\x06\x00\x00\x00'+b'\x00'*4
graded=upload_bytes(client,png,name='arte-270dpi.png')
small={**draft,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',90,graded)]}
auto=client.call('/quotes',small)
assert auto['status']=='approved'
seen=client.call('/quotes/'+auto['id'])
@@ -188,6 +192,12 @@ def run():
held=client.call('/quotes',{**small,'request_key':str(uuid4()),'items':[claimed]})
assert held['status']=='pending_review'
assert client.call('/quotes/'+held['id'])['review_reason']=='Nota informada sem análise da arte'
# A better grade than the file supports, or one the server cannot check, waits too.
inflated=client.call('/quotes',{**small,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',100,graded)]})
assert inflated['status']=='pending_review'
assert client.call('/quotes/'+inflated['id'])['review_reason']=='Nota 100 maior que a do arquivo (90)'
unchecked=client.call('/quotes',{**small,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',90,uid)]})
assert client.call('/quotes/'+unchecked['id'])['review_reason']=='Nota não conferida no servidor'
print('PASS: priced carts are approved at checkout; large or inconsistent ones wait for review')
client.call('/orders/dev-paid',{'quote_id':qid,'total_cents':1},expected=422)
other.call('/orders/dev-paid',{'quote_id':qid},expected=404)

86
tests/test_grade_check.py Normal file
View File

@@ -0,0 +1,86 @@
"""The server's grade against real image and PDF files.
Runs where Pillow and pikepdf are installed (the API image).
"""
import io
import tempfile
import unittest
import pikepdf
from PIL import Image
from app import grade_check
class FakeFiles:
def __init__(self, blobs):
self.blobs = blobs
def head(self, row):
return self.blobs[row['name']][:grade_check.HEAD_BYTES]
def pdf_dpi(self, row):
with tempfile.NamedTemporaryFile(suffix='.pdf') as handle:
handle.write(self.blobs[row['name']])
handle.flush()
return grade_check.pdf_dpi(handle.name)
def encoded(fmt, size, **options):
out = io.BytesIO()
Image.new('RGBA' if fmt in ('PNG', 'WEBP') else 'RGB', size, (200, 30, 30)).save(out, fmt, **options)
return out.getvalue()
def item(kind, grade, sources):
return {'grade': grade, 'production': {'sources': [
{'upload_id': name, 'kind': kind, 'width_cm': width, 'rotation_degrees': rotation}
for name, width, rotation in sources]}}
class GradeCheckTests(unittest.TestCase):
def test_image_sizes_from_the_first_bytes(self):
for fmt, options in (('PNG', {}), ('JPEG', {'quality': 80}), ('JPEG', {'progressive': True}),
('WEBP', {'lossless': True}), ('WEBP', {'quality': 80})):
self.assertEqual(grade_check.image_size(encoded(fmt, (1234, 567), **options)), (1234, 567), (fmt, options))
# A JPEG with a large metadata block before the frame header.
exif = Image.Exif()
exif[0x010E] = 'x' * 60000
self.assertEqual(grade_check.image_size(encoded('JPEG', (800, 600), exif=exif)), (800, 600))
self.assertIsNone(grade_check.image_size(b'not an image at all'))
def test_pdf_dpi_is_the_images_area_weighted(self):
out = io.BytesIO()
Image.new('RGB', (1500, 750), 'white').save(out, 'PDF', resolution=150)
with tempfile.NamedTemporaryFile(suffix='.pdf') as handle:
handle.write(out.getvalue()); handle.flush()
self.assertEqual(grade_check.pdf_dpi(handle.name), 150)
vector = pikepdf.new()
vector.add_blank_page(page_size=(1615, 850))
with tempfile.NamedTemporaryFile(suffix='.pdf') as handle:
vector.save(handle.name)
self.assertEqual(grade_check.pdf_dpi(handle.name), 300)
def test_grades_follow_the_site(self):
# 57 cm is 22.44 in: 6059 px is 270 DPI, grade 90; 3366 px is 150 DPI, grade 50.
files = FakeFiles({'a.png': encoded('PNG', (6059, 100)), 'b.jpg': encoded('JPEG', (3366, 100)),
'tall.png': encoded('PNG', (100, 2362)), 'x.cdr': b'CDR'})
rows = {n: {'name': n, 'size': 1} for n in files.blobs}
# A sheet takes the worst sheet's grade.
self.assertEqual(grade_check.item_grade(files, item('sheet', 0, [('a.png', 57, 0)]), rows), 90)
self.assertEqual(grade_check.item_grade(files, item('sheet', 0, [('a.png', 57, 0), ('b.jpg', 57, 0)]), rows), 50)
# Artworks average; a rotated one is measured along its height.
self.assertEqual(grade_check.item_grade(files, item('artwork', 0, [('a.png', 57, 0), ('b.jpg', 57, 0)]), rows), 70)
self.assertEqual(grade_check.item_grade(files, item('artwork', 0, [('tall.png', 20, 90)]), rows), 100)
self.assertIsNone(grade_check.item_grade(files, item('sheet', 0, [('x.cdr', 57, 0)]), rows))
# What the cart claims is checked, with a point or two of rounding.
claim = lambda g, src: grade_check.mismatch(files, [item('sheet', g, src)], rows)
self.assertIsNone(claim(90, [('a.png', 57, 0)]))
self.assertIsNone(claim(92, [('a.png', 57, 0)]))
self.assertEqual(claim(100, [('a.png', 57, 0)]), 'Nota 100 maior que a do arquivo (90)')
self.assertEqual(claim(40, [('x.cdr', 57, 0)]), 'Nota não conferida no servidor')
self.assertIsNone(claim(0, [('x.cdr', 57, 0)]))
if __name__ == '__main__':
unittest.main()

View File

@@ -15,7 +15,23 @@ def run():
password='local-test-password-'+uuid4().hex
customer.call('/account/register',{'customer':profile,'password':password})
assert customer.call('/account/me')['customer']['mail']==profile['mail']
assert customer.call('/customer/orders')['orders'][0]['id']==oid
assert [e['id'] for e in customer.call('/customer/orders')['items'] if e['kind']=='order'][0]==oid
# The list filters, pages and counts by group.
listing=customer.call('/customer/orders?status=prod&page=1&size=1')
assert listing['counts']['prod']>=1 and listing['total']==listing['counts']['prod'] and len(listing['items'])==1, listing
assert customer.call('/customer/orders?status=fin')['items']==[]
assert customer.call('/customer/orders?number='+str(order['number']))['items'][0]['id']==oid
# The account's details change; the CNPJ does not, and e-mail and password need the password.
address={'recipient':'Workflow Ltda','street':'Rua de Teste','number':'10','complement':'',
'district':'Centro','city':'Franca','state':'SP','postal_code':'14400000'}
saved=customer.call('/account/profile',{'zap':'(16) 98888-7777','address':address})['customer']
assert saved['zap']=='16988887777' and saved['cnpj']==profile['cnpj'] and saved['address']['city']=='Franca'
assert customer.call('/account/me')['customer']['address']['postal_code']=='14400000'
customer.call('/account/profile',{'zap':'16988887777','cnpj':'00000000000000'},expected=422)
customer.call('/account/email',{'email':'x-'+profile['mail'],'password':'wrong-password'},expected=401)
customer.call('/account/password',{'current':'wrong-password','new':'another-password-123'},expected=401)
other.call('/account/profile',{'zap':'16988887777'},expected=401)
print('PASS: customer order list filters and pages; account details change, CNPJ does not')
# Email/CNPJ do not grant ownership; only current guest session is migrated.
other.call('/customer/orders/'+oid,expected=404)
other.call('/account/login',{'email':profile['mail'],'password':'wrong-password'},expected=401)
@@ -72,7 +88,7 @@ def run():
old_cookie=list(other.jar)[0].value
other.call('/account/logout',{})
cookie.value=old_cookie;revoked.jar.set_cookie(cookie);revoked.call('/customer/orders',expected=401)
other.call('/session');assert other.call('/customer/orders')['orders']==[]
other.call('/session');assert other.call('/customer/orders')['items']==[]
print('PASS: logout revokes server session and signed-out visitors cannot see account orders')
if __name__=='__main__':run()

View File

@@ -1,5 +1,11 @@
/* Browser-local cart recovery. Files are stored only for an unfinished cart, for 24h. */
/* Browser-local cart recovery. Files are stored only for an unfinished cart, for 24h.
Only the items are kept: the customer's details and address are typed again
after a reload or a closed tab, so nothing personal stays in the browser. */
(() => {
// A browser may put back what was typed before the reload without telling
// the page, which then shows values it does not know and cannot pay with.
const CAMPOS=['fCnpj','fZap','fMail','cepIn','eNome','eRua','eNum','eComp','eBairro','eCidade','eUf'];
for(const id of CAMPOS)if($(id))$(id).value='';
let database,scope,timer,restoring=true;
let signedOut=false;
window.addEventListener('dtf-private-data-cleared',()=>{signedOut=true;clearTimeout(timer);pedido=[];itemAtual=null;folhas=[];artes=[];});
@@ -11,8 +17,8 @@
const items=[...pedido,...(itemAtual?[itemAtual]:[])];
try{
if(!items.length){await transaction('readwrite',store=>store.delete(scope));return;}
await transaction('readwrite',store=>store.put({items,customer:{...cliente},delivery:{...entrega},expires:Date.now()+86400000},scope));
notice.textContent='Carrinho salvo neste navegador por 24 horas. Você pode remover itens e adicionar outros após recarregar.';
await transaction('readwrite',store=>store.put({items,expires:Date.now()+86400000},scope));
notice.textContent='';
}catch(error){notice.textContent='Não foi possível salvar o carrinho neste navegador. Mantenha esta página aberta até enviar o pedido.';}
}
window.dtfClearCart=async()=>{clearTimeout(timer);if(database&&scope)await transaction('readwrite',store=>store.delete(scope));notice.textContent='';};
@@ -30,20 +36,26 @@
for(const key of records){const value=await transaction('readonly',store=>store.get(key));if(value.expires<Date.now())await transaction('readwrite',store=>store.delete(key));}
const saved=await transaction('readonly',store=>store.get(scope));
if(saved && !pedido.length && !itemAtual){
pedido=saved.items;cliente=saved.customer;entrega=saved.delivery;
// Freight must be quoted again; restored browser values are never final.
if(entrega.tipo==='frete'){entrega.cotado=false;entrega.valor=0;entrega.dias=null;}
for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];
$('cepIn').value=fmtCep(entrega.cep);
entrega.end=entrega.end||{};
for(const [id,key] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp','comp'],
['eBairro','bairro'],['eCidade','cidade'],['eUf','uf']])$(id).value=entrega.end[key]||'';
pedido=saved.items;
$('atual').style.display='none';pintaEntrega();
if(entrega.tipo==='frete' && entrega.cep.length===8 && window.dtfFreight) window.dtfFreight();
notice.textContent='Carrinho recuperado. Remova e adicione novamente um item se precisar alterar sua montagem.';
}else{
const account=await window.dtfApi('/account/me');
if(account.customer && !cliente.mail){cliente={...account.customer};for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];pintaEntrega();}
}
// A signed-in customer's details come from the account, with the saved
// delivery address, whether or not a cart was recovered.
const account=await window.dtfApi('/account/me');
const c=account.customer;
if(c && !cliente.mail){
cliente={cnpj:c.cnpj,zap:c.zap,mail:c.mail};
// The fields' own input handlers format them, as if typed.
for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']]){$(id).value=cliente[key];$(id).dispatchEvent(new Event('input'));}
const a=c.address;
if(a && !entrega.cep){
entrega.cep=a.postal_code;
entrega.end={nome:a.recipient,rua:a.street,num:a.number,comp:a.complement||'',bairro:a.district,cidade:a.city,uf:a.state};
$('cepIn').value=a.postal_code.replace(/^(\d{5})(\d{3})$/,'$1-$2');
for(const [id,key] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp','comp'],['eBairro','bairro'],['eCidade','cidade'],['eUf','uf']])$(id).value=entrega.end[key];
}
pintaEntrega();
}
}catch(error){notice.textContent='Recuperação de carrinho indisponível; o pedido ainda pode ser feito nesta sessão.';}
finally{restoring=false;}

View File

@@ -15,7 +15,7 @@
let quotedCart = localStorage.getItem('dtf-quote-cart');
let refreshVersion = 0;
function cartSnapshot() {
const items=[...pedido,...(itemAtual?[itemAtual]:[])];
const items=itensAPagar();
return JSON.stringify({customer:cliente,delivery:entrega,items:items.map(item=>({
mode:item.modo,metres:item.metros,grade:item.nota,production:item.production,
quality:item.qualityStatus,
@@ -127,7 +127,7 @@
arquivos.forEach(f=>enviar(f).catch(()=>{}));
pintaEnvio();
}
const arquivosDoCarrinho=()=>[...pedido,...(busy&&itemAtual?[itemAtual]:[])].flatMap(it=>it.localFiles||[]);
const arquivosDoCarrinho=()=>[...pedido.filter(it=>it.marcado!==false),...(busy&&itemAtual?[itemAtual]:[])].flatMap(it=>it.localFiles||[]);
const gb=n=>(n/1073741824).toLocaleString('pt-BR',{maximumFractionDigits:1})+' GB';
const mb=n=>n>=1073741824 ? gb(n) : Math.round(n/1048576)+' MB';
// Where the uploads stand, and roughly how long is left from the recent speed.
@@ -147,13 +147,15 @@
return {total, sent, fase, pct:Math.floor(sent/total*100),
restante: fase==='enviando' && taxa>0 ? (total-sent)/taxa : null};
}
const tempo=s=>s<60 ? 'menos de 1 min' : 'cerca de '+Math.ceil(s/60)+' min';
const falta=s=>s<60 ? 'falta menos de 1 min' : 'faltam cerca de '+duracao(s);
// The time left, measured from the upload's own speed; until there is a
// measure, a range from the size.
const restante=e=>e.restante!=null ? falta(e.restante) : 'estimativa '+faixaEnvio(e.total-e.sent);
function textoEnvio(e) {
if (!e) return '';
if (e.fase==='pronto') return 'Arquivos enviados ✓';
if (e.fase==='pronto') return 'Arquivos enviados';
if (e.fase==='verificando') return 'Arquivos enviados · verificando a segurança…';
return 'Enviando seus arquivos: '+e.pct+'% · '+mb(e.sent)+' de '+mb(e.total)+
(e.restante!=null ? ' · '+tempo(e.restante) : '');
return 'Enviando seus arquivos · '+restante(e);
}
function pintaEnvio() {
const e=estadoEnvio(), texto=textoEnvio(e);
@@ -179,14 +181,20 @@
const i=document.createElement('i'); i.style.width=(e.fase==='verificando'?100:e.pct)+'%'; barra.append(i);
el.append(barra);
}
if (e?.fase==='pronto') el.insertAdjacentHTML('beforeend', icone('okCirculo'));
if (texto) el.append(document.createTextNode(texto));
if (e?.fase==='enviando') {
const nota=document.createElement('small'); nota.className='envioNota';
nota.textContent='O tempo depende da velocidade da sua internet. Mantenha a página aberta.';
el.append(nota);
}
el.classList.toggle('ok', e?.fase==='pronto');
}
// Visible on every page while it runs.
if (topo) topo.textContent = !e || e.fase==='pronto' ? '' : e.fase==='verificando' ? '· verificando' : '· enviando '+e.pct+'%';
if (topo) topo.textContent = !e || e.fase==='pronto' ? '' : e.fase==='verificando' ? '· verificando' : e.restante!=null ? '· '+duracao(e.restante) : '· enviando';
if (busy && texto) {
message(texto);
$('bPagar').textContent = e && e.fase!=='pronto' ? (e.fase==='verificando' ? 'Verificando os arquivos…' : 'Enviando arquivos… '+e.pct+'%') : $('bPagar').textContent;
$('bPagarTx').textContent = e && e.fase!=='pronto' ? (e.fase==='verificando' ? 'Verificando os arquivos…' : 'Enviando arquivos… '+restante(e)) : $('bPagarTx').textContent;
}
}
// Leaving the page pauses an upload; it resumes, but the customer is warned.
@@ -205,7 +213,7 @@
// The cart's package: billed metres and value. The charged freight is
// quoted again by the server from the approved items.
const pacote = () => {
const items=[...pedido,...(itemAtual?[itemAtual]:[])];
const items=itensAPagar();
return {metres:items.reduce((t,it)=>t+(it.cob||0),0), cents:Math.round(items.reduce((t,it)=>t+(it.total||0),0)*100)};
};
let freightQuoted = null;
@@ -244,7 +252,7 @@
if (!clienteOk() || !entrega.cotado) return;
if (!enderecoOk()) return message('Preencha o endereço de entrega.');
if (!cartPodeEnviar()) return message('Revise a qualidade e confirme a ressalva de cada item antes de enviar o pedido.');
const cart = [...pedido,...(itemAtual?[itemAtual]:[])];
const cart = itensAPagar();
if (!cart.length) return message('Adicione um item ao pedido.');
const initialCart=cartSnapshot();
busy = true;
@@ -330,8 +338,7 @@
event.target.disabled=true;
try {
const order=await api('/orders/dev-paid',{quote_id:draftId});
pedido=[]; itemAtual=null; limpaPaineis();
await window.dtfClearCart?.();
await pagos();
message('Pedido #'+order.number+' criado e disponível no Kanban.');
await refresh();
} catch(error) { message(error.message); event.target.disabled=false; }
@@ -357,8 +364,7 @@
const quote=await api('/quotes/'+draftId);
if (quote.status==='paid') {
clearInterval(waitTimer);
pedido=[]; itemAtual=null; limpaPaineis();
await window.dtfClearCart?.();
await pagos();
await refresh();
} else if (quote.payment?.status==='rejected') {
// Refused after the bank's confirmation or the provider's review.
@@ -386,12 +392,19 @@
function confirmado(quote) {
clearInterval(waitTimer);
const box=node('div',null,'confirmado');
box.append(node('h4','Pagamento confirmado'),
const titulo=node('h4','Pagamento confirmado');
titulo.insertAdjacentHTML('afterbegin', icone('okCirculo'));
box.append(titulo,
node('p','Pedido #'+quote.order.number+' recebido e enviado para a produção. Você acompanha cada etapa em Meus pedidos.'));
button('Ver meus pedidos',()=>location.assign('/portal.html?order='+quote.order.id),box);
button('Ver meus pedidos',()=>location.assign('/conta/pedidos?order='+quote.order.id),box);
actions.append(box);
}
const cartOk=()=>!!quotedCart && quotedCart===cartSnapshot();
// What was paid leaves the cart; unchecked items stay for another order.
async function pagos() {
pedido=pedido.filter(it=>it.marcado===false); itemAtual=null; limpaPaineis();
if (pedido.length) pintaPedido(); else await window.dtfClearCart?.();
}
// Paying: card (the default, paid on this page) or PIX (paid on its own page).
async function escolhaPagamento(quote, version) {
@@ -404,6 +417,7 @@
const radios=opcoes.map(([key,label])=>{
const el=button(label,()=>{ if(escolhido!==key){ escolhido=key; pinta(); } },grupo,'metodo');
el.setAttribute('role','radio'); el.dataset.metodo=key;
el.insertAdjacentHTML('afterbegin', icone(key==='pix'?'qrcode':'cartao'));
return el;
});
actions.append(node('h4','Forma de pagamento'),grupo,area);
@@ -451,10 +465,14 @@
code.readOnly=true;code.value=intent.pix_qr_code;code.setAttribute('aria-label','Código PIX copia e cola');
linha.append(code);
const copiar=button('Copiar código',async()=>{
try{ await navigator.clipboard.writeText(intent.pix_qr_code); copiar.textContent='Código copiado'; }
try{ await navigator.clipboard.writeText(intent.pix_qr_code);
copiar.replaceChildren(); copiar.insertAdjacentHTML('beforeend', icone('ok')+'<span>Código copiado</span>'); }
catch(_){ code.select(); }
},linha);
copiar.insertAdjacentHTML('afterbegin', icone('copiar'));
const prazo=node('p',null,'prazo');
prazo.insertAdjacentHTML('afterbegin', icone('relogio'));
const prazoTx=node('span'); prazo.append(prazoTx);
box.append(linha,prazo,node('p','Aguardando a confirmação do pagamento…','aguarda'));
actions.append(box);
waitForOrder();
@@ -463,7 +481,7 @@
if (!Number.isFinite(fim)) { prazo.remove(); return; }
const tick=()=>{
const s=Math.max(0,Math.ceil((fim-Date.now())/1000));
prazo.textContent='Pague em '+String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');
prazoTx.textContent='Pague em '+String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');
if (s>0) return;
clearInterval(pixClock);
const fimBox=node('div',null,'pixBox');

View File

@@ -7,79 +7,14 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/site-shell.css">
<style>
:root{
/* tokens do site institucional */
--font-family-base:"Roboto",sans-serif; --font-family-title:"Inter",sans-serif;
--font-color-base:#03060b; --font-color-title:#03060b;
--font-weight-base:400; --font-weight-title:700; --font-weight-subtitle:600; --font-weight-button:600;
--font-size-title:3rem; --font-size-subtitle:2rem; --font-size-button:1.4rem;
--font-size-small:1.2rem; --font-size-base:1.4rem; --font-size-medium:1.6rem; --font-size-big:1.8rem;
--line-height:1.5;
--white:#fff; --black:#03060b;
--gray-1:#f9f9f9; --gray-2:#c3c3c3; --gray-3:#707070; --gray-4:#363636;
--primary-color:#ffa81a; --secondary-color:#ff7f0a;
--white-transparency:#ffffffb3; --transition:all .4s ease;
/* apelidos usados nesta pagina, mapeados nos tokens acima */
--marinho:var(--black); --papel:var(--white); --cinza:var(--gray-1);
--tx:var(--font-color-base); --fraco:var(--gray-3); --linha:#e6e6e6;
--laranja:var(--primary-color); --laranja2:var(--secondary-color);
--vermelho:#D94A28; --verde:#2E9E5B; --verde-ml:#00A650; --verde-claro:#57D68A;
--sombra:0 1px 2px rgba(3,6,11,.05), 0 4px 14px rgba(3,6,11,.05);
--sombra-alta:0 2px 6px rgba(3,6,11,.07), 0 10px 30px rgba(3,6,11,.11)}
*{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth;font-size:62.5%}
body{background:var(--white);color:var(--font-color-base);font-family:var(--font-family-base);
font-size:var(--font-size-base);font-weight:var(--font-weight-base);line-height:var(--line-height);
-webkit-font-smoothing:antialiased}
h1,h2,h3{font-family:var(--font-family-title);color:var(--font-color-title);
font-weight:var(--font-weight-title);letter-spacing:normal;line-height:1.15}
.w{max-width:1020px;margin:0 auto;padding:0 22px}
section{padding:clamp(34px,5vw,56px) 0}
section+section{border-top:1px solid var(--linha)}
.prova+section{border-top:0}
/* tarja rotativa, como no site */
.tarja{background:var(--laranja);color:#111;text-align:center;padding:8px 16px;
font-family:"Inter",sans-serif;font-weight:700;font-size:12px;letter-spacing:.02em}
.tarja a{color:#111;text-decoration:none}
/* topo */
.topo{background:#fff;border-bottom:1px solid var(--linha);position:sticky;top:0;z-index:60}
.topo .w{display:flex;align-items:center;gap:22px;padding-top:15px;padding-bottom:15px}
.logo{display:flex;align-items:center;text-decoration:none;white-space:nowrap;flex:none}
.logo img{height:26px;width:auto;display:block}
.logotexto{font-family:"Inter",sans-serif;font-weight:700;font-size:19px;color:var(--black);
letter-spacing:-.03em}
.logotexto i{font-style:normal;color:var(--laranja)}
.busca{flex:1;max-width:420px;position:relative}
.busca input{width:100%;padding:9px 14px 9px 36px;border:1px solid var(--linha);border-radius:22px;
font-family:inherit;font-size:13px;background:var(--cinza);color:var(--tx)}
.busca input:focus{outline:none;border-color:var(--laranja);background:#fff}
.busca .lupa{position:absolute;left:13px;top:50%;transform:translateY(-50%);color:var(--fraco);font-size:13px}
.acts{margin-left:auto;display:flex;gap:18px;align-items:center;font-size:12.5px}
.acts a{color:var(--tx);text-decoration:none;display:flex;align-items:center;gap:6px}
.acts a:hover{color:var(--laranja)}
/* menu */
.menu{background:#fff;border-bottom:1px solid var(--linha)}
.menu .w{display:flex;gap:0;flex-wrap:wrap}
.mi{position:relative}
.mi>a{display:block;padding:13px 16px;font-family:"Inter",sans-serif;font-weight:600;
font-size:12.5px;color:var(--tx);text-decoration:none;white-space:nowrap}
.mi:hover>a{color:var(--laranja)}
.mi.on>a{color:var(--laranja);box-shadow:inset 0 -2px 0 var(--laranja)}
.sub{display:none;position:absolute;top:100%;left:0;background:#fff;border:1px solid var(--linha);
border-radius:0 0 9px 9px;min-width:224px;padding:7px 0;z-index:70;
box-shadow:0 10px 26px rgba(0,0,0,.09)}
.mi:hover .sub,.mi:focus-within .sub{display:block}
.sub a{display:block;padding:8px 17px;font-size:12.5px;color:var(--fraco);text-decoration:none}
.sub a:hover{color:var(--laranja);background:var(--cinza)}
@media(max-width:820px){
.menu .w{overflow-x:auto;flex-wrap:nowrap}
.sub{display:none!important}
.busca{display:none}
}
.hero{background:#000;color:#fff;position:relative;overflow:hidden}
.hero .fundo{position:absolute;inset:0;pointer-events:none}
@@ -93,13 +28,13 @@ section+section{border-top:1px solid var(--linha)}
@media(max-width:940px){.hgrid{grid-template-columns:1fr;gap:22px}}
h1{font-size:clamp(24px,3.6vw,38px);font-weight:700;max-width:20ch;line-height:1.08}
h1 em{font-style:normal;color:var(--laranja)}
.hero .sub2{color:#B7C1D2;font-size:14px;max-width:56ch;margin-top:10px}
.hero .sub2{color:#B7C1D2;font-size:16px;max-width:56ch;margin-top:10px}
.hero .sub2 b{color:#fff}
.hcta{display:flex;gap:12px;margin-top:16px;flex-wrap:wrap;align-items:center}
.btn1{background:var(--laranja);color:#241703;border:0;border-radius:9px;padding:12px 24px;
font-family:"Inter",sans-serif;font-weight:700;font-size:14.5px;cursor:pointer;text-decoration:none}
font-family:"Inter",sans-serif;font-weight:700;font-size:16px;cursor:pointer;text-decoration:none}
.btn1:hover{background:var(--laranja2)}
.mini{font-size:12px;color:#8D9AB0}
.mini{font-size:14.5px;color:#8D9AB0}
/* preços lado a lado, em faixa */
.pfaixa{display:flex;gap:10px;flex-wrap:wrap}
@@ -110,28 +45,28 @@ h1 em{font-style:normal;color:var(--laranja)}
.pcard:hover{background:rgba(255,168,26,.12);border-color:rgba(255,168,26,.45)}
.pcard.dest{border-color:rgba(255,168,26,.5);background:rgba(255,168,26,.09)}
.pcard .marc{position:absolute;top:-8px;left:13px;background:var(--verde);color:#fff;
font-family:"Inter",sans-serif;font-size:8.5px;font-weight:700;letter-spacing:.09em;
font-family:"Inter",sans-serif;font-size:11px;font-weight:700;letter-spacing:.09em;
text-transform:uppercase;padding:2px 8px;border-radius:4px;white-space:nowrap}
.pcard .apartir{display:block;font-size:9.5px;color:var(--verde-claro);letter-spacing:.09em;
.pcard .apartir{display:block;font-size:12px;color:var(--verde-claro);letter-spacing:.09em;
text-transform:uppercase;font-family:"Inter",sans-serif;font-weight:700;margin-bottom:2px}
.pcard .vl{font-family:"Inter",sans-serif;font-size:20px;font-weight:700;color:var(--laranja);
line-height:1;display:block;margin-bottom:3px}
.pcard .vl small{font-size:11px;font-weight:600;color:#8D9AB0}
.pcard .nm{font-family:"Inter",sans-serif;font-size:12.5px;font-weight:700;color:#fff;display:block}
.pcard .dsc{font-size:10.5px;color:#8D9AB0;display:block;margin-top:1px}
.pcard .vl small{font-size:13.5px;font-weight:600;color:#8D9AB0}
.pcard .nm{font-family:"Inter",sans-serif;font-size:15px;font-weight:700;color:#fff;display:block}
.pcard .dsc{font-size:13px;color:#8D9AB0;display:block;margin-top:1px}
/* selos e barra */
.selos1{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-bottom:11px}
.sel{font-family:"Inter",sans-serif;font-size:9.5px;font-weight:700;letter-spacing:.09em;
.sel{font-family:"Inter",sans-serif;font-size:12px;font-weight:700;letter-spacing:.09em;
text-transform:uppercase;padding:3px 9px;border-radius:5px}
.sel.top{background:var(--laranja);color:#241703}
.sel.novo{background:rgba(46,158,91,.18);color:#7FD4A0;border:1px solid rgba(46,158,91,.4)}
.sel.pos{background:rgba(255,255,255,.09);color:#B7C1D2;border:1px solid rgba(255,255,255,.16)}
.barrah{border-top:1px solid rgba(255,255,255,.1);padding:11px 0 14px;
display:flex;gap:24px;flex-wrap:wrap;font-size:12px;color:#B7C1D2}
display:flex;gap:24px;flex-wrap:wrap;font-size:14.5px;color:#B7C1D2}
.barrah span{display:flex;align-items:center;gap:7px}
.barrah b{color:#fff;font-family:"Inter",sans-serif}
.barrah i{color:var(--laranja);font-style:normal;font-size:9px}
.barrah i{color:var(--laranja);font-style:normal;font-size:11.5px}
/* aviso do topo do envio */
@@ -170,7 +105,7 @@ h1 em{font-style:normal;color:var(--laranja)}
.ec .top,.ec .in{position:relative;z-index:1}
.ec:hover{border-color:var(--laranja);box-shadow:var(--sombra-alta);transform:translateY(-2px)}
.ec .marc{display:block;background:var(--verde-ml);color:#fff;text-align:center;
font-family:"Inter",sans-serif;font-size:9px;font-weight:700;letter-spacing:.14em;
font-family:"Inter",sans-serif;font-size:11.5px;font-weight:700;letter-spacing:.14em;
text-transform:uppercase;padding:5px 8px;position:relative;z-index:2}
.ec.f{border-color:var(--verde-ml)}
.ec.f:hover{border-color:var(--verde-ml)}
@@ -187,25 +122,25 @@ h1 em{font-style:normal;color:var(--laranja)}
</g></svg>")}
.ec .top{padding:17px 20px;display:flex;justify-content:space-between;align-items:flex-start;gap:10px}
.ec.f .top{background:#FFF4E3} .ec.a .top{background:var(--cinza)} .ec.u .top{background:#EEF4FB}
.ec .top h3{font-family:"Inter",sans-serif;font-size:17px;font-weight:700;color:var(--tx)}
.ec .top h3{font-family:"Inter",sans-serif;font-size:18px;font-weight:700;color:var(--tx)}
.ec .top .p{font-family:"Inter",sans-serif;font-weight:700;font-size:15px;white-space:nowrap;
.ec .top .p{font-family:"Inter",sans-serif;font-weight:700;font-size:16.5px;white-space:nowrap;
color:var(--tx);text-align:right;line-height:1.15}
.ec .top .p i{display:block;font-style:normal;font-family:"Roboto",sans-serif;font-size:10px;
.ec .top .p i{display:block;font-style:normal;font-family:"Roboto",sans-serif;font-size:12.5px;
font-weight:500;color:var(--fraco);margin-bottom:1px;letter-spacing:0;
text-transform:uppercase;letter-spacing:.1em}
.chama2{background:#F1FBF5;border-top:1px solid #BFE6CE;border-bottom:1px solid #BFE6CE;
padding:9px 20px;font-size:11.5px;color:#1F5C3A;line-height:1.45;position:relative;z-index:1}
.chama2 b{font-family:"Inter",sans-serif;font-weight:700;color:var(--verde-ml);font-size:12.5px}
.ec .top .p small{font-size:11px;font-weight:600;color:var(--fraco)}
padding:9px 20px;font-size:14px;color:#1F5C3A;line-height:1.45;position:relative;z-index:1}
.chama2 b{font-family:"Inter",sans-serif;font-weight:700;color:var(--verde-ml);font-size:15px}
.ec .top .p small{font-size:13.5px;font-weight:600;color:var(--fraco)}
.ec .in{padding:18px 20px 20px}
.ec .in p{font-size:13px;color:var(--fraco);margin-bottom:13px}
.ec ul{list-style:none;font-size:12.5px;margin-bottom:15px}
.ec .in p{font-size:15px;color:var(--fraco);margin-bottom:13px}
.ec ul{list-style:none;font-size:15px;margin-bottom:15px}
.ec li{padding:4px 0 4px 19px;position:relative;color:var(--fraco)}
.ec li::before{content:"✓";position:absolute;left:2px;color:var(--verde);font-size:11px}
.ec .go{font-family:"Inter",sans-serif;font-weight:700;font-size:13.5px;color:var(--laranja);display:block}
.ec .lg{display:block;font-size:10.5px;color:var(--fraco);letter-spacing:.1em;
.ec li::before{content:"✓";position:absolute;left:2px;color:var(--verde);font-size:13.5px}
.ec .go{font-family:"Inter",sans-serif;font-weight:700;font-size:15.5px;color:var(--laranja);display:block}
.ec .lg{display:block;font-size:13px;color:var(--fraco);letter-spacing:.1em;
text-transform:uppercase;font-family:"Inter",sans-serif;font-weight:700;margin-bottom:5px}
/* painel focado */
@@ -216,9 +151,9 @@ h1 em{font-style:normal;color:var(--laranja)}
@media(max-width:820px){.focoGrid{grid-template-columns:1fr}}
.voltar{display:inline-flex;align-items:center;gap:7px;background:#fff;
border:1px solid var(--linha);border-radius:8px;padding:8px 14px 8px 11px;
font-family:"Inter",sans-serif;font-weight:600;font-size:12.5px;color:var(--tx);
font-family:"Inter",sans-serif;font-weight:600;font-size:15px;color:var(--tx);
cursor:pointer;margin-bottom:14px;box-shadow:var(--sombra);transition:var(--transition)}
.voltar span{font-size:15px;line-height:1;color:var(--fraco)}
.voltar span{font-size:16.5px;line-height:1;color:var(--fraco)}
.voltar:hover{border-color:var(--laranja);color:var(--laranja)}
.voltar:hover span{color:var(--laranja)}
.cat{border:1px solid var(--linha);border-radius:14px;background:#fff;overflow:hidden;
@@ -228,9 +163,9 @@ h1 em{font-style:normal;color:var(--laranja)}
flex-wrap:wrap;border-bottom:1px solid var(--linha);background:var(--cinza);padding:18px 22px}
.cat.file .cabfoco{background:#FFF4E3}
.cabfoco h3{font-family:"Inter",sans-serif;font-size:20px;font-weight:700}
.cabfoco .pr2{font-family:"Inter",sans-serif;font-weight:700;font-size:17px;white-space:nowrap}
.cabfoco p{font-size:12.5px;color:var(--fraco);width:100%;margin-top:4px}
.cabfoco .lg{font-size:10.5px;color:var(--fraco);letter-spacing:.1em;text-transform:uppercase;
.cabfoco .pr2{font-family:"Inter",sans-serif;font-weight:700;font-size:18px;white-space:nowrap}
.cabfoco p{font-size:15px;color:var(--fraco);width:100%;margin-top:4px}
.cabfoco .lg{font-size:13px;color:var(--fraco);letter-spacing:.1em;text-transform:uppercase;
font-family:"Inter",sans-serif;font-weight:700}
/* upload */
@@ -248,15 +183,15 @@ h1 em{font-style:normal;color:var(--laranja)}
.cam.on .mk2{border-color:var(--verde-ml);
background:radial-gradient(circle,var(--verde-ml) 0 3px,#fff 4px)}
.cam div{flex:1;min-width:0;overflow-wrap:anywhere}
.cam b{display:block;font-family:"Inter",sans-serif;font-size:12.5px;margin-bottom:2px}
.cam span{display:block;font-size:10.5px;color:var(--fraco);line-height:1.45}
.cam b{display:block;font-family:"Inter",sans-serif;font-size:15px;margin-bottom:2px}
.cam span{display:block;font-size:13px;color:var(--fraco);line-height:1.45}
.cam em{display:block;margin-top:6px;font-style:normal;font-family:"Inter",sans-serif;
font-weight:700;font-size:12px;color:var(--verde-ml);line-height:1.3}
font-weight:700;font-size:14.5px;color:var(--verde-ml);line-height:1.3}
.trocouCam{margin-top:-4px;margin-bottom:11px;padding:10px 12px;border-radius:9px;
background:#FFF6E8;border:1px solid #F0D9AE;font-size:11.5px;color:#7A5A16;line-height:1.5}
background:#FFF6E8;border:1px solid #F0D9AE;font-size:14px;color:#7A5A16;line-height:1.5}
.trocouCam b{color:#B07A18;font-family:"Inter",sans-serif;font-weight:700}
.trocarModo{margin:-4px 0 11px;padding:10px 12px;border-radius:9px;background:#F1FBF5;
border:1px solid #BFE6CE;font-size:11.5px;color:#1F5C3A;line-height:1.5}
border:1px solid #BFE6CE;font-size:14px;color:#1F5C3A;line-height:1.5}
.trocarModo b{font-family:"Inter",sans-serif;font-weight:700}
.trocarModo label{display:block;margin-top:7px;cursor:pointer}
.zona{border:2px dashed var(--laranja);border-radius:13px;padding:44px 20px;text-align:center;
@@ -267,50 +202,50 @@ h1 em{font-style:normal;color:var(--laranja)}
background:var(--laranja);color:#fff;font-size:22px;line-height:46px;font-weight:700;
box-shadow:0 0 0 7px rgba(255,168,26,.18)}
.zona:hover .ico,.zona.sobre .ico{background:var(--laranja2)}
.zona b{display:block;font-family:"Inter",sans-serif;font-size:18px;font-weight:700;
.zona b{display:block;font-family:"Inter",sans-serif;font-size:19px;font-weight:700;
margin-bottom:5px;color:var(--tx);line-height:1.2}
.zona span{font-size:12px;color:#8A6A2E;display:block;max-width:30ch;margin:0 auto;line-height:1.5}
.zona span{font-size:14.5px;color:#8A6A2E;display:block;max-width:30ch;margin:0 auto;line-height:1.5}
.zona .bt{display:inline-block;margin-top:13px;background:var(--laranja);color:#241703;
font-family:"Inter",sans-serif;font-weight:700;font-size:13px;border-radius:8px;
font-family:"Inter",sans-serif;font-weight:700;font-size:15px;border-radius:8px;
padding:9px 18px}
.zona:hover .bt{background:var(--laranja2)}
.recusa{margin-top:11px;padding:11px 13px;border-radius:9px;background:#FFF3F0;
border:1px solid #F0C4B8;font-size:11.5px;color:#8C3B22;line-height:1.5}
border:1px solid #F0C4B8;font-size:14px;color:#8C3B22;line-height:1.5}
.recusa b{font-family:"Inter",sans-serif;font-weight:700;color:#8C3B22}
.aoVivo{margin-top:11px;padding:11px 13px;border-radius:9px;background:var(--cinza);
border:1px solid var(--linha);font-size:12px;color:var(--fraco);line-height:1.45;
border:1px solid var(--linha);font-size:14.5px;color:var(--fraco);line-height:1.45;
display:flex;align-items:center;gap:9px}
.aoVivo b{font-family:"Inter",sans-serif;font-weight:700;color:var(--tx);font-size:12.5px}
.aoVivo b{font-family:"Inter",sans-serif;font-weight:700;color:var(--tx);font-size:15px}
.aoVivo .pt2{flex:none;width:8px;height:8px;border-radius:50%;background:var(--gray-2)}
.aoVivo.pronto{background:#F1FBF5;border-color:#BFE6CE;color:#2A6B45}
.aoVivo.pronto .pt2{background:var(--verde-ml)}
.aoVivo.pronto b{color:#1F5C3A}
.b2{width:100%;padding:13px;border-radius:9px;border:1px solid var(--marinho);background:#fff;
color:var(--tx);font-family:"Inter",sans-serif;font-weight:600;font-size:14.5px;cursor:pointer}
color:var(--tx);font-family:"Inter",sans-serif;font-weight:600;font-size:16px;cursor:pointer}
.cat.file .b2{background:var(--laranja);border-color:var(--laranja);color:#241703}
.b2:hover{background:#111;color:#fff}
.cat.file .b2:hover{background:var(--laranja2);color:#241703}
.it{display:flex;justify-content:space-between;gap:9px;padding:10px 13px;background:var(--cinza);
border-radius:8px;margin-bottom:7px;align-items:center;font-size:12.5px}
border-radius:8px;margin-bottom:7px;align-items:center;font-size:15px}
.it b{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.it span{color:var(--fraco);font-size:11px;flex:none}
.it button{border:0;background:none;color:var(--fraco);cursor:pointer;font-size:15px}
.it span{color:var(--fraco);font-size:13.5px;flex:none}
.it button{border:0;background:none;color:var(--fraco);cursor:pointer;font-size:16.5px}
/* repetição do FILE */
.rep{background:var(--cinza);border-radius:9px;padding:12px 14px;margin-top:8px}
.rep .l1{display:flex;justify-content:space-between;gap:10px;align-items:center;margin-bottom:9px}
.rep .l1 b{font-weight:500;font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rep .l1 button{border:0;background:none;color:var(--fraco);cursor:pointer;font-size:15px;flex:none}
.rep .l1 b{font-weight:500;font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rep .l1 button{border:0;background:none;color:var(--fraco);cursor:pointer;font-size:16.5px;flex:none}
.prog{height:5px;border-radius:4px;background:#E4E7EC;overflow:hidden;margin:9px 0 5px}
.prog i{display:block;height:100%;background:var(--laranja);border-radius:4px;
transition:width .35s ease}
.progT{display:block;font-size:10.5px;color:var(--fraco)}
.progT{display:block;font-size:13px;color:var(--fraco)}
.progF{margin:9px 0 4px;padding:9px 11px;border-radius:7px;background:#FFF6E8;
border:1px solid #F0D9AE;font-size:11px;color:#7A5A16;line-height:1.5}
border:1px solid #F0D9AE;font-size:13.5px;color:#7A5A16;line-height:1.5}
.progF b{font-family:"Inter",sans-serif;font-weight:700;color:#B07A18}
.rep label{display:block;font-size:10px;color:var(--fraco);margin-bottom:3px}
.rep label{display:block;font-size:12.5px;color:var(--fraco);margin-bottom:3px}
.rep input{width:100%;padding:7px 9px;border:1px solid var(--linha);border-radius:6px;
font-family:inherit;font-size:13px;background:#fff;color:var(--tx)}
font-family:inherit;font-size:15px;background:#fff;color:var(--tx)}
.rep input:focus{outline:none;border-color:var(--laranja)}
/* fila de artes ao lado da montagem */
@@ -323,16 +258,16 @@ h1 em{font-style:normal;color:var(--laranja)}
.mont{position:sticky;top:104px;border:1px solid var(--linha);border-radius:11px;
background:var(--cinza);padding:12px;box-shadow:var(--sombra)}
.montcab{display:flex;justify-content:space-between;align-items:baseline;gap:10px;margin-bottom:9px}
.montcab b{font-family:"Inter",sans-serif;font-size:12.5px;font-weight:700}
.montcab b{font-family:"Inter",sans-serif;font-size:15px;font-weight:700}
.zoomB{display:flex;align-items:center;gap:4px;margin-left:auto}
.zoomB button{width:22px;height:22px;border-radius:6px;border:1px solid var(--linha);
background:#fff;color:var(--tx);cursor:pointer;font-size:14px;line-height:1;padding:0}
background:#fff;color:var(--tx);cursor:pointer;font-size:16px;line-height:1;padding:0}
.zoomB button:hover{border-color:var(--laranja);color:var(--laranja)}
.zoomB button:disabled{opacity:.35;cursor:not-allowed}
.zoomB span{font-family:"Inter",sans-serif;font-size:10.5px;font-weight:700;
.zoomB span{font-family:"Inter",sans-serif;font-size:13px;font-weight:700;
color:var(--fraco);min-width:34px;text-align:center}
#vArea.ampliado{overflow:auto}
.montcab span{font-family:"Inter",sans-serif;font-weight:700;font-size:16px;color:var(--tx)}
.montcab span{font-family:"Inter",sans-serif;font-weight:700;font-size:17.5px;color:var(--tx)}
#vArea{max-height:min(56vh,560px);min-height:200px;overflow-y:auto;overscroll-behavior:contain;
border:1px solid var(--linha);border-radius:8px;background:#fff}
#vArea::-webkit-scrollbar{width:8px}
@@ -344,83 +279,83 @@ h1 em{font-style:normal;color:var(--laranja)}
#vArea .folhaPrevias{min-height:200px;padding:10px;display:grid;gap:12px;background:#F8F9FA}
#vArea .folhaPrevia{margin:0;background:#fff;border:1px solid var(--linha);border-radius:6px;overflow:hidden}
#vArea .folhaPrevia img{width:100%;height:auto;max-height:520px;object-fit:contain;display:block;background:#fff}
#vArea .folhaPrevia figcaption{padding:7px 9px;font-size:10.5px;color:var(--fraco);border-top:1px solid var(--linha)}
#vArea .folhaPrevia figcaption{padding:7px 9px;font-size:13px;color:var(--fraco);border-top:1px solid var(--linha)}
#vArea .semmont{border:0;min-height:200px;display:flex;flex-direction:column;
align-items:center;justify-content:center}
#vUso:empty{display:none}
.semmont{border:1px dashed var(--linha);border-radius:8px;background:#fff;
padding:30px 14px;text-align:center}
.semmont b{display:block;font-family:"Inter",sans-serif;font-size:13px;margin-bottom:4px}
.semmont span{font-size:11.5px;color:var(--fraco);line-height:1.4;display:block}
.montpe{font-size:10.5px;color:var(--fraco);margin-top:9px;line-height:1.45}
.semmont b{display:block;font-family:"Inter",sans-serif;font-size:15px;margin-bottom:4px}
.semmont span{font-size:14px;color:var(--fraco);line-height:1.4;display:block}
.montpe{font-size:13px;color:var(--fraco);margin-top:9px;line-height:1.45}
/* caixa de requisitos · o que a máquina aceita */
.req{position:sticky;top:104px;background:#fff;border:1px solid var(--linha);
border-radius:14px;box-shadow:var(--sombra);padding:16px 16px 6px}
@media(max-width:820px){.req{position:static}}
.reqcab{font-family:"Inter",sans-serif;font-size:18px;font-weight:700;line-height:1.15;
.reqcab{font-family:"Inter",sans-serif;font-size:19px;font-weight:700;line-height:1.15;
letter-spacing:normal;color:var(--tx);margin-bottom:12px}
.req details{border-top:1px solid var(--linha)}
.req details:first-of-type{border-top:0}
.req summary{cursor:pointer;list-style:none;padding:9px 0;font-family:"Inter",sans-serif;
font-size:13px;font-weight:700;color:var(--tx);display:flex;align-items:center;gap:8px}
font-size:15px;font-weight:700;color:var(--tx);display:flex;align-items:center;gap:8px}
.req summary::-webkit-details-marker{display:none}
.req summary::before{content:"+";flex:none;width:16px;height:16px;border-radius:50%;
border:1px solid rgba(0,166,80,.45);background:#fff;color:var(--verde-ml);font-size:11px;
border:1px solid rgba(0,166,80,.45);background:#fff;color:var(--verde-ml);font-size:13.5px;
line-height:14px;text-align:center;font-weight:700}
.req summary:hover::before{background:#F1FBF5}
.req details[open] summary::before{content:"−";background:var(--verde-ml);
border-color:var(--verde-ml);color:#fff}
.req ul{list-style:none;padding:0 0 9px;margin:0}
.req li{position:relative;padding:4px 0 4px 17px;font-size:11.5px;color:var(--fraco);
.req li{position:relative;padding:4px 0 4px 17px;font-size:14px;color:var(--fraco);
line-height:1.5}
.req li b{color:var(--tx);font-weight:700;font-family:"Inter",sans-serif}
.req li::before{position:absolute;left:0;top:4px;font-size:11px;font-weight:700}
.req li::before{position:absolute;left:0;top:4px;font-size:13.5px;font-weight:700}
.req li.s::before{content:"✓";color:var(--verde-ml)}
.req li.a::before{content:"!";color:var(--laranja)}
.req li.n::before{content:"×";color:var(--vermelho)}
.req .obs2{font-size:10.5px;color:var(--fraco);padding:0 0 9px;line-height:1.45;
.req .obs2{font-size:13px;color:var(--fraco);padding:0 0 9px;line-height:1.45;
font-style:italic}
.mont .usoBom,.mont .usoRuim{margin-top:8px;font-size:11px;line-height:1.4}
.mont .usoBom,.mont .usoRuim{margin-top:8px;font-size:13.5px;line-height:1.4}
/* artes avulsas */
.art{background:var(--cinza);border-radius:9px;padding:11px 12px;margin-bottom:7px;
transition:box-shadow .3s,background .3s}
.art.nova{background:#FFF6E8;box-shadow:inset 0 0 0 1px rgba(255,168,26,.55)}
.art.nova .l1 b::after{content:"nova";font-family:"Inter",sans-serif;font-size:9px;
.art.nova .l1 b::after{content:"nova";font-family:"Inter",sans-serif;font-size:11.5px;
font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#B07A18;
background:#FFE9C4;border-radius:3px;padding:1px 5px;margin-left:7px;vertical-align:1px}
.art .l1{display:flex;justify-content:space-between;gap:8px;align-items:center;margin-bottom:8px}
.art .l1 b{font-weight:500;font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.art .l1 button{border:0;background:none;color:var(--fraco);cursor:pointer;font-size:15px;flex:none}
.art .l1 b{font-weight:500;font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.art .l1 button{border:0;background:none;color:var(--fraco);cursor:pointer;font-size:16.5px;flex:none}
.art .cps{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.atalhos{display:flex;flex-wrap:wrap;gap:5px;align-items:center;margin-top:9px}
.atalhos .tm,.atalhos .gr{border:1px solid var(--linha);background:#fff;border-radius:6px;
padding:5px 9px;font-family:"Inter",sans-serif;font-size:11px;font-weight:600;
padding:5px 9px;font-family:"Inter",sans-serif;font-size:13.5px;font-weight:600;
color:var(--tx);cursor:pointer;transition:var(--transition)}
.atalhos .tm:hover,.atalhos .gr:hover{border-color:var(--laranja);color:var(--laranja)}
.atalhos .tm.on{background:var(--verde-ml);border-color:var(--verde-ml);color:#fff}
.atalhos .gr{font-size:14px;line-height:1;padding:4px 9px}
.atalhos .gr{font-size:16px;line-height:1;padding:4px 9px}
.atalhos .gr.on{background:var(--laranja);border-color:var(--laranja);color:#241703}
.atalhos .sep{flex:1;min-width:4px}
.art label{display:block;font-size:10px;color:var(--fraco);margin-bottom:3px}
.art label{display:block;font-size:12.5px;color:var(--fraco);margin-bottom:3px}
.art input{width:100%;padding:7px 9px;border:1px solid var(--linha);border-radius:6px;
font-family:inherit;font-size:13px;background:#fff;color:var(--tx)}
font-family:inherit;font-size:15px;background:#fff;color:var(--tx)}
.art input:focus{outline:none;border-color:var(--laranja)}
.qb2{height:7px;border-radius:5px;background:#E4E7EC;overflow:hidden;margin-top:9px}
.qb2 i{display:block;height:100%;border-radius:5px}
.qb2 i.ok{background:var(--verde)}.qb2 i.av{background:var(--laranja)}.qb2 i.er{background:var(--vermelho)}
.calc{font-size:10.5px;margin-top:6px;display:flex;justify-content:space-between;gap:8px;color:var(--fraco)}
.calc{font-size:13px;margin-top:6px;display:flex;justify-content:space-between;gap:8px;color:var(--fraco)}
.calc .ok{color:var(--verde)}.calc .av{color:var(--laranja)}.calc .er{color:var(--vermelho)}
.dica{margin-top:9px;background:#FFF8EE;border:1px solid #F0D9AE;border-radius:8px;
padding:10px 12px;font-size:11.5px;color:#7A5A16;line-height:1.45}
.dica b{display:block;font-family:"Inter",sans-serif;color:#B07A18;font-size:12px;margin-bottom:3px}
padding:10px 12px;font-size:14px;color:#7A5A16;line-height:1.45}
.dica b{display:block;font-family:"Inter",sans-serif;color:#B07A18;font-size:14.5px;margin-bottom:3px}
.dica button{display:block;width:100%;margin-top:10px;padding:10px 14px;border-radius:7px;
border:1px solid var(--verde-ml);background:var(--verde-ml);color:#fff;
font-family:"Inter",sans-serif;font-weight:700;font-size:13px;letter-spacing:.01em;
font-family:"Inter",sans-serif;font-weight:700;font-size:15px;letter-spacing:.01em;
cursor:pointer;box-shadow:0 2px 8px rgba(0,166,80,.28);transition:var(--transition)}
.dica button:hover{background:#00934A;border-color:#00934A;box-shadow:0 4px 14px rgba(0,166,80,.38)}
.dicaok{margin-top:8px;font-size:11px;color:var(--verde)}
.dicaok{margin-top:8px;font-size:13.5px;color:var(--verde)}
/* cota em escala da peça dentro da largura do filme */
.viz{margin-top:10px;max-width:100%}
@@ -428,27 +363,27 @@ h1 em{font-style:normal;color:var(--laranja)}
.vizL i{flex:1;height:1px;background:var(--gray-2);position:relative}
.vizL i::before{content:"";position:absolute;top:-3px;width:1px;height:7px;background:var(--gray-2)}
.vizL i:first-child::before{left:0} .vizL i:last-child::before{right:0}
.vizL b{font-family:"Inter",sans-serif;font-size:9.5px;font-weight:700;letter-spacing:.08em;
.vizL b{font-family:"Inter",sans-serif;font-size:12px;font-weight:700;letter-spacing:.08em;
text-transform:uppercase;color:var(--fraco)}
.vizRow{display:flex;gap:7px;align-items:flex-end}
.vizTira{flex:none;max-width:100%}
.vizH{flex:none;display:flex;align-items:center;justify-content:center;padding-left:4px;
border-left:1px solid var(--gray-2);min-height:22px}
.vizH span{writing-mode:vertical-rl;font-family:"Inter",sans-serif;font-size:9.5px;
.vizH span{writing-mode:vertical-rl;font-family:"Inter",sans-serif;font-size:12px;
font-weight:700;color:var(--fraco);white-space:nowrap}
.tira{display:flex;background:#fff;border:1px solid var(--linha);border-radius:4px;
padding:2px;overflow:hidden;min-height:22px;width:100%}
.pc{flex:none;overflow:hidden;display:flex;align-items:center;justify-content:center;
background:#F2F2F2;border-radius:2px}
.pc img{width:100%;height:100%;object-fit:contain;display:block}
.pc span{font-family:"Inter",sans-serif;font-size:9px;font-weight:700;color:var(--fraco)}
.pc span{font-family:"Inter",sans-serif;font-size:11.5px;font-weight:700;color:var(--fraco)}
.folga{flex:1;min-width:0;display:flex;align-items:center;justify-content:center;border-radius:2px;
background:repeating-linear-gradient(45deg,#FBFBFB,#FBFBFB 4px,#F0F0F0 4px,#F0F0F0 8px)}
.folga span{font-size:8.5px;color:var(--fraco);white-space:nowrap;overflow:hidden}
.vizPe{font-size:10.5px;color:var(--fraco);margin-top:6px;line-height:1.4}
.folga span{font-size:11px;color:var(--fraco);white-space:nowrap;overflow:hidden}
.vizPe{font-size:13px;color:var(--fraco);margin-top:6px;line-height:1.4}
.vizPe b{color:var(--tx);font-family:"Inter",sans-serif;font-weight:700}
.resumoA{background:#fff;border:1px solid var(--linha);border-radius:9px;padding:11px 13px;
font-size:12px;margin-bottom:12px;display:none}
font-size:14.5px;margin-bottom:12px;display:none}
.resumoA.on{display:block}
.resumoA .r{display:flex;justify-content:space-between;padding:2px 0}
.resumoA .r b{font-family:"Inter",sans-serif}
@@ -457,18 +392,18 @@ h1 em{font-style:normal;color:var(--laranja)}
.qual{background:#fff;border:1px solid var(--linha);border-radius:13px;padding:22px;margin-top:16px;display:none}
.qual.on{display:block}
.qh{display:flex;justify-content:space-between;align-items:flex-end;gap:14px;flex-wrap:wrap;margin-bottom:14px}
.qh h3{font-family:"Inter",sans-serif;font-size:17px;font-weight:700}
.qh h3{font-family:"Inter",sans-serif;font-size:18px;font-weight:700}
.qh .pct{font-family:"Inter",sans-serif;font-weight:700;font-size:38px;line-height:1;color:var(--verde)}
.qh .pct small{font-size:14px;color:var(--fraco)}
.qh .pct small{font-size:16px;color:var(--fraco)}
.qbar{height:13px;border-radius:8px;background:var(--cinza);overflow:hidden}
.qbar i{display:block;height:100%;border-radius:8px;background:var(--verde)}
.qitens{display:grid;grid-template-columns:repeat(auto-fit,minmax(178px,1fr));gap:7px;margin-top:13px}
.qi{display:flex;gap:8px;align-items:center;border:1px solid var(--linha);border-radius:8px;
padding:7px 10px;font-size:11.5px}
.qi .c{width:15px;height:15px;border-radius:50%;display:grid;place-items:center;font-size:8.5px;
padding:7px 10px;font-size:14px}
.qi .c{width:15px;height:15px;border-radius:50%;display:grid;place-items:center;font-size:11px;
flex:none;color:#fff;font-family:"Inter",sans-serif;font-weight:700}
.qi b{display:block;font-family:"Inter",sans-serif;font-size:11.5px}
.qi span{color:var(--fraco);font-size:10.5px}
.qi b{display:block;font-family:"Inter",sans-serif;font-size:14px}
.qi span{color:var(--fraco);font-size:13px}
.qi.ok .c{background:var(--verde)}.qi.av .c{background:var(--laranja);color:#241703}
.qi.er .c{background:var(--vermelho)}
.qi.fix{border-color:#BBD9E8;background:#F2F9FC}
@@ -480,20 +415,20 @@ h1 em{font-style:normal;color:var(--laranja)}
.ciente.recusa h4{color:#8C3B22}
.ciente.recusa label{display:none}
.ciente.recusa .lista{color:#8C3B22}
.ciente h4{font-family:"Inter",sans-serif;font-size:14px;font-weight:700;color:#B07A18;margin-bottom:5px}
.ciente p{font-size:12.5px;color:#7A5A16;line-height:1.5}
.ciente .lista{margin:10px 0 0;font-size:12px;color:#7A5A16}
.ciente h4{font-family:"Inter",sans-serif;font-size:16px;font-weight:700;color:#B07A18;margin-bottom:5px}
.ciente p{font-size:15px;color:#7A5A16;line-height:1.5}
.ciente .lista{margin:10px 0 0;font-size:14.5px;color:#7A5A16}
.ciente .lista div{padding:3px 0 3px 15px;position:relative}
.ciente .lista div::before{content:"·";position:absolute;left:4px;font-weight:700}
.ciente label{display:flex;gap:9px;align-items:flex-start;margin-top:12px;padding-top:11px;
border-top:1px solid #F0D9AE;cursor:pointer;font-size:12.5px;color:#5E4512}
border-top:1px solid #F0D9AE;cursor:pointer;font-size:15px;color:#5E4512}
.ciente input{margin-top:2px;width:16px;height:16px;flex:none;accent-color:#B07A18}
@keyframes pulsa{0%,100%{transform:scale(1);box-shadow:0 0 0 0 rgba(217,74,40,.55)}
50%{transform:scale(1.04);box-shadow:0 0 0 12px rgba(217,74,40,0)}}
.qbts{display:flex;gap:9px;flex-wrap:wrap;margin-top:16px}
.qbts .qb{flex:1;min-width:220px}
.qb{padding:11px 17px;border-radius:8px;border:1px solid var(--linha);background:#fff;
font-family:"Inter",sans-serif;font-weight:600;font-size:13.5px;cursor:pointer;color:var(--tx)}
font-family:"Inter",sans-serif;font-weight:600;font-size:15.5px;cursor:pointer;color:var(--tx)}
.qb.p{background:var(--laranja);border-color:var(--laranja);color:#241703}
.qb.chama{background:var(--vermelho);border-color:var(--vermelho);color:#fff;
animation:pulsa 1.6s ease-in-out infinite}
@@ -505,31 +440,31 @@ h1 em{font-style:normal;color:var(--laranja)}
.prev{background:#fff;border:1px solid var(--linha);border-radius:13px;padding:22px;margin-top:16px;display:none}
.prev.on{display:block}
.prev .ph{display:flex;justify-content:space-between;align-items:flex-end;gap:14px;flex-wrap:wrap;margin-bottom:14px}
.prev .ph h3{font-family:"Inter",sans-serif;font-size:17px;font-weight:700}
.prev .ph p{font-size:12.5px;color:var(--fraco)}
.prev .ph h3{font-family:"Inter",sans-serif;font-size:18px;font-weight:700}
.prev .ph p{font-size:15px;color:var(--fraco)}
.prev .ph .mt{font-family:"Inter",sans-serif;font-weight:700;font-size:22px;white-space:nowrap}
.prev .ph .mt small{font-size:12px;color:var(--fraco);font-weight:600}
.prev .ph .mt small{font-size:14.5px;color:var(--fraco);font-weight:600}
.pgrid{display:grid;grid-template-columns:300px 1fr;gap:18px;align-items:start}
@media(max-width:760px){.pgrid{grid-template-columns:1fr}}
.pfolha{background:var(--cinza);border:1px solid var(--linha);border-radius:10px;padding:12px}
.pfolha canvas,.pfolha img{width:100%;height:auto;display:block;border-radius:6px;background:#fff}
.pfolha .semprev,.pfolha .carregando{padding:40px 16px;text-align:center;color:var(--fraco);
font-size:12px;background:#fff;border-radius:6px}
.pfolha .semprev b{display:block;font-family:"Inter",sans-serif;color:var(--tx);font-size:13px;margin-bottom:4px}
.usoBom,.usoRuim{margin-top:10px;border-radius:8px;padding:9px 12px;font-size:11.5px;line-height:1.45}
font-size:14.5px;background:#fff;border-radius:6px}
.pfolha .semprev b{display:block;font-family:"Inter",sans-serif;color:var(--tx);font-size:15px;margin-bottom:4px}
.usoBom,.usoRuim{margin-top:10px;border-radius:8px;padding:9px 12px;font-size:14px;line-height:1.45}
.usoBom{background:#F1F8F3;border:1px solid #CDE6D6;color:#2A6B45}
.usoRuim{background:#FFF8EE;border:1px solid #F0D9AE;color:#7A5A16}
.usoBom b,.usoRuim b{display:block;font-family:"Inter",sans-serif;font-size:12.5px;margin-bottom:2px}
.pinfo .li{display:flex;gap:10px;align-items:flex-start;padding:7px 0;font-size:12.5px;
.usoBom b,.usoRuim b{display:block;font-family:"Inter",sans-serif;font-size:15px;margin-bottom:2px}
.pinfo .li{display:flex;gap:10px;align-items:flex-start;padding:7px 0;font-size:15px;
border-bottom:1px solid var(--linha)}
.pinfo .li:last-child{border:0}
.pinfo .li .c{width:19px;height:19px;border-radius:50%;display:grid;place-items:center;flex:none;
margin-top:1px;font-size:10px;color:#fff;font-family:"Inter",sans-serif;font-weight:700}
.pinfo .li b{display:block;font-family:"Inter",sans-serif;font-size:12.5px}
.pinfo .li span{color:var(--fraco);font-size:11.5px}
margin-top:1px;font-size:12.5px;color:#fff;font-family:"Inter",sans-serif;font-weight:700}
.pinfo .li b{display:block;font-family:"Inter",sans-serif;font-size:15px}
.pinfo .li span{color:var(--fraco);font-size:14px}
.pcta{display:flex;gap:9px;flex-wrap:wrap;margin-top:16px;padding-top:15px;border-top:1px solid var(--linha)}
.pb{padding:11px 18px;border-radius:8px;border:1px solid var(--linha);background:#fff;
font-family:"Inter",sans-serif;font-weight:600;font-size:13.5px;cursor:pointer;color:var(--tx)}
font-family:"Inter",sans-serif;font-weight:600;font-size:15.5px;cursor:pointer;color:var(--tx)}
.pb.p{background:var(--laranja);border-color:var(--laranja);color:#241703}
.pb:hover{border-color:#111}
@@ -538,10 +473,10 @@ h1 em{font-style:normal;color:var(--laranja)}
.prova{padding:clamp(22px,3vw,34px) 0}
.prova .w{display:grid;grid-template-columns:repeat(auto-fit,minmax(178px,1fr));gap:14px}
.p1{border-left:2px solid var(--laranja);padding:2px 0 2px 13px}
.p1 b{display:block;font-family:"Inter",sans-serif;font-size:14px;font-weight:700;
.p1 b{display:block;font-family:"Inter",sans-serif;font-size:16px;font-weight:700;
line-height:1.25;color:var(--tx);margin-bottom:5px}
.p1 b em{font-style:normal;color:var(--laranja)}
.p1 span{display:block;font-size:11.5px;color:var(--fraco);line-height:1.5}
.p1 span{display:block;font-size:14px;color:var(--fraco);line-height:1.5}
/* ── Carrinho no padrão de checkout: entrega à esquerda, resumo fixo à direita ── */
.carr{display:none;margin-top:18px}
@@ -552,37 +487,37 @@ h1 em{font-style:normal;color:var(--laranja)}
.carr h3{font-family:"Inter",sans-serif;font-size:19px;font-weight:700;margin-bottom:12px}
.cxE{background:#fff;border:1px solid var(--linha);border-radius:12px;
box-shadow:var(--sombra);padding:17px 18px;margin-bottom:14px}
.cxE h4{font-family:"Inter",sans-serif;font-size:15px;font-weight:700;margin-bottom:11px}
.cxE h4{font-family:"Inter",sans-serif;font-size:16.5px;font-weight:700;margin-bottom:11px}
/* itens do pedido */
.item{display:flex;gap:11px;align-items:flex-start;padding:11px 0;border-top:1px solid var(--linha)}
.item:first-child{border-top:0;padding-top:2px}
.item .nm2{flex:1;min-width:0}
.item .nm2 b{display:block;font-family:"Inter",sans-serif;font-size:13.5px;color:var(--tx)}
.item .nm2 span{font-size:11.5px;color:var(--fraco)}
.item .vl2{font-family:"Inter",sans-serif;font-weight:700;font-size:14px;color:var(--tx);
.item .nm2 b{display:block;font-family:"Inter",sans-serif;font-size:15.5px;color:var(--tx)}
.item .nm2 span{font-size:14px;color:var(--fraco)}
.item .vl2{font-family:"Inter",sans-serif;font-weight:700;font-size:16px;color:var(--tx);
white-space:nowrap}
.item .x{border:0;background:none;color:var(--gray-2);cursor:pointer;font-size:17px;
.item .x{border:0;background:none;color:var(--gray-2);cursor:pointer;font-size:18px;
line-height:1;padding:0 0 0 4px}
.item .x:hover{color:var(--vermelho)}
.rot{font-family:"Inter",sans-serif;font-size:10px;font-weight:700;letter-spacing:.12em;
.rot{font-family:"Inter",sans-serif;font-size:12.5px;font-weight:700;letter-spacing:.12em;
text-transform:uppercase;color:var(--fraco);margin:0 0 7px}
.carr .l{display:flex;justify-content:space-between;font-size:12.5px;padding:5px 0;
.carr .l{display:flex;justify-content:space-between;font-size:15px;padding:5px 0;
color:var(--fraco);gap:14px}
.carr .l b{color:var(--tx);font-family:"Inter",sans-serif;font-weight:700;white-space:nowrap}
/* identificação do cliente */
.subC{font-size:11.5px;color:var(--fraco);line-height:1.5;margin:-4px 0 12px}
.subC{font-size:14px;color:var(--fraco);line-height:1.5;margin:-4px 0 12px}
.campos{display:grid;grid-template-columns:1fr 1fr;gap:11px}
@media(max-width:620px){.campos{grid-template-columns:1fr}}
.cp.larga{grid-column:1/-1}
.cp label{display:block;font-size:10.5px;color:var(--fraco);margin-bottom:5px}
.cp label{display:block;font-size:13px;color:var(--fraco);margin-bottom:5px}
.cp input{width:100%;padding:11px 12px;border-radius:8px;border:1px solid var(--linha);
background:#fff;font-family:inherit;font-size:13.5px;color:var(--tx)}
background:#fff;font-family:inherit;font-size:15.5px;color:var(--tx)}
.cp input:focus{outline:none;border-color:var(--laranja)}
.cp input.ruim{border-color:var(--vermelho);background:#FFF6F4}
.cp input.bom{border-color:var(--verde-ml)}
.cp .err{display:block;font-size:10.5px;color:var(--vermelho);margin-top:4px;min-height:13px}
.cp .err{display:block;font-size:13px;color:var(--vermelho);margin-top:4px;min-height:13px}
/* forma de entrega, como no checkout do marketplace */
.entrega{display:grid;gap:9px}
@@ -596,42 +531,42 @@ h1 em{font-style:normal;color:var(--laranja)}
.opE.on .mk{border-color:var(--verde-ml);
background:radial-gradient(circle,var(--verde-ml) 0 4px,#fff 5px)}
.opE div{flex:1;min-width:0}
.opE b{display:block;font-family:"Inter",sans-serif;font-size:13.5px;margin-bottom:2px}
.opE span{font-size:11.5px;color:var(--fraco);line-height:1.5}
.opE b{display:block;font-family:"Inter",sans-serif;font-size:15.5px;margin-bottom:2px}
.opE span{font-size:14px;color:var(--fraco);line-height:1.5}
.opE em{flex:none;font-style:normal;font-family:"Inter",sans-serif;font-weight:700;
font-size:13px;color:var(--verde-ml);white-space:nowrap}
font-size:15px;color:var(--verde-ml);white-space:nowrap}
.cep{display:none;margin-top:10px;padding:12px;background:var(--cinza);border-radius:9px}
.cep.on{display:block}
.cep label{display:block;font-size:10.5px;color:var(--fraco);margin-bottom:6px}
.cep label{display:block;font-size:13px;color:var(--fraco);margin-bottom:6px}
.cepL{display:flex;gap:8px}
.cepL input{flex:1;min-width:0;padding:10px 12px;border-radius:8px;background:#fff;
border:1px solid var(--linha);font-family:inherit;font-size:13px;color:var(--tx)}
border:1px solid var(--linha);font-family:inherit;font-size:15px;color:var(--tx)}
.cepL input:focus{outline:none;border-color:var(--laranja)}
.cepL button{flex:none;width:auto;padding:10px 18px;border-radius:8px;
border:1px solid var(--linha);cursor:pointer;background:#fff;color:var(--tx);
font-family:"Inter",sans-serif;font-weight:600;font-size:12.5px}
font-family:"Inter",sans-serif;font-weight:600;font-size:15px}
.cepL button:hover{border-color:var(--laranja);color:var(--laranja)}
.cep p{font-size:11.5px;color:var(--fraco);margin-top:9px;line-height:1.5}
.cep p{font-size:14px;color:var(--fraco);margin-top:9px;line-height:1.5}
.cep p:empty{display:none}
.cep .endereco{margin-top:12px}
.cep .err{color:var(--vermelho)}
.avisoE:empty{display:none}
.avisoE{font-size:11.5px;color:#1F5C3A;background:#F1FBF5;border:1px solid #BFE6CE;
.avisoE{font-size:14px;color:#1F5C3A;background:#F1FBF5;border:1px solid #BFE6CE;
border-radius:8px;padding:10px 12px;line-height:1.5;margin-top:9px}
/* resumo da compra */
.resumo{position:sticky;top:104px;background:#fff;border:1px solid var(--linha);
border-radius:12px;box-shadow:var(--sombra);padding:17px 18px}
@media(max-width:900px){.resumo{position:static}}
.resumo h4{font-family:"Inter",sans-serif;font-size:16px;font-weight:700;margin-bottom:12px}
.resumo h4{font-family:"Inter",sans-serif;font-size:17.5px;font-weight:700;margin-bottom:12px}
.resumo .l{padding:6px 0}
.carr .tot{display:flex;justify-content:space-between;align-items:baseline;gap:12px;
font-family:"Inter",sans-serif;font-size:15px;font-weight:700;padding-top:12px;margin-top:8px;
font-family:"Inter",sans-serif;font-size:16.5px;font-weight:700;padding-top:12px;margin-top:8px;
border-top:1px solid var(--linha);color:var(--tx)}
.carr .tot b{font-size:23px;color:var(--tx)}
.cbts{display:grid;gap:9px;margin-top:14px}
.cbts button{width:100%;padding:13px;border-radius:9px;border:0;background:var(--laranja);
color:#241703;font-family:"Inter",sans-serif;font-weight:700;font-size:14.5px;cursor:pointer;
color:#241703;font-family:"Inter",sans-serif;font-weight:700;font-size:16px;cursor:pointer;
transition:var(--transition)}
.cbts button:hover{background:var(--laranja2)}
.cbts button:disabled{opacity:.4;cursor:not-allowed}
@@ -644,10 +579,10 @@ h1 em{font-style:normal;color:var(--laranja)}
.trilho::-webkit-scrollbar{height:0}
.slide{flex:0 0 clamp(244px,76%,316px);scroll-snap-align:start;
background:var(--cinza);border:1px solid var(--linha);border-radius:13px;padding:17px 18px}
.slide .et2{font-family:"Inter",sans-serif;font-size:9.5px;font-weight:700;letter-spacing:.14em;
.slide .et2{font-family:"Inter",sans-serif;font-size:12px;font-weight:700;letter-spacing:.14em;
text-transform:uppercase;color:var(--laranja);margin-bottom:8px;display:block}
.slide h3{font-family:"Inter",sans-serif;font-size:16px;font-weight:700;margin-bottom:6px;line-height:1.18}
.slide p{font-size:12.5px;color:var(--fraco);line-height:1.5}
.slide h3{font-family:"Inter",sans-serif;font-size:17.5px;font-weight:700;margin-bottom:6px;line-height:1.18}
.slide p{font-size:15px;color:var(--fraco);line-height:1.5}
/* selo circular · o benefício em número, com os anéis da marca em volta */
.slide{position:relative;overflow:hidden}
.stop{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
@@ -661,26 +596,26 @@ h1 em{font-style:normal;color:var(--laranja)}
.selo::after{inset:-21px;border:1px solid rgba(0,166,80,.09)}
.selo b{font-family:"Inter",sans-serif;font-weight:700;font-size:19px;line-height:1;
color:var(--verde-ml);letter-spacing:-.01em}
.selo b em{font-style:normal;font-size:11px;margin-left:1px}
.selo span{font-family:"Inter",sans-serif;font-weight:700;font-size:8px;line-height:1.2;
.selo b em{font-style:normal;font-size:13.5px;margin-left:1px}
.selo span{font-family:"Inter",sans-serif;font-weight:700;font-size:10.5px;line-height:1.2;
letter-spacing:.07em;text-transform:uppercase;color:var(--fraco);margin-top:4px;padding:0 5px}
.slide .num{font-family:"Inter",sans-serif;font-weight:700;font-size:26px;
color:var(--laranja);line-height:1;margin-bottom:6px}
.slide ul{list-style:none;font-size:12px;margin-top:9px}
.slide ul{list-style:none;font-size:14.5px;margin-top:9px}
.slide li{padding:4px 0 4px 19px;position:relative;color:var(--fraco)}
.slide li::before{content:"✓";position:absolute;left:2px;color:var(--verde);font-size:11px}
.slide li::before{content:"✓";position:absolute;left:2px;color:var(--verde);font-size:13.5px}
.slide li.no::before{content:"!";color:var(--laranja);font-weight:700}
.slide table{width:100%;font-size:12px;margin-top:9px;border-collapse:collapse}
.slide table{width:100%;font-size:14.5px;margin-top:9px;border-collapse:collapse}
.slide td{padding:5px 0;border-bottom:1px solid #E3E3E3;color:var(--fraco)}
.slide td+td{text-align:right;font-family:"Inter",sans-serif;font-weight:700;color:var(--tx)}
.slide td.cab{font-family:"Inter",sans-serif;font-size:9.5px;font-weight:700;letter-spacing:.1em;
.slide td.cab{font-family:"Inter",sans-serif;font-size:12px;font-weight:700;letter-spacing:.1em;
text-transform:uppercase;color:var(--fraco)}
.slide tr:last-child td{border:0}
.slide .dest{background:#fff;border-radius:8px;padding:9px 11px;margin-top:9px;
font-size:11.5px;color:var(--tx)}
font-size:14px;color:var(--tx)}
.navc{display:flex;gap:8px;align-items:center;margin-top:14px}
.navc button{width:32px;height:32px;border-radius:50%;border:1px solid var(--linha);
background:#fff;color:var(--tx);cursor:pointer;font-size:15px;line-height:1}
background:#fff;color:var(--tx);cursor:pointer;font-size:16.5px;line-height:1}
.navc button:hover{border-color:var(--laranja);color:var(--laranja)}
.pontos{display:flex;gap:5px;margin-left:auto}
.pt{width:20px;height:3px;border-radius:2px;background:var(--linha);border:0;padding:0;cursor:pointer}
@@ -689,31 +624,19 @@ h1 em{font-style:normal;color:var(--laranja)}
h2{font-size:clamp(23px,3.6vw,34px);font-weight:700;max-width:20ch}
h2 em{font-style:normal;color:var(--laranja)}
.et{font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:var(--laranja);
.et{font-size:13.5px;letter-spacing:.2em;text-transform:uppercase;color:var(--laranja);
font-family:"Inter",sans-serif;font-weight:700;margin-bottom:10px}
.lead{color:var(--fraco);font-size:15px;max-width:56ch;margin-top:12px}
.lead{color:var(--fraco);font-size:16.5px;max-width:56ch;margin-top:12px}
.dor{display:grid;grid-template-columns:repeat(auto-fit,minmax(215px,1fr));gap:13px;margin-top:24px}
.dc{background:#fff;border:1px solid var(--linha);border-radius:12px;padding:18px 19px}
.dc .ic{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;
font-family:"Inter",sans-serif;font-weight:700;font-size:14px;margin-bottom:11px;
font-family:"Inter",sans-serif;font-weight:700;font-size:16px;margin-bottom:11px;
background:#FBEEEA;color:var(--vermelho)}
.dc h3{font-size:15px;font-weight:700;margin-bottom:4px}
.dc p{font-size:12.5px;color:var(--fraco)}
.nota{background:var(--cinza);border-radius:11px;padding:14px 18px;margin-top:15px;font-size:13px;color:var(--fraco)}
.dc h3{font-size:16.5px;font-weight:700;margin-bottom:4px}
.dc p{font-size:15px;color:var(--fraco)}
.nota{background:var(--cinza);border-radius:11px;padding:14px 18px;margin-top:15px;font-size:15px;color:var(--fraco)}
.nota b{color:var(--tx);font-family:"Inter",sans-serif}
footer{background:#000;color:#8D9AB0;padding:26px 0 20px;font-size:12.5px}
.frow{display:flex;gap:26px;align-items:flex-start;flex-wrap:wrap;padding-bottom:18px}
.fmarca{display:flex;align-items:center;gap:9px;font-family:"Inter",sans-serif;
font-weight:700;font-size:13px;color:var(--laranja);letter-spacing:.06em;white-space:nowrap}
.fmarca img{height:22px;width:auto;display:block;filter:brightness(0) invert(1)}
footer a{color:#8D9AB0;text-decoration:none}
footer a:hover{color:var(--laranja2)}
.flinks{display:flex;gap:18px;flex-wrap:wrap;font-size:12.5px}
.fcont{margin-left:auto;text-align:right;font-size:12.5px;line-height:1.7}
@media(max-width:720px){.fcont{margin-left:0;text-align:left}}
.fbase{border-top:1px solid rgba(255,255,255,.1);padding-top:14px;display:flex;
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">
@@ -727,13 +650,9 @@ footer a:hover{color:var(--laranja2)}
<header class="topo"><div class="w">
<a class="logo" href="https://www.dropstaratacado.com.br" aria-label="Dropstar Atacado"><img src="https://cdn.vnda.com.br/dropstar/2025/12/17/logo-header-15125700-16129080.png?v=1766009286" alt="Dropstar"></a>
<div class="busca">
<span class="lupa">⌕</span>
<input type="search" placeholder="Buscar" aria-label="Buscar">
</div>
<div class="acts">
<a href="/portal.html">Minha conta</a>
<a href="/carrinho" id="cartLink">Carrinho (0)</a><span class="cartEnvio" id="cartEnvio" aria-live="polite"></span>
<a href="/conta"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span class="actTx">Minha conta</span></a>
<a href="/carrinho" id="cartLink" aria-label="Carrinho, 0 itens"><span class="cartIc"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/></svg><b class="cartN" id="cartN" hidden>0</b></span><span class="actTx">Carrinho</span></a><span class="cartEnvio" id="cartEnvio" aria-live="polite"></span>
</div>
</div></header>
@@ -918,7 +837,7 @@ footer a:hover{color:var(--laranja2)}
<div class="recusa" id="recusa" style="display:none"></div>
<div id="lista"></div>
<div class="zona" id="zona" tabindex="0" role="button">
<div class="ico">↑</div>
<div class="ico"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/></svg></div>
<b id="zTit">Arraste aqui</b><span id="zSub"></span>
<span id="zLimite">Até 5 GB por arquivo.</span>
<span class="bt">ou escolher no computador</span>
@@ -1023,14 +942,14 @@ footer a:hover{color:var(--laranja2)}
<span>Li e entendi. Quero imprimir assim mesmo.</span></label>
</div>
</div>
<button type="button" class="cAdd" id="bAdd" disabled>Adicionar ao carrinho</button>
<button type="button" class="cAdd" id="bAdd" disabled><svg class="ic" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M12 10v6"/><path d="M9 13h6"/></svg><span>Adicionar ao carrinho</span></button>
<p class="cPe">Revisamos e reencaixamos antes de imprimir, sem custo.<span id="cPartes"></span></p>
</div>
</aside>
<dialog class="ampliaDlg" id="ampliaDlg" aria-labelledby="ampliaTit">
<div class="ampliaCab"><b id="ampliaTit">Sua folha</b>
<button type="button" id="ampliaFecha" aria-label="Fechar">×</button></div>
<button type="button" id="ampliaFecha" aria-label="Fechar"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button></div>
<div id="ampliaArea"></div>
</dialog>
</div>
@@ -1044,9 +963,9 @@ footer a:hover{color:var(--laranja2)}
<div class="qual" id="qual">
<div class="qh">
<div><h3 id="qNome">sua folha</h3>
<span style="font-size:12px;color:var(--fraco)" id="qSub"></span></div>
<span style="font-size:14.5px;color:var(--fraco)" id="qSub"></span></div>
<div style="text-align:right"><div class="pct" id="pct">—</div>
<span style="font-size:11px;color:var(--fraco)">qualidade</span></div>
<span style="font-size:13.5px;color:var(--fraco)">qualidade</span></div>
</div>
<div class="qbar"><i id="qbi" style="width:0%"></i></div>
<div class="qitens" id="qitens"></div>
@@ -1062,14 +981,14 @@ footer a:hover{color:var(--laranja2)}
<p id="pSub">confira antes de fechar o pedido</p></div>
<div style="text-align:right">
<div class="mt" id="pMt">—</div>
<span style="font-size:11px;color:var(--fraco)">cobrado por proporção</span></div>
<span style="font-size:13.5px;color:var(--fraco)">cobrado por proporção</span></div>
</div>
<div class="pgrid">
<div class="pfolha"><div id="pArea"></div></div>
<div class="pinfo" id="pInfo"></div>
</div>
<div class="pcta">
<button class="pb p" id="pOk">Adicionar ao carrinho</button>
<button class="pb p" id="pOk"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M12 10v6"/><path d="M9 13h6"/></svg><span>Adicionar ao carrinho</span></button>
<button class="pb" id="pMais">Adicionar mais artes</button>
<button class="pb" id="pVolta">Trocar o arquivo</button>
</div>
@@ -1093,17 +1012,15 @@ footer a:hover{color:var(--laranja2)}
<div class="cxE">
<h4>Seus dados</h4>
<p class="subC">A conta nasce aqui, no pagamento. O CNPJ é o mesmo da nota fiscal,
e é por ele que suas artes ficam guardadas para o próximo pedido.</p>
<div class="campos">
<div class="cp"><label>CNPJ</label>
<input id="fCnpj" inputmode="numeric" maxlength="18" placeholder="00.000.000/0000-00">
<div class="cp"><label>CNPJ <span class="obr" aria-hidden="true">*</span></label>
<input id="fCnpj" aria-required="true" autocomplete="off" inputmode="numeric" maxlength="18" placeholder="00.000.000/0000-00">
<span class="err" id="eCnpj"></span></div>
<div class="cp"><label>WhatsApp</label>
<input id="fZap" inputmode="numeric" maxlength="15" placeholder="(16) 99999-9999">
<div class="cp"><label>WhatsApp <span class="obr" aria-hidden="true">*</span></label>
<input id="fZap" aria-required="true" autocomplete="off" inputmode="numeric" maxlength="15" placeholder="(16) 99999-9999">
<span class="err" id="eZap"></span></div>
<div class="cp larga"><label>E-mail para a nota</label>
<input id="fMail" type="email" placeholder="voce@empresa.com.br">
<div class="cp larga"><label>E-mail para a nota <span class="obr" aria-hidden="true">*</span></label>
<input id="fMail" aria-required="true" autocomplete="off" type="email" placeholder="voce@empresa.com.br">
<span class="err" id="eMail"></span></div>
</div>
</div>
@@ -1114,7 +1031,7 @@ footer a:hover{color:var(--laranja2)}
<button class="opE on" data-ent="retira">
<span class="mk"></span>
<div>
<b>Retirar em Franca</b>
<b><svg class="ic" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M20 10c0 4.99-5.54 10.19-7.4 11.8a1 1 0 0 1-1.2 0C9.54 20.19 4 14.99 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg>Retirar em Franca</b>
<span>Av. Major Elias Motta, 1281 · Jardim São Luís<br>
Segunda a sexta, das 7h às 17h</span>
</div>
@@ -1123,34 +1040,33 @@ footer a:hover{color:var(--laranja2)}
<button class="opE" data-ent="frete">
<span class="mk"></span>
<div>
<b>Receber em casa</b>
<b><svg class="ic" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2"/><path d="M15 18H9"/><path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.62l-3.48-4.35A1 1 0 0 0 17.52 8H14"/><circle cx="17" cy="18" r="2"/><circle cx="7" cy="18" r="2"/></svg>Receber em casa</b>
<span>Entrega pela Jadlog em todo o Brasil. Informe o CEP para ver o valor e o prazo.</span>
</div>
<em id="vFrete">—</em>
</button>
<div class="cep" id="cep">
<label>CEP de entrega</label>
<label>CEP de entrega <span class="obr" aria-hidden="true">*</span></label>
<div class="cepL">
<input id="cepIn" inputmode="numeric" maxlength="9" placeholder="00000-000" autocomplete="postal-code">
<input id="cepIn" aria-required="true" autocomplete="off" inputmode="numeric" maxlength="9" placeholder="00000-000">
</div>
<p id="cepMsg"></p>
<div class="campos endereco" id="endereco">
<div class="cp larga"><label>Quem recebe</label>
<input id="eNome" maxlength="120" autocomplete="name" placeholder="Nome ou empresa"></div>
<div class="cp larga"><label>Rua / avenida</label>
<input id="eRua" maxlength="160" autocomplete="address-line1"></div>
<div class="cp"><label>Número</label>
<input id="eNum" maxlength="20" placeholder="123 ou S/N"></div>
<div class="cp larga"><label>Quem recebe <span class="obr" aria-hidden="true">*</span></label>
<input id="eNome" aria-required="true" autocomplete="off" maxlength="120" placeholder="Nome ou empresa"></div>
<div class="cp larga"><label>Rua / avenida <span class="obr" aria-hidden="true">*</span></label>
<input id="eRua" aria-required="true" autocomplete="off" maxlength="160"></div>
<div class="cp"><label>Número <span class="obr" aria-hidden="true">*</span></label>
<input id="eNum" aria-required="true" autocomplete="off" maxlength="20" placeholder="123 ou S/N"></div>
<div class="cp"><label>Complemento (opcional)</label>
<input id="eComp" maxlength="80" autocomplete="address-line2"></div>
<div class="cp"><label>Bairro</label>
<input id="eBairro" maxlength="80"></div>
<div class="cp"><label>Cidade</label>
<input id="eCidade" maxlength="80" autocomplete="address-level2"></div>
<div class="cp"><label>UF</label>
<input id="eUf" maxlength="2" autocomplete="address-level1" placeholder="SP"></div>
<input id="eComp" autocomplete="off" maxlength="80"></div>
<div class="cp"><label>Bairro <span class="obr" aria-hidden="true">*</span></label>
<input id="eBairro" aria-required="true" autocomplete="off" maxlength="80"></div>
<div class="cp"><label>Cidade <span class="obr" aria-hidden="true">*</span></label>
<input id="eCidade" aria-required="true" autocomplete="off" maxlength="80"></div>
<div class="cp"><label>UF <span class="obr" aria-hidden="true">*</span></label>
<input id="eUf" aria-required="true" autocomplete="off" maxlength="2" placeholder="SP"></div>
</div>
<p class="dicaEnd" id="eEnd"></p>
</div>
</div>
</div>
@@ -1161,7 +1077,7 @@ footer a:hover{color:var(--laranja2)}
<div id="resLin"></div>
<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="bPagar"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2Z"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg><span id="bPagarTx">Ir para o pagamento</span></button>
<button id="bMais" class="sec">Continuar comprando</button>
</div>
<p class="envioArq" id="envioArq" role="status" aria-live="polite"></p>

View File

@@ -18,46 +18,46 @@ a{color:var(--accent-tx)}
.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{height:36px;padding:0 12px;border-radius:8px;border:1px solid var(--line2);background:var(--card2);color:var(--tx);font-size:14px;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{height:44px;border:0;background:var(--accent);color:#0F1113;font-weight:800;font-size:14.5px;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.danger{height:44px;background:transparent;border-color:var(--bad-line);color:var(--bad);font-weight:700;font-size:14.5px}
.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{height:34px;padding:0 12px;border-radius:8px;border:1px solid var(--line2);background:transparent;color:var(--muted);font-size:14px;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{font-size:13.5px;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}
.eyebrow{font-size:13.5px;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)}
input[type=file]{font-size:14px;color:var(--muted);max-width:100%}
.check{display:flex;align-items:center;gap:10px;font-size:14px;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}
.login label{display:flex;flex-direction:column;gap:6px;font-size:14px;font-weight:600;color:var(--muted)}
.login .hint{font-size:13.5px;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{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:14.5px}
.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)}
.tabs .count{font-family:var(--mono);font-size:13.5px;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{display:flex;align-items:center;gap:8px;font-size:14px;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)}
.who{font-size:14px;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:14px;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)}
@@ -74,23 +74,23 @@ main{padding:0 24px 24px}
.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 h2{margin:0;font-size:14.5px;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}
.col-head .n{margin-left:auto;font-family:var(--mono);font-size:13.5px;color:var(--muted)}
.col-head small{font-size:13.5px;color:var(--faint)}
.col .empty{font-size:13.5px;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}
.card .num{font-size:14.5px;font-weight:800}
.card .age{margin-left:auto;font-size:13.5px;color:var(--faint)}
.card .cust{font-size:14px;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)}
.tag-teste{font-size:11px;font-weight:800;letter-spacing:.04em;padding:2px 7px;border-radius:6px;background:var(--warn);color:#1a1300}
.card .foot{display:flex;align-items:center;gap:6px;font-size:12px}
.tag{font-size:13.5px;padding:3px 8px;border-radius:6px;background:var(--card2);color:var(--tx2)}
.tag-teste{font-size:12.5px;font-weight:800;letter-spacing:.04em;padding:2px 7px;border-radius:6px;background:var(--warn);color:#1a1300}
.card .foot{display:flex;align-items:center;gap:6px;font-size:13.5px}
.card .foot .where{margin-left:auto;color:var(--muted)}
.late{font-size:12px;color:#F06A5B;font-weight:600}
.late{font-size:13.5px;color:#F06A5B;font-weight:600}
/* Order panel */
.drawer{position:fixed;inset:0;z-index:10;display:flex;justify-content:flex-end}
@@ -99,10 +99,10 @@ main{padding:0 24px 24px}
.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)}
.stage{font-size:13.5px;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{display:flex;flex-direction:column;gap:6px;font-size:12.5px;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)}
@@ -111,27 +111,27 @@ main{padding:0 24px 24px}
.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}
.facts b{display:block;font-size:14.5px}
.facts span{font-size:14px;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}
.item .title b{font-size:14.5px}
.item .title .mono{margin-left:auto;font-size:14px}
.item .line{font-size:13.5px;color:var(--muted)}
.item .file{display:flex;align-items:center;gap:8px;font-size:14px}
.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 .rev{font-size:14px;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}
.finals .slot{display:flex;flex-direction:column;gap:6px;font-size:14px;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{display:flex;gap:12px;font-size:14px}
.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}
.timeline small{display:block;color:var(--faint);font-size:13.5px}
/* Quotes */
#tab-quotes{padding-top:18px}
@@ -139,8 +139,8 @@ main{padding:0 24px 24px}
.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 b{display:block;font-size:14.5px;overflow-wrap:anywhere}
.qitem span{font-size:13.5px;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}
@@ -148,7 +148,7 @@ main{padding:0 24px 24px}
.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 label{display:flex;flex-direction:column;gap:6px;font-size:13.5px;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)}
@@ -160,11 +160,11 @@ main{padding:0 24px 24px}
.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 .text b{font-size:14.5px}
.issue .text span{font-size:14px;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{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:14px 4px;border-top:1px solid var(--line);margin-top:8px;font-size:14px;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}
@@ -177,7 +177,7 @@ main{padding:0 24px 24px}
.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{display:flex;flex-direction:column;gap:6px;font-size:13.5px;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}
@@ -186,10 +186,10 @@ main{padding:0 24px 24px}
.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 .head em{margin-left:auto;font-style:normal;font-size:13.5px;font-weight:700;color:var(--dotx,var(--muted))}
.integration p{margin:0;font-size:14px;color:var(--muted)}
.integration .buttons{margin-top:auto}
table.log{width:100%;border-collapse:collapse;font-size:13px}
table.log{width:100%;border-collapse:collapse;font-size:14px}
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}
@@ -202,3 +202,8 @@ table.log td{padding:10px 12px;border-bottom:1px solid #1E2226;vertical-align:to
.split .list{width:auto;border-right:0;border-bottom:1px solid var(--line)}
.facts,.pane .fields{grid-template-columns:1fr}
}
/* Icons beside a label: the download buttons, where a card ships, the WhatsApp link. */
.btn.with-icon{display:inline-flex;align-items:center;gap:6px}
.card .foot .where{display:inline-flex;align-items:center;gap:4px}
a.zap{display:inline-flex;align-items:center;gap:4px;vertical-align:bottom}

View File

@@ -54,7 +54,7 @@ function contato(customer){
const d=String(customer.zap).replace(/\D/g,'').replace(/^55(?=\d{10,11}$)/,'');
const shown=d.length===11?'('+d.slice(0,2)+') '+d.slice(2,7)+'-'+d.slice(7):d.length===10?'('+d.slice(0,2)+') '+d.slice(2,6)+'-'+d.slice(6):customer.zap;
const a=node('a',shown,'zap');a.href=href;a.target='_blank';a.rel='noopener noreferrer';
a.title='Abrir conversa no WhatsApp';
a.title='Abrir conversa no WhatsApp';a.prepend(icon(ICON_CHAT));
line.append(a);return line;
}
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;}
@@ -80,6 +80,14 @@ function icon(path){
return svg;
}
const ICON_OK=['M5 12l4 4 10-10'], ICON_WARN=['M12 8v5M12 17h.01','circle 12 12 9'];
// Lucide shapes (ISC licence), in the same stroke as the two above.
const ICON_TRUCK=['M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2','M15 18H9',
'M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.62l-3.48-4.35A1 1 0 0 0 17.52 8H14','circle 17 18 2','circle 7 18 2'];
const ICON_PIN=['M20 10c0 4.99-5.54 10.19-7.4 11.8a1 1 0 0 1-1.2 0C9.54 20.19 4 14.99 4 10a8 8 0 0 1 16 0','circle 12 10 3'];
const ICON_DOWNLOAD=['M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4','m7 10 5 5 5-5','M12 15V3'];
const ICON_CHAT=['M7.9 20A9 9 0 1 0 4 16.1L2 22Z'];
// A download button: the arrow, then its label.
function downloadButton(text,fn,className){const b=button(text,fn,className);b.prepend(icon(ICON_DOWNLOAD));b.classList.add('with-icon');return b;}
async function api(path,body){
const r=await fetch('/api/operator'+path,{headers:{'Content-Type':'application/json'},...(body===undefined?{}:{method:'POST',body:JSON.stringify(body)})});
const d=await r.json();if(!r.ok){const error=new Error(typeof d.detail==='string'?d.detail:'Confira os campos ('+r.status+').');error.status=r.status;throw error;}return d;
@@ -200,7 +208,8 @@ function card(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'));
const where=node('span',dest?'Entrega · '+dest.state:'Retirada','where');where.prepend(icon(dest?ICON_TRUCK:ICON_PIN));
foot.append(status,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'));
@@ -336,8 +345,8 @@ function itemCard(order,item,index){
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'&&row.detail?.source!=='original')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(row?.status==='ready'&&row.detail?.source!=='original')buttons.append(downloadButton('Baixar PDF',download(row.upload_id)));
item.uploads.forEach((uid,i)=>buttons.append(downloadButton(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'));
@@ -350,7 +359,7 @@ function finalsSection(order,revisions,section){
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);}
if(!file.expired){const b=downloadButton('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;}
@@ -504,7 +513,7 @@ function quoteItem(item,fields){
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')));
item.uploads.forEach((uid,i)=>buttons.append(downloadButton(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;
}

152
web/portal.css Normal file
View File

@@ -0,0 +1,152 @@
/* Minha conta: the Site's look (tokens and shell from site-shell.css and
site-v2.css) for sign-in, the overview, the orders and the details. */
[hidden]{display:none!important}
body{background:var(--cinza)}
.topo .acts a[aria-current="page"]{color:var(--laranja-escuro)}
#quem{max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.ped{padding-top:28px;padding-bottom:64px;min-height:70vh}
.ped h1{font-size:30px;font-weight:800;margin-bottom:14px}
.pedAviso{margin-bottom:14px;padding:12px 16px;border-radius:var(--raio2);background:var(--aviso-fundo);border:1px solid var(--aviso-linha);font-size:15px}
.pedMsg{margin-bottom:14px;padding:10px 14px;border-radius:var(--raio2);background:var(--verde-fundo);color:var(--verde);font-size:15px}
.pedMsg.erro{background:#FDE8E6;color:#B42318}
.pedMsg:empty{display:none}
/* Buttons, as on the Site. */
.bPri{display:inline-flex;align-items:center;justify-content:center;gap:8px;height:48px;padding:0 22px;border:0;border-radius:var(--raio2);background:var(--laranja);color:var(--preto);font-family:"Inter",sans-serif;font-weight:800;font-size:16px;text-decoration:none;cursor:pointer}
.bPri:hover:not(:disabled){background:#FFB53D}
.bPri.bMini{height:38px;padding:0 16px;font-size:15px}
.bSec{display:inline-flex;align-items:center;justify-content:center;gap:7px;height:40px;padding:0 16px;border:1px solid #D6D6D6;border-radius:var(--raio2);background:#fff;color:var(--preto);font-family:"Inter",sans-serif;font-weight:700;font-size:15px;cursor:pointer}
.bSec:hover:not(:disabled){border-color:var(--preto)}
.bSec .ic{width:18px;height:18px}
.bLink{align-self:flex-start;margin-top:12px;padding:0;border:0;background:none;color:var(--laranja-escuro);font-family:inherit;font-weight:600;font-size:15px;cursor:pointer;text-decoration:underline;text-underline-offset:3px}
button:disabled{opacity:.6;cursor:default}
/* Forms. */
.acessoGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px;align-items:start}
.bloco{display:flex;flex-direction:column;margin-bottom:16px;padding:22px;border:1px solid var(--linha);border-radius:var(--raio);background:#fff}
.bloco h2{font-size:20px;font-weight:800}
.nota2{margin:4px 0 8px;font-size:14.5px;color:var(--fraco)}
.nota2 a{color:var(--laranja-escuro)}
.bloco label{margin:10px 0 5px;font-size:14px;font-weight:600;color:var(--texto2)}
.bloco label .obs{font-weight:400;color:var(--fraco)}
.bloco input,.busca2 input,.busca2 select,.correcaoForm textarea{width:100%;height:46px;padding:0 13px;border:1px solid #D6D6D6;border-radius:var(--raio2);font:inherit;font-size:16px;background:#fff;color:var(--preto)}
.bloco input[readonly]{background:var(--cinza);color:var(--texto2)}
.bloco input:focus,.busca2 input:focus,.busca2 select:focus,.correcaoForm textarea:focus{outline:none;border-color:var(--laranja);box-shadow:0 0 0 3px rgba(255,168,26,.2)}
.bloco>.bPri,.bloco>.bSec{margin-top:16px;align-self:flex-start}
.duas{display:grid;grid-template-columns:1fr 1fr;gap:0 12px}
.tres{display:grid;grid-template-columns:1fr 1fr 90px;gap:0 12px}
.duas>div,.tres>div{display:flex;flex-direction:column}
/* The account area: menu on the side, one view beside it. */
.area{display:grid;grid-template-columns:210px minmax(0,1fr);gap:28px;align-items:start}
.lateral{position:sticky;top:90px;display:flex;flex-direction:column;gap:2px;padding:10px;border:1px solid var(--linha);border-radius:var(--raio);background:#fff}
.lateral a,.lateral button{display:block;padding:10px 12px;border:0;border-radius:var(--raio2);background:none;color:var(--texto2);font:inherit;font-size:15.5px;text-align:left;text-decoration:none;cursor:pointer}
.lateral a:hover,.lateral button:hover{background:var(--cinza);color:var(--preto)}
.lateral a.on{background:var(--aviso-fundo);color:var(--laranja-escuro);font-weight:700}
.lateral button{margin-top:6px;border-top:1px solid var(--linha);border-radius:0 0 var(--raio2) var(--raio2)}
.conta{margin:-8px 0 16px;font-size:15px;color:var(--fraco)}
.acessoIntro{margin:-6px 0 14px;font-size:15px;color:var(--texto2)}
.acessoIntro:empty{display:none}
.tituloGrupo{margin:22px 0 10px;font-size:18px;font-weight:800}
/* Overview. */
.metricas{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
.metrica{display:flex;flex-direction:column;gap:4px;padding:16px 18px;border:1px solid var(--linha);border-radius:var(--raio);background:#fff;color:var(--texto2);font-size:14.5px;text-decoration:none}
.metrica:hover{border-color:var(--laranja)}
.metrica b{font-family:"Inter",sans-serif;font-size:28px;font-weight:800;color:var(--preto)}
.metrica.alerta{border-color:#F3B7A9;background:#FFF6F4}
.verTodos{display:inline-block;margin-top:12px;color:var(--laranja-escuro);font-weight:700;text-decoration:none}
/* Orders: filters, rows, pages. */
.filtros{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}
.chipF{height:36px;padding:0 14px;border:1px solid #D6D6D6;border-radius:999px;background:#fff;color:var(--texto2);font:inherit;font-size:14.5px;font-weight:600;cursor:pointer}
.chipF:hover{border-color:var(--preto)}
.chipF.on{border-color:var(--laranja);background:var(--aviso-fundo);color:var(--laranja-escuro)}
.busca2{display:grid;grid-template-columns:minmax(0,1fr) 200px;gap:10px;margin-bottom:12px}
.lista{border:1px solid var(--linha);border-radius:var(--raio);background:#fff;overflow:hidden}
.lista:empty{display:none}
.item+.item{border-top:1px solid var(--linha)}
.item.pendente{background:#FFFCF6}
.linha{display:grid;grid-template-columns:90px minmax(0,1fr) auto 110px 90px;gap:14px;align-items:center;padding:14px 18px;cursor:pointer}
.item.pendente .linha{cursor:default}
.linha:hover{background:var(--cinza)}
.linha .num{font-family:"Inter",sans-serif;font-size:16px}
.oque{display:flex;flex-direction:column;min-width:0}
.oque span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:15px}
.oque .data{font-size:14px;color:var(--fraco)}
.estadoW{display:flex;align-items:center;gap:6px}
.estado{padding:4px 10px;border-radius:999px;font-family:"Inter",sans-serif;font-size:13.5px;font-weight:700;background:var(--cinza);color:var(--texto2);white-space:nowrap}
.via{font-size:13px;font-weight:700;color:var(--fraco)}
.est-fin{background:var(--verde-fundo);color:var(--verde)}
.est-cor{background:#FDE8E6;color:#B42318}
.est-pay{background:#FFF1D6;color:#8A4B00}
.est-rev{background:#EEF1F5;color:#45505E}
.est-imp,.est-fil,.est-tra,.est-rec{background:#E7F0FB;color:#1D4E89}
.linha .valor{font-family:"Inter",sans-serif;font-size:16px;text-align:right}
.acao{display:flex;justify-content:flex-end}
.acao .bSec{height:36px;padding:0 14px}
.paginas{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:12px}
.pags{display:flex;gap:6px}
.pag{min-width:38px;height:38px;border:1px solid #D6D6D6;border-radius:var(--raio2);background:#fff;font:inherit;font-weight:700;cursor:pointer}
.pag.on{border-color:var(--laranja);background:var(--aviso-fundo);color:var(--laranja-escuro);cursor:default}
.vazio{display:flex;flex-direction:column;align-items:flex-start;gap:12px;padding:26px 22px;font-size:16px;color:var(--texto2)}
/* An order opened in the list. */
.detalhe{padding:4px 18px 18px;border-top:1px dashed var(--linha);background:#fff}
.trilha{display:flex;list-style:none;margin:18px 0 6px;padding:0}
.trilha li{flex:1;position:relative;display:flex;flex-direction:column;align-items:center;gap:6px;text-align:center;font-size:14px;color:var(--fraco)}
.trilha li::before{content:"";position:absolute;top:14px;left:-50%;width:100%;height:2px;background:#E0E0E0;z-index:0}
.trilha li:first-child::before{display:none}
.trilha li.feito::before,.trilha li.atual::before{background:var(--verde)}
.trilha .marca{position:relative;z-index:1;width:30px;height:30px;border-radius:50%;display:flex;align-items:center;justify-content:center;border:2px solid #D0D0D0;background:#fff;font-family:"Inter",sans-serif;font-weight:700;font-size:14px}
.trilha .marca .ic{width:16px;height:16px;stroke-width:3}
.trilha li.feito{color:var(--verde)}
.trilha li.feito .marca{background:var(--verde-fundo);border-color:var(--verde-fundo);color:var(--verde)}
.trilha li.atual{color:var(--preto)}
.trilha li.atual .marca{background:var(--laranja);border-color:var(--laranja)}
.trilha b{font-weight:600}
.trilha li.atual b{font-weight:800}
.pedAlerta{display:flex;gap:10px;align-items:flex-start;margin:16px 0 4px;padding:12px 14px;border-radius:var(--raio2);background:#FDE8E6;color:#7A1C10;font-size:15px}
.pedAlerta .ic{flex:none;width:20px;height:20px;margin-top:1px;color:#B42318}
.itensPed{list-style:none;margin:14px 0 0;padding:0;border-top:1px solid var(--linha)}
.itensPed li{display:flex;gap:12px;align-items:baseline;padding:10px 0;border-bottom:1px solid var(--linha);font-size:15.5px}
.itensPed .nome{flex:1;font-weight:600}
.itensPed .qt{color:var(--fraco)}
.itensPed b{min-width:96px;text-align:right;font-family:"Inter",sans-serif}
.itensPed .frete .nome{font-weight:400;color:var(--texto2)}
.entregaPed{display:flex;align-items:center;gap:8px;margin-top:12px;font-size:15px;color:var(--texto2)}
.entregaPed .ic{flex:none;width:19px;height:19px;color:var(--laranja-escuro)}
.secao{margin-top:16px}
.secao h4{margin-bottom:8px;font-size:16px;font-weight:800}
.historico{list-style:none;margin:0;padding:0 0 0 14px;border-left:2px solid var(--linha)}
.historico li{position:relative;padding:0 0 12px 12px;font-size:15px}
.historico li::before{content:"";position:absolute;left:-20px;top:6px;width:10px;height:10px;border-radius:50%;background:var(--laranja)}
.historico li b{margin-right:8px}
.historico li span{color:var(--fraco)}
.historico .motivo{margin-top:4px;padding:8px 10px;border-radius:8px;background:#FDE8E6;font-size:14.5px}
.arquivo{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--linha)}
.arquivo>div{flex:1;min-width:0;display:flex;flex-direction:column}
.arquivo b{overflow-wrap:anywhere;font-size:15px}
.arquivo span{font-size:14px;color:var(--fraco)}
.correcaoForm{display:flex;flex-direction:column;padding:16px;border-radius:var(--raio2);background:var(--cinza)}
.correcaoForm label{margin:10px 0 5px;font-size:14px;font-weight:600;color:var(--texto2)}
.correcaoForm textarea{height:auto;padding:10px 13px;resize:vertical}
.correcaoForm .bPri{margin-top:14px;align-self:flex-start}
@media (max-width:820px){
.area{grid-template-columns:1fr;gap:16px}
.lateral{position:static;flex-direction:row;flex-wrap:wrap}
.lateral button{margin:0;border:0;border-radius:var(--raio2)}
.linha{grid-template-columns:minmax(0,1fr) auto;gap:6px 12px}
.linha .num{grid-column:1}
.linha .oque{grid-column:1/-1;grid-row:2}
.linha .estadoW{grid-column:2;grid-row:1;justify-self:end}
.linha .valor{grid-column:1;grid-row:3;text-align:left}
.linha .acao{grid-column:2;grid-row:3}
}
@media (max-width:600px){
.ped h1{font-size:26px}
.trilha b{font-size:12.5px}
.duas,.tres,.busca2{grid-template-columns:1fr}
}

View File

@@ -1,11 +1,169 @@
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Dropstar · Meus pedidos DTF</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#f6f7f9;color:#20252d;font:15px/1.5 system-ui,sans-serif}header{background:white;border-bottom:1px solid #ddd;padding:20px max(20px,calc((100% - 1100px)/2));display:flex;gap:24px;align-items:center;flex-wrap:wrap}.brand{color:#ef8500;font-size:24px;font-style:italic;font-weight:900}a{color:#8a4e00}main{max-width:1100px;margin:24px auto;padding:0 20px}h1{font-size:28px}h2{font-size:21px}h3{font-size:17px}.notice{background:#fff1d4;border-left:4px solid #ffa900;padding:12px 16px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:20px}.card{background:white;border:1px solid #ddd;border-radius:10px;padding:20px;margin:16px 0}.card p{overflow-wrap:anywhere}.muted{color:#596471}label{display:block;margin:10px 0}input,textarea,button{font:inherit;border:1px solid #9aa0a8;border-radius:6px;padding:10px}input:not([type=file]),textarea{width:100%}button{background:#ffa900;border-color:#ffa900;cursor:pointer}button:disabled{opacity:.6}form{margin:10px 0}.actions{display:flex;gap:10px;flex-wrap:wrap;align-items:center}#message{min-height:24px;color:#9b3800}li{margin:8px 0}.tag{background:#eef2f6;padding:5px 10px;border-radius:6px}details{margin:12px 0}[hidden]{display:none!important}
</style></head><body>
<header><a class="brand" href="/">DROPSTAR</a><a href="/">Enviar arte</a><span>Minha conta · DTF</span><button id="logout" hidden>Sair</button></header>
<main><h1>Meus pedidos DTF</h1><p class="notice" 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</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>
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Minha conta · DTF Dropstar</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/site-shell.css">
<link rel="stylesheet" href="/site-v2.css">
<link rel="stylesheet" href="/portal.css">
</head>
<body>
<div class="tarja">
ENVIE SEU ARQUIVO DTF PELO SITE · DTF TÊXTIL COM O MENOR PREÇO DO METRO EM FRANCA-SP
</div>
<header class="topo"><div class="w">
<a class="logo" href="https://www.dropstaratacado.com.br" aria-label="Dropstar Atacado"><img src="https://cdn.vnda.com.br/dropstar/2025/12/17/logo-header-15125700-16129080.png?v=1766009286" alt="Dropstar"></a>
<div class="acts">
<a href="/conta" aria-current="page"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span class="actTx" id="quem">Minha conta</span></a>
<a href="/carrinho"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/></svg><span class="actTx">Carrinho</span></a>
</div>
</div></header>
<main class="ped w">
<p class="pedAviso" id="dev-notice" hidden>Ambiente de desenvolvimento local. Pagamentos e notificações são simulados. Use apenas dados de teste.</p>
<p class="pedMsg" id="message" role="status"></p>
<!-- /conta/entrar -->
<section class="acesso" id="auth" hidden aria-labelledby="tEntrar">
<h1 id="tEntrar">Minha conta</h1>
<div class="acessoGrid">
<form class="bloco" id="login">
<h2>Entrar</h2>
<p class="nota2">Para ver seus pedidos e seus dados.</p>
<label for="email">E-mail</label>
<input id="email" type="email" autocomplete="username" required>
<label for="password">Senha</label>
<input id="password" type="password" autocomplete="current-password" required>
<button class="bPri">Entrar</button>
<button type="button" class="bLink" id="esqueci">Esqueci minha senha</button>
<p class="nota2" id="esqueciTx" hidden>A recuperação por e-mail chega em breve. Enquanto isso, fale com a Dropstar pelo WhatsApp <a href="https://wa.me/message/NJ76CLIOUGXLB1" target="_blank" rel="noopener">16 99428-0056</a>.</p>
</form>
<form class="bloco" id="register">
<h2>Criar conta</h2>
<p class="nota2">Os pedidos feitos neste navegador passam para a sua conta.</p>
<div class="duas">
<div><label for="cnpj">CNPJ</label><input id="cnpj" inputmode="numeric" autocomplete="off" required></div>
<div><label for="phone">WhatsApp</label><input id="phone" inputmode="numeric" autocomplete="tel" required></div>
</div>
<label for="register-email">E-mail</label>
<input id="register-email" type="email" autocomplete="email" required>
<label for="register-password">Senha <span class="obs">12 caracteres ou mais</span></label>
<input id="register-password" type="password" minlength="12" maxlength="128" autocomplete="new-password" required>
<button class="bPri">Criar conta</button>
</form>
</div>
</section>
<!-- /conta, /conta/pedidos, /conta/dados -->
<div class="area" id="area" hidden>
<nav class="lateral" aria-label="Minha conta">
<a href="/conta" data-view="geral" data-conta>Visão geral</a>
<a href="/conta/pedidos" data-view="pedidos">Pedidos</a>
<a href="/conta/dados" data-view="dados" data-conta>Dados da conta</a>
<a href="/conta/entrar" data-view="entrar" id="navEntrar">Entrar</a>
<button type="button" id="logout" hidden>Sair</button>
</nav>
<section class="vista" id="vGeral" hidden aria-labelledby="tGeral">
<h1 id="tGeral">Olá</h1>
<p class="conta" id="account"></p>
<div class="metricas" id="metricas"></div>
<h2 class="tituloGrupo">Último pedido</h2>
<div class="lista" id="ultimo"></div>
<a class="verTodos" href="/conta/pedidos" data-view="pedidos">Ver todos os pedidos ›</a>
</section>
<section class="vista" id="vPedidos" hidden aria-labelledby="tPedidos">
<h1 id="tPedidos">Pedidos</h1>
<p class="acessoIntro" id="guest"></p>
<div class="filtros" id="filtros" role="group" aria-label="Filtrar pedidos"></div>
<div class="busca2">
<label class="sr" for="fNumero">Número do pedido</label>
<input id="fNumero" inputmode="numeric" placeholder="Nº do pedido">
<label class="sr" for="fPeriodo">Período</label>
<select id="fPeriodo">
<option value="30">Últimos 30 dias</option>
<option value="90">Últimos 90 dias</option>
<option value="365">Último ano</option>
<option value="0" selected>Todo o período</option>
</select>
</div>
<div class="lista" id="orders"></div>
<div class="paginas" id="paginas"></div>
</section>
<section class="vista" id="vDados" hidden aria-labelledby="tDados">
<h1 id="tDados">Dados da conta</h1>
<form class="bloco" id="fPerfil">
<h2>Empresa e entrega</h2>
<div class="duas">
<div><label for="dCnpj">CNPJ</label><input id="dCnpj" readonly aria-readonly="true"></div>
<div><label for="dZap">WhatsApp</label><input id="dZap" inputmode="numeric" autocomplete="tel" required></div>
</div>
<p class="nota2">Endereço de entrega: preenche o carrinho quando você escolhe receber em casa. Deixe em branco para não guardar.</p>
<div class="duas">
<div><label for="dCep">CEP</label><input id="dCep" inputmode="numeric" maxlength="9" placeholder="00000-000"></div>
<div><label for="dNome">Quem recebe</label><input id="dNome" maxlength="120"></div>
</div>
<label for="dRua">Rua / avenida</label><input id="dRua" maxlength="160">
<div class="duas">
<div><label for="dNum">Número</label><input id="dNum" maxlength="20"></div>
<div><label for="dComp">Complemento (opcional)</label><input id="dComp" maxlength="80"></div>
</div>
<div class="tres">
<div><label for="dBairro">Bairro</label><input id="dBairro" maxlength="80"></div>
<div><label for="dCidade">Cidade</label><input id="dCidade" maxlength="80"></div>
<div><label for="dUf">UF</label><input id="dUf" maxlength="2"></div>
</div>
<button class="bPri">Salvar</button>
</form>
<form class="bloco" id="fEmail">
<h2>E-mail</h2>
<p class="nota2">É o seu acesso e para onde vai a nota fiscal.</p>
<label for="dMail">E-mail</label><input id="dMail" type="email" autocomplete="email" required>
<label for="dMailSenha">Senha atual, para confirmar</label><input id="dMailSenha" type="password" autocomplete="current-password" required>
<button class="bSec">Alterar e-mail</button>
</form>
<form class="bloco" id="fSenha">
<h2>Senha</h2>
<label for="dAtual">Senha atual</label><input id="dAtual" type="password" autocomplete="current-password" required>
<label for="dNova">Nova senha <span class="obs">12 caracteres ou mais</span></label><input id="dNova" type="password" minlength="12" maxlength="128" autocomplete="new-password" required>
<button class="bSec">Alterar senha</button>
<p class="nota2">Os outros aparelhos conectados saem da conta.</p>
</form>
</section>
</div>
</main>
<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>
<div class="flinks">
<a href="https://www.dropstaratacado.com.br" target="_blank" rel="noopener">Loja de atacado</a>
<a href="https://www.dropstaratacado.com.br/p/termos" target="_blank" rel="noopener">Trocas e privacidade</a>
<a href="https://www.dropstaratacado.com.br/p/atendimento" target="_blank" rel="noopener">Fale conosco</a>
<a href="https://www.instagram.com/dropstar.ofc/" target="_blank" rel="noopener">Instagram</a>
</div>
<div class="fcont">
<a href="https://wa.me/message/NJ76CLIOUGXLB1" target="_blank" rel="noopener">16 99428-0056</a>
· arquivos 16 99230-2546<br>
<a href="mailto:atendimentodropstar@gmail.com">atendimentodropstar@gmail.com</a><br>
Segunda a sexta, 07h às 17h
</div>
</div>
<div class="fbase">
<span>© Dropstar Atacado · CNPJ 55.786.635/0001-58 · Franca-SP</span>
<span>Visa · Master · Elo · Amex · Pix · Boleto</span>
<span>têxtil 57 cm · UV 28,5 cm</span>
</div>
</div></footer>
<script src="/privacy.js"></script><script src="/upload.js"></script><script src="/portal.js"></script>
</body>
</html>

View File

@@ -1,62 +1,325 @@
/* Minha conta: sign-in, the overview, the orders (filtered, a page at a time,
with the cart waiting for payment among them) and the account's details.
One page; the address says which part shows (/conta, /conta/entrar,
/conta/pedidos, /conta/dados). */
const $=id=>document.getElementById(id);
const node=(tag,text)=>{const el=document.createElement(tag);if(text!==undefined)el.textContent=text;return el;};
const node=(tag,text,className)=>{const el=document.createElement(tag);if(text!==undefined&&text!==null)el.textContent=text;if(className)el.className=className;return el;};
const money=c=>(c/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'});
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{
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:'';
$('guest').textContent=account.customer?'':'Você está vendo apenas os pedidos desta sessão. Crie uma conta para acessar seus pedidos em outro navegador.';
const data=await api('/customer/orders');states=data.states;
$('quotes').replaceChildren();
for(const quote of data.quotes){
const card=node('article');card.className='card';card.append(node('h3','Cotação '+quote.id.slice(0,8)));
card.append(node('p',quote.approved?'Total aprovado: '+money(quote.approved.total_cents):'Aguardando conferência de metragem e nota.'));
const link=node('a','Abrir cotação no Site');link.href='/pagamento?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)+(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);
if(new URLSearchParams(location.search).get('order')===order.id)await details(order.id,content);
}
}catch(error){$('message').textContent=error.message;}
const quando=d=>new Date(d).toLocaleString('pt-BR',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
const dia=d=>new Date(d).toLocaleDateString('pt-BR',{day:'2-digit',month:'2-digit',year:'2-digit'});
const metros=m=>Number(m).toLocaleString('pt-BR',{minimumFractionDigits:2,maximumFractionDigits:2})+' m';
const soDigitos=v=>String(v||'').replace(/\D/g,'');
const fmtCnpj=v=>soDigitos(v).replace(/^(\d{2})(\d{3})(\d{3})(\d{4})(\d{2})$/,'$1.$2.$3/$4-$5');
const fmtZap=v=>{const d=soDigitos(v);return d.length===11?'('+d.slice(0,2)+') '+d.slice(2,7)+'-'+d.slice(7):d.length===10?'('+d.slice(0,2)+') '+d.slice(2,6)+'-'+d.slice(6):v;};
const fmtCep=v=>{const d=soDigitos(v).slice(0,8);return d.length>5?d.slice(0,5)+'-'+d.slice(5):d;};
// Lucide shapes (ISC licence), the same as on the Site.
const ICONES={
baixar:'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
local:'<path d="M20 10c0 4.99-5.54 10.19-7.4 11.8a1 1 0 0 1-1.2 0C9.54 20.19 4 14.99 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>',
caminhao:'<path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2"/><path d="M15 18H9"/><path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.62l-3.48-4.35A1 1 0 0 0 17.52 8H14"/><circle cx="17" cy="18" r="2"/><circle cx="7" cy="18" r="2"/>',
ok:'<path d="M20 6 9 17l-5-5"/>',
alerta:'<path d="M12 7v6"/><path d="M12 17h.01"/>',
};
function icone(nome){
const span=document.createElement('span');
span.innerHTML='<svg class="ic" viewBox="0 0 24 24" aria-hidden="true" focusable="false">'+ICONES[nome]+'</svg>';
return span.firstChild;
}
async function details(id,container){
const PRODUTO={file:'DTF Têxtil · folha montada',avulsa:'DTF Têxtil · artes avulsas',
uvfile:'DTF UV · folha montada',uv:'DTF UV · artes avulsas'};
const TRILHA=['rec','tra','fil','imp','fin'];
const TAMANHO=10;
let scope,states={},environment='',conta=null;
let filtro={status:'all',number:'',days:0,page:1};
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;}
const aviso=(text,erro)=>{$('message').textContent=text||'';$('message').classList.toggle('erro',!!erro);};
function button(text,fn,className='bSec'){const b=node('button',text,className);b.type='button';b.onclick=async event=>{event?.stopPropagation?.();b.disabled=true;try{await fn();}catch(e){aviso(e.message,true);}finally{b.disabled=false;}};return b;}
function forgetCheckout(){for(const k of ['dtf-quote','dtf-request-key','dtf-request-body'])localStorage.removeItem(k);}
// ---- Addresses -----------------------------------------------------------
function vista(){
const p=location.pathname.replace(/\/+$/,'');
if(p==='/conta/entrar') return 'entrar';
if(p==='/conta/dados') return 'dados';
if(p==='/conta') return 'geral';
return 'pedidos'; // /conta/pedidos, and the old /portal.html
}
function ir(caminho){history.pushState({},'',caminho);mostra().catch(e=>aviso(e.message,true));}
window.addEventListener('popstate',()=>mostra().catch(e=>aviso(e.message,true)));
document.addEventListener('click',event=>{
const a=event.target.closest('a[data-view]');
if(!a||event.metaKey||event.ctrlKey||event.shiftKey)return;
event.preventDefault();ir(a.getAttribute('href'));
});
async function mostra(){
aviso('');
const v=vista(), proximo=new URLSearchParams(location.search).get('next');
// Overview and details are the account's; orders also work for a guest.
if((v==='geral'||v==='dados')&&!conta){history.replaceState({},'','/conta/entrar?next='+encodeURIComponent(location.pathname));return mostra();}
if(v==='entrar'&&conta){history.replaceState({},'',proximo&&/^\/conta(\/[a-z]+)?$/.test(proximo)?proximo:'/conta');return mostra();}
$('auth').hidden=v!=='entrar';
$('area').hidden=v==='entrar';
$('vGeral').hidden=v!=='geral';$('vPedidos').hidden=v!=='pedidos';$('vDados').hidden=v!=='dados';
for(const a of document.querySelectorAll('.lateral a'))a.classList.toggle('on',a.dataset.view===v);
for(const a of document.querySelectorAll('.lateral [data-conta]'))a.hidden=!conta;
$('navEntrar').hidden=!!conta;$('logout').hidden=!conta;
document.title=({entrar:'Entrar',geral:'Minha conta',pedidos:'Pedidos',dados:'Dados da conta'})[v]+' · DTF Dropstar';
if(v==='geral')await geral();
else if(v==='pedidos')await pedidos();
else if(v==='dados')dados();
}
// ---- Orders --------------------------------------------------------------
function trilha(state){
const ol=node('ol',null,'trilha');
const atual=TRILHA.indexOf(state);
TRILHA.forEach((s,i)=>{
const feito=i<atual||state==='fin';
const li=node('li',null,feito?'feito':i===atual?'atual':'');
const marca=node('span',null,'marca');
if(feito)marca.append(icone('ok'));else marca.textContent=String(i+1);
li.append(marca,node('b',states[s]||s));ol.append(li);
});
return ol;
}
function estado(e){
if(e.kind==='quote')return e.state==='review'?['Em conferência','est-rev']:['Aguardando pagamento','est-pay'];
return [states[e.state]||e.state,'est-'+e.state];
}
function resumoItens(items){
if(!items.length)return '';
const total=items.reduce((t,i)=>t+Number(i.billed_metres||0),0);
return (items.length===1?PRODUTO[items[0].mode]||items[0].mode:items.length+' itens')+(total?' · '+metros(total):'');
}
function linha(e){
const row=node('div',null,'linha');
const [rotulo,cls]=estado(e);
const num=node('b',e.kind==='quote'?'Carrinho':'#'+e.number,'num');
const oque=node('div',null,'oque');oque.append(node('span',resumoItens(e.items)),node('span',dia(e.created_at),'data'));
const st=node('span',null,'estadoW');st.append(node('span',rotulo,'estado '+cls));
if(e.kind==='quote'&&e.pay_with==='pix')st.append(node('span','PIX','via'));
const valor=node('b',e.total_cents!=null?money(e.total_cents):'—','valor');
const acao=node('div',null,'acao');
row.append(num,oque,st,valor,acao);
const wrap=node('div',null,'item'+(e.kind==='quote'?' pendente':'')+(e.state==='cor'?' correcao':''));
wrap.append(row);
if(e.kind==='quote'){
if(e.state==='pay'){
const pagar=node('a','Pagar','bPri bMini');
pagar.href=(e.pay_with==='pix'?'/pagamento/pix':'/pagamento')+'?quote='+e.id;
acao.append(pagar);
}
return wrap;
}
wrap.id='order-'+e.id;
const detalhe=node('div',null,'detalhe');detalhe.hidden=true;
const alterna=async()=>{
if(!detalhe.hidden){detalhe.hidden=true;ver.textContent='Ver';return;}
await details(e.id,detalhe,e.created_at);detalhe.hidden=false;ver.textContent='Fechar';
};
const ver=button('Ver',alterna);
acao.append(ver);
row.addEventListener('click',event=>{if(!event.target.closest('button,a'))ver.click();});
wrap.append(detalhe);
wrap.abrir=async()=>{if(detalhe.hidden)await alterna();};
return wrap;
}
function filtros(counts,groups){
const box=$('filtros');box.replaceChildren();
const todos=Object.values(counts).reduce((a,b)=>a+b,0);
for(const [key,label] of [['all','Todos'],...Object.entries(groups)]){
const n=key==='all'?todos:counts[key];
const chip=node('button',label+(n?' · '+n:''),'chipF'+(filtro.status===key?' on':''));chip.type='button';
chip.setAttribute('aria-pressed',String(filtro.status===key));
chip.onclick=()=>{filtro={...filtro,status:key,page:1};pedidos().catch(x=>aviso(x.message,true));};
box.append(chip);
}
}
function paginas(total){
const box=$('paginas');box.replaceChildren();
const n=Math.ceil(total/TAMANHO);
if(n<=1){if(total)box.append(node('span',total+(total===1?' pedido':' pedidos'),'nota2'));return;}
const de=(filtro.page-1)*TAMANHO+1, ate=Math.min(total,filtro.page*TAMANHO);
box.append(node('span',de+'–'+ate+' de '+total+' pedidos','nota2'));
const nav=node('div',null,'pags');
const vai=p=>{filtro.page=p;pedidos().then(()=>$('vPedidos').scrollIntoView({block:'start'})).catch(x=>aviso(x.message,true));};
const b=(t,p,label)=>{const x=node('button',t,'pag'+(p===filtro.page?' on':''));x.type='button';if(label)x.setAttribute('aria-label',label);
if(p===filtro.page)x.setAttribute('aria-current','page');else if(p<1||p>n)x.disabled=true;else x.onclick=()=>vai(p);nav.append(x);};
b('‹',filtro.page-1,'Página anterior');
for(let p=Math.max(1,filtro.page-2);p<=Math.min(n,filtro.page+2);p++)b(String(p),p);
b('›',filtro.page+1,'Próxima página');
box.append(nav);
}
async function pedidos(){
$('guest').textContent=conta?'':'Você está vendo os pedidos feitos neste navegador. Entre ou crie uma conta para vê-los em qualquer lugar.';
const q=new URLSearchParams({status:filtro.status,days:String(filtro.days),page:String(filtro.page),size:String(TAMANHO)});
if(filtro.number)q.set('number',filtro.number);
const data=await api('/customer/orders?'+q);states=data.states;
filtros(data.counts,data.groups);
const lista=$('orders');lista.replaceChildren();
if(!data.items.length){
const livre=filtro.status==='all'&&!filtro.number&&!filtro.days;
const vazio=node('div',null,'vazio');
vazio.append(node('p',livre?'Seus pedidos aparecem aqui.':'Nenhum pedido com esse filtro.'));
if(livre){const a=node('a','Enviar uma arte','bPri');a.href='/#envio';vazio.append(a);}
lista.append(vazio);
}
for(const e of data.items)lista.append(linha(e));
paginas(data.total);
const aberto=new URLSearchParams(location.search).get('order');
if(aberto&&/^[0-9a-f-]{36}$/.test(aberto)){
let alvo=$('order-'+aberto);
if(!alvo){
// Not on this page of the list: shown on its own, above it.
try{const o=await api('/customer/orders/'+aberto);
alvo=linha({kind:'order',id:o.id,number:o.number,state:o.state,total_cents:o.snapshot.total_cents,
created_at:o.created_at,items:o.snapshot.items});lista.prepend(alvo);}catch(e){alvo=null;}
}
if(alvo){await alvo.abrir();alvo.scrollIntoView({block:'start'});}
}
}
let buscaTimer;
$('fNumero').addEventListener('input',e=>{e.target.value=soDigitos(e.target.value);clearTimeout(buscaTimer);
buscaTimer=setTimeout(()=>{filtro={...filtro,number:e.target.value,page:1};pedidos().catch(x=>aviso(x.message,true));},350);});
$('fPeriodo').addEventListener('change',e=>{filtro={...filtro,days:Number(e.target.value),page:1};pedidos().catch(x=>aviso(x.message,true));});
function itensPedido(snapshot){
const ul=node('ul',null,'itensPed');
for(const item of snapshot.items){
const li=node('li');
li.append(node('span',PRODUTO[item.mode]||item.mode,'nome'),node('span',metros(item.billed_metres),'qt'),node('b',money(item.total_cents)));
ul.append(li);
}
const f=snapshot.freight;
if(f&&f.total_cents){const li=node('li',null,'frete');li.append(node('span','Frete','nome'),node('span',f.days?'até '+f.days+' dias úteis':'','qt'),node('b',money(f.total_cents)));ul.append(li);}
return ul;
}
function entrega(snapshot){
const d=snapshot.destination, p=node('p',null,'entregaPed');
p.append(icone(d?'caminhao':'local'),node('span',d?'Entrega pela Jadlog · '+d.street+', '+d.number+' · '+d.city+'/'+d.state:'Retirada em Franca · Av. Major Elias Motta, 1281'));
return p;
}
async function details(id,container,criado){
const order=await api('/customer/orders/'+id);container.replaceChildren();
for(const [i,item] of order.snapshot.items.entries())container.append(node('p',(i+1)+'. '+item.mode+' · '+item.billed_metres+' m · nota '+item.grade+' · '+money(item.total_cents)));
const history=node('ol');
for(const h of order.history)history.append(node('li',new Date(h.created_at).toLocaleString('pt-BR')+' · '+states[h.to_state]+(h.reason?' — '+h.reason:'')));
container.append(history);
for(const file of order.files){
const row=node('p',(file.kind==='final'?'Arquivo final':'Correção')+' · item '+(file.item_index+1)+' · '+file.name+' · '+(file.expired?'expirado':!file.active?'substituído':'disponível até '+new Date(file.expires_at).toLocaleDateString('pt-BR')));
if(file.active&&!file.expired)row.append(button('Baixar',async()=>{const d=await api('/customer/orders/'+id+'/files/'+file.id+'/download');const link=node('a');link.href=d.url;link.download=d.name;link.referrerPolicy='no-referrer';link.click();}));
container.append(row);
if(order.state==='cor'){
const p=node('p',null,'pedAlerta');p.append(icone('alerta'),node('span','A equipe precisa de uma correção na sua arte. Veja o motivo abaixo e envie o arquivo corrigido.'));
container.append(p);
}else container.append(trilha(order.state));
container.append(itensPedido(order.snapshot),entrega(order.snapshot));
const hist=node('div',null,'secao');hist.append(node('h4','Andamento'));
const ol=node('ol',null,'historico');
// Payment is where the order starts: the first step is not a movement.
if(criado){const li=node('li');li.append(node('b',states.rec||'Arte recebida'),node('span',quando(criado)));ol.append(li);}
for(const h of order.history){
const li=node('li');li.append(node('b',states[h.to_state]),node('span',quando(h.created_at)));
if(h.reason)li.append(node('p',h.reason,'motivo'));
ol.append(li);
}
hist.append(ol);container.append(hist);
if(order.files.length){
const arq=node('div',null,'secao');arq.append(node('h4','Arquivos'));
for(const file of order.files){
const row=node('div',null,'arquivo');
const info=node('div');
info.append(node('b',file.name),node('span',(file.kind==='final'?'Arquivo final':'Correção')+' · item '+(file.item_index+1)+' · '+
(file.expired?'expirado':!file.active?'substituído':'disponível até '+dia(file.expires_at))));
row.append(info);
if(file.active&&!file.expired){
const b=button('Baixar',async()=>{const d=await api('/customer/orders/'+id+'/files/'+file.id+'/download');const link=node('a');link.href=d.url;link.download=d.name;link.referrerPolicy='no-referrer';link.click();});
b.prepend(icone('baixar'));row.append(b);
}
arq.append(row);
}
container.append(arq);
}
if(order.state==='cor'){
const form=node('form');form.append(node('h3','Enviar arte corrigida'));
const inputs=order.snapshot.items.map((item,index)=>{const label=node('label','Item '+(index+1)+' · '+item.mode);const input=node('input');input.type='file';input.multiple=true;input.dataset.item=index;label.append(input);form.append(label);return input;});
const note=node('textarea');note.placeholder='Descreva a correção';note.required=true;note.maxLength=1000;form.append(note);
const submit=node('button','Enviar correção');submit.type='submit';form.append(submit);
const form=node('form',null,'secao correcaoForm');form.append(node('h4','Enviar arte corrigida'));
const inputs=order.snapshot.items.map((item,index)=>{
const label=node('label',(index+1)+'. '+(PRODUTO[item.mode]||item.mode));
const input=node('input');input.type='file';input.multiple=true;input.dataset.item=index;
form.append(label,input);return input;
});
const nl=node('label','O que foi corrigido');const note=node('textarea');note.required=true;note.maxLength=1000;note.rows=3;
form.append(nl,note);
const submit=node('button','Enviar correção','bPri');submit.type='submit';form.append(submit);
form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{
const files=[];for(const [index,input] of inputs.entries())for(const file of input.files)files.push({item_index:index,upload_id:await dtfUpload(file,{api,scope:scope+':correction:'+id+':'+order.version,progress:text=>$('message').textContent=text})});
const files=[];for(const [index,input] of inputs.entries())for(const file of input.files)files.push({item_index:index,upload_id:await dtfUpload(file,{api,scope:scope+':correction:'+id+':'+order.version,progress:text=>aviso(text)})});
if(!files.length)throw new Error('Selecione pelo menos um arquivo corrigido.');
await api('/customer/orders/'+id+'/corrections',{version:order.version,files,note:note.value});
$('message').textContent='Correção enviada. Aguarde a conferência da equipe.';await details(id,container);
}catch(error){$('message').textContent=error.message;}finally{submit.disabled=false;}};
aviso('Correção enviada. A equipe confere e segue com o pedido.');await details(id,container,criado);
}catch(error){aviso(error.message,true);}finally{submit.disabled=false;}};
container.append(form);
}
}
// ---- Overview ------------------------------------------------------------
async function geral(){
$('account').textContent='CNPJ '+fmtCnpj(conta.cnpj)+' · '+conta.mail;
const data=await api('/customer/orders?page=1&size=1');states=data.states;
const box=$('metricas');box.replaceChildren();
for(const [key,label] of [['pay','Aguardando pagamento'],['prod','Em produção'],['cor','Correção'],['fin','Finalizados']]){
if(key==='cor'&&!data.counts.cor)continue;
const a=node('a',null,'metrica'+(key==='cor'?' alerta':''));a.href='/conta/pedidos';a.dataset.view='pedidos';
a.addEventListener('click',()=>{filtro={status:key,number:'',days:0,page:1};});
a.append(node('span',label),node('b',String(data.counts[key])));
box.append(a);
}
const ultimo=$('ultimo');ultimo.replaceChildren();
if(data.items.length)ultimo.append(linha(data.items[0]));
else{const v=node('div',null,'vazio');v.append(node('p','Seu primeiro pedido aparece aqui.'));const a=node('a','Enviar uma arte','bPri');a.href='/#envio';v.append(a);ultimo.append(v);}
}
// ---- Account details -----------------------------------------------------
const CAMPOS_END=[['dNome','recipient'],['dRua','street'],['dNum','number'],['dComp','complement'],['dBairro','district'],['dCidade','city'],['dUf','state']];
function dados(){
$('dCnpj').value=fmtCnpj(conta.cnpj);$('dZap').value=fmtZap(conta.zap);$('dMail').value=conta.mail;
const end=conta.address||{};
$('dCep').value=fmtCep(end.postal_code||'');
for(const [id,k] of CAMPOS_END)$(id).value=end[k]||'';
$('dMailSenha').value='';$('dAtual').value='';$('dNova').value='';
}
$('dZap').addEventListener('input',e=>{e.target.value=fmtZap(soDigitos(e.target.value).slice(0,11));});
$('dCep').addEventListener('input',async e=>{
e.target.value=fmtCep(e.target.value);
const cep=soDigitos(e.target.value);
if(cep.length!==8)return;
try{const a=await api('/cep/'+cep);
for(const [id,v] of [['dRua',a.street],['dBairro',a.district],['dCidade',a.city],['dUf',a.state]])if(v)$(id).value=v;
(['dNome','dRua','dNum'].map($).find(el=>!el.value.trim())||$('dNum')).focus();
}catch(_){/* sem consulta: o cliente preenche */}
});
$('fPerfil').onsubmit=async event=>{
event.preventDefault();
const cep=soDigitos($('dCep').value);
const campos=Object.fromEntries(CAMPOS_END.map(([id,k])=>[k,$(id).value.trim()]));
const algum=cep||Object.values(campos).some(Boolean);
const address=algum?{...campos,state:campos.state.toUpperCase(),postal_code:cep}:null;
try{
const r=await api('/account/profile',{zap:$('dZap').value,address});
conta=r.customer;aviso('Dados salvos.');
}catch(e){aviso(e.status===422?(algum?'Confira o WhatsApp e o endereço: todos os campos, menos o complemento, são necessários.':'Confira o WhatsApp.'):e.message,true);}
};
$('fEmail').onsubmit=async event=>{
event.preventDefault();
try{const r=await api('/account/email',{email:$('dMail').value,password:$('dMailSenha').value});
conta=r.customer;$('dMailSenha').value='';$('quem').textContent=conta.mail;aviso('E-mail alterado. Use o novo e-mail para entrar.');}
catch(e){aviso(e.status===422?'Confira o e-mail.':e.message,true);}
};
$('fSenha').onsubmit=async event=>{
event.preventDefault();
try{await api('/account/password',{current:$('dAtual').value,new:$('dNova').value});
$('dAtual').value='';$('dNova').value='';aviso('Senha alterada.');}
catch(e){aviso(e.status===422?'A nova senha precisa ter 12 caracteres ou mais.':e.message,true);}
};
// ---- Signing in ----------------------------------------------------------
async function migrateGuestCart(previous,next){
if(previous===next)return;
const database=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onupgradeneeded=()=>r.result.createObjectStore('cart');r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});
@@ -64,14 +327,25 @@ async function migrateGuestCart(previous,next){
database.close();
}
async function authenticate(path,body){
const previous=scope,guest=!(await api('/account/me')).customer;
await api(path,body);forgetCheckout();$('message').textContent='Acesso confirmado.';
const next=(await api('/session')).cart_scope;
if(guest)try{await migrateGuestCart(previous,next);}catch{$('message').textContent='Acesso confirmado. Não foi possível transferir o carrinho salvo neste navegador.';}
await load();$('password').value='';$('register-password').value='';
const previous=scope,guest=!conta;
const r=await api(path,body);forgetCheckout();
const next=(await api('/session')).cart_scope;scope=next;
if(guest)try{await migrateGuestCart(previous,next);}catch{aviso('Não foi possível transferir o carrinho salvo neste navegador.',true);}
conta=r.customer;$('quem').textContent=conta.mail;
$('password').value='';$('register-password').value='';
await mostra();
}
$('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 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();
$('login').onsubmit=async event=>{event.preventDefault();try{await authenticate('/account/login',{email:$('email').value,password:$('password').value});}catch(e){aviso(e.message,true);}};
$('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){aviso(e.status===422?'Confira o CNPJ, o WhatsApp e o e-mail. A senha precisa ter 12 caracteres ou mais.':e.message,true);}};
$('esqueci').onclick=()=>{$('esqueciTx').hidden=!$('esqueciTx').hidden;};
$('logout').onclick=async()=>{try{await window.dtfForgetPrivateData();await api('/account/logout',{});location.assign('/conta/entrar');}catch(error){aviso('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.',true);}};
(async()=>{
try{
const session=await api('/session');scope=session.cart_scope;environment=session.environment;
$('dev-notice').hidden=environment!=='local';
conta=(await api('/account/me')).customer;
if(conta)$('quem').textContent=conta.mail;
await mostra();
}catch(error){aviso(error.message,true);}
})();

View File

@@ -15,8 +15,13 @@ function itemPodeEnviar(item){
return item.qualityStatus==='ok' || item.qualityStatus==='unverified' ||
(item.qualityStatus==='warning' && item.qualityAcknowledged===true);
}
// Os itens marcados no carrinho são os que vão para o pagamento; um item
// desmarcado fica guardado para depois. O item aberto no produto sempre vai.
function itensAPagar(){
return [...pedido.filter(it=>it.marcado!==false),...(itemAtual?[itemAtual]:[])];
}
function cartPodeEnviar(){
const items=[...pedido,...(itemAtual?[itemAtual]:[])];
const items=itensAPagar();
return items.length>0 && items.every(itemPodeEnviar);
}
function limpaPaineis(){
@@ -28,9 +33,17 @@ function limpaPaineis(){
// O carrinho junta itens de produtos diferentes e cobra uma vez só.
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="item'+(it.marcado===false?' fora':'')+'">'+
'<label class="marca"><input type="checkbox" data-sel="'+i+'"'+(it.marcado===false?'':' checked')+
' aria-label="Incluir '+escapeHTML(it.tit)+' no pagamento"></label>'+
'<span class="miniItem">'+(it.mini ? '<img src="'+escapeHTML(it.mini)+'" alt="">' : '<i>'+escapeHTML(it.ext||'')+'</i>')+
(it.arquivos>1 ? '<em>+'+(it.arquivos-1)+'</em>' : '')+'</span>'+
'<div class="nm2"><b>'+escapeHTML(it.tit)+'</b><span>'+escapeHTML(it.desc)+'</span></div>'+
'<div class="vl2">'+rs(it.total)+'</div>'+
'<button type="button" class="x" data-rmi="'+i+'" aria-label="Remover '+escapeHTML(it.tit)+'">Remover</button></div>').join('');
'<button type="button" class="x" data-rmi="'+i+'" aria-label="Remover '+escapeHTML(it.tit)+'">'+icone('lixeira')+'<span>Remover</span></button></div>').join('');
$('itens').querySelectorAll('[data-sel]').forEach(c=>c.addEventListener('change',()=>{
pedido[+c.dataset.sel].marcado=c.checked; pintaPedido();
}));
$('itens').querySelectorAll('[data-rmi]').forEach(b=>b.addEventListener('click',()=>{
const i=+b.dataset.rmi, [item]=pedido.splice(i,1);
pintaPedido();
@@ -39,11 +52,12 @@ function pintaPedido(){
});
}));
$('bEsvaziar').hidden = pedido.length+(itemAtual?1:0) < 2;
const itens = pedido.reduce((t,it)=>t+it.total,0) + (itemAtual? itemAtual.total : 0);
const aPagar = itensAPagar();
const itens = aPagar.reduce((t,it)=>t+it.total,0);
const frete = entrega.tipo==='frete' && entrega.cotado ? entrega.valor : 0;
const soma = itens + frete;
const n = pedido.length + (itemAtual?1:0);
const eco = [...pedido, ...(itemAtual?[itemAtual]:[])]
const n = aPagar.length, noCarrinho = pedido.length + (itemAtual?1:0);
const eco = aPagar
.reduce((t,it)=>t+(TABELA[it.modo]-it.unit)*it.cob, 0);
$('resLin').innerHTML =
'<div class="l"><span>'+n+(n===1?' item':' itens')+'</span><b>'+rs(itens+eco)+'</b></div>'+
@@ -56,12 +70,13 @@ function pintaPedido(){
'<div class="l"><span>Revisão e reencaixe</span>'+
'<b><span style="color:var(--verde-ml)">Grátis</span></b></div>';
$('carrTot').textContent=rs(soma);
$('bPagar').textContent = (pedido.length + (itemAtual?1:0))>1
? 'Pagar os '+(pedido.length+(itemAtual?1:0))+' itens de uma vez'
$('bPagarTx').textContent = n>1
? 'Pagar os '+n+' itens de uma vez'
: 'Ir para o pagamento';
$('bPagar').disabled = !cartPodeEnviar() || !entrega.cotado || !clienteOk() || !enderecoOk();
if(pedido.length || itemAtual) $('carr').classList.add('on');
if($('cartLink')) $('cartLink').textContent='Carrinho ('+n+')';
if($('cartN')){ $('cartN').textContent=noCarrinho>99?'99+':noCarrinho; $('cartN').hidden=!noCarrinho;
$('cartLink').setAttribute('aria-label','Carrinho, '+noCarrinho+(noCarrinho===1?' item':' itens')); }
window.dispatchEvent(new Event('dtf-cart-changed'));
}
@@ -91,7 +106,7 @@ function carrinho(){
measurement:x.med?'file':'customer'}
: {kind:'artwork',width_cm:x.cm,length_cm:+(x.cm*propDe(x)).toFixed(4),
copies:x.q,rotation_degrees:x.giro||0,mirrored:!!x.esp,
measurement:x.src?'file':'customer'});
measurement:(x.src||x.semPrevia)?'file':'customer'});
let layout;
if(ehFolha()){
let y=0;
@@ -111,6 +126,9 @@ function carrinho(){
}
itemAtual={modo, tit:MODOS[modo].tit, nota, metros, cob, unit, total:tot,
localFiles:(ehFolha()?folhas:artes).map(x=>x.f),
mini:(ehFolha()?folhas:artes).map(x=>x.miniSrc).find(Boolean)||null,
ext:extDe(((ehFolha()?folhas:artes)[0]||{f:{name:''}}).f.name),
arquivos:(ehFolha()?folhas:artes).length,
production:{version:2,film_width_cm:larguraFilme(),sources,...layout},
qualityStatus:$('ciente').classList.contains('recusa')?'rejected':
$('ciente').classList.contains('on')?'warning':nota===0?'unverified':'ok',

View File

@@ -5,6 +5,30 @@
const $=id=>document.getElementById(id);
const rs=v=>'R$ '+v.toFixed(2).replace('.',',');
// Ícones (Lucide, licença ISC) desenhados no próprio HTML: nada a carregar, e a
// cor vem do texto em volta. Os que ficam no HTML estático são os mesmos.
const ICONES={
conta:'<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
sacola:'<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/>',
enviar:'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>',
girar:'<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/>',
espelhar:'<path d="m3 7 5 5-5 5V7"/><path d="m21 7-5 5 5 5V7"/><path d="M12 20v2"/><path d="M12 14v2"/><path d="M12 8v2"/><path d="M12 2v2"/>',
fechar:'<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
adicionar:'<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M12 10v6"/><path d="M9 13h6"/>',
cadeado:'<path d="M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2Z"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
lixeira:'<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2"/><path d="M10 11v6"/><path d="M14 11v6"/>',
local:'<path d="M20 10c0 4.99-5.54 10.19-7.4 11.8a1 1 0 0 1-1.2 0C9.54 20.19 4 14.99 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>',
caminhao:'<path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2"/><path d="M15 18H9"/><path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.62l-3.48-4.35A1 1 0 0 0 17.52 8H14"/><circle cx="17" cy="18" r="2"/><circle cx="7" cy="18" r="2"/>',
cartao:'<path d="M4 5h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2Z"/><path d="M2 10h20"/>',
qrcode:'<path d="M3 3h5v5H3z"/><path d="M16 3h5v5h-5z"/><path d="M3 16h5v5H3z"/><path d="M21 16h-3a2 2 0 0 0-2 2v3"/><path d="M21 21v.01"/><path d="M12 7v3a2 2 0 0 1-2 2H7"/><path d="M3 12h.01"/><path d="M12 3h.01"/><path d="M12 16v.01"/><path d="M16 12h1"/><path d="M21 12v.01"/><path d="M12 21v-1"/>',
copiar:'<path d="M10 8h10a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2Z"/><path d="M4 16a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2"/>',
relogio:'<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
ok:'<path d="M20 6 9 17l-5-5"/>',
okCirculo:'<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
alerta:'<path d="M12 7v6"/><path d="M12 17h.01"/>',
refazer:'<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
};
const icone=n=>'<svg class="ic" viewBox="0 0 24 24" aria-hidden="true" focusable="false">'+ICONES[n]+'</svg>';
const fmt=b=>b<1048576?(b/1024).toFixed(0)+' KB'
:b<1073741824?(b/1048576).toFixed(1).replace('.',',')+' MB':(b/1073741824).toFixed(1).replace('.',',')+' GB';
@@ -96,6 +120,23 @@ const destinoApi=()=>entrega.tipo!=='frete' ? null : {
recipient:entrega.end.nome.trim(), street:entrega.end.rua.trim(), number:entrega.end.num.trim(),
complement:(entrega.end.comp||'').trim(), district:entrega.end.bairro.trim(),
city:entrega.end.cidade.trim(), state:entrega.end.uf.trim().toUpperCase(), postal_code:entrega.cep};
// Tempo de envio antes de medir a internet de quem compra: uma faixa entre um
// upload lento (10 Mbps) e um rápido (150 Mbps). O arquivo vai direto para o
// armazenamento, então é o upload do cliente que manda.
const ENVIO_LENTO=10e6/8, ENVIO_RAPIDO=150e6/8;
const duracao=s=>{
const min=Math.ceil(s/60);
return s<60 ? 'menos de 1 min' : min<60 ? min+' min' : Math.floor(min/60)+' h'+(min%60? ' '+min%60+' min' : '');
};
const faixaEnvio=bytes=>bytes/ENVIO_RAPIDO<60 ? 'até '+duracao(bytes/ENVIO_LENTO)
: 'de '+duracao(bytes/ENVIO_RAPIDO)+' a '+duracao(bytes/ENVIO_LENTO);
// Quanto falta para ler um arquivo grande no navegador, pelo ritmo até aqui.
// A leitura é quase linear, então o tempo gasto diz o que resta.
function faltaLeitura(l){
if(!l || !(l.p>0.02)) return 'calculando o tempo…';
const s=(Date.now()-l.inicio)/1000*(1-l.p)/l.p;
return s<10 ? 'faltam '+Math.max(1,Math.ceil(s))+' s' : s<60 ? 'faltam cerca de '+Math.ceil(s/5)*5+' s' : 'faltam cerca de '+duracao(s);
}
const larguraFilme=()=>MODOS[modo].larg;
// cobrança proporcional: arredonda para cima em 10 cm · 2,75 m vira 2,80 m
const cobrar=m=>Math.max(MINIMO_M, Math.ceil(m*10)/10);

View File

@@ -80,8 +80,6 @@ function pintaEntrega(){
$('vFrete').textContent = entrega.tipo!=='frete' ? '—'
: entrega.cotado ? rs(entrega.valor) : 'a cotar';
$('bPagar').disabled = !entrega.cotado || !clienteOk() || !enderecoOk() || !cartPodeEnviar();
$('eEnd').textContent = entrega.tipo==='frete' && entrega.cotado && !enderecoOk()
? 'Complete o endereço de entrega para ir ao pagamento.' : '';
pintaPedido();
}

View File

@@ -37,7 +37,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';
$('qOk').innerHTML = ehFolha(m) ? icone('adicionar')+'<span>Adicionar ao carrinho</span>' : 'Ver a folha montada';
pintaTipoEnvio();
}
// O par folha montada / artes separadas fica visível e com preço nos dois lados,

View File

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

View File

@@ -353,7 +353,7 @@ function medirFolha(file){
}
function textoProgresso(x){
if(x.med && x.med.grande) return 'gerando a prévia e conferindo a arte… '+x.pct+'%';
if(x.med && x.med.grande) return 'gerando a prévia e conferindo a arte · '+faltaLeitura(x.leitura);
return x.pct<50?'lendo o arquivo…':x.pct<70?'medindo a folha…':'conferindo a arte…';
}
// The file card's thumbnail (52 x 96 px, drawn at twice that for sharp screens).
@@ -472,7 +472,7 @@ function pintaFolha(){
return '<div class="rep" data-folha="'+i+'">'+
'<div class="repTopo"><div class="miniF">'+mini+'</div><div class="repInfo">'+
'<div class="l1"><b>'+(folhas.length>1? (i+1)+'. ':'')+escapeHTML(x.f.name)+'</b>'+
'<button data-rmf="'+i+'" aria-label="Remover '+escapeHTML(x.f.name)+'">×</button></div>'+
'<button data-rmf="'+i+'" aria-label="Remover '+escapeHTML(x.f.name)+'">'+icone('fechar')+'</button></div>'+
(lido || x.measurementError ? '' : '<span class="repPeso">'+escapeHTML(extDe(x.f.name))+' · '+fmt(x.f.size)+'</span>')+
barra+medida+
'</div></div>'+copias+
@@ -524,7 +524,7 @@ function pintaProgressoArte(a){
const el=$('lista').querySelector('[data-arte="'+artes.indexOf(a)+'"]');
const barra=el && el.querySelector('.prog i'), texto=el && el.querySelector('.progT');
if(!barra){ pintaArtes(); return; }
barra.style.width=a.lendo+'%'; if(texto) texto.textContent='arquivo grande · gerando a prévia… '+a.lendo+'%';
barra.style.width=a.lendo+'%'; if(texto) texto.textContent='arquivo grande · gerando a prévia · '+faltaLeitura(a.leitura);
}
function pintaArtes(){
invalidaItemAtual();
@@ -538,14 +538,12 @@ function pintaArtes(){
let barra='', dica='', chips='', aviso='', medida='<b class="pede">Informe a largura</b>';
if(a.lendo!=null){
barra='<div class="prog"><i style="width:'+a.lendo+'%"></i></div>'+
'<span class="progT">arquivo grande · gerando a prévia… '+a.lendo+'%</span>';
}else if(a.decodeError==='grande'){
aviso='<p class="avisoF"><b>Arquivo grande em JPG ou WebP.</b> Exporte em PNG para montar a folha.</p>';
'<span class="progT">arquivo grande · gerando a prévia · '+faltaLeitura(a.leitura)+'</span>';
}else if(a.decodeError){
aviso='<p class="avisoF"><b>Não conseguimos ler esta imagem.</b> Exporte novamente em PNG ou JPG.</p>';
}else if(a.cm>larguraFilme()){
aviso='<p class="avisoF"><b>Largura maior que o filme.</b> O filme aqui tem '+n1(larguraFilme())+' cm.</p>';
}else if(a.cm>0 && a.src){
}else if(a.cm>0 && (a.src||a.semPrevia)){
const dpi=Math.round(dpiDe(a));
medida='<b>'+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</b>';
chips='<ul class="chips"><li class="chip '+(dpi>=DPI_AVISA?'ok':dpi>=DPI_RECUSA?'av':'er')+'">'+dpi+' DPI</li>'+
@@ -562,12 +560,15 @@ function pintaArtes(){
n1(sug.cm)+' cm.<button data-usar="'+i+'" data-cm="'+sug.alvo+'">Usar '+n1(sug.alvo)+' cm</button></div>'
: '';
}
if(a.semPrevia && !a.decodeError)
aviso+='<p class="repTxt"><b>Sem prévia para '+escapeHTML(extDe(a.f.name))+' deste tamanho.</b> '+
'A arte entra na folha pela medida, e a equipe confere antes de imprimir. Em PNG, a prévia aparece.</p>';
const mini = a.miniSrc ? '<img src="'+escapeHTML(a.miniSrc)+'" alt="">' : '<span>'+escapeHTML(extDe(a.f.name))+'</span>';
const sub = a.cm>0 && a.src ? (a.q>1? a.q+' × ' : '')+'<b>'+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</b>' : '';
const sub = a.cm>0 && (a.src||a.semPrevia) ? (a.q>1? a.q+' × ' : '')+'<b>'+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</b>' : '';
return '<div class="rep art2'+(a===recemChegada?' nova':'')+'" data-arte="'+i+'">'+
'<div class="repTopo"><div class="miniF">'+mini+'</div><div class="repInfo">'+
'<div class="l1"><b>'+escapeHTML(a.f.name)+'</b>'+(a===recemChegada?'<em class="novaT">nova</em>':'')+
'<button data-rm="'+i+'" aria-label="Remover '+escapeHTML(a.f.name)+'">×</button></div>'+
'<button data-rm="'+i+'" aria-label="Remover '+escapeHTML(a.f.name)+'">'+icone('fechar')+'</button></div>'+
'<div class="repMed">'+medida+'<span>'+(a.px>0? a.px+' × '+a.py+' px · ' : '')+
escapeHTML(extDe(a.f.name))+' · '+fmt(a.f.size)+'</span></div>'+
barra+chips+aviso+dica+
@@ -581,8 +582,8 @@ function pintaArtes(){
'<input type="number" id="q'+i+'" min="1" max="200" data-q="'+i+'" value="'+a.q+'">'+
'<button type="button" data-passoa="1" data-i="'+i+'" aria-label="Mais uma cópia">+</button></span></div>'+
'<span class="giros">'+
'<button class="gr'+(a.giro?' on':'')+'" data-giro="'+i+'" title="Girar 90°" aria-label="Girar 90°">⟳</button>'+
'<button class="gr'+(a.esp?' on':'')+'" data-esp="'+i+'" title="Espelhar" aria-label="Espelhar">⇋</button></span>'+
'<button class="gr'+(a.giro?' on':'')+'" data-giro="'+i+'" title="Girar 90°" aria-label="Girar 90°">'+icone('girar')+'</button>'+
'<button class="gr'+(a.esp?' on':'')+'" data-esp="'+i+'" title="Espelhar" aria-label="Espelhar">'+icone('espelhar')+'</button></span>'+
(sub? '<div class="conta">'+sub+'</div>' : '')+
'<div class="atalhos">'+(TAMANHOS[larguraFilme()]||[]).map(t=>'<button class="tm'+
(Math.abs(a.cm-t)<0.05?' on':'')+'" data-tam="'+i+'" data-t="'+t+'">'+n1(t)+' cm</button>').join('')+'</div>'+
@@ -620,7 +621,7 @@ function pintaArtes(){
previaAoVivo();
}
function resumoArtes(){
const r=$('rA'), prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && a.src);
const r=$('rA'), prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && (a.src||a.semPrevia));
if(!prontas.length){ r.style.display='none'; return; }
const m=montagemCm/100; // a mesma folha que aparece ao lado
const notas=prontas.map(a=>Math.max(6,Math.min(100,Math.round(dpiDe(a)/300*100))));

View File

@@ -64,9 +64,7 @@ function avaliar(){
}
if(!artes.length) return {pronto:false, falta:'Arraste suas artes para começar.'};
if(artes.some(a=>a.lendo!=null)) return {pronto:false, falta:'Gerando a prévia das artes grandes…'};
if(artes.some(a=>a.decodeError==='grande'))
return {pronto:false, falta:'Uma arte grande está em JPG ou WebP. Exporte-a em PNG para montar a folha.'};
if(artes.some(a=>a.decodeError || !a.src || !(a.px>0) || !(a.py>0)))
if(artes.some(a=>a.decodeError || !(a.src||a.semPrevia) || !(a.px>0) || !(a.py>0)))
return {pronto:false, falta:'Não conseguimos ler uma das imagens. Exporte-a novamente em PNG ou JPG antes de continuar.'};
if(artes.some(a=>!Number.isFinite(a.cm) || a.cm>larguraFilme()))
return {pronto:false, falta:'A largura da arte precisa caber no filme de '+
@@ -119,7 +117,7 @@ function mostraNota(itens,n,titulo,sub){
$('qbi').style.width=(semNota?0:n)+'%'; $('qbi').style.background=c;
$('qitens').innerHTML=itens.map(i=>
'<div class="qi '+i[0]+'"><span class="c">'+
(i[0]==='ok'?'✓':i[0]==='av'?'!':i[0]==='fix'?'↻':'×')+'</span>'+
icone(i[0]==='ok'?'ok':i[0]==='av'?'alerta':i[0]==='fix'?'refazer':'fechar')+'</span>'+
'<div><b>'+escapeHTML(i[1])+'</b><span>'+escapeHTML(i[2])+'</span></div></div>').join('');
$('qmsg').style.display='none';
ressalvaResolucao();

87
web/site-shell.css Normal file
View File

@@ -0,0 +1,87 @@
/* Site DTF — what every page shares: the tokens, the base type, the stripe,
header, category menu and footer. The Site (index.html) and the order page
(portal.html) both load it before their own styles. */
:root{
/* tokens do site institucional */
--font-family-base:"Roboto",sans-serif; --font-family-title:"Inter",sans-serif;
--font-color-base:#03060b; --font-color-title:#03060b;
--font-weight-base:400; --font-weight-title:700; --font-weight-subtitle:600; --font-weight-button:600;
--font-size-title:3rem; --font-size-subtitle:2rem; --font-size-button:1.4rem;
--font-size-small:1.2rem; --font-size-base:1.4rem; --font-size-medium:1.6rem; --font-size-big:1.8rem;
--line-height:1.5;
--white:#fff; --black:#03060b;
--gray-1:#f9f9f9; --gray-2:#c3c3c3; --gray-3:#707070; --gray-4:#363636;
--primary-color:#ffa81a; --secondary-color:#ff7f0a;
--white-transparency:#ffffffb3; --transition:all .4s ease;
/* apelidos usados nesta pagina, mapeados nos tokens acima */
--marinho:var(--black); --papel:var(--white); --cinza:var(--gray-1);
--tx:var(--font-color-base); --fraco:var(--gray-3); --linha:#e6e6e6;
--laranja:var(--primary-color); --laranja2:var(--secondary-color);
--vermelho:#D94A28; --verde:#2E9E5B; --verde-ml:#00A650; --verde-claro:#57D68A;
--sombra:0 1px 2px rgba(3,6,11,.05), 0 4px 14px rgba(3,6,11,.05);
--sombra-alta:0 2px 6px rgba(3,6,11,.07), 0 10px 30px rgba(3,6,11,.11)}
*{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth;font-size:62.5%}
body{background:var(--white);color:var(--font-color-base);font-family:var(--font-family-base);
font-size:var(--font-size-base);font-weight:var(--font-weight-base);line-height:var(--line-height);
-webkit-font-smoothing:antialiased}
h1,h2,h3{font-family:var(--font-family-title);color:var(--font-color-title);
font-weight:var(--font-weight-title);letter-spacing:normal;line-height:1.15}
.w{max-width:1020px;margin:0 auto;padding:0 22px}
/* tarja rotativa, como no site */
.tarja{background:var(--laranja);color:#111;text-align:center;padding:8px 16px;
font-family:"Inter",sans-serif;font-weight:700;font-size:14.5px;letter-spacing:.02em}
.tarja a{color:#111;text-decoration:none}
/* topo */
.topo{background:#fff;border-bottom:1px solid var(--linha);position:sticky;top:0;z-index:60}
.topo .w{display:flex;align-items:center;gap:22px;padding-top:15px;padding-bottom:15px}
.logo{display:flex;align-items:center;text-decoration:none;white-space:nowrap;flex:none}
.logo img{height:26px;width:auto;display:block}
.logotexto{font-family:"Inter",sans-serif;font-weight:700;font-size:19px;color:var(--black);
letter-spacing:-.03em}
.logotexto i{font-style:normal;color:var(--laranja)}
.acts{margin-left:auto;display:flex;gap:18px;align-items:center;font-size:15px}
.acts a{color:var(--tx);text-decoration:none;display:flex;align-items:center;gap:6px}
.acts a:hover{color:var(--laranja)}
/* menu */
.menu{background:#fff;border-bottom:1px solid var(--linha)}
.menu .w{display:flex;gap:0;flex-wrap:wrap}
.mi{position:relative}
.mi>a{display:block;padding:13px 16px;font-family:"Inter",sans-serif;font-weight:600;
font-size:15px;color:var(--tx);text-decoration:none;white-space:nowrap}
.mi:hover>a{color:var(--laranja)}
.mi.on>a{color:var(--laranja);box-shadow:inset 0 -2px 0 var(--laranja)}
.sub{display:none;position:absolute;top:100%;left:0;background:#fff;border:1px solid var(--linha);
border-radius:0 0 9px 9px;min-width:224px;padding:7px 0;z-index:70;
box-shadow:0 10px 26px rgba(0,0,0,.09)}
.mi:hover .sub,.mi:focus-within .sub{display:block}
.sub a{display:block;padding:8px 17px;font-size:15px;color:var(--fraco);text-decoration:none}
.sub a:hover{color:var(--laranja);background:var(--cinza)}
@media(max-width:820px){
.menu .w{overflow-x:auto;flex-wrap:nowrap}
.sub{display:none!important}
}
footer{background:#000;color:#8D9AB0;padding:26px 0 20px;font-size:15px}
.frow{display:flex;gap:26px;align-items:flex-start;flex-wrap:wrap;padding-bottom:18px}
.fmarca{display:flex;align-items:center;gap:9px;font-family:"Inter",sans-serif;
font-weight:700;font-size:15px;color:var(--laranja);letter-spacing:.06em;white-space:nowrap}
.fmarca img{height:22px;width:auto;display:block;filter:brightness(0) invert(1)}
footer a{color:#8D9AB0;text-decoration:none}
footer a:hover{color:var(--laranja2)}
.flinks{display:flex;gap:18px;flex-wrap:wrap;font-size:15px}
.fcont{margin-left:auto;text-align:right;font-size:15px;line-height:1.7}
@media(max-width:720px){.fcont{margin-left:0;text-align:left}}
.fbase{border-top:1px solid rgba(255,255,255,.1);padding-top:14px;display:flex;
justify-content:space-between;gap:12px;flex-wrap:wrap;font-size:13.5px;color:#5A6570}
/* The footer sits at the bottom of the screen even when the page is short. */
body{display:flex;flex-direction:column;min-height:100vh}
body>footer{margin-top:auto}
/* In a column, auto side margins shrink a box to its content: the page's
centred blocks keep their full width, up to their max-width, as before. */
body>.w{width:100%}

View File

@@ -97,6 +97,8 @@ function vaiPara(url){
passos.querySelectorAll('[data-passo]').forEach(a=>{
const n=Number(a.dataset.passo);
a.classList.toggle('atual',n===atual); a.classList.toggle('feito',n<atual);
const marca=a.querySelector('span');
if(marca) marca.innerHTML = n<atual ? icone('ok') : String(n);
if(n===atual) a.setAttribute('aria-current','step'); else a.removeAttribute('aria-current');
});
passos.querySelector('[data-passo="1"]').href =

View File

@@ -65,10 +65,11 @@ function sel(fs){
x.an={dpi:md.dpiFolha, grande:true, artes:0, residuos:0, encostadas:0,
fundoChapado:false, aproveitamento:0, alturaCm:md.alt, fonteDpi:'arquivo'};
if(!/\.png$/i.test(x.f.name)){ x.pct=null; pintaFolha(); return; }
x.pct=60; pintaFolha();
x.pct=60; x.leitura={inicio:Date.now(), p:0}; pintaFolha();
let mostrado=0;
previaPngGrande(x.f, 600, p=>{
const pct=60+Math.floor(p*38);
x.leitura.p=p;
if(pct-mostrado>=1){ mostrado=pct; x.pct=pct; pintaProgressoFolha(x); }
}).then(cv=>{
if(cv && folhas.includes(x)){
@@ -128,11 +129,14 @@ function medir(a){
return dimensoesImagem(a.f).then(async d=>{
if(!d || !(d.w>0) || !(d.h>0)){ a.decodeError=true; return a; }
a.px=d.w; a.py=d.h; a.prop=d.h/d.w; a.grande=true;
if(!/\.png$/i.test(a.f.name)){ a.decodeError='grande'; return a; }
a.lendo=0; pintaArtes();
// JPG and WebP cannot be read in parts: the art is placed by its
// measurements, as a full box, and the team checks it before printing.
if(!/\.png$/i.test(a.f.name)){ a.semPrevia=true; return a; }
a.lendo=0; a.leitura={inicio:Date.now(), p:0}; pintaArtes();
let mostrado=0;
const cv=await previaPngGrande(a.f, 1200, p=>{
const pct=Math.floor(p*100);
a.leitura.p=p;
if(pct-mostrado>=1){ mostrado=pct; a.lendo=pct; pintaProgressoArte(a); }
}).catch(()=>null);
a.lendo=null;

View File

@@ -10,7 +10,7 @@
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)}
.et{font-family:"Inter",sans-serif;font-weight:800;font-size:15px;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),
@@ -23,31 +23,31 @@ html:not([data-rota="inicio"]) #envio{padding-top:0;padding-bottom:64px;min-heig
.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}
.carrVazio p{color:var(--fraco);font-size:17.5px}
/* 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{font-family:"Inter",sans-serif;font-weight:700;font-size:14.5px;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-text>p{font-size:19px;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}
.hero2-cta span{font-size:16px;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:18px;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-cab b{font-family:"Inter",sans-serif;font-size:16.5px}
.hf-cab span{margin-left:auto;font-size:15px;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 span{display:block;font-size:14.5px;color:#9EA3AA}
.hf-num b{font-family:"Inter",sans-serif;font-size:19px}
.hf-num .verde{color:#57D68A}.hf-num .laranja{color:var(--laranja)}
/* Steps strip */
@@ -55,15 +55,15 @@ html:not([data-rota="inicio"]) #envio{padding-top:0;padding-bottom:64px;min-heig
.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)}
.etapas2 b{font-family:"Inter",sans-serif;font-size:17px}
.etapas2 p{margin-top:3px;font-size:16px;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)}
.cards-cab p{margin-left:auto;max-width:440px;font-size:16.5px;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}
@@ -71,23 +71,23 @@ section+section{border-top:0}
.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-kind{font-family:"Inter",sans-serif;font-weight:700;font-size:14.5px;letter-spacing:.05em;color:#707070}
.marc2{margin-left:auto;font-family:"Inter",sans-serif;font-weight:800;font-size:13.5px;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>p{font-size:16.5px;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 span{font-size:15px;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-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:16.5px;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{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:16px}
.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 span{width:26px;height:26px;border-radius:999px;border:2px solid #D0D0D0;display:flex;align-items:center;justify-content:center;font-size:15px}
.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)}
@@ -100,13 +100,13 @@ section+section{border-top:0}
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{height:auto;padding:0;border:0;background:none;box-shadow:none;font-size:16px;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 24px!important;background:transparent;border:0;display:flex;flex-direction:column;gap:20px}
.cabfoco>div:first-child{display:flex;flex-direction:column-reverse;gap:4px}
.cabfoco .lg{font-size:12.5px;font-weight:800;color:var(--laranja-escuro);letter-spacing:.06em;text-transform:uppercase}
.cabfoco .lg{font-size:15px;font-weight:800;color:var(--laranja-escuro);letter-spacing:.06em;text-transform:uppercase}
.cabfoco h3{font-size:36px;font-weight:800;letter-spacing:-.02em;line-height:1.1}
.tipoEnvio{margin:0}
.tipoEnvio:not([hidden]){display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;max-width:calc(100% - 452px)}
@@ -114,11 +114,11 @@ section+section{border-top:0}
.tipoEnvio .cam.on{padding:13px 15px;border:2px solid var(--preto);background:#fff}
.tipoEnvio .cam .mk2{display:none}
.tipoEnvio .cam div{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:12px;row-gap:4px;align-items:baseline}
.tipoEnvio .cam b{margin:0;font-size:15px;font-weight:700;color:var(--texto2)}
.tipoEnvio .cam b{margin:0;font-size:16.5px;font-weight:700;color:var(--texto2)}
.tipoEnvio .cam.on b{color:var(--preto);font-weight:800}
.tipoEnvio .cam em{margin:0;font-size:13px;font-weight:500;color:var(--fraco);white-space:nowrap}
.tipoEnvio .cam em{margin:0;font-size:15px;font-weight:500;color:var(--fraco);white-space:nowrap}
.tipoEnvio .cam.on em{font-weight:600;color:var(--preto)}
.tipoEnvio .cam div>span{grid-column:1/-1;font-size:13px;color:var(--fraco);line-height:1.4}
.tipoEnvio .cam div>span{grid-column:1/-1;font-size:15px;color:var(--fraco);line-height:1.4}
.tipoEnvio .cam.on div>span{color:var(--texto2)}
.tipoEnvio .cam:disabled{cursor:default}
.tipoEnvio .cam:disabled:not(.on){opacity:.55}
@@ -132,58 +132,58 @@ section+section{border-top:0}
#larguraFolhaPronta{display:inline;font-size:inherit;color:inherit}
.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;display:flex;align-items:center;justify-content:center;width:44px;height:44px;line-height:1;margin:0;border-radius:10px;background:var(--laranja);color:var(--preto);font-size:20px;box-shadow:none}
.zona b{font-size:15px;margin:0}
.zona span{max-width:none;margin:0;font-size:13px;color:var(--fraco)}
.zona b{font-size:16.5px;margin:0}
.zona span{max-width:none;margin:0;font-size:15px;color:var(--fraco)}
.zona .bt{display:none}
.zona:hover,.zona.sobre{border-color:var(--laranja2);box-shadow:none}
.zona .zManual{grid-column:1/-1;margin-top:12px;padding-top:12px;border-top:1px solid var(--aviso-linha);color:var(--texto2);line-height:1.5}
.zona .zManual[hidden]{display:none}
.zona .zManual b{display:inline;font-size:13px}
.zona .zManual b{display:inline;font-size:15px}
.zona .zOutra{display:none}
.zona.compacta{display:flex;justify-content:center;align-items:center;min-height:56px;padding:0 20px;border:1.5px dashed #D0D0D0;background:#fff;cursor:pointer}
.zona.compacta>*{display:none}
.zona.compacta .zOutra{display:inline;font-size:14px;color:var(--fraco)}
.zona.compacta .zOutra b{display:inline;font-size:14px;font-weight:600;color:var(--preto)}
.zona.compacta .zOutra{display:inline;font-size:16px;color:var(--fraco)}
.zona.compacta .zOutra b{display:inline;font-size:16px;font-weight:600;color:var(--preto)}
.zona.compacta .zOutra b::before{content:"+";margin-right:8px;font-weight:700}
.zona.compacta:hover,.zona.compacta.sobre{border-color:var(--laranja2)}
#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 .l1 b{font-size:16.5px;font-weight:700}
.art label{font-size:14.5px}
.art input{height:42px;font-size:16.5px;font-weight:600;border-radius:8px}
.art .cps{grid-template-columns:repeat(2,minmax(0,160px))}
.listaCab{display:flex;justify-content:space-between;align-items:baseline;gap:12px}
.listaCab h4{margin:0;font-size:18px;font-weight:700}
.listaCab span{font-size:13px;color:var(--fraco)}
.listaCab h4{margin:0;font-size:19px;font-weight:700}
.listaCab span{font-size:15px;color:var(--fraco)}
.rep{margin:0;padding:20px;border-radius:14px;background:#fff;border:1px solid var(--linha);display:flex;flex-direction:column;gap:16px}
.repTopo{display:flex;gap:16px;align-items:flex-start}
.miniF{flex:none;width:52px;height:96px;border-radius:6px;border:1px solid #D6D6D6;background:#F4F4F2;display:flex;align-items:center;justify-content:center;overflow:hidden}
.miniF img{width:100%;height:100%;object-fit:contain;display:block}
.miniF span{font-size:11px;font-weight:800;color:var(--texto2)}
.miniF span{font-size:13.5px;font-weight:800;color:var(--texto2)}
.repInfo{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px}
.rep .l1{margin:0}
.rep .l1 b{font-size:14px;font-weight:600;color:var(--preto)}
.rep .l1 button{width:32px;height:32px;margin:-6px -6px -6px 0;border-radius:8px;font-size:18px}
.rep .l1 b{font-size:16px;font-weight:600;color:var(--preto)}
.rep .l1 button{width:32px;height:32px;margin:-6px -6px -6px 0;border-radius:8px;font-size:19px}
.rep .l1 button:hover{background:#F1F1F1;color:var(--preto)}
.repMed{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
.repMed b{font-family:"Inter",sans-serif;font-size:22px;font-weight:700;letter-spacing:-.01em}
.repMed span,.repPeso{font-size:13px;color:var(--fraco)}
.repMed span,.repPeso{font-size:15px;color:var(--fraco)}
.chips{list-style:none;margin:4px 0 0;padding:0;display:flex;flex-wrap:wrap;gap:8px}
.chip{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:999px;font-size:13px;font-weight:600;line-height:1.2}
.chip{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:999px;font-size:15px;font-weight:600;line-height:1.2}
.chip::before{font-weight:800}
.chip.ok{background:var(--verde-fundo);color:var(--verde)}.chip.ok::before{content:"✓"}
.chip.av{background:#FFF1DB;color:#8A4B00}.chip.av::before{content:"!"}
.chip.er{background:#FDE8E6;color:#B3261E}.chip.er::before{content:"×"}
.chip.fix{background:#E8F0FB;color:#1F4E8C}.chip.fix::before{content:"✓"}
.chip.nt{background:#F1F1F1;color:var(--texto2)}
.chipSo{align-self:flex-start;padding:5px 10px;border-radius:999px;background:#F1F1F1;color:var(--texto2);font-size:13px;font-weight:600}
.avisoF{margin:4px 0 0;padding:10px 12px;border-radius:10px;background:#FDE8E6;font-size:13px;line-height:1.5;color:var(--preto)}
.chipSo{align-self:flex-start;padding:5px 10px;border-radius:999px;background:#F1F1F1;color:var(--texto2);font-size:15px;font-weight:600}
.avisoF{margin:4px 0 0;padding:10px 12px;border-radius:10px;background:#FDE8E6;font-size:15px;line-height:1.5;color:var(--preto)}
.avisoF b{color:#B3261E}
.repTxt{margin:2px 0 0;font-size:13.5px;line-height:1.5;color:var(--texto2)}
.repTxt{margin:2px 0 0;font-size:15.5px;line-height:1.5;color:var(--texto2)}
.repTxt b{color:var(--preto)}
.confDet{font-size:13px}
.confDet{font-size:15px}
.confDet summary{display:inline;cursor:pointer;list-style:none;color:var(--laranja-escuro);font-weight:500}
.confDet summary::-webkit-details-marker{display:none}
.confDet summary:hover{text-decoration:underline}
@@ -191,96 +191,96 @@ section+section{border-top:0}
.confDet span{display:block;color:var(--texto2);line-height:1.6}
.rep .cps{display:flex;align-items:center;gap:16px 24px;flex-wrap:wrap;margin:0;padding-top:16px;border-top:1px solid #EFEFEF}
.rep .campo{display:flex;align-items:center;gap:12px}
.rep .campo label{margin:0;font-size:14px;font-weight:600;color:var(--preto)}
.rep .campo label{margin:0;font-size:16px;font-weight:600;color:var(--preto)}
.passo{display:inline-flex;border:1px solid #D6D6D6;border-radius:10px;overflow:hidden}
.passo button{width:44px;height:44px;border:0;background:#F9F9F9;font-size:18px;color:var(--preto);cursor:pointer}
.passo button{width:44px;height:44px;border:0;background:#F9F9F9;font-size:19px;color:var(--preto);cursor:pointer}
.passo button:hover{background:#EFEFEF}
.rep .passo input{width:56px;height:44px;padding:0;border:0;border-radius:0;text-align:center;font-size:16px;font-weight:700;-moz-appearance:textfield;appearance:textfield}
.rep .passo input{width:56px;height:44px;padding:0;border:0;border-radius:0;text-align:center;font-size:17.5px;font-weight:700;-moz-appearance:textfield;appearance:textfield}
.passo input::-webkit-inner-spin-button,.passo input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}
.rep .un{display:inline-flex;align-items:center;gap:6px;height:44px;padding:0 12px;border:1px solid #D6D6D6;border-radius:10px}
.rep .un input{width:64px;padding:0;border:0;font-size:16px;font-weight:700;text-align:right}
.rep .un i{font-style:normal;font-size:14px;color:var(--fraco)}
.rep .conta{margin-left:auto;font-size:14px;color:var(--texto2)}
.rep .conta b{font-family:"Inter",sans-serif;font-size:16px;color:var(--preto)}
.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}
.rep .un input{width:64px;padding:0;border:0;font-size:17.5px;font-weight:700;text-align:right}
.rep .un i{font-style:normal;font-size:16px;color:var(--fraco)}
.rep .conta{margin-left:auto;font-size:16px;color:var(--texto2)}
.rep .conta b{font-family:"Inter",sans-serif;font-size:17.5px;color:var(--preto)}
.calc{font-size:15px;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:15px;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{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:15px}
.dica button:hover{background:var(--verde);color:#fff;box-shadow:none}
.req{position:static;padding:8px 0 0;border:0;border-radius:0;background:none;box-shadow:none;display:flex;flex-wrap:wrap;align-items:baseline;gap:8px 20px}
.reqcab{margin:0;font-size:13px;font-weight:400;color:var(--fraco)}
.reqcab{margin:0;font-size:15px;font-weight:400;color:var(--fraco)}
.req details,.req details:first-of-type{border:0}
.reqLinks{display:contents}
.reqLinks button{padding:0;border:0;background:none;font:inherit;font-size:13px;font-weight:500;color:var(--laranja-escuro);cursor:pointer}
.reqLinks button{padding:0;border:0;background:none;font:inherit;font-size:15px;font-weight:500;color:var(--laranja-escuro);cursor:pointer}
.reqLinks button:hover{text-decoration:underline}
.reqLinks button.on{font-weight:700;color:var(--preto);text-decoration:underline;text-underline-offset:4px;text-decoration-color:var(--laranja)}
/* The links above open and close the answers; a closed one is not shown. */
.req details:not([open]){display:none}
.req summary{display:inline;padding:0;font-size:13px;font-weight:500;color:var(--laranja-escuro)}
.req summary{display:inline;padding:0;font-size:15px;font-weight:500;color:var(--laranja-escuro)}
.req summary::before{display:none}
.req details[open]{order:1;flex-basis:100%;padding:14px 16px 4px;border-radius:12px;background:var(--cinza)}
.req details[open] summary{display:block;margin-bottom:6px;font-size:14px;font-weight:700;color:var(--preto);text-decoration:none}
.req li{font-size:13px}
.req .obs2{font-size:12px}
.req details[open] summary{display:block;margin-bottom:6px;font-size:16px;font-weight:700;color:var(--preto);text-decoration:none}
.req li{font-size:15px}
.req .obs2{font-size:14.5px}
/* 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}
.montcab b{font-size:18px;font-weight:800}
.montpe{font-size:14.5px;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{display:flex;flex-direction:column;gap:10px;font-size:16.5px}
#compraVals[hidden]{display:none}
.cat.file .zoomB{display:none}
.ampliar{margin-left:auto;height:32px;padding:0 12px;border-radius:8px;border:1px solid #D6D6D6;background:#fff;font-size:12.5px;font-weight:600;color:var(--preto);cursor:pointer}
.ampliar{margin-left:auto;height:32px;padding:0 12px;border-radius:8px;border:1px solid #D6D6D6;background:#fff;font-size:15px;font-weight:600;color:var(--preto);cursor:pointer}
.ampliar:hover{border-color:var(--preto)}
.ampliar[hidden]{display:none}
.cat.file #vArea{max-height:340px}
.cEscada{display:flex;flex-direction:column;gap:8px;margin-bottom:4px}
.cEscada>span{font-size:13px;font-weight:600}
.cEscada>span{font-size:15px;font-weight:600}
.cEscada ol{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:4px}
.cEscada li{padding:8px 2px;border-radius:8px;background:#F4F4F2;border:1.5px solid transparent;display:flex;flex-direction:column;align-items:center;gap:2px}
.cEscada li span{font-size:11px;color:var(--fraco);white-space:nowrap}
.cEscada li b{font-size:13px;font-weight:500;color:var(--texto2)}
.cEscada li span{font-size:13.5px;color:var(--fraco);white-space:nowrap}
.cEscada li b{font-size:15px;font-weight:500;color:var(--texto2)}
.cEscada li.on{background:var(--verde-fundo);border-color:var(--verde)}
.cEscada li.on span,.cEscada li.on b{font-weight:700;color:var(--verde)}
.cEscada li.on:last-child{background:#F1F1F1;border-color:var(--preto)}
.cEscada li.on:last-child span,.cEscada li.on:last-child b{color:var(--preto)}
.cLin>span:first-child{color:var(--texto2)}
.cLin small{font-size:12px;color:var(--fraco)}
.cEco{margin:-6px 0 0;text-align:right;font-size:13px;font-weight:600;color:var(--verde)}
.cLin small{font-size:14.5px;color:var(--fraco)}
.cEco{margin:-6px 0 0;text-align:right;font-size:15px;font-weight:600;color:var(--verde)}
.cEco:empty{display:none}
#cPartes{display:block}
.ampliaDlg{width:min(960px,calc(100vw - 32px));max-height:calc(100vh - 48px);padding:0;border:0;border-radius:16px;box-shadow:0 20px 60px rgba(3,6,11,.3)}
.ampliaDlg::backdrop{background:rgba(3,6,11,.55)}
.ampliaCab{position:sticky;top:0;z-index:1;display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--linha);background:#fff}
.ampliaCab b{font-size:16px;font-weight:800}
.ampliaCab b{font-size:17.5px;font-weight:800}
.ampliaCab button{width:40px;height:40px;border:0;border-radius:8px;background:#F1F1F1;font-size:20px;cursor:pointer}
#ampliaArea .folhaPrevias{display:grid;gap:16px;padding:16px;background:#F8F9FA}
#ampliaArea .folhaPrevia{margin:0;background:#fff;border:1px solid var(--linha);border-radius:8px;overflow:hidden}
#ampliaArea .folhaPilha{display:flex;flex-direction:column;align-items:center;gap:4px}
#ampliaArea .folhaPilha img{display:block;width:auto;max-width:100%;height:auto;max-height:calc(100vh - 180px)!important}
#ampliaArea figcaption{padding:8px 12px;font-size:13px;color:var(--texto2);border-top:1px solid var(--linha)}
#ampliaArea figcaption{padding:8px 12px;font-size:15px;color:var(--texto2);border-top:1px solid var(--linha)}
.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)}
.cLin s{font-size:15px;color:#707070}
.cObs{margin:-4px 0 0;font-size:15px;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}
#qmsg{font-size:15px;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}
.compra .ciente h4{font-size:15.5px}
.compra .ciente p,.compra .ciente .lista{font-size:15px}
.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:17.5px;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}
.cPe{margin:0;font-size:15px;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}
@media(max-width:620px){.montcab b{font-size:16.5px}
.cabfoco h3{font-size:28px}
.tipoEnvio .cam div{grid-template-columns:1fr}
.tipoEnvio .cam div>span{display:none}
@@ -298,68 +298,104 @@ section+section{border-top:0}
/* 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{margin-left:auto;border:0;background:none;padding:6px 0;font-size:15.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 .marca{flex:none;display:flex;align-items:center;cursor:pointer}
.item .marca input{width:20px;height:20px;margin:0;accent-color:var(--laranja);cursor:pointer}
.miniItem{position:relative;flex:none;width:52px;height:52px;border-radius:8px;border:1px solid var(--linha);background:repeating-conic-gradient(#F1F1F1 0 25%,#fff 0 50%) 0 0/12px 12px;display:flex;align-items:center;justify-content:center;overflow:hidden}
.miniItem img{max-width:100%;max-height:100%;object-fit:contain}
.miniItem i{font-style:normal;font-size:12px;font-weight:700;color:var(--fraco)}
.miniItem em{position:absolute;right:2px;bottom:2px;padding:0 4px;border-radius:5px;background:rgba(3,6,11,.75);color:#fff;font-style:normal;font-size:11px;font-weight:700}
.item.fora .miniItem,.item.fora .nm2,.item.fora .vl2{opacity:.45}
.item .x{flex:none;height:36px;padding:0 12px;border-radius:8px;border:1px solid #D6D6D6;background:#fff;color:var(--texto2);font-size:15px;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{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:16px;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}
.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:16px;cursor:pointer}
@media(max-width:820px){.desfazer{bottom:100px}}
.carrAviso{grid-column:1/-1;font-size:13px;color:var(--fraco)}
.carrAviso{grid-column:1/-1;font-size:15px;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}
.cxE h4,.resumo h4{font-family:"Inter",sans-serif;font-weight:800;font-size:18px}
.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{height:auto;min-height:54px;padding:8px 14px;line-height:1.25;border-radius:var(--raio2);background:var(--laranja);color:var(--preto);border:0;font-family:"Inter",sans-serif;font-weight:800;font-size:17.5px}
#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}
.checkout #checkoutStatus:not(:empty){padding:16px 18px;border-radius:var(--raio);background:var(--verde-fundo);border:1px solid #BFE6CE;color:#14532D;font-size:16.5px}
#checkoutActions{margin-top:12px}
/* Only our own fields: Mercado Pago's card form (its instalment radios) keeps its own styles. */
#checkoutActions .pixBox input{height:44px;border-radius:var(--raio2);border:1px solid #D6D6D6;padding:0 12px}
/* The payment pages: paying on the left, the order summary on the right */
html:is([data-rota="pagamento"],[data-rota="pix"]) .checkout{max-width:1040px;margin-top:8px}
html:is([data-rota="pagamento"],[data-rota="pix"]) .pagGrid{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:32px;align-items:start}
.envioArq{font-size:13px;color:var(--texto2);margin-top:14px;line-height:1.5}
.ic{width:21px;height:21px;flex:none;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;vertical-align:middle}
.acts a .ic{width:23px;height:23px}
.cartIc{position:relative;display:inline-flex}
.acts #cartLink{gap:11px}
.cartN{position:absolute;top:-7px;right:-9px;min-width:17px;height:17px;padding:0 4px;border-radius:9px;background:var(--laranja);color:var(--preto);font-size:13px;font-weight:800;line-height:17px;text-align:center}
.cartN[hidden]{display:none}
.zona .ico .ic{width:23px;height:23px}
.art2 .giros .gr{display:inline-flex;align-items:center;justify-content:center}
.art2 .giros .gr .ic{width:22px;height:22px}
.rep .l1 button{display:inline-flex;align-items:center;justify-content:center}
.rep .l1 button .ic{width:20px;height:20px}
#ampliaFecha .ic{width:23px;height:23px}
@media (max-width:600px){.acts .actTx{display:none}.acts{gap:16px}}
.cAdd,#bPagar,#pOk,#qOk{display:flex;align-items:center;justify-content:center;gap:8px}
#pOk{display:inline-flex}
.cAdd .ic,#bPagar .ic{width:22px;height:22px}
.item .x{display:inline-flex;align-items:center;gap:6px}
.item .x .ic{width:18px;height:18px}
.opE b .ic{width:19px;height:19px;margin:-2px 6px 0 0;color:var(--laranja-escuro)}
.metodo .ic{width:23px;height:23px;color:var(--texto2)}
.codigo .pagBtn{display:inline-flex;align-items:center;gap:7px}
.codigo .pagBtn .ic{width:20px;height:20px}
.pixBox .prazo{display:flex;align-items:center;gap:8px}
.pixBox .prazo .ic{width:23px;height:23px}
.confirmado h4{display:flex;align-items:center;gap:8px}
.confirmado h4 .ic{width:24px;height:24px}
.envioArq .ic{width:19px;height:19px;margin:-2px 6px 0 0}
.qi .c .ic{width:15px;height:15px;stroke-width:3}
.passos a span .ic{width:17px;height:17px;stroke-width:3}
.obr{color:#B42318;font-weight:700}
.envioNota{display:block;margin-top:4px;font-size:14px;color:var(--fraco)}
.envioArq{font-size:15px;color:var(--texto2);margin-top:14px;line-height:1.5}
.envioArq.ok{color:var(--verde)}
.envioArq.erro{color:#B42318}
.envioDeNovo{margin-left:4px;padding:0;border:0;background:none;color:inherit;font:inherit;font-weight:700;text-decoration:underline;cursor:pointer}
.envioBarra{height:6px;border-radius:999px;background:#EDEDED;overflow:hidden;margin-bottom:6px}
.envioBarra i{display:block;height:100%;background:var(--laranja);border-radius:999px;transition:width .4s}
.cartEnvio{font-size:13px;font-weight:700;color:var(--laranja-escuro);margin-left:6px}
.cartEnvio{font-size:15px;font-weight:700;color:var(--laranja-escuro);margin-left:6px}
.cartEnvio:empty{display:none}
.envioArq:empty{display:none}
.dicaEnd{font-size:13px;color:var(--texto2);margin-top:12px}
.dicaEnd:empty{display:none}
.pagCab{display:flex;align-items:baseline;justify-content:space-between;gap:16px;margin-bottom:20px}
.pagCab h3{font-size:26px;font-weight:800;letter-spacing:-.02em}
.pagVolta{font-size:14px;color:var(--texto2)}
#checkoutActions > h4,#checkoutActions .pixBox > h4{font-size:17px;font-weight:800;margin-bottom:12px}
.pagNota{font-size:14px;color:var(--texto2);margin:4px 0 16px;line-height:1.5}
.pagVolta{font-size:16px;color:var(--texto2)}
#checkoutActions > h4,#checkoutActions .pixBox > h4{font-size:18px;font-weight:800;margin-bottom:12px}
.pagNota{font-size:16px;color:var(--texto2);margin:4px 0 16px;line-height:1.5}
.pagResumo{position:sticky;top:var(--topo-passos,128px);border:1px solid var(--linha);border-radius:16px;background:#fff;padding:20px 22px}
.pagResumo h4{font-size:17px;font-weight:800;margin-bottom:10px}
.pagResumo .l{display:flex;justify-content:space-between;gap:12px;font-size:14px;padding:5px 0}
.pagResumo h4{font-size:18px;font-weight:800;margin-bottom:10px}
.pagResumo .l{display:flex;justify-content:space-between;gap:12px;font-size:16px;padding:5px 0}
.pagResumo .l span{color:var(--texto2)}
.pagResumo .tot{display:flex;justify-content:space-between;align-items:baseline;border-top:1px solid #EFEFEF;margin-top:10px;padding-top:12px}
.pagResumo .tot b{font-family:"Inter",sans-serif;font-weight:900;font-size:24px}
.metodos{display:grid;grid-template-columns:1fr;gap:10px;max-width:420px;margin-bottom:20px}
.metodo{height:56px;display:flex;align-items:center;gap:12px;padding:0 16px;border:2px solid var(--linha);border-radius:var(--raio2);background:#fff;color:var(--preto);font-family:"Inter",sans-serif;font-weight:700;font-size:15px;text-align:left;cursor:pointer}
.metodo{height:56px;display:flex;align-items:center;gap:12px;padding:0 16px;border:2px solid var(--linha);border-radius:var(--raio2);background:#fff;color:var(--preto);font-family:"Inter",sans-serif;font-weight:700;font-size:16.5px;text-align:left;cursor:pointer}
.metodo::before{content:"";width:18px;height:18px;flex-shrink:0;border-radius:50%;border:2px solid #B5B8BD;box-sizing:border-box}
.metodo[aria-checked="true"]{border-color:var(--laranja);background:var(--aviso-fundo)}
.metodo[aria-checked="true"]::before{border:6px solid var(--laranja)}
.pagBtn{height:48px;padding:0 24px;margin:0 10px 0 0;border-radius:var(--raio2);border:0;background:var(--laranja);color:var(--preto);font-family:"Inter",sans-serif;font-weight:800;font-size:15px;cursor:pointer}
.pagBtn{height:48px;padding:0 24px;margin:0 10px 0 0;border-radius:var(--raio2);border:0;background:var(--laranja);color:var(--preto);font-family:"Inter",sans-serif;font-weight:800;font-size:16.5px;cursor:pointer}
.pagBtn:hover:not(:disabled){background:#FFB53D}
.pagBtn:disabled{background:#EDEDED;color:#8A8D92;cursor:default}
.pagBtn.sec{background:#fff;border:1px solid #D6D6D6}
@@ -367,12 +403,12 @@ html:is([data-rota="pagamento"],[data-rota="pix"]) .pagGrid{display:grid;grid-te
.pixBox{display:flex;flex-direction:column;align-items:flex-start;gap:12px}
.pixBox img{border:1px solid var(--linha);border-radius:12px;padding:8px;background:#fff}
.pixBox .codigo{display:flex;gap:10px;width:100%}
.pixBox .codigo input{flex:1;min-width:0;font-size:13px}
.pixBox .aguarda{font-size:14px;color:var(--texto2)}
.pixBox .prazo{font-family:"Inter",sans-serif;font-weight:800;font-size:18px;font-variant-numeric:tabular-nums}
.pixBox .codigo input{flex:1;min-width:0;font-size:15px}
.pixBox .aguarda{font-size:16px;color:var(--texto2)}
.pixBox .prazo{font-family:"Inter",sans-serif;font-weight:800;font-size:19px;font-variant-numeric:tabular-nums}
.confirmado{border:1px solid #BFE6CE;background:var(--verde-fundo);border-radius:16px;padding:24px}
.confirmado h4{font-size:22px;color:#14532D;margin-bottom:8px}
.confirmado p{font-size:15px;color:#14532D;margin-bottom:18px;line-height:1.5}
.confirmado p{font-size:16.5px;color:#14532D;margin-bottom:18px;line-height:1.5}
@media(max-width:820px){
html:is([data-rota="pagamento"],[data-rota="pix"]) .pagGrid{grid-template-columns:1fr;gap:20px}
.pagResumo{position:static;order:-1}
@@ -384,26 +420,26 @@ html:is([data-rota="pagamento"],[data-rota="pix"]) .pagGrid{display:grid;grid-te
.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-preco>p{font-size:16.5px;color:var(--texto2)}
.info2 table{border-collapse:collapse;background:#fff;border:1px solid var(--linha);border-radius:12px;overflow:hidden;font-size:16.5px}
.info2 th{text-align:left;background:var(--preto);color:#fff;font-family:"Inter",sans-serif;font-size:15px;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 .nota2{font-size:15px;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{font-family:"Inter",sans-serif;font-size:17.5px}
.info2-vant b.n{font-size:32px;font-weight:900;color:var(--laranja2)}
.info2-vant span{font-size:14px;color:var(--fraco)}
.info2-vant span{font-size:16px;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 span{font-size:14.5px;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}
.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:17.5px;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}
@@ -414,7 +450,7 @@ html:is([data-rota="pagamento"],[data-rota="pix"]) .pagGrid{display:grid;grid-te
.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{gap:8px;font-size:15px;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}
@@ -429,23 +465,29 @@ html:is([data-rota="pagamento"],[data-rota="pix"]) .pagGrid{display:grid;grid-te
@keyframes skel{0%{background-position:100% 50%}100%{background-position:0 50%}}
@media (prefers-reduced-motion:reduce){.skel{animation:none}}
.folhaSkel{display:flex;align-items:center;justify-content:center;max-height:520px;min-height:180px;width:100%}
.folhaSkel span{font-size:12.5px;color:var(--fraco);background:#fffc;padding:4px 10px;border-radius:999px}
.folhaSkel span{font-size:15px;color:var(--fraco);background:#fffc;padding:4px 10px;border-radius:999px}
#vArea .folhaPilha{display:flex;flex-direction:column;gap:4px;max-height:520px;overflow:auto;background:#fff}
#vArea .folhaPilha img{width:100%;height:auto;display:block}
.folhaMais{font-size:12px;color:var(--texto2);text-align:center;padding:8px}
.folhaMais{font-size:14.5px;color:var(--texto2);text-align:center;padding:8px}
.skelLinha{height:14px;margin:8px 0}
.skelLinha.curta{width:40%}
.skelBloco{height:160px;margin-top:8px}
/* Artes avulsas: the file card of a finished sheet, plus width, rotation and sizes */
.rep.nova{box-shadow:0 0 0 2px rgba(255,168,26,.45)}
.novaT{margin-left:8px;padding:2px 7px;border-radius:999px;background:#FFF1DB;color:#8A4B00;font-size:11px;font-style:normal;font-weight:800;text-transform:uppercase;letter-spacing:.04em}
.repMed .pede{font-size:16px;color:var(--texto2)}
.novaT{margin-left:8px;padding:2px 7px;border-radius:999px;background:#FFF1DB;color:#8A4B00;font-size:13.5px;font-style:normal;font-weight:800;text-transform:uppercase;letter-spacing:.04em}
.repMed .pede{font-size:17.5px;color:var(--texto2)}
.art2 .giros{display:inline-flex;gap:6px}
.art2 .giros .gr{width:44px;height:44px;border:1px solid #D6D6D6;border-radius:10px;background:#fff;font-size:16px;cursor:pointer}
.art2 .giros .gr{width:44px;height:44px;border:1px solid #D6D6D6;border-radius:10px;background:#fff;font-size:17.5px;cursor:pointer}
.art2 .giros .gr.on{border-color:var(--laranja);background:var(--aviso-fundo)}
.art2 .atalhos{flex-basis:100%;display:flex;flex-wrap:wrap;gap:8px;margin:0}
.art2 .atalhos .tm{height:34px;padding:0 12px;border:1px solid #D6D6D6;border-radius:8px;background:#fff;font-size:13px;font-weight:600;cursor:pointer}
.art2 .atalhos .tm{height:34px;padding:0 12px;border:1px solid #D6D6D6;border-radius:8px;background:#fff;font-size:15px;font-weight:600;cursor:pointer}
.art2 .atalhos .tm.on{border-color:var(--verde);background:var(--verde-fundo);color:var(--verde)}
.art2 .dica{margin-top:4px}
.rep .l1 .novaT{margin-right:auto}
/* On a computer the Site is drawn at 80%: the sizes above were chosen looking
at it that way, and at 100% everything felt too big. Phones and tablets
keep their own sizes. The zoom scales viewport units too, so the page's
full height is 125vh there (site-shell.css keeps the footer at the bottom). */
@media (min-width:1100px){html{zoom:.8}body{min-height:125vh}}