mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-18 14:27:51 +00:00
Compare commits
188 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc0ef9ba27 | ||
|
|
f883a6e9c2 | ||
|
|
2ff79ced53 | ||
|
|
374ac90a3b | ||
|
|
cdb98eb61d | ||
|
|
c53fbeb444 | ||
|
|
c2bcfe1e3f | ||
|
|
bb85c83c5b | ||
|
|
2175458186 | ||
|
|
190777dcd0 | ||
|
|
8c6e6aedd3 | ||
|
|
8b45fe72e7 | ||
|
|
ccbac66d28 | ||
|
|
46e8ba0312 | ||
|
|
c94c46f5c1 | ||
|
|
28ac3ef8cc | ||
|
|
756335f78f | ||
|
|
9d303b0c7a | ||
|
|
0eafbd5350 | ||
|
|
4609217fb7 | ||
|
|
1c6aa56b32 | ||
|
|
2929946499 | ||
|
|
1bc307d599 | ||
|
|
e9005e429f | ||
|
|
b767d60c50 | ||
|
|
63498e92c6 | ||
|
|
cc45bb974d | ||
|
|
da62f5e722 | ||
|
|
d1e44f84b7 | ||
|
|
f602f4c666 | ||
|
|
206f7bcb30 | ||
|
|
478ca2441d | ||
|
|
7a0bc3f8ca | ||
|
|
7bb2a9a1b7 | ||
|
|
c029aab50f | ||
|
|
18bc42d280 | ||
|
|
14c71062f1 | ||
|
|
be340d132e | ||
|
|
75c1e6f9f2 | ||
|
|
66866602e7 | ||
|
|
f3fbdb8228 | ||
|
|
7e93a59609 | ||
|
|
69dbb5fa48 | ||
|
|
9e3f7a7c31 | ||
|
|
821b6ca9ec | ||
|
|
abe4ce86d4 | ||
|
|
ba93f3ef29 | ||
|
|
fa8073dcee | ||
|
|
45a4b1ba24 | ||
|
|
13536790fe | ||
|
|
9f7ea648fe | ||
|
|
e81dc5bedf | ||
|
|
4790d9788b | ||
|
|
d949587c44 | ||
|
|
bc2f120700 | ||
|
|
d35565dee4 | ||
|
|
94835a427b | ||
|
|
dadcb048bb | ||
|
|
e5204e0430 | ||
|
|
51b8fb4088 | ||
|
|
dd9e2f4dd3 | ||
|
|
59a7adfeee | ||
|
|
ccacf76d9a | ||
|
|
c5a3017a7c | ||
|
|
90506ca894 | ||
|
|
62594f5e62 | ||
|
|
e154dd2372 | ||
|
|
ea5c5e87f1 | ||
|
|
229a1bffbb | ||
|
|
e4c225ebd7 | ||
|
|
7880ce8dda | ||
|
|
a0cfccc14d | ||
|
|
663c2fb8ff | ||
|
|
f1f2906d09 | ||
|
|
ce845607f9 | ||
|
|
198cad0047 | ||
|
|
0c2a63dcd3 | ||
|
|
5d4c9b6d49 | ||
|
|
f37f8f2f6d | ||
|
|
350a66bb9e | ||
|
|
e1a99f32f5 | ||
|
|
18cf6a2495 | ||
|
|
6a1a471ce5 | ||
|
|
bcbdd07a41 | ||
|
|
98411b2aa1 | ||
|
|
41a225d460 | ||
|
|
09c4894a1c | ||
|
|
bd58cbad7d | ||
|
|
a975e2486b | ||
|
|
546690fbc8 | ||
|
|
2852b889b2 | ||
|
|
3cdd136a4e | ||
|
|
33b9b38ff4 | ||
|
|
1bcb0a9c37 | ||
|
|
d2567ac478 | ||
|
|
953b7a78d0 | ||
|
|
21f7aa7c40 | ||
|
|
6e9d847c77 | ||
|
|
1542572be4 | ||
|
|
75d9d4635b | ||
|
|
a7612879bf | ||
|
|
00cd9edb1c | ||
|
|
a45ebd2719 | ||
|
|
6398e2ac81 | ||
|
|
0ccea7c7b9 | ||
|
|
9fa7b9732d | ||
|
|
6478d20d62 | ||
|
|
0e2215b6ad | ||
|
|
1ea1b3e841 | ||
|
|
9b023e7ef9 | ||
|
|
b8562bfda1 | ||
|
|
c422a6186e | ||
|
|
634fa6fb48 | ||
|
|
9840fe76b0 | ||
|
|
745f8de40e | ||
|
|
e23914657f | ||
|
|
b008b4134b | ||
|
|
3e7bf811fe | ||
|
|
087104a7f5 | ||
|
|
de28dea3b5 | ||
|
|
4765be66da | ||
|
|
c562ae570a | ||
|
|
f4965db3e6 | ||
|
|
933358483e | ||
|
|
66d401f98f | ||
|
|
a3b522d283 | ||
|
|
5812d46049 | ||
|
|
007441c285 | ||
|
|
c776efaec9 | ||
|
|
6cf273126e | ||
|
|
ec97f640f9 | ||
|
|
a8c332d442 | ||
|
|
4d09386d96 | ||
|
|
cc23c83c05 | ||
|
|
521a99a5c2 | ||
|
|
563a62a517 | ||
|
|
3ef8c99062 | ||
|
|
d5c75ab6c2 | ||
|
|
28fa4d70e6 | ||
|
|
02119a62d1 | ||
|
|
7087a87ece | ||
|
|
fbeeace8bb | ||
|
|
961fce03f6 | ||
|
|
8af9950ed7 | ||
|
|
7e3b4551ec | ||
|
|
03732de610 | ||
|
|
3701e692f1 | ||
|
|
4f3b80246f | ||
|
|
0b8c050bd7 | ||
|
|
1a3a603ff6 | ||
|
|
0661f2c225 | ||
|
|
6531a9282c | ||
|
|
1132f7438d | ||
|
|
797967ca5b | ||
|
|
6f8e890e86 | ||
|
|
f70585e9c1 | ||
|
|
5573274ad4 | ||
|
|
9ecf46a9ac | ||
|
|
6e7c85e853 | ||
|
|
1e181785b4 | ||
|
|
eb77476d51 | ||
|
|
8e8936e9f4 | ||
|
|
dea81a5711 | ||
|
|
89c325cc7c | ||
|
|
7430ae15a8 | ||
|
|
c8420421eb | ||
|
|
441b55535e | ||
|
|
4b431358e0 | ||
|
|
c0aa725fa6 | ||
|
|
fca293c4fc | ||
|
|
beef3da647 | ||
|
|
5952d83ec8 | ||
|
|
f1a7cd8730 | ||
|
|
e9e72677a4 | ||
|
|
fd734a5c26 | ||
|
|
70953ab57a | ||
|
|
fd50d59d3c | ||
|
|
d8c665d48e | ||
|
|
39bbc2c827 | ||
|
|
3176e95a75 | ||
|
|
6f03e72a22 | ||
|
|
abf0033590 | ||
|
|
4cc6ab641e | ||
|
|
5193ba95f4 | ||
|
|
b7d30fdc06 | ||
|
|
6afb728dce | ||
|
|
543ed7532b | ||
|
|
677ee422c4 |
@ -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
38
.cursorignore
Normal 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
|
||||
@ -1,4 +0,0 @@
|
||||
VITE_SUPABASE_URL=your_supabase_url
|
||||
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
VITE_RESEND_API_KEY=your_resend_api_key
|
||||
VITE_APP_URL=http://localhost:5173
|
||||
@ -1,4 +0,0 @@
|
||||
VITE_SUPABASE_URL=https://bsjlbnyslxzsdwxvkaap.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJzamxibnlzbHh6c2R3eHZrYWFwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzQ2MzUzNzYsImV4cCI6MjA1MDIxMTM3Nn0.ygEUrAu2ZnCkfgS4-k4Puvk7ywkn3U7Bnzh7BSOQWFo
|
||||
VITE_RESEND_API_KEY=GEoM_cVt4qyBFVkngJWi8wBrMWOiPMUAuxuFGykcP0A
|
||||
VITE_APP_URL=https://historiasmagicas.netlify.app/
|
||||
@ -7,17 +7,25 @@
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended"
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react/jsx-runtime"
|
||||
],
|
||||
"ignorePatterns": ["dist", ".eslintrc.json"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["react-refresh"],
|
||||
"plugins": ["react-refresh", "@typescript-eslint", "react"],
|
||||
"rules": {
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ "allowConstantExport": true }
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": "warn",
|
||||
"no-unused-vars": "warn"
|
||||
"@typescript-eslint/no-unused-vars": ["warn"],
|
||||
"react/prop-types": "off",
|
||||
"no-console": "warn"
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -18,7 +18,7 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: seu-registry.com/historias-magicas
|
||||
images: seu-registry.com/leiturama
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
@ -43,8 +43,8 @@ jobs:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=seu-registry.com/historias-magicas:buildcache
|
||||
cache-to: type=registry,ref=seu-registry.com/historias-magicas:buildcache,mode=max
|
||||
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'
|
||||
@ -55,4 +55,4 @@ jobs:
|
||||
key: ${{ secrets.DEPLOY_KEY }}
|
||||
script: |
|
||||
cd /opt/portainer
|
||||
docker stack deploy -c portainer-stack.yml historias-magicas
|
||||
docker stack deploy -c portainer-stack.yml leiturama
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -9,6 +9,8 @@ lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist/
|
||||
build/
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
@ -26,6 +28,10 @@ dist-ssr
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env*
|
||||
.env.*
|
||||
.env.production
|
||||
.env.development
|
||||
|
||||
# Backup files
|
||||
*copy*
|
||||
|
||||
13
.prettierrc
Normal file
13
.prettierrc
Normal 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
|
||||
}
|
||||
353
CHANGELOG.md
353
CHANGELOG.md
@ -5,43 +5,328 @@ 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
|
||||
- Configuração Docker para ambiente de produção
|
||||
- Pipeline de CI/CD no Gitea Actions
|
||||
- Integração com Redis para cache
|
||||
- Healthcheck da aplicação
|
||||
- Adiciona seções:
|
||||
- Hero com CTA e social proof
|
||||
- Problemas e Soluções
|
||||
- Como a Magia Acontece
|
||||
- Comparação antes/depois
|
||||
- Benefícios Mágicos em layout horizontal
|
||||
- Testimoniais
|
||||
- Planos e preços
|
||||
- FAQ
|
||||
- CTA final
|
||||
- Footer
|
||||
|
||||
### Técnico
|
||||
- Dockerfile com multi-stage build para otimização
|
||||
- Configuração de registry no Gitea
|
||||
- Cache de histórias com Redis
|
||||
- Scripts de deploy e monitoramento
|
||||
- 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
|
||||
- Atualização do next.config.js para suporte standalone
|
||||
- Adaptação da API para usar Redis cache
|
||||
- Configuração de redes Docker
|
||||
- Otimiza UX/UI com:
|
||||
- Animações suaves
|
||||
- Gradientes modernos
|
||||
- Layout responsivo
|
||||
- Elementos interativos
|
||||
- Social proof estratégico
|
||||
- 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
|
||||
|
||||
### Segurança
|
||||
- Implementação de healthchecks
|
||||
- Configuração de redes isoladas
|
||||
- Proteção de variáveis de ambiente
|
||||
### 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
|
||||
|
||||
15
Dockerfile.dev
Normal file
15
Dockerfile.dev
Normal 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
51
PROJECT_CONTEXT.md
Normal 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
|
||||
10
README.md
10
README.md
@ -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
|
||||
@ -32,17 +32,16 @@ Histórias Mágicas é uma aplicação web desenvolvida em React que permite que
|
||||
- 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
|
||||
@ -51,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
|
||||
|
||||
32
create_phonics_policies.sql
Normal file
32
create_phonics_policies.sql
Normal 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
31
docker-compose.dev.yml
Normal 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:
|
||||
@ -1,8 +1,8 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
historias-magicas:
|
||||
image: ${REGISTRY}/historias-magicas:${TAG}
|
||||
leiturama:
|
||||
image: ${REGISTRY}/leiturama:${TAG}
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||
@ -14,10 +14,10 @@ services:
|
||||
deploy:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.historias-magicas.rule=Host(`${DOMAIN}`)"
|
||||
- "traefik.http.routers.historias-magicas.entrypoints=websecure"
|
||||
- "traefik.http.routers.historias-magicas.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.historias-magicas.loadbalancer.server.port=3000"
|
||||
- "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:
|
||||
@ -27,7 +27,5 @@ services:
|
||||
condition: on-failure
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
network_public:
|
||||
external: true
|
||||
redis-network:
|
||||
external: true
|
||||
15
docs/accessibility-features.md
Normal file
15
docs/accessibility-features.md
Normal 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
248
docs/arquitetura.md
Normal 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
315
docs/banco-dados.md
Normal 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
117
docs/controles-texto.md
Normal 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
246
docs/desenvolvimento.md
Normal 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
177
docs/exercicios.md
Normal 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
166
docs/geracao-historia.md
Normal 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
151
docs/gravacao-audio.md
Normal 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
90
docs/modo-foco.md
Normal 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
150
docs/processamento-audio.md
Normal 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
25
docs/voice-features.md
Normal 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
|
||||
@ -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">
|
||||
|
||||
41
netlify.toml
41
netlify.toml
@ -5,15 +5,52 @@
|
||||
[build.environment]
|
||||
NODE_VERSION = "18"
|
||||
VITE_SUPABASE_URL = "https://bsjlbnyslxzsdwxvkaap.supabase.co"
|
||||
VITE_SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJzamxibnlzbHh6c2R3eHZrYWFwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzQ2MzUzNzYsImV4cCI6MjA1MDIxMTM3Nn0.ygEUrAu2ZnCkfgS4-k4Puvk7ywkn3U7Bnzh7BSOQWFo"
|
||||
VITE_RESEND_API_KEY = "GEoM_cVt4qyBFVkngJWi8wBrMWOiPMUAuxuFGykcP0A"
|
||||
VITE_APP_URL = "https://historiasmagicas.netlify.app/"
|
||||
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
|
||||
|
||||
@ -4,8 +4,17 @@ const nextConfig = {
|
||||
images: {
|
||||
domains: [
|
||||
'oaidalleapiprodscus.blob.core.windows.net',
|
||||
// outros domínios necessários
|
||||
'leiturama.ai',
|
||||
'localhost',
|
||||
'bsjlbnyslxzsdwxvkaap.supabase.co',
|
||||
'leiturama.netlify.app'
|
||||
],
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
minimumCacheTTL: 60,
|
||||
},
|
||||
experimental: {
|
||||
optimizeCss: true,
|
||||
optimizeImages: true,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
6105
package-lock.json
generated
6105
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
48
package.json
48
package.json
@ -10,29 +10,69 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
||||
"docker:build": "docker build -t historias-magicas .",
|
||||
"docker:run": "docker run -p 3000:3000 historias-magicas",
|
||||
"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",
|
||||
"recharts": "^2.15.0",
|
||||
"recharts": "^2.15.1",
|
||||
"resend": "^3.2.0",
|
||||
"tailwind-merge": "^2.5.5"
|
||||
"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",
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
historias-magicas:
|
||||
image: ${REGISTRY}/historias-magicas:${TAG}
|
||||
leiturama:
|
||||
image: ${REGISTRY}/leiturama:${TAG}
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||
@ -15,10 +15,10 @@ services:
|
||||
deploy:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.historias-magicas.rule=Host(`${DOMAIN}`)"
|
||||
- "traefik.http.routers.historias-magicas.entrypoints=websecure"
|
||||
- "traefik.http.routers.historias-magicas.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.historias-magicas.loadbalancer.server.port=3000"
|
||||
- "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:
|
||||
|
||||
1
public/images/director-1.webp
Normal file
1
public/images/director-1.webp
Normal file
@ -0,0 +1 @@
|
||||
data:image/webp;base64,UklGRlIAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAABBxAREYiI/gcAAABWUDggGAAAADABAJ0BKgEAAQABABwlpAADcAD+/gbQAA==
|
||||
1
public/images/evidence-based.webp
Normal file
1
public/images/evidence-based.webp
Normal file
@ -0,0 +1 @@
|
||||
data:image/webp;base64,UklGRlIAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAABBxAREYiI/gcAAABWUDggGAAAADABAJ0BKgEAAQABABwlpAADcAD+/gbQAA==
|
||||
1
public/images/parent-1.webp
Normal file
1
public/images/parent-1.webp
Normal file
@ -0,0 +1 @@
|
||||
data:image/webp;base64,UklGRlIAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAABBxAREYiI/gcAAABWUDggGAAAADABAJ0BKgEAAQABABwlpAADcAD+/gbQAA==
|
||||
1
public/images/teacher-1.webp
Normal file
1
public/images/teacher-1.webp
Normal file
@ -0,0 +1 @@
|
||||
data:image/webp;base64,UklGRlIAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAABBxAREYiI/gcAAABWUDggGAAAADABAJ0BKgEAAQABABwlpAADcAD+/gbQAA==
|
||||
8
public/patterns/dots.svg
Normal file
8
public/patterns/dots.svg
Normal 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 |
@ -11,6 +11,9 @@ 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'
|
||||
@ -87,6 +90,7 @@ export function App() {
|
||||
return (
|
||||
<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
|
||||
@ -122,6 +126,7 @@ export function App() {
|
||||
onStorySelect={handleStorySelect}
|
||||
/>
|
||||
)}
|
||||
<Toaster />
|
||||
</div>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@ -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!
|
||||
|
||||
39
src/components/analytics/GoogleTagManager.tsx
Normal file
39
src/components/analytics/GoogleTagManager.tsx
Normal 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;
|
||||
}
|
||||
127
src/components/analytics/PageTracker.tsx
Normal file
127
src/components/analytics/PageTracker.tsx
Normal 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;
|
||||
}
|
||||
330
src/components/analytics/README.md
Normal file
330
src/components/analytics/README.md
Normal 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
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
@ -1,10 +1,20 @@
|
||||
import React from 'react';
|
||||
import { processAudio } from '../../services/audioService';
|
||||
import { Button } from '../ui/button';
|
||||
import { EVENT_CATEGORIES } from '../../constants/analytics';
|
||||
|
||||
export function AudioUploader(): JSX.Element {
|
||||
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 [transcription, setTranscription] = React.useState<string>();
|
||||
const [error, setError] = React.useState<string>();
|
||||
|
||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@ -15,16 +25,18 @@ export function AudioUploader(): JSX.Element {
|
||||
setIsProcessing(true);
|
||||
setError(undefined);
|
||||
|
||||
const response = await processAudio(file);
|
||||
const response = await processAudio(file, storyId);
|
||||
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
} else {
|
||||
setTranscription(response.transcription);
|
||||
onError?.(response.error);
|
||||
} else if (response.transcription) {
|
||||
onUploadComplete?.(response.transcription);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Erro ao processar áudio. Tente novamente.');
|
||||
console.error(err);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Erro ao processar áudio';
|
||||
setError(errorMessage);
|
||||
onError?.(errorMessage);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
@ -46,6 +58,12 @@ export function AudioUploader(): JSX.Element {
|
||||
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>
|
||||
@ -53,14 +71,7 @@ export function AudioUploader(): JSX.Element {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-500 text-sm">{error}</p>
|
||||
)}
|
||||
|
||||
{transcription && (
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="font-medium mb-2">Transcrição:</h3>
|
||||
<p>{transcription}</p>
|
||||
</div>
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
import React, { useState } from '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';
|
||||
@ -30,6 +35,20 @@ export function LoginForm({ userType, onLogin, onRegisterClick }: LoginFormProps
|
||||
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();
|
||||
@ -46,10 +65,21 @@ export function LoginForm({ userType, onLogin, onRegisterClick }: LoginFormProps
|
||||
|
||||
console.log('Resposta do Supabase:', { data, error });
|
||||
|
||||
if (error) throw error;
|
||||
if (error) {
|
||||
errorTracking.trackApiError(error, '/auth/sign-in', 'POST', { email, userType });
|
||||
formTracking.trackFormError('auth_error', error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data.user) {
|
||||
throw new Error('Usuário não encontrado');
|
||||
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;
|
||||
@ -58,9 +88,24 @@ export function LoginForm({ userType, onLogin, onRegisterClick }: LoginFormProps
|
||||
console.log('Role atual:', userRole);
|
||||
|
||||
if (userRole !== userType) {
|
||||
throw new Error(`Este não é um login de ${userTypeLabels[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');
|
||||
@ -75,18 +120,36 @@ export function LoginForm({ userType, onLogin, onRegisterClick }: LoginFormProps
|
||||
throw new Error('Tipo de usuário inválido');
|
||||
}
|
||||
|
||||
trackEvent('auth', 'login_success', 'form');
|
||||
} catch (err) {
|
||||
console.error('Erro no login:', err);
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('Email ou senha incorretos');
|
||||
}
|
||||
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="min-h-screen bg-gradient-to-b from-purple-50 to-white py-12">
|
||||
<div className="max-w-md mx-auto px-4">
|
||||
@ -119,7 +182,9 @@ export function LoginForm({ userType, onLogin, onRegisterClick }: LoginFormProps
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
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>
|
||||
@ -134,7 +199,9 @@ export function LoginForm({ userType, onLogin, onRegisterClick }: LoginFormProps
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
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
|
||||
@ -151,32 +218,49 @@ export function LoginForm({ userType, onLogin, onRegisterClick }: LoginFormProps
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
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 disabled:opacity-50"
|
||||
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" />
|
||||
<LogIn className="h-5 w-5 mr-2" />
|
||||
Entrar
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{onRegisterClick && (
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Ainda não tem uma conta?{' '}
|
||||
<button
|
||||
<Button
|
||||
trackingId="register-link"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={onRegisterClick}
|
||||
className="text-purple-600 hover:text-purple-500 font-medium"
|
||||
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>
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -13,9 +13,6 @@ export function ProtectedRoute({ children, allowedRoles = [] }: ProtectedRoutePr
|
||||
const { user, loading, userRole } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
console.log('ProtectedRoute - User:', user?.user_metadata);
|
||||
console.log('ProtectedRoute - UserRole do contexto:', userRole);
|
||||
console.log('ProtectedRoute - Roles permitidas:', allowedRoles);
|
||||
|
||||
if (loading) {
|
||||
return <div>Carregando...</div>;
|
||||
@ -28,7 +25,6 @@ export function ProtectedRoute({ children, allowedRoles = [] }: ProtectedRoutePr
|
||||
|
||||
// Pegar o role diretamente dos metadados do usuário
|
||||
const currentRole = user.user_metadata?.role;
|
||||
console.log('ProtectedRoute - Role dos metadados:', currentRole);
|
||||
|
||||
// Se não houver roles requeridas, permite acesso
|
||||
if (allowedRoles.length === 0) {
|
||||
|
||||
160
src/components/dashboard/DashboardMetrics.tsx
Normal file
160
src/components/dashboard/DashboardMetrics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/components/dashboard/MetricCard.tsx
Normal file
45
src/components/dashboard/MetricCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
239
src/components/dashboard/MetricsChart.tsx
Normal file
239
src/components/dashboard/MetricsChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
281
src/components/dashboard/WritingMetricsChart.tsx
Normal file
281
src/components/dashboard/WritingMetricsChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
src/components/dashboard/WritingMetricsSection.tsx
Normal file
143
src/components/dashboard/WritingMetricsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
219
src/components/exercises/PronunciationPractice.tsx
Normal file
219
src/components/exercises/PronunciationPractice.tsx
Normal 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 há 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>
|
||||
);
|
||||
}
|
||||
203
src/components/exercises/SentenceCompletion.tsx
Normal file
203
src/components/exercises/SentenceCompletion.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
294
src/components/exercises/WordFormation.tsx
Normal file
294
src/components/exercises/WordFormation.tsx
Normal 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 há 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>
|
||||
);
|
||||
}
|
||||
@ -12,7 +12,7 @@ export function Header() {
|
||||
<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">Histórias Mágicas</span>
|
||||
<span className="font-semibold text-gray-900">Leiturama</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
@ -4,90 +4,26 @@ import {
|
||||
BookOpen, ArrowRight, School, Users, Shield,
|
||||
Sparkles, BookCheck, Play, CheckCircle, Star,
|
||||
GraduationCap, BarChart, Brain, X, Check,
|
||||
Pencil,
|
||||
Wand,
|
||||
Mic,
|
||||
Share2
|
||||
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';
|
||||
|
||||
// Components
|
||||
const FeatureCard = ({ icon, title, description }: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
}) => (
|
||||
<div className="p-6 rounded-xl border border-gray-200 hover:shadow-lg transition bg-white">
|
||||
<div className="w-12 h-12 rounded-lg bg-purple-100 flex items-center justify-center mb-4">
|
||||
<div className="text-purple-600">{icon}</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
<p className="text-gray-600">{description}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const StatCard = ({ number, label }: { number: string; label: string }) => (
|
||||
<div className="p-6">
|
||||
<div className="text-4xl font-bold mb-2">{number}</div>
|
||||
<div className="text-purple-200">{label}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const TestimonialCard = ({ quote, author, role, image }: {
|
||||
quote: string;
|
||||
author: string;
|
||||
role: string;
|
||||
image: string;
|
||||
}) => (
|
||||
<div className="p-6 rounded-xl bg-white shadow-md">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<img src={image} alt={author} className="w-12 h-12 rounded-full" />
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900">{author}</div>
|
||||
<div className="text-sm text-gray-600">{role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 italic">"{quote}"</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PriceCard = ({
|
||||
plan,
|
||||
price,
|
||||
description,
|
||||
features,
|
||||
highlighted = false
|
||||
}: {
|
||||
plan: string;
|
||||
price: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
highlighted?: boolean;
|
||||
}) => (
|
||||
<div className={`p-6 rounded-xl border ${
|
||||
highlighted ? 'border-purple-600 shadow-lg' : 'border-gray-200'
|
||||
}`}>
|
||||
<div className="text-xl font-semibold text-gray-900 mb-2">{plan}</div>
|
||||
<div className="text-3xl font-bold text-gray-900 mb-2">
|
||||
R$ {price}<span className="text-sm font-normal text-gray-600">/mês</span>
|
||||
</div>
|
||||
<p className="text-gray-600 mb-6">{description}</p>
|
||||
<ul className="space-y-3 mb-6">
|
||||
{features.map((feature, index) => (
|
||||
<li key={index} className="flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-purple-600" />
|
||||
<span className="text-gray-600">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button className={`w-full py-2 px-4 rounded-lg transition ${
|
||||
highlighted
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'border border-purple-600 text-purple-600 hover:bg-purple-50'
|
||||
}`}>
|
||||
Começar agora
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
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();
|
||||
@ -100,7 +36,7 @@ export function HomePage() {
|
||||
const handleSchoolLogin = () => navigate('/login/school');
|
||||
const handleTeacherLogin = () => navigate('/login/teacher');
|
||||
const handleStudentLogin = () => navigate('/login/student');
|
||||
const handleSchoolRegister = () => navigate('/register/school');
|
||||
const handleSchoolRegister = () => navigate('/register');
|
||||
const handleDemo = () => navigate('/demo');
|
||||
|
||||
return (
|
||||
@ -111,54 +47,89 @@ export function HomePage() {
|
||||
<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">Histórias Mágicas</span>
|
||||
<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
|
||||
<Button
|
||||
onClick={handleLoginClick}
|
||||
className="text-gray-600 hover:text-gray-900 px-3 py-2"
|
||||
variant="ghost"
|
||||
trackingId="nav_login_button"
|
||||
trackingProperties={{
|
||||
category: EVENT_CATEGORIES.NAVIGATION,
|
||||
action: 'click',
|
||||
label: 'login_dropdown'
|
||||
}}
|
||||
>
|
||||
Entrar
|
||||
</button>
|
||||
</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
|
||||
<Button
|
||||
onClick={handleSchoolLogin}
|
||||
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-purple-50"
|
||||
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
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleTeacherLogin}
|
||||
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-purple-50"
|
||||
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
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleStudentLogin}
|
||||
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-purple-50"
|
||||
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>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
{/*
|
||||
<Button
|
||||
onClick={handleSchoolRegister}
|
||||
className="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition"
|
||||
variant="primary"
|
||||
trackingId="nav_register_button"
|
||||
trackingProperties={{
|
||||
category: EVENT_CATEGORIES.NAVIGATION,
|
||||
action: 'click',
|
||||
label: 'register_school'
|
||||
}}
|
||||
>
|
||||
Cadastrar Escola
|
||||
</button>
|
||||
</Button>
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<div className="pt-32 pb-20 px-4 sm:px-6 lg:px-8">
|
||||
<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>
|
||||
@ -174,20 +145,38 @@ export function HomePage() {
|
||||
aprendizado personalizadas através de histórias interativas.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<button
|
||||
onClick={handleSchoolRegister}
|
||||
className="bg-purple-600 text-white px-8 py-4 rounded-xl hover:bg-purple-700 transition flex items-center justify-center gap-2 text-lg font-semibold"
|
||||
<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'
|
||||
}}
|
||||
>
|
||||
Começar Gratuitamente
|
||||
Entre em contato
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDemo}
|
||||
className="border-2 border-purple-600 text-purple-600 px-8 py-4 rounded-xl hover:bg-purple-50 transition text-lg font-semibold flex items-center justify-center gap-2"
|
||||
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>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-8 flex items-center gap-4">
|
||||
<div className="flex -space-x-2">
|
||||
@ -213,14 +202,22 @@ export function HomePage() {
|
||||
alt="Platform demo"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
<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>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -228,7 +225,7 @@ export function HomePage() {
|
||||
</div>
|
||||
|
||||
{/* Student Journey Section */}
|
||||
<div className="py-20 bg-gradient-to-b from-purple-50 to-white">
|
||||
<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">
|
||||
@ -239,136 +236,73 @@ export function HomePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/* Timeline Line */}
|
||||
<div className="hidden md:block absolute left-1/2 transform -translate-x-1/2 h-full w-0.5 bg-purple-200" />
|
||||
<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."
|
||||
/>
|
||||
|
||||
{/* Timeline Items */}
|
||||
{[
|
||||
{
|
||||
icon: <Pencil className="w-6 h-6" />,
|
||||
title: "Criação Personalizada",
|
||||
description: "O aluno cria uma história baseada em seus interesses e características pessoais",
|
||||
image: "/journey/create-story.png"
|
||||
},
|
||||
{
|
||||
icon: <Wand className="w-6 h-6" />,
|
||||
title: "Geração por IA",
|
||||
description: "Nossa IA avançada gera uma história única e personalizada",
|
||||
image: "/journey/ai-generation.png"
|
||||
},
|
||||
{
|
||||
icon: <Mic className="w-6 h-6" />,
|
||||
title: "Gravação de Áudio",
|
||||
description: "O aluno grava sua voz lendo a história criada",
|
||||
image: "/journey/audio-recording.png"
|
||||
},
|
||||
{
|
||||
icon: <BarChart className="w-6 h-6" />,
|
||||
title: "Análise de Leitura",
|
||||
description: "A IA analisa a leitura e fornece feedback detalhado sobre o desempenho",
|
||||
image: "/journey/reading-analysis.png"
|
||||
},
|
||||
{
|
||||
icon: <Share2 className="w-6 h-6" />,
|
||||
title: "Compartilhamento de Resultados",
|
||||
description: "Dados e insights são compartilhados com pais, professores e escola",
|
||||
image: "/journey/share-results.png"
|
||||
}
|
||||
].map((item, index) => (
|
||||
<div key={index} className={`mb-12 md:mb-24 relative ${
|
||||
index % 2 === 0 ? 'md:text-right' : ''
|
||||
}`}>
|
||||
<div className={`flex items-center gap-8 ${
|
||||
index % 2 === 0 ? 'md:flex-row-reverse' : ''
|
||||
}`}>
|
||||
{/* Content Side */}
|
||||
<div className="flex-1">
|
||||
<div className={`bg-white rounded-xl shadow-lg p-6 transform transition-all duration-300 hover:scale-105 ${
|
||||
index % 2 === 0 ? 'md:ml-auto' : ''
|
||||
}`}>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 rounded-full bg-purple-100 flex items-center justify-center text-purple-600">
|
||||
{item.icon}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900">
|
||||
{item.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ProcessStep
|
||||
number={2}
|
||||
title="Personalize os personagens"
|
||||
description="Crie personagens que seu filho vai adorar, com características únicas e cativantes."
|
||||
/>
|
||||
|
||||
{/* Timeline Marker */}
|
||||
<div className="hidden md:flex items-center justify-center">
|
||||
<div className="w-12 h-12 rounded-full bg-purple-600 text-white flex items-center justify-center font-bold">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
<ProcessStep
|
||||
number={3}
|
||||
title="A IA cria a história mágica"
|
||||
description="Nossa IA educacional gera uma história personalizada em segundos."
|
||||
/>
|
||||
|
||||
{/* Image Side */}
|
||||
<div className="flex-1 hidden md:block">
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.title}
|
||||
className="rounded-xl shadow-lg w-full max-w-md mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Results Summary */}
|
||||
<div className="mt-16 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">
|
||||
{[
|
||||
{
|
||||
number: "95%",
|
||||
label: "Melhoria na fluência de leitura"
|
||||
},
|
||||
{
|
||||
number: "87%",
|
||||
label: "Aumento no engajamento"
|
||||
},
|
||||
{
|
||||
number: "92%",
|
||||
label: "Satisfação dos pais"
|
||||
},
|
||||
{
|
||||
number: "3x",
|
||||
label: "Mais histórias lidas por aluno"
|
||||
}
|
||||
].map((stat, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<div className="text-3xl font-bold text-purple-600 mb-2">
|
||||
{stat.number}
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm">
|
||||
{stat.label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Features Grid */}
|
||||
<div className="py-20 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
{/* 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>
|
||||
@ -378,217 +312,165 @@ export function HomePage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<FeatureCard
|
||||
icon={<Brain />}
|
||||
<FeatureCard
|
||||
icon={Brain}
|
||||
title="IA Adaptativa"
|
||||
description="Conteúdo que se adapta ao ritmo e estilo de aprendizagem de cada aluno"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<BookOpen />}
|
||||
<FeatureCard
|
||||
icon={BookOpen}
|
||||
title="Histórias Interativas"
|
||||
description="Narrativas envolventes que tornam o aprendizado mais divertido e eficaz"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<BarChart />}
|
||||
<FeatureCard
|
||||
icon={BarChart}
|
||||
title="Analytics Avançado"
|
||||
description="Insights detalhados sobre o progresso e engajamento dos alunos"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Users />}
|
||||
<FeatureCard
|
||||
icon={Users}
|
||||
title="Colaboração"
|
||||
description="Ferramentas para professores trabalharem juntos e compartilharem recursos"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Shield />}
|
||||
<FeatureCard
|
||||
icon={Shield}
|
||||
title="Ambiente Seguro"
|
||||
description="Proteção de dados e conteúdo adequado para todas as idades"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<GraduationCap />}
|
||||
<FeatureCard
|
||||
icon={GraduationCap}
|
||||
title="Suporte Pedagógico"
|
||||
description="Recursos e orientações para maximizar o potencial de aprendizagem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Before & After Section */}
|
||||
<div className="py-20 bg-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">
|
||||
Transforme a Experiência de Aprendizagem
|
||||
</h2>
|
||||
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||
Veja como o Histórias Mágicas revoluciona o ensino
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-16">
|
||||
{/* Before */}
|
||||
<div className="rounded-2xl bg-red-50 p-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<X className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-gray-900">Antes</h3>
|
||||
</div>
|
||||
<ul className="space-y-4">
|
||||
{[
|
||||
'Conteúdo padronizado que não atende necessidades individuais',
|
||||
'Alunos desmotivados com material didático tradicional',
|
||||
'Professores sobrecarregados com correções manuais',
|
||||
'Dificuldade em acompanhar o progresso individual',
|
||||
'Baixo engajamento nas atividades de leitura e escrita',
|
||||
'Falta de dados para tomada de decisão pedagógica'
|
||||
].map((item, index) => (
|
||||
<li key={index} className="flex items-start gap-3">
|
||||
<div className="mt-1">
|
||||
<X className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
<span className="text-gray-600">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* After */}
|
||||
<div className="rounded-2xl bg-green-50 p-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-12 h-12 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<Check className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-gray-900">Depois</h3>
|
||||
</div>
|
||||
<ul className="space-y-4">
|
||||
{[
|
||||
'Histórias adaptativas que evoluem com cada aluno',
|
||||
'Estudantes engajados com conteúdo personalizado',
|
||||
'Correção automática com feedback instantâneo',
|
||||
'Dashboard em tempo real do progresso individual',
|
||||
'Aumento de 300% no engajamento com leitura',
|
||||
'Insights precisos para intervenções pedagógicas'
|
||||
].map((item, index) => (
|
||||
<li key={index} className="flex items-start gap-3">
|
||||
<div className="mt-1">
|
||||
<Check className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<span className="text-gray-600">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Results Preview */}
|
||||
<div className="md:col-span-2 mt-8">
|
||||
<div className="bg-white rounded-xl shadow-lg p-8">
|
||||
<div className="grid md:grid-cols-3 gap-8 text-center">
|
||||
<div>
|
||||
<div className="text-4xl font-bold text-purple-600 mb-2">300%</div>
|
||||
<p className="text-gray-600">Aumento no engajamento</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-4xl font-bold text-purple-600 mb-2">85%</div>
|
||||
<p className="text-gray-600">Melhoria no desempenho</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-4xl font-bold text-purple-600 mb-2">50%</div>
|
||||
<p className="text-gray-600">Redução da carga dos professores</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="py-20 bg-gray-50">
|
||||
<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">
|
||||
Planos para Cada Necessidade
|
||||
</h2>
|
||||
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||
Escolha o plano ideal para sua escola
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
<PriceCard
|
||||
plan="Básico"
|
||||
price="497"
|
||||
description="Ideal para escolas pequenas"
|
||||
features={[
|
||||
"Até 200 alunos",
|
||||
"Histórias básicas",
|
||||
"Suporte por email"
|
||||
]}
|
||||
/>
|
||||
<PriceCard
|
||||
plan="Profissional"
|
||||
price="997"
|
||||
description="Para escolas em crescimento"
|
||||
features={[
|
||||
"Até 1000 alunos",
|
||||
"Histórias personalizadas",
|
||||
"Suporte prioritário",
|
||||
"Analytics avançado"
|
||||
]}
|
||||
highlighted
|
||||
/>
|
||||
<PriceCard
|
||||
plan="Enterprise"
|
||||
price="Consulte"
|
||||
description="Para redes de ensino"
|
||||
features={[
|
||||
"Alunos ilimitados",
|
||||
"Customização completa",
|
||||
"Suporte 24/7",
|
||||
"API dedicada"
|
||||
]}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Final CTA */}
|
||||
<div className="py-20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 rounded-2xl p-8 md:p-16 text-center text-white">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">
|
||||
{/* 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-lg mb-8 max-w-2xl mx-auto">
|
||||
<p className="text-white/90 mb-8 max-w-2xl mx-auto text-lg">
|
||||
Junte-se a mais de 1000 escolas que já estão revolucionando a educação
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSchoolRegister}
|
||||
className="bg-white text-purple-600 px-8 py-4 rounded-xl hover:bg-purple-50 transition text-lg font-semibold"
|
||||
<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"
|
||||
>
|
||||
Começar Gratuitamente
|
||||
Entre em contato
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid md:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<BookOpen className="h-8 w-8" />
|
||||
<span className="text-xl font-bold">Histórias Mágicas</span>
|
||||
</div>
|
||||
<p className="text-gray-400">
|
||||
Transformando a educação através de histórias interativas
|
||||
</p>
|
||||
</div>
|
||||
{/* Adicione mais seções do footer conforme necessário */}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default HomePage;
|
||||
11
src/components/layouts/BaseLayout.tsx
Normal file
11
src/components/layouts/BaseLayout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { PageTracker } from '../analytics/PageTracker';
|
||||
|
||||
export function BaseLayout() {
|
||||
return (
|
||||
<>
|
||||
<PageTracker />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -30,7 +30,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps): JSX.Element
|
||||
<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">
|
||||
Histórias Mágicas
|
||||
Leiturama
|
||||
</span>
|
||||
</div>
|
||||
<DashboardSidebar />
|
||||
|
||||
128
src/components/learning/ExerciseSuggestions.tsx
Normal file
128
src/components/learning/ExerciseSuggestions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
src/components/learning/WordHighlighter.test.tsx
Normal file
83
src/components/learning/WordHighlighter.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
129
src/components/learning/WordHighlighter.tsx
Normal file
129
src/components/learning/WordHighlighter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/components/phonics/AudioPlayer.tsx
Normal file
70
src/components/phonics/AudioPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/components/phonics/CategoryTabs.tsx
Normal file
43
src/components/phonics/CategoryTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/components/phonics/ExerciseCard.tsx
Normal file
72
src/components/phonics/ExerciseCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/components/phonics/ExerciseGrid.tsx
Normal file
48
src/components/phonics/ExerciseGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
src/components/phonics/ExercisePlayer.tsx
Normal file
182
src/components/phonics/ExercisePlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/components/phonics/exercises/AlliterationExercise.tsx
Normal file
41
src/components/phonics/exercises/AlliterationExercise.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/components/phonics/exercises/BaseExercise.tsx
Normal file
31
src/components/phonics/exercises/BaseExercise.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/components/phonics/exercises/ExerciseFactory.tsx
Normal file
66
src/components/phonics/exercises/ExerciseFactory.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
41
src/components/phonics/exercises/FinalSoundExercise.tsx
Normal file
41
src/components/phonics/exercises/FinalSoundExercise.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/components/phonics/exercises/InitialSoundExercise.tsx
Normal file
41
src/components/phonics/exercises/InitialSoundExercise.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/components/phonics/exercises/RhymeExercise.tsx
Normal file
41
src/components/phonics/exercises/RhymeExercise.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/components/phonics/exercises/SoundMatchExercise.tsx
Normal file
54
src/components/phonics/exercises/SoundMatchExercise.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/components/phonics/exercises/SyllablesExercise.tsx
Normal file
41
src/components/phonics/exercises/SyllablesExercise.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,14 +1,28 @@
|
||||
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 }: AudioRecorderProps) {
|
||||
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);
|
||||
@ -16,9 +30,14 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
|
||||
|
||||
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 = [];
|
||||
@ -30,11 +49,14 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
|
||||
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);
|
||||
@ -46,8 +68,35 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
|
||||
// Parar todas as tracks do stream
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -63,41 +112,59 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Criar nome único para o arquivo
|
||||
const timestamp = new Date().getTime();
|
||||
const filePath = `${studentId}/${storyId}/${timestamp}.webm`;
|
||||
const fileId = uuidv4();
|
||||
const filePath = `${studentId}/${storyId}/${fileId}.webm`;
|
||||
|
||||
// Upload do arquivo
|
||||
const { data, error: uploadError } = await supabase.storage
|
||||
try {
|
||||
// 1. Primeiro fazer o upload do arquivo
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('recordings')
|
||||
.upload(filePath, audioBlob, {
|
||||
contentType: 'audio/webm',
|
||||
cacheControl: '3600'
|
||||
cacheControl: '3600',
|
||||
upsert: false
|
||||
});
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
// Obter URL pública
|
||||
// 2. Obter URL pública
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('recordings')
|
||||
.getPublicUrl(filePath);
|
||||
|
||||
// Salvar referência no banco apenas com os campos necessários
|
||||
const { error: dbError } = await supabase
|
||||
// 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,
|
||||
status: 'pending_analysis'
|
||||
});
|
||||
audio_url: publicUrl, // Salvar o caminho relativo
|
||||
status: 'pending_analysis',
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (dbError) throw dbError;
|
||||
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 {
|
||||
@ -106,15 +173,23 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-white rounded-lg shadow">
|
||||
<div className={cn(
|
||||
"",
|
||||
focusModeActive && "bg-purple-50"
|
||||
)}>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
{!isRecording && !audioBlob && (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
|
||||
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" />
|
||||
Iniciar Gravação
|
||||
{focusModeActive ? "Iniciar Leitura" : "Iniciar Gravação"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@ -3,7 +3,9 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useSession } from '../../hooks/useSession';
|
||||
import { useStoryCategories } from '../../hooks/useStoryCategories';
|
||||
import { Wand2, ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
import { Wand2, ArrowLeft, Globe } from 'lucide-react';
|
||||
import { useStudentTracking } from '../../hooks/useStudentTracking';
|
||||
import { useLanguages } from '../../hooks/useLanguages';
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
@ -18,34 +20,47 @@ interface StoryStep {
|
||||
key?: keyof StoryChoices;
|
||||
items?: Category[];
|
||||
isContextStep?: boolean;
|
||||
isLanguageStep?: boolean;
|
||||
}
|
||||
|
||||
interface StoryChoices {
|
||||
export interface StoryChoices {
|
||||
theme_id: string | null;
|
||||
subject_id: string | null;
|
||||
character_id: string | null;
|
||||
setting_id: string | null;
|
||||
context?: string;
|
||||
language_type: string;
|
||||
}
|
||||
|
||||
export function StoryGenerator() {
|
||||
const navigate = useNavigate();
|
||||
const { session } = useSession();
|
||||
const { themes, subjects, characters, settings, isLoading } = useStoryCategories();
|
||||
const [step, setStep] = React.useState(1);
|
||||
const [choices, setChoices] = React.useState<StoryChoices>({
|
||||
theme_id: null,
|
||||
subject_id: null,
|
||||
character_id: null,
|
||||
setting_id: null,
|
||||
context: ''
|
||||
});
|
||||
const [isGenerating, setIsGenerating] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [generationStatus, setGenerationStatus] = React.useState<
|
||||
'idle' | 'creating' | 'generating-images' | 'saving'
|
||||
>('idle');
|
||||
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',
|
||||
@ -68,77 +83,339 @@ export function StoryGenerator() {
|
||||
key: 'setting_id'
|
||||
},
|
||||
{
|
||||
title: 'Adicione um Contexto (Opcional)',
|
||||
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 isLastStep = step === steps.length;
|
||||
|
||||
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 => prev + 1);
|
||||
setStep((prev: number) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setChoices(prev => ({ ...prev, context: event.target.value }));
|
||||
const handleNext = () => {
|
||||
if (currentStep.isContextStep) {
|
||||
setStep((prev: number) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (step > 1) {
|
||||
setStep(prev => 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 () => {
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
if (!choices.theme_id || !choices.subject_id || !choices.character_id || !choices.setting_id) {
|
||||
setError('Por favor, preencha todas as escolhas antes de continuar.');
|
||||
// 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({
|
||||
.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,
|
||||
title: 'Gerando...',
|
||||
theme_id: choices.theme_id,
|
||||
subject_id: choices.subject_id,
|
||||
character_id: choices.character_id,
|
||||
setting_id: choices.setting_id,
|
||||
context: choices.context || null,
|
||||
status: 'draft',
|
||||
content: {
|
||||
prompt: choices,
|
||||
pages: []
|
||||
}
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
language_type: choices.language_type,
|
||||
theme: selectedTheme,
|
||||
subject: selectedSubject,
|
||||
character: selectedCharacter,
|
||||
setting: selectedSetting,
|
||||
story_id: story.id // Garantindo que o ID existe
|
||||
};
|
||||
|
||||
if (storyError) throw storyError;
|
||||
console.log('=== Dados da História ===');
|
||||
console.log('ID:', story.id);
|
||||
console.log('Payload completo:', storyPayload);
|
||||
|
||||
setGenerationStatus('generating-images');
|
||||
console.log('Chamando Edge Function com:', story);
|
||||
const response = await supabase.functions
|
||||
.invoke('generate-story', {
|
||||
body: storyPayload
|
||||
});
|
||||
|
||||
const { data: functionData, error: functionError } = await supabase.functions
|
||||
.invoke('generate-story', {
|
||||
body: { record: story }
|
||||
});
|
||||
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}`);
|
||||
}
|
||||
|
||||
console.log('Resposta da Edge Function:', functionData);
|
||||
// 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');
|
||||
}
|
||||
|
||||
if (functionError) {
|
||||
throw new Error(`Erro na Edge Function: ${functionError.message}`);
|
||||
// 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');
|
||||
@ -148,12 +425,32 @@ export function StoryGenerator() {
|
||||
.eq('id', story.id)
|
||||
.single();
|
||||
|
||||
if (updateError) throw updateError;
|
||||
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 ao gerar história:', err);
|
||||
setError('Não foi possível criar sua história. Tente novamente.');
|
||||
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');
|
||||
@ -173,7 +470,7 @@ export function StoryGenerator() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
if (isCategoriesLoading || isLanguagesLoading) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-8">
|
||||
<div className="h-2 bg-gray-200 rounded-full" />
|
||||
@ -204,14 +501,57 @@ export function StoryGenerator() {
|
||||
{currentStep.title}
|
||||
</h2>
|
||||
|
||||
{currentStep.isContextStep ? (
|
||||
{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={choices.context}
|
||||
value={initialContext}
|
||||
onChange={handleContextChange}
|
||||
placeholder="Adicione detalhes ou ideias específicas para sua história..."
|
||||
className="w-full h-32 p-4 border border-gray-300 rounded-lg focus:ring-purple-500 focus:border-purple-500"
|
||||
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">
|
||||
@ -243,10 +583,16 @@ export function StoryGenerator() {
|
||||
</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={handleBack}
|
||||
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"
|
||||
>
|
||||
@ -254,14 +600,13 @@ export function StoryGenerator() {
|
||||
Voltar
|
||||
</button>
|
||||
|
||||
{isLastStep && (
|
||||
{!currentStep.isLanguageStep && !currentStep.isContextStep && (
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!choices.theme_id || !choices.subject_id || !choices.character_id || !choices.setting_id || isGenerating}
|
||||
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"
|
||||
>
|
||||
<Wand2 className="h-5 w-5" />
|
||||
{isGenerating ? getGenerationStatusText() : 'Criar História Mágica'}
|
||||
Próximo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
105
src/components/story/StoryReader.tsx
Normal file
105
src/components/story/StoryReader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
src/components/ui/adaptive-text.tsx
Normal file
94
src/components/ui/adaptive-text.tsx
Normal 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]
|
||||
);
|
||||
}
|
||||
146
src/components/ui/alert-dialog.tsx
Normal file
146
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
@ -1,27 +1,82 @@
|
||||
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';
|
||||
children: React.ReactNode;
|
||||
trackingId: string;
|
||||
variant?: 'default' | 'primary' | 'secondary' | 'outline' | 'ghost' | 'link' | 'destructive';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
trackingProperties?: ButtonTrackingOptions;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
as: Component = 'button',
|
||||
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,
|
||||
...props
|
||||
}: ButtonProps): JSX.Element {
|
||||
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
|
||||
className={`
|
||||
inline-flex items-center justify-center px-4 py-2
|
||||
text-sm font-medium text-white
|
||||
bg-purple-600 hover:bg-purple-700
|
||||
rounded-md shadow-sm
|
||||
transition-colors duration-200
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${className}
|
||||
`}
|
||||
type={Component === 'button' ? type : undefined}
|
||||
className={buttonVariants({ variant, size, className })}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -1,16 +1,79 @@
|
||||
import React from 'react';
|
||||
import * as React from "react"
|
||||
|
||||
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function Card({ className = '', children, ...props }: CardProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-sm border border-gray-200 ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 }
|
||||
81
src/components/ui/comparison-section.tsx
Normal file
81
src/components/ui/comparison-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
251
src/components/ui/editor.tsx
Normal file
251
src/components/ui/editor.tsx
Normal file
@ -0,0 +1,251 @@
|
||||
import { useEditor, EditorContent, Editor as TiptapEditor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import CharacterCount from '@tiptap/extension-character-count'
|
||||
import Highlight from '@tiptap/extension-highlight'
|
||||
import TextAlign from '@tiptap/extension-text-align'
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
import TextStyle from '@tiptap/extension-text-style'
|
||||
import Color from '@tiptap/extension-color'
|
||||
import { useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from './button'
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline as UnderlineIcon,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
Highlighter,
|
||||
Strikethrough,
|
||||
Code,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface EditorProps {
|
||||
content: string
|
||||
onChange: (content: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
minHeight?: string
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
interface MenuBarProps {
|
||||
editor: TiptapEditor | null
|
||||
}
|
||||
|
||||
function MenuBar({ editor }: MenuBarProps) {
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-input bg-transparent p-1">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
className={cn(editor.isActive('bold') && 'bg-muted')}
|
||||
aria-label="Negrito"
|
||||
trackingId="editor-bold-button"
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
className={cn(editor.isActive('italic') && 'bg-muted')}
|
||||
aria-label="Itálico"
|
||||
trackingId="editor-italic-button"
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
className={cn(editor.isActive('underline') && 'bg-muted')}
|
||||
aria-label="Sublinhado"
|
||||
trackingId="editor-underline-button"
|
||||
>
|
||||
<UnderlineIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleHighlight().run()}
|
||||
className={cn(editor.isActive('highlight') && 'bg-muted')}
|
||||
aria-label="Destacar"
|
||||
trackingId="editor-highlight-button"
|
||||
>
|
||||
<Highlighter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
className={cn(editor.isActive('strike') && 'bg-muted')}
|
||||
aria-label="Tachado"
|
||||
trackingId="editor-strike-button"
|
||||
>
|
||||
<Strikethrough className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
className={cn(editor.isActive('code') && 'bg-muted')}
|
||||
aria-label="Código"
|
||||
trackingId="editor-code-button"
|
||||
>
|
||||
<Code className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
className={cn(editor.isActive('bulletList') && 'bg-muted')}
|
||||
aria-label="Lista com marcadores"
|
||||
trackingId="editor-bullet-list-button"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
className={cn(editor.isActive('orderedList') && 'bg-muted')}
|
||||
aria-label="Lista numerada"
|
||||
trackingId="editor-ordered-list-button"
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
className={cn(editor.isActive('blockquote') && 'bg-muted')}
|
||||
aria-label="Citação"
|
||||
trackingId="editor-blockquote-button"
|
||||
>
|
||||
<Quote className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="mx-2 w-[1px] bg-border" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||
className={cn(editor.isActive({ textAlign: 'left' }) && 'bg-muted')}
|
||||
aria-label="Alinhar à esquerda"
|
||||
trackingId="editor-align-left-button"
|
||||
>
|
||||
<AlignLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||
className={cn(editor.isActive({ textAlign: 'center' }) && 'bg-muted')}
|
||||
aria-label="Centralizar"
|
||||
trackingId="editor-align-center-button"
|
||||
>
|
||||
<AlignCenter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||
className={cn(editor.isActive({ textAlign: 'right' }) && 'bg-muted')}
|
||||
aria-label="Alinhar à direita"
|
||||
trackingId="editor-align-right-button"
|
||||
>
|
||||
<AlignRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Editor({
|
||||
content,
|
||||
onChange,
|
||||
placeholder = 'Comece a escrever...',
|
||||
className,
|
||||
minHeight = '500px',
|
||||
readOnly = false,
|
||||
}: EditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
emptyEditorClass: 'is-editor-empty',
|
||||
}),
|
||||
CharacterCount,
|
||||
Highlight.configure({
|
||||
multicolor: true,
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ['paragraph'],
|
||||
}),
|
||||
Underline,
|
||||
TextStyle,
|
||||
Color,
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange(editor.getHTML())
|
||||
},
|
||||
editable: !readOnly,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && content !== editor.getHTML()) {
|
||||
editor.commands.setContent(content)
|
||||
}
|
||||
}, [content, editor])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-transparent',
|
||||
'focus-within:outline-none focus-within:ring-2',
|
||||
'focus-within:ring-ring focus-within:ring-offset-2',
|
||||
'transition-all duration-200',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!readOnly && <MenuBar editor={editor} />}
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-purple max-w-none px-4 py-2',
|
||||
'[&_.is-editor-empty]:before:text-muted-foreground',
|
||||
'[&_.is-editor-empty]:before:content-[attr(data-placeholder)]',
|
||||
'[&_.is-editor-empty]:before:float-left',
|
||||
'[&_.is-editor-empty]:before:h-0',
|
||||
'[&_.is-editor-empty]:before:pointer-events-none',
|
||||
'transition-all duration-200',
|
||||
readOnly && 'prose-sm'
|
||||
)}
|
||||
style={{ minHeight }}
|
||||
>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
{!readOnly && editor && (
|
||||
<div className="border-t border-input px-4 py-2 text-sm text-muted-foreground">
|
||||
{editor.storage.characterCount.words()} palavras
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
src/components/ui/faq.tsx
Normal file
49
src/components/ui/faq.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
|
||||
interface FAQItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
interface FAQProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
items: FAQItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FAQ({
|
||||
title = "Perguntas Frequentes",
|
||||
description = "Tire suas dúvidas sobre nossa plataforma",
|
||||
items,
|
||||
className = ""
|
||||
}: FAQProps) {
|
||||
return (
|
||||
<section className={`px-4 py-24 bg-white ${className}`}>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h2 className="text-4xl font-bold text-center text-gray-900 mb-16">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{description && (
|
||||
<p className="text-center text-gray-600 mb-12">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-8">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="bg-gray-50 rounded-xl p-6 hover:bg-gray-100 transition-colors">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">
|
||||
{item.question}
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
{item.answer}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
41
src/components/ui/feature-card.tsx
Normal file
41
src/components/ui/feature-card.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface FeatureCardProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
items?: string[];
|
||||
description?: string;
|
||||
borderColor?: string;
|
||||
iconColor?: string;
|
||||
}
|
||||
|
||||
export function FeatureCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
items,
|
||||
description,
|
||||
borderColor = 'border-purple-200',
|
||||
iconColor = 'text-purple-600'
|
||||
}: FeatureCardProps) {
|
||||
return (
|
||||
<div className="p-6 rounded-xl border border-gray-200 hover:shadow-lg transition-all duration-300 bg-white">
|
||||
<div className="w-12 h-12 rounded-lg bg-purple-100 flex items-center justify-center mb-4">
|
||||
<Icon className={`w-6 h-6 ${iconColor}`} />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-gray-600 mb-4">{description}</p>
|
||||
)}
|
||||
{items && items.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<li key={index} className="text-gray-600">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
src/components/ui/footer.tsx
Normal file
178
src/components/ui/footer.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
BookOpen,
|
||||
Facebook,
|
||||
Instagram,
|
||||
Twitter,
|
||||
Youtube,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin
|
||||
} from 'lucide-react';
|
||||
|
||||
interface FooterLink {
|
||||
text: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface FooterColumn {
|
||||
title: string;
|
||||
links: FooterLink[];
|
||||
}
|
||||
|
||||
interface SocialLink {
|
||||
icon: React.ElementType;
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface FooterProps {
|
||||
/** Links de navegação organizados em colunas */
|
||||
columns?: FooterColumn[];
|
||||
/** Links para redes sociais */
|
||||
socialLinks?: SocialLink[];
|
||||
/** Texto de direitos autorais */
|
||||
copyrightText?: string;
|
||||
/** Endereço da empresa */
|
||||
address?: string;
|
||||
/** Email de contato */
|
||||
email?: string;
|
||||
/** Telefone de contato */
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
const defaultColumns: FooterColumn[] = [
|
||||
{
|
||||
title: 'Produto',
|
||||
links: [
|
||||
{ text: 'Funcionalidades', href: '/funcionalidades' },
|
||||
{ text: 'Planos', href: '/planos' },
|
||||
{ text: 'Como Funciona', href: '/como-funciona' },
|
||||
{ text: 'Histórias de Sucesso', href: '/historias-de-sucesso' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Recursos',
|
||||
links: [
|
||||
{ text: 'Base Científica', href: '/base-cientifica' },
|
||||
{ text: 'Central de Ajuda', href: '/ajuda' },
|
||||
{ text: 'Contato', href: '/contato' },
|
||||
{ text: 'FAQ', href: '/faq' },
|
||||
{ text: 'Tutoriais', href: '/tutoriais' },
|
||||
{ text: 'Estudos', href: '/estudos' },
|
||||
{ text: 'Blog', href: '/blog' },
|
||||
{ text: 'Webinars', href: '/webinars' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Empresa',
|
||||
links: [
|
||||
{ text: 'Sobre', href: '/sobre' },
|
||||
{ text: 'Carreiras', href: '/carreiras' },
|
||||
{ text: 'Imprensa', href: '/imprensa' },
|
||||
{ text: 'Termos de Uso', href: '/termos' },
|
||||
{ text: 'Privacidade', href: '/privacidade' },
|
||||
{ text: 'Segurança', href: '/seguranca' },
|
||||
{ text: 'Cookies', href: '/cookies' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const defaultSocialLinks: SocialLink[] = [
|
||||
{ icon: Facebook, href: 'https://facebook.com/leiturama.ai', label: 'Facebook' },
|
||||
{ icon: Instagram, href: 'https://instagram.com/leiturama.ai', label: 'Instagram' },
|
||||
{ icon: Twitter, href: 'https://twitter.com/leiturama.ai', label: 'Twitter' },
|
||||
{ icon: Youtube, href: 'https://youtube.com/leiturama.ai', label: 'Youtube' }
|
||||
];
|
||||
|
||||
export function Footer({
|
||||
columns = defaultColumns,
|
||||
socialLinks = defaultSocialLinks,
|
||||
copyrightText = '© 2024 Leiturama. Todos os direitos reservados.',
|
||||
address = 'Rua das Histórias, 123 - São Paulo, SP',
|
||||
email = 'contato@leiturama.ai',
|
||||
phone = '(11) 4002-8922'
|
||||
}: FooterProps) {
|
||||
return (
|
||||
<footer className="bg-gray-900 text-gray-400">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
{/* Logo e Informações de Contato + Links de Navegação */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 pb-8 border-b border-gray-800">
|
||||
{/* Logo e Informações - Ocupa 1 coluna em telas grandes */}
|
||||
<div className="lg:col-span-1 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="h-8 w-8 text-purple-500" />
|
||||
<span className="text-xl font-bold text-white">Leiturama</span>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
Transformando a educação através de histórias interativas e métodos cientificamente comprovados
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<MapPin className="h-4 w-4" />
|
||||
<span>{address}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Mail className="h-4 w-4" />
|
||||
<a href={`mailto:${email}`} className="hover:text-white transition-colors">
|
||||
{email}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Phone className="h-4 w-4" />
|
||||
<a href={`tel:${phone}`} className="hover:text-white transition-colors">
|
||||
{phone}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links de Navegação - Ocupa 3 colunas em telas grandes */}
|
||||
<div className="lg:col-span-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{columns.map((column, index) => (
|
||||
<div key={index} className="space-y-4">
|
||||
<h4 className="text-white font-bold">{column.title}</h4>
|
||||
<ul className="space-y-2">
|
||||
{column.links.map((link, idx) => (
|
||||
<li key={idx}>
|
||||
<Link
|
||||
to={link.href}
|
||||
className="text-sm hover:text-white transition-colors"
|
||||
>
|
||||
{link.text}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copyright e Redes Sociais */}
|
||||
<div className="pt-8 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<p className="text-sm text-center md:text-left">{copyrightText}</p>
|
||||
|
||||
<div className="flex gap-6">
|
||||
{socialLinks.map((social, index) => {
|
||||
const Icon = social.icon;
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={social.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
aria-label={social.label}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
47
src/components/ui/form.tsx
Normal file
47
src/components/ui/form.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { useFormTracking } from '../../hooks/useFormTracking';
|
||||
import { EVENT_CATEGORIES } from '../../constants/analytics';
|
||||
|
||||
interface FormProps extends React.FormHTMLAttributes<HTMLFormElement> {
|
||||
formId: string;
|
||||
formName?: string;
|
||||
trackingProperties?: {
|
||||
category?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function Form({
|
||||
formId,
|
||||
formName = formId,
|
||||
children,
|
||||
trackingProperties,
|
||||
onSubmit,
|
||||
...props
|
||||
}: FormProps) {
|
||||
const { trackFormStarted, trackFormSubmitted } = useFormTracking({
|
||||
formId,
|
||||
formName,
|
||||
category: EVENT_CATEGORIES.FORM,
|
||||
...trackingProperties
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
trackFormSubmitted(true);
|
||||
onSubmit?.(e);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
trackFormStarted();
|
||||
}, [formId, trackFormStarted]);
|
||||
|
||||
return (
|
||||
<form
|
||||
{...props}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
67
src/components/ui/info-card.tsx
Normal file
67
src/components/ui/info-card.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface InfoCardProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
result?: string;
|
||||
icon?: LucideIcon;
|
||||
items?: string[];
|
||||
bgColor?: string;
|
||||
titleColor?: string;
|
||||
textColor?: string;
|
||||
resultColor?: string;
|
||||
iconColor?: string;
|
||||
}
|
||||
|
||||
export function InfoCard({
|
||||
title,
|
||||
description,
|
||||
result,
|
||||
icon: Icon,
|
||||
items,
|
||||
bgColor = 'bg-white',
|
||||
titleColor = 'text-gray-900',
|
||||
textColor = 'text-gray-600',
|
||||
resultColor = 'text-purple-600',
|
||||
iconColor = 'text-purple-600',
|
||||
}: InfoCardProps) {
|
||||
return (
|
||||
<div className={`${bgColor} p-8 rounded-2xl shadow-sm`}>
|
||||
{/* Icon */}
|
||||
{Icon && (
|
||||
<div className="mb-6">
|
||||
<div className="w-16 h-16 rounded-full bg-purple-100 flex items-center justify-center">
|
||||
<Icon className={`w-8 h-8 ${iconColor}`} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<h3 className={`text-2xl font-bold ${titleColor} mb-3`}>{title}</h3>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className={`${textColor} text-lg mb-6`}>{description}</p>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className="bg-purple-50 rounded-xl p-4">
|
||||
<p className={`${resultColor} text-lg font-medium`}>
|
||||
Resultado: {result}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Items List */}
|
||||
{items && items.length > 0 && (
|
||||
<ul className={`list-disc pl-6 space-y-2 mt-6 ${textColor}`}>
|
||||
{items.map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/components/ui/link.tsx
Normal file
41
src/components/ui/link.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useButtonTracking } from '../../hooks/useButtonTracking';
|
||||
import { EVENT_CATEGORIES } from '../../constants/analytics';
|
||||
import { ButtonTrackingOptions } from '../../types/analytics';
|
||||
|
||||
interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
to: string;
|
||||
trackingId?: string;
|
||||
trackingProperties?: ButtonTrackingOptions;
|
||||
}
|
||||
|
||||
export function Link({
|
||||
to,
|
||||
children,
|
||||
trackingId,
|
||||
trackingProperties,
|
||||
...props
|
||||
}: LinkProps) {
|
||||
const { trackButtonClick } = useButtonTracking({
|
||||
category: EVENT_CATEGORIES.INTERACTION,
|
||||
element_type: 'link',
|
||||
...trackingProperties
|
||||
});
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (trackingId) {
|
||||
trackButtonClick(trackingId);
|
||||
}
|
||||
props.onClick?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<RouterLink
|
||||
to={to}
|
||||
{...props}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{children}
|
||||
</RouterLink>
|
||||
);
|
||||
}
|
||||
145
src/components/ui/plan-for-parents.tsx
Normal file
145
src/components/ui/plan-for-parents.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle } from 'lucide-react';
|
||||
import { Button } from './button';
|
||||
import { EVENT_CATEGORIES } from '../../constants/analytics';
|
||||
|
||||
interface PlanProps {
|
||||
title: string;
|
||||
description: string;
|
||||
price: string;
|
||||
period: string;
|
||||
features: string[];
|
||||
highlighted?: boolean;
|
||||
commitment?: string;
|
||||
onSubscribe?: () => void;
|
||||
}
|
||||
|
||||
interface PlanForParentsProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
plans?: PlanProps[];
|
||||
}
|
||||
|
||||
const defaultPlans: PlanProps[] = [
|
||||
{
|
||||
title: "Aprendiz de Mago",
|
||||
description: "Perfeito para começar",
|
||||
price: "49,90",
|
||||
period: "mês",
|
||||
features: [
|
||||
"5 histórias personalizadas por mês",
|
||||
"Análise básica de progresso",
|
||||
"Suporte por email",
|
||||
"Acesso ao portal dos pais",
|
||||
"Relatórios mensais"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Mago Experiente",
|
||||
description: "Mais popular",
|
||||
price: "39,90",
|
||||
period: "mês",
|
||||
features: [
|
||||
"15 histórias personalizadas por mês",
|
||||
"Análise avançada de progresso",
|
||||
"Suporte prioritário",
|
||||
"Portal dos pais premium",
|
||||
"Relatórios semanais",
|
||||
"Histórias temáticas especiais",
|
||||
"Bônus: Kit de Atividades Mágicas"
|
||||
],
|
||||
highlighted: true,
|
||||
commitment: "Semestral"
|
||||
},
|
||||
{
|
||||
title: "Grão-Mestre",
|
||||
description: "Melhor custo-benefício",
|
||||
price: "29,90",
|
||||
period: "mês",
|
||||
features: [
|
||||
"Histórias ilimitadas",
|
||||
"Análise completa de progresso",
|
||||
"Suporte VIP 24/7",
|
||||
"Portal dos pais premium",
|
||||
"Relatórios diários",
|
||||
"Histórias temáticas especiais",
|
||||
"Bônus: Kit de Atividades Mágicas",
|
||||
"Bônus: Sessões com pedagogo"
|
||||
],
|
||||
commitment: "Anual"
|
||||
}
|
||||
];
|
||||
|
||||
export function PlanForParents({
|
||||
title = "Planos Mágicos",
|
||||
description = "Escolha o plano perfeito para a jornada mágica do seu filho",
|
||||
plans = defaultPlans
|
||||
}: PlanForParentsProps) {
|
||||
return (
|
||||
<section className="px-4 py-24 bg-gradient-to-b from-purple-50 via-white to-purple-50">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<h2 className="text-4xl font-bold text-center text-gray-900 mb-4">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-center text-gray-600 mb-16 max-w-2xl mx-auto">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{plans.map((plan, index) => (
|
||||
<div key={index} className={`
|
||||
p-8 rounded-xl shadow-lg border-2
|
||||
${plan.highlighted ? 'bg-gradient-to-br from-purple-50 to-blue-50 border-purple-200 transform scale-105'
|
||||
: 'bg-white border-gray-100'}
|
||||
`}>
|
||||
<div className="text-center mb-8">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">{plan.title}</h3>
|
||||
<p className="text-gray-600">{plan.description}</p>
|
||||
<div className="mt-4">
|
||||
<span className="text-4xl font-bold text-gray-900">R${plan.price}</span>
|
||||
<span className="text-gray-500">/{plan.period}</span>
|
||||
{plan.commitment && (
|
||||
<div className="text-sm text-purple-600 mt-1">
|
||||
Plano {plan.commitment}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-4 mb-8">
|
||||
{plan.features.map((feature, 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">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
onClick={plan.onSubscribe}
|
||||
variant={plan.highlighted ? 'primary' : 'outline'}
|
||||
className="w-full"
|
||||
trackingId={`parent_plan_subscribe_${plan.title.toLowerCase().replace(/\s+/g, '_')}`}
|
||||
trackingProperties={{
|
||||
category: EVENT_CATEGORIES.PRICING,
|
||||
action: 'click',
|
||||
label: plan.title,
|
||||
value: parseFloat(plan.price.replace(/[.,]/g, '')),
|
||||
plan_type: plan.title,
|
||||
plan_price: plan.price,
|
||||
plan_period: plan.period,
|
||||
plan_commitment: plan.commitment || 'mensal',
|
||||
is_highlighted: plan.highlighted,
|
||||
features_count: plan.features.length,
|
||||
position: index.toString()
|
||||
}}
|
||||
>
|
||||
Começar Agora
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
158
src/components/ui/plan-for-schools.tsx
Normal file
158
src/components/ui/plan-for-schools.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle } from 'lucide-react';
|
||||
import { Button } from './button';
|
||||
import { EVENT_CATEGORIES } from '../../constants/analytics';
|
||||
|
||||
interface PlanProps {
|
||||
title: string;
|
||||
description: string;
|
||||
price: string;
|
||||
features: string[];
|
||||
highlighted?: boolean;
|
||||
onSubscribe?: () => void;
|
||||
}
|
||||
|
||||
interface PlanForSchoolsProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
plans?: PlanProps[];
|
||||
onContactClick?: () => void;
|
||||
}
|
||||
|
||||
const defaultPlans: PlanProps[] = [
|
||||
{
|
||||
title: "Escola Iniciante",
|
||||
description: "Para escolas começando a transformação",
|
||||
price: "1.997",
|
||||
features: [
|
||||
"Até 100 alunos",
|
||||
"Método fônico estruturado",
|
||||
"Relatórios básicos",
|
||||
"Suporte por email"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Escola Transformadora",
|
||||
description: "Para escolas comprometidas",
|
||||
price: "3.997",
|
||||
features: [
|
||||
"Até 500 alunos",
|
||||
"Método fônico completo",
|
||||
"Relatórios avançados",
|
||||
"Suporte prioritário",
|
||||
"Treinamento da equipe",
|
||||
"Consultoria pedagógica"
|
||||
],
|
||||
highlighted: true
|
||||
},
|
||||
{
|
||||
title: "Rede de Ensino",
|
||||
description: "Para redes de escolas",
|
||||
price: "9.887",
|
||||
features: [
|
||||
"Alunos ilimitados",
|
||||
"Sistema completo",
|
||||
"Relatórios personalizados",
|
||||
"Suporte 24/7",
|
||||
"Treinamento completo",
|
||||
"Consultoria dedicada"
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export function PlanForSchools({
|
||||
title = "Invista em Educação Baseada em Evidências",
|
||||
description = "Escolha o plano ideal para transformar a educação com base científica",
|
||||
plans = defaultPlans,
|
||||
onContactClick
|
||||
}: PlanForSchoolsProps) {
|
||||
return (
|
||||
<section className="px-4 py-24 bg-gradient-to-b from-purple-50 via-white to-purple-50">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-8 max-w-2xl mx-auto">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8 mb-16">
|
||||
{plans.map((plan, index) => (
|
||||
<div key={index} className={`
|
||||
p-8 rounded-xl shadow-lg border-2
|
||||
${plan.highlighted
|
||||
? 'bg-gradient-to-br from-purple-50 to-blue-50 border-purple-200 transform scale-105'
|
||||
: 'bg-white border-gray-100'}
|
||||
`}>
|
||||
<div className="text-center mb-8">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">{plan.title}</h3>
|
||||
<p className="text-gray-600">{plan.description}</p>
|
||||
<div className="mt-4">
|
||||
<span className="text-4xl font-bold text-gray-900">R${plan.price}</span>
|
||||
<span className="text-gray-500">/mês</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-4 mb-8">
|
||||
{plan.features.map((feature, 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">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
// onClick={plan.onSubscribe}
|
||||
onClick={() => window.location.href = 'https://typebot-public.inventivos.co/leiturama-leads'}
|
||||
variant={plan.highlighted ? 'primary' : 'outline'}
|
||||
className="w-full"
|
||||
trackingId={`plan_subscribe_${plan.title.toLowerCase().replace(/\s+/g, '_')}`}
|
||||
trackingProperties={{
|
||||
category: EVENT_CATEGORIES.PRICING,
|
||||
action: 'click',
|
||||
label: plan.title,
|
||||
value: parseFloat(plan.price.replace(/[.,]/g, '')),
|
||||
plan_type: plan.title,
|
||||
plan_price: plan.price,
|
||||
is_highlighted: plan.highlighted,
|
||||
features_count: plan.features.length,
|
||||
position: index.toString()
|
||||
}}
|
||||
>
|
||||
Entre em contato
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
|
||||
Precisa de um plano personalizado para sua instituição?
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button
|
||||
// onClick={onContactClick}
|
||||
onClick={() => window.location.href = 'https://typebot-public.inventivos.co/leiturama-leads'}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="gap-2"
|
||||
trackingId="pricing_contact_button"
|
||||
trackingProperties={{
|
||||
category: EVENT_CATEGORIES.PRICING,
|
||||
action: 'click',
|
||||
label: 'contact_sales',
|
||||
position: 'footer',
|
||||
section: 'pricing'
|
||||
}}
|
||||
>
|
||||
Fale com um Consultor
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
34
src/components/ui/process-step.tsx
Normal file
34
src/components/ui/process-step.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ProcessStepProps {
|
||||
number: number;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function ProcessStep({
|
||||
number,
|
||||
title,
|
||||
description,
|
||||
}: ProcessStepProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-6">
|
||||
{/* Número */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-14 h-14 rounded-full bg-purple-600 flex items-center justify-center text-white text-2xl font-bold">
|
||||
{number}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conteúdo */}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-3">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-lg text-gray-600">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/components/ui/progress.tsx
Normal file
28
src/components/ui/progress.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-gray-200",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-purple-600 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
33
src/components/ui/stat-card.tsx
Normal file
33
src/components/ui/stat-card.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface StatCardProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
value: string;
|
||||
description: string;
|
||||
valueColor?: string;
|
||||
iconBgColor?: string;
|
||||
iconColor?: string;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
valueColor = 'text-purple-600',
|
||||
iconBgColor = 'bg-purple-600',
|
||||
iconColor = 'text-white',
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<div className="bg-white shadow-lg rounded-xl p-6">
|
||||
<div className={`flex items-center justify-center h-12 w-12 rounded-md ${iconBgColor} ${iconColor} mb-4`}>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900">{title}</h3>
|
||||
<p className={`mt-2 text-4xl font-bold ${valueColor}`}>{value}</p>
|
||||
<p className="mt-2 text-gray-600">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
src/components/ui/testimonial-card.tsx
Normal file
67
src/components/ui/testimonial-card.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { Heart } from 'lucide-react';
|
||||
|
||||
interface TestimonialCardProps {
|
||||
quote: string;
|
||||
author: string;
|
||||
role: string;
|
||||
image?: string;
|
||||
magicMoment?: string;
|
||||
result?: string;
|
||||
borderColor?: string;
|
||||
quoteColor?: string;
|
||||
authorColor?: string;
|
||||
}
|
||||
|
||||
export function TestimonialCard({
|
||||
quote,
|
||||
author,
|
||||
role,
|
||||
image,
|
||||
magicMoment,
|
||||
result,
|
||||
borderColor = 'border-purple-200',
|
||||
quoteColor = 'text-gray-600',
|
||||
authorColor = 'text-gray-900'
|
||||
}: TestimonialCardProps) {
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-purple-50 to-blue-50 p-6 rounded-xl shadow-sm">
|
||||
{image && (
|
||||
<div className="relative mb-8">
|
||||
<img
|
||||
src={image}
|
||||
alt={`Foto de ${author}`}
|
||||
className="w-full h-48 object-cover rounded-lg"
|
||||
/>
|
||||
<div className="absolute -bottom-4 -right-4 bg-white p-2 rounded-full shadow-lg">
|
||||
<Heart className="h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={`${quoteColor} mb-4 italic`}>"{quote}"</p>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{image && (
|
||||
<img
|
||||
src={image}
|
||||
alt={author}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p className={`font-bold ${authorColor}`}>{author}</p>
|
||||
<p className="text-sm text-gray-500">{role}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(magicMoment || result) && (
|
||||
<div className="mt-4 p-3 bg-white rounded-lg">
|
||||
<p className="text-sm text-purple-600 font-medium">
|
||||
{magicMoment ? `✨ Momento mágico: ${magicMoment}` : `🎯 Resultado: ${result}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/components/ui/text-case-toggle.tsx
Normal file
40
src/components/ui/text-case-toggle.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Loader2, Type } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface TextCaseToggleProps {
|
||||
isUpperCase: boolean;
|
||||
onToggle: () => void;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TextCaseToggle({
|
||||
isUpperCase,
|
||||
onToggle,
|
||||
isLoading = false,
|
||||
className
|
||||
}: TextCaseToggleProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors',
|
||||
'text-gray-600 hover:text-gray-900 hover:bg-gray-100',
|
||||
'border border-gray-200',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
title={isUpperCase ? 'Mudar para minúsculas' : 'MUDAR PARA MAIÚSCULAS'}
|
||||
>
|
||||
<Type className="h-4 w-4" />
|
||||
<span className="text-sm font-medium select-none">
|
||||
{isUpperCase ? 'Aa: Ativar Minúsculas' : 'AA: ATIVAR MAIÚSCULAS'}
|
||||
</span>
|
||||
{isLoading && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
250
src/components/ui/text-controls.tsx
Normal file
250
src/components/ui/text-controls.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Type,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Play,
|
||||
Pause,
|
||||
Timer,
|
||||
ArrowLeftRight,
|
||||
MoveVertical
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface TextControlsProps {
|
||||
isUpperCase: boolean;
|
||||
onToggleUpperCase: () => void;
|
||||
isSyllablesEnabled: boolean;
|
||||
onToggleSyllables: () => void;
|
||||
fontSize: number;
|
||||
onFontSizeChange: (size: number) => void;
|
||||
readingSpeed: number;
|
||||
onReadingSpeedChange: (speed: number) => void;
|
||||
letterSpacing: number;
|
||||
onLetterSpacingChange: (spacing: number) => void;
|
||||
wordSpacing: number;
|
||||
onWordSpacingChange: (spacing: number) => void;
|
||||
lineHeight: number;
|
||||
onLineHeightChange: (height: number) => void;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
isHighlighting?: boolean;
|
||||
onToggleHighlight?: () => void;
|
||||
}
|
||||
|
||||
export function TextControls({
|
||||
isUpperCase,
|
||||
onToggleUpperCase,
|
||||
isSyllablesEnabled,
|
||||
onToggleSyllables,
|
||||
fontSize,
|
||||
onFontSizeChange,
|
||||
readingSpeed,
|
||||
onReadingSpeedChange,
|
||||
letterSpacing,
|
||||
onLetterSpacingChange,
|
||||
wordSpacing,
|
||||
onWordSpacingChange,
|
||||
lineHeight,
|
||||
onLineHeightChange,
|
||||
isLoading = false,
|
||||
className,
|
||||
isHighlighting = false,
|
||||
onToggleHighlight
|
||||
}: TextControlsProps) {
|
||||
const handleFontSizeChange = (delta: number) => {
|
||||
const newSize = Math.min(Math.max(12, fontSize + delta), 32);
|
||||
onFontSizeChange(newSize);
|
||||
};
|
||||
|
||||
const handleReadingSpeedChange = (delta: number) => {
|
||||
const newSpeed = Math.min(Math.max(30, readingSpeed + delta), 300);
|
||||
onReadingSpeedChange(newSpeed);
|
||||
};
|
||||
|
||||
const handleLetterSpacingChange = (delta: number) => {
|
||||
const newSpacing = Math.min(Math.max(0, letterSpacing + delta), 10);
|
||||
onLetterSpacingChange(newSpacing);
|
||||
};
|
||||
|
||||
const handleWordSpacingChange = (delta: number) => {
|
||||
const newSpacing = Math.min(Math.max(0, wordSpacing + delta), 20);
|
||||
onWordSpacingChange(newSpacing);
|
||||
};
|
||||
|
||||
const handleLineHeightChange = (delta: number) => {
|
||||
const newHeight = Math.min(Math.max(1, lineHeight + delta), 3);
|
||||
onLineHeightChange(newHeight);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* Primeira Seção: Controles Principais */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Controle de Maiúsculas */}
|
||||
<button
|
||||
onClick={onToggleUpperCase}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors',
|
||||
'text-gray-600 hover:text-gray-900 hover:bg-gray-100',
|
||||
'border border-gray-200',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
title={isUpperCase ? 'Mudar para minúsculas' : 'MUDAR PARA MAIÚSCULAS'}
|
||||
>
|
||||
<Type className="h-4 w-4" />
|
||||
<span className="text-sm font-medium select-none">
|
||||
{isUpperCase ? 'Aa: Minúsculas' : 'AA: MAIÚSCULAS'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Controle de Sílabas */}
|
||||
<button
|
||||
onClick={onToggleSyllables}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors',
|
||||
'text-gray-600 hover:text-gray-900',
|
||||
'border border-gray-200',
|
||||
isSyllablesEnabled ? 'bg-purple-50 text-purple-600 border-purple-200' : 'hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium">Sí-la-bas</span>
|
||||
</button>
|
||||
|
||||
{/* Word Highlighter */}
|
||||
<button
|
||||
onClick={onToggleHighlight}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors',
|
||||
'text-gray-600 hover:text-gray-900',
|
||||
'border border-gray-200',
|
||||
isHighlighting ? 'bg-blue-50 text-blue-600 border-blue-200' : 'hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
{isHighlighting ? (
|
||||
<>
|
||||
<Pause className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Pausar</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Destacar Palavras</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Segunda Seção: Ajustes de Texto */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Controle de Tamanho da Fonte */}
|
||||
<div className="flex items-center gap-1 border border-gray-200 rounded-lg">
|
||||
<button
|
||||
onClick={() => handleFontSizeChange(-2)}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-l-lg"
|
||||
aria-label="Diminuir fonte"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="text-sm font-medium px-2">{fontSize}px</span>
|
||||
<button
|
||||
onClick={() => handleFontSizeChange(2)}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-r-lg"
|
||||
aria-label="Aumentar fonte"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Controle de Velocidade de Leitura */}
|
||||
<div className="flex items-center gap-1 border border-gray-200 rounded-lg">
|
||||
<button
|
||||
onClick={() => handleReadingSpeedChange(-10)}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-l-lg"
|
||||
aria-label="Diminuir velocidade"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-1 px-2">
|
||||
<Timer className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-sm font-medium">{readingSpeed} ppm</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleReadingSpeedChange(10)}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-r-lg"
|
||||
aria-label="Aumentar velocidade"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Controle de Espaçamento entre Letras */}
|
||||
<div className="flex items-center gap-1 border border-gray-200 rounded-lg">
|
||||
<button
|
||||
onClick={() => handleLetterSpacingChange(-0.5)}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-l-lg"
|
||||
aria-label="Diminuir espaçamento entre letras"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-1 px-2">
|
||||
<Type className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-sm font-medium">{letterSpacing}px</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleLetterSpacingChange(0.5)}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-r-lg"
|
||||
aria-label="Aumentar espaçamento entre letras"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Controle de Espaçamento entre Palavras */}
|
||||
<div className="flex items-center gap-1 border border-gray-200 rounded-lg">
|
||||
<button
|
||||
onClick={() => handleWordSpacingChange(-1)}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-l-lg"
|
||||
aria-label="Diminuir espaçamento entre palavras"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-1 px-2">
|
||||
<ArrowLeftRight className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-sm font-medium">{wordSpacing}px</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleWordSpacingChange(1)}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-r-lg"
|
||||
aria-label="Aumentar espaçamento entre palavras"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Controle de Altura da Linha */}
|
||||
<div className="flex items-center gap-1 border border-gray-200 rounded-lg">
|
||||
<button
|
||||
onClick={() => handleLineHeightChange(-0.1)}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-l-lg"
|
||||
aria-label="Diminuir altura da linha"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-1 px-2">
|
||||
<MoveVertical className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-sm font-medium">{lineHeight.toFixed(1)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleLineHeightChange(0.1)}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-r-lg"
|
||||
aria-label="Aumentar altura da linha"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/components/ui/textarea.tsx
Normal file
24
src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
127
src/components/ui/toast.tsx
Normal file
127
src/components/ui/toast.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border border-gray-200 p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-white text-gray-900",
|
||||
destructive:
|
||||
"destructive group border-red-500 bg-red-500 text-white",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
console.log('Toast component props:', { className, variant, ...props })
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-gray-300 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-red-100 group-[.destructive]:hover:border-red-200 group-[.destructive]:hover:bg-red-100 group-[.destructive]:focus:ring-red-400",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-1 top-1 rounded-md p-1 text-gray-400 opacity-0 transition-opacity hover:text-gray-900 focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-100 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold text-gray-900", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-gray-600", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user