Initial SeConectai briefing system
This commit is contained in:
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
dist
|
||||
.env
|
||||
.env*.local
|
||||
*.log
|
||||
storage/uploads
|
||||
coverage
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
email {$LETSENCRYPT_EMAIL}
|
||||
}
|
||||
|
||||
{$APP_DOMAIN} {
|
||||
encode gzip zstd
|
||||
reverse_proxy app:3000
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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?
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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”.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
@@ -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()}` : ""}`);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user