diff --git a/.env.example b/.env.example index 5e6077e..d45427f 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,6 @@ # Dominio API_DOMAIN=api.comerciobes.com.br FRONTEND_URL=https://comerciobes.com.br -WEBHOOK_BASE_URL=https://api.comerciobes.com.br COOKIE_DOMAIN=.comerciobes.com.br # Banco Docker local/producao @@ -44,9 +43,5 @@ CLOUDINARY_CLOUD_NAME= CLOUDINARY_API_KEY= CLOUDINARY_API_SECRET= -# Pagamentos Mercado Pago - opcional -MERCADO_PAGO_ACCESS_TOKEN= -MERCADO_PAGO_PUBLIC_KEY= - # Nao habilite em publico; serve apenas para homologacao sem banco. AUTH_FALLBACK_ENABLED=false diff --git a/.github/workflows/ci-deploy.yml b/.github/workflows/ci-deploy.yml index 3fe8f99..da4cb20 100644 --- a/.github/workflows/ci-deploy.yml +++ b/.github/workflows/ci-deploy.yml @@ -30,6 +30,7 @@ jobs: --health-retries 5 env: TEST_DATABASE_URL: postgresql://comerciobes:test-password@127.0.0.1:5432/comerciobes_test?schema=public + DATABASE_URL: postgresql://comerciobes:test-password@127.0.0.1:5432/comerciobes_test?schema=public defaults: run: working-directory: backend diff --git a/AGENTS.md b/AGENTS.md index e86bdb6..c23e87a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,14 +2,13 @@ ## O que é -Guia comercial + marketplace local para **Boa Esperança do Sul — SP**. Conecta moradores ao comércio local via web/PWA. Projeto de um colaborador (dono do repo), sendo desenvolvido com Pedro como co-desenvolvedor. +Vitrine digital e guia comercial para **Boa Esperança do Sul — SP**. Conecta moradores aos estabelecimentos locais via web/PWA. O Comercio BES cuida da descoberta; transacoes pertencem ao Braga Commerce. ## Stack | Camada | Tecnologia | |--------|-----------| | Frontend atual | HTML5 + CSS3 + JS vanilla (PWA) | -| Frontend alvo | React/Next em migracao planejada | | Backend | Node.js + Express.js | | Banco | PostgreSQL 16 via Prisma ORM | | Auth | JWT (HS256) + bcrypt | @@ -24,13 +23,13 @@ backend/ → API Express src/ server.js → entry point controllers/ → lógica de negócio - routes/ → auth, comercios, categorias, avaliacoes, pedidos, pagamentos, upload, estatisticas + routes/ → auth, comercios, categorias, avaliacoes, upload e estatisticas middleware/ → auth.js (JWT), upload.js (multer), errorHandler.js lib/prisma.js → PrismaClient singleton minha-conta/ → painel unico por role (/minha-conta) prisma/ - schema.prisma → 12 modelos: User, Endereco, Categoria, Comercio, Produto, Promocao, Avaliacao, Estatistica, Pedido, ItemPedido, Pagamento, AuditLog -docs/ → OPERACAO.md + schema.prisma → schema atual em transicao para o modelo minimo da vitrine +docs/ → roadmap, ADRs, operacao e auditoria de seguranca ``` ## Comandos úteis @@ -64,7 +63,7 @@ docker compose up -d - `admin` — acesso administrativo, painel em `/minha-conta` - `comerciante` — gerencia própria loja, painel em `/minha-conta` -- `cliente` — avalia, faz pedidos +- `cliente` — salva favoritos e avalia quando autenticado ## Branch strategy @@ -89,12 +88,12 @@ https://api.comerciobes.com.br ## Fase atual -**Fase 4 — Rebase do Marketplace** (em andamento) -- Escopo congelado: pedido real, painel, pagamento testado e checks confiaveis. -- Vitrine vanilla atual deve ser mantida apenas ate a migracao cobrir o fluxo essencial. -- `localStorage` pode manter carrinho/favoritos, mas nao deve ser fonte de verdade para pedidos. -- Cupons, notificacoes push e novas features ficam fora ate checkout/pagamento estarem testados. -- Frontend React/Next deve nascer em paralelo, consumindo a API existente, sem reescrever backend agora. +**v0.1 - Scope Reset** (em andamento) +- Carrinho, checkout, pedidos e pagamentos nao fazem parte do produto publico. +- Interfaces, rotas e controllers do marketplace legado foram removidos; apenas tabelas historicas aguardam migracao controlada. +- O Braga Commerce e dono de catalogo transacional, estoque, pedido, pagamento, entrega e pos-venda. +- A integracao com o Braga Commerce deve passar por um adapter read-only com contrato versionado. +- A vitrine vanilla sera estabilizada antes de qualquer decisao de reescrita. ## Auditoria de segurança @@ -102,4 +101,6 @@ Concluída em março/2026. **43 vulnerabilidades corrigidas** (4 críticas, 8 al ## O que ainda não existe -- CI/CD completo de deploy; checks backend com PostgreSQL existem e devem ser mantidos obrigatorios +- Cobertura automatizada suficiente para bloquear regressao +- Adapter homologado com o Braga Commerce +- CI/CD completo de deploy diff --git a/CLAUDE.md b/CLAUDE.md index a5caba6..b5f2bd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## O que é -Guia comercial + marketplace local para **Boa Esperança do Sul — SP**. Conecta moradores ao comércio local via web/PWA. Projeto de um colaborador (dono do repo), sendo desenvolvido com Pedro como co-desenvolvedor. +Vitrine digital e guia comercial para **Boa Esperança do Sul — SP**. O Comercio BES cuida da descoberta local; transacoes pertencem ao Braga Commerce. ## Stack @@ -23,13 +23,13 @@ backend/ → API Express src/ server.js → entry point controllers/ → lógica de negócio - routes/ → auth, comercios, categorias, avaliacoes, pedidos, pagamentos, upload, estatisticas + routes/ → auth, comercios, categorias, avaliacoes, upload e estatisticas middleware/ → auth.js (JWT), upload.js (multer), errorHandler.js lib/prisma.js → PrismaClient singleton minha-conta/ → painel unico por role (/minha-conta) prisma/ - schema.prisma → 12 modelos: User, Endereco, Categoria, Comercio, Produto, Promocao, Avaliacao, Estatistica, Pedido, ItemPedido, Pagamento, AuditLog -docs/ → OPERACAO.md + schema.prisma → schema atual com tabelas transacionais historicas aguardando migracao +docs/ → roadmap, ADRs, operacao e auditoria ``` ## Comandos úteis @@ -63,7 +63,7 @@ docker compose up -d - `admin` — acesso administrativo, painel em `/minha-conta` - `comerciante` — gerencia própria loja, painel em `/minha-conta` -- `cliente` — avalia, faz pedidos +- `cliente` — gerencia a conta e avalia quando autenticado ## Branch strategy @@ -82,12 +82,12 @@ claude/* → worktrees do Claude Code (merge na dev ao final da sessão) ## Fase atual -**Fase 4 — Marketplace** (em andamento) -- Carrinho + histórico de pedidos: implementado (localStorage) -- Pedidos backend (`/api/pedidos`): implementado -- Pagamento PIX Mercado Pago (`/api/pagamentos`): backend implementado, pendente testes + UI -- Notificações push: pendente -- Sistema de cupons: pendente +**v0.1 - Scope Reset** (em andamento) +- Carrinho, checkout, pedidos, pagamentos e estoque nao fazem parte do produto. +- Interfaces, APIs e dependencias transacionais foram removidas. +- Tabelas historicas permanecem ate inventario, backup e migracao versionada. +- O Braga Commerce e dono da experiencia de compra. +- A vitrine vanilla sera simplificada e estabilizada antes de qualquer reescrita. ## Auditoria de segurança diff --git a/README.md b/README.md index 5ff9f83..a55055a 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,22 @@ # Comercio BES -Guia comercial e marketplace local de Boa Esperanca do Sul - SP. +Vitrine digital e guia comercial de Boa Esperanca do Sul - SP. -## Decisao atual +O produto ajuda moradores a encontrar estabelecimentos, consultar informacoes essenciais e iniciar contato pelos canais oficiais da loja. Carrinho, pedido, pagamento, estoque e entrega nao fazem parte do nucleo do Comercio BES. -Nao recomecar do zero. A base atual continua como backend/API/infra, mas a Fase 4 fica congelada em estabilizacao do marketplace real. +## Direcao do produto -Prioridade atual: +- O Comercio BES e a fonte da descoberta local: busca, categorias, perfil, fotos, horario, mapa, contato e moderacao. +- O Braga Commerce e a fonte da transacao: catalogo comercial, estoque, carrinho, pedido, pagamento, entrega e pos-venda. +- A integracao sera feita por um adapter read-only e links assinados/deep links quando o contrato estiver definido. +- As interfaces e APIs do marketplace legado foram removidas; as tabelas historicas permanecem somente ate a migracao controlada dos dados. +- Dados transacionais existentes somente poderao ser removidos apos inventario, backup restauravel e aprovacao da politica de retencao. -1. checkout criando pedido real em `/api/pedidos`; -2. painel lendo e atualizando esses pedidos; -3. checks com PostgreSQL real no CI; -4. pagamento Mercado Pago testado e endurecido; -5. migracao planejada da vitrine para React/Next sem apagar a vitrine atual antes de cobrir o fluxo essencial. - -Nada de cupons, push ou novas features antes disso. - -Rodar a API em servidor local com Docker agora e migrar para VPS depois. O dominio da API ja deve ser definitivo: - -```txt -https://api.comerciobes.com.br -``` - -Assim a migracao futura troca DNS e restaura backup, sem mudar o frontend. +A decisao completa esta em [`docs/adr/0001-product-scope-and-braga-commerce.md`](docs/adr/0001-product-scope-and-braga-commerce.md). ## Stack -- Frontend: HTML, CSS e JavaScript vanilla. +- Frontend: HTML, CSS e JavaScript vanilla, distribuido como PWA. - Backend: Node.js + Express. - Banco: PostgreSQL 16 via Prisma. - Proxy/SSL: Caddy. @@ -72,7 +62,18 @@ URLs locais: - Lojista: `lojista@comerciobes.com` / `lojista123` - Alias lojista: `comerciante@demo.com` / `demo123` -## Docs importantes +## Qualidade + +```bash +cd backend +npm run build +npm test +npm audit --omit=dev --audit-level=high +``` + +## Documentacao -- Operacao, backup e migracao: `docs/OPERACAO.md` -- Roadmap, milestones e issues: `docs/ROADMAP.md` +- [Roadmap, milestones e issues](docs/ROADMAP.md) +- [ADR de escopo e Braga Commerce](docs/adr/0001-product-scope-and-braga-commerce.md) +- [Operacao, backup e migracao](docs/OPERACAO.md) +- [Auditoria de seguranca](docs/security-audit.md) diff --git a/backend/minha-conta/js/sections.js b/backend/minha-conta/js/sections.js index 5ac0f24..0389544 100644 --- a/backend/minha-conta/js/sections.js +++ b/backend/minha-conta/js/sections.js @@ -20,11 +20,6 @@ const ICONS = { users: ``, shield: ``, box: ``, - archive: ``, - receipt: ``, - bag: ``, - creditCard: ``, - mail: ``, }; /** @type {Section[]} */ @@ -74,48 +69,6 @@ export const sections = [ icon: ICONS.box, load: '/minha-conta/js/sections/merchant/ProdutosSection.js', }, - { - key: 'estoque', - path: '/minha-conta/estoque', - roles: ['comerciante'], - label: 'Estoque', - icon: ICONS.archive, - load: '/minha-conta/js/sections/merchant/EstoqueSection.js', - }, - { - key: 'pedidos-loja', - path: '/minha-conta/pedidos-loja', - roles: ['comerciante'], - label: 'Pedidos', - icon: ICONS.receipt, - load: '/minha-conta/js/sections/merchant/PedidosSection.js', - }, - - // ---- Cliente ---- - { - key: 'historico', - path: '/minha-conta/historico', - roles: ['cliente'], - label: 'Meus Pedidos', - icon: ICONS.bag, - load: '/minha-conta/js/sections/customer/HistoricoSection.js', - }, - { - key: 'pagamentos', - path: '/minha-conta/pagamentos', - roles: ['cliente'], - label: 'Pagamentos', - icon: ICONS.creditCard, - load: '/minha-conta/js/sections/customer/PagamentosSection.js', - }, - { - key: 'enderecos', - path: '/minha-conta/enderecos', - roles: ['cliente'], - label: 'Endereços', - icon: ICONS.mail, - load: '/minha-conta/js/sections/customer/EnderecosSection.js', - }, ]; /** diff --git a/backend/minha-conta/js/sections/common/InicioSection.js b/backend/minha-conta/js/sections/common/InicioSection.js index a7ff435..2b57fa3 100644 --- a/backend/minha-conta/js/sections/common/InicioSection.js +++ b/backend/minha-conta/js/sections/common/InicioSection.js @@ -21,22 +21,14 @@ export function mount(container, ctx) { users: ``, shield: ``, list: ``, - settings: ``, box: ``, - receipt: ``, - truck: ``, - dollar: ``, - mapPin: ``, - creditCard: ``, - mail: ``, - cornerUpLeft: `` }; const roleConfig = { admin: { emoji: SVG.crown, titulo: 'Painel de Administração', - descricao: 'Gerencie usuários, lojas, pedidos e configurações globais do marketplace.', + descricao: 'Gerencie usuários, lojas e a moderação da vitrine local.', cor: '#7C3AED', items: [ { icon: SVG.users, label: 'Usuários', path: '/minha-conta/usuarios', cap: 'users.manage' }, @@ -48,23 +40,18 @@ export function mount(container, ctx) { titulo: 'Painel do Lojista', descricao: stores.length > 1 ? `Você gerencia ${stores.length} lojas. Use o seletor no topo para alternar.` - : 'Gerencie seus produtos, pedidos e recebíveis.', + : 'Gerencie as informações públicas e os produtos em destaque da sua loja.', cor: '#047857', items: [ - { icon: SVG.box, label: 'Produtos', path: '/minha-conta/produtos', cap: 'products.manage.own' }, - { icon: SVG.receipt, label: 'Pedidos', path: '/minha-conta/pedidos-loja', cap: 'orders.view.ownStore' }, + { icon: SVG.box, label: 'Produtos em destaque', path: '/minha-conta/produtos', cap: 'products.manage.own' }, ], }, cliente: { emoji: SVG.bag, titulo: 'Minha Conta', - descricao: 'Acompanhe seus pedidos, endereços e dados de pagamento.', + descricao: 'Gerencie seus dados de acesso ao guia comercial.', cor: '#2563EB', - items: [ - { icon: SVG.bag, label: 'Meus Pedidos', path: '/minha-conta/historico', cap: 'orders.view.own' }, - { icon: SVG.mail, label: 'Endereços', path: '/minha-conta/enderecos', cap: 'addresses.manage.own' }, - { icon: SVG.creditCard, label: 'Pagamentos', path: '/minha-conta/pagamentos', cap: 'payments.manage.own' }, - ], + items: [], }, }; diff --git a/backend/minha-conta/js/sections/common/PerfilSection.js b/backend/minha-conta/js/sections/common/PerfilSection.js index fc14017..a72c455 100644 --- a/backend/minha-conta/js/sections/common/PerfilSection.js +++ b/backend/minha-conta/js/sections/common/PerfilSection.js @@ -110,7 +110,6 @@ async function loadPerfil(container, ctx) { try { const res = await fetch('/api/auth/perfil', { credentials: 'include', - headers: { 'Authorization': `Bearer ${getToken()}` }, }); if (!res.ok) return; const data = await res.json(); @@ -145,7 +144,6 @@ async function submitPerfil(e, container, ctx) { headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': ctx.csrfToken, - 'Authorization': `Bearer ${getToken()}`, }, body: JSON.stringify(body), }); @@ -173,9 +171,6 @@ function showFeedback(el, msg, type) { el.className = `feedback-box ${type}`; } function hideFeedback(el) { el.className = 'feedback-box hidden'; } -function getToken() { - return document.cookie.split(';').find(c => c.trim().startsWith('access_token='))?.split('=')[1] || ''; -} export function unmount() { _cleanup.forEach(fn => fn()); diff --git a/backend/minha-conta/js/sections/customer/EnderecosSection.js b/backend/minha-conta/js/sections/customer/EnderecosSection.js deleted file mode 100644 index 816f60f..0000000 --- a/backend/minha-conta/js/sections/customer/EnderecosSection.js +++ /dev/null @@ -1,192 +0,0 @@ -// ===================================================== -// EnderecosSection.js — Gerenciamento de endereços (cliente) -// ===================================================== - -let _cleanup = []; - -export function mount(container, ctx) { - _cleanup = []; - - container.innerHTML = ` -
-
-
-

📬 Endereços de Entrega

-

Gerencie seus endereços salvos

-
- -
- - - -
-
-
-
- - - `; - - carregarEnderecos(container, ctx); - - const btnNovo = container.querySelector('#btn-novo-endereco'); - const onNovo = () => mostrarFormulario(container, ctx, null); - btnNovo.addEventListener('click', onNovo); - _cleanup.push(() => btnNovo.removeEventListener('click', onNovo)); -} - -async function carregarEnderecos(container, ctx) { - const el = container.querySelector('#enderecos-list'); - try { - const res = await fetch('/api/auth/enderecos', { credentials: 'include' }); - if (!res.ok) throw new Error(); - const enderecos = await res.json(); - - if (!enderecos.length) { - el.innerHTML = `

Nenhum endereço salvo

Adicione um endereço de entrega.

`; - return; - } - - el.innerHTML = `
${enderecos.map(e => renderEnderecoCard(e)).join('')}
`; - - el.querySelectorAll('.btn-excluir[data-id]').forEach(btn => { - btn.addEventListener('click', () => excluirEndereco(Number(btn.dataset.id), container, ctx)); - }); - } catch { - el.innerHTML = '

Erro ao carregar endereços

'; - } -} - -function renderEnderecoCard(e) { - return ` -
-
-

- ${e.apelido} - ${e.principal ? 'Principal' : ''} -

-

${e.rua}, ${e.numero}${e.complemento ? ` ${e.complemento}` : ''}

-

${e.bairro} · ${e.cidade}/${e.estado} · CEP ${formatCEP(e.cep)}

-
-
- -
-
`; -} - -function mostrarFormulario(container, ctx, endereco) { - const formEl = container.querySelector('#endereco-form-container'); - formEl.classList.remove('hidden'); - formEl.innerHTML = ` -
-

Novo endereço

-
-
-
-
-
-
-
-
-
-
-
- -
- - -
-
-
`; - - formEl.querySelector('#btn-cancelar-form').addEventListener('click', () => { - formEl.classList.add('hidden'); - formEl.innerHTML = ''; - }); - - formEl.querySelector('#form-endereco').addEventListener('submit', async (e) => { - e.preventDefault(); - const f = e.target; - const body = { - apelido: f.apelido.value.trim() || 'Casa', - cep: f.cep.value.replace(/\D/g, ''), - rua: f.rua.value.trim(), - numero: f.numero.value.trim(), - complemento: f.complemento.value.trim() || undefined, - bairro: f.bairro.value.trim(), - cidade: f.cidade.value.trim(), - estado: f.estado.value.trim().toUpperCase(), - principal: f.principal.checked, - }; - try { - const res = await fetch('/api/auth/enderecos', { - method: 'POST', credentials: 'include', - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': ctx.csrfToken }, - body: JSON.stringify(body), - }); - if (res.ok) { - formEl.classList.add('hidden'); - formEl.innerHTML = ''; - carregarEnderecos(container, ctx); - } else { - const d = await res.json(); - alert(d.error || 'Erro ao salvar'); - } - } catch { alert('Erro de rede.'); } - }); -} - -async function excluirEndereco(id, container, ctx) { - if (!confirm('Excluir este endereço?')) return; - try { - const res = await fetch(`/api/auth/enderecos/${id}`, { - method: 'DELETE', credentials: 'include', - headers: { 'X-CSRF-Token': ctx.csrfToken }, - }); - if (res.ok) carregarEnderecos(container, ctx); - else { const d = await res.json(); alert(d.error || 'Erro ao excluir'); } - } catch { alert('Erro de rede.'); } -} - -function formatCEP(cep) { - return cep.length === 8 ? `${cep.slice(0, 5)}-${cep.slice(5)}` : cep; -} - -export function unmount() { - _cleanup.forEach(fn => fn()); - _cleanup = []; -} diff --git a/backend/minha-conta/js/sections/customer/HistoricoSection.js b/backend/minha-conta/js/sections/customer/HistoricoSection.js deleted file mode 100644 index 29b1450..0000000 --- a/backend/minha-conta/js/sections/customer/HistoricoSection.js +++ /dev/null @@ -1,206 +0,0 @@ -// ===================================================== -// HistoricoSection.js — Histórico de pedidos (cliente) -// ===================================================== -import { esc } from '../../utils.js'; - -let _cleanup = []; -let _currentPage = 1; - -export function mount(container, ctx) { - _cleanup = []; - _currentPage = 1; - - container.innerHTML = ` -
-
-

🛍️ Meus Pedidos

-

Acompanhe seus pedidos e histórico de compras

-
- -
- - -
- -
-
-
- - -
- - - `; - - carregarPedidos(container, ctx, 1); - - const filtro = container.querySelector('#filtro-status'); - const onFiltro = () => carregarPedidos(container, ctx, 1); - filtro.addEventListener('change', onFiltro); - _cleanup.push(() => filtro.removeEventListener('change', onFiltro)); -} - -async function carregarPedidos(container, ctx, page) { - _currentPage = page; - const listaEl = container.querySelector('#pedidos-list'); - const pagEl = container.querySelector('#paginacao'); - const status = container.querySelector('#filtro-status').value; - - listaEl.innerHTML = '
'; - pagEl.classList.add('hidden'); - - try { - const params = new URLSearchParams({ page, limit: 8 }); - if (status) params.set('status', status); - - const res = await fetch(`/api/pedidos?${params}`, { credentials: 'include' }); - if (!res.ok) throw new Error(res.status); - - const { pedidos, paginacao } = await res.json(); - - if (!pedidos.length) { - listaEl.innerHTML = ` -
-

Nenhum pedido encontrado

-

${status ? 'Tente remover o filtro de status.' : 'Você ainda não fez nenhum pedido.'}

-
`; - return; - } - - listaEl.innerHTML = pedidos.map(p => renderPedidoCard(p)).join(''); - - // Paginação - if (paginacao.totalPaginas > 1) { - pagEl.classList.remove('hidden'); - pagEl.innerHTML = renderPaginacao(paginacao); - pagEl.querySelectorAll('.btn-pag[data-page]').forEach(btn => { - btn.addEventListener('click', () => carregarPedidos(container, ctx, Number(btn.dataset.page))); - }); - } - - // Botões cancelar - listaEl.querySelectorAll('.btn-cancelar[data-codigo]').forEach(btn => { - btn.addEventListener('click', () => cancelarPedido(btn.dataset.codigo, container, ctx)); - }); - - } catch { - listaEl.innerHTML = '

Erro ao carregar pedidos

Verifique sua conexão e tente novamente.

'; - } -} - -function renderPedidoCard(p) { - const itensTxt = p.itens.map(i => `${esc(String(i.quantidade))}x ${esc(i.nome)}`).join(', '); - const data = new Date(p.createdAt).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' }); - const total = p.total.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); - const statusLabel = { - pendente: '⏳ Pendente', confirmado: '✅ Confirmado', - preparando: '👨‍🍳 Preparando', saiu_entrega: '🚚 Em entrega', - entregue: '✔️ Entregue', cancelado: '❌ Cancelado', - }[p.status] || esc(p.status); - - const podeCancelar = ['pendente', 'confirmado'].includes(p.status); - - return ` -
-
-
-

${esc(p.codigo)}

-

${esc(p.comercio?.emoji) || '🏪'} ${esc(p.comercio?.nome) || 'Loja'}

-
- ${statusLabel} -
-
-

${itensTxt}

- -
-
`; -} - -function renderPaginacao(p) { - let btns = ``; - for (let i = 1; i <= p.totalPaginas; i++) { - btns += ``; - } - btns += ``; - return btns; -} - -async function cancelarPedido(codigo, container, ctx) { - const motivo = prompt(`Cancelar pedido ${codigo}?\nInforme o motivo:`); - if (!motivo) return; - - try { - const res = await fetch(`/api/pedidos/${codigo}/status`, { - method: 'PUT', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': ctx.csrfToken, - }, - body: JSON.stringify({ status: 'cancelado', motivoCancelamento: motivo }), - }); - if (res.ok) { - carregarPedidos(container, ctx, _currentPage); - } else { - const d = await res.json(); - alert(d.error || 'Erro ao cancelar pedido'); - } - } catch { - alert('Erro de rede. Tente novamente.'); - } -} - -export function unmount() { - _cleanup.forEach(fn => fn()); - _cleanup = []; -} diff --git a/backend/minha-conta/js/sections/customer/PagamentosSection.js b/backend/minha-conta/js/sections/customer/PagamentosSection.js deleted file mode 100644 index 95bac7d..0000000 --- a/backend/minha-conta/js/sections/customer/PagamentosSection.js +++ /dev/null @@ -1,95 +0,0 @@ -// ===================================================== -// PagamentosSection.js — Pagamentos do cliente -// ===================================================== -// Exibe histórico de pagamentos dos pedidos -// (não há endpoints de cartão salvo no MVP) - -let _cleanup = []; - -export function mount(container, ctx) { - _cleanup = []; - - container.innerHTML = ` -
-
-

💳 Pagamentos

-

Histórico de pagamentos dos seus pedidos

-
- -
-
-
-
- - - `; - - carregarPagamentos(container); -} - -const METODO_LABEL = { - pix: '🔷 PIX', cartao_credito: '💳 Cartão de Crédito', - cartao_debito: '💳 Cartão de Débito', boleto: '📄 Boleto', - na_entrega: '💵 Pagar na Entrega', -}; - -async function carregarPagamentos(container) { - const el = container.querySelector('#pagamentos-list'); - try { - const res = await fetch('/api/pedidos?limit=30', { credentials: 'include' }); - if (!res.ok) throw new Error(); - const { pedidos } = await res.json(); - - const comPagamento = pedidos.filter(p => p.pagamento); - if (!comPagamento.length) { - el.innerHTML = `

Nenhum pagamento encontrado

Seus pagamentos aparecerão aqui após realizar pedidos.

`; - return; - } - - el.innerHTML = comPagamento.map(p => { - const pag = p.pagamento; - const valor = p.total.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); - const data = new Date(p.createdAt).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' }); - const metodo = METODO_LABEL[pag.metodo] || pag.metodo || '—'; - const statusCls = { aprovado: 'pag-aprovado', pendente: 'pag-pendente', recusado: 'pag-recusado', devolvido: 'pag-devolvido' }[pag.status] || 'pag-pendente'; - const statusLabel = { aprovado: 'Aprovado', pendente: 'Pendente', recusado: 'Recusado', devolvido: 'Devolvido' }[pag.status] || pag.status; - - return ` -
-
-

${p.codigo} · ${p.comercio?.nome || 'Loja'}

-

${metodo} · ${data}

-
-
-

${valor}

- ${statusLabel} -
-
`; - }).join(''); - - } catch { - el.innerHTML = '

Erro ao carregar pagamentos

'; - } -} - -export function unmount() { - _cleanup.forEach(fn => fn()); - _cleanup = []; -} diff --git a/backend/minha-conta/js/sections/merchant/EstoqueSection.js b/backend/minha-conta/js/sections/merchant/EstoqueSection.js deleted file mode 100644 index b2177e5..0000000 --- a/backend/minha-conta/js/sections/merchant/EstoqueSection.js +++ /dev/null @@ -1,118 +0,0 @@ -// ===================================================== -// EstoqueSection.js — Gerenciamento rápido de estoque -// ===================================================== -import { esc } from '../../utils.js'; - -let _cleanup = []; - -export function mount(container, ctx) { - _cleanup = []; - - container.innerHTML = ` -
-
-
-

🗃️ Estoque Rápido

-

Alterne rapidamente a disponibilidade dos seus produtos

-
-
- - - -
-
-
-
- - - `; - - if (!ctx.activeStoreId) { - container.querySelector('#no-store-banner').classList.remove('hidden'); - container.querySelector('#estoque-list').classList.add('hidden'); - return; - } - - carregarEstoque(container, ctx); -} - -async function carregarEstoque(container, ctx) { - const el = container.querySelector('#estoque-list'); - try { - const storeObj = ctx.stores.find(s => s.id === ctx.activeStoreId); - if (!storeObj) throw new Error('Loja não encontrada'); - const res = await fetch(`/api/comercios/${storeObj.slug}`); - if (!res.ok) throw new Error(); - const loja = await res.json(); - - const catalogo = loja.catalogo || []; - - if (!catalogo.length) { - el.innerHTML = `

Sem produtos

Adicione produtos primeiro na aba Produtos.

`; - return; - } - - const canMutate = !ctx.fallbackAuth; - - el.innerHTML = catalogo.map(p => { - const disponivel = p.disponivel !== false; - const produtoId = p.id ?? ''; - return ` -
-
-

${esc(p.nome_produto || p.nome)}

-

R$ ${p.preco.toFixed(2).replace('.', ',')}

-
- -
- `; - }).join(''); - - if (canMutate) { - el.querySelectorAll('.toggle-disponivel').forEach(chk => { - chk.addEventListener('change', async (e) => { - const id = e.target.dataset.id; - const disponivel = e.target.checked; - try { - await fetch(`/api/comercios/${storeObj.slug}/produtos/${id}`, { - method: 'PUT', credentials: 'include', - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': ctx.csrfToken }, - body: JSON.stringify({ disponivel }) - }); - } catch { alert('Erro de rede ao salvar alteração'); } - }); - }); - } - - } catch { - el.innerHTML = '

Erro ao carregar estoque

'; - } -} - -export function unmount() { - _cleanup.forEach(fn => fn()); - _cleanup = []; -} diff --git a/backend/minha-conta/js/sections/merchant/PedidosSection.js b/backend/minha-conta/js/sections/merchant/PedidosSection.js deleted file mode 100644 index 205f5eb..0000000 --- a/backend/minha-conta/js/sections/merchant/PedidosSection.js +++ /dev/null @@ -1,218 +0,0 @@ -// ===================================================== -// PedidosSection.js — Gerenciamento de pedidos (comerciante) -// ===================================================== -import { esc } from '../../utils.js'; - -let _cleanup = []; -let _currentPage = 1; - -export function mount(container, ctx) { - _cleanup = []; - _currentPage = 1; - - container.innerHTML = ` -
-
-
-

🧾 Pedidos Recebidos

-

Gerencie os pedidos da loja ativa

-
-
- - - -
- - -
- -
-
-
- - -
- - - `; - - if (!ctx.activeStoreId) { - container.querySelector('#no-store-banner').classList.remove('hidden'); - container.querySelector('#pedidos-filters').classList.add('hidden'); - container.querySelector('#pedidos-list').classList.add('hidden'); - return; - } - - if (ctx.fallbackAuth) { - container.querySelector('#pedidos-list').innerHTML = ` -
-

Nenhum pedido

-

Modo demo sem banco: os pedidos reais aparecem depois que o PostgreSQL estiver configurado.

-
`; - return; - } - - carregarPedidos(container, ctx, 1); - - const filtro = container.querySelector('#filtro-status'); - filtro.addEventListener('change', () => carregarPedidos(container, ctx, 1)); - _cleanup.push(() => filtro.removeEventListener('change', () => { })); -} - -async function carregarPedidos(container, ctx, page) { - _currentPage = page; - const listaEl = container.querySelector('#pedidos-list'); - const pagEl = container.querySelector('#paginacao'); - const status = container.querySelector('#filtro-status').value; - - listaEl.innerHTML = '
'; - pagEl.classList.add('hidden'); - - try { - const params = new URLSearchParams({ page, limit: 10, comercioId: ctx.activeStoreId }); - if (status) params.set('status', status); - - const res = await fetch(`/api/pedidos?${params}`, { credentials: 'include' }); - if (!res.ok) throw new Error(); - const { pedidos, paginacao } = await res.json(); - - if (!pedidos.length) { - listaEl.innerHTML = ` -
-

Nenhum pedido

-

Não há pedidos para a loja selecionada com os filtros atuais.

-
`; - return; - } - - listaEl.innerHTML = pedidos.map(p => renderPedidoCard(p)).join(''); - - // Eventos de alteração de status - listaEl.querySelectorAll('.btn-status[data-status]').forEach(btn => { - btn.addEventListener('click', () => atualizarStatus(btn.dataset.codigo, btn.dataset.status, container, ctx)); - }); - - } catch { - listaEl.innerHTML = '

Erro ao carregar pedidos

'; - } -} - -function renderPedidoCard(p) { - const itensTxt = p.itens.map(i => `${esc(String(i.quantidade))}x ${esc(i.nome)}`).join('
'); - const end = p.endereco ? `📍 ${esc(p.endereco.rua)}, ${esc(p.endereco.numero)} - ${esc(p.endereco.bairro)}` : '📍 Retirada no local'; - const total = p.total.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); - - const statusLabel = { - pendente: '⏳ Pendente', confirmado: '✅ Confirmado', - preparando: '👨‍🍳 Preparando', saiu_entrega: '🚚 Em entrega', - entregue: '✔️ Entregue', cancelado: '❌ Cancelado', - }[p.status] || esc(p.status); - - // Botões de ação baseados no status atual - let acoes = ''; - if (p.status === 'pendente') { - acoes = ` - - - `; - } else if (p.status === 'confirmado') { - acoes = ``; - } else if (p.status === 'preparando') { - acoes = ``; - } else if (p.status === 'saiu_entrega') { - acoes = ``; - } - - return ` -
-
-
- ${esc(p.codigo)} - 👤 ${esc(p.cliente?.nome) || 'Cliente'} -
- ${statusLabel} -
-
-
${itensTxt}
-
${end}
-
- -
- `; -} - -async function atualizarStatus(codigo, status, container, ctx) { - let body = { status }; - if (status === 'cancelado') { - const motivo = prompt('Motivo do cancelamento:'); - if (!motivo) return; - body.motivoCancelamento = motivo; - } - - try { - const res = await fetch(`/api/pedidos/${codigo}/status`, { - method: 'PUT', credentials: 'include', - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': ctx.csrfToken }, - body: JSON.stringify(body) - }); - if (res.ok) carregarPedidos(container, ctx, _currentPage); - else alert('Erro ao atualizar pedido'); - } catch { - alert('Erro de rede'); - } -} - -export function unmount() { - _cleanup.forEach(fn => fn()); - _cleanup = []; -} diff --git a/backend/package-lock.json b/backend/package-lock.json index 000d84c..902d117 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -21,7 +21,6 @@ "express-rate-limit": "^8.5.2", "helmet": "^8.2.0", "jsonwebtoken": "^9.0.3", - "mercadopago": "^3.2.0", "morgan": "^1.11.0", "multer": "^2.2.0", "pg": "^8.22.0", @@ -35,7 +34,7 @@ "supertest": "^7.2.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=24.0.0" } }, "node_modules/@babel/code-frame": { @@ -5427,14 +5426,6 @@ "node": ">= 0.6" } }, - "node_modules/mercadopago": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/mercadopago/-/mercadopago-3.2.0.tgz", - "integrity": "sha512-9keIOj21CpIvpLepeUp+VTUejiD7Dc2eCy2yqW21G4yBtj3Ey2E4AsnFrxRfGchtRc6L7qxA3YNkT4N7IcWj+g==", - "engines": { - "node": ">=18" - } - }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index ee310f4..c35d02b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,7 +23,7 @@ "keywords": [ "comercio", "boa-esperanca-do-sul", - "marketplace", + "guia-comercial", "api" ], "author": "Pedro", @@ -43,7 +43,6 @@ "express-rate-limit": "^8.5.2", "helmet": "^8.2.0", "jsonwebtoken": "^9.0.3", - "mercadopago": "^3.2.0", "morgan": "^1.11.0", "multer": "^2.2.0", "pg": "^8.22.0", diff --git a/backend/public/index.html b/backend/public/index.html index 667c1ad..a98261f 100644 --- a/backend/public/index.html +++ b/backend/public/index.html @@ -197,27 +197,6 @@ background: rgba(255, 68, 68, 0.15); } - /* Cart badge counter */ - .nav-badge { - position: absolute; - top: -6px; - right: -6px; - background: var(--verde); - color: #fff; - font-size: 11px; - font-weight: 700; - width: 20px; - height: 20px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - border: 2px solid var(--creme); - } - - body.dark-mode .nav-badge { - border-color: #0f0f0f; - } @@ -376,11 +355,6 @@ - @@ -590,7 +564,7 @@

Construindo em público

O caminho do Comércio BES até o lançamento

-

Estamos começando simples: primeiro um guia confiável para encontrar lojas, depois pedidos mais completos, pagamentos e ferramentas para os próprios comerciantes cuidarem das páginas.

+

Estamos construindo uma vitrine confiável para encontrar lojas locais. Quando um comércio também usar o Braga Commerce, o perfil poderá apontar para a experiência de compra mantida por ele.

@@ -601,7 +575,7 @@

Guia comercial

  • Busca por comércios, categorias e mapa
  • Páginas de lojas com fotos, horário e WhatsApp
  • Favoritos, ranking e promoções simples
  • -
  • Pedido leve direcionado ao WhatsApp
  • +
  • Contato direto por WhatsApp e canais oficiais
  • @@ -617,13 +591,13 @@

    Homologação privada

    - Depois do básico -

    Marketplace gradual

    + Integração futura +

    Braga Commerce

      -
    • Pedidos salvos no backend
    • -
    • Histórico do cliente e painel do lojista
    • -
    • PIX Mercado Pago testado em sandbox
    • -
    • Segurança reforçada e auditoria final
    • +
    • Vínculo seguro entre as lojas dos dois sistemas
    • +
    • Disponibilidade resumida com origem e data da sincronização
    • +
    • Link para a experiência de compra externa
    • +
    • Monitoramento, privacidade e degradação segura
    @@ -712,7 +686,7 @@

    Comerciante?

    - + @@ -743,27 +717,6 @@

    Comerciante?

    Cadastrar minha loja - -
    -
    -
    -

    - - Meu Carrinho

    - -
    -
    - -
    -
    @@ -822,81 +775,6 @@

    ➕ Cadastrar Minha Loja

    - -
    -
    - -

    🛒 Finalizar Pedido

    -
    - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    -
    - - -
    -
    - - -
    -
    -
    -
    - - -
    -
    - - -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    - -

    📋 Meus Pedidos

    -
    -
    -
    -
    @@ -962,12 +840,6 @@

    📋 Meus Pedidos

    document.addEventListener('DOMContentLoaded', () => { verificarLogin(); - if (localStorage.getItem('bes_cart_pending') === '1' && Auth.isLoggedIn()) { - localStorage.removeItem('bes_cart_pending'); - const user = Auth.getUser(); - const nome = user ? ' ' + user.nome.split(' ')[0] : ''; - setTimeout(() => mostrarToast('Bem-vindo(a)' + nome + '! Agora você pode adicionar itens ao carrinho.'), 600); - } }); diff --git a/backend/public/js/app.js b/backend/public/js/app.js index 0a5b48e..0e66407 100644 --- a/backend/public/js/app.js +++ b/backend/public/js/app.js @@ -13,11 +13,6 @@ import { instalarPWA, fecharBannerPWA } from './modules/ui.js'; import { carregarComercios, registrarEstatistica } from './modules/api.js'; -import { Cart } from './modules/cart.js'; -import { - toggleCarrinhoDrawer, fecharCarrinhoDrawer, - renderCarrinhoDrawer, cartUpdateQtd, cartRemove -} from './modules/cart.js'; import { Merchants } from './modules/merchants.js'; import { renderizarCards, criarCard, irPagina } from './render/cards.js'; import { renderPromos, renderRanking } from './render/promotions.js'; @@ -28,7 +23,6 @@ import { } from './render/favorites.js'; import { abrirModal, fecharModal, - alterarQtdModal, adicionarAoCarrinho, enviarPedidoWhatsApp, avaliar, enviarAvaliacao, abrirLightbox, fecharLightbox, navLightbox } from './render/modal.js'; @@ -39,11 +33,6 @@ import { toggleContaMenu, fecharContaMenu } from './modules/auth-ui.js'; import { Auth } from './modules/auth.js'; -import { - abrirCheckout, fecharCheckout, - confirmarPedido, confirmarPedidoWhatsApp, enviarTudoWhatsApp -} from './modules/checkout.js'; -import { abrirPedidos, fecharPedidos } from './render/orders-ui.js'; import { abrirCadastroLoja, fecharCadastroLoja, salvarCadastroLoja } from './modules/merchant-ui.js'; import { filtrarCategoria, filtrarBusca, setSearch, @@ -59,7 +48,6 @@ Object.assign(window, { toggleDarkMode, toggleMobileMenu, fecharMobileMenu, toggleContaMenu, fecharContaMenu, abrirFavoritos, fecharFavoritos, - toggleCarrinhoDrawer, fecharCarrinhoDrawer, // Auth abrirAuth, fecharAuth, trocarAuthTab, @@ -74,21 +62,12 @@ Object.assign(window, { // Modal de perfil abrirModal, fecharModal, toggleFavoritoModal, toggleFavoritoCard, - alterarQtdModal, adicionarAoCarrinho, enviarPedidoWhatsApp, avaliar, enviarAvaliacao, copiarLinkLoja, // definida abaixo neste arquivo // Lightbox de fotos abrirLightbox, fecharLightbox, navLightbox, - // Carrinho / checkout - cartUpdateQtd, cartRemove, - abrirCheckout, fecharCheckout, enviarTudoWhatsApp, - confirmarPedido, confirmarPedidoWhatsApp, - - // Pedidos - abrirPedidos, fecharPedidos, - // API (usado em popups do mapa e cards gerados) registrarEstatistica, mostrarToast, @@ -143,11 +122,14 @@ async function carregarDados() { } async function inicializar() { + // Remove rascunhos locais do marketplace descontinuado. Nenhum deles era + // fonte de verdade no backend. + ['bes_carrinho', 'bes_pedidos', 'bes_cart_pending', 'bes_api_token'] + .forEach(key => localStorage.removeItem(key)); aplicarTema(); initNavScroll(); await carregarDados(); atualizarAnoRodape(); - Cart.updateBadge(); atualizarNavUser(); verificarDeepLink(); renderTudo(); @@ -190,11 +172,8 @@ function copiarLinkLoja(slug) { document.addEventListener('keydown', e => { if (e.key === 'Escape') { fecharModal(); - fecharCarrinhoDrawer(); fecharAuth(); fecharCadastroLoja(); - fecharCheckout(); - fecharPedidos(); } }); diff --git a/backend/public/js/config.js b/backend/public/js/config.js index cdcf761..00291f7 100644 --- a/backend/public/js/config.js +++ b/backend/public/js/config.js @@ -32,11 +32,8 @@ export const ENABLE_DATA_FALLBACK = window.location.protocol === 'file:' || LOCA // ===== STORAGE KEYS ===== export const KEYS = { SESSION: 'bes_sessao', - CART: 'bes_carrinho', - ORDERS: 'bes_pedidos', FAVORITES: 'bes_favoritos', - MERCHANTS: 'bes_merchants', - API_TOKEN: 'bes_api_token' + MERCHANTS: 'bes_merchants' }; // ===== PAGINAÇÃO ===== diff --git a/backend/public/js/modules/api.js b/backend/public/js/modules/api.js index 4fc3fee..d98ed47 100644 --- a/backend/public/js/modules/api.js +++ b/backend/public/js/modules/api.js @@ -1,10 +1,9 @@ // ===== COMÉRCIO BES — API CLIENT ===== -// Toda comunicação com o backend REST. -// Retorna dados — não manipula DOM, não altera estado global. +// Chamadas publicas usadas pela vitrine. Fluxos transacionais pertencem ao +// Braga Commerce e nao devem ser adicionados neste modulo. import { API_BASE } from '../config.js'; import { state } from './state.js'; -import { Auth } from './auth.js'; export function registrarEstatistica(comercioId, tipo) { if (!state.apiDisponivel || !comercioId) return; @@ -21,14 +20,11 @@ export async function carregarComercios() { return res.json(); } -export async function enviarAvaliacaoApi(slug, nota, comentario, token) { - const headers = { 'Content-Type': 'application/json' }; - if (token) headers['Authorization'] = 'Bearer ' + token; - +export async function enviarAvaliacaoApi(slug, nota, comentario) { const res = await fetch(API_BASE + '/avaliacoes/' + slug, { method: 'POST', credentials: 'include', - headers, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ nota, comentario: comentario || undefined }) }); @@ -36,114 +32,3 @@ export async function enviarAvaliacaoApi(slug, nota, comentario, token) { if (!res.ok) throw new Error(data.error || 'Erro ao enviar avaliação'); return data; } - -function getProdutoId(item) { - const id = Number(item?.produto?.id); - return Number.isInteger(id) && id > 0 ? id : null; -} - -function agruparCarrinhoPorLoja(cart) { - const grupos = new Map(); - - cart.forEach(item => { - const lojaId = Number(item.lojaId); - if (!Number.isInteger(lojaId) || lojaId <= 0) { - throw new Error('O carrinho tem uma loja sem ID valido na API.'); - } - - const produtoId = getProdutoId(item); - if (!produtoId) { - throw new Error('O carrinho tem produto sem ID valido na API. Recarregue a pagina e tente novamente.'); - } - - if (!grupos.has(lojaId)) { - grupos.set(lojaId, { - lojaId, - lojaNome: item.lojaNome, - itens: [] - }); - } - - grupos.get(lojaId).itens.push({ - produtoId, - quantidade: Math.max(1, Number.parseInt(item.qtd, 10) || 1) - }); - }); - - return Array.from(grupos.values()); -} - -function montarObservacaoPedido(cliente, lojaNome) { - const partes = []; - if (lojaNome) partes.push(`Loja: ${lojaNome}`); - if (cliente.tel) partes.push(`Telefone: ${cliente.tel}`); - if (cliente.pagamento) partes.push(`Pagamento informado: ${cliente.pagamento}`); - if (cliente.referencia) partes.push(`Referencia: ${cliente.referencia}`); - if (cliente.obs) partes.push(`Obs: ${cliente.obs}`); - return partes.join(' | '); -} - -async function apiJson(path, options) { - const res = await fetch(API_BASE + path, { - credentials: 'include', - ...options, - headers: { - 'Content-Type': 'application/json', - ...(options.headers || {}) - } - }); - - const data = await res.json().catch(() => ({})); - if (!res.ok) { - throw new Error(data.error || `Erro HTTP ${res.status}`); - } - return data; -} - -async function criarEnderecoEntrega(cliente) { - const csrfToken = await Auth.getCsrfToken(); - - return apiJson('/auth/enderecos', { - method: 'POST', - headers: { 'X-CSRF-Token': csrfToken }, - body: JSON.stringify({ - apelido: 'Pedido', - rua: cliente.rua, - numero: cliente.numero, - complemento: cliente.complemento || undefined, - bairro: cliente.bairro, - cidade: 'Boa Esperanca do Sul', - estado: 'SP', - cep: cliente.cep, - principal: false - }) - }); -} - -export async function criarPedidosDoCarrinho(cart, cliente) { - const grupos = agruparCarrinhoPorLoja(cart); - if (grupos.length === 0) { - throw new Error('Carrinho vazio.'); - } - - const csrfToken = await Auth.getCsrfToken(true); - const endereco = await criarEnderecoEntrega(cliente); - const pedidos = []; - - for (const grupo of grupos) { - const data = await apiJson('/pedidos', { - method: 'POST', - headers: { 'X-CSRF-Token': csrfToken }, - body: JSON.stringify({ - comercioId: grupo.lojaId, - enderecoId: endereco.id, - tipoEntrega: 'entrega', - observacao: montarObservacaoPedido(cliente, grupo.lojaNome), - itens: grupo.itens - }) - }); - pedidos.push(data.pedido); - } - - return pedidos; -} diff --git a/backend/public/js/modules/auth.js b/backend/public/js/modules/auth.js index 1753f45..498a6da 100644 --- a/backend/public/js/modules/auth.js +++ b/backend/public/js/modules/auth.js @@ -11,9 +11,6 @@ export const Auth = { getSession() { return storageGet(KEYS.SESSION); }, - // Sessao principal usa cookies httpOnly; nao persistimos Bearer em localStorage. - getToken() { return null; }, - isLoggedIn() { return !!this.getSession(); }, getUser() { return this.getSession(); }, @@ -56,7 +53,6 @@ export const Auth = { const data = await res.json(); if (!res.ok) return { ok: false, msg: data.error || 'Erro ao criar conta.' }; - localStorage.removeItem(KEYS.API_TOKEN); this._csrfToken = null; storageSet(KEYS.SESSION, { userId: data.user.id, @@ -83,7 +79,6 @@ export const Auth = { const data = await res.json(); if (!res.ok) return { ok: false, msg: data.error || 'E-mail ou senha incorretos.' }; - localStorage.removeItem(KEYS.API_TOKEN); this._csrfToken = null; storageSet(KEYS.SESSION, { userId: data.user.id, @@ -99,13 +94,18 @@ export const Auth = { } }, - logout() { - fetch(API_BASE + '/auth/logout', { - method: 'POST', - credentials: 'include' - }).catch(err => console.warn('[Auth] API logout falhou:', err.message)); + async logout() { + try { + const csrfToken = await this.getCsrfToken(); + await fetch(API_BASE + '/auth/logout', { + method: 'POST', + credentials: 'include', + headers: { 'X-CSRF-Token': csrfToken } + }); + } catch (err) { + console.warn('[Auth] API logout falhou:', err.message); + } localStorage.removeItem(KEYS.SESSION); - localStorage.removeItem(KEYS.API_TOKEN); this._csrfToken = null; } }; diff --git a/backend/public/js/modules/cart.js b/backend/public/js/modules/cart.js deleted file mode 100644 index be9b2d5..0000000 --- a/backend/public/js/modules/cart.js +++ /dev/null @@ -1,150 +0,0 @@ -// ===== COMÉRCIO BES — CART MODULE ===== -// Carrinho persistente (localStorage) + Drawer UI. - -import { KEYS } from '../config.js'; -import { storageGet, storageSet, escapeHTML, formatCurrency } from './utils.js'; -import { mostrarToast } from './ui.js'; - -// ===== DADOS ===== -export const Cart = { - get() { return storageGet(KEYS.CART) || []; }, - - save(cart) { storageSet(KEYS.CART, cart); }, - - add(lojaId, lojaNome, lojaWhatsapp, produto, qtd) { - const cart = this.get(); - const existing = cart.find(i => i.lojaId === lojaId && i.produto.nome_produto === produto.nome_produto); - if (existing) { - existing.qtd += qtd; - } else { - cart.push({ lojaId, lojaNome, lojaWhatsapp, produto, qtd }); - } - this.save(cart); - this.updateBadge(); - }, - - remove(index) { - const cart = this.get(); - cart.splice(index, 1); - this.save(cart); - this.updateBadge(); - }, - - updateQtd(index, delta) { - const cart = this.get(); - if (!cart[index]) return; - cart[index].qtd = Math.max(1, cart[index].qtd + delta); - this.save(cart); - this.updateBadge(); - }, - - clear() { - localStorage.removeItem(KEYS.CART); - this.updateBadge(); - }, - - total() { - return this.get().reduce((sum, i) => sum + (i.produto.preco * i.qtd), 0); - }, - - count() { - return this.get().reduce((sum, i) => sum + i.qtd, 0); - }, - - updateBadge() { - const badge = document.getElementById('cart-badge'); - const count = this.count(); - if (badge) { - badge.textContent = count > 0 ? count : ''; - badge.style.display = count > 0 ? 'flex' : 'none'; - } - const fab = document.getElementById('fab-cart'); - const fabBadge = document.getElementById('fab-cart-badge'); - if (fab) fab.style.display = count > 0 ? 'flex' : 'none'; - if (fabBadge) fabBadge.textContent = count; - } -}; - -// ===== DRAWER UI ===== - -export function toggleCarrinhoDrawer() { - const drawer = document.getElementById('cart-drawer'); - const overlay = document.getElementById('drawer-overlay'); - if (drawer.classList.contains('open')) { - fecharCarrinhoDrawer(); - } else { - renderCarrinhoDrawer(); - drawer.classList.add('open'); - overlay.classList.add('open'); - document.body.style.overflow = 'hidden'; - } -} - -export function fecharCarrinhoDrawer() { - document.getElementById('cart-drawer').classList.remove('open'); - document.getElementById('drawer-overlay').classList.remove('open'); - if (!document.querySelector('.modal-overlay.open, .auth-overlay.open, .checkout-overlay.open')) { - document.body.style.overflow = ''; - } -} - -export function renderCarrinhoDrawer() { - const cart = Cart.get(); - const body = document.getElementById('drawer-body'); - const footer = document.getElementById('drawer-footer'); - - if (cart.length === 0) { - body.innerHTML = '

    🛒

    Seu carrinho está vazio

    Adicione itens do cardápio das lojas

    '; - footer.style.display = 'none'; - return; - } - - const byStore = {}; - cart.forEach((item, idx) => { - if (!byStore[item.lojaId]) { - byStore[item.lojaId] = { nome: item.lojaNome, whatsapp: item.lojaWhatsapp, items: [] }; - } - byStore[item.lojaId].items.push({ ...item, globalIdx: idx }); - }); - - let html = ''; - Object.entries(byStore).forEach(([, store]) => { - html += '
    '; - html += '
    ' + escapeHTML(store.nome) + '
    '; - store.items.forEach(item => { - const subtotal = item.produto.preco * item.qtd; - html += '
    ' + - '
    ' + - '
    ' + escapeHTML(item.produto.nome_produto) + '
    ' + - '
    R$ ' + formatCurrency(subtotal) + '
    ' + - '
    ' + - '
    ' + - '' + - '' + parseInt(item.qtd) + '' + - '' + - '' + - '
    ' + - '
    '; - }); - html += '
    '; - }); - - body.innerHTML = html; - footer.style.display = 'block'; - document.getElementById('drawer-total').innerHTML = 'Total: R$ ' + formatCurrency(Cart.total()) + ''; -} - -export function cartUpdateQtd(idx, delta) { - Cart.updateQtd(idx, delta); - renderCarrinhoDrawer(); -} - -export function cartRemove(idx) { - Cart.remove(idx); - renderCarrinhoDrawer(); -} - -// enviarTudoWhatsApp vive em checkout.js para evitar dependência circular -export function abrirCarrinhoVazio() { - mostrarToast('🛒 Carrinho vazio!'); -} diff --git a/backend/public/js/modules/checkout.js b/backend/public/js/modules/checkout.js deleted file mode 100644 index e9fc7ed..0000000 --- a/backend/public/js/modules/checkout.js +++ /dev/null @@ -1,167 +0,0 @@ -// ===== COMÉRCIO BES — CHECKOUT MODULE ===== - -import { Auth } from './auth.js'; -import { Cart, fecharCarrinhoDrawer, renderCarrinhoDrawer } from './cart.js'; -import { criarPedidosDoCarrinho } from './api.js'; -import { escapeHTML, formatCurrency } from './utils.js'; -import { mostrarToast } from './ui.js'; -import { abrirAuth } from './auth-ui.js'; - -// ===== WHATSAPP LINK ===== - -export function gerarLinkWhatsApp(loja, produtosSelecionados, cliente) { - const nome = cliente && cliente.nome ? cliente.nome : (Auth.getUser()?.nome || 'Cliente'); - let texto = 'Olá meu nome é ' + nome + ' vim pelo ComércioBes! e gostaria de fazer um pedido:\n'; - texto += '======================\n'; - let total = 0; - produtosSelecionados.forEach(item => { - total += item.produto.preco * item.qtd; - texto += '- ' + item.qtd + 'x ' + item.produto.nome_produto + '\n'; - }); - texto += '======================\n'; - if (cliente && cliente.rua) { - texto += cliente.rua + '\n'; - texto += (cliente.bairro || '') + '\n'; - const compNum = ((cliente.complemento || '') + ' ' + (cliente.numero || '')).trim(); - if (compNum) texto += compNum + '\n'; - if (cliente.referencia) texto += cliente.referencia + '\n'; - } - texto += '=========================\n'; - texto += 'Total: R$' + formatCurrency(total); - return 'https://wa.me/' + encodeURIComponent(loja.whatsapp) + '?text=' + encodeURIComponent(texto); -} - -// ===== CHECKOUT OVERLAY ===== - -export function abrirCheckout() { - if (!Auth.isLoggedIn()) { - mostrarToast('👤 Faça login para finalizar o pedido.'); - abrirAuth(); - return; - } - const cart = Cart.get(); - if (cart.length === 0) { mostrarToast('🛒 Carrinho vazio!'); return; } - - fecharCarrinhoDrawer(); - - const user = Auth.getUser(); - if (user) { - document.getElementById('checkout-nome').value = user.nome || ''; - document.getElementById('checkout-tel').value = user.tel || ''; - ['checkout-rua', 'checkout-numero', 'checkout-bairro', 'checkout-complemento', 'checkout-referencia'] - .forEach(id => { document.getElementById(id).value = ''; }); - } - - let html = ''; - cart.forEach(item => { - html += '
    ' + - '' + parseInt(item.qtd) + 'x ' + escapeHTML(item.produto.nome_produto) + '' + - 'R$ ' + formatCurrency(item.produto.preco * item.qtd) + '' + - '
    '; - }); - document.getElementById('checkout-items').innerHTML = html; - document.getElementById('checkout-total').innerHTML = 'Total: R$ ' + formatCurrency(Cart.total()) + ''; - - document.getElementById('checkout-overlay').classList.add('open'); - document.body.style.overflow = 'hidden'; -} - -export function fecharCheckout(e) { - if (e && e.target !== document.getElementById('checkout-overlay')) return; - document.getElementById('checkout-overlay').classList.remove('open'); - document.body.style.overflow = ''; -} - -function getCheckoutCliente() { - const nome = document.getElementById('checkout-nome').value.trim(); - const rua = document.getElementById('checkout-rua').value.trim(); - const numero = document.getElementById('checkout-numero').value.trim(); - const bairro = document.getElementById('checkout-bairro').value.trim(); - const cep = document.getElementById('checkout-cep').value.replace(/\D/g, ''); - const complemento = document.getElementById('checkout-complemento').value.trim(); - const referencia = document.getElementById('checkout-referencia').value.trim(); - const tel = document.getElementById('checkout-tel').value.trim(); - const pagamento = document.getElementById('checkout-pagamento').value; - const obs = document.getElementById('checkout-obs').value.trim(); - - if (!nome || !rua || !numero || !bairro || !cep || !tel) { - mostrarToast('⚠️ Preencha todos os campos obrigatórios.'); - return null; - } - if (cep.length !== 8) { - mostrarToast('Informe um CEP valido com 8 digitos.'); - return null; - } - return { nome, rua, numero, bairro, cep, complemento, referencia, tel, pagamento, obs }; -} - -export async function confirmarPedido(e) { - if (e) e.preventDefault(); - const cliente = getCheckoutCliente(); - if (!cliente) return false; - - const cart = Cart.get(); - if (cart.length === 0) { - mostrarToast('Carrinho vazio.'); - return false; - } - - const submitBtn = document.querySelector('#checkout-form .btn-confirmar-pedido'); - if (submitBtn) { - submitBtn.disabled = true; - submitBtn.textContent = 'Enviando...'; - } - - try { - const pedidos = await criarPedidosDoCarrinho(cart, cliente); - const codigos = pedidos.map(p => p.codigo).join(', '); - - Cart.clear(); - fecharCheckout(); - renderCarrinhoDrawer(); - mostrarToast('Pedido ' + codigos + ' enviado para a loja.'); - } catch (err) { - console.error('[Checkout] Erro ao criar pedido:', err); - mostrarToast('Nao foi possivel criar o pedido: ' + err.message); - } finally { - if (submitBtn) { - submitBtn.disabled = false; - submitBtn.textContent = 'Enviar pedido para a loja'; - } - } - - return false; -} - -export function confirmarPedidoWhatsApp() { - const cliente = getCheckoutCliente(); - if (!cliente) return; - - const cart = Cart.get(); - const byStore = {}; - cart.forEach(item => { - if (!byStore[item.lojaId]) byStore[item.lojaId] = { whatsapp: item.lojaWhatsapp, items: [] }; - byStore[item.lojaId].items.push(item); - }); - - Object.values(byStore).forEach(store => { - const link = gerarLinkWhatsApp( - { whatsapp: store.whatsapp }, - store.items.map(i => ({ produto: i.produto, qtd: i.qtd })), - cliente - ); - window.open(link, '_blank', 'noopener,noreferrer'); - }); - - Cart.clear(); - fecharCheckout(); - renderCarrinhoDrawer(); - mostrarToast('Pedido enviado via WhatsApp. Ele nao entra no historico da plataforma.'); -} - -export function enviarTudoWhatsApp() { - const cart = Cart.get(); - if (cart.length === 0) { mostrarToast('🛒 Carrinho vazio!'); return; } - abrirCheckout(); - mostrarToast('📋 Preencha seus dados e clique em "Enviar via WhatsApp".'); -} diff --git a/backend/public/js/modules/orders.js b/backend/public/js/modules/orders.js deleted file mode 100644 index 31fbe04..0000000 --- a/backend/public/js/modules/orders.js +++ /dev/null @@ -1,21 +0,0 @@ -// ===== COMÉRCIO BES — ORDERS MODULE ===== - -import { KEYS } from '../config.js'; -import { storageGet, storageSet } from './utils.js'; - -export const Orders = { - get() { return storageGet(KEYS.ORDERS) || []; }, - - create(orderData) { - const orders = this.get(); - const order = { - id: 'PED-' + Date.now().toString(36).toUpperCase(), - ...orderData, - status: 'pendente', - criadoEm: new Date().toISOString() - }; - orders.unshift(order); - storageSet(KEYS.ORDERS, orders); - return order; - } -}; diff --git a/backend/public/js/modules/utils.js b/backend/public/js/modules/utils.js index 87df736..67e8e66 100644 --- a/backend/public/js/modules/utils.js +++ b/backend/public/js/modules/utils.js @@ -12,6 +12,23 @@ export function escapeHTML(str) { .replace(/'/g, '''); } +// Aceita apenas imagens servidas pelo proprio app ou pela conta Cloudinary. +// Retorna uma URL normalizada; outros esquemas e hosts sao rejeitados. +export function safeImageUrl(value) { + if (typeof value !== 'string') return null; + + try { + const url = new URL(value, window.location.origin); + const isLocal = url.origin === window.location.origin && ( + url.pathname.startsWith('/images/') || url.pathname.startsWith('/uploads/') + ); + const isCloudinary = url.protocol === 'https:' && url.hostname === 'res.cloudinary.com'; + return isLocal || isCloudinary ? url.href : null; + } catch { + return null; + } +} + // ===== STORAGE ===== export function storageGet(key) { try { return JSON.parse(localStorage.getItem(key)) || null; } catch { return null; } diff --git a/backend/public/js/render/cards.js b/backend/public/js/render/cards.js index 991861d..0e712ef 100644 --- a/backend/public/js/render/cards.js +++ b/backend/public/js/render/cards.js @@ -2,7 +2,7 @@ // Circular com modules/search.js — seguro (só em function bodies). import { ITEMS_POR_PAGINA } from '../config.js'; -import { escapeHTML, gerarStars, formatCurrency } from '../modules/utils.js'; +import { escapeHTML, gerarStars, safeImageUrl } from '../modules/utils.js'; import { Favorites } from '../modules/favorites.js'; import { observarLazyImages } from '../modules/ui.js'; import { state } from '../modules/state.js'; @@ -16,8 +16,10 @@ export function criarCard(c) { const destaqueBadge = c.destaque ? 'Destaque' : ''; const temCatalogo = c.catalogo && c.catalogo.length > 0; const isFav = Favorites.isFav(c.id); - const heroFoto = (c.fotos || []).find(f => f.startsWith('http') || f.startsWith('/')); - const imgStyle = heroFoto ? ' style="background-image:url(' + heroFoto + ');background-size:cover;background-position:center;"' : ''; + const heroFoto = (c.fotos || []).map(safeImageUrl).find(Boolean); + const imgStyle = heroFoto + ? ' style="background-image:url("' + escapeHTML(heroFoto) + '");background-size:cover;background-position:center;"' + : ''; return '
    ' + '
    ' + diff --git a/backend/public/js/render/modal.js b/backend/public/js/render/modal.js index d2fecfd..0396ef9 100644 --- a/backend/public/js/render/modal.js +++ b/backend/public/js/render/modal.js @@ -1,19 +1,16 @@ // ===== COMÉRCIO BES — RENDER: MODAL ===== -// Perfil da loja, catálogo, avaliação e carrinho modal. +// Perfil da loja, catalogo informativo e avaliacao. -import { escapeHTML, gerarStars, formatCurrency } from '../modules/utils.js'; +import { escapeHTML, gerarStars, formatCurrency, safeImageUrl } from '../modules/utils.js'; import { registrarEstatistica, enviarAvaliacaoApi } from '../modules/api.js'; import { Auth } from '../modules/auth.js'; -import { Cart } from '../modules/cart.js'; import { Favorites } from '../modules/favorites.js'; import { mostrarToast } from '../modules/ui.js'; -import { abrirCheckout } from '../modules/checkout.js'; import { state } from '../modules/state.js'; import { GOOGLE_MAPS_KEY } from '../config.js'; // ===== STATE LOCAL ===== let avaliacao = 0; -let carrinhoModal = {}; let lightboxFotos = []; let lightboxIdx = 0; @@ -24,14 +21,13 @@ export function abrirModal(id) { if (!c) return; state.comercioAtual = c; avaliacao = 0; - carrinhoModal = {}; resetStars(); registrarEstatistica(c.id, 'visita'); // Hero foto ou emoji no cabeçalho const modalImgEl = document.getElementById('modal-img'); - const heroFoto = (c.fotos || []).find(f => f.startsWith('http') || f.startsWith('/')); + const heroFoto = (c.fotos || []).map(safeImageUrl).find(Boolean); if (heroFoto) { modalImgEl.style.backgroundImage = 'url(' + heroFoto + ')'; modalImgEl.classList.add('has-photo'); @@ -55,7 +51,7 @@ export function abrirModal(id) { document.getElementById('modal-info').innerHTML = '' + '' + - '' + + (c.tel ? '' : '') + '' + (c.promo ? '' : ''); @@ -65,16 +61,20 @@ export function abrirModal(id) { '(' + parseInt(c.visitas) + ' avaliações)'; // Fotos — detecta URL real vs emoji placeholder - lightboxFotos = (c.fotos || []).filter(f => f.startsWith('http') || f.startsWith('/')); - document.getElementById('modal-fotos').innerHTML = (c.fotos || []).map((f, idx) => { - const isUrl = f.startsWith('http') || f.startsWith('/'); - return '
    ' + - (isUrl - ? 'Foto ' + (idx + 1) + '' + lightboxFotos = (c.fotos || []).map(safeImageUrl).filter(Boolean); + const fotosEl = document.getElementById('modal-fotos'); + fotosEl.innerHTML = (c.fotos || []).map((f, idx) => { + const safeUrl = safeImageUrl(f); + return '
    '; + ''; }).join(''); + fotosEl.querySelectorAll('[data-lightbox-index]').forEach(button => { + button.addEventListener('click', () => abrirLightbox(Number(button.dataset.lightboxIndex))); + }); // Mapa embed — Google Maps (se tiver key) ou OpenStreetMap (gratuito) const mapaEl = document.getElementById('modal-mapa'); @@ -95,30 +95,19 @@ export function abrirModal(id) { catalogoContainer.style.display = 'block'; catalogoContainer.innerHTML = '' + - '

    Cardápio / Produtos

    ' + + '

    Produtos em destaque

    ' + '
    ' + - c.catalogo.map((prod, idx) => + c.catalogo.map(prod => '
    ' + '
    ' + '
    ' + escapeHTML(prod.nome_produto) + '
    ' + '
    ' + escapeHTML(prod.descricao) + '
    ' + '
    R$ ' + formatCurrency(prod.preco) + '
    ' + '
    ' + - '
    ' + - '' + - '0' + - '' + - '
    ' + '
    ' ).join('') + '
    ' + - ''; + '

    Consulte disponibilidade e condicoes diretamente com o comercio.

    '; } else { catalogoContainer.style.display = 'none'; catalogoContainer.innerHTML = ''; @@ -139,98 +128,6 @@ export function fecharModal(e) { document.body.style.overflow = ''; } -// ===== CARRINHO MODAL ===== - -export function alterarQtdModal(idx, delta) { - const atual = carrinhoModal[idx] || 0; - const novo = Math.max(0, atual + delta); - carrinhoModal[idx] = novo; - const el = document.getElementById('qtd-' + idx); - if (el) el.textContent = novo; - atualizarResumoModal(); -} - -function atualizarResumoModal() { - if (!state.comercioAtual || !state.comercioAtual.catalogo) return; - const itens = Object.entries(carrinhoModal) - .filter(([, qtd]) => qtd > 0) - .map(([idx, qtd]) => ({ produto: state.comercioAtual.catalogo[parseInt(idx)], qtd })); - - const resumoEl = document.getElementById('carrinho-resumo'); - const totalEl = document.getElementById('carrinho-total'); - if (itens.length === 0) { resumoEl.style.display = 'none'; return; } - - const total = itens.reduce((sum, i) => sum + (i.produto.preco * i.qtd), 0); - resumoEl.style.display = 'block'; - totalEl.innerHTML = '' + itens.length + ' ' + (itens.length === 1 ? 'item' : 'itens') + ' · Total: R$ ' + formatCurrency(total) + ''; -} - -function mostrarAuthGateModal() { - const container = document.getElementById('modal-catalogo'); - if (!container) return; - const existing = document.getElementById('auth-gate-modal'); - if (existing) { existing.classList.add('visible'); existing.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); return; } - - const gate = document.createElement('div'); - gate.id = 'auth-gate-modal'; - gate.className = 'auth-gate-modal'; - gate.innerHTML = - '

    Faça login para fazer pedidos

    ' + - '

    Crie sua conta gratuita em menos de 1 minuto

    ' + - 'Criar conta gratuita' + - ''; - container.appendChild(gate); - requestAnimationFrame(() => gate.classList.add('visible')); - gate.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); -} - -export function adicionarAoCarrinho() { - if (!state.comercioAtual || !state.comercioAtual.catalogo) return; - if (!Auth.isLoggedIn()) { mostrarAuthGateModal(); return; } - - const itens = Object.entries(carrinhoModal) - .filter(([, qtd]) => qtd > 0) - .map(([idx, qtd]) => ({ produto: state.comercioAtual.catalogo[parseInt(idx)], qtd })); - - if (itens.length === 0) { mostrarToast('🛒 Selecione pelo menos um item!'); return; } - - itens.forEach(i => { - Cart.add(state.comercioAtual.id, state.comercioAtual.nome, state.comercioAtual.whatsapp, i.produto, i.qtd); - }); - - mostrarToast('✅ ' + itens.length + ' item(ns) adicionado(s) ao carrinho!'); - carrinhoModal = {}; - state.comercioAtual.catalogo.forEach((_, idx) => { - const el = document.getElementById('qtd-' + idx); - if (el) el.textContent = '0'; - }); - atualizarResumoModal(); -} - -export function enviarPedidoWhatsApp() { - if (!state.comercioAtual || !state.comercioAtual.catalogo) return; - if (!Auth.isLoggedIn()) { mostrarAuthGateModal(); return; } - - const produtos = Object.entries(carrinhoModal) - .filter(([, qtd]) => qtd > 0) - .map(([idx, qtd]) => ({ produto: state.comercioAtual.catalogo[parseInt(idx)], qtd })); - - if (produtos.length === 0) { mostrarToast('🛒 Selecione pelo menos um item!'); return; } - - produtos.forEach(i => { - Cart.add(state.comercioAtual.id, state.comercioAtual.nome, state.comercioAtual.whatsapp, i.produto, i.qtd); - }); - carrinhoModal = {}; - state.comercioAtual.catalogo.forEach((_, idx) => { - const el = document.getElementById('qtd-' + idx); - if (el) el.textContent = '0'; - }); - atualizarResumoModal(); - fecharModal(); - abrirCheckout(); - mostrarToast('📋 Preencha seus dados e clique em "Enviar via WhatsApp".'); -} - // ===== LIGHTBOX ===== export function abrirLightbox(idx) { @@ -277,9 +174,7 @@ export async function enviarAvaliacao() { if (state.apiDisponivel && state.comercioAtual.slug) { try { - const data = await enviarAvaliacaoApi( - state.comercioAtual.slug, avaliacao, comentario, Auth.getToken() - ); + const data = await enviarAvaliacaoApi(state.comercioAtual.slug, avaliacao, comentario); state.comercioAtual.rating = data.mediaAtual; state.comercioAtual.totalAvaliacoes = data.totalAvaliacoes; document.getElementById('modal-stars-big').innerHTML = diff --git a/backend/public/js/render/orders-ui.js b/backend/public/js/render/orders-ui.js deleted file mode 100644 index 62da574..0000000 --- a/backend/public/js/render/orders-ui.js +++ /dev/null @@ -1,45 +0,0 @@ -// ===== COMÉRCIO BES — RENDER: ORDERS UI ===== - -import { Auth } from '../modules/auth.js'; -import { Orders } from '../modules/orders.js'; -import { escapeHTML, formatCurrency } from '../modules/utils.js'; -import { fecharContaMenu, abrirAuth } from '../modules/auth-ui.js'; - -export function abrirPedidos() { - fecharContaMenu(); - if (!Auth.isLoggedIn()) { abrirAuth(); return; } - - const orders = Orders.get(); - const container = document.getElementById('orders-list'); - - if (orders.length === 0) { - container.innerHTML = '

    📋

    Nenhum pedido ainda

    '; - } else { - container.innerHTML = orders.map(o => { - const data = new Date(o.criadoEm); - const dataStr = data.toLocaleDateString('pt-BR') + ' ' + data.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); - const statusCor = o.status === 'pendente' ? '#EA580C' : o.status === 'confirmado' ? '#10B981' : '#aaa'; - return '
    ' + - '
    ' + - '' + escapeHTML(o.id) + '' + - '' + escapeHTML(o.status).toUpperCase() + '' + - '
    ' + - '
    ' + escapeHTML(dataStr) + '
    ' + - '
    ' + - o.items.map(i => '
    ' + parseInt(i.qtd) + 'x ' + escapeHTML(i.produto.nome_produto) + ' (' + escapeHTML(i.lojaNome) + ')
    ').join('') + - '
    ' + - '
    Total: R$ ' + formatCurrency(o.total) + '
    ' + - '
    Pagamento: ' + escapeHTML(o.pagamento) + '
    ' + - '
    '; - }).join(''); - } - - document.getElementById('orders-overlay').classList.add('open'); - document.body.style.overflow = 'hidden'; -} - -export function fecharPedidos(e) { - if (e && e.target !== document.getElementById('orders-overlay')) return; - document.getElementById('orders-overlay').classList.remove('open'); - document.body.style.overflow = ''; -} diff --git a/backend/public/sw.js b/backend/public/sw.js index 8e5cf80..0103d50 100644 --- a/backend/public/sw.js +++ b/backend/public/sw.js @@ -1,5 +1,5 @@ // ===== SERVICE WORKER — Comércio BES ===== -const CACHE_NAME = 'comercio-bes-v8'; +const CACHE_NAME = 'comercio-bes-v9'; const OFFLINE_ASSETS = [ './', './index.html', @@ -10,21 +10,17 @@ const OFFLINE_ASSETS = [ './js/modules/utils.js', './js/modules/api.js', './js/modules/auth.js', - './js/modules/cart.js', './js/modules/favorites.js', - './js/modules/orders.js', './js/modules/merchants.js', './js/modules/theme.js', './js/modules/ui.js', './js/modules/map.js', './js/modules/search.js', './js/modules/auth-ui.js', - './js/modules/checkout.js', './js/modules/merchant-ui.js', './js/render/promotions.js', './js/render/cards.js', './js/render/favorites.js', - './js/render/orders-ui.js', './js/render/modal.js', './data/data.json', './manifest.json', @@ -49,12 +45,21 @@ self.addEventListener('activate', event => { self.clients.claim(); }); -// Fetch — network-first for data, cache-first for assets +// Fetch — somente assets publicos entram no Cache Storage. +// APIs e areas autenticadas devem sempre ir para a rede para evitar vazamento +// de dados entre sessoes no mesmo navegador. self.addEventListener('fetch', event => { const url = new URL(event.request.url); - // Skip non-GET and cross-origin (except fonts/tiles) + // Skip non-GET and qualquer endpoint privado/dinamico. if (event.request.method !== 'GET') return; + if (url.origin === self.location.origin && ( + url.pathname.startsWith('/api/') || + url.pathname === '/api' || + url.pathname.startsWith('/minha-conta') || + url.pathname.startsWith('/html/login') || + url.pathname.startsWith('/html/cadastro') + )) return; // Network-first for data.json (always try fresh data) if (url.pathname.endsWith('/data.json')) { @@ -70,13 +75,14 @@ self.addEventListener('fetch', event => { return; } - // Cache-first for everything else + // Cache-first apenas para assets declarados no shell ou recursos estaticos. event.respondWith( caches.match(event.request).then(cached => { if (cached) return cached; return fetch(event.request).then(response => { - // Only cache same-origin and fonts - if (response.ok && (url.origin === self.location.origin || url.hostname.includes('googleapis') || url.hostname.includes('gstatic'))) { + const isStaticAsset = url.origin === self.location.origin && /\.(?:css|js|svg|png|jpe?g|webp|avif|woff2?)$/i.test(url.pathname); + const isApprovedFont = url.hostname === 'fonts.googleapis.com' || url.hostname === 'fonts.gstatic.com'; + if (response.ok && (isStaticAsset || isApprovedFont)) { const clone = response.clone(); caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone)); } diff --git a/backend/src/__tests__/auth.test.js b/backend/src/__tests__/auth.test.js index 9bf166c..68bd0bd 100644 --- a/backend/src/__tests__/auth.test.js +++ b/backend/src/__tests__/auth.test.js @@ -27,7 +27,8 @@ describe('POST /api/auth/registro', () => { expect(res.status).toBe(201); expect(res.body.user.email).toBe('maria@example.com'); expect(res.body.user.tipo).toBe('cliente'); - expect(res.body.token).toBeDefined(); + expect(res.body.token).toBeUndefined(); + expect(res.headers['set-cookie'].join(';')).toMatch(/access_token=.*HttpOnly/i); expect(res.body.user.senha).toBeUndefined(); // senha nunca retorna }); @@ -137,7 +138,8 @@ describe('POST /api/auth/login', () => { .send({ email: 'usuario@example.com', senha: 'correta123' }); expect(res.status).toBe(200); - expect(res.body.token).toBeDefined(); + expect(res.body.token).toBeUndefined(); + expect(res.headers['set-cookie'].join(';')).toMatch(/access_token=.*HttpOnly/i); expect(res.body.user.email).toBe('usuario@example.com'); }); @@ -171,19 +173,17 @@ describe('POST /api/auth/login', () => { // GET /api/auth/perfil (rota protegida) // ============================================================ describe('GET /api/auth/perfil', () => { - let token; + let agent; beforeEach(async () => { - const res = await request(app) + agent = request.agent(app); + await agent .post('/api/auth/registro') .send({ nome: 'Perfil Teste', email: 'perfil@example.com', senha: 'senha123' }); - token = res.body.token; }); - it('retorna perfil com token valido', async () => { - const res = await request(app) - .get('/api/auth/perfil') - .set('Authorization', `Bearer ${token}`); + it('retorna perfil com cookie httpOnly valido', async () => { + const res = await agent.get('/api/auth/perfil'); expect(res.status).toBe(200); expect(res.body.email).toBe('perfil@example.com'); @@ -202,3 +202,29 @@ describe('GET /api/auth/perfil', () => { expect(res.status).toBe(401); }); }); + +// ============================================================ +// POST /api/auth/logout +// ============================================================ +describe('POST /api/auth/logout', () => { + it('exige double-submit CSRF e encerra a sessao', async () => { + const agent = request.agent(app); + await agent + .post('/api/auth/registro') + .send({ nome: 'Logout Teste', email: 'logout@example.com', senha: 'senha123' }); + + const semCsrf = await agent.post('/api/auth/logout'); + expect(semCsrf.status).toBe(403); + + const csrf = await agent.get('/api/auth/csrf'); + const logout = await agent + .post('/api/auth/logout') + .set('X-CSRF-Token', csrf.body.csrfToken); + + expect(logout.status).toBe(200); + expect(logout.headers['set-cookie'].join(';')).toMatch(/access_token=;/i); + + const perfil = await agent.get('/api/auth/perfil'); + expect(perfil.status).toBe(401); + }); +}); diff --git a/backend/src/__tests__/helpers.js b/backend/src/__tests__/helpers.js index cd8fb08..74fb892 100644 --- a/backend/src/__tests__/helpers.js +++ b/backend/src/__tests__/helpers.js @@ -27,7 +27,17 @@ async function criarCategoriaTeste(dados = {}) { }); } -// Cria um usuario de teste e retorna { user, token } +function gerarTokenTeste(user) { + const jwt = require('jsonwebtoken'); + return jwt.sign( + { id: user.id, email: user.email, tipo: user.tipo }, + process.env.JWT_SECRET, + { expiresIn: '1h', algorithm: 'HS256' } + ); +} + +// Cria um usuario de teste e retorna { user, token }. +// A API publica nao expoe mais o JWT; o helper o emite somente no processo de teste. async function criarUsuarioTeste(app, dados = {}) { const supertest = require('supertest'); const payload = { @@ -46,7 +56,11 @@ async function criarUsuarioTeste(app, dados = {}) { .post('/api/auth/registro') .send(payload); - return { user: res.body.user, token: res.body.token }; + if (res.status !== 201) { + throw new Error(`Falha ao criar usuario de teste: ${res.status} ${JSON.stringify(res.body)}`); + } + + return { user: res.body.user, token: gerarTokenTeste(res.body.user) }; } // Cria um admin diretamente no banco (registro via API nao permite tipo admin) diff --git a/backend/src/__tests__/productScope.test.js b/backend/src/__tests__/productScope.test.js new file mode 100644 index 0000000..5063aca --- /dev/null +++ b/backend/src/__tests__/productScope.test.js @@ -0,0 +1,68 @@ +const fs = require('fs'); +const path = require('path'); +const request = require('supertest'); +const app = require('../server'); + +describe('Escopo publico da vitrine', () => { + it('serve a pagina inicial mesmo dentro de um worktree oculto', async () => { + const page = await request(app).get('/'); + + expect(page.status).toBe(200); + expect(page.headers['content-type']).toMatch(/text\/html/); + expect(page.text).toContain('Comércio BES'); + }); + + it('nao anuncia nem monta endpoints transacionais', async () => { + const api = await request(app).get('/api'); + expect(api.status).toBe(200); + expect(api.body.marketplace).toBe('fora deste servico'); + expect(api.body.endpoints.pedidos).toBeUndefined(); + expect(api.body.endpoints.pagamentos).toBeUndefined(); + + expect((await request(app).get('/api/pedidos')).status).toBe(404); + expect((await request(app).post('/api/pagamentos')).status).toBe(404); + expect((await request(app).get('/api/auth/enderecos')).status).toBe(404); + }); + + it('nao distribui modulos ou secoes do marketplace legado', () => { + const projectRoot = path.join(__dirname, '..', '..', '..'); + const sections = fs.readFileSync( + path.join(projectRoot, 'backend', 'minha-conta', 'js', 'sections.js'), + 'utf8' + ); + const config = fs.readFileSync(path.join(projectRoot, 'js', 'config.js'), 'utf8'); + + expect(sections).not.toMatch(/pedidos-loja|historico|pagamentos|enderecos|estoque/); + expect(config).not.toMatch(/\b(?:CART|ORDERS|API_TOKEN)\b/); + expect(fs.existsSync(path.join(projectRoot, 'js', 'modules', 'checkout.js'))).toBe(false); + expect(fs.existsSync(path.join(projectRoot, 'backend', 'src', 'routes', 'pedidos.js'))).toBe(false); + expect(fs.existsSync(path.join(projectRoot, 'backend', 'src', 'routes', 'pagamentos.js'))).toBe(false); + }); + + it('mantem APIs e areas autenticadas fora do Cache Storage', () => { + const serviceWorker = fs.readFileSync( + path.join(__dirname, '..', '..', '..', 'sw.js'), + 'utf8' + ); + + expect(serviceWorker).toContain("url.pathname.startsWith('/api/')"); + expect(serviceWorker).toContain("url.pathname.startsWith('/minha-conta')"); + expect(serviceWorker).not.toMatch(/cache\.put\([^\n]*(?:\/api|request\.url)/i); + }); + + it('aplica allowlist antes de renderizar imagens de comercios', () => { + const utils = fs.readFileSync( + path.join(__dirname, '..', '..', '..', 'js', 'modules', 'utils.js'), + 'utf8' + ); + const cards = fs.readFileSync( + path.join(__dirname, '..', '..', '..', 'js', 'render', 'cards.js'), + 'utf8' + ); + + expect(utils).toContain("url.hostname === 'res.cloudinary.com'"); + expect(utils).toContain("url.pathname.startsWith('/uploads/')"); + expect(cards).toContain('.map(safeImageUrl).find(Boolean)'); + expect(cards).not.toContain("f.startsWith('http')"); + }); +}); diff --git a/backend/src/__tests__/rbac.test.js b/backend/src/__tests__/rbac.test.js index e7b4587..bdd2a23 100644 --- a/backend/src/__tests__/rbac.test.js +++ b/backend/src/__tests__/rbac.test.js @@ -17,6 +17,12 @@ describe('RBAC Capabilities', () => { expect(ROLE_CAPABILITIES.cliente).toBeDefined(); expect(ROLE_CAPABILITIES.comerciante).toBeDefined(); }); + + test('nao expoe capabilities transacionais no nucleo da vitrine', () => { + Object.values(ROLE_CAPABILITIES).flat().forEach((cap) => { + expect(cap).not.toMatch(/^(orders|payments|addresses)\./); + }); + }); }); describe('Middleware requireCapability', () => { diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authController.js index 3a7a4fa..040c46f 100644 --- a/backend/src/controllers/authController.js +++ b/backend/src/controllers/authController.js @@ -125,21 +125,12 @@ async function registro(req, res, next) { data.telefoneComercial = telefoneComercial ? sanitize(telefoneComercial) : null; } - if (tipoPermitido === 'cliente') { - const { cpf } = req.body; - if (cpf) { - const cpfLimpo = limparDocumento(cpf); - if (!CPF_REGEX.test(cpfLimpo)) return res.status(400).json({ error: 'CPF invalido (11 digitos)' }); - data.cpf = cpfLimpo; - } - } - const user = await prisma.user.create({ data, select: { id: true, nome: true, email: true, tipo: true, telefone: true, nomeFantasia: true, createdAt: true }, }); - // Emite token legado (Bearer) para compat + cookies novos + // A sessao e entregue exclusivamente por cookies httpOnly. const accessToken = gerarAccessToken(user); const refreshToken = generateRefreshToken(); const csrfToken = generateCsrfToken(); @@ -151,7 +142,7 @@ async function registro(req, res, next) { setAuthCookies(res, accessToken, refreshToken, csrfToken); - res.status(201).json({ message: 'Conta criada com sucesso', user, token: accessToken }); + res.status(201).json({ message: 'Conta criada com sucesso', user }); } catch (err) { next(err); } @@ -167,11 +158,10 @@ async function login(req, res, next) { const fallbackUser = findFallbackUserByCredentials(email, senha); if (fallbackUser) { - const accessToken = issueFallbackSession(res, fallbackUser); + issueFallbackSession(res, fallbackUser); return res.json({ message: 'Login demo realizado com sucesso', user: publicUser(fallbackUser), - token: accessToken, fallbackAuth: true }); } @@ -199,7 +189,7 @@ async function login(req, res, next) { }; if (user.tipo === 'comerciante') userData.nomeFantasia = user.nomeFantasia; - res.json({ message: 'Login realizado com sucesso', user: userData, token: accessToken }); + res.json({ message: 'Login realizado com sucesso', user: userData }); } catch (err) { next(err); } @@ -392,8 +382,6 @@ async function perfil(req, res, next) { return res.json({ ...publicUser(fallbackUser), comercios: fallbackUser.stores || [], - enderecos: [], - pedidos: [], }); } @@ -401,17 +389,8 @@ async function perfil(req, res, next) { where: { id: req.userId }, select: { id: true, nome: true, email: true, tipo: true, telefone: true, avatar: true, createdAt: true, - nomeFantasia: true, cpfCnpj: true, enderecoComercial: true, telefoneComercial: true, cpf: true, + nomeFantasia: true, cpfCnpj: true, enderecoComercial: true, telefoneComercial: true, comercios: { select: { id: true, nome: true, slug: true, emoji: true, aberto: true } }, - enderecos: { - select: { id: true, apelido: true, rua: true, numero: true, complemento: true, bairro: true, cidade: true, estado: true, cep: true, principal: true }, - orderBy: { principal: 'desc' }, - }, - pedidos: { - select: { id: true, codigo: true, status: true, total: true, tipoEntrega: true, createdAt: true, comercio: { select: { nome: true, slug: true, emoji: true } } }, - orderBy: { createdAt: 'desc' }, - take: 10, - }, }, }); @@ -458,15 +437,6 @@ async function atualizarPerfil(req, res, next) { if (telefoneComercial !== undefined) data.telefoneComercial = sanitize(telefoneComercial); } - if (currentUser.tipo === 'cliente') { - const { cpf } = req.body; - if (cpf) { - const cpfLimpo = limparDocumento(cpf); - if (!CPF_REGEX.test(cpfLimpo)) return res.status(400).json({ error: 'CPF invalido' }); - data.cpf = cpfLimpo; - } - } - if (novaSenha) { if (!senhaAtual) return res.status(400).json({ error: 'Informe a senha atual' }); const senhaValida = await bcrypt.compare(senhaAtual, currentUser.senha); @@ -478,7 +448,7 @@ async function atualizarPerfil(req, res, next) { const user = await prisma.user.update({ where: { id: req.userId }, data, - select: { id: true, nome: true, email: true, tipo: true, telefone: true, avatar: true, nomeFantasia: true, cpfCnpj: true, enderecoComercial: true, telefoneComercial: true, cpf: true }, + select: { id: true, nome: true, email: true, tipo: true, telefone: true, avatar: true, nomeFantasia: true, cpfCnpj: true, enderecoComercial: true, telefoneComercial: true }, }); res.json({ message: 'Perfil atualizado', user }); @@ -487,73 +457,7 @@ async function atualizarPerfil(req, res, next) { } } -// --------------------------------------------------------------------------- -// Endereços (mantidos para compat) -// --------------------------------------------------------------------------- -async function listarEnderecos(req, res, next) { - try { - const enderecos = await prisma.endereco.findMany({ where: { userId: req.userId }, orderBy: { principal: 'desc' } }); - res.json(enderecos); - } catch (err) { next(err); } -} - -async function criarEndereco(req, res, next) { - try { - const { apelido, rua, numero, complemento, bairro, cidade, estado, cep, principal } = req.body; - if (!rua || !numero || !bairro || !cep) return res.status(400).json({ error: 'Rua, numero, bairro e CEP sao obrigatorios' }); - if (principal) await prisma.endereco.updateMany({ where: { userId: req.userId }, data: { principal: false } }); - const endereco = await prisma.endereco.create({ - data: { - userId: req.userId, apelido: sanitize(apelido) || 'Casa', - rua: sanitize(rua), numero: sanitize(numero), - complemento: complemento ? sanitize(complemento) : null, - bairro: sanitize(bairro), cidade: cidade ? sanitize(cidade) : 'Boa Esperança do Sul', - estado: estado ? sanitize(estado) : 'SP', cep: sanitize(cep).replace(/\D/g, ''), - principal: principal || false, - } - }); - res.status(201).json(endereco); - } catch (err) { next(err); } -} - -async function atualizarEndereco(req, res, next) { - try { - const id = parseInt(req.params.id, 10); - if (isNaN(id)) return res.status(400).json({ error: 'ID invalido' }); - const endereco = await prisma.endereco.findFirst({ where: { id, userId: req.userId } }); - if (!endereco) return res.status(404).json({ error: 'Endereco nao encontrado' }); - const { apelido, rua, numero, complemento, bairro, cidade, estado, cep, principal } = req.body; - const data = {}; - if (apelido) data.apelido = sanitize(apelido); - if (rua) data.rua = sanitize(rua); - if (numero) data.numero = sanitize(numero); - if (complemento !== undefined) data.complemento = complemento ? sanitize(complemento) : null; - if (bairro) data.bairro = sanitize(bairro); - if (cidade) data.cidade = sanitize(cidade); - if (estado) data.estado = sanitize(estado); - if (cep) data.cep = sanitize(cep).replace(/\D/g, ''); - if (principal) { - await prisma.endereco.updateMany({ where: { userId: req.userId }, data: { principal: false } }); - data.principal = true; - } - const updated = await prisma.endereco.update({ where: { id }, data }); - res.json(updated); - } catch (err) { next(err); } -} - -async function excluirEndereco(req, res, next) { - try { - const id = parseInt(req.params.id, 10); - if (isNaN(id)) return res.status(400).json({ error: 'ID invalido' }); - const endereco = await prisma.endereco.findFirst({ where: { id, userId: req.userId } }); - if (!endereco) return res.status(404).json({ error: 'Endereco nao encontrado' }); - await prisma.endereco.delete({ where: { id } }); - res.json({ message: 'Endereco excluido' }); - } catch (err) { next(err); } -} - module.exports = { registro, login, perfil, atualizarPerfil, me, refresh, logout, csrf, activeStore, - listarEnderecos, criarEndereco, atualizarEndereco, excluirEndereco, }; diff --git a/backend/src/controllers/pagamentosController.js b/backend/src/controllers/pagamentosController.js deleted file mode 100644 index 167c788..0000000 --- a/backend/src/controllers/pagamentosController.js +++ /dev/null @@ -1,322 +0,0 @@ -// =========================================== -// Controller - Pagamentos (Mercado Pago) -// =========================================== -const prisma = require('../lib/prisma'); - -// Mercado Pago SDK v2 -let mpClient = null; -let mpPreference = null; -let mpPayment = null; - -// Inicializar Mercado Pago (lazy load) -function initMP() { - if (mpClient) return true; - - const accessToken = process.env.MERCADO_PAGO_ACCESS_TOKEN; - if (!accessToken || accessToken.includes('seu-access-token')) { - console.warn('[PAGAMENTO] Mercado Pago nao configurado. Configure MERCADO_PAGO_ACCESS_TOKEN no .env'); - return false; - } - - try { - const { MercadoPagoConfig, Preference, Payment } = require('mercadopago'); - mpClient = new MercadoPagoConfig({ accessToken }); - mpPreference = new Preference(mpClient); - mpPayment = new Payment(mpClient); - console.log('[PAGAMENTO] Mercado Pago inicializado'); - return true; - } catch (err) { - console.error('[PAGAMENTO] Erro ao inicializar Mercado Pago:', err.message); - return false; - } -} - -// POST /api/pagamentos/criar -// Cria uma preferencia de pagamento no Mercado Pago -async function criarPagamento(req, res, next) { - try { - const { pedidoCodigo, metodo } = req.body; - - if (!pedidoCodigo) { - return res.status(400).json({ error: 'pedidoCodigo e obrigatorio' }); - } - - // Buscar pedido - const pedido = await prisma.pedido.findUnique({ - where: { codigo: pedidoCodigo }, - include: { - itens: true, - comercio: { select: { nome: true, slug: true } }, - cliente: { select: { id: true, nome: true, email: true } }, - pagamento: true - } - }); - - if (!pedido) return res.status(404).json({ error: 'Pedido nao encontrado' }); - - // Verificar se e o dono do pedido - if (pedido.clienteId !== req.userId) { - return res.status(403).json({ error: 'Sem permissao' }); - } - - // Verificar se ja tem pagamento aprovado - if (pedido.pagamento && pedido.pagamento.status === 'aprovado') { - return res.status(400).json({ error: 'Este pedido ja foi pago' }); - } - - // Se metodo "na_entrega", criar pagamento local sem MP - if (metodo === 'na_entrega') { - const pagamento = await prisma.pagamento.upsert({ - where: { pedidoId: pedido.id }, - create: { - pedidoId: pedido.id, - metodo: 'na_entrega', - status: 'pendente', - valor: pedido.total - }, - update: { - metodo: 'na_entrega', - status: 'pendente', - valor: pedido.total - } - }); - - // Confirmar pedido automaticamente - await prisma.pedido.update({ - where: { id: pedido.id }, - data: { status: 'confirmado' } - }); - - return res.json({ - message: 'Pedido confirmado - pagamento na entrega', - pagamento, - tipo: 'na_entrega' - }); - } - - // Integração com Mercado Pago - if (!initMP()) { - return res.status(503).json({ - error: 'Sistema de pagamento nao configurado. Use pagamento na entrega.', - fallback: 'na_entrega' - }); - } - - const webhookUrl = process.env.WEBHOOK_BASE_URL || `http://localhost:${process.env.PORT || 3000}`; - - // Criar preferencia de pagamento - const preferenceData = { - body: { - items: pedido.itens.map(item => ({ - id: String(item.id), - title: `${item.nome} (${pedido.comercio.nome})`, - quantity: item.quantidade, - unit_price: item.preco, - currency_id: 'BRL' - })), - // Adicionar taxa de entrega como item se > 0 - ...(pedido.taxaEntrega > 0 ? { - shipments: { - cost: pedido.taxaEntrega, - mode: 'not_specified' - } - } : {}), - payer: { - name: pedido.cliente.nome, - email: pedido.cliente.email - }, - back_urls: { - success: `${process.env.FRONTEND_URL || 'http://localhost:5500'}?pedido=${pedido.codigo}&status=sucesso`, - failure: `${process.env.FRONTEND_URL || 'http://localhost:5500'}?pedido=${pedido.codigo}&status=falha`, - pending: `${process.env.FRONTEND_URL || 'http://localhost:5500'}?pedido=${pedido.codigo}&status=pendente` - }, - auto_return: 'approved', - notification_url: `${webhookUrl}/api/pagamentos/webhook`, - external_reference: pedido.codigo, - statement_descriptor: 'COMERCIO BES', - // Excluir metodos se especificado - ...(metodo === 'pix' ? { - payment_methods: { - excluded_payment_types: [ - { id: 'credit_card' }, - { id: 'debit_card' }, - { id: 'ticket' } - ] - } - } : {}) - } - }; - - const preference = await mpPreference.create(preferenceData); - - // Salvar/atualizar pagamento no banco - const pagamento = await prisma.pagamento.upsert({ - where: { pedidoId: pedido.id }, - create: { - pedidoId: pedido.id, - preferenceId: preference.id, - metodo: metodo || null, - status: 'pendente', - valor: pedido.total - }, - update: { - preferenceId: preference.id, - metodo: metodo || null, - status: 'pendente', - valor: pedido.total - } - }); - - res.json({ - message: 'Preferencia de pagamento criada', - pagamento, - tipo: 'mercado_pago', - checkout_url: preference.init_point, - sandbox_url: preference.sandbox_init_point, - preference_id: preference.id, - public_key: process.env.MERCADO_PAGO_PUBLIC_KEY - }); - } catch (err) { - next(err); - } -} - -// POST /api/pagamentos/webhook -// Webhook do Mercado Pago (recebe notificacoes de pagamento) -async function webhook(req, res, next) { - try { - const { type, data } = req.body; - - // Mercado Pago envia "payment" para eventos de pagamento - if (type === 'payment' && data && data.id) { - if (!initMP()) { - console.error('[WEBHOOK] Mercado Pago nao configurado'); - return res.sendStatus(200); // Responder 200 para MP nao reenviar - } - - // Buscar detalhes do pagamento no MP - const mpPaymentData = await mpPayment.get({ id: data.id }); - - if (!mpPaymentData) { - console.error('[WEBHOOK] Pagamento nao encontrado no MP:', data.id); - return res.sendStatus(200); - } - - const externalRef = mpPaymentData.external_reference; // = codigo do pedido - const mpStatus = mpPaymentData.status; - - // Mapear status do MP para nosso status - const statusMap = { - 'approved': 'aprovado', - 'pending': 'pendente', - 'in_process': 'em_analise', - 'rejected': 'recusado', - 'refunded': 'devolvido', - 'cancelled': 'recusado', - 'charged_back': 'devolvido' - }; - - const nossoStatus = statusMap[mpStatus] || 'pendente'; - - // Atualizar pagamento no banco - const pedido = await prisma.pedido.findUnique({ - where: { codigo: externalRef }, - include: { pagamento: true } - }); - - if (pedido && pedido.pagamento) { - await prisma.pagamento.update({ - where: { id: pedido.pagamento.id }, - data: { - mercadoPagoId: String(data.id), - status: nossoStatus, - metodo: mpPaymentData.payment_type_id || null, - mercadoPagoStatus: mpStatus, - detalhes: JSON.stringify({ - payment_method_id: mpPaymentData.payment_method_id, - status_detail: mpPaymentData.status_detail, - transaction_amount: mpPaymentData.transaction_amount - }), - ...(mpStatus === 'approved' ? { paidAt: new Date() } : {}) - } - }); - - // Se aprovado, confirmar pedido automaticamente - if (mpStatus === 'approved' && pedido.status === 'pendente') { - await prisma.pedido.update({ - where: { id: pedido.id }, - data: { status: 'confirmado' } - }); - } - - console.log(`[WEBHOOK] Pedido ${externalRef}: ${mpStatus} -> ${nossoStatus}`); - } - } - - // Sempre responder 200 para o MP - res.sendStatus(200); - } catch (err) { - console.error('[WEBHOOK] Erro:', err.message); - res.sendStatus(200); // Responder 200 mesmo com erro para MP nao reenviar - } -} - -// GET /api/pagamentos/:pedidoCodigo -// Consultar status do pagamento -async function consultarPagamento(req, res, next) { - try { - const { pedidoCodigo } = req.params; - - const pedido = await prisma.pedido.findUnique({ - where: { codigo: pedidoCodigo }, - include: { - pagamento: true, - comercio: { select: { nome: true } } - } - }); - - if (!pedido) return res.status(404).json({ error: 'Pedido nao encontrado' }); - - // Verificar acesso - const isCliente = pedido.clienteId === req.userId; - const isComercio = await prisma.comercio.findFirst({ - where: { id: pedido.comercioId, ownerId: req.userId } - }); - const isAdmin = req.userTipo === 'admin'; - - if (!isCliente && !isComercio && !isAdmin) { - return res.status(403).json({ error: 'Sem permissao' }); - } - - if (!pedido.pagamento) { - return res.json({ status: 'sem_pagamento', pedido: pedido.codigo }); - } - - // Se tem mercadoPagoId e o MP esta configurado, consultar status atualizado - if (pedido.pagamento.mercadoPagoId && initMP()) { - try { - const mpData = await mpPayment.get({ id: parseInt(pedido.pagamento.mercadoPagoId, 10) }); - if (mpData) { - res.json({ - pagamento: pedido.pagamento, - mercadoPago: { - status: mpData.status, - status_detail: mpData.status_detail, - payment_method_id: mpData.payment_method_id, - transaction_amount: mpData.transaction_amount - } - }); - return; - } - } catch (e) { - // Se nao conseguir consultar MP, retornar dados locais - } - } - - res.json({ pagamento: pedido.pagamento }); - } catch (err) { - next(err); - } -} - -module.exports = { criarPagamento, webhook, consultarPagamento }; diff --git a/backend/src/controllers/pedidosController.js b/backend/src/controllers/pedidosController.js deleted file mode 100644 index 9b3f1e6..0000000 --- a/backend/src/controllers/pedidosController.js +++ /dev/null @@ -1,364 +0,0 @@ -// =========================================== -// Controller - Pedidos (Orders) -// =========================================== -const prisma = require('../lib/prisma'); -const sanitize = require('../lib/sanitize'); - -// Gerar codigo do pedido: BES-00001 -async function gerarCodigo() { - const ultimo = await prisma.pedido.findFirst({ - orderBy: { id: 'desc' }, - select: { id: true } - }); - const num = (ultimo ? ultimo.id : 0) + 1; - return `BES-${String(num).padStart(5, '0')}`; -} - -// POST /api/pedidos -// Cria um pedido a partir do carrinho do cliente -async function criar(req, res, next) { - try { - const { comercioId, enderecoId, tipoEntrega, itens, observacao } = req.body; - - // Validacoes - if (!comercioId || !itens || !Array.isArray(itens) || itens.length === 0) { - return res.status(400).json({ - error: 'comercioId e itens (array) sao obrigatorios' - }); - } - - const tipo = tipoEntrega || 'entrega'; - if (!['entrega', 'retirada'].includes(tipo)) { - return res.status(400).json({ error: 'tipoEntrega deve ser "entrega" ou "retirada"' }); - } - - // Se entrega, precisa de endereco - if (tipo === 'entrega' && !enderecoId) { - return res.status(400).json({ - error: 'enderecoId e obrigatorio para entregas' - }); - } - - // Verificar comercio existe - const comercio = await prisma.comercio.findUnique({ - where: { id: parseInt(comercioId, 10) }, - select: { id: true, nome: true, aberto: true } - }); - if (!comercio) return res.status(404).json({ error: 'Comercio nao encontrado' }); - if (!comercio.aberto) return res.status(400).json({ error: 'Este comercio esta fechado no momento' }); - - // Verificar endereco pertence ao usuario - if (enderecoId) { - const endereco = await prisma.endereco.findFirst({ - where: { id: parseInt(enderecoId, 10), userId: req.userId } - }); - if (!endereco) return res.status(404).json({ error: 'Endereco nao encontrado' }); - } - - // Validar itens e calcular subtotal - const produtoIds = itens.map(i => parseInt(i.produtoId, 10)).filter(id => !isNaN(id)); - const produtos = await prisma.produto.findMany({ - where: { - id: { in: produtoIds }, - comercioId: parseInt(comercioId, 10), - disponivel: true - } - }); - - const produtosMap = new Map(produtos.map(p => [p.id, p])); - - const itensValidados = []; - let subtotal = 0; - - for (const item of itens) { - const prodId = parseInt(item.produtoId, 10); - const produto = produtosMap.get(prodId); - if (!produto) { - return res.status(400).json({ - error: `Produto ID ${prodId} nao encontrado ou indisponivel neste comercio` - }); - } - - const qtd = Math.max(1, Math.min(parseInt(item.quantidade, 10) || 1, 99)); - const precoItem = produto.preco * qtd; - subtotal += precoItem; - - itensValidados.push({ - produtoId: produto.id, - nome: produto.nome, - preco: produto.preco, - quantidade: qtd - }); - } - - // Limitar valor maximo por pedido (seguranca) - if (subtotal > 10000) { - return res.status(400).json({ error: 'Valor maximo por pedido: R$ 10.000,00' }); - } - - const taxaEntrega = tipo === 'entrega' ? 5.0 : 0; // Taxa fixa por enquanto - const total = subtotal + taxaEntrega; - const codigo = await gerarCodigo(); - - const pedido = await prisma.pedido.create({ - data: { - codigo, - clienteId: req.userId, - comercioId: parseInt(comercioId, 10), - enderecoId: enderecoId ? parseInt(enderecoId, 10) : null, - status: 'pendente', - tipoEntrega: tipo, - subtotal, - taxaEntrega, - desconto: 0, - total, - observacao: observacao ? sanitize(observacao) : null, - itens: { - create: itensValidados - } - }, - include: { - itens: true, - comercio: { select: { nome: true, slug: true, emoji: true, whatsapp: true } }, - endereco: true - } - }); - - res.status(201).json({ - message: 'Pedido criado com sucesso', - pedido - }); - } catch (err) { - next(err); - } -} - -// GET /api/pedidos -// Lista pedidos do usuario (cliente) ou pedidos recebidos (comerciante) -async function listar(req, res, next) { - try { - const page = Math.max(1, parseInt(req.query.page, 10) || 1); - const limit = Math.min(50, Math.max(1, parseInt(req.query.limit, 10) || 10)); - const status = req.query.status; - - const where = {}; - - // Cliente ve seus pedidos, comerciante ve pedidos das suas lojas - if (req.userTipo === 'comerciante') { - // Buscar IDs dos comercios do usuario - const comercios = await prisma.comercio.findMany({ - where: { ownerId: req.userId }, - select: { id: true } - }); - const idsDoUsuario = comercios.map(c => c.id); - - // Se passou ?comercioId, filtrar apenas por essa loja (validando pertencimento) - const comercioIdParam = parseInt(req.query.comercioId, 10); - if (!isNaN(comercioIdParam) && idsDoUsuario.includes(comercioIdParam)) { - where.comercioId = comercioIdParam; - } else { - where.comercioId = { in: idsDoUsuario }; - } - } else if (req.userTipo === 'admin') { - // Admin ve tudo (mas pode filtrar por ?comercioId se quiser) - const comercioIdParam = parseInt(req.query.comercioId, 10); - if (!isNaN(comercioIdParam)) where.comercioId = comercioIdParam; - } else { - // Cliente ve apenas seus pedidos - where.clienteId = req.userId; - } - - if (status) where.status = status; - - const [pedidos, total] = await Promise.all([ - prisma.pedido.findMany({ - where, - skip: (page - 1) * limit, - take: limit, - orderBy: { createdAt: 'desc' }, - include: { - itens: true, - comercio: { select: { nome: true, slug: true, emoji: true } }, - cliente: { select: { nome: true, email: true, telefone: true } }, - endereco: true, - pagamento: { select: { status: true, metodo: true, mercadoPagoId: true } } - } - }), - prisma.pedido.count({ where }) - ]); - - res.json({ - pedidos, - paginacao: { - total, - pagina: page, - porPagina: limit, - totalPaginas: Math.ceil(total / limit) - } - }); - } catch (err) { - next(err); - } -} - -// GET /api/pedidos/:codigo -// Detalhes de um pedido -async function buscarPorCodigo(req, res, next) { - try { - const { codigo } = req.params; - - const pedido = await prisma.pedido.findUnique({ - where: { codigo }, - include: { - itens: { include: { produto: { select: { imagem: true, disponivel: true } } } }, - comercio: { select: { nome: true, slug: true, emoji: true, whatsapp: true, endereco: true, horario: true } }, - cliente: { select: { nome: true, email: true, telefone: true } }, - endereco: true, - pagamento: true - } - }); - - if (!pedido) return res.status(404).json({ error: 'Pedido nao encontrado' }); - - // Verificar acesso: dono do pedido, dono do comercio, ou admin - const isCliente = pedido.clienteId === req.userId; - const isComercio = await prisma.comercio.findFirst({ - where: { id: pedido.comercioId, ownerId: req.userId } - }); - const isAdmin = req.userTipo === 'admin'; - - if (!isCliente && !isComercio && !isAdmin) { - return res.status(403).json({ error: 'Sem permissao para ver este pedido' }); - } - - res.json(pedido); - } catch (err) { - next(err); - } -} - -// PUT /api/pedidos/:codigo/status -// Atualizar status do pedido (comerciante/admin) -async function atualizarStatus(req, res, next) { - try { - const { codigo } = req.params; - const { status, motivoCancelamento } = req.body; - - const statusValidos = ['pendente', 'confirmado', 'preparando', 'saiu_entrega', 'entregue', 'cancelado']; - if (!status || !statusValidos.includes(status)) { - return res.status(400).json({ - error: `Status invalido. Valores aceitos: ${statusValidos.join(', ')}` - }); - } - - const pedido = await prisma.pedido.findUnique({ - where: { codigo }, - include: { comercio: { select: { ownerId: true } } } - }); - - if (!pedido) return res.status(404).json({ error: 'Pedido nao encontrado' }); - - // Verificar permissao - const isComercio = pedido.comercio.ownerId === req.userId; - const isCliente = pedido.clienteId === req.userId; - const isAdmin = req.userTipo === 'admin'; - - // Cliente so pode cancelar - if (isCliente && status !== 'cancelado') { - return res.status(403).json({ error: 'Cliente so pode cancelar pedidos' }); - } - - // Apenas comerciante/admin podem mudar status (exceto cancelar) - if (!isComercio && !isAdmin && !isCliente) { - return res.status(403).json({ error: 'Sem permissao' }); - } - - // Nao pode alterar pedido ja entregue ou cancelado - if (['entregue', 'cancelado'].includes(pedido.status)) { - return res.status(400).json({ error: `Pedido ja esta ${pedido.status}` }); - } - - // Se cancelando, exigir motivo - const data = { status }; - if (status === 'cancelado') { - if (!motivoCancelamento) { - return res.status(400).json({ error: 'Motivo do cancelamento e obrigatorio' }); - } - data.motivoCancelamento = sanitize(motivoCancelamento); - } - - const updated = await prisma.pedido.update({ - where: { codigo }, - data, - include: { - itens: true, - comercio: { select: { nome: true, slug: true } }, - cliente: { select: { nome: true, email: true } }, - pagamento: { select: { status: true, metodo: true } } - } - }); - - res.json({ message: `Status atualizado para ${status}`, pedido: updated }); - } catch (err) { - next(err); - } -} - -// GET /api/pedidos/resumo -// Resumo de pedidos para o comerciante (dashboard) -async function resumo(req, res, next) { - try { - let comercioIds; - - if (req.userTipo === 'admin') { - const comercios = await prisma.comercio.findMany({ select: { id: true } }); - comercioIds = comercios.map(c => c.id); - } else { - const comercios = await prisma.comercio.findMany({ - where: { ownerId: req.userId }, - select: { id: true } - }); - comercioIds = comercios.map(c => c.id); - } - - if (comercioIds.length === 0) { - return res.json({ - total: 0, pendentes: 0, confirmados: 0, - preparando: 0, emEntrega: 0, entregues: 0, - cancelados: 0, faturamento: 0 - }); - } - - const [total, pendentes, confirmados, preparando, emEntrega, entregues, cancelados] = - await Promise.all([ - prisma.pedido.count({ where: { comercioId: { in: comercioIds } } }), - prisma.pedido.count({ where: { comercioId: { in: comercioIds }, status: 'pendente' } }), - prisma.pedido.count({ where: { comercioId: { in: comercioIds }, status: 'confirmado' } }), - prisma.pedido.count({ where: { comercioId: { in: comercioIds }, status: 'preparando' } }), - prisma.pedido.count({ where: { comercioId: { in: comercioIds }, status: 'saiu_entrega' } }), - prisma.pedido.count({ where: { comercioId: { in: comercioIds }, status: 'entregue' } }), - prisma.pedido.count({ where: { comercioId: { in: comercioIds }, status: 'cancelado' } }) - ]); - - // Faturamento (pedidos entregues) - const entreguesData = await prisma.pedido.aggregate({ - where: { comercioId: { in: comercioIds }, status: 'entregue' }, - _sum: { total: true } - }); - - res.json({ - total, - pendentes, - confirmados, - preparando, - emEntrega, - entregues, - cancelados, - faturamento: entreguesData._sum.total || 0 - }); - } catch (err) { - next(err); - } -} - -module.exports = { criar, listar, buscarPorCodigo, atualizarStatus, resumo }; diff --git a/backend/src/rbac/capabilities.js b/backend/src/rbac/capabilities.js index af81498..8fda1fd 100644 --- a/backend/src/rbac/capabilities.js +++ b/backend/src/rbac/capabilities.js @@ -9,20 +9,15 @@ const ROLE_CAPABILITIES = { 'account.view', 'users.manage', 'stores.moderate', - 'orders.view.global', ], comerciante: [ 'account.view', 'account.view.store', 'stores.manage.own', 'products.manage.own', - 'orders.view.ownStore', ], cliente: [ 'account.view', - 'orders.view.own', - 'payments.manage.own', - 'addresses.manage.own', ], }; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 9e37fd7..1c4dbb1 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -28,7 +28,7 @@ router.get('/me', auth, ctrl.me); router.post('/refresh', ctrl.refresh); // --- Logout --- -router.post('/logout', ctrl.logout); +router.post('/logout', auth, csrfGuard, ctrl.logout); // --- CSRF --- router.get('/csrf', ctrl.csrf); @@ -40,10 +40,4 @@ router.patch('/active-store', auth, csrfGuard, ctrl.activeStore); router.get('/perfil', auth, ctrl.perfil); router.put('/perfil', auth, csrfGuard, ctrl.atualizarPerfil); -// --- Endereços de entrega --- -router.get('/enderecos', auth, ctrl.listarEnderecos); -router.post('/enderecos', auth, csrfGuard, ctrl.criarEndereco); -router.put('/enderecos/:id', auth, csrfGuard, ctrl.atualizarEndereco); -router.delete('/enderecos/:id', auth, csrfGuard, ctrl.excluirEndereco); - module.exports = router; diff --git a/backend/src/routes/pagamentos.js b/backend/src/routes/pagamentos.js deleted file mode 100644 index 7b1f514..0000000 --- a/backend/src/routes/pagamentos.js +++ /dev/null @@ -1,18 +0,0 @@ -// =========================================== -// Rotas - Pagamentos (Mercado Pago) -// =========================================== -const express = require('express'); -const router = express.Router(); -const { auth, csrfGuard } = require('../middleware/auth'); -const ctrl = require('../controllers/pagamentosController'); - -// Criar pagamento (cliente autenticado) -router.post('/criar', auth, csrfGuard, ctrl.criarPagamento); - -// Webhook do Mercado Pago (publico - chamado pelo MP) -router.post('/webhook', ctrl.webhook); - -// Consultar pagamento (autenticado) -router.get('/:pedidoCodigo', auth, ctrl.consultarPagamento); - -module.exports = router; diff --git a/backend/src/routes/pedidos.js b/backend/src/routes/pedidos.js deleted file mode 100644 index f89ea9e..0000000 --- a/backend/src/routes/pedidos.js +++ /dev/null @@ -1,24 +0,0 @@ -// =========================================== -// Rotas - Pedidos -// =========================================== -const express = require('express'); -const router = express.Router(); -const { auth, requireTipo, csrfGuard } = require('../middleware/auth'); -const ctrl = require('../controllers/pedidosController'); - -// Resumo (comerciante/admin) - ANTES de /:codigo para nao conflitar -router.get('/resumo', auth, requireTipo('comerciante', 'admin'), ctrl.resumo); - -// Criar pedido (cliente autenticado) -router.post('/', auth, csrfGuard, ctrl.criar); - -// Listar pedidos (todos os tipos autenticados) -router.get('/', auth, ctrl.listar); - -// Detalhes do pedido -router.get('/:codigo', auth, ctrl.buscarPorCodigo); - -// Atualizar status (comerciante/admin/cliente para cancelar) -router.put('/:codigo/status', auth, csrfGuard, ctrl.atualizarStatus); - -module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index baba7e9..7c7bb0c 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -19,9 +19,6 @@ const categoriasRoutes = require('./routes/categorias'); const avaliacoesRoutes = require('./routes/avaliacoes'); const uploadRoutes = require('./routes/upload'); const estatisticasRoutes = require('./routes/estatisticas'); -const pedidosRoutes = require('./routes/pedidos'); -const pagamentosRoutes = require('./routes/pagamentos'); - const app = express(); app.set('trust proxy', 1); @@ -37,8 +34,6 @@ const SITE_PASSWORD = process.env.SITE_PASSWORD || ''; function privateSiteGate(req, res, next) { if (!SITE_PASSWORD || process.env.NODE_ENV !== 'production') return next(); - if (req.path === '/api/pagamentos/webhook') return next(); - const header = req.headers.authorization || ''; const [scheme, encoded] = header.split(' '); @@ -65,12 +60,12 @@ app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com", "https://sdk.mercadopago.com", "https://http2.mlstatic.com"], + scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"], styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"], fontSrc: ["'self'", "https://fonts.gstatic.com"], imgSrc: ["'self'", "data:", "https://*.tile.openstreetmap.org", "https://res.cloudinary.com", "https://http2.mlstatic.com", "blob:"], - connectSrc: ["'self'", "https://api.comerciobes.com.br", "https://unpkg.com", "https://api.mercadopago.com", "https://api.mercadolibre.com"], - frameSrc: ["https://sdk.mercadopago.com", "https://*.mercadopago.com"], + connectSrc: ["'self'", "https://api.comerciobes.com.br", "https://unpkg.com"], + frameSrc: ["'none'"], objectSrc: ["'none'"], baseUri: ["'self'"], formAction: ["'self'"], @@ -195,14 +190,14 @@ app.use( express.static(path.join(BACKEND_ROOT, 'minha-conta')) ); app.get(/^\/minha-conta\/.*$/, (req, res) => { - res.sendFile(path.join(BACKEND_ROOT, 'minha-conta', 'index.html')); + res.sendFile('index.html', { root: path.join(BACKEND_ROOT, 'minha-conta') }); }); // --- Frontend Principal (Se acessado pela porta 3000) --- -app.get('/', (req, res) => res.sendFile(path.join(FRONTEND_ROOT, 'index.html'))); -app.get('/index.html', (req, res) => res.sendFile(path.join(FRONTEND_ROOT, 'index.html'))); -app.get('/manifest.json', (req, res) => res.sendFile(path.join(FRONTEND_ROOT, 'manifest.json'))); -app.get('/sw.js', (req, res) => res.sendFile(path.join(FRONTEND_ROOT, 'sw.js'))); +app.get('/', (req, res) => res.sendFile('index.html', { root: FRONTEND_ROOT })); +app.get('/index.html', (req, res) => res.sendFile('index.html', { root: FRONTEND_ROOT })); +app.get('/manifest.json', (req, res) => res.sendFile('manifest.json', { root: FRONTEND_ROOT })); +app.get('/sw.js', (req, res) => res.sendFile('sw.js', { root: FRONTEND_ROOT })); app.get('/login', (req, res) => res.redirect(301, '/html/login.html')); app.get('/cadastro', (req, res) => res.redirect(301, '/html/cadastro.html')); @@ -235,9 +230,6 @@ app.use('/api/categorias', categoriasRoutes); app.use('/api/avaliacoes', avaliacoesRoutes); app.use('/api/upload', uploadRoutes); app.use('/api/estatisticas', estatisticasRoutes); -app.use('/api/pedidos', pedidosRoutes); -app.use('/api/pagamentos', pagamentosRoutes); - // Rota raiz da API app.get('/api', (req, res) => { res.json({ @@ -249,13 +241,12 @@ app.get('/api', (req, res) => { comercios: '/api/comercios', categorias: '/api/categorias', avaliacoes: '/api/avaliacoes', - pedidos: '/api/pedidos', - pagamentos: '/api/pagamentos', upload: '/api/upload', estatisticas: '/api/estatisticas', health: '/api/health', painel: '/minha-conta' - } + }, + marketplace: 'fora deste servico' }); }); diff --git a/docker-compose.yml b/docker-compose.yml index bd48ce1..6a75bdd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,6 @@ services: PORT: 3000 DATABASE_URL: postgresql://${POSTGRES_USER:-comerciobes}:${POSTGRES_PASSWORD:-troque-esta-senha}@postgres:5432/${POSTGRES_DB:-comerciobes}?schema=public FRONTEND_URL: ${FRONTEND_URL:-https://comerciobes.com.br} - WEBHOOK_BASE_URL: https://${API_DOMAIN:-api.comerciobes.com.br} COOKIE_DOMAIN: ${COOKIE_DOMAIN:-.comerciobes.com.br} PRISMA_CLIENT_ENGINE_TYPE: binary volumes: diff --git a/docs/OPERACAO.md b/docs/OPERACAO.md index a90c8ce..a3ad580 100644 --- a/docs/OPERACAO.md +++ b/docs/OPERACAO.md @@ -66,7 +66,7 @@ O lojista seed fica vinculado a uma loja demo para o dashboard abrir com context ## Rodar local sem Docker instalado -Este modo requer Node.js 24+ e serve apenas para validar vitrine, login demo e navegacao do painel. Ele nao substitui PostgreSQL: cadastro real, pedidos, edicao de produtos e persistencia dependem do banco. +Este modo requer Node.js 24+ e serve apenas para validar vitrine, login demo e navegacao do painel. Ele nao substitui PostgreSQL: cadastro real, edicao de produtos e persistencia dependem do banco. ```powershell cd backend @@ -114,7 +114,7 @@ Antes de migrar para VPS, faca um backup manual e teste restore em uma maquina s 6. Troque o DNS de `api.comerciobes.com.br` para o IP da VPS. 7. Confira `/api/health`, login e painel. -Escolha inicial recomendada: KVM 2. Suba para KVM 4 quando houver loja usando diariamente, pagamento real, instabilidade ou consumo alto. +Escolha inicial recomendada: KVM 2. Suba para KVM 4 quando houver uso diario relevante, instabilidade ou consumo alto. ## Checklist antes de expor @@ -124,7 +124,6 @@ Escolha inicial recomendada: KVM 2. Suba para KVM 4 quando houver loja usando di - Banco sem porta publica. - `COOKIE_DOMAIN=.comerciobes.com.br`. - `FRONTEND_URL=https://comerciobes.com.br`. -- `WEBHOOK_BASE_URL=https://api.comerciobes.com.br`. - `AUTH_FALLBACK_ENABLED=false`. - `/api/health` com banco reachable. - Backup gerado e restore testado. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2c0a0c7..a4ab5f1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,96 +1,74 @@ -# Roadmap - Rebase do Marketplace +# Roadmap - Comercio BES como vitrine local -Este roadmap substitui a ideia de "continuar adicionando features" por um fechamento pragmático do marketplace real. +Este roadmap e o espelho versionado do planejamento no GitHub. O objetivo e entregar uma vitrine local pequena, confiavel e integravel ao Braga Commerce, sem reconstruir e-commerce dentro deste repositorio. ## Regra de escopo -Nao entram cupons, notificacoes push, automacoes comerciais ou novas telas antes de: +Entram no nucleo: -- checkout criar pedido real no backend; -- cliente e lojista enxergarem o mesmo pedido; -- CI rodar testes com PostgreSQL real; -- pagamento Mercado Pago ter testes e webhook seguro; -- vitrine React/Next cobrir o fluxo essencial antes de substituir a vanilla. +- descoberta por busca, categoria e mapa; +- perfil publico do comercio, fotos, horario e contatos; +- cadastro, moderacao e administracao da informacao local; +- metricas de visualizacao sem dados transacionais; +- integracao read-only com o Braga Commerce. -## Milestones para GitHub +Ficam fora do nucleo: -### M0 - Fundacao e Checks +- carrinho, checkout, pedido, pagamento e cupom; +- estoque, entrega, devolucao e pos-venda; +- duplicacao do catalogo transacional do Braga Commerce. -Objetivo: deixar a base verificavel antes de qualquer feature nova. +## Milestones do GitHub -Issues sugeridas: +### v0.1 - Scope Reset -1. `CI: rodar testes backend com PostgreSQL service` - - Check: workflow sobe Postgres 16 e executa `npm test`. - - Check: PR falha se testes de integracao falharem. +Resultado: produto e codigo deixam explicito que o Comercio BES e uma vitrine. -2. `Seguranca: aplicar CSRF nas rotas mutantes autenticadas` - - Check: mutacoes via cookie exigem `X-CSRF-Token`. - - Check: testes com Bearer continuam funcionando. +- Epic #12: redefinir o produto como vitrine local. +- #19: ADR e ownership do Braga Commerce. +- #20: desativar checkout, pedidos e pagamentos do nucleo. +- #36: documentacao e ADRs. -3. `Testes: cobrir criacao e listagem de pedidos` - - Check: cliente cria pedido com produto valido. - - Check: lojista ve apenas pedidos das proprias lojas. - - Check: outro lojista nao acessa pedido alheio. +### v0.2 - Production Baseline -4. `Schema: trocar valores monetarios para Decimal` - - Check: `Produto.preco`, `Pedido.total`, `Pagamento.valor` e snapshots nao usam `Float`. - - Check: seed e controllers continuam convertendo valores corretamente. +Resultado: baseline de seguranca, dados, testes e CI capaz de sustentar deploys. -### M1 - Checkout Real +- Epic #13: seguranca e privacidade (#22 a #25 e #27). +- Epic #14: arquitetura e modelo de dados (#21, #26 e #28). +- Epic #15: qualidade e CI (#29 e #30). -Objetivo: acabar com a duplicidade entre pedido local e pedido backend. +### v0.3 - Directory MVP -Issues sugeridas: +Resultado: fluxo principal busca -> perfil -> contato rapido, acessivel e performatico. -1. `Vitrine: confirmar pedido usando /api/pedidos` - - Check: botao principal cria pedido real. - - Check: carrinho com lojas diferentes cria pedidos separados. - - Check: WhatsApp fica marcado como fluxo externo, sem historico falso. +- Epic #16: Directory MVP. +- #31: imagens e payload inicial. +- #32: acessibilidade, teclado, foco e mobile. +- #33: home centrada em descoberta e contato. -2. `Cliente: historico mostra pedidos criados pela vitrine` - - Check: pedido criado aparece em `/minha-conta/historico`. - - Check: cliente consegue cancelar pedido pendente/confirmado. +### v0.4 - Braga Commerce Adapter -3. `Lojista: painel opera pedidos reais` - - Check: pedido aparece em `/minha-conta/pedidos-loja`. - - Check: lojista altera status seguindo o fluxo permitido. +Resultado: lojas elegiveis podem apontar para uma experiencia transacional externa sem compartilhar credenciais ou dados indevidos. -### M2 - Pagamento Mercado Pago +- Epic #17: adapter Braga Commerce. +- #34: contrato, ownership matrix e seguranca. +- #35: adapter read-only observavel. -Objetivo: deixar pagamento real seguro antes de expor publicamente. +### v1.0 - Public Launch -Issues sugeridas: +Resultado: lancamento com staging, observabilidade, backup, rollback e runbooks validados. -1. `Mercado Pago: validar webhook com assinatura` - - Check: eventos sem assinatura valida sao recusados ou ignorados. - - Check: evento duplicado nao duplica efeito. +- Epic #18: lancamento e operacao. +- #37: staging, observabilidade, backup e rollback. -2. `Pagamento: testes de criacao e consulta` - - Check: pagamento na entrega confirma pedido sem Mercado Pago. - - Check: PIX retorna preferencia quando credenciais estao configuradas. +## Ordem de entrega -3. `UI: estado de pagamento no historico do cliente` - - Check: pendente/aprovado/recusado aparecem no painel. - - Check: erro de pagamento tem mensagem clara. - -### M3 - Frontend Next - -Objetivo: migrar a vitrine sem apagar o que ainda funciona. - -Issues sugeridas: - -1. `Next: criar app frontend-next consumindo API atual` - - Check: rotas de home, busca e loja usam `/api/comercios`. - - Check: `.env.example` documenta `NEXT_PUBLIC_API_BASE`. - -2. `Next: carrinho e checkout real` - - Check: carrinho usa estado local apenas como rascunho. - - Check: confirmacao chama `/api/pedidos`. - -3. `Next: paridade antes de substituir vanilla` - - Check: home, busca, loja, carrinho, login e checkout passam em teste manual. - - Check: vitrine antiga continua disponivel ate paridade minima. +1. Fechar P0 de escopo, cache, autenticacao e XSS. +2. Congelar um contrato publico pequeno para a vitrine. +3. Criar migracoes versionadas, testes e gates de CI. +4. Simplificar a experiencia publica e validar acessibilidade/performance. +5. Homologar o contrato do Braga Commerce antes de implementar o adapter. +6. Lancar apenas depois de exercitar backup, restore e rollback em staging. ## Checks locais antes de PR diff --git a/docs/adr/0001-product-scope-and-braga-commerce.md b/docs/adr/0001-product-scope-and-braga-commerce.md new file mode 100644 index 0000000..6b664cb --- /dev/null +++ b/docs/adr/0001-product-scope-and-braga-commerce.md @@ -0,0 +1,98 @@ +# ADR 0001 - Escopo do produto e fronteira com o Braga Commerce + +- Status: aceito +- Data: 2026-07-16 +- Decisores: mantenedores do Comercio BES +- Issues relacionadas: #12, #19, #20, #34 e #35 + +## Contexto + +O repositorio evoluiu de guia comercial para marketplace, acumulando carrinho, pedido e pagamento. Essa direcao aumenta custo operacional, superficie de seguranca e duplicacao de dados. Ao mesmo tempo, o Braga Commerce ja e o sistema natural para capacidades transacionais. + +O objetivo do Comercio BES e permitir que um morador encontre um estabelecimento de Boa Esperanca do Sul e obtenha informacoes confiaveis para visita-lo ou contata-lo. + +## Decisao + +O Comercio BES sera uma vitrine digital local. + +### Comercio BES e dono de + +- identidade publica e status de moderacao do estabelecimento; +- categoria, descricao, endereco, coordenadas, horario e contatos; +- fotos institucionais, destaques e indicadores de descoberta; +- busca, mapa, favoritos e avaliacoes, quando habilitadas; +- vinculo tecnico com um comercio no Braga Commerce. + +### Braga Commerce e dono de + +- catalogo transacional e precos de venda; +- estoque e disponibilidade para compra; +- carrinho, checkout, pedido e pagamento; +- entrega, cancelamento, devolucao e pos-venda; +- dados fiscais e demais dados sensiveis da transacao. + +### Integracao + +1. O primeiro contrato sera read-only do ponto de vista do Comercio BES. +2. O vinculo usara um identificador externo estavel, nunca email ou nome como chave. +3. A vitrine podera exibir disponibilidade resumida e um link para a loja externa, desde que esses campos tenham origem e horario de sincronizacao visiveis. +4. Credenciais de integracao permanecem apenas no backend e devem ter menor privilegio possivel. +5. Falhas de sincronizacao nao removem o perfil local; dados externos ficam marcados como indisponiveis ou desatualizados. +6. Nenhum pedido ou pagamento sera criado pelo Comercio BES. +7. Contrato, autenticacao, rate limit, retry, idempotencia, observabilidade e LGPD devem ser homologados antes do adapter de producao. + +## Modelo inicial do vinculo + +O desenho alvo inclui, no minimo: + +- `provider`: identificador do Braga Commerce; +- `externalStoreId`: chave opaca da loja externa; +- `storefrontUrl`: destino aprovado para a experiencia de compra; +- `syncStatus`: estado da ultima sincronizacao; +- `lastSyncedAt`: horario da ultima resposta valida; +- `syncErrorCode`: codigo operacional sem segredos ou dados pessoais. + +O schema definitivo sera criado por migracao versionada depois que o contrato do Braga Commerce for aprovado. + +## Consequencias + +### Positivas + +- escopo menor e lancamento mais seguro; +- uma unica fonte de verdade para transacoes; +- menor tratamento de dados pessoais e financeiros; +- evolucao independente da vitrine e do e-commerce. + +### Custos + +- remocao gradual do marketplace legado; +- necessidade de contrato formal entre os sistemas; +- experiencia de compra ocorre fora da vitrine; +- sincronizacao exige monitoramento e estrategia de degradacao. + +## Plano de transicao + +1. Remover pontos de entrada de carrinho e checkout do frontend publico. +2. Desabilitar rotas transacionais por padrao, preservando dados ate existir plano de retencao/exportacao. +3. Corrigir riscos P0 de cache, autenticacao e conteudo nao confiavel. +4. Reduzir o schema em migracoes seguras, sem apagar dados em producao sem backup e aprovacao. +5. Homologar o contrato Braga Commerce e implementar o adapter atras de feature flag. +6. Excluir o codigo legado somente depois da verificacao de dados e rollback. + +## Retencao do marketplace legado + +As tabelas `Endereco`, `Pedido`, `ItemPedido` e `Pagamento` nao serao apagadas neste milestone. A politica operacional e: + +1. manter removidas as interfaces, rotas e dependencias transacionais, impedindo novos dados no produto publico; +2. antes de alterar o schema, gerar backup restauravel e inventario por tabela com contagem, menor e maior data; +3. definir com os responsaveis pelo produto a necessidade e o prazo de retencao, considerando privacidade e obrigacoes aplicaveis; +4. exportar somente o necessario, com acesso restrito, e validar a restauracao em staging; +5. executar remocao por migracao versionada em issue separada, com aprovacao explicita e plano de rollback. + +Codigo de interface sem dados autoritativos — carrinho e historico em `localStorage` — e APIs transacionais foram removidos imediatamente. Apenas os modelos persistentes permanecem temporariamente para inventario/exportacao controlada, sem fazer parte do core da vitrine. + +## Alternativas rejeitadas + +- Manter dois marketplaces completos: cria fontes de verdade concorrentes e duplica operacao. +- Reescrever todo o produto antes de estabilizar: posterga valor e nao resolve a fronteira de dominio. +- Integrar diretamente no navegador: exporia credenciais e reduziria controle de seguranca e observabilidade. diff --git a/index.html b/index.html index 667c1ad..a98261f 100644 --- a/index.html +++ b/index.html @@ -197,27 +197,6 @@ background: rgba(255, 68, 68, 0.15); } - /* Cart badge counter */ - .nav-badge { - position: absolute; - top: -6px; - right: -6px; - background: var(--verde); - color: #fff; - font-size: 11px; - font-weight: 700; - width: 20px; - height: 20px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - border: 2px solid var(--creme); - } - - body.dark-mode .nav-badge { - border-color: #0f0f0f; - } @@ -376,11 +355,6 @@ -
    @@ -590,7 +564,7 @@

    Construindo em público

    O caminho do Comércio BES até o lançamento

    -

    Estamos começando simples: primeiro um guia confiável para encontrar lojas, depois pedidos mais completos, pagamentos e ferramentas para os próprios comerciantes cuidarem das páginas.

    +

    Estamos construindo uma vitrine confiável para encontrar lojas locais. Quando um comércio também usar o Braga Commerce, o perfil poderá apontar para a experiência de compra mantida por ele.

    @@ -601,7 +575,7 @@

    Guia comercial

  • Busca por comércios, categorias e mapa
  • Páginas de lojas com fotos, horário e WhatsApp
  • Favoritos, ranking e promoções simples
  • -
  • Pedido leve direcionado ao WhatsApp
  • +
  • Contato direto por WhatsApp e canais oficiais
  • @@ -617,13 +591,13 @@

    Homologação privada

    - Depois do básico -

    Marketplace gradual

    + Integração futura +

    Braga Commerce

      -
    • Pedidos salvos no backend
    • -
    • Histórico do cliente e painel do lojista
    • -
    • PIX Mercado Pago testado em sandbox
    • -
    • Segurança reforçada e auditoria final
    • +
    • Vínculo seguro entre as lojas dos dois sistemas
    • +
    • Disponibilidade resumida com origem e data da sincronização
    • +
    • Link para a experiência de compra externa
    • +
    • Monitoramento, privacidade e degradação segura
    @@ -712,7 +686,7 @@

    Comerciante?

    - + @@ -743,27 +717,6 @@

    Comerciante?

    Cadastrar minha loja - -
    -
    -
    -

    - - Meu Carrinho

    - -
    -
    - -
    -
    @@ -822,81 +775,6 @@

    ➕ Cadastrar Minha Loja

    - -
    -
    - -

    🛒 Finalizar Pedido

    -
    - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    -
    - - -
    -
    - - -
    -
    -
    -
    - - -
    -
    - - -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    - -

    📋 Meus Pedidos

    -
    -
    -
    -
    @@ -962,12 +840,6 @@

    📋 Meus Pedidos

    document.addEventListener('DOMContentLoaded', () => { verificarLogin(); - if (localStorage.getItem('bes_cart_pending') === '1' && Auth.isLoggedIn()) { - localStorage.removeItem('bes_cart_pending'); - const user = Auth.getUser(); - const nome = user ? ' ' + user.nome.split(' ')[0] : ''; - setTimeout(() => mostrarToast('Bem-vindo(a)' + nome + '! Agora você pode adicionar itens ao carrinho.'), 600); - } }); diff --git a/js/app.js b/js/app.js index 0a5b48e..0e66407 100644 --- a/js/app.js +++ b/js/app.js @@ -13,11 +13,6 @@ import { instalarPWA, fecharBannerPWA } from './modules/ui.js'; import { carregarComercios, registrarEstatistica } from './modules/api.js'; -import { Cart } from './modules/cart.js'; -import { - toggleCarrinhoDrawer, fecharCarrinhoDrawer, - renderCarrinhoDrawer, cartUpdateQtd, cartRemove -} from './modules/cart.js'; import { Merchants } from './modules/merchants.js'; import { renderizarCards, criarCard, irPagina } from './render/cards.js'; import { renderPromos, renderRanking } from './render/promotions.js'; @@ -28,7 +23,6 @@ import { } from './render/favorites.js'; import { abrirModal, fecharModal, - alterarQtdModal, adicionarAoCarrinho, enviarPedidoWhatsApp, avaliar, enviarAvaliacao, abrirLightbox, fecharLightbox, navLightbox } from './render/modal.js'; @@ -39,11 +33,6 @@ import { toggleContaMenu, fecharContaMenu } from './modules/auth-ui.js'; import { Auth } from './modules/auth.js'; -import { - abrirCheckout, fecharCheckout, - confirmarPedido, confirmarPedidoWhatsApp, enviarTudoWhatsApp -} from './modules/checkout.js'; -import { abrirPedidos, fecharPedidos } from './render/orders-ui.js'; import { abrirCadastroLoja, fecharCadastroLoja, salvarCadastroLoja } from './modules/merchant-ui.js'; import { filtrarCategoria, filtrarBusca, setSearch, @@ -59,7 +48,6 @@ Object.assign(window, { toggleDarkMode, toggleMobileMenu, fecharMobileMenu, toggleContaMenu, fecharContaMenu, abrirFavoritos, fecharFavoritos, - toggleCarrinhoDrawer, fecharCarrinhoDrawer, // Auth abrirAuth, fecharAuth, trocarAuthTab, @@ -74,21 +62,12 @@ Object.assign(window, { // Modal de perfil abrirModal, fecharModal, toggleFavoritoModal, toggleFavoritoCard, - alterarQtdModal, adicionarAoCarrinho, enviarPedidoWhatsApp, avaliar, enviarAvaliacao, copiarLinkLoja, // definida abaixo neste arquivo // Lightbox de fotos abrirLightbox, fecharLightbox, navLightbox, - // Carrinho / checkout - cartUpdateQtd, cartRemove, - abrirCheckout, fecharCheckout, enviarTudoWhatsApp, - confirmarPedido, confirmarPedidoWhatsApp, - - // Pedidos - abrirPedidos, fecharPedidos, - // API (usado em popups do mapa e cards gerados) registrarEstatistica, mostrarToast, @@ -143,11 +122,14 @@ async function carregarDados() { } async function inicializar() { + // Remove rascunhos locais do marketplace descontinuado. Nenhum deles era + // fonte de verdade no backend. + ['bes_carrinho', 'bes_pedidos', 'bes_cart_pending', 'bes_api_token'] + .forEach(key => localStorage.removeItem(key)); aplicarTema(); initNavScroll(); await carregarDados(); atualizarAnoRodape(); - Cart.updateBadge(); atualizarNavUser(); verificarDeepLink(); renderTudo(); @@ -190,11 +172,8 @@ function copiarLinkLoja(slug) { document.addEventListener('keydown', e => { if (e.key === 'Escape') { fecharModal(); - fecharCarrinhoDrawer(); fecharAuth(); fecharCadastroLoja(); - fecharCheckout(); - fecharPedidos(); } }); diff --git a/js/config.js b/js/config.js index cdcf761..00291f7 100644 --- a/js/config.js +++ b/js/config.js @@ -32,11 +32,8 @@ export const ENABLE_DATA_FALLBACK = window.location.protocol === 'file:' || LOCA // ===== STORAGE KEYS ===== export const KEYS = { SESSION: 'bes_sessao', - CART: 'bes_carrinho', - ORDERS: 'bes_pedidos', FAVORITES: 'bes_favoritos', - MERCHANTS: 'bes_merchants', - API_TOKEN: 'bes_api_token' + MERCHANTS: 'bes_merchants' }; // ===== PAGINAÇÃO ===== diff --git a/js/modules/api.js b/js/modules/api.js index 4fc3fee..d98ed47 100644 --- a/js/modules/api.js +++ b/js/modules/api.js @@ -1,10 +1,9 @@ // ===== COMÉRCIO BES — API CLIENT ===== -// Toda comunicação com o backend REST. -// Retorna dados — não manipula DOM, não altera estado global. +// Chamadas publicas usadas pela vitrine. Fluxos transacionais pertencem ao +// Braga Commerce e nao devem ser adicionados neste modulo. import { API_BASE } from '../config.js'; import { state } from './state.js'; -import { Auth } from './auth.js'; export function registrarEstatistica(comercioId, tipo) { if (!state.apiDisponivel || !comercioId) return; @@ -21,14 +20,11 @@ export async function carregarComercios() { return res.json(); } -export async function enviarAvaliacaoApi(slug, nota, comentario, token) { - const headers = { 'Content-Type': 'application/json' }; - if (token) headers['Authorization'] = 'Bearer ' + token; - +export async function enviarAvaliacaoApi(slug, nota, comentario) { const res = await fetch(API_BASE + '/avaliacoes/' + slug, { method: 'POST', credentials: 'include', - headers, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ nota, comentario: comentario || undefined }) }); @@ -36,114 +32,3 @@ export async function enviarAvaliacaoApi(slug, nota, comentario, token) { if (!res.ok) throw new Error(data.error || 'Erro ao enviar avaliação'); return data; } - -function getProdutoId(item) { - const id = Number(item?.produto?.id); - return Number.isInteger(id) && id > 0 ? id : null; -} - -function agruparCarrinhoPorLoja(cart) { - const grupos = new Map(); - - cart.forEach(item => { - const lojaId = Number(item.lojaId); - if (!Number.isInteger(lojaId) || lojaId <= 0) { - throw new Error('O carrinho tem uma loja sem ID valido na API.'); - } - - const produtoId = getProdutoId(item); - if (!produtoId) { - throw new Error('O carrinho tem produto sem ID valido na API. Recarregue a pagina e tente novamente.'); - } - - if (!grupos.has(lojaId)) { - grupos.set(lojaId, { - lojaId, - lojaNome: item.lojaNome, - itens: [] - }); - } - - grupos.get(lojaId).itens.push({ - produtoId, - quantidade: Math.max(1, Number.parseInt(item.qtd, 10) || 1) - }); - }); - - return Array.from(grupos.values()); -} - -function montarObservacaoPedido(cliente, lojaNome) { - const partes = []; - if (lojaNome) partes.push(`Loja: ${lojaNome}`); - if (cliente.tel) partes.push(`Telefone: ${cliente.tel}`); - if (cliente.pagamento) partes.push(`Pagamento informado: ${cliente.pagamento}`); - if (cliente.referencia) partes.push(`Referencia: ${cliente.referencia}`); - if (cliente.obs) partes.push(`Obs: ${cliente.obs}`); - return partes.join(' | '); -} - -async function apiJson(path, options) { - const res = await fetch(API_BASE + path, { - credentials: 'include', - ...options, - headers: { - 'Content-Type': 'application/json', - ...(options.headers || {}) - } - }); - - const data = await res.json().catch(() => ({})); - if (!res.ok) { - throw new Error(data.error || `Erro HTTP ${res.status}`); - } - return data; -} - -async function criarEnderecoEntrega(cliente) { - const csrfToken = await Auth.getCsrfToken(); - - return apiJson('/auth/enderecos', { - method: 'POST', - headers: { 'X-CSRF-Token': csrfToken }, - body: JSON.stringify({ - apelido: 'Pedido', - rua: cliente.rua, - numero: cliente.numero, - complemento: cliente.complemento || undefined, - bairro: cliente.bairro, - cidade: 'Boa Esperanca do Sul', - estado: 'SP', - cep: cliente.cep, - principal: false - }) - }); -} - -export async function criarPedidosDoCarrinho(cart, cliente) { - const grupos = agruparCarrinhoPorLoja(cart); - if (grupos.length === 0) { - throw new Error('Carrinho vazio.'); - } - - const csrfToken = await Auth.getCsrfToken(true); - const endereco = await criarEnderecoEntrega(cliente); - const pedidos = []; - - for (const grupo of grupos) { - const data = await apiJson('/pedidos', { - method: 'POST', - headers: { 'X-CSRF-Token': csrfToken }, - body: JSON.stringify({ - comercioId: grupo.lojaId, - enderecoId: endereco.id, - tipoEntrega: 'entrega', - observacao: montarObservacaoPedido(cliente, grupo.lojaNome), - itens: grupo.itens - }) - }); - pedidos.push(data.pedido); - } - - return pedidos; -} diff --git a/js/modules/auth.js b/js/modules/auth.js index 1753f45..498a6da 100644 --- a/js/modules/auth.js +++ b/js/modules/auth.js @@ -11,9 +11,6 @@ export const Auth = { getSession() { return storageGet(KEYS.SESSION); }, - // Sessao principal usa cookies httpOnly; nao persistimos Bearer em localStorage. - getToken() { return null; }, - isLoggedIn() { return !!this.getSession(); }, getUser() { return this.getSession(); }, @@ -56,7 +53,6 @@ export const Auth = { const data = await res.json(); if (!res.ok) return { ok: false, msg: data.error || 'Erro ao criar conta.' }; - localStorage.removeItem(KEYS.API_TOKEN); this._csrfToken = null; storageSet(KEYS.SESSION, { userId: data.user.id, @@ -83,7 +79,6 @@ export const Auth = { const data = await res.json(); if (!res.ok) return { ok: false, msg: data.error || 'E-mail ou senha incorretos.' }; - localStorage.removeItem(KEYS.API_TOKEN); this._csrfToken = null; storageSet(KEYS.SESSION, { userId: data.user.id, @@ -99,13 +94,18 @@ export const Auth = { } }, - logout() { - fetch(API_BASE + '/auth/logout', { - method: 'POST', - credentials: 'include' - }).catch(err => console.warn('[Auth] API logout falhou:', err.message)); + async logout() { + try { + const csrfToken = await this.getCsrfToken(); + await fetch(API_BASE + '/auth/logout', { + method: 'POST', + credentials: 'include', + headers: { 'X-CSRF-Token': csrfToken } + }); + } catch (err) { + console.warn('[Auth] API logout falhou:', err.message); + } localStorage.removeItem(KEYS.SESSION); - localStorage.removeItem(KEYS.API_TOKEN); this._csrfToken = null; } }; diff --git a/js/modules/cart.js b/js/modules/cart.js deleted file mode 100644 index be9b2d5..0000000 --- a/js/modules/cart.js +++ /dev/null @@ -1,150 +0,0 @@ -// ===== COMÉRCIO BES — CART MODULE ===== -// Carrinho persistente (localStorage) + Drawer UI. - -import { KEYS } from '../config.js'; -import { storageGet, storageSet, escapeHTML, formatCurrency } from './utils.js'; -import { mostrarToast } from './ui.js'; - -// ===== DADOS ===== -export const Cart = { - get() { return storageGet(KEYS.CART) || []; }, - - save(cart) { storageSet(KEYS.CART, cart); }, - - add(lojaId, lojaNome, lojaWhatsapp, produto, qtd) { - const cart = this.get(); - const existing = cart.find(i => i.lojaId === lojaId && i.produto.nome_produto === produto.nome_produto); - if (existing) { - existing.qtd += qtd; - } else { - cart.push({ lojaId, lojaNome, lojaWhatsapp, produto, qtd }); - } - this.save(cart); - this.updateBadge(); - }, - - remove(index) { - const cart = this.get(); - cart.splice(index, 1); - this.save(cart); - this.updateBadge(); - }, - - updateQtd(index, delta) { - const cart = this.get(); - if (!cart[index]) return; - cart[index].qtd = Math.max(1, cart[index].qtd + delta); - this.save(cart); - this.updateBadge(); - }, - - clear() { - localStorage.removeItem(KEYS.CART); - this.updateBadge(); - }, - - total() { - return this.get().reduce((sum, i) => sum + (i.produto.preco * i.qtd), 0); - }, - - count() { - return this.get().reduce((sum, i) => sum + i.qtd, 0); - }, - - updateBadge() { - const badge = document.getElementById('cart-badge'); - const count = this.count(); - if (badge) { - badge.textContent = count > 0 ? count : ''; - badge.style.display = count > 0 ? 'flex' : 'none'; - } - const fab = document.getElementById('fab-cart'); - const fabBadge = document.getElementById('fab-cart-badge'); - if (fab) fab.style.display = count > 0 ? 'flex' : 'none'; - if (fabBadge) fabBadge.textContent = count; - } -}; - -// ===== DRAWER UI ===== - -export function toggleCarrinhoDrawer() { - const drawer = document.getElementById('cart-drawer'); - const overlay = document.getElementById('drawer-overlay'); - if (drawer.classList.contains('open')) { - fecharCarrinhoDrawer(); - } else { - renderCarrinhoDrawer(); - drawer.classList.add('open'); - overlay.classList.add('open'); - document.body.style.overflow = 'hidden'; - } -} - -export function fecharCarrinhoDrawer() { - document.getElementById('cart-drawer').classList.remove('open'); - document.getElementById('drawer-overlay').classList.remove('open'); - if (!document.querySelector('.modal-overlay.open, .auth-overlay.open, .checkout-overlay.open')) { - document.body.style.overflow = ''; - } -} - -export function renderCarrinhoDrawer() { - const cart = Cart.get(); - const body = document.getElementById('drawer-body'); - const footer = document.getElementById('drawer-footer'); - - if (cart.length === 0) { - body.innerHTML = '

    🛒

    Seu carrinho está vazio

    Adicione itens do cardápio das lojas

    '; - footer.style.display = 'none'; - return; - } - - const byStore = {}; - cart.forEach((item, idx) => { - if (!byStore[item.lojaId]) { - byStore[item.lojaId] = { nome: item.lojaNome, whatsapp: item.lojaWhatsapp, items: [] }; - } - byStore[item.lojaId].items.push({ ...item, globalIdx: idx }); - }); - - let html = ''; - Object.entries(byStore).forEach(([, store]) => { - html += '
    '; - html += '
    ' + escapeHTML(store.nome) + '
    '; - store.items.forEach(item => { - const subtotal = item.produto.preco * item.qtd; - html += '
    ' + - '
    ' + - '
    ' + escapeHTML(item.produto.nome_produto) + '
    ' + - '
    R$ ' + formatCurrency(subtotal) + '
    ' + - '
    ' + - '
    ' + - '' + - '' + parseInt(item.qtd) + '' + - '' + - '' + - '
    ' + - '
    '; - }); - html += '
    '; - }); - - body.innerHTML = html; - footer.style.display = 'block'; - document.getElementById('drawer-total').innerHTML = 'Total: R$ ' + formatCurrency(Cart.total()) + ''; -} - -export function cartUpdateQtd(idx, delta) { - Cart.updateQtd(idx, delta); - renderCarrinhoDrawer(); -} - -export function cartRemove(idx) { - Cart.remove(idx); - renderCarrinhoDrawer(); -} - -// enviarTudoWhatsApp vive em checkout.js para evitar dependência circular -export function abrirCarrinhoVazio() { - mostrarToast('🛒 Carrinho vazio!'); -} diff --git a/js/modules/checkout.js b/js/modules/checkout.js deleted file mode 100644 index e9fc7ed..0000000 --- a/js/modules/checkout.js +++ /dev/null @@ -1,167 +0,0 @@ -// ===== COMÉRCIO BES — CHECKOUT MODULE ===== - -import { Auth } from './auth.js'; -import { Cart, fecharCarrinhoDrawer, renderCarrinhoDrawer } from './cart.js'; -import { criarPedidosDoCarrinho } from './api.js'; -import { escapeHTML, formatCurrency } from './utils.js'; -import { mostrarToast } from './ui.js'; -import { abrirAuth } from './auth-ui.js'; - -// ===== WHATSAPP LINK ===== - -export function gerarLinkWhatsApp(loja, produtosSelecionados, cliente) { - const nome = cliente && cliente.nome ? cliente.nome : (Auth.getUser()?.nome || 'Cliente'); - let texto = 'Olá meu nome é ' + nome + ' vim pelo ComércioBes! e gostaria de fazer um pedido:\n'; - texto += '======================\n'; - let total = 0; - produtosSelecionados.forEach(item => { - total += item.produto.preco * item.qtd; - texto += '- ' + item.qtd + 'x ' + item.produto.nome_produto + '\n'; - }); - texto += '======================\n'; - if (cliente && cliente.rua) { - texto += cliente.rua + '\n'; - texto += (cliente.bairro || '') + '\n'; - const compNum = ((cliente.complemento || '') + ' ' + (cliente.numero || '')).trim(); - if (compNum) texto += compNum + '\n'; - if (cliente.referencia) texto += cliente.referencia + '\n'; - } - texto += '=========================\n'; - texto += 'Total: R$' + formatCurrency(total); - return 'https://wa.me/' + encodeURIComponent(loja.whatsapp) + '?text=' + encodeURIComponent(texto); -} - -// ===== CHECKOUT OVERLAY ===== - -export function abrirCheckout() { - if (!Auth.isLoggedIn()) { - mostrarToast('👤 Faça login para finalizar o pedido.'); - abrirAuth(); - return; - } - const cart = Cart.get(); - if (cart.length === 0) { mostrarToast('🛒 Carrinho vazio!'); return; } - - fecharCarrinhoDrawer(); - - const user = Auth.getUser(); - if (user) { - document.getElementById('checkout-nome').value = user.nome || ''; - document.getElementById('checkout-tel').value = user.tel || ''; - ['checkout-rua', 'checkout-numero', 'checkout-bairro', 'checkout-complemento', 'checkout-referencia'] - .forEach(id => { document.getElementById(id).value = ''; }); - } - - let html = ''; - cart.forEach(item => { - html += '
    ' + - '' + parseInt(item.qtd) + 'x ' + escapeHTML(item.produto.nome_produto) + '' + - 'R$ ' + formatCurrency(item.produto.preco * item.qtd) + '' + - '
    '; - }); - document.getElementById('checkout-items').innerHTML = html; - document.getElementById('checkout-total').innerHTML = 'Total: R$ ' + formatCurrency(Cart.total()) + ''; - - document.getElementById('checkout-overlay').classList.add('open'); - document.body.style.overflow = 'hidden'; -} - -export function fecharCheckout(e) { - if (e && e.target !== document.getElementById('checkout-overlay')) return; - document.getElementById('checkout-overlay').classList.remove('open'); - document.body.style.overflow = ''; -} - -function getCheckoutCliente() { - const nome = document.getElementById('checkout-nome').value.trim(); - const rua = document.getElementById('checkout-rua').value.trim(); - const numero = document.getElementById('checkout-numero').value.trim(); - const bairro = document.getElementById('checkout-bairro').value.trim(); - const cep = document.getElementById('checkout-cep').value.replace(/\D/g, ''); - const complemento = document.getElementById('checkout-complemento').value.trim(); - const referencia = document.getElementById('checkout-referencia').value.trim(); - const tel = document.getElementById('checkout-tel').value.trim(); - const pagamento = document.getElementById('checkout-pagamento').value; - const obs = document.getElementById('checkout-obs').value.trim(); - - if (!nome || !rua || !numero || !bairro || !cep || !tel) { - mostrarToast('⚠️ Preencha todos os campos obrigatórios.'); - return null; - } - if (cep.length !== 8) { - mostrarToast('Informe um CEP valido com 8 digitos.'); - return null; - } - return { nome, rua, numero, bairro, cep, complemento, referencia, tel, pagamento, obs }; -} - -export async function confirmarPedido(e) { - if (e) e.preventDefault(); - const cliente = getCheckoutCliente(); - if (!cliente) return false; - - const cart = Cart.get(); - if (cart.length === 0) { - mostrarToast('Carrinho vazio.'); - return false; - } - - const submitBtn = document.querySelector('#checkout-form .btn-confirmar-pedido'); - if (submitBtn) { - submitBtn.disabled = true; - submitBtn.textContent = 'Enviando...'; - } - - try { - const pedidos = await criarPedidosDoCarrinho(cart, cliente); - const codigos = pedidos.map(p => p.codigo).join(', '); - - Cart.clear(); - fecharCheckout(); - renderCarrinhoDrawer(); - mostrarToast('Pedido ' + codigos + ' enviado para a loja.'); - } catch (err) { - console.error('[Checkout] Erro ao criar pedido:', err); - mostrarToast('Nao foi possivel criar o pedido: ' + err.message); - } finally { - if (submitBtn) { - submitBtn.disabled = false; - submitBtn.textContent = 'Enviar pedido para a loja'; - } - } - - return false; -} - -export function confirmarPedidoWhatsApp() { - const cliente = getCheckoutCliente(); - if (!cliente) return; - - const cart = Cart.get(); - const byStore = {}; - cart.forEach(item => { - if (!byStore[item.lojaId]) byStore[item.lojaId] = { whatsapp: item.lojaWhatsapp, items: [] }; - byStore[item.lojaId].items.push(item); - }); - - Object.values(byStore).forEach(store => { - const link = gerarLinkWhatsApp( - { whatsapp: store.whatsapp }, - store.items.map(i => ({ produto: i.produto, qtd: i.qtd })), - cliente - ); - window.open(link, '_blank', 'noopener,noreferrer'); - }); - - Cart.clear(); - fecharCheckout(); - renderCarrinhoDrawer(); - mostrarToast('Pedido enviado via WhatsApp. Ele nao entra no historico da plataforma.'); -} - -export function enviarTudoWhatsApp() { - const cart = Cart.get(); - if (cart.length === 0) { mostrarToast('🛒 Carrinho vazio!'); return; } - abrirCheckout(); - mostrarToast('📋 Preencha seus dados e clique em "Enviar via WhatsApp".'); -} diff --git a/js/modules/orders.js b/js/modules/orders.js deleted file mode 100644 index 31fbe04..0000000 --- a/js/modules/orders.js +++ /dev/null @@ -1,21 +0,0 @@ -// ===== COMÉRCIO BES — ORDERS MODULE ===== - -import { KEYS } from '../config.js'; -import { storageGet, storageSet } from './utils.js'; - -export const Orders = { - get() { return storageGet(KEYS.ORDERS) || []; }, - - create(orderData) { - const orders = this.get(); - const order = { - id: 'PED-' + Date.now().toString(36).toUpperCase(), - ...orderData, - status: 'pendente', - criadoEm: new Date().toISOString() - }; - orders.unshift(order); - storageSet(KEYS.ORDERS, orders); - return order; - } -}; diff --git a/js/modules/utils.js b/js/modules/utils.js index 87df736..67e8e66 100644 --- a/js/modules/utils.js +++ b/js/modules/utils.js @@ -12,6 +12,23 @@ export function escapeHTML(str) { .replace(/'/g, '''); } +// Aceita apenas imagens servidas pelo proprio app ou pela conta Cloudinary. +// Retorna uma URL normalizada; outros esquemas e hosts sao rejeitados. +export function safeImageUrl(value) { + if (typeof value !== 'string') return null; + + try { + const url = new URL(value, window.location.origin); + const isLocal = url.origin === window.location.origin && ( + url.pathname.startsWith('/images/') || url.pathname.startsWith('/uploads/') + ); + const isCloudinary = url.protocol === 'https:' && url.hostname === 'res.cloudinary.com'; + return isLocal || isCloudinary ? url.href : null; + } catch { + return null; + } +} + // ===== STORAGE ===== export function storageGet(key) { try { return JSON.parse(localStorage.getItem(key)) || null; } catch { return null; } diff --git a/js/render/cards.js b/js/render/cards.js index 991861d..0e712ef 100644 --- a/js/render/cards.js +++ b/js/render/cards.js @@ -2,7 +2,7 @@ // Circular com modules/search.js — seguro (só em function bodies). import { ITEMS_POR_PAGINA } from '../config.js'; -import { escapeHTML, gerarStars, formatCurrency } from '../modules/utils.js'; +import { escapeHTML, gerarStars, safeImageUrl } from '../modules/utils.js'; import { Favorites } from '../modules/favorites.js'; import { observarLazyImages } from '../modules/ui.js'; import { state } from '../modules/state.js'; @@ -16,8 +16,10 @@ export function criarCard(c) { const destaqueBadge = c.destaque ? 'Destaque' : ''; const temCatalogo = c.catalogo && c.catalogo.length > 0; const isFav = Favorites.isFav(c.id); - const heroFoto = (c.fotos || []).find(f => f.startsWith('http') || f.startsWith('/')); - const imgStyle = heroFoto ? ' style="background-image:url(' + heroFoto + ');background-size:cover;background-position:center;"' : ''; + const heroFoto = (c.fotos || []).map(safeImageUrl).find(Boolean); + const imgStyle = heroFoto + ? ' style="background-image:url("' + escapeHTML(heroFoto) + '");background-size:cover;background-position:center;"' + : ''; return '
    ' + '
    ' + diff --git a/js/render/modal.js b/js/render/modal.js index d2fecfd..0396ef9 100644 --- a/js/render/modal.js +++ b/js/render/modal.js @@ -1,19 +1,16 @@ // ===== COMÉRCIO BES — RENDER: MODAL ===== -// Perfil da loja, catálogo, avaliação e carrinho modal. +// Perfil da loja, catalogo informativo e avaliacao. -import { escapeHTML, gerarStars, formatCurrency } from '../modules/utils.js'; +import { escapeHTML, gerarStars, formatCurrency, safeImageUrl } from '../modules/utils.js'; import { registrarEstatistica, enviarAvaliacaoApi } from '../modules/api.js'; import { Auth } from '../modules/auth.js'; -import { Cart } from '../modules/cart.js'; import { Favorites } from '../modules/favorites.js'; import { mostrarToast } from '../modules/ui.js'; -import { abrirCheckout } from '../modules/checkout.js'; import { state } from '../modules/state.js'; import { GOOGLE_MAPS_KEY } from '../config.js'; // ===== STATE LOCAL ===== let avaliacao = 0; -let carrinhoModal = {}; let lightboxFotos = []; let lightboxIdx = 0; @@ -24,14 +21,13 @@ export function abrirModal(id) { if (!c) return; state.comercioAtual = c; avaliacao = 0; - carrinhoModal = {}; resetStars(); registrarEstatistica(c.id, 'visita'); // Hero foto ou emoji no cabeçalho const modalImgEl = document.getElementById('modal-img'); - const heroFoto = (c.fotos || []).find(f => f.startsWith('http') || f.startsWith('/')); + const heroFoto = (c.fotos || []).map(safeImageUrl).find(Boolean); if (heroFoto) { modalImgEl.style.backgroundImage = 'url(' + heroFoto + ')'; modalImgEl.classList.add('has-photo'); @@ -55,7 +51,7 @@ export function abrirModal(id) { document.getElementById('modal-info').innerHTML = '' + '' + - '' + + (c.tel ? '' : '') + '' + (c.promo ? '' : ''); @@ -65,16 +61,20 @@ export function abrirModal(id) { '(' + parseInt(c.visitas) + ' avaliações)'; // Fotos — detecta URL real vs emoji placeholder - lightboxFotos = (c.fotos || []).filter(f => f.startsWith('http') || f.startsWith('/')); - document.getElementById('modal-fotos').innerHTML = (c.fotos || []).map((f, idx) => { - const isUrl = f.startsWith('http') || f.startsWith('/'); - return '
    ' + - (isUrl - ? 'Foto ' + (idx + 1) + '' + lightboxFotos = (c.fotos || []).map(safeImageUrl).filter(Boolean); + const fotosEl = document.getElementById('modal-fotos'); + fotosEl.innerHTML = (c.fotos || []).map((f, idx) => { + const safeUrl = safeImageUrl(f); + return '
    '; + ''; }).join(''); + fotosEl.querySelectorAll('[data-lightbox-index]').forEach(button => { + button.addEventListener('click', () => abrirLightbox(Number(button.dataset.lightboxIndex))); + }); // Mapa embed — Google Maps (se tiver key) ou OpenStreetMap (gratuito) const mapaEl = document.getElementById('modal-mapa'); @@ -95,30 +95,19 @@ export function abrirModal(id) { catalogoContainer.style.display = 'block'; catalogoContainer.innerHTML = '' + - '

    Cardápio / Produtos

    ' + + '

    Produtos em destaque

    ' + '
    ' + - c.catalogo.map((prod, idx) => + c.catalogo.map(prod => '
    ' + '
    ' + '
    ' + escapeHTML(prod.nome_produto) + '
    ' + '
    ' + escapeHTML(prod.descricao) + '
    ' + '
    R$ ' + formatCurrency(prod.preco) + '
    ' + '
    ' + - '
    ' + - '' + - '0' + - '' + - '
    ' + '
    ' ).join('') + '
    ' + - ''; + '

    Consulte disponibilidade e condicoes diretamente com o comercio.

    '; } else { catalogoContainer.style.display = 'none'; catalogoContainer.innerHTML = ''; @@ -139,98 +128,6 @@ export function fecharModal(e) { document.body.style.overflow = ''; } -// ===== CARRINHO MODAL ===== - -export function alterarQtdModal(idx, delta) { - const atual = carrinhoModal[idx] || 0; - const novo = Math.max(0, atual + delta); - carrinhoModal[idx] = novo; - const el = document.getElementById('qtd-' + idx); - if (el) el.textContent = novo; - atualizarResumoModal(); -} - -function atualizarResumoModal() { - if (!state.comercioAtual || !state.comercioAtual.catalogo) return; - const itens = Object.entries(carrinhoModal) - .filter(([, qtd]) => qtd > 0) - .map(([idx, qtd]) => ({ produto: state.comercioAtual.catalogo[parseInt(idx)], qtd })); - - const resumoEl = document.getElementById('carrinho-resumo'); - const totalEl = document.getElementById('carrinho-total'); - if (itens.length === 0) { resumoEl.style.display = 'none'; return; } - - const total = itens.reduce((sum, i) => sum + (i.produto.preco * i.qtd), 0); - resumoEl.style.display = 'block'; - totalEl.innerHTML = '' + itens.length + ' ' + (itens.length === 1 ? 'item' : 'itens') + ' · Total: R$ ' + formatCurrency(total) + ''; -} - -function mostrarAuthGateModal() { - const container = document.getElementById('modal-catalogo'); - if (!container) return; - const existing = document.getElementById('auth-gate-modal'); - if (existing) { existing.classList.add('visible'); existing.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); return; } - - const gate = document.createElement('div'); - gate.id = 'auth-gate-modal'; - gate.className = 'auth-gate-modal'; - gate.innerHTML = - '

    Faça login para fazer pedidos

    ' + - '

    Crie sua conta gratuita em menos de 1 minuto

    ' + - 'Criar conta gratuita' + - ''; - container.appendChild(gate); - requestAnimationFrame(() => gate.classList.add('visible')); - gate.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); -} - -export function adicionarAoCarrinho() { - if (!state.comercioAtual || !state.comercioAtual.catalogo) return; - if (!Auth.isLoggedIn()) { mostrarAuthGateModal(); return; } - - const itens = Object.entries(carrinhoModal) - .filter(([, qtd]) => qtd > 0) - .map(([idx, qtd]) => ({ produto: state.comercioAtual.catalogo[parseInt(idx)], qtd })); - - if (itens.length === 0) { mostrarToast('🛒 Selecione pelo menos um item!'); return; } - - itens.forEach(i => { - Cart.add(state.comercioAtual.id, state.comercioAtual.nome, state.comercioAtual.whatsapp, i.produto, i.qtd); - }); - - mostrarToast('✅ ' + itens.length + ' item(ns) adicionado(s) ao carrinho!'); - carrinhoModal = {}; - state.comercioAtual.catalogo.forEach((_, idx) => { - const el = document.getElementById('qtd-' + idx); - if (el) el.textContent = '0'; - }); - atualizarResumoModal(); -} - -export function enviarPedidoWhatsApp() { - if (!state.comercioAtual || !state.comercioAtual.catalogo) return; - if (!Auth.isLoggedIn()) { mostrarAuthGateModal(); return; } - - const produtos = Object.entries(carrinhoModal) - .filter(([, qtd]) => qtd > 0) - .map(([idx, qtd]) => ({ produto: state.comercioAtual.catalogo[parseInt(idx)], qtd })); - - if (produtos.length === 0) { mostrarToast('🛒 Selecione pelo menos um item!'); return; } - - produtos.forEach(i => { - Cart.add(state.comercioAtual.id, state.comercioAtual.nome, state.comercioAtual.whatsapp, i.produto, i.qtd); - }); - carrinhoModal = {}; - state.comercioAtual.catalogo.forEach((_, idx) => { - const el = document.getElementById('qtd-' + idx); - if (el) el.textContent = '0'; - }); - atualizarResumoModal(); - fecharModal(); - abrirCheckout(); - mostrarToast('📋 Preencha seus dados e clique em "Enviar via WhatsApp".'); -} - // ===== LIGHTBOX ===== export function abrirLightbox(idx) { @@ -277,9 +174,7 @@ export async function enviarAvaliacao() { if (state.apiDisponivel && state.comercioAtual.slug) { try { - const data = await enviarAvaliacaoApi( - state.comercioAtual.slug, avaliacao, comentario, Auth.getToken() - ); + const data = await enviarAvaliacaoApi(state.comercioAtual.slug, avaliacao, comentario); state.comercioAtual.rating = data.mediaAtual; state.comercioAtual.totalAvaliacoes = data.totalAvaliacoes; document.getElementById('modal-stars-big').innerHTML = diff --git a/js/render/orders-ui.js b/js/render/orders-ui.js deleted file mode 100644 index 62da574..0000000 --- a/js/render/orders-ui.js +++ /dev/null @@ -1,45 +0,0 @@ -// ===== COMÉRCIO BES — RENDER: ORDERS UI ===== - -import { Auth } from '../modules/auth.js'; -import { Orders } from '../modules/orders.js'; -import { escapeHTML, formatCurrency } from '../modules/utils.js'; -import { fecharContaMenu, abrirAuth } from '../modules/auth-ui.js'; - -export function abrirPedidos() { - fecharContaMenu(); - if (!Auth.isLoggedIn()) { abrirAuth(); return; } - - const orders = Orders.get(); - const container = document.getElementById('orders-list'); - - if (orders.length === 0) { - container.innerHTML = '

    📋

    Nenhum pedido ainda

    '; - } else { - container.innerHTML = orders.map(o => { - const data = new Date(o.criadoEm); - const dataStr = data.toLocaleDateString('pt-BR') + ' ' + data.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); - const statusCor = o.status === 'pendente' ? '#EA580C' : o.status === 'confirmado' ? '#10B981' : '#aaa'; - return '
    ' + - '
    ' + - '' + escapeHTML(o.id) + '' + - '' + escapeHTML(o.status).toUpperCase() + '' + - '
    ' + - '
    ' + escapeHTML(dataStr) + '
    ' + - '
    ' + - o.items.map(i => '
    ' + parseInt(i.qtd) + 'x ' + escapeHTML(i.produto.nome_produto) + ' (' + escapeHTML(i.lojaNome) + ')
    ').join('') + - '
    ' + - '
    Total: R$ ' + formatCurrency(o.total) + '
    ' + - '
    Pagamento: ' + escapeHTML(o.pagamento) + '
    ' + - '
    '; - }).join(''); - } - - document.getElementById('orders-overlay').classList.add('open'); - document.body.style.overflow = 'hidden'; -} - -export function fecharPedidos(e) { - if (e && e.target !== document.getElementById('orders-overlay')) return; - document.getElementById('orders-overlay').classList.remove('open'); - document.body.style.overflow = ''; -} diff --git a/sw.js b/sw.js index 8e5cf80..0103d50 100644 --- a/sw.js +++ b/sw.js @@ -1,5 +1,5 @@ // ===== SERVICE WORKER — Comércio BES ===== -const CACHE_NAME = 'comercio-bes-v8'; +const CACHE_NAME = 'comercio-bes-v9'; const OFFLINE_ASSETS = [ './', './index.html', @@ -10,21 +10,17 @@ const OFFLINE_ASSETS = [ './js/modules/utils.js', './js/modules/api.js', './js/modules/auth.js', - './js/modules/cart.js', './js/modules/favorites.js', - './js/modules/orders.js', './js/modules/merchants.js', './js/modules/theme.js', './js/modules/ui.js', './js/modules/map.js', './js/modules/search.js', './js/modules/auth-ui.js', - './js/modules/checkout.js', './js/modules/merchant-ui.js', './js/render/promotions.js', './js/render/cards.js', './js/render/favorites.js', - './js/render/orders-ui.js', './js/render/modal.js', './data/data.json', './manifest.json', @@ -49,12 +45,21 @@ self.addEventListener('activate', event => { self.clients.claim(); }); -// Fetch — network-first for data, cache-first for assets +// Fetch — somente assets publicos entram no Cache Storage. +// APIs e areas autenticadas devem sempre ir para a rede para evitar vazamento +// de dados entre sessoes no mesmo navegador. self.addEventListener('fetch', event => { const url = new URL(event.request.url); - // Skip non-GET and cross-origin (except fonts/tiles) + // Skip non-GET and qualquer endpoint privado/dinamico. if (event.request.method !== 'GET') return; + if (url.origin === self.location.origin && ( + url.pathname.startsWith('/api/') || + url.pathname === '/api' || + url.pathname.startsWith('/minha-conta') || + url.pathname.startsWith('/html/login') || + url.pathname.startsWith('/html/cadastro') + )) return; // Network-first for data.json (always try fresh data) if (url.pathname.endsWith('/data.json')) { @@ -70,13 +75,14 @@ self.addEventListener('fetch', event => { return; } - // Cache-first for everything else + // Cache-first apenas para assets declarados no shell ou recursos estaticos. event.respondWith( caches.match(event.request).then(cached => { if (cached) return cached; return fetch(event.request).then(response => { - // Only cache same-origin and fonts - if (response.ok && (url.origin === self.location.origin || url.hostname.includes('googleapis') || url.hostname.includes('gstatic'))) { + const isStaticAsset = url.origin === self.location.origin && /\.(?:css|js|svg|png|jpe?g|webp|avif|woff2?)$/i.test(url.pathname); + const isApprovedFont = url.hostname === 'fonts.googleapis.com' || url.hostname === 'fonts.gstatic.com'; + if (response.ok && (isStaticAsset || isApprovedFont)) { const clone = response.clone(); caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone)); }