From 24ee4e9eabd9765fe20d6c9ae6858361bdda8537 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Wed, 30 Sep 2026 16:57:37 -0300 Subject: [PATCH] feat: Minha conta with sign-in, overview, orders and account details 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 --- app/api/customer.py | 120 ++++++++++++- app/core/models.py | 29 ++++ deploy/nginx.conf.template | 5 + infra/nginx.conf.template | 4 + tests/browser_test.mjs | 12 +- tests/workflow_test.py | 20 ++- web/cart.js | 20 ++- web/checkout.js | 2 +- web/index.html | 2 +- web/portal.css | 154 ++++++++++------- web/portal.html | 107 +++++++++--- web/portal.js | 340 +++++++++++++++++++++++++++---------- 12 files changed, 631 insertions(+), 184 deletions(-) diff --git a/app/api/customer.py b/app/api/customer.py index f3f22e7..0bccef5 100644 --- a/app/api/customer.py +++ b/app/api/customer.py @@ -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)): diff --git a/app/core/models.py b/app/core/models.py index b9d0507..806cca6 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -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) diff --git a/deploy/nginx.conf.template b/deploy/nginx.conf.template index eb33d83..f80b4b8 100644 --- a/deploy/nginx.conf.template +++ b/deploy/nginx.conf.template @@ -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; diff --git a/infra/nginx.conf.template b/infra/nginx.conf.template index 10fc420..787cff4 100644 --- a/infra/nginx.conf.template +++ b/infra/nginx.conf.template @@ -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 { diff --git a/tests/browser_test.mjs b/tests/browser_test.mjs index 06e3f29..2fb8d1a 100644 --- a/tests/browser_test.mjs +++ b/tests/browser_test.mjs @@ -192,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();})()`); diff --git a/tests/workflow_test.py b/tests/workflow_test.py index c857ac7..a00c064 100644 --- a/tests/workflow_test.py +++ b/tests/workflow_test.py @@ -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() diff --git a/web/cart.js b/web/cart.js index e826179..e91172f 100644 --- a/web/cart.js +++ b/web/cart.js @@ -39,9 +39,23 @@ pedido=saved.items; $('atual').style.display='none';pintaEntrega(); notice.textContent='Carrinho recuperado. Remova e adicione novamente um item se precisar alterar sua montagem.'; - }else{ - const account=await window.dtfApi('/account/me'); - if(account.customer && !cliente.mail){cliente={...account.customer};for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];pintaEntrega();} + } + // 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;} diff --git a/web/checkout.js b/web/checkout.js index 10b0c34..ff157c0 100644 --- a/web/checkout.js +++ b/web/checkout.js @@ -396,7 +396,7 @@ 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(); diff --git a/web/index.html b/web/index.html index 3805586..133f4d7 100644 --- a/web/index.html +++ b/web/index.html @@ -651,7 +651,7 @@ h2 em{font-style:normal;color:var(--laranja)}
diff --git a/web/portal.css b/web/portal.css index 483f922..2fa1cd5 100644 --- a/web/portal.css +++ b/web/portal.css @@ -1,67 +1,99 @@ -/* Meus pedidos: the Site's look (tokens and shell from site-shell.css and - site-v2.css) for the order page. */ +/* 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)} -.ped{padding-top:26px;padding-bottom:64px;min-height:70vh} -.volta{display:inline-block;margin-bottom:14px;font-size:15px;color:var(--texto2);text-decoration:none} -.volta:hover{color:var(--laranja-escuro)} -.pedCab{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:18px} -.pedCab h1{font-size:34px;font-weight:800;margin-top:4px} -.conta{margin-top:6px;font-size:15px;color:var(--fraco)} -.conta:empty{display:none} -.pedAcoes{display:flex;gap:10px} +#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;font-size:15px;color:var(--laranja-escuro)} +.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{margin-top:14px;padding:0;border:0;background:none;color:var(--laranja-escuro);font-family:"Inter",sans-serif;font-weight:700;font-size:15px;cursor:pointer;text-decoration:underline;text-underline-offset:3px} +.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} -/* Sign in / create account. */ -.acesso{margin-bottom:26px} -.acessoIntro{margin-bottom:12px;font-size:15px;color:var(--texto2)} -.acessoIntro:empty{display:none} -.acessoGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px} -.bloco{display:flex;flex-direction:column;padding:22px;border:1px solid var(--linha);border-radius:var(--raio);background:#fff} -.bloco h2{font-size:21px;font-weight:800} -.bloco .nota2{margin:4px 0 12px;font-size:14.5px;color:var(--fraco)} -.bloco label,.correcaoForm label{margin:10px 0 5px;font-size:14px;font-weight:600;color:var(--texto2)} +/* 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,.correcaoForm textarea{width:100%;height:46px;padding:0 13px;border:1px solid #D6D6D6;border-radius:var(--raio2);font:inherit;font-size:16px;background:#fff} -.correcaoForm textarea{height:auto;padding:10px 13px;resize:vertical} -.bloco input:focus,.correcaoForm textarea:focus{outline:none;border-color:var(--laranja);box-shadow:0 0 0 3px rgba(255,168,26,.2)} -.bloco .bPri{margin-top:16px} -.bloco .nota2:last-child{margin:12px 0 0} +.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} -.duas>div{display:flex;flex-direction:column} +.tres{display:grid;grid-template-columns:1fr 1fr 90px;gap:0 12px} +.duas>div,.tres>div{display:flex;flex-direction:column} -/* Orders. */ -.grupo{margin-bottom:22px} -.grupo:empty{display:none} -.tituloGrupo{margin-bottom:12px;font-size:20px;font-weight:800} -.pedido{margin-bottom:14px;padding:20px 22px;border:1px solid var(--linha);border-radius:var(--raio);background:#fff} -.pedido.pendente{border-color:var(--aviso-linha)} -.pedido.correcao{border-color:#F3B7A9} -.pedTopo{display:flex;align-items:center;gap:14px;flex-wrap:wrap} -.pedTopo>div{flex:1;min-width:160px} -.pedTopo h3{font-size:19px;font-weight:800} -.pedTopo .data{font-size:14.5px;color:var(--fraco)} -.pedTopo .valor{font-family:"Inter",sans-serif;font-size:19px;font-weight:800} -.pedido.pendente .bPri{margin-top:14px} -.pedido .nota2{margin-top:10px;font-size:15px;color:var(--fraco)} -.estado{padding:5px 11px;border-radius:999px;font-family:"Inter",sans-serif;font-size:14px;font-weight:700;background:var(--cinza);color:var(--texto2);white-space:nowrap} +/* 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-imp,.est-fil,.est-tra,.est-rec{background:var(--aviso-fundo);color:var(--laranja-escuro)} +.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)} -/* Progress through production. */ +/* 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} @@ -77,7 +109,6 @@ button:disabled{opacity:.6;cursor:default} .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} @@ -85,12 +116,8 @@ button:disabled{opacity:.6;cursor:default} .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{width:19px;height:19px;color:var(--laranja-escuro)} - -/* Details. */ -.detalhe:empty{display:none} -.detalhe{margin-top:14px;padding-top:4px;border-top:1px dashed var(--linha)} -.secao{margin-top:14px} +.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} @@ -103,14 +130,23 @@ button:disabled{opacity:.6;cursor:default} .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 input[type=file]{font-size:15px} +.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} -.vazio{display:flex;flex-direction:column;align-items:flex-start;gap:12px;padding:26px 22px;border:1px dashed #D6D6D6;border-radius:var(--raio);background:#fff;font-size:16px;color:var(--texto2)} - -@media (max-width:600px){ - .pedCab h1{font-size:28px} - .trilha b{font-size:12.5px} - .duas{grid-template-columns:1fr} - .pedido{padding:16px} +@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} } diff --git a/web/portal.html b/web/portal.html index 5b64e7c..c68f962 100644 --- a/web/portal.html +++ b/web/portal.html @@ -3,7 +3,7 @@ -Meus pedidos · DTF Dropstar +Minha conta · DTF Dropstar @@ -20,38 +20,29 @@
- ‹ Enviar outra arte -
-
-

DTF Dropstar

-

Meus pedidos

-

-
-
- - -
-

-
-

+ + -
-
+ +