mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-19 14:57:51 +00:00
Compare commits
69 Commits
618ecaf040
...
de28dea3b5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de28dea3b5 | ||
|
|
4765be66da | ||
|
|
c562ae570a | ||
|
|
f4965db3e6 | ||
|
|
933358483e | ||
|
|
66d401f98f | ||
|
|
a3b522d283 | ||
|
|
5812d46049 | ||
|
|
007441c285 | ||
|
|
c776efaec9 | ||
|
|
6cf273126e | ||
|
|
ec97f640f9 | ||
|
|
a8c332d442 | ||
|
|
4d09386d96 | ||
|
|
cc23c83c05 | ||
|
|
521a99a5c2 | ||
|
|
563a62a517 | ||
|
|
3ef8c99062 | ||
|
|
d5c75ab6c2 | ||
|
|
28fa4d70e6 | ||
|
|
02119a62d1 | ||
|
|
7087a87ece | ||
|
|
fbeeace8bb | ||
|
|
961fce03f6 | ||
|
|
8af9950ed7 | ||
|
|
7e3b4551ec | ||
|
|
03732de610 | ||
|
|
3701e692f1 | ||
|
|
4f3b80246f | ||
|
|
0b8c050bd7 | ||
|
|
1a3a603ff6 | ||
|
|
0661f2c225 | ||
|
|
6531a9282c | ||
|
|
1132f7438d | ||
|
|
797967ca5b | ||
|
|
6f8e890e86 | ||
|
|
f70585e9c1 | ||
|
|
5573274ad4 | ||
|
|
9ecf46a9ac | ||
|
|
6e7c85e853 | ||
|
|
1e181785b4 | ||
|
|
eb77476d51 | ||
|
|
8e8936e9f4 | ||
|
|
dea81a5711 | ||
|
|
89c325cc7c | ||
|
|
7430ae15a8 | ||
|
|
c8420421eb | ||
|
|
441b55535e | ||
|
|
4b431358e0 | ||
|
|
c0aa725fa6 | ||
|
|
fca293c4fc | ||
|
|
beef3da647 | ||
|
|
5952d83ec8 | ||
|
|
f1a7cd8730 | ||
|
|
e9e72677a4 | ||
|
|
fd734a5c26 | ||
|
|
70953ab57a | ||
|
|
fd50d59d3c | ||
|
|
d8c665d48e | ||
|
|
39bbc2c827 | ||
|
|
3176e95a75 | ||
|
|
6f03e72a22 | ||
|
|
abf0033590 | ||
|
|
4cc6ab641e | ||
|
|
5193ba95f4 | ||
|
|
b7d30fdc06 | ||
|
|
6afb728dce | ||
|
|
543ed7532b | ||
|
|
677ee422c4 |
153
.cursorrules
153
.cursorrules
@ -1,27 +1,49 @@
|
|||||||
{
|
{
|
||||||
"rules": [
|
"name": "Educational Platform Guidelines",
|
||||||
{
|
"version": "1.0.0",
|
||||||
"name": "Padrões de Código",
|
"rules": {
|
||||||
"description": "Regras gerais para manter consistência no código",
|
"naming": {
|
||||||
"patterns": [
|
"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",
|
"id": "use-interfaces",
|
||||||
"pattern": "^[a-z][a-zA-Z0-9]*$",
|
"message": "Prefer interfaces over types"
|
||||||
"message": "Use camelCase para nomes de variáveis e funções"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "component-naming",
|
"id": "avoid-enums",
|
||||||
"pattern": "^[A-Z][a-zA-Z0-9]*$",
|
"message": "Use const objects with 'as const' assertion instead of enums"
|
||||||
"message": "Componentes React devem começar com letra maiúscula"
|
},
|
||||||
|
{
|
||||||
|
"id": "explicit-returns",
|
||||||
|
"message": "Use explicit return types for all functions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "relative-imports",
|
||||||
|
"message": "Use relative imports"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
"security": {
|
||||||
"name": "Segurança",
|
|
||||||
"description": "Regras para garantir segurança da aplicação",
|
|
||||||
"patterns": [
|
"patterns": [
|
||||||
{
|
{
|
||||||
"id": "no-sensitive-data",
|
"id": "sensitive-data",
|
||||||
"pattern": "(password|senha|token|key|secret)",
|
"pattern": "(password|senha|token|key|secret)",
|
||||||
"message": "Não exponha dados sensíveis no código"
|
"message": "Não exponha dados sensíveis no código"
|
||||||
},
|
},
|
||||||
@ -32,9 +54,7 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
"accessibility": {
|
||||||
"name": "Acessibilidade",
|
|
||||||
"description": "Regras para garantir acessibilidade",
|
|
||||||
"patterns": [
|
"patterns": [
|
||||||
{
|
{
|
||||||
"id": "alt-text",
|
"id": "alt-text",
|
||||||
@ -48,9 +68,7 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
"performance": {
|
||||||
"name": "Performance",
|
|
||||||
"description": "Regras para otimização de performance",
|
|
||||||
"patterns": [
|
"patterns": [
|
||||||
{
|
{
|
||||||
"id": "large-images",
|
"id": "large-images",
|
||||||
@ -58,15 +76,13 @@
|
|||||||
"message": "Evite imagens muito grandes (max 1200px)"
|
"message": "Evite imagens muito grandes (max 1200px)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "memo-check",
|
"id": "memo-usage",
|
||||||
"pattern": "React.memo\\(",
|
"pattern": "React.memo\\(",
|
||||||
"message": "Verifique se o uso de memo é necessário"
|
"message": "Verifique se o uso de memo é necessário"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
"styling": {
|
||||||
"name": "Estilo",
|
|
||||||
"description": "Regras de estilo e formatação",
|
|
||||||
"patterns": [
|
"patterns": [
|
||||||
{
|
{
|
||||||
"id": "tailwind-classes",
|
"id": "tailwind-classes",
|
||||||
@ -80,9 +96,7 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
"content": {
|
||||||
"name": "Conteúdo",
|
|
||||||
"description": "Regras para conteúdo infantil",
|
|
||||||
"patterns": [
|
"patterns": [
|
||||||
{
|
{
|
||||||
"id": "child-friendly",
|
"id": "child-friendly",
|
||||||
@ -90,13 +104,71 @@
|
|||||||
"message": "Evite conteúdo inadequado para crianças"
|
"message": "Evite conteúdo inadequado para crianças"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "educational-content",
|
"id": "educational-focus",
|
||||||
"pattern": "(educativo|educacional|aprendizado|ensino)",
|
"pattern": "(educativo|educacional|aprendizado|ensino)",
|
||||||
"message": "Priorize conteúdo educacional e construtivo"
|
"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": [
|
"ignoreFiles": [
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
"dist/**",
|
"dist/**",
|
||||||
@ -104,5 +176,24 @@
|
|||||||
".git/**",
|
".git/**",
|
||||||
"*.test.*",
|
"*.test.*",
|
||||||
"*.spec.*"
|
"*.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
9
.dockerignore
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.env*
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile
|
||||||
|
README.md
|
||||||
|
.next
|
||||||
|
build
|
||||||
|
dist
|
||||||
23
.eslintrc.json
Normal file
23
.eslintrc.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
.gitea/config/registry.yml
Normal file
8
.gitea/config/registry.yml
Normal 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}
|
||||||
58
.gitea/workflows/docker-build.yml
Normal file
58
.gitea/workflows/docker-build.yml
Normal 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
18
.github/workflows/deploy.yml
vendored
Normal 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
10
.gitignore
vendored
@ -9,6 +9,8 @@ lerna-debug.log*
|
|||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
@ -26,3 +28,11 @@ dist-ssr
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
.env*
|
||||||
|
.env.*
|
||||||
|
.env.production
|
||||||
|
.env.development
|
||||||
|
|
||||||
|
# Backup files
|
||||||
|
*copy*
|
||||||
|
*.bak
|
||||||
|
|||||||
5
.vscode/extensions.json
vendored
Normal file
5
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"denoland.vscode-deno"
|
||||||
|
]
|
||||||
|
}
|
||||||
13
CHANGELOG.md
Normal file
13
CHANGELOG.md
Normal 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
40
Dockerfile
Normal 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
15
Dockerfile.dev
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Adicionar dependência do Redis
|
||||||
|
RUN apk add --no-cache redis
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY yarn.lock ./
|
||||||
|
|
||||||
|
RUN yarn install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["yarn", "dev"]
|
||||||
@ -27,6 +27,12 @@ Histórias Mágicas é uma aplicação web desenvolvida em React que permite que
|
|||||||
- Tailwind CSS
|
- Tailwind CSS
|
||||||
- Lucide React (ícones)
|
- Lucide React (ícones)
|
||||||
- Vite
|
- Vite
|
||||||
|
- Supabase
|
||||||
|
- Supabase Functions
|
||||||
|
- OpenAI
|
||||||
|
- DALL-E
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 🚀 Como Executar
|
## 🚀 Como Executar
|
||||||
|
|
||||||
|
|||||||
31
docker-compose.dev.yml
Normal file
31
docker-compose.dev.yml
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- /app/node_modules
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
|
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||||
|
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||||
|
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis_data:
|
||||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal 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
22
netlify.toml
Normal 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
12
next.config.js
Normal 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
2167
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@ -9,20 +9,36 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"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": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-accordion": "^1.2.2",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
"@supabase/supabase-js": "^2.39.7",
|
"@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",
|
"lucide-react": "^0.344.0",
|
||||||
|
"next": "^15.1.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hook-form": "^7.54.2",
|
||||||
"react-router-dom": "^6.28.0",
|
"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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.9.1",
|
"@eslint/js": "^9.9.1",
|
||||||
"@types/react": "^18.3.17",
|
"@types/react": "^18.3.17",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^9.9.1",
|
"eslint": "^9.9.1",
|
||||||
@ -30,6 +46,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.4.11",
|
"eslint-plugin-react-refresh": "^0.4.11",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
|
"supabase": "^2.1.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"typescript-eslint": "^8.3.0",
|
"typescript-eslint": "^8.3.0",
|
||||||
|
|||||||
34
portainer-stack.yml
Normal file
34
portainer-stack.yml
Normal 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
4
public/book.svg
Normal 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 |
85
src/App.tsx
85
src/App.tsx
@ -10,6 +10,7 @@ import { AuthUser, SavedStory } from './types/auth';
|
|||||||
import { User, Theme } from './types';
|
import { User, Theme } from './types';
|
||||||
import { AuthProvider } from './contexts/AuthContext'
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
|
||||||
type AppStep =
|
type AppStep =
|
||||||
| 'welcome'
|
| 'welcome'
|
||||||
@ -20,6 +21,16 @@ type AppStep =
|
|||||||
| 'story'
|
| 'story'
|
||||||
| 'library';
|
| '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() {
|
export function App() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [step, setStep] = useState<AppStep>('welcome');
|
const [step, setStep] = useState<AppStep>('welcome');
|
||||||
@ -74,43 +85,45 @@ export function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
<div className="min-h-screen bg-gradient-to-b from-purple-100 to-blue-100">
|
<AuthProvider>
|
||||||
{step === 'welcome' && (
|
<div className="min-h-screen bg-gradient-to-b from-purple-100 to-blue-100">
|
||||||
<WelcomePage
|
{step === 'welcome' && (
|
||||||
onLoginClick={() => setStep('login')}
|
<WelcomePage
|
||||||
onRegisterClick={() => setStep('register')}
|
onLoginClick={() => setStep('login')}
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{step === 'login' && (
|
|
||||||
<div className="min-h-screen flex items-center justify-center p-6">
|
|
||||||
<LoginForm
|
|
||||||
userType="school"
|
|
||||||
onLogin={handleLogin}
|
|
||||||
onRegisterClick={() => setStep('register')}
|
onRegisterClick={() => setStep('register')}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
)}
|
{step === 'login' && (
|
||||||
{step === 'register' && (
|
<div className="min-h-screen flex items-center justify-center p-6">
|
||||||
<RegistrationForm
|
<LoginForm
|
||||||
userType="school"
|
userType="school"
|
||||||
onComplete={handleRegistrationComplete}
|
onLogin={handleLogin}
|
||||||
/>
|
onRegisterClick={() => setStep('register')}
|
||||||
)}
|
/>
|
||||||
{step === 'avatar' && user && (
|
</div>
|
||||||
<AvatarSelector user={user} onComplete={handleAvatarComplete} />
|
)}
|
||||||
)}
|
{step === 'register' && (
|
||||||
{step === 'theme' && <ThemeSelector onSelect={handleThemeSelect} />}
|
<RegistrationForm
|
||||||
{step === 'story' && user && selectedTheme && (
|
userType="school"
|
||||||
<StoryViewer theme={selectedTheme} user={user} />
|
onComplete={handleRegistrationComplete}
|
||||||
)}
|
/>
|
||||||
{step === 'library' && authUser && (
|
)}
|
||||||
<StoryLibrary
|
{step === 'avatar' && user && (
|
||||||
stories={savedStories}
|
<AvatarSelector user={user} onComplete={handleAvatarComplete} />
|
||||||
onStorySelect={handleStorySelect}
|
)}
|
||||||
/>
|
{step === 'theme' && <ThemeSelector onSelect={handleThemeSelect} />}
|
||||||
)}
|
{step === 'story' && user && selectedTheme && (
|
||||||
</div>
|
<StoryViewer theme={selectedTheme} user={user} />
|
||||||
</AuthProvider>
|
)}
|
||||||
|
{step === 'library' && authUser && (
|
||||||
|
<StoryLibrary
|
||||||
|
stories={savedStories}
|
||||||
|
onStorySelect={handleStorySelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AuthProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
71
src/components/audio/AudioUploader.tsx
Normal file
71
src/components/audio/AudioUploader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
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 { useAuth } from '../../hooks/useAuth';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { supabase } from '../../lib/supabase';
|
||||||
|
|
||||||
interface LoginFormProps {
|
interface LoginFormProps {
|
||||||
userType: 'school' | 'teacher' | 'student';
|
userType: 'school' | 'teacher' | 'student';
|
||||||
@ -9,99 +10,178 @@ interface LoginFormProps {
|
|||||||
onRegisterClick?: () => void;
|
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) {
|
export function LoginForm({ userType, onLogin, onRegisterClick }: LoginFormProps) {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
const { signIn } = useAuth();
|
const { signIn } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { user } = await signIn(email, password);
|
console.log('Tentando login com:', { email, userType });
|
||||||
if (user) {
|
|
||||||
if (userType === 'school') {
|
const { data, error } = await supabase.auth.signInWithPassword({
|
||||||
navigate('/dashboard');
|
email: email,
|
||||||
} else if (onLogin) {
|
password: password
|
||||||
await onLogin({ email, 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) {
|
} 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 (
|
return (
|
||||||
<div className="max-w-md w-full space-y-8">
|
<div className="min-h-screen bg-gradient-to-b from-purple-50 to-white py-12">
|
||||||
<div>
|
<div className="max-w-md mx-auto px-4">
|
||||||
<h2 className="text-3xl font-bold text-center text-purple-600">
|
<div className="text-center mb-8">
|
||||||
Bem-vindo de volta!
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-purple-100 mb-4">
|
||||||
</h2>
|
{userTypeIcons[userType]}
|
||||||
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<button
|
{error && (
|
||||||
type="submit"
|
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-600">
|
||||||
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"
|
{error}
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
56
src/components/auth/ProtectedRoute.tsx
Normal file
56
src/components/auth/ProtectedRoute.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
263
src/components/auth/SchoolRegistrationForm.tsx
Normal file
263
src/components/auth/SchoolRegistrationForm.tsx
Normal 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">
|
||||||
|
Já 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/dashboard/StatsCard.tsx
Normal file
24
src/components/dashboard/StatsCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
src/components/demo/AudioRecorderDemo.tsx
Normal file
154
src/components/demo/AudioRecorderDemo.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/header/Header.tsx
Normal file
42
src/components/header/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/components/header/ProfileMenu.tsx
Normal file
100
src/components/header/ProfileMenu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
594
src/components/home/HomePage.tsx
Normal file
594
src/components/home/HomePage.tsx
Normal 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> já
|
||||||
|
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 já estão revolucionando a educação
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleSchoolRegister}
|
||||||
|
className="bg-white text-purple-600 px-8 py-4 rounded-xl hover:bg-purple-50 transition text-lg font-semibold"
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/components/layout/RootLayout.tsx
Normal file
10
src/components/layout/RootLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/components/layouts/DashboardLayout.tsx
Normal file
47
src/components/layouts/DashboardLayout.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/components/layouts/DashboardPageLayout.tsx
Normal file
39
src/components/layouts/DashboardPageLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/components/layouts/DashboardSidebar.tsx
Normal file
37
src/components/layouts/DashboardSidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
233
src/components/story/AudioRecorder.tsx
Normal file
233
src/components/story/AudioRecorder.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/components/story/RecordingHistoryCard.tsx
Normal file
124
src/components/story/RecordingHistoryCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
270
src/components/story/StoryGenerator.tsx
Normal file
270
src/components/story/StoryGenerator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
src/components/story/StoryMetrics.tsx
Normal file
142
src/components/story/StoryMetrics.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/components/student/StudentDashboardNavbar.tsx
Normal file
41
src/components/student/StudentDashboardNavbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/components/ui/accordion.tsx
Normal file
54
src/components/ui/accordion.tsx
Normal 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 };
|
||||||
40
src/components/ui/avatar-upload.tsx
Normal file
40
src/components/ui/avatar-upload.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/ui/badge.tsx
Normal file
34
src/components/ui/badge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/components/ui/button.tsx
Normal file
30
src/components/ui/button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/components/ui/card.tsx
Normal file
16
src/components/ui/card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/components/ui/date-picker.tsx
Normal file
27
src/components/ui/date-picker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/components/ui/select.tsx
Normal file
39
src/components/ui/select.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/components/ui/tabs.tsx
Normal file
52
src/components/ui/tabs.tsx
Normal 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 }
|
||||||
@ -1,13 +1,23 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { supabase } from '../lib/supabase'
|
import { supabase } from '../lib/supabase'
|
||||||
import { User, Session } from '@supabase/supabase-js'
|
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;
|
user: User | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
signIn: (email: string, password: string) => Promise<{ user: User; session: Session }>;
|
userRole: UserRole | null;
|
||||||
signUp: (email: string, password: string) => Promise<{ user: User; session: Session }>;
|
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>;
|
signOut: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -15,6 +25,7 @@ export function useAuth() {
|
|||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [userRole, setUserRole] = useState<AuthContextType['userRole']>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Verificar sessão atual
|
// Verificar sessão atual
|
||||||
@ -31,6 +42,12 @@ export function useAuth() {
|
|||||||
return () => subscription.unsubscribe()
|
return () => subscription.unsubscribe()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.user_metadata?.role) {
|
||||||
|
setUserRole(user.user_metadata.role);
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
const signIn = async (email: string, password: string) => {
|
const signIn = async (email: string, password: string) => {
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase.auth.signInWithPassword({
|
const { data, error } = await supabase.auth.signInWithPassword({
|
||||||
@ -78,6 +95,7 @@ export function useAuth() {
|
|||||||
user,
|
user,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
userRole,
|
||||||
signIn,
|
signIn,
|
||||||
signUp,
|
signUp,
|
||||||
signOut
|
signOut
|
||||||
|
|||||||
102
src/hooks/useAuth.tsx
Normal file
102
src/hooks/useAuth.tsx
Normal 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
28
src/hooks/useSession.ts
Normal 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 };
|
||||||
|
}
|
||||||
72
src/hooks/useStoryCategories.ts
Normal file
72
src/hooks/useStoryCategories.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
14
src/layouts/student/StudentDashboardLayout.tsx
Normal file
14
src/layouts/student/StudentDashboardLayout.tsx
Normal 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
17
src/lib/imageCache.ts
Normal 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
35
src/lib/imageUtils.ts
Normal 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
62
src/lib/redis.ts
Normal 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;
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { StoryPrompt } from '@/types/story-generator'
|
||||||
import { createClient } from '@supabase/supabase-js'
|
import { createClient } from '@supabase/supabase-js'
|
||||||
|
|
||||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||||
@ -14,3 +15,25 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
|||||||
detectSessionInUrl: 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
6
src/lib/utils.ts
Normal 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))
|
||||||
|
}
|
||||||
15
src/main.tsx
15
src/main.tsx
@ -1,11 +1,24 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { router } from './routes';
|
import { router } from './routes';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
gcTime: 1000 * 60 * 30,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<RouterProvider router={router} />
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
28
src/pages/AuthCallback.tsx
Normal file
28
src/pages/AuthCallback.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
src/pages/admin/UserManagementPage.tsx
Normal file
120
src/pages/admin/UserManagementPage.tsx
Normal 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
22
src/pages/api/health.ts
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/pages/api/stories/[id].ts
Normal file
28
src/pages/api/stories/[id].ts
Normal 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);
|
||||||
|
}
|
||||||
16
src/pages/api/updateRole.ts
Normal file
16
src/pages/api/updateRole.ts
Normal 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;
|
||||||
|
}
|
||||||
14
src/pages/api/updateUserRole.ts
Normal file
14
src/pages/api/updateUserRole.ts
Normal 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;
|
||||||
|
}
|
||||||
172
src/pages/dashboard/DashboardHome.tsx
Normal file
172
src/pages/dashboard/DashboardHome.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
src/pages/dashboard/DashboardLayout.tsx
Normal file
135
src/pages/dashboard/DashboardLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/pages/dashboard/DashboardPage.tsx
Normal file
10
src/pages/dashboard/DashboardPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/pages/dashboard/classes/ClassesPage.tsx
Normal file
121
src/pages/dashboard/classes/ClassesPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
src/pages/dashboard/classes/CreateClassPage.tsx
Normal file
151
src/pages/dashboard/classes/CreateClassPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
286
src/pages/dashboard/settings/SettingsPage.tsx
Normal file
286
src/pages/dashboard/settings/SettingsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
330
src/pages/dashboard/students/AddStudentPage.tsx
Normal file
330
src/pages/dashboard/students/AddStudentPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
src/pages/dashboard/students/StudentsPage.tsx
Normal file
175
src/pages/dashboard/students/StudentsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
src/pages/dashboard/teachers/InviteTeacherPage.tsx
Normal file
148
src/pages/dashboard/teachers/InviteTeacherPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/pages/dashboard/teachers/TeachersPage.tsx
Normal file
115
src/pages/dashboard/teachers/TeachersPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
src/pages/demo/DemoPage.tsx
Normal file
96
src/pages/demo/DemoPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
277
src/pages/demo/StoryPageDemo.tsx
Normal file
277
src/pages/demo/StoryPageDemo.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
807
src/pages/landing/EducationalForParents.tsx
Normal file
807
src/pages/landing/EducationalForParents.tsx
Normal 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" }
|
||||||
|
];
|
||||||
458
src/pages/landing/ParentsLandingPage.tsx
Normal file
458
src/pages/landing/ParentsLandingPage.tsx
Normal 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 já 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 }
|
||||||
|
];
|
||||||
86
src/pages/student-dashboard/AchievementsPage.tsx
Normal file
86
src/pages/student-dashboard/AchievementsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/pages/student-dashboard/CreateStoryPage.tsx
Normal file
71
src/pages/student-dashboard/CreateStoryPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
459
src/pages/student-dashboard/StoryPage.tsx
Normal file
459
src/pages/student-dashboard/StoryPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/pages/student-dashboard/StudentClassPage.tsx
Normal file
15
src/pages/student-dashboard/StudentClassPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/pages/student-dashboard/StudentDashboard.tsx
Normal file
12
src/pages/student-dashboard/StudentDashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
src/pages/student-dashboard/StudentDashboardLayout.tsx
Normal file
142
src/pages/student-dashboard/StudentDashboardLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
286
src/pages/student-dashboard/StudentDashboardPage.tsx
Normal file
286
src/pages/student-dashboard/StudentDashboardPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/pages/student-dashboard/StudentSettingsPage.tsx
Normal file
66
src/pages/student-dashboard/StudentSettingsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/pages/student-dashboard/StudentSettingsPage_old.tsx
Normal file
71
src/pages/student-dashboard/StudentSettingsPage_old.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
235
src/pages/student-dashboard/StudentStoriesPage.tsx
Normal file
235
src/pages/student-dashboard/StudentStoriesPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/pages/student-dashboard/index.ts
Normal file
9
src/pages/student-dashboard/index.ts
Normal 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
196
src/routes.tsx
Normal 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 />,
|
||||||
|
}
|
||||||
|
]);
|
||||||
33
src/scripts/updateUserRole.ts
Normal file
33
src/scripts/updateUserRole.ts
Normal 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');
|
||||||
86
src/services/audioService.ts
Normal file
86
src/services/audioService.ts
Normal 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
84
src/services/email.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -33,6 +33,7 @@ export interface Class {
|
|||||||
export interface Student {
|
export interface Student {
|
||||||
id: string;
|
id: string;
|
||||||
class_id: string;
|
class_id: string;
|
||||||
|
school_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
birth_date?: string;
|
birth_date?: string;
|
||||||
@ -41,6 +42,18 @@ export interface Student {
|
|||||||
guardian_email?: string;
|
guardian_email?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_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 {
|
export interface TeacherClass {
|
||||||
@ -92,8 +105,11 @@ export interface StoryPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Story {
|
export interface Story {
|
||||||
|
cover: any;
|
||||||
id: string;
|
id: string;
|
||||||
student_id: string;
|
student_id: string;
|
||||||
|
class_id: string;
|
||||||
|
school_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
theme: string;
|
theme: string;
|
||||||
content: {
|
content: {
|
||||||
@ -115,3 +131,20 @@ export interface StudentWithStories extends Student {
|
|||||||
export interface ClassWithStudentsAndStories extends Class {
|
export interface ClassWithStudentsAndStories extends Class {
|
||||||
students: StudentWithStories[];
|
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;
|
||||||
|
}
|
||||||
29
src/types/story-generator.ts
Normal file
29
src/types/story-generator.ts
Normal 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
21
src/types/supabase.ts
Normal 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[];
|
||||||
|
}
|
||||||
12
src/utils/passwordGenerator.ts
Normal file
12
src/utils/passwordGenerator.ts
Normal 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}`;
|
||||||
|
}
|
||||||
20
src/utils/updateUserRole.ts
Normal file
20
src/utils/updateUserRole.ts
Normal 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
4
supabase/.gitignore
vendored
Normal 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
Loading…
Reference in New Issue
Block a user