Compare commits

...

188 Commits
v0.2.0 ... main

Author SHA1 Message Date
Lucas Santana
fc0ef9ba27 fix: corrige tipagem das métricas de escrita com competências do ENEM
Some checks failed
Docker Build and Push / build (push) Has been cancelled
- Atualiza interface WritingMetrics para incluir campos das competências do ENEM
- Corrige valores padrão no metricsStore
- Atualiza inicialização de métricas no StudentDashboardPage
- Mantém compatibilidade com o sistema de métricas existente

patch: Correção de tipagem sem alteração de funcionalidade
2025-02-13 10:17:57 -03:00
Lucas Santana
f883a6e9c2 feat: melhora layout da análise de redações com seção dedicada para competências do ENEM
Some checks are pending
Docker Build and Push / build (push) Waiting to run
- Separa critérios gerais e competências do ENEM em seções distintas
- Adiciona nova seção dedicada com layout aprimorado para competências do ENEM
- Melhora visualização das barras de progresso e justificativas
- Inclui descrições detalhadas para cada competência
- Implementa cards coloridos para melhor organização visual
- Aprimora apresentação dos critérios gerais de avaliação

patch: Apenas melhorias visuais, sem alterações na funcionalidade
2025-02-12 20:03:12 -03:00
Lucas Santana
2ff79ced53 feat: adiciona competências ENEM na análise de redações
- Adiciona campos para armazenar as 5 competências do ENEM na tabela essay_analyses
- Atualiza função analyze-essay para salvar notas e justificativas das competências
- Adiciona restrições para validar valores entre 0 e 200 pontos
- Atualiza documentação com comentários nos campos

patch: atualização incremental que adiciona funcionalidade sem quebrar compatibilidade
2025-02-12 19:29:12 -03:00
Lucas Santana
374ac90a3b feat: Adicionando análises do ENEM 2025-02-12 19:26:05 -03:00
Lucas Santana
cdb98eb61d refactor: Imagem vertical no mobile na leitura de história
Some checks failed
Docker Build and Push / build (push) Has been cancelled
2025-02-08 12:02:09 -03:00
Lucas Santana
c53fbeb444 fix: Comentando seção de criação de história por voz 2025-02-08 10:44:43 -03:00
Lucas Santana
c2bcfe1e3f fix: Retirando tracking do Rudderstack do localhost
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2025-02-08 06:51:01 -03:00
Lucas Santana
bb85c83c5b Dashboard
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2025-02-07 12:03:46 -03:00
Lucas Santana
2175458186 feat: implementa store global de métricas e corrige processamento de dados
- Adiciona store global usando Zustand para gerenciamento de métricas
- Implementa funções específicas para atualização de métricas
- Corrige processamento de métricas semanais
- Melhora manipulação de estados e performance
- Resolve problema de dados vazios nos gráficos
2025-02-07 11:04:49 -03:00
Lucas Santana
190777dcd0 feat: adiciona componentes de visualização para métricas de escrita
Cria WritingMetricsSection para exibição de cards de métricas
Cria WritingMetricsChart para visualização da evolução
Integra novos componentes ao StudentDashboardPage
Mantém consistência visual com métricas de leitura
type: feat
scope: metrics
breaking: false
2025-02-07 10:55:24 -03:00
Lucas Santana
8c6e6aedd3 feat: separa estrutura de dados para métricas de leitura e escrita
- Cria novo arquivo de tipos para métricas
- Refatora interfaces para separar métricas de leitura e escrita
- Atualiza StudentDashboardPage para usar novas interfaces
- Prepara estrutura para implementação das métricas de escrita

type: feat
scope: metrics
breaking: false
2025-02-07 10:50:10 -03:00
Lucas Santana
8b45fe72e7 refactor: Refatorando estilo da Essay Analysis 2025-02-07 10:43:40 -03:00
Lucas Santana
ccbac66d28 feat: adiciona novos recursos de formatação e tracking no editor 2025-02-07 10:32:28 -03:00
Lucas Santana
46e8ba0312 fix: corrige fluxo de redações e visualização pós-análise - Corrige carregamento do conteúdo após envio - Adiciona salvamento automático antes da análise - Melhora UX com feedback visual e badges 2025-02-07 10:20:48 -03:00
Lucas Santana
c94c46f5c1 fix: corrige consulta de análise de redações - Adiciona join com tabelas relacionadas - Implementa transformação dos dados - Adiciona tratamento para valores nulos 2025-02-07 10:06:27 -03:00
Lucas Santana
28ac3ef8cc refactor: simplifica validação do JSON Schema da análise de redações - Remove limites min/max dos campos numéricos - Remove restrição minItems dos arrays - Simplifica validação para maior flexibilidade 2025-02-07 10:04:54 -03:00
Lucas Santana
756335f78f fix: corrige políticas RLS para análise de redações - Simplifica política de inserção para service_role - Adiciona políticas para tabelas relacionadas - Melhora segurança com políticas específicas 2025-02-07 09:42:25 -03:00
Lucas Santana
9d303b0c7a refactor: normaliza JSON Schema da análise de redações - Reordena campos para corresponder à estrutura do banco de dados - Ajusta descrições dos campos para maior clareza - Alinha com as tabelas: essay_analyses e relacionadas - Melhora validação dos dados com JSON Schema mais preciso 2025-02-07 09:37:15 -03:00
Lucas Santana
0eafbd5350 feat: melhora logs e tratamento de erros na análise de redações
- Adiciona logs detalhados em cada etapa do processo
- Melhora validação das variáveis de ambiente
- Implementa tratamento de erros mais robusto
- Padroniza formato de respostas de erro
- Refina schema de validação da OpenAI
2025-02-06 22:13:14 -03:00
Lucas Santana
4609217fb7 feat: Corrigindo analyze-essay 2025-02-06 21:59:18 -03:00
Lucas Santana
1c6aa56b32 style: padroniza visual da listagem de redações
- Alinha estilo com StudentStoriesPage
- Adiciona busca e filtros avançados
- Melhora feedback visual e estados interativos
- Implementa loading states animados
2025-02-06 21:54:01 -03:00
Lucas Santana
2929946499 feat: implementa editor de texto rico com TipTap
- Adiciona editor WYSIWYG com formatação básica
- Implementa contagem de palavras em tempo real
- Adiciona barra de ferramentas de formatação
- Suporte a alinhamento de texto e destaque
2025-02-06 21:44:56 -03:00
Lucas Santana
1bc307d599 style: padroniza visual do editor de redações
- Alinha estilo com StoryPage e CreateStoryPage
- Adiciona suporte a texto adaptativo
- Melhora feedback visual e estados interativos
- Implementa loading states animados
2025-02-06 21:41:08 -03:00
Lucas Santana
e9005e429f fix: corrige erro de undefined em NewEssay
- Adiciona verificação de segurança para requirements
- Implementa valores padrão para min/max words
- Adiciona renderização condicional para elementos necessários
2025-02-06 21:39:02 -03:00
Lucas Santana
b767d60c50 style: padroniza visual da criação de redações
- Alinha estilo dos cards com CreateStoryPage
- Adiciona suporte a texto adaptativo
- Melhora feedback visual e estados interativos
- Implementa loading states animados
2025-02-06 21:38:01 -03:00
Lucas Santana
63498e92c6 feat: adiciona política RLS para deleção de redações
- Permite que alunos deletem apenas suas próprias redações
- Mantém consistência com outras políticas RLS existentes
- Adiciona rollback apropriado para a nova política
2025-02-06 21:34:17 -03:00
Lucas Santana
cc45bb974d style: ajusta visual e animações do AlertDialog
- Reduz opacidade do overlay para 50%
- Simplifica animações mantendo apenas fade in/out
- Aumenta duração da transição para 300ms
2025-02-06 21:30:20 -03:00
Lucas Santana
da62f5e722 refactor: atualiza dialog de confirmação para AlertDialog no EssayPage
- Substitui Dialog básico pelo AlertDialog especializado
- Melhora feedback visual na confirmação de deleção
- Mantém consistência com o design system
- Implementa padrões de acessibilidade do Radix UI
2025-02-06 21:26:46 -03:00
Lucas Santana
d1e44f84b7 feat: Implementando páginas de essays 2025-02-06 20:44:41 -03:00
Lucas Santana
f602f4c666 feat: Implementando Edge Function Analyze-Essay 2025-02-06 20:22:30 -03:00
Lucas Santana
206f7bcb30 feat: Criando tabelas para nova funcionalidade de correção de redação 2025-02-06 20:06:25 -03:00
Lucas Santana
478ca2441d refactor: extrai componentes de métricas do dashboard
Some checks are pending
Docker Build and Push / build (push) Waiting to run
Modulariza os cards de métricas em componentes reutilizáveis:

- Cria componente MetricCard para cards individuais
- Cria componente DashboardMetrics para agrupamento
- Move configurações de métricas para constantes
- Adiciona suporte a tooltips e ícones personalizados
- Mantém responsividade e acessibilidade
- Simplifica o StudentDashboardPage

Mudanças técnicas:
- Extrai lógica de renderização para componentes dedicados
- Centraliza configuração de métricas em constantes
- Melhora tipagem com interfaces dedicadas
- Adiciona suporte a tooltips informativos
- Mantém consistência visual com o design system
- Reduz duplicação de código
2025-02-06 14:34:58 -03:00
Lucas Santana
7a0bc3f8ca refactor: extrai componente MetricsChart do dashboard
Modulariza o gráfico de métricas em um componente reutilizável:

- Cria novo componente @/components/dashboard/MetricsChart
- Move toda a lógica de filtragem e visualização para o componente
- Define interfaces e tipos apropriados
- Encapsula estados e lógica de filtragem
- Simplifica a interface do componente (props)
- Mantém toda funcionalidade existente

Mudanças técnicas:
- Extrai interfaces e tipos relacionados
- Move constantes de configuração para o componente
- Encapsula lógica de filtragem temporal
- Simplifica o StudentDashboardPage
- Melhora a manutenibilidade e reusabilidade
2025-02-06 14:27:45 -03:00
Lucas Santana
7bb2a9a1b7 feat: adiciona filtro de período no gráfico de métricas
Implementa um sistema de filtragem temporal no gráfico de evolução das métricas,
permitindo visualizar diferentes períodos de tempo:

- Opções de 3, 6, 12 meses e todo período
- Visualização padrão dos últimos 12 meses
- Interface intuitiva com botões de período
- Filtragem automática dos dados
- Design consistente com o resto da aplicação

Mudanças técnicas:
- Adiciona sistema de filtragem temporal
- Implementa conversão de datas semana/ano
- Otimiza renderização do gráfico
2025-02-06 14:01:03 -03:00
Lucas Santana
c029aab50f feat: adiciona gráfico de evolução das métricas na dashboard
Implementa um novo gráfico combinado (linha + barras) para visualização da
evolução das métricas do aluno ao longo do tempo. O gráfico inclui:

- Visualização das métricas principais (fluência, pronúncia, etc.)
- Gráfico de barras com minutos lidos por semana
- Botões interativos para filtrar métricas
- Design moderno com gradientes e animações
- Agrupamento automático por semana
- Layout responsivo

Principais mudanças técnicas:
- Adiciona Recharts para visualização de dados
- Implementa processamento de métricas semanais
- Otimiza carregamento e agrupamento de dados
2025-02-06 13:54:30 -03:00
Lucas Santana
18bc42d280 feat: Ajustando links da Homepage
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2025-02-06 10:09:24 -03:00
Lucas Santana
14c71062f1 feat: Ajustando os link s da Homepage
git push
git add -A
2025-02-06 10:09:07 -03:00
Lucas Santana
be340d132e fix: Corrigindo separação silábica
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2025-02-05 16:59:12 -03:00
Lucas Santana
75c1e6f9f2 fix: adicionando mais contexto na geração das histórias 2025-02-05 12:08:05 -03:00
Lucas Santana
66866602e7 fix: Definindo json_schema no prompt das histórias
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2025-02-05 09:07:37 -03:00
Lucas Santana
f3fbdb8228 fix: Corrigindo visualização do dashboard do estudante 2025-02-04 17:14:09 -03:00
Lucas Santana
7e93a59609 feat: aprimora métricas do dashboard do aluno - Calcula métricas usando todas as histórias e gravações - Adiciona novas métricas detalhadas - Implementa tooltips explicativos - Separa consultas de métricas e exibição 2025-02-04 16:04:57 -03:00
Lucas Santana
69dbb5fa48 fix: Corrigindo visualização de imagens das histórias
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2025-02-04 15:25:05 -03:00
Lucas Santana
9e3f7a7c31 fix: Ajustando menu lateral do aluno 2025-02-04 10:11:08 -03:00
Lucas Santana
821b6ca9ec fix: Corrigindo responsividade 2025-02-04 10:01:49 -03:00
Lucas Santana
abe4ce86d4 feat: integra sistema de idiomas com banco de dados
Some checks failed
Docker Build and Push / build (push) Has been cancelled
- Integra completamente com a tabela languages
- Adiciona suporte para ícones de bandeira e instruções
- Remove LANGUAGE_OPTIONS hard coded
- Usa DEFAULT_LANGUAGE do type
- Melhora validações e UX do seletor de idiomas
- Atualiza CHANGELOG.md para versão 1.4.0
2025-02-02 08:20:33 -03:00
Lucas Santana
ba93f3ef29 fix: corrigindo o titulo das historias
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2025-02-01 18:58:42 -03:00
Lucas Santana
fa8073dcee feat: adiciona suporte a múltiplos idiomas na geração de histórias
- Adiciona suporte para Português (Brasil), Inglês (EUA) e Espanhol (Espanha)
- Implementa nova etapa de seleção de idioma no fluxo de criação
- Adiciona instruções específicas por idioma no prompt da IA
- Atualiza CHANGELOG.md para versão 1.3.0
2025-02-01 09:38:59 -03:00
Lucas Santana
45a4b1ba24 docs: adicionando documentação ao projeto 2025-01-31 10:50:48 -03:00
Lucas Santana
13536790fe fix: Ajustando páigna de leitura
Some checks failed
Docker Build and Push / build (push) Has been cancelled
2025-01-29 14:37:18 -03:00
Lucas Santana
9f7ea648fe fix: Ajustando visualização da página de leitura de história 2025-01-29 14:22:35 -03:00
Lucas Santana
e81dc5bedf fix: Ajustando visualização da página de leitura de história 2025-01-29 14:18:16 -03:00
Lucas Santana
4790d9788b fix: Ajustando páigna de leitura 2025-01-29 14:15:59 -03:00
Lucas Santana
d949587c44 fix: Ajustando visualização da história 2025-01-29 11:34:43 -03:00
Lucas Santana
bc2f120700 docs: Adicionando documentação no projeto 2025-01-27 17:44:00 -03:00
Lucas Santana
d35565dee4 docs: adiciona documentação completa do banco de dados - Documenta estrutura atual do Supabase - Adiciona diagramas ER e relacionamentos - Inclui pol��ticas de seguran��a e ��ndices - Documenta triggers e fun����es
Some checks failed
Docker Build and Push / build (push) Has been cancelled
2025-01-27 15:52:42 -03:00
Lucas Santana
94835a427b fix: Mudando o botão de gravação de lugar
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2025-01-26 12:55:09 -03:00
Lucas Santana
dadcb048bb feat: implementa Modo Foco para grava����o de leitura - Adiciona ativa����o/desativa����o autom��tica, integra AudioRecorder, interface adaptativa e atualiza CHANGELOG 2025-01-26 12:26:21 -03:00
Lucas Santana
e5204e0430 feat: aprimora sistema de gravação de áudio
- Atualiza AudioRecorder com tipagem correta
- Corrige gerenciamento de gravações no StoryPage
- Adiciona suporte para conversão WebM para MP3
- Melhora feedback visual e tratamento de erros
- Implementa inicialização adequada de métricas
2025-01-26 12:04:11 -03:00
Lucas Santana
51b8fb4088 feat: adicionando controles de texto 2025-01-26 12:03:00 -03:00
Lucas Santana
dd9e2f4dd3 feat: adicionando controles de texto 2025-01-26 11:55:06 -03:00
Lucas Santana
59a7adfeee fix: SpeechRecognition import 2025-01-26 11:11:04 -03:00
Lucas Santana
ccacf76d9a feat: adiciona criação de histórias por comando de voz - Implementa VoiceCommandButton e useSpeechRecognition - Adiciona validação de conteúdo de áudio - Integra geração de histórias por voz - Atualiza CHANGELOG.md para versão 1.2.0 2025-01-26 07:23:11 -03:00
Lucas Santana
c5a3017a7c feat: Implementando criação de história por voz 2025-01-25 11:58:30 -03:00
Lucas Santana
90506ca894 fix: corrigindo UI de letras maiúsculas
Some checks failed
Docker Build and Push / build (push) Has been cancelled
2025-01-24 10:10:41 -03:00
Lucas Santana
62594f5e62 fix: Capas
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2025-01-23 19:59:01 -03:00
Lucas Santana
e154dd2372 fix: Corrigindo capas das histórias 2025-01-23 19:01:03 -03:00
Lucas Santana
ea5c5e87f1 feat: Adicionando separação de sílabas 2025-01-23 16:49:12 -03:00
Lucas Santana
229a1bffbb feat: Adiciona toggle de texto maiúsculo para apoio à alfabetização
- Implementa componente TextCaseToggle para alternância de caixa
- Cria sistema de texto adaptativo com componentes AdaptiveText
- Adiciona hook useUppercasePreference para gerenciar estado
- Integra funcionalidade em todas as páginas principais
- Persiste preferência do usuário no banco de dados
2025-01-23 15:30:35 -03:00
Lucas Santana
e4c225ebd7 feat: Adiciona toggle de texto maiúsculo para apoio à alfabetização
- Implementa componente TextCaseToggle para alternância de caixa
- Cria sistema de texto adaptativo com componentes AdaptiveText
- Adiciona hook useUppercasePreference para gerenciar estado
- Integra funcionalidade em todas as páginas principais
- Persiste preferência do usuário no banco de dados
2025-01-23 15:29:08 -03:00
Lucas Santana
7880ce8dda feat: Adicionando transformação de texto para maiúsculo 2025-01-23 13:28:13 -03:00
Lucas Santana
a0cfccc14d fix: Phonic types
Some checks failed
Docker Build and Push / build (push) Has been cancelled
2025-01-20 07:08:43 -03:00
Lucas Santana
663c2fb8ff fix: Phonic types 2025-01-19 17:02:32 -03:00
Lucas Santana
f1f2906d09 fix: Phonic types
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2025-01-19 16:57:41 -03:00
Lucas Santana
ce845607f9 fix: Corrigindo tracking na Geracao de Historia 2025-01-19 10:47:50 -03:00
Lucas Santana
198cad0047 fix: corrigindo CORS Headers
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2025-01-19 10:31:52 -03:00
Lucas Santana
0c2a63dcd3 fix: corrigindo CORS
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2025-01-18 17:42:58 -03:00
Lucas Santana
5d4c9b6d49 fix: corrigindo image_url na functions generate-story 2025-01-18 12:15:46 -03:00
Lucas Santana
f37f8f2f6d fix: corrige tipos e queries dos hooks de exercícios fônicos
Some checks are pending
Docker Build and Push / build (push) Waiting to run
- Corrige tipo de retorno em useExerciseWords
- Ajusta usePhonicsExercises para filtrar por categoria
- Atualiza queries para usar inner join e ordenação
- Adiciona interfaces para melhor tipagem
- Corrige convenção de nomes para snake_case
2025-01-18 06:53:24 -03:00
Lucas Santana
350a66bb9e feat: implementa sistema de exercícios f��nicos
Some checks are pending
Docker Build and Push / build (push) Waiting to run
- Cria estrutura completa de banco de dados para exerc��cios f��nicos

- Implementa tabelas para categorias, tipos, exerc��cios e palavras

- Adiciona sistema de progresso e conquistas do estudante

- Configura pol��ticas de seguran��a RLS para prote����o dos dados

- Otimiza performance com ��ndices e relacionamentos apropriados

BREAKING CHANGE: Nova estrutura de banco de dados para exerc��cios f��nicos
2025-01-17 20:59:50 -03:00
Lucas Santana
e1a99f32f5 fix: Consolidando StudentDashbord
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2025-01-17 17:25:30 -03:00
Lucas Santana
18cf6a2495 fix: Adicionando tracking na página de Demo 2025-01-17 16:07:07 -03:00
Lucas Santana
6a1a471ce5 fix: Corrigindo deduplicação de eventos no Rudderstack 2025-01-17 12:51:36 -03:00
Lucas Santana
bcbdd07a41 fix: PageTracker geral 2025-01-17 12:39:10 -03:00
Lucas Santana
98411b2aa1 feat: Documentação do Analytics 2025-01-17 11:23:20 -03:00
Lucas Santana
41a225d460 feat: Melhorando tracking com o Rudderstack
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2025-01-17 11:14:05 -03:00
Lucas Santana
09c4894a1c feat: Mudando nome do app de Histórias Mágicas para Leiturama
Some checks failed
Docker Build and Push / build (push) Has been cancelled
2025-01-16 08:57:04 -03:00
Lucas Santana
bd58cbad7d feat: Mudando nome do app de Histórias Mágicas para Leiturama 2025-01-16 08:49:56 -03:00
Lucas Santana
a975e2486b feat: Mudando nome do app de Histórias Mágicas para Leiturama 2025-01-16 07:41:05 -03:00
Lucas Santana
546690fbc8 feat: Mudando nome do app de Histórias Mágicas para Leiturama 2025-01-16 04:40:37 -03:00
Lucas Santana
2852b889b2 feat: Melhorando tracking
Some checks failed
Docker Build and Push / build (push) Has been cancelled
2025-01-12 14:15:07 -03:00
Lucas Santana
3cdd136a4e feat: implementa tracking avançado nos planos e p��ginas
Some checks are pending
Docker Build and Push / build (push) Waiting to run
- Adiciona tracking detalhado nos bot��es dos planos

- Atualiza PageTracker com dados enriquecidos do usu��rio

- Remove CTA de demonstra����o dos planos

- Corrige tipagem do objeto User no PageTracker

- Adiciona CHANGELOG.md com documenta����o das mudan��as
2025-01-12 09:38:05 -03:00
Lucas Santana
33b9b38ff4 Ajustando Rudderstack
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2025-01-11 15:29:52 -03:00
Lucas Santana
1bcb0a9c37 Ajustando Rudderstack 2025-01-11 15:24:31 -03:00
Lucas Santana
d2567ac478 Ajustando Rudderstack 2025-01-11 15:18:03 -03:00
Lucas Santana
953b7a78d0 Ajustando Rudderstack 2025-01-11 15:08:35 -03:00
Lucas Santana
21f7aa7c40 Implementando Rudderstack 2025-01-11 14:52:27 -03:00
Lucas Santana
6e9d847c77 Implementando Rudderstack 2025-01-11 14:45:47 -03:00
Lucas Santana
1542572be4 Implementação do GTM 2025-01-11 14:23:39 -03:00
Lucas Santana
75d9d4635b feat: Sentry implementado 2025-01-11 13:54:02 -03:00
Lucas Santana
a7612879bf feat: atualiza configurações de CORS e CSP 2025-01-11 13:46:22 -03:00
Lucas Santana
00cd9edb1c fix: Corrigindo a Interface do TestimonialCard 2025-01-11 10:37:46 -03:00
Lucas Santana
a45ebd2719 feat: Ajustando EducationalForParents 2025-01-11 10:08:15 -03:00
Lucas Santana
6398e2ac81 Consolidando estilos 2025-01-11 09:46:07 -03:00
Lucas Santana
0ccea7c7b9 feat: expande conteúdo da Text Sales Letter para mais de 5.000 palavras 2025-01-11 09:16:25 -03:00
Lucas Santana
9fa7b9732d feat: Text Sales Letter 2025-01-11 09:12:03 -03:00
Lucas Santana
6478d20d62 feat: adiciona Text Sales Letter sobre educa����o baseada em evid��ncias
- Cria p��gina TSL com foco em m��todos cient��ficos

- Adiciona rota /evidencias/tsl

- Atualiza CHANGELOG.md
2025-01-11 09:11:38 -03:00
Lucas Santana
0e2215b6ad feat: implementa FAQ simplificado em todas as Landing Pages
- Cria componente FAQ reutiliz��vel sem Accordion

- Implementa FAQ em todas as Landing Pages com conte��do espec��fico

- Remove depend��ncia do Radix UI

- Atualiza CHANGELOG.md
2025-01-11 09:05:54 -03:00
Lucas Santana
1ea1b3e841 fix: corrige tipos e testes
Some checks are pending
Docker Build and Push / build (push) Waiting to run
- Refatora interfaces do banco de dados com BaseEntity

- Corrige conflitos de tipos em email, status e cover

- Padroniza tipos de campos em todas as interfaces

- Corrige erro no teste do WordHighlighter

- Atualiza CHANGELOG.md
2025-01-11 07:55:58 -03:00
Lucas Santana
9b023e7ef9 feat: implementa componentes reutiliz��veis Footer e Plans
- Adiciona componente Footer reutiliz��vel para todas as Landing Pages

- Cria componentes PlanForParents e PlanForSchools

- Implementa os novos componentes nas p��ginas existentes

- Melhora a organiza����o e reutiliza����o de c��digo

- Atualiza CHANGELOG.md com as altera����es
2025-01-11 07:51:18 -03:00
Lucas Santana
b8562bfda1 Cursorignore added
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2025-01-10 21:43:02 -03:00
Lucas Santana
c422a6186e feat: implementa interesses do aluno e melhora responsividade dos menus
- Adiciona nova aba de Interesses nas configura����es do aluno

- Implementa sistema de notifica����es toast usando Radix UI

- Torna menus laterais responsivos e colaps��veis

- Adiciona colapso autom��tico dos menus ao clicar em um item

- Cria tabela interests no banco de dados com pol��ticas RLS
2025-01-10 21:41:41 -03:00
Lucas Santana
634fa6fb48 fix: corrige erros de tipagem no ExercisePage
Some checks failed
Docker Build and Push / build (push) Has been cancelled
- Adiciona interfaces para Story e StoryRecording
- Corrige tipagem no sort de recordings
- Melhora type safety no acesso aos dados

type: fix
scope: typescript
breaking: false
2025-01-01 10:10:57 -03:00
Lucas Santana
9840fe76b0 feat: aprimora interface do exercício de formação de palavras
- Adiciona barra de progresso e feedback visual
- Implementa lista de palavras encontradas
- Melhora interatividade e estados visuais
- Adiciona validação de palavras repetidas
- Otimiza transições e animações
- Mantém consistência com outros exercícios

type: feat
scope: exercises
breaking: false
2025-01-01 10:09:59 -03:00
Lucas Santana
745f8de40e feat: implementa sistema de deleção de histórias
Some checks failed
Docker Build and Push / build (push) Has been cancelled
- Adiciona modal de confirmação de deleção
- Implementa limpeza em cascata de recursos
- Otimiza remoção de arquivos no storage
- Adiciona feedback visual do processo
- Melhora tratamento de erros
- Implementa navegação pós-deleção
2024-12-31 07:05:36 -03:00
Lucas Santana
e23914657f feat: implementa sistema de deleção de histórias
- Adiciona modal de confirmação de deleção
- Implementa limpeza em cascata de recursos
- Otimiza remoção de arquivos no storage
- Adiciona feedback visual do processo
- Melhora tratamento de erros
- Implementa navegação pós-deleção
2024-12-31 06:40:48 -03:00
Lucas Santana
b008b4134b feat: adiciona sistema de destaque de palavras
Some checks are pending
Docker Build and Push / build (push) Waiting to run
- Implementa WordHighlighter com testes
- Adiciona modal de detalhes da palavra
- Integra sistema de tracking de palavras
- Melhora experiência de leitura
- Implementa feedback visual
2024-12-30 10:21:26 -03:00
Lucas Santana
3e7bf811fe fix: simplifica reprodução de áudio e corrige CORS
- Remove lógica redundante de URL pública
- Usa diretamente audio_url do banco
- Mantém configuração original do Supabase client
- Melhora tratamento de erros na reprodução
2024-12-30 10:20:29 -03:00
Lucas Santana
087104a7f5 Fix: Corrigindo o Build
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2024-12-29 12:26:50 -03:00
Lucas Santana
de28dea3b5 Fixing Git History
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2024-12-29 08:46:22 -03:00
Lucas Santana
4765be66da feat: implementa upload atômico e processamento assíncrono de áudio
- Usa UUID para evitar colisões de arquivos
- Implementa transação atômica para upload
- Adiciona chamada assíncrona para Edge Function
- Melhora tratamento de erros
- Mantém consistência entre storage e banco de dados
2024-12-29 07:11:37 -03:00
Lucas Santana
c562ae570a Corrigindo processamento do áudio 2024-12-28 12:53:23 -03:00
Lucas Santana
f4965db3e6 Corrigindo processamento do áudio 2024-12-28 12:48:02 -03:00
Lucas Santana
933358483e Corrigindo processamento do áudio 2024-12-28 12:42:26 -03:00
Lucas Santana
66d401f98f .gitignore fix 2024-12-27 17:10:42 -03:00
Lucas Santana
a3b522d283 Corrigindo erro supabase 2024-12-27 17:03:12 -03:00
Lucas Santana
5812d46049 Corrigindo erro supabase 2024-12-27 17:01:32 -03:00
Lucas Santana
007441c285 Corrigindo erro supabase 2024-12-27 16:54:34 -03:00
Lucas Santana
c776efaec9 Erro ao salvar no supabase 2024-12-27 15:56:41 -03:00
Lucas Santana
6cf273126e Process audio 2024-12-27 14:41:40 -03:00
Lucas Santana
ec97f640f9 feat: adiciona processamento automático de áudio
- Implementa Edge Function para processamento de áudio
- Adiciona integração com OpenAI Whisper e GPT-4
- Configura Database Trigger para story_recordings
- Implementa análise automática de leitura
- Atualiza documentação e variáveis de ambiente
2024-12-27 13:25:10 -03:00
Lucas Santana
a8c332d442 feat: adiciona processamento automático de áudio
- Implementa Edge Function para processamento de áudio
- Adiciona integração com OpenAI Whisper e GPT-4
- Configura Database Trigger para story_recordings
- Implementa análise automática de leitura
- Atualiza documentação e variáveis de ambiente
2024-12-27 13:24:25 -03:00
Lucas Santana
4d09386d96 generate-story prompt 2024-12-25 14:00:05 -03:00
Lucas Santana
cc23c83c05 feat: adiciona redis e healthcheck
- Implementa cliente Redis com retry e cache
- Adiciona healthcheck da API
- Configura tipagem para Next.js API routes
- Implementa cache de histórias
- Adiciona tratamento de erros robusto
- Configura monitoramento de conexões
- Otimiza performance com cache distribuído
2024-12-25 13:55:03 -03:00
Lucas Santana
521a99a5c2 feat: adiciona configuração docker e ci/cd
- Implementa Dockerfile com multi-stage build
- Configura pipeline no Gitea Actions
- Adiciona integração com Redis
- Implementa healthchecks
- Configura registry no Gitea

minor: novas funcionalidades de infraestrutura
2024-12-25 12:57:08 -03:00
Lucas Santana
563a62a517 feat: adiciona landing page para pais
- Implementa layout moderno e responsivo
- Adiciona seções: Hero, Benefícios, Como Funciona, Análise, Diferencial, Depoimentos e CTA
- Integra gráficos interativos com recharts
- Adiciona métricas de exemplo e comparativos
- Mantém consistência visual com HomePage
- Implementa navegação e rotas
- Otimiza imagens e assets
- Adiciona animações e transições suaves
2024-12-24 17:07:13 -03:00
Lucas Santana
3ef8c99062 fix: melhora tratamento de URLs de imagem
- Adiciona verificação de URLs indefinidas
- Implementa fallback para imagem padrão
- Corrige tipagem em getOptimizedImageUrl
- Padroniza otimização em edge functions
- Previne erros de runtime
2024-12-24 16:19:58 -03:00
Lucas Santana
d5c75ab6c2 fix: melhora tratamento de URLs de imagem
- Adiciona verificação de URLs indefinidas
- Implementa fallback para imagem padrão
- Corrige tipagem em getOptimizedImageUrl
- Padroniza otimização em edge functions
- Previne erros de runtime
2024-12-24 16:19:47 -03:00
Lucas Santana
28fa4d70e6 refactor: remove pasta /pages/story
- Remove pasta /pages/story obsoleta
- Consolida componentes de história em /pages/student-dashboard
- Mantém consistência na organização de arquivos
- Simplifica estrutura de diretórios
2024-12-24 15:46:22 -03:00
Lucas Santana
02119a62d1 feat: implementa otimização global de imagens
- Adiciona função utilitária para otimização de imagens
- Converte automaticamente para WebP
- Implementa redimensionamento contextual
- Centraliza lógica de transformação
- Melhora performance de carregamento
2024-12-23 18:42:53 -03:00
Lucas Santana
7087a87ece refactor: atualiza interface de capa das histórias
- Adiciona tipagem para cover na interface Story
- Atualiza queries para usar story_pages como capa
- Usa página 1 como capa padrão das histórias
- Otimiza carregamento de imagens com parâmetros
2024-12-23 18:21:32 -03:00
Lucas Santana
fbeeace8bb refactor: otimiza carregamento e visualização de imagens
- Implementa lazy loading e placeholders para imagens
- Adiciona pré-carregamento da próxima imagem
- Otimiza URLs de imagem com parâmetros de transformação
- Padroniza visualização de cards de histórias
- Ajusta estilos para consistência entre páginas
- Implementa cache de imagens no frontend
- Atualiza queries para usar story_pages como capa
2024-12-23 15:30:19 -03:00
Lucas Santana
961fce03f6 refactor: atualiza estrutura de dados das histórias
- Migra dados das páginas para tabela story_pages
- Atualiza queries para usar nova estrutura
- Separa componente de demo em StoryPageDemo
- Mantém compatibilidade com interface existente
- Melhora tipagem e tratamento de erros
2024-12-23 14:45:16 -03:00
Lucas Santana
8af9950ed7 fix: corrigindo salvamento da história no banco de dados 2024-12-23 10:05:30 -03:00
Lucas Santana
7e3b4551ec feat: implementa geração de histórias com IA
- Adiciona edge function para geração de histórias
- Integra OpenAI GPT para criação de texto
- Integra DALL-E para geração de imagens
- Implementa fluxo de seleção de categorias
- Adiciona logs detalhados para monitoramento
- Melhora tratamento de erros e validações
- Adiciona feedback visual do processo de geração

Principais mudanças:
- Cria edge function generate-story
- Implementa StoryGenerator com seleção de categorias
- Adiciona integração com OpenAI e DALL-E
- Implementa logs estruturados para debug
- Adiciona tratamento de erros robusto
2024-12-23 09:22:45 -03:00
Lucas Santana
03732de610 feat: implementa geração de histórias com IA
- Adiciona integração com OpenAI GPT e DALL-E
- Implementa fluxo de geração de histórias
- Adiciona feedback visual do processo
- Melhora tratamento de erros
- Adiciona logs para debug

Resolves: #FEAT-123
2024-12-23 09:03:23 -03:00
Lucas Santana
3701e692f1 fix: adiciona optional chaining para prevenir erros de undefined
- Corrige acesso a propriedades undefined em story.content.pages
- Adiciona verificações de segurança com optional chaining (?.)
- Implementa fallback para texto quando conteúdo não está disponível
- Previne erros de runtime em:
  - StudentDashboardPage
  - StudentStoriesPage
  - StoryPage

Resolves: #BUG-789
2024-12-23 07:33:22 -03:00
Lucas Santana
4f3b80246f Alteração do fluxo de geração de histórias 2024-12-22 23:45:42 -03:00
Lucas Santana
0b8c050bd7 feat: implementa geração de histórias com IA
- Adiciona fluxo de criação em etapas com cards
- Implementa Edge Function para geração via GPT-4
- Cria interfaces e tipos para o gerador de histórias
- Adiciona seleção de tema, disciplina, personagem e cenário
- Integra com Supabase para armazenamento e processamento
- Melhora UX com feedback visual e navegação intuitiva
2024-12-22 16:42:39 -03:00
Lucas Santana
1a3a603ff6 Demo StoryPage 2024-12-22 16:28:54 -03:00
Lucas Santana
0661f2c225 Changed Demo Page 2024-12-22 16:08:08 -03:00
Lucas Santana
6531a9282c fix: corrige tipagem do RecordingHistoryCard
- Exporta interface StoryRecording do arquivo de tipos
- Adiciona importação da interface no componente
- Adiciona tipos explícitos nos parâmetros das funções map
- Resolve erros de tipagem no build
2024-12-22 16:01:42 -03:00
Lucas Santana
1132f7438d feat: reorganiza estrutura de métricas e feedback de leitura
- Exporta interface MetricsData do StoryMetrics para reuso
- Adiciona importação da interface no StoryPage
- Mantém consistência de tipos entre gravações e métricas
- Melhora organização do feedback em colunas
- Implementa layout responsivo para diferentes tamanhos de tela
2024-12-22 15:58:32 -03:00
Lucas Santana
797967ca5b feat: adiciona integração com edge function para processamento de áudio
- Cria serviço audioService para upload e processamento
- Implementa componente AudioUploader com feedback visual
- Adiciona componente Button reutilizável
- Integra processamento de áudio na página de histórias
2024-12-21 16:12:02 -03:00
Lucas Santana
6f8e890e86 Corrigindo StoryPage 2024-12-20 18:02:51 -03:00
Lucas Santana
f70585e9c1 feat: adiciona página de conquistas do aluno
- Cria componente AchievementsPage para exibir conquistas do aluno
- Implementa componentes Card e Badge para UI
- Adiciona mock inicial de conquistas para demonstração
- Corrige caminhos de importação relativos
2024-12-20 18:01:12 -03:00
Lucas Santana
5573274ad4 fix: corrige gravação de áudio na página de história
- Remove campos não utilizados (class_id e school_id) da tabela story_recordings
- Simplifica o componente AudioRecorder para usar apenas campos necessários
- Atualiza interface StoryRecording no types/database.ts
- Corrige erro de constraint na inserção de gravações
2024-12-20 16:00:47 -03:00
Lucas Santana
9ecf46a9ac fix: adiciona políticas RLS para story_recordings
- Habilita Row Level Security na tabela story_recordings
- Adiciona política para inserção de gravações por estudantes
- Adiciona política para leitura de gravações por estudantes, professores e escolas
- Corrige erro 403 no upload de áudios
2024-12-20 15:39:46 -03:00
Lucas Santana
6e7c85e853 feat: adiciona página de configurações do aluno
- Cria componente StudentSettingsPage
- Adiciona rota de configurações
- Implementa utils para classes condicionais
- Atualiza navegação no dashboard do aluno
2024-12-20 15:23:48 -03:00
Lucas Santana
1e181785b4 fix: corrige tipagem do sistema de autenticação 2024-12-20 14:30:09 -03:00
Lucas Santana
eb77476d51 fix: corrige tipagem do sistema de autenticação
- Adiciona tipos UserRole e WeakPassword
- Corrige tipagem do UserManagementPage
- Atualiza interface AuthContextType
- Melhora tratamento de erros no fetchUsers
- Adiciona tipagem explícita para User no filter
2024-12-20 14:29:34 -03:00
Lucas Santana
8e8936e9f4 feat: adiciona tipagem forte para metadados do usuário
- Cria interface UserMetadata para tipagem dos metadados do Supabase
- Estende tipos do @supabase/supabase-js com metadados personalizados
- Atualiza useAuth para usar tipagem forte nos roles
- Corrige tipagem do userRole no AuthContext
- Adiciona validação de tipos para roles permitidos
2024-12-20 13:56:43 -03:00
Lucas Santana
dea81a5711 fix: corrige tipagem do sistema de autenticação
- Exporta interface AuthContextType corretamente
- Atualiza tipagem do contexto de autenticação com User do Supabase
- Corrige interface AdminUser para estender User do Supabase
- Implementa type guard mais seguro para filtragem de usuários
- Adiciona implementações vazias para signIn e signUp no AuthContext
2024-12-20 13:53:09 -03:00
Lucas Santana
89c325cc7c fix: corrige tipagem do contexto de autenticação
- Adiciona tipagem User do Supabase para o estado do usuário
- Corrige interface AuthContextType com tipos corretos
- Atualiza AdminUser para garantir email obrigatório
- Adiciona type guard para filtrar usuários válidos
- Exporta e importa tipos do AuthContext corretamente
2024-12-20 13:48:22 -03:00
Lucas Santana
7430ae15a8 feat: adiciona menu de perfil no header
- Cria componente ProfileMenu com dropdown
- Implementa navegação contextual baseada no role do usuário
- Adiciona opções de acesso ao dashboard, perfil e logout
- Atualiza Header para mostrar/esconder botões baseado no estado de autenticação
- Adiciona detecção de clique fora do menu para fechá-lo
2024-12-20 13:45:29 -03:00
Lucas Santana
c8420421eb fix: ajusta verificação de roles no ProtectedRoute
- Adiciona logs detalhados para debug do fluxo de autenticação
- Pega role diretamente dos metadados do usuário
- Simplifica lógica de verificação de roles com switch case
- Melhora mensagens de debug para identificar problemas de acesso
2024-12-20 13:29:31 -03:00
Lucas Santana
441b55535e fix: corrige acesso ao role nos metadados do usuário
- Remove verificação opcional (?.) ao acessar role nos metadados
- Ajusta ordem de declaração da variável userRole no LoginForm
- Atualiza logs para melhor debug do fluxo de autenticação
- Garante acesso direto ao role em user_metadata
2024-12-20 13:17:53 -03:00
Lucas Santana
4b431358e0 Adicionar User Role 2024-12-20 12:10:59 -03:00
Lucas Santana
c0aa725fa6 fix: corrige redirecionamento após login
- Ajusta ordem de redirecionamento no LoginForm para priorizar escola
- Centraliza lógica de redirecionamento no handleRedirect do useAuth
- Adiciona redirecionamento automático ao carregar sessão existente
- Melhora tratamento de eventos de autenticação
2024-12-20 12:10:37 -03:00
Lucas Santana
fca293c4fc fix: corrige redirecionamento após login do aluno
- Atualiza lógica de redirecionamento no LoginForm
- Ajusta verificação de roles no useAuth hook
- Melhora proteção de rotas no ProtectedRoute
- Atualiza rotas para suportar diferentes perfis de usuário
2024-12-20 11:55:19 -03:00
Lucas Santana
beef3da647 feat: adiciona geração de senha mnemônica no cadastro de alunos
- Implementa gerador de senhas mnemônicas (cor + animal + número)
- Adiciona campo de senha com opção de copiar e regenerar
- Remove geração de senha temporária aleatória
- Integra senha mnemônica com envio de email
- Adiciona feedback visual ao copiar senha
2024-12-20 11:42:59 -03:00
Lucas Santana
5952d83ec8 feat: implementa páginas do dashboard do aluno
- Adiciona página de listagem de histórias com filtros e ordenação
- Cria formulário de criação de novas histórias com temas
- Implementa visualizador de história com navegação entre páginas
- Integra gravador de áudio para leitura
- Adiciona funcionalidade de compartilhamento
- Implementa estados de loading e tratamento de erros
2024-12-20 11:11:28 -03:00
Lucas Santana
f1a7cd8730 feat: adiciona página de configurações da escola
- Cria página de configurações com formulário para dados da escola
- Adiciona campos para informações básicas e endereço
- Implementa integração com Supabase para salvar dados
- Adiciona feedback visual de sucesso/erro
- Atualiza rotas e menu lateral com novo link
2024-12-20 10:52:39 -03:00
Lucas Santana
e9e72677a4 style: padroniza layout das páginas de Classes e Professores
- Alinha o visual das páginas com o padrão do StudentsPage
- Ajusta espaçamentos, cores e tipografia
- Melhora a consistência dos componentes de lista
- Adiciona tratamento de erros uniforme
- Padroniza os estados de loading e empty
2024-12-20 10:49:03 -03:00
Lucas Santana
fd734a5c26 feat: implementa dashboard com estatísticas em tempo real
- Adiciona busca de totais de turmas, professores e alunos
- Implementa listagem de turmas recentes com contagem de alunos
- Adiciona seção de histórias recentes com nome dos alunos
- Melhora feedback visual com estados de loading
- Usa queries otimizadas do Supabase com contagem e joins
2024-12-20 10:41:02 -03:00
Lucas Santana
70953ab57a feat: adiciona RootLayout e atualiza rotas da aplicação
- Cria componente RootLayout como container principal
- Atualiza router para usar RootLayout como elemento raiz
- Organiza rotas aninhadas com Outlet do React Router
- Adiciona rota para visualização de histórias individuais
2024-12-20 10:25:21 -03:00
Lucas Santana
fd50d59d3c fix: atualiza regras do cursor e página de história
- Atualiza configurações do .cursorrules
- Ajusta componentes na StoryPage
- Mantém consistência com navegação do demo
- Integra com funcionalidades existentes
2024-12-20 10:19:42 -03:00
Lucas Santana
d8c665d48e fix: corrige navegação para página de demo
- Ajusta configuração da rota /demo no router
- Garante que o handleDemo está sendo chamado corretamente
- Adiciona debug para verificar navegação
- Mantém consistência com padrão de rotas existente
2024-12-20 10:10:02 -03:00
Lucas Santana
39bbc2c827 Adiciona Página Demo 2024-12-20 10:06:24 -03:00
Lucas Santana
3176e95a75 feat: adiciona seção de jornada do aluno na landing page
- Implementa timeline interativa do processo
- Adiciona 5 etapas do fluxo de aprendizado
- Inclui métricas de resultados comprovados
- Melhora UX com animações e hover effects
- Mantém responsividade em diferentes dispositivos
2024-12-20 09:05:50 -03:00
Lucas Santana
6f03e72a22 feat: adiciona seção antes e depois na landing page
- Implementa comparação visual do antes/depois
- Adiciona lista de benefícios e melhorias
- Inclui métricas de resultados
- Melhora apresentação visual com ícones e cores
- Mantém consistência com design system
2024-12-20 08:58:32 -03:00
Lucas Santana
abf0033590 feat: redesign da landing page com novas seções
- Adiciona hero section com demo e social proof
- Implementa grid de features com ícones
- Adiciona seção 'Como Funciona'
- Inclui testimonials de usuários
- Implementa pricing table
- Adiciona CTA final e footer
- Melhora UX/UI geral da página
2024-12-20 08:53:49 -03:00
Lucas Santana
4cc6ab641e fix: corrige tipagem do array classes na interface StudentData 2024-12-20 08:44:25 -03:00
Lucas Santana
5193ba95f4 Adicionando cursorrules 2024-12-20 08:38:22 -03:00
Lucas Santana
b7d30fdc06 fix: corrige tipagem da interface StudentData e mapeamento de dados 2024-12-20 08:37:56 -03:00
Lucas Santana
6afb728dce Correções 2024-12-19 19:47:29 -03:00
Lucas Santana
543ed7532b Correcoes 2024-12-19 19:38:32 -03:00
Lucas Santana
677ee422c4 Correcoes 2024-12-19 19:36:07 -03:00
250 changed files with 40260 additions and 537 deletions

View File

@ -1,3 +1,24 @@
{
"template": "bolt-vite-react-ts"
"template": "bolt-vite-react-ts",
"version": "1.0.0",
"features": {
"tailwind": true,
"radix": true,
"shadcn": true,
"supabase": true,
"testing": true,
"i18n": true,
"pwa": true
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.11.2",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-tabs": "^1.0.4",
"@supabase/supabase-js": "^2.26.0",
"lucide-react": "^0.259.0",
"tailwindcss": "^3.3.2"
}
}

38
.cursorignore Normal file
View File

@ -0,0 +1,38 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist/
build/
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env
.env.local
.env.*.local
.env*
.env.*
.env.production
.env.development
# Backup files
*copy*
*.bak

View File

@ -1,27 +1,49 @@
{
"rules": [
{
"name": "Padrões de Código",
"description": "Regras gerais para manter consistência no código",
"patterns": [
"name": "Educational Platform Guidelines",
"version": "1.0.0",
"rules": {
"naming": {
"directories": {
"pattern": "^[a-z-]+$",
"message": "Use lowercase with dashes for directories (e.g., components/form-wizard)"
},
"components": {
"pattern": "^[A-Z][a-zA-Z0-9]*\\.tsx?$",
"message": "Use PascalCase for component files (e.g., VisaForm.tsx)"
},
"utilities": {
"pattern": "^[a-z][a-zA-Z0-9]*\\.ts$",
"message": "Use camelCase for utility files (e.g., formValidator.ts)"
},
"variables": {
"pattern": "^[a-z][a-zA-Z0-9]*$",
"message": "Use camelCase for variables and functions"
}
},
"typescript": {
"rules": [
{
"id": "naming-conventions",
"pattern": "^[a-z][a-zA-Z0-9]*$",
"message": "Use camelCase para nomes de variáveis e funções"
"id": "use-interfaces",
"message": "Prefer interfaces over types"
},
{
"id": "component-naming",
"pattern": "^[A-Z][a-zA-Z0-9]*$",
"message": "Componentes React devem começar com letra maiúscula"
"id": "avoid-enums",
"message": "Use const objects with 'as const' assertion instead of enums"
},
{
"id": "explicit-returns",
"message": "Use explicit return types for all functions"
},
{
"id": "relative-imports",
"message": "Use relative imports"
}
]
},
{
"name": "Segurança",
"description": "Regras para garantir segurança da aplicação",
"security": {
"patterns": [
{
"id": "no-sensitive-data",
"id": "sensitive-data",
"pattern": "(password|senha|token|key|secret)",
"message": "Não exponha dados sensíveis no código"
},
@ -32,9 +54,7 @@
}
]
},
{
"name": "Acessibilidade",
"description": "Regras para garantir acessibilidade",
"accessibility": {
"patterns": [
{
"id": "alt-text",
@ -48,9 +68,7 @@
}
]
},
{
"name": "Performance",
"description": "Regras para otimização de performance",
"performance": {
"patterns": [
{
"id": "large-images",
@ -58,15 +76,13 @@
"message": "Evite imagens muito grandes (max 1200px)"
},
{
"id": "memo-check",
"id": "memo-usage",
"pattern": "React.memo\\(",
"message": "Verifique se o uso de memo é necessário"
}
]
},
{
"name": "Estilo",
"description": "Regras de estilo e formatação",
"styling": {
"patterns": [
{
"id": "tailwind-classes",
@ -80,9 +96,7 @@
}
]
},
{
"name": "Conteúdo",
"description": "Regras para conteúdo infantil",
"content": {
"patterns": [
{
"id": "child-friendly",
@ -90,13 +104,71 @@
"message": "Evite conteúdo inadequado para crianças"
},
{
"id": "educational-content",
"id": "educational-focus",
"pattern": "(educativo|educacional|aprendizado|ensino)",
"message": "Priorize conteúdo educacional e construtivo"
}
]
},
"git": {
"commit_prefixes": [
"fix:",
"feat:",
"perf:",
"docs:",
"style:",
"refactor:",
"test:",
"chore:"
],
"commit_rules": {
"pattern": "^(fix|feat|perf|docs|style|refactor|test|chore): [a-z].*$",
"message": "Use proper commit message format with prefix"
},
"changelog_rules": {
"required": true,
"message": "Atualize o CHANGELOG.md antes de fazer commit",
"format": {
"header": [
"# Changelog",
"",
"Todas as mudanças notáveis neste projeto serão documentadas neste arquivo.",
"",
"O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.0.0/),",
"e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).",
""
],
"version_pattern": "^## \\[(\\d+\\.\\d+\\.\\d+)\\] - \\d{4}-\\d{2}-\\d{2}$",
"version_message": "Use o formato '## [X.Y.Z] - YYYY-MM-DD' para versões"
},
"patterns": [
{
"id": "added",
"pattern": "### Adicionado",
"message": "Use '### Adicionado' para novos recursos"
},
{
"id": "modified",
"pattern": "### Modificado",
"message": "Use '### Modificado' para mudanças em funcionalidades existentes"
},
{
"id": "technical",
"pattern": "### Técnico",
"message": "Use '### Técnico' para mudanças técnicas/internas"
},
{
"id": "semantic_version",
"pattern": "^(major|minor|patch):",
"message": "Indique o tipo de mudança: major (quebra compatibilidade), minor (novo recurso) ou patch (correção)"
}
],
"verify_files": [
"CHANGELOG.md"
]
}
}
],
},
"ignoreFiles": [
"node_modules/**",
"dist/**",
@ -104,5 +176,24 @@
".git/**",
"*.test.*",
"*.spec.*"
]
}
],
"documentation": {
"required": [
"README.md",
"API.md",
"CHANGELOG.md"
],
"rules": [
{
"id": "readme-sections",
"required": [
"Setup Instructions",
"Development Workflow",
"Testing",
"Security",
"Contributing"
]
}
]
}
}

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
node_modules
.git
.env*
.dockerignore
Dockerfile
README.md
.next
build
dist

31
.eslintrc.json Normal file
View File

@ -0,0 +1,31 @@
{
"root": true,
"env": {
"browser": true,
"es2020": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
"plugin:react/recommended",
"plugin:react/jsx-runtime"
],
"ignorePatterns": ["dist", ".eslintrc.json"],
"parser": "@typescript-eslint/parser",
"plugins": ["react-refresh", "@typescript-eslint", "react"],
"rules": {
"react-refresh/only-export-components": [
"warn",
{ "allowConstantExport": true }
],
"@typescript-eslint/no-unused-vars": ["warn"],
"react/prop-types": "off",
"no-console": "warn"
},
"settings": {
"react": {
"version": "detect"
}
}
}

View File

@ -0,0 +1,8 @@
version: "1.0"
registries:
- name: seu-registry
host: seu-registry.com
type: container
credentials:
username: ${REGISTRY_USERNAME}
password: ${REGISTRY_PASSWORD}

View File

@ -0,0 +1,58 @@
name: Docker Build and Push
on:
push:
branches: [ main ]
tags: [ 'v*' ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: seu-registry.com/leiturama
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Container Registry
uses: docker/login-action@v2
with:
registry: seu-registry.com
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=seu-registry.com/leiturama:buildcache
cache-to: type=registry,ref=seu-registry.com/leiturama:buildcache,mode=max
- name: Update Portainer stack
if: github.ref == 'refs/heads/main'
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
cd /opt/portainer
docker stack deploy -c portainer-stack.yml leiturama

18
.github/workflows/deploy.yml vendored Normal file
View File

@ -0,0 +1,18 @@
name: Deploy
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Deploy to production
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
run: |
echo "$DEPLOY_KEY" > deploy_key
chmod 600 deploy_key
ssh -i deploy_key user@seu-servidor.com 'cd /app && ./scripts/deploy.sh'

10
.gitignore vendored
View File

@ -9,6 +9,8 @@ lerna-debug.log*
node_modules
dist
dist/
build/
dist-ssr
*.local
@ -26,3 +28,11 @@ dist-ssr
.env
.env.local
.env.*.local
.env*
.env.*
.env.production
.env.development
# Backup files
*copy*
*.bak

13
.prettierrc Normal file
View File

@ -0,0 +1,13 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf",
"jsxSingleQuote": false,
"quoteProps": "as-needed",
"useTabs": false
}

5
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"recommendations": [
"denoland.vscode-deno"
]
}

332
CHANGELOG.md Normal file
View File

@ -0,0 +1,332 @@
# Changelog
Todas as mudanças notáveis neste projeto serão documentadas neste arquivo.
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.0.0/),
e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
## [0.5.1] - 2024-01-31
### Técnico
- Corrigido erro de constraint na tabela stories ao atualizar status
- Removida tentativa de atualizar coluna inexistente error_message
- Ajustados os status da história para valores válidos: 'pending', 'published', 'failed'
- Melhorada validação e logs durante o processo de geração da história
### Modificado
- Alterado fluxo de status da história para usar estados válidos do banco de dados
- Melhorada mensagem de erro para usuário final em caso de falha na geração
## [1.0.0] - 2024-03-20
### Adicionado
#### Sistema de Exercícios Fônicos
- Criação do sistema de exercícios fônicos com categorias e tipos
- Implementação de exercícios de rima, aliteração, sílabas e sons
- Sistema de progresso do estudante com pontuação e estrelas
- Sistema de conquistas e recompensas
#### Banco de Dados
- Tabelas para categorias de exercícios (`phonics_exercise_categories`)
- Tabelas para tipos de exercícios (`phonics_exercise_types`)
- Tabela principal de exercícios (`phonics_exercises`)
- Tabela de palavras e suas características fonéticas (`phonics_words`)
- Tabela de relação exercício-palavras (`phonics_exercise_words`)
- Sistema de mídia para exercícios (`media_types`, `phonics_exercise_media`)
- Sistema de progresso do estudante (`student_phonics_progress`)
- Sistema de tentativas e respostas (`student_phonics_attempts`, `student_phonics_attempt_answers`)
- Sistema de conquistas (`achievement_types`, `phonics_achievements`, `student_phonics_achievements`)
#### Funcionalidades
- Categorização de exercícios por nível e tipo
- Sistema de pontuação e progresso
- Registro detalhado de tentativas e respostas
- Sistema de conquistas com diferentes tipos (sequência, conclusão, maestria)
- Suporte a diferentes tipos de mídia (imagens, sons, animações)
#### Segurança
- Políticas de acesso baseadas em Row Level Security (RLS)
- Proteção de dados específicos do estudante
- Controle de acesso para diferentes tipos de usuários
#### Performance
- Índices otimizados para consultas frequentes
- Estrutura de dados normalizada
- Relacionamentos e chaves estrangeiras para integridade dos dados
### Técnico
- Implementação de migrações do banco de dados
- Criação de índices para otimização de consultas
- Implementação de políticas de segurança RLS
- Estrutura de dados normalizada com relacionamentos apropriados
### Modificado
- N/A (primeira versão)
- Todas as páginas principais para usar texto adaptativo
- Componentes de exercícios para suportar transformação de texto
- Movido controle de sílabas para a página de histórias
### Removido
- N/A (primeira versão)
## [1.1.1] - 2024-05-21
### Adicionado
- Componente `TextCaseToggle` para alternar entre maiúsculas e minúsculas
- Componente `AdaptiveText` para renderização adaptativa de texto
- Hook `useUppercasePreference` para gerenciar preferências de texto
- Suporte a texto adaptativo em exercícios fônicos
### Modificado
- Atualização do layout do dashboard para incluir controle de texto
- Integração do sistema de texto adaptativo em componentes existentes
- Melhorias na acessibilidade dos componentes de texto
### Técnico
- Refatoração dos componentes de texto para suportar transformação dinâmica
- Otimização do sistema de preferências do usuário
- Melhorias na performance de renderização de texto
## [1.1.0] - 2024-03-21
### Adicionado
- Novo recurso "Modo Foco" para melhorar a experiência de leitura
- Ativação automática ao iniciar gravação
- Desativação automática ao parar gravação
- Interface adaptativa com foco no texto
- Controles de acessibilidade (tamanho da fonte, espaçamento)
- Destaque automático de palavras durante a leitura
### Técnico
- Integração entre componentes `AudioRecorder` e `StoryPage` para gerenciamento do Modo Foco
- Adição de novos props no componente `AudioRecorder`:
- `onFocusModeToggle`
- `focusModeActive`
- `onRecordingStart`
- `onRecordingStop`
- Otimização de código com remoção de variáveis não utilizadas
### Modificado
- Atualizado o componente `AudioRecorder` para incluir tipagem correta e melhor gerenciamento de estado
- Corrigido o gerenciamento de gravações no `StoryPage` com inicialização adequada de métricas
- Melhorado o tratamento de erros e feedback do usuário durante a gravação
- Otimizado o fluxo de upload e processamento de áudio
### Técnico
- Adicionada interface `StoryRecording` com todas as propriedades necessárias
- Corrigido tipo do callback `onAudioUploaded` no `AudioRecorder`
- Removidos imports não utilizados e variáveis redundantes
- Implementada lógica de fallback para usuários não autenticados
### Adicionado
- Suporte para conversão de áudio WebM para MP3
- Feedback visual durante o processamento do áudio
- Inicialização de métricas zeradas para novas gravações
## [1.2.0] - 2024-03-21
### Adicionado
- Novo Modo Foco para leitura e gravação
- Estilos específicos para o Modo Foco
- Timer de gravação no Modo Foco
- Transições suaves entre modos
- Controles flutuantes durante o Modo Foco
- Documentação completa da estrutura do banco de dados em `/docs/banco-dados.md`:
- Escolas e Classes
- Sistema de Alunos
- Histórias
- Interesses
- Sistema de Conquistas
- Sistema Fonético completo
- Relacionamentos e índices
- Políticas de segurança
- Triggers e funções
- Considerações de performance
### Modificado
- Componente AudioRecorder atualizado para suportar Modo Foco
- Interface do StoryPage reorganizada para Modo Foco
- Comportamento de gravação integrado com Modo Foco
- Melhorias na experiência do usuário durante a leitura
### Técnico
- Novo arquivo CSS para estilos do Modo Foco
- Interface FocusMode para gerenciamento de estado
- Callbacks de início e fim de gravação
- Sistema de transição entre modos normal e foco
- Otimização de performance para transições suaves
- Atualização das definições de tabelas para refletir a estrutura atual do Supabase
- Adição de diagramas ER para visualização dos relacionamentos
- Documentação de índices e políticas de segurança
- Inclusão de considerações de performance e backup
## [1.3.0] - 2024-01-31
### Adicionado
- Suporte a múltiplos idiomas na geração de histórias:
- Português (Brasil)
- Inglês (EUA)
- Espanhol (Espanha)
- Nova etapa de seleção de idioma no fluxo de criação de história
- Instruções específicas para cada idioma no prompt da IA
### Modificado
- Fluxo de geração de história para incluir seleção de idioma
- Interface do gerador de histórias com novo passo de idioma
- Adaptação do prompt da IA para considerar o idioma selecionado
### Técnico
- Adicionada constante `LANGUAGE_OPTIONS` com opções de idiomas suportados
- Implementada validação de idioma antes da geração
- Atualizado payload da Edge Function para incluir `language_type`
- Melhorada tipagem para suporte a múltiplos idiomas
## [1.4.0] - 2024-03-28
### Adicionado
- Novas competências na análise de redações:
- Domínio da língua (0-200 pontos)
- Compreensão da proposta (0-200 pontos)
- Seleção de argumentos (0-200 pontos)
- Mecanismos linguísticos (0-200 pontos)
- Proposta de intervenção (0-200 pontos)
### Técnico
- Adicionados novos campos na tabela `essay_analyses` para armazenar as competências
- Atualizada a função `analyze-essay` para salvar as notas e justificativas das competências
- Adicionada restrição para garantir que os valores das competências estejam entre 0 e 200
- Corrigida tipagem das métricas de escrita para incluir competências do ENEM
- Atualizados valores padrão das métricas de escrita
### Modificado
- Melhorado o layout da página de análise de redações:
- Separação clara entre critérios gerais e competências do ENEM
- Nova seção dedicada às competências do ENEM com layout aprimorado
- Barras de progresso mais visíveis para as competências
- Adicionadas descrições detalhadas para cada competência
- Cards coloridos para justificativas das competências
- Melhorias visuais nos critérios gerais de avaliação
## [1.5.0] - 2024-03-19
### Modificado
- Aprimoramento no cálculo de métricas do dashboard do aluno:
- Métricas agora são calculadas considerando todas as histórias e gravações do aluno
- Adicionadas novas métricas detalhadas: pronúncia, precisão, compreensão, velocidade, pausas e erros
- Melhorias na interface com tooltips explicativos para cada métrica
- Separação entre dados para métricas (todas as histórias) e exibição (6 mais recentes)
### Técnico
- Refatoração da busca de dados no StudentDashboardPage:
- Separação entre consulta de métricas e consulta de exibição
- Otimização no cálculo de médias das métricas
- Melhoria na organização do código com comentários explicativos
## [Não publicado]
### Adicionado
- Novo gráfico de evolução das métricas na dashboard do aluno
- Visualização combinada de linhas e barras
- Métricas de fluência, pronúncia, precisão, compreensão e palavras por minuto
- Gráfico de barras mostrando minutos lidos por semana
- Botões interativos para filtrar métricas
- Design moderno com gradientes e animações suaves
- Tooltip personalizado com informações detalhadas
- Agrupamento automático por semana
- Layout responsivo e adaptável
- Filtro de período com opções de 3, 6, 12 meses e todo período
- Visualização padrão dos últimos 12 meses
### Técnico
- Implementação do Recharts para visualização de dados
- Novo sistema de processamento de métricas semanais
- Otimização do carregamento de dados com agrupamento eficiente
- Integração com o tema existente do sistema
- Sistema de filtragem temporal com conversão de datas
- Componente MetricsChart extraído e modularizado
- Interfaces e tipos bem definidos
- Lógica de filtragem encapsulada
- Estado interno gerenciado
- Props minimalistas e bem tipadas
- Componente reutilizável em outros contextos
- Componentes de métricas extraídos e modularizados
- Novo componente MetricCard para cards individuais
- Novo componente DashboardMetrics para agrupamento
- Configuração centralizada de métricas
- Suporte a tooltips e ícones personalizados
- Responsividade e acessibilidade melhoradas
### Técnico
- Normalização do JSON Schema da análise de redações para corresponder à estrutura do banco de dados
- Reordenação dos campos para corresponder à estrutura das tabelas
- Ajuste nas descrições dos campos para maior clareza
- Alinhamento com as tabelas: essay_analyses, essay_analysis_feedback, essay_analysis_strengths e essay_analysis_improvements
- Melhoria na validação dos dados com JSON Schema mais preciso
### Técnico
- Correção das políticas de segurança (RLS) para o sistema de análise de redações:
- Simplificada a política de inserção para service_role
- Adicionadas políticas para tabelas relacionadas (feedback, pontos fortes, melhorias e notas)
- Melhorada a segurança com políticas específicas para cada operação
- Corrigido erro de permissão na inserção de análises pela Edge Function
### Técnico
- Removidas restrições de validação do JSON Schema da análise de redações:
- Removidos limites `minimum` e `maximum` dos campos numéricos
- Removida restrição `minItems` dos arrays de pontos fortes e melhorias
- Simplificada a validação para maior flexibilidade na Edge Function
### Técnico
- Corrigida consulta de análise de redações no componente `EssayAnalysis`:
- Adicionado join com tabelas relacionadas (feedback, strengths, improvements, scores)
- Implementada transformação dos dados para o formato esperado
- Adicionado tratamento para valores nulos
- Melhorada tipagem dos dados retornados
### Modificado
- Melhorado o fluxo de redações:
- Corrigido carregamento do conteúdo da redação após envio para análise
- Adicionado salvamento automático do conteúdo antes de enviar para análise
- Melhorada visualização do status 'analisada' com badge verde
- Adicionado botão "Ver Análise" para redações analisadas
- Ajustado Editor para modo somente leitura após envio
- Melhorada contagem de palavras em todos os estados da redação
### Técnico
- Refatorado componente `EssayPage`:
- Adicionada lógica de salvamento antes do envio para análise
- Melhorada query do Supabase para incluir conteúdo explicitamente
- Implementado feedback visual durante operações de salvamento
- Otimizado carregamento inicial da redação
- Adicionado tratamento de estados para diferentes status da redação
## [0.2.0] - 2024-03-21
### Adicionado
- Novos recursos de formatação no editor:
- Tachado (strike-through)
- Código inline
- Lista com marcadores
- Lista numerada
- Citação (blockquote)
- Rastreamento de eventos (tracking) em todos os botões do editor
### Modificado
- Melhorias no fluxo de redações:
- Carregamento correto do conteúdo após submissão para análise
- Salvamento automático do conteúdo antes da submissão
- Badge verde para status "analisada"
- Botão "Ver Análise" para redações analisadas
- Editor em modo somente leitura após submissão
- Contagem de palavras em todos os estados da redação
### Técnico
- Refatoração do componente Editor para incluir novos recursos de formatação
- Adição de trackingId em todos os botões para análise de uso
- Melhorias de acessibilidade com aria-labels em português
- Refatoração do EssayPage para incluir lógica de salvamento antes da submissão para análise
- Melhoria na query do Supabase para incluir conteúdo explicitamente
- Implementação de feedback visual durante operações de salvamento
- Otimização do carregamento inicial da redação
- Adição de tratamento de estado para diferentes status da redação

40
Dockerfile Normal file
View File

@ -0,0 +1,40 @@
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
# Adicionar dependência do Redis
RUN apk add --no-cache redis
# Copiar arquivos de dependências
COPY package*.json ./
COPY yarn.lock ./
# Instalar dependências
RUN yarn install --frozen-lockfile
# Copiar código fonte
COPY . .
# Build da aplicação
RUN yarn build
# Production stage
FROM node:18-alpine AS runner
WORKDIR /app
# Adicionar dependência do Redis
RUN apk add --no-cache redis
# Copiar arquivos necessários do builder
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Expor porta
EXPOSE 3000
# Comando para rodar a aplicação
CMD ["node", "server.js"]

15
Dockerfile.dev Normal file
View File

@ -0,0 +1,15 @@
FROM node:18-alpine
WORKDIR /app
# Adicionar dependência do Redis
RUN apk add --no-cache redis
COPY package*.json ./
COPY yarn.lock ./
RUN yarn install
COPY . .
CMD ["yarn", "dev"]

51
PROJECT_CONTEXT.md Normal file
View File

@ -0,0 +1,51 @@
# Story Generator - Plataforma Educacional de Leitura
## Visão Geral
Plataforma educacional focada em crianças de 6-12 anos para prática e desenvolvimento de leitura, utilizando histórias geradas por IA e análise de áudio para feedback em tempo real.
## Principais Funcionalidades
1. **Geração de Histórias**
- Histórias personalizadas por IA
- Adaptação ao nível do aluno
- Imagens ilustrativas geradas por IA
2. **Sistema de Leitura**
- Gravação de áudio da leitura
- Análise de pronúncia e fluência
- Destaque de palavras importantes (WordHighlighter)
- Modal de detalhes para palavras difíceis
3. **Análise de Performance**
- Métricas de leitura (fluência, pronúncia, etc.)
- Dashboard de progresso
- Histórico de gravações
- Conversão de áudio WebM para MP3
## Arquitetura
### Frontend (React + TypeScript)
- `/src/components/learning/` - Componentes educacionais
- `/src/components/story/` - Componentes de história
- `/src/pages/student-dashboard/` - Dashboard do aluno
- `/src/utils/` - Utilitários (conversão de áudio, etc.)
### Backend (Supabase)
- Functions:
- `process-audio` - Análise de áudio e feedback
- `generate-story` - Geração de histórias
### Storage
- `recordings/` - Áudios das leituras
- `story-images/` - Imagens das histórias
## Decisões Técnicas
1. Uso de Supabase para backend serverless
2. FFmpeg.js para conversão de áudio no cliente
3. Testes com Vitest e Testing Library
4. Tailwind CSS para estilização
5. Radix UI para componentes acessíveis
## Estado Atual
- Implementado sistema de gravação e análise de áudio
- Desenvolvido componente WordHighlighter com testes
- Sistema de deleção de histórias com limpeza de recursos

View File

@ -1,10 +1,10 @@
# Histórias Mágicas 🌟
# Leiturama 🌟
Uma plataforma educacional interativa que oferece histórias personalizadas para crianças entre 6 e 12 anos, com foco na cultura brasileira e educação.
## 🎯 Sobre o Projeto
Histórias Mágicas é uma aplicação web desenvolvida em React que permite que crianças explorem histórias educativas de forma interativa e personalizada. O projeto tem como objetivo:
Leiturama é uma aplicação web desenvolvida em React que permite que crianças explorem histórias educativas de forma interativa e personalizada. O projeto tem como objetivo:
- Promover a educação através de narrativas envolventes
- Valorizar a diversidade cultural brasileira
@ -27,16 +27,21 @@ Histórias Mágicas é uma aplicação web desenvolvida em React que permite que
- Tailwind CSS
- Lucide React (ícones)
- Vite
- Supabase
- Supabase Functions
- OpenAI
- DALL-E
## 🚀 Como Executar
1. Clone o repositório:
1. Clone o repositório:
## 🚀 Deploy
### Opções Recomendadas
#### 1. Vercel (Recomendação Principal)
- Ideal para aplicações React/Next.js
- Deploy automático integrado com GitHub
- SSL gratuito
@ -45,6 +50,7 @@ Histórias Mágicas é uma aplicação web desenvolvida em React que permite que
- Plano gratuito generoso
#### 2. Netlify
- Também oferece deploy automático
- Funções serverless incluídas
- SSL gratuito

View File

@ -0,0 +1,32 @@
-- Políticas para permitir leitura por usuários autenticados
CREATE POLICY "Permitir leitura de exercícios fonéticos para usuários autenticados" ON phonics_exercises
FOR SELECT
TO authenticated
USING (true);
CREATE POLICY "Permitir leitura de categorias fonéticas para usuários autenticados" ON phonics_categories
FOR SELECT
TO authenticated
USING (true);
CREATE POLICY "Permitir leitura de tipos de exercícios fonéticos para usuários autenticados" ON phonics_exercise_types
FOR SELECT
TO authenticated
USING (true);
CREATE POLICY "Permitir leitura de palavras fonéticas para usuários autenticados" ON phonics_words
FOR SELECT
TO authenticated
USING (true);
CREATE POLICY "Permitir leitura de relações exercício-palavra para usuários autenticados" ON phonics_exercise_words
FOR SELECT
TO authenticated
USING (true);
-- Habilitar RLS nas tabelas
ALTER TABLE phonics_exercises ENABLE ROW LEVEL SECURITY;
ALTER TABLE phonics_categories ENABLE ROW LEVEL SECURITY;
ALTER TABLE phonics_exercise_types ENABLE ROW LEVEL SECURITY;
ALTER TABLE phonics_words ENABLE ROW LEVEL SECURITY;
ALTER TABLE phonics_exercise_words ENABLE ROW LEVEL SECURITY;

31
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,31 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
environment:
- NODE_ENV=development
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- REDIS_URL=redis://redis:6379
depends_on:
- redis
redis:
image: redis:alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
volumes:
redis_data:

31
docker-compose.yml Normal file
View File

@ -0,0 +1,31 @@
version: '3.8'
services:
leiturama:
image: ${REGISTRY}/leiturama:${TAG}
environment:
- NODE_ENV=production
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- REDIS_URL=redis://redis:6379
networks:
- network_public
deploy:
labels:
- "traefik.enable=true"
- "traefik.http.routers.leiturama.rule=Host(`${DOMAIN}`)"
- "traefik.http.routers.leiturama.entrypoints=websecure"
- "traefik.http.routers.leiturama.tls.certresolver=letsencrypt"
- "traefik.http.services.leiturama.loadbalancer.server.port=3000"
- "traefik.docker.network=traefik-public"
replicas: 1
update_config:
parallelism: 1
delay: 10s
restart_policy:
condition: on-failure
networks:
network_public:
external: true

View File

@ -0,0 +1,15 @@
## Destaque Silábico
**Objetivo:**
Facilitar a identificação das sílabas durante a leitura
**Como usar:**
1. Clique no botão "Sílabas" ao lado do título da história
2. Todas as palavras serão divididas em sílabas
3. Sílabas destacadas em fundo amarelo
4. Clique novamente para desativar
**Benefícios:**
- Auxilia na decodificação fonêmica
- Promove consciência silábica
- Facilita a leitura de palavras complexas

248
docs/arquitetura.md Normal file
View File

@ -0,0 +1,248 @@
# Arquitetura do Sistema
## Visão Geral
A plataforma é construída usando uma arquitetura moderna e escalável, focada em proporcionar uma experiência educacional interativa e segura para crianças.
## Stack Tecnológica
### Frontend
- Next.js 14 (App Router)
- TypeScript
- Tailwind CSS
- Framer Motion
- React Query
### Backend
- Node.js
- Supabase
- Redis Upstash (cache)
- Supabase Storage (mídia)
## Estrutura de Diretórios
```
src/
├── app/ # Rotas e páginas
├── components/ # Componentes React
│ ├── ui/ # Componentes base
│ ├── forms/ # Formulários
│ └── exercises/ # Componentes de exercícios
├── features/ # Features do sistema
├── layouts/ # Layouts do sistema
├── constants/ # Constantes do sistema
├── lib/ # Utilitários e configurações
├── hooks/ # Hooks personalizados
├── services/ # Serviços externos
└── types/ # Definições de tipos
```
## Componentes Principais
### 1. Sistema de Autenticação
```typescript
interface AuthConfig {
providers: {
google: boolean;
email: boolean;
};
session: {
maxAge: number;
updateAge: number;
};
security: {
jwtSecret: string;
cookiePrefix: string;
};
}
```
### 2. Gerenciamento de Estado
```typescript
interface AppState {
user: UserState;
exercises: ExerciseState;
progress: ProgressState;
settings: SettingsState;
}
```
## Segurança
### 1. Autenticação
- JWT tokens
- Refresh tokens
- Sessões seguras
- Rate limiting
### 2. Dados
- Criptografia em trânsito
- Backup automático
- Sanitização de inputs
- Logs de auditoria
## Performance
### 1. Otimizações
- Lazy loading
- Code splitting
- Caching estratégico
- Compressão de assets
### 2. Monitoramento
- Métricas de tempo real
- Alertas automáticos
- Análise de performance
- Debug em produção
## Integração Contínua
### 1. Pipeline
```yaml
steps:
- name: Lint
run: yarn lint
- name: Type Check
run: yarn tsc
- name: Test
run: yarn test
- name: Build
run: yarn build
- name: Deploy
if: branch = main
run: yarn deploy
```
### 2. Qualidade de Código
- ESLint
- Prettier
- Jest
- Cypress
## Escalabilidade
### 1. Infraestrutura
- Containers Docker
- Load balancing
- Auto-scaling
- CDN global
### 2. Database
- Sharding
- Replicação
- Índices otimizados
- Migrations automáticas
## APIs e Integrações
### 1. REST APIs
```typescript
interface APIEndpoints {
auth: {
login: string;
register: string;
refresh: string;
};
exercises: {
list: string;
submit: string;
progress: string;
};
content: {
stories: string;
media: string;
exercises: string;
};
}
```
### 2. WebSockets
- Chat em tempo real
- Notificações
- Multiplayer
- Status online
## Acessibilidade e SEO
### 1. Acessibilidade
- WCAG 2.1
- ARIA labels
- Keyboard navigation
- Screen readers
### 2. SEO
- Meta tags
- Sitemap
- Robots.txt
- Schema.org
## Ambiente de Desenvolvimento
### 1. Setup
```bash
# Instalação
yarn install
# Desenvolvimento
yarn dev
# Testes
yarn test
# Build
yarn build
```
### 2. Ferramentas
- VSCode
- Docker
- Postman
- Git
## Monitoramento e Logs
### 1. Métricas
```typescript
interface SystemMetrics {
performance: {
responseTime: number;
errorRate: number;
userCount: number;
};
resources: {
cpuUsage: number;
memoryUsage: number;
diskSpace: number;
};
}
```
### 2. Logs
- Error tracking
- User actions
- Performance
- Security events
## Próximos Passos
1. **Microserviços**
- Auth service
- Content service
- Analytics service
- Notification service
2. **Machine Learning**
- Recomendações
- Análise de progresso
- Detecção de padrões
- Personalização
3. **Mobile**
- PWA
- App nativo
- Offline mode
- Push notifications

315
docs/banco-dados.md Normal file
View File

@ -0,0 +1,315 @@
# Estrutura do Banco de Dados
## Visão Geral
O banco de dados foi projetado para suportar um sistema educacional de leitura, geração de histórias e exercícios fonéticos, com foco em rastreamento de progresso e gamificação.
## Entidades
### 1. Escolas e Classes
```sql
create table schools (
id uuid primary key default uuid_generate_v4(),
name text not null,
address text,
phone text,
email text,
director_name text,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
create table classes (
id uuid primary key default uuid_generate_v4(),
school_id uuid references schools(id),
teacher_id uuid references users(id),
name text not null,
grade text,
year integer,
period text,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
create table students (
id uuid primary key default uuid_generate_v4(),
user_id uuid references users(id),
class_id uuid references classes(id),
reading_level text,
birth_date date,
active boolean default true,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
```
### 2. Histórias
```sql
create table stories (
id uuid primary key default uuid_generate_v4(),
student_id uuid references users(id),
title text not null,
content jsonb not null,
status text,
theme_id uuid,
subject_id uuid,
character_id uuid,
setting_id uuid,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
```
### 3. Interesses
```sql
create table interests (
id uuid primary key default uuid_generate_v4(),
student_id uuid references users(id),
category text not null,
item text not null,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
```
### 4. Sistema de Conquistas
```sql
create table achievement_types (
id uuid primary key default uuid_generate_v4(),
name varchar not null,
description text,
created_at timestamptz default now()
);
create table achievements (
id uuid primary key default uuid_generate_v4(),
name text not null,
description text
);
```
### 5. Sistema Fonético
#### Categorias e Palavras
```sql
create table phonics_categories (
id uuid primary key default uuid_generate_v4(),
name varchar not null,
description text,
level integer,
order_index integer,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
create table phonics_words (
id uuid primary key default uuid_generate_v4(),
word varchar not null,
phonetic_transcription varchar,
syllables_count integer,
created_at timestamptz default now()
);
create table phonics_word_audio (
id uuid primary key default uuid_generate_v4(),
word text not null,
audio_url text,
audio_path text,
created_at timestamptz default now()
);
```
#### Exercícios
```sql
create table phonics_exercise_types (
id uuid primary key default uuid_generate_v4(),
name varchar not null,
description text,
created_at timestamptz default now()
);
create table phonics_exercises (
id uuid primary key default uuid_generate_v4(),
category_id uuid references phonics_categories(id),
type_id uuid references phonics_exercise_types(id),
title varchar not null,
description text,
difficulty_level integer,
estimated_time_seconds integer,
instructions text,
points integer,
is_active boolean default true,
required_score double precision,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
create table phonics_exercise_words (
id uuid primary key default uuid_generate_v4(),
exercise_id uuid references phonics_exercises(id),
word_id uuid references phonics_words(id),
is_correct_answer boolean,
order_index integer,
created_at timestamptz default now()
);
```
#### Mídia
```sql
create table media_types (
id uuid primary key default uuid_generate_v4(),
name varchar not null,
description text,
created_at timestamptz default now()
);
create table phonics_exercise_media (
id uuid primary key default uuid_generate_v4(),
exercise_id uuid references phonics_exercises(id),
media_type_id uuid references media_types(id),
url text,
alt_text text,
order_index integer,
created_at timestamptz default now()
);
```
#### Conquistas Fonéticas
```sql
create table phonics_achievements (
id uuid primary key default uuid_generate_v4(),
type_id uuid references achievement_types(id),
name varchar not null,
description text,
points integer,
icon_url text,
required_count integer,
created_at timestamptz default now()
);
```
## Relacionamentos
### Hierarquia Principal
```mermaid
erDiagram
Schools ||--o{ Classes : "has"
Classes ||--o{ Students : "contains"
Students ||--o{ Stories : "creates"
Students ||--o{ Interests : "has"
Students ||--o{ Achievements : "earns"
PhonicsCategories ||--o{ PhonicsExercises : "contains"
PhonicsExercises ||--o{ PhonicsExerciseWords : "has"
PhonicsWords }|--o{ PhonicsExerciseWords : "used_in"
PhonicsExercises ||--o{ PhonicsExerciseMedia : "has"
MediaTypes ||--o{ PhonicsExerciseMedia : "defines"
```
## Índices e Otimizações
### Performance
```sql
-- Busca de exercícios por categoria
create index idx_exercises_category on phonics_exercises(category_id);
-- Busca de histórias por aluno
create index idx_stories_student on stories(student_id);
-- Busca de palavras por exercício
create index idx_exercise_words on phonics_exercise_words(exercise_id);
-- Busca por conteúdo de história
create index idx_stories_content on stories using gin (content);
```
## Considerações de Segurança
### RLS (Row Level Security)
```sql
-- Alunos só podem ver suas próprias histórias
create policy "Students view own stories" on stories
for select using (auth.uid() = student_id);
-- Professores podem ver histórias de seus alunos
create policy "Teachers view class stories" on stories
for select using (
auth.uid() in (
select teacher_id from classes c
join students s on s.class_id = c.id
where s.id = stories.student_id
)
);
```
## Triggers
### Atualização Automática
```sql
-- Atualizar student_progress após nova gravação
create trigger update_student_progress
after insert or update on story_recordings
for each row
execute function update_student_progress();
-- Calcular duração da sessão de leitura
create trigger calculate_session_duration
before update on reading_sessions
for each row
when (NEW.end_time is not null)
execute function calculate_session_duration();
```
## Funções
### Análise de Progresso
```sql
-- Calcular nível de leitura
create function calculate_reading_level(
student_id uuid
) returns text as $$
-- Lógica de cálculo baseada em:
-- - Média de palavras por minuto
-- - Scores de fluência
-- - Quantidade de histórias lidas
$$ language plpgsql;
-- Atualizar métricas de progresso
create function update_student_progress() returns trigger as $$
-- Atualiza:
-- - Médias de performance
-- - Total de tempo lido
-- - Histórias completadas
-- - Pontos fortes e melhorias
$$ language plpgsql;
```
## Considerações de Performance
### 1. Particionamento
- Gravações particionadas por mês
- Sessões particionadas por aluno
- Histórias particionadas por complexidade
### 2. Vacuum
- Análise regular de dead tuples
- Vacuum automático configurado
- Monitoramento de bloat
### 3. Cache
- Histórias populares em cache
- Métricas de progresso em cache
- Configurações de usuário em cache
## Backup e Recuperação
### 1. Estratégia
- Backup completo diário
- WAL archiving contínuo
- Retenção de 30 dias
- Teste mensal de recuperação
### 2. Monitoramento
- Tamanho do banco
- Tempo de queries
- Uso de índices
- Deadlocks

117
docs/controles-texto.md Normal file
View File

@ -0,0 +1,117 @@
# Controles de Texto
## Visão Geral
Os controles de texto são um conjunto de funcionalidades que permitem aos usuários personalizar a apresentação do texto para melhor legibilidade e compreensão.
## Componentes Principais
### 1. TextControls
Componente principal que agrupa todos os controles de texto.
#### Seção 1: Controles de Formatação Básica
- **Maiúsculas/Minúsculas**
- Alterna entre texto em maiúsculas e minúsculas
- Útil para leitores iniciantes
- Mantém estado global da preferência
- **Sílabas**
- Ativa/desativa a separação silábica
- Ajuda na compreensão da estrutura das palavras
- Usa hífens para separação visual
- **Destaque de Palavras**
- Realça palavras sequencialmente
- Auxilia no acompanhamento da leitura
- Velocidade ajustável
#### Seção 2: Controles de Formatação Avançada
- **Tamanho da Fonte**
- Range: 12px - 32px
- Incrementos de 2px
- Ícone visual indicativo
- **Espaçamento entre Letras**
- Ajuste fino do kerning
- Melhora legibilidade
- Suporte para necessidades especiais
- **Espaçamento entre Palavras**
- Controle da distância entre palavras
- Facilita a leitura
- Ajuda na compreensão do texto
- **Altura da Linha**
- Ajuste do espaçamento vertical
- Melhora conforto visual
- Previne confusão entre linhas
### 2. AdaptiveText
Componente que implementa as transformações de texto.
```typescript
interface AdaptiveTextProps {
text: string;
isUpperCase: boolean;
preserveWhitespace?: boolean;
highlightSyllables?: boolean;
}
```
## Funcionalidades Técnicas
### 1. Gestão de Estado
- Uso de hooks personalizados para gerenciar preferências
- Persistência de configurações por usuário
- Sincronização em tempo real
### 2. Transformações de Texto
- Conversão maiúsculo/minúsculo
- Separação silábica
- Destaque sequencial de palavras
### 3. Acessibilidade
- Suporte a ARIA labels
- Alto contraste
- Feedback visual claro
- Suporte a leitores de tela
## Integração com Modo Foco
- Controles permanecem acessíveis
- Transições suaves
- Estado preservado entre modos
## Exemplos de Uso
### Implementação Básica
```typescript
<TextControls
fontSize={18}
onFontSizeChange={handleFontSizeChange}
letterSpacing={0.5}
onLetterSpacingChange={handleLetterSpacingChange}
wordSpacing={2}
onWordSpacingChange={handleWordSpacingChange}
lineHeight={1.5}
onLineHeightChange={handleLineHeightChange}
/>
```
### Uso com AdaptiveText
```typescript
<AdaptiveText
text="Exemplo de texto adaptativo"
isUpperCase={isUpperCase}
highlightSyllables={isSyllablesEnabled}
/>
```
## Considerações de Performance
- Memoização de componentes
- Otimização de re-renders
- Lazy loading de recursos
## Próximas Melhorias
1. Adicionar mais opções de formatação
2. Implementar temas personalizados
3. Melhorar algoritmo de separação silábica
4. Adicionar suporte a mais idiomas

246
docs/desenvolvimento.md Normal file
View File

@ -0,0 +1,246 @@
# Fluxo de Desenvolvimento
## Padrões de Código
### 1. Nomenclatura
- Diretórios em kebab-case: `components/form-wizard`
- Componentes em PascalCase: `StoryCard.tsx`
- Utilitários em camelCase: `formatText.ts`
- Variáveis em camelCase: `userScore`
### 2. TypeScript
- Preferir interfaces sobre types
- Usar const com asserção `as const`
- Retornos explícitos em funções
- Imports relativos
## Estrutura de Commits
### 1. Prefixos
- `fix:` Correções de bugs
- `feat:` Novos recursos
- `perf:` Melhorias de performance
- `docs:` Documentação
- `style:` Formatação
- `refactor:` Refatoração
- `test:` Testes
- `chore:` Manutenção
### 2. Formato
```
<tipo>: <descrição>
[corpo]
[rodapé]
```
## Fluxo de Branches
### 1. Principais
- `main`: Produção
- `develop`: Desenvolvimento
- `staging`: Homologação
### 2. Features
```bash
# Nova feature
git checkout -b feature/nome-da-feature
# Commit das mudanças
git commit -m "feat: adiciona novo componente"
# Push para remote
git push origin feature/nome-da-feature
```
## Code Review
### 1. Checklist
- Código limpo e legível
- Testes adequados
- Documentação atualizada
- Performance otimizada
- Segurança verificada
### 2. Pull Request
```markdown
## Descrição
Breve descrição das mudanças
## Mudanças
- [ ] Item 1
- [ ] Item 2
## Screenshots
[Se aplicável]
## Testes
- [ ] Unitários
- [ ] Integração
- [ ] E2E
```
## Testes
### 1. Unitários
```typescript
describe('StoryComponent', () => {
it('deve renderizar corretamente', () => {
const { getByText } = render(<Story />);
expect(getByText('Título')).toBeInTheDocument();
});
});
```
### 2. Integração
```typescript
describe('ExerciseFlow', () => {
it('deve completar exercício', async () => {
const result = await completeExercise({
type: 'word-formation',
answer: 'casa'
});
expect(result.success).toBe(true);
});
});
```
## Documentação
### 1. Código
```typescript
/**
* Componente de exercício de formação de palavras
* @param {WordFormationProps} props - Propriedades do componente
* @returns {JSX.Element} Componente renderizado
*/
export const WordFormation: React.FC<WordFormationProps> = ({
word,
syllables,
onComplete
}) => {
// ...
};
```
### 2. README
- Setup do projeto
- Comandos disponíveis
- Estrutura de arquivos
- Contribuição
- Licença
## Segurança
### 1. Checklist
- Validação de inputs
- Sanitização de dados
- Proteção contra XSS
- Autenticação segura
### 2. Revisão
```typescript
// ❌ Inseguro
const query = `SELECT * FROM users WHERE id = ${id}`;
// ✅ Seguro
const query = 'SELECT * FROM users WHERE id = $1';
const values = [id];
```
## Performance
### 1. Frontend
- Lazy loading
- Memoização
- Bundle splitting
- Image optimization
### 2. Backend
- Caching
- Query optimization
- Connection pooling
- Rate limiting
## Deploy
### 1. Staging
```bash
# Build
yarn build
# Testes
yarn test
# Deploy staging
yarn deploy:staging
```
### 2. Produção
```bash
# Merge para main
git checkout main
git merge develop
# Deploy produção
yarn deploy:prod
```
## Monitoramento
### 1. Métricas
- Tempo de resposta
- Taxa de erro
- Uso de recursos
- Satisfação do usuário
### 2. Logs
```typescript
logger.info('Exercício completado', {
userId: user.id,
exerciseId: exercise.id,
score: result.score,
timeSpent: result.time
});
```
## Manutenção
### 1. Dependências
```bash
# Atualizar deps
yarn upgrade-interactive --latest
# Auditar segurança
yarn audit
# Limpar cache
yarn cache clean
```
### 2. Backup
- Database dumps
- Logs históricos
- Configurações
- Assets
## Contribuição
### 1. Setup
```bash
# Clone
git clone https://github.com/org/repo.git
# Install
yarn install
# Dev
yarn dev
```
### 2. Guidelines
- Código limpo
- Testes completos
- Documentação clara
- Pull requests concisos

177
docs/exercicios.md Normal file
View File

@ -0,0 +1,177 @@
# Sistema de Exercícios
## Visão Geral
O sistema de exercícios oferece diferentes tipos de atividades para reforçar o aprendizado da leitura e compreensão textual.
## Tipos de Exercícios
### 1. Formação de Palavras
```typescript
interface WordFormationExercise {
word: string;
syllables: string[];
hints?: string[];
difficulty: 'easy' | 'medium' | 'hard';
}
```
### 2. Completar Sentenças
```typescript
interface SentenceCompletionExercise {
sentence: string;
options: string[];
correctAnswer: string;
context: string;
}
```
### 3. Prática de Pronúncia
```typescript
interface PronunciationExercise {
word: string;
phonemes: string[];
audioUrl?: string;
examples: string[];
}
```
## Fluxo de Exercícios
### 1. Seleção
- Baseada no nível do aluno
- Progressão gradual
- Adaptação por desempenho
- Variedade de tipos
### 2. Execução
- Instruções claras
- Feedback imediato
- Dicas contextuais
- Suporte visual
### 3. Avaliação
- Pontuação automática
- Feedback detalhado
- Sugestões de melhoria
- Registro de progresso
## Componentes Principais
### 1. ExercisePlayer
- Controle de fluxo
- Timer integrado
- Sistema de pontuação
- Feedback visual
### 2. ExerciseFactory
- Criação dinâmica
- Validação de respostas
- Adaptação de dificuldade
- Geração de feedback
## Integração com Banco de Dados
### 1. Tabelas Relacionadas
```sql
-- Exercícios
create table exercises (
id uuid primary key,
type text,
difficulty text,
content jsonb,
created_at timestamptz
);
-- Progresso
create table exercise_progress (
student_id uuid,
exercise_id uuid,
score numeric,
completed_at timestamptz
);
```
### 2. Métricas Armazenadas
- Tempo de conclusão
- Taxa de acerto
- Tentativas realizadas
- Padrões de erro
## Acessibilidade
### 1. Visual
- Alto contraste
- Fontes ajustáveis
- Ícones intuitivos
- Animações suaves
### 2. Auditiva
- Instruções em áudio
- Feedback sonoro
- Controle de volume
- Legendas
### 3. Motora
- Controles simplificados
- Atalhos de teclado
- Tempo ajustável
- Pausas automáticas
## Gamificação
### 1. Sistema de Pontos
- Pontuação base
- Bônus por velocidade
- Combos de acertos
- Conquistas especiais
### 2. Progressão
- Níveis de dificuldade
- Desbloqueio gradual
- Medalhas e troféus
- Rankings opcionais
### 3. Recompensas
- Novos conteúdos
- Personalização
- Badges especiais
- Poder de escolha
## Monitoramento
### 1. Métricas Coletadas
```typescript
interface ExerciseMetrics {
timeSpent: number;
correctAnswers: number;
totalAttempts: number;
hintsUsed: number;
score: number;
}
```
### 2. Análise de Desempenho
- Padrões de erro
- Tempo de resposta
- Uso de dicas
- Evolução temporal
## Próximas Melhorias
1. **Novos Tipos**
- Exercícios de ritmo
- Compreensão auditiva
- Produção textual
- Jogos educativos
2. **Personalização**
- Temas customizados
- Níveis adaptativos
- Conteúdo dinâmico
- Preferências salvas
3. **Interatividade**
- Multiplayer
- Desafios em grupo
- Compartilhamento
- Competições

166
docs/geracao-historia.md Normal file
View File

@ -0,0 +1,166 @@
# Geração de Histórias
## Visão Geral
O sistema de geração de histórias permite criar conteúdo personalizado baseado em parâmetros fornecidos pelo usuário, utilizando IA para gerar narrativas educativas e envolventes.
## Parâmetros de Entrada
### StoryChoices
```typescript
interface StoryChoices {
protagonist: string; // Nome/tipo do protagonista
setting: string; // Ambiente da história
theme: string; // Tema principal
genre: string; // Gênero da história
educationalGoal: string; // Objetivo educacional
ageGroup: string; // Faixa etária
length: 'short' | 'medium' | 'long'; // Extensão da história
complexity: 'easy' | 'medium' | 'hard'; // Nível de complexidade
language: 'pt-BR'; // Idioma (fixo em português)
}
```
## Modos de Entrada
### 1. Formulário
- Interface gráfica com campos estruturados
- Validação em tempo real
- Sugestões pré-definidas
- Preview instantâneo
### 2. Comando de Voz
- Reconhecimento de fala natural
- Extração automática de parâmetros
- Confirmação por voz
- Correção por voz ou texto
### 3. Texto Livre
- Processamento de linguagem natural
- Identificação de parâmetros-chave
- Sugestão de complementos
- Refinamento interativo
## Fluxo de Geração
### 1. Coleta de Parâmetros
```typescript
// Exemplo de validação de parâmetros
const validateStoryParams = (choices: StoryChoices): boolean => {
return (
!!choices.protagonist &&
!!choices.setting &&
!!choices.theme &&
!!choices.genre &&
!!choices.educationalGoal &&
!!choices.ageGroup
);
};
```
### 2. Processamento
1. **Validação**
- Verificação de campos obrigatórios
- Validação de conteúdo apropriado
- Checagem de restrições de idade
2. **Preparação**
- Formatação dos parâmetros
- Ajuste de complexidade
- Definição de estrutura
3. **Geração**
- Criação do conteúdo via IA
- Revisão automática
- Formatação do texto
### 3. Pós-processamento
- Verificação de adequação
- Ajustes de formatação
- Adição de metadados
- Geração de recursos visuais
## Controles de Qualidade
### 1. Adequação de Conteúdo
- Filtro de conteúdo impróprio
- Verificação de complexidade
- Adequação à faixa etária
- Alinhamento educacional
### 2. Estrutura Narrativa
- Coerência da história
- Desenvolvimento de personagens
- Arco narrativo apropriado
- Conclusão educativa
### 3. Linguagem
- Vocabulário adequado
- Estruturas gramaticais
- Pontuação correta
- Ritmo de leitura
## Integração com Modo Foco
### 1. Formatação Adaptativa
- Ajuste automático de fonte
- Espaçamento otimizado
- Quebras de linha estratégicas
- Destaque de palavras-chave
### 2. Recursos de Acessibilidade
- Suporte a leitura em voz alta
- Marcadores visuais
- Controles de navegação
- Ajustes de contraste
## Armazenamento
### 1. Estrutura de Dados
```typescript
interface Story {
id: string;
title: string;
content: string;
parameters: StoryChoices;
metadata: {
wordCount: number;
readingTime: number;
complexity: number;
keywords: string[];
};
created_at: string;
updated_at: string;
}
```
### 2. Indexação
- Busca por parâmetros
- Filtros de complexidade
- Tags educacionais
- Histórico de geração
## Próximas Melhorias
1. **Personalização Avançada**
- Perfis de aprendizado
- Adaptação dinâmica
- Temas customizados
- Integração curricular
2. **Geração Multimodal**
- Ilustrações automáticas
- Efeitos sonoros
- Animações simples
- Recursos interativos
3. **Análise de Impacto**
- Métricas de engajamento
- Progresso educacional
- Feedback do usuário
- Ajustes automáticos
4. **Colaboração**
- Edição compartilhada
- Biblioteca de recursos
- Compartilhamento social
- Feedback comunitário

151
docs/gravacao-audio.md Normal file
View File

@ -0,0 +1,151 @@
# Sistema de Gravação de Áudio
## Visão Geral
O sistema de gravação de áudio permite aos alunos gravar suas leituras para análise posterior, com integração ao Modo Foco e recursos de acessibilidade.
## Componentes
### 1. AudioRecorder
Componente principal responsável pela gravação de áudio.
#### Props
```typescript
interface AudioRecorderProps {
storyId: string;
studentId: string;
onAudioUploaded: (audioUrl: string) => void;
onRecordingStart?: () => void;
onRecordingStop?: () => void;
focusModeActive?: boolean;
onFocusModeToggle?: () => void;
}
```
### 2. Estados de Gravação
- **Não Iniciado**: Exibe botão "Iniciar Gravação"
- **Gravando**: Exibe botão "Parar Gravação"
- **Gravado**: Exibe opções de ouvir e enviar
- **Enviando**: Exibe indicador de progresso
## Funcionalidades
### 1. Controle de Gravação
- Início/parada de gravação
- Feedback visual do estado atual
- Integração com Modo Foco
- Controle de permissões do microfone
### 2. Processamento de Áudio
- Formato: WebM
- Armazenamento temporário em Blob
- Conversão e compressão antes do upload
- Validação de qualidade
### 3. Upload e Armazenamento
- Upload para Supabase Storage
- Geração de URLs públicas
- Organização por aluno/história
- Backup e cache
### 4. Integração com Modo Foco
- Ativação automática do Modo Foco
- Desativação ao finalizar gravação
- Sincronização de estados
- Transições suaves
## Fluxo de Gravação
### 1. Início da Gravação
```typescript
const startRecording = async () => {
// 1. Ativar Modo Foco
if (!focusModeActive && onFocusModeToggle) {
onFocusModeToggle();
}
// 2. Solicitar permissões
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// 3. Configurar gravador
mediaRecorderRef.current = new MediaRecorder(stream);
// 4. Iniciar gravação
mediaRecorderRef.current.start();
};
```
### 2. Finalização da Gravação
```typescript
const stopRecording = () => {
// 1. Parar gravação
mediaRecorderRef.current?.stop();
// 2. Liberar recursos
mediaRecorderRef.current?.stream.getTracks().forEach(track => track.stop());
// 3. Desativar Modo Foco
if (focusModeActive && onFocusModeToggle) {
onFocusModeToggle();
}
};
```
### 3. Upload do Áudio
```typescript
const uploadAudio = async () => {
// 1. Preparar arquivo
const filePath = `${studentId}/${storyId}/${fileId}.webm`;
// 2. Fazer upload
await supabase.storage
.from('recordings')
.upload(filePath, audioBlob);
// 3. Obter URL pública
const { publicUrl } = supabase.storage
.from('recordings')
.getPublicUrl(filePath);
// 4. Criar registro
await supabase
.from('story_recordings')
.insert({
id: fileId,
story_id: storyId,
student_id: studentId,
audio_url: publicUrl
});
};
```
## Tratamento de Erros
### 1. Permissões
- Verificação de disponibilidade do microfone
- Solicitação de permissões do usuário
- Feedback claro em caso de negação
### 2. Gravação
- Monitoramento de qualidade
- Detecção de silêncio
- Limite de duração
- Feedback de volume
### 3. Upload
- Retry em caso de falha
- Limpeza de arquivos temporários
- Validação de formato
- Feedback de progresso
## Considerações de Segurança
- Validação de tipos de arquivo
- Sanitização de nomes de arquivo
- Controle de acesso por usuário
- Expiração de URLs temporárias
## Próximas Melhorias
1. Adicionar visualização de forma de onda
2. Implementar edição básica de áudio
3. Melhorar feedback de qualidade
4. Adicionar suporte a mais formatos
5. Implementar detecção de ruído

90
docs/modo-foco.md Normal file
View File

@ -0,0 +1,90 @@
# Modo Foco
## Visão Geral
O Modo Foco é uma funcionalidade projetada para melhorar a experiência de leitura e gravação de histórias, focando na concentração e acessibilidade.
## Funcionalidades
### 1. Ativação Automática
- Inicia automaticamente ao começar uma gravação
- Desativa automaticamente ao parar a gravação
- Pode ser ativado/desativado manualmente através do botão dedicado
### 2. Interface Adaptativa
- Remove distrações visuais durante a leitura
- Aumenta o foco no texto atual
- Transições suaves entre estados
### 3. Controles de Acessibilidade
- Ajuste de tamanho da fonte (12px - 32px)
- Controle de espaçamento entre letras
- Controle de espaçamento entre palavras
- Ajuste de altura da linha
- Velocidade de leitura personalizável
### 4. Organização dos Controles
#### Seção 1 (Controles Principais)
- Maiúsculas/Minúsculas
- Sílabas
- Destacar palavras
#### Seção 2 (Controles de Formatação)
- Tamanho da fonte
- Espaçamento entre letras
- Espaçamento entre palavras
- Altura da linha
- Velocidade de leitura
### 5. Indicadores Visuais
- Ícones intuitivos para cada função
- Feedback visual do estado atual
- Timer de gravação
- Destaque de palavras durante a leitura
## Estilos e Temas
- Modo claro com fundo suave
- Contraste adequado para leitura
- Sombras sutis para hierarquia visual
- Design responsivo para diferentes tamanhos de tela
## Integração
- Componente `AudioRecorder` para gravações
- Componente `TextControls` para formatação
- Sistema de destaque de palavras
- Gerenciamento de estado global
## Uso Técnico
### Ativação do Modo Foco
```typescript
// Em AudioRecorder
const startRecording = async () => {
if (!focusModeActive && onFocusModeToggle) {
onFocusModeToggle();
}
// ... resto do código
};
```
### Desativação do Modo Foco
```typescript
// Em AudioRecorder
const stopRecording = () => {
// ... código de parada da gravação
if (focusModeActive && onFocusModeToggle) {
onFocusModeToggle();
}
};
```
## Considerações de Acessibilidade
- Alto contraste para melhor legibilidade
- Suporte a diferentes tamanhos de fonte
- Controles de espaçamento para dislexia
- Feedback visual claro das ações
## Próximos Passos
1. Implementar persistência das preferências do usuário
2. Adicionar mais opções de temas
3. Expandir controles de acessibilidade
4. Melhorar feedback de progresso da leitura

150
docs/processamento-audio.md Normal file
View File

@ -0,0 +1,150 @@
# Processamento de Áudio (Edge Function)
## Visão Geral
O sistema de processamento de áudio é uma Edge Function que analisa gravações de leitura, fornecendo métricas detalhadas sobre fluência, pronúncia e compreensão.
## Estrutura de Dados
### AudioRecord
```typescript
interface AudioRecord {
id: string
story_id: string
student_id: string
audio_url: string
status: 'pending_analysis' | 'processing' | 'completed' | 'error'
analysis: any
created_at: string
transcription: string | null
processed_at: string | null
error_message: string | null
fluency_score: number | null
pronunciation_score: number | null
accuracy_score: number | null
comprehension_score: number | null
words_per_minute: number | null
pause_count: number | null
error_count: number | null
self_corrections: number | null
strengths: string[]
improvements: string[]
suggestions: string | null
}
```
## Fluxo de Processamento
### 1. Recebimento da Requisição
- Validação inicial dos dados recebidos
- Configuração de CORS e headers
- Inicialização do logger
### 2. Processamento Principal
O processamento ocorre em etapas sequenciais:
1. **Verificação e Atualização de Status**
- Verifica existência do registro
- Cria registro se necessário
- Atualiza status para 'processing'
2. **Processamento do Áudio**
- Transcrição via Whisper API
- Análise do texto transcrito
3. **Análise da Leitura**
- Cálculo de métricas de fluência
- Avaliação de pronúncia
- Identificação de pontos fortes e melhorias
4. **Atualização do Banco**
- Preparação dos dados de análise
- Verificação pré-update
- Atualização do registro
- Verificação pós-update
## Tratamento de Erros
### 1. Validação de Dados
```typescript
if (!data?.record?.id || !data?.record?.audio_url) {
throw new Error('Dados inválidos: ID ou URL do áudio ausentes')
}
```
### 2. Atualização de Status de Erro
- Em caso de falha, atualiza o registro com status 'error'
- Armazena mensagem de erro para diagnóstico
- Retorna resposta com detalhes do erro
## Métricas Analisadas
### Pontuações
- Fluência (0-100)
- Pronúncia (0-100)
- Precisão (0-100)
- Compreensão (0-100)
### Métricas Quantitativas
- Palavras por minuto
- Contagem de pausas
- Contagem de erros
- Autocorreções
### Feedback Qualitativo
- Pontos fortes identificados
- Áreas para melhoria
- Sugestões personalizadas
## Logs e Monitoramento
### Eventos Registrados
- Recebimento de requisição
- Atualizações de status
- Resultados de processamento
- Erros e exceções
### Formato dos Logs
```typescript
logger.info('event_name', 'Descrição do evento', {
contextData: 'dados adicionais'
})
```
## Considerações de Segurança
### 1. Autenticação
- Validação de tokens
- Verificação de permissões
- Controle de acesso por usuário
### 2. Dados Sensíveis
- Sanitização de inputs
- Validação de URLs
- Proteção contra injeção
### 3. Rate Limiting
- Controle de requisições
- Proteção contra sobrecarga
- Cache de resultados
## Próximas Melhorias
1. **Análise Avançada**
- Detecção de padrões de erro
- Análise de entonação
- Reconhecimento de emoção
2. **Performance**
- Otimização de processamento
- Cache distribuído
- Processamento em lote
3. **Feedback**
- Relatórios detalhados
- Visualizações gráficas
- Recomendações personalizadas
4. **Integração**
- Webhooks para notificações
- API para consultas em tempo real
- Exportação de dados

25
docs/voice-features.md Normal file
View File

@ -0,0 +1,25 @@
## Geração por Voz
### Como usar:
1. Clique no ícone de microfone
2. Fale sua descrição por 15-120 segundos
3. Confira a transcrição
4. Ajuste se necessário
5. Envie para gerar a história
### Requisitos:
- Navegador moderno (Chrome, Edge, Safari 14+)
- Microfone habilitado
- Conexão estável
## Segurança
- Gravações temporárias são excluídas após 1h
- Transcrições são validadas contra conteúdo sensível
- Dados de áudio não são armazenados permanentemente
## Limitações Conhecidas
- Acento pode afetar precisão da transcrição
- Ruído ambiente pode interferir na qualidade
- Suporte limitado a sotaques regionais

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/book.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Histórias Mágicas - Plataforma educacional de histórias interativas para escolas" />
<title>Histórias Mágicas | Educação através de histórias interativas</title>
<meta name="description" content="Leiturama - Plataforma educacional de histórias interativas para escolas" />
<title>Leiturama | Educação através de histórias interativas</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">

60
netlify.toml Normal file
View File

@ -0,0 +1,60 @@
[build]
command = "npm run build"
publish = "dist"
[build.environment]
NODE_VERSION = "18"
VITE_SUPABASE_URL = "https://bsjlbnyslxzsdwxvkaap.supabase.co"
VITE_RESEND_API_KEY = "GEoM_cVt4qyBFVkngJWi8wBrMWOiPMUAuxuFGykcP0A"
VITE_APP_URL = "https://leiturama.ai/"
SECRETS_SCAN_OMIT_KEYS = "SUPABASE_ANON_KEY"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-XSS-Protection = "1; mode=block"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
Cross-Origin-Embedder-Policy = "credentialless"
Cross-Origin-Opener-Policy = "same-origin"
Cross-Origin-Resource-Policy = "cross-origin"
Content-Security-Policy = """
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.rudderlabs.com https://*.cloudfront.net https://www.googletagmanager.com https://*.sentry.io;
connect-src 'self' https://*.rudderlabs.com https://*.ingest.sentry.io https://*.supabase.co https://www.google-analytics.com https://*.dataplane.rudderstack.com https://*.bugsnag.com/ https://*.ingest.us.sentry.io/ https://*.sentry.io/;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' data: https: blob: https://*.supabase.co;
font-src 'self' data: https://fonts.gstatic.com;
frame-src 'self' https://www.googletagmanager.com;
worker-src 'self' blob:;
"""
Access-Control-Allow-Origin = "*"
Access-Control-Allow-Methods = "GET, POST, PUT, DELETE, OPTIONS"
Access-Control-Allow-Headers = """
Authorization,
Content-Type,
Accept,
Origin,
User-Agent,
DNT,
Cache-Control,
X-Mx-ReqToken,
Keep-Alive,
X-Requested-With,
If-Modified-Since
"""
Access-Control-Max-Age = "3600"
[dev]
command = "npm run dev"
port = 5173
publish = "dist"
[functions]
node_bundler = "esbuild"

21
next.config.js Normal file
View File

@ -0,0 +1,21 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
images: {
domains: [
'oaidalleapiprodscus.blob.core.windows.net',
'leiturama.ai',
'localhost',
'bsjlbnyslxzsdwxvkaap.supabase.co',
'leiturama.netlify.app'
],
formats: ['image/avif', 'image/webp'],
minimumCacheTTL: 60,
},
experimental: {
optimizeCss: true,
optimizeImages: true,
},
}
module.exports = nextConfig

7132
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,20 +9,70 @@
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"format": "prettier --write \"src/**/*.{ts,tsx}\""
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
"docker:build": "docker build -t leiturama .",
"docker:run": "docker run -p 3000:3000 leiturama",
"deploy:prod": "docker-compose up -d --build"
},
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.7",
"@ffmpeg/util": "^0.12.1",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.57.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.57.1",
"@opentelemetry/sdk-metrics": "^1.30.1",
"@opentelemetry/sdk-trace-web": "^1.30.1",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@sentry/react": "^8.48.0",
"@supabase/supabase-js": "^2.39.7",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "^5.62.8",
"@testing-library/react": "^16.1.0",
"@tiptap/extension-character-count": "^2.11.5",
"@tiptap/extension-color": "^2.11.5",
"@tiptap/extension-highlight": "^2.11.5",
"@tiptap/extension-placeholder": "^2.11.5",
"@tiptap/extension-text-align": "^2.11.5",
"@tiptap/extension-text-style": "^2.11.5",
"@tiptap/extension-underline": "^2.11.5",
"@tiptap/pm": "^2.11.5",
"@tiptap/react": "^2.11.5",
"@tiptap/starter-kit": "^2.11.5",
"@tremor/react": "^3.18.7",
"@types/ioredis": "^4.28.10",
"@types/jest": "^29.5.14",
"@types/next": "^8.0.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"ioredis": "^5.4.2",
"lucide-react": "^0.344.0",
"next": "^15.1.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"react-router-dom": "^6.28.0",
"resend": "^3.2.0"
"recharts": "^2.15.1",
"resend": "^3.2.0",
"shadcn-ui": "^0.9.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.0.3",
"vitest": "^2.1.8",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@shadcn/ui": "^0.0.4",
"@testing-library/jest-dom": "^6.6.3",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.1",
@ -30,6 +80,7 @@
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.4.49",
"supabase": "^2.1.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",

34
portainer-stack.yml Normal file
View File

@ -0,0 +1,34 @@
version: '3.8'
services:
leiturama:
image: ${REGISTRY}/leiturama:${TAG}
environment:
- NODE_ENV=production
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- REDIS_URL=redis://redis:6379
networks:
- traefik-public
- redis-network
deploy:
labels:
- "traefik.enable=true"
- "traefik.http.routers.leiturama.rule=Host(`${DOMAIN}`)"
- "traefik.http.routers.leiturama.entrypoints=websecure"
- "traefik.http.routers.leiturama.tls.certresolver=letsencrypt"
- "traefik.http.services.leiturama.loadbalancer.server.port=3000"
- "traefik.docker.network=traefik-public"
replicas: 2
update_config:
parallelism: 1
delay: 10s
restart_policy:
condition: on-failure
networks:
traefik-public:
external: true
redis-network:
external: true

4
public/book.svg Normal file
View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path>
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>
</svg>

After

Width:  |  Height:  |  Size: 313 B

View File

@ -0,0 +1 @@
data:image/webp;base64,UklGRlIAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAABBxAREYiI/gcAAABWUDggGAAAADABAJ0BKgEAAQABABwlpAADcAD+/gbQAA==

View File

@ -0,0 +1 @@
data:image/webp;base64,UklGRlIAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAABBxAREYiI/gcAAABWUDggGAAAADABAJ0BKgEAAQABABwlpAADcAD+/gbQAA==

View File

@ -0,0 +1 @@
data:image/webp;base64,UklGRlIAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAABBxAREYiI/gcAAABWUDggGAAAADABAJ0BKgEAAQABABwlpAADcAD+/gbQAA==

View File

@ -0,0 +1 @@
data:image/webp;base64,UklGRlIAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAABBxAREYiI/gcAAABWUDggGAAAADABAJ0BKgEAAQABABwlpAADcAD+/gbQAA==

8
public/patterns/dots.svg Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="2" cy="2" r="2" fill="currentColor"/>
<circle cx="18" cy="2" r="2" fill="currentColor"/>
<circle cx="10" cy="10" r="2" fill="currentColor"/>
<circle cx="2" cy="18" r="2" fill="currentColor"/>
<circle cx="18" cy="18" r="2" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 408 B

View File

@ -10,6 +10,10 @@ import { AuthUser, SavedStory } from './types/auth';
import { User, Theme } from './types';
import { AuthProvider } from './contexts/AuthContext'
import { useNavigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Toaster } from './components/ui/toaster';
import { router } from './routes';
import { RouterProvider } from 'react-router-dom';
type AppStep =
| 'welcome'
@ -20,6 +24,16 @@ type AppStep =
| 'story'
| 'library';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutos
gcTime: 1000 * 60 * 30, // 30 minutos (antes era cacheTime)
refetchOnWindowFocus: false,
},
},
})
export function App() {
const navigate = useNavigate();
const [step, setStep] = useState<AppStep>('welcome');
@ -74,43 +88,47 @@ export function App() {
};
return (
<AuthProvider>
<div className="min-h-screen bg-gradient-to-b from-purple-100 to-blue-100">
{step === 'welcome' && (
<WelcomePage
onLoginClick={() => setStep('login')}
onRegisterClick={() => setStep('register')}
/>
)}
{step === 'login' && (
<div className="min-h-screen flex items-center justify-center p-6">
<LoginForm
userType="school"
onLogin={handleLogin}
<QueryClientProvider client={queryClient}>
<AuthProvider>
<RouterProvider router={router} />
<div className="min-h-screen bg-gradient-to-b from-purple-100 to-blue-100">
{step === 'welcome' && (
<WelcomePage
onLoginClick={() => setStep('login')}
onRegisterClick={() => setStep('register')}
/>
</div>
)}
{step === 'register' && (
<RegistrationForm
userType="school"
onComplete={handleRegistrationComplete}
/>
)}
{step === 'avatar' && user && (
<AvatarSelector user={user} onComplete={handleAvatarComplete} />
)}
{step === 'theme' && <ThemeSelector onSelect={handleThemeSelect} />}
{step === 'story' && user && selectedTheme && (
<StoryViewer theme={selectedTheme} user={user} />
)}
{step === 'library' && authUser && (
<StoryLibrary
stories={savedStories}
onStorySelect={handleStorySelect}
/>
)}
</div>
</AuthProvider>
)}
{step === 'login' && (
<div className="min-h-screen flex items-center justify-center p-6">
<LoginForm
userType="school"
onLogin={handleLogin}
onRegisterClick={() => setStep('register')}
/>
</div>
)}
{step === 'register' && (
<RegistrationForm
userType="school"
onComplete={handleRegistrationComplete}
/>
)}
{step === 'avatar' && user && (
<AvatarSelector user={user} onComplete={handleAvatarComplete} />
)}
{step === 'theme' && <ThemeSelector onSelect={handleThemeSelect} />}
{step === 'story' && user && selectedTheme && (
<StoryViewer theme={selectedTheme} user={user} />
)}
{step === 'library' && authUser && (
<StoryLibrary
stories={savedStories}
onStorySelect={handleStorySelect}
/>
)}
<Toaster />
</div>
</AuthProvider>
</QueryClientProvider>
);
}

View File

@ -18,7 +18,7 @@ export function StoryViewer({ theme, user, demo = false }: Props) {
title: "Uma Aventura Educacional",
pages: [
{
text: "Bem-vindo à demonstração do Histórias Mágicas! Aqui você pode ver como funciona nossa plataforma...",
text: "Bem-vindo à demonstração do Leiturama! Aqui você pode ver como funciona nossa plataforma...",
image: "https://images.unsplash.com/photo-1472162072942-cd5147eb3902?auto=format&fit=crop&q=80&w=800&h=600",
},
{

View File

@ -12,7 +12,7 @@ export function WelcomePage({ onLoginClick, onRegisterClick }: Props) {
<div className="max-w-6xl mx-auto px-6 py-16">
<div className="text-center mb-16">
<h1 className="text-5xl font-bold text-purple-600 mb-4">
Histórias Mágicas
Leiturama
</h1>
<p className="text-xl text-gray-600">
Embarque em uma jornada de aprendizado e diversão!

View File

@ -0,0 +1,39 @@
import React from 'react';
interface GoogleTagManagerProps {
gtmId: string;
}
export function GoogleTagManager({ gtmId }: GoogleTagManagerProps) {
React.useEffect(() => {
// Carrega o script do GTM
const script = document.createElement('script');
script.innerHTML = `
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${gtmId}');
`;
document.head.appendChild(script);
// Adiciona o noscript iframe
const noscript = document.createElement('noscript');
const iframe = document.createElement('iframe');
iframe.src = `https://www.googletagmanager.com/ns.html?id=${gtmId}`;
iframe.height = '0';
iframe.width = '0';
iframe.style.display = 'none';
iframe.style.visibility = 'hidden';
noscript.appendChild(iframe);
document.body.insertBefore(noscript, document.body.firstChild);
return () => {
// Cleanup
document.head.removeChild(script);
document.body.removeChild(noscript);
};
}, [gtmId]);
return null;
}

View File

@ -0,0 +1,127 @@
import { useEffect, useRef } from 'react';
import { useRudderstack } from '../../hooks/useRudderstack';
import { useLocation } from 'react-router-dom';
import { useAuth } from '../../hooks/useAuth';
export function PageTracker() {
const location = useLocation();
const { page } = useRudderstack();
const { user } = useAuth();
const lastPageTracked = useRef<string | null>(null);
const timeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
// Se já rastreamos esta página, não rastrear novamente
if (lastPageTracked.current === location.pathname) {
return;
}
// Limpa o timeout anterior se existir
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Debounce de 300ms para evitar múltiplos eventos
timeoutRef.current = setTimeout(() => {
// Coleta informações do dispositivo/navegador
const deviceInfo = {
screenWidth: window.screen.width,
screenHeight: window.screen.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
deviceType: getDeviceType(),
deviceOrientation: window.screen.orientation.type,
userAgent: navigator.userAgent,
language: navigator.language,
};
// Coleta informações de performance
const performanceInfo = {
loadTime: window.performance.timing.loadEventEnd - window.performance.timing.navigationStart,
domInteractive: window.performance.timing.domInteractive - window.performance.timing.navigationStart,
firstContentfulPaint: getFirstContentfulPaint(),
};
// Informações da sessão
const sessionInfo = {
sessionStartTime: sessionStorage.getItem('sessionStartTime') || new Date().toISOString(),
isFirstVisit: !localStorage.getItem('returningVisitor'),
lastVisitedPage: sessionStorage.getItem('lastVisitedPage'),
};
// Traits do usuário (se autenticado)
const userTraits = user ? {
user_id: user.id,
email: user.email,
school_id: user.user_metadata?.school_id,
class_id: user.user_metadata?.class_id,
name: user.user_metadata?.name,
role: user.user_metadata?.role,
last_updated: user.updated_at,
created_at: user.created_at
} : {};
// Envia dados adicionais usando o page() do Rudderstack
page(undefined, {
// Informações da página
path: location.pathname,
search: location.search,
hash: location.hash,
title: document.title,
referrer: document.referrer,
url: window.location.href,
// Informações do dispositivo e navegador
...deviceInfo,
// Informações de performance
...performanceInfo,
// Informações da sessão
...sessionInfo,
// Traits do usuário
...userTraits,
// Metadados adicionais
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
// Atualiza a última página rastreada
lastPageTracked.current = location.pathname;
// Atualiza informações da sessão
sessionStorage.setItem('lastVisitedPage', location.pathname);
if (!localStorage.getItem('returningVisitor')) {
localStorage.setItem('returningVisitor', 'true');
}
if (!sessionStorage.getItem('sessionStartTime')) {
sessionStorage.setItem('sessionStartTime', new Date().toISOString());
}
}, 300);
// Cleanup
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [location.pathname, user]); // Reduzido dependências para apenas pathname e user
return null;
}
// Função auxiliar para determinar o tipo de dispositivo
function getDeviceType() {
const width = window.innerWidth;
if (width < 768) return 'mobile';
if (width < 1024) return 'tablet';
return 'desktop';
}
// Função auxiliar para obter o First Contentful Paint
function getFirstContentfulPaint() {
const perfEntries = performance.getEntriesByType('paint');
const fcpEntry = perfEntries.find(entry => entry.name === 'first-contentful-paint');
return fcpEntry ? fcpEntry.startTime : null;
}

View File

@ -0,0 +1,330 @@
# 📊 Sistema de Analytics
Este diretório contém a implementação do sistema de analytics do Leiturama, utilizando Rudderstack como principal ferramenta de tracking.
## 🚀 Inicialização
Para inicializar o sistema de analytics corretamente, certifique-se de:
1. Configurar as variáveis de ambiente:
```env
VITE_RUDDERSTACK_WRITE_KEY=seu_write_key
VITE_RUDDERSTACK_DATA_PLANE_URL=sua_url
```
2. Inicializar o analytics antes de usar:
```typescript
// main.tsx
import { analytics } from './lib/analytics';
// Inicializa o analytics antes de renderizar o app
await analytics.init();
// Renderiza o app
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
```
## 🔧 Troubleshooting
### Erros Comuns
1. **Falha na Inicialização**
```typescript
Failed to initialize analytics: Event {...}
```
Possíveis causas:
- Variáveis de ambiente não configuradas
- Script do Rudderstack bloqueado
- Erro na carga do script
Soluções:
- Verifique as variáveis de ambiente
- Verifique se o domínio do Rudderstack está liberado
- Adicione tratamento de erro na inicialização:
```typescript
try {
await analytics.init();
} catch (error) {
console.error('Falha ao inicializar analytics:', error);
// Continue renderizando o app mesmo com falha no analytics
}
```
2. **Eventos Não Rastreados**
Se os eventos não estão sendo rastreados, verifique:
- Se o analytics foi inicializado corretamente
- Se há erros no console
- Se o writeKey e dataPlaneUrl estão corretos
- Se há bloqueadores de rastreamento no navegador
3. **Erros de Tipo**
Se encontrar erros de tipo ao usar os hooks:
- Verifique se está usando as interfaces corretas
- Importe os tipos necessários
- Use as constantes de EVENT_CATEGORIES
## 📦 Componentes
### PageTracker
Componente responsável pelo tracking automático de visualizações de página.
```tsx
// App.tsx
<PageTracker />
```
### GoogleTagManager
Componente para integração com Google Tag Manager.
```tsx
// App.tsx
<GoogleTagManager gtmId="GTM-XXXXXX" />
```
## 🎯 Hooks Disponíveis
### useButtonTracking
Hook para rastreamento de interações com botões e elementos clicáveis.
```typescript
const { trackButtonClick } = useButtonTracking({
category?: string; // Categoria do evento (default: 'interaction')
location?: string; // Localização do botão (default: pathname atual)
});
// Uso:
trackButtonClick('button-id', {
label: 'Botão de Login',
variant: 'primary',
position: 'header',
section: 'auth'
});
```
### useFormTracking
Hook para rastreamento de interações com formulários.
```typescript
const {
trackFormStarted,
trackFormStepCompleted,
trackFormSubmitted,
trackFormError,
trackFormAbandoned,
trackFieldInteraction
} = useFormTracking({
formId: string; // ID único do formulário
formName: string; // Nome descritivo do formulário
category?: string; // Categoria (default: 'form')
});
// Exemplos de Uso:
// Início do formulário
trackFormStarted();
// Completou um passo
trackFormStepCompleted('dados-pessoais', true);
// Submeteu o formulário
trackFormSubmitted(true, {
user_type: 'student'
});
// Erro no formulário
trackFormError('validation', 'Email inválido', 'email');
// Abandonou o formulário
trackFormAbandoned('payment');
// Interação com campo
trackFieldInteraction('email', 'focus');
```
### useStudentTracking
Hook especializado para rastreamento de atividades do estudante.
```typescript
const {
trackStoryGenerated,
trackAudioRecorded,
trackExerciseCompleted,
trackInterestAdded,
trackInterestRemoved
} = useStudentTracking();
// Exemplo: Rastrear geração de história
trackStoryGenerated({
story_id: 'story-123',
theme: 'aventura',
prompt: 'Uma história sobre...',
generation_time: 2.5,
word_count: 300,
student_id: 'student-123'
});
// Exemplo: Rastrear exercício completado
trackExerciseCompleted({
exercise_id: 'ex-123',
story_id: 'story-123',
student_id: 'student-123',
exercise_type: 'pronunciation',
score: 85,
time_spent: 120
});
```
### useErrorTracking
Hook para rastreamento de erros e exceções.
```typescript
const {
trackError,
trackErrorBoundary,
trackApiError
} = useErrorTracking({
category?: string; // Categoria (default: 'error')
userId?: string; // ID do usuário
userEmail?: string; // Email do usuário
});
// Exemplo: Rastrear erro genérico
trackError(error, {
componentName: 'LoginForm',
action: 'submit',
metadata: { attempt: 2 }
});
// Exemplo: Rastrear erro de API
trackApiError(error, '/api/login', 'POST', {
email: 'user@example.com'
});
```
## 📝 Padrões de Nomenclatura
### Eventos
- Use snake_case para nomes de eventos
- Formato: `{objeto}_{ação}`
- Exemplos:
- `button_clicked`
- `form_submitted`
- `story_generated`
- `exercise_completed`
### Propriedades
- Use snake_case para nomes de propriedades
- Categorize propriedades por namespace:
```typescript
{
// Propriedades de página
page_url: string;
page_title: string;
// Propriedades de usuário
user_id: string;
user_type: string;
// Propriedades de elemento
element_type: string;
element_id: string;
// Propriedades de formulário
form_id: string;
form_name: string;
}
```
### Categorias
Categorias predefinidas disponíveis em `EVENT_CATEGORIES`:
- `page`: Eventos de visualização de página
- `user`: Eventos relacionados ao usuário
- `story`: Eventos de histórias
- `exercise`: Eventos de exercícios
- `interaction`: Eventos de interação do usuário
- `error`: Eventos de erro
- `subscription`: Eventos de assinatura
- `auth`: Eventos de autenticação
- `navigation`: Eventos de navegação
- `form`: Eventos de formulário
## 🔨 Exemplos de Implementação
### Botão com Tracking
```typescript
<Button
trackingId="signup-button"
variant="primary"
size="lg"
trackingProperties={{
category: EVENT_CATEGORIES.AUTH,
action: 'signup_click',
label: 'homepage_hero',
position: 'hero_section'
}}
onClick={handleSignup}
>
Cadastre-se Agora
</Button>
```
### Formulário com Tracking
```typescript
<Form
formId="signup-form"
formName="student-signup"
trackingProperties={{
category: EVENT_CATEGORIES.AUTH,
user_type: 'student',
source: 'homepage'
}}
onSubmit={handleSubmit}
>
{/* campos do formulário */}
</Form>
```
### Link com Tracking
```typescript
<Link
to="/dashboard"
trackingId="dashboard-link"
trackingProperties={{
category: EVENT_CATEGORIES.NAVIGATION,
section: 'sidebar',
position: 'top'
}}
>
Dashboard
</Link>
```
### Tracking de Erro em Try/Catch
```typescript
try {
await submitForm(data);
} catch (error) {
errorTracking.trackError(error, {
componentName: 'SignupForm',
action: 'submit',
metadata: {
formData: data,
attempt: retryCount
}
});
}
```

View File

@ -0,0 +1,78 @@
import React from 'react';
import { processAudio } from '../../services/audioService';
import { Button } from '../ui/button';
import { EVENT_CATEGORIES } from '../../constants/analytics';
interface AudioUploaderProps {
storyId: string;
onUploadComplete?: (transcription: string) => void;
onError?: (error: string) => void;
}
export function AudioUploader({
storyId,
onUploadComplete,
onError
}: AudioUploaderProps): JSX.Element {
const [isProcessing, setIsProcessing] = React.useState(false);
const [error, setError] = React.useState<string>();
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
setIsProcessing(true);
setError(undefined);
const response = await processAudio(file, storyId);
if (response.error) {
setError(response.error);
onError?.(response.error);
} else if (response.transcription) {
onUploadComplete?.(response.transcription);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Erro ao processar áudio';
setError(errorMessage);
onError?.(errorMessage);
} finally {
setIsProcessing(false);
}
};
return (
<div className="space-y-4">
<div>
<input
type="file"
accept="audio/*"
onChange={handleFileUpload}
disabled={isProcessing}
className="hidden"
id="audio-upload"
/>
<label htmlFor="audio-upload">
<Button
as="span"
disabled={isProcessing}
className="cursor-pointer"
trackingId="audio-upload-button"
trackingProperties={{
category: EVENT_CATEGORIES.AUDIO,
action: 'upload_click',
label: 'audio_uploader'
}}
>
{isProcessing ? 'Processando...' : 'Enviar Áudio'}
</Button>
</label>
</div>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
</div>
);
}

View File

@ -1,7 +1,13 @@
import React, { useState } from 'react';
import { LogIn } from 'lucide-react';
import React, { useState, useEffect } from 'react';
import { LogIn, Eye, EyeOff, School, GraduationCap, User } from 'lucide-react';
import { useAuth } from '../../hooks/useAuth';
import { useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase';
import { useDataLayer } from '../../hooks/useDataLayer';
import { useFormTracking } from '../../hooks/useFormTracking';
import { Button } from '../ui/button';
import { useErrorTracking } from '../../hooks/useErrorTracking';
import { EVENT_CATEGORIES } from '../../constants/analytics';
interface LoginFormProps {
userType: 'school' | 'teacher' | 'student';
@ -9,99 +15,257 @@ interface LoginFormProps {
onRegisterClick?: () => void;
}
const userTypeIcons = {
school: <School className="h-8 w-8 text-purple-600" />,
teacher: <GraduationCap className="h-8 w-8 text-purple-600" />,
student: <User className="h-8 w-8 text-purple-600" />
};
const userTypeLabels = {
school: 'Escola',
teacher: 'Professor',
student: 'Aluno'
};
export function LoginForm({ userType, onLogin, onRegisterClick }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { signIn } = useAuth();
const navigate = useNavigate();
const { trackEvent } = useDataLayer();
const formTracking = useFormTracking({
formId: 'login-form',
formName: `${userType}-login`,
category: 'auth'
});
const errorTracking = useErrorTracking({
category: 'auth',
userEmail: email
});
useEffect(() => {
formTracking.trackFormStarted();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const { user } = await signIn(email, password);
if (user) {
if (userType === 'school') {
navigate('/dashboard');
} else if (onLogin) {
await onLogin({ email, password });
}
console.log('Tentando login com:', { email, userType });
const { data, error } = await supabase.auth.signInWithPassword({
email: email,
password: password
});
console.log('Resposta do Supabase:', { data, error });
if (error) {
errorTracking.trackApiError(error, '/auth/sign-in', 'POST', { email, userType });
formTracking.trackFormError('auth_error', error.message);
throw error;
}
if (!data.user) {
const err = new Error('Usuário não encontrado');
errorTracking.trackError(err, {
componentName: 'LoginForm',
action: 'login_attempt',
metadata: { userType }
});
formTracking.trackFormError('user_not_found', 'Usuário não encontrado');
throw err;
}
const userRole = data.user.user_metadata.role;
console.log('Metadados do usuário:', data.user.user_metadata);
console.log('Role esperado:', userType);
console.log('Role atual:', userRole);
if (userRole !== userType) {
const err = new Error(`Este não é um login de ${userTypeLabels[userType]}`);
errorTracking.trackError(err, {
componentName: 'LoginForm',
action: 'role_validation',
metadata: {
expectedRole: userType,
actualRole: userRole
}
});
formTracking.trackFormError('invalid_role', err.message);
throw err;
}
formTracking.trackFormSubmitted(true, {
user_type: userType,
user_id: data.user.id
});
switch (userType) {
case 'school':
navigate('/dashboard');
break;
case 'teacher':
navigate('/professor');
break;
case 'student':
navigate('/aluno');
break;
default:
throw new Error('Tipo de usuário inválido');
}
trackEvent('auth', 'login_success', 'form');
} catch (err) {
setError('Erro ao fazer login. Verifique suas credenciais.');
console.error('Erro no login:', err);
const errorMessage = err instanceof Error ? err.message : 'Email ou senha incorretos';
setError(errorMessage);
formTracking.trackFormSubmitted(false, {
error_type: err instanceof Error ? 'validation_error' : 'unknown_error',
error_message: errorMessage
});
trackEvent('auth', 'login_error', errorMessage);
} finally {
setLoading(false);
}
};
const handleFieldChange = (field: string, value: string) => {
formTracking.trackFieldInteraction(field, 'change');
if (field === 'email') setEmail(value);
if (field === 'password') setPassword(value);
};
const handleFieldFocus = (field: string) => {
formTracking.trackFieldInteraction(field, 'focus');
};
const handleFieldBlur = (field: string) => {
formTracking.trackFieldInteraction(field, 'blur');
};
return (
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="text-3xl font-bold text-center text-purple-600">
Bem-vindo de volta!
</h2>
<p className="mt-2 text-center text-gray-600">
Continue sua jornada de histórias mágicas
</p>
</div>
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Senha
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
/>
<div className="min-h-screen bg-gradient-to-b from-purple-50 to-white py-12">
<div className="max-w-md mx-auto px-4">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-purple-100 mb-4">
{userTypeIcons[userType]}
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Bem-vindo de volta!
</h1>
<p className="text-gray-600">
Faça login como {userTypeLabels[userType]}
</p>
</div>
<button
type="submit"
className="w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent rounded-lg shadow-sm text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
>
<LogIn className="w-5 h-5" />
Entrar
</button>
{onRegisterClick && (
<div className="text-center">
<button
type="button"
onClick={onRegisterClick}
className="text-purple-600 hover:text-purple-500"
>
Criar uma nova conta
</button>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-600">
{error}
</div>
)}
</form>
<div className="bg-white rounded-2xl shadow-xl p-8">
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => handleFieldChange('email', e.target.value)}
onFocus={() => handleFieldFocus('email')}
onBlur={() => handleFieldBlur('email')}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Senha
</label>
<div className="relative mt-1">
<input
id="password"
type={showPassword ? 'text' : 'password'}
required
value={password}
onChange={(e) => handleFieldChange('password', e.target.value)}
onFocus={() => handleFieldFocus('password')}
onBlur={() => handleFieldBlur('password')}
className="block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500 pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
{showPassword ? (
<EyeOff className="h-5 w-5 text-gray-400" />
) : (
<Eye className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
</div>
<Button
type="submit"
disabled={loading}
trackingId="login-submit"
variant="primary"
size="lg"
trackingProperties={{
category: EVENT_CATEGORIES.AUTH,
action: 'login_attempt',
label: `${userType}_login`,
value: 1,
}}
className="w-full"
>
{loading ? (
'Entrando...'
) : (
<>
<LogIn className="h-5 w-5 mr-2" />
Entrar
</>
)}
</Button>
</form>
{onRegisterClick && (
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
Ainda não tem uma conta?{' '}
<Button
trackingId="register-link"
variant="link"
size="sm"
onClick={onRegisterClick}
trackingProperties={{
category: EVENT_CATEGORIES.AUTH,
action: 'register_click',
label: userType,
}}
className="text-purple-600 hover:text-purple-500 font-medium p-0"
>
Cadastre-se
</Button>
</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,52 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../hooks/useAuth';
import type { AuthContextType } from '../../hooks/useAuth';
import type { UserRole } from '../../types/supabase';
interface ProtectedRouteProps {
children: React.ReactNode;
allowedRoles?: UserRole[];
}
export function ProtectedRoute({ children, allowedRoles = [] }: ProtectedRouteProps) {
const { user, loading, userRole } = useAuth();
const location = useLocation();
if (loading) {
return <div>Carregando...</div>;
}
// Se não houver usuário, redireciona para login
if (!user) {
return <Navigate to="/login/school" state={{ from: location }} replace />;
}
// Pegar o role diretamente dos metadados do usuário
const currentRole = user.user_metadata?.role;
// Se não houver roles requeridas, permite acesso
if (allowedRoles.length === 0) {
return <>{children}</>;
}
// Se o usuário não tiver o role necessário
if (!allowedRoles.includes(currentRole)) {
console.log('ProtectedRoute - Acesso negado');
// Redireciona para a página apropriada
switch (currentRole) {
case 'school':
return <Navigate to="/dashboard" replace />;
case 'teacher':
return <Navigate to="/professor" replace />;
case 'student':
return <Navigate to="/aluno" replace />;
default:
return <Navigate to="/" replace />;
}
}
return <>{children}</>;
}

View File

@ -0,0 +1,263 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { School, Eye, EyeOff } from 'lucide-react';
import { useAuth } from '../../hooks/useAuth';
import { supabase } from '../../lib/supabase';
export function SchoolRegistrationForm() {
const navigate = useNavigate();
const { error: authError } = useAuth();
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [formData, setFormData] = useState({
schoolName: '',
email: '',
password: '',
confirmPassword: '',
directorName: '',
});
const [error, setError] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
if (formData.password !== formData.confirmPassword) {
setError('As senhas não coincidem');
setLoading(false);
return;
}
try {
// 1. Criar usuário na autenticação
const { data: authData, error: signUpError } = await supabase.auth.signUp({
email: formData.email,
password: formData.password,
options: {
data: {
role: 'school_admin',
school_name: formData.schoolName,
director_name: formData.directorName
},
emailRedirectTo: `${window.location.origin}/auth/callback`
}
});
if (signUpError) {
console.error('Erro no signup:', signUpError);
throw new Error(signUpError.message);
}
if (!authData.user?.id) {
throw new Error('Usuário não foi criado corretamente');
}
// 2. Criar registro da escola usando a função do servidor
const { error: schoolError } = await supabase.rpc('create_school', {
school_id: authData.user.id,
school_name: formData.schoolName,
school_email: formData.email,
director_name: formData.directorName
});
if (schoolError) {
console.error('Erro ao criar escola:', schoolError);
throw new Error(schoolError.message);
}
// 3. Redirecionar para login com mensagem de sucesso
navigate('/login/school', {
state: {
message: 'Cadastro realizado com sucesso! Por favor, verifique seu email para confirmar o cadastro.'
}
});
} catch (err) {
console.error('Erro detalhado:', err);
setError(
err instanceof Error
? err.message
: 'Erro ao cadastrar escola. Por favor, tente novamente.'
);
} finally {
setLoading(false);
}
};
const togglePasswordVisibility = (field: 'password' | 'confirmPassword') => {
if (field === 'password') {
setShowPassword(!showPassword);
} else {
setShowConfirmPassword(!showConfirmPassword);
}
};
return (
<div className="min-h-screen bg-gradient-to-b from-purple-50 to-white py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-2xl mx-auto bg-white rounded-xl shadow-md p-8">
<div className="text-center mb-8">
<div className="flex justify-center">
<School className="h-12 w-12 text-purple-600" />
</div>
<h2 className="mt-4 text-3xl font-bold text-gray-900">
Cadastro de Escola
</h2>
<p className="mt-2 text-gray-600">
Comece a transformar a educação com histórias interativas
</p>
</div>
{(error || authError) && (
<div className="mb-4 p-4 bg-red-50 text-red-600 rounded-lg text-sm">
{error || authError}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="schoolName" className="block text-sm font-medium text-gray-700">
Nome da Escola
</label>
<input
id="schoolName"
name="schoolName"
type="text"
required
value={formData.schoolName}
onChange={handleChange}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email Institucional
</label>
<input
id="email"
name="email"
type="email"
required
value={formData.email}
onChange={handleChange}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="relative">
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Senha
</label>
<div className="relative">
<input
id="password"
name="password"
type={showPassword ? "text" : "password"}
required
value={formData.password}
onChange={handleChange}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500 pr-10"
/>
<button
type="button"
onClick={() => togglePasswordVisibility('password')}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
</div>
<div className="relative">
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
Confirmar Senha
</label>
<div className="relative">
<input
id="confirmPassword"
name="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
required
value={formData.confirmPassword}
onChange={handleChange}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500 pr-10"
/>
<button
type="button"
onClick={() => togglePasswordVisibility('confirmPassword')}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
>
{showConfirmPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
</div>
</div>
<div>
<label htmlFor="directorName" className="block text-sm font-medium text-gray-700">
Nome do Diretor(a)
</label>
<input
id="directorName"
name="directorName"
type="text"
required
value={formData.directorName}
onChange={handleChange}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
/>
</div>
<div className="flex items-center">
<input
id="terms"
name="terms"
type="checkbox"
required
className="h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded"
/>
<label htmlFor="terms" className="ml-2 block text-sm text-gray-900">
Li e aceito os{' '}
<a href="#" className="text-purple-600 hover:text-purple-500">
termos de uso
</a>{' '}
e a{' '}
<a href="#" className="text-purple-600 hover:text-purple-500">
política de privacidade
</a>
</label>
</div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-lg font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50"
>
{loading ? 'Cadastrando...' : 'Cadastrar Escola'}
</button>
</form>
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
tem uma conta?{' '}
<button
onClick={() => navigate('/login/school')}
className="text-purple-600 hover:text-purple-500 font-medium"
>
Faça login
</button>
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,160 @@
import React from 'react';
import { BookOpen, Clock, TrendingUp, Award, Mic, Target, Brain, Gauge, Pause, XCircle, HelpCircle } from 'lucide-react';
import { MetricCard } from './MetricCard';
interface DashboardMetricsData {
totalStories: number;
averageReadingFluency: number;
totalReadingTime: number;
currentLevel: number;
averagePronunciation: number;
averageAccuracy: number;
averageComprehension: number;
averageWordsPerMinute: number;
averagePauses: number;
averageErrors: number;
}
interface DashboardMetricsProps {
data: DashboardMetricsData;
className?: string;
}
const MAIN_METRICS = [
{
key: 'totalStories',
title: 'Total de Histórias',
getValue: (data: DashboardMetricsData) => data.totalStories,
icon: BookOpen,
iconColor: 'text-purple-600',
iconBgColor: 'bg-purple-100'
},
{
key: 'averageReadingFluency',
title: 'Fluência Média',
getValue: (data: DashboardMetricsData) => `${data.averageReadingFluency}%`,
icon: TrendingUp,
iconColor: 'text-green-600',
iconBgColor: 'bg-green-100'
},
{
key: 'totalReadingTime',
title: 'Tempo de Leitura',
getValue: (data: DashboardMetricsData) => `${data.totalReadingTime}min`,
icon: Clock,
iconColor: 'text-blue-600',
iconBgColor: 'bg-blue-100'
},
{
key: 'currentLevel',
title: 'Nível Atual',
getValue: (data: DashboardMetricsData) => data.currentLevel,
icon: Award,
iconColor: 'text-yellow-600',
iconBgColor: 'bg-yellow-100'
}
];
const DETAILED_METRICS = [
{
key: 'averagePronunciation',
title: 'Pronúncia Média',
getValue: (data: DashboardMetricsData) => `${data.averagePronunciation}%`,
icon: Mic,
iconColor: 'text-indigo-600',
iconBgColor: 'bg-indigo-100',
tooltip: 'Avalia a qualidade da sua pronúncia durante a leitura, considerando a clareza e correção dos sons das palavras'
},
{
key: 'averageAccuracy',
title: 'Precisão na Leitura',
getValue: (data: DashboardMetricsData) => `${data.averageAccuracy}%`,
icon: Target,
iconColor: 'text-pink-600',
iconBgColor: 'bg-pink-100',
tooltip: 'Indica o quão preciso você é ao ler as palavras, sem trocas ou omissões de letras e sílabas'
},
{
key: 'averageComprehension',
title: 'Compreensão do Texto',
getValue: (data: DashboardMetricsData) => `${data.averageComprehension}%`,
icon: Brain,
iconColor: 'text-orange-600',
iconBgColor: 'bg-orange-100',
tooltip: 'Avalia seu nível de entendimento do texto durante a leitura, baseado no ritmo e entonação adequados'
},
{
key: 'averageWordsPerMinute',
title: 'Velocidade de Leitura',
getValue: (data: DashboardMetricsData) => `${data.averageWordsPerMinute} WPM`,
icon: Gauge,
iconColor: 'text-cyan-600',
iconBgColor: 'bg-cyan-100',
tooltip: 'Média de palavras lidas por minuto (WPM), indicando a velocidade e fluidez da sua leitura'
},
{
key: 'averagePauses',
title: 'Pausas na Leitura',
getValue: (data: DashboardMetricsData) => data.averagePauses,
icon: Pause,
iconColor: 'text-amber-600',
iconBgColor: 'bg-amber-100',
tooltip: 'Média de pausas não planejadas durante a leitura, indicando momentos de hesitação'
},
{
key: 'averageErrors',
title: 'Erros de Leitura',
getValue: (data: DashboardMetricsData) => data.averageErrors,
icon: XCircle,
iconColor: 'text-red-600',
iconBgColor: 'bg-red-100',
tooltip: 'Média de erros cometidos durante a leitura, como trocas, omissões ou adições de palavras'
}
];
export function DashboardMetrics({ data, className = '' }: DashboardMetricsProps) {
return (
<div className={className}>
{/* Métricas Principais */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{MAIN_METRICS.map(metric => (
<MetricCard
key={metric.key}
title={metric.title}
value={metric.getValue(data)}
icon={metric.icon}
iconColor={metric.iconColor}
iconBgColor={metric.iconBgColor}
/>
))}
</div>
{/* Métricas Detalhadas */}
<div className="mb-8">
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-semibold text-gray-900">Métricas Detalhadas de Leitura</h2>
<div
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
title="Estas métricas são calculadas com base em todas as suas gravações de leitura, fornecendo uma visão detalhada do seu progresso"
>
<HelpCircle className="h-4 w-4" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{DETAILED_METRICS.map(metric => (
<MetricCard
key={metric.key}
title={metric.title}
value={metric.getValue(data)}
icon={metric.icon}
iconColor={metric.iconColor}
iconBgColor={metric.iconBgColor}
tooltip={metric.tooltip}
/>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
import { HelpCircle } from 'lucide-react';
interface MetricCardProps {
title: string;
value: string | number;
icon: LucideIcon;
iconColor: string;
iconBgColor: string;
tooltip?: string;
}
export function MetricCard({
title,
value,
icon: Icon,
iconColor,
iconBgColor,
tooltip
}: MetricCardProps) {
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-4">
<div className={`p-3 ${iconBgColor} rounded-lg`}>
<Icon className={`h-6 w-6 ${iconColor}`} />
</div>
<div>
<div className="flex items-center gap-1">
<p className="text-sm text-gray-500">{title}</p>
{tooltip && (
<div
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
title={tooltip}
>
<HelpCircle className="h-4 w-4" />
</div>
)}
</div>
<p className="text-2xl font-bold text-gray-900">{value}</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,239 @@
import React from 'react';
import { Calendar, HelpCircle } from 'lucide-react';
import { Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Bar, ComposedChart } from 'recharts';
import type { WeeklyReadingMetrics } from '@/types/metrics';
interface MetricConfig {
key: string;
name: string;
color: string;
}
type TimeFilter = '3m' | '6m' | '12m' | 'all';
interface TimeFilterOption {
value: TimeFilter;
label: string;
months: number | null;
}
const METRICS_CONFIG: MetricConfig[] = [
{ key: 'fluency', name: 'Fluência', color: '#6366f1' },
{ key: 'pronunciation', name: 'Pronúncia', color: '#f43f5e' },
{ key: 'accuracy', name: 'Precisão', color: '#0ea5e9' },
{ key: 'comprehension', name: 'Compreensão', color: '#10b981' },
{ key: 'wordsPerMinute', name: 'Palavras/Min', color: '#8b5cf6' }
];
const TIME_FILTERS: TimeFilterOption[] = [
{ value: '3m', label: '3 meses', months: 3 },
{ value: '6m', label: '6 meses', months: 6 },
{ value: '12m', label: '12 meses', months: 12 },
{ value: 'all', label: 'Todo período', months: null },
];
interface MetricsChartProps {
data: WeeklyReadingMetrics[];
className?: string;
}
export function MetricsChart({ data = [], className = '' }: MetricsChartProps) {
const [visibleMetrics, setVisibleMetrics] = React.useState<Set<string>>(
new Set(METRICS_CONFIG.map(metric => metric.key))
);
const [timeFilter, setTimeFilter] = React.useState<TimeFilter>('12m');
const toggleMetric = (metricKey: string) => {
setVisibleMetrics(prev => {
const newSet = new Set(prev);
if (newSet.has(metricKey)) {
newSet.delete(metricKey);
} else {
newSet.add(metricKey);
}
return newSet;
});
};
const filterDataByTime = (data: WeeklyReadingMetrics[]): WeeklyReadingMetrics[] => {
if (!data || !Array.isArray(data)) return [];
if (timeFilter === 'all') return data;
const months = TIME_FILTERS.find(f => f.value === timeFilter)?.months || 12;
const cutoffDate = new Date();
cutoffDate.setMonth(cutoffDate.getMonth() - months);
return data.filter(item => {
if (!item?.week) return false;
const [year, week] = item.week.split('-W').map(Number);
if (!year || !week) return false;
const itemDate = new Date(year, 0, 1 + (week - 1) * 7);
return itemDate >= cutoffDate;
});
};
const filteredData = React.useMemo(() => filterDataByTime(data), [data, timeFilter]);
return (
<div className={`bg-white rounded-xl shadow-sm border border-gray-200 p-8 ${className}`}>
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h2 className="text-xl font-semibold text-gray-900">Evolução da Leitura por Semana</h2>
<p className="text-sm text-gray-500">Acompanhe seu progresso na leitura ao longo do tempo</p>
</div>
<div className="flex items-center gap-4">
{/* Filtro de Período */}
<div className="flex items-center gap-2 bg-gray-50 p-1 rounded-lg">
<Calendar className="h-4 w-4 text-gray-500" />
{TIME_FILTERS.map(filter => (
<button
key={filter.value}
onClick={() => setTimeFilter(filter.value)}
className={`
px-3 py-1 rounded-md text-sm font-medium transition-all duration-200
${timeFilter === filter.value
? 'bg-white text-purple-600 shadow-sm'
: 'text-gray-600 hover:bg-gray-100'
}
`}
>
{filter.label}
</button>
))}
</div>
<div
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
title="Gráfico mostrando a evolução das suas métricas de leitura ao longo das semanas"
>
<HelpCircle className="h-4 w-4" />
</div>
</div>
</div>
{/* Pill Buttons */}
<div className="flex flex-wrap gap-2 p-1">
{METRICS_CONFIG.map(metric => (
<button
key={metric.key}
onClick={() => toggleMetric(metric.key)}
className={`
px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 ease-in-out
${visibleMetrics.has(metric.key)
? 'shadow-md transform -translate-y-px'
: 'bg-gray-50 text-gray-500 hover:bg-gray-100'
}
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-opacity-50
`}
style={{
backgroundColor: visibleMetrics.has(metric.key) ? metric.color : undefined,
color: visibleMetrics.has(metric.key) ? 'white' : undefined,
boxShadow: visibleMetrics.has(metric.key) ? '0 2px 4px rgba(0,0,0,0.1)' : undefined
}}
>
<span className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${visibleMetrics.has(metric.key) ? 'bg-white' : 'bg-gray-400'}`}></span>
{metric.name}
</span>
</button>
))}
</div>
<div className="h-[400px] w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={filteredData} margin={{ top: 20, right: 30, left: 20, bottom: 20 }}>
<defs>
<linearGradient id="barGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#6366f1" stopOpacity={0.3} />
<stop offset="100%" stopColor="#6366f1" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke="#f0f0f0"
/>
<XAxis
dataKey="week"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#6b7280' }}
dy={10}
/>
<YAxis
yAxisId="left"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#6b7280' }}
dx={-10}
/>
<YAxis
yAxisId="right"
orientation="right"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#6b7280' }}
dx={10}
/>
<Tooltip
formatter={(value: number, name: string) => {
const metricNames: { [key: string]: string } = {
fluency: 'Fluência',
pronunciation: 'Pronúncia',
accuracy: 'Precisão',
comprehension: 'Compreensão',
wordsPerMinute: 'Palavras/Min',
minutesRead: 'Minutos Lendo'
};
return [value, metricNames[name] || name];
}}
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.98)',
border: 'none',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
padding: '12px'
}}
isAnimationActive={false}
/>
<Legend
verticalAlign="top"
align="right"
iconType="circle"
wrapperStyle={{
paddingBottom: '20px'
}}
/>
{METRICS_CONFIG.map(metric => (
visibleMetrics.has(metric.key) && (
<Line
key={metric.key}
yAxisId="left"
type="monotone"
dataKey={metric.key}
stroke={metric.color}
name={metric.name}
strokeWidth={2.5}
dot={{ strokeWidth: 2, r: 4, fill: 'white' }}
activeDot={{ r: 6, strokeWidth: 2 }}
isAnimationActive={false}
/>
)
))}
<Bar
yAxisId="right"
dataKey="minutesRead"
name="Minutos Lendo"
fill="url(#barGradient)"
radius={[4, 4, 0, 0]}
isAnimationActive={false}
maxBarSize={50}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,24 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
interface StatsCardProps {
icon: LucideIcon;
title: string;
value: number;
iconBgColor: string;
iconColor: string;
}
export function StatsCard({ icon: Icon, title, value, iconBgColor, iconColor }: StatsCardProps) {
return (
<div className="bg-white rounded-2xl p-6 flex items-center gap-4 border border-gray-200 shadow-sm">
<div className={`w-12 h-12 rounded-xl ${iconBgColor} flex items-center justify-center`}>
<Icon className={`h-6 w-6 ${iconColor}`} />
</div>
<div>
<p className="text-gray-600">{title}</p>
<p className="text-3xl font-bold">{value}</p>
</div>
</div>
);
}

View File

@ -0,0 +1,281 @@
import React from 'react';
import { Calendar, HelpCircle } from 'lucide-react';
import { Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Bar, ComposedChart } from 'recharts';
import type { WeeklyWritingMetrics } from '@/types/metrics';
interface MetricConfig {
key: string;
name: string;
color: string;
}
type TimeFilter = '3m' | '6m' | '12m' | 'all';
interface TimeFilterOption {
value: TimeFilter;
label: string;
months: number | null;
}
interface MetricNames {
[key: string]: string;
}
const WRITING_METRICS: MetricConfig[] = [
{ key: 'score', name: 'Nota Geral', color: '#6366f1' },
{ key: 'adequacy', name: 'Adequação', color: '#f43f5e' },
{ key: 'coherence', name: 'Coerência', color: '#0ea5e9' },
{ key: 'cohesion', name: 'Coesão', color: '#10b981' },
{ key: 'vocabulary', name: 'Vocabulário', color: '#8b5cf6' },
{ key: 'grammar', name: 'Gramática', color: '#f59e0b' }
];
const ENEM_METRICS: MetricConfig[] = [
{ key: 'language_domain', name: 'Domínio da Língua', color: '#f43f5e' },
{ key: 'proposal_comprehension', name: 'Compreensão da Proposta', color: '#0ea5e9' },
{ key: 'argument_selection', name: 'Seleção de Argumentos', color: '#10b981' },
{ key: 'linguistic_mechanisms', name: 'Mecanismos Linguísticos', color: '#8b5cf6' },
{ key: 'intervention_proposal', name: 'Proposta de Intervenção', color: '#f59e0b' }
];
const TIME_FILTERS: TimeFilterOption[] = [
{ value: '3m', label: '3 meses', months: 3 },
{ value: '6m', label: '6 meses', months: 6 },
{ value: '12m', label: '12 meses', months: 12 },
{ value: 'all', label: 'Todo período', months: null },
];
interface WritingMetricsChartProps {
data: WeeklyWritingMetrics[];
className?: string;
}
export function WritingMetricsChart({ data = [], className = '' }: WritingMetricsChartProps) {
const [visibleWritingMetrics, setVisibleWritingMetrics] = React.useState<Set<string>>(
new Set(WRITING_METRICS.map(metric => metric.key))
);
const [visibleEnemMetrics, setVisibleEnemMetrics] = React.useState<Set<string>>(
new Set(ENEM_METRICS.map(metric => metric.key))
);
const [timeFilter, setTimeFilter] = React.useState<TimeFilter>('12m');
const toggleWritingMetric = (metricKey: string) => {
setVisibleWritingMetrics(prev => {
const newSet = new Set(prev);
if (newSet.has(metricKey)) {
newSet.delete(metricKey);
} else {
newSet.add(metricKey);
}
return newSet;
});
};
const toggleEnemMetric = (metricKey: string) => {
setVisibleEnemMetrics(prev => {
const newSet = new Set(prev);
if (newSet.has(metricKey)) {
newSet.delete(metricKey);
} else {
newSet.add(metricKey);
}
return newSet;
});
};
const filterDataByTime = (data: WeeklyWritingMetrics[]): WeeklyWritingMetrics[] => {
if (!data || !Array.isArray(data)) return [];
if (timeFilter === 'all') return data;
const months = TIME_FILTERS.find(f => f.value === timeFilter)?.months || 12;
const cutoffDate = new Date();
cutoffDate.setMonth(cutoffDate.getMonth() - months);
return data.filter(item => {
if (!item?.week) return false;
const [year, week] = item.week.split('-W').map(Number);
if (!year || !week) return false;
const itemDate = new Date(year, 0, 1 + (week - 1) * 7);
return itemDate >= cutoffDate;
});
};
const filteredData = React.useMemo(() => filterDataByTime(data), [data, timeFilter]);
const renderChart = (title: string, description: string, metrics: MetricConfig[], visibleMetrics: Set<string>, toggleMetric: (key: string) => void) => (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 mb-8">
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h2 className="text-xl font-semibold text-gray-900">{title}</h2>
<p className="text-sm text-gray-500">{description}</p>
</div>
<div className="flex items-center gap-4">
{/* Filtro de Período */}
<div className="flex items-center gap-2 bg-gray-50 p-1 rounded-lg">
<Calendar className="h-4 w-4 text-gray-500" />
{TIME_FILTERS.map(filter => (
<button
key={filter.value}
onClick={() => setTimeFilter(filter.value)}
className={`
px-3 py-1 rounded-md text-sm font-medium transition-all duration-200
${timeFilter === filter.value
? 'bg-white text-purple-600 shadow-sm'
: 'text-gray-600 hover:bg-gray-100'
}
`}
>
{filter.label}
</button>
))}
</div>
<div
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
title={`Gráfico mostrando a evolução das suas ${title.toLowerCase()} ao longo das semanas`}
>
<HelpCircle className="h-4 w-4" />
</div>
</div>
</div>
{/* Pill Buttons */}
<div className="flex flex-wrap gap-2 p-1">
{metrics.map(metric => (
<button
key={metric.key}
onClick={() => toggleMetric(metric.key)}
className={`
px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 ease-in-out
${visibleMetrics.has(metric.key)
? 'shadow-md transform -translate-y-px'
: 'bg-gray-50 text-gray-500 hover:bg-gray-100'
}
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-opacity-50
`}
style={{
backgroundColor: visibleMetrics.has(metric.key) ? metric.color : undefined,
color: visibleMetrics.has(metric.key) ? 'white' : undefined,
boxShadow: visibleMetrics.has(metric.key) ? '0 2px 4px rgba(0,0,0,0.1)' : undefined
}}
>
<span className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${visibleMetrics.has(metric.key) ? 'bg-white' : 'bg-gray-400'}`}></span>
{metric.name}
</span>
</button>
))}
</div>
<div className="h-[400px] w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={filteredData} margin={{ top: 20, right: 30, left: 20, bottom: 20 }}>
<defs>
<linearGradient id="barGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#6366f1" stopOpacity={0.3} />
<stop offset="100%" stopColor="#6366f1" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke="#f0f0f0"
/>
<XAxis
dataKey="week"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#6b7280' }}
dy={10}
/>
<YAxis
yAxisId="left"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#6b7280' }}
dx={-10}
/>
<YAxis
yAxisId="right"
orientation="right"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#6b7280' }}
dx={10}
/>
<Tooltip
formatter={(value: number, name: string) => {
const metricNames: MetricNames = metrics.reduce((acc, m) => ({ ...acc, [m.key]: m.name }), {
minutesWriting: 'Minutos Escrevendo'
});
return [value, metricNames[name] || name];
}}
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.98)',
border: 'none',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
padding: '12px'
}}
isAnimationActive={false}
/>
<Legend
verticalAlign="top"
align="right"
iconType="circle"
wrapperStyle={{
paddingBottom: '20px'
}}
/>
{metrics.map(metric => (
visibleMetrics.has(metric.key) && (
<Line
key={metric.key}
yAxisId="left"
type="monotone"
dataKey={metric.key}
stroke={metric.color}
name={metric.name}
strokeWidth={2.5}
dot={{ strokeWidth: 2, r: 4, fill: 'white' }}
activeDot={{ r: 6, strokeWidth: 2 }}
isAnimationActive={false}
/>
)
))}
<Bar
yAxisId="right"
dataKey="minutesWriting"
name="Minutos Escrevendo"
fill="url(#barGradient)"
radius={[4, 4, 0, 0]}
isAnimationActive={false}
maxBarSize={50}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
return (
<div className={className}>
{renderChart(
"Evolução da Escrita por Semana",
"Acompanhe seu progresso na escrita ao longo do tempo",
WRITING_METRICS,
visibleWritingMetrics,
toggleWritingMetric
)}
{renderChart(
"Evolução das Competências do ENEM",
"Acompanhe seu progresso nas competências do ENEM ao longo do tempo",
ENEM_METRICS,
visibleEnemMetrics,
toggleEnemMetric
)}
</div>
);
}

View File

@ -0,0 +1,143 @@
import React from 'react';
import { BookOpen, Clock, TrendingUp, Award, Target, Brain, Gauge, Sparkles, Puzzle, Pencil, HelpCircle } from 'lucide-react';
import { MetricCard } from './MetricCard';
import type { WritingMetrics } from '@/types/metrics';
interface WritingMetricsSectionProps {
data: WritingMetrics;
className?: string;
}
const MAIN_METRICS = [
{
key: 'totalEssays',
title: 'Total de Redações',
getValue: (data: WritingMetrics) => data.totalEssays,
icon: BookOpen,
iconColor: 'text-purple-600',
iconBgColor: 'bg-purple-100'
},
{
key: 'averageScore',
title: 'Nota Média',
getValue: (data: WritingMetrics) => `${data.averageScore}%`,
icon: TrendingUp,
iconColor: 'text-green-600',
iconBgColor: 'bg-green-100'
},
{
key: 'totalEssaysTime',
title: 'Tempo de Escrita',
getValue: (data: WritingMetrics) => `${data.totalEssaysTime}min`,
icon: Clock,
iconColor: 'text-blue-600',
iconBgColor: 'bg-blue-100'
},
{
key: 'currentWritingLevel',
title: 'Nível de Escrita',
getValue: (data: WritingMetrics) => data.currentWritingLevel,
icon: Award,
iconColor: 'text-yellow-600',
iconBgColor: 'bg-yellow-100'
}
];
const DETAILED_METRICS = [
{
key: 'averageAdequacy',
title: 'Adequação ao Tema',
getValue: (data: WritingMetrics) => `${data.averageAdequacy}%`,
icon: Target,
iconColor: 'text-indigo-600',
iconBgColor: 'bg-indigo-100',
tooltip: 'Avalia o quanto sua redação está alinhada com o tema e gênero propostos'
},
{
key: 'averageCoherence',
title: 'Coerência',
getValue: (data: WritingMetrics) => `${data.averageCoherence}%`,
icon: Brain,
iconColor: 'text-pink-600',
iconBgColor: 'bg-pink-100',
tooltip: 'Indica a clareza e lógica no desenvolvimento das ideias do texto'
},
{
key: 'averageCohesion',
title: 'Coesão',
getValue: (data: WritingMetrics) => `${data.averageCohesion}%`,
icon: Puzzle,
iconColor: 'text-orange-600',
iconBgColor: 'bg-orange-100',
tooltip: 'Avalia o uso adequado de conectivos e elementos de ligação entre as partes do texto'
},
{
key: 'averageVocabulary',
title: 'Vocabulário',
getValue: (data: WritingMetrics) => `${data.averageVocabulary}%`,
icon: Sparkles,
iconColor: 'text-cyan-600',
iconBgColor: 'bg-cyan-100',
tooltip: 'Analisa a riqueza e adequação do vocabulário utilizado'
},
{
key: 'averageGrammar',
title: 'Gramática',
getValue: (data: WritingMetrics) => `${data.averageGrammar}%`,
icon: Pencil,
iconColor: 'text-amber-600',
iconBgColor: 'bg-amber-100',
tooltip: 'Avalia o uso correto das regras gramaticais e ortográficas'
}
];
export function WritingMetricsSection({ data, className = '' }: WritingMetricsSectionProps) {
return (
<div className={className}>
<div className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Métricas de Escrita</h2>
{/* Métricas Principais */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{MAIN_METRICS.map(metric => (
<MetricCard
key={metric.key}
title={metric.title}
value={metric.getValue(data)}
icon={metric.icon}
iconColor={metric.iconColor}
iconBgColor={metric.iconBgColor}
/>
))}
</div>
{/* Métricas Detalhadas */}
<div>
<div className="flex items-center gap-2 mb-4">
<h3 className="text-lg font-semibold text-gray-900">Métricas Detalhadas de Escrita</h3>
<div
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
title="Estas métricas são calculadas com base em todas as suas redações, fornecendo uma visão detalhada do seu progresso na escrita"
>
<HelpCircle className="h-4 w-4" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{DETAILED_METRICS.map(metric => (
<MetricCard
key={metric.key}
title={metric.title}
value={metric.getValue(data)}
icon={metric.icon}
iconColor={metric.iconColor}
iconBgColor={metric.iconBgColor}
tooltip={metric.tooltip}
/>
))}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,154 @@
import React, { useState, useRef } from 'react';
import { Mic, Square, Loader, Play, RotateCcw } from 'lucide-react';
interface AudioRecorderDemoProps {
onAnalysisComplete: (result: {
fluency: number;
accuracy: number;
confidence: number;
feedback: string;
}) => void;
}
export function AudioRecorderDemo({ onAnalysisComplete }: AudioRecorderDemoProps) {
const [isRecording, setIsRecording] = useState(false);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [error, setError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorderRef.current = new MediaRecorder(stream);
chunksRef.current = [];
mediaRecorderRef.current.ondataavailable = (e) => {
chunksRef.current.push(e.data);
};
mediaRecorderRef.current.onstop = () => {
const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
setAudioBlob(audioBlob);
};
mediaRecorderRef.current.start();
setIsRecording(true);
setError(null);
} catch (err) {
setError('Erro ao acessar microfone. Verifique as permissões.');
console.error('Erro ao iniciar gravação:', err);
}
};
const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
}
};
const analyzeAudio = async () => {
if (!audioBlob) return;
setIsAnalyzing(true);
setError(null);
try {
// Simulação de análise para demo
await new Promise(resolve => setTimeout(resolve, 2000));
// Resultados simulados para demonstração
onAnalysisComplete({
fluency: Math.floor(Math.random() * 20) + 80, // 80-100
accuracy: Math.floor(Math.random() * 15) + 85, // 85-100
confidence: Math.floor(Math.random() * 25) + 75, // 75-100
feedback: "Excelente leitura! Sua fluência está muito boa e você demonstra confiança na pronúncia. Continue praticando para melhorar ainda mais."
});
} catch (err) {
setError('Erro ao analisar áudio. Tente novamente.');
console.error('Erro na análise:', err);
} finally {
setIsAnalyzing(false);
}
};
const resetRecording = () => {
setAudioBlob(null);
setError(null);
};
return (
<div className="p-6 bg-gray-50 rounded-xl">
<div className="flex flex-wrap items-center gap-4 justify-center">
{!isRecording && !audioBlob && (
<button
onClick={startRecording}
className="flex items-center gap-2 px-6 py-3 bg-red-600 text-white rounded-xl hover:bg-red-700 transition"
>
<Mic className="w-5 h-5" />
Iniciar Gravação
</button>
)}
{isRecording && (
<button
onClick={stopRecording}
className="flex items-center gap-2 px-6 py-3 bg-gray-600 text-white rounded-xl hover:bg-gray-700 transition"
>
<Square className="w-5 h-5" />
Parar Gravação
</button>
)}
{audioBlob && !isAnalyzing && (
<>
<button
onClick={() => {
const url = URL.createObjectURL(audioBlob);
const audio = new Audio(url);
audio.play();
}}
className="flex items-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition"
>
<Play className="w-5 h-5" />
Ouvir
</button>
<button
onClick={analyzeAudio}
className="flex items-center gap-2 px-6 py-3 bg-green-600 text-white rounded-xl hover:bg-green-700 transition"
>
Analisar Leitura
</button>
<button
onClick={resetRecording}
className="flex items-center gap-2 px-6 py-3 bg-gray-200 text-gray-600 rounded-xl hover:bg-gray-300 transition"
>
<RotateCcw className="w-5 h-5" />
Recomeçar
</button>
</>
)}
{isAnalyzing && (
<div className="flex items-center gap-2 text-gray-600">
<Loader className="w-5 h-5 animate-spin" />
Analisando sua leitura...
</div>
)}
</div>
{error && (
<div className="mt-4 text-red-600 text-sm text-center">
{error}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,219 @@
import React, { useState, useRef } from 'react';
import { supabase } from '../../lib/supabase';
import { Mic, Square, Play, Loader2, ArrowRight } from 'lucide-react';
import { useStudentTracking } from '../../hooks/useStudentTracking';
interface PronunciationPracticeProps {
words: string[];
storyId: string;
studentId: string;
onComplete?: (score: number) => void;
}
export function PronunciationPractice({ words, storyId, studentId, onComplete }: PronunciationPracticeProps) {
const [currentWordIndex, setCurrentWordIndex] = useState(0);
const [isRecording, setIsRecording] = useState(false);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [score, setScore] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [completed, setCompleted] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const { trackExerciseCompleted } = useStudentTracking();
const startTime = useRef(Date.now());
const wordsAttempted = useRef(0);
const wordsCorrect = useRef(0);
// Verificar se há palavras para praticar
if (!words.length) {
return (
<div className="max-w-2xl mx-auto p-6">
<p className="text-gray-600">
Não palavras para praticar neste momento.
</p>
</div>
);
}
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorderRef.current = new MediaRecorder(stream);
chunksRef.current = [];
mediaRecorderRef.current.ondataavailable = (e) => {
chunksRef.current.push(e.data);
};
mediaRecorderRef.current.onstop = () => {
const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
setAudioBlob(audioBlob);
};
mediaRecorderRef.current.start();
setIsRecording(true);
} catch (err) {
console.error('Erro ao iniciar gravação:', err);
}
};
const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
}
};
const playAudio = () => {
if (audioBlob) {
const url = URL.createObjectURL(audioBlob);
const audio = new Audio(url);
audio.play();
}
};
const handleNextWord = async () => {
setIsSubmitting(true);
try {
// Simular análise de pronúncia (você pode substituir por uma análise real)
const wordScore = Math.floor(Math.random() * 30) + 70; // Score entre 70-100
const newScore = score + wordScore;
setScore(newScore);
wordsAttempted.current += 1;
if (wordScore >= 80) {
wordsCorrect.current += 1;
}
if (currentWordIndex < words.length - 1) {
setCurrentWordIndex(prev => prev + 1);
setAudioBlob(null);
} else {
setCompleted(true);
const timeSpent = Date.now() - startTime.current;
// Track exercise completion
trackExerciseCompleted({
exercise_id: `${storyId}_pronunciation`,
story_id: storyId,
student_id: studentId,
exercise_type: 'pronunciation',
score: Math.floor(newScore / words.length),
time_spent: timeSpent,
words_attempted: wordsAttempted.current,
words_correct: wordsCorrect.current,
pronunciation_score: Math.floor(newScore / words.length),
fluency_score: 85, // Este valor poderia vir de uma análise real de fluência
difficulty_level: words.length <= 5 ? 'easy' : words.length <= 10 ? 'medium' : 'hard'
});
if (onComplete) {
onComplete(Math.floor(newScore / words.length));
}
}
} catch (error) {
console.error('Erro ao processar áudio:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="max-w-2xl mx-auto p-6">
{/* Cabeçalho */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-gray-900">
Treino de Pronúncia
</h2>
<div className="mt-2 flex justify-between items-center text-sm text-gray-500">
<span>Palavra {currentWordIndex + 1} de {words.length}</span>
<span>{score} pontos</span>
</div>
<div className="mt-2 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-purple-600 transition-all"
style={{ width: `${((currentWordIndex + 1) / words.length) * 100}%` }}
/>
</div>
</div>
{completed ? (
<div className="text-center">
<h3 className="text-xl font-semibold text-gray-900 mb-4">
Parabéns! Você completou o exercício!
</h3>
<p className="text-gray-600">
Pontuação final: {score} pontos
</p>
</div>
) : (
<>
{/* Palavra atual */}
<div className="mb-8 text-center">
<h3 className="text-4xl font-bold text-gray-900">
{words[currentWordIndex]}
</h3>
</div>
{/* Controles de gravação */}
<div className="flex justify-center gap-4 mb-8">
{!isRecording && !audioBlob && (
<button
onClick={startRecording}
className="flex items-center gap-2 px-6 py-3 bg-red-600 text-white rounded-lg
hover:bg-red-700 transition-colors"
>
<Mic className="w-5 h-5" />
Gravar
</button>
)}
{isRecording && (
<button
onClick={stopRecording}
className="flex items-center gap-2 px-6 py-3 bg-gray-600 text-white rounded-lg
hover:bg-gray-700 transition-colors"
>
<Square className="w-5 h-5" />
Parar
</button>
)}
{audioBlob && (
<button
onClick={playAudio}
className="flex items-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-lg
hover:bg-purple-700 transition-colors"
>
<Play className="w-5 h-5" />
Ouvir
</button>
)}
</div>
{/* Botão de próxima palavra */}
{audioBlob && (
<button
onClick={handleNextWord}
disabled={isSubmitting}
className="w-full flex items-center justify-center gap-2 py-3 bg-purple-600 text-white
rounded-lg font-medium hover:bg-purple-700 disabled:bg-gray-300
disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
{currentWordIndex < words.length - 1 ? 'Próxima Palavra' : 'Finalizar'}
<ArrowRight className="w-5 h-5" />
</>
)}
</button>
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,203 @@
import React, { useState, useRef } from 'react';
import { supabase } from '../../lib/supabase';
import { Loader2 } from 'lucide-react';
import { useStudentTracking } from '../../hooks/useStudentTracking';
interface SentenceCompletionProps {
story: {
id: string;
content: {
pages: Array<{
text: string;
}>;
};
};
studentId: string;
onComplete?: (score: number) => void;
}
export function SentenceCompletion({ story, studentId, onComplete }: SentenceCompletionProps) {
const [currentSentence, setCurrentSentence] = useState(0);
const [userAnswer, setUserAnswer] = useState('');
const [score, setScore] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [exerciseSentences, setExerciseSentences] = useState<Array<{
sentence: string;
answer: string;
}>>([]);
const [isLoading, setIsLoading] = useState(true);
const { trackExerciseCompleted } = useStudentTracking();
const startTime = useRef(Date.now());
const correctAnswers = useRef(0);
// Carregar palavras e preparar sentenças
React.useEffect(() => {
const loadExerciseWords = async () => {
try {
const { data: words, error } = await supabase
.from('story_exercise_words')
.select('*')
.eq('story_id', story.id)
.eq('exercise_type', 'completion');
if (error) throw error;
// Extrair todas as sentenças do texto
const allSentences = story.content.pages
.map(page => page.text.split(/[.!?]+/))
.flat()
.filter(Boolean)
.map(sentence => sentence.trim());
// Preparar exercícios com as palavras do banco
const exercises = allSentences
.filter(sentence =>
words?.some(word =>
sentence.toLowerCase().includes(word.word.toLowerCase())
)
)
.map(sentence => {
const word = words?.find(w =>
sentence.toLowerCase().includes(w.word.toLowerCase())
);
return {
sentence: sentence.replace(
new RegExp(word?.word || '', 'i'),
'_____'
),
answer: word?.word || ''
};
})
.filter(exercise => exercise.answer); // Remover exercícios sem resposta
setExerciseSentences(exercises);
setIsLoading(false);
} catch (error) {
console.error('Erro ao carregar palavras:', error);
setIsLoading(false);
}
};
loadExerciseWords();
}, [story.id, story.content.pages]);
if (isLoading) {
return (
<div className="max-w-2xl mx-auto p-6">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/3 mb-6" />
<div className="h-64 bg-gray-200 rounded" />
</div>
</div>
);
}
if (!exerciseSentences.length) {
return (
<div className="max-w-2xl mx-auto p-6">
<p className="text-gray-600">
Não foi possível gerar exercícios para este texto.
</p>
</div>
);
}
const currentExercise = exerciseSentences[currentSentence];
const handleSubmit = async () => {
setIsSubmitting(true);
try {
const isCorrect = userAnswer.toLowerCase() === currentExercise.answer.toLowerCase();
if (isCorrect) {
setScore(prev => prev + 10);
correctAnswers.current += 1;
}
// Avançar para próxima sentença ou finalizar
if (currentSentence < exerciseSentences.length - 1) {
setCurrentSentence(prev => prev + 1);
setUserAnswer('');
} else {
// Track exercise completion
const timeSpent = Date.now() - startTime.current;
trackExerciseCompleted({
exercise_id: `${story.id}_completion`,
story_id: story.id,
student_id: studentId,
exercise_type: 'completion',
score: Math.floor((correctAnswers.current / exerciseSentences.length) * 100),
time_spent: timeSpent,
answers_correct: correctAnswers.current,
answers_total: exerciseSentences.length,
difficulty_level: exerciseSentences.length <= 5 ? 'easy' : exerciseSentences.length <= 10 ? 'medium' : 'hard'
});
if (onComplete) {
onComplete(Math.floor((correctAnswers.current / exerciseSentences.length) * 100));
}
}
} catch (error) {
console.error('Erro ao verificar resposta:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="max-w-2xl mx-auto p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Complete a Frase
</h2>
{/* Progresso */}
<div className="mb-6">
<div className="flex justify-between text-sm text-gray-500">
<span>Questão {currentSentence + 1} de {exerciseSentences.length}</span>
<span>{score} pontos</span>
</div>
<div className="mt-2 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-purple-600 transition-all"
style={{ width: `${((currentSentence + 1) / exerciseSentences.length) * 100}%` }}
/>
</div>
</div>
{/* Sentença atual */}
<div className="mb-8">
<p className="text-xl leading-relaxed text-gray-700">
{currentExercise.sentence}
</p>
</div>
{/* Campo de resposta */}
<div className="mb-8">
<input
type="text"
value={userAnswer}
onChange={(e) => setUserAnswer(e.target.value)}
placeholder="Digite a palavra que completa a frase..."
className="w-full px-4 py-3 text-lg border border-gray-300 rounded-lg
focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
{/* Botão de verificação */}
<button
onClick={handleSubmit}
disabled={!userAnswer || isSubmitting}
className="w-full py-3 bg-purple-600 text-white rounded-lg font-medium
hover:bg-purple-700 disabled:bg-gray-300 disabled:cursor-not-allowed
transition-colors"
>
{isSubmitting ? (
<Loader2 className="w-5 h-5 animate-spin mx-auto" />
) : (
'Verificar'
)}
</button>
</div>
);
}

View File

@ -0,0 +1,294 @@
import React, { useState, useEffect, useRef } from 'react';
import { supabase } from '../../lib/supabase';
import { useStudentTracking } from '../../hooks/useStudentTracking';
interface WordFormationProps {
words: string[];
storyId: string;
studentId: string;
onComplete?: (score: number) => void;
}
interface SyllableWord {
word: string;
syllables: string[];
}
export function WordFormation({ words, storyId, studentId, onComplete }: WordFormationProps) {
const [availableSyllables, setAvailableSyllables] = useState<string[]>([]);
const [userWord, setUserWord] = useState<string>('');
const [score, setScore] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [targetWords, setTargetWords] = useState<SyllableWord[]>([]);
const [completedWords, setCompletedWords] = useState<string[]>([]);
const [showFeedback, setShowFeedback] = useState<{
type: 'success' | 'error';
message: string;
} | null>(null);
const { trackExerciseCompleted } = useStudentTracking();
const startTime = useRef(Date.now());
const correctAnswers = useRef(0);
useEffect(() => {
const loadWords = async () => {
try {
const { data: exerciseWords, error } = await supabase
.from('story_exercise_words')
.select('*')
.eq('story_id', storyId)
.eq('exercise_type', 'formation');
if (error) throw error;
// Dividir palavras em sílabas (simplificado)
const wordList = exerciseWords?.map(w => ({
word: w.word,
syllables: dividePalavraEmSilabas(w.word)
})) || [];
// Coletar todas as sílabas únicas
const allSyllables = wordList.flatMap(w => w.syllables);
const uniqueSyllables = [...new Set(allSyllables)];
setTargetWords(wordList);
setAvailableSyllables(uniqueSyllables);
setIsLoading(false);
} catch (error) {
console.error('Erro ao carregar palavras:', error);
setIsLoading(false);
}
};
loadWords();
}, [storyId]);
const dividePalavraEmSilabas = (palavra: string): string[] => {
// Implementação simplificada - você pode usar uma biblioteca mais robusta
// ou implementar regras mais complexas de divisão silábica
const silabas: string[] = [];
let silaba = '';
for (let i = 0; i < palavra.length; i++) {
const letra = palavra[i];
silaba += letra;
// Regras básicas de divisão silábica
if (i < palavra.length - 1) {
const proximaLetra = palavra[i + 1];
// Se a próxima letra for uma vogal e a atual não
if (isVogal(proximaLetra) && !isVogal(letra)) {
silabas.push(silaba);
silaba = '';
}
// Se a atual for uma vogal e a próxima uma consoante
else if (isVogal(letra) && !isVogal(proximaLetra)) {
silabas.push(silaba);
silaba = '';
}
}
}
if (silaba) {
silabas.push(silaba);
}
return silabas;
};
const isVogal = (letra: string): boolean => {
return /[aeiouáéíóúâêîôûãõàèìòùäëïöü]/i.test(letra);
};
const handleSyllableClick = (syllable: string) => {
setUserWord(prev => prev + syllable);
};
const handleVerify = () => {
const matchedWord = targetWords.find(
w => w.word.toLowerCase() === userWord.toLowerCase()
);
if (matchedWord && !completedWords.includes(matchedWord.word)) {
setScore(prev => prev + 10);
correctAnswers.current += 1;
setCompletedWords(prev => [...prev, matchedWord.word]);
setShowFeedback({
type: 'success',
message: 'Parabéns! Palavra correta!'
});
// Se completou todas as palavras
if (completedWords.length + 1 === targetWords.length) {
const timeSpent = Date.now() - startTime.current;
trackExerciseCompleted({
exercise_id: `${storyId}_word_formation`,
story_id: storyId,
student_id: studentId,
exercise_type: 'word_formation',
score: score + 10,
time_spent: timeSpent,
words_formed: correctAnswers.current,
words_correct: correctAnswers.current,
difficulty_level: targetWords.length <= 5 ? 'easy' : targetWords.length <= 10 ? 'medium' : 'hard'
});
if (onComplete) {
onComplete(score + 10);
}
}
} else if (completedWords.includes(matchedWord?.word || '')) {
setShowFeedback({
type: 'error',
message: 'Você já encontrou esta palavra!'
});
} else {
setShowFeedback({
type: 'error',
message: 'Tente novamente!'
});
}
setTimeout(() => {
setShowFeedback(null);
}, 2000);
setUserWord('');
};
if (isLoading) {
return (
<div className="max-w-2xl mx-auto p-6">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/3 mb-6" />
<div className="h-64 bg-gray-200 rounded" />
</div>
</div>
);
}
if (!availableSyllables.length) {
return (
<div className="max-w-2xl mx-auto p-6">
<p className="text-gray-600">
Não palavras para formar neste momento.
</p>
</div>
);
}
return (
<div className="max-w-2xl mx-auto p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Formação de Palavras
</h2>
{/* Barra de Progresso */}
<div className="mb-8">
<div className="flex justify-between text-sm text-gray-500 mb-2">
<span>
Palavras encontradas: {completedWords.length} de {targetWords.length}
</span>
<span>{score} pontos</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-purple-600 transition-all duration-500"
style={{
width: `${(completedWords.length / targetWords.length) * 100}%`
}}
/>
</div>
</div>
{/* Feedback Visual */}
{showFeedback && (
<div className={`mb-4 p-4 rounded-lg text-center font-medium
${showFeedback.type === 'success'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{showFeedback.message}
</div>
)}
{/* Sílabas Disponíveis */}
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-3">
Sílabas Disponíveis:
</h3>
<div className="flex flex-wrap gap-2">
{availableSyllables.map((syllable, index) => (
<button
key={index}
onClick={() => handleSyllableClick(syllable)}
className="px-4 py-2 bg-purple-100 rounded-lg
hover:bg-purple-200 active:bg-purple-300
transition-all transform hover:scale-105
text-purple-900 font-medium shadow-sm"
>
{syllable}
</button>
))}
</div>
</div>
{/* Palavra do Usuário */}
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-3">
Sua Palavra:
</h3>
<div className="p-4 bg-gray-50 rounded-lg min-h-[60px] text-xl
font-medium text-gray-900 flex items-center justify-center
border-2 border-dashed border-gray-300">
{userWord || (
<span className="text-gray-400">
Clique nas sílabas para formar uma palavra
</span>
)}
</div>
</div>
{/* Palavras Encontradas */}
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-3">
Palavras Encontradas:
</h3>
<div className="flex flex-wrap gap-2">
{completedWords.map((word, index) => (
<span
key={index}
className="px-3 py-1 bg-green-100 text-green-800
rounded-full font-medium"
>
{word}
</span>
))}
</div>
</div>
{/* Botões de Ação */}
<div className="flex gap-4">
<button
onClick={() => setUserWord('')}
className="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg
hover:bg-gray-300 active:bg-gray-400 transition-colors flex-1
font-medium shadow-sm"
>
Limpar
</button>
<button
onClick={handleVerify}
disabled={!userWord}
className="px-6 py-3 bg-purple-600 text-white rounded-lg
hover:bg-purple-700 active:bg-purple-800 transition-colors flex-1
disabled:bg-gray-300 disabled:cursor-not-allowed
font-medium shadow-sm"
>
Verificar Palavra
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,42 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../../hooks/useAuth';
import { ProfileMenu } from './ProfileMenu';
export function Header() {
const { user } = useAuth();
return (
<header className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<Link to="/" className="flex items-center gap-2">
<img src="/logo.svg" alt="Logo" className="h-8 w-8" />
<span className="font-semibold text-gray-900">Leiturama</span>
</Link>
<div className="flex items-center gap-4">
{user ? (
<ProfileMenu />
) : (
<>
<Link
to="/login/school"
className="text-gray-600 hover:text-gray-900"
>
Entrar
</Link>
<Link
to="/register/school"
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
Cadastrar Escola
</Link>
</>
)}
</div>
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,100 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { LogOut, Settings, LayoutDashboard } from 'lucide-react';
import { useAuth } from '../../hooks/useAuth';
import type { AuthContextType } from '../../hooks/useAuth';
import type { UserRole } from '../../types/supabase';
export function ProfileMenu() {
const { user, userRole, signOut } = useAuth();
const navigate = useNavigate();
const [isOpen, setIsOpen] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
// Fecha o menu quando clicar fora dele
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const getDashboardPath = () => {
switch (userRole) {
case 'school':
return '/dashboard';
case 'teacher':
return '/professor';
case 'student':
return '/aluno';
default:
return '/';
}
};
const getProfilePath = () => {
switch (userRole) {
case 'school':
return '/dashboard/configuracoes';
case 'teacher':
return '/professor/perfil';
case 'student':
return '/aluno/perfil';
default:
return '/';
}
};
return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center hover:bg-purple-200 transition"
>
<span className="text-lg font-medium text-purple-600">
{user?.user_metadata?.name?.[0]?.toUpperCase() || user?.email?.[0]?.toUpperCase()}
</span>
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg py-1 border border-gray-200">
<button
onClick={() => {
navigate(getDashboardPath());
setIsOpen(false);
}}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2"
>
<LayoutDashboard className="h-4 w-4" />
Acessar Dashboard
</button>
<button
onClick={() => {
navigate(getProfilePath());
setIsOpen(false);
}}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2"
>
<Settings className="h-4 w-4" />
Meu Perfil
</button>
<hr className="my-1" />
<button
onClick={signOut}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-gray-100 flex items-center gap-2"
>
<LogOut className="h-4 w-4" />
Sair
</button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,476 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
BookOpen, ArrowRight, School, Users, Shield,
Sparkles, BookCheck, Play, CheckCircle, Star,
GraduationCap, BarChart, Brain, X, Check,
Pencil, Wand, Mic, Share2
} from 'lucide-react';
import { Footer } from '@/components/ui/footer';
import { PlanForSchools } from '@/components/ui/plan-for-schools';
import { FAQ } from '@/components/ui/faq';
import { StatCard } from '@/components/ui/stat-card';
import { TestimonialCard } from '@/components/ui/testimonial-card';
import { FeatureCard } from '@/components/ui/feature-card';
import { ProcessStep } from '@/components/ui/process-step';
import { InfoCard } from '@/components/ui/info-card';
import { ComparisonSection } from '@/components/ui/comparison-section';
import { Button } from '@/components/ui/button';
import { EVENT_CATEGORIES } from '../../constants/analytics';
const navigation = [
{ name: 'Início', href: '/' },
{ name: 'Para Pais', href: '/para-pais' },
{ name: 'Evidências', href: '/evidencias' },
{ name: 'Para Educadores', href: '/para-educadores' },
];
export function HomePage() {
const navigate = useNavigate();
const [showUserOptions, setShowUserOptions] = useState(false);
const [activeTab, setActiveTab] = useState('schools');
const [showFaq, setShowFaq] = useState<number | null>(null);
// Navigation handlers
const handleLoginClick = () => setShowUserOptions(!showUserOptions);
const handleSchoolLogin = () => navigate('/login/school');
const handleTeacherLogin = () => navigate('/login/teacher');
const handleStudentLogin = () => navigate('/login/student');
const handleSchoolRegister = () => navigate('/register');
const handleDemo = () => navigate('/demo');
return (
<div className="min-h-screen bg-gradient-to-b from-purple-50 to-white">
{/* Header/Nav */}
<nav className="bg-white/80 backdrop-blur-md fixed w-full z-50 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
<BookOpen className="h-8 w-8 text-purple-600" />
<span className="ml-2 text-xl font-bold text-gray-900">Leiturama</span>
</div>
<div className="flex items-center gap-4">
<div className="relative">
<Button
onClick={handleLoginClick}
variant="ghost"
trackingId="nav_login_button"
trackingProperties={{
category: EVENT_CATEGORIES.NAVIGATION,
action: 'click',
label: 'login_dropdown'
}}
>
Entrar
</Button>
{showUserOptions && (
<div className="absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5">
<div className="py-1" role="menu">
<Button
onClick={handleSchoolLogin}
variant="ghost"
className="w-full text-left"
trackingId="nav_school_login"
trackingProperties={{
category: EVENT_CATEGORIES.NAVIGATION,
action: 'click',
label: 'school_login'
}}
>
Entrar como Escola
</Button>
<Button
onClick={handleTeacherLogin}
variant="ghost"
className="w-full text-left"
trackingId="nav_teacher_login"
trackingProperties={{
category: EVENT_CATEGORIES.NAVIGATION,
action: 'click',
label: 'teacher_login'
}}
>
Entrar como Professor
</Button>
<Button
onClick={handleStudentLogin}
variant="ghost"
className="w-full text-left"
trackingId="nav_student_login"
trackingProperties={{
category: EVENT_CATEGORIES.NAVIGATION,
action: 'click',
label: 'student_login'
}}
>
Entrar como Aluno
</Button>
</div>
</div>
)}
</div>
{/*
<Button
onClick={handleSchoolRegister}
variant="primary"
trackingId="nav_register_button"
trackingProperties={{
category: EVENT_CATEGORIES.NAVIGATION,
action: 'click',
label: 'register_school'
}}
>
Cadastrar Escola
</Button>
*/}
</div>
</div>
</div>
</nav>
{/* Hero Section */}
<div className="pt-32 pb-24 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<div className="grid lg:grid-cols-2 gap-12 items-center">
<div>
<div className="inline-block px-4 py-1 rounded-full bg-purple-100 text-purple-600 font-medium text-sm mb-6">
Potencializado por IA 🚀
</div>
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-gray-900 mb-6">
Transforme a educação com
<span className="text-purple-600"> histórias inteligentes</span>
</h1>
<p className="text-xl text-gray-600 mb-8">
Uma plataforma educacional que usa IA para criar experiências de
aprendizado personalizadas através de histórias interativas.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Button
onClick={() => window.location.href = 'https://typebot-public.inventivos.co/leiturama-leads'}
variant="primary"
size="lg"
className="gap-2"
trackingId="hero_register_button"
trackingProperties={{
category: EVENT_CATEGORIES.HERO,
action: 'click',
label: 'contact_us',
position: 'hero_section'
}}
>
Entre em contato
<ArrowRight className="w-5 h-5" />
</Button>
<Button
onClick={handleDemo}
variant="outline"
size="lg"
className="gap-2"
trackingId="hero_demo_button"
trackingProperties={{
category: EVENT_CATEGORIES.HERO,
action: 'click',
label: 'watch_demo',
position: 'hero_section'
}}
>
<Play className="w-5 h-5" />
Ver Demo
</Button>
</div>
<div className="mt-8 flex items-center gap-4">
<div className="flex -space-x-2">
{[1,2,3,4].map((i) => (
<img
key={i}
src={`/avatars/${i}.jpg`}
alt=""
className="w-8 h-8 rounded-full border-2 border-white"
/>
))}
</div>
<div className="text-sm text-gray-600">
<span className="text-purple-600 font-semibold">+1000 escolas</span>
transformaram sua educação
</div>
</div>
</div>
<div className="relative">
<div className="aspect-video rounded-xl overflow-hidden shadow-2xl">
<img
src="/demo-platform.png"
alt="Platform demo"
className="w-full h-full object-cover"
/>
<Button
onClick={handleDemo}
variant="ghost"
className="absolute inset-0 flex items-center justify-center bg-black/30 hover:bg-black/40 transition group"
trackingId="hero_video_play"
trackingProperties={{
category: EVENT_CATEGORIES.HERO,
action: 'click',
label: 'play_demo_video',
position: 'hero_video'
}}
>
<div className="w-16 h-16 rounded-full bg-white/90 flex items-center justify-center">
<Play className="w-8 h-8 text-purple-600 group-hover:scale-110 transition" />
</div>
</Button>
</div>
</div>
</div>
</div>
</div>
{/* Student Journey Section */}
<div className="py-24 bg-gradient-to-b from-purple-50 to-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold text-gray-900 mb-4">
Jornada do Aluno
</h2>
<p className="text-gray-600 max-w-2xl mx-auto">
Um processo inteligente e envolvente de aprendizado
</p>
</div>
<div className="space-y-12">
<ProcessStep
number={1}
title="Escolha o tema da aventura"
description="Selecione entre diversos temas educativos alinhados com a BNCC e adequados à idade."
/>
<ProcessStep
number={2}
title="Personalize os personagens"
description="Crie personagens que seu filho vai adorar, com características únicas e cativantes."
/>
<ProcessStep
number={3}
title="A IA cria a história mágica"
description="Nossa IA educacional gera uma história personalizada em segundos."
/>
<ProcessStep
number={4}
title="A aventura educativa começa"
description="Seu filho mergulha em uma jornada mágica de aprendizado e diversão."
/>
</div>
</div>
</div>
{/* Results Summary */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="mt-24 bg-white rounded-xl shadow-lg p-8">
<div className="text-center mb-8">
<h3 className="text-2xl font-bold text-gray-900 mb-2">
Resultados Comprovados
</h3>
<p className="text-gray-600">
Nossa abordagem inovadora tem transformado a experiência de leitura
</p>
</div>
<div className="grid md:grid-cols-4 gap-8">
<FeatureCard
icon={Star}
title="Fluência de Leitura"
description="Nossa tecnologia avalia a leitura dos alunos e gera relatórios para melhorar a comunicação com confiança."
/>
<FeatureCard
icon={Sparkles}
title="Mais tempo"
description="Queremos economizar o tempo dos professores, permitindo mais foco nos alunos e melhorando os resultados."
/>
<FeatureCard
icon={CheckCircle}
title="Alunos engajados"
description="Histórias personalizadas com IA e gamificação tornam a aprendizagem mais divertida."
/>
<FeatureCard
icon={BookOpen}
title="Bilíngue"
description="Melhore leitura e compreensão em português, inglês e espanhol em uma única plataforma."
/>
</div>
</div>
{/* Features Grid */}
<div className="mt-24">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-gray-900 mb-4">
Tecnologia e Educação em Harmonia
</h2>
<p className="text-gray-600 max-w-2xl mx-auto">
Uma plataforma completa que une o poder da IA com as melhores práticas pedagógicas
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<FeatureCard
icon={Brain}
title="IA Adaptativa"
description="Conteúdo que se adapta ao ritmo e estilo de aprendizagem de cada aluno"
/>
<FeatureCard
icon={BookOpen}
title="Histórias Interativas"
description="Narrativas envolventes que tornam o aprendizado mais divertido e eficaz"
/>
<FeatureCard
icon={BarChart}
title="Analytics Avançado"
description="Insights detalhados sobre o progresso e engajamento dos alunos"
/>
<FeatureCard
icon={Users}
title="Colaboração"
description="Ferramentas para professores trabalharem juntos e compartilharem recursos"
/>
<FeatureCard
icon={Shield}
title="Ambiente Seguro"
description="Proteção de dados e conteúdo adequado para todas as idades"
/>
<FeatureCard
icon={GraduationCap}
title="Suporte Pedagógico"
description="Recursos e orientações para maximizar o potencial de aprendizagem"
/>
</div>
</div>
{/* Before & After Section */}
<div className="mt-24">
<ComparisonSection
title="Compare a Transformação"
items={[
{
title: "Personalização",
without: [
"Conteúdo padronizado que não atende necessidades individuais",
"Material didático tradicional e pouco envolvente",
"Mesma abordagem para todos os alunos"
],
with: [
"Histórias adaptativas que evoluem com cada aluno",
"Conteúdo personalizado e envolvente",
"Experiência única para cada estudante"
]
},
{
title: "Engajamento",
without: [
"Alunos desmotivados com atividades repetitivas",
"Baixo interesse nas atividades de leitura",
"Dificuldade em manter a atenção dos alunos"
],
with: [
"Estudantes engajados e participativos",
"Aumento de 300% no engajamento com leitura",
"Alunos ansiosos pela próxima atividade"
]
},
{
title: "Acompanhamento",
without: [
"Professores sobrecarregados com correções manuais",
"Dificuldade em acompanhar o progresso individual",
"Falta de dados para decisões pedagógicas"
],
with: [
"Correção automática com feedback instantâneo",
"Dashboard em tempo real do progresso individual",
"Insights precisos para intervenções pedagógicas"
]
}
]}
/>
</div>
{/* Testimonials */}
<div className="mt-24 grid md:grid-cols-2 gap-8">
<TestimonialCard
quote="A transformação que vimos em nossa escola foi incrível. Alunos que mal conseguiam juntar letras agora estão lendo com fluência e, mais importante, com prazer."
author="Maria Silva"
role="Diretora Pedagógica"
/>
<TestimonialCard
quote="Como professora há 15 anos, nunca vi um método tão eficaz e envolvente. A plataforma me ajuda a personalizar o ensino para cada aluno."
author="Ana Paula Santos"
role="Professora"
/>
</div>
{/* Pricing Section
<div className="mt-24">
<PlanForSchools />
</div>
*/}
{/* FAQ Section */}
<div className="mt-24">
<FAQ
title="Dúvidas Frequentes"
description="Tire suas dúvidas sobre a implementação do Leiturama em sua escola"
items={[
{
question: "Como o Leiturama se integra ao currículo escolar?",
answer: "Nossa plataforma foi desenvolvida para complementar e enriquecer o currículo existente. Oferecemos conteúdo alinhado à BNCC e ferramentas de personalização que permitem adaptar as atividades aos objetivos pedagógicos específicos de cada escola."
},
{
question: "Quanto tempo leva para implementar a plataforma?",
answer: "O processo de implementação é personalizado e gradual, levando em média 2-3 semanas. Iniciamos com uma fase piloto, oferecemos treinamento completo para a equipe e fornecemos suporte contínuo durante todo o processo."
},
{
question: "Como posso acompanhar o progresso dos alunos?",
answer: "Disponibilizamos um dashboard intuitivo com métricas em tempo real, relatórios detalhados e insights sobre o desempenho individual e coletivo. Professores e coordenadores podem monitorar o progresso, identificar áreas de melhoria e personalizar intervenções."
},
{
question: "Quais são os requisitos técnicos?",
answer: "A plataforma é acessível via navegador web em qualquer dispositivo (computadores, tablets, smartphones). Recomendamos uma conexão estável à internet e o uso de fones de ouvido para melhor experiência nas atividades de áudio."
},
{
question: "Como vocês protegem os dados dos alunos?",
answer: "Seguimos rigorosos protocolos de segurança em conformidade com a LGPD. Todos os dados são criptografados, o acesso é controlado e realizamos auditorias regulares de segurança."
},
{
question: "Que tipo de suporte vocês oferecem?",
answer: "Oferecemos suporte técnico e pedagógico através de múltiplos canais (chat, email, telefone), além de atualizações regulares da plataforma e workshops para capacitação contínua da equipe escolar."
}
]}
/>
</div>
{/* Final CTA */}
<div className="mt-24 pb-24">
<div className="bg-purple-600 rounded-3xl px-8 py-16 text-center">
<h2 className="text-4xl font-bold text-white mb-4">
Pronto para Transformar sua Escola?
</h2>
<p className="text-white/90 mb-8 max-w-2xl mx-auto text-lg">
Junte-se a mais de 1000 escolas que estão revolucionando a educação
</p>
<button
onClick={() => window.location.href = 'https://typebot-public.inventivos.co/leiturama-leads'}
className="bg-white text-purple-600 px-8 py-3 rounded-lg font-semibold hover:bg-purple-50 transition"
>
Entre em contato
</button>
</div>
</div>
</div>
{/* Footer */}
<Footer />
</div>
);
}
export default HomePage;

View File

@ -0,0 +1,10 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
export function RootLayout(): JSX.Element {
return (
<div className="min-h-screen">
<Outlet />
</div>
);
}

View File

@ -0,0 +1,11 @@
import { Outlet } from 'react-router-dom';
import { PageTracker } from '../analytics/PageTracker';
export function BaseLayout() {
return (
<>
<PageTracker />
<Outlet />
</>
);
}

View File

@ -0,0 +1,47 @@
import React from 'react';
import { DashboardSidebar } from './DashboardSidebar';
interface DashboardLayoutProps {
children: React.ReactNode;
}
export function DashboardLayout({ children }: DashboardLayoutProps): JSX.Element {
const [sidebarOpen, setSidebarOpen] = React.useState(false);
return (
<>
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
type="button"
className="inline-flex items-center p-2 mt-2 ms-3 text-sm text-gray-500 rounded-lg sm:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
>
<span className="sr-only">Abrir menu</span>
<svg className="w-6 h-6" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
<path clipRule="evenodd" fillRule="evenodd" d="M2 4.75A.75.75 0 012.75 4h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 4.75zm0 10.5a.75.75 0 01.75-.75h7.5a.75.75 0 010 1.5h-7.5a.75.75 0 01-.75-.75zM2 10a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 10z" />
</svg>
</button>
<aside
className={`fixed top-0 left-0 z-40 w-64 h-screen transition-transform ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
} sm:translate-x-0`}
>
<div className="h-full px-3 py-4 overflow-y-auto bg-white border-r border-gray-200 dark:bg-gray-800 dark:border-gray-700">
<div className="flex items-center pb-4 mb-4 border-b border-gray-200 dark:border-gray-700">
<img src="/logo.svg" className="h-8 me-3" alt="Logo" />
<span className="self-center text-xl font-semibold whitespace-nowrap dark:text-white">
Leiturama
</span>
</div>
<DashboardSidebar />
</div>
</aside>
<div className="p-4 sm:ml-64">
<div className="p-4 border-2 border-gray-200 border-dashed rounded-lg dark:border-gray-700">
{children}
</div>
</div>
</>
);
}

View File

@ -0,0 +1,39 @@
import React from 'react';
import { DashboardSidebar } from './DashboardSidebar';
interface DashboardPageLayoutProps {
children: React.ReactNode;
title: string;
description?: string;
}
export function DashboardPageLayout({
children,
title,
description
}: DashboardPageLayoutProps): JSX.Element {
return (
<div className="min-h-screen bg-gray-50 flex">
<DashboardSidebar />
<main className="flex-1 min-h-screen transition-all duration-300">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="mb-6 sm:mb-8">
<h1 className="text-2xl sm:text-3xl font-bold text-primary mb-2">
{title}
</h1>
{description && (
<p className="text-sm sm:text-base text-gray-600">
{description}
</p>
)}
</div>
<div className="space-y-6">
{children}
</div>
</div>
</main>
</div>
);
}

View File

@ -0,0 +1,37 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { LayoutDashboard, Users, GraduationCap, UserCircle, BookOpen, Settings, LogOut } from 'lucide-react';
const links = [
{ icon: <LayoutDashboard className="w-5 h-5" />, label: 'Visão Geral', href: '/dashboard' },
{ icon: <Users className="w-5 h-5" />, label: 'Turmas', href: '/dashboard/turmas' },
{ icon: <GraduationCap className="w-5 h-5" />, label: 'Professores', href: '/dashboard/professores' },
{ icon: <UserCircle className="w-5 h-5" />, label: 'Alunos', href: '/dashboard/alunos' },
{ icon: <BookOpen className="w-5 h-5" />, label: 'Histórias', href: '/dashboard/historias' },
{ icon: <Settings className="w-5 h-5" />, label: 'Configurações', href: '/dashboard/configuracoes' },
{ icon: <LogOut className="w-5 h-5" />, label: 'Sair', href: '/logout' },
];
export function DashboardSidebar(): JSX.Element {
return (
<ul className="space-y-2 font-medium">
{links.map((link) => (
<li key={link.href}>
<NavLink
to={link.href}
className={({ isActive }) => `
flex items-center p-2 text-gray-900 rounded-lg dark:text-white
hover:bg-gray-100 dark:hover:bg-gray-700 group
${isActive ? 'bg-gray-100 dark:bg-gray-700' : ''}
`}
>
<div className="flex-shrink-0 w-5 h-5 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white">
{link.icon}
</div>
<span className="flex-1 ms-3 whitespace-nowrap">{link.label}</span>
</NavLink>
</li>
))}
</ul>
);
}

View File

@ -0,0 +1,128 @@
import React, { useEffect, useState } from 'react';
import { BookOpen, Puzzle, Mic } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase';
interface ExerciseSuggestionsProps {
storyId: string;
storyText: string;
readingMetrics: {
difficultWords: string[];
errorCount: number;
pauseCount: number;
fluencyScore: number;
};
}
interface ExerciseWord {
word: string;
exercise_type: string;
phonemes: string[] | null;
syllable_pattern: string | null;
}
export function ExerciseSuggestions({ storyId, storyText, readingMetrics }: ExerciseSuggestionsProps) {
const navigate = useNavigate();
const [exerciseWords, setExerciseWords] = useState<ExerciseWord[]>([]);
useEffect(() => {
const loadExerciseWords = async () => {
const { data, error } = await supabase
.from('story_exercise_words')
.select('*')
.eq('story_id', storyId)
.order('created_at', { ascending: true });
if (!error && data) {
setExerciseWords(data);
}
};
loadExerciseWords();
}, [storyId]);
const handleExerciseSelect = (exerciseType: string) => {
if (!storyId) {
console.error('ID da história não fornecido');
return;
}
navigate(`/aluno/historias/${storyId}/exercicios/${exerciseType}`);
};
const generateExercises = () => {
const exercises = [
{
type: 'word-formation',
title: 'Formação de Palavras',
description: 'Monte novas palavras usando sílabas da história',
icon: <Puzzle className="w-6 h-6" />,
words: exerciseWords
.filter(w => w.exercise_type === 'formation')
.map(w => w.word),
},
{
type: 'sentence-completion',
title: 'Complete a História',
description: 'Complete as frases com as palavras corretas',
icon: <BookOpen className="w-6 h-6" />,
words: exerciseWords
.filter(w => w.exercise_type === 'completion')
.map(w => w.word),
},
{
type: 'pronunciation-practice',
title: 'Treino de Pronúncia',
description: 'Pratique a pronúncia das palavras difíceis',
icon: <Mic className="w-6 h-6" />,
words: exerciseWords
.filter(w => w.exercise_type === 'pronunciation')
.map(w => w.word),
}
];
return exercises;
};
return (
<div className="mt-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4 ">
Exercícios Sugeridos
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{generateExercises().map((exercise) => (
<button
key={exercise.type}
onClick={() => handleExerciseSelect(exercise.type)}
className="p-4 bg-white rounded-lg border border-gray-200 hover:border-purple-500 transition-colors"
>
<div className="flex items-center gap-3 mb-2">
<div className="text-purple-600">
{exercise.icon}
</div>
<h4 className="font-semibold text-gray-900">
{exercise.title}
</h4>
</div>
<p className="text-sm text-gray-600 mb-3">
{exercise.description}
</p>
<div className="text-xs text-gray-500">
{exercise.type === 'word-formation' && (
<div>
Palavras para praticar:
<div className="mt-1 flex flex-wrap gap-1">
{exercise.words.map(word => (
<span key={word} className="bg-purple-100 px-2 py-1 rounded">
{word}
</span>
))}
</div>
</div>
)}
</div>
</button>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,83 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { WordHighlighter } from './WordHighlighter'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import '@testing-library/jest-dom/vitest'
describe('WordHighlighter', () => {
const mockText = "O gato pulou o muro."
const mockHighlightedWords = ['gato', 'pulou']
const mockDifficultWords = ['muro']
const mockOnWordClick = vi.fn()
beforeEach(() => {
mockOnWordClick.mockClear()
})
it('deve renderizar todas as palavras do texto', () => {
render(
<WordHighlighter
text={mockText}
highlightedWords={mockHighlightedWords}
difficultWords={mockDifficultWords}
onWordClick={mockOnWordClick}
/>
)
// Verifica se cada palavra está presente
const words = mockText.split(/(\s+)/).filter(word => word.trim().length > 0)
words.forEach(word => {
expect(screen.getByText(word)).toBeInTheDocument()
})
})
it('deve destacar as palavras corretas', () => {
render(
<WordHighlighter
text={mockText}
highlightedWords={mockHighlightedWords}
difficultWords={mockDifficultWords}
onWordClick={mockOnWordClick}
/>
)
// Verifica palavras destacadas
const highlightedElements = screen.getAllByText(/gato|pulou/)
highlightedElements.forEach(element => {
expect(element).toHaveClass('bg-yellow-200')
})
// Verifica palavras difíceis
const difficultElements = screen.getAllByText('muro')
difficultElements.forEach(element => {
expect(element).toHaveClass('bg-red-100')
})
})
it('deve chamar onWordClick com a palavra correta', () => {
render(
<WordHighlighter
text={mockText}
highlightedWords={mockHighlightedWords}
difficultWords={mockDifficultWords}
onWordClick={mockOnWordClick}
/>
)
// Clica em uma palavra
fireEvent.click(screen.getByText('gato'))
expect(mockOnWordClick).toHaveBeenCalledWith('gato')
})
it('deve lidar com pontuação corretamente', () => {
render(
<WordHighlighter
text="Olá, mundo!"
highlightedWords={['mundo']}
difficultWords={[]}
onWordClick={mockOnWordClick}
/>
)
expect(screen.getByText('mundo!')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,129 @@
import React, { useState, useEffect } from 'react';
import { ChevronUp, ChevronDown, Play, Pause } from 'lucide-react';
interface WordHighlighterProps {
text: string; // Texto completo
highlightedWords: string[]; // Palavras para destacar (ex: palavras difíceis)
difficultWords: string[]; // Palavras que o aluno teve dificuldade
onWordClick: (word: string) => void; // Função para quando clicar na palavra
highlightSpeed?: number; // palavras por minuto
initialFontSize?: number;
}
export function WordHighlighter({
text,
highlightedWords,
difficultWords,
onWordClick,
highlightSpeed = 60,
initialFontSize = 18
}: WordHighlighterProps) {
const [isPlaying, setIsPlaying] = useState(false);
const [currentWordIndex, setCurrentWordIndex] = useState(0);
const [fontSize, setFontSize] = useState(initialFontSize);
// Divide o texto em palavras mantendo a pontuação
const words = text.split(/(\s+)/).filter(word => word.trim().length > 0);
useEffect(() => {
if (!isPlaying) return;
const intervalTime = (60 / highlightSpeed) * 1000;
const interval = setInterval(() => {
setCurrentWordIndex((prevIndex) => {
if (prevIndex >= words.length - 1) {
setIsPlaying(false);
return prevIndex;
}
return prevIndex + 1;
});
}, intervalTime);
return () => clearInterval(interval);
}, [isPlaying, highlightSpeed, words.length]);
const handleFontSizeChange = (delta: number) => {
setFontSize(prev => Math.min(Math.max(12, prev + delta), 32));
};
const togglePlayPause = () => {
if (!isPlaying) {
setCurrentWordIndex(0);
}
setIsPlaying(!isPlaying);
};
return (
<div className="space-y-4">
{/* Controles */}
<div className="flex items-center gap-4 mb-4">
<div className="flex items-center gap-2">
<button
onClick={() => handleFontSizeChange(-2)}
className="p-2 rounded-lg hover:bg-gray-100"
aria-label="Diminuir fonte"
>
<ChevronDown className="h-5 w-5" />
</button>
<span className="text-sm font-medium">{fontSize}px</span>
<button
onClick={() => handleFontSizeChange(2)}
className="p-2 rounded-lg hover:bg-gray-100"
aria-label="Aumentar fonte"
>
<ChevronUp className="h-5 w-5" />
</button>
</div>
<button
onClick={togglePlayPause}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-100 text-purple-700 hover:bg-purple-200"
>
{isPlaying ? (
<>
<Pause className="h-4 w-4" />
Pausar Leitura
</>
) : (
<>
<Play className="h-4 w-4" />
Iniciar Leitura
</>
)}
</button>
</div>
{/* Texto */}
<div
className="leading-relaxed space-y-4"
style={{ fontSize: `${fontSize}px` }}
>
{words.map((word, i) => {
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/, '');
const isHighlighted = highlightedWords.includes(cleanWord);
const isDifficult = difficultWords.includes(cleanWord);
const isCurrentWord = i === currentWordIndex && isPlaying;
return (
<span
key={i}
onClick={() => onWordClick(word)}
className={`
inline-block mx-1 px-1 rounded cursor-pointer transition-all
hover:scale-110
${isHighlighted ? 'bg-yellow-200 hover:bg-yellow-300' : ''}
${isDifficult ? 'bg-red-100 hover:bg-red-200' : ''}
${isCurrentWord ? 'bg-purple-200 scale-110' : ''}
hover:bg-gray-100
`}
title="Clique para ver mais informações"
>
{word}
</span>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,70 @@
import { useRef, useState } from 'react';
import { Button } from "@/components/ui/button";
import { Volume2, Loader2 } from "lucide-react";
import { supabase } from '@/lib/supabase';
interface AudioPlayerProps {
word: string;
disabled?: boolean;
}
export function AudioPlayer({ word, disabled }: AudioPlayerProps) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const playAudio = async () => {
try {
setIsLoading(true);
setError(null);
// Buscar ou gerar o áudio da palavra
const { data, error } = await supabase.functions.invoke('generate-word-audio', {
body: { word }
});
if (error) throw error;
if (data?.audioUrl) {
if (!audioRef.current) {
audioRef.current = new Audio(data.audioUrl);
} else {
audioRef.current.src = data.audioUrl;
}
await audioRef.current.play();
}
} catch (err) {
console.error('Erro ao reproduzir áudio:', err);
setError('Erro ao reproduzir áudio');
} finally {
setIsLoading(false);
}
};
return (
<div>
<Button
variant="ghost"
size="lg"
className="gap-2"
onClick={playAudio}
disabled={disabled || isLoading}
trackingId="audio-player-toggle"
>
{isLoading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Volume2 className="w-5 h-5" />
)}
Ouvir Palavra
</Button>
{error && (
<p className="text-sm text-red-500 mt-1">
{error}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,43 @@
import { usePhonicsCategories } from "@/hooks/phonics/usePhonicsExercises";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton";
interface CategoryTabsProps {
selectedCategory?: string;
onSelectCategory: (categoryId: string) => void;
}
export function CategoryTabs({ selectedCategory, onSelectCategory }: CategoryTabsProps) {
const { data: categories, isLoading } = usePhonicsCategories();
if (isLoading) {
return <Skeleton className="h-10 w-full max-w-[600px]" />;
}
if (!categories?.length) {
return null;
}
return (
<Tabs
value={selectedCategory || "all"}
onValueChange={(value) => onSelectCategory(value === "all" ? "" : value)}
className="w-full"
>
<TabsList className="w-full max-w-[600px] h-auto flex-wrap">
<TabsTrigger value="all" className="flex-1">
Todos
</TabsTrigger>
{categories.map((category) => (
<TabsTrigger
key={category.id}
value={category.id}
className="flex-1"
>
{category.name}
</TabsTrigger>
))}
</TabsList>
</Tabs>
);
}

View File

@ -0,0 +1,72 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Clock, Star, Timer } from "lucide-react";
import { cn } from "@/lib/utils";
import type { PhonicsExercise, StudentPhonicsProgress } from "@/types/phonics";
interface ExerciseCardProps {
exercise: PhonicsExercise;
progress?: StudentPhonicsProgress;
onStart: (exerciseId: string) => void;
}
export function ExerciseCard({ exercise, progress, onStart }: ExerciseCardProps) {
const isCompleted = progress?.completed;
const stars = progress?.stars || 0;
const progressValue = progress ? (progress.best_score * 100) : 0;
return (
<Card className="w-full hover:shadow-lg transition-shadow">
<CardHeader>
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-lg font-bold">{exercise.title}</CardTitle>
<CardDescription>{exercise.description}</CardDescription>
</div>
<Badge variant={isCompleted ? "success" : "secondary"}>
{isCompleted ? "Completo" : "Pendente"}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>{Math.ceil((exercise.estimated_time_seconds ?? 0) / 60)} min</span>
</div>
<div className="flex items-center gap-1">
{Array.from({ length: 3 }).map((_, i) => (
<Star
key={i}
className={`w-4 h-4 ${i < stars ? "fill-yellow-400 text-yellow-400" : "text-gray-300"}`}
/>
))}
</div>
</div>
{progress && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>Progresso</span>
<span>{Math.round(progressValue)}%</span>
</div>
<Progress value={progressValue} className="h-2" />
</div>
)}
<Button
className="mt-4"
onClick={() => onStart(exercise.id)}
variant={isCompleted ? "secondary" : "default"}
trackingId="exercise-card-start"
>
{isCompleted ? "Praticar Novamente" : "Começar"}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,48 @@
import { usePhonicsExercises } from "@/hooks/phonics/usePhonicsExercises";
import { usePhonicsProgress } from "@/hooks/phonics/usePhonicsProgress";
import { ExerciseCard } from "./ExerciseCard";
import { Skeleton } from "@/components/ui/skeleton";
interface ExerciseGridProps {
categoryId?: string;
studentId: string;
onSelectExercise: (exerciseId: string) => void;
}
export function ExerciseGrid({ categoryId, studentId, onSelectExercise }: ExerciseGridProps) {
const { data: exercises, isLoading: isLoadingExercises } = usePhonicsExercises(categoryId);
const { data: progress, isLoading: isLoadingProgress } = usePhonicsProgress(studentId);
const isLoading = isLoadingExercises || isLoadingProgress;
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-[250px] w-full" />
))}
</div>
);
}
if (!exercises?.length) {
return (
<div className="text-center py-8 text-muted-foreground">
Nenhum exercício encontrado nesta categoria.
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{exercises.map((exercise) => (
<ExerciseCard
key={exercise.id}
exercise={exercise}
progress={progress?.find((p) => p.exercise_id === exercise.id)}
onStart={onSelectExercise}
/>
))}
</div>
);
}

View File

@ -0,0 +1,182 @@
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { useUpdatePhonicsProgress } from "@/hooks/phonics/usePhonicsProgress";
import { ExerciseFactory } from "./exercises/ExerciseFactory";
import { Timer } from "lucide-react";
import type { PhonicsExercise, UpdateProgressParams } from "@/types/phonics";
import { cn } from "@/lib/utils";
import { AdaptiveText } from '../ui/adaptive-text';
import { useUppercasePreference } from '../../hooks/useUppercasePreference';
interface ExercisePlayerProps {
exercise: PhonicsExercise;
student_id: string;
onComplete: (result: {
score: number;
stars: number;
xp_earned: number;
completed: boolean;
}) => void;
onExit: () => void;
}
export function ExercisePlayer({
exercise,
student_id,
onComplete,
onExit
}: ExercisePlayerProps) {
const [currentStep, setCurrentStep] = useState(0);
const [score, setScore] = useState(0);
const [timeSpent, setTimeSpent] = useState(0);
const [showFeedback, setShowFeedback] = useState(false);
const [lastAnswerCorrect, setLastAnswerCorrect] = useState<boolean | null>(null);
const updateProgress = useUpdatePhonicsProgress();
const { isUpperCase } = useUppercasePreference();
useEffect(() => {
const timer = setInterval(() => {
setTimeSpent((prev) => prev + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
const handleAnswer = async (word: string, isCorrect: boolean) => {
setLastAnswerCorrect(isCorrect);
setShowFeedback(true);
if (isCorrect) {
setScore((prev) => prev + 1);
}
// Aguardar feedback antes de prosseguir
await new Promise(resolve => setTimeout(resolve, 1500));
setShowFeedback(false);
// Filtra apenas as palavras corretas
const correctWords = exercise.words?.filter(w => w.is_correct_answer) || [];
if (currentStep < correctWords.length - 1) {
setCurrentStep((prev) => prev + 1);
} else {
handleComplete();
}
};
const handleComplete = async () => {
// Filtra apenas as palavras corretas
const correctWords = exercise.words?.filter(w => w.is_correct_answer) || [];
const finalScore = score / correctWords.length;
const stars = Math.ceil(finalScore * 3);
const xp_earned = Math.round(finalScore * exercise.points);
const completed = finalScore >= exercise.required_score;
const updateParams: UpdateProgressParams = {
student_id,
exercise_id: exercise.id,
best_score: finalScore,
last_score: finalScore,
completed,
stars,
xp_earned,
time_spent_seconds: timeSpent,
correct_answers_count: score,
total_answers_count: correctWords.length
};
await updateProgress.mutateAsync(updateParams);
onComplete({
score: finalScore,
stars,
xp_earned,
completed
});
};
if (!exercise.words?.length) {
return (
<Card className="w-full max-w-2xl mx-auto">
<CardContent className="py-8">
<div className="text-center text-muted-foreground">
Carregando exercício...
</div>
</CardContent>
</Card>
);
}
// Filtra apenas as palavras corretas e ordena por order_index
const correctWords = exercise.words
.filter(w => w.is_correct_answer)
.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
// Pega a palavra atual
const currentWord = correctWords[currentStep];
// Pega as opções (incluindo a palavra correta)
const options = exercise.words
.filter(w => w.order_index === currentWord.order_index)
.map(w => w.word)
.sort(() => Math.random() - 0.5);
const progress = ((currentStep + 1) / correctWords.length) * 100;
return (
<Card className={cn(
"w-full max-w-2xl mx-auto transition-colors duration-500",
showFeedback && lastAnswerCorrect && "bg-green-50",
showFeedback && !lastAnswerCorrect && "bg-red-50"
)}>
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle>{exercise.title}</CardTitle>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Timer className="w-4 h-4" />
<span>{Math.floor(timeSpent / 60)}:{(timeSpent % 60).toString().padStart(2, '0')}</span>
</div>
<Button variant="outline" size="sm" onClick={onExit} trackingId="exercise-player-exit">
Sair do Exercício
</Button>
</div>
</div>
<Progress value={progress} className="h-2" />
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="text-center text-muted-foreground mb-8">
Exercício {currentStep + 1} de {correctWords.length}
</div>
<AdaptiveText
text={exercise.instructions}
isUpperCase={isUpperCase}
className="text-lg"
/>
<ExerciseFactory
type_id={exercise.type_id}
currentWord={currentWord.word}
options={options}
onAnswer={handleAnswer}
disabled={showFeedback}
/>
{showFeedback && (
<div className={cn(
"text-center text-lg font-medium py-4 rounded-lg",
lastAnswerCorrect ? "text-green-600" : "text-red-600"
)}>
{lastAnswerCorrect ? "Muito bem!" : "Tente novamente na próxima!"}
</div>
)}
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,41 @@
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import type { PhonicsWord } from "@/types/phonics";
interface AlliterationExerciseProps {
currentWord: PhonicsWord;
options: PhonicsWord[];
onAnswer: (word: string, isCorrect: boolean) => void;
disabled?: boolean;
}
export function AlliterationExercise({ currentWord, options, onAnswer, disabled }: AlliterationExerciseProps) {
return (
<div className="space-y-6">
<div className="text-center">
<h3 className="text-lg font-medium mb-2">Qual palavra começa com o mesmo som?</h3>
<div className="text-4xl font-bold">{currentWord.word}</div>
</div>
<div className="grid grid-cols-2 gap-4">
{options.map((option) => (
<Button
key={option.id}
size="lg"
variant="outline"
className={cn(
"h-auto py-6 text-xl font-medium",
disabled && "opacity-50 cursor-not-allowed"
)}
onClick={() => onAnswer(option.word, option.id === currentWord.id)}
disabled={disabled}
trackingId={`alliteration-option-${option.word}`}
>
{option.word}
</Button>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,31 @@
import type { PhonicsWord } from "@/types/phonics";
import { AudioPlayer } from "../AudioPlayer";
export interface BaseExerciseProps {
currentWord: PhonicsWord;
onAnswer: (word: string, isCorrect: boolean) => void;
disabled?: boolean;
}
export interface ExerciseOption {
id: string;
text: string;
isCorrect: boolean;
}
export function BaseExercise({ currentWord, disabled }: BaseExerciseProps) {
return (
<div className="space-y-6">
<div className="flex flex-col items-center gap-4">
<AudioPlayer
word={currentWord.word}
disabled={disabled}
/>
<div className="text-2xl font-bold">
{currentWord.word}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,66 @@
import { RhymeExercise } from "./RhymeExercise";
import { AlliterationExercise } from "./AlliterationExercise";
import { SyllablesExercise } from "./SyllablesExercise";
import { InitialSoundExercise } from "./InitialSoundExercise";
import { FinalSoundExercise } from "./FinalSoundExercise";
import type { PhonicsWord } from "@/types/phonics";
interface ExerciseFactoryProps {
type_id: string;
currentWord: PhonicsWord;
options: PhonicsWord[];
onAnswer: (word: string, isCorrect: boolean) => void;
disabled?: boolean;
}
export function ExerciseFactory({ type_id, currentWord, options, onAnswer, disabled }: ExerciseFactoryProps) {
switch (type_id) {
case '1': // Rima
return (
<RhymeExercise
currentWord={currentWord}
options={options}
onAnswer={onAnswer}
disabled={disabled}
/>
);
case '2': // Aliteração
return (
<AlliterationExercise
currentWord={currentWord}
options={options}
onAnswer={onAnswer}
disabled={disabled}
/>
);
case '3': // Sílabas
return (
<SyllablesExercise
currentWord={currentWord}
options={options}
onAnswer={onAnswer}
disabled={disabled}
/>
);
case '4': // Som Inicial
return (
<InitialSoundExercise
currentWord={currentWord}
options={options}
onAnswer={onAnswer}
disabled={disabled}
/>
);
case '5': // Som Final
return (
<FinalSoundExercise
currentWord={currentWord}
options={options}
onAnswer={onAnswer}
disabled={disabled}
/>
);
default:
return null;
}
}

View File

@ -0,0 +1,41 @@
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import type { PhonicsWord } from "@/types/phonics";
interface FinalSoundExerciseProps {
currentWord: PhonicsWord;
options: PhonicsWord[];
onAnswer: (word: string, isCorrect: boolean) => void;
disabled?: boolean;
}
export function FinalSoundExercise({ currentWord, options, onAnswer, disabled }: FinalSoundExerciseProps) {
return (
<div className="space-y-6">
<div className="text-center">
<h3 className="text-lg font-medium mb-2">Qual palavra termina com o mesmo som?</h3>
<div className="text-4xl font-bold">{currentWord.word}</div>
</div>
<div className="grid grid-cols-2 gap-4">
{options.map((option) => (
<Button
key={option.id}
size="lg"
variant="outline"
className={cn(
"h-auto py-6 text-xl font-medium",
disabled && "opacity-50 cursor-not-allowed"
)}
onClick={() => onAnswer(option.word, option.id === currentWord.id)}
disabled={disabled}
trackingId={`final-sound-option-${option.word}`}
>
{option.word}
</Button>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,41 @@
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import type { PhonicsWord } from "@/types/phonics";
interface InitialSoundExerciseProps {
currentWord: PhonicsWord;
options: PhonicsWord[];
onAnswer: (word: string, isCorrect: boolean) => void;
disabled?: boolean;
}
export function InitialSoundExercise({ currentWord, options, onAnswer, disabled }: InitialSoundExerciseProps) {
return (
<div className="space-y-6">
<div className="text-center">
<h3 className="text-lg font-medium mb-2">Qual palavra começa com o mesmo som?</h3>
<div className="text-4xl font-bold">{currentWord.word}</div>
</div>
<div className="grid grid-cols-2 gap-4">
{options.map((option) => (
<Button
key={option.id}
size="lg"
variant="outline"
className={cn(
"h-auto py-6 text-xl font-medium",
disabled && "opacity-50 cursor-not-allowed"
)}
onClick={() => onAnswer(option.word, option.id === currentWord.id)}
disabled={disabled}
trackingId={`initial-sound-option-${option.word}`}
>
{option.word}
</Button>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,41 @@
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import type { PhonicsWord } from "@/types/phonics";
interface RhymeExerciseProps {
currentWord: PhonicsWord;
options: PhonicsWord[];
onAnswer: (word: string, isCorrect: boolean) => void;
disabled?: boolean;
}
export function RhymeExercise({ currentWord, options, onAnswer, disabled }: RhymeExerciseProps) {
return (
<div className="space-y-6">
<div className="text-center">
<h3 className="text-lg font-medium mb-2">Qual palavra rima com:</h3>
<div className="text-4xl font-bold">{currentWord.word}</div>
</div>
<div className="grid grid-cols-2 gap-4">
{options.map((option) => (
<Button
key={option.id}
size="lg"
variant="outline"
className={cn(
"h-auto py-6 text-xl font-medium",
disabled && "opacity-50 cursor-not-allowed"
)}
onClick={() => onAnswer(option.word, option.id === currentWord.id)}
disabled={disabled}
trackingId={`rhyme-option-${option.word}`}
>
{option.word}
</Button>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,54 @@
import { Button } from "@/components/ui/button";
import { BaseExercise, type BaseExerciseProps } from "./BaseExercise";
import { cn } from "@/lib/utils";
interface SoundMatchExerciseProps extends BaseExerciseProps {
type: 'initial' | 'final';
options: Array<{
word: string;
hasMatchingSound: boolean;
}>;
}
export function SoundMatchExercise({
currentWord,
onAnswer,
type,
options,
disabled
}: SoundMatchExerciseProps) {
const instruction = type === 'initial'
? "Qual palavra começa com o mesmo som?"
: "Qual palavra termina com o mesmo som?";
return (
<div className="space-y-8">
<BaseExercise currentWord={currentWord} onAnswer={onAnswer} disabled={disabled} />
<div className="space-y-4">
<div className="text-center text-muted-foreground">
{instruction}
</div>
<div className="grid grid-cols-2 gap-4">
{options.map((option) => (
<Button
key={option.word}
onClick={() => onAnswer(option.word, option.hasMatchingSound)}
disabled={disabled}
variant="outline"
className={cn(
"h-16 text-lg",
disabled && option.hasMatchingSound && "border-green-500 bg-green-50",
disabled && !option.hasMatchingSound && "border-red-500 bg-red-50"
)}
trackingId={`sound-match-option-${option.word}`}
>
{option.word}
</Button>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,41 @@
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import type { PhonicsWord } from "@/types/phonics";
interface SyllablesExerciseProps {
currentWord: PhonicsWord;
options: PhonicsWord[];
onAnswer: (word: string, isCorrect: boolean) => void;
disabled?: boolean;
}
export function SyllablesExercise({ currentWord, options, onAnswer, disabled }: SyllablesExerciseProps) {
return (
<div className="space-y-6">
<div className="text-center">
<h3 className="text-lg font-medium mb-2">Quantas sílabas tem a palavra?</h3>
<div className="text-4xl font-bold">{currentWord.word}</div>
</div>
<div className="grid grid-cols-2 gap-4">
{options.map((option) => (
<Button
key={option.id}
size="lg"
variant="outline"
className={cn(
"h-auto py-6 text-xl font-medium",
disabled && "opacity-50 cursor-not-allowed"
)}
onClick={() => onAnswer(option.word, option.syllables_count === currentWord.syllables_count)}
disabled={disabled}
trackingId={`syllables-option-${option.syllables_count}`}
>
{option.syllables_count} sílabas
</Button>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,245 @@
import React, { useState, useRef } from 'react';
import { Mic, Square, Loader, Play, Upload } from 'lucide-react';
import { supabase } from '../../lib/supabase';
import { v4 as uuidv4 } from 'uuid';
import { cn } from '../../lib/utils';
interface AudioRecorderProps {
storyId: string;
studentId: string;
onAudioUploaded: (audioUrl: string) => void;
onRecordingStart?: () => void;
onRecordingStop?: () => void;
focusModeActive?: boolean;
onFocusModeToggle?: () => void;
}
export function AudioRecorder({
storyId,
studentId,
onAudioUploaded,
onRecordingStart,
onRecordingStop,
focusModeActive = false,
onFocusModeToggle
}: AudioRecorderProps) {
const [isRecording, setIsRecording] = useState(false);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startTime = React.useRef<number | null>(null);
const startRecording = async () => {
try {
if (!focusModeActive && onFocusModeToggle) {
onFocusModeToggle();
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorderRef.current = new MediaRecorder(stream);
chunksRef.current = [];
mediaRecorderRef.current.ondataavailable = (e) => {
chunksRef.current.push(e.data);
};
mediaRecorderRef.current.onstop = () => {
const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
setAudioBlob(audioBlob);
onRecordingStop?.();
};
mediaRecorderRef.current.start();
startTime.current = Date.now();
setIsRecording(true);
setError(null);
onRecordingStart?.();
} catch (err) {
setError('Erro ao acessar microfone. Verifique as permissões.');
console.error('Erro ao iniciar gravação:', err);
}
};
const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
// Desativar modo foco ao parar a gravação
if (focusModeActive && onFocusModeToggle) {
onFocusModeToggle();
}
}
};
const triggerAudioProcessing = async (recordingData: {
id: string;
story_id: string;
student_id: string;
audio_url: string;
status: string;
}): Promise<void> => {
try {
const { error } = await supabase.functions.invoke('process-audio', {
body: {
record: recordingData
}
});
if (error) {
console.error('Erro ao iniciar processamento:', error);
// Não vamos tratar o erro aqui pois o processamento é assíncrono
}
} catch (err) {
console.error('Erro ao chamar função de processamento:', err);
}
};
const uploadAudio = async () => {
if (!audioBlob) return;
const { data: { session } } = await supabase.auth.getSession();
if (!session?.user) {
setError('Usuário não autenticado');
return;
}
setIsUploading(true);
setError(null);
const fileId = uuidv4();
const filePath = `${studentId}/${storyId}/${fileId}.webm`;
try {
// 1. Primeiro fazer o upload do arquivo
const { error: uploadError } = await supabase.storage
.from('recordings')
.upload(filePath, audioBlob, {
contentType: 'audio/webm',
cacheControl: '3600',
upsert: false
});
if (uploadError) throw uploadError;
// 2. Obter URL pública
const { data: { publicUrl } } = supabase.storage
.from('recordings')
.getPublicUrl(filePath);
// 3. Criar o registro com a URL do áudio
const { error: recordError } = await supabase
.from('story_recordings')
.insert({
id: fileId,
story_id: storyId,
student_id: studentId,
audio_url: publicUrl, // Salvar o caminho relativo
status: 'pending_analysis',
created_at: new Date().toISOString()
})
.select()
.single();
if (recordError) throw recordError;
// 4. Disparar processamento
await triggerAudioProcessing({
id: fileId,
story_id: storyId,
student_id: studentId,
audio_url: publicUrl,
status: 'pending_analysis'
}).catch(console.error);
onAudioUploaded(publicUrl);
setAudioBlob(null);
} catch (err) {
// Em caso de erro, limpar arquivo se foi feito upload
if (filePath) {
await supabase.storage.from('recordings').remove([filePath]);
}
setError('Erro ao enviar áudio. Tente novamente.');
console.error('Erro no upload:', err);
} finally {
setIsUploading(false);
}
};
return (
<div className={cn(
"",
focusModeActive && "bg-purple-50"
)}>
<div className="flex items-center gap-4 mb-4">
{!isRecording && !audioBlob && (
<button
onClick={startRecording}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-lg transition",
focusModeActive
? "bg-purple-600 text-white hover:bg-purple-700"
: "bg-red-600 text-white hover:bg-red-700"
)}
>
<Mic className="w-5 h-5" />
{focusModeActive ? "Iniciar Leitura" : "Iniciar Gravação"}
</button>
)}
{isRecording && (
<button
onClick={stopRecording}
className="flex items-center gap-2 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
>
<Square className="w-5 h-5" />
Parar Gravação
</button>
)}
{audioBlob && !isUploading && (
<>
<button
onClick={() => {
const url = URL.createObjectURL(audioBlob);
const audio = new Audio(url);
audio.play();
}}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
<Play className="w-5 h-5" />
Ouvir
</button>
<button
onClick={uploadAudio}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
>
<Upload className="w-5 h-5" />
Enviar Áudio
</button>
</>
)}
{isUploading && (
<div className="flex items-center gap-2 text-gray-600">
<Loader className="w-5 h-5 animate-spin" />
Enviando áudio...
</div>
)}
</div>
{error && (
<div className="text-red-600 text-sm">
{error}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,124 @@
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion';
import type { StoryRecording } from '../../types/database';
export function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
const metrics = [
{ label: 'Fluência', value: recording.fluency_score, color: 'text-blue-600' },
{ label: 'Pronúncia', value: recording.pronunciation_score, color: 'text-green-600' },
{ label: 'Precisão', value: recording.accuracy_score, color: 'text-purple-600' },
{ label: 'Compreensão', value: recording.comprehension_score, color: 'text-orange-600' }
];
const details = [
{ label: 'Palavras por minuto', value: recording.words_per_minute },
{ label: 'Pausas', value: recording.pause_count },
{ label: 'Erros', value: recording.error_count },
{ label: 'Autocorreções', value: recording.self_corrections }
];
return (
<Accordion type="single" collapsible className="bg-white rounded-lg border border-gray-200">
<AccordionItem value={recording.id}>
<AccordionTrigger className="px-4 hover:no-underline hover:bg-gray-50">
<div className="w-full">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-gray-500">
{new Date(recording.created_at).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{metrics.map((metric) => (
<div key={metric.label} className="flex flex-col">
<span className={`text-sm font-medium ${metric.color}`}>
{metric.label}
</span>
<span className="text-lg font-semibold">
{metric.value}%
</span>
</div>
))}
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Coluna 1: Detalhes Técnicos */}
<div className="space-y-4">
<h5 className="text-sm font-medium text-gray-900">Detalhes Técnicos</h5>
<div className="grid grid-cols-2 gap-3">
{details.map((detail) => (
<div key={detail.label} className="bg-gray-50 p-3 rounded-lg">
<span className="text-xs text-gray-500 block">
{detail.label}
</span>
<span className="text-sm font-medium">
{detail.value}
</span>
</div>
))}
</div>
</div>
{/* Coluna 2: Pontos Fortes e Melhorias */}
<div className="space-y-4">
<div>
<h5 className="text-sm font-medium text-green-600 mb-2 flex items-center gap-2">
<span className="w-2 h-2 bg-green-600 rounded-full" />
Pontos Fortes
</h5>
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
{recording.strengths.map((strength: string, i: number) => (
<li key={i} className="leading-relaxed">{strength}</li>
))}
</ul>
</div>
<div>
<h5 className="text-sm font-medium text-orange-600 mb-2 flex items-center gap-2">
<span className="w-2 h-2 bg-orange-600 rounded-full" />
Pontos para Melhorar
</h5>
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
{recording.improvements.map((improvement: string, i: number) => (
<li key={i} className="leading-relaxed">{improvement}</li>
))}
</ul>
</div>
</div>
{/* Coluna 3: Sugestões e Próximos Passos */}
<div className="space-y-4">
<div>
<h5 className="text-sm font-medium text-blue-600 mb-2 flex items-center gap-2">
<span className="w-2 h-2 bg-blue-600 rounded-full" />
Sugestões
</h5>
<p className="text-sm text-gray-600 leading-relaxed bg-blue-50 p-3 rounded-lg">
{recording.suggestions}
</p>
</div>
<div>
<h5 className="text-sm font-medium text-purple-600 mb-2 flex items-center gap-2">
<span className="w-2 h-2 bg-purple-600 rounded-full" />
Próxima Meta
</h5>
<p className="text-sm text-gray-600 leading-relaxed bg-purple-50 p-3 rounded-lg">
Tente alcançar {Math.min(100, recording.fluency_score + 5)}% de fluência na próxima leitura.
</p>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
);
}

View File

@ -0,0 +1,615 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase';
import { useSession } from '../../hooks/useSession';
import { useStoryCategories } from '../../hooks/useStoryCategories';
import { Wand2, ArrowLeft, Globe } from 'lucide-react';
import { useStudentTracking } from '../../hooks/useStudentTracking';
import { useLanguages } from '../../hooks/useLanguages';
interface Category {
id: string;
slug: string;
title: string;
description: string;
icon: string;
}
interface StoryStep {
title: string;
key?: keyof StoryChoices;
items?: Category[];
isContextStep?: boolean;
isLanguageStep?: boolean;
}
export interface StoryChoices {
theme_id: string | null;
subject_id: string | null;
character_id: string | null;
setting_id: string | null;
context?: string;
language_type: string;
}
interface StoryGeneratorProps {
initialContext?: string;
onContextChange: (context: string) => void;
inputMode: 'voice' | 'form';
voiceTranscript: string;
isGenerating: boolean;
setIsGenerating: (value: boolean) => void;
step: number;
setStep: (value: number | ((prev: number) => number)) => void;
choices: StoryChoices;
setChoices: React.Dispatch<React.SetStateAction<StoryChoices>>;
}
export function StoryGenerator({
initialContext = '',
onContextChange,
inputMode,
voiceTranscript,
isGenerating,
setIsGenerating,
step,
setStep,
choices,
setChoices
}: StoryGeneratorProps) {
const { themes, subjects, characters, settings, isLoading: isCategoriesLoading } = useStoryCategories();
const { languages, supportedLanguages, isLoading: isLanguagesLoading } = useLanguages();
// Definir steps com os dados obtidos
const steps: StoryStep[] = [
{
title: 'Escolha o Tema',
items: themes || [],
key: 'theme_id'
},
{
title: 'Escolha a Disciplina',
items: subjects || [],
key: 'subject_id'
},
{
title: 'Escolha o Personagem',
items: characters || [],
key: 'character_id'
},
{
title: 'Escolha o Cenário',
items: settings || [],
key: 'setting_id'
},
{
title: 'Escolha o Idioma da História',
isLanguageStep: true
},
{
title: 'Contexto da História (Opcional)',
isContextStep: true
}
];
// useEffect que depende dos dados
React.useEffect(() => {
// Só aplicar escolhas aleatórias se estiver no modo voz
if (inputMode === 'voice' && voiceTranscript && themes && !choices.theme_id) {
setStep(steps.length); // Vai para o último passo (contexto)
// Selecionar IDs aleatórios válidos para cada categoria
const randomTheme = themes[Math.floor(Math.random() * themes.length)];
const randomSubject = subjects?.[Math.floor(Math.random() * (subjects?.length || 1))] || null;
const randomCharacter = characters?.[Math.floor(Math.random() * (characters?.length || 1))] || null;
const randomSetting = settings?.[Math.floor(Math.random() * (settings?.length || 1))] || null;
setChoices(prev => ({
...prev,
theme_id: randomTheme?.id || null,
subject_id: randomSubject?.id || null,
character_id: randomCharacter?.id || null,
setting_id: randomSetting?.id || null,
language_type: prev.language_type // Mantém o idioma selecionado
}));
}
}, [inputMode, voiceTranscript, themes, subjects, characters, settings, setStep, setChoices, choices.theme_id]);
// Atualizar apenas o contexto quando mudar o modo ou a transcrição
React.useEffect(() => {
if (inputMode === 'voice' && voiceTranscript) {
setChoices(prev => ({
...prev,
context: voiceTranscript
}));
} else if (inputMode === 'form') {
setChoices(prev => ({
...prev,
context: initialContext
}));
}
}, [voiceTranscript, initialContext, inputMode]);
const handleContextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onContextChange(e.target.value);
};
const navigate = useNavigate();
const { session } = useSession();
const [error, setError] = React.useState<string | null>(null);
const [generationStatus, setGenerationStatus] = React.useState<
'idle' | 'creating' | 'generating-images' | 'saving'
>('idle');
const { trackStoryGenerated } = useStudentTracking();
const startTime = React.useRef(Date.now());
const currentStep = steps[step - 1];
const handleSelect = (key: keyof StoryChoices, value: string) => {
console.log(`Selecionando ${key}:`, value); // Log para debug
if (!value) {
setError(`Valor inválido para ${key}`);
return;
}
setChoices(prev => ({ ...prev, [key]: value }));
// Avançar apenas se houver um próximo passo
if (step < steps.length) {
setStep((prev: number) => prev + 1);
}
};
const handleNext = () => {
if (currentStep.isContextStep) {
setStep((prev: number) => prev + 1);
}
};
const handleLanguageSelect = (language: string) => {
console.log('Selecionando idioma:', language);
const selectedLanguage = languages.find(lang => lang.code === language);
if (!selectedLanguage) {
setError('Idioma inválido selecionado');
return;
}
setChoices(prev => ({
...prev,
language_type: language
}));
// Avançar para o próximo passo
if (step < steps.length) {
setStep((prev: number) => prev + 1);
}
};
const handleGenerate = async () => {
// Validação apenas para modo voz
if (inputMode === 'voice' && !voiceTranscript) {
setError('Grave uma descrição por voz antes de enviar');
return;
}
// Contexto é opcional no formulário
const finalContext = inputMode === 'voice' ? voiceTranscript : initialContext;
if (!session?.user?.id) {
setError('Usuário não autenticado');
return;
}
// Log inicial para debug
console.log('=== Iniciando geração de história ===');
console.log('Modo:', inputMode);
console.log('Choices:', choices);
// Validações iniciais
if (!themes?.length || !subjects?.length || !characters?.length || !settings?.length) {
console.error('Dados das categorias não carregados:', { themes, subjects, characters, settings });
setError('Erro ao carregar dados necessários. Tente novamente.');
return;
}
// Validar se todos os IDs são UUIDs válidos
const isValidUUID = (id: string | null) => {
if (!id) return false;
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidRegex.test(id);
};
// Validar cada ID individualmente
const validations = [
{ field: 'theme_id', value: choices.theme_id, exists: themes.some(t => t.id === choices.theme_id) },
{ field: 'subject_id', value: choices.subject_id, exists: subjects.some(s => s.id === choices.subject_id) },
{ field: 'character_id', value: choices.character_id, exists: characters.some(c => c.id === choices.character_id) },
{ field: 'setting_id', value: choices.setting_id, exists: settings.some(s => s.id === choices.setting_id) }
];
// Verificar cada validação
for (const validation of validations) {
console.log(`Validando ${validation.field}:`, validation);
if (!validation.value) {
setError(`${validation.field} não selecionado`);
return;
}
if (!isValidUUID(validation.value)) {
setError(`${validation.field} não é um UUID válido`);
return;
}
if (!validation.exists) {
setError(`${validation.field} não encontrado na lista de opções`);
return;
}
}
// Validar idioma
if (!choices.language_type || !languages.some(lang => lang.code === choices.language_type)) {
setError('Idioma não selecionado ou inválido');
return;
}
try {
setIsGenerating(true);
setError(null);
setGenerationStatus('creating');
// Log detalhado antes de fazer a inserção
console.log('=== Dados validados para inserção ===', {
student_id: session.user.id,
theme_id: choices.theme_id,
subject_id: choices.subject_id,
character_id: choices.character_id,
setting_id: choices.setting_id,
context: finalContext,
language_type: choices.language_type
});
// Criar objeto da história antes da inserção para validação
const storyData = {
student_id: session.user.id,
title: 'Gerando...',
theme_id: choices.theme_id,
subject_id: choices.subject_id,
character_id: choices.character_id,
setting_id: choices.setting_id,
context: finalContext,
language_type: choices.language_type,
status: 'draft',
content: {
prompt: choices,
pages: []
}
} as const;
// Validar se todos os campos necessários estão presentes
const requiredFields = ['student_id', 'theme_id', 'subject_id', 'character_id', 'setting_id', 'language_type'] as const;
const missingFields = requiredFields.filter(field => !storyData[field]);
if (missingFields.length > 0) {
throw new Error(`Campos obrigatórios faltando: ${missingFields.join(', ')}`);
}
const { data: story, error: storyError } = await supabase
.from('stories')
.insert(storyData)
.select()
.single();
if (storyError) {
console.error('Erro ao inserir história:', storyError);
throw storyError;
}
// Tracking da criação da história
const selectedTheme = themes?.find(t => t.id === choices.theme_id)?.title || '';
const selectedSubject = subjects?.find(s => s.id === choices.subject_id)?.title || '';
const selectedCharacter = characters?.find(c => c.id === choices.character_id)?.title || '';
const selectedSetting = settings?.find(s => s.id === choices.setting_id)?.title || '';
trackStoryGenerated({
story_id: story.id,
theme: selectedTheme,
subject: selectedSubject,
character: selectedCharacter,
setting: selectedSetting,
context: finalContext,
generation_time: Date.now() - startTime.current,
word_count: 0,
student_id: session.user.id,
school_id: session.user.user_metadata?.school_id,
class_id: session.user.user_metadata?.class_id
});
setGenerationStatus('generating-images');
console.log('=== Chamando Edge Function ===');
console.log('Story ID:', story.id);
console.log('Story Data:', story);
try {
if (!story?.id) {
throw new Error('ID da história não encontrado');
}
const storyPayload = {
voice_context: finalContext || '',
student_id: session.user.id,
theme_id: choices.theme_id,
subject_id: choices.subject_id,
character_id: choices.character_id,
setting_id: choices.setting_id,
language_type: choices.language_type,
theme: selectedTheme,
subject: selectedSubject,
character: selectedCharacter,
setting: selectedSetting,
story_id: story.id // Garantindo que o ID existe
};
console.log('=== Dados da História ===');
console.log('ID:', story.id);
console.log('Payload completo:', storyPayload);
const response = await supabase.functions
.invoke('generate-story', {
body: storyPayload
});
console.log('=== Resposta da Edge Function ===');
console.log('Resposta completa:', response);
// Se a resposta não for 200, lançar erro
if (response.error) {
console.error('Erro na Edge Function:', response.error);
throw new Error(`Erro na Edge Function: ${response.error.message}`);
}
// Se não houver dados na resposta
if (!response.data) {
console.error('Edge Function não retornou dados');
throw new Error('Edge Function não retornou dados');
}
// Atualizar o status da história para success
const { error: updateError } = await supabase
.from('stories')
.update({
status: 'published',
updated_at: new Date().toISOString()
})
.eq('id', story.id)
.single();
if (updateError) {
console.error('Erro ao atualizar status da história:', updateError);
throw updateError;
}
} catch (error) {
console.error('=== Erro na Edge Function ===');
console.error('Erro completo:', error);
console.error('Story ID:', story?.id);
console.error('Estado atual:', { choices, inputMode, step });
if (!story?.id) {
throw new Error('ID da história não encontrado para atualizar status de erro');
}
// Atualizar status da história para erro
const { error: updateError } = await supabase
.from('stories')
.update({
status: 'failed',
title: 'Erro na Geração',
updated_at: new Date().toISOString()
})
.eq('id', story.id)
.single();
if (updateError) {
console.error('Erro ao atualizar status de erro:', updateError);
}
throw new Error(`Erro na geração da história. Por favor, tente novamente.`);
}
setGenerationStatus('saving');
const { data: updatedStory, error: updateError } = await supabase
.from('stories')
.select('*')
.eq('id', story.id)
.single();
if (updateError) {
console.error('Erro ao buscar história atualizada:', updateError);
throw updateError;
}
// Atualizar a contagem de palavras após a geração
const wordCount = updatedStory.content.pages.reduce((acc: number, page: { text: string }) =>
acc + page.text.split(/\s+/).length, 0);
await supabase.from('story_metrics').insert({
story_id: story.id,
word_count: wordCount,
generation_time: Date.now() - startTime.current
});
navigate(`/aluno/historias/${story.id}`);
} catch (err) {
console.error('=== Erro detalhado ===');
console.error('Erro:', err);
console.error('Estado atual:', { choices, inputMode, step });
if (err instanceof Error) {
setError(`Erro ao gerar história: ${err.message}`);
} else {
setError('Erro desconhecido ao gerar história. Tente novamente.');
}
} finally {
setIsGenerating(false);
setGenerationStatus('idle');
}
};
const getGenerationStatusText = () => {
switch (generationStatus) {
case 'creating':
return 'Iniciando criação...';
case 'generating-images':
return 'Gerando história e imagens...';
case 'saving':
return 'Finalizando...';
default:
return 'Criar História Mágica';
}
};
if (isCategoriesLoading || isLanguagesLoading) {
return (
<div className="animate-pulse space-y-8">
<div className="h-2 bg-gray-200 rounded-full" />
<div className="grid grid-cols-2 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-32 bg-gray-200 rounded-xl" />
))}
</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Progress Bar */}
<div className="flex gap-2 mb-8">
{steps.map((s, i) => (
<div
key={i}
className={`h-2 rounded-full flex-1 ${
i + 1 <= step ? 'bg-purple-600' : 'bg-gray-200'
}`}
/>
))}
</div>
<h2 className="text-xl font-medium text-gray-900 mb-6">
{currentStep.title}
</h2>
{currentStep.isLanguageStep ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{supportedLanguages.map((option) => {
const languageDetails = languages.find(lang => lang.code === option.value);
return (
<button
key={option.value}
onClick={() => handleLanguageSelect(option.value)}
className={`p-6 rounded-xl border-2 transition-all text-left ${
choices.language_type === option.value
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-purple-200 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-3">
{languageDetails?.flag_icon ? (
<img
src={languageDetails.flag_icon}
alt={`Bandeira ${option.label}`}
className="h-6 w-6 object-cover rounded-full"
/>
) : (
<Globe className="h-6 w-6 text-purple-600" />
)}
<div>
<h3 className="font-medium text-gray-900">{option.label}</h3>
<p className="text-sm text-gray-600">
{`Escreva sua história em ${option.label}`}
</p>
</div>
</div>
</button>
);
})}
</div>
) : currentStep.isContextStep ? (
<div className="space-y-4">
<textarea
value={initialContext}
onChange={handleContextChange}
className="w-full p-3 border rounded-lg"
placeholder="Descreva sua história... (opcional)"
/>
<button
onClick={handleGenerate}
disabled={isGenerating}
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
<Wand2 className="h-5 w-5" />
{isGenerating ? getGenerationStatusText() : 'Criar História Mágica'}
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{currentStep.items?.map((item) => (
<button
key={item.id}
onClick={() => handleSelect(currentStep.key!, item.id)}
className={`p-6 rounded-xl border-2 transition-all text-left ${
choices[currentStep.key!] === item.id
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-purple-200 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-3">
<span className="text-2xl">{item.icon}</span>
<div>
<h3 className="font-medium text-gray-900">{item.title}</h3>
<p className="text-sm text-gray-600">{item.description}</p>
</div>
</div>
</button>
))}
</div>
)}
{error && (
<div className="p-4 bg-red-50 text-red-600 rounded-lg text-sm">
{error}
</div>
)}
{choices.theme_id === 'auto' && (
<div className="mb-4 p-3 bg-blue-50 text-blue-600 rounded-lg">
Configurações automáticas selecionadas com base na descrição por voz
</div>
)}
{/* Navigation Buttons */}
<div className="flex justify-between pt-6">
<button
onClick={() => setStep((prev: number) => prev - 1)}
disabled={step === 1}
className="flex items-center gap-2 px-4 py-2 text-gray-600 disabled:opacity-50"
>
<ArrowLeft className="h-5 w-5" />
Voltar
</button>
{!currentStep.isLanguageStep && !currentStep.isContextStep && (
<button
onClick={handleNext}
disabled={currentStep.key && !choices[currentStep.key] || isGenerating}
className="flex items-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
Próximo
</button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,142 @@
import React from 'react';
import { Activity, Book, Mic, Brain } from 'lucide-react';
export interface MetricsData {
metrics: {
fluency: number;
pronunciation: number;
accuracy: number;
comprehension: number;
};
feedback: {
strengths: string[];
improvements: string[];
suggestions: string;
};
details: {
wordsPerMinute: number;
pauseCount: number;
errorCount: number;
selfCorrections: number;
};
}
interface StoryMetricsProps {
data?: MetricsData;
isLoading?: boolean;
}
export function StoryMetrics({ data, isLoading }: StoryMetricsProps): JSX.Element {
if (isLoading) {
return (
<div className="animate-pulse">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-24 bg-gray-100 rounded-lg" />
))}
</div>
</div>
);
}
if (!data) {
return (
<div className="bg-gray-50 rounded-lg p-6 text-center mb-6">
<p className="text-gray-600">
Aguardando gravação para gerar métricas de leitura...
</p>
</div>
);
}
const metrics = [
{
label: 'Fluência',
value: data.metrics.fluency,
icon: Activity,
color: 'text-blue-600',
detail: `${data.details.wordsPerMinute} palavras/min`
},
{
label: 'Pronúncia',
value: data.metrics.pronunciation,
icon: Mic,
color: 'text-green-600',
detail: `${data.details.errorCount} erros`
},
{
label: 'Precisão',
value: data.metrics.accuracy,
icon: Book,
color: 'text-purple-600',
detail: `${data.details.selfCorrections} autocorreções`
},
{
label: 'Compreensão',
value: data.metrics.comprehension,
icon: Brain,
color: 'text-orange-600',
detail: `${data.details.pauseCount} pausas`
}
];
return (
<div className="space-y-6 mb-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{metrics.map((metric) => (
<div
key={metric.label}
className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm"
>
<div className="flex items-center justify-between mb-2">
<metric.icon className={`w-5 h-5 ${metric.color}`} />
<span className="text-2xl font-bold">{metric.value}%</span>
</div>
<h3 className="text-sm font-medium text-gray-600">{metric.label}</h3>
<p className="text-xs text-gray-500 mt-1">{metric.detail}</p>
</div>
))}
</div>
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
<h3 className="font-medium mb-4">Feedback da Leitura</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<h4 className="text-sm font-medium text-green-600 mb-2 flex items-center gap-2">
<span className="w-2 h-2 bg-green-600 rounded-full" />
Pontos Fortes
</h4>
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
{data.feedback.strengths.map((strength, i) => (
<li key={i} className="leading-relaxed">{strength}</li>
))}
</ul>
</div>
<div>
<h4 className="text-sm font-medium text-orange-600 mb-2 flex items-center gap-2">
<span className="w-2 h-2 bg-orange-600 rounded-full" />
Pontos para Melhorar
</h4>
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
{data.feedback.improvements.map((improvement, i) => (
<li key={i} className="leading-relaxed">{improvement}</li>
))}
</ul>
</div>
<div>
<h4 className="text-sm font-medium text-blue-600 mb-2 flex items-center gap-2">
<span className="w-2 h-2 bg-blue-600 rounded-full" />
Sugestões
</h4>
<p className="text-sm text-gray-600 leading-relaxed">
{data.feedback.suggestions}
</p>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,105 @@
import { WordHighlighter } from "../learning/WordHighlighter";
import { useState } from "react";
import * as Dialog from '@radix-ui/react-dialog';
interface StoryReaderProps {
storyText: string;
studentProgress: {
difficultWords: string[];
masteredWords: string[];
};
}
export function StoryReader({ storyText, studentProgress }: StoryReaderProps) {
const [selectedWord, setSelectedWord] = useState<string | null>(null);
const [showWordDetails, setShowWordDetails] = useState(false);
// Palavras importantes para destacar
const highlightedWords = [
'casa', 'bola', 'menino', 'cachorro',
// Palavras frequentes ou importantes para a história
];
const handleWordClick = (word: string) => {
// Abre um modal ou popover com:
// 1. Definição da palavra
// 2. Exemplo de uso
// 3. Imagem relacionada
// 4. Exercícios de pronúncia
setSelectedWord(word);
setShowWordDetails(true);
};
return (
<div className="max-w-2xl mx-auto p-6">
<WordHighlighter
text={storyText}
highlightedWords={highlightedWords}
difficultWords={studentProgress.difficultWords}
onWordClick={handleWordClick}
/>
{/* Modal de detalhes da palavra */}
<WordDetailsModal
word={selectedWord}
isOpen={showWordDetails}
onClose={() => setShowWordDetails(false)}
/>
</div>
);
}
// Adicionar interface para as props do modal
interface WordDetailsModalProps {
word: string | null;
isOpen: boolean;
onClose: () => void;
}
// Componente para mostrar detalhes da palavra
function WordDetailsModal({ word, isOpen, onClose }: WordDetailsModalProps) {
return (
<Dialog.Root open={isOpen} onOpenChange={onClose}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
<div className="p-6 bg-white rounded-xl">
<h3 className="text-2xl font-bold mb-4">{word}</h3>
{/* Significado */}
<div className="mb-4">
<h4 className="font-semibold">Significado:</h4>
<p>{/* Buscar significado da palavra */}</p>
</div>
{/* Sílabas */}
<div className="mb-4">
<h4 className="font-semibold">Sílabas:</h4>
<div className="flex gap-2">
{word?.split(/(?=[BCDFGHJKLMNPQRSTVWXZ][aeiou])/i).map((syllable, i) => (
<span key={i} className="bg-purple-100 px-2 py-1 rounded">
{syllable}
</span>
))}
</div>
</div>
{/* Exemplo */}
<div className="mb-4">
<h4 className="font-semibold">Exemplo:</h4>
<p className="italic">{/* Exemplo contextualizado */}</p>
</div>
{/* Botão de áudio para ouvir a pronúncia */}
<button
className="bg-blue-500 text-white px-4 py-2 rounded-lg"
onClick={() => {/* Reproduzir áudio */}}
>
Ouvir Pronúncia
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@ -0,0 +1,41 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
export function StudentDashboardNavbar() {
const location = useLocation();
return (
<nav className="bg-white border-b border-gray-200">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-8">
<Link
to="/aluno"
className={`text-gray-900 hover:text-purple-600 ${
location.pathname === '/aluno' ? 'text-purple-600' : ''
}`}
>
Dashboard
</Link>
<Link
to="/aluno/historias"
className={`text-gray-900 hover:text-purple-600 ${
location.pathname.includes('/aluno/historias') ? 'text-purple-600' : ''
}`}
>
Histórias
</Link>
<Link
to="/aluno/configuracoes"
className={`text-gray-900 hover:text-purple-600 ${
location.pathname === '/aluno/configuracoes' ? 'text-purple-600' : ''
}`}
>
Configurações
</Link>
</div>
</div>
</div>
</nav>
);
}

View File

@ -0,0 +1,54 @@
import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDown } from 'lucide-react';
import { cn } from '../../lib/utils';
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('border-b', className)}
{...props}
/>
));
AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-gray-500 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = 'AccordionTrigger';
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn('pb-4 pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = 'AccordionContent';
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@ -0,0 +1,94 @@
import React from 'react';
import { cn } from '../../lib/utils';
import { SyllableHighlighter } from '../../features/syllables/components/SyllableHighlighter';
import { formatTextWithSyllables } from '../../features/syllables/utils/syllableSplitter';
interface AdaptiveTextProps extends React.HTMLAttributes<HTMLSpanElement> {
text: string;
isUpperCase: boolean;
as?: keyof JSX.IntrinsicElements;
preserveWhitespace?: boolean;
highlightSyllables?: boolean;
formattedText?: string;
}
export const AdaptiveText = React.memo(({
text,
isUpperCase,
as: Component = 'span',
preserveWhitespace = false,
highlightSyllables = false,
formattedText,
className,
...props
}: AdaptiveTextProps) => {
// Se tiver texto formatado (com sílabas), usa ele
// Senão, formata o texto normal
const finalText = formattedText || formatTextWithSyllables(text, highlightSyllables);
const displayText = isUpperCase ? finalText.toUpperCase() : finalText;
return React.createElement(
Component,
{
className: cn(
'transition-colors duration-200',
className
),
...props
},
displayText
);
});
AdaptiveText.displayName = 'AdaptiveText';
// Variantes específicas para diferentes contextos
export const AdaptiveTitle = ({
className,
...props
}: AdaptiveTextProps) => (
<AdaptiveText
as="h1"
className={cn(
'text-2xl font-bold text-gray-900',
className
)}
{...props}
/>
);
export const AdaptiveParagraph = ({
className,
...props
}: AdaptiveTextProps) => (
<AdaptiveText
as="p"
className={cn(
'text-base text-gray-700 leading-relaxed',
className
)}
{...props}
/>
);
export const AdaptiveLabel = ({
className,
...props
}: AdaptiveTextProps) => (
<AdaptiveText
as="span"
className={cn(
'text-sm font-medium text-gray-600',
className
)}
{...props}
/>
);
// Hook para memoização de textos longos
export function useAdaptiveText(text: string, isUpperCase: boolean) {
return React.useMemo(
() => isUpperCase ? text.toUpperCase() : text,
[text, isUpperCase]
);
}

View File

@ -0,0 +1,146 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/50",
"transition-all duration-200",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4",
"border bg-white p-6 shadow-lg rounded-lg",
"transition-all duration-300 ease-in-out",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -0,0 +1,40 @@
import React, { useRef, useState } from 'react';
import { Camera } from 'lucide-react';
export function AvatarUpload(): JSX.Element {
const [preview, setPreview] = useState<string>();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
return (
<div className="relative w-24 h-24">
<div
className="w-full h-full rounded-full bg-gray-100 flex items-center justify-center overflow-hidden border-2 border-gray-200"
onClick={() => fileInputRef.current?.click()}
>
{preview ? (
<img src={preview} alt="Avatar" className="w-full h-full object-cover" />
) : (
<Camera className="h-8 w-8 text-gray-400" />
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
</div>
);
}

View File

@ -0,0 +1,34 @@
import React from 'react';
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'success' | 'warning' | 'error' | 'secondary';
children: React.ReactNode;
}
const variantStyles = {
default: 'bg-primary text-primary-foreground',
success: 'bg-green-100 text-green-800',
warning: 'bg-yellow-100 text-yellow-800',
error: 'bg-red-100 text-red-800',
secondary: 'bg-gray-100 text-gray-800'
};
export function Badge({
variant = 'default',
className = '',
children,
...props
}: BadgeProps): JSX.Element {
return (
<div
className={`
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
${variantStyles[variant]}
${className}
`}
{...props}
>
{children}
</div>
);
}

View File

@ -0,0 +1,85 @@
import React from 'react';
import { useButtonTracking } from '../../hooks/useButtonTracking';
import { ButtonTrackingOptions } from '../../types/analytics';
import { cn } from '../../lib/utils';
import { EVENT_CATEGORIES } from '../../constants/analytics';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
as?: 'button' | 'span';
trackingId: string;
variant?: 'default' | 'primary' | 'secondary' | 'outline' | 'ghost' | 'link' | 'destructive';
size?: 'sm' | 'md' | 'lg';
trackingProperties?: ButtonTrackingOptions;
}
export function buttonVariants({
variant = 'default',
size = 'md',
className = '',
}: {
variant?: ButtonProps['variant'];
size?: ButtonProps['size'];
className?: string;
} = {}) {
return cn(
'inline-flex items-center justify-center px-4 py-2',
'text-sm font-medium',
'rounded-md shadow-sm',
'transition-colors duration-200',
'disabled:opacity-50 disabled:cursor-not-allowed',
{
'text-white bg-purple-600 hover:bg-purple-700': variant === 'primary' || variant === 'default',
'text-gray-700 bg-white border border-gray-300 hover:bg-gray-50': variant === 'secondary',
'text-purple-600 bg-transparent hover:bg-purple-50': variant === 'ghost',
'text-purple-600 bg-transparent hover:underline': variant === 'link',
'text-purple-600 border border-purple-600 hover:bg-purple-50': variant === 'outline',
'text-white bg-red-600 hover:bg-red-700': variant === 'destructive',
'px-3 py-1.5 text-sm': size === 'sm',
'px-4 py-2 text-base': size === 'md',
'px-6 py-3 text-lg': size === 'lg',
},
className
);
}
export function Button({
as: Component = 'button',
children,
className = '',
trackingId,
variant = 'default',
size = 'md',
trackingProperties,
onClick,
disabled,
type = 'button',
...props
}: ButtonProps) {
const { trackButtonClick } = useButtonTracking({
category: EVENT_CATEGORIES.INTERACTION,
element_type: 'button',
...trackingProperties
});
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
trackButtonClick(trackingId, {
variant,
size,
...trackingProperties,
});
onClick?.(event);
};
return (
<Component
type={Component === 'button' ? type : undefined}
className={buttonVariants({ variant, size, className })}
onClick={handleClick}
disabled={disabled}
{...props}
>
{children}
</Component>
);
}

View File

@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,81 @@
import React from 'react';
import { X, CheckCircle } from 'lucide-react';
interface ComparisonItem {
title: string;
without: string[];
with: string[];
}
interface ComparisonSectionProps {
title: string;
items: ComparisonItem[];
}
export function ComparisonSection({ title, items }: ComparisonSectionProps) {
return (
<section className="px-4 py-24 bg-white">
<div className="mx-auto max-w-7xl">
<h2 className="text-4xl font-bold text-center text-gray-900 mb-16">
{title}
</h2>
<div className="grid md:grid-cols-2 gap-8">
{/* Sem Leiturama */}
<div className="p-8 bg-gray-50 rounded-xl border border-gray-200">
<div className="flex items-center gap-3 mb-8">
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<X className="h-6 w-6 text-red-500" />
</div>
<h3 className="text-xl font-bold text-gray-900">
Sem Leiturama
</h3>
</div>
{items.map((category, index) => (
<div key={index} className="mb-8 last:mb-0">
<h4 className="font-bold text-gray-900 mb-4">{category.title}</h4>
<ul className="space-y-3">
{category.without.map((item, idx) => (
<li key={idx} className="flex items-start gap-2">
<X className="h-5 w-5 text-red-500 flex-shrink-0 mt-1" />
<span className="text-gray-600">{item}</span>
</li>
))}
</ul>
</div>
))}
</div>
{/* Com Leiturama */}
<div className="p-8 bg-gradient-to-br from-purple-50 to-blue-50 rounded-xl
border-2 border-purple-200 transform hover:scale-[1.02] transition-transform">
<div className="flex items-center gap-3 mb-8">
<div className="w-12 h-12 bg-gradient-to-r from-purple-600 to-blue-500
rounded-full flex items-center justify-center">
<CheckCircle className="h-6 w-6 text-white" />
</div>
<h3 className="text-xl font-bold text-gray-900">
Com Leiturama
</h3>
</div>
{items.map((category, index) => (
<div key={index} className="mb-8 last:mb-0">
<h4 className="font-bold text-gray-900 mb-4">{category.title}</h4>
<ul className="space-y-3">
{category.with.map((item, idx) => (
<li key={idx} className="flex items-start gap-2">
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-1" />
<span className="text-gray-600">{item}</span>
</li>
))}
</ul>
</div>
))}
</div>
</div>
</div>
</section>
);
}

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