🚀 Auto-deploy: BrainWind atualizado em 14/07/2026 15:18:53

This commit is contained in:
2026-07-14 15:18:53 +00:00
parent c07016ab82
commit 4e0b26f1c4
14 changed files with 1580 additions and 158 deletions
+242
View File
@@ -0,0 +1,242 @@
# Plano de Comercialização — BrainWind
## Software de cálculo de vento conforme a norma brasileira NBR 6123
---
**Preparado por:** Marcos Reifonas
**Data:** Julho de 2026
**Aplicativo:** BrainWind (cálculo de vento em edificações)
---
## 1. Resumo da situação atual
O BrainWind é um aplicativo de cálculo de vento para projetos de engenharia civil (galpões, silos, abóbadas, cúpulas, muros, pontes, torres e coberturas). Ele aplica a norma brasileira **NBR 6123:2023** na íntegra.
**O que já está pronto:**
- Toda a norma técnica foi implementada (36 tabelas e 3 anexos oficiais)
- Cobre 9 tipos diferentes de estrutura
- Foi feita uma auditoria independente que testou 91 cenários contra os exemplos da própria norma — todos passaram
- Existe um motor de cálculo matemático robusto, testado e validado
- O programa gera relatórios em PDF prontos para apresentação ao cliente
**Resumo:** o produto técnico já é sólido. O que falta é o lado comercial — transformar um aplicativo interno em algo que pode ser vendido e mantido como negócio.
**Oportunidade de mercado:**
Não existe hoje no Brasil nenhum software dedicado exclusivamente ao cálculo de vento conforme a norma atualizada. Os engenheiros resolvem isso em planilhas de Excel ou compram softwares estrangeiros caros (que não seguem a norma brasileira). O BrainWind entra em um espaço de mercado pouco ocupado, com um produto superior ao que está disponível.
---
## 2. Como vamos ganhar dinheiro
A forma mais indicada para esse tipo de software técnico é a **licença anual**, com a opção de licença definitiva (pagamento único) para quem prefere.
### Planos sugeridos para iniciar
| Plano | Preço | Para quem é |
|-------|-------|-------------|
| **Teste grátis** | R$ 0,00 | Qualquer engenheiro, por 14 dias, sem compromisso |
| **Licença Anual** | R$ 1.500 por ano | Escritórios pequenos, autônomos, projetistas |
| **Licença Perpétua** | R$ 4.000 (à vista) | Quem quer pagar uma vez e usar por tempo indeterminado |
| **Licença Empresa** | R$ 6.000 por ano (5 usuários) | Escritórios de engenharia com equipe |
**Por que esses valores funcionam:**
O engenheiro cobra em média R$ 5.000 a R$ 15.000 por projeto de cálculo estrutural. Se o BrainWind economiza 4 horas de trabalho em cada projeto (o que faz), o software se paga em um único projeto. Para o profissional, é um investimento óbvio.
---
## 3. As fases do plano
O trabalho foi dividido em 8 fases. Cada fase tem um objetivo claro e um prazo realista para um time de uma ou duas pessoas trabalhando em meio período.
### Fase 0 — Decisões iniciais (1 a 2 dias)
Antes de começar qualquer trabalho técnico, é preciso travar algumas decisões que vão impactar tudo depois.
**O que precisa ser decidido:**
- Confirmar os preços finais (sugestão acima)
- Registrar o domínio do site (exemplo: brainwind.com.br)
- Escolher a plataforma de pagamento (sugestão: Kiwify, que é brasileira e fácil de usar)
- Decidir se vamos abrir um CNPJ/MEI para emitir nota fiscal (recomendado — sem isso não dá para vender para empresas)
### Fase 1 — Tornar o app um produto vendável (2 a 3 semanas)
Hoje o BrainWind funciona apenas no computador de quem instalou, com os dados guardados localmente. Para vender, é preciso transformar em algo que:
- Exige login com usuário e senha
- Valida uma licença (chave de ativação)
- Salva os projetos na nuvem (para o cliente acessar de qualquer lugar)
- Controla o período de teste grátis automaticamente
A infraestrutura técnica já existe (o servidor dessa VPS roda Supabase, que serve para isso sem custo adicional).
### Fase 2 — Criar a identidade visual e a página de vendas (1 a 2 semanas)
**O que precisa ser feito:**
- Criar um logotipo simples (pode ser feito online sem custo)
- Definir cores e estilo visual (azul técnico + branco é uma escolha segura e profissional)
- Construir uma página na internet que explique o que o app faz, para quem é, quanto custa, e como comprar
- Escrever os Termos de Uso e a Política de Privacidade (exigidos por lei)
### Fase 3 — Configurar o pagamento e a entrega automática (1 semana)
Quando o cliente pagar, o sistema precisa liberar o acesso sozinho — sem ninguém precisar ficar apertando botão.
**O que fazer:**
- Criar os produtos na plataforma Kiwify
- Configurar o sistema para que, assim que o pagamento for confirmado, uma licença seja criada e enviada por e-mail
- Configurar avisos automáticos quando a licença estiver perto de expirar
### Fase 4 — Marketing e captação de clientes (1 a 2 semanas)
Sem divulgação, ninguém vai saber que o produto existe.
**Estratégia de comunicação:**
- Criar um vídeo curto (3 minutos) mostrando o app calculando um galpão real do início ao fim
- Publicar posts no LinkedIn e Instagram mostrando casos de uso
- Participar de grupos de Facebook e LinkedIn voltados para engenheiros estruturais
- Produzir um artigo técnico sobre as mudanças da NBR 6123 versão 2023 — isso traz clientes por mecanismos de busca
**Estratégia de beta-testers (etapa fundamental):**
Antes de abrir para todo mundo, oferecer o BrainWind de graça por 30 dias para 20 a 30 engenheiros selecionados. Em troca, eles dão feedback, relatam problemas e fornecem depoimentos para usar no marketing. Esse é o passo mais importante para validar o produto com usuários reais antes de começar a vender oficialmente.
### Fase 5 — Testes com usuários reais (2 a 4 semanas)
Rodar o programa com o grupo de beta-testers. Acompanhar o uso deles, responder dúvidas, corrigir o que estiver confuso, melhorar o que pedirem.
**O que medir:**
- Quantos realmente usam o programa (não só instalam)
- Quantos chegam até o final do cálculo e geram o PDF
- Quantos recomendariam para um colega (nota de satisfação)
- Quais funcionalidades foram mais pedidas
- Quais bugs apareceram
### Fase 6 — Lançamento oficial (1 dia, mais uma semana de ajuste)
Abrir para todo mundo.
**Checklist do dia do lançamento:**
- Página de vendas no ar e testada
- Sistema de pagamento funcionando
- Suporte (WhatsApp ou e-mail) pronto para responder
- Documentação legal publicada
- Vídeo de demonstração publicado
- Depoimentos de beta-testers em mãos
- Posts de lançamento agendados nas redes sociais
**Meta para os primeiros 90 dias:**
- 100 testes grátis iniciados
- 20 clientes pagantes
- R$ 30.000 de receita
- Nota de satisfação média acima de 50 (em escala de 0 a 100)
### Fase 7 — Crescimento pós-lançamento (contínuo)
Depois do lançamento, o trabalho continua:
- Criar versão instalável para Windows e Mac (sensação de "software profissional")
- Integrar com outros programas que engenheiros já usam (Ftool, SAP2000, AutoCAD)
- Avaliar expansão para outras normas de vento internacionais (mercado americano e europeu paga muito mais)
- Criar programa de afiliados — engenheiro indica, ganha comissão recorrente
- Considerar versão mobile para consulta rápida em obra
---
## 4. Investimento necessário
### Custos para começar
| Item | Valor |
|------|-------|
| Abertura de MEI | Grátis |
| Registro de domínio (por ano) | R$ 50 |
| Logo (versão simples, online) | R$ 0 a 200 |
| Plataforma Kiwify | Grátis (cobra só % sobre venda) |
| Infraestrutura de servidor | Já existente (sem custo adicional) |
| Email transacional | Grátis no início |
| **Total inicial** | **R$ 50 a 250** |
### Custos mensais recorrentes
| Item | Valor mensal |
|------|--------------|
| Contador (MEI ou Simples Nacional) | R$ 100 a 300 |
| Comissão Kiwify (~10% sobre vendas) | Variável |
| **Total fixo mensal** | **R$ 100 a 300** |
### Projeção de receita (cenário conservador)
| Mês | Novos testes grátis | Novos clientes pagantes | Receita acumulada |
|-----|---------------------|--------------------------|-------------------|
| 1 (lançamento) | 30 | 6 | R$ 750 |
| 2 | 50 | 10 | R$ 2.500 |
| 3 | 80 | 16 | R$ 4.500 |
| 6 | 150 por mês | 30 por mês | R$ 18.000 |
| 12 | 250 por mês | 50 por mês | R$ 60.000 |
Mesmo no cenário conservador, em 12 meses o produto se paga e começa a gerar lucro consistente. A margem esperada é alta (cerca de 85%) porque não há custo de produção por unidade vendida — o software é o mesmo para todos os clientes.
---
## 5. Cronograma resumido
| Mês 1 | Mês 2 | Mês 3 | Mês 4 | Mês 5 em diante |
|-------|-------|-------|-------|------------------|
| Decisões iniciais | Sistema de login e licenças | Pagamento e entrega | Marketing e beta-testers | Lançamento oficial e crescimento |
**Tempo total até o lançamento:** entre 3 e 4 meses, trabalhando em meio período.
---
## 6. Riscos e como mitigar
| Risco | Probabilidade | Impacto | Como mitigar |
|-------|---------------|---------|---------------|
| Pouca demanda no mercado | Baixa | Alto | Validar com beta-testers antes de investir pesado em marketing |
| Concorrente surgir | Média | Médio | Velocidade: lançar logo, fidelizar primeiros clientes |
| Bug grave aparecer | Média | Médio | Programa de beta-testers pega antes do lançamento público |
| Problema com a norma (nova versão) | Baixa | Alto | Arquitetura modular permite atualizar tabelas sem reescrever |
| Dificuldade de explicar o valor | Média | Alto | Vídeo de demonstração + depoimentos resolvem |
---
## 7. Próximos passos imediatos
Esta semana, três ações simples para começar:
**1. Decidir os preços finais**
Reunião entre os sócios para confirmar os valores sugeridos. Pode-se ajustar para cima se houver demanda ou para baixo se quiser crescer mais rápido no início.
**2. Verificar disponibilidade do domínio**
Acessar registro.br e pesquisar brainwind.com.br. Se estiver disponível, registrar (R$ 50). Se não, pensar em alternativas (brainwind.app, brainwind.eng.br).
**3. Abrir MEI no nome de um dos sócios**
Acessar gov.br/mei. Leva cerca de 30 minutos. Sem CNPJ não dá para emitir nota fiscal, e empresa nenhuma compra sem nota.
---
## 8. Conclusão
O BrainWind tem uma base técnica sólida, auditada e validada. O produto está pronto no que diz respeito à engenharia. O que falta é o lado comercial — transformar esse ativo técnico em um negócio que gera receita recorrente.
A oportunidade de mercado é real e clara: não existe hoje no Brasil um software dedicado exclusivamente ao cálculo de vento conforme a norma brasileira atualizada. Os engenheiros estão resolvendo isso em planilhas, com medo de errar a versão da norma.
O investimento inicial é baixo (menos de R$ 300), o risco é controlável (validar com beta-testers antes de investir pesado), e a margem esperada é alta.
A recomendação é seguir as fases descritas neste documento, começando pelas decisões da Fase 0 e seguindo em frente com disciplina. Em 3 a 4 meses, o produto pode estar no ar gerando receita.
---
**Anexo:** documento técnico detalhado disponível em `ROADMAP-VENDA.md` (versão para a equipe técnica com instruções específicas de implementação).
+10 -1
View File
@@ -15,7 +15,7 @@
| Item | Valor | | Item | Valor |
|------|-------| |------|-------|
| Data da última atualização | 2026-07-14 | | Data da última atualização | 2026-07-14 |
| Marcos concluídos | **8 / 8** (plano completo) + M9.1 ✅ + M9.2 ✅ + M9.3 ✅ + M9.4 ✅ + M9.5 ✅ + M9.6 ✅ + M9.7 ✅ + M9.8 ✅ + M9.9 ✅ + M9.10 ✅ + M9.13 ✅ + M9.14 ✅ + M9.15 ✅ + M9.16 ✅ + M9.17 ✅ + M9.18 ✅ | | Marcos concluídos | **8 / 8** (plano completo) + M9.1 ✅ + M9.2 ✅ + M9.3 ✅ + M9.4 ✅ + M9.5 ✅ + M9.6 ✅ + M9.7 ✅ + M9.8 ✅ + M9.9 ✅ + M9.10 ✅ + M9.13 ✅ + M9.14 ✅ + M9.15 ✅ + M9.16 ✅ + M9.17 ✅ + M9.18 ✅ + M9.19 ✅ |
| Cobertura NBR 6123:2023 | **100% das 11 seções + 3 anexos** | | Cobertura NBR 6123:2023 | **100% das 11 seções + 3 anexos** |
| Linhas de código TS/TSX | ~8.200 | | Linhas de código TS/TSX | ~8.200 |
| Arquivos `.ts`/`.tsx` no `src/` | **120** | | Arquivos `.ts`/`.tsx` no `src/` | **120** |
@@ -715,6 +715,15 @@ gráficos SVG inline para variáveis CSS do tema, suportando dark mode.
- **Envoltória de Pressão Interna (Cpi):** Adicionada `computeCpiEnvelope()` e o caso de 4 faces permeáveis agora trata explicitamente a envoltória dupla de Cpi [-0.3, 0]. - **Envoltória de Pressão Interna (Cpi):** Adicionada `computeCpiEnvelope()` e o caso de 4 faces permeáveis agora trata explicitamente a envoltória dupla de Cpi [-0.3, 0].
- **Refinamentos:** Erro legado com constantes (`ka` modificado de 4.06 para precisos 4.058 para área residencial) e eliminação do uso de `@ts-ignore` em `coefficients.ts`. - **Refinamentos:** Erro legado com constantes (`ka` modificado de 4.06 para precisos 4.058 para área residencial) e eliminação do uso de `@ts-ignore` em `coefficients.ts`.
#### M9.19 — Reorganização do Menu Lateral e Padronização de Layouts
**Status:** ✅ Concluído (2026-07-14)
**Escopo:** Reorganizar a estrutura e hierarquia do menu lateral desktop para separar parâmetros globais, arquivos e glossário, e padronizar o posicionamento dos botões de ação nos módulos.
**Entregas:**
- **Reorganização do Sidebar:** "Parâmetros do Vento" movido para o bloco inferior do menu lateral desktop. O antigo menu "Preferências" foi renomeado para "Configurações" e agora agrupa apenas as abas "Preferências" e "Testes". "Arquivos" (antigo Gerenciador) e "Glossário" foram promovidos a links independentes de primeiro nível no menu (sem exibir abas de topo no desktop).
- **Padronização de Botões de Ação:** O agrupamento de botões de Salvar/Exportar nos módulos de **Pipe-rack** e **Galpão** foi padronizado e movido para ficar imediatamente acima do painel de "Captura 3D" (SceneCapturePanel).
- **Salvamento no Galpão:** Implementado o `SaveModuleDialog` no módulo de Galpão, com suporte completo a salvar/carregar as configurações geométricas dinâmicas através do `useHydrationStore`.
- **Limpeza de Warnings:** Removidas variáveis e imports não utilizados no arquivo `SettingsModule.tsx` e corrigida a vinculação do input de arquivo oculto.
#### M9.11 — Persistência opcional em servidor (~especulativo) #### M9.11 — Persistência opcional em servidor (~especulativo)
**Problema:** Tudo é local (IndexedDB). **Problema:** Tudo é local (IndexedDB).
**Tarefa:** **Tarefa:**
+644
View File
@@ -0,0 +1,644 @@
# BrainWind — Roadmap de Comercialização
> **Objetivo:** Levar o BrainWind (antigo VentoApp/WindApp) do estágio atual de desenvolvimento interno até a venda comercial, com plano detalhado, fases e tarefas concretas.
**Estado atual (jul/2026):**
- ✅ 8/8 marcos concluídos
- ✅ 100% NBR 6123:2023 coberta (36 tabelas + 3 anexos)
- ✅ Auditoria independente Opus: 91/91 cenários PASS
- ✅ 38 testes vitest passando
- ✅ TS estrito, build verde
- ⚠️ Tudo local (IndexedDB) — sem auth, sem servidor, sem licença
---
## 📋 Visão geral das fases
| Fase | Nome | Duração estimada | Pré-requisito |
|------|------|------------------|---------------|
| **0** | Decisões estratégicas | 1-2 dias | Nenhum |
| **1** | Infraestrutura comercial (auth + licença + persistência) | 2-3 semanas | Fase 0 |
| **2** | Identidade visual + landing page | 1-2 semanas | Fase 0 (paralela à 1) |
| **3** | Empacotamento de venda (preço, checkout, webhook) | 1 semana | Fases 1 e 2 |
| **4** | Materiais de marketing + aquisição | 1-2 semanas | Fases 1, 2 e 3 |
| **5** | Beta-tester + ajustes finais | 2-4 semanas | Fases 1-4 |
| **6** | Lançamento oficial | 1 dia | Fase 5 |
| **7** | Escala (desktop, integrações, novos módulos) | contínuo pós-lançamento | Fase 6 |
---
## 🎯 FASE 0 — Decisões estratégicas
**Objetivo:** Travar as decisões que vão moldar todo o resto. Sem isso, qualquer trabalho técnico vai ter que ser refeito.
### 0.1 — Escolher modelo de cobrança
| Modelo | Vantagem | Desvantagem | Minha recomendação |
|--------|----------|-------------|---------------------|
| Assinatura mensal | Receita recorrente previsível | Cliente não gosta de mensal | ❌ Não começar com |
| Assinatura anual | Receita previsível + desconto incentiva | Ticket mais alto | ✅ **Modelo principal** |
| Licença perpétua | Cliente paga uma vez e esquece | Receita não-recorrente | ✅ **Modelo secundário** |
| Por projeto | Acompanha uso real do engenheiro | Dificulta automação | ❌ Não agora |
**Decisão sugerida:**
- **Plano 1 — Anual:** R$ 1.500/ano (~R$ 125/mês)
- **Plano 2 — Perpétua:** R$ 4.000 (atualização de norma inclusa por 2 anos)
- **Plano 3 — Empresa (5 usuários):** R$ 6.000/ano
- **Trial:** 14 dias grátis com todas as funções (gate automático)
### 0.2 — Domínio e marca
- Verificar disponibilidade de `brainwind.com.br` (registro.br, ~R$ 40/ano)
- Alternativas: `brainwind.app`, `brainwind.com`, `brainwind.eng.br`
- Logo: pode começar com texto estilizado + ícone de vento (Fiverr/99designs, R$ 100-300, ou gerador de logo AI grátis)
- Cor primária: azul escuro/azul técnico (engenharia remete a confiança)
- Tipografia: Inter ou Manrope (clean, técnica)
### 0.3 — Canal de checkout
| Gateway | Custo | Nota fiscal | Webhook | Recomendação |
|---------|-------|-------------|---------|--------------|
| **Kiwify** | ~R$ 0 + 9.99% | Sim | Sim, simples | ✅ **Principal** |
| Hotmart | ~R$ 0 + 9.99% | Sim | Sim | Alternativa |
| Eduzz | ~R$ 0 + 9.99% | Sim | Sim | Alternativa |
| Asaas | R$ 0-10 + 2,49% | Não | Sim | Para assinatura recorrente |
| Stripe direto | 4,99% + R$ 0,40 | Não | Sim | Para internacional (não agora) |
**Decisão sugerida:** Começar com **Kiwify**. Depois, se crescer, considerar Asaas pra recorrência pura (margem maior).
### 0.4 — Stack de auth + persistência
-**Supabase Auth + Postgres + RLS** (já rodando na VPS, custo zero)
- Email/senha + OAuth Google
- Tabela `licenses` com chave única por usuário
- Tabela `projects` com `user_id` e RLS
### 0.5 — Forma jurídica
- Pra emitir nota e receber de pessoa jurídica, **precisa de CNPJ**
- MEI funciona pra R$ 81k/ano (teto de receita)
- Simples Nacional se passar do MEI (R$ 100-500/mês de contador)
- Abrir MEI: 30 minutos no portal do empreendedor, grátis
- **Decisão a tomar:** abrir MEI antes do lançamento oficial
---
## 🛠️ FASE 1 — Infraestrutura comercial (auth + licença + persistência)
**Objetivo:** Transformar o app local num SaaS com autenticação, licenciamento e persistência na nuvem. Sem isso, ninguém compra — é um arquivo Excel glorificado.
**Duração:** 2-3 semanas
**Pré-requisito:** Fase 0
### 1.1 — Configurar Supabase para BrainWind
1. Criar projeto no Supabase da VPS (`supabase` service, porta 5432)
2. Schema SQL inicial:
```sql
-- Tabela de usuários (Supabase Auth já cria auth.users)
CREATE TABLE public.user_profiles (
id UUID REFERENCES auth.users(id) PRIMARY KEY,
full_name TEXT,
company TEXT,
crea TEXT,
phone TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Tabela de licenças
CREATE TABLE public.licenses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) NOT NULL,
license_key TEXT UNIQUE NOT NULL,
plan TEXT NOT NULL CHECK (plan IN ('annual', 'perpetual', 'enterprise')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'revoked')),
started_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ,
kiwify_order_id TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Tabela de projetos (substitui IndexedDB)
CREATE TABLE public.projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) NOT NULL,
name TEXT NOT NULL,
module TEXT NOT NULL,
data JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- RLS: usuário só vê seus próprios dados
ALTER TABLE public.user_profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.licenses ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own profile" ON public.user_profiles
FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Users can update own profile" ON public.user_profiles
FOR UPDATE USING (auth.uid() = id);
CREATE POLICY "Users can view own licenses" ON public.licenses
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can CRUD own projects" ON public.projects
FOR ALL USING (auth.uid() = user_id);
```
3. Variáveis de ambiente no `.env` do app:
```
VITE_SUPABASE_URL=https://supabase.reifonas.cloud
VITE_SUPABASE_ANON_KEY=<chave_anon>
```
### 1.2 — Implementar autenticação no app
1. Instalar cliente: `npm install @supabase/supabase-js`
2. Criar `src/lib/supabase.ts` com cliente singleton
3. Criar `src/pages/Login.tsx`:
- Login com email/senha
- Login com Google OAuth
- Esqueci senha
4. Criar `src/pages/Signup.tsx`:
- Cadastro com email/senha
- Confirmação por email (Supabase já gerencia)
5. Criar `src/store/auth.ts` (Zustand):
- State: `user`, `session`, `license`, `loading`
- Actions: `signIn`, `signOut`, `signUp`, `checkLicense`
6. Adicionar rota `/login` e `/signup` no `App.tsx`
7. **Guard de rota:** se não autenticado, redireciona pra `/login`
8. Persistir sessão (Supabase já faz via localStorage)
### 1.3 — Sistema de licença (client-side)
1. Criar `src/lib/license.ts`:
- `getLicenseKey()` → busca na tabela `licenses` ativa do user
- `validateLicense()` → confirma status = 'active' e não expirada
- `requireLicense()` → throw se inválida
2. Criar `src/components/LicenseGate.tsx`:
- Verifica licença no carregamento do app
- Se sem licença válida E sem trial ativo: mostra tela "Ative sua licença"
- Se trial ativo: mostra banner "Faltam X dias"
- Se licença válida: libera tudo
3. **Trial automático:**
- No primeiro login, criar registro de trial com 14 dias
- Verificação: `trial_started_at + 14 dias > now()`
4. **Cache local da licença** pra não bater no Supabase toda hora
- Validar no carregamento, depois a cada 6h
### 1.4 — Migração de IndexedDB para Supabase
1. Criar `src/lib/storage-adapter.ts`:
- Interface comum: `saveProject`, `loadProject`, `listProjects`, `deleteProject`
- Implementação A: `IndexedDBAdapter` (atual)
- Implementação B: `SupabaseAdapter` (novo)
- **Strategy pattern** — escolhe baseado em `user.authenticated`
2. Manter IndexedDB como fallback offline (sync quando voltar online)
3. Migração one-time: ao primeiro login, copiar projetos do IndexedDB pro Supabase
### 1.5 — Telemetria mínima (opcional mas útil)
- Contar projetos criados por usuário
- Saber quais módulos são mais usados
- **NÃO** telemetria invasiva — apenas métricas de uso agregado
- LGPD: política de privacidade clara, opt-in
### 1.6 — Testes da Fase 1
- Teste E2E: signup → login → criar projeto → salvar → listar
- Teste de licença: trial expira → gate aparece
- Teste de licença: chave válida → acesso liberado
---
## 🎨 FASE 2 — Identidade visual + landing page
**Objetivo:** Ter uma cara profissional + página de vendas que converta visitante em lead.
**Duração:** 1-2 semanas
**Pré-requisito:** Fase 0 (paralela à Fase 1)
### 2.1 — Identidade visual
1. **Logo:**
- Opção A: contratar Fiverr (R$ 80-200, 2-3 dias)
- Opção B: usar gerador de logo AI (Looka, Brandmark) — grátis/barato
- Opção C: começar só com wordmark (texto estilizado)
2. **Paleta de cores:**
- Primária: `#1e40af` (azul confiança) ou `#0f766e` (verde técnico)
- Secundária: `#f59e0b` (âmbar — destaque)
- Neutros: `#0f172a` (texto), `#64748b` (subtexto), `#f1f5f9` (background)
3. **Tipografia:**
- Heading: Inter Bold / Manrope Bold
- Body: Inter Regular / Manrope Regular
- Mono (código): JetBrains Mono / Fira Code
4. **Aplicação no app:**
- Header com logo
- Botões e CTAs com cor primária
- Footer com links legais
### 2.2 — Landing page
1. **Stack:** Coolify dessa VPS (subdomínio `app.brainwind.com.br` aponta pra landing, e o app SaaS fica em `app.brainwind.com.br/app`)
2. **Estrutura da landing (1 página):**
- **Hero:** Título grande + subtítulo + CTA "Começar trial grátis"
- **Problema:** "Calcular vento no Excel dá 4h e sempre dá medo de errar a norma"
- **Solução:** "BrainWind calcula em 5 minutos com NBR 6123:2023 atualizada"
- **Demo:** GIF/vídeo de 30 segundos mostrando o app calculando um galpão
- **Features:** Lista dos 9 tipos de estrutura cobertos
- **Para quem é:** Engenheiros estruturais, calculistas, projetistas
- **Validação:** "Auditado por IA independente, 91 cenários validados, NBR 6123:2023 completa"
- **Preço:** 3 cards (Anual R$ 1.500 / Perpétua R$ 4.000 / Empresa R$ 6.000)
- **FAQ:** 6-8 perguntas (norma atualizada?, tem nota fiscal?, roda offline?, ...)
- **CTA final:** "Comece grátis por 14 dias"
3. **SEO básico:**
- Title: "BrainWind — Cálculo de vento conforme NBR 6123:2023"
- Meta description otimizada
- Schema.org SoftwareApplication
### 2.3 — Páginas legais (obrigatórias)
1. **Termos de Uso** — pode usar template + adaptar
2. **Política de Privacidade** — LGPD compliance (Supabase tem template)
3. **Política de Cookies** (se usar)
4. **Disclaimer técnico:** "Software auxiliar. Profissional habilitado (CREA) é responsável pelo projeto."
### 2.4 — Email transacional
1. Criar `contato@brainwind.com.br` (Zoho Mail grátis até 5 usuários, ou usar Gmail com domínio próprio via Cloudflare Email Routing — grátis)
2. Templates de email:
- Boas-vindas (após signup)
- Confirmação de licença (após compra)
- Aviso de expiração (7 dias antes)
- Email de expiração
3. **Não precisa de ferramenta cara** — Supabase ou Resend (grátis até 100 emails/dia)
---
## 💳 FASE 3 — Empacotamento de venda (checkout + webhook)
**Objetivo:** Conectar o gateway de pagamento ao sistema de licença. Quando o cliente paga, recebe acesso automaticamente.
**Duração:** 1 semana
**Pré-requisito:** Fases 1 e 2
### 3.1 — Criar produtos no Kiwify
1. Conta Kiwify (kyc com CPF/CNPJ)
2. Criar 3 produtos:
- **BrainWind — Licença Anual** (R$ 1.500, recorrência anual)
- **BrainWind — Licença Perpétua** (R$ 4.000, pagamento único)
- **BrainWind — Empresa 5 usuários** (R$ 6.000, recorrência anual)
3. Configurar:
- URL de obrigado: `brainwind.com.br/obrigado?order=XYZ`
- Webhook URL: `https://app.brainwind.com.br/api/webhook/kiwify`
- Email de comprador: coletar (Kiwify faz isso)
### 3.2 — Endpoint de webhook
1. Criar pequeno serviço backend (FastAPI na VPS ou Edge Function Supabase):
- `POST /api/webhook/kiwify` recebe payload do Kiwify
- Valida assinatura (Kiwify envia hash)
- Identifica produto comprado → plano correspondente
- Verifica se já existe usuário com esse email
- **Se sim:** cria licença e vincula
- **Se não:** cria usuário via Supabase Admin API + cria licença + envia email de boas-vindas
- Resposta 200 OK
2. **Importante:** proteger endpoint com validação de assinatura do Kiwify
### 3.3 — Fluxo de pós-compra
1. Cliente paga no Kiwify
2. Kiwify chama webhook
3. Webhook cria licença no Supabase
4. Sistema envia email:
- Link de acesso
- License key (se modelo perpétuo)
- Instrução de primeiro acesso
5. Cliente entra no app → vê status "Ativo" automaticamente
### 3.4 — Renovação e expiração
1. **Cron diário** (rodar na VPS, PM2 ou cronjob):
- Verifica licenças próximas de expirar (7 dias antes)
- Envia email de aviso
- Se passou da data: marca como `expired`
2. **Reativação:** cliente paga de novo → webhook reativa licença
### 3.5 — Testes da Fase 3
- Teste de webhook em sandbox do Kiwify (eles têm ambiente de teste)
- Teste de fluxo completo: comprar → webhook → licença criada → login → acesso liberado
- Teste de expiração: forçar data de expiração no passado → gate aparece
---
## 📢 FASE 4 — Marketing + aquisição
**Objetivo:** Gerar leads e converter em clientes pagantes.
**Duração:** 1-2 semanas (esforço contínuo depois)
**Pré-requisito:** Fases 1, 2 e 3
### 4.1 — Identificar personas
**Persona primária: "João, o calculista"**
- Idade: 30-55 anos
- Cargo: Engenheiro estrutural autônomo ou em escritório pequeno/médio
- Atende projetos de galpão industrial, silo, cobertura metálica
- Usa Eberick/TQS para concreto, mas vento faz em planilha
- Dor: 4h por projeto recalculando, medo de errar versão da norma
- Onde está: grupos Facebook de engenharia, LinkedIn, YouTube técnico
**Persona secundária: "Marina, a projetista"**
- Idade: 25-40 anos
- Cargo: Projetista em escritório de cálculo estrutural
- Faz projetos repetitivos (galpões padronizados)
- Dor: retrabalho, normas desatualizadas
### 4.2 — Mensagem central (copy)
**Título hero:**
> "Cálculo de vento NBR 6123 em 5 minutos, sem planilha, sem erro de norma"
**Subtítulo:**
> "BrainWind calcula pressão, arrasto, atrito, Cpi e coeficientes aerodinâmicos para galpões, silos, abóbadas, cúpulas, muros, pontes, torres e estruturas reticuladas. Auditado e validado contra 91 cenários da norma."
### 4.3 — Conteúdo de aquisição
1. **Vídeo de demonstração (3 min):**
- Mostra fluxo: criar projeto → inserir dimensões → ver zonas de pressão → exportar PDF
- Hospedar no YouTube (unlisted ou público)
- Usar como principal material de venda
2. **Posts LinkedIn (engenheiro-para-engenheiro):**
- "Quanto tempo você perde recalculando vento de projeto?"
- "A NBR 6123:2023 mudou 3 coisas que você talvez ainda não atualizou"
- "Comparação: tempo de cálculo Excel vs BrainWind"
- Frequência: 2-3x por semana
3. **Instagram Reels:**
- Versões curtas (15-30s) do vídeo de demo
- "Tela + voz, sem aparecer"
- Frequência: 2-3x por semana
4. **Posts em grupos Facebook:**
- "Estruturalistas do Brasil"
- "Engenharia Estrutural"
- "Cálculo Estrutural — Galpões"
- "NBR 6118 / 6123 / 8800 — Dúvidas"
- **Não spammar** — participar como membro, oferecer ajuda, mencionar BrainWind quando pertinente
5. **Artigo técnico (blog):**
- "O que mudou na NBR 6123:2023 vs 2013?"
- SEO: rankeia pra "NBR 6123:2023 alterações"
- CTA no final: "Calculadora online que aplica a nova norma"
### 4.4 — Estratégia de beta-tester
**Objetivo:** 20-30 engenheiros usando de graça em troca de feedback + depoimento.
1. Recrutar nos grupos Facebook e LinkedIn:
- Post: "Estou lançando um software de cálculo de vento conforme NBR 6123:2023. Procuro 30 engenheiros pra usar de graça por 30 dias em troca de feedback."
2. Oferecer:
- 30 dias grátis (trial estendido)
- 50% de desconto vitalício se converter depois
- Acesso ao roadmap (influência no que é construído)
3. Coletar:
- Feedback semanal (formulário curto, 5 perguntas)
- Depoimentos em vídeo ou texto (com autorização pra usar em marketing)
- Bug reports
4. Cronograma:
- Semana 1-2: recrutamento
- Semana 3-6: uso + feedback
- Semana 7: ajustes finais baseados em feedback
### 4.5 — Materiais de venda
1. **One-pager PDF** (1 página com tudo que o app faz + preço)
- Pra mandar por WhatsApp quando alguém perguntar
2. **Tabela comparativa** ("BrainWind vs Excel vs Eberick")
3. **Prints do app** com destaque pros diferenciais
4. **Screenshot do relatório PDF** (motor de venda mais forte)
---
## 🧪 FASE 5 — Beta-tester + ajustes finais
**Objetivo:** Validar com usuários reais antes de abrir pra todo mundo.
**Duração:** 2-4 semanas
**Pré-requisito:** Fases 1-4
### 5.1 — Recrutar beta-testers
- 20-30 engenheiros (alvo da persona)
- Mix: autônomos + escritório pequeno + projetistas
- Captar via grupos Facebook e LinkedIn (Fase 4.4)
### 5.2 — Onboarding
1. Email de boas-vindas com:
- Link de cadastro
- License key de trial estendido
- Manual rápido PDF (5 páginas)
- Link do vídeo de 3 min
- Canal de suporte (WhatsApp ou Discord)
2. Webinar de lançamento interno (opcional): 30min apresentando o app + Q&A
### 5.3 — Coletar feedback
**Métricas a acompanhar:**
- Quantos se cadastram
- Quantos efetivamente criam um projeto
- Quantos completam um cálculo até o PDF
- NPS (Net Promoter Score)
- Bugs reportados
- Pedidos de feature
**Formulário semanal (Google Forms):**
1. Como foi sua experiência essa semana? (1-10)
2. Qual feature mais te ajudou?
3. O que está faltando?
4. Encontrou algum bug? Qual?
5. Recomendaria pra um colega? (0-10 NPS)
### 5.4 — Ajustes
- Corrigir bugs prioritários (P0/P1)
- Ajustar UX confuso (se houver)
- Adicionar features muito pedidas (1-2 no máximo, pra não virar bagunça)
### 5.5 — Coletar depoimentos
- Email ou vídeo curto (1 min) de cada beta-tester satisfeito
- **Autorização por escrito** pra usar em marketing
---
## 🚀 FASE 6 — Lançamento oficial
**Objetivo:** Abrir venda pra todo mundo.
**Duração:** 1 dia (o lançamento em si) + 1 semana de acompanhamento
**Pré-requisito:** Fase 5
### 6.1 — Checklist pré-lançamento
- [ ] Landing page no ar e testada
- [ ] Checkout Kiwify funcionando (teste com R$ 1 real)
- [ ] Webhook validando e criando licença
- [ ] App funcionando com auth + licença
- [ ] PDF de relatório profissional
- [ ] Termos de Uso + Política de Privacidade publicados
- [ ] Email transacional funcionando
- [ ] Suporte (WhatsApp Business) ativo
- [ ] MEI/CNPJ aberto
- [ ] Conta Kiwify aprovada
- [ ] Domínio registrado
- [ ] 3-5 depoimentos de beta-testers em mãos
- [ ] Vídeo de demo publicado
- [ ] Posts de lançamento agendados (LinkedIn, Instagram, FB)
### 6.2 — Dia do lançamento
1. **Post LinkedIn** (8h): "BrainWind v1.0 — cálculo de vento NBR 6123:2023 em 5 minutos"
2. **Post Instagram** (10h): Reel de 30s
3. **Posts em grupos Facebook** (12h, 16h, 20h): evitar spam, variar mensagens
4. **Email para lista** (se tiver): avisar do lançamento
5. **Atualizar landing** com badge "Lançamento" + contagem regressiva
6. **Atendimento extra** no suporte (primeiras 48h)
### 6.3 — Semana pós-lançamento
- Acompanhar conversão (visitantes → cadastros → compras)
- Responder todo feedback rápido
- Resolver bugs urgentes em < 24h
- Coletar mais depoimentos
### 6.4 — Definição de sucesso (primeiros 90 dias)
- 🎯 100 trials iniciados
- 🎯 20 clientes pagantes
- 🎯 R$ 30.000 de receita
- 🎯 NPS médio ≥ 50
---
## 📈 FASE 7 — Escala (pós-lançamento)
**Objetivo:** Crescer a base de clientes e expandir o produto.
### 7.1 — Versão desktop instalável
- Empacotar webapp com **Tauri** (binário menor que Electron, ~10MB)
- Windows .exe + macOS .dmg + Linux .AppImage
- Vantagem: sensação de "software sério", pode usar offline (com sync posterior)
- **Quando:** 1-2 meses após lançamento, se houver demanda
### 7.2 — Integrações
- **Ftool** (exportação .txt de carregamentos de vento)
- **SAP2000 / Robot** (exportação de cargas)
- **AutoCAD** (importação de geometria)
- **Excel** (importação de inputs em massa)
### 7.3 — Novos módulos NBR
- NBR 8800 (estruturas metálicas — já parcialmente coberto em pipe rack)
- NBR 6118 (concreto armado — parceria com Eberick seria mais estratégico)
- NBR 15421 (estruturas em situação de incêndio)
- **Cuidado:** não virar canivete suíço. Foco em vento = diferencial.
### 7.4 — Versão mobile (consulta rápida)
- Capacitor ou React Native empacotando webapp
- Cálculos de consulta rápida em obra
- Versão simplificada (input → resultado, sem edição de projeto)
### 7.5 — Marketplace de templates
- Usuários publicam templates de geometrias comuns
- Comissão pra você (10-20%)
- Cria comunidade em torno do produto
### 7.6 — Programa de afiliados
- Engenheiros indicam → ganham % recorrente
- Cupons de desconto
- Programa Kiwify já tem suporte nativo
### 7.7 — Internacionalização
- Outras normas de vento: ASCE 7 (EUA), Eurocódigo 1 (UE), AS/NZS 1170 (Austrália)
- Mercado americano e europeu paga muito mais (US$ 50-200/mês seria trivial)
- **Quando:** depois de validar no Brasil
---
## 📊 Métricas e KPIs (acompanhar sempre)
| Métrica | Onde medir | Meta |
|---------|-----------|------|
| Visitantes únicos/mês na landing | Plausible Analytics (LGPD-friendly) | 1.000+ |
| Conversão visitante → trial | Plausible + Supabase | 5-10% |
| Conversão trial → pago | Supabase + Kiwify | 20-30% |
| Churn mensal | Kiwify + Supabase | < 5% |
| NPS | Formulário no app | ≥ 50 |
| Tempo médio de uso | Telemetria | > 30min/sessão |
| Tickets de suporte/dia | WhatsApp / email | < 10 |
---
## 💰 Projeção financeira (cenário base)
| Mês | Trials novos | Pagantes novos | MRR |
|-----|--------------|----------------|-----|
| 1 (lançamento) | 30 | 6 | R$ 750 |
| 2 | 50 | 10 | R$ 1.250 + R$ 1.250 (renov.) |
| 3 | 80 | 16 | R$ 2.000 |
| 6 | 150/mês | 30/mês | R$ 3.750 |
| 12 | 250/mês | 50/mês | R$ 6.250 |
Cenário conservador baseado em produto técnico de nicho. Considerar que **80% da receita vem de 20% dos clientes** (licenças perpétuas).
**Custos fixos:**
- VPS (já tem) — R$ 0 incremental
- Domínio — R$ 50/ano
- Supabase (mesma VPS) — R$ 0
- Kiwify — ~10% sobre venda
- Email transacional — R$ 0 (Resend free tier)
- Contador (MEI → Simples) — R$ 100-300/mês
**Margem:** ~85% mesmo com Kiwify + contador.
---
## ⏱️ Cronograma macro
```
Mês 1 Mês 2 Mês 3 Mês 4 Mês 5+
├─────────┼──────────────┼───────────┼───────────┼──────────
│ Fase 0 │ Fase 1 + 2 │ Fase 3 │ Fase 4+5 │ Fase 6 │ Fase 7
│ Decisões│ Auth + Land. │ Checkout │ Marketing │ Lança │ Escala
│ │ │ │ + Beta │ │
└─────────┴──────────────┴───────────┴───────────┴──────────┘
```
Estimativa total até lançamento: **3-4 meses** com trabalho dedicado de meio período.
---
## ✅ Próxima ação imediata
**Escolha UMA das tarefas abaixo para começar HOJE:**
1. **[ ] Abrir MEI** — portal do empreendedor, 30 minutos
2. **[ ] Verificar domínio brainwind.com.br** — registro.br
3. **[ ] Criar conta Kiwify** — kiwify.com.br
4. **[ ] Esboçar landing page** — Figma/Canva/Word
5. **[ ] Começar implementação Supabase Auth** — código
Qual quer atacar primeiro?
+41 -9
View File
@@ -14,7 +14,7 @@ import PiperackModule from './pages/PiperackModule';
import { import {
Home, Settings, Menu, Cylinder, Church, CircleDot, Home, Settings, Menu, Cylinder, Church, CircleDot,
Square, Layers, BarChart3, Activity, Warehouse, Square, Layers, BarChart3, Activity, Warehouse,
Settings2, Sun, Moon, Frame, Settings2, Sun, Moon, Frame, FolderOpen, BookOpen
} from 'lucide-react'; } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -141,13 +141,14 @@ function AppLayout({ children }: { children: React.ReactNode }) {
</Link> </Link>
); );
})} })}
</nav>
<div className="border-t p-2 flex flex-col gap-1 items-center justify-center">
<GlobalWindSettingsModal <GlobalWindSettingsModal
trigger={ trigger={
<button <button
className={cn( className={cn(
'flex items-center gap-3 px-3 py-2 rounded-md transition-colors text-sm w-full text-left cursor-pointer text-muted-foreground hover:bg-secondary/50 hover:text-secondary-foreground', 'flex items-center gap-3 px-3 py-2.5 rounded-md transition-colors text-sm w-full text-left cursor-pointer text-muted-foreground hover:bg-secondary/50 hover:text-secondary-foreground',
isCollapsed && 'justify-center px-0', isCollapsed && 'justify-center px-0'
)} )}
title={isCollapsed ? 'Parâmetros do Vento' : undefined} title={isCollapsed ? 'Parâmetros do Vento' : undefined}
> >
@@ -156,18 +157,49 @@ function AppLayout({ children }: { children: React.ReactNode }) {
</button> </button>
} }
/> />
</nav> <Link
<div className="border-t p-2 flex flex-col gap-2 items-center justify-center"> to="/settings?tab=gerenciador"
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-md transition-colors text-sm w-full cursor-pointer',
location.pathname === '/settings' && new URLSearchParams(location.search).get('tab') === 'gerenciador'
? 'bg-primary text-primary-foreground font-medium shadow-sm'
: 'text-muted-foreground hover:bg-secondary/50 hover:text-secondary-foreground',
isCollapsed && 'justify-center px-0'
)}
title={isCollapsed ? 'Arquivos' : undefined}
>
<FolderOpen className="w-5 h-5 shrink-0" />
{!isCollapsed && <span>Arquivos</span>}
</Link>
<Link <Link
to="/settings" to="/settings"
className={cn( className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-md transition-colors text-sm w-full cursor-pointer text-muted-foreground hover:bg-secondary/50 hover:text-secondary-foreground', 'flex items-center gap-3 px-3 py-2.5 rounded-md transition-colors text-sm w-full cursor-pointer',
isCollapsed && 'justify-center px-0', location.pathname === '/settings' && (!location.search || location.search.includes('tab=config') || location.search.includes('tab=testes'))
? 'bg-primary text-primary-foreground font-medium shadow-sm'
: 'text-muted-foreground hover:bg-secondary/50 hover:text-secondary-foreground',
isCollapsed && 'justify-center px-0'
)} )}
title={isCollapsed ? t('nav_settings') : undefined} title={isCollapsed ? t('nav_settings') : undefined}
> >
<Settings className="w-5 h-5 shrink-0" /> <Settings className="w-5 h-5 shrink-0" />
{!isCollapsed && <span>{t('nav_settings')}</span>} {!isCollapsed && <span>Configurações</span>}
</Link>
<Link
to="/settings?tab=glossary"
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-md transition-colors text-sm w-full cursor-pointer',
location.pathname === '/settings' && new URLSearchParams(location.search).get('tab') === 'glossary'
? 'bg-primary text-primary-foreground font-medium shadow-sm'
: 'text-muted-foreground hover:bg-secondary/50 hover:text-secondary-foreground',
isCollapsed && 'justify-center px-0'
)}
title={isCollapsed ? 'Glossário' : undefined}
>
<BookOpen className="w-5 h-5 shrink-0" />
{!isCollapsed && <span>Glossário</span>}
</Link> </Link>
</div> </div>
</aside> </aside>
+87
View File
@@ -0,0 +1,87 @@
import React, { useState, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Search, BookOpen } from 'lucide-react';
import { nbr6123Glossary } from '@/data/glossary';
const Glossary: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const filteredTerms = useMemo(() => {
const termLower = searchTerm.toLowerCase();
return nbr6123Glossary.filter(item => {
if (item.term.toLowerCase().includes(termLower)) return true;
if (item.definition.toLowerCase().includes(termLower)) return true;
if (item.tags.some(t => t.toLowerCase().includes(termLower))) return true;
if (item.reference.toLowerCase().includes(termLower)) return true;
return false;
});
}, [searchTerm]);
return (
<div className="space-y-6">
<Card className="border-border shadow-sm bg-card/50">
<CardHeader className="pb-4">
<div className="flex items-center gap-2">
<div className="p-2 bg-primary/10 rounded-lg">
<BookOpen className="w-5 h-5 text-primary" />
</div>
<div>
<CardTitle className="text-xl">Glossário NBR 6123</CardTitle>
<CardDescription>
Dicionário técnico com termos aerodinâmicos e variáveis normativas explicadas de forma didática.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar termo, sigla (ex: Cpe, S1) ou conceito..."
className="pl-10 max-w-full"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2">
{filteredTerms.length > 0 ? (
filteredTerms.map((item) => (
<Card key={item.id} className="overflow-hidden hover:border-primary/50 transition-colors">
<CardHeader className="p-4 bg-muted/20 border-b pb-3">
<CardTitle className="text-base text-primary flex items-center justify-between">
{item.term}
</CardTitle>
<CardDescription className="text-xs text-muted-foreground font-mono mt-1">
{item.reference}
</CardDescription>
</CardHeader>
<CardContent className="p-4">
<p className="text-sm leading-relaxed text-foreground/90">
{item.definition}
</p>
<div className="flex flex-wrap gap-1.5 mt-4">
{item.tags.map((tag, idx) => (
<Badge key={idx} variant="secondary" className="text-[10px] font-normal px-2 py-0 h-5">
{tag}
</Badge>
))}
</div>
</CardContent>
</Card>
))
) : (
<div className="col-span-1 md:col-span-2 py-12 text-center text-muted-foreground border border-dashed rounded-lg">
Nenhum termo encontrado para "{searchTerm}".
</div>
)}
</div>
</div>
);
};
export default Glossary;
+121
View File
@@ -0,0 +1,121 @@
import React, { useState } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Save, FileJson, FileBox } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from './ui/dialog';
import { useProjects } from '../lib/hooks/useProjects';
import { useWindStore } from '../store/appStore';
interface SaveModuleDialogProps {
moduleType: 'galpao' | 'cilindro' | 'vault' | 'dome' | 'sign' | 'isolated-roof' | 'bar' | 'bridge' | 'dynamics' | 'piperack';
inputs: Record<string, unknown>;
triggerButton?: React.ReactNode;
}
export const SaveModuleDialog: React.FC<SaveModuleDialogProps> = ({ moduleType, inputs, triggerButton }) => {
const { save } = useProjects();
const windStore = useWindStore();
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [type, setType] = useState<'projeto' | 'template'>('projeto');
// Gera um nome sugerido ao abrir
React.useEffect(() => {
if (open && !name) {
const date = new Date().toLocaleDateString('pt-BR').replace(/\//g, '');
const prefix = type === 'projeto' ? 'PRJ' : 'TPL';
const modName = moduleType.charAt(0).toUpperCase() + moduleType.slice(1);
setName(`${prefix}-${modName}-${date}`);
}
}, [open, type, moduleType, name]);
const handleSave = async () => {
if (!name.trim()) return;
const windData = type === 'projeto' ? {
v0: windStore.v0,
s1: windStore.s1,
s3: windStore.s3,
s3Group: windStore.s3Group,
terrainCategory: windStore.terrainCategory,
largestDimension: windStore.largestDimension,
heightZ: windStore.heightZ,
} : undefined;
await save({
name,
type,
module: moduleType,
inputs,
windData,
createdAt: Date.now(),
updatedAt: Date.now(),
});
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{triggerButton || (
<Button variant="outline" size="sm" className="gap-2">
<Save className="h-4 w-4" />
Salvar
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Salvar Módulo</DialogTitle>
<DialogDescription>
Escolha como deseja salvar este módulo.
Projetos incluem os dados de vento atuais. Templates salvam apenas a geometria da estrutura.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<Button
variant={type === 'projeto' ? 'default' : 'outline'}
className="h-20 flex flex-col gap-2"
onClick={() => setType('projeto')}
>
<FileBox className="h-6 w-6" />
Projeto Completo
</Button>
<Button
variant={type === 'template' ? 'default' : 'outline'}
className="h-20 flex flex-col gap-2"
onClick={() => setType('template')}
>
<FileJson className="h-6 w-6" />
Template (Geometria)
</Button>
</div>
<div className="grid gap-2">
<label htmlFor="name" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Nome do Arquivo</label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Digite um nome para identificar..."
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>Cancelar</Button>
<Button onClick={handleSave} disabled={!name.trim()}>Salvar {type === 'projeto' ? 'Projeto' : 'Template'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+29 -25
View File
@@ -55,49 +55,53 @@ function PiperackModel({
); );
} }
// Viga Longitudinal da fileira // Viga Longitudinal da fileira (representando a face reticulada do pórtico)
frames.push( frames.push(
<mesh key={`beam-${i}`} position={[0, elevation, z]} receiveShadow castShadow> <mesh key={`beam-${i}`} position={[0, elevation, z]} receiveShadow castShadow>
<boxGeometry args={[width, height, 0.3]} /> <boxGeometry args={[width, height, 0.3]} />
<meshStandardMaterial color={steelColor} metalness={0.6} roughness={0.4} transparent opacity={phiTotal} /> {/* Opacidade ajustada para atingir 90% quando a solidez for 1.0 */}
<meshStandardMaterial color={steelColor} metalness={0.6} roughness={0.4} transparent opacity={Math.max(0.1, Math.min(0.9, phiTotal * 0.9))} depthWrite={false} />
</mesh> </mesh>
); );
} }
// Vigas transversais conectando as fileiras nas pontas
if (numFrames > 1) {
frames.push(
<mesh key={`cross-1`} position={[-halfW, elevation, 0]} receiveShadow castShadow>
<boxGeometry args={[0.3, height, totalSpacing]} />
<meshStandardMaterial color={steelColor} metalness={0.6} roughness={0.4} transparent opacity={phiTotal} />
</mesh>
);
frames.push(
<mesh key={`cross-2`} position={[halfW, elevation, 0]} receiveShadow castShadow>
<boxGeometry args={[0.3, height, totalSpacing]} />
<meshStandardMaterial color={steelColor} metalness={0.6} roughness={0.4} transparent opacity={phiTotal} />
</mesh>
);
}
// Gera as tubulações correndo longitudinalmente (ao longo de X) // Gera as tubulações correndo longitudinalmente (ao longo de X)
const pipeMeshes = pipes.map((pipe, idx) => { // Agrupa tubos pela mesma elevação para evitar colisões e distribuí-los lado a lado (ao longo de Z)
// Distribui os tubos ao longo do espaçamento transversal (Z) const pipeMeshes: any[] = [];
const zPos = numFrames > 1 const groups: Record<number, {pipe: PipeDef, idx: number}[]> = {};
? -zOffset + 0.5 + (idx % Math.max(pipes.length, 1)) * (totalSpacing - 1) / Math.max(pipes.length - 1, 1) || 0
: 0; pipes.forEach((pipe, idx) => {
// elevationOffset varia de 0 (base da estrutura reticulada) a height (topo da estrutura) // Arredondamos a elevação para agrupar valores muito próximos
// A base da estrutura fica em: rackTop - height, que é elevation - height/2 const key = Math.round(pipe.elevationOffset * 100) / 100;
if (!groups[key]) groups[key] = [];
groups[key].push({pipe, idx});
});
Object.values(groups).forEach(group => {
const gap = 0.2; // 20cm de espaçamento entre tubos na mesma elevação
const totalDiam = group.reduce((sum, item) => sum + item.pipe.diameter, 0);
const totalGroupWidth = totalDiam + gap * (group.length - 1);
// Inicia a distribuição centralizada em Z=0
let currentZ = -totalGroupWidth / 2;
group.forEach((item) => {
const { pipe, idx } = item;
const zPos = currentZ + pipe.diameter / 2;
currentZ += pipe.diameter + gap;
const yPos = (elevation - height/2) + pipe.elevationOffset + pipe.diameter/2; const yPos = (elevation - height/2) + pipe.elevationOffset + pipe.diameter/2;
const color = pipeColors[idx % pipeColors.length]; const color = pipeColors[idx % pipeColors.length];
return ( pipeMeshes.push(
<mesh key={`pipe-${pipe.id}`} position={[0, yPos, zPos]} rotation={[0, 0, Math.PI / 2]} receiveShadow castShadow> <mesh key={`pipe-${pipe.id}`} position={[0, yPos, zPos]} rotation={[0, 0, Math.PI / 2]} receiveShadow castShadow>
<cylinderGeometry args={[pipe.diameter / 2, pipe.diameter / 2, width + 1, 16]} /> <cylinderGeometry args={[pipe.diameter / 2, pipe.diameter / 2, width + 1, 16]} />
<meshStandardMaterial color={color} metalness={0.3} roughness={0.2} /> <meshStandardMaterial color={color} metalness={0.3} roughness={0.2} />
</mesh> </mesh>
); );
}); });
});
return ( return (
<group> <group>
+150
View File
@@ -0,0 +1,150 @@
export interface GlossaryTerm {
id: string;
term: string;
definition: string;
reference: string;
tags: string[];
}
export const nbr6123Glossary: GlossaryTerm[] = [
{
id: 'v0',
term: 'Velocidade Básica do Vento (V₀)',
definition: 'Velocidade do vento obtida a partir de dados meteorológicos. É definida como a velocidade média de 3 segundos, excedida em média uma vez em 50 anos, a 10 metros de altura, sobre terreno plano e aberto. Usada como base para todos os cálculos aerodinâmicos na NBR 6123.',
reference: 'NBR 6123:2023 - Seção 5.2',
tags: ['velocidade', 'isopletas', 'parâmetro básico']
},
{
id: 's1',
term: 'Fator Topográfico (S₁)',
definition: 'Coeficiente que considera as variações do relevo do terreno. Leva em conta se o terreno é plano, fracamente acidentado, talude ou morro, aumentando a velocidade do vento em encostas e cumes.',
reference: 'NBR 6123:2023 - Seção 5.3',
tags: ['relevo', 'topografia', 'talude', 'morro']
},
{
id: 's2',
term: 'Fator de Rugosidade (S₂)',
definition: 'Coeficiente que leva em consideração a rugosidade do terreno (obstáculos como árvores e prédios) e as dimensões da edificação. Depende da categoria do terreno (I a V) e aumenta de acordo com a altura (z) da estrutura.',
reference: 'NBR 6123:2023 - Seção 5.4 / Tabela 3',
tags: ['rugosidade', 'altura', 'categoria', 'dimensões']
},
{
id: 's3',
term: 'Fator Estatístico (S₃)',
definition: 'Fator de probabilidade que considera o nível de segurança e a vida útil da edificação. Valores comuns são 1,0 para edificações normais (50 anos), mas pode ser maior (ex: hospitais) ou menor (ex: instalações temporárias).',
reference: 'NBR 6123:2023 - Seção 5.5',
tags: ['segurança', 'probabilidade', 'uso']
},
{
id: 'vk',
term: 'Velocidade Característica (Vₖ)',
definition: 'A velocidade do vento de projeto em uma determinada altura e situação, calculada multiplicando a velocidade básica pelos três fatores: Vₖ = V₀ × S₁ × S₂ × S₃.',
reference: 'NBR 6123:2023 - Seção 5.1',
tags: ['velocidade de projeto', 'fórmula']
},
{
id: 'q',
term: 'Pressão Dinâmica (q)',
definition: 'A energia cinética do vento transformada em pressão. É calculada a partir da velocidade característica: q = 0,613 × Vₖ² (em N/m²). Esta pressão é aplicada nas superfícies junto com os coeficientes aerodinâmicos.',
reference: 'NBR 6123:2023 - Seção 5.6',
tags: ['pressão', 'energia cinética']
},
{
id: 'cpe',
term: 'Coeficiente de Pressão Externa (Cpe)',
definition: 'Valor adimensional que indica como o vento atua nas faces externas de uma edificação (paredes e telhados). Valores positivos indicam pressão (empurrando a face), valores negativos indicam sucção (puxando a face).',
reference: 'NBR 6123:2023 - Seção 6.2 / Tabelas 6 a 12',
tags: ['pressão', 'sucção', 'externo', 'fachada', 'cobertura']
},
{
id: 'cpi',
term: 'Coeficiente de Pressão Interna (Cpi)',
definition: 'Valor que determina a pressão exercida pelo ar no interior da edificação, gerada devido a aberturas e frestas (portas, janelas, ventilação). Depende do índice de permeabilidade de cada face do prédio.',
reference: 'NBR 6123:2023 - Seção 6.3',
tags: ['pressão', 'interno', 'permeabilidade', 'aberturas']
},
{
id: 'barlavento',
term: 'Barlavento',
definition: 'Região ou face da edificação por onde o vento "chega" ou incide primeiro. Normalmente as superfícies a barlavento sofrem pressões positivas (empuxo contra a parede).',
reference: 'NBR 6123:2023 - Conceito Geral',
tags: ['incidência', 'pressão positiva', 'vento']
},
{
id: 'sotavento',
term: 'Sotavento',
definition: 'Região ou face da edificação que fica "escondida" ou protegida do vento incidente (a face traseira). Normalmente sofre pressões negativas (sucção), pois o vento passa e "puxa" o ar daquela região.',
reference: 'NBR 6123:2023 - Conceito Geral',
tags: ['traseira', 'sucção', 'esteira']
},
{
id: 'ca',
term: 'Coeficiente de Arrasto (Ca)',
definition: 'Coeficiente de força global. Diferente do Cpe, que atua pontualmente em painéis, o Ca define a força total de arrasto gerada no volume inteiro da estrutura (ex: chaminés, torres, pórticos).',
reference: 'NBR 6123:2023 - Seção 6.1.2',
tags: ['arrasto', 'força global', 'torres', 'cilindros']
},
{
id: 'esbeltez',
term: 'Índice de Esbeltez (λ)',
definition: 'Relação entre a altura da estrutura e a sua largura base (ou diâmetro). Estruturas muito esbeltas (finas e altas) são mais sensíveis aos efeitos do vento e exigem coeficientes de arrasto específicos e análises dinâmicas.',
reference: 'NBR 6123:2023 - Seção 6.1 / Diversas Tabelas',
tags: ['altura', 'largura', 'aerodinâmica']
},
{
id: 'vortex',
term: 'Desprendimento de Vórtices (Vortex Shedding)',
definition: 'Fenômeno dinâmico onde o vento, ao passar por uma estrutura (geralmente cilíndrica ou prismática), cria vórtices alternados nas laterais, causando oscilações transversais à direção do vento. Pode gerar ressonância estrutural perigosa.',
reference: 'NBR 6123:2023 - Seção 10.2',
tags: ['dinâmica', 'ressonância', 'cilindros', 'chaminés', 'vibração transversal']
},
{
id: 'flutter',
term: 'Esvoaçamento (Flutter)',
definition: 'Instabilidade aeroelástica dinâmica onde a estrutura (ex: tabuleiro de ponte estaiada) começa a girar e vibrar de forma autossustentada e crescente devido à interação complexa entre o vento e o formato da seção.',
reference: 'NBR 6123:2023 - Seção 11',
tags: ['pontes', 'aeroelasticidade', 'torção', 'instabilidade']
},
{
id: 'galloping',
term: 'Galope (Galloping)',
definition: 'Fenômeno dinâmico de instabilidade em flexão pura transversal à direção do vento. Comum em perfis não circulares ou cabos com formação de gelo/água assimétrica.',
reference: 'NBR 6123:2023 - Seção 11.4',
tags: ['dinâmica', 'pontes', 'cabos', 'vibração lateral']
},
{
id: 'phi',
term: 'Índice de Solidez (φ)',
definition: 'Fator que define a "porosidade" de uma estrutura vazada (como uma torre treliçada, letreiro vazado ou pipe-rack). É a área efetiva (cheia) dos perfis dividida pela área de contorno total do painel. Aumenta o coeficiente de arrasto quanto mais "sólida" for a estrutura.',
reference: 'NBR 6123:2023 - Seção 7',
tags: ['treliças', 'torres', 'permeabilidade', 'bloqueio']
},
{
id: 'eta',
term: 'Fator de Proteção (η)',
definition: 'Quando há pórticos ou treliças posicionados um atrás do outro (ex: pipe-racks ou torres retangulares), a face traseira sofre menor incidência do vento por estar "protegida" pela da frente. O fator η pondera essa redução.',
reference: 'NBR 6123:2023 - Seção 7 / Tabela 28',
tags: ['treliças', 'sombreamento', 'pipe-racks', 'arrasto conjunto']
},
{
id: 'rugosidade',
term: 'Categoria de Terreno',
definition: 'A norma define 5 categorias de rugosidade: I (superfícies lisas, mar calmo), II (campos abertos), III (terrenos planos com pequenos obstáculos, fazendas), IV (áreas urbanas, subúrbios densos) e V (centros de grandes cidades).',
reference: 'NBR 6123:2023 - Seção 5.4.1',
tags: ['terreno', 's2', 'urbanização', 'relevo']
},
{
id: 'forca_atrito',
term: 'Força de Atrito (Ff)',
definition: 'Força que o vento exerce tangencialmente às superfícies paralelas ao seu escoamento, devido à fricção superficial (ex: tetos muito compridos ou paredes extensas).',
reference: 'NBR 6123:2023 - Seção 6.1.5',
tags: ['fricção', 'tangencial', 'galpões compridos']
},
{
id: 'pressao_efetiva',
term: 'Pressão Efetiva (Δp)',
definition: 'A diferença real de pressão que atua em uma superfície (como parede ou telhado), combinando a ação externa (Cpe) e interna (Cpi). Fórmula: Δp = q × (Cpe - Cpi). Resulta na carga real em N/m² que a estrutura vai suportar.',
reference: 'NBR 6123:2023 - Seção 6.1.3',
tags: ['pressão resultante', 'cálculo final']
}
];
+3 -1
View File
@@ -8,8 +8,10 @@
export interface SavedProject { export interface SavedProject {
id?: number; id?: number;
name: string; name: string;
module: 'galpao' | 'cilindro' | 'vault' | 'dome' | 'sign' | 'isolated-roof' | 'bar' | 'bridge' | 'dynamics'; type: 'projeto' | 'template';
module: 'galpao' | 'cilindro' | 'vault' | 'dome' | 'sign' | 'isolated-roof' | 'bar' | 'bridge' | 'dynamics' | 'piperack';
inputs: Record<string, unknown>; inputs: Record<string, unknown>;
windData?: Record<string, unknown>;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
} }
+30
View File
@@ -1,5 +1,8 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { useHydrationStore } from '@/store/hydrationStore';
import { SaveModuleDialog } from '@/components/SaveModuleDialog';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
@@ -12,11 +15,32 @@ import ExportMenu from '../components/ExportMenu';
import { EducationalManual } from '@/components/EducationalManual'; import { EducationalManual } from '@/components/EducationalManual';
import { exportGenericToPDF, type GenericPDFSection } from '../lib/export-generic-pdf'; import { exportGenericToPDF, type GenericPDFSection } from '../lib/export-generic-pdf';
const BridgeModule: React.FC = () => { const BridgeModule: React.FC = () => {
const { v0, s1, terrainCategory, q } = useWindStore(); const { v0, s1, terrainCategory, q } = useWindStore();
const [lp, setLp] = React.useState(120); const [lp, setLp] = React.useState(120);
const [width, setWidth] = React.useState(14); const [width, setWidth] = React.useState(14);
const [deckHeight, setDeckHeight] = React.useState(15); const [deckHeight, setDeckHeight] = React.useState(15);
const pendingLoad = useHydrationStore((s) => s.pendingLoad);
const clearPendingLoad = useHydrationStore((s) => s.clearPendingLoad);
React.useEffect(() => {
if (pendingLoad && pendingLoad.module === 'bridge') {
const p = pendingLoad.inputs as any;
if (p.lp !== undefined) setLp(p.lp);
if (p.width !== undefined) setWidth(p.width);
if (p.deckHeight !== undefined) setDeckHeight(p.deckHeight);
if (p.heg !== undefined) setHeg(p.heg);
if (p.mass !== undefined) setMass(p.mass);
if (p.fv !== undefined) setFv(p.fv);
if (p.vk !== undefined) setVk(p.vk);
if (p.alpha !== undefined) setAlpha(p.alpha);
clearPendingLoad();
}
}, [pendingLoad, clearPendingLoad]);
// Constantes normativas de flutter da seção
const [heg, setHeg] = React.useState(2.5); const [heg, setHeg] = React.useState(2.5);
const [mass, setMass] = React.useState(18000); const [mass, setMass] = React.useState(18000);
const [fv, setFv] = React.useState(0.6); const [fv, setFv] = React.useState(0.6);
@@ -246,8 +270,14 @@ const BridgeModule: React.FC = () => {
</Card> </Card>
<div className="flex justify-center bg-background rounded-xl border border-border shadow-sm p-4"> <div className="flex justify-center bg-background rounded-xl border border-border shadow-sm p-4">
<div className="flex flex-col items-end gap-2">
<SaveModuleDialog
moduleType="bridge"
inputs={{ lp, width, deckHeight, heg, mass, fv, vk, alpha }}
/>
<ExportMenu onExportPDF={handleExportPDF} /> <ExportMenu onExportPDF={handleExportPDF} />
</div> </div>
</div>
<SceneCapturePanel /> <SceneCapturePanel />
</div> </div>
+28 -1
View File
@@ -15,6 +15,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import ExportMenu from '../components/ExportMenu'; import ExportMenu from '../components/ExportMenu';
import { EducationalManual } from '@/components/EducationalManual'; import { EducationalManual } from '@/components/EducationalManual';
import { PressureLegend } from '@/components/PressureLegend'; import { PressureLegend } from '@/components/PressureLegend';
import { SaveModuleDialog } from '@/components/SaveModuleDialog';
import { useHydrationStore } from '@/store/hydrationStore';
const GalpaoModule: React.FC = () => { const GalpaoModule: React.FC = () => {
const { const {
@@ -33,6 +35,21 @@ const GalpaoModule: React.FC = () => {
windAngle, windAngle,
setWindAngle, setWindAngle,
} = useWindStore(); } = useWindStore();
const pendingLoad = useHydrationStore((s) => s.pendingLoad);
const clearPendingLoad = useHydrationStore((s) => s.clearPendingLoad);
React.useEffect(() => {
if (pendingLoad && pendingLoad.module === 'galpao') {
const p = pendingLoad.inputs as any;
if (p.width !== undefined) setWidth(p.width);
if (p.length !== undefined) setLength(p.length);
if (p.height !== undefined) setHeight(p.height);
if (p.roofPitch !== undefined) setRoofPitch(p.roofPitch);
clearPendingLoad();
}
}, [pendingLoad, clearPendingLoad, setWidth, setLength, setHeight, setRoofPitch]);
return ( return (
<div className="flex flex-col lg:flex-row h-full w-full gap-4 p-4 lg:p-6 bg-muted/30"> <div className="flex flex-col lg:flex-row h-full w-full gap-4 p-4 lg:p-6 bg-muted/30">
{/* Coluna Esquerda: Controles */} {/* Coluna Esquerda: Controles */}
@@ -146,7 +163,6 @@ const GalpaoModule: React.FC = () => {
</div> </div>
<div className="flex flex-wrap gap-2 pointer-events-auto justify-start sm:justify-end"> <div className="flex flex-wrap gap-2 pointer-events-auto justify-start sm:justify-end">
<ExportMenu />
<EducationalManual type="warehouse" params={{ windAngle, cpi }} /> <EducationalManual type="warehouse" params={{ windAngle, cpi }} />
</div> </div>
</div> </div>
@@ -161,6 +177,17 @@ const GalpaoModule: React.FC = () => {
{/* Coluna Direita-Inferior: Cargas Lineares (M9.2) */} {/* Coluna Direita-Inferior: Cargas Lineares (M9.2) */}
<div className="w-full lg:w-96 shrink-0 flex flex-col gap-4 overflow-y-auto pb-8 lg:pb-0"> <div className="w-full lg:w-96 shrink-0 flex flex-col gap-4 overflow-y-auto pb-8 lg:pb-0">
<LinearLoadsTable /> <LinearLoadsTable />
<div className="flex justify-center bg-background rounded-xl border border-border shadow-sm p-4">
<div className="flex flex-col items-end gap-2">
<SaveModuleDialog
moduleType="galpao"
inputs={{ width, length, height, roofPitch }}
/>
<ExportMenu />
</div>
</div>
<SceneCapturePanel /> <SceneCapturePanel />
<FtoolExportCard /> <FtoolExportCard />
</div> </div>
+31 -4
View File
@@ -1,5 +1,8 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useHydrationStore } from '@/store/hydrationStore';
import { SaveModuleDialog } from '@/components/SaveModuleDialog';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -24,6 +27,23 @@ const PiperackModule: React.FC = () => {
const [phiStruct, setPhiStruct] = useState(0.5); const [phiStruct, setPhiStruct] = useState(0.5);
const [pipes, setPipes] = useState<PipeDef[]>([]); const [pipes, setPipes] = useState<PipeDef[]>([]);
const pendingLoad = useHydrationStore((s) => s.pendingLoad);
const clearPendingLoad = useHydrationStore((s) => s.clearPendingLoad);
React.useEffect(() => {
if (pendingLoad && pendingLoad.module === 'piperack') {
const p = pendingLoad.inputs as any;
if (p.elevation !== undefined) setElevation(p.elevation);
if (p.width !== undefined) setWidth(p.width);
if (p.height !== undefined) setHeight(p.height);
if (p.spacing !== undefined) setSpacing(p.spacing);
if (p.numFrames !== undefined) setNumFrames(p.numFrames);
if (p.pipes !== undefined) setPipes(p.pipes);
if (p.phiStruct !== undefined) setPhiStruct(p.phiStruct);
clearPendingLoad();
}
}, [pendingLoad, clearPendingLoad]);
const addPipe = () => { const addPipe = () => {
setPipes([...pipes, { id: Math.random().toString(36).substr(2, 9), diameter: 0.5, elevationOffset: height / 2 }]); setPipes([...pipes, { id: Math.random().toString(36).substr(2, 9), diameter: 0.5, elevationOffset: height / 2 }]);
}; };
@@ -91,7 +111,7 @@ const PiperackModule: React.FC = () => {
return ( return (
<div className="flex flex-col h-[calc(100vh-3.5rem)] md:h-screen bg-background"> <div className="flex flex-col h-[calc(100vh-3.5rem)] md:h-screen bg-background">
<header className="shrink-0 flex items-center justify-between px-6 py-4 border-b bg-card"> <header className="shrink-0 flex items-center px-6 py-4 border-b bg-card">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg"> <div className="p-2 bg-primary/10 rounded-lg">
<Layers className="w-5 h-5 text-primary" /> <Layers className="w-5 h-5 text-primary" />
@@ -101,9 +121,6 @@ const PiperackModule: React.FC = () => {
<p className="text-sm text-muted-foreground">Vento transversal (Sec. 7 - NBR 6123)</p> <p className="text-sm text-muted-foreground">Vento transversal (Sec. 7 - NBR 6123)</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-3">
<ExportMenu onExportPDF={handleExportPDF} />
</div>
</header> </header>
<div className="flex-1 flex flex-col lg:flex-row p-4 gap-4 overflow-hidden bg-muted/30"> <div className="flex-1 flex flex-col lg:flex-row p-4 gap-4 overflow-hidden bg-muted/30">
@@ -403,6 +420,16 @@ const PiperackModule: React.FC = () => {
</CardContent> </CardContent>
</Card> </Card>
<div className="flex justify-center bg-background rounded-xl border border-border shadow-sm p-4">
<div className="flex flex-col items-end gap-2">
<SaveModuleDialog
moduleType="piperack"
inputs={{ elevation, width, height, spacing, numFrames, pipes, phiStruct }}
/>
<ExportMenu onExportPDF={handleExportPDF} />
</div>
</div>
<SceneCapturePanel /> <SceneCapturePanel />
</div> </div>
</div> </div>
+140 -109
View File
@@ -1,27 +1,31 @@
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Sun, Moon, Laptop, Save, Trash2, Upload, CheckCircle2, AlertCircle, FlaskConical } from 'lucide-react'; import { Sun, Moon, Laptop, Save, Trash2, Upload, FlaskConical, Download, FolderOpen, FileBox, FileJson } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useTheme } from '@/lib/theme'; import { useTheme } from '@/lib/theme';
import { useProjects } from '@/lib/hooks/useProjects'; import { useProjects } from '@/lib/hooks/useProjects';
import { useWindStore } from '@/store/appStore'; import { useWindStore } from '@/store/appStore';
import { useHydrationStore } from '@/store/hydrationStore';
import { useI18n } from '@/store/i18nStore'; import { useI18n } from '@/store/i18nStore';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { useNavigate, useLocation } from 'react-router-dom';
import { import {
importProjectFromText, importProjectFromText,
readProjectFile, readProjectFile,
type ImportResult,
} from '@/lib/import-project'; } from '@/lib/import-project';
import AuditPanel from '@/components/AuditPanel'; import AuditPanel from '@/components/AuditPanel';
import Glossary from '@/components/Glossary';
const SettingsModule: React.FC = () => { const SettingsModule: React.FC = () => {
const { theme, setTheme, effectiveTheme } = useTheme(); const { theme, setTheme, effectiveTheme } = useTheme();
const { projects, loading, error, remove } = useProjects(); const { projects, loading, error, remove, save } = useProjects();
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const navigate = useNavigate();
const location = useLocation();
const setPendingLoad = useHydrationStore((s) => s.setPendingLoad);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const exportSnapshot = () => { const exportSnapshot = () => {
@@ -30,12 +34,36 @@ const SettingsModule: React.FC = () => {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.setAttribute('href', url); link.setAttribute('href', url);
link.setAttribute('download', 'ventoapp-state.json'); link.setAttribute('download', 'ventoapp-snapshot.json');
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
}; };
const handleDownloadJSON = (project: any) => {
const blob = new Blob([JSON.stringify(project, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', `${project.name}.json`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleLoadProject = (project: any) => {
if (project.type === 'projeto' && project.windData) {
// Restore wind data
useWindStore.setState(project.windData);
}
// Set pending load
setPendingLoad(project.module, project.inputs);
// Navigate to module
navigate(`/${project.module === 'isolated-roof' ? 'cobertura-isolada' : project.module === 'sign' ? 'muros' : project.module === 'bar' ? 'barras' : project.module === 'bridge' ? 'pontes' : project.module === 'dynamics' ? 'dinamica' : project.module === 'vault' ? 'abobada' : project.module === 'dome' ? 'cupula' : project.module}`);
};
const handleImportClick = () => { const handleImportClick = () => {
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
@@ -44,29 +72,51 @@ const SettingsModule: React.FC = () => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
setImporting(true); setImporting(true);
setImportResult(null);
try { try {
const text = await readProjectFile(file); const text = await readProjectFile(file);
const result = importProjectFromText(text); const parsed = JSON.parse(text);
setImportResult(result); if (parsed.module && parsed.inputs) {
// Formato SavedProject (Projeto ou Template)
const newProject = {
...parsed,
name: parsed.name || 'Importado - ' + file.name,
type: parsed.type || 'projeto',
createdAt: Date.now(),
updatedAt: Date.now(),
};
// Ensure type is correct
if (!['projeto', 'template'].includes(newProject.type)) newProject.type = 'projeto';
await save(newProject as any);
} else {
// Old snapshot import fallback
importProjectFromText(text);
}
} catch (e) { } catch (e) {
setImportResult({ console.error(e);
ok: false,
error: e instanceof Error ? e.message : 'Falha ao ler arquivo',
});
} finally { } finally {
setImporting(false); setImporting(false);
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
} }
}; };
const currentTab = new URLSearchParams(location.search).get('tab') || 'config';
const isSettingsGroup = currentTab === 'config' || currentTab === 'testes';
return ( return (
<div className="p-6 max-w-5xl mx-auto overflow-auto"> <div className="p-6 max-w-5xl mx-auto overflow-auto">
<h1 className="text-3xl font-bold mb-6">{t('nav_settings')}</h1> <h1 className="text-3xl font-bold mb-6 md:hidden">Configurações</h1>
<h1 className="text-3xl font-bold mb-6 hidden md:block">
{currentTab === 'gerenciador' ? 'Arquivos' :
currentTab === 'glossary' ? 'Glossário' :
'Configurações'}
</h1>
<Tabs defaultValue="config" className="w-full"> <Tabs value={currentTab} onValueChange={(val) => navigate(`/settings?tab=${val}`)} className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6"> <TabsList className={`grid w-full mb-6 ${isSettingsGroup ? 'grid-cols-4 md:grid-cols-2' : 'grid-cols-4 md:hidden'}`}>
<TabsTrigger value="config">Configurações</TabsTrigger> <TabsTrigger value="config">Preferências</TabsTrigger>
<TabsTrigger value="gerenciador" className="md:hidden">Arquivos</TabsTrigger>
<TabsTrigger value="glossary" className="text-red-500 uppercase font-bold tracking-wider md:hidden">GLOSSÁRIO</TabsTrigger>
<TabsTrigger value="testes"> <TabsTrigger value="testes">
<FlaskConical className="w-4 h-4 mr-1.5" /> <FlaskConical className="w-4 h-4 mr-1.5" />
Testes Testes
@@ -98,99 +148,6 @@ const SettingsModule: React.FC = () => {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">{t('settings_projects')}</CardTitle>
<CardDescription>{t('settings_projects_desc')}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between mb-3 gap-2 flex-wrap">
<p className="text-sm text-muted-foreground">
{t('settings_projects_count', { count: projects.length })}
</p>
<div className="grid grid-cols-2 gap-1.5 w-full sm:w-auto sm:flex">
<Button size="sm" variant="outline" className="text-xs px-2 h-8 flex-1 sm:flex-none" onClick={handleImportClick} disabled={importing}>
<Upload className="w-3.5 h-3.5 mr-1.5 shrink-0" /> {importing ? t('settings_importing') : t('export_import')}
</Button>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleFileSelected}
className="hidden"
/>
<Button size="sm" variant="outline" className="text-xs px-2 h-8 flex-1 sm:flex-none" onClick={exportSnapshot}>
<Save className="w-3.5 h-3.5 mr-1.5 shrink-0" /> {t('export_snapshot')}
</Button>
</div>
</div>
{importResult && (
<div
className={`rounded-md border p-3 mb-3 text-sm flex items-start gap-2 ${
importResult.ok
? 'bg-emerald-50 border-emerald-200 text-emerald-700 dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-300'
: 'bg-red-50 border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300'
}`}
>
{importResult.ok ? (
<CheckCircle2 className="w-4 h-4 mt-0.5 shrink-0" />
) : (
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="font-medium">
{importResult.ok ? t('settings_import_success') : t('settings_import_error')}
</div>
{importResult.module && (
<div className="text-xs mt-1">
{t('settings_import_module')}: <Badge variant="outline">{importResult.module}</Badge>
{importResult.projectName && (
<span className="ml-2">{t('settings_import_project')}: {importResult.projectName}</span>
)}
</div>
)}
{importResult.appliedFields && importResult.appliedFields.length > 0 && (
<div className="text-xs mt-1">
{t('settings_import_fields', { count: importResult.appliedFields.length })}: {importResult.appliedFields.join(', ')}
</div>
)}
{importResult.warnings && importResult.warnings.length > 0 && (
<div className="text-xs mt-1 opacity-80">
{t('settings_import_warnings')}: {importResult.warnings.join('; ')}
</div>
)}
{importResult.error && (
<div className="text-xs mt-1">{importResult.error}</div>
)}
</div>
</div>
)}
{loading && <p className="text-sm text-muted-foreground">{t('common_loading')}</p>}
{error && <p className="text-sm text-destructive">{error}</p>}
{projects.length === 0 && !loading && (
<p className="text-sm text-muted-foreground">{t('settings_no_projects')}</p>
)}
<div className="space-y-2">
{projects.map((p) => (
<div key={p.id} className="flex items-center justify-between p-2 border rounded-md hover:bg-muted/40">
<div className="flex-1">
<div className="font-medium text-sm">{p.name}</div>
<div className="text-xs text-muted-foreground">
{p.module} · {new Date(p.updatedAt).toLocaleString(locale === 'pt-BR' ? 'pt-BR' : 'en-US')}
</div>
</div>
<Button size="sm" variant="ghost" onClick={() => p.id && remove(p.id)}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">{t('settings_about')}</CardTitle> <CardTitle className="text-lg">{t('settings_about')}</CardTitle>
@@ -210,10 +167,84 @@ const SettingsModule: React.FC = () => {
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="gerenciador" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">Arquivos (Gerenciador)</CardTitle>
<CardDescription>Gerencie seus Projetos Completos e Templates de geometria salvos localmente.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between mb-4 gap-2 flex-wrap">
<p className="text-sm text-muted-foreground">
{projects.length} arquivo(s) armazenado(s).
</p>
<div className="grid grid-cols-2 gap-1.5 w-full sm:w-auto sm:flex">
<Button size="sm" variant="outline" className="text-xs px-2 h-8 flex-1 sm:flex-none" onClick={handleImportClick} disabled={importing}>
<Upload className="w-3.5 h-3.5 mr-1.5 shrink-0" /> Importar Arquivo
</Button>
<Button size="sm" variant="outline" className="text-xs px-2 h-8 flex-1 sm:flex-none" onClick={exportSnapshot}>
<Save className="w-3.5 h-3.5 mr-1.5 shrink-0" /> Exportar Wind Snapshot
</Button>
</div>
</div>
{loading && <p className="text-sm text-muted-foreground">{t('common_loading')}</p>}
{error && <p className="text-sm text-destructive">{error}</p>}
{projects.length === 0 && !loading && (
<p className="text-sm text-muted-foreground">Nenhum projeto ou template salvo ainda.</p>
)}
<div className="space-y-2">
{projects.map((p) => (
<div key={p.id} className="flex items-center justify-between p-3 border rounded-md hover:bg-muted/40 transition-colors">
<div className="flex items-center gap-3">
{p.type === 'projeto' ? (
<div className="p-2 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded-md">
<FileBox className="w-5 h-5" />
</div>
) : (
<div className="p-2 bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400 rounded-md">
<FileJson className="w-5 h-5" />
</div>
)}
<div>
<div className="font-medium text-sm flex items-center gap-2">
{p.name}
<Badge variant="outline" className="text-[10px] px-1 py-0 h-4">
{p.module}
</Badge>
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{p.type === 'projeto' ? 'Projeto (Vento + Geometria)' : 'Template (Apenas Geometria)'} · {new Date(p.updatedAt).toLocaleString(locale === 'pt-BR' ? 'pt-BR' : 'en-US')}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Button size="sm" variant="ghost" title="Carregar" className="h-8 w-8 p-0 text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:bg-emerald-950/50" onClick={() => handleLoadProject(p)}>
<FolderOpen className="w-4 h-4" />
</Button>
<Button size="sm" variant="ghost" title="Baixar JSON" className="h-8 w-8 p-0" onClick={() => handleDownloadJSON(p)}>
<Download className="w-4 h-4" />
</Button>
<Button size="sm" variant="ghost" title="Excluir" className="h-8 w-8 p-0 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/50" onClick={() => p.id && remove(p.id)}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="glossary" className="space-y-6">
<Glossary />
</TabsContent>
<TabsContent value="testes" className="space-y-6"> <TabsContent value="testes" className="space-y-6">
<AuditPanel /> <AuditPanel />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<input type="file" ref={fileInputRef} onChange={handleFileSelected} className="hidden" accept=".json" />
</div> </div>
); );
}; };
+16
View File
@@ -0,0 +1,16 @@
import { create } from 'zustand';
export interface HydrationState {
pendingLoad: {
module: string;
inputs: Record<string, unknown>;
} | null;
setPendingLoad: (module: string, inputs: Record<string, unknown>) => void;
clearPendingLoad: () => void;
}
export const useHydrationStore = create<HydrationState>((set) => ({
pendingLoad: null,
setPendingLoad: (module, inputs) => set({ pendingLoad: { module, inputs } }),
clearPendingLoad: () => set({ pendingLoad: null }),
}));