Compare commits

...

69 Commits

Author SHA1 Message Date
Lucas Santana
de28dea3b5 Fixing Git History
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2024-12-29 08:46:22 -03:00
Lucas Santana
4765be66da feat: implementa upload atômico e processamento assíncrono de áudio
- Usa UUID para evitar colisões de arquivos
- Implementa transação atômica para upload
- Adiciona chamada assíncrona para Edge Function
- Melhora tratamento de erros
- Mantém consistência entre storage e banco de dados
2024-12-29 07:11:37 -03:00
Lucas Santana
c562ae570a Corrigindo processamento do áudio 2024-12-28 12:53:23 -03:00
Lucas Santana
f4965db3e6 Corrigindo processamento do áudio 2024-12-28 12:48:02 -03:00
Lucas Santana
933358483e Corrigindo processamento do áudio 2024-12-28 12:42:26 -03:00
Lucas Santana
66d401f98f .gitignore fix 2024-12-27 17:10:42 -03:00
Lucas Santana
a3b522d283 Corrigindo erro supabase 2024-12-27 17:03:12 -03:00
Lucas Santana
5812d46049 Corrigindo erro supabase 2024-12-27 17:01:32 -03:00
Lucas Santana
007441c285 Corrigindo erro supabase 2024-12-27 16:54:34 -03:00
Lucas Santana
c776efaec9 Erro ao salvar no supabase 2024-12-27 15:56:41 -03:00
Lucas Santana
6cf273126e Process audio 2024-12-27 14:41:40 -03:00
Lucas Santana
ec97f640f9 feat: adiciona processamento automático de áudio
- Implementa Edge Function para processamento de áudio
- Adiciona integração com OpenAI Whisper e GPT-4
- Configura Database Trigger para story_recordings
- Implementa análise automática de leitura
- Atualiza documentação e variáveis de ambiente
2024-12-27 13:25:10 -03:00
Lucas Santana
a8c332d442 feat: adiciona processamento automático de áudio
- Implementa Edge Function para processamento de áudio
- Adiciona integração com OpenAI Whisper e GPT-4
- Configura Database Trigger para story_recordings
- Implementa análise automática de leitura
- Atualiza documentação e variáveis de ambiente
2024-12-27 13:24:25 -03:00
Lucas Santana
4d09386d96 generate-story prompt 2024-12-25 14:00:05 -03:00
Lucas Santana
cc23c83c05 feat: adiciona redis e healthcheck
- Implementa cliente Redis com retry e cache
- Adiciona healthcheck da API
- Configura tipagem para Next.js API routes
- Implementa cache de histórias
- Adiciona tratamento de erros robusto
- Configura monitoramento de conexões
- Otimiza performance com cache distribuído
2024-12-25 13:55:03 -03:00
Lucas Santana
521a99a5c2 feat: adiciona configuração docker e ci/cd
- Implementa Dockerfile com multi-stage build
- Configura pipeline no Gitea Actions
- Adiciona integração com Redis
- Implementa healthchecks
- Configura registry no Gitea

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

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

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

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

View File

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

9
.dockerignore Normal file
View File

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

23
.eslintrc.json Normal file
View File

@ -0,0 +1,23 @@
{
"root": true,
"env": {
"browser": true,
"es2020": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended"
],
"ignorePatterns": ["dist", ".eslintrc.json"],
"parser": "@typescript-eslint/parser",
"plugins": ["react-refresh"],
"rules": {
"react-refresh/only-export-components": [
"warn",
{ "allowConstantExport": true }
],
"@typescript-eslint/no-unused-vars": "warn",
"no-unused-vars": "warn"
}
}

View File

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

View File

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

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

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

10
.gitignore vendored
View File

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

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

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

13
CHANGELOG.md Normal file
View File

@ -0,0 +1,13 @@
# Changelog
## [1.1.0] - 2024-03-21
### Modificado
- Melhorado o processo de upload de áudio para evitar colisões de arquivos e garantir integridade dos dados
- Implementado processamento assíncrono de áudio via Edge Function
### Técnico
- Adicionado UUID para identificação única de arquivos de áudio
- Implementada transação atômica para upload de áudio
- Integrada chamada assíncrona para processamento de áudio
- Melhorado tratamento de erros no processo de upload

40
Dockerfile Normal file
View File

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

15
Dockerfile.dev Normal file
View File

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

View File

@ -27,6 +27,12 @@ Histórias Mágicas é uma aplicação web desenvolvida em React que permite que
- Tailwind CSS
- Lucide React (ícones)
- Vite
- Supabase
- Supabase Functions
- OpenAI
- DALL-E
## 🚀 Como Executar

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

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

31
docker-compose.yml Normal file
View File

@ -0,0 +1,31 @@
version: '3.8'
services:
historias-magicas:
image: ${REGISTRY}/historias-magicas:${TAG}
environment:
- NODE_ENV=production
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- REDIS_URL=redis://redis:6379
networks:
- network_public
deploy:
labels:
- "traefik.enable=true"
- "traefik.http.routers.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.docker.network=traefik-public"
replicas: 1
update_config:
parallelism: 1
delay: 10s
restart_policy:
condition: on-failure
networks:
network_public:
external: true

22
netlify.toml Normal file
View File

@ -0,0 +1,22 @@
[build]
command = "npm run build"
publish = "dist"
[build.environment]
NODE_VERSION = "18"
VITE_SUPABASE_URL = "https://bsjlbnyslxzsdwxvkaap.supabase.co"
VITE_RESEND_API_KEY = "GEoM_cVt4qyBFVkngJWi8wBrMWOiPMUAuxuFGykcP0A"
VITE_APP_URL = "https://historiasmagicas.netlify.app/"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
[dev]
command = "npm run dev"
port = 5173
publish = "dist"
[functions]
node_bundler = "esbuild"

12
next.config.js Normal file
View File

@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
images: {
domains: [
'oaidalleapiprodscus.blob.core.windows.net',
// outros domínios necessários
],
},
}
module.exports = nextConfig

2167
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,20 +9,36 @@
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"format": "prettier --write \"src/**/*.{ts,tsx}\""
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
"docker:build": "docker build -t historias-magicas .",
"docker:run": "docker run -p 3000:3000 historias-magicas",
"deploy:prod": "docker-compose up -d --build"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.2",
"@supabase/supabase-js": "^2.39.7",
"@tanstack/react-query": "^5.62.8",
"@types/ioredis": "^4.28.10",
"@types/next": "^8.0.7",
"clsx": "^2.1.1",
"ioredis": "^5.4.2",
"lucide-react": "^0.344.0",
"next": "^15.1.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"react-router-dom": "^6.28.0",
"resend": "^3.2.0"
"recharts": "^2.15.0",
"resend": "^3.2.0",
"tailwind-merge": "^2.5.5",
"uuid": "^11.0.3"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.1",
@ -30,6 +46,7 @@
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.4.49",
"supabase": "^2.1.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",

34
portainer-stack.yml Normal file
View File

@ -0,0 +1,34 @@
version: '3.8'
services:
historias-magicas:
image: ${REGISTRY}/historias-magicas:${TAG}
environment:
- NODE_ENV=production
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- REDIS_URL=redis://redis:6379
networks:
- traefik-public
- redis-network
deploy:
labels:
- "traefik.enable=true"
- "traefik.http.routers.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.docker.network=traefik-public"
replicas: 2
update_config:
parallelism: 1
delay: 10s
restart_policy:
condition: on-failure
networks:
traefik-public:
external: true
redis-network:
external: true

4
public/book.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 313 B

View File

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

View File

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

View File

@ -1,7 +1,8 @@
import React, { useState } from 'react';
import { LogIn } from 'lucide-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';
interface LoginFormProps {
userType: 'school' | 'teacher' | 'student';
@ -9,99 +10,178 @@ interface LoginFormProps {
onRegisterClick?: () => void;
}
const userTypeIcons = {
school: <School className="h-8 w-8 text-purple-600" />,
teacher: <GraduationCap className="h-8 w-8 text-purple-600" />,
student: <User className="h-8 w-8 text-purple-600" />
};
const userTypeLabels = {
school: 'Escola',
teacher: 'Professor',
student: 'Aluno'
};
export function LoginForm({ userType, onLogin, onRegisterClick }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { signIn } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const { user } = await signIn(email, password);
if (user) {
if (userType === 'school') {
navigate('/dashboard');
} else if (onLogin) {
await onLogin({ email, password });
}
console.log('Tentando login com:', { email, userType });
const { data, error } = await supabase.auth.signInWithPassword({
email: email,
password: password
});
console.log('Resposta do Supabase:', { data, error });
if (error) throw error;
if (!data.user) {
throw new Error('Usuário não encontrado');
}
const userRole = data.user.user_metadata.role;
console.log('Metadados do usuário:', data.user.user_metadata);
console.log('Role esperado:', userType);
console.log('Role atual:', userRole);
if (userRole !== userType) {
throw new Error(`Este não é um login de ${userTypeLabels[userType]}`);
}
switch (userType) {
case 'school':
navigate('/dashboard');
break;
case 'teacher':
navigate('/professor');
break;
case 'student':
navigate('/aluno');
break;
default:
throw new Error('Tipo de usuário inválido');
}
} catch (err) {
setError('Erro ao fazer login. Verifique suas credenciais.');
console.error('Erro no login:', err);
if (err instanceof Error) {
setError(err.message);
} else {
setError('Email ou senha incorretos');
}
} finally {
setLoading(false);
}
};
return (
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="text-3xl font-bold text-center text-purple-600">
Bem-vindo de volta!
</h2>
<p className="mt-2 text-center text-gray-600">
Continue sua jornada de histórias mágicas
</p>
</div>
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Senha
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
/>
<div className="min-h-screen bg-gradient-to-b from-purple-50 to-white py-12">
<div className="max-w-md mx-auto px-4">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-purple-100 mb-4">
{userTypeIcons[userType]}
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Bem-vindo de volta!
</h1>
<p className="text-gray-600">
Faça login como {userTypeLabels[userType]}
</p>
</div>
<button
type="submit"
className="w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent rounded-lg shadow-sm text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
>
<LogIn className="w-5 h-5" />
Entrar
</button>
{onRegisterClick && (
<div className="text-center">
<button
type="button"
onClick={onRegisterClick}
className="text-purple-600 hover:text-purple-500"
>
Criar uma nova conta
</button>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-600">
{error}
</div>
)}
</form>
<div className="bg-white rounded-2xl shadow-xl p-8">
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Senha
</label>
<div className="relative mt-1">
<input
id="password"
type={showPassword ? 'text' : 'password'}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500 pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
{showPassword ? (
<EyeOff className="h-5 w-5 text-gray-400" />
) : (
<Eye className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
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"
>
{loading ? (
'Entrando...'
) : (
<>
<LogIn className="h-5 w-5" />
Entrar
</>
)}
</button>
</form>
{onRegisterClick && (
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
Ainda não tem uma conta?{' '}
<button
onClick={onRegisterClick}
className="text-purple-600 hover:text-purple-500 font-medium"
>
Cadastre-se
</button>
</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,594 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
BookOpen, ArrowRight, School, Users, Shield,
Sparkles, BookCheck, Play, CheckCircle, Star,
GraduationCap, BarChart, Brain, X, Check,
Pencil,
Wand,
Mic,
Share2
} from 'lucide-react';
// 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>
);
export function HomePage() {
const navigate = useNavigate();
const [showUserOptions, setShowUserOptions] = useState(false);
const [activeTab, setActiveTab] = useState('schools');
const [showFaq, setShowFaq] = useState<number | null>(null);
// Navigation handlers
const handleLoginClick = () => setShowUserOptions(!showUserOptions);
const handleSchoolLogin = () => navigate('/login/school');
const handleTeacherLogin = () => navigate('/login/teacher');
const handleStudentLogin = () => navigate('/login/student');
const handleSchoolRegister = () => navigate('/register/school');
const handleDemo = () => navigate('/demo');
return (
<div className="min-h-screen bg-gradient-to-b from-purple-50 to-white">
{/* Header/Nav */}
<nav className="bg-white/80 backdrop-blur-md fixed w-full z-50 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
<BookOpen className="h-8 w-8 text-purple-600" />
<span className="ml-2 text-xl font-bold text-gray-900">Histórias Mágicas</span>
</div>
<div className="flex items-center gap-4">
<div className="relative">
<button
onClick={handleLoginClick}
className="text-gray-600 hover:text-gray-900 px-3 py-2"
>
Entrar
</button>
{showUserOptions && (
<div className="absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5">
<div className="py-1" role="menu">
<button
onClick={handleSchoolLogin}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-purple-50"
>
Entrar como Escola
</button>
<button
onClick={handleTeacherLogin}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-purple-50"
>
Entrar como Professor
</button>
<button
onClick={handleStudentLogin}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-purple-50"
>
Entrar como Aluno
</button>
</div>
</div>
)}
</div>
<button
onClick={handleSchoolRegister}
className="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition"
>
Cadastrar Escola
</button>
</div>
</div>
</div>
</nav>
{/* Hero Section */}
<div className="pt-32 pb-20 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<div className="grid lg:grid-cols-2 gap-12 items-center">
<div>
<div className="inline-block px-4 py-1 rounded-full bg-purple-100 text-purple-600 font-medium text-sm mb-6">
Potencializado por IA 🚀
</div>
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-gray-900 mb-6">
Transforme a educação com
<span className="text-purple-600"> histórias inteligentes</span>
</h1>
<p className="text-xl text-gray-600 mb-8">
Uma plataforma educacional que usa IA para criar experiências de
aprendizado personalizadas através de histórias interativas.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<button
onClick={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"
>
Começar Gratuitamente
<ArrowRight className="w-5 h-5" />
</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"
>
<Play className="w-5 h-5" />
Ver Demo
</button>
</div>
<div className="mt-8 flex items-center gap-4">
<div className="flex -space-x-2">
{[1,2,3,4].map((i) => (
<img
key={i}
src={`/avatars/${i}.jpg`}
alt=""
className="w-8 h-8 rounded-full border-2 border-white"
/>
))}
</div>
<div className="text-sm text-gray-600">
<span className="text-purple-600 font-semibold">+1000 escolas</span>
transformaram sua educação
</div>
</div>
</div>
<div className="relative">
<div className="aspect-video rounded-xl overflow-hidden shadow-2xl">
<img
src="/demo-platform.png"
alt="Platform demo"
className="w-full h-full object-cover"
/>
<button
onClick={handleDemo}
className="absolute inset-0 flex items-center justify-center bg-black/30 hover:bg-black/40 transition group"
>
<div className="w-16 h-16 rounded-full bg-white/90 flex items-center justify-center">
<Play className="w-8 h-8 text-purple-600 group-hover:scale-110 transition" />
</div>
</button>
</div>
</div>
</div>
</div>
</div>
{/* Student Journey Section */}
<div className="py-20 bg-gradient-to-b from-purple-50 to-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold text-gray-900 mb-4">
Jornada do Aluno
</h2>
<p className="text-gray-600 max-w-2xl mx-auto">
Um processo inteligente e envolvente de aprendizado
</p>
</div>
<div className="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" />
{/* 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>
{/* 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>
{/* 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>
</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">
<h2 className="text-3xl font-bold text-gray-900 mb-4">
Tecnologia e Educação em Harmonia
</h2>
<p className="text-gray-600 max-w-2xl mx-auto">
Uma plataforma completa que une o poder da IA com as melhores práticas pedagógicas
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<FeatureCard
icon={<Brain />}
title="IA Adaptativa"
description="Conteúdo que se adapta ao ritmo e estilo de aprendizagem de cada aluno"
/>
<FeatureCard
icon={<BookOpen />}
title="Histórias Interativas"
description="Narrativas envolventes que tornam o aprendizado mais divertido e eficaz"
/>
<FeatureCard
icon={<BarChart />}
title="Analytics Avançado"
description="Insights detalhados sobre o progresso e engajamento dos alunos"
/>
<FeatureCard
icon={<Users />}
title="Colaboração"
description="Ferramentas para professores trabalharem juntos e compartilharem recursos"
/>
<FeatureCard
icon={<Shield />}
title="Ambiente Seguro"
description="Proteção de dados e conteúdo adequado para todas as idades"
/>
<FeatureCard
icon={<GraduationCap />}
title="Suporte Pedagógico"
description="Recursos e orientações para maximizar o potencial de aprendizagem"
/>
</div>
</div>
</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>
</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>
</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">
Pronto para Transformar sua Escola?
</h2>
<p className="text-lg mb-8 max-w-2xl mx-auto">
Junte-se a mais de 1000 escolas que 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"
>
Começar Gratuitamente
</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>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,233 @@
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';
interface AudioRecorderProps {
storyId: string;
studentId: string;
onAudioUploaded: (audioUrl: string) => void;
}
export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioRecorderProps) {
const [isRecording, setIsRecording] = useState(false);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorderRef.current = new MediaRecorder(stream);
chunksRef.current = [];
mediaRecorderRef.current.ondataavailable = (e) => {
chunksRef.current.push(e.data);
};
mediaRecorderRef.current.onstop = () => {
const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
setAudioBlob(audioBlob);
};
mediaRecorderRef.current.start();
setIsRecording(true);
setError(null);
} catch (err) {
setError('Erro ao acessar microfone. Verifique as permissões.');
console.error('Erro ao iniciar gravação:', err);
}
};
const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
// Parar todas as tracks do stream
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
}
};
const triggerAudioProcessing = async (recordingData: {
id: string;
story_id: string;
student_id: string;
audio_url: string;
status: string;
}): Promise<void> => {
try {
const { error } = await supabase.functions.invoke('process-audio', {
body: {
record: recordingData
}
});
if (error) {
console.error('Erro ao iniciar processamento:', error);
// Não vamos tratar o erro aqui pois o processamento é assíncrono
}
} catch (err) {
console.error('Erro ao chamar função de processamento:', err);
}
};
const uploadAudio = async () => {
if (!audioBlob) return;
const { data: { session } } = await supabase.auth.getSession();
if (!session?.user) {
setError('Usuário não autenticado');
return;
}
setIsUploading(true);
setError(null);
// Gerar um UUID único para o arquivo
const fileId = uuidv4();
const filePath = `${studentId}/${storyId}/${fileId}.webm`;
try {
// Iniciar uma transação
const { data: recordData, error: recordError } = await supabase
.from('story_recordings')
.insert({
id: fileId, // Usar o mesmo UUID como ID do registro
story_id: storyId,
student_id: studentId,
status: 'uploading', // Status inicial
created_at: new Date().toISOString()
})
.select('id')
.single();
if (recordError) throw recordError;
// Upload do arquivo
const { error: uploadError } = await supabase.storage
.from('recordings')
.upload(filePath, audioBlob, {
contentType: 'audio/webm',
cacheControl: '3600',
upsert: false
});
if (uploadError) {
// Se o upload falhar, remover o registro do banco
await supabase
.from('story_recordings')
.delete()
.eq('id', fileId);
throw uploadError;
}
// Obter URL pública
const { data: { publicUrl } } = supabase.storage
.from('recordings')
.getPublicUrl(filePath);
// Atualizar o registro com a URL e status
const { error: updateError } = await supabase
.from('story_recordings')
.update({
audio_url: publicUrl,
status: 'pending_analysis'
})
.eq('id', fileId);
if (updateError) {
// Se a atualização falhar, limpar tudo
await Promise.all([
supabase.storage.from('recordings').remove([filePath]),
supabase.from('story_recordings').delete().eq('id', fileId)
]);
throw updateError;
}
// Disparar o processamento de forma assíncrona
triggerAudioProcessing({
id: fileId,
story_id: storyId,
student_id: studentId,
audio_url: publicUrl,
status: 'pending_analysis'
}).catch(console.error); // Capturar erros mas não esperar pela conclusão
onAudioUploaded(publicUrl);
setAudioBlob(null);
} catch (err) {
setError('Erro ao enviar áudio. Tente novamente.');
console.error('Erro no upload:', err);
} finally {
setIsUploading(false);
}
};
return (
<div className="p-4 bg-white rounded-lg shadow">
<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"
>
<Mic className="w-5 h-5" />
Iniciar Gravação
</button>
)}
{isRecording && (
<button
onClick={stopRecording}
className="flex items-center gap-2 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
>
<Square className="w-5 h-5" />
Parar Gravação
</button>
)}
{audioBlob && !isUploading && (
<>
<button
onClick={() => {
const url = URL.createObjectURL(audioBlob);
const audio = new Audio(url);
audio.play();
}}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
<Play className="w-5 h-5" />
Ouvir
</button>
<button
onClick={uploadAudio}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
>
<Upload className="w-5 h-5" />
Enviar Áudio
</button>
</>
)}
{isUploading && (
<div className="flex items-center gap-2 text-gray-600">
<Loader className="w-5 h-5 animate-spin" />
Enviando áudio...
</div>
)}
</div>
{error && (
<div className="text-red-600 text-sm">
{error}
</div>
)}
</div>
);
}

View File

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

View File

@ -0,0 +1,270 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase';
import { useSession } from '../../hooks/useSession';
import { useStoryCategories } from '../../hooks/useStoryCategories';
import { Wand2, ArrowLeft, ArrowRight } from 'lucide-react';
interface Category {
id: string;
slug: string;
title: string;
description: string;
icon: string;
}
interface StoryStep {
title: string;
key?: keyof StoryChoices;
items?: Category[];
isContextStep?: boolean;
}
interface StoryChoices {
theme_id: string | null;
subject_id: string | null;
character_id: string | null;
setting_id: string | null;
context?: 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');
const steps: StoryStep[] = [
{
title: 'Escolha o Tema',
items: themes || [],
key: 'theme_id'
},
{
title: 'Escolha a Disciplina',
items: subjects || [],
key: 'subject_id'
},
{
title: 'Escolha o Personagem',
items: characters || [],
key: 'character_id'
},
{
title: 'Escolha o Cenário',
items: settings || [],
key: 'setting_id'
},
{
title: 'Adicione um Contexto (Opcional)',
isContextStep: true
}
];
const currentStep = steps[step - 1];
const isLastStep = step === steps.length;
const handleSelect = (key: keyof StoryChoices, value: string) => {
setChoices(prev => ({ ...prev, [key]: value }));
if (step < steps.length) {
setStep(prev => prev + 1);
}
};
const handleContextChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setChoices(prev => ({ ...prev, context: event.target.value }));
};
const handleBack = () => {
if (step > 1) {
setStep(prev => 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.');
return;
}
try {
setIsGenerating(true);
setError(null);
setGenerationStatus('creating');
const { data: story, error: storyError } = await supabase
.from('stories')
.insert({
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();
if (storyError) throw storyError;
setGenerationStatus('generating-images');
console.log('Chamando Edge Function com:', story);
const { data: functionData, error: functionError } = await supabase.functions
.invoke('generate-story', {
body: { record: story }
});
console.log('Resposta da Edge Function:', functionData);
if (functionError) {
throw new Error(`Erro na Edge Function: ${functionError.message}`);
}
setGenerationStatus('saving');
const { data: updatedStory, error: updateError } = await supabase
.from('stories')
.select('*')
.eq('id', story.id)
.single();
if (updateError) throw updateError;
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.');
} finally {
setIsGenerating(false);
setGenerationStatus('idle');
}
};
const getGenerationStatusText = () => {
switch (generationStatus) {
case 'creating':
return 'Iniciando criação...';
case 'generating-images':
return 'Gerando história e imagens...';
case 'saving':
return 'Finalizando...';
default:
return 'Criar História Mágica';
}
};
if (isLoading) {
return (
<div className="animate-pulse space-y-8">
<div className="h-2 bg-gray-200 rounded-full" />
<div className="grid grid-cols-2 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-32 bg-gray-200 rounded-xl" />
))}
</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Progress Bar */}
<div className="flex gap-2 mb-8">
{steps.map((s, i) => (
<div
key={i}
className={`h-2 rounded-full flex-1 ${
i + 1 <= step ? 'bg-purple-600' : 'bg-gray-200'
}`}
/>
))}
</div>
<h2 className="text-xl font-medium text-gray-900 mb-6">
{currentStep.title}
</h2>
{currentStep.isContextStep ? (
<div className="space-y-4">
<textarea
value={choices.context}
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"
/>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{currentStep.items?.map((item) => (
<button
key={item.id}
onClick={() => handleSelect(currentStep.key!, item.id)}
className={`p-6 rounded-xl border-2 transition-all text-left ${
choices[currentStep.key!] === item.id
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-purple-200 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-3">
<span className="text-2xl">{item.icon}</span>
<div>
<h3 className="font-medium text-gray-900">{item.title}</h3>
<p className="text-sm text-gray-600">{item.description}</p>
</div>
</div>
</button>
))}
</div>
)}
{error && (
<div className="p-4 bg-red-50 text-red-600 rounded-lg text-sm">
{error}
</div>
)}
{/* Navigation Buttons */}
<div className="flex justify-between pt-6">
<button
onClick={handleBack}
disabled={step === 1}
className="flex items-center gap-2 px-4 py-2 text-gray-600 disabled:opacity-50"
>
<ArrowLeft className="h-5 w-5" />
Voltar
</button>
{isLastStep && (
<button
onClick={handleGenerate}
disabled={!choices.theme_id || !choices.subject_id || !choices.character_id || !choices.setting_id || 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'}
</button>
)}
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,30 @@
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
as?: 'button' | 'span';
children: React.ReactNode;
}
export function Button({
as: Component = 'button',
className = '',
children,
...props
}: ButtonProps): JSX.Element {
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}
`}
{...props}
>
{children}
</Component>
);
}

View File

@ -0,0 +1,16 @@
import React from 'react';
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
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>
);
}

View File

@ -0,0 +1,27 @@
import React from 'react';
interface DatePickerProps {
label?: string;
name: string;
value?: string;
onChange?: (date: string) => void;
}
export function DatePicker({ label, name, value, onChange }: DatePickerProps): JSX.Element {
return (
<div className="space-y-2">
{label && (
<label className="text-sm font-medium text-gray-700">
{label}
</label>
)}
<input
type="date"
name={name}
value={value}
onChange={(e) => onChange?.(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
);
}

View File

@ -0,0 +1,21 @@
import React from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
}
export function Input({ label, className, ...props }: InputProps): JSX.Element {
return (
<div className="space-y-2">
{label && (
<label className="text-sm font-medium text-gray-700">
{label}
</label>
)}
<input
className={`w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 ${className}`}
{...props}
/>
</div>
);
}

View File

@ -0,0 +1,39 @@
import React from 'react';
interface SelectOption {
value: string;
label: string;
}
interface SelectProps {
label?: string;
name: string;
options: SelectOption[];
value?: string;
onChange?: (value: string) => void;
}
export function Select({ label, name, options, value, onChange }: SelectProps): JSX.Element {
return (
<div className="space-y-2">
{label && (
<label className="text-sm font-medium text-gray-700">
{label}
</label>
)}
<select
name={name}
value={value}
onChange={(e) => onChange?.(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="">Selecione...</option>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
}

View File

@ -0,0 +1,52 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "../../lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-lg bg-gray-100 p-1 text-gray-500",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-950 data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -1,13 +1,23 @@
import { useState, useEffect } from 'react'
import { supabase } from '../lib/supabase'
import { User, Session } from '@supabase/supabase-js'
import type { WeakPassword } from '../types/supabase'
import { UserRole } from '../types/supabase'
interface AuthContextType {
export interface AuthContextType {
user: User | null;
loading: boolean;
error: string | null;
signIn: (email: string, password: string) => Promise<{ user: User; session: Session }>;
signUp: (email: string, password: string) => Promise<{ user: User; session: Session }>;
userRole: UserRole | null;
signIn: (email: string, password: string) => Promise<{
user: User;
session: Session;
weakPassword?: WeakPassword;
}>;
signUp: (email: string, password: string) => Promise<{
user: User;
session: Session;
}>;
signOut: () => Promise<void>;
}
@ -15,6 +25,7 @@ export function useAuth() {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [userRole, setUserRole] = useState<AuthContextType['userRole']>(null)
useEffect(() => {
// Verificar sessão atual
@ -31,6 +42,12 @@ export function useAuth() {
return () => subscription.unsubscribe()
}, [])
useEffect(() => {
if (user?.user_metadata?.role) {
setUserRole(user.user_metadata.role);
}
}, [user]);
const signIn = async (email: string, password: string) => {
try {
const { data, error } = await supabase.auth.signInWithPassword({
@ -78,6 +95,7 @@ export function useAuth() {
user,
loading,
error,
userRole,
signIn,
signUp,
signOut

102
src/hooks/useAuth.tsx Normal file
View File

@ -0,0 +1,102 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '../lib/supabase';
import { User } from '@supabase/supabase-js';
import { UserMetadata, UserRole } from '../types/supabase';
export interface AuthContextType {
user: User | null;
loading: boolean;
error: string | null;
signIn: (email: string, password: string) => Promise<any>;
signUp: (email: string, password: string) => Promise<any>;
signOut: () => Promise<void>;
userRole: UserRole | null;
}
const AuthContext = React.createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const navigate = useNavigate();
const [user, setUser] = React.useState<User | null>(null);
const [userRole, setUserRole] = React.useState<UserRole | null>(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
const fetchSession = async () => {
const { data: { session } } = await supabase.auth.getSession();
console.log('Sessão atual:', session);
if (session?.user) {
setUser(session.user);
const role = session.user.user_metadata.role as UserMetadata['role'];
console.log('Role na sessão:', role);
setUserRole(role);
if (role === 'school') {
navigate('/dashboard');
}
}
setLoading(false);
};
fetchSession();
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
console.log('Evento de auth:', event);
console.log('Sessão no evento:', session);
if (session?.user) {
setUser(session.user);
const role = session.user.user_metadata.role as UserMetadata['role'];
console.log('Role no evento:', role);
setUserRole(role);
if (event === 'SIGNED_IN') {
if (role === 'school') {
navigate('/dashboard');
}
}
} else {
setUser(null);
setUserRole(null);
navigate('/');
}
setLoading(false);
});
return () => {
subscription.unsubscribe();
};
}, [navigate]);
const value: AuthContextType = {
user,
loading,
error: null,
signIn: async () => ({}),
signUp: async () => ({}),
signOut: async () => {
await supabase.auth.signOut();
setUser(null);
setUserRole(null);
navigate('/');
},
userRole
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextType {
const context = React.useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

28
src/hooks/useSession.ts Normal file
View File

@ -0,0 +1,28 @@
import { useEffect, useState } from 'react';
import { Session } from '@supabase/supabase-js';
import { supabase } from '../lib/supabase';
export function useSession() {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Pega a sessão atual
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setLoading(false);
});
// Escuta mudanças na autenticação
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
setLoading(false);
});
return () => subscription.unsubscribe();
}, []);
return { session, loading };
}

View File

@ -0,0 +1,72 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '../lib/supabase';
interface Category {
id: string;
slug: string;
title: string;
description: string;
icon: string;
}
export function useStoryCategories() {
const { data: themes, isLoading: loadingThemes } = useQuery({
queryKey: ['story-themes'],
queryFn: async () => {
const { data, error } = await supabase
.from('story_themes')
.select('*')
.order('title');
if (error) throw error;
return data as Category[];
}
});
const { data: subjects, isLoading: loadingSubjects } = useQuery({
queryKey: ['story-subjects'],
queryFn: async () => {
const { data, error } = await supabase
.from('story_subjects')
.select('*')
.order('title');
if (error) throw error;
return data as Category[];
}
});
const { data: characters, isLoading: loadingCharacters } = useQuery({
queryKey: ['story-characters'],
queryFn: async () => {
const { data, error } = await supabase
.from('story_characters')
.select('*')
.order('title');
if (error) throw error;
return data as Category[];
}
});
const { data: settings, isLoading: loadingSettings } = useQuery({
queryKey: ['story-settings'],
queryFn: async () => {
const { data, error } = await supabase
.from('story_settings')
.select('*')
.order('title');
if (error) throw error;
return data as Category[];
}
});
return {
themes,
subjects,
characters,
settings,
isLoading: loadingThemes || loadingSubjects || loadingCharacters || loadingSettings
};
}

View File

@ -0,0 +1,14 @@
import { Outlet } from 'react-router-dom';
import { StudentDashboardNavbar } from '@/components/student/StudentDashboardNavbar';
import React from 'react';
export function StudentDashboardLayout() {
return (
<div className="min-h-screen bg-gray-50">
<StudentDashboardNavbar />
<main className="container mx-auto px-4 py-8">
<Outlet />
</main>
</div>
);
}

17
src/lib/imageCache.ts Normal file
View File

@ -0,0 +1,17 @@
const imageCache = new Map<string, string>();
export function cacheImage(url: string): Promise<string> {
if (imageCache.has(url)) {
return Promise.resolve(imageCache.get(url)!);
}
return new Promise((resolve, reject) => {
const img = new Image();
img.src = url;
img.onload = () => {
imageCache.set(url, url);
resolve(url);
};
img.onerror = reject;
});
}

35
src/lib/imageUtils.ts Normal file
View File

@ -0,0 +1,35 @@
interface ImageOptions {
width?: number;
height?: number;
quality?: number;
}
export function getOptimizedImageUrl(url: string | undefined, options: ImageOptions = {}): string {
// Retorna uma imagem padrão ou vazia se a URL for undefined
if (!url) {
return '/placeholder-image.jpg'; // ou retorne uma imagem padrão apropriada
}
const {
width = 800,
height = undefined,
quality = 80
} = options;
// Se for URL do Supabase Storage
if (url.includes('storage.googleapis.com')) {
const params = new URLSearchParams({
width: width.toString(),
quality: quality.toString(),
format: 'webp'
});
if (height) {
params.append('height', height.toString());
}
return `${url}?${params.toString()}`;
}
return url;
}

62
src/lib/redis.ts Normal file
View File

@ -0,0 +1,62 @@
import Redis from 'ioredis';
if (!process.env.REDIS_URL) {
throw new Error('REDIS_URL não configurada');
}
const redis = new Redis(process.env.REDIS_URL, {
maxRetriesPerRequest: 3,
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
return delay;
},
reconnectOnError(err) {
const targetError = 'READONLY';
if (err.message.includes(targetError)) {
return true;
}
return false;
}
});
redis.on('error', (error) => {
console.error('[Redis] Erro de conexão:', error);
});
redis.on('connect', () => {
console.log('[Redis] Conectado com sucesso');
});
export async function getFromCache<T>(key: string): Promise<T | null> {
try {
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached) as T;
}
return null;
} catch (error) {
console.error('[Redis] Erro ao buscar do cache:', error);
return null;
}
}
export async function setInCache(key: string, value: any, expireInSeconds = 3600): Promise<void> {
try {
await redis.set(key, JSON.stringify(value), 'EX', expireInSeconds);
} catch (error) {
console.error('[Redis] Erro ao salvar no cache:', error);
}
}
export async function invalidateCache(pattern: string): Promise<void> {
try {
const keys = await redis.keys(pattern);
if (keys.length > 0) {
await redis.del(...keys);
}
} catch (error) {
console.error('[Redis] Erro ao invalidar cache:', error);
}
}
export default redis;

View File

@ -1,3 +1,4 @@
import { StoryPrompt } from '@/types/story-generator'
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
@ -13,4 +14,26 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
persistSession: true,
detectSessionInUrl: true
}
})
})
export const generateStoryFunction = async (prompt: StoryPrompt) => {
const { data: { session } } = await supabase.auth.getSession()
const response = await fetch(
'https://seu-project-ref.supabase.co/functions/v1/generate-story',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session?.access_token}`,
},
body: JSON.stringify(prompt),
}
)
if (!response.ok) {
throw new Error('Falha ao gerar história')
}
return response.json()
}

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -1,11 +1,24 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { router } from './routes';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 30,
refetchOnWindowFocus: false,
},
},
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>
);

View File

@ -0,0 +1,28 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '../lib/supabase';
export function AuthCallback() {
const navigate = useNavigate();
useEffect(() => {
supabase.auth.onAuthStateChange((event, session) => {
if (event === 'SIGNED_IN') {
navigate('/dashboard');
}
});
}, [navigate]);
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-semibold text-gray-900">
Verificando autenticação...
</h2>
<p className="mt-2 text-gray-600">
Por favor, aguarde enquanto confirmamos seu acesso.
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,120 @@
import React, { useState, useEffect } from 'react';
import { supabase } from '../../lib/supabase';
import { User } from '@supabase/supabase-js';
import { UserMetadata } from '../../types/supabase';
interface AdminUser extends User {
user_metadata: UserMetadata;
}
export function UserManagementPage() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [updating, setUpdating] = React.useState<string | null>(null);
const fetchUsers = async () => {
try {
const { data: users, error } = await supabase.auth.admin.listUsers();
if (error) {
setError(error.message);
return;
}
const validUsers = users.filter((user: User) =>
user.user_metadata && user.user_metadata.role
) as AdminUser[];
setUsers(validUsers);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erro ao carregar usuários');
}
};
useEffect(() => {
fetchUsers();
}, []);
const handleRefresh = async () => {
await fetchUsers();
};
const handleUpdateRole = async (userId: string, role: string) => {
setUpdating(userId);
try {
const { error } = await supabase.auth.admin.updateUserById(
userId,
{ user_metadata: { role } }
);
if (error) throw error;
await fetchUsers();
} catch (err) {
console.error('Erro ao atualizar papel:', err);
setError('Não foi possível atualizar o papel do usuário');
} finally {
setUpdating(null);
}
};
if (loading) {
return <div>Carregando...</div>;
}
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-6">Gerenciamento de Usuários</h1>
{error && (
<div className="mb-4 p-4 bg-red-50 text-red-600 rounded-lg">
{error}
</div>
)}
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Papel Atual
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Ações
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id}>
<td className="px-6 py-4 whitespace-nowrap">
{user.email}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
{user.user_metadata?.role || 'Não definido'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<select
value={user.user_metadata?.role || ''}
onChange={(e) => handleUpdateRole(user.id, e.target.value)}
disabled={updating === user.id}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
>
<option value="">Selecione um papel</option>
<option value="school">Escola</option>
<option value="teacher">Professor</option>
<option value="student">Aluno</option>
</select>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

22
src/pages/api/health.ts Normal file
View File

@ -0,0 +1,22 @@
import redis from '@/lib/redis';
import { supabase } from '@/lib/supabase';
export default async function handler(_: any, res: { status: (arg0: number) => { (): any; new(): any; json: { (arg0: { status: string; error?: any; }): void; new(): any; }; }; }) {
try {
// Verifica conexão com Redis
await redis.ping();
// Verifica conexão com Supabase
const { data, error } = await supabase.from('stories').select('id').limit(1);
if (error) throw error;
res.status(200).json({ status: 'healthy' });
} catch (error: unknown) {
if (error instanceof Error) {
res.status(500).json({ status: 'unhealthy', error: error.message });
} else {
res.status(500).json({ status: 'unhealthy', error: 'Erro desconhecido' });
}
}
}

View File

@ -0,0 +1,28 @@
import { NextApiRequest, NextApiResponse } from 'next';
import redis from '@/lib/redis';
import { supabase } from '@/lib/supabase';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { id } = req.query;
// Tenta pegar do cache primeiro
const cachedStory = await redis.get(`story:${id}`);
if (cachedStory) {
return res.json(JSON.parse(cachedStory));
}
// Se não estiver no cache, busca do Supabase
const { data: story } = await supabase
.from('stories')
.select('*')
.eq('id', id)
.single();
// Salva no cache por 1 hora
await redis.setex(`story:${id}`, 3600, JSON.stringify(story));
return res.json(story);
}

View File

@ -0,0 +1,16 @@
import { supabase } from '../../lib/supabase';
export async function updateRole(email: string, role: string) {
const { data: { user }, error } = await supabase.auth.admin.updateUserById(
email,
{
user_metadata: { role }
}
);
if (error) {
throw error;
}
return user;
}

View File

@ -0,0 +1,14 @@
import { supabase } from '../../lib/supabase';
export async function updateUserRole(userId: string, role: string) {
const { data, error } = await supabase.auth.admin.updateUserById(
userId,
{ user_metadata: { role: role } }
);
if (error) {
throw error;
}
return data;
}

View File

@ -0,0 +1,172 @@
import React, { useEffect, useState } from 'react';
import { Users, GraduationCap, BookOpen } from 'lucide-react';
import { supabase } from '../../lib/supabase';
import { StatsCard } from '../../components/dashboard/StatsCard';
interface DashboardStats {
totalClasses: number;
totalTeachers: number;
totalStudents: number;
recentClasses: any[];
recentStories: any[];
}
export function DashboardHome() {
const [stats, setStats] = useState<DashboardStats>({
totalClasses: 0,
totalTeachers: 0,
totalStudents: 0,
recentClasses: [],
recentStories: []
});
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchDashboardStats = async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.user?.id) return;
const schoolId = session.user.id;
// Buscar total de turmas
const { count: classesCount } = await supabase
.from('classes')
.select('*', { count: 'exact', head: true })
.eq('school_id', schoolId);
// Buscar total de professores
const { count: teachersCount } = await supabase
.from('teacher_classes')
.select('teacher_id', { count: 'exact', head: true })
.eq('school_id', schoolId);
// Buscar total de alunos
const { count: studentsCount } = await supabase
.from('students')
.select('*', { count: 'exact', head: true })
.eq('school_id', schoolId);
// Buscar turmas recentes
const { data: recentClasses } = await supabase
.from('classes')
.select(`
*,
students:students(count)
`)
.eq('school_id', schoolId)
.order('created_at', { ascending: false })
.limit(5);
// Buscar histórias recentes
const { data: recentStories } = await supabase
.from('stories')
.select(`
*,
students:students(name)
`)
.eq('school_id', schoolId)
.order('created_at', { ascending: false })
.limit(5);
setStats({
totalClasses: classesCount || 0,
totalTeachers: teachersCount || 0,
totalStudents: studentsCount || 0,
recentClasses: recentClasses || [],
recentStories: recentStories || []
});
} catch (error) {
console.error('Erro ao buscar estatísticas:', error);
} finally {
setLoading(false);
}
};
fetchDashboardStats();
}, []);
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-8">Dashboard</h1>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 mb-8">
<StatsCard
icon={Users}
title="Total de Turmas"
value={stats.totalClasses}
iconBgColor="bg-purple-100"
iconColor="text-purple-600"
/>
<StatsCard
icon={GraduationCap}
title="Total de Professores"
value={stats.totalTeachers}
iconBgColor="bg-blue-100"
iconColor="text-blue-600"
/>
<StatsCard
icon={BookOpen}
title="Total de Alunos"
value={stats.totalStudents}
iconBgColor="bg-green-100"
iconColor="text-green-600"
/>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="bg-white rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4">Últimas Turmas</h2>
{loading ? (
<div className="animate-pulse space-y-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-12 bg-gray-100 rounded-lg" />
))}
</div>
) : stats.recentClasses.length === 0 ? (
<p className="text-gray-500 text-center py-4">Nenhuma turma cadastrada</p>
) : (
<div className="space-y-4">
{stats.recentClasses.map((classItem) => (
<div key={classItem.id} className="flex justify-between items-center">
<div>
<h3 className="font-medium">{classItem.name}</h3>
<p className="text-sm text-gray-500">
{classItem.grade} {classItem.students.count} alunos
</p>
</div>
</div>
))}
</div>
)}
</div>
<div className="bg-white rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4">Histórias Recentes</h2>
{loading ? (
<div className="animate-pulse space-y-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-12 bg-gray-100 rounded-lg" />
))}
</div>
) : stats.recentStories.length === 0 ? (
<p className="text-gray-500 text-center py-4">Nenhuma história registrada</p>
) : (
<div className="space-y-4">
{stats.recentStories.map((story) => (
<div key={story.id} className="flex justify-between items-center">
<div>
<h3 className="font-medium">{story.title}</h3>
<p className="text-sm text-gray-500">
por {story.students.name}
</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,135 @@
import React from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
Users,
GraduationCap,
BookOpen,
Settings,
LogOut,
School,
UserRound
} from 'lucide-react';
import { useAuth } from '../../hooks/useAuth';
export function DashboardLayout() {
const navigate = useNavigate();
const { signOut } = useAuth();
const handleLogout = async () => {
await signOut();
navigate('/');
};
return (
<div className="min-h-screen bg-gray-50">
{/* Sidebar */}
<aside className="fixed left-0 top-0 h-full w-64 bg-white border-r border-gray-200">
<div className="flex items-center gap-2 p-6 border-b border-gray-200">
<School className="h-8 w-8 text-purple-600" />
<span className="font-semibold text-gray-900">Histórias Mágicas</span>
</div>
<nav className="p-4 space-y-1">
<NavLink
to="/dashboard"
end
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
isActive
? 'bg-purple-50 text-purple-700'
: 'text-gray-600 hover:bg-gray-50'
}`
}
>
<LayoutDashboard className="h-5 w-5" />
Visão Geral
</NavLink>
<NavLink
to="/dashboard/turmas"
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
isActive
? 'bg-purple-50 text-purple-700'
: 'text-gray-600 hover:bg-gray-50'
}`
}
>
<Users className="h-5 w-5" />
Turmas
</NavLink>
<NavLink
to="/dashboard/professores"
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
isActive
? 'bg-purple-50 text-purple-700'
: 'text-gray-600 hover:bg-gray-50'
}`
}
>
<GraduationCap className="h-5 w-5" />
Professores
</NavLink>
<NavLink
to="/dashboard/alunos"
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
isActive
? 'bg-purple-50 text-purple-700'
: 'text-gray-600 hover:bg-gray-50'
}`
}
>
<UserRound className="h-5 w-5" />
Alunos
</NavLink>
<NavLink
to="/dashboard/historias"
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
isActive
? 'bg-purple-50 text-purple-700'
: 'text-gray-600 hover:bg-gray-50'
}`
}
>
<BookOpen className="h-5 w-5" />
Histórias
</NavLink>
<NavLink
to="/dashboard/configuracoes"
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
isActive
? 'bg-purple-50 text-purple-700'
: 'text-gray-600 hover:bg-gray-50'
}`
}
>
<Settings className="h-5 w-5" />
Configurações
</NavLink>
<button
onClick={handleLogout}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-50 w-full"
>
<LogOut className="h-5 w-5" />
Sair
</button>
</nav>
</aside>
{/* Main Content */}
<main className="ml-64 p-8">
<Outlet />
</main>
</div>
);
}

View File

@ -0,0 +1,10 @@
import { DashboardLayout } from "../../components/layouts/DashboardLayout";
import React from "react";
export function DashboardPage(): JSX.Element {
return (
<DashboardLayout>
<div>Conteúdo do Dashboard</div>
</DashboardLayout>
);
}

View File

@ -0,0 +1,121 @@
import React from 'react';
import { Plus, Search, MoreVertical, GraduationCap } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useDatabase } from '../../../hooks/useDatabase';
import { supabase } from '../../../lib/supabase';
import type { Class } from '../../../types/database';
export function ClassesPage() {
const navigate = useNavigate();
const [classes, setClasses] = React.useState<Class[]>([]);
const [searchTerm, setSearchTerm] = React.useState('');
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
const fetchClasses = async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.user?.id) return;
const { data, error } = await supabase
.from('classes')
.select(`
*,
students:students(count),
teachers:teacher_classes(count)
`)
.eq('school_id', session.user.id);
if (error) throw error;
setClasses(data || []);
} catch (err) {
console.error('Erro ao buscar turmas:', err);
setError('Erro ao buscar turmas');
} finally {
setLoading(false);
}
};
fetchClasses();
}, []);
const filteredClasses = classes.filter(classItem =>
classItem.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
classItem.grade.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-900">Turmas</h1>
<button
onClick={() => navigate('nova')}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
<Plus className="h-5 w-5" />
Adicionar Turma
</button>
</div>
{error && (
<div className="mb-4 p-4 bg-red-50 text-red-600 rounded-lg">
{error}
</div>
)}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className="p-4 border-b border-gray-200">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Buscar turmas..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
{loading ? (
<div className="p-8 text-center text-gray-500">Carregando...</div>
) : filteredClasses.length === 0 ? (
<div className="p-8 text-center text-gray-500">
{searchTerm ? 'Nenhuma turma encontrada' : 'Nenhuma turma cadastrada'}
</div>
) : (
<div className="divide-y divide-gray-200">
{filteredClasses.map((classItem) => (
<div
key={classItem.id}
className="p-4 hover:bg-gray-50 cursor-pointer"
onClick={() => navigate(`/dashboard/turmas/${classItem.id}`)}
>
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium text-gray-900">
{classItem.name}
</h3>
<div className="flex items-center gap-2 text-sm text-gray-500">
<GraduationCap className="h-4 w-4" />
{classItem.grade} {(classItem as any).students?.count || 0} alunos
</div>
</div>
<div className="flex items-center gap-6">
<span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
Ativo
</span>
<button className="p-2 hover:bg-gray-100 rounded-full">
<MoreVertical className="h-5 w-5 text-gray-400" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,151 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import { supabase } from '../../../lib/supabase';
import type { Class } from '../../../types';
export function CreateClassPage() {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [formData, setFormData] = useState<Omit<Class, 'id' | 'school_id' | 'created_at' | 'updated_at'>>({
name: '',
grade: '',
year: new Date().getFullYear(),
period: 'morning'
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setFormError(null);
try {
const { data: { session }, error: authError } = await supabase.auth.getSession();
if (authError) throw authError;
if (!session?.user?.id) {
throw new Error('Usuário não autenticado');
}
const { data: newClass, error: classError } = await supabase
.from('classes')
.insert({
school_id: session.user.id,
name: formData.name,
grade: formData.grade,
year: formData.year,
period: formData.period
})
.select()
.single();
if (classError) throw classError;
navigate('/dashboard/turmas', {
state: { message: 'Turma criada com sucesso!' }
});
} catch (err) {
console.error('Erro ao criar turma:', err);
setFormError(err instanceof Error ? err.message : 'Erro ao criar turma');
} finally {
setIsLoading(false);
}
};
return (
<div>
<button
onClick={() => navigate('/dashboard/turmas')}
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6"
>
<ArrowLeft className="h-5 w-5" />
Voltar para turmas
</button>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Nova Turma</h1>
{formError && (
<div className="mb-4 p-4 bg-red-50 text-red-600 rounded-lg">
{formError}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6 max-w-2xl">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Nome da Turma
</label>
<input
type="text"
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
required
placeholder="Ex: 5º Ano A"
/>
</div>
<div>
<label htmlFor="grade" className="block text-sm font-medium text-gray-700">
Série/Ano
</label>
<input
type="text"
id="grade"
value={formData.grade}
onChange={(e) => setFormData({ ...formData, grade: e.target.value })}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
required
placeholder="Ex: 5º Ano"
/>
</div>
<div>
<label htmlFor="year" className="block text-sm font-medium text-gray-700">
Ano Letivo
</label>
<input
type="number"
id="year"
value={formData.year}
onChange={(e) => setFormData({ ...formData, year: parseInt(e.target.value) })}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
required
min={2024}
max={2100}
/>
</div>
<div>
<label htmlFor="period" className="block text-sm font-medium text-gray-700">
Período
</label>
<select
id="period"
value={formData.period}
onChange={(e) => setFormData({ ...formData, period: e.target.value as Class['period'] })}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
>
<option value="morning">Manhã</option>
<option value="afternoon">Tarde</option>
<option value="evening">Noite</option>
</select>
</div>
<div className="pt-4">
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50"
>
{isLoading ? 'Criando...' : 'Criar Turma'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,286 @@
import React from 'react';
import { Building2, Mail, Phone, MapPin, Save, User } from 'lucide-react';
import { supabase } from '../../../lib/supabase';
import type { School } from '../../../types/database';
interface SchoolSettings {
name: string;
email: string;
phone: string;
address: string;
city: string;
state: string;
zipCode: string;
directorName: string;
}
export function SettingsPage() {
const [settings, setSettings] = React.useState<SchoolSettings>({
name: '',
email: '',
phone: '',
address: '',
city: '',
state: '',
zipCode: '',
directorName: ''
});
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [successMessage, setSuccessMessage] = React.useState<string | null>(null);
React.useEffect(() => {
const fetchSchoolSettings = async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.user?.id) return;
const { data, error } = await supabase
.from('schools')
.select('*')
.eq('id', session.user.id)
.single();
if (error) throw error;
if (data) {
setSettings({
name: data.name || '',
email: data.email || '',
phone: data.phone || '',
address: data.address || '',
city: data.city || '',
state: data.state || '',
zipCode: data.zip_code || '',
directorName: data.director_name || ''
});
}
} catch (err) {
console.error('Erro ao carregar configurações:', err);
setError('Não foi possível carregar as configurações da escola');
} finally {
setLoading(false);
}
};
fetchSchoolSettings();
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setSettings(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setError(null);
setSuccessMessage(null);
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.user?.id) throw new Error('Usuário não autenticado');
const { error } = await supabase
.from('schools')
.update({
name: settings.name,
email: settings.email,
phone: settings.phone,
address: settings.address,
city: settings.city,
state: settings.state,
zip_code: settings.zipCode,
director_name: settings.directorName,
updated_at: new Date().toISOString()
})
.eq('id', session.user.id);
if (error) throw error;
setSuccessMessage('Configurações atualizadas com sucesso!');
} catch (err) {
console.error('Erro ao salvar configurações:', err);
setError('Não foi possível salvar as configurações');
} finally {
setSaving(false);
}
};
if (loading) {
return <div className="p-8 text-center text-gray-500">Carregando...</div>;
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-900">Configurações da Escola</h1>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{error && (
<div className="p-4 bg-red-50 text-red-600 rounded-lg">
{error}
</div>
)}
{successMessage && (
<div className="p-4 bg-green-50 text-green-600 rounded-lg">
{successMessage}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Nome da Escola
</label>
<div className="relative">
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
id="name"
name="name"
value={settings.name}
onChange={handleChange}
className="pl-10 w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
required
/>
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="email"
id="email"
name="email"
value={settings.email}
onChange={handleChange}
className="pl-10 w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
required
/>
</div>
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-1">
Telefone
</label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="tel"
id="phone"
name="phone"
value={settings.phone}
onChange={handleChange}
className="pl-10 w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
<div>
<label htmlFor="directorName" className="block text-sm font-medium text-gray-700 mb-1">
Nome do Diretor(a)
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
id="directorName"
name="directorName"
value={settings.directorName}
onChange={handleChange}
className="pl-10 w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
</div>
<div className="border-t border-gray-200 pt-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Endereço</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="address" className="block text-sm font-medium text-gray-700 mb-1">
Endereço
</label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
id="address"
name="address"
value={settings.address}
onChange={handleChange}
className="pl-10 w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="city" className="block text-sm font-medium text-gray-700 mb-1">
Cidade
</label>
<input
type="text"
id="city"
name="city"
value={settings.city}
onChange={handleChange}
className="w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<div>
<label htmlFor="state" className="block text-sm font-medium text-gray-700 mb-1">
Estado
</label>
<input
type="text"
id="state"
name="state"
value={settings.state}
onChange={handleChange}
className="w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
<div>
<label htmlFor="zipCode" className="block text-sm font-medium text-gray-700 mb-1">
CEP
</label>
<input
type="text"
id="zipCode"
name="zipCode"
value={settings.zipCode}
onChange={handleChange}
className="w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
</div>
<div className="flex justify-end pt-6">
<button
type="submit"
disabled={saving}
className="flex items-center gap-2 px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition disabled:opacity-50"
>
<Save className="h-5 w-5" />
{saving ? 'Salvando...' : 'Salvar Alterações'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,330 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Copy, RefreshCw } from 'lucide-react';
import { useDatabase } from '../../../hooks/useDatabase';
import { supabase } from '../../../lib/supabase';
import { sendStudentCredentialsEmail } from '../../../services/email';
import { generateMnemonicPassword } from '../../../utils/passwordGenerator';
interface Class {
id: string;
name: string;
grade: string;
}
export function AddStudentPage() {
const navigate = useNavigate();
const { loading, error } = useDatabase();
const [classes, setClasses] = React.useState<Class[]>([]);
const [formData, setFormData] = React.useState({
name: '',
email: '',
class_id: '',
guardian_name: '',
guardian_email: '',
guardian_phone: '',
password: generateMnemonicPassword()
});
const [formError, setFormError] = React.useState<string | null>(null);
const [successMessage, setSuccessMessage] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
React.useEffect(() => {
const fetchClasses = async () => {
try {
const { data: authData } = await supabase.auth.getSession();
if (!authData.session?.user) return;
const { data: schoolData, error: schoolError } = await supabase
.from('schools')
.select('id')
.eq('id', authData.session.user.id)
.single();
if (schoolError) throw schoolError;
const { data: classesData, error: classesError } = await supabase
.from('classes')
.select('id, name, grade')
.eq('school_id', schoolData.id)
.order('name');
if (classesError) throw classesError;
setClasses(classesData || []);
} catch (err) {
console.error('Erro ao buscar turmas:', err);
}
};
fetchClasses();
}, []);
const handleRegeneratePassword = () => {
setFormData(prev => ({
...prev,
password: generateMnemonicPassword()
}));
};
const handleCopyPassword = async () => {
try {
await navigator.clipboard.writeText(formData.password);
setSuccessMessage('Senha copiada!');
setTimeout(() => setSuccessMessage(null), 2000);
} catch (err) {
console.error('Erro ao copiar senha:', err);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setFormError(null);
try {
const { data: authData } = await supabase.auth.getSession();
if (!authData.session?.user) {
throw new Error('Usuário não autenticado');
}
const { data: schoolData, error: schoolError } = await supabase
.from('schools')
.select('id')
.eq('id', authData.session.user.id)
.single();
if (schoolError) throw schoolError;
// Criar usuário para o aluno com a senha mnemônica
const { data: userData, error: userError } = await supabase.auth.signUp({
email: formData.email,
password: formData.password,
options: {
data: {
role: 'student',
name: formData.name,
school_id: schoolData.id
}
}
});
if (userError) throw userError;
if (!userData.user) throw new Error('Erro ao criar usuário');
// Criar registro do aluno
const { data: newStudent, error: studentError } = await supabase
.from('students')
.insert({
id: userData.user.id,
school_id: schoolData.id,
class_id: formData.class_id,
name: formData.name,
email: formData.email,
guardian_name: formData.guardian_name,
guardian_email: formData.guardian_email,
guardian_phone: formData.guardian_phone
})
.select()
.single();
if (studentError) throw studentError;
// Enviar emails com as credenciais
const emailSent = await sendStudentCredentialsEmail({
studentName: formData.name,
studentEmail: formData.email,
password: formData.password,
guardianName: formData.guardian_name,
guardianEmail: formData.guardian_email
});
navigate('/dashboard/alunos', {
state: {
message: `Aluno adicionado com sucesso! ${
emailSent
? 'As credenciais foram enviadas por email.'
: 'Não foi possível enviar os emails com as credenciais.'
}`
}
});
} catch (err) {
console.error('Erro ao adicionar aluno:', err);
setFormError(err instanceof Error ? err.message : 'Erro ao adicionar aluno');
} finally {
setIsLoading(false);
}
};
return (
<div>
<button
onClick={() => navigate('/dashboard/alunos')}
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6"
>
<ArrowLeft className="h-5 w-5" />
Voltar para alunos
</button>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Adicionar Aluno</h1>
{formError && (
<div className="mb-4 p-4 bg-red-50 text-red-600 rounded-lg">
{formError}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6 max-w-2xl">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Nome do Aluno
</label>
<input
type="text"
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
required
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email do Aluno
</label>
<input
type="email"
id="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Senha de Acesso
</label>
<div className="mt-1 flex gap-2">
<div className="relative flex-1">
<input
type="text"
id="password"
value={formData.password}
readOnly
className="block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500 pr-10"
/>
<button
type="button"
onClick={handleCopyPassword}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<Copy className="h-5 w-5" />
</button>
</div>
<button
type="button"
onClick={handleRegeneratePassword}
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg border border-gray-300"
title="Gerar nova senha"
>
<RefreshCw className="h-5 w-5" />
</button>
</div>
<p className="mt-1 text-sm text-gray-500">
Senha gerada automaticamente para fácil memorização
</p>
</div>
{successMessage && (
<div className="p-4 bg-green-50 text-green-600 rounded-lg">
{successMessage}
</div>
)}
<div>
<label htmlFor="class_id" className="block text-sm font-medium text-gray-700">
Turma
</label>
<select
id="class_id"
value={formData.class_id}
onChange={(e) => setFormData({ ...formData, class_id: e.target.value })}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
required
>
<option value="">Selecione uma turma</option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name} - {c.grade}
</option>
))}
</select>
</div>
<div className="border-t border-gray-200 pt-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">
Informações do Responsável
</h2>
<div className="space-y-6">
<div>
<label htmlFor="guardian_name" className="block text-sm font-medium text-gray-700">
Nome do Responsável
</label>
<input
type="text"
id="guardian_name"
value={formData.guardian_name}
onChange={(e) => setFormData({ ...formData, guardian_name: e.target.value })}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
required
/>
</div>
<div>
<label htmlFor="guardian_email" className="block text-sm font-medium text-gray-700">
Email do Responsável
</label>
<input
type="email"
id="guardian_email"
value={formData.guardian_email}
onChange={(e) => setFormData({ ...formData, guardian_email: e.target.value })}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
required
/>
</div>
<div>
<label htmlFor="guardian_phone" className="block text-sm font-medium text-gray-700">
Telefone do Responsável
</label>
<input
type="tel"
id="guardian_phone"
value={formData.guardian_phone}
onChange={(e) => setFormData({ ...formData, guardian_phone: e.target.value })}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
required
/>
</div>
</div>
</div>
<div className="pt-4">
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50"
>
{isLoading ? 'Adicionando...' : 'Adicionar Aluno'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,175 @@
import React from 'react';
import { Plus, Search, MoreVertical, GraduationCap } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useDatabase } from '../../../hooks/useDatabase';
import { supabase } from '../../../lib/supabase';
interface StudentData {
id: string;
name: string;
email: string;
class_id: string;
school_id: string;
status: 'active' | 'inactive';
classes: {
name: string;
}[];
}
interface StudentWithDetails {
id: string;
name: string;
email: string;
class_name: string;
stories_count: number;
status: 'active' | 'inactive';
}
export function StudentsPage() {
const navigate = useNavigate();
const { loading, error } = useDatabase();
const [students, setStudents] = React.useState<StudentWithDetails[]>([]);
const [searchTerm, setSearchTerm] = React.useState('');
React.useEffect(() => {
const fetchStudents = async () => {
try {
const { data: authData } = await supabase.auth.getSession();
if (!authData.session?.user) return;
const { data: studentsData, error: studentsError } = await supabase
.from('students')
.select(`
id,
name,
email,
class_id,
status,
classes (
name
)
`);
if (studentsError) throw studentsError;
const studentsWithCounts = studentsData.map((student) => ({
id: student.id,
name: student.name,
email: student.email,
class_name: student.classes && student.classes[0] ? student.classes[0].name : 'Sem turma',
stories_count: 0,
status: student.status || 'active'
}));
setStudents(studentsWithCounts);
} catch (err) {
console.error('Erro ao buscar alunos:', err);
}
};
fetchStudents();
}, []);
const handleAddStudent = () => {
navigate('/dashboard/alunos/novo');
};
const handleStudentClick = (studentId: string) => {
navigate(`/dashboard/alunos/${studentId}`);
};
const getStatusColor = (status: StudentData['status']) => {
return status === 'active'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800';
};
const getStatusText = (status: StudentData['status']) => {
return status === 'active' ? 'Ativo' : 'Inativo';
};
const filteredStudents = students.filter(s =>
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.class_name.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-900">Alunos</h1>
<button
onClick={handleAddStudent}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
<Plus className="h-5 w-5" />
Adicionar Aluno
</button>
</div>
{error && (
<div className="mb-4 p-4 bg-red-50 text-red-600 rounded-lg">
{error}
</div>
)}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className="p-4 border-b border-gray-200">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Buscar alunos..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
{loading ? (
<div className="p-8 text-center text-gray-500">Carregando...</div>
) : filteredStudents.length === 0 ? (
<div className="p-8 text-center text-gray-500">
Nenhum aluno encontrado
</div>
) : (
<div className="divide-y divide-gray-200">
{filteredStudents.map((student) => (
<div
key={student.id}
className="p-4 hover:bg-gray-50 cursor-pointer"
onClick={() => handleStudentClick(student.id)}
>
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium text-gray-900">
{student.name}
</h3>
<div className="flex items-center gap-2 text-sm text-gray-500">
<GraduationCap className="h-4 w-4" />
{student.class_name}
</div>
</div>
<div className="flex items-center gap-6">
<div className="text-sm text-gray-500">
<span className="font-medium text-gray-900">
{student.stories_count}
</span>{' '}
histórias
</div>
<div className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(student.status)}`}>
{getStatusText(student.status)}
</div>
<button className="p-2 hover:bg-gray-100 rounded-full">
<MoreVertical className="h-5 w-5 text-gray-400" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,148 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Send } from 'lucide-react';
import { useDatabase } from '../../../hooks/useDatabase';
import { supabase } from '../../../lib/supabase';
export function InviteTeacherPage() {
const navigate = useNavigate();
const { inviteTeacher } = useDatabase();
const [isLoading, setIsLoading] = React.useState(false);
const [formData, setFormData] = React.useState({
name: '',
email: '',
subject: '',
message: ''
});
const [formError, setFormError] = React.useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setFormError(null);
try {
const { data: authData } = await supabase.auth.getSession();
if (!authData.session?.user) {
throw new Error('Usuário não autenticado');
}
const { data: schoolData, error: schoolError } = await supabase
.from('schools')
.select('id')
.eq('id', authData.session.user.id)
.single();
if (schoolError) throw schoolError;
const result = await inviteTeacher(schoolData.id, {
name: formData.name,
email: formData.email,
subject: formData.subject,
message: formData.message
});
if (!result) {
throw new Error('Erro ao enviar convite');
}
navigate('/dashboard/professores', {
state: { message: 'Convite enviado com sucesso!' }
});
} catch (err) {
console.error('Erro ao enviar convite:', err);
setFormError(err instanceof Error ? err.message : 'Erro ao enviar convite');
} finally {
setIsLoading(false);
}
};
return (
<div>
<button
onClick={() => navigate('/dashboard/professores')}
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6"
>
<ArrowLeft className="h-5 w-5" />
Voltar para professores
</button>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Convidar Professor</h1>
{formError && (
<div className="mb-4 p-4 bg-red-50 text-red-600 rounded-lg">
{formError}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6 max-w-2xl">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Nome do Professor
</label>
<input
type="text"
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
required
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
type="email"
id="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
required
/>
</div>
<div>
<label htmlFor="subject" className="block text-sm font-medium text-gray-700">
Disciplina
</label>
<input
type="text"
id="subject"
value={formData.subject}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700">
Mensagem Personalizada (opcional)
</label>
<textarea
id="message"
rows={4}
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
/>
</div>
<div className="pt-4">
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center items-center gap-2 py-2 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50"
>
<Send className="h-4 w-4" />
{isLoading ? 'Enviando...' : 'Enviar Convite'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,115 @@
import React from 'react';
import { Plus, Search, MoreVertical, GraduationCap, Mail } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '../../../lib/supabase';
import type { Teacher } from '../../../types/database';
export function TeachersPage() {
const navigate = useNavigate();
const [teachers, setTeachers] = React.useState<Teacher[]>([]);
const [searchTerm, setSearchTerm] = React.useState('');
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
const fetchTeachers = async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.user?.id) return;
const { data, error } = await supabase
.from('teachers')
.select('*')
.eq('school_id', session.user.id);
if (error) throw error;
setTeachers(data || []);
} catch (err) {
console.error('Erro ao buscar professores:', err);
setError('Erro ao buscar professores');
} finally {
setLoading(false);
}
};
fetchTeachers();
}, []);
const filteredTeachers = teachers.filter(teacher =>
teacher.name.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-900">Professores</h1>
<button
onClick={() => navigate('convidar')}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
<Plus className="h-5 w-5" />
Convidar Professor
</button>
</div>
{error && (
<div className="mb-4 p-4 bg-red-50 text-red-600 rounded-lg">
{error}
</div>
)}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className="p-4 border-b border-gray-200">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Buscar professores..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
{loading ? (
<div className="p-8 text-center text-gray-500">Carregando...</div>
) : filteredTeachers.length === 0 ? (
<div className="p-8 text-center text-gray-500">
{searchTerm ? 'Nenhum professor encontrado' : 'Nenhum professor cadastrado'}
</div>
) : (
<div className="divide-y divide-gray-200">
{filteredTeachers.map((teacher) => (
<div
key={teacher.id}
className="p-4 hover:bg-gray-50 cursor-pointer"
onClick={() => navigate(`/dashboard/professores/${teacher.id}`)}
>
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium text-gray-900">
{teacher.name}
</h3>
<div className="flex items-center gap-2 text-sm text-gray-500">
<Mail className="h-4 w-4" />
{teacher.email}
</div>
</div>
<div className="flex items-center gap-6">
<span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
Ativo
</span>
<button className="p-2 hover:bg-gray-100 rounded-full">
<MoreVertical className="h-5 w-5 text-gray-400" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,96 @@
import React, { useState } from 'react';
import { AudioRecorderDemo } from '../../components/demo/AudioRecorderDemo';
import { ArrowRight, Sparkles } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
export function DemoPage() {
const navigate = useNavigate();
const [demoResult, setDemoResult] = useState<{
fluency?: number;
accuracy?: number;
confidence?: number;
feedback?: string;
} | null>(null);
const handleDemoComplete = (result: typeof demoResult) => {
setDemoResult(result);
};
return (
<div className="min-h-screen bg-gradient-to-b from-purple-50 to-white py-12">
<div className="max-w-4xl mx-auto px-4">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
Experimente Agora!
</h1>
<p className="text-xl text-gray-600">
Grave um trecho de leitura e veja como nossa IA avalia seu desempenho
</p>
</div>
<div className="bg-white rounded-2xl shadow-xl p-8 mb-8">
<div className="prose max-w-none mb-8">
<h2>Texto Sugerido para Leitura:</h2>
<blockquote className="text-lg text-gray-700 border-l-4 border-purple-300 pl-4">
"O pequeno príncipe sentou-se numa pedra e levantou os olhos para o céu:
Pergunto-me se as estrelas são iluminadas para que cada um possa um dia encontrar a sua."
</blockquote>
</div>
<AudioRecorderDemo onAnalysisComplete={handleDemoComplete} />
</div>
{demoResult && (
<div className="bg-white rounded-2xl shadow-xl p-8 mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center gap-2">
<Sparkles className="text-purple-600" />
Resultado da Análise
</h2>
<div className="grid md:grid-cols-3 gap-6 mb-8">
<div className="bg-purple-50 rounded-xl p-6">
<div className="text-3xl font-bold text-purple-600 mb-2">
{demoResult.fluency}%
</div>
<div className="text-gray-600">Fluência</div>
</div>
<div className="bg-purple-50 rounded-xl p-6">
<div className="text-3xl font-bold text-purple-600 mb-2">
{demoResult.accuracy}%
</div>
<div className="text-gray-600">Precisão</div>
</div>
<div className="bg-purple-50 rounded-xl p-6">
<div className="text-3xl font-bold text-purple-600 mb-2">
{demoResult.confidence}%
</div>
<div className="text-gray-600">Confiança</div>
</div>
</div>
<div className="bg-green-50 rounded-xl p-6 mb-8">
<h3 className="text-lg font-semibold text-green-800 mb-2">
Feedback da IA
</h3>
<p className="text-green-700">
{demoResult.feedback}
</p>
</div>
<div className="text-center">
<button
onClick={() => navigate('/register/school')}
className="inline-flex items-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition"
>
Começar a Usar na Minha Escola
<ArrowRight className="w-5 h-5" />
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,277 @@
import React from 'react';
import { ArrowLeft, ArrowRight, Mic, Volume2, Share2, ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
import { AudioRecorder } from '../../components/story/AudioRecorder';
import { StoryMetrics } from '../../components/story/StoryMetrics';
import type { StoryRecording } from '../../types/database';
// Separar dados mock em arquivo próprio
const DEMO_DATA = {
recording: {
id: 'demo-recording',
fluency_score: 85,
pronunciation_score: 90,
accuracy_score: 88,
comprehension_score: 92,
words_per_minute: 120,
pause_count: 3,
error_count: 2,
self_corrections: 1,
strengths: [
'Ótima pronúncia das palavras',
'Boa velocidade de leitura',
'Excelente compreensão do texto'
],
improvements: [
'Reduzir pequenas pausas entre frases',
'Praticar palavras mais complexas'
],
suggestions: 'Continue praticando a leitura em voz alta regularmente',
created_at: new Date().toISOString(),
processed_at: new Date().toISOString()
},
story: {
id: 'demo',
student_id: 'demo',
title: 'Uma Aventura Educacional',
content: {
pages: [
{
text: 'Bem-vindo à demonstração do Histórias Mágicas! Aqui você pode ver como funciona nossa plataforma de leitura interativa...',
image: 'https://images.unsplash.com/photo-1472162072942-cd5147eb3902?auto=format&fit=crop&q=80&w=800&h=600',
},
{
text: 'Com histórias interativas e educativas, seus alunos aprenderão de forma divertida e envolvente. Cada história é uma nova aventura!',
image: 'https://images.unsplash.com/photo-1519681393784-d120267933ba?auto=format&fit=crop&q=80&w=800&h=600',
}
]
}
}
};
// Componente para imagem com loading
function ImageWithLoading({ src, alt }: { src: string; alt: string }) {
const [isLoading, setIsLoading] = React.useState(true);
return (
<div className="relative aspect-video bg-gray-50">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="w-8 h-8 text-purple-600 animate-spin" />
</div>
)}
<img
src={src}
alt={alt}
className={`w-full h-full object-cover transition-opacity duration-300 ${
isLoading ? 'opacity-0' : 'opacity-100'
}`}
onLoad={() => setIsLoading(false)}
/>
</div>
);
}
// Componente para navegação entre páginas
function PageNavigation({
currentPage,
totalPages,
onPrevious,
onNext
}: {
currentPage: number;
totalPages: number;
onPrevious: () => void;
onNext: () => void;
}) {
return (
<div className="flex justify-between items-center mt-8 pt-6 border-t border-gray-200">
<button
onClick={onPrevious}
disabled={currentPage === 0}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50 transition"
>
<ArrowLeft className="h-5 w-5" />
Anterior
</button>
<span className="text-sm font-medium text-gray-500">
Página {currentPage + 1} de {totalPages}
</span>
<button
onClick={onNext}
disabled={currentPage === totalPages - 1}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50 transition"
>
Próxima
<ArrowRight className="h-5 w-5" />
</button>
</div>
);
}
export function StoryPageDemo(): JSX.Element {
const [currentPage, setCurrentPage] = React.useState(0);
const [isPlaying, setIsPlaying] = React.useState(false);
const [showMetrics, setShowMetrics] = React.useState(false);
const [isRecording, setIsRecording] = React.useState(false);
const handleShare = () => {
alert('Funcionalidade de compartilhamento disponível apenas na versão completa!');
};
// Simula o processo de gravação e análise
const handleRecordingComplete = () => {
setIsRecording(true);
setTimeout(() => {
setIsRecording(false);
setShowMetrics(true);
}, 3000); // Simula 3 segundos de "processamento"
};
return (
<div className="min-h-screen bg-gradient-to-b from-purple-50 to-white py-12">
<div className="max-w-4xl mx-auto px-4 space-y-8">
{/* Header */}
<header className="flex justify-between items-center">
<button
onClick={() => window.history.back()}
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 transition"
>
<ArrowLeft className="h-5 w-5" />
Voltar
</button>
<div className="flex items-center gap-4">
<button
onClick={handleShare}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 transition"
>
<Share2 className="h-5 w-5" />
Compartilhar
</button>
<button
onClick={() => setIsPlaying(!isPlaying)}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 transition"
>
<Volume2 className="h-5 w-5" />
{isPlaying ? 'Pausar' : 'Ouvir'}
</button>
</div>
</header>
{/* História com Imagem */}
<main className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<ImageWithLoading
src={DEMO_DATA.story.content.pages[currentPage].image}
alt={`Página ${currentPage + 1}`}
/>
<div className="p-8">
<h1 className="text-3xl font-bold text-gray-900 mb-6">
{DEMO_DATA.story.title}
</h1>
<p className="text-xl leading-relaxed text-gray-700 mb-8">
{DEMO_DATA.story.content.pages[currentPage].text}
</p>
<div className="flex items-center gap-4">
<button
onClick={handleRecordingComplete}
disabled={isRecording}
className={`flex items-center gap-2 px-6 py-3 rounded-lg text-white transition
${isRecording
? 'bg-gray-400 cursor-not-allowed'
: 'bg-purple-600 hover:bg-purple-700'}`}
>
<Mic className="h-5 w-5" />
{isRecording ? 'Processando...' : 'Gravar Leitura'}
</button>
{isRecording && (
<div className="flex items-center gap-2 text-gray-600">
<Loader2 className="h-5 w-5 animate-spin" />
Analisando sua leitura...
</div>
)}
</div>
<PageNavigation
currentPage={currentPage}
totalPages={DEMO_DATA.story.content.pages.length}
onPrevious={() => setCurrentPage(p => Math.max(0, p - 1))}
onNext={() => setCurrentPage(p => Math.min(DEMO_DATA.story.content.pages.length - 1, p + 1))}
/>
</div>
</main>
{/* Dashboard de Métricas Condicional */}
{showMetrics && (
<section className="space-y-6">
<h2 className="text-2xl font-bold text-gray-900">
Dashboard de Leitura
</h2>
<StoryMetrics
data={{
metrics: {
fluency: DEMO_DATA.recording.fluency_score,
pronunciation: DEMO_DATA.recording.pronunciation_score,
accuracy: DEMO_DATA.recording.accuracy_score,
comprehension: DEMO_DATA.recording.comprehension_score
},
feedback: {
strengths: DEMO_DATA.recording.strengths,
improvements: DEMO_DATA.recording.improvements,
suggestions: DEMO_DATA.recording.suggestions
},
details: {
wordsPerMinute: DEMO_DATA.recording.words_per_minute,
pauseCount: DEMO_DATA.recording.pause_count,
errorCount: DEMO_DATA.recording.error_count,
selfCorrections: DEMO_DATA.recording.self_corrections
}
}}
isLoading={false}
/>
</section>
)}
{/* CTAs */}
<section className="border-t border-gray-200 pt-8 mt-12">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 text-center">
<h3 className="text-xl font-bold text-gray-900 mb-4">
Para Escolas
</h3>
<p className="text-gray-600 mb-6">
Transforme a experiência de leitura na sua escola com nossa plataforma educacional.
</p>
<button
onClick={() => window.location.href = '/register/school'}
className="w-full px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
Começar a Usar na Minha Escola
</button>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 text-center">
<h3 className="text-xl font-bold text-gray-900 mb-4">
Para Pais
</h3>
<p className="text-gray-600 mb-6">
Acompanhe e incentive o desenvolvimento da leitura do seu filho de forma interativa.
</p>
<button
onClick={() => window.location.href = '/register/parent'}
className="w-full px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
>
Quero Usar com Meu Filho
</button>
</div>
</div>
</section>
</div>
</div>
);
}

View File

@ -0,0 +1,807 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import {
ArrowRight, Wand2, Shield, Star, BookOpen,
Brain, Target, Users, Award, CheckCircle,
Clock, Heart, Sparkles, ScrollText, Lock, X,
Facebook, Instagram, Twitter, Youtube
} from 'lucide-react';
export function EducationalForParents(): JSX.Element {
const navigate = useNavigate();
return (
<div className="min-h-screen">
{/* 1. Hero Section */}
<section className="relative overflow-hidden bg-gradient-to-b from-purple-50 via-white to-purple-50">
<div className="absolute inset-0 bg-[url('/patterns/magic.svg')] opacity-5" />
<div className="px-4 py-24 mx-auto max-w-7xl relative">
{/* Reading Time */}
<div className="absolute top-8 right-8 flex items-center gap-2 text-sm text-gray-500">
<Clock className="h-4 w-4" />
<span>Tempo de leitura: 5 minutos</span>
</div>
<div className="flex flex-col md:flex-row items-center gap-16">
<div className="flex-1 space-y-8">
<h1 className="text-6xl font-bold text-gray-900 leading-tight">
Transforme o Aprendizado em Uma
<span className="block bg-gradient-to-r from-purple-600 to-blue-500 bg-clip-text text-transparent">
Aventura Mágica
</span>
</h1>
<p className="text-xl text-gray-600 leading-relaxed">
Histórias educativas personalizadas que encantam e ensinam, criadas especialmente
para o desenvolvimento único do seu filho.
</p>
<div className="flex gap-4">
<button
onClick={() => navigate('/register/parent')}
className="group px-8 py-4 bg-gradient-to-r from-purple-600 to-blue-500
text-white rounded-xl hover:from-purple-700 hover:to-blue-600
transform hover:scale-105 transition-all shadow-lg"
>
Comece Sua Aventura Mágica Grátis
<ArrowRight className="inline-block ml-2 h-5 w-5
group-hover:translate-x-1 transition-transform" />
</button>
</div>
{/* Social Proof */}
<div className="flex gap-8 text-sm text-gray-600">
<div className="flex items-center gap-2">
<BookOpen className="h-5 w-5 text-purple-600" />
<span>Mais de 10.000 histórias mágicas criadas</span>
</div>
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-blue-500" />
<span>5.000 pequenos leitores encantados</span>
</div>
</div>
</div>
<div className="flex-1">
<div className="relative">
<div className="absolute -inset-4 bg-gradient-to-r from-purple-600 to-blue-500
rounded-2xl blur-lg opacity-20" />
<img
src="/images/magic-book.webp"
alt="Crianças mergulhadas em um livro mágico"
className="relative rounded-2xl shadow-2xl transform hover:scale-[1.02]
transition-transform"
/>
</div>
</div>
</div>
</div>
</section>
{/* 2. Problema & Solução */}
<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">
Desafios que Todo Pai Enfrenta
</h2>
<div className="grid md:grid-cols-3 gap-8 mb-16">
{challenges.map((challenge, index) => (
<div key={index} className="p-6 bg-purple-50 rounded-xl">
<challenge.icon className="h-12 w-12 text-purple-600 mb-4" />
<h3 className="text-xl font-bold text-gray-900 mb-2">
{challenge.title}
</h3>
<p className="text-gray-600">{challenge.description}</p>
</div>
))}
</div>
<div className="grid md:grid-cols-4 gap-8">
{benefits.map((benefit, index) => (
<div key={index} className="text-center">
<div className="mx-auto w-16 h-16 flex items-center justify-center
bg-gradient-to-r from-purple-600 to-blue-500 rounded-full mb-4">
<benefit.icon className="h-8 w-8 text-white" />
</div>
<h4 className="font-bold text-gray-900 mb-2">{benefit.title}</h4>
<p className="text-sm text-gray-600">{benefit.description}</p>
</div>
))}
</div>
</div>
</section>
{/* 3. Como a Magia Acontece */}
<section className="px-4 py-24 bg-gradient-to-br from-purple-50 to-blue-50">
<div className="mx-auto max-w-7xl">
<h2 className="text-4xl font-bold text-center text-gray-900 mb-16">
Como a Magia Acontece
</h2>
<div className="grid md:grid-cols-2 gap-12 items-center">
<div className="space-y-12">
{magicSteps.map((step, index) => (
<div key={index} className="flex gap-6">
<div className="flex-shrink-0 w-12 h-12 bg-gradient-to-r from-purple-600
to-blue-500 text-white rounded-full flex items-center justify-center
text-xl font-bold">
{index + 1}
</div>
<div>
<h3 className="text-xl font-bold text-gray-900 mb-2">
{step.title}
</h3>
<p className="text-gray-600">{step.description}</p>
</div>
</div>
))}
</div>
<div className="relative">
<div className="aspect-video rounded-xl overflow-hidden shadow-xl">
<video
className="w-full h-full object-cover"
autoPlay
muted
loop
playsInline
poster="/images/demo-poster.webp"
>
<source src="/videos/demo.mp4" type="video/mp4" />
</video>
</div>
<div className="absolute -bottom-6 -right-6 bg-white p-4 rounded-lg shadow-lg">
<div className="flex items-center gap-2 text-sm text-purple-600 font-medium">
<Sparkles className="h-4 w-4" />
<span>Veja a mágica acontecer!</span>
</div>
</div>
</div>
</div>
</div>
</section>
{/* 4. Comparação */}
<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">
A Magia da Transformação
</h2>
<div className="grid md:grid-cols-2 gap-8">
{/* Sem Histórias Mágicas */}
<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 Histórias Mágicas
</h3>
</div>
{comparisonData.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 Histórias Mágicas */}
<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 Histórias Mágicas
</h3>
</div>
{comparisonData.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>
{/* 5. Benefícios Mágicos Detalhados */}
<section className="px-4 py-24 bg-gradient-to-br from-purple-50 to-blue-50">
<div className="mx-auto max-w-7xl">
<h2 className="text-4xl font-bold text-center text-gray-900 mb-16">
Benefícios Mágicos Detalhados
</h2>
<div className="grid lg:grid-cols-5 gap-8 mb-16">
{detailedBenefits.map((benefit, index) => (
<div
key={index}
className="bg-white p-6 rounded-xl shadow-sm hover:shadow-md
transform hover:scale-105 transition-all"
>
<div className="flex flex-col items-center text-center gap-4">
<div className="w-16 h-16 flex items-center justify-center
bg-gradient-to-r from-purple-600 to-blue-500 rounded-full">
<benefit.icon className="h-8 w-8 text-white" />
</div>
<h3 className="text-xl font-bold text-gray-900">{benefit.title}</h3>
<p className="text-gray-600">{benefit.description}</p>
</div>
</div>
))}
</div>
{/* Preview do Portal */}
<div className="relative mt-20">
<div className="absolute -inset-4 bg-gradient-to-r from-purple-600 to-blue-500
rounded-2xl blur-lg opacity-20" />
<div className="relative bg-white p-8 rounded-xl shadow-xl">
<div className="grid md:grid-cols-2 gap-12 items-center">
<div>
<h3 className="text-2xl font-bold text-gray-900 mb-6">
Portal dos Pais: Acompanhamento em Tempo Real
</h3>
<ul className="space-y-4">
<li className="flex items-start gap-3">
<CheckCircle className="h-6 w-6 text-green-500 flex-shrink-0 mt-1" />
<span className="text-gray-600">
Métricas detalhadas de progresso e desenvolvimento
</span>
</li>
<li className="flex items-start gap-3">
<CheckCircle className="h-6 w-6 text-green-500 flex-shrink-0 mt-1" />
<span className="text-gray-600">
Relatórios semanais personalizados
</span>
</li>
<li className="flex items-start gap-3">
<CheckCircle className="h-6 w-6 text-green-500 flex-shrink-0 mt-1" />
<span className="text-gray-600">
Recomendações pedagógicas baseadas em dados
</span>
</li>
<li className="flex items-start gap-3">
<CheckCircle className="h-6 w-6 text-green-500 flex-shrink-0 mt-1" />
<span className="text-gray-600">
Histórico completo de leituras e conquistas
</span>
</li>
</ul>
</div>
<div className="relative">
<img
src="/images/dashboard-preview.webp"
alt="Portal dos Pais"
className="rounded-xl shadow-2xl"
/>
<div className="absolute -bottom-6 -right-6 bg-white p-4 rounded-lg shadow-lg">
<div className="flex items-center gap-2 text-sm text-purple-600 font-medium">
<Lock className="h-4 w-4" />
<span>Ambiente 100% seguro e monitorado</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* 6. Testimoniais */}
<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">
Histórias de Transformação
</h2>
<div className="grid md:grid-cols-3 gap-8">
{testimonials.map((testimonial, index) => (
<div key={index} className="bg-gradient-to-br from-purple-50 to-blue-50
p-6 rounded-xl shadow-sm">
<div className="relative mb-8">
<img
src={testimonial.image}
alt={`Família de ${testimonial.name}`}
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="text-gray-600 mb-4 italic">"{testimonial.text}"</p>
<div>
<p className="font-bold text-gray-900">{testimonial.name}</p>
<p className="text-sm text-gray-500">{testimonial.role}</p>
</div>
<div className="mt-4 p-3 bg-white rounded-lg">
<p className="text-sm text-purple-600 font-medium">
Momento mágico: {testimonial.magicMoment}
</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* 7. Planos */}
<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">
Planos Mágicos
</h2>
<p className="text-center text-gray-600 mb-16 max-w-2xl mx-auto">
Escolha o plano perfeito para a jornada mágica do seu filho
</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
${index === 1 ? '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>
</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={() => navigate('/register/parent')}
className={`
w-full py-4 rounded-xl font-medium transition-all
${index === 1
? 'bg-gradient-to-r from-purple-600 to-blue-500 text-white hover:from-purple-700 hover:to-blue-600'
: 'border-2 border-purple-600 text-purple-600 hover:bg-purple-50'}
`}
>
Começar Agora
</button>
{index === 1 && (
<div className="mt-4 text-center">
<span className="inline-block px-4 py-1 bg-purple-100 text-purple-600
rounded-full text-sm font-medium">
Mais Popular
</span>
</div>
)}
</div>
))}
</div>
<div className="mt-12 text-center">
<p className="text-gray-600 mb-4">
Garantia mágica de 30 dias ou seu dinheiro de volta
</p>
<div className="flex justify-center gap-4">
{paymentMethods.map((method, index) => (
<img
key={index}
src={method.icon}
alt={method.name}
className="h-8"
/>
))}
</div>
</div>
</div>
</section>
{/* 8. FAQ */}
<section className="px-4 py-24 bg-white">
<div className="mx-auto max-w-3xl">
<h2 className="text-4xl font-bold text-center text-gray-900 mb-16">
Perguntas Mágicas
</h2>
<div className="space-y-8">
{faqItems.map((item, index) => (
<div key={index} className="bg-gray-50 rounded-xl p-6">
<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>
{/* 9. CTA Final */}
<section className="px-4 py-24 bg-gradient-to-br from-purple-600 to-blue-500 text-white">
<div className="mx-auto max-w-3xl text-center">
<h2 className="text-4xl font-bold mb-8">
Comece a Jornada Mágica Hoje
</h2>
<p className="text-xl opacity-90 mb-12">
Transforme a educação do seu filho em uma aventura inesquecível
</p>
<div className="bg-white/10 p-6 rounded-xl mb-8">
<p className="font-medium mb-2">
Oferta por tempo limitado!
</p>
<p className="text-sm opacity-90">
7 dias grátis + Bônus especial de boas-vindas
</p>
</div>
<button
onClick={() => navigate('/register/parent')}
className="px-12 py-6 bg-white text-purple-600 rounded-xl text-xl font-bold
hover:bg-gray-100 transform hover:scale-105 transition-all shadow-lg"
>
Criar Conta Gratuita
<ArrowRight className="inline-block ml-2 h-6 w-6" />
</button>
<p className="mt-6 text-sm opacity-75">
Garantia de 30 dias ou seu dinheiro de volta
</p>
</div>
</section>
{/* 10. Rodapé */}
<footer className="bg-gray-900 text-gray-400 py-16">
<div className="mx-auto max-w-7xl px-4">
<div className="grid md:grid-cols-4 gap-12">
<div>
<h4 className="text-white font-bold mb-4">Histórias Mágicas</h4>
<p className="text-sm">
Transformando a educação através da magia da leitura personalizada
</p>
</div>
{footerLinks.map((column, index) => (
<div key={index}>
<h4 className="text-white font-bold mb-4">{column.title}</h4>
<ul className="space-y-2">
{column.links.map((link, idx) => (
<li key={idx}>
<a href={link.href} className="text-sm hover:text-white transition-colors">
{link.text}
</a>
</li>
))}
</ul>
</div>
))}
</div>
<div className="mt-12 pt-8 border-t border-gray-800 text-sm">
<div className="flex justify-between items-center">
<p>© 2024 Histórias Mágicas. Todos os direitos reservados.</p>
<div className="flex gap-4">
{socialLinks.map((social, index) => (
<a
key={index}
href={social.href}
className="text-gray-400 hover:text-white transition-colors"
>
<social.icon className="h-5 w-5" />
</a>
))}
</div>
</div>
</div>
</div>
</footer>
</div>
);
}
const challenges = [
{
icon: Brain,
title: "Manter as crianças interessadas em aprender",
description: "É difícil competir com jogos e vídeos para capturar a atenção das crianças."
},
{
icon: BookOpen,
title: "Encontrar conteúdo educativo de qualidade",
description: "Muito conteúdo disponível, mas pouco realmente educativo e envolvente."
},
{
icon: Target,
title: "Acompanhar o desenvolvimento da criança",
description: "Falta de ferramentas para monitorar o progresso de forma clara e objetiva."
}
];
const benefits = [
{
icon: Wand2,
title: "Personalização por IA",
description: "Histórias únicas criadas especialmente para cada criança"
},
{
icon: Star,
title: "Monitoramento Educacional",
description: "Acompanhe o progresso com métricas claras e objetivas"
},
{
icon: Shield,
title: "Segurança de Conteúdo",
description: "Ambiente seguro e controlado para o aprendizado"
},
{
icon: Sparkles,
title: "Engajamento Garantido",
description: "Histórias que prendem a atenção e estimulam a imaginação"
}
];
const magicSteps = [
{
title: "Escolha o tema da aventura",
description: "Selecione entre diversos temas educativos alinhados com a BNCC e adequados à idade."
},
{
title: "Personalize os personagens",
description: "Crie personagens que seu filho vai adorar, com características únicas e cativantes."
},
{
title: "A IA cria a história mágica",
description: "Nossa IA educacional gera uma história personalizada em segundos."
},
{
title: "A aventura educativa começa",
description: "Seu filho mergulha em uma jornada mágica de aprendizado e diversão."
}
];
const comparisonData = [
{
title: "Tempo & Diversão",
without: [
"Horas procurando conteúdo educativo adequado",
"Crianças entediadas com leituras tradicionais",
"Histórias que não capturam a imaginação",
"Dificuldade em acompanhar o progresso"
],
with: [
"Histórias mágicas personalizadas em minutos",
"Crianças fascinadas por aventuras únicas",
"Mundos mágicos que educam e encantam",
"Portal mágico de acompanhamento do progresso"
]
},
{
title: "Qualidade do Aprendizado",
without: [
"Conteúdo genérico e previsível",
"Falta de conexão emocional com a leitura",
"Dificuldade em manter o interesse",
"Aprendizado fragmentado"
],
with: [
"Histórias que evoluem com cada criança",
"Conexão emocional com personagens únicos",
"Aventuras que mesclam diversão e educação",
"Jornada de aprendizado mágica e integrada"
]
},
{
title: "Resultados",
without: [
"Progresso lento e desmotivador",
"Resistência à leitura e aprendizado",
"Rotina de estudos cansativa",
"Pais preocupados com desenvolvimento"
],
with: [
"Evolução visível e empolgante",
"Amor natural pela leitura e conhecimento",
"Aventuras diárias de aprendizado",
"Pais confiantes no desenvolvimento mágico"
]
}
];
const detailedBenefits = [
{
icon: Wand2,
title: "Aprendizado Através de Aventuras",
description: "Histórias que se adaptam ao nível e interesses do seu filho, tornando o aprendizado natural e divertido."
},
{
icon: ScrollText,
title: "Portal dos Pais",
description: "Acompanhe em tempo real o progresso de leitura, compreensão e desenvolvimento do seu filho."
},
{
icon: Shield,
title: "Proteção Mágica",
description: "Conteúdo 100% seguro e adequado, com moderação constante e controles parentais."
},
{
icon: BookOpen,
title: "Alinhamento com BNCC",
description: "Histórias criadas seguindo as diretrizes da Base Nacional Comum Curricular."
},
{
icon: Brain,
title: "IA Educacional",
description: "Nossa inteligência artificial analisa o perfil do seu filho para criar histórias personalizadas e adaptativas."
}
];
const testimonials = [
{
image: "/images/testimonial-1.webp",
text: "Minha filha passou de resistente à leitura para não querer parar de ler! As histórias personalizadas fizeram toda a diferença.",
name: "Ana Silva",
role: "Mãe da Maria, 8 anos",
magicMoment: "Primeira história completa lida sozinha"
},
{
image: "/images/testimonial-2.webp",
text: "Como pai, é incrível ver o progresso do Pedro. O portal dos pais me ajuda a entender exatamente onde ele precisa de apoio.",
name: "Carlos Santos",
role: "Pai do Pedro, 10 anos",
magicMoment: "Superou a dificuldade com palavras complexas"
},
{
image: "/images/testimonial-3.webp",
text: "As histórias são tão envolventes que meu filho pede para ler mais uma toda noite. O aprendizado acontece naturalmente!",
name: "Juliana Costa",
role: "Mãe do Lucas, 7 anos",
magicMoment: "Começou a criar suas próprias histórias"
}
];
const plans = [
{
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"
],
highlight: 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"
}
];
const faqItems = [
{
question: "Como a magia da IA funciona?",
answer: "Nossa IA educacional analisa o perfil do seu filho, incluindo idade, interesses e nível de leitura, para criar histórias únicas que combinam diversão com aprendizado personalizado."
},
{
question: "Como garantimos histórias seguras?",
answer: "Todas as histórias passam por múltiplas camadas de verificação, incluindo filtros de IA e revisão humana, garantindo conteúdo 100% adequado e seguro."
},
{
question: "Como acompanhar a evolução mágica?",
answer: "Através do Portal dos Pais, você tem acesso a relatórios detalhados sobre fluência, compreensão, vocabulário e muito mais, com visualizações claras do progresso."
},
{
question: "Qual é a política de cancelamento?",
answer: "Você pode cancelar sua assinatura a qualquer momento, sem multas. Oferecemos garantia de 30 dias - se não estiver satisfeito, devolvemos seu dinheiro."
},
{
question: "Quantas histórias mágicas por mês?",
answer: "O número de histórias varia conforme o plano escolhido, desde 5 histórias mensais no plano básico até histórias ilimitadas no plano Grão-Mestre."
},
{
question: "Como funciona o suporte aos pais?",
answer: "Oferecemos suporte via chat, email e telefone, com especialistas em educação prontos para ajudar. Planos premium incluem acesso a pedagogos."
}
];
const footerLinks = [
{
title: "Produto",
links: [
{ text: "Recursos", href: "#recursos" },
{ text: "Preços", href: "#precos" },
{ text: "Como Funciona", href: "#como-funciona" },
{ text: "Histórias de Sucesso", href: "#testimoniais" }
]
},
{
title: "Suporte",
links: [
{ text: "Central de Ajuda", href: "#ajuda" },
{ text: "Contato", href: "#contato" },
{ text: "FAQ", href: "#faq" },
{ text: "Tutoriais", href: "#tutoriais" }
]
},
{
title: "Legal",
links: [
{ text: "Termos de Uso", href: "#termos" },
{ text: "Privacidade", href: "#privacidade" },
{ text: "Segurança", href: "#seguranca" },
{ text: "Cookies", href: "#cookies" }
]
}
];
const socialLinks = [
{ icon: Facebook, href: "https://facebook.com/historias-magicas" },
{ icon: Instagram, href: "https://instagram.com/historias-magicas" },
{ icon: Twitter, href: "https://twitter.com/historias-magicas" },
{ icon: Youtube, href: "https://youtube.com/historias-magicas" }
];
const paymentMethods = [
{ name: "Cartão de Crédito", icon: "/icons/credit-card.svg" },
{ name: "Boleto", icon: "/icons/boleto.svg" },
{ name: "PIX", icon: "/icons/pix.svg" },
{ name: "PayPal", icon: "/icons/paypal.svg" }
];

View File

@ -0,0 +1,458 @@
import React from 'react';
import { ArrowRight, BookOpen, Brain, Target, Clock, Shield, Check, X } from 'lucide-react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { useNavigate } from 'react-router-dom';
export function ParentsLandingPage(): JSX.Element {
const navigate = useNavigate();
return (
<div className="min-h-screen bg-gradient-to-b from-purple-50 via-white to-purple-50">
{/* 1. Hero Section */}
<section className="relative overflow-hidden">
<div className="absolute inset-0 bg-[url('/patterns/grid.svg')] opacity-5" />
<div className="px-4 py-24 mx-auto max-w-7xl relative">
<div className="flex flex-col md:flex-row items-center gap-16">
<div className="flex-1 space-y-8">
<h1 className="text-6xl font-bold text-gray-900 leading-tight">
Transforme a Leitura do Seu Filho em uma
<span className="bg-gradient-to-r from-purple-600 to-indigo-600 bg-clip-text text-transparent">
Jornada Mágica
</span>
</h1>
<p className="text-xl text-gray-600 leading-relaxed">
Uma plataforma educacional que combina tecnologia e pedagogia para
desenvolver habilidades essenciais de leitura de forma divertida e envolvente.
</p>
<div className="flex gap-4">
<button
onClick={() => navigate('/register/parent')}
className="px-8 py-4 bg-gradient-to-r from-purple-600 to-indigo-600 text-white rounded-xl
hover:from-purple-700 hover:to-indigo-700 transform hover:scale-105 transition-all shadow-lg"
>
Começar Gratuitamente
<ArrowRight className="inline-block ml-2 h-5 w-5" />
</button>
<button
onClick={() => navigate('/demo')}
className="px-8 py-4 border-2 border-purple-600 text-purple-600 rounded-xl
hover:bg-purple-50 transform hover:scale-105 transition-all"
>
Ver Demonstração
</button>
</div>
</div>
<div className="flex-1">
<div className="relative">
<div className="absolute -inset-4 bg-gradient-to-r from-purple-600 to-indigo-600 rounded-2xl blur-lg opacity-20" />
<img
src="/images/reading-kid.webp"
alt="Criança lendo com entusiasmo"
className="relative rounded-2xl shadow-2xl transform hover:scale-[1.02] transition-transform"
/>
</div>
</div>
</div>
</div>
</section>
{/* 2. Por que escolher */}
<section className="px-4 py-24 bg-white relative overflow-hidden">
<div className="absolute inset-0 bg-[url('/patterns/dots.svg')] opacity-5" />
<div className="mx-auto max-w-7xl relative">
<h2 className="text-3xl font-bold text-center text-gray-900 mb-16">
Por que escolher o Histórias Mágicas?
</h2>
<div className="grid md:grid-cols-3 gap-8">
<div className="p-6 bg-purple-50 rounded-xl">
<BookOpen className="h-12 w-12 text-purple-600 mb-4" />
<h3 className="text-xl font-bold text-gray-900 mb-2">
Leitura Personalizada
</h3>
<p className="text-gray-600">
Histórias adaptadas ao nível e interesses do seu filho,
garantindo engajamento e progresso contínuo.
</p>
</div>
<div className="p-6 bg-purple-50 rounded-xl">
<Brain className="h-12 w-12 text-purple-600 mb-4" />
<h3 className="text-xl font-bold text-gray-900 mb-2">
Análise em Tempo Real
</h3>
<p className="text-gray-600">
Feedback instantâneo sobre fluência, compreensão e
pronúncia para identificar áreas de melhoria.
</p>
</div>
<div className="p-6 bg-purple-50 rounded-xl">
<Target className="h-12 w-12 text-purple-600 mb-4" />
<h3 className="text-xl font-bold text-gray-900 mb-2">
Progresso Mensurável
</h3>
<p className="text-gray-600">
Acompanhe o desenvolvimento do seu filho com métricas
claras e relatórios detalhados.
</p>
</div>
</div>
</div>
</section>
{/* 3. Como Funciona */}
<section className="px-4 py-24">
<div className="mx-auto max-w-7xl">
<h2 className="text-3xl font-bold text-center text-gray-900 mb-16">
Como Funciona
</h2>
<div className="grid md:grid-cols-2 gap-12 items-center">
<div className="space-y-8">
<div className="flex gap-4">
<div className="flex-shrink-0 w-8 h-8 bg-purple-600 text-white rounded-full flex items-center justify-center">
1
</div>
<div>
<h3 className="text-xl font-bold text-gray-900 mb-2">
Escolha uma História
</h3>
<p className="text-gray-600">
Biblioteca diversificada com conteúdo educativo e adequado
para cada idade e nível de leitura.
</p>
</div>
</div>
<div className="flex gap-4">
<div className="flex-shrink-0 w-8 h-8 bg-purple-600 text-white rounded-full flex items-center justify-center">
2
</div>
<div>
<h3 className="text-xl font-bold text-gray-900 mb-2">
Pratique a Leitura
</h3>
<p className="text-gray-600">
Interface interativa que incentiva a leitura em voz alta
e fornece suporte quando necessário.
</p>
</div>
</div>
<div className="flex gap-4">
<div className="flex-shrink-0 w-8 h-8 bg-purple-600 text-white rounded-full flex items-center justify-center">
3
</div>
<div>
<h3 className="text-xl font-bold text-gray-900 mb-2">
Receba Feedback
</h3>
<p className="text-gray-600">
Análise detalhada do desempenho com sugestões
personalizadas para melhorar.
</p>
</div>
</div>
</div>
<div className="bg-white p-8 rounded-xl shadow-xl">
<img
src="/images/app-demo.webp"
alt="Demonstração da plataforma"
className="rounded-lg"
/>
</div>
</div>
</div>
</section>
{/* 4. Análise Detalhada */}
<section className="px-4 py-24 bg-gray-50">
<div className="mx-auto max-w-7xl">
<h2 className="text-3xl font-bold text-center text-gray-900 mb-16">
Análise Detalhada do Progresso
</h2>
<div className="grid md:grid-cols-2 gap-12">
{/* Métricas */}
<div className="bg-white p-8 rounded-xl shadow-sm">
<h3 className="text-xl font-bold text-gray-900 mb-6">
Exemplo de Análise de Leitura
</h3>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<p className="text-sm text-gray-500">Fluência</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-4 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-green-500 rounded-full" style={{ width: '85%' }} />
</div>
<span className="text-sm font-medium">85%</span>
</div>
</div>
<div className="space-y-2">
<p className="text-sm text-gray-500">Pronúncia</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-4 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-blue-500 rounded-full" style={{ width: '92%' }} />
</div>
<span className="text-sm font-medium">92%</span>
</div>
</div>
<div className="space-y-2">
<p className="text-sm text-gray-500">Compreensão</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-4 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-purple-500 rounded-full" style={{ width: '88%' }} />
</div>
<span className="text-sm font-medium">88%</span>
</div>
</div>
<div className="space-y-2">
<p className="text-sm text-gray-500">Ritmo</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-4 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-yellow-500 rounded-full" style={{ width: '78%' }} />
</div>
<span className="text-sm font-medium">78%</span>
</div>
</div>
</div>
<div className="mt-8 p-4 bg-purple-50 rounded-lg">
<h4 className="font-medium text-purple-900 mb-2">Sugestões de Melhoria</h4>
<ul className="text-sm text-purple-700 space-y-2">
<li> Praticar palavras mais complexas</li>
<li> Manter ritmo constante durante a leitura</li>
<li> Fazer pausas adequadas na pontuação</li>
</ul>
</div>
</div>
{/* Gráfico */}
<div className="bg-white p-8 rounded-xl shadow-sm">
<h3 className="text-xl font-bold text-gray-900 mb-6">
Evolução da Leitura
</h3>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={progressData}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Line
type="monotone"
dataKey="fluency"
stroke="#8b5cf6"
strokeWidth={2}
name="Fluência"
/>
<Line
type="monotone"
dataKey="comprehension"
stroke="#10b981"
strokeWidth={2}
name="Compreensão"
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>
</section>
{/* 5. A Diferença que Faz */}
<section className="px-4 py-24">
<div className="mx-auto max-w-7xl">
<h2 className="text-3xl font-bold text-center text-gray-900 mb-16">
A Diferença que Faz
</h2>
<div className="grid md:grid-cols-2 gap-8">
{/* Sem o Histórias Mágicas */}
<div className="p-8 bg-gray-50 rounded-xl">
<h3 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-2">
<X className="text-red-500" />
Sem o Histórias Mágicas
</h3>
<ul className="space-y-4">
<li 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">
Leitura monótona e desmotivadora
</span>
</li>
<li 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">
Sem feedback sobre o progresso
</span>
</li>
<li 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">
Dificuldade em manter consistência
</span>
</li>
<li 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">
Erros passam despercebidos
</span>
</li>
<li 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">
Falta de direcionamento pedagógico
</span>
</li>
</ul>
</div>
{/* Com o Histórias Mágicas */}
<div className="p-8 bg-purple-50 rounded-xl">
<h3 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-2">
<Check className="text-green-500" />
Com o Histórias Mágicas
</h3>
<ul className="space-y-4">
<li className="flex items-start gap-2">
<Check className="h-5 w-5 text-green-500 flex-shrink-0 mt-1" />
<span className="text-gray-600">
Histórias interativas e envolventes
</span>
</li>
<li className="flex items-start gap-2">
<Check className="h-5 w-5 text-green-500 flex-shrink-0 mt-1" />
<span className="text-gray-600">
Análise detalhada do desempenho
</span>
</li>
<li className="flex items-start gap-2">
<Check className="h-5 w-5 text-green-500 flex-shrink-0 mt-1" />
<span className="text-gray-600">
Gamificação que incentiva a prática diária
</span>
</li>
<li className="flex items-start gap-2">
<Check className="h-5 w-5 text-green-500 flex-shrink-0 mt-1" />
<span className="text-gray-600">
Correção em tempo real e sugestões
</span>
</li>
<li className="flex items-start gap-2">
<Check className="h-5 w-5 text-green-500 flex-shrink-0 mt-1" />
<span className="text-gray-600">
Acompanhamento pedagógico personalizado
</span>
</li>
</ul>
</div>
</div>
</div>
</section>
{/* 6. O que os Pais Dizem */}
<section className="px-4 py-24 bg-purple-50">
<div className="mx-auto max-w-7xl">
<h2 className="text-3xl font-bold text-center text-gray-900 mb-16">
O Que os Pais Dizem
</h2>
<div className="grid md:grid-cols-3 gap-8">
{testimonials.map((testimonial, index) => (
<div key={index} className="bg-white p-6 rounded-xl shadow-sm">
<p className="text-gray-600 mb-4">"{testimonial.text}"</p>
<div className="flex items-center gap-4">
<img
src={testimonial.avatar}
alt={testimonial.name}
className="w-12 h-12 rounded-full"
/>
<div>
<p className="font-medium text-gray-900">{testimonial.name}</p>
<p className="text-sm text-gray-500">{testimonial.role}</p>
</div>
</div>
</div>
))}
</div>
</div>
</section>
{/* 7. CTA Final */}
<section className="px-4 py-24 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-purple-50 to-purple-100 opacity-50" />
<div className="mx-auto max-w-3xl text-center relative">
<h2 className="text-5xl font-bold text-gray-900 mb-8 leading-tight">
Comece a Jornada de Leitura do Seu Filho
<span className="bg-gradient-to-r from-purple-600 to-indigo-600 bg-clip-text text-transparent">
Hoje
</span>
</h2>
<p className="text-xl text-gray-600 mb-12 leading-relaxed">
Junte-se a milhares de pais que transformaram a experiência
de leitura de seus filhos com o Histórias Mágicas.
</p>
<button
onClick={() => navigate('/register/parent')}
className="px-10 py-5 bg-gradient-to-r from-purple-600 to-indigo-600 text-white text-lg
rounded-xl hover:from-purple-700 hover:to-indigo-700 transform hover:scale-105
transition-all shadow-lg"
>
Criar Conta Gratuita
<ArrowRight className="inline-block ml-2 h-6 w-6" />
</button>
</div>
</section>
</div>
);
}
const testimonials = [
{
text: "Minha filha melhorou muito sua leitura em apenas 3 meses. Ela adora as histórias e sempre pede para ler mais!",
name: "Ana Silva",
role: "Mãe da Maria, 8 anos",
avatar: "/avatars/parent-1.webp"
},
{
text: "O feedback em tempo real ajuda muito a identificar onde meu filho precisa melhorar. É como ter um professor particular.",
name: "Carlos Santos",
role: "Pai do Pedro, 10 anos",
avatar: "/avatars/parent-2.webp"
},
{
text: "As histórias são envolventes e educativas. É ótimo ver meu filho animado para ler todos os dias.",
name: "Juliana Costa",
role: "Mãe do Lucas, 7 anos",
avatar: "/avatars/parent-3.webp"
}
];
const progressData = [
{ month: 'Jan', fluency: 45, comprehension: 40 },
{ month: 'Fev', fluency: 52, comprehension: 48 },
{ month: 'Mar', fluency: 58, comprehension: 55 },
{ month: 'Abr', fluency: 65, comprehension: 62 },
{ month: 'Mai', fluency: 75, comprehension: 70 },
{ month: 'Jun', fluency: 85, comprehension: 82 }
];

View File

@ -0,0 +1,86 @@
import React from 'react';
import { Card } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge';
interface Achievement {
id: string;
titulo: string;
descricao: string;
icone: string;
conquistado: boolean;
dataConquista?: string;
progresso?: number;
}
const conquistas: Achievement[] = [
{
id: '1',
titulo: 'Primeira História',
descricao: 'Completou sua primeira história',
icone: '📚',
conquistado: true,
dataConquista: '2024-03-15',
},
{
id: '2',
titulo: 'Leitor Dedicado',
descricao: 'Leu histórias por 5 dias seguidos',
icone: '🌟',
conquistado: false,
progresso: 3,
},
{
id: '3',
titulo: 'Explorador de Mundos',
descricao: 'Leu histórias de 5 categorias diferentes',
icone: '🌍',
conquistado: false,
progresso: 2,
},
];
export function AchievementsPage(): JSX.Element {
return (
<div className="container mx-auto p-6">
<div className="mb-8">
<h1 className="text-3xl font-bold text-primary mb-2">Minhas Conquistas</h1>
<p className="text-gray-600">
Continue lendo e completando atividades para desbloquear mais conquistas!
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{conquistas.map((conquista) => (
<Card key={conquista.id} className={`p-6 ${!conquista.conquistado ? 'opacity-75' : ''}`}>
<div className="flex items-start justify-between mb-4">
<span className="text-4xl">{conquista.icone}</span>
{conquista.conquistado ? (
<Badge variant="success">Conquistado!</Badge>
) : (
<Badge variant="secondary">Em progresso</Badge>
)}
</div>
<h3 className="text-xl font-semibold mb-2">{conquista.titulo}</h3>
<p className="text-gray-600 mb-4">{conquista.descricao}</p>
{conquista.progresso !== undefined && (
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className="bg-primary h-2.5 rounded-full transition-all"
style={{ width: `${(conquista.progresso / 5) * 100}%` }}
/>
</div>
)}
{conquista.dataConquista && (
<p className="text-sm text-gray-500 mt-4">
Conquistado em: {new Date(conquista.dataConquista).toLocaleDateString('pt-BR')}
</p>
)}
</Card>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,71 @@
import React from 'react';
import { ArrowLeft, Sparkles } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { StoryGenerator } from '../../components/story/StoryGenerator';
import { useSession } from '../../hooks/useSession';
export function CreateStoryPage() {
const navigate = useNavigate();
const { session } = useSession();
const [error, setError] = React.useState<string | null>(null);
if (!session) {
return (
<div className="text-center py-12">
<p className="text-gray-600">Você precisa estar logado para criar histórias.</p>
<button
onClick={() => navigate('/login')}
className="mt-4 text-purple-600 hover:text-purple-700"
>
Fazer login
</button>
</div>
);
}
return (
<div>
<button
onClick={() => navigate('/aluno/historias')}
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6"
>
<ArrowLeft className="h-5 w-5" />
Voltar para histórias
</button>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-3 mb-8">
<div className="p-2 bg-purple-100 rounded-lg">
<Sparkles className="h-6 w-6 text-purple-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Criar Nova História</h1>
<p className="text-gray-600">
Vamos criar uma história personalizada baseada nos seus interesses
</p>
</div>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 text-red-600 rounded-lg">
{error}
</div>
)}
<StoryGenerator />
<div className="mt-8 p-4 bg-purple-50 rounded-lg">
<h3 className="text-sm font-medium text-purple-900 mb-2">
Como funciona?
</h3>
<ol className="text-sm text-purple-700 space-y-2">
<li>1. Conte-nos sobre seus interesses e preferências</li>
<li>2. Escolha personagens e cenários para sua história</li>
<li>3. Nossa IA criará uma história única e personalizada</li>
<li>4. Você poderá ler e praticar com sua nova história</li>
</ol>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,459 @@
import React, { useState, useEffect } from 'react';
import { ArrowLeft, ArrowRight, Mic, Volume2, Share2, Save, ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
import { useParams, useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase';
import { AudioRecorder } from '../../components/story/AudioRecorder';
import type { Story } from '../../types/database';
import { StoryMetrics } from '../../components/story/StoryMetrics';
import type { MetricsData } from '../../components/story/StoryMetrics';
import { getOptimizedImageUrl } from '../../lib/imageUtils';
interface StoryRecording {
id: string;
fluency_score: number;
pronunciation_score: number;
accuracy_score: number;
comprehension_score: number;
words_per_minute: number;
pause_count: number;
error_count: number;
self_corrections: number;
strengths: string[];
improvements: string[];
suggestions: string;
created_at: string;
processed_at: string | null;
}
function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
const [isExpanded, setIsExpanded] = React.useState(false);
const metrics = [
{ label: 'Fluência', value: recording.fluency_score, color: 'text-blue-600' },
{ label: 'Pronúncia', value: recording.pronunciation_score, color: 'text-green-600' },
{ label: 'Precisão', value: recording.accuracy_score, color: 'text-purple-600' },
{ label: 'Compreensão', value: recording.comprehension_score, color: 'text-orange-600' }
];
return (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
{/* Cabeçalho sempre visível */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full p-4 text-left hover:bg-gray-50 transition-colors"
>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-gray-500">
{new Date(recording.created_at).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</span>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
</div>
{/* Grid de métricas */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{metrics.map((metric) => (
<div key={metric.label} className="flex flex-col">
<span className={`text-sm font-medium ${metric.color}`}>
{metric.label}
</span>
<span className="text-lg font-semibold">
{metric.value}%
</span>
</div>
))}
</div>
</button>
{/* Conteúdo expandido */}
{isExpanded && (
<div className="px-4 pb-4 border-t border-gray-100">
<div className="grid grid-cols-2 gap-4 mt-4 text-sm">
<div>
<span className="text-gray-500">Palavras por minuto:</span>
<span className="ml-2 font-medium">{recording.words_per_minute}</span>
</div>
<div>
<span className="text-gray-500">Pausas:</span>
<span className="ml-2 font-medium">{recording.pause_count}</span>
</div>
<div>
<span className="text-gray-500">Erros:</span>
<span className="ml-2 font-medium">{recording.error_count}</span>
</div>
<div>
<span className="text-gray-500">Autocorreções:</span>
<span className="ml-2 font-medium">{recording.self_corrections}</span>
</div>
</div>
<div className="mt-4 space-y-3">
<div>
<h5 className="text-sm font-medium text-green-600 mb-1">Pontos Fortes</h5>
<ul className="list-disc list-inside text-sm text-gray-600">
{recording.strengths.map((strength, i) => (
<li key={i}>{strength}</li>
))}
</ul>
</div>
<div>
<h5 className="text-sm font-medium text-orange-600 mb-1">Pontos para Melhorar</h5>
<ul className="list-disc list-inside text-sm text-gray-600">
{recording.improvements.map((improvement, i) => (
<li key={i}>{improvement}</li>
))}
</ul>
</div>
<div>
<h5 className="text-sm font-medium text-blue-600 mb-1">Sugestões</h5>
<p className="text-sm text-gray-600">{recording.suggestions}</p>
</div>
</div>
</div>
)}
</div>
);
}
function ImageWithLoading({ src, alt, className }: { src: string; alt: string; className?: string }) {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(false);
return (
<div className="relative aspect-video bg-gray-100">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="w-8 h-8 text-purple-600 animate-spin" />
</div>
)}
<img
src={src}
alt={alt}
loading="lazy"
className={`w-full h-full object-cover transition-opacity duration-300 ${
isLoading ? 'opacity-0' : 'opacity-100'
} ${className}`}
onLoad={() => setIsLoading(false)}
onError={() => {
setError(true);
setIsLoading(false);
}}
/>
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
<p className="text-gray-500">Erro ao carregar imagem</p>
</div>
)}
</div>
);
}
export function StoryPage() {
const navigate = useNavigate();
const { id } = useParams();
const [story, setStory] = React.useState<Story | null>(null);
const [currentPage, setCurrentPage] = React.useState(0);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [isPlaying, setIsPlaying] = React.useState(false);
const [recordings, setRecordings] = React.useState<StoryRecording[]>([]);
const [loadingRecordings, setLoadingRecordings] = React.useState(true);
const [metrics, setMetrics] = React.useState<MetricsData | null>(null);
const [loadingMetrics, setLoadingMetrics] = React.useState(true);
React.useEffect(() => {
const fetchStory = async () => {
try {
if (!id) throw new Error('ID da história não fornecido');
// Buscar história e suas páginas
const { data: storyData, error: storyError } = await supabase
.from('stories')
.select(`
*,
story_pages (
id,
page_number,
text,
image_url
)
`)
.eq('id', id)
.single();
if (storyError) throw storyError;
// Ordenar páginas por número
const orderedPages = storyData.story_pages.sort((a: { page_number: number }, b: { page_number: number }) => a.page_number - b.page_number);
setStory({
...storyData,
content: {
pages: orderedPages.map((page: { text: string; image_url: string }) => ({
text: page.text,
image: page.image_url
}))
}
});
} catch (err) {
console.error('Erro ao carregar história:', err);
setError('Não foi possível carregar a história');
} finally {
setLoading(false);
}
};
fetchStory();
}, [id]);
React.useEffect(() => {
const fetchMetrics = async () => {
if (!story?.id) return;
try {
const { data, error } = await supabase
.from('reading_metrics')
.select('*')
.eq('story_id', story.id)
.single();
if (error) throw error;
setMetrics(data);
} catch (err) {
console.error('Erro ao carregar métricas:', err);
} finally {
setLoadingMetrics(false);
}
};
fetchMetrics();
}, [story?.id]);
React.useEffect(() => {
const fetchRecordings = async () => {
if (!story?.id) return;
try {
const { data, error } = await supabase
.from('story_recordings')
.select('*')
.eq('story_id', story.id)
.eq('status', 'completed')
.order('created_at', { ascending: false });
if (error) throw error;
setRecordings(data || []);
} catch (err) {
console.error('Erro ao carregar gravações:', err);
} finally {
setLoadingRecordings(false);
}
};
fetchRecordings();
}, [story?.id]);
const handleShare = async () => {
if (navigator.share) {
try {
await navigator.share({
title: story?.title,
text: 'Confira minha história!',
url: window.location.href
});
} catch (err) {
console.error('Erro ao compartilhar:', err);
}
}
};
const getLatestRecording = () => recordings[0];
const formatMetricsData = (recording: StoryRecording) => ({
metrics: {
fluency: recording.fluency_score,
pronunciation: recording.pronunciation_score,
accuracy: recording.accuracy_score,
comprehension: recording.comprehension_score
},
feedback: {
strengths: recording.strengths,
improvements: recording.improvements,
suggestions: recording.suggestions
},
details: {
wordsPerMinute: recording.words_per_minute,
pauseCount: recording.pause_count,
errorCount: recording.error_count,
selfCorrections: recording.self_corrections
}
});
// Pré-carregar próxima imagem
useEffect(() => {
const nextImageUrl = story?.content?.pages?.[currentPage + 1]?.image;
if (nextImageUrl) {
const nextImage = new Image();
nextImage.src = getOptimizedImageUrl(nextImageUrl, {
width: 1200,
quality: 85
});
}
}, [currentPage, story]);
if (loading) {
return (
<div className="animate-pulse">
<div className="h-96 bg-gray-200 rounded-xl mb-8" />
<div className="h-20 bg-gray-200 rounded-xl" />
</div>
);
}
if (error || !story) {
return (
<div className="text-center py-12">
<div className="text-red-500 mb-4">{error}</div>
<button
onClick={() => navigate('/aluno/historias')}
className="text-purple-600 hover:text-purple-700"
>
Voltar para histórias
</button>
</div>
);
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<button
onClick={() => navigate('/aluno/historias')}
className="flex items-center gap-2 text-gray-600 hover:text-gray-900"
>
<ArrowLeft className="h-5 w-5" />
Voltar para histórias
</button>
<div className="flex items-center gap-4">
<button
onClick={handleShare}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900"
>
<Share2 className="h-5 w-5" />
Compartilhar
</button>
<button
onClick={() => setIsPlaying(!isPlaying)}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900"
>
<Volume2 className="h-5 w-5" />
{isPlaying ? 'Pausar' : 'Ouvir'}
</button>
</div>
</div>
{/* Dashboard de métricas */}
{loadingRecordings ? (
<div className="animate-pulse">
<div className="h-48 bg-gray-100 rounded-lg mb-6" />
</div>
) : recordings.length > 0 ? (
<StoryMetrics
data={formatMetricsData(getLatestRecording())}
isLoading={false}
/>
) : (
<div className="bg-gray-50 rounded-lg p-6 text-center mb-6">
<p className="text-gray-600">
Você ainda não tem gravações para esta história.
Faça sua primeira gravação para ver suas métricas!
</p>
</div>
)}
{/* Histórico de gravações */}
{recordings.length > 1 && (
<div className="mb-6">
<h3 className="text-lg font-medium mb-4">Histórico de Gravações</h3>
<div className="space-y-4">
{recordings.slice(1).map((recording) => (
<RecordingHistoryCard
key={recording.id}
recording={recording}
/>
))}
</div>
</div>
)}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{/* Imagem da página atual */}
{story?.content?.pages?.[currentPage]?.image && (
<ImageWithLoading
src={getOptimizedImageUrl(story.content.pages[currentPage].image, {
width: 1200,
quality: 85
})}
alt={`Página ${currentPage + 1}`}
className="w-full h-full object-cover"
/>
)}
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-4">{story?.title}</h1>
{/* Texto da página atual */}
<p className="text-lg text-gray-700 mb-8">
{story?.content?.pages?.[currentPage]?.text || 'Carregando...'}
</p>
{/* Gravador de áudio */}
<AudioRecorder
storyId={story.id}
studentId={story.student_id}
onAudioUploaded={(audioUrl) => {
console.log('Áudio gravado:', audioUrl);
}}
/>
{/* Navegação entre páginas */}
<div className="flex justify-between items-center mt-8 pt-6 border-t border-gray-200">
<button
onClick={() => setCurrentPage(prev => Math.max(0, prev - 1))}
disabled={currentPage === 0}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50"
>
<ArrowLeft className="h-5 w-5" />
Anterior
</button>
<span className="text-sm text-gray-500">
Página {currentPage + 1} de {story.content.pages.length}
</span>
<button
onClick={() => setCurrentPage(prev => Math.min(story.content.pages.length - 1, prev + 1))}
disabled={currentPage === story.content.pages.length - 1}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50"
>
Próxima
<ArrowRight className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,15 @@
import React from 'react';
import { useParams } from 'react-router-dom';
export function StudentClassPage(): JSX.Element {
const { classId } = useParams();
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">
Turma {classId}
</h1>
{/* Conteúdo da página da turma será implementado aqui */}
</div>
);
}

View File

@ -0,0 +1,12 @@
import React from 'react';
export function StudentDashboard(): JSX.Element {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">
Bem-vindo ao Dashboard
</h1>
{/* Conteúdo do dashboard será implementado aqui */}
</div>
);
}

View File

@ -0,0 +1,142 @@
import React from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
BookOpen,
Settings,
LogOut,
School,
Trophy,
History
} from 'lucide-react';
import { useAuth } from '../../hooks/useAuth';
export function StudentDashboardLayout() {
const navigate = useNavigate();
const { signOut } = useAuth();
const handleLogout = async () => {
await signOut();
navigate('/');
};
return (
<div className="min-h-screen bg-gray-50">
{/* Sidebar */}
<aside className="fixed left-0 top-0 h-full w-64 bg-white border-r border-gray-200">
<div className="flex items-center gap-2 p-6 border-b border-gray-200">
<School className="h-8 w-8 text-purple-600" />
<span className="font-semibold text-gray-900">Histórias Mágicas</span>
</div>
<nav className="p-4 space-y-1">
<NavLink
to="/aluno"
end
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
isActive
? 'bg-purple-50 text-purple-700'
: 'text-gray-600 hover:bg-gray-50'
}`
}
>
<LayoutDashboard className="h-5 w-5" />
Início
</NavLink>
<NavLink
to="/aluno/historias"
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
isActive
? 'bg-purple-50 text-purple-700'
: 'text-gray-600 hover:bg-gray-50'
}`
}
>
<BookOpen className="h-5 w-5" />
Minhas Histórias
</NavLink>
<NavLink
to="/aluno/conquistas"
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
isActive
? 'bg-purple-50 text-purple-700'
: 'text-gray-600 hover:bg-gray-50'
}`
}
>
<Trophy className="h-5 w-5" />
Conquistas
</NavLink>
<NavLink
to="/aluno/historico"
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
isActive
? 'bg-purple-50 text-purple-700'
: 'text-gray-600 hover:bg-gray-50'
}`
}
>
<History className="h-5 w-5" />
Histórico
</NavLink>
<NavLink
to="/aluno/configuracoes"
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
isActive
? 'bg-purple-50 text-purple-600'
: 'text-gray-600 hover:bg-gray-50'
}`
}
>
<Settings className="h-5 w-5" />
Configurações
</NavLink>
<button
onClick={handleLogout}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-50 w-full mt-4"
>
<LogOut className="h-5 w-5" />
Sair
</button>
</nav>
{/* Footer com informações do aluno */}
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-purple-600">
{/* Primeira letra do nome do aluno */}
A
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{/* Nome do aluno */}
Aluno da Silva
</p>
<p className="text-xs text-gray-500 truncate">
{/* Turma do aluno */}
5º Ano A
</p>
</div>
</div>
</div>
</aside>
{/* Main Content */}
<main className="ml-64 p-8">
<Outlet />
</main>
</div>
);
}

View File

@ -0,0 +1,286 @@
import React from 'react';
import { Plus, BookOpen, Clock, TrendingUp, Award } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase';
import type { Story, Student } from '../../types/database';
import { getOptimizedImageUrl } from '../../lib/imageUtils';
interface DashboardMetrics {
totalStories: number;
averageReadingFluency: number;
totalReadingTime: number;
currentLevel: number;
}
export function StudentDashboardPage() {
const navigate = useNavigate();
const [student, setStudent] = React.useState<Student | null>(null);
const [stories, setStories] = React.useState<Story[]>([]);
const [metrics, setMetrics] = React.useState<DashboardMetrics>({
totalStories: 0,
averageReadingFluency: 0,
totalReadingTime: 0,
currentLevel: 1
});
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [recentStories, setRecentStories] = React.useState<Story[]>([]);
React.useEffect(() => {
const fetchDashboardData = async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.user?.id) return;
// Buscar dados do aluno
const { data: studentData, error: studentError } = await supabase
.from('students')
.select(`
*,
class:classes(name, grade),
school:schools(name)
`)
.eq('id', session.user.id)
.single();
if (studentError) throw studentError;
setStudent(studentData);
// Buscar histórias do aluno
const { data: storiesData, error: storiesError } = await supabase
.from('stories')
.select('*')
.eq('student_id', session.user.id)
.order('created_at', { ascending: false })
.limit(6);
if (storiesError) throw storiesError;
setStories(storiesData || []);
// Calcular métricas
// Em produção: Implementar cálculos reais baseados nos dados
setMetrics({
totalStories: storiesData?.length || 0,
averageReadingFluency: 85, // Exemplo
totalReadingTime: 120, // Exemplo: 120 minutos
currentLevel: 3 // Exemplo
});
// Buscar histórias recentes com a primeira página como capa
const { data, error } = await supabase
.from('stories')
.select(`
*,
cover:story_pages!inner(
image_url
)
`)
.eq('student_id', session.user.id)
.eq('story_pages.page_number', 1) // Garante que pegamos a primeira página
.order('created_at', { ascending: false })
.limit(3);
if (error) throw error;
setRecentStories(data || []);
} catch (err) {
console.error('Erro ao carregar dashboard:', err);
setError('Não foi possível carregar seus dados');
} finally {
setLoading(false);
}
};
fetchDashboardData();
}, []);
if (loading) {
return (
<div className="animate-pulse">
<div className="h-32 bg-gray-200 rounded-xl mb-8" />
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-24 bg-gray-200 rounded-xl" />
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-64 bg-gray-200 rounded-xl" />
))}
</div>
</div>
);
}
if (error) {
return (
<div className="text-center py-12">
<div className="text-red-500 mb-4">{error}</div>
<button
onClick={() => window.location.reload()}
className="text-purple-600 hover:text-purple-700"
>
Tentar novamente
</button>
</div>
);
}
return (
<div>
{/* Cabeçalho */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8">
<div className="flex justify-between items-start">
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center">
{student?.avatar_url ? (
<img
src={student.avatar_url}
alt={student.name}
className="w-10 h-10 rounded-full"
/>
) : (
<span className="text-2xl font-bold text-purple-600">
{student?.name?.charAt(0)}
</span>
)}
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">{student?.name}</h1>
<p className="text-xs text-gray-500 truncate">
{student?.class?.name} - {student?.class?.grade} {student?.school?.name}
</p>
</div>
</div>
<button
onClick={() => navigate('/aluno/historias/nova')}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
<Plus className="h-5 w-5" />
Nova História
</button>
</div>
</div>
{/* Métricas */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<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 bg-purple-100 rounded-lg">
<BookOpen className="h-6 w-6 text-purple-600" />
</div>
<div>
<p className="text-sm text-gray-500">Total de Histórias</p>
<p className="text-2xl font-bold text-gray-900">{metrics.totalStories}</p>
</div>
</div>
</div>
<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 bg-green-100 rounded-lg">
<TrendingUp className="h-6 w-6 text-green-600" />
</div>
<div>
<p className="text-sm text-gray-500">Fluência Média</p>
<p className="text-2xl font-bold text-gray-900">{metrics.averageReadingFluency}%</p>
</div>
</div>
</div>
<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 bg-blue-100 rounded-lg">
<Clock className="h-6 w-6 text-blue-600" />
</div>
<div>
<p className="text-sm text-gray-500">Tempo de Leitura</p>
<p className="text-2xl font-bold text-gray-900">{metrics.totalReadingTime}min</p>
</div>
</div>
</div>
<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 bg-yellow-100 rounded-lg">
<Award className="h-6 w-6 text-yellow-600" />
</div>
<div>
<p className="text-sm text-gray-500">Nível Atual</p>
<p className="text-2xl font-bold text-gray-900">{metrics.currentLevel}</p>
</div>
</div>
</div>
</div>
{/* Histórias Recentes */}
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Histórias Recentes</h2>
<button
onClick={() => navigate('/aluno/historias')}
className="flex items-center gap-2 text-purple-600 hover:text-purple-700"
>
Ver todas
</button>
</div>
{recentStories.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center">
<BookOpen className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Nenhuma história ainda
</h3>
<p className="text-gray-500 mb-6">
Comece sua jornada criando sua primeira história!
</p>
<button
onClick={() => navigate('/aluno/historias/nova')}
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
<Plus className="h-5 w-5" />
Criar História
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{recentStories.map((story) => (
<div
key={story.id}
className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden cursor-pointer hover:shadow-md transition"
onClick={() => navigate(`/aluno/historias/${story.id}`)}
>
{story.cover && (
<div className="relative aspect-video">
<img
src={getOptimizedImageUrl(story.cover.image_url, {
width: 400,
height: 300
})}
alt={story.title}
className="w-full h-48 object-cover"
loading="lazy"
/>
</div>
)}
<div className="p-6">
<h3 className="font-medium text-gray-900 mb-2">{story.title}</h3>
<div className="flex items-center justify-between text-sm text-gray-500">
<span>{new Date(story.created_at).toLocaleDateString()}</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
story.status === 'published'
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{story.status === 'published' ? 'Publicada' : 'Rascunho'}
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,66 @@
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../components/ui/tabs';
import { Input } from '../../components/ui/input';
import { DatePicker } from '../../components/ui/date-picker';
import { Select } from '../../components/ui/select';
import { AvatarUpload } from '../../components/ui/avatar-upload';
export function StudentSettingsPage() {
return (
<div className="max-w-4xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">
Configurações do Perfil
</h1>
<Tabs defaultValue="personal">
<TabsList>
<TabsTrigger value="personal">Informações Pessoais</TabsTrigger>
<TabsTrigger value="preferences">Preferências</TabsTrigger>
<TabsTrigger value="accessibility">Acessibilidade</TabsTrigger>
<TabsTrigger value="notifications">Notificações</TabsTrigger>
</TabsList>
<TabsContent value="personal">
<div className="space-y-6">
<div className="flex items-center gap-4">
<AvatarUpload />
<div>
<h3 className="font-medium">Foto do Perfil</h3>
<p className="text-sm text-gray-500">
JPG ou PNG, máximo 2MB
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Input
label="Nome Completo"
name="fullName"
placeholder="Seu nome completo"
/>
<Input
label="Nome Social/Apelido"
name="nickname"
placeholder="Como prefere ser chamado"
/>
<DatePicker
label="Data de Nascimento"
name="birthDate"
/>
<Select
label="Gênero"
name="gender"
options={[
{ value: 'male', label: 'Masculino' },
{ value: 'female', label: 'Feminino' },
{ value: 'non_binary', label: 'Não-binário' },
{ value: 'other', label: 'Outro' },
{ value: 'prefer_not_to_say', label: 'Prefiro não dizer' }
]}
/>
</div>
</div>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,71 @@
import { AvatarUpload } from "@/components/ui/avatar-upload";
import { DatePicker } from "@/components/ui/date-picker";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs";
export function StudentSettingsPage() {
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<h1 className="text-2xl font-bold text-gray-900 mb-6">
Configurações do Perfil
</h1>
{/* Seções em Tabs */}
<Tabs defaultValue="personal">
<TabsList>
<TabsTrigger value="personal">Informações Pessoais</TabsTrigger>
<TabsTrigger value="preferences">Preferências</TabsTrigger>
<TabsTrigger value="accessibility">Acessibilidade</TabsTrigger>
<TabsTrigger value="notifications">Notificações</TabsTrigger>
</TabsList>
<TabsContent value="personal">
<div className="space-y-6">
{/* Avatar Upload */}
<div className="flex items-center gap-4">
<AvatarUpload />
<div>
<h3 className="font-medium">Foto do Perfil</h3>
<p className="text-sm text-gray-500">
JPG ou PNG, máximo 2MB
</p>
</div>
</div>
{/* Informações Básicas */}
<div className="grid grid-cols-2 gap-4">
<Input
label="Nome Completo"
name="fullName"
placeholder="Seu nome completo"
/>
<Input
label="Nome Social/Apelido"
name="nickname"
placeholder="Como prefere ser chamado"
/>
<DatePicker
label="Data de Nascimento"
name="birthDate"
/>
<Select
label="Gênero"
name="gender"
options={[
{ value: 'male', label: 'Masculino' },
{ value: 'female', label: 'Feminino' },
{ value: 'non_binary', label: 'Não-binário' },
{ value: 'other', label: 'Outro' },
{ value: 'prefer_not_to_say', label: 'Prefiro não dizer' }
]}
/>
</div>
</div>
</TabsContent>
{/* Outras tabs... */}
</Tabs>
</div>
);
}

View File

@ -0,0 +1,235 @@
import React from 'react';
import { Plus, Search, Filter, BookOpen, ArrowUpDown } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase';
import type { Story } from '../../types/database';
import { getOptimizedImageUrl } from '../../lib/imageUtils';
type StoryStatus = 'all' | 'draft' | 'published';
type SortOption = 'recent' | 'oldest' | 'title' | 'performance';
export function StudentStoriesPage() {
const navigate = useNavigate();
const [stories, setStories] = React.useState<Story[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [searchTerm, setSearchTerm] = React.useState('');
const [statusFilter, setStatusFilter] = React.useState<StoryStatus>('all');
const [sortBy, setSortBy] = React.useState<SortOption>('recent');
const [showFilters, setShowFilters] = React.useState(false);
React.useEffect(() => {
const fetchStories = async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.user?.id) return;
const query = supabase
.from('stories')
.select('*')
.eq('student_id', session.user.id);
if (statusFilter !== 'all') {
query.eq('status', statusFilter);
}
let { data, error } = await query;
if (error) throw error;
// Aplicar ordenação
const sortedData = (data || []).sort((a, b) => {
switch (sortBy) {
case 'oldest':
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
case 'title':
return a.title.localeCompare(b.title);
case 'performance':
return (b.performance_score || 0) - (a.performance_score || 0);
default: // recent
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
}
});
setStories(sortedData);
} catch (err) {
console.error('Erro ao buscar histórias:', err);
setError('Não foi possível carregar suas histórias');
} finally {
setLoading(false);
}
};
fetchStories();
}, [statusFilter, sortBy]);
const filteredStories = stories.filter(story =>
story.title.toLowerCase().includes(searchTerm.toLowerCase())
);
if (loading) {
return (
<div className="animate-pulse">
<div className="h-20 bg-gray-200 rounded-xl mb-6" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-64 bg-gray-200 rounded-xl" />
))}
</div>
</div>
);
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-900">Minhas Histórias</h1>
<button
onClick={() => navigate('/aluno/historias/nova')}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
<Plus className="h-5 w-5" />
Nova História
</button>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6">
<div className="p-4 border-b border-gray-200">
<div className="flex flex-col md:flex-row gap-4">
{/* Busca */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Buscar histórias..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-purple-500 focus:border-purple-500"
/>
</div>
{/* Filtros e Ordenação */}
<div className="flex gap-2">
<button
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
>
<Filter className="h-5 w-5" />
Filtros
</button>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-purple-500 focus:border-purple-500"
>
<option value="recent">Mais recentes</option>
<option value="oldest">Mais antigas</option>
<option value="title">Por título</option>
<option value="performance">Melhor desempenho</option>
</select>
</div>
</div>
{/* Painel de Filtros */}
{showFilters && (
<div className="mt-4 pt-4 border-t border-gray-200">
<div className="flex gap-2">
<button
onClick={() => setStatusFilter('all')}
className={`px-4 py-2 rounded-lg ${
statusFilter === 'all'
? 'bg-purple-100 text-purple-700'
: 'hover:bg-gray-100'
}`}
>
Todas
</button>
<button
onClick={() => setStatusFilter('published')}
className={`px-4 py-2 rounded-lg ${
statusFilter === 'published'
? 'bg-purple-100 text-purple-700'
: 'hover:bg-gray-100'
}`}
>
Publicadas
</button>
<button
onClick={() => setStatusFilter('draft')}
className={`px-4 py-2 rounded-lg ${
statusFilter === 'draft'
? 'bg-purple-100 text-purple-700'
: 'hover:bg-gray-100'
}`}
>
Rascunhos
</button>
</div>
</div>
)}
</div>
{/* Lista de Histórias */}
{filteredStories.length === 0 ? (
<div className="p-12 text-center">
<BookOpen className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Nenhuma história encontrada
</h3>
<p className="text-gray-500 mb-6">
{searchTerm
? 'Tente usar outros termos na busca'
: 'Comece criando sua primeira história!'}
</p>
{!searchTerm && (
<button
onClick={() => navigate('/aluno/historias/nova')}
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
<Plus className="h-5 w-5" />
Criar História
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 p-6">
{filteredStories.map((story) => (
<div
key={story.id}
className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden cursor-pointer hover:shadow-md transition"
onClick={() => navigate(`/aluno/historias/${story.id}`)}
>
{story.cover && (
<div className="relative aspect-video">
<img
src={getOptimizedImageUrl(story.cover.image_url, {
width: 400,
height: 300,
quality: 80
})}
alt={story.title}
className="w-full h-48 object-cover"
loading="lazy"
/>
</div>
)}
<div className="p-6">
<h3 className="font-medium text-gray-900 mb-2">{story.title}</h3>
<div className="flex items-center justify-between text-sm text-gray-500">
<span>{new Date(story.created_at).toLocaleDateString()}</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
story.status === 'published'
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{story.status === 'published' ? 'Publicada' : 'Rascunho'}
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,9 @@
export { StudentDashboardLayout } from './StudentDashboardLayout';
export { StudentDashboard } from './StudentDashboard';
export { StudentClassPage } from './StudentClassPage';
export { StudentSettingsPage } from './StudentSettingsPage';
export { CreateStoryPage } from './CreateStoryPage';
export { StoryPage } from './StoryPage';
export { StudentDashboardPage } from './StudentDashboardPage';
export { StudentStoriesPage } from './StudentStoriesPage';

196
src/routes.tsx Normal file
View File

@ -0,0 +1,196 @@
import { createBrowserRouter } from 'react-router-dom';
import { HomePage } from './components/home/HomePage';
import { LoginForm } from './components/auth/LoginForm';
import { SchoolRegistrationForm } from './components/auth/SchoolRegistrationForm';
import { RegistrationForm } from './components/RegistrationForm';
import { StoryViewer } from './components/StoryViewer';
import { AuthCallback } from './pages/AuthCallback';
import { DashboardLayout } from './pages/dashboard/DashboardLayout';
import { DashboardHome } from './pages/dashboard/DashboardHome';
import { ClassesPage } from './pages/dashboard/classes/ClassesPage';
import { CreateClassPage } from './pages/dashboard/classes/CreateClassPage';
import { TeachersPage } from './pages/dashboard/teachers/TeachersPage';
import { InviteTeacherPage } from './pages/dashboard/teachers/InviteTeacherPage';
import { StudentsPage } from './pages/dashboard/students/StudentsPage';
import { AddStudentPage } from './pages/dashboard/students/AddStudentPage';
import { SettingsPage } from './pages/dashboard/settings/SettingsPage';
import { StudentDashboardPage } from './pages/student-dashboard/StudentDashboardPage';
import { StudentDashboardLayout } from './pages/student-dashboard/StudentDashboardLayout';
import { StudentStoriesPage } from './pages/student-dashboard/StudentStoriesPage';
import { StudentSettingsPage } from './pages/student-dashboard/StudentSettingsPage';
import { CreateStoryPage } from './pages/student-dashboard/CreateStoryPage';
import { StoryPageDemo } from './pages/demo/StoryPageDemo';
import { StoryPage } from './pages/student-dashboard/StoryPage';
import { ProtectedRoute } from './components/auth/ProtectedRoute';
import { UserManagementPage } from './pages/admin/UserManagementPage';
import { AchievementsPage } from './pages/student-dashboard/AchievementsPage';
import { StudentClassPage } from './pages/student-dashboard/StudentClassPage';
import { DemoPage } from './pages/demo/DemoPage';
import { ParentsLandingPage } from './pages/landing/ParentsLandingPage';
import { EducationalForParents } from './pages/landing/EducationalForParents';
export const router = createBrowserRouter([
{
path: '/',
element: <HomePage />,
},
{
path: '/para-pais',
element: <ParentsLandingPage />,
},
{
path: '/login',
children: [
{
path: 'school',
element: <LoginForm userType="school" />,
},
{
path: 'teacher',
element: <LoginForm userType="teacher" />,
},
{
path: 'student',
element: <LoginForm userType="student" />,
}
]
},
{
path: '/register',
children: [
{
path: 'school',
element: <SchoolRegistrationForm />,
},
{
path: 'teacher',
element: <RegistrationForm
userType="teacher"
onComplete={(userData) => {
console.log('Registro completo:', userData);
}}
/>,
}
]
},
{
path: '/dashboard',
element: (
<ProtectedRoute allowedRoles={['school']}>
<DashboardLayout />
</ProtectedRoute>
),
children: [
{
index: true,
element: <DashboardHome />,
},
{
path: 'turmas',
children: [
{
index: true,
element: <ClassesPage />,
},
{
path: 'nova',
element: <CreateClassPage />,
}
]
},
{
path: 'professores',
children: [
{
index: true,
element: <TeachersPage />,
},
{
path: 'convidar',
element: <InviteTeacherPage />,
}
]
},
{
path: 'alunos',
children: [
{
index: true,
element: <StudentsPage />,
},
{
path: 'novo',
element: <AddStudentPage />,
}
]
},
{
path: 'configuracoes',
element: <SettingsPage />
}
]
},
{
path: '/demo',
element: <StoryPageDemo />
},
{
path: '/auth/callback',
element: <AuthCallback />
},
{
path: '/aluno',
element: (
<ProtectedRoute allowedRoles={['student']}>
<StudentDashboardLayout />
</ProtectedRoute>
),
children: [
{
index: true,
element: <StudentDashboardPage />,
},
{
path: 'historias',
children: [
{
index: true,
element: <StudentStoriesPage />,
},
{
path: 'nova',
element: <CreateStoryPage />,
},
{
path: ':id',
element: <StoryPage />,
}
]
},
{
path: 'configuracoes',
element: <StudentSettingsPage />,
},
{
path: 'conquistas',
element: <AchievementsPage />,
},
{
path: 'turmas/:classId',
element: <StudentClassPage />,
}
]
},
{
path: '/admin/users',
element: (
<ProtectedRoute allowedRoles={['admin']}>
<UserManagementPage />
</ProtectedRoute>
),
},
{
path: '/para-educadores',
element: <EducationalForParents />,
}
]);

View File

@ -0,0 +1,33 @@
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
'SUA_URL_DO_SUPABASE',
'SUA_ANON_KEY'
);
async function updateUserRole(email: string, role: 'school' | 'teacher' | 'student') {
try {
// Primeiro fazer login como o usuário
const { data: authData, error: authError } = await supabase.auth.signInWithPassword({
email: email,
password: 'SENHA_DO_USUARIO' // Substitua pela senha real
});
if (authError) throw authError;
// Depois atualizar os metadados
const { data, error } = await supabase.auth.updateUser({
data: { role: role }
});
if (error) throw error;
console.log('Papel do usuário atualizado com sucesso:', data);
} catch (err) {
console.error('Erro ao atualizar papel do usuário:', err);
}
}
// Exemplo de uso:
updateUserRole('email@escola.com', 'school');

View File

@ -0,0 +1,86 @@
import { supabase } from '../lib/supabase';
interface ProcessAudioResponse {
transcription?: string;
error?: string;
}
export async function processAudio(audioFile: File, storyId: string): Promise<ProcessAudioResponse> {
try {
// 1. Gerar nome único para o arquivo
const fileName = `${crypto.randomUUID()}-${audioFile.name}`;
// 2. Primeiro criar o registro no banco
const { data: recordData, error: recordError } = await supabase
.from('story_recordings')
.insert({
story_id: storyId,
status: 'pending_analysis',
created_at: new Date().toISOString()
})
.select()
.single();
if (recordError) throw recordError;
// 3. Upload do arquivo para o bucket do Supabase
const { data: uploadData, error: uploadError } = await supabase.storage
.from('audio-uploads')
.upload(`recordings/${recordData.id}/${fileName}`, audioFile, {
cacheControl: '3600',
contentType: audioFile.type,
upsert: false
});
if (uploadError) {
// Se falhar o upload, deletar o registro
await supabase
.from('story_recordings')
.delete()
.eq('id', recordData.id);
throw uploadError;
}
// 4. Pegar URL pública do arquivo
const { data: { publicUrl } } = supabase.storage
.from('audio-uploads')
.getPublicUrl(`recordings/${recordData.id}/${fileName}`);
// 5. Atualizar registro com URL do áudio
const { error: updateError } = await supabase
.from('story_recordings')
.update({
audio_url: publicUrl
})
.eq('id', recordData.id);
if (updateError) throw updateError;
// 6. Chamar a Edge Function para processar o áudio
const { data, error } = await supabase.functions.invoke<ProcessAudioResponse>(
'process-audio',
{
body: {
record: {
id: recordData.id,
story_id: storyId,
audio_url: publicUrl
}
}
}
);
if (error) throw error;
return {
transcription: data?.transcription
};
} catch (error) {
console.error('Erro ao processar áudio:', error);
return {
error: 'Falha ao processar o áudio. Tente novamente.'
};
}
}

84
src/services/email.ts Normal file
View File

@ -0,0 +1,84 @@
import { Resend } from 'resend';
const resend = new Resend(import.meta.env.VITE_RESEND_API_KEY);
interface SendStudentCredentialsEmailProps {
studentName: string;
studentEmail: string;
password: string;
guardianName: string;
guardianEmail: string;
}
export async function sendStudentCredentialsEmail({
studentName,
studentEmail,
password,
guardianName,
guardianEmail
}: SendStudentCredentialsEmailProps) {
try {
// Email para o aluno
await resend.emails.send({
from: 'Histórias Mágicas <noreply@historias-magicas.com.br>',
to: studentEmail,
subject: 'Bem-vindo ao Histórias Mágicas!',
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #7C3AED;">Olá ${studentName}!</h1>
<p>Bem-vindo ao Histórias Mágicas! Sua conta foi criada com sucesso.</p>
<p>Use as credenciais abaixo para acessar sua conta:</p>
<div style="background-color: #F3F4F6; padding: 16px; border-radius: 8px; margin: 16px 0;">
<p style="margin: 0;"><strong>Email:</strong> ${studentEmail}</p>
<p style="margin: 8px 0 0;"><strong>Senha:</strong> ${password}</p>
</div>
<p style="color: #EF4444; font-size: 14px;">
Por favor, altere sua senha no primeiro acesso.
</p>
<a
href="${import.meta.env.VITE_APP_URL}/login/student"
style="display: inline-block; background-color: #7C3AED; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; margin-top: 16px;"
>
Acessar Plataforma
</a>
</div>
`
});
// Email para o responsável
await resend.emails.send({
from: 'Histórias Mágicas <noreply@historias-magicas.com.br>',
to: guardianEmail,
subject: `Conta do ${studentName} criada no Histórias Mágicas`,
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #7C3AED;">Olá ${guardianName}!</h1>
<p>
Uma conta foi criada para ${studentName} na plataforma Histórias Mágicas.
Como responsável, você receberá atualizações sobre o progresso e atividades.
</p>
<p>As credenciais de acesso foram enviadas para o email do aluno:</p>
<div style="background-color: #F3F4F6; padding: 16px; border-radius: 8px; margin: 16px 0;">
<p style="margin: 0;"><strong>Email do aluno:</strong> ${studentEmail}</p>
</div>
<p>
Por favor, ajude o aluno a fazer o primeiro acesso e alterar a senha.
Em caso de dúvidas, entre em contato com a escola.
</p>
<div style="font-size: 14px; color: #6B7280; margin-top: 24px;">
<p>
O Histórias Mágicas é uma plataforma educacional que permite aos alunos
criarem e compartilharem histórias interativas, incentivando a criatividade
e o aprendizado.
</p>
</div>
</div>
`
});
return true;
} catch (error) {
console.error('Erro ao enviar emails:', error);
return false;
}
}

View File

@ -33,6 +33,7 @@ export interface Class {
export interface Student {
id: string;
class_id: string;
school_id: string;
name: string;
email: string;
birth_date?: string;
@ -41,6 +42,18 @@ export interface Student {
guardian_email?: string;
created_at: string;
updated_at: string;
status?: string;
avatar_url?: string;
// Relacionamentos
class?: {
id: string;
name: string;
grade: string;
};
school?: {
id: string;
name: string;
};
}
export interface TeacherClass {
@ -92,8 +105,11 @@ export interface StoryPage {
}
export interface Story {
cover: any;
id: string;
student_id: string;
class_id: string;
school_id: string;
title: string;
theme: string;
content: {
@ -114,4 +130,21 @@ export interface StudentWithStories extends Student {
// Atualizando ClassWithStudents para incluir histórias dos alunos
export interface ClassWithStudentsAndStories extends Class {
students: StudentWithStories[];
}
export interface StoryRecording {
id: string;
fluency_score: number;
pronunciation_score: number;
accuracy_score: number;
comprehension_score: number;
words_per_minute: number;
pause_count: number;
error_count: number;
self_corrections: number;
strengths: string[];
improvements: string[];
suggestions: string;
created_at: string;
processed_at: string | null;
}

View File

@ -0,0 +1,29 @@
export interface StoryPrompt {
studentInterests: string[];
characters: {
main: string;
supporting?: string[];
};
setting: {
place: string;
time?: string;
};
practiceWords?: string[];
studentCharacteristics?: {
age?: number;
gender?: string;
personalityTraits?: string[];
};
theme?: string;
difficulty?: 'easy' | 'medium' | 'hard';
}
export interface GeneratedStory {
title: string;
content: {
pages: {
text: string;
image?: string;
}[];
};
}

21
src/types/supabase.ts Normal file
View File

@ -0,0 +1,21 @@
export type UserRole = 'admin' | 'school' | 'teacher' | 'student';
export interface UserMetadata {
role: UserRole;
name: string;
school_id?: string;
class_id?: string;
}
export interface User {
id: string;
email: string;
user_metadata: UserMetadata;
created_at: string;
updated_at: string;
}
export interface WeakPassword {
message: string;
suggestions: string[];
}

View File

@ -0,0 +1,12 @@
const ANIMALS = ['leao', 'tigre', 'gato', 'cao', 'panda', 'urso', 'lobo', 'rato', 'sapo', 'peixe'];
const COLORS = ['azul', 'verde', 'rosa', 'roxo', 'ouro', 'prata', 'coral', 'jade', 'ruby', 'safira'];
const NUMBERS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
export function generateMnemonicPassword(): string {
const randomAnimal = ANIMALS[Math.floor(Math.random() * ANIMALS.length)];
const randomColor = COLORS[Math.floor(Math.random() * COLORS.length)];
const randomNumber = NUMBERS[Math.floor(Math.random() * NUMBERS.length)] +
NUMBERS[Math.floor(Math.random() * NUMBERS.length)];
return `${randomColor}${randomAnimal}${randomNumber}`;
}

View File

@ -0,0 +1,20 @@
import { supabase } from '../lib/supabase';
export async function updateUserRole(userId: string, role: 'school' | 'teacher' | 'student') {
try {
const { data, error } = await supabase.auth.updateUser({
data: { role }
});
if (error) throw error;
console.log('Role atualizado com sucesso:', data);
return data;
} catch (err) {
console.error('Erro ao atualizar role:', err);
throw err;
}
}
// Uso:
// await updateUserRole('id-do-usuario', 'school');

4
supabase/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# Supabase
.branches
.temp
.env

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