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

-

-
-
- - -
-

-
-

+ + -
-
+ +