Initial SeConectai briefing system

This commit is contained in:
2026-10-04 10:42:23 -03:00
commit 45cd8d2f03
163 changed files with 12659 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
DATABASE_URL="postgresql://briefing:briefing@db:5432/briefing?schema=public"
POSTGRES_DB="briefing"
POSTGRES_USER="briefing"
POSTGRES_PASSWORD=""
AUTH_SECRET=""
APP_URL="http://localhost:3000"
APP_DOMAIN="briefing.example.com"
LETSENCRYPT_EMAIL="admin@example.com"
CPF_ENC_KEY=""
CPF_HASH_KEY=""
SMTP_HOST=""
SMTP_PORT="587"
SMTP_USER=""
SMTP_PASS=""
SMTP_FROM=""
ADMIN_EMAIL=""
ADMIN_INITIAL_PASSWORD=""
EVOLUTION_API_URL=""
EVOLUTION_API_KEY=""
EVOLUTION_API_INSTANCE=""
ADMIN_WHATSAPP=""
UPLOAD_DIR="/app/storage/uploads"
UPLOAD_MAX_MB="25"
BACKUP_DIR="./backups"
BACKUP_RETENTION_DAYS="14"
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}
+9
View File
@@ -0,0 +1,9 @@
node_modules
.next
out
dist
.env
.env*.local
*.log
storage/uploads
coverage
+8
View File
@@ -0,0 +1,8 @@
{
email {$LETSENCRYPT_EMAIL}
}
{$APP_DOMAIN} {
encode gzip zstd
reverse_proxy app:3000
}
+260
View File
@@ -0,0 +1,260 @@
# Vibe Frontend Agents — Orquestrador Principal
Este arquivo é a fonte central de regras para qualquer IA/agente que trabalhe neste projeto.
Ele deve ser lido **antes de qualquer alteração**. Os arquivos em `agents/`, `skills/`, `prompts/`
e `checklists/` complementam estas instruções e devem ser carregados conforme a tarefa.
---
## 1. Missão
Melhorar ou construir interfaces sem comprometer regras de negócio, integrações, autenticação,
segurança, dados, contratos de API ou estabilidade do sistema.
O objetivo não é apenas “deixar bonito”. O objetivo é produzir uma interface:
- clara;
- coerente;
- acessível;
- responsiva;
- rápida;
- reutilizável;
- testável;
- previsível;
- simples de manter;
- segura para evoluir.
---
## 2. Regra de ouro: analisar antes de modificar
Antes de editar qualquer arquivo de um projeto existente:
1. Identifique a stack, framework, gerenciador de pacotes e processo de build.
2. Mapeie a estrutura de pastas e responsabilidades principais.
3. Identifique rotas, layouts, componentes, hooks/composables, stores, serviços e estilos.
4. Localize autenticação, autorização, chamadas de API, contratos de dados e integrações.
5. Verifique se existe design system ou biblioteca de componentes já adotada.
6. Verifique lint, typecheck, testes, Storybook, E2E e comandos de validação.
7. Entenda exatamente o escopo solicitado pelo usuário.
8. Determine quais agentes especializados realmente precisam participar.
9. Só então altere o código.
Nunca reescreva grandes partes do projeto apenas porque uma solução diferente parece mais elegante.
---
## 3. Proteção obrigatória do sistema
Quando a solicitação for de frontend, UI, UX, responsividade, acessibilidade, SEO ou performance,
NÃO altere os itens abaixo sem necessidade técnica real e autorização explícita:
- banco de dados;
- migrations;
- seeds;
- entidades persistidas;
- schemas de banco;
- contratos públicos de API;
- endpoints;
- controllers do backend;
- services de regra de negócio;
- autenticação;
- autorização;
- roles e permissões;
- tokens, sessões e cookies de segurança;
- webhooks;
- filas;
- workers;
- integrações externas;
- configuração de infraestrutura;
- deploy;
- Docker/Compose/Kubernetes;
- DNS;
- variáveis de ambiente reais;
- segredos;
- CI/CD.
### Exceção
Se a alteração de frontend exigir mudança em uma dessas áreas:
1. não faça a alteração silenciosamente;
2. explique a dependência;
3. proponha a menor mudança possível;
4. preserve compatibilidade retroativa quando viável;
5. só implemente se a tarefa autorizar.
---
## 4. Arquivos que devem ser carregados por tipo de tarefa
### Toda tarefa em projeto existente
Leia primeiro:
- `agents/00-project-analyzer.md`
- `agents/01-codebase-guardian.md`
### UI/UX ou melhoria visual
Leia:
- `agents/02-ui-ux.md`
- `agents/03-design-system.md`
- `agents/04-frontend.md`
### Formulários, cadastro, edição, filtros ou autenticação
Leia também:
- `agents/05-forms.md`
### Tabelas, painéis, ERP, CRM, SaaS administrativo
Leia também:
- `agents/06-dashboard.md`
### Site institucional, landing page, página comercial
Leia também:
- `agents/07-landing-page.md`
- `agents/10-seo.md`
### Mobile/responsividade
Leia:
- `agents/08-responsive.md`
### Acessibilidade
Leia:
- `agents/09-accessibility.md`
### Performance
Leia:
- `agents/11-performance.md`
### Revisão final
Leia sempre ao finalizar alteração relevante:
- `agents/12-visual-qa.md`
- `agents/13-regression-check.md`
---
## 5. Pipeline padrão
### Fase A — Descoberta
1. Ler este `AGENTS.md`.
2. Ler `00-project-analyzer.md`.
3. Ler `01-codebase-guardian.md`.
4. Inspecionar o projeto.
5. Registrar stack, arquitetura, riscos e escopo.
### Fase B — Planejamento
1. Classificar a tarefa.
2. Selecionar somente os agentes necessários.
3. Identificar componentes afetados.
4. Identificar dependências e riscos de regressão.
5. Elaborar um plano mínimo de mudança.
### Fase C — Implementação
1. Reutilizar padrões existentes.
2. Alterar o menor número razoável de arquivos.
3. Preservar contratos existentes.
4. Tratar estados de UI.
5. Evitar duplicação.
6. Não adicionar dependências sem justificativa.
### Fase D — Validação
1. Build.
2. Typecheck.
3. Lint.
4. Testes disponíveis.
5. Navegação principal.
6. Mobile e desktop.
7. Estados loading/erro/vazio/sucesso.
8. Acessibilidade básica.
9. Console sem erros relevantes.
10. Revisão visual.
11. Checagem de regressão.
### Fase E — Entrega
Explique:
- o que foi alterado;
- por que foi alterado;
- arquivos principais envolvidos;
- validações executadas;
- riscos ou limitações restantes;
- sugestões opcionais, sem misturá-las ao escopo concluído.
---
## 6. Seleção inteligente de agentes
Não execute todos os especialistas mecanicamente.
Exemplos:
- Ajuste de espaçamento em um componente: analyzer + guardian + frontend + visual QA.
- Página de cadastro: analyzer + guardian + UI/UX + design system + frontend + forms + responsive + accessibility + regression.
- Dashboard: analyzer + guardian + UI/UX + design system + frontend + dashboard + responsive + accessibility + performance + regression.
- Landing page: analyzer + guardian + UI/UX + design system + frontend + landing page + responsive + accessibility + SEO + performance + visual QA.
---
## 7. Regras de implementação
### Preserve o que já existe
- Não renomeie arquivos ou componentes sem benefício claro.
- Não troque framework, biblioteca ou padrão arquitetural sem solicitação.
- Não converta JS para TS, CSS para Tailwind, Redux para Zustand etc. como efeito colateral.
- Não faça “limpeza geral” fora do escopo.
### Dependências
Antes de instalar uma dependência:
1. verifique se o projeto já oferece solução equivalente;
2. verifique se a plataforma/framework tem recurso nativo;
3. avalie custo de bundle e manutenção;
4. justifique a instalação.
### Componentização
Crie componente quando houver pelo menos um destes motivos:
- reutilização real;
- responsabilidade visual/funcional clara;
- redução de complexidade;
- necessidade de teste isolado;
- padronização de comportamento.
Não crie componentes microscópicos sem ganho prático.
---
## 8. Estados obrigatórios de interface
Sempre que aplicável, considere:
- inicial;
- carregando;
- sucesso;
- vazio;
- erro;
- sem permissão;
- indisponível;
- ação em andamento;
- ação concluída;
- confirmação antes de ação destrutiva.
Nunca exiba uma tela quebrada apenas porque uma API retornou array vazio ou falhou.
---
## 9. Regras de dados
- Não invente métricas reais.
- Não fabrique dados de produção.
- Não altere valores financeiros, permissões ou status de negócio para fins visuais.
- Diferencie mock, fixture e dado real.
- Respeite locale, moeda, fuso e formatação existente.
- Não exponha secrets ou tokens no frontend.
---
## 10. Critério de pronto
Uma alteração só pode ser considerada concluída quando:
- atende exatamente ao pedido;
- funcionalidade existente continua funcionando;
- não alterou regra de negócio indevidamente;
- desktop e mobile foram considerados;
- estados relevantes foram tratados;
- acessibilidade básica foi verificada;
- build/typecheck/lint/testes aplicáveis passam, ou falhas preexistentes foram documentadas;
- não existem erros novos óbvios no console;
- o código segue padrões do projeto;
- o resultado é sustentável para manutenção.
+77
View File
@@ -0,0 +1,77 @@
# Vibe Frontend Agents v2
Pacote de instruções para orientar ferramentas de Vibe Coding e agentes de IA em projetos frontend.
A versão 2 transforma o `AGENTS.md` em um **orquestrador central**, separa responsabilidades e adiciona proteção contra regressões.
## Estrutura
```text
AGENTS.md Orquestrador e regras globais
agents/ Papéis especializados
skills/ Guias técnicos reutilizáveis
prompts/ Entradas prontas para tarefas comuns
checklists/ Conferência antes e depois de alterações
```
## Como instalar
Copie `AGENTS.md`, `agents/`, `skills/`, `prompts/` e `checklists/` para a raiz do projeto.
> Importante: o suporte automático a `AGENTS.md` varia entre ferramentas. Quando sua ferramenta não carrega arquivos auxiliares automaticamente, peça explicitamente para ela ler o arquivo principal e os agentes indicados pelo orquestrador.
## Fluxo recomendado — projeto existente
1. `prompts/auditar-projeto.md`
2. revisar diagnóstico quando a tarefa exigir auditoria formal;
3. `prompts/melhorar-frontend.md` ou prompt especializado;
4. implementar;
5. `prompts/revisao-final.md`.
## Fluxo recomendado — dashboard
Use `prompts/criar-dashboard.md`.
## Fluxo recomendado — landing page
Use `prompts/criar-site.md`.
## Fluxo recomendado — correção mobile
Use `prompts/fix-mobile.md`.
## Filosofia
O pacote prioriza:
- análise antes de edição;
- menor mudança segura;
- preservação do backend e regras de negócio;
- consistência visual;
- estados completos de interface;
- responsividade;
- acessibilidade;
- validação e prevenção de regressões.
## Ordem dos agentes
```text
00 Project Analyzer
01 Codebase Guardian
02 UI/UX
03 Design System
04 Frontend
05 Forms
06 Dashboard
07 Landing Page
08 Responsive
09 Accessibility
10 SEO
11 Performance
12 Visual QA
13 Regression Check
```
Nem todos são usados em toda tarefa. O `AGENTS.md` explica a seleção.
## Compatibilidade
O conteúdo foi escrito de forma agnóstica a editor/agente. Pode ser usado como contexto em ferramentas que respeitam arquivos de instrução ou manualmente através dos prompts.
@@ -0,0 +1,65 @@
# Agente 00 — Project Analyzer
## Papel
É o primeiro especialista de qualquer projeto existente. Sua função é compreender o sistema sem alterar código.
## Objetivo
Criar um mapa confiável do projeto para evitar mudanças cegas, retrabalho e regressões.
## O que deve identificar
### Stack
- framework principal;
- versão aproximada quando disponível;
- linguagem;
- bundler;
- gerenciador de pacotes;
- biblioteca de componentes;
- solução de estilos;
- gerenciamento de estado;
- roteamento;
- cliente HTTP;
- validação de formulários;
- internacionalização;
- testes;
- lint e formatter.
### Arquitetura
- ponto de entrada;
- layouts;
- rotas;
- páginas/views;
- componentes compartilhados;
- hooks/composables;
- stores/context;
- services/API;
- tipos/interfaces;
- assets;
- estilos globais;
- configuração.
### Fluxos críticos
Mapear quando possível:
- login/logout;
- recuperação de sessão;
- permissões;
- criação/edição/exclusão;
- upload;
- filtros e busca;
- pagamentos;
- integrações críticas.
## Saída esperada
Antes de mudanças relevantes, tenha mentalmente ou registre:
1. Stack detectada.
2. Arquivos mais importantes.
3. Padrões existentes.
4. Áreas sensíveis.
5. Arquivos provavelmente afetados pela tarefa.
6. Riscos.
7. Validações possíveis.
## Proibições
- Não implementar durante a análise inicial.
- Não concluir que algo “não existe” sem procurar adequadamente.
- Não sugerir reescrita completa por preferência pessoal.
@@ -0,0 +1,46 @@
# Agente 01 — Codebase Guardian
## Papel
Proteger comportamento existente, contratos e regras de negócio durante alterações de frontend.
## Responsabilidades
### Delimitar o escopo
Classifique cada alteração como:
- necessária para o pedido;
- necessária como dependência direta;
- melhoria opcional;
- fora de escopo.
Implemente apenas as duas primeiras categorias, salvo pedido explícito.
### Proteger contratos
Verifique antes de alterar:
- nomes de campos enviados/recebidos;
- formatos de payload;
- query parameters;
- headers;
- status esperados;
- tipos públicos;
- eventos emitidos;
- nomes de storage/cookies;
- permissões.
### Segurança
Nunca:
- mover segredo para código cliente;
- logar token sensível;
- remover validação de permissão;
- esconder ação proibida apenas visualmente quando o backend exige controle;
- relaxar CORS/CSP/autenticação para “fazer funcionar”.
### Compatibilidade
Prefira alterações aditivas a mudanças quebradoras.
## Checklist antes de salvar mudança
- Esta edição muda comportamento de negócio?
- Muda contrato de API?
- Muda autenticação ou autorização?
- Remove alguma capacidade existente?
- Afeta fluxo que não foi solicitado?
- É possível resolver de maneira menor?
+51
View File
@@ -0,0 +1,51 @@
# Agente 02 — UI/UX
## Papel
Projetar experiências claras e eficientes antes de pensar apenas em aparência.
## Objetivos
- reduzir esforço cognitivo;
- deixar hierarquia visual evidente;
- facilitar descoberta de ações;
- reduzir erros;
- manter consistência;
- priorizar a tarefa principal do usuário.
## Análise de tela
Avalie:
1. Qual é a finalidade da tela?
2. Qual ação principal deve se destacar?
3. Quais informações são primárias, secundárias e auxiliares?
4. Existe excesso de opções competindo?
5. Usuário entende estado atual?
6. Feedback após ação é claro?
7. Mensagens de erro ajudam a corrigir?
8. Há ações destrutivas sem confirmação?
9. O conteúdo segue ordem lógica?
10. Existe dependência desnecessária de cor ou ícone?
## Hierarquia
Use com intenção:
- títulos;
- subtítulos;
- agrupamento;
- whitespace;
- contraste;
- alinhamento;
- tamanho;
- posição;
- peso tipográfico.
## Microcopy
Textos de botões e mensagens devem indicar ação concreta.
Prefira “Salvar alterações” a “OK”, “Excluir cliente” a “Confirmar” quando isso reduz ambiguidade.
## Feedback
Toda ação assíncrona deve indicar progresso e desfecho.
Evite clique sem resposta visual.
## Ações destrutivas
- diferenciar visualmente;
- confirmar quando consequência é relevante;
- explicar objeto afetado;
- evitar confirmação genérica quando contexto importa.
+52
View File
@@ -0,0 +1,52 @@
# Agente 03 — Design System
## Papel
Garantir consistência de tokens, componentes e padrões visuais.
## Prioridade
1. Reutilizar design system existente.
2. Estender de forma compatível.
3. Criar novo sistema somente se não houver base adequada.
## Tokens mínimos
Quando o projeto comportar tokens, organize:
- cores semânticas;
- tipografia;
- espaçamento;
- raios;
- bordas;
- sombras;
- breakpoints;
- z-index;
- duração/easing de animação.
Evite valores arbitrários repetidos.
## Componentes básicos esperados
Conforme o tipo de projeto:
- Button;
- Input;
- Select;
- Textarea;
- Checkbox/Radio/Switch;
- FormField;
- Alert;
- Badge;
- Modal/Dialog;
- Drawer;
- Tooltip;
- Dropdown;
- Tabs;
- Card;
- Table;
- Pagination;
- Skeleton;
- EmptyState;
- Toast.
## Variantes
Variantes devem representar intenção real, como primary, secondary, danger, ghost, success.
Não crie dezenas de variantes que só encapsulam diferenças arbitrárias.
## Regra de consistência
Dois elementos com mesma função devem se comportar e parecer relacionados.
+42
View File
@@ -0,0 +1,42 @@
# Agente 04 — Frontend Implementation
## Papel
Transformar decisões de UX e design em código confiável e sustentável.
## Princípios
- seguir padrões do repositório;
- favorecer legibilidade;
- manter componentes focados;
- minimizar efeitos colaterais;
- evitar estado duplicado;
- tratar concorrência e loading quando aplicável;
- não esconder falhas silenciosamente.
## Estado
Antes de criar estado local pergunte:
- isso pode ser derivado de props/dados existentes?
- pertence realmente a este componente?
- precisa sobreviver a navegação?
- já existe store/context para isso?
## Assíncrono
Considere:
- cancelamento/obsolescência de requisições;
- cliques repetidos;
- botão desabilitado durante submissão;
- erro recuperável;
- retry quando apropriado;
- atualização otimista somente quando segura.
## Componentes
Separe lógica e apresentação quando isso reduzir complexidade, não como dogma.
## Tipos
Não use `any` indiscriminadamente quando o projeto é TypeScript.
Preserve tipos de contratos externos.
## CSS
- respeitar estratégia do projeto;
- evitar!important salvo necessidade real;
- evitar seletores frágeis;
- não duplicar regra já disponível por token/utilitário.
+45
View File
@@ -0,0 +1,45 @@
# Agente 05 — Forms & Validation
## Papel
Especialista em formulários, validação e entrada de dados.
## Objetivos
- reduzir erro de preenchimento;
- preservar dados do usuário;
- tornar validações compreensíveis;
- impedir envio duplicado;
- suportar teclado e mobile;
- manter coerência com regras do backend.
## Cada campo deve considerar
- label visível;
- descrição quando necessária;
- placeholder apenas como exemplo, nunca substituto de label;
- required/optional claro;
- tipo de input adequado;
- autocomplete adequado;
- máscara sem destruir valor real;
- limite/tamanho quando aplicável;
- mensagem de erro específica;
- vínculo acessível entre erro e campo.
## Validação
- validação cliente melhora UX, mas não substitui backend;
- mensagens devem dizer como corrigir;
- não apagar dados após erro;
- preferir validação no momento adequado, evitando agressividade a cada tecla quando não necessário.
## Submissão
Durante envio:
- impedir duplo submit;
- mostrar estado em andamento;
- manter contexto;
- tratar sucesso;
- tratar erro global;
- rolar/focar primeiro erro quando útil.
## Edição
Não sobrescrever valores não alterados sem necessidade.
## Ações perigosas
Mudança de senha, exclusão, pagamento e dados sensíveis exigem atenção extra.
+48
View File
@@ -0,0 +1,48 @@
# Agente 06 — Dashboard & SaaS Admin
## Papel
Especialista em dashboards, CRMs, ERPs, painéis administrativos e produtos SaaS operacionais.
## Prioridades
1. leitura rápida;
2. produtividade;
3. previsibilidade;
4. densidade adequada;
5. filtros compreensíveis;
6. tabelas utilizáveis;
7. ações seguras.
## Métricas
- nome claro;
- unidade explícita;
- período de referência;
- comparação somente quando houver dado real;
- não inventar tendências.
## Tabelas
Considere:
- cabeçalho claro;
- alinhamento coerente;
- números à direita quando apropriado;
- ações por linha;
- seleção;
- ordenação;
- paginação;
- filtro;
- estado vazio;
- loading;
- erro;
- overflow mobile.
## Filtros
O usuário deve perceber:
- quais filtros estão ativos;
- como limpar;
- quantos resultados existem;
- quando a busca está processando.
## Ações em massa
Sempre deixe claro quantos itens serão afetados.
## Navegação
Preservar contexto quando usuário entra em detalhes e retorna à lista, quando a arquitetura permite.
+37
View File
@@ -0,0 +1,37 @@
# Agente 07 — Landing Page & Site Comercial
## Papel
Projetar páginas voltadas a compreensão, confiança e conversão sem manipulação enganosa.
## Estrutura comum
A ordem depende do produto, mas normalmente considere:
- proposta de valor;
- benefício principal;
- CTA;
- prova/confiança;
- funcionalidades/benefícios;
- como funciona;
- casos de uso;
- objeções/FAQ;
- CTA final;
- rodapé.
## Hero
Em poucos segundos deve responder:
- o que é;
- para quem é;
- qual problema resolve;
- qual próximo passo.
## Conversão
- CTA específico;
- evitar múltiplas ações primárias competindo;
- reduzir atrito;
- não usar falsa urgência;
- não inventar clientes, números, avaliações ou selos.
## Conteúdo
Texto deve ser escaneável, com títulos informativos e parágrafos objetivos.
## Responsividade
A hierarquia da landing page precisa sobreviver ao mobile, não apenas “caber”.
+42
View File
@@ -0,0 +1,42 @@
# Agente 08 — Responsive
## Papel
Garantir que a experiência funcione em diferentes larguras, orientações e dispositivos.
## Filosofia
Responsividade não é reduzir desktop. É reorganizar prioridades conforme espaço disponível.
## Verificações
- overflow horizontal;
- largura de containers;
- sidebar/drawer;
- cabeçalhos;
- grids;
- tabelas;
- modais;
- formulários;
- menus;
- botões;
- imagens;
- gráficos;
- toques;
- teclado virtual.
## Breakpoints
Use os breakpoints existentes. Não crie valores novos sem motivo.
## Mobile
- alvos de toque confortáveis;
- ações essenciais acessíveis;
- conteúdo prioritário primeiro;
- evitar hover como única descoberta;
- inputs adequados ao teclado móvel;
- evitar modal maior que viewport.
## Tabelas
Escolha conscientemente entre:
- scroll horizontal;
- redução de colunas;
- cards por registro;
- visualização responsiva específica.
Não esconder informação crítica sem alternativa.
+32
View File
@@ -0,0 +1,32 @@
# Agente 09 — Accessibility
## Papel
Garantir acesso por teclado, leitores de tela, baixa visão e diferentes capacidades motoras/cognitivas.
## Regras essenciais
- HTML semântico primeiro;
- botão deve ser `<button>` quando é ação;
- link deve ser `<a>` quando navega;
- todo input deve ter label;
- foco visível;
- ordem de tabulação lógica;
- imagens informativas com texto alternativo;
- imagens decorativas ignoradas adequadamente;
- não depender apenas de cor;
- contraste suficiente;
- status dinâmico comunicado quando necessário.
## ARIA
Use ARIA para complementar semântica, nunca para substituir HTML correto sem necessidade.
## Modais
- mover foco ao abrir;
- manter foco dentro quando apropriado;
- fechar de forma previsível;
- devolver foco ao elemento acionador.
## Erros de formulário
Associar erro ao campo e fornecer instrução útil.
## Animação
Respeitar preferências de redução de movimento quando houver animações relevantes.
+26
View File
@@ -0,0 +1,26 @@
# Agente 10 — SEO Técnico e On-page
## Papel
Melhorar descoberta orgânica sem comprometer UX ou produzir conteúdo enganoso.
## Aplicação
Principalmente sites públicos, landing pages, blogs, documentação e páginas indexáveis.
Dashboards autenticados normalmente não precisam deste agente.
## Checklist
- title único;
- meta description adequada;
- heading hierarchy;
- canonical quando aplicável;
- robots correto;
- sitemap quando aplicável;
- Open Graph/Twitter cards;
- URLs legíveis;
- links internos;
- alt text útil;
- dados estruturados somente quando verdadeiros;
- SSR/SSG/prerender quando stack e caso exigirem.
## Conteúdo
Não repetir palavra-chave artificialmente.
SEO deve melhorar clareza, não prejudicar leitura.
+30
View File
@@ -0,0 +1,30 @@
# Agente 11 — Performance
## Papel
Reduzir custo de carregamento, renderização e interação sem micro-otimização prematura.
## Prioridades
1. gargalos reais;
2. recursos grandes;
3. JavaScript desnecessário;
4. imagens;
5. fontes;
6. renderizações repetidas;
7. listas grandes;
8. requests redundantes.
## Avalie
- tamanho de bundle;
- lazy loading;
- code splitting;
- cache;
- compressão;
- imagens responsivas;
- dimensões de mídia para evitar layout shift;
- renderização de listas;
- memoização somente quando traz benefício;
- waterfalls de rede.
## Regra
Não sacrificar legibilidade por otimizações sem evidência.
Não remover funcionalidade para melhorar métrica sem autorização.
+35
View File
@@ -0,0 +1,35 @@
# Agente 12 — Visual QA
## Papel
Revisar resultado final como usuário e como designer, buscando inconsistências visíveis.
## Revisar
- alinhamentos;
- espaçamentos;
- tipografia;
- contraste;
- truncamento;
- sobreposição;
- z-index;
- responsividade;
- consistência de botões;
- ícones;
- estados hover/focus/disabled/loading;
- mensagens de erro;
- skeletons;
- empty states;
- modais/drawers;
- scroll inesperado.
## Cenários mínimos
- viewport amplo;
- notebook;
- tablet se relevante;
- celular estreito;
- conteúdo curto;
- conteúdo longo;
- dados vazios;
- erro.
## Regra
Não aprovar apenas porque “compila”. Qualidade visual e funcional devem coexistir.
@@ -0,0 +1,25 @@
# Agente 13 — Regression Check
## Papel
Última barreira antes da entrega. Verifica se a alteração que resolveu um problema criou outro.
## Procedimento
1. Compare escopo pedido com diff realizado.
2. Procure alterações não relacionadas.
3. Verifique imports, tipos e chamadas quebradas.
4. Execute validações disponíveis.
5. Refaça fluxo principal afetado.
6. Teste estado feliz e pelo menos um erro relevante.
7. Revise permissões e condições de exibição.
8. Verifique mobile/desktop quando UI mudou.
9. Verifique console.
10. Registre limitações conhecidas.
## Classificação de falhas
- Bloqueante: quebra função, build, segurança ou dados.
- Alta: fluxo importante degradado.
- Média: UX significativa, mas contornável.
- Baixa: detalhe visual ou melhoria opcional.
## Entrega
Nunca esconda falha de validação. Diferencie claramente problema novo de problema preexistente.
+6
View File
@@ -0,0 +1,6 @@
# Compatibilidade
Este arquivo foi mantido para compatibilidade com a versão anterior.
A fonte atual é [`09-accessibility.md`](./09-accessibility.md).
Ao executar este papel, leia e siga integralmente `agents/09-accessibility.md`.
+6
View File
@@ -0,0 +1,6 @@
# Compatibilidade
Este arquivo foi mantido para compatibilidade com a versão anterior.
A fonte atual é [`06-dashboard.md`](./06-dashboard.md).
Ao executar este papel, leia e siga integralmente `agents/06-dashboard.md`.
+6
View File
@@ -0,0 +1,6 @@
# Compatibilidade
Este arquivo foi mantido para compatibilidade com a versão anterior.
A fonte atual é [`03-design-system.md`](./03-design-system.md).
Ao executar este papel, leia e siga integralmente `agents/03-design-system.md`.
+6
View File
@@ -0,0 +1,6 @@
# Compatibilidade
Este arquivo foi mantido para compatibilidade com a versão anterior.
A fonte atual é [`04-frontend.md`](./04-frontend.md).
Ao executar este papel, leia e siga integralmente `agents/04-frontend.md`.
+6
View File
@@ -0,0 +1,6 @@
# Compatibilidade
Este arquivo foi mantido para compatibilidade com a versão anterior.
A fonte atual é [`07-landing-page.md`](./07-landing-page.md).
Ao executar este papel, leia e siga integralmente `agents/07-landing-page.md`.
+6
View File
@@ -0,0 +1,6 @@
# Compatibilidade
Este arquivo foi mantido para compatibilidade com a versão anterior.
A fonte atual é [`11-performance.md`](./11-performance.md).
Ao executar este papel, leia e siga integralmente `agents/11-performance.md`.
+6
View File
@@ -0,0 +1,6 @@
# Compatibilidade
Este arquivo foi mantido para compatibilidade com a versão anterior.
A fonte atual é [`08-responsive.md`](./08-responsive.md).
Ao executar este papel, leia e siga integralmente `agents/08-responsive.md`.
+6
View File
@@ -0,0 +1,6 @@
# Compatibilidade
Este arquivo foi mantido para compatibilidade com a versão anterior.
A fonte atual é [`10-seo.md`](./10-seo.md).
Ao executar este papel, leia e siga integralmente `agents/10-seo.md`.
+6
View File
@@ -0,0 +1,6 @@
# Compatibilidade
Este arquivo foi mantido para compatibilidade com a versão anterior.
A fonte atual é [`02-ui-ux.md`](./02-ui-ux.md).
Ao executar este papel, leia e siga integralmente `agents/02-ui-ux.md`.
+6
View File
@@ -0,0 +1,6 @@
# Compatibilidade
Este arquivo foi mantido para compatibilidade com a versão anterior.
A fonte atual é [`12-visual-qa.md`](./12-visual-qa.md).
Ao executar este papel, leia e siga integralmente `agents/12-visual-qa.md`.
+17
View File
@@ -0,0 +1,17 @@
# Checklist — Depois de Alterar
- [ ] Pedido foi atendido sem ampliar escopo indevidamente.
- [ ] Build passa ou falha preexistente foi documentada.
- [ ] Typecheck passa quando existe.
- [ ] Lint passa quando existe.
- [ ] Testes relevantes passam quando existem.
- [ ] Não surgiram erros de console óbvios.
- [ ] Loading foi considerado.
- [ ] Empty state foi considerado.
- [ ] Erro foi considerado.
- [ ] Desktop foi verificado.
- [ ] Mobile foi verificado.
- [ ] Teclado/foco foram considerados.
- [ ] Alteração não quebrou contrato de API.
- [ ] Não houve mudança acidental em autenticação/permissão.
- [ ] Diff final não contém alterações estranhas ao escopo.
+11
View File
@@ -0,0 +1,11 @@
# Checklist — Antes de Alterar
- [ ] Li `AGENTS.md`.
- [ ] Identifiquei stack e gerenciador de pacotes.
- [ ] Entendi a tarefa.
- [ ] Identifiquei arquivos afetados.
- [ ] Localizei regras de negócio relacionadas.
- [ ] Verifiquei design system/componentes existentes.
- [ ] Determinei riscos de regressão.
- [ ] Não estou alterando backend/API/auth sem necessidade.
- [ ] Selecionei somente os agentes necessários.
+25
View File
@@ -0,0 +1,25 @@
# Prompt — Auditar Projeto
Leia `AGENTS.md`, `agents/00-project-analyzer.md` e `agents/01-codebase-guardian.md` antes de começar.
Não altere arquivos nesta etapa.
Faça uma auditoria detalhada do projeto cobrindo:
1. stack e versões relevantes;
2. estrutura e arquitetura;
3. rotas e layouts;
4. componentes reutilizáveis;
5. estilos e design system;
6. estado e data fetching;
7. formulários;
8. responsividade;
9. acessibilidade;
10. SEO quando aplicável;
11. performance;
12. qualidade do código;
13. testes e validações disponíveis;
14. riscos de segurança visíveis no frontend;
15. dívidas técnicas que afetam diretamente a experiência.
Classifique achados como Bloqueante, Alto, Médio ou Baixo.
Para cada achado informe: problema, impacto, evidência/arquivo e recomendação.
Não invente problema sem evidência.
+16
View File
@@ -0,0 +1,16 @@
# Prompt — Criar Dashboard / Painel Administrativo
Leia `AGENTS.md` e aplique os agentes de análise, proteção, UI/UX, design system, frontend,
dashboard, forms, responsive, accessibility, performance, visual QA e regression check.
Priorize:
- produtividade;
- hierarquia de informação;
- filtros;
- tabelas;
- estados vazios;
- feedback de ações;
- segurança de operações destrutivas;
- consistência entre telas.
Não invente métricas ou regras de negócio. Use dados fictícios somente se estiver claramente marcado como mock.
+23
View File
@@ -0,0 +1,23 @@
# Prompt — Criar Site / Landing Page
Leia `AGENTS.md` e aplique:
- `agents/02-ui-ux.md`
- `agents/03-design-system.md`
- `agents/04-frontend.md`
- `agents/07-landing-page.md`
- `agents/08-responsive.md`
- `agents/09-accessibility.md`
- `agents/10-seo.md`
- `agents/11-performance.md`
- `agents/12-visual-qa.md`
Antes de implementar, determine:
- público;
- objetivo da página;
- proposta de valor;
- CTA principal;
- conteúdo disponível;
- identidade existente;
- restrições técnicas.
Não invente logos, clientes, números, depoimentos ou certificações como fatos reais.
+17
View File
@@ -0,0 +1,17 @@
# Prompt — Corrigir Mobile
Leia `AGENTS.md`, `agents/00-project-analyzer.md`, `agents/01-codebase-guardian.md`,
`agents/08-responsive.md`, `agents/09-accessibility.md` e `agents/13-regression-check.md`.
Procure especialmente:
- overflow horizontal;
- navegação impossível;
- tabelas ilegíveis;
- modais fora da viewport;
- botões pequenos;
- campos difíceis de preencher;
- grids quebrados;
- conteúdo cortado;
- ações dependentes de hover.
Não mascare erro com `overflow-x: hidden` sem corrigir a causa.
+19
View File
@@ -0,0 +1,19 @@
# Prompt — Melhorar Frontend
Leia `AGENTS.md` e siga seu pipeline.
Objetivo: melhorar a interface existente sem alterar regras de negócio ou contratos desnecessariamente.
Etapas obrigatórias:
1. analisar projeto;
2. identificar arquivos afetados;
3. selecionar agentes necessários;
4. preservar padrões existentes;
5. implementar mudanças mínimas e coesas;
6. tratar estados relevantes;
7. verificar responsividade e acessibilidade;
8. executar validações disponíveis;
9. executar `agents/12-visual-qa.md`;
10. executar `agents/13-regression-check.md`.
Na entrega, descreva mudanças, validações e limitações.
+20
View File
@@ -0,0 +1,20 @@
# Prompt — Revisão Final
Não implemente novas funcionalidades nesta etapa, salvo correção direta de defeito encontrado.
Leia:
- `agents/12-visual-qa.md`
- `agents/13-regression-check.md`
Verifique:
1. pedido original;
2. diff realizado;
3. build/typecheck/lint/testes;
4. desktop/mobile;
5. estados de UI;
6. acessibilidade básica;
7. console;
8. regressões;
9. alterações fora de escopo.
Entregue uma conclusão objetiva e liste qualquer limitação real restante.
+23
View File
@@ -0,0 +1,23 @@
# Skill — Design System
Use esta skill quando precisar criar, organizar ou ampliar padrões visuais.
## Método
1. Inventarie tokens existentes.
2. Inventarie componentes compartilhados.
3. Identifique duplicações.
4. Normalize primeiro os padrões mais repetidos.
5. Preserve API de componentes sempre que possível.
6. Documente novas variantes e estados.
## Tokens semânticos
Prefira nomes como `surface`, `foreground`, `primary`, `danger`, `border-muted` a nomes puramente visuais como `blue500` quando o projeto já usa semântica.
## Estados obrigatórios de componentes interativos
- default;
- hover quando aplicável;
- focus-visible;
- active;
- disabled;
- loading quando aplicável;
- error/invalid quando aplicável.
+14
View File
@@ -0,0 +1,14 @@
# Skill — Form Engineering
## Checklist rápido
- labels;
- tipos corretos;
- validação cliente e servidor;
- mensagens específicas;
- foco em erro;
- prevenção de duplo submit;
- loading;
- preservação de valores;
- autocomplete;
- acessibilidade;
- máscaras sem corromper payload.
+13
View File
@@ -0,0 +1,13 @@
# Skill — Next.js
## Verifique a arquitetura usada
- App Router ou Pages Router;
- Server/Client Components;
- estratégia de data fetching;
- cache/revalidation;
- metadata;
- route handlers;
- imagens/fontes.
Não misture padrões de versões diferentes sem necessidade.
Mantenha código cliente mínimo quando Server Components estiverem disponíveis e adequados.
+13
View File
@@ -0,0 +1,13 @@
# Skill — React
## Regras
- componentes devem ser previsíveis e com efeitos controlados;
- não use `useEffect` para estado derivável;
- mantenha dependências de efeitos corretas;
- não duplique fonte de verdade;
- keys de listas devem ser estáveis;
- handlers devem refletir intenção clara;
- Context não deve virar store global por conveniência.
## Performance
Memoização só quando há custo ou rerender relevante. Evite `useMemo`/`useCallback` indiscriminados.
+15
View File
@@ -0,0 +1,15 @@
# Skill — Responsive Engineering
## Processo
1. Descubra breakpoints existentes.
2. Ache containers com largura fixa problemática.
3. Identifique componentes que dependem de hover.
4. Teste navegação, tabelas, formulários e modais.
5. Corrija primeiro estrutura, depois detalhes.
## Anti-padrões
- esconder metade do sistema no mobile;
- fonte minúscula para fazer caber;
- `overflow-x: hidden` para mascarar layout quebrado;
- breakpoints arbitrários espalhados;
- largura fixa em componentes reutilizáveis.
+18
View File
@@ -0,0 +1,18 @@
# Skill — Data Tables
## Objetivo
Tabelas devem favorecer comparação e operação, não apenas exibir dados.
## Avaliar
- ordenação;
- filtros;
- busca;
- paginação;
- seleção;
- ações;
- densidade;
- responsividade;
- estados;
- persistência de contexto.
Nunca remova coluna importante no mobile sem oferecer outra forma de acesso ao dado.
+16
View File
@@ -0,0 +1,16 @@
# Skill — Tailwind CSS
Use somente quando Tailwind já fizer parte do projeto ou houver decisão explícita de adotá-lo.
## Regras
- reutilizar tokens/configuração do projeto;
- evitar classes arbitrárias repetidas;
- extrair componente quando sequência extensa representa padrão reutilizável;
- não substituir toda folha CSS existente sem necessidade;
- usar `cn`/merge de classes conforme convenção existente;
- respeitar variantes e dark mode existentes.
## Evitar
- dezenas de valores `[13px]`, `[17px]`, `[#123456]` sem sistema;
- `!` modifiers para vencer conflitos mal compreendidos;
- copiar blocos enormes de classes em várias telas.
+9
View File
@@ -0,0 +1,9 @@
# Skill — Vue
## Regras
- respeitar Options API ou Composition API predominante;
- evitar mutação inesperada de props;
- usar computed para valores derivados;
- watchers apenas quando efeito colateral é necessário;
- manter emits e props explícitos;
- preservar convenções de Pinia/Vue Router existentes.
+36
View File
@@ -0,0 +1,36 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
FROM node:20-alpine AS dev
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]
FROM node:20-alpine AS build
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=build /app/public ./public
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=build --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=build --chown=nextjs:nodejs /app/package.json ./package.json
RUN mkdir -p /app/storage/uploads && chown -R nextjs:nodejs /app/storage
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
+133
View File
@@ -0,0 +1,133 @@
# Briefing de Site SeConectai
Sistema web para cadastro de clientes e preenchimento guiado do briefing de criação de sites.
As pastas `Conhecimento/` e `Schema/` ficam como documentação de referência. O sistema em si é criado na raiz do projeto, com `app/`, `components/`, `lib/`, `prisma/`, Docker e configurações do Next.js.
## Stack
- Next.js 14+ com App Router
- TypeScript
- Tailwind CSS
- shadcn/ui como padrão de componentes
- Prisma + PostgreSQL 16
- Auth.js v5
- React Hook Form + Zod
- Docker Compose
## Desenvolvimento
Crie um `.env` a partir do exemplo quando for implementar autenticação, banco e integrações reais:
```sh
cp .env.example .env
```
Instale as dependências:
```sh
npm install
```
Rode o app:
```sh
npm run dev
```
Ou suba com Docker:
```sh
docker compose up
```
## Estrutura inicial
- `app/`: rotas e layout base.
- `components/ui/`: componentes base no padrão shadcn.
- `lib/`: utilitários compartilhados.
- `prisma/briefing-seed.json`: cópia do seed usado para carregar as seções e perguntas do briefing.
- `Schema/`: documentação das etapas do backend e regras de negócio.
- `Conhecimento/`: guias de frontend, UI, responsividade, acessibilidade e revisão.
## Observação
O ambiente atual do shell não tem `node`, `npm` nem `docker` instalados, então a validação de instalação, lint, build e compose precisa ser feita em uma máquina com essas ferramentas disponíveis.
## Backup
Com `DATABASE_URL` e `UPLOAD_DIR` configurados:
```sh
sh scripts/backup.sh
```
Os arquivos são salvos em `./backups` por padrão, com rotação de 14 dias.
Para restaurar:
```sh
sh scripts/restore.sh backups/db-YYYYMMDD-HHMMSS.sql backups/uploads-YYYYMMDD-HHMMSS.tar.gz
```
## Produção
Crie o `.env` no servidor. Variáveis mínimas:
```sh
POSTGRES_DB=briefing
POSTGRES_USER=briefing
POSTGRES_PASSWORD=troque-esta-senha
DATABASE_URL=postgresql://briefing:troque-esta-senha@db:5432/briefing?schema=public
AUTH_SECRET=gere-um-segredo-forte
APP_URL=https://seu-dominio.com.br
APP_DOMAIN=seu-dominio.com.br
LETSENCRYPT_EMAIL=voce@dominio.com.br
CPF_ENC_KEY=chave-base64-com-32-bytes
CPF_HASH_KEY=segredo-hmac-forte
ADMIN_EMAIL=admin@dominio.com.br
ADMIN_INITIAL_PASSWORD=senha-inicial-forte
UPLOAD_DIR=/app/storage/uploads
UPLOAD_MAX_MB=25
```
Para gerar uma chave base64 de 32 bytes:
```sh
openssl rand -base64 32
```
Suba em produção:
```sh
docker compose -f docker-compose.prod.yml up -d --build
```
O serviço `release` roda `prisma migrate deploy` e o seed idempotente antes do app iniciar. O seed cria o admin inicial usando `ADMIN_EMAIL` e `ADMIN_INITIAL_PASSWORD` somente se o usuário ainda não existir.
Para atualizar:
```sh
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --build
```
O Postgres não publica porta externa no `docker-compose.prod.yml`; ele fica acessível apenas na rede interna do Compose. Os uploads ficam no volume nomeado `uploads`, e o banco no volume `pgdata`.
## HTTPS
O `docker-compose.prod.yml` usa Caddy com o `Caddyfile` da raiz. Configure `APP_DOMAIN` e `LETSENCRYPT_EMAIL` no `.env`; o Caddy emite e renova o certificado automaticamente e redireciona HTTP para HTTPS.
## Healthcheck
A aplicação expõe:
```sh
/api/health
```
Ela verifica app + conexão com o banco.
## E-mail
Configure `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS` e `SMTP_FROM`. No DNS do remetente, confira SPF e DKIM do provedor SMTP para reduzir chance de spam. O sistema nunca envia CPF completo por e-mail.
+51
View File
@@ -0,0 +1,51 @@
# 00 — Arquitetura: Sistema de Briefing de Site
## Objetivo
Cliente se cadastra (pessoa física ou empresa) → faz login → preenche o briefing de 19 seções (149 perguntas) com rascunho automático e botão Ajuda por pergunta → envia → Tiago visualiza, exporta e muda o status no painel admin.
## Fluxos
1. **Cadastro:** Nome completo, CPF, WhatsApp, E-mail, senha. Se "É para empresa?": Razão Social, CNPJ, Endereço (CEP, logradouro, número, complemento, bairro, cidade, UF). Aceite de LGPD obrigatório.
2. **Login / recuperação de senha** por e-mail.
3. **Meus briefings:** o cliente cria um briefing (um cliente pode ter mais de um, ex.: um site por empresa) e continua de onde parou.
4. **Preenchimento:** tela por seção, barra de progresso, botão Ajuda por pergunta (popup), autosave, anexos na seção 19.
5. **Envio:** confirmação do cliente (texto da seção "Confirmação do cliente") → status ENVIADO, bloqueia edição (admin pode reabrir).
6. **Admin:** lista de clientes e briefings, detalhe com todas as respostas e anexos, mudança de status, exportação PDF, edição das perguntas/ajudas.
## Decisão central: formulário dirigido por dados
Seções, perguntas e textos de ajuda ficam no banco e vêm do arquivo `briefing-seed.json` (gerado dos .docx originais: 19 seções, 149 perguntas, todas com ajuda). O frontend renderiza o formulário a partir desses dados; nada de 149 campos escritos à mão.
Tipos de pergunta (`TipoPergunta`): `TEXTAREA` (117), `RADIO` (11), `CHECKBOX` (6), `ANEXO` (15, seção 19). Toda pergunta tem também um campo opcional de **complemento** em texto (o documento original permite escrever junto das caixas marcadas, ex.: "Outro: qual?").
## Modelo de dados (Prisma, resumo)
- `User` (id, email único, senhaHash, papel: CLIENTE | ADMIN, criadoEm)
- `Cliente` (userId único, nomeCompleto, cpfCifrado, cpfHash único, whatsapp, tipo: PF | PJ, razaoSocial?, cnpj?, cep?, logradouro?, numero?, complemento?, bairro?, cidade?, uf?, lgpdAceiteEm)
- `Secao` (id, ordem, titulo, descricao, ativo)
- `Pergunta` (id, secaoId, ordem, rotulo, dica, tipo, opcoes Json, obrigatoria default false, permiteComplemento, ativo)
- `Ajuda` (perguntaId único, oQueResponder, porQuePerguntamos, miniExemplo)
- `Briefing` (id, clienteId, titulo, status: RASCUNHO | ENVIADO | EM_ANALISE | CONCLUIDO, confirmadoEm?, enviadoEm?, criadoEm, atualizadoEm)
- `Resposta` (briefingId, perguntaId, texto?, selecionadas Json?, único por briefing+pergunta)
- `Anexo` (briefingId, perguntaId, nomeOriginal, caminho, mime, tamanho, criadoEm)
- `PasswordResetToken` (userId, tokenHash, expiraEm, usadoEm?)
## Princípios (do padrão SeConectai)
- **Nunca apagar dados históricos:** briefing enviado não é deletado; reabrir muda o status, não perde respostas.
- **Dados sensíveis por desenho:** CPF cifrado (AES-256-GCM, chave em variável de ambiente) + `cpfHash` (HMAC-SHA256) para unicidade/busca. CPF nunca vai para log nem para o JSON de listagens.
- **Validação dupla:** Zod no cliente e no servidor (server actions / route handlers).
- **Permissões:** cliente só enxerga os próprios briefings; admin enxerga todos. Toda leitura/escrita checa o dono no servidor.
- **Incremental:** mudanças futuras via migrations Prisma numeradas, sem reescrever o schema base.
## Estrutura de pastas alvo
```
/app
/(publico)/cadastro, /login, /recuperar-senha, /redefinir-senha
/(cliente)/briefings, /briefings/[id], /briefings/[id]/[secao], /briefings/[id]/revisao
/(admin)/admin, /admin/clientes, /admin/briefings/[id], /admin/perguntas
/api/anexos/[id] (download autenticado)
/components (ui shadcn, form/, ajuda/)
/lib (auth, db, crypto, validators, storage, pdf, mail)
/prisma (schema.prisma, seed.ts, briefing-seed.json)
/storage/uploads (volume Docker, fora de /public)
```
## Ordem de execução
01 setup → 02 schema → 03 seed → 04 auth/cadastro → 05 motor do formulário → 06 popup de ajuda → 07 rascunho e envio → 08 anexos → 09 painel admin → 10 PDF e notificações → 11 LGPD/segurança → 12 deploy. Acompanhar em `99-progresso.md`.
+23
View File
@@ -0,0 +1,23 @@
# 01 — Setup do projeto e Docker
## Contexto do projeto (leia sempre)
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
## Tarefa
1. Criar projeto Next.js 14+ com TypeScript, App Router, Tailwind e ESLint (`src` opcional — siga a estrutura do doc 00).
2. Instalar: `prisma @prisma/client zod react-hook-form @hookform/resolvers next-auth@beta @auth/core argon2 nodemailer @tanstack/react-query lucide-react`, e iniciar shadcn/ui (componentes: button, input, textarea, label, checkbox, radio-group, dialog, form, card, progress, badge, table, select, sonner, tabs, alert).
3. Criar `docker-compose.yml` com `app` (Node 20) e `db` (postgres:16, volume nomeado `pgdata`), `healthcheck` no db, rede interna. Volume `uploads` montado em `/app/storage/uploads`.
4. Criar `Dockerfile` multi-stage (deps → build → runner, `output: "standalone"`).
5. `.env.example` com: `DATABASE_URL`, `AUTH_SECRET`, `APP_URL`, `CPF_ENC_KEY` (32 bytes base64), `CPF_HASH_KEY`, `SMTP_HOST/PORT/USER/PASS/FROM`, `ADMIN_EMAIL`, `ADMIN_INITIAL_PASSWORD`, `EVOLUTION_API_URL/KEY/INSTANCE` e `ADMIN_WHATSAPP` (opcionais), `UPLOAD_DIR`, `UPLOAD_MAX_MB=25`.
6. Layout base pt-BR (`<html lang="pt-BR">`), tema simples e responsivo (mobile primeiro: clientes vão preencher pelo celular).
7. Script `npm run dev`, `build`, `db:migrate`, `db:seed`, `lint`.
## Critérios de aceite
- [ ] `docker compose up` sobe app + banco sem erro; página inicial abre em `http://localhost:3000`.
- [ ] `.env.example` completo; `.env` no `.gitignore`.
- [ ] `npm run lint` e `npm run build` passam.
- [ ] Nenhuma dependência fora da stack.
Ao final, atualize `99-progresso.md` (marque 01).
+26
View File
@@ -0,0 +1,26 @@
# 02 — Banco de dados e schema Prisma
## Contexto do projeto (leia sempre)
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
## Pré-requisito
Etapa 01 concluída em `99-progresso.md`.
## Tarefa
Implementar em `prisma/schema.prisma` o modelo descrito no doc 00 (seção "Modelo de dados"), com:
- enums `Papel {CLIENTE ADMIN}`, `TipoPessoa {PF PJ}`, `TipoPergunta {TEXTAREA RADIO CHECKBOX ANEXO}`, `StatusBriefing {RASCUNHO ENVIADO EM_ANALISE CONCLUIDO}`.
- `@@unique([briefingId, perguntaId])` em `Resposta`; `@@unique([ordem])` em `Secao` e `@@unique([secaoId, ordem])` em `Pergunta`; índices em `Briefing.clienteId`, `Briefing.status`, `Anexo.briefingId`.
- `onDelete: Cascade` apenas de `Briefing → Resposta/Anexo`; **não** colocar cascade de `Cliente → Briefing` (preservar histórico).
- campos `criadoEm`/`atualizadoEm` (`@default(now())`, `@updatedAt`).
- `opcoes` e `selecionadas` como `Json`.
Gerar a migration inicial `0001_init` e o client Prisma; criar `lib/db.ts` com singleton do PrismaClient.
## Critérios de aceite
- [ ] `npx prisma migrate dev` aplica sem erro no Postgres do Docker.
- [ ] `npx prisma validate` ok; `prisma studio` mostra todas as tabelas.
- [ ] Nenhuma coluna de CPF em texto puro (apenas `cpfCifrado` e `cpfHash`).
Atualize `99-progresso.md` (02).
+35
View File
@@ -0,0 +1,35 @@
# 03 — Seed do briefing (seções, perguntas e ajudas)
## Contexto do projeto (leia sempre)
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
## Pré-requisito
Etapa 02 concluída.
## Arquivo de dados
`prisma/briefing-seed.json` (fornecido junto com este guia; copie para `prisma/`). Estrutura:
```json
{ "meta": { "comoPreencher": [..], "avisoSenhas": "..", "confirmacaoCliente": ".." },
"secoes": [ { "ordem": 1, "titulo": "..", "descricao": "..",
"perguntas": [ { "ordem": 1, "rotulo": "..", "dica": "..", "tipo": "TEXTAREA|RADIO|CHECKBOX|ANEXO",
"opcoes": ["..."], "permiteComplemento": true,
"ajuda": { "oQueResponder": "..", "porQuePerguntamos": "..", "miniExemplo": ".." } } ] } ] }
```
O arquivo tem 19 seções e 149 perguntas; todas têm `ajuda`.
## Tarefa
1. Criar `prisma/seed.ts` **idempotente** (upsert por `secao.ordem` e por `secaoId+ordem`): cria `Secao`, `Pergunta` e `Ajuda`. Reexecutar o seed atualiza textos sem duplicar e **sem apagar respostas** existentes.
2. Guardar `meta` (como preencher, aviso de senhas, confirmação do cliente) em uma tabela `Configuracao (chave, valor Json)` — adicionar via migration `0002_configuracao`.
3. No mesmo seed, criar o usuário ADMIN a partir de `ADMIN_EMAIL`/`ADMIN_INITIAL_PASSWORD` (hash argon2), apenas se não existir.
4. Configurar `prisma.seed` no `package.json`.
5. Criar `lib/briefing-queries.ts` com `getEstruturaBriefing()` que retorna seções → perguntas → ajuda, ordenadas, em uma única consulta.
## Critérios de aceite
- [ ] Após `npm run db:seed`: 19 seções, 149 perguntas, 149 ajudas.
- [ ] Rodar o seed duas vezes não duplica nada.
- [ ] Contagem por tipo: TEXTAREA 117, RADIO 11, CHECKBOX 6, ANEXO 15.
- [ ] Admin criado; senha só em hash.
Atualize `99-progresso.md` (03).
+35
View File
@@ -0,0 +1,35 @@
# 04 — Autenticação e cadastro do cliente
## Contexto do projeto (leia sempre)
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
## Pré-requisito
Etapas 01–03 concluídas.
## Tarefa
### Cadastro (`/cadastro`)
Campos: **Nome completo, CPF, WhatsApp, E-mail, Senha (mín. 8) e confirmação**. Seletor "Este site é para uma empresa?" — se sim, exibir **Razão Social, CNPJ e Endereço** (CEP com busca opcional no ViaCEP *no navegador*, logradouro, número, complemento, bairro, cidade, UF). Checkbox obrigatório de aceite da Política de Privacidade/LGPD (guardar `lgpdAceiteEm`).
Validações Zod (cliente + servidor) em `lib/validators.ts`: CPF e CNPJ com dígito verificador; WhatsApp brasileiro (DDD + 9 dígitos, normalizar para `+55DDDNÚMERO`); e-mail normalizado em minúsculas; máscara de entrada para CPF/CNPJ/WhatsApp/CEP.
Servidor: criar `User` + `Cliente` em transação; CPF → `lib/crypto.ts` (`encrypt`/`decrypt` AES-256-GCM com `CPF_ENC_KEY`, `hashCpf` HMAC-SHA256 com `CPF_HASH_KEY`); rejeitar e-mail ou CPF já cadastrados com mensagem genérica que não revele qual existe; senha com argon2.
### Login e sessão
Auth.js v5 com provider Credentials, JWT, cookie httpOnly/secure/sameSite=lax; sessão contém `userId` e `papel`. `middleware.ts` protege `/briefings*` (CLIENTE) e `/admin*` (ADMIN) e redireciona para `/login`. Limitar tentativas de login (rate limit simples em memória por IP+e-mail).
### Recuperar senha
`/recuperar-senha` (pede e-mail; resposta sempre igual exista ou não a conta) → e-mail com link de token aleatório (guardar só o hash, expira em 1 h, uso único) → `/redefinir-senha`. Enviar por SMTP via `lib/mail.ts` (nodemailer).
### Após o login
Cliente cai em `/briefings` (lista vazia com botão "Iniciar meu briefing"). Admin cai em `/admin`.
## Critérios de aceite
- [ ] Cadastro PF e PJ funcionam; campos de empresa só aparecem/são exigidos no modo PJ.
- [ ] CPF/CNPJ inválidos são recusados; CPF não aparece em texto puro no banco nem em logs.
- [ ] E-mail/CPF duplicado é recusado sem revelar qual.
- [ ] Rotas protegidas redirecionam; cliente não acessa `/admin`.
- [ ] Recuperação de senha funciona de ponta a ponta (token expira e é de uso único).
Atualize `99-progresso.md` (04).
+30
View File
@@ -0,0 +1,30 @@
# 05 — Motor do formulário do briefing
## Contexto do projeto (leia sempre)
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
## Pré-requisito
Etapas 01–04 concluídas.
## Tarefa
1. **Criar briefing:** botão "Iniciar meu briefing" cria `Briefing` RASCUNHO. Os campos do cabeçalho do documento original (Empresa/Marca, Responsável, Data, Contato) são pré-preenchidos com os dados do cadastro e exibidos na primeira tela, editáveis.
2. **Tela de abertura** `/briefings/[id]`: mostra o "Como preencher" e o aviso de senhas (vindos de `Configuracao`), lista das 19 seções com progresso por seção (respondidas/total) e botão "Continuar de onde parei".
3. **Tela de seção** `/briefings/[id]/[secao]`: título e descrição da seção; cada pergunta renderizada por `components/form/PerguntaField.tsx`, escolhendo o componente pelo `tipo`:
- `TEXTAREA`: textarea com contador discreto.
- `RADIO`: grupo de opções (uma escolha).
- `CHECKBOX`: múltipla escolha.
- `ANEXO`: tratado na etapa 08 (aqui, deixar placeholder).
Se `permiteComplemento`, exibir abaixo um campo curto "Complemento / outro". O rótulo mostra `rotulo`, e `dica` aparece como texto de apoio. Cada pergunta tem espaço reservado para o botão Ajuda (etapa 06).
4. **Navegação:** Anterior/Próxima seção, menu lateral (drawer no celular) com as 19 seções e indicadores de concluída/parcial; barra de progresso geral.
5. **Estado:** React Hook Form por seção; valores iniciais carregados de `Resposta`. Persistência vem na etapa 07.
6. **"Respondida":** considerar respondida se houver texto não vazio ou ao menos uma opção marcada. Perguntas não são obrigatórias por padrão (`obrigatoria=false`); o documento aceita "Não se aplica" / "A definir" — exibir esses dois como atalhos (chips) em campos de texto que preenchem o valor ao toque.
## Critérios de aceite
- [ ] As 19 seções renderizam a partir do banco, sem campo escrito à mão.
- [ ] Os 4 tipos funcionam com os valores corretos (inclusive complemento).
- [ ] Navegação entre seções e progresso corretos; usável em tela de 360 px.
- [ ] Cliente não consegue abrir briefing de outro cliente (404).
Atualize `99-progresso.md` (05).
+29
View File
@@ -0,0 +1,29 @@
# 06 — Botão Ajuda com popup por pergunta
## Contexto do projeto (leia sempre)
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
## Pré-requisito
Etapa 05 concluída.
## Tarefa
Criar `components/ajuda/AjudaButton.tsx` e usá-lo em **toda** pergunta que tenha `ajuda`:
- Botão pequeno ao lado do rótulo: ícone `HelpCircle` + texto "Ajuda" (o texto some só em telas muito estreitas, mantendo `aria-label="Ajuda sobre: {rotulo}"`).
- Ao clicar, abre um `Dialog` (shadcn) com:
- **Título:** o `rotulo` da pergunta.
- **O que responder** — `oQueResponder`
- **Por que perguntamos** — `porQuePerguntamos`
- **Mini exemplo** — `miniExemplo`, em caixa destacada, com aviso fixo: "Os exemplos servem apenas como referência. Substitua pelas informações reais da sua empresa."
- Rodapé: botão Fechar; opcional "Usar 'Não se aplica'" / "Usar 'A definir'" que preenche o campo e fecha o popup (apenas em campos de texto).
- Acessibilidade: foco preso no diálogo, fecha com Esc e clique fora, foco volta ao botão, leitura por leitor de tela.
- Dados de ajuda já vêm junto da estrutura (consulta única da etapa 03); não fazer requisição por clique.
- Se a pergunta não tiver ajuda, não renderizar o botão.
## Critérios de aceite
- [ ] Todas as 149 perguntas exibem o botão e abrem o texto correto do guia (conferir por amostragem de pelo menos 1 pergunta por seção).
- [ ] Popup funciona em celular e desktop, com teclado e leitor de tela.
- [ ] Abrir/fechar o popup não perde o que foi digitado no campo.
Atualize `99-progresso.md` (06).
+23
View File
@@ -0,0 +1,23 @@
# 07 — Rascunho automático, revisão e envio
## Contexto do projeto (leia sempre)
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
## Pré-requisito
Etapas 05 e 06 concluídas.
## Tarefa
1. **Autosave:** ao parar de digitar (debounce ~800 ms) e ao mudar de seção, salvar via server action `salvarRespostas(briefingId, secaoId, valores)` com upsert em `Resposta`. Validar com Zod, checar que o briefing pertence ao usuário e está em RASCUNHO. Indicador "Salvo às HH:MM" / "Salvando…" / "Erro ao salvar — tentar de novo". Aviso ao sair com alterações não salvas.
2. **Idempotência:** salvar valor vazio remove/zera a resposta daquela pergunta sem afetar outras; concorrência: última gravação vence, com `atualizadoEm`.
3. **Revisão** `/briefings/[id]/revisao`: lista todas as seções com respostas, destaca perguntas em branco (sem bloquear) e permite voltar para editar com um clique.
4. **Envio:** exibir o texto "Confirmação do cliente" (de `Configuracao`) com checkbox obrigatório; ao confirmar, `status=ENVIADO`, `confirmadoEm` e `enviadoEm` preenchidos, edição bloqueada (somente leitura). Se houver perguntas em branco, avisar e pedir confirmação extra, sem impedir.
5. Admin pode reabrir (volta a RASCUNHO) — preservando todas as respostas (etapa 09 expõe a ação; aqui criar a função de serviço `reabrirBriefing`).
## Critérios de aceite
- [ ] Fechar o navegador no meio e voltar mantém tudo que foi digitado.
- [ ] Briefing ENVIADO não aceita novas gravações (servidor recusa, não só a interface).
- [ ] Nenhuma resposta de outro cliente/briefing é acessível ou gravável.
Atualize `99-progresso.md` (07).
+26
View File
@@ -0,0 +1,26 @@
# 08 — Anexos (seção 19: checklist de materiais)
## Contexto do projeto (leia sempre)
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
## Pré-requisito
Etapa 07 concluída.
## Tarefa
A seção 19 do briefing tem 15 itens do tipo `ANEXO` (logotipo, manual de identidade, fotos, vídeos, textos, catálogo, lista de serviços, depoimentos, cases, certificações, dados de contato, redes sociais, referências de sites, política/termos, domínio e hospedagem sem senhas).
1. Em cada item `ANEXO`: caixa "Estou enviando este material" (equivale ao ☐ do documento), campo de texto para links (Drive, Dropbox, YouTube) e área de upload de arquivos.
2. **Upload** via route handler com `multipart/form-data`: máximo `UPLOAD_MAX_MB` por arquivo e 20 arquivos por pergunta; permitir imagens (png, jpg, webp, svg), pdf, docx/doc, xlsx/xls, pptx, zip, mp4/mov; validar extensão **e** tipo MIME **e** conteúdo básico (magic bytes); nome do arquivo no disco = UUID (nunca o nome original); salvar em `UPLOAD_DIR` (volume Docker, **fora de `/public`**).
3. **Download** apenas via `/api/anexos/[id]`, autenticado: dono do briefing ou ADMIN. Cabeçalhos `Content-Disposition: attachment` e `X-Content-Type-Options: nosniff`.
4. Listar anexos com nome, tamanho e botão remover (somente em RASCUNHO). Barra de progresso por arquivo.
5. Aviso fixo na seção: "Não envie senhas. Informe apenas provedores e responsáveis."
## Critérios de aceite
- [ ] Upload/remoção funcionam; arquivo inválido ou grande demais é recusado com mensagem clara.
- [ ] Acessar a URL do arquivo sem login ou como outro cliente retorna 403/404.
- [ ] Nenhum arquivo fica acessível por URL pública estática.
- [ ] Briefing ENVIADO não aceita novos uploads/remoções.
Atualize `99-progresso.md` (08).
+25
View File
@@ -0,0 +1,25 @@
# 09 — Painel administrativo
## Contexto do projeto (leia sempre)
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
## Pré-requisito
Etapas 01–08 concluídas.
## Tarefa
Área `/admin` (somente ADMIN, checado no middleware **e** em cada server action):
1. **Início:** contadores (briefings por status, novos nos últimos 7 dias) e últimos envios.
2. **Clientes** `/admin/clientes`: tabela com busca (nome, e-mail, WhatsApp, CNPJ, razão social) e filtro PF/PJ; detalhe do cliente com dados cadastrais e seus briefings. CPF exibido **mascarado** (`***.123.456-**`) e só revelado completo por botão "Mostrar CPF" que registra o acesso em auditoria (`AuditoriaAcesso`: adminId, clienteId, ação, data — migration `0003`).
3. **Briefings** `/admin/briefings/[id]`: todas as respostas por seção (em modo leitura, com as opções marcadas e o complemento), anexos com download, linha do tempo de status. Ações: mudar status (ENVIADO → EM_ANALISE → CONCLUIDO), **reabrir para edição**, adicionar nota interna (`NotaInterna`, invisível ao cliente).
4. **Perguntas** `/admin/perguntas`: editar rótulo, dica, textos de ajuda (o que responder / por que perguntamos / mini exemplo), ordem, ativo e obrigatória, sem mexer em código. Desativar uma pergunta nunca apaga respostas já dadas.
5. Link de WhatsApp (`wa.me`) no detalhe do cliente para contato rápido.
## Critérios de aceite
- [ ] Usuário CLIENTE não acessa nenhuma rota/ação admin (testar chamada direta).
- [ ] Editar um texto de ajuda reflete no popup do cliente imediatamente.
- [ ] Reabrir briefing preserva respostas e anexos.
- [ ] Revelar CPF gera registro de auditoria.
Atualize `99-progresso.md` (09).
@@ -0,0 +1,22 @@
# 10 — Exportação em PDF e notificações
## Contexto do projeto (leia sempre)
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
## Pré-requisito
Etapa 09 concluída.
## Tarefa
1. **PDF do briefing** (`/api/briefings/[id]/pdf`, dono ou ADMIN): documento com dados do cliente, 19 seções, perguntas e respostas (opções marcadas com ☒/☐), lista de anexos (nomes, sem o conteúdo) e a confirmação do cliente com data/hora. Gerar no servidor (ex.: `@react-pdf/renderer`). Botão "Baixar PDF" para o cliente (após envio) e para o admin.
2. **E-mails** (SMTP, `lib/mail.ts`, templates simples em pt-BR): confirmação de cadastro, briefing enviado (ao cliente, com resumo e PDF), aviso de novo briefing enviado (ao admin).
3. **WhatsApp opcional (Evolution API):** se `EVOLUTION_API_URL/KEY/INSTANCE` e `ADMIN_WHATSAPP` estiverem definidos, avisar o admin quando um briefing for enviado. **Antes de implementar, consultar a versão da Evolution API da instância e confirmar o formato do payload** (não assumir). Falha de WhatsApp/e-mail nunca pode impedir o envio do briefing: registrar erro e seguir (fila simples com retry ou tabela `EnvioNotificacao` com status).
4. Dados sensíveis: PDF e e-mails **não** incluem CPF completo.
## Critérios de aceite
- [ ] PDF gerado corretamente com todas as seções e acentuação; abre em leitor comum.
- [ ] Falha de SMTP/WhatsApp não quebra o envio do briefing.
- [ ] Nenhum CPF completo em PDF, e-mails ou logs.
Atualize `99-progresso.md` (10).
+26
View File
@@ -0,0 +1,26 @@
# 11 — LGPD e segurança
## Contexto do projeto (leia sempre)
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
## Pré-requisito
Etapas 01–10 concluídas.
## Tarefa
1. **Páginas legais:** `/privacidade` (Política de Privacidade) e `/termos`, com texto-base editável (marcar claramente como "revisar com assessoria jurídica"); links no rodapé e no cadastro.
2. **Direitos do titular:** em "Minha conta", cliente pode ver e editar seus dados, baixar seus dados (JSON) e solicitar exclusão (cria `SolicitacaoExclusao`; admin processa — anonimiza o cadastro mantendo o briefing sem identificação pessoal, conforme decisão do Tiago).
3. **Cabeçalhos de segurança** no `next.config`: CSP, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`, HSTS em produção.
4. **Proteções:** rate limit nos endpoints de cadastro, login, recuperação e upload; proteção CSRF nas server actions (padrão Next) e checagem de origem nas route handlers; sanitização de saída (React já escapa; nunca usar `dangerouslySetInnerHTML` com dados do cliente).
5. **Logs:** nunca registrar senha, token, CPF ou conteúdo de respostas; logar apenas ids e eventos.
6. **Backups:** script `scripts/backup.sh` com `pg_dump` + cópia do volume de uploads, com rotação.
7. Revisão final: tentar manualmente acessar recurso de outro cliente em cada rota (briefing, resposta, anexo, PDF) e confirmar bloqueio.
## Critérios de aceite
- [ ] Cabeçalhos de segurança presentes (verificar com `curl -I`).
- [ ] Teste manual de isolamento entre clientes passa em todas as rotas.
- [ ] `grep` no código não encontra CPF/senha em logs.
- [ ] Backup e restauração testados em ambiente limpo.
Atualize `99-progresso.md` (11).
+24
View File
@@ -0,0 +1,24 @@
# 12 — Deploy em produção
## Contexto do projeto (leia sempre)
Sistema web "Briefing de Site" da SeConectai: o cliente se cadastra e depois preenche o briefing de criação do site, com um botão **Ajuda** em cada pergunta que abre um popup com o texto do guia auxiliar. O dono (Tiago) acompanha tudo por um painel admin.
**Stack fixa:** Next.js 14+ (App Router) + TypeScript, PostgreSQL 16, Prisma, Tailwind + shadcn/ui, React Hook Form + Zod, Auth.js v5 (Credentials, estratégia JWT), Docker Compose. Idioma da interface: pt-BR.
**Regras gerais:** leia `00-arquitetura-briefing-site.md` e `99-progresso.md` antes de começar; não avance se a etapa anterior estiver incompleta; ao terminar, devolva o `99-progresso.md` atualizado. Não invente dependências fora da stack. Nada de senhas de clientes em lugar nenhum.
## Pré-requisito
Etapas 01–11 concluídas.
## Tarefa
1. `docker-compose.prod.yml`: app (imagem standalone), Postgres 16 com volume, volume de uploads, restart `unless-stopped`, sem portas do banco expostas, variáveis só por `.env` do servidor.
2. Proxy reverso com HTTPS (Caddy ou Nginx + Let's Encrypt) para o domínio escolhido; redirecionar HTTP → HTTPS.
3. Comando de release: `prisma migrate deploy` + seed idempotente (que não sobrescreve nada do cliente).
4. Healthcheck `/api/health` (app + banco) e política de reinício.
5. Documentar em `README.md`: variáveis de ambiente, como subir, como rodar backup/restauração, como criar o admin, como atualizar.
6. Conferir e-mail (SPF/DKIM do remetente) para os e-mails não caírem em spam.
## Critérios de aceite
- [ ] Sistema acessível por HTTPS no domínio, cadastro → briefing → envio funcionando de ponta a ponta em produção.
- [ ] Banco e uploads persistem após `docker compose down && up`.
- [ ] Porta do Postgres não acessível externamente.
Atualize `99-progresso.md` (12) e marque o projeto como concluído.
+29
View File
@@ -0,0 +1,29 @@
# 99 — Progresso do projeto (guardrail obrigatório)
> **Instrução à IA executora:** leia este arquivo ANTES de iniciar cada etapa e devolva-o atualizado ao terminar. Nunca avance se a etapa anterior não estiver 100% concluída (todos os critérios de aceite marcados). Se algo falhar, registre em "Bloqueios" e pare.
| Etapa | Arquivo | Status | Observações |
|---|---|---|---|
| 01 | 01-setup-projeto-docker.md | 🟨 em andamento | Estrutura Next/Tailwind/Docker criada na raiz; falta validar `npm install`, `npm run lint`, `npm run build` e `docker compose up`. |
| 02 | 02-banco-prisma-schema.md | 🟨 em andamento | `prisma/schema.prisma`, migrations `0001_init` e `0002_configuracao`, e `lib/db.ts` criados; falta aplicar/validar com Prisma e Postgres. |
| 03 | 03-seed-briefing-do-docx.md | 🟨 em andamento | `prisma/briefing-seed.json`, `prisma/seed.ts` e `lib/briefing-queries.ts` criados; falta executar seed e conferir contagens. |
| 04 | 04-autenticacao-e-cadastro.md | 🟨 em andamento | Cadastro, validadores, criptografia de CPF, Auth.js Credentials, middleware, login, logout, recuperação/redefinição de senha e rotas protegidas iniciais criados; falta validar em runtime com banco, SMTP e build. |
| 05 | 05-motor-do-formulario-briefing.md | 🟨 em andamento | Motor inicial criado: lista de briefings, criação de RASCUNHO, abertura com orientações/configurações, cabeçalho editável local, progresso por seção, tela de seção renderizada pelo banco com TEXTAREA/RADIO/CHECKBOX/ANEXO e navegação. Falta validar em runtime e conectar persistência na etapa 07. |
| 06 | 06-botao-ajuda-popup.md | 🟨 em andamento | `components/ajuda/AjudaButton.tsx` e `components/ui/dialog.tsx` criados; perguntas com `ajuda` agora exibem botão Ajuda com popup, textos do guia e atalhos para TEXTAREA. Falta validar foco/teclado/mobile e amostrar 1 pergunta por seção em runtime. |
| 07 | 07-rascunho-autosave-e-envio.md | 🟨 em andamento | Autosave com debounce, salvar ao navegar, bloqueio server-side para briefing ENVIADO, revisão com respostas/brancos, confirmação e envio criados. Serviço `reabrirBriefing` criado. Falta validar persistência real, concorrência e navegação no runtime. |
| 08 | 08-upload-anexos-checklist.md | 🟨 em andamento | Campo ANEXO criado com checkbox, links, upload, listagem, download e remoção. Route handlers privados para upload/download/delete criados, com validação de extensão, MIME, tamanho, magic bytes e bloqueio para briefing ENVIADO. Falta validar upload real, permissões e arquivos inválidos no runtime. |
| 09 | 09-painel-admin.md | 🟨 em andamento | Painel admin criado: dashboard com contadores/últimos envios, clientes com busca/filtro, detalhe com CPF mascarado e revelação auditada, detalhe de briefing com respostas/anexos/status/reabertura/notas internas, edição de perguntas e ajudas. Migration `0003` criada. Falta validar permissões, auditoria e edição em runtime. |
| 10 | 10-exportacao-pdf-e-notificacoes.md | 🟨 em andamento | PDF em `/api/briefings/[id]/pdf` criado com acesso dono/admin, dados do cliente sem CPF completo, respostas, opções ☒/☐, anexos e confirmação. Botões de download adicionados para cliente/admin. E-mails de cadastro e envio criados; falhas não bloqueiam cadastro/envio. Tabela `EnvioNotificacao` e migration `0004` criadas. WhatsApp/Evolution fica pendente de consulta da versão/payload da instância. Falta validar PDF, SMTP e registros em runtime. |
| 11 | 11-lgpd-e-seguranca.md | 🟨 em andamento | Páginas legais marcadas como texto-base jurídico, Minha conta criada com edição de dados, exportação JSON e solicitação de exclusão (`SolicitacaoExclusao`, migration `0005`). Cabeçalhos de segurança adicionados no `next.config`, rate limits ampliados, checagem de origem em route handlers mutáveis, `/minha-conta` protegida no middleware e script `scripts/backup.sh` criado. Falta validar `curl -I`, isolamento manual entre clientes, backup/restauração e migrations em runtime. |
| 12 | 12-deploy-producao.md | 🟨 em andamento | Deploy preparado: `docker-compose.prod.yml` com app standalone, Postgres sem porta externa, volumes persistentes, Caddy HTTPS, serviço release (`migrate deploy` + seed), `/api/health`, scripts release/restore e README de produção. Falta executar em servidor real, configurar domínio/SPF/DKIM, validar HTTPS e fluxo cadastro → briefing → envio. |
Legenda: ⬜ pendente · 🟨 em andamento · ✅ concluída
## Decisões tomadas durante a execução
- O sistema está sendo criado na raiz do projeto, mantendo `Conhecimento/` e `Schema/` como referência.
- `Configuracao` foi incluída no schema desde a preparação inicial para armazenar os textos do seed.
- `Resposta.complemento` foi incluído para suportar o campo complementar previsto nas perguntas.
## Bloqueios
- O shell atual não possui `node`, `npm` nem `docker`; por isso os critérios de aceite que exigem instalação, build, lint, Docker, Prisma migrate e seed ainda não puderam ser executados.
- Etapa 12 depende de servidor com Docker, domínio apontado por DNS e credenciais SMTP reais para validar HTTPS, persistência, e-mail e fluxo ponta a ponta.
File diff suppressed because it is too large Load Diff
+136
View File
@@ -0,0 +1,136 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import type { StatusBriefing } from "@prisma/client";
import { db } from "@/lib/db";
import {
requireAdmin,
revelarCpfCliente,
atualizarStatusBriefing
} from "@/lib/admin-service";
import { reabrirBriefing } from "@/lib/briefing-service";
export type AdminActionState = {
ok: boolean;
message?: string;
value?: string;
};
export async function revelarCpfAction(
clienteId: string,
_state: AdminActionState
): Promise<AdminActionState> {
const cpf = await revelarCpfCliente(clienteId);
return { ok: true, value: cpf };
}
export async function mudarStatusAction(formData: FormData) {
await requireAdmin();
const briefingId = String(formData.get("briefingId") ?? "");
const status = String(formData.get("status") ?? "") as StatusBriefing;
if (!["ENVIADO", "EM_ANALISE", "CONCLUIDO"].includes(status)) {
return;
}
await atualizarStatusBriefing(briefingId, status);
revalidatePath(`/admin/briefings/${briefingId}`);
}
export async function reabrirBriefingAction(formData: FormData) {
await requireAdmin();
const briefingId = String(formData.get("briefingId") ?? "");
await reabrirBriefing(briefingId);
revalidatePath(`/admin/briefings/${briefingId}`);
}
export async function adicionarNotaInternaAction(formData: FormData) {
const admin = await requireAdmin();
const briefingId = String(formData.get("briefingId") ?? "");
const texto = String(formData.get("texto") ?? "").trim();
if (!texto) {
return;
}
await db.notaInterna.create({
data: {
briefingId,
adminId: admin.id,
texto
}
});
revalidatePath(`/admin/briefings/${briefingId}`);
}
const perguntaSchema = z.object({
perguntaId: z.string().min(1),
rotulo: z.string().trim().min(1),
dica: z.string().trim().optional(),
ordem: z.coerce.number().int().positive(),
ativo: z.boolean(),
obrigatoria: z.boolean(),
oQueResponder: z.string().trim().min(1),
porQuePerguntamos: z.string().trim().min(1),
miniExemplo: z.string().trim().min(1)
});
export async function salvarPerguntaAction(formData: FormData) {
await requireAdmin();
const parsed = perguntaSchema.safeParse({
perguntaId: formData.get("perguntaId"),
rotulo: formData.get("rotulo"),
dica: formData.get("dica") || undefined,
ordem: formData.get("ordem"),
ativo: formData.get("ativo") === "on",
obrigatoria: formData.get("obrigatoria") === "on",
oQueResponder: formData.get("oQueResponder"),
porQuePerguntamos: formData.get("porQuePerguntamos"),
miniExemplo: formData.get("miniExemplo")
});
if (!parsed.success) {
return;
}
await db.pergunta.update({
where: { id: parsed.data.perguntaId },
data: {
rotulo: parsed.data.rotulo,
dica: parsed.data.dica ?? null,
ordem: parsed.data.ordem,
ativo: parsed.data.ativo,
obrigatoria: parsed.data.obrigatoria,
ajuda: {
upsert: {
update: {
oQueResponder: parsed.data.oQueResponder,
porQuePerguntamos: parsed.data.porQuePerguntamos,
miniExemplo: parsed.data.miniExemplo
},
create: {
oQueResponder: parsed.data.oQueResponder,
porQuePerguntamos: parsed.data.porQuePerguntamos,
miniExemplo: parsed.data.miniExemplo
}
}
}
}
});
revalidatePath("/admin/perguntas");
}
export async function irParaClientes(formData: FormData) {
const busca = String(formData.get("busca") ?? "").trim();
const tipo = String(formData.get("tipo") ?? "").trim();
const params = new URLSearchParams();
if (busca) params.set("busca", busca);
if (tipo) params.set("tipo", tipo);
redirect(`/admin/clientes${params.size ? `?${params.toString()}` : ""}`);
}
+137
View File
@@ -0,0 +1,137 @@
import Link from "next/link";
import { adicionarNotaInternaAction, mudarStatusAction, reabrirBriefingAction } from "@/app/(admin)/admin/actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { getBriefingAdmin } from "@/lib/admin-service";
import { formatFileSize } from "@/lib/format";
function respostaTexto(resposta: {
texto: string | null;
complemento: string | null;
selecionadas: unknown;
}) {
const partes: string[] = [];
const selecionadas = Array.isArray(resposta.selecionadas)
? resposta.selecionadas.filter((item): item is string => typeof item === "string")
: [];
if (resposta.texto?.trim()) partes.push(resposta.texto.trim());
if (selecionadas.length > 0) partes.push(selecionadas.join(", "));
if (resposta.complemento?.trim()) partes.push(`Complemento: ${resposta.complemento.trim()}`);
return partes.join(" | ");
}
export default async function BriefingAdminPage({ params }: { params: { id: string } }) {
const { briefing, secoes } = await getBriefingAdmin(params.id);
return (
<main className="container py-8">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<Badge variant="muted">{briefing.status}</Badge>
<h1 className="mt-3 text-3xl font-semibold">{briefing.titulo}</h1>
<p className="mt-2 text-sm text-muted-foreground">
{briefing.cliente.nomeCompleto} | {briefing.cliente.user.email}
</p>
</div>
<Button asChild variant="outline">
<Link href="/admin">Voltar</Link>
</Button>
<Button asChild>
<a href={`/api/briefings/${briefing.id}/pdf`}>Baixar PDF</a>
</Button>
</div>
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
<div className="space-y-4">
{secoes.map((secao) => (
<Card key={secao.id}>
<CardHeader>
<CardTitle className="text-lg">{secao.ordem}. {secao.titulo}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{secao.perguntas.map((pergunta) => {
const resposta = pergunta.respostas[0];
const texto = resposta ? respostaTexto(resposta) : "";
return (
<div key={pergunta.id} className="rounded-md border p-3">
<p className="text-sm font-medium">{pergunta.rotulo}</p>
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">
{texto || "Sem resposta informada."}
</p>
{pergunta.anexos.length > 0 ? (
<ul className="mt-3 space-y-1 text-sm">
{pergunta.anexos.map((anexo) => (
<li key={anexo.id}>
<a className="text-primary hover:underline" href={`/api/anexos/${anexo.id}`}>
{anexo.nomeOriginal} ({formatFileSize(anexo.tamanho)})
</a>
</li>
))}
</ul>
) : null}
</div>
);
})}
</CardContent>
</Card>
))}
</div>
<aside className="space-y-4 lg:sticky lg:top-6 lg:self-start">
<Card>
<CardHeader>
<CardTitle className="text-lg">Acoes</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<form action={mudarStatusAction} className="grid gap-3">
<input type="hidden" name="briefingId" value={briefing.id} />
<select name="status" defaultValue={briefing.status} className="h-10 rounded-md border bg-background px-3 text-sm">
<option value="ENVIADO">ENVIADO</option>
<option value="EM_ANALISE">EM_ANALISE</option>
<option value="CONCLUIDO">CONCLUIDO</option>
</select>
<Button type="submit">Atualizar status</Button>
</form>
<form action={reabrirBriefingAction}>
<input type="hidden" name="briefingId" value={briefing.id} />
<Button type="submit" variant="outline" className="w-full">Reabrir para edicao</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Nota interna</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<form action={adicionarNotaInternaAction} className="space-y-3">
<input type="hidden" name="briefingId" value={briefing.id} />
<div className="space-y-2">
<Label htmlFor="texto">Texto</Label>
<Textarea id="texto" name="texto" required />
</div>
<Button type="submit">Adicionar nota</Button>
</form>
<div className="space-y-3">
{briefing.notasInternas.map((nota) => (
<div key={nota.id} className="rounded-md border p-3">
<p className="text-sm leading-6">{nota.texto}</p>
<p className="mt-2 text-xs text-muted-foreground">
{nota.admin.email} | {nota.criadoEm.toLocaleString("pt-BR")}
</p>
</div>
))}
</div>
</CardContent>
</Card>
</aside>
</div>
</main>
);
}
@@ -0,0 +1,28 @@
"use client";
import { useFormState, useFormStatus } from "react-dom";
import { revelarCpfAction, type AdminActionState } from "@/app/(admin)/admin/actions";
import { Button } from "@/components/ui/button";
const initialState: AdminActionState = { ok: false };
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" variant="outline" disabled={pending}>
{pending ? "Registrando..." : "Mostrar CPF"}
</Button>
);
}
export function MostrarCpf({ clienteId }: { clienteId: string }) {
const action = revelarCpfAction.bind(null, clienteId);
const [state, formAction] = useFormState(action, initialState);
return (
<form action={formAction} className="space-y-2">
<SubmitButton />
{state.value ? <p className="text-sm font-medium">{state.value}</p> : null}
</form>
);
}
+101
View File
@@ -0,0 +1,101 @@
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { decryptCpf } from "@/lib/crypto";
import { getClienteAdmin, maskCpf, whatsappLink } from "@/lib/admin-service";
import { MostrarCpf } from "./mostrar-cpf";
export default async function ClienteAdminDetalhePage({ params }: { params: { id: string } }) {
const cliente = await getClienteAdmin(params.id);
const cpfMascarado = maskCpf(decryptCpf(cliente.cpfCifrado));
return (
<main className="container py-8">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<Badge variant="muted">{cliente.tipo}</Badge>
<h1 className="mt-3 text-3xl font-semibold">{cliente.nomeCompleto}</h1>
<p className="mt-2 text-sm text-muted-foreground">{cliente.user.email}</p>
</div>
<Button asChild variant="outline">
<Link href="/admin/clientes">Voltar</Link>
</Button>
</div>
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
<Card>
<CardHeader>
<CardTitle>Dados cadastrais</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div>
<p className="font-medium">CPF</p>
<p className="text-muted-foreground">{cpfMascarado}</p>
<div className="mt-2">
<MostrarCpf clienteId={cliente.id} />
</div>
</div>
<div>
<p className="font-medium">WhatsApp</p>
<a className="text-primary hover:underline" href={whatsappLink(cliente.whatsapp)} target="_blank" rel="noreferrer">
{cliente.whatsapp}
</a>
</div>
{cliente.razaoSocial ? (
<div>
<p className="font-medium">Razao social</p>
<p className="text-muted-foreground">{cliente.razaoSocial}</p>
</div>
) : null}
{cliente.cnpj ? (
<div>
<p className="font-medium">CNPJ</p>
<p className="text-muted-foreground">{cliente.cnpj}</p>
</div>
) : null}
<div>
<p className="font-medium">Endereco</p>
<p className="text-muted-foreground">
{[cliente.logradouro, cliente.numero, cliente.bairro, cliente.cidade, cliente.uf]
.filter(Boolean)
.join(", ") || "Nao informado"}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Briefings</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Titulo</TableHead>
<TableHead>Status</TableHead>
<TableHead>Atualizado</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cliente.briefings.map((briefing) => (
<TableRow key={briefing.id}>
<TableCell>
<Link className="font-medium text-primary hover:underline" href={`/admin/briefings/${briefing.id}`}>
{briefing.titulo}
</Link>
</TableCell>
<TableCell><Badge variant="muted">{briefing.status}</Badge></TableCell>
<TableCell>{briefing.atualizadoEm.toLocaleString("pt-BR")}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</main>
);
}
+89
View File
@@ -0,0 +1,89 @@
import Link from "next/link";
import { irParaClientes } from "@/app/(admin)/admin/actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { listarClientesAdmin, maskCpf } from "@/lib/admin-service";
import { decryptCpf } from "@/lib/crypto";
export default async function ClientesAdminPage({
searchParams
}: {
searchParams: { busca?: string; tipo?: "PF" | "PJ" };
}) {
const clientes = await listarClientesAdmin({
busca: searchParams.busca,
tipo: searchParams.tipo
});
return (
<main className="container py-8">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-3xl font-semibold">Clientes</h1>
<p className="mt-2 text-sm text-muted-foreground">Busque clientes e acompanhe seus briefings.</p>
</div>
<Button asChild variant="outline">
<Link href="/admin">Voltar</Link>
</Button>
</div>
<Card className="mb-6">
<CardContent className="p-4">
<form action={irParaClientes} className="grid gap-4 sm:grid-cols-[1fr_160px_auto] sm:items-end">
<div className="space-y-2">
<Label htmlFor="busca">Busca</Label>
<Input id="busca" name="busca" defaultValue={searchParams.busca ?? ""} />
</div>
<div className="space-y-2">
<Label htmlFor="tipo">Tipo</Label>
<select id="tipo" name="tipo" defaultValue={searchParams.tipo ?? ""} className="h-10 w-full rounded-md border bg-background px-3 text-sm">
<option value="">Todos</option>
<option value="PF">PF</option>
<option value="PJ">PJ</option>
</select>
</div>
<Button type="submit">Filtrar</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{clientes.length} cliente(s)</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>E-mail</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>CPF</TableHead>
<TableHead>Briefings</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{clientes.map((cliente) => (
<TableRow key={cliente.id}>
<TableCell>
<Link href={`/admin/clientes/${cliente.id}`} className="font-medium text-primary hover:underline">
{cliente.nomeCompleto}
</Link>
</TableCell>
<TableCell>{cliente.user.email}</TableCell>
<TableCell><Badge variant="muted">{cliente.tipo}</Badge></TableCell>
<TableCell>{maskCpf(decryptCpf(cliente.cpfCifrado))}</TableCell>
<TableCell>{cliente.briefings.length}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</main>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { auth } from "@/auth";
import { sair } from "@/app/(auth)/actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { getAdminDashboard } from "@/lib/admin-service";
import Link from "next/link";
export default async function AdminPage() {
const session = await auth();
const dashboard = await getAdminDashboard();
const countByStatus = Object.fromEntries(
dashboard.porStatus.map((item) => [item.status, item._count._all])
);
return (
<main className="container py-8">
<header className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-semibold">Painel administrativo</h1>
<p className="mt-2 text-sm text-muted-foreground">
Area restrita para {session?.user.email}.
</p>
</div>
<form action={sair}>
<Button variant="outline" type="submit">
Sair
</Button>
</form>
</header>
<div className="mb-6 flex flex-wrap gap-3">
<Button asChild variant="outline">
<Link href="/admin/clientes">Clientes</Link>
</Button>
<Button asChild variant="outline">
<Link href="/admin/perguntas">Perguntas</Link>
</Button>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Briefings enviados</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold">{countByStatus.ENVIADO ?? 0}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Em analise</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold">{countByStatus.EM_ANALISE ?? 0}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Concluidos</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold">{countByStatus.CONCLUIDO ?? 0}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Novos em 7 dias</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold">{dashboard.novosUltimos7Dias}</p>
</CardContent>
</Card>
</div>
<Card className="mt-6">
<CardHeader>
<CardTitle>Ultimos envios</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Briefing</TableHead>
<TableHead>Cliente</TableHead>
<TableHead>Status</TableHead>
<TableHead>Enviado em</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dashboard.ultimosEnvios.map((briefing) => (
<TableRow key={briefing.id}>
<TableCell>
<Link className="font-medium text-primary hover:underline" href={`/admin/briefings/${briefing.id}`}>
{briefing.titulo}
</Link>
</TableCell>
<TableCell>{briefing.cliente.nomeCompleto}</TableCell>
<TableCell><Badge variant="muted">{briefing.status}</Badge></TableCell>
<TableCell>{briefing.enviadoEm?.toLocaleString("pt-BR")}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</main>
);
}
+90
View File
@@ -0,0 +1,90 @@
import Link from "next/link";
import { salvarPerguntaAction } from "@/app/(admin)/admin/actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { listarPerguntasAdmin } from "@/lib/admin-service";
export default async function PerguntasAdminPage() {
const secoes = await listarPerguntasAdmin();
return (
<main className="container py-8">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-3xl font-semibold">Perguntas e ajudas</h1>
<p className="mt-2 text-sm text-muted-foreground">
Edite textos do briefing sem alterar codigo.
</p>
</div>
<Button asChild variant="outline">
<Link href="/admin">Voltar</Link>
</Button>
</div>
<div className="space-y-6">
{secoes.map((secao) => (
<Card key={secao.id}>
<CardHeader>
<CardTitle>{secao.ordem}. {secao.titulo}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{secao.perguntas.map((pergunta) => (
<form key={pergunta.id} action={salvarPerguntaAction} className="space-y-4 rounded-lg border p-4">
<input type="hidden" name="perguntaId" value={pergunta.id} />
<div className="flex flex-wrap items-center gap-2">
<Badge variant="muted">{pergunta.tipo}</Badge>
{!pergunta.ativo ? <Badge variant="outline">Inativa</Badge> : null}
</div>
<div className="grid gap-4 md:grid-cols-[100px_1fr]">
<div className="space-y-2">
<Label htmlFor={`ordem-${pergunta.id}`}>Ordem</Label>
<Input id={`ordem-${pergunta.id}`} name="ordem" type="number" defaultValue={pergunta.ordem} />
</div>
<div className="space-y-2">
<Label htmlFor={`rotulo-${pergunta.id}`}>Rotulo</Label>
<Input id={`rotulo-${pergunta.id}`} name="rotulo" defaultValue={pergunta.rotulo} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`dica-${pergunta.id}`}>Dica</Label>
<Input id={`dica-${pergunta.id}`} name="dica" defaultValue={pergunta.dica ?? ""} />
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex items-center gap-3 rounded-md border p-3 text-sm">
<Checkbox name="ativo" defaultChecked={pergunta.ativo} />
Ativa
</label>
<label className="flex items-center gap-3 rounded-md border p-3 text-sm">
<Checkbox name="obrigatoria" defaultChecked={pergunta.obrigatoria} />
Obrigatoria
</label>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor={`oq-${pergunta.id}`}>O que responder</Label>
<Textarea id={`oq-${pergunta.id}`} name="oQueResponder" defaultValue={pergunta.ajuda?.oQueResponder ?? ""} />
</div>
<div className="space-y-2">
<Label htmlFor={`pq-${pergunta.id}`}>Por que perguntamos</Label>
<Textarea id={`pq-${pergunta.id}`} name="porQuePerguntamos" defaultValue={pergunta.ajuda?.porQuePerguntamos ?? ""} />
</div>
<div className="space-y-2">
<Label htmlFor={`ex-${pergunta.id}`}>Mini exemplo</Label>
<Textarea id={`ex-${pergunta.id}`} name="miniExemplo" defaultValue={pergunta.ajuda?.miniExemplo ?? ""} />
</div>
</div>
<Button type="submit">Salvar pergunta</Button>
</form>
))}
</CardContent>
</Card>
))}
</div>
</main>
);
}
+7
View File
@@ -0,0 +1,7 @@
"use server";
import { signOut } from "@/auth";
export async function sair() {
await signOut({ redirectTo: "/login" });
}
@@ -0,0 +1,97 @@
"use server";
import { z } from "zod";
import { getBriefingDoCliente } from "@/lib/briefing-service";
import { db } from "@/lib/db";
const respostaSchema = z.object({
texto: z.string().optional(),
complemento: z.string().optional(),
selecionadas: z.array(z.string()).optional()
});
const salvarRespostasSchema = z.object({
perguntas: z.record(respostaSchema)
});
export type SalvarRespostasResult = {
ok: boolean;
savedAt?: string;
message?: string;
};
function hasValue(resposta: z.infer<typeof respostaSchema>) {
return Boolean(
resposta.texto?.trim() ||
resposta.complemento?.trim() ||
(resposta.selecionadas && resposta.selecionadas.length > 0)
);
}
export async function salvarRespostas(
briefingId: string,
secaoId: string,
valores: unknown
): Promise<SalvarRespostasResult> {
const parsed = salvarRespostasSchema.safeParse(valores);
if (!parsed.success) {
return { ok: false, message: "Dados invalidos para salvar." };
}
const briefing = await getBriefingDoCliente(briefingId);
if (briefing.status !== "RASCUNHO") {
return { ok: false, message: "Este briefing ja foi enviado e nao aceita novas alteracoes." };
}
const perguntasDaSecao = await db.pergunta.findMany({
where: {
secaoId,
ativo: true
},
select: { id: true }
});
const perguntaIds = new Set(perguntasDaSecao.map((pergunta) => pergunta.id));
await db.$transaction(
Object.entries(parsed.data.perguntas)
.filter(([perguntaId]) => perguntaIds.has(perguntaId))
.map(([perguntaId, resposta]) => {
if (!hasValue(resposta)) {
return db.resposta.deleteMany({
where: {
briefingId,
perguntaId
}
});
}
return db.resposta.upsert({
where: {
briefingId_perguntaId: {
briefingId,
perguntaId
}
},
update: {
texto: resposta.texto?.trim() || null,
complemento: resposta.complemento?.trim() || null,
selecionadas: resposta.selecionadas?.length ? resposta.selecionadas : null
},
create: {
briefingId,
perguntaId,
texto: resposta.texto?.trim() || null,
complemento: resposta.complemento?.trim() || null,
selecionadas: resposta.selecionadas?.length ? resposta.selecionadas : null
}
});
})
);
return {
ok: true,
savedAt: new Date().toISOString()
};
}
@@ -0,0 +1,108 @@
import Link from "next/link";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { getSecaoBriefing } from "@/lib/briefing-service";
import { SecaoForm } from "./secao-form";
export default async function SecaoBriefingPage({
params
}: {
params: { id: string; secao: string };
}) {
const ordemSecao = Number(params.secao);
const { briefing, secao, secoes, progressoGeral, anterior, proxima } = await getSecaoBriefing(
params.id,
ordemSecao
);
const percentual =
progressoGeral.total > 0 ? Math.round((progressoGeral.respondidas / progressoGeral.total) * 100) : 0;
return (
<main className="container py-8">
<div className="mb-6">
<Button asChild variant="ghost" className="mb-4 px-0">
<Link href={`/briefings/${briefing.id}`}>
<ChevronLeft className="mr-2 h-4 w-4" />
Voltar para abertura
</Link>
</Button>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<Badge variant="muted">Secao {secao.ordem} de {secoes.length}</Badge>
<h1 className="mt-3 text-3xl font-semibold">{secao.titulo}</h1>
{secao.descricao ? <p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">{secao.descricao}</p> : null}
</div>
<div className="min-w-56 space-y-2">
<div className="flex justify-between text-sm">
<span className="font-medium">Progresso geral</span>
<span className="text-muted-foreground">{progressoGeral.respondidas}/{progressoGeral.total}</span>
</div>
<Progress value={percentual} />
</div>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-[280px_1fr]">
<aside className="lg:sticky lg:top-6 lg:self-start">
<Card>
<CardContent className="p-3">
<nav aria-label="Secoes do briefing" className="grid gap-1">
{secoes.map((item) => (
<Link
key={item.id}
href={`/briefings/${briefing.id}/${item.ordem}`}
className={`rounded-md px-3 py-2 text-sm transition ${
item.ordem === secao.ordem
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
}`}
>
<span className="block font-medium">{item.ordem}. {item.titulo}</span>
<span className={item.ordem === secao.ordem ? "text-primary-foreground/80" : "text-muted-foreground"}>
{item.respondidas}/{item.total} respondidas
</span>
</Link>
))}
</nav>
</CardContent>
</Card>
</aside>
<div className="space-y-6">
<SecaoForm
briefingId={briefing.id}
secaoId={secao.id}
perguntas={secao.perguntas}
readOnly={briefing.status !== "RASCUNHO"}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:justify-between">
{anterior ? (
<Button asChild variant="outline">
<Link href={`/briefings/${briefing.id}/${anterior.ordem}`}>
<ChevronLeft className="mr-2 h-4 w-4" />
Secao anterior
</Link>
</Button>
) : <span />}
{proxima ? (
<Button asChild>
<Link href={`/briefings/${briefing.id}/${proxima.ordem}`}>
Proxima secao
<ChevronRight className="ml-2 h-4 w-4" />
</Link>
</Button>
) : (
<Button asChild>
<Link href={`/briefings/${briefing.id}/revisao`}>Ir para revisao</Link>
</Button>
)}
</div>
</div>
</div>
</main>
);
}
@@ -0,0 +1,205 @@
"use client";
import type { Ajuda, Anexo, Pergunta, Resposta } from "@prisma/client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { FormProvider, useForm } from "react-hook-form";
import { PerguntaField } from "@/components/form/PerguntaField";
import { Button } from "@/components/ui/button";
import { salvarRespostas } from "./actions";
type PerguntaComDados = Pergunta & {
ajuda: Ajuda | null;
anexos?: Anexo[];
respostas: Resposta[];
};
type Props = {
briefingId: string;
secaoId: string;
perguntas: PerguntaComDados[];
readOnly?: boolean;
};
type FormValues = {
perguntas: Record<
string,
{
texto?: string;
selecionadas?: string[];
complemento?: string;
}
>;
};
function getDefaultValues(perguntas: PerguntaComDados[]): FormValues {
return {
perguntas: perguntas.reduce<FormValues["perguntas"]>((acc, pergunta) => {
const resposta = pergunta.respostas[0];
const selecionadas = Array.isArray(resposta?.selecionadas)
? resposta.selecionadas.filter((item): item is string => typeof item === "string")
: [];
acc[pergunta.id] = {
texto: resposta?.texto ?? "",
selecionadas,
complemento: resposta?.complemento ?? ""
};
return acc;
}, {})
};
}
type SaveStatus = "idle" | "dirty" | "saving" | "saved" | "error";
function formatTime(value: string) {
return new Intl.DateTimeFormat("pt-BR", {
hour: "2-digit",
minute: "2-digit"
}).format(new Date(value));
}
export function SecaoForm({ briefingId, secaoId, perguntas, readOnly = false }: Props) {
const [status, setStatus] = useState<SaveStatus>("idle");
const [savedAt, setSavedAt] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [, startTransition] = useTransition();
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const router = useRouter();
const form = useForm<FormValues>({
defaultValues: getDefaultValues(perguntas)
});
const watchedValues = form.watch();
async function saveNow(values: FormValues) {
if (readOnly) {
return true;
}
setStatus("saving");
setMessage(null);
const result = await salvarRespostas(briefingId, secaoId, values);
startTransition(() => {
if (!result.ok) {
setStatus("error");
setMessage(result.message ?? "Erro ao salvar. Tente novamente.");
return;
}
form.reset(values);
setStatus("saved");
setSavedAt(result.savedAt ?? new Date().toISOString());
});
return result.ok;
}
useEffect(() => {
if (readOnly || !form.formState.isDirty) {
return;
}
setStatus("dirty");
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
void saveNow(form.getValues());
}, 800);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [watchedValues, form, readOnly]);
useEffect(() => {
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (form.formState.isDirty && status !== "saved") {
event.preventDefault();
}
}
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [form.formState.isDirty, status]);
useEffect(() => {
async function handleClick(event: MouseEvent) {
const link = (event.target as HTMLElement).closest("a");
if (!link || readOnly || !form.formState.isDirty) {
return;
}
const href = link.getAttribute("href");
if (!href || !href.startsWith(`/briefings/${briefingId}`)) {
return;
}
event.preventDefault();
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
const saved = await saveNow(form.getValues());
if (saved) {
router.push(href);
}
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, [briefingId, form, readOnly, router]);
return (
<FormProvider {...form}>
<form className="space-y-4">
<div className="sticky top-0 z-20 rounded-lg border bg-card p-3 text-sm shadow-sm">
{readOnly ? (
<span className="text-muted-foreground">Briefing enviado. Respostas em modo leitura.</span>
) : status === "saving" ? (
<span className="text-muted-foreground">Salvando...</span>
) : status === "saved" && savedAt ? (
<span className="text-muted-foreground">Salvo as {formatTime(savedAt)}</span>
) : status === "error" ? (
<span className="text-destructive">{message ?? "Erro ao salvar — tentar de novo"}</span>
) : status === "dirty" ? (
<span className="text-muted-foreground">Alteracoes pendentes...</span>
) : (
<span className="text-muted-foreground">Rascunho pronto para edicao.</span>
)}
</div>
{perguntas.map((pergunta, index) => (
<fieldset key={pergunta.id} disabled={readOnly} className="disabled:opacity-75">
<PerguntaField
briefingId={briefingId}
pergunta={pergunta}
index={index}
readOnly={readOnly}
/>
</fieldset>
))}
{!readOnly ? (
<div className="flex flex-col gap-3 sm:flex-row">
<Button type="button" variant="outline" onClick={() => form.reset()}>
Descartar alteracoes locais
</Button>
<Button type="button" onClick={() => saveNow(form.getValues())}>
Salvar agora
</Button>
</div>
) : null}
</form>
</FormProvider>
);
}
@@ -0,0 +1,37 @@
"use client";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type Props = {
empresaMarca: string;
responsavel: string;
contato: string;
data: string;
};
export function CabecalhoBriefingForm({ empresaMarca, responsavel, contato, data }: Props) {
return (
<form className="grid gap-4">
<div className="space-y-2">
<Label htmlFor="empresaMarca">Empresa / marca</Label>
<Input id="empresaMarca" name="empresaMarca" defaultValue={empresaMarca} />
</div>
<div className="space-y-2">
<Label htmlFor="responsavel">Responsavel</Label>
<Input id="responsavel" name="responsavel" defaultValue={responsavel} />
</div>
<div className="space-y-2">
<Label htmlFor="data">Data</Label>
<Input id="data" name="data" type="date" defaultValue={data} />
</div>
<div className="space-y-2">
<Label htmlFor="contato">Contato</Label>
<Input id="contato" name="contato" defaultValue={contato} />
</div>
<p className="text-xs leading-5 text-muted-foreground">
Estes campos serao salvos junto com o rascunho na etapa de autosave.
</p>
</form>
);
}
+110
View File
@@ -0,0 +1,110 @@
import Link from "next/link";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { getAberturaBriefing } from "@/lib/briefing-service";
import { CabecalhoBriefingForm } from "./cabecalho-briefing-form";
function asStringArray(value: unknown) {
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
}
export default async function AberturaBriefingPage({ params }: { params: { id: string } }) {
const { briefing, progresso, comoPreencher, avisoSenhas } = await getAberturaBriefing(params.id);
const totalPerguntas = progresso.reduce((sum, secao) => sum + secao.total, 0);
const totalRespondidas = progresso.reduce((sum, secao) => sum + secao.respondidas, 0);
const percentual = totalPerguntas > 0 ? Math.round((totalRespondidas / totalPerguntas) * 100) : 0;
const primeiraPendente = progresso.find((secao) => secao.respondidas < secao.total) ?? progresso[0];
return (
<main className="container py-8">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<Badge variant="muted">{briefing.status.replace("_", " ")}</Badge>
<h1 className="mt-3 text-3xl font-semibold">{briefing.titulo}</h1>
<p className="mt-2 text-sm text-muted-foreground">
Revise as orientacoes e continue pela secao indicada.
</p>
</div>
{primeiraPendente ? (
<Button asChild>
<Link href={`/briefings/${briefing.id}/${primeiraPendente.ordem}`}>Continuar de onde parei</Link>
</Button>
) : null}
</div>
<div className="mb-6 space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">Progresso geral</span>
<span className="text-muted-foreground">{totalRespondidas}/{totalPerguntas}</span>
</div>
<Progress value={percentual} />
</div>
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-lg">Cabecalho do briefing</CardTitle>
</CardHeader>
<CardContent>
<CabecalhoBriefingForm
empresaMarca={briefing.cliente.razaoSocial ?? briefing.cliente.nomeCompleto}
responsavel={briefing.cliente.nomeCompleto}
contato={`${briefing.cliente.user.email} | ${briefing.cliente.whatsapp}`}
data={new Date().toISOString().slice(0, 10)}
/>
</CardContent>
</Card>
{avisoSenhas ? (
<Alert>
<AlertTitle>Aviso importante</AlertTitle>
<AlertDescription>{String(avisoSenhas)}</AlertDescription>
</Alert>
) : null}
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">Como preencher</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-3 text-sm leading-6 text-muted-foreground">
{asStringArray(comoPreencher).map((item) => (
<li key={item} className="rounded-md bg-muted p-3">{item}</li>
))}
</ul>
</CardContent>
</Card>
<div className="grid gap-3">
{progresso.map((secao) => {
const secaoPercentual = secao.total > 0 ? Math.round((secao.respondidas / secao.total) * 100) : 0;
return (
<Link
key={secao.id}
href={`/briefings/${briefing.id}/${secao.ordem}`}
className="rounded-lg border bg-card p-4 transition hover:border-primary"
>
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<p className="font-medium">{secao.ordem}. {secao.titulo}</p>
{secao.descricao ? <p className="mt-1 text-sm text-muted-foreground">{secao.descricao}</p> : null}
</div>
<Badge variant="muted">{secao.respondidas}/{secao.total}</Badge>
</div>
<Progress value={secaoPercentual} />
</Link>
);
})}
</div>
</div>
</div>
</main>
);
}
@@ -0,0 +1,67 @@
"use server";
import { redirect } from "next/navigation";
import { getBriefingDoCliente } from "@/lib/briefing-service";
import { db } from "@/lib/db";
import { notificarBriefingEnviado } from "@/lib/notifications";
export type EnvioBriefingState = {
ok: boolean;
message?: string;
};
export async function enviarBriefing(
briefingId: string,
_state: EnvioBriefingState,
formData: FormData
): Promise<EnvioBriefingState> {
const briefing = await getBriefingDoCliente(briefingId);
if (briefing.status !== "RASCUNHO") {
return { ok: false, message: "Este briefing ja foi enviado." };
}
if (formData.get("confirmacaoCliente") !== "on") {
return { ok: false, message: "Confirme a declaracao do cliente para enviar." };
}
const totalPerguntas = await db.pergunta.count({
where: { ativo: true }
});
const respostas = await db.resposta.findMany({
where: { briefingId: briefing.id },
select: {
texto: true,
complemento: true,
selecionadas: true
}
});
const respondidas = respostas.filter((resposta) => {
const selecionadas = Array.isArray(resposta.selecionadas) ? resposta.selecionadas : [];
return Boolean(resposta.texto?.trim() || resposta.complemento?.trim() || selecionadas.length > 0);
}).length;
if (respondidas < totalPerguntas && formData.get("confirmarBrancos") !== "on") {
return {
ok: false,
message: "Confirme que deseja enviar mesmo com perguntas em branco."
};
}
await db.briefing.update({
where: { id: briefing.id },
data: {
status: "ENVIADO",
confirmadoEm: new Date(),
enviadoEm: new Date()
}
});
try {
await notificarBriefingEnviado(briefing.id);
} catch {
// Falha de notificacao nao pode impedir o envio do briefing.
}
redirect(`/briefings/${briefing.id}/revisao`);
}
@@ -0,0 +1,66 @@
"use client";
import { useFormState, useFormStatus } from "react-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { enviarBriefing, type EnvioBriefingState } from "./actions";
const initialState: EnvioBriefingState = { ok: false };
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" disabled={pending}>
{pending ? "Enviando..." : "Enviar briefing"}
</Button>
);
}
export function EnvioBriefingForm({
briefingId,
confirmacaoCliente,
perguntasEmBranco
}: {
briefingId: string;
confirmacaoCliente: string;
perguntasEmBranco: number;
}) {
const enviarComId = enviarBriefing.bind(null, briefingId);
const [state, action] = useFormState(enviarComId, initialState);
return (
<form action={action} className="space-y-4 rounded-lg border bg-card p-4">
{state.message ? (
<Alert variant="destructive">
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
{perguntasEmBranco > 0 ? (
<Alert>
<AlertDescription>
Existem {perguntasEmBranco} pergunta(s) em branco. Voce pode enviar mesmo assim se estiver tudo certo.
</AlertDescription>
</Alert>
) : null}
<p className="text-sm leading-6 text-muted-foreground">{confirmacaoCliente}</p>
<label className="flex items-start gap-3 rounded-md border p-3 text-sm leading-6">
<Checkbox name="confirmacaoCliente" required />
<span>Confirmo a declaracao acima e desejo enviar o briefing para analise.</span>
</label>
{perguntasEmBranco > 0 ? (
<label className="flex items-start gap-3 rounded-md border p-3 text-sm leading-6">
<Checkbox name="confirmarBrancos" required />
<span>Estou ciente de que ha perguntas em branco e quero enviar mesmo assim.</span>
</label>
) : null}
<SubmitButton />
</form>
);
}
@@ -0,0 +1,131 @@
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getRevisaoBriefing } from "@/lib/briefing-service";
import { EnvioBriefingForm } from "./envio-briefing-form";
function respostaTexto(resposta: {
texto: string | null;
complemento: string | null;
selecionadas: unknown;
}) {
const partes: string[] = [];
const selecionadas = Array.isArray(resposta.selecionadas)
? resposta.selecionadas.filter((item): item is string => typeof item === "string")
: [];
if (resposta.texto?.trim()) {
partes.push(resposta.texto.trim());
}
if (selecionadas.length > 0) {
partes.push(selecionadas.join(", "));
}
if (resposta.complemento?.trim()) {
partes.push(`Complemento: ${resposta.complemento.trim()}`);
}
return partes.join(" | ");
}
export default async function RevisaoBriefingPage({ params }: { params: { id: string } }) {
const { briefing, secoes, confirmacaoCliente } = await getRevisaoBriefing(params.id);
const perguntas = secoes.flatMap((secao) => secao.perguntas);
const perguntasEmBranco = perguntas.filter((pergunta) => {
const resposta = pergunta.respostas[0];
const anexos = "anexos" in pergunta && Array.isArray(pergunta.anexos) ? pergunta.anexos : [];
return (!resposta || !respostaTexto(resposta)) && anexos.length === 0;
}).length;
return (
<main className="container py-8">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<Badge variant="muted">{briefing.status.replace("_", " ")}</Badge>
<h1 className="mt-3 text-3xl font-semibold">Revisao do briefing</h1>
<p className="mt-2 text-sm text-muted-foreground">
Confira as respostas antes de enviar para analise.
</p>
</div>
<Button asChild variant="outline">
<Link href={`/briefings/${briefing.id}`}>Voltar para abertura</Link>
</Button>
{briefing.status !== "RASCUNHO" ? (
<Button asChild>
<a href={`/api/briefings/${briefing.id}/pdf`}>Baixar PDF</a>
</Button>
) : null}
</div>
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
<div className="grid gap-4">
{secoes.map((secao) => (
<Card key={secao.id}>
<CardHeader>
<CardTitle className="text-lg">{secao.ordem}. {secao.titulo}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{secao.perguntas.map((pergunta) => {
const resposta = pergunta.respostas[0];
const texto = resposta ? respostaTexto(resposta) : "";
const anexos = "anexos" in pergunta && Array.isArray(pergunta.anexos) ? pergunta.anexos : [];
return (
<div key={pergunta.id} className="rounded-md border p-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<p className="text-sm font-medium">{pergunta.rotulo}</p>
{!texto && anexos.length === 0 ? <Badge variant="outline">Em branco</Badge> : null}
</div>
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">
{texto || "Sem resposta informada."}
</p>
{anexos.length > 0 ? (
<ul className="mt-3 space-y-1 text-sm text-muted-foreground">
{anexos.map((anexo) => (
<li key={anexo.id}>
<a className="text-primary underline-offset-4 hover:underline" href={`/api/anexos/${anexo.id}`}>
{anexo.nomeOriginal}
</a>
</li>
))}
</ul>
) : null}
</div>
);
})}
{briefing.status === "RASCUNHO" ? (
<Button asChild variant="outline">
<Link href={`/briefings/${briefing.id}/${secao.ordem}`}>Editar secao</Link>
</Button>
) : null}
</CardContent>
</Card>
))}
</div>
<aside className="lg:sticky lg:top-6 lg:self-start">
{briefing.status === "RASCUNHO" ? (
<EnvioBriefingForm
briefingId={briefing.id}
confirmacaoCliente={String(confirmacaoCliente ?? "")}
perguntasEmBranco={perguntasEmBranco}
/>
) : (
<Card>
<CardHeader>
<CardTitle className="text-lg">Briefing enviado</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm leading-6 text-muted-foreground">
Este briefing foi enviado e esta bloqueado para edicao.
</p>
</CardContent>
</Card>
)}
</aside>
</div>
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
"use server";
import { redirect } from "next/navigation";
import { criarBriefingParaCliente } from "@/lib/briefing-service";
export async function iniciarBriefing() {
const briefing = await criarBriefingParaCliente();
redirect(`/briefings/${briefing.id}`);
}
+74
View File
@@ -0,0 +1,74 @@
import { auth } from "@/auth";
import { sair } from "@/app/(auth)/actions";
import { iniciarBriefing } from "@/app/(cliente)/briefings/actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { listarBriefingsDoCliente } from "@/lib/briefing-service";
import Link from "next/link";
export default async function BriefingsPage() {
const session = await auth();
const briefings = await listarBriefingsDoCliente();
return (
<main className="container py-8">
<header className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-semibold">Meus briefings</h1>
<p className="mt-2 text-sm text-muted-foreground">
{session?.user.email} pode iniciar um briefing e continuar depois.
</p>
</div>
<form action={sair}>
<div className="flex gap-2">
<Button asChild variant="outline">
<Link href="/minha-conta">Minha conta</Link>
</Button>
<Button variant="outline" type="submit">
Sair
</Button>
</div>
</form>
</header>
<form action={iniciarBriefing} className="mb-6">
<Button type="submit">Iniciar meu briefing</Button>
</form>
{briefings.length === 0 ? (
<Card>
<CardHeader>
<CardTitle>Nenhum briefing iniciado</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm leading-6 text-muted-foreground">
Inicie seu primeiro briefing para organizar as informacoes do projeto.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{briefings.map((briefing) => (
<Card key={briefing.id}>
<CardHeader className="gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle className="text-lg">{briefing.titulo}</CardTitle>
<p className="mt-2 text-sm text-muted-foreground">
Atualizado em {briefing.atualizadoEm.toLocaleDateString("pt-BR")}
</p>
</div>
<Badge variant="muted">{briefing.status.replace("_", " ")}</Badge>
</CardHeader>
<CardContent>
<Button asChild variant="outline">
<Link href={`/briefings/${briefing.id}`}>Abrir briefing</Link>
</Button>
</CardContent>
</Card>
))}
</div>
)}
</main>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getClienteConta } from "@/lib/account-service";
import { db } from "@/lib/db";
import { normalizeWhatsapp } from "@/lib/validators";
export type ContaState = {
ok: boolean;
message?: string;
};
const atualizarContaSchema = z.object({
nomeCompleto: z.string().trim().min(3),
whatsapp: z.string().trim().min(10),
razaoSocial: z.string().trim().optional(),
cep: z.string().trim().optional(),
logradouro: z.string().trim().optional(),
numero: z.string().trim().optional(),
complemento: z.string().trim().optional(),
bairro: z.string().trim().optional(),
cidade: z.string().trim().optional(),
uf: z.string().trim().max(2).optional()
});
export async function atualizarConta(
_state: ContaState,
formData: FormData
): Promise<ContaState> {
const cliente = await getClienteConta();
const parsed = atualizarContaSchema.safeParse({
nomeCompleto: formData.get("nomeCompleto"),
whatsapp: formData.get("whatsapp"),
razaoSocial: formData.get("razaoSocial") || undefined,
cep: formData.get("cep") || undefined,
logradouro: formData.get("logradouro") || undefined,
numero: formData.get("numero") || undefined,
complemento: formData.get("complemento") || undefined,
bairro: formData.get("bairro") || undefined,
cidade: formData.get("cidade") || undefined,
uf: formData.get("uf") || undefined
});
if (!parsed.success) {
return { ok: false, message: "Revise os dados informados." };
}
const whatsapp = normalizeWhatsapp(parsed.data.whatsapp);
if (!whatsapp) {
return { ok: false, message: "Informe um WhatsApp com DDD." };
}
await db.cliente.update({
where: { id: cliente.id },
data: {
nomeCompleto: parsed.data.nomeCompleto,
whatsapp,
razaoSocial: parsed.data.razaoSocial ?? null,
cep: parsed.data.cep ?? null,
logradouro: parsed.data.logradouro ?? null,
numero: parsed.data.numero ?? null,
complemento: parsed.data.complemento ?? null,
bairro: parsed.data.bairro ?? null,
cidade: parsed.data.cidade ?? null,
uf: parsed.data.uf?.toUpperCase() ?? null
}
});
revalidatePath("/minha-conta");
return { ok: true, message: "Dados atualizados." };
}
export async function solicitarExclusao(formData: FormData) {
const cliente = await getClienteConta();
const motivo = String(formData.get("motivo") ?? "").trim();
await db.solicitacaoExclusao.create({
data: {
clienteId: cliente.id,
motivo: motivo || null
}
});
revalidatePath("/minha-conta");
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useFormState, useFormStatus } from "react-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { atualizarConta, type ContaState } from "./actions";
type Props = {
cliente: {
nomeCompleto: string;
whatsapp: string;
razaoSocial: string | null;
cep: string | null;
logradouro: string | null;
numero: string | null;
complemento: string | null;
bairro: string | null;
cidade: string | null;
uf: string | null;
};
};
const initialState: ContaState = { ok: false };
function SubmitButton() {
const { pending } = useFormStatus();
return <Button type="submit" disabled={pending}>{pending ? "Salvando..." : "Salvar dados"}</Button>;
}
export function ContaForm({ cliente }: Props) {
const [state, action] = useFormState(atualizarConta, initialState);
return (
<form action={action} className="space-y-4">
{state.message ? (
<Alert variant={state.ok ? "default" : "destructive"}>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="nomeCompleto">Nome completo</Label>
<Input id="nomeCompleto" name="nomeCompleto" defaultValue={cliente.nomeCompleto} />
</div>
<div className="space-y-2">
<Label htmlFor="whatsapp">WhatsApp</Label>
<Input id="whatsapp" name="whatsapp" defaultValue={cliente.whatsapp} />
</div>
<div className="space-y-2">
<Label htmlFor="razaoSocial">Razao social</Label>
<Input id="razaoSocial" name="razaoSocial" defaultValue={cliente.razaoSocial ?? ""} />
</div>
<div className="space-y-2">
<Label htmlFor="cep">CEP</Label>
<Input id="cep" name="cep" defaultValue={cliente.cep ?? ""} />
</div>
<div className="space-y-2">
<Label htmlFor="logradouro">Logradouro</Label>
<Input id="logradouro" name="logradouro" defaultValue={cliente.logradouro ?? ""} />
</div>
<div className="space-y-2">
<Label htmlFor="numero">Numero</Label>
<Input id="numero" name="numero" defaultValue={cliente.numero ?? ""} />
</div>
<div className="space-y-2">
<Label htmlFor="complemento">Complemento</Label>
<Input id="complemento" name="complemento" defaultValue={cliente.complemento ?? ""} />
</div>
<div className="space-y-2">
<Label htmlFor="bairro">Bairro</Label>
<Input id="bairro" name="bairro" defaultValue={cliente.bairro ?? ""} />
</div>
<div className="space-y-2">
<Label htmlFor="cidade">Cidade</Label>
<Input id="cidade" name="cidade" defaultValue={cliente.cidade ?? ""} />
</div>
<div className="space-y-2">
<Label htmlFor="uf">UF</Label>
<Input id="uf" name="uf" defaultValue={cliente.uf ?? ""} maxLength={2} />
</div>
</div>
<SubmitButton />
</form>
);
}
+77
View File
@@ -0,0 +1,77 @@
import Link from "next/link";
import { solicitarExclusao } from "@/app/(cliente)/minha-conta/actions";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { getClienteConta } from "@/lib/account-service";
import { ContaForm } from "./conta-form";
export default async function MinhaContaPage() {
const cliente = await getClienteConta();
return (
<main className="container py-8">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-3xl font-semibold">Minha conta</h1>
<p className="mt-2 text-sm text-muted-foreground">Gerencie seus dados e direitos LGPD.</p>
</div>
<Button asChild variant="outline">
<Link href="/briefings">Voltar</Link>
</Button>
</div>
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
<Card>
<CardHeader>
<CardTitle>Dados cadastrais</CardTitle>
</CardHeader>
<CardContent>
<ContaForm cliente={cliente} />
</CardContent>
</Card>
<aside className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Meus dados</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm leading-6 text-muted-foreground">
Baixe uma copia em JSON dos dados associados a sua conta.
</p>
<Button asChild>
<a href="/api/minha-conta/exportar">Baixar JSON</a>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Solicitar exclusao</CardTitle>
</CardHeader>
<CardContent>
<form action={solicitarExclusao} className="space-y-3">
<div className="space-y-2">
<Label htmlFor="motivo">Motivo opcional</Label>
<Textarea id="motivo" name="motivo" />
</div>
<Button type="submit" variant="outline">Solicitar exclusao</Button>
</form>
{cliente.solicitacoesExclusao.length > 0 ? (
<div className="mt-4 space-y-2">
{cliente.solicitacoesExclusao.map((solicitacao) => (
<p key={solicitacao.id} className="text-sm text-muted-foreground">
{solicitacao.status} em {solicitacao.criadoEm.toLocaleDateString("pt-BR")}
</p>
))}
</div>
) : null}
</CardContent>
</Card>
</aside>
</div>
</main>
);
}
+131
View File
@@ -0,0 +1,131 @@
"use server";
import argon2 from "argon2";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { encryptCpf, hashCpf, onlyDigits } from "@/lib/crypto";
import { cadastroSchema, normalizeWhatsapp } from "@/lib/validators";
import { sendMail } from "@/lib/mail";
import { checkRateLimit } from "@/lib/rate-limit";
export type CadastroState = {
ok: boolean;
message?: string;
};
export async function cadastrarCliente(_state: CadastroState, formData: FormData): Promise<CadastroState> {
const emailForLimit = formData.get("email")?.toString().trim().toLowerCase() ?? "sem-email";
const limit = checkRateLimit(`cadastro:${emailForLimit}`, 5, 60 * 60 * 1000);
if (!limit.allowed) {
return { ok: false, message: "Muitas tentativas. Tente novamente mais tarde." };
}
const parsed = cadastroSchema.safeParse({
nomeCompleto: formData.get("nomeCompleto"),
cpf: formData.get("cpf"),
whatsapp: formData.get("whatsapp"),
email: formData.get("email"),
senha: formData.get("senha"),
confirmarSenha: formData.get("confirmarSenha"),
tipo: formData.get("tipo"),
razaoSocial: formData.get("razaoSocial") || undefined,
cnpj: formData.get("cnpj") || undefined,
cep: formData.get("cep") || undefined,
logradouro: formData.get("logradouro") || undefined,
numero: formData.get("numero") || undefined,
complemento: formData.get("complemento") || undefined,
bairro: formData.get("bairro") || undefined,
cidade: formData.get("cidade") || undefined,
uf: formData.get("uf") || undefined,
lgpdAceite: formData.get("lgpdAceite")
});
if (!parsed.success) {
return {
ok: false,
message: parsed.error.issues[0]?.message ?? "Revise os dados informados."
};
}
const data = parsed.data;
const cpfHash = hashCpf(data.cpf);
const whatsapp = normalizeWhatsapp(data.whatsapp);
if (!whatsapp) {
return { ok: false, message: "Informe um WhatsApp com DDD." };
}
try {
await db.$transaction(async (tx) => {
const exists = await tx.user.findFirst({
where: {
OR: [{ email: data.email }, { cliente: { cpfHash } }]
},
select: { id: true }
});
if (exists) {
throw new Error("DUPLICADO");
}
await tx.user.create({
data: {
email: data.email,
senhaHash: await argon2.hash(data.senha),
papel: "CLIENTE",
cliente: {
create: {
nomeCompleto: data.nomeCompleto,
cpfCifrado: encryptCpf(data.cpf),
cpfHash,
whatsapp,
tipo: data.tipo,
razaoSocial: data.tipo === "PJ" ? data.razaoSocial : null,
cnpj: data.tipo === "PJ" && data.cnpj ? onlyDigits(data.cnpj) : null,
cep: data.cep ? onlyDigits(data.cep) : null,
logradouro: data.logradouro || null,
numero: data.numero || null,
complemento: data.complemento || null,
bairro: data.bairro || null,
cidade: data.cidade || null,
uf: data.uf || null,
lgpdAceiteEm: new Date()
}
}
}
});
});
} catch (error) {
if (error instanceof Error && error.message === "DUPLICADO") {
return {
ok: false,
message: "Nao foi possivel concluir o cadastro com os dados informados."
};
}
return {
ok: false,
message: "Nao foi possivel concluir o cadastro agora. Tente novamente."
};
}
try {
await sendMail({
to: data.email,
subject: "Cadastro criado - Briefing SeConectai",
text: [
"Seu cadastro foi criado com sucesso.",
"",
"Acesse o sistema para iniciar ou acompanhar seu briefing.",
"",
"Este e-mail nao inclui CPF completo nem senhas."
].join("\n")
});
} catch {
// Falha de e-mail nao desfaz cadastro.
}
redirect("/login");
}
+128
View File
@@ -0,0 +1,128 @@
"use client";
import { useFormState, useFormStatus } from "react-dom";
import { useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cadastrarCliente, type CadastroState } from "./actions";
const initialState: CadastroState = { ok: false };
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" className="w-full" disabled={pending}>
{pending ? "Criando cadastro..." : "Criar cadastro"}
</Button>
);
}
export function CadastroForm() {
const [state, action] = useFormState(cadastrarCliente, initialState);
const [tipo, setTipo] = useState<"PF" | "PJ">("PF");
return (
<form action={action} className="space-y-5">
{state.message ? (
<Alert variant="destructive">
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<input type="hidden" name="tipo" value={tipo} />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="nomeCompleto">Nome completo</Label>
<Input id="nomeCompleto" name="nomeCompleto" autoComplete="name" required />
</div>
<div className="space-y-2">
<Label htmlFor="cpf">CPF</Label>
<Input id="cpf" name="cpf" inputMode="numeric" autoComplete="off" required />
</div>
<div className="space-y-2">
<Label htmlFor="whatsapp">WhatsApp</Label>
<Input id="whatsapp" name="whatsapp" inputMode="tel" autoComplete="tel" required />
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="email">E-mail</Label>
<Input id="email" name="email" type="email" autoComplete="email" required />
</div>
<div className="space-y-2">
<Label htmlFor="senha">Senha</Label>
<Input id="senha" name="senha" type="password" autoComplete="new-password" required />
</div>
<div className="space-y-2">
<Label htmlFor="confirmarSenha">Confirmar senha</Label>
<Input id="confirmarSenha" name="confirmarSenha" type="password" autoComplete="new-password" required />
</div>
</div>
<fieldset className="space-y-3 rounded-lg border p-4">
<legend className="px-1 text-sm font-medium">Este site e para uma empresa?</legend>
<div className="grid gap-3 sm:grid-cols-2">
<Button type="button" variant={tipo === "PF" ? "default" : "outline"} onClick={() => setTipo("PF")}>
Nao
</Button>
<Button type="button" variant={tipo === "PJ" ? "default" : "outline"} onClick={() => setTipo("PJ")}>
Sim
</Button>
</div>
</fieldset>
{tipo === "PJ" ? (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="razaoSocial">Razao social</Label>
<Input id="razaoSocial" name="razaoSocial" required />
</div>
<div className="space-y-2">
<Label htmlFor="cnpj">CNPJ</Label>
<Input id="cnpj" name="cnpj" inputMode="numeric" required />
</div>
<div className="space-y-2">
<Label htmlFor="cep">CEP</Label>
<Input id="cep" name="cep" inputMode="numeric" autoComplete="postal-code" />
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="logradouro">Logradouro</Label>
<Input id="logradouro" name="logradouro" autoComplete="address-line1" />
</div>
<div className="space-y-2">
<Label htmlFor="numero">Numero</Label>
<Input id="numero" name="numero" autoComplete="address-line2" />
</div>
<div className="space-y-2">
<Label htmlFor="complemento">Complemento</Label>
<Input id="complemento" name="complemento" />
</div>
<div className="space-y-2">
<Label htmlFor="bairro">Bairro</Label>
<Input id="bairro" name="bairro" />
</div>
<div className="space-y-2">
<Label htmlFor="cidade">Cidade</Label>
<Input id="cidade" name="cidade" autoComplete="address-level2" />
</div>
<div className="space-y-2">
<Label htmlFor="uf">UF</Label>
<Input id="uf" name="uf" maxLength={2} autoComplete="address-level1" />
</div>
</div>
) : null}
<label className="flex items-start gap-3 rounded-lg border p-4 text-sm leading-6">
<Checkbox name="lgpdAceite" required />
<span>
Li e aceito a Politica de Privacidade e autorizo o tratamento dos dados para criacao e acompanhamento do briefing.
</span>
</label>
<SubmitButton />
</form>
);
}
+37
View File
@@ -0,0 +1,37 @@
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CadastroForm } from "./cadastro-form";
export default function CadastroPage() {
return (
<main className="container flex min-h-screen items-center justify-center py-8">
<Card className="w-full max-w-2xl shadow-sm">
<CardHeader>
<CardTitle>Crie seu cadastro</CardTitle>
<p className="text-sm leading-6 text-muted-foreground">
Depois do cadastro, voce podera iniciar e continuar seu briefing de site.
</p>
</CardHeader>
<CardContent>
<CadastroForm />
<p className="mt-4 text-center text-xs leading-5 text-muted-foreground">
Ao criar cadastro, consulte tambem a{" "}
<Link href="/privacidade" className="text-primary underline-offset-4 hover:underline">
Politica de Privacidade
</Link>{" "}
e os{" "}
<Link href="/termos" className="text-primary underline-offset-4 hover:underline">
Termos de Uso
</Link>.
</p>
<p className="mt-6 text-center text-sm text-muted-foreground">
Ja tem cadastro?{" "}
<Link href="/login" className="font-medium text-primary underline-offset-4 hover:underline">
Entrar
</Link>
</p>
</CardContent>
</Card>
</main>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use server";
import { AuthError } from "next-auth";
import { redirect } from "next/navigation";
import { signIn } from "@/auth";
export type LoginState = {
ok: boolean;
message?: string;
};
export async function login(_state: LoginState, formData: FormData): Promise<LoginState> {
const callbackUrl = formData.get("callbackUrl")?.toString();
try {
await signIn("credentials", {
email: formData.get("email"),
senha: formData.get("senha"),
redirectTo: callbackUrl || undefined
});
} catch (error) {
if (error instanceof AuthError) {
return {
ok: false,
message: "E-mail ou senha invalidos."
};
}
throw error;
}
redirect(callbackUrl || "/briefings");
}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useFormState, useFormStatus } from "react-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { login, type LoginState } from "./actions";
const initialState: LoginState = { ok: false };
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" className="w-full" disabled={pending}>
{pending ? "Entrando..." : "Entrar"}
</Button>
);
}
export function LoginForm() {
const searchParams = useSearchParams();
const [state, action] = useFormState(login, initialState);
const callbackUrl = searchParams.get("callbackUrl") ?? "";
return (
<form action={action} className="space-y-4">
<input type="hidden" name="callbackUrl" value={callbackUrl} />
{state.message ? (
<Alert variant="destructive">
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<div className="space-y-2">
<Label htmlFor="email">E-mail</Label>
<Input id="email" name="email" type="email" autoComplete="email" required />
</div>
<div className="space-y-2">
<Label htmlFor="senha">Senha</Label>
<Input id="senha" name="senha" type="password" autoComplete="current-password" required />
</div>
<SubmitButton />
</form>
);
}
+29
View File
@@ -0,0 +1,29 @@
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LoginForm } from "./login-form";
export default function LoginPage() {
return (
<main className="container flex min-h-screen items-center justify-center py-8">
<Card className="w-full max-w-md shadow-sm">
<CardHeader>
<CardTitle>Entrar no briefing</CardTitle>
<p className="text-sm leading-6 text-muted-foreground">
Acesse sua area para preencher ou acompanhar o briefing.
</p>
</CardHeader>
<CardContent>
<LoginForm />
<div className="mt-6 flex items-center justify-between text-sm">
<Link href="/recuperar-senha" className="text-primary underline-offset-4 hover:underline">
Esqueci minha senha
</Link>
<Link href="/cadastro" className="text-primary underline-offset-4 hover:underline">
Criar cadastro
</Link>
</div>
</CardContent>
</Card>
</main>
);
}
+25
View File
@@ -0,0 +1,25 @@
import Link from "next/link";
export default function PrivacidadePage() {
return (
<main className="container max-w-3xl py-10">
<Link href="/" className="text-sm font-medium text-primary underline-offset-4 hover:underline">
Voltar
</Link>
<article className="mt-8 space-y-5 rounded-lg border bg-card p-6">
<h1 className="text-3xl font-semibold">Politica de Privacidade</h1>
<p className="rounded-md border bg-muted p-3 text-sm font-medium">
Texto-base: revisar com assessoria juridica antes de publicar em producao.
</p>
<p className="text-sm leading-7 text-muted-foreground">
Texto-base para revisao juridica. Os dados informados no cadastro e no briefing sao usados para identificar o cliente,
organizar o projeto e permitir o acompanhamento pela SeConectai.
</p>
<p className="text-sm leading-7 text-muted-foreground">
Dados sensiveis, como CPF, devem ser protegidos por criptografia e nunca devem aparecer em logs, e-mails ou listagens
publicas do sistema.
</p>
</article>
</main>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use server";
import { createHash, randomBytes } from "crypto";
import { db } from "@/lib/db";
import { sendMail } from "@/lib/mail";
import { checkRateLimit } from "@/lib/rate-limit";
export type RecuperarSenhaState = {
ok: boolean;
message?: string;
};
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
export async function solicitarRecuperacao(
_state: RecuperarSenhaState,
formData: FormData
): Promise<RecuperarSenhaState> {
const email = formData.get("email")?.toString().trim().toLowerCase();
const genericMessage = "Se o e-mail estiver cadastrado, enviaremos as instrucoes de redefinicao.";
const limit = checkRateLimit(`recuperar-senha:${email ?? "sem-email"}`, 5, 60 * 60 * 1000);
if (!limit.allowed) {
return { ok: true, message: genericMessage };
}
if (!email) {
return { ok: true, message: genericMessage };
}
const user = await db.user.findUnique({
where: { email },
select: { id: true, email: true }
});
if (!user) {
return { ok: true, message: genericMessage };
}
const token = randomBytes(32).toString("base64url");
const tokenHash = hashToken(token);
const expiraEm = new Date(Date.now() + 60 * 60 * 1000);
await db.passwordResetToken.create({
data: {
userId: user.id,
tokenHash,
expiraEm
}
});
const baseUrl = process.env.APP_URL ?? "http://localhost:3000";
const resetUrl = `${baseUrl}/redefinir-senha?token=${token}`;
await sendMail({
to: user.email,
subject: "Redefinicao de senha - Briefing SeConectai",
text: `Use o link abaixo para redefinir sua senha. Ele expira em 1 hora.\n\n${resetUrl}`
});
return { ok: true, message: genericMessage };
}
export { hashToken };
+26
View File
@@ -0,0 +1,26 @@
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { RecuperarSenhaForm } from "./recuperar-senha-form";
export default function RecuperarSenhaPage() {
return (
<main className="container flex min-h-screen items-center justify-center py-8">
<Card className="w-full max-w-md shadow-sm">
<CardHeader>
<CardTitle>Recuperar senha</CardTitle>
<p className="text-sm leading-6 text-muted-foreground">
Informe seu e-mail para receber as instrucoes de redefinicao.
</p>
</CardHeader>
<CardContent>
<RecuperarSenhaForm />
<p className="mt-6 text-center text-sm text-muted-foreground">
<Link href="/login" className="text-primary underline-offset-4 hover:underline">
Voltar para o login
</Link>
</p>
</CardContent>
</Card>
</main>
);
}
@@ -0,0 +1,39 @@
"use client";
import { useFormState, useFormStatus } from "react-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { solicitarRecuperacao, type RecuperarSenhaState } from "./actions";
const initialState: RecuperarSenhaState = { ok: false };
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" className="w-full" disabled={pending}>
{pending ? "Enviando..." : "Enviar instrucoes"}
</Button>
);
}
export function RecuperarSenhaForm() {
const [state, action] = useFormState(solicitarRecuperacao, initialState);
return (
<form action={action} className="space-y-4">
{state.message ? (
<Alert>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<div className="space-y-2">
<Label htmlFor="email">E-mail</Label>
<Input id="email" name="email" type="email" autoComplete="email" required />
</div>
<SubmitButton />
</form>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use server";
import argon2 from "argon2";
import { redirect } from "next/navigation";
import { z } from "zod";
import { db } from "@/lib/db";
import { hashToken } from "@/app/(publico)/recuperar-senha/actions";
const redefinirSenhaSchema = z
.object({
token: z.string().min(1),
senha: z.string().min(8, "A senha precisa ter pelo menos 8 caracteres."),
confirmarSenha: z.string()
})
.refine((data) => data.senha === data.confirmarSenha, {
path: ["confirmarSenha"],
message: "As senhas nao conferem."
});
export type RedefinirSenhaState = {
ok: boolean;
message?: string;
};
export async function redefinirSenha(
_state: RedefinirSenhaState,
formData: FormData
): Promise<RedefinirSenhaState> {
const parsed = redefinirSenhaSchema.safeParse({
token: formData.get("token"),
senha: formData.get("senha"),
confirmarSenha: formData.get("confirmarSenha")
});
if (!parsed.success) {
return {
ok: false,
message: parsed.error.issues[0]?.message ?? "Revise os dados informados."
};
}
const tokenHash = hashToken(parsed.data.token);
const resetToken = await db.passwordResetToken.findUnique({
where: { tokenHash },
select: {
id: true,
userId: true,
expiraEm: true,
usadoEm: true
}
});
if (!resetToken || resetToken.usadoEm || resetToken.expiraEm < new Date()) {
return { ok: false, message: "Link invalido ou expirado." };
}
await db.$transaction([
db.user.update({
where: { id: resetToken.userId },
data: { senhaHash: await argon2.hash(parsed.data.senha) }
}),
db.passwordResetToken.update({
where: { id: resetToken.id },
data: { usadoEm: new Date() }
})
]);
redirect("/login");
}
+36
View File
@@ -0,0 +1,36 @@
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { RedefinirSenhaForm } from "./redefinir-senha-form";
export default function RedefinirSenhaPage({
searchParams
}: {
searchParams: { token?: string };
}) {
const token = searchParams.token ?? "";
return (
<main className="container flex min-h-screen items-center justify-center py-8">
<Card className="w-full max-w-md shadow-sm">
<CardHeader>
<CardTitle>Redefinir senha</CardTitle>
<p className="text-sm leading-6 text-muted-foreground">
Crie uma nova senha para acessar o briefing.
</p>
</CardHeader>
<CardContent>
{token ? (
<RedefinirSenhaForm token={token} />
) : (
<p className="text-sm text-muted-foreground">Link de redefinicao invalido.</p>
)}
<p className="mt-6 text-center text-sm text-muted-foreground">
<Link href="/login" className="text-primary underline-offset-4 hover:underline">
Voltar para o login
</Link>
</p>
</CardContent>
</Card>
</main>
);
}
@@ -0,0 +1,44 @@
"use client";
import { useFormState, useFormStatus } from "react-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { redefinirSenha, type RedefinirSenhaState } from "./actions";
const initialState: RedefinirSenhaState = { ok: false };
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" className="w-full" disabled={pending}>
{pending ? "Salvando..." : "Redefinir senha"}
</Button>
);
}
export function RedefinirSenhaForm({ token }: { token: string }) {
const [state, action] = useFormState(redefinirSenha, initialState);
return (
<form action={action} className="space-y-4">
<input type="hidden" name="token" value={token} />
{state.message ? (
<Alert variant="destructive">
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<div className="space-y-2">
<Label htmlFor="senha">Nova senha</Label>
<Input id="senha" name="senha" type="password" autoComplete="new-password" required />
</div>
<div className="space-y-2">
<Label htmlFor="confirmarSenha">Confirmar nova senha</Label>
<Input id="confirmarSenha" name="confirmarSenha" type="password" autoComplete="new-password" required />
</div>
<SubmitButton />
</form>
);
}
+25
View File
@@ -0,0 +1,25 @@
import Link from "next/link";
export default function TermosPage() {
return (
<main className="container max-w-3xl py-10">
<Link href="/" className="text-sm font-medium text-primary underline-offset-4 hover:underline">
Voltar
</Link>
<article className="mt-8 space-y-5 rounded-lg border bg-card p-6">
<h1 className="text-3xl font-semibold">Termos de Uso</h1>
<p className="rounded-md border bg-muted p-3 text-sm font-medium">
Texto-base: revisar com assessoria juridica antes de publicar em producao.
</p>
<p className="text-sm leading-7 text-muted-foreground">
Texto-base para revisao juridica. O sistema deve ser usado para preenchimento do briefing de criacao de site, sem envio
de senhas ou acessos sensiveis.
</p>
<p className="text-sm leading-7 text-muted-foreground">
Informacoes enviadas pelo cliente podem orientar escopo, prazos e prioridades do projeto. Mudancas relevantes depois do
envio podem exigir reavaliacao.
</p>
</article>
</main>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { createReadStream } from "fs";
import { stat } from "fs/promises";
import { NextResponse } from "next/server";
import { Readable } from "stream";
import { getAnexoAutorizado } from "@/lib/anexo-service";
import { db } from "@/lib/db";
import { assertSameOrigin } from "@/lib/request-security";
import { removeStoredFile } from "@/lib/storage";
export const runtime = "nodejs";
function safeFileName(name: string) {
return name.replace(/["\r\n]/g, "_");
}
export async function GET(_request: Request, { params }: { params: { id: string } }) {
const anexo = await getAnexoAutorizado(params.id);
if (!anexo) {
return NextResponse.json({ message: "Anexo nao encontrado." }, { status: 404 });
}
try {
const fileStat = await stat(anexo.caminho);
const stream = Readable.toWeb(createReadStream(anexo.caminho)) as ReadableStream;
return new Response(stream, {
headers: {
"Content-Type": anexo.mime,
"Content-Length": String(fileStat.size),
"Content-Disposition": `attachment; filename="${safeFileName(anexo.nomeOriginal)}"`,
"X-Content-Type-Options": "nosniff"
}
});
} catch {
return NextResponse.json({ message: "Arquivo nao encontrado no armazenamento." }, { status: 404 });
}
}
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
const originError = assertSameOrigin(request);
if (originError) return originError;
const anexo = await getAnexoAutorizado(params.id);
if (!anexo) {
return NextResponse.json({ message: "Anexo nao encontrado." }, { status: 404 });
}
if (anexo.briefing.status !== "RASCUNHO") {
return NextResponse.json(
{ message: "Briefing enviado nao aceita remocao de anexos." },
{ status: 409 }
);
}
await db.anexo.delete({
where: { id: anexo.id }
});
await removeStoredFile(anexo.caminho);
return NextResponse.json({ ok: true });
}
+3
View File
@@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

Some files were not shown because too many files have changed in this diff Show More