mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-18 06:17:56 +00:00
Compare commits
188 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc0ef9ba27 | ||
|
|
f883a6e9c2 | ||
|
|
2ff79ced53 | ||
|
|
374ac90a3b | ||
|
|
cdb98eb61d | ||
|
|
c53fbeb444 | ||
|
|
c2bcfe1e3f | ||
|
|
bb85c83c5b | ||
|
|
2175458186 | ||
|
|
190777dcd0 | ||
|
|
8c6e6aedd3 | ||
|
|
8b45fe72e7 | ||
|
|
ccbac66d28 | ||
|
|
46e8ba0312 | ||
|
|
c94c46f5c1 | ||
|
|
28ac3ef8cc | ||
|
|
756335f78f | ||
|
|
9d303b0c7a | ||
|
|
0eafbd5350 | ||
|
|
4609217fb7 | ||
|
|
1c6aa56b32 | ||
|
|
2929946499 | ||
|
|
1bc307d599 | ||
|
|
e9005e429f | ||
|
|
b767d60c50 | ||
|
|
63498e92c6 | ||
|
|
cc45bb974d | ||
|
|
da62f5e722 | ||
|
|
d1e44f84b7 | ||
|
|
f602f4c666 | ||
|
|
206f7bcb30 | ||
|
|
478ca2441d | ||
|
|
7a0bc3f8ca | ||
|
|
7bb2a9a1b7 | ||
|
|
c029aab50f | ||
|
|
18bc42d280 | ||
|
|
14c71062f1 | ||
|
|
be340d132e | ||
|
|
75c1e6f9f2 | ||
|
|
66866602e7 | ||
|
|
f3fbdb8228 | ||
|
|
7e93a59609 | ||
|
|
69dbb5fa48 | ||
|
|
9e3f7a7c31 | ||
|
|
821b6ca9ec | ||
|
|
abe4ce86d4 | ||
|
|
ba93f3ef29 | ||
|
|
fa8073dcee | ||
|
|
45a4b1ba24 | ||
|
|
13536790fe | ||
|
|
9f7ea648fe | ||
|
|
e81dc5bedf | ||
|
|
4790d9788b | ||
|
|
d949587c44 | ||
|
|
bc2f120700 | ||
|
|
d35565dee4 | ||
|
|
94835a427b | ||
|
|
dadcb048bb | ||
|
|
e5204e0430 | ||
|
|
51b8fb4088 | ||
|
|
dd9e2f4dd3 | ||
|
|
59a7adfeee | ||
|
|
ccacf76d9a | ||
|
|
c5a3017a7c | ||
|
|
90506ca894 | ||
|
|
62594f5e62 | ||
|
|
e154dd2372 | ||
|
|
ea5c5e87f1 | ||
|
|
229a1bffbb | ||
|
|
e4c225ebd7 | ||
|
|
7880ce8dda | ||
|
|
a0cfccc14d | ||
|
|
663c2fb8ff | ||
|
|
f1f2906d09 | ||
|
|
ce845607f9 | ||
|
|
198cad0047 | ||
|
|
0c2a63dcd3 | ||
|
|
5d4c9b6d49 | ||
|
|
f37f8f2f6d | ||
|
|
350a66bb9e | ||
|
|
e1a99f32f5 | ||
|
|
18cf6a2495 | ||
|
|
6a1a471ce5 | ||
|
|
bcbdd07a41 | ||
|
|
98411b2aa1 | ||
|
|
41a225d460 | ||
|
|
09c4894a1c | ||
|
|
bd58cbad7d | ||
|
|
a975e2486b | ||
|
|
546690fbc8 | ||
|
|
2852b889b2 | ||
|
|
3cdd136a4e | ||
|
|
33b9b38ff4 | ||
|
|
1bcb0a9c37 | ||
|
|
d2567ac478 | ||
|
|
953b7a78d0 | ||
|
|
21f7aa7c40 | ||
|
|
6e9d847c77 | ||
|
|
1542572be4 | ||
|
|
75d9d4635b | ||
|
|
a7612879bf | ||
|
|
00cd9edb1c | ||
|
|
a45ebd2719 | ||
|
|
6398e2ac81 | ||
|
|
0ccea7c7b9 | ||
|
|
9fa7b9732d | ||
|
|
6478d20d62 | ||
|
|
0e2215b6ad | ||
|
|
1ea1b3e841 | ||
|
|
9b023e7ef9 | ||
|
|
b8562bfda1 | ||
|
|
c422a6186e | ||
|
|
634fa6fb48 | ||
|
|
9840fe76b0 | ||
|
|
745f8de40e | ||
|
|
e23914657f | ||
|
|
b008b4134b | ||
|
|
3e7bf811fe | ||
|
|
087104a7f5 | ||
|
|
de28dea3b5 | ||
|
|
4765be66da | ||
|
|
c562ae570a | ||
|
|
f4965db3e6 | ||
|
|
933358483e | ||
|
|
66d401f98f | ||
|
|
a3b522d283 | ||
|
|
5812d46049 | ||
|
|
007441c285 | ||
|
|
c776efaec9 | ||
|
|
6cf273126e | ||
|
|
ec97f640f9 | ||
|
|
a8c332d442 | ||
|
|
4d09386d96 | ||
|
|
cc23c83c05 | ||
|
|
521a99a5c2 | ||
|
|
563a62a517 | ||
|
|
3ef8c99062 | ||
|
|
d5c75ab6c2 | ||
|
|
28fa4d70e6 | ||
|
|
02119a62d1 | ||
|
|
7087a87ece | ||
|
|
fbeeace8bb | ||
|
|
961fce03f6 | ||
|
|
8af9950ed7 | ||
|
|
7e3b4551ec | ||
|
|
03732de610 | ||
|
|
3701e692f1 | ||
|
|
4f3b80246f | ||
|
|
0b8c050bd7 | ||
|
|
1a3a603ff6 | ||
|
|
0661f2c225 | ||
|
|
6531a9282c | ||
|
|
1132f7438d | ||
|
|
797967ca5b | ||
|
|
6f8e890e86 | ||
|
|
f70585e9c1 | ||
|
|
5573274ad4 | ||
|
|
9ecf46a9ac | ||
|
|
6e7c85e853 | ||
|
|
1e181785b4 | ||
|
|
eb77476d51 | ||
|
|
8e8936e9f4 | ||
|
|
dea81a5711 | ||
|
|
89c325cc7c | ||
|
|
7430ae15a8 | ||
|
|
c8420421eb | ||
|
|
441b55535e | ||
|
|
4b431358e0 | ||
|
|
c0aa725fa6 | ||
|
|
fca293c4fc | ||
|
|
beef3da647 | ||
|
|
5952d83ec8 | ||
|
|
f1a7cd8730 | ||
|
|
e9e72677a4 | ||
|
|
fd734a5c26 | ||
|
|
70953ab57a | ||
|
|
fd50d59d3c | ||
|
|
d8c665d48e | ||
|
|
39bbc2c827 | ||
|
|
3176e95a75 | ||
|
|
6f03e72a22 | ||
|
|
abf0033590 | ||
|
|
4cc6ab641e | ||
|
|
5193ba95f4 | ||
|
|
b7d30fdc06 | ||
|
|
6afb728dce | ||
|
|
543ed7532b | ||
|
|
677ee422c4 |
@ -1,3 +1,24 @@
|
||||
{
|
||||
"template": "bolt-vite-react-ts"
|
||||
"template": "bolt-vite-react-ts",
|
||||
"version": "1.0.0",
|
||||
"features": {
|
||||
"tailwind": true,
|
||||
"radix": true,
|
||||
"shadcn": true,
|
||||
"supabase": true,
|
||||
"testing": true,
|
||||
"i18n": true,
|
||||
"pwa": true
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.11.2",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@supabase/supabase-js": "^2.26.0",
|
||||
"lucide-react": "^0.259.0",
|
||||
"tailwindcss": "^3.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
38
.cursorignore
Normal file
38
.cursorignore
Normal file
@ -0,0 +1,38 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist/
|
||||
build/
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env*
|
||||
.env.*
|
||||
.env.production
|
||||
.env.development
|
||||
|
||||
# Backup files
|
||||
*copy*
|
||||
*.bak
|
||||
155
.cursorrules
155
.cursorrules
@ -1,27 +1,49 @@
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"name": "Padrões de Código",
|
||||
"description": "Regras gerais para manter consistência no código",
|
||||
"patterns": [
|
||||
"name": "Educational Platform Guidelines",
|
||||
"version": "1.0.0",
|
||||
"rules": {
|
||||
"naming": {
|
||||
"directories": {
|
||||
"pattern": "^[a-z-]+$",
|
||||
"message": "Use lowercase with dashes for directories (e.g., components/form-wizard)"
|
||||
},
|
||||
"components": {
|
||||
"pattern": "^[A-Z][a-zA-Z0-9]*\\.tsx?$",
|
||||
"message": "Use PascalCase for component files (e.g., VisaForm.tsx)"
|
||||
},
|
||||
"utilities": {
|
||||
"pattern": "^[a-z][a-zA-Z0-9]*\\.ts$",
|
||||
"message": "Use camelCase for utility files (e.g., formValidator.ts)"
|
||||
},
|
||||
"variables": {
|
||||
"pattern": "^[a-z][a-zA-Z0-9]*$",
|
||||
"message": "Use camelCase for variables and functions"
|
||||
}
|
||||
},
|
||||
"typescript": {
|
||||
"rules": [
|
||||
{
|
||||
"id": "naming-conventions",
|
||||
"pattern": "^[a-z][a-zA-Z0-9]*$",
|
||||
"message": "Use camelCase para nomes de variáveis e funções"
|
||||
"id": "use-interfaces",
|
||||
"message": "Prefer interfaces over types"
|
||||
},
|
||||
{
|
||||
"id": "component-naming",
|
||||
"pattern": "^[A-Z][a-zA-Z0-9]*$",
|
||||
"message": "Componentes React devem começar com letra maiúscula"
|
||||
"id": "avoid-enums",
|
||||
"message": "Use const objects with 'as const' assertion instead of enums"
|
||||
},
|
||||
{
|
||||
"id": "explicit-returns",
|
||||
"message": "Use explicit return types for all functions"
|
||||
},
|
||||
{
|
||||
"id": "relative-imports",
|
||||
"message": "Use relative imports"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Segurança",
|
||||
"description": "Regras para garantir segurança da aplicação",
|
||||
"security": {
|
||||
"patterns": [
|
||||
{
|
||||
"id": "no-sensitive-data",
|
||||
"id": "sensitive-data",
|
||||
"pattern": "(password|senha|token|key|secret)",
|
||||
"message": "Não exponha dados sensíveis no código"
|
||||
},
|
||||
@ -32,9 +54,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Acessibilidade",
|
||||
"description": "Regras para garantir acessibilidade",
|
||||
"accessibility": {
|
||||
"patterns": [
|
||||
{
|
||||
"id": "alt-text",
|
||||
@ -48,9 +68,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Performance",
|
||||
"description": "Regras para otimização de performance",
|
||||
"performance": {
|
||||
"patterns": [
|
||||
{
|
||||
"id": "large-images",
|
||||
@ -58,15 +76,13 @@
|
||||
"message": "Evite imagens muito grandes (max 1200px)"
|
||||
},
|
||||
{
|
||||
"id": "memo-check",
|
||||
"id": "memo-usage",
|
||||
"pattern": "React.memo\\(",
|
||||
"message": "Verifique se o uso de memo é necessário"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Estilo",
|
||||
"description": "Regras de estilo e formatação",
|
||||
"styling": {
|
||||
"patterns": [
|
||||
{
|
||||
"id": "tailwind-classes",
|
||||
@ -80,9 +96,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Conteúdo",
|
||||
"description": "Regras para conteúdo infantil",
|
||||
"content": {
|
||||
"patterns": [
|
||||
{
|
||||
"id": "child-friendly",
|
||||
@ -90,13 +104,71 @@
|
||||
"message": "Evite conteúdo inadequado para crianças"
|
||||
},
|
||||
{
|
||||
"id": "educational-content",
|
||||
"id": "educational-focus",
|
||||
"pattern": "(educativo|educacional|aprendizado|ensino)",
|
||||
"message": "Priorize conteúdo educacional e construtivo"
|
||||
}
|
||||
]
|
||||
},
|
||||
"git": {
|
||||
"commit_prefixes": [
|
||||
"fix:",
|
||||
"feat:",
|
||||
"perf:",
|
||||
"docs:",
|
||||
"style:",
|
||||
"refactor:",
|
||||
"test:",
|
||||
"chore:"
|
||||
],
|
||||
"commit_rules": {
|
||||
"pattern": "^(fix|feat|perf|docs|style|refactor|test|chore): [a-z].*$",
|
||||
"message": "Use proper commit message format with prefix"
|
||||
},
|
||||
"changelog_rules": {
|
||||
"required": true,
|
||||
"message": "Atualize o CHANGELOG.md antes de fazer commit",
|
||||
"format": {
|
||||
"header": [
|
||||
"# Changelog",
|
||||
"",
|
||||
"Todas as mudanças notáveis neste projeto serão documentadas neste arquivo.",
|
||||
"",
|
||||
"O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.0.0/),",
|
||||
"e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).",
|
||||
""
|
||||
],
|
||||
"version_pattern": "^## \\[(\\d+\\.\\d+\\.\\d+)\\] - \\d{4}-\\d{2}-\\d{2}$",
|
||||
"version_message": "Use o formato '## [X.Y.Z] - YYYY-MM-DD' para versões"
|
||||
},
|
||||
"patterns": [
|
||||
{
|
||||
"id": "added",
|
||||
"pattern": "### Adicionado",
|
||||
"message": "Use '### Adicionado' para novos recursos"
|
||||
},
|
||||
{
|
||||
"id": "modified",
|
||||
"pattern": "### Modificado",
|
||||
"message": "Use '### Modificado' para mudanças em funcionalidades existentes"
|
||||
},
|
||||
{
|
||||
"id": "technical",
|
||||
"pattern": "### Técnico",
|
||||
"message": "Use '### Técnico' para mudanças técnicas/internas"
|
||||
},
|
||||
{
|
||||
"id": "semantic_version",
|
||||
"pattern": "^(major|minor|patch):",
|
||||
"message": "Indique o tipo de mudança: major (quebra compatibilidade), minor (novo recurso) ou patch (correção)"
|
||||
}
|
||||
],
|
||||
"verify_files": [
|
||||
"CHANGELOG.md"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
},
|
||||
"ignoreFiles": [
|
||||
"node_modules/**",
|
||||
"dist/**",
|
||||
@ -104,5 +176,24 @@
|
||||
".git/**",
|
||||
"*.test.*",
|
||||
"*.spec.*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"documentation": {
|
||||
"required": [
|
||||
"README.md",
|
||||
"API.md",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"rules": [
|
||||
{
|
||||
"id": "readme-sections",
|
||||
"required": [
|
||||
"Setup Instructions",
|
||||
"Development Workflow",
|
||||
"Testing",
|
||||
"Security",
|
||||
"Contributing"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.git
|
||||
.env*
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
README.md
|
||||
.next
|
||||
build
|
||||
dist
|
||||
31
.eslintrc.json
Normal file
31
.eslintrc.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"root": true,
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2020": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react/jsx-runtime"
|
||||
],
|
||||
"ignorePatterns": ["dist", ".eslintrc.json"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["react-refresh", "@typescript-eslint", "react"],
|
||||
"rules": {
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ "allowConstantExport": true }
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": ["warn"],
|
||||
"react/prop-types": "off",
|
||||
"no-console": "warn"
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
}
|
||||
}
|
||||
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/leiturama
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: seu-registry.com
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=seu-registry.com/leiturama:buildcache
|
||||
cache-to: type=registry,ref=seu-registry.com/leiturama:buildcache,mode=max
|
||||
|
||||
- name: Update Portainer stack
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_KEY }}
|
||||
script: |
|
||||
cd /opt/portainer
|
||||
docker stack deploy -c portainer-stack.yml leiturama
|
||||
18
.github/workflows/deploy.yml
vendored
Normal file
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
|
||||
dist
|
||||
dist/
|
||||
build/
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
@ -26,3 +28,11 @@ dist-ssr
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env*
|
||||
.env.*
|
||||
.env.production
|
||||
.env.development
|
||||
|
||||
# Backup files
|
||||
*copy*
|
||||
*.bak
|
||||
|
||||
13
.prettierrc
Normal file
13
.prettierrc
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf",
|
||||
"jsxSingleQuote": false,
|
||||
"quoteProps": "as-needed",
|
||||
"useTabs": false
|
||||
}
|
||||
5
.vscode/extensions.json
vendored
Normal file
5
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"denoland.vscode-deno"
|
||||
]
|
||||
}
|
||||
332
CHANGELOG.md
Normal file
332
CHANGELOG.md
Normal file
@ -0,0 +1,332 @@
|
||||
# Changelog
|
||||
|
||||
Todas as mudanças notáveis neste projeto serão documentadas neste arquivo.
|
||||
|
||||
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.0.0/),
|
||||
e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
|
||||
|
||||
## [0.5.1] - 2024-01-31
|
||||
|
||||
### Técnico
|
||||
- Corrigido erro de constraint na tabela stories ao atualizar status
|
||||
- Removida tentativa de atualizar coluna inexistente error_message
|
||||
- Ajustados os status da história para valores válidos: 'pending', 'published', 'failed'
|
||||
- Melhorada validação e logs durante o processo de geração da história
|
||||
|
||||
### Modificado
|
||||
- Alterado fluxo de status da história para usar estados válidos do banco de dados
|
||||
- Melhorada mensagem de erro para usuário final em caso de falha na geração
|
||||
|
||||
## [1.0.0] - 2024-03-20
|
||||
|
||||
### Adicionado
|
||||
|
||||
#### Sistema de Exercícios Fônicos
|
||||
- Criação do sistema de exercícios fônicos com categorias e tipos
|
||||
- Implementação de exercícios de rima, aliteração, sílabas e sons
|
||||
- Sistema de progresso do estudante com pontuação e estrelas
|
||||
- Sistema de conquistas e recompensas
|
||||
|
||||
#### Banco de Dados
|
||||
- Tabelas para categorias de exercícios (`phonics_exercise_categories`)
|
||||
- Tabelas para tipos de exercícios (`phonics_exercise_types`)
|
||||
- Tabela principal de exercícios (`phonics_exercises`)
|
||||
- Tabela de palavras e suas características fonéticas (`phonics_words`)
|
||||
- Tabela de relação exercício-palavras (`phonics_exercise_words`)
|
||||
- Sistema de mídia para exercícios (`media_types`, `phonics_exercise_media`)
|
||||
- Sistema de progresso do estudante (`student_phonics_progress`)
|
||||
- Sistema de tentativas e respostas (`student_phonics_attempts`, `student_phonics_attempt_answers`)
|
||||
- Sistema de conquistas (`achievement_types`, `phonics_achievements`, `student_phonics_achievements`)
|
||||
|
||||
#### Funcionalidades
|
||||
- Categorização de exercícios por nível e tipo
|
||||
- Sistema de pontuação e progresso
|
||||
- Registro detalhado de tentativas e respostas
|
||||
- Sistema de conquistas com diferentes tipos (sequência, conclusão, maestria)
|
||||
- Suporte a diferentes tipos de mídia (imagens, sons, animações)
|
||||
|
||||
#### Segurança
|
||||
- Políticas de acesso baseadas em Row Level Security (RLS)
|
||||
- Proteção de dados específicos do estudante
|
||||
- Controle de acesso para diferentes tipos de usuários
|
||||
|
||||
#### Performance
|
||||
- Índices otimizados para consultas frequentes
|
||||
- Estrutura de dados normalizada
|
||||
- Relacionamentos e chaves estrangeiras para integridade dos dados
|
||||
|
||||
### Técnico
|
||||
- Implementação de migrações do banco de dados
|
||||
- Criação de índices para otimização de consultas
|
||||
- Implementação de políticas de segurança RLS
|
||||
- Estrutura de dados normalizada com relacionamentos apropriados
|
||||
|
||||
### Modificado
|
||||
- N/A (primeira versão)
|
||||
- Todas as páginas principais para usar texto adaptativo
|
||||
- Componentes de exercícios para suportar transformação de texto
|
||||
- Movido controle de sílabas para a página de histórias
|
||||
|
||||
### Removido
|
||||
- N/A (primeira versão)
|
||||
|
||||
## [1.1.1] - 2024-05-21
|
||||
|
||||
### Adicionado
|
||||
- Componente `TextCaseToggle` para alternar entre maiúsculas e minúsculas
|
||||
- Componente `AdaptiveText` para renderização adaptativa de texto
|
||||
- Hook `useUppercasePreference` para gerenciar preferências de texto
|
||||
- Suporte a texto adaptativo em exercícios fônicos
|
||||
|
||||
### Modificado
|
||||
- Atualização do layout do dashboard para incluir controle de texto
|
||||
- Integração do sistema de texto adaptativo em componentes existentes
|
||||
- Melhorias na acessibilidade dos componentes de texto
|
||||
|
||||
### Técnico
|
||||
- Refatoração dos componentes de texto para suportar transformação dinâmica
|
||||
- Otimização do sistema de preferências do usuário
|
||||
- Melhorias na performance de renderização de texto
|
||||
|
||||
## [1.1.0] - 2024-03-21
|
||||
|
||||
### Adicionado
|
||||
- Novo recurso "Modo Foco" para melhorar a experiência de leitura
|
||||
- Ativação automática ao iniciar gravação
|
||||
- Desativação automática ao parar gravação
|
||||
- Interface adaptativa com foco no texto
|
||||
- Controles de acessibilidade (tamanho da fonte, espaçamento)
|
||||
- Destaque automático de palavras durante a leitura
|
||||
|
||||
### Técnico
|
||||
- Integração entre componentes `AudioRecorder` e `StoryPage` para gerenciamento do Modo Foco
|
||||
- Adição de novos props no componente `AudioRecorder`:
|
||||
- `onFocusModeToggle`
|
||||
- `focusModeActive`
|
||||
- `onRecordingStart`
|
||||
- `onRecordingStop`
|
||||
- Otimização de código com remoção de variáveis não utilizadas
|
||||
|
||||
### Modificado
|
||||
- Atualizado o componente `AudioRecorder` para incluir tipagem correta e melhor gerenciamento de estado
|
||||
- Corrigido o gerenciamento de gravações no `StoryPage` com inicialização adequada de métricas
|
||||
- Melhorado o tratamento de erros e feedback do usuário durante a gravação
|
||||
- Otimizado o fluxo de upload e processamento de áudio
|
||||
|
||||
### Técnico
|
||||
- Adicionada interface `StoryRecording` com todas as propriedades necessárias
|
||||
- Corrigido tipo do callback `onAudioUploaded` no `AudioRecorder`
|
||||
- Removidos imports não utilizados e variáveis redundantes
|
||||
- Implementada lógica de fallback para usuários não autenticados
|
||||
|
||||
### Adicionado
|
||||
- Suporte para conversão de áudio WebM para MP3
|
||||
- Feedback visual durante o processamento do áudio
|
||||
- Inicialização de métricas zeradas para novas gravações
|
||||
|
||||
## [1.2.0] - 2024-03-21
|
||||
|
||||
### Adicionado
|
||||
- Novo Modo Foco para leitura e gravação
|
||||
- Estilos específicos para o Modo Foco
|
||||
- Timer de gravação no Modo Foco
|
||||
- Transições suaves entre modos
|
||||
- Controles flutuantes durante o Modo Foco
|
||||
- Documentação completa da estrutura do banco de dados em `/docs/banco-dados.md`:
|
||||
- Escolas e Classes
|
||||
- Sistema de Alunos
|
||||
- Histórias
|
||||
- Interesses
|
||||
- Sistema de Conquistas
|
||||
- Sistema Fonético completo
|
||||
- Relacionamentos e índices
|
||||
- Políticas de segurança
|
||||
- Triggers e funções
|
||||
- Considerações de performance
|
||||
|
||||
### Modificado
|
||||
- Componente AudioRecorder atualizado para suportar Modo Foco
|
||||
- Interface do StoryPage reorganizada para Modo Foco
|
||||
- Comportamento de gravação integrado com Modo Foco
|
||||
- Melhorias na experiência do usuário durante a leitura
|
||||
|
||||
### Técnico
|
||||
- Novo arquivo CSS para estilos do Modo Foco
|
||||
- Interface FocusMode para gerenciamento de estado
|
||||
- Callbacks de início e fim de gravação
|
||||
- Sistema de transição entre modos normal e foco
|
||||
- Otimização de performance para transições suaves
|
||||
- Atualização das definições de tabelas para refletir a estrutura atual do Supabase
|
||||
- Adição de diagramas ER para visualização dos relacionamentos
|
||||
- Documentação de índices e políticas de segurança
|
||||
- Inclusão de considerações de performance e backup
|
||||
|
||||
## [1.3.0] - 2024-01-31
|
||||
|
||||
### Adicionado
|
||||
- Suporte a múltiplos idiomas na geração de histórias:
|
||||
- Português (Brasil)
|
||||
- Inglês (EUA)
|
||||
- Espanhol (Espanha)
|
||||
- Nova etapa de seleção de idioma no fluxo de criação de história
|
||||
- Instruções específicas para cada idioma no prompt da IA
|
||||
|
||||
### Modificado
|
||||
- Fluxo de geração de história para incluir seleção de idioma
|
||||
- Interface do gerador de histórias com novo passo de idioma
|
||||
- Adaptação do prompt da IA para considerar o idioma selecionado
|
||||
|
||||
### Técnico
|
||||
- Adicionada constante `LANGUAGE_OPTIONS` com opções de idiomas suportados
|
||||
- Implementada validação de idioma antes da geração
|
||||
- Atualizado payload da Edge Function para incluir `language_type`
|
||||
- Melhorada tipagem para suporte a múltiplos idiomas
|
||||
|
||||
## [1.4.0] - 2024-03-28
|
||||
|
||||
### Adicionado
|
||||
- Novas competências na análise de redações:
|
||||
- Domínio da língua (0-200 pontos)
|
||||
- Compreensão da proposta (0-200 pontos)
|
||||
- Seleção de argumentos (0-200 pontos)
|
||||
- Mecanismos linguísticos (0-200 pontos)
|
||||
- Proposta de intervenção (0-200 pontos)
|
||||
|
||||
### Técnico
|
||||
- Adicionados novos campos na tabela `essay_analyses` para armazenar as competências
|
||||
- Atualizada a função `analyze-essay` para salvar as notas e justificativas das competências
|
||||
- Adicionada restrição para garantir que os valores das competências estejam entre 0 e 200
|
||||
- Corrigida tipagem das métricas de escrita para incluir competências do ENEM
|
||||
- Atualizados valores padrão das métricas de escrita
|
||||
|
||||
### Modificado
|
||||
- Melhorado o layout da página de análise de redações:
|
||||
- Separação clara entre critérios gerais e competências do ENEM
|
||||
- Nova seção dedicada às competências do ENEM com layout aprimorado
|
||||
- Barras de progresso mais visíveis para as competências
|
||||
- Adicionadas descrições detalhadas para cada competência
|
||||
- Cards coloridos para justificativas das competências
|
||||
- Melhorias visuais nos critérios gerais de avaliação
|
||||
|
||||
## [1.5.0] - 2024-03-19
|
||||
|
||||
### Modificado
|
||||
- Aprimoramento no cálculo de métricas do dashboard do aluno:
|
||||
- Métricas agora são calculadas considerando todas as histórias e gravações do aluno
|
||||
- Adicionadas novas métricas detalhadas: pronúncia, precisão, compreensão, velocidade, pausas e erros
|
||||
- Melhorias na interface com tooltips explicativos para cada métrica
|
||||
- Separação entre dados para métricas (todas as histórias) e exibição (6 mais recentes)
|
||||
|
||||
### Técnico
|
||||
- Refatoração da busca de dados no StudentDashboardPage:
|
||||
- Separação entre consulta de métricas e consulta de exibição
|
||||
- Otimização no cálculo de médias das métricas
|
||||
- Melhoria na organização do código com comentários explicativos
|
||||
|
||||
## [Não publicado]
|
||||
|
||||
### Adicionado
|
||||
- Novo gráfico de evolução das métricas na dashboard do aluno
|
||||
- Visualização combinada de linhas e barras
|
||||
- Métricas de fluência, pronúncia, precisão, compreensão e palavras por minuto
|
||||
- Gráfico de barras mostrando minutos lidos por semana
|
||||
- Botões interativos para filtrar métricas
|
||||
- Design moderno com gradientes e animações suaves
|
||||
- Tooltip personalizado com informações detalhadas
|
||||
- Agrupamento automático por semana
|
||||
- Layout responsivo e adaptável
|
||||
- Filtro de período com opções de 3, 6, 12 meses e todo período
|
||||
- Visualização padrão dos últimos 12 meses
|
||||
|
||||
### Técnico
|
||||
- Implementação do Recharts para visualização de dados
|
||||
- Novo sistema de processamento de métricas semanais
|
||||
- Otimização do carregamento de dados com agrupamento eficiente
|
||||
- Integração com o tema existente do sistema
|
||||
- Sistema de filtragem temporal com conversão de datas
|
||||
- Componente MetricsChart extraído e modularizado
|
||||
- Interfaces e tipos bem definidos
|
||||
- Lógica de filtragem encapsulada
|
||||
- Estado interno gerenciado
|
||||
- Props minimalistas e bem tipadas
|
||||
- Componente reutilizável em outros contextos
|
||||
- Componentes de métricas extraídos e modularizados
|
||||
- Novo componente MetricCard para cards individuais
|
||||
- Novo componente DashboardMetrics para agrupamento
|
||||
- Configuração centralizada de métricas
|
||||
- Suporte a tooltips e ícones personalizados
|
||||
- Responsividade e acessibilidade melhoradas
|
||||
|
||||
### Técnico
|
||||
- Normalização do JSON Schema da análise de redações para corresponder à estrutura do banco de dados
|
||||
- Reordenação dos campos para corresponder à estrutura das tabelas
|
||||
- Ajuste nas descrições dos campos para maior clareza
|
||||
- Alinhamento com as tabelas: essay_analyses, essay_analysis_feedback, essay_analysis_strengths e essay_analysis_improvements
|
||||
- Melhoria na validação dos dados com JSON Schema mais preciso
|
||||
|
||||
### Técnico
|
||||
- Correção das políticas de segurança (RLS) para o sistema de análise de redações:
|
||||
- Simplificada a política de inserção para service_role
|
||||
- Adicionadas políticas para tabelas relacionadas (feedback, pontos fortes, melhorias e notas)
|
||||
- Melhorada a segurança com políticas específicas para cada operação
|
||||
- Corrigido erro de permissão na inserção de análises pela Edge Function
|
||||
|
||||
### Técnico
|
||||
- Removidas restrições de validação do JSON Schema da análise de redações:
|
||||
- Removidos limites `minimum` e `maximum` dos campos numéricos
|
||||
- Removida restrição `minItems` dos arrays de pontos fortes e melhorias
|
||||
- Simplificada a validação para maior flexibilidade na Edge Function
|
||||
|
||||
### Técnico
|
||||
- Corrigida consulta de análise de redações no componente `EssayAnalysis`:
|
||||
- Adicionado join com tabelas relacionadas (feedback, strengths, improvements, scores)
|
||||
- Implementada transformação dos dados para o formato esperado
|
||||
- Adicionado tratamento para valores nulos
|
||||
- Melhorada tipagem dos dados retornados
|
||||
|
||||
### Modificado
|
||||
- Melhorado o fluxo de redações:
|
||||
- Corrigido carregamento do conteúdo da redação após envio para análise
|
||||
- Adicionado salvamento automático do conteúdo antes de enviar para análise
|
||||
- Melhorada visualização do status 'analisada' com badge verde
|
||||
- Adicionado botão "Ver Análise" para redações analisadas
|
||||
- Ajustado Editor para modo somente leitura após envio
|
||||
- Melhorada contagem de palavras em todos os estados da redação
|
||||
|
||||
### Técnico
|
||||
- Refatorado componente `EssayPage`:
|
||||
- Adicionada lógica de salvamento antes do envio para análise
|
||||
- Melhorada query do Supabase para incluir conteúdo explicitamente
|
||||
- Implementado feedback visual durante operações de salvamento
|
||||
- Otimizado carregamento inicial da redação
|
||||
- Adicionado tratamento de estados para diferentes status da redação
|
||||
|
||||
## [0.2.0] - 2024-03-21
|
||||
|
||||
### Adicionado
|
||||
- Novos recursos de formatação no editor:
|
||||
- Tachado (strike-through)
|
||||
- Código inline
|
||||
- Lista com marcadores
|
||||
- Lista numerada
|
||||
- Citação (blockquote)
|
||||
- Rastreamento de eventos (tracking) em todos os botões do editor
|
||||
|
||||
### Modificado
|
||||
- Melhorias no fluxo de redações:
|
||||
- Carregamento correto do conteúdo após submissão para análise
|
||||
- Salvamento automático do conteúdo antes da submissão
|
||||
- Badge verde para status "analisada"
|
||||
- Botão "Ver Análise" para redações analisadas
|
||||
- Editor em modo somente leitura após submissão
|
||||
- Contagem de palavras em todos os estados da redação
|
||||
|
||||
### Técnico
|
||||
- Refatoração do componente Editor para incluir novos recursos de formatação
|
||||
- Adição de trackingId em todos os botões para análise de uso
|
||||
- Melhorias de acessibilidade com aria-labels em português
|
||||
- Refatoração do EssayPage para incluir lógica de salvamento antes da submissão para análise
|
||||
- Melhoria na query do Supabase para incluir conteúdo explicitamente
|
||||
- Implementação de feedback visual durante operações de salvamento
|
||||
- Otimização do carregamento inicial da redação
|
||||
- Adição de tratamento de estado para diferentes status da redação
|
||||
40
Dockerfile
Normal file
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"]
|
||||
51
PROJECT_CONTEXT.md
Normal file
51
PROJECT_CONTEXT.md
Normal file
@ -0,0 +1,51 @@
|
||||
# Story Generator - Plataforma Educacional de Leitura
|
||||
|
||||
## Visão Geral
|
||||
Plataforma educacional focada em crianças de 6-12 anos para prática e desenvolvimento de leitura, utilizando histórias geradas por IA e análise de áudio para feedback em tempo real.
|
||||
|
||||
## Principais Funcionalidades
|
||||
1. **Geração de Histórias**
|
||||
- Histórias personalizadas por IA
|
||||
- Adaptação ao nível do aluno
|
||||
- Imagens ilustrativas geradas por IA
|
||||
|
||||
2. **Sistema de Leitura**
|
||||
- Gravação de áudio da leitura
|
||||
- Análise de pronúncia e fluência
|
||||
- Destaque de palavras importantes (WordHighlighter)
|
||||
- Modal de detalhes para palavras difíceis
|
||||
|
||||
3. **Análise de Performance**
|
||||
- Métricas de leitura (fluência, pronúncia, etc.)
|
||||
- Dashboard de progresso
|
||||
- Histórico de gravações
|
||||
- Conversão de áudio WebM para MP3
|
||||
|
||||
## Arquitetura
|
||||
|
||||
### Frontend (React + TypeScript)
|
||||
- `/src/components/learning/` - Componentes educacionais
|
||||
- `/src/components/story/` - Componentes de história
|
||||
- `/src/pages/student-dashboard/` - Dashboard do aluno
|
||||
- `/src/utils/` - Utilitários (conversão de áudio, etc.)
|
||||
|
||||
### Backend (Supabase)
|
||||
- Functions:
|
||||
- `process-audio` - Análise de áudio e feedback
|
||||
- `generate-story` - Geração de histórias
|
||||
|
||||
### Storage
|
||||
- `recordings/` - Áudios das leituras
|
||||
- `story-images/` - Imagens das histórias
|
||||
|
||||
## Decisões Técnicas
|
||||
1. Uso de Supabase para backend serverless
|
||||
2. FFmpeg.js para conversão de áudio no cliente
|
||||
3. Testes com Vitest e Testing Library
|
||||
4. Tailwind CSS para estilização
|
||||
5. Radix UI para componentes acessíveis
|
||||
|
||||
## Estado Atual
|
||||
- Implementado sistema de gravação e análise de áudio
|
||||
- Desenvolvido componente WordHighlighter com testes
|
||||
- Sistema de deleção de histórias com limpeza de recursos
|
||||
12
README.md
12
README.md
@ -1,10 +1,10 @@
|
||||
# Histórias Mágicas 🌟
|
||||
# Leiturama 🌟
|
||||
|
||||
Uma plataforma educacional interativa que oferece histórias personalizadas para crianças entre 6 e 12 anos, com foco na cultura brasileira e educação.
|
||||
|
||||
## 🎯 Sobre o Projeto
|
||||
|
||||
Histórias Mágicas é uma aplicação web desenvolvida em React que permite que crianças explorem histórias educativas de forma interativa e personalizada. O projeto tem como objetivo:
|
||||
Leiturama é uma aplicação web desenvolvida em React que permite que crianças explorem histórias educativas de forma interativa e personalizada. O projeto tem como objetivo:
|
||||
|
||||
- Promover a educação através de narrativas envolventes
|
||||
- Valorizar a diversidade cultural brasileira
|
||||
@ -27,16 +27,21 @@ Histórias Mágicas é uma aplicação web desenvolvida em React que permite que
|
||||
- Tailwind CSS
|
||||
- Lucide React (ícones)
|
||||
- Vite
|
||||
- Supabase
|
||||
- Supabase Functions
|
||||
- OpenAI
|
||||
- DALL-E
|
||||
|
||||
## 🚀 Como Executar
|
||||
|
||||
1. Clone o repositório:
|
||||
1. Clone o repositório:
|
||||
|
||||
## 🚀 Deploy
|
||||
|
||||
### Opções Recomendadas
|
||||
|
||||
#### 1. Vercel (Recomendação Principal)
|
||||
|
||||
- Ideal para aplicações React/Next.js
|
||||
- Deploy automático integrado com GitHub
|
||||
- SSL gratuito
|
||||
@ -45,6 +50,7 @@ Histórias Mágicas é uma aplicação web desenvolvida em React que permite que
|
||||
- Plano gratuito generoso
|
||||
|
||||
#### 2. Netlify
|
||||
|
||||
- Também oferece deploy automático
|
||||
- Funções serverless incluídas
|
||||
- SSL gratuito
|
||||
|
||||
32
create_phonics_policies.sql
Normal file
32
create_phonics_policies.sql
Normal file
@ -0,0 +1,32 @@
|
||||
-- Políticas para permitir leitura por usuários autenticados
|
||||
CREATE POLICY "Permitir leitura de exercícios fonéticos para usuários autenticados" ON phonics_exercises
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
CREATE POLICY "Permitir leitura de categorias fonéticas para usuários autenticados" ON phonics_categories
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
CREATE POLICY "Permitir leitura de tipos de exercícios fonéticos para usuários autenticados" ON phonics_exercise_types
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
CREATE POLICY "Permitir leitura de palavras fonéticas para usuários autenticados" ON phonics_words
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
CREATE POLICY "Permitir leitura de relações exercício-palavra para usuários autenticados" ON phonics_exercise_words
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- Habilitar RLS nas tabelas
|
||||
ALTER TABLE phonics_exercises ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE phonics_categories ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE phonics_exercise_types ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE phonics_words ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE phonics_exercise_words ENABLE ROW LEVEL SECURITY;
|
||||
31
docker-compose.dev.yml
Normal file
31
docker-compose.dev.yml
Normal file
@ -0,0 +1,31 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- REDIS_URL=redis://redis:6379
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@ -0,0 +1,31 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
leiturama:
|
||||
image: ${REGISTRY}/leiturama:${TAG}
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- REDIS_URL=redis://redis:6379
|
||||
networks:
|
||||
- network_public
|
||||
deploy:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.leiturama.rule=Host(`${DOMAIN}`)"
|
||||
- "traefik.http.routers.leiturama.entrypoints=websecure"
|
||||
- "traefik.http.routers.leiturama.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.leiturama.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
replicas: 1
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
networks:
|
||||
network_public:
|
||||
external: true
|
||||
15
docs/accessibility-features.md
Normal file
15
docs/accessibility-features.md
Normal file
@ -0,0 +1,15 @@
|
||||
## Destaque Silábico
|
||||
|
||||
**Objetivo:**
|
||||
Facilitar a identificação das sílabas durante a leitura
|
||||
|
||||
**Como usar:**
|
||||
1. Clique no botão "Sílabas" ao lado do título da história
|
||||
2. Todas as palavras serão divididas em sílabas
|
||||
3. Sílabas destacadas em fundo amarelo
|
||||
4. Clique novamente para desativar
|
||||
|
||||
**Benefícios:**
|
||||
- Auxilia na decodificação fonêmica
|
||||
- Promove consciência silábica
|
||||
- Facilita a leitura de palavras complexas
|
||||
248
docs/arquitetura.md
Normal file
248
docs/arquitetura.md
Normal file
@ -0,0 +1,248 @@
|
||||
# Arquitetura do Sistema
|
||||
|
||||
## Visão Geral
|
||||
|
||||
A plataforma é construída usando uma arquitetura moderna e escalável, focada em proporcionar uma experiência educacional interativa e segura para crianças.
|
||||
|
||||
## Stack Tecnológica
|
||||
|
||||
### Frontend
|
||||
- Next.js 14 (App Router)
|
||||
- TypeScript
|
||||
- Tailwind CSS
|
||||
- Framer Motion
|
||||
- React Query
|
||||
|
||||
### Backend
|
||||
- Node.js
|
||||
- Supabase
|
||||
- Redis Upstash (cache)
|
||||
- Supabase Storage (mídia)
|
||||
|
||||
## Estrutura de Diretórios
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Rotas e páginas
|
||||
├── components/ # Componentes React
|
||||
│ ├── ui/ # Componentes base
|
||||
│ ├── forms/ # Formulários
|
||||
│ └── exercises/ # Componentes de exercícios
|
||||
├── features/ # Features do sistema
|
||||
├── layouts/ # Layouts do sistema
|
||||
├── constants/ # Constantes do sistema
|
||||
├── lib/ # Utilitários e configurações
|
||||
├── hooks/ # Hooks personalizados
|
||||
├── services/ # Serviços externos
|
||||
└── types/ # Definições de tipos
|
||||
```
|
||||
|
||||
## Componentes Principais
|
||||
|
||||
### 1. Sistema de Autenticação
|
||||
```typescript
|
||||
interface AuthConfig {
|
||||
providers: {
|
||||
google: boolean;
|
||||
email: boolean;
|
||||
};
|
||||
session: {
|
||||
maxAge: number;
|
||||
updateAge: number;
|
||||
};
|
||||
security: {
|
||||
jwtSecret: string;
|
||||
cookiePrefix: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Gerenciamento de Estado
|
||||
```typescript
|
||||
interface AppState {
|
||||
user: UserState;
|
||||
exercises: ExerciseState;
|
||||
progress: ProgressState;
|
||||
settings: SettingsState;
|
||||
}
|
||||
```
|
||||
|
||||
## Segurança
|
||||
|
||||
### 1. Autenticação
|
||||
- JWT tokens
|
||||
- Refresh tokens
|
||||
- Sessões seguras
|
||||
- Rate limiting
|
||||
|
||||
### 2. Dados
|
||||
- Criptografia em trânsito
|
||||
- Backup automático
|
||||
- Sanitização de inputs
|
||||
- Logs de auditoria
|
||||
|
||||
## Performance
|
||||
|
||||
### 1. Otimizações
|
||||
- Lazy loading
|
||||
- Code splitting
|
||||
- Caching estratégico
|
||||
- Compressão de assets
|
||||
|
||||
### 2. Monitoramento
|
||||
- Métricas de tempo real
|
||||
- Alertas automáticos
|
||||
- Análise de performance
|
||||
- Debug em produção
|
||||
|
||||
## Integração Contínua
|
||||
|
||||
### 1. Pipeline
|
||||
```yaml
|
||||
steps:
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
|
||||
- name: Type Check
|
||||
run: yarn tsc
|
||||
|
||||
- name: Test
|
||||
run: yarn test
|
||||
|
||||
- name: Build
|
||||
run: yarn build
|
||||
|
||||
- name: Deploy
|
||||
if: branch = main
|
||||
run: yarn deploy
|
||||
```
|
||||
|
||||
### 2. Qualidade de Código
|
||||
- ESLint
|
||||
- Prettier
|
||||
- Jest
|
||||
- Cypress
|
||||
|
||||
## Escalabilidade
|
||||
|
||||
### 1. Infraestrutura
|
||||
- Containers Docker
|
||||
- Load balancing
|
||||
- Auto-scaling
|
||||
- CDN global
|
||||
|
||||
### 2. Database
|
||||
- Sharding
|
||||
- Replicação
|
||||
- Índices otimizados
|
||||
- Migrations automáticas
|
||||
|
||||
## APIs e Integrações
|
||||
|
||||
### 1. REST APIs
|
||||
```typescript
|
||||
interface APIEndpoints {
|
||||
auth: {
|
||||
login: string;
|
||||
register: string;
|
||||
refresh: string;
|
||||
};
|
||||
exercises: {
|
||||
list: string;
|
||||
submit: string;
|
||||
progress: string;
|
||||
};
|
||||
content: {
|
||||
stories: string;
|
||||
media: string;
|
||||
exercises: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 2. WebSockets
|
||||
- Chat em tempo real
|
||||
- Notificações
|
||||
- Multiplayer
|
||||
- Status online
|
||||
|
||||
## Acessibilidade e SEO
|
||||
|
||||
### 1. Acessibilidade
|
||||
- WCAG 2.1
|
||||
- ARIA labels
|
||||
- Keyboard navigation
|
||||
- Screen readers
|
||||
|
||||
### 2. SEO
|
||||
- Meta tags
|
||||
- Sitemap
|
||||
- Robots.txt
|
||||
- Schema.org
|
||||
|
||||
## Ambiente de Desenvolvimento
|
||||
|
||||
### 1. Setup
|
||||
```bash
|
||||
# Instalação
|
||||
yarn install
|
||||
|
||||
# Desenvolvimento
|
||||
yarn dev
|
||||
|
||||
# Testes
|
||||
yarn test
|
||||
|
||||
# Build
|
||||
yarn build
|
||||
```
|
||||
|
||||
### 2. Ferramentas
|
||||
- VSCode
|
||||
- Docker
|
||||
- Postman
|
||||
- Git
|
||||
|
||||
## Monitoramento e Logs
|
||||
|
||||
### 1. Métricas
|
||||
```typescript
|
||||
interface SystemMetrics {
|
||||
performance: {
|
||||
responseTime: number;
|
||||
errorRate: number;
|
||||
userCount: number;
|
||||
};
|
||||
resources: {
|
||||
cpuUsage: number;
|
||||
memoryUsage: number;
|
||||
diskSpace: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Logs
|
||||
- Error tracking
|
||||
- User actions
|
||||
- Performance
|
||||
- Security events
|
||||
|
||||
## Próximos Passos
|
||||
|
||||
1. **Microserviços**
|
||||
- Auth service
|
||||
- Content service
|
||||
- Analytics service
|
||||
- Notification service
|
||||
|
||||
2. **Machine Learning**
|
||||
- Recomendações
|
||||
- Análise de progresso
|
||||
- Detecção de padrões
|
||||
- Personalização
|
||||
|
||||
3. **Mobile**
|
||||
- PWA
|
||||
- App nativo
|
||||
- Offline mode
|
||||
- Push notifications
|
||||
315
docs/banco-dados.md
Normal file
315
docs/banco-dados.md
Normal file
@ -0,0 +1,315 @@
|
||||
# Estrutura do Banco de Dados
|
||||
|
||||
## Visão Geral
|
||||
O banco de dados foi projetado para suportar um sistema educacional de leitura, geração de histórias e exercícios fonéticos, com foco em rastreamento de progresso e gamificação.
|
||||
|
||||
## Entidades
|
||||
|
||||
### 1. Escolas e Classes
|
||||
```sql
|
||||
create table schools (
|
||||
id uuid primary key default uuid_generate_v4(),
|
||||
name text not null,
|
||||
address text,
|
||||
phone text,
|
||||
email text,
|
||||
director_name text,
|
||||
created_at timestamptz default now(),
|
||||
updated_at timestamptz default now()
|
||||
);
|
||||
|
||||
create table classes (
|
||||
id uuid primary key default uuid_generate_v4(),
|
||||
school_id uuid references schools(id),
|
||||
teacher_id uuid references users(id),
|
||||
name text not null,
|
||||
grade text,
|
||||
year integer,
|
||||
period text,
|
||||
created_at timestamptz default now(),
|
||||
updated_at timestamptz default now()
|
||||
);
|
||||
|
||||
create table students (
|
||||
id uuid primary key default uuid_generate_v4(),
|
||||
user_id uuid references users(id),
|
||||
class_id uuid references classes(id),
|
||||
reading_level text,
|
||||
birth_date date,
|
||||
active boolean default true,
|
||||
created_at timestamptz default now(),
|
||||
updated_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Histórias
|
||||
```sql
|
||||
create table stories (
|
||||
id uuid primary key default uuid_generate_v4(),
|
||||
student_id uuid references users(id),
|
||||
title text not null,
|
||||
content jsonb not null,
|
||||
status text,
|
||||
theme_id uuid,
|
||||
subject_id uuid,
|
||||
character_id uuid,
|
||||
setting_id uuid,
|
||||
created_at timestamptz default now(),
|
||||
updated_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
### 3. Interesses
|
||||
```sql
|
||||
create table interests (
|
||||
id uuid primary key default uuid_generate_v4(),
|
||||
student_id uuid references users(id),
|
||||
category text not null,
|
||||
item text not null,
|
||||
created_at timestamptz default now(),
|
||||
updated_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
### 4. Sistema de Conquistas
|
||||
```sql
|
||||
create table achievement_types (
|
||||
id uuid primary key default uuid_generate_v4(),
|
||||
name varchar not null,
|
||||
description text,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
|
||||
create table achievements (
|
||||
id uuid primary key default uuid_generate_v4(),
|
||||
name text not null,
|
||||
description text
|
||||
);
|
||||
```
|
||||
|
||||
### 5. Sistema Fonético
|
||||
|
||||
#### Categorias e Palavras
|
||||
```sql
|
||||
create table phonics_categories (
|
||||
id uuid primary key default uuid_generate_v4(),
|
||||
name varchar not null,
|
||||
description text,
|
||||
level integer,
|
||||
order_index integer,
|
||||
created_at timestamptz default now(),
|
||||
updated_at timestamptz default now()
|
||||
);
|
||||
|
||||
create table phonics_words (
|
||||
id uuid primary key default uuid_generate_v4(),
|
||||
word varchar not null,
|
||||
phonetic_transcription varchar,
|
||||
syllables_count integer,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
|
||||
create table phonics_word_audio (
|
||||
id uuid primary key default uuid_generate_v4(),
|
||||
word text not null,
|
||||
audio_url text,
|
||||
audio_path text,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
#### Exercícios
|
||||
```sql
|
||||
create table phonics_exercise_types (
|
||||
id uuid primary key default uuid_generate_v4(),
|
||||
name varchar not null,
|
||||
description text,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
|
||||
create table phonics_exercises (
|
||||
id uuid primary key default uuid_generate_v4(),
|
||||
category_id uuid references phonics_categories(id),
|
||||
type_id uuid references phonics_exercise_types(id),
|
||||
title varchar not null,
|
||||
description text,
|
||||
difficulty_level integer,
|
||||
estimated_time_seconds integer,
|
||||
instructions text,
|
||||
points integer,
|
||||
is_active boolean default true,
|
||||
required_score double precision,
|
||||
created_at timestamptz default now(),
|
||||
updated_at timestamptz default now()
|
||||
);
|
||||
|
||||
create table phonics_exercise_words (
|
||||
id uuid primary key default uuid_generate_v4(),
|
||||
exercise_id uuid references phonics_exercises(id),
|
||||
word_id uuid references phonics_words(id),
|
||||
is_correct_answer boolean,
|
||||
order_index integer,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
#### Mídia
|
||||
```sql
|
||||
create table media_types (
|
||||
id uuid primary key default uuid_generate_v4(),
|
||||
name varchar not null,
|
||||
description text,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
|
||||
create table phonics_exercise_media (
|
||||
id uuid primary key default uuid_generate_v4(),
|
||||
exercise_id uuid references phonics_exercises(id),
|
||||
media_type_id uuid references media_types(id),
|
||||
url text,
|
||||
alt_text text,
|
||||
order_index integer,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
#### Conquistas Fonéticas
|
||||
```sql
|
||||
create table phonics_achievements (
|
||||
id uuid primary key default uuid_generate_v4(),
|
||||
type_id uuid references achievement_types(id),
|
||||
name varchar not null,
|
||||
description text,
|
||||
points integer,
|
||||
icon_url text,
|
||||
required_count integer,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
## Relacionamentos
|
||||
|
||||
### Hierarquia Principal
|
||||
```mermaid
|
||||
erDiagram
|
||||
Schools ||--o{ Classes : "has"
|
||||
Classes ||--o{ Students : "contains"
|
||||
Students ||--o{ Stories : "creates"
|
||||
Students ||--o{ Interests : "has"
|
||||
Students ||--o{ Achievements : "earns"
|
||||
|
||||
PhonicsCategories ||--o{ PhonicsExercises : "contains"
|
||||
PhonicsExercises ||--o{ PhonicsExerciseWords : "has"
|
||||
PhonicsWords }|--o{ PhonicsExerciseWords : "used_in"
|
||||
PhonicsExercises ||--o{ PhonicsExerciseMedia : "has"
|
||||
MediaTypes ||--o{ PhonicsExerciseMedia : "defines"
|
||||
```
|
||||
|
||||
## Índices e Otimizações
|
||||
|
||||
### Performance
|
||||
```sql
|
||||
-- Busca de exercícios por categoria
|
||||
create index idx_exercises_category on phonics_exercises(category_id);
|
||||
|
||||
-- Busca de histórias por aluno
|
||||
create index idx_stories_student on stories(student_id);
|
||||
|
||||
-- Busca de palavras por exercício
|
||||
create index idx_exercise_words on phonics_exercise_words(exercise_id);
|
||||
|
||||
-- Busca por conteúdo de história
|
||||
create index idx_stories_content on stories using gin (content);
|
||||
```
|
||||
|
||||
## Considerações de Segurança
|
||||
|
||||
### RLS (Row Level Security)
|
||||
```sql
|
||||
-- Alunos só podem ver suas próprias histórias
|
||||
create policy "Students view own stories" on stories
|
||||
for select using (auth.uid() = student_id);
|
||||
|
||||
-- Professores podem ver histórias de seus alunos
|
||||
create policy "Teachers view class stories" on stories
|
||||
for select using (
|
||||
auth.uid() in (
|
||||
select teacher_id from classes c
|
||||
join students s on s.class_id = c.id
|
||||
where s.id = stories.student_id
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
## Triggers
|
||||
|
||||
### Atualização Automática
|
||||
```sql
|
||||
-- Atualizar student_progress após nova gravação
|
||||
create trigger update_student_progress
|
||||
after insert or update on story_recordings
|
||||
for each row
|
||||
execute function update_student_progress();
|
||||
|
||||
-- Calcular duração da sessão de leitura
|
||||
create trigger calculate_session_duration
|
||||
before update on reading_sessions
|
||||
for each row
|
||||
when (NEW.end_time is not null)
|
||||
execute function calculate_session_duration();
|
||||
```
|
||||
|
||||
## Funções
|
||||
|
||||
### Análise de Progresso
|
||||
```sql
|
||||
-- Calcular nível de leitura
|
||||
create function calculate_reading_level(
|
||||
student_id uuid
|
||||
) returns text as $$
|
||||
-- Lógica de cálculo baseada em:
|
||||
-- - Média de palavras por minuto
|
||||
-- - Scores de fluência
|
||||
-- - Quantidade de histórias lidas
|
||||
$$ language plpgsql;
|
||||
|
||||
-- Atualizar métricas de progresso
|
||||
create function update_student_progress() returns trigger as $$
|
||||
-- Atualiza:
|
||||
-- - Médias de performance
|
||||
-- - Total de tempo lido
|
||||
-- - Histórias completadas
|
||||
-- - Pontos fortes e melhorias
|
||||
$$ language plpgsql;
|
||||
```
|
||||
|
||||
## Considerações de Performance
|
||||
|
||||
### 1. Particionamento
|
||||
- Gravações particionadas por mês
|
||||
- Sessões particionadas por aluno
|
||||
- Histórias particionadas por complexidade
|
||||
|
||||
### 2. Vacuum
|
||||
- Análise regular de dead tuples
|
||||
- Vacuum automático configurado
|
||||
- Monitoramento de bloat
|
||||
|
||||
### 3. Cache
|
||||
- Histórias populares em cache
|
||||
- Métricas de progresso em cache
|
||||
- Configurações de usuário em cache
|
||||
|
||||
## Backup e Recuperação
|
||||
|
||||
### 1. Estratégia
|
||||
- Backup completo diário
|
||||
- WAL archiving contínuo
|
||||
- Retenção de 30 dias
|
||||
- Teste mensal de recuperação
|
||||
|
||||
### 2. Monitoramento
|
||||
- Tamanho do banco
|
||||
- Tempo de queries
|
||||
- Uso de índices
|
||||
- Deadlocks
|
||||
117
docs/controles-texto.md
Normal file
117
docs/controles-texto.md
Normal file
@ -0,0 +1,117 @@
|
||||
# Controles de Texto
|
||||
|
||||
## Visão Geral
|
||||
Os controles de texto são um conjunto de funcionalidades que permitem aos usuários personalizar a apresentação do texto para melhor legibilidade e compreensão.
|
||||
|
||||
## Componentes Principais
|
||||
|
||||
### 1. TextControls
|
||||
Componente principal que agrupa todos os controles de texto.
|
||||
|
||||
#### Seção 1: Controles de Formatação Básica
|
||||
- **Maiúsculas/Minúsculas**
|
||||
- Alterna entre texto em maiúsculas e minúsculas
|
||||
- Útil para leitores iniciantes
|
||||
- Mantém estado global da preferência
|
||||
|
||||
- **Sílabas**
|
||||
- Ativa/desativa a separação silábica
|
||||
- Ajuda na compreensão da estrutura das palavras
|
||||
- Usa hífens para separação visual
|
||||
|
||||
- **Destaque de Palavras**
|
||||
- Realça palavras sequencialmente
|
||||
- Auxilia no acompanhamento da leitura
|
||||
- Velocidade ajustável
|
||||
|
||||
#### Seção 2: Controles de Formatação Avançada
|
||||
- **Tamanho da Fonte**
|
||||
- Range: 12px - 32px
|
||||
- Incrementos de 2px
|
||||
- Ícone visual indicativo
|
||||
|
||||
- **Espaçamento entre Letras**
|
||||
- Ajuste fino do kerning
|
||||
- Melhora legibilidade
|
||||
- Suporte para necessidades especiais
|
||||
|
||||
- **Espaçamento entre Palavras**
|
||||
- Controle da distância entre palavras
|
||||
- Facilita a leitura
|
||||
- Ajuda na compreensão do texto
|
||||
|
||||
- **Altura da Linha**
|
||||
- Ajuste do espaçamento vertical
|
||||
- Melhora conforto visual
|
||||
- Previne confusão entre linhas
|
||||
|
||||
### 2. AdaptiveText
|
||||
Componente que implementa as transformações de texto.
|
||||
|
||||
```typescript
|
||||
interface AdaptiveTextProps {
|
||||
text: string;
|
||||
isUpperCase: boolean;
|
||||
preserveWhitespace?: boolean;
|
||||
highlightSyllables?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
## Funcionalidades Técnicas
|
||||
|
||||
### 1. Gestão de Estado
|
||||
- Uso de hooks personalizados para gerenciar preferências
|
||||
- Persistência de configurações por usuário
|
||||
- Sincronização em tempo real
|
||||
|
||||
### 2. Transformações de Texto
|
||||
- Conversão maiúsculo/minúsculo
|
||||
- Separação silábica
|
||||
- Destaque sequencial de palavras
|
||||
|
||||
### 3. Acessibilidade
|
||||
- Suporte a ARIA labels
|
||||
- Alto contraste
|
||||
- Feedback visual claro
|
||||
- Suporte a leitores de tela
|
||||
|
||||
## Integração com Modo Foco
|
||||
- Controles permanecem acessíveis
|
||||
- Transições suaves
|
||||
- Estado preservado entre modos
|
||||
|
||||
## Exemplos de Uso
|
||||
|
||||
### Implementação Básica
|
||||
```typescript
|
||||
<TextControls
|
||||
fontSize={18}
|
||||
onFontSizeChange={handleFontSizeChange}
|
||||
letterSpacing={0.5}
|
||||
onLetterSpacingChange={handleLetterSpacingChange}
|
||||
wordSpacing={2}
|
||||
onWordSpacingChange={handleWordSpacingChange}
|
||||
lineHeight={1.5}
|
||||
onLineHeightChange={handleLineHeightChange}
|
||||
/>
|
||||
```
|
||||
|
||||
### Uso com AdaptiveText
|
||||
```typescript
|
||||
<AdaptiveText
|
||||
text="Exemplo de texto adaptativo"
|
||||
isUpperCase={isUpperCase}
|
||||
highlightSyllables={isSyllablesEnabled}
|
||||
/>
|
||||
```
|
||||
|
||||
## Considerações de Performance
|
||||
- Memoização de componentes
|
||||
- Otimização de re-renders
|
||||
- Lazy loading de recursos
|
||||
|
||||
## Próximas Melhorias
|
||||
1. Adicionar mais opções de formatação
|
||||
2. Implementar temas personalizados
|
||||
3. Melhorar algoritmo de separação silábica
|
||||
4. Adicionar suporte a mais idiomas
|
||||
246
docs/desenvolvimento.md
Normal file
246
docs/desenvolvimento.md
Normal file
@ -0,0 +1,246 @@
|
||||
# Fluxo de Desenvolvimento
|
||||
|
||||
## Padrões de Código
|
||||
|
||||
### 1. Nomenclatura
|
||||
- Diretórios em kebab-case: `components/form-wizard`
|
||||
- Componentes em PascalCase: `StoryCard.tsx`
|
||||
- Utilitários em camelCase: `formatText.ts`
|
||||
- Variáveis em camelCase: `userScore`
|
||||
|
||||
### 2. TypeScript
|
||||
- Preferir interfaces sobre types
|
||||
- Usar const com asserção `as const`
|
||||
- Retornos explícitos em funções
|
||||
- Imports relativos
|
||||
|
||||
## Estrutura de Commits
|
||||
|
||||
### 1. Prefixos
|
||||
- `fix:` Correções de bugs
|
||||
- `feat:` Novos recursos
|
||||
- `perf:` Melhorias de performance
|
||||
- `docs:` Documentação
|
||||
- `style:` Formatação
|
||||
- `refactor:` Refatoração
|
||||
- `test:` Testes
|
||||
- `chore:` Manutenção
|
||||
|
||||
### 2. Formato
|
||||
```
|
||||
<tipo>: <descrição>
|
||||
|
||||
[corpo]
|
||||
|
||||
[rodapé]
|
||||
```
|
||||
|
||||
## Fluxo de Branches
|
||||
|
||||
### 1. Principais
|
||||
- `main`: Produção
|
||||
- `develop`: Desenvolvimento
|
||||
- `staging`: Homologação
|
||||
|
||||
### 2. Features
|
||||
```bash
|
||||
# Nova feature
|
||||
git checkout -b feature/nome-da-feature
|
||||
|
||||
# Commit das mudanças
|
||||
git commit -m "feat: adiciona novo componente"
|
||||
|
||||
# Push para remote
|
||||
git push origin feature/nome-da-feature
|
||||
```
|
||||
|
||||
## Code Review
|
||||
|
||||
### 1. Checklist
|
||||
- Código limpo e legível
|
||||
- Testes adequados
|
||||
- Documentação atualizada
|
||||
- Performance otimizada
|
||||
- Segurança verificada
|
||||
|
||||
### 2. Pull Request
|
||||
```markdown
|
||||
## Descrição
|
||||
Breve descrição das mudanças
|
||||
|
||||
## Mudanças
|
||||
- [ ] Item 1
|
||||
- [ ] Item 2
|
||||
|
||||
## Screenshots
|
||||
[Se aplicável]
|
||||
|
||||
## Testes
|
||||
- [ ] Unitários
|
||||
- [ ] Integração
|
||||
- [ ] E2E
|
||||
```
|
||||
|
||||
## Testes
|
||||
|
||||
### 1. Unitários
|
||||
```typescript
|
||||
describe('StoryComponent', () => {
|
||||
it('deve renderizar corretamente', () => {
|
||||
const { getByText } = render(<Story />);
|
||||
expect(getByText('Título')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Integração
|
||||
```typescript
|
||||
describe('ExerciseFlow', () => {
|
||||
it('deve completar exercício', async () => {
|
||||
const result = await completeExercise({
|
||||
type: 'word-formation',
|
||||
answer: 'casa'
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Documentação
|
||||
|
||||
### 1. Código
|
||||
```typescript
|
||||
/**
|
||||
* Componente de exercício de formação de palavras
|
||||
* @param {WordFormationProps} props - Propriedades do componente
|
||||
* @returns {JSX.Element} Componente renderizado
|
||||
*/
|
||||
export const WordFormation: React.FC<WordFormationProps> = ({
|
||||
word,
|
||||
syllables,
|
||||
onComplete
|
||||
}) => {
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### 2. README
|
||||
- Setup do projeto
|
||||
- Comandos disponíveis
|
||||
- Estrutura de arquivos
|
||||
- Contribuição
|
||||
- Licença
|
||||
|
||||
## Segurança
|
||||
|
||||
### 1. Checklist
|
||||
- Validação de inputs
|
||||
- Sanitização de dados
|
||||
- Proteção contra XSS
|
||||
- Autenticação segura
|
||||
|
||||
### 2. Revisão
|
||||
```typescript
|
||||
// ❌ Inseguro
|
||||
const query = `SELECT * FROM users WHERE id = ${id}`;
|
||||
|
||||
// ✅ Seguro
|
||||
const query = 'SELECT * FROM users WHERE id = $1';
|
||||
const values = [id];
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
### 1. Frontend
|
||||
- Lazy loading
|
||||
- Memoização
|
||||
- Bundle splitting
|
||||
- Image optimization
|
||||
|
||||
### 2. Backend
|
||||
- Caching
|
||||
- Query optimization
|
||||
- Connection pooling
|
||||
- Rate limiting
|
||||
|
||||
## Deploy
|
||||
|
||||
### 1. Staging
|
||||
```bash
|
||||
# Build
|
||||
yarn build
|
||||
|
||||
# Testes
|
||||
yarn test
|
||||
|
||||
# Deploy staging
|
||||
yarn deploy:staging
|
||||
```
|
||||
|
||||
### 2. Produção
|
||||
```bash
|
||||
# Merge para main
|
||||
git checkout main
|
||||
git merge develop
|
||||
|
||||
# Deploy produção
|
||||
yarn deploy:prod
|
||||
```
|
||||
|
||||
## Monitoramento
|
||||
|
||||
### 1. Métricas
|
||||
- Tempo de resposta
|
||||
- Taxa de erro
|
||||
- Uso de recursos
|
||||
- Satisfação do usuário
|
||||
|
||||
### 2. Logs
|
||||
```typescript
|
||||
logger.info('Exercício completado', {
|
||||
userId: user.id,
|
||||
exerciseId: exercise.id,
|
||||
score: result.score,
|
||||
timeSpent: result.time
|
||||
});
|
||||
```
|
||||
|
||||
## Manutenção
|
||||
|
||||
### 1. Dependências
|
||||
```bash
|
||||
# Atualizar deps
|
||||
yarn upgrade-interactive --latest
|
||||
|
||||
# Auditar segurança
|
||||
yarn audit
|
||||
|
||||
# Limpar cache
|
||||
yarn cache clean
|
||||
```
|
||||
|
||||
### 2. Backup
|
||||
- Database dumps
|
||||
- Logs históricos
|
||||
- Configurações
|
||||
- Assets
|
||||
|
||||
## Contribuição
|
||||
|
||||
### 1. Setup
|
||||
```bash
|
||||
# Clone
|
||||
git clone https://github.com/org/repo.git
|
||||
|
||||
# Install
|
||||
yarn install
|
||||
|
||||
# Dev
|
||||
yarn dev
|
||||
```
|
||||
|
||||
### 2. Guidelines
|
||||
- Código limpo
|
||||
- Testes completos
|
||||
- Documentação clara
|
||||
- Pull requests concisos
|
||||
177
docs/exercicios.md
Normal file
177
docs/exercicios.md
Normal file
@ -0,0 +1,177 @@
|
||||
# Sistema de Exercícios
|
||||
|
||||
## Visão Geral
|
||||
O sistema de exercícios oferece diferentes tipos de atividades para reforçar o aprendizado da leitura e compreensão textual.
|
||||
|
||||
## Tipos de Exercícios
|
||||
|
||||
### 1. Formação de Palavras
|
||||
```typescript
|
||||
interface WordFormationExercise {
|
||||
word: string;
|
||||
syllables: string[];
|
||||
hints?: string[];
|
||||
difficulty: 'easy' | 'medium' | 'hard';
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Completar Sentenças
|
||||
```typescript
|
||||
interface SentenceCompletionExercise {
|
||||
sentence: string;
|
||||
options: string[];
|
||||
correctAnswer: string;
|
||||
context: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Prática de Pronúncia
|
||||
```typescript
|
||||
interface PronunciationExercise {
|
||||
word: string;
|
||||
phonemes: string[];
|
||||
audioUrl?: string;
|
||||
examples: string[];
|
||||
}
|
||||
```
|
||||
|
||||
## Fluxo de Exercícios
|
||||
|
||||
### 1. Seleção
|
||||
- Baseada no nível do aluno
|
||||
- Progressão gradual
|
||||
- Adaptação por desempenho
|
||||
- Variedade de tipos
|
||||
|
||||
### 2. Execução
|
||||
- Instruções claras
|
||||
- Feedback imediato
|
||||
- Dicas contextuais
|
||||
- Suporte visual
|
||||
|
||||
### 3. Avaliação
|
||||
- Pontuação automática
|
||||
- Feedback detalhado
|
||||
- Sugestões de melhoria
|
||||
- Registro de progresso
|
||||
|
||||
## Componentes Principais
|
||||
|
||||
### 1. ExercisePlayer
|
||||
- Controle de fluxo
|
||||
- Timer integrado
|
||||
- Sistema de pontuação
|
||||
- Feedback visual
|
||||
|
||||
### 2. ExerciseFactory
|
||||
- Criação dinâmica
|
||||
- Validação de respostas
|
||||
- Adaptação de dificuldade
|
||||
- Geração de feedback
|
||||
|
||||
## Integração com Banco de Dados
|
||||
|
||||
### 1. Tabelas Relacionadas
|
||||
```sql
|
||||
-- Exercícios
|
||||
create table exercises (
|
||||
id uuid primary key,
|
||||
type text,
|
||||
difficulty text,
|
||||
content jsonb,
|
||||
created_at timestamptz
|
||||
);
|
||||
|
||||
-- Progresso
|
||||
create table exercise_progress (
|
||||
student_id uuid,
|
||||
exercise_id uuid,
|
||||
score numeric,
|
||||
completed_at timestamptz
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Métricas Armazenadas
|
||||
- Tempo de conclusão
|
||||
- Taxa de acerto
|
||||
- Tentativas realizadas
|
||||
- Padrões de erro
|
||||
|
||||
## Acessibilidade
|
||||
|
||||
### 1. Visual
|
||||
- Alto contraste
|
||||
- Fontes ajustáveis
|
||||
- Ícones intuitivos
|
||||
- Animações suaves
|
||||
|
||||
### 2. Auditiva
|
||||
- Instruções em áudio
|
||||
- Feedback sonoro
|
||||
- Controle de volume
|
||||
- Legendas
|
||||
|
||||
### 3. Motora
|
||||
- Controles simplificados
|
||||
- Atalhos de teclado
|
||||
- Tempo ajustável
|
||||
- Pausas automáticas
|
||||
|
||||
## Gamificação
|
||||
|
||||
### 1. Sistema de Pontos
|
||||
- Pontuação base
|
||||
- Bônus por velocidade
|
||||
- Combos de acertos
|
||||
- Conquistas especiais
|
||||
|
||||
### 2. Progressão
|
||||
- Níveis de dificuldade
|
||||
- Desbloqueio gradual
|
||||
- Medalhas e troféus
|
||||
- Rankings opcionais
|
||||
|
||||
### 3. Recompensas
|
||||
- Novos conteúdos
|
||||
- Personalização
|
||||
- Badges especiais
|
||||
- Poder de escolha
|
||||
|
||||
## Monitoramento
|
||||
|
||||
### 1. Métricas Coletadas
|
||||
```typescript
|
||||
interface ExerciseMetrics {
|
||||
timeSpent: number;
|
||||
correctAnswers: number;
|
||||
totalAttempts: number;
|
||||
hintsUsed: number;
|
||||
score: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Análise de Desempenho
|
||||
- Padrões de erro
|
||||
- Tempo de resposta
|
||||
- Uso de dicas
|
||||
- Evolução temporal
|
||||
|
||||
## Próximas Melhorias
|
||||
|
||||
1. **Novos Tipos**
|
||||
- Exercícios de ritmo
|
||||
- Compreensão auditiva
|
||||
- Produção textual
|
||||
- Jogos educativos
|
||||
|
||||
2. **Personalização**
|
||||
- Temas customizados
|
||||
- Níveis adaptativos
|
||||
- Conteúdo dinâmico
|
||||
- Preferências salvas
|
||||
|
||||
3. **Interatividade**
|
||||
- Multiplayer
|
||||
- Desafios em grupo
|
||||
- Compartilhamento
|
||||
- Competições
|
||||
166
docs/geracao-historia.md
Normal file
166
docs/geracao-historia.md
Normal file
@ -0,0 +1,166 @@
|
||||
# Geração de Histórias
|
||||
|
||||
## Visão Geral
|
||||
O sistema de geração de histórias permite criar conteúdo personalizado baseado em parâmetros fornecidos pelo usuário, utilizando IA para gerar narrativas educativas e envolventes.
|
||||
|
||||
## Parâmetros de Entrada
|
||||
|
||||
### StoryChoices
|
||||
```typescript
|
||||
interface StoryChoices {
|
||||
protagonist: string; // Nome/tipo do protagonista
|
||||
setting: string; // Ambiente da história
|
||||
theme: string; // Tema principal
|
||||
genre: string; // Gênero da história
|
||||
educationalGoal: string; // Objetivo educacional
|
||||
ageGroup: string; // Faixa etária
|
||||
length: 'short' | 'medium' | 'long'; // Extensão da história
|
||||
complexity: 'easy' | 'medium' | 'hard'; // Nível de complexidade
|
||||
language: 'pt-BR'; // Idioma (fixo em português)
|
||||
}
|
||||
```
|
||||
|
||||
## Modos de Entrada
|
||||
|
||||
### 1. Formulário
|
||||
- Interface gráfica com campos estruturados
|
||||
- Validação em tempo real
|
||||
- Sugestões pré-definidas
|
||||
- Preview instantâneo
|
||||
|
||||
### 2. Comando de Voz
|
||||
- Reconhecimento de fala natural
|
||||
- Extração automática de parâmetros
|
||||
- Confirmação por voz
|
||||
- Correção por voz ou texto
|
||||
|
||||
### 3. Texto Livre
|
||||
- Processamento de linguagem natural
|
||||
- Identificação de parâmetros-chave
|
||||
- Sugestão de complementos
|
||||
- Refinamento interativo
|
||||
|
||||
## Fluxo de Geração
|
||||
|
||||
### 1. Coleta de Parâmetros
|
||||
```typescript
|
||||
// Exemplo de validação de parâmetros
|
||||
const validateStoryParams = (choices: StoryChoices): boolean => {
|
||||
return (
|
||||
!!choices.protagonist &&
|
||||
!!choices.setting &&
|
||||
!!choices.theme &&
|
||||
!!choices.genre &&
|
||||
!!choices.educationalGoal &&
|
||||
!!choices.ageGroup
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Processamento
|
||||
1. **Validação**
|
||||
- Verificação de campos obrigatórios
|
||||
- Validação de conteúdo apropriado
|
||||
- Checagem de restrições de idade
|
||||
|
||||
2. **Preparação**
|
||||
- Formatação dos parâmetros
|
||||
- Ajuste de complexidade
|
||||
- Definição de estrutura
|
||||
|
||||
3. **Geração**
|
||||
- Criação do conteúdo via IA
|
||||
- Revisão automática
|
||||
- Formatação do texto
|
||||
|
||||
### 3. Pós-processamento
|
||||
- Verificação de adequação
|
||||
- Ajustes de formatação
|
||||
- Adição de metadados
|
||||
- Geração de recursos visuais
|
||||
|
||||
## Controles de Qualidade
|
||||
|
||||
### 1. Adequação de Conteúdo
|
||||
- Filtro de conteúdo impróprio
|
||||
- Verificação de complexidade
|
||||
- Adequação à faixa etária
|
||||
- Alinhamento educacional
|
||||
|
||||
### 2. Estrutura Narrativa
|
||||
- Coerência da história
|
||||
- Desenvolvimento de personagens
|
||||
- Arco narrativo apropriado
|
||||
- Conclusão educativa
|
||||
|
||||
### 3. Linguagem
|
||||
- Vocabulário adequado
|
||||
- Estruturas gramaticais
|
||||
- Pontuação correta
|
||||
- Ritmo de leitura
|
||||
|
||||
## Integração com Modo Foco
|
||||
|
||||
### 1. Formatação Adaptativa
|
||||
- Ajuste automático de fonte
|
||||
- Espaçamento otimizado
|
||||
- Quebras de linha estratégicas
|
||||
- Destaque de palavras-chave
|
||||
|
||||
### 2. Recursos de Acessibilidade
|
||||
- Suporte a leitura em voz alta
|
||||
- Marcadores visuais
|
||||
- Controles de navegação
|
||||
- Ajustes de contraste
|
||||
|
||||
## Armazenamento
|
||||
|
||||
### 1. Estrutura de Dados
|
||||
```typescript
|
||||
interface Story {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
parameters: StoryChoices;
|
||||
metadata: {
|
||||
wordCount: number;
|
||||
readingTime: number;
|
||||
complexity: number;
|
||||
keywords: string[];
|
||||
};
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Indexação
|
||||
- Busca por parâmetros
|
||||
- Filtros de complexidade
|
||||
- Tags educacionais
|
||||
- Histórico de geração
|
||||
|
||||
## Próximas Melhorias
|
||||
|
||||
1. **Personalização Avançada**
|
||||
- Perfis de aprendizado
|
||||
- Adaptação dinâmica
|
||||
- Temas customizados
|
||||
- Integração curricular
|
||||
|
||||
2. **Geração Multimodal**
|
||||
- Ilustrações automáticas
|
||||
- Efeitos sonoros
|
||||
- Animações simples
|
||||
- Recursos interativos
|
||||
|
||||
3. **Análise de Impacto**
|
||||
- Métricas de engajamento
|
||||
- Progresso educacional
|
||||
- Feedback do usuário
|
||||
- Ajustes automáticos
|
||||
|
||||
4. **Colaboração**
|
||||
- Edição compartilhada
|
||||
- Biblioteca de recursos
|
||||
- Compartilhamento social
|
||||
- Feedback comunitário
|
||||
151
docs/gravacao-audio.md
Normal file
151
docs/gravacao-audio.md
Normal file
@ -0,0 +1,151 @@
|
||||
# Sistema de Gravação de Áudio
|
||||
|
||||
## Visão Geral
|
||||
O sistema de gravação de áudio permite aos alunos gravar suas leituras para análise posterior, com integração ao Modo Foco e recursos de acessibilidade.
|
||||
|
||||
## Componentes
|
||||
|
||||
### 1. AudioRecorder
|
||||
Componente principal responsável pela gravação de áudio.
|
||||
|
||||
#### Props
|
||||
```typescript
|
||||
interface AudioRecorderProps {
|
||||
storyId: string;
|
||||
studentId: string;
|
||||
onAudioUploaded: (audioUrl: string) => void;
|
||||
onRecordingStart?: () => void;
|
||||
onRecordingStop?: () => void;
|
||||
focusModeActive?: boolean;
|
||||
onFocusModeToggle?: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Estados de Gravação
|
||||
- **Não Iniciado**: Exibe botão "Iniciar Gravação"
|
||||
- **Gravando**: Exibe botão "Parar Gravação"
|
||||
- **Gravado**: Exibe opções de ouvir e enviar
|
||||
- **Enviando**: Exibe indicador de progresso
|
||||
|
||||
## Funcionalidades
|
||||
|
||||
### 1. Controle de Gravação
|
||||
- Início/parada de gravação
|
||||
- Feedback visual do estado atual
|
||||
- Integração com Modo Foco
|
||||
- Controle de permissões do microfone
|
||||
|
||||
### 2. Processamento de Áudio
|
||||
- Formato: WebM
|
||||
- Armazenamento temporário em Blob
|
||||
- Conversão e compressão antes do upload
|
||||
- Validação de qualidade
|
||||
|
||||
### 3. Upload e Armazenamento
|
||||
- Upload para Supabase Storage
|
||||
- Geração de URLs públicas
|
||||
- Organização por aluno/história
|
||||
- Backup e cache
|
||||
|
||||
### 4. Integração com Modo Foco
|
||||
- Ativação automática do Modo Foco
|
||||
- Desativação ao finalizar gravação
|
||||
- Sincronização de estados
|
||||
- Transições suaves
|
||||
|
||||
## Fluxo de Gravação
|
||||
|
||||
### 1. Início da Gravação
|
||||
```typescript
|
||||
const startRecording = async () => {
|
||||
// 1. Ativar Modo Foco
|
||||
if (!focusModeActive && onFocusModeToggle) {
|
||||
onFocusModeToggle();
|
||||
}
|
||||
|
||||
// 2. Solicitar permissões
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
|
||||
// 3. Configurar gravador
|
||||
mediaRecorderRef.current = new MediaRecorder(stream);
|
||||
|
||||
// 4. Iniciar gravação
|
||||
mediaRecorderRef.current.start();
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Finalização da Gravação
|
||||
```typescript
|
||||
const stopRecording = () => {
|
||||
// 1. Parar gravação
|
||||
mediaRecorderRef.current?.stop();
|
||||
|
||||
// 2. Liberar recursos
|
||||
mediaRecorderRef.current?.stream.getTracks().forEach(track => track.stop());
|
||||
|
||||
// 3. Desativar Modo Foco
|
||||
if (focusModeActive && onFocusModeToggle) {
|
||||
onFocusModeToggle();
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Upload do Áudio
|
||||
```typescript
|
||||
const uploadAudio = async () => {
|
||||
// 1. Preparar arquivo
|
||||
const filePath = `${studentId}/${storyId}/${fileId}.webm`;
|
||||
|
||||
// 2. Fazer upload
|
||||
await supabase.storage
|
||||
.from('recordings')
|
||||
.upload(filePath, audioBlob);
|
||||
|
||||
// 3. Obter URL pública
|
||||
const { publicUrl } = supabase.storage
|
||||
.from('recordings')
|
||||
.getPublicUrl(filePath);
|
||||
|
||||
// 4. Criar registro
|
||||
await supabase
|
||||
.from('story_recordings')
|
||||
.insert({
|
||||
id: fileId,
|
||||
story_id: storyId,
|
||||
student_id: studentId,
|
||||
audio_url: publicUrl
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## Tratamento de Erros
|
||||
|
||||
### 1. Permissões
|
||||
- Verificação de disponibilidade do microfone
|
||||
- Solicitação de permissões do usuário
|
||||
- Feedback claro em caso de negação
|
||||
|
||||
### 2. Gravação
|
||||
- Monitoramento de qualidade
|
||||
- Detecção de silêncio
|
||||
- Limite de duração
|
||||
- Feedback de volume
|
||||
|
||||
### 3. Upload
|
||||
- Retry em caso de falha
|
||||
- Limpeza de arquivos temporários
|
||||
- Validação de formato
|
||||
- Feedback de progresso
|
||||
|
||||
## Considerações de Segurança
|
||||
- Validação de tipos de arquivo
|
||||
- Sanitização de nomes de arquivo
|
||||
- Controle de acesso por usuário
|
||||
- Expiração de URLs temporárias
|
||||
|
||||
## Próximas Melhorias
|
||||
1. Adicionar visualização de forma de onda
|
||||
2. Implementar edição básica de áudio
|
||||
3. Melhorar feedback de qualidade
|
||||
4. Adicionar suporte a mais formatos
|
||||
5. Implementar detecção de ruído
|
||||
90
docs/modo-foco.md
Normal file
90
docs/modo-foco.md
Normal file
@ -0,0 +1,90 @@
|
||||
# Modo Foco
|
||||
|
||||
## Visão Geral
|
||||
O Modo Foco é uma funcionalidade projetada para melhorar a experiência de leitura e gravação de histórias, focando na concentração e acessibilidade.
|
||||
|
||||
## Funcionalidades
|
||||
|
||||
### 1. Ativação Automática
|
||||
- Inicia automaticamente ao começar uma gravação
|
||||
- Desativa automaticamente ao parar a gravação
|
||||
- Pode ser ativado/desativado manualmente através do botão dedicado
|
||||
|
||||
### 2. Interface Adaptativa
|
||||
- Remove distrações visuais durante a leitura
|
||||
- Aumenta o foco no texto atual
|
||||
- Transições suaves entre estados
|
||||
|
||||
### 3. Controles de Acessibilidade
|
||||
- Ajuste de tamanho da fonte (12px - 32px)
|
||||
- Controle de espaçamento entre letras
|
||||
- Controle de espaçamento entre palavras
|
||||
- Ajuste de altura da linha
|
||||
- Velocidade de leitura personalizável
|
||||
|
||||
### 4. Organização dos Controles
|
||||
#### Seção 1 (Controles Principais)
|
||||
- Maiúsculas/Minúsculas
|
||||
- Sílabas
|
||||
- Destacar palavras
|
||||
|
||||
#### Seção 2 (Controles de Formatação)
|
||||
- Tamanho da fonte
|
||||
- Espaçamento entre letras
|
||||
- Espaçamento entre palavras
|
||||
- Altura da linha
|
||||
- Velocidade de leitura
|
||||
|
||||
### 5. Indicadores Visuais
|
||||
- Ícones intuitivos para cada função
|
||||
- Feedback visual do estado atual
|
||||
- Timer de gravação
|
||||
- Destaque de palavras durante a leitura
|
||||
|
||||
## Estilos e Temas
|
||||
- Modo claro com fundo suave
|
||||
- Contraste adequado para leitura
|
||||
- Sombras sutis para hierarquia visual
|
||||
- Design responsivo para diferentes tamanhos de tela
|
||||
|
||||
## Integração
|
||||
- Componente `AudioRecorder` para gravações
|
||||
- Componente `TextControls` para formatação
|
||||
- Sistema de destaque de palavras
|
||||
- Gerenciamento de estado global
|
||||
|
||||
## Uso Técnico
|
||||
|
||||
### Ativação do Modo Foco
|
||||
```typescript
|
||||
// Em AudioRecorder
|
||||
const startRecording = async () => {
|
||||
if (!focusModeActive && onFocusModeToggle) {
|
||||
onFocusModeToggle();
|
||||
}
|
||||
// ... resto do código
|
||||
};
|
||||
```
|
||||
|
||||
### Desativação do Modo Foco
|
||||
```typescript
|
||||
// Em AudioRecorder
|
||||
const stopRecording = () => {
|
||||
// ... código de parada da gravação
|
||||
if (focusModeActive && onFocusModeToggle) {
|
||||
onFocusModeToggle();
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Considerações de Acessibilidade
|
||||
- Alto contraste para melhor legibilidade
|
||||
- Suporte a diferentes tamanhos de fonte
|
||||
- Controles de espaçamento para dislexia
|
||||
- Feedback visual claro das ações
|
||||
|
||||
## Próximos Passos
|
||||
1. Implementar persistência das preferências do usuário
|
||||
2. Adicionar mais opções de temas
|
||||
3. Expandir controles de acessibilidade
|
||||
4. Melhorar feedback de progresso da leitura
|
||||
150
docs/processamento-audio.md
Normal file
150
docs/processamento-audio.md
Normal file
@ -0,0 +1,150 @@
|
||||
# Processamento de Áudio (Edge Function)
|
||||
|
||||
## Visão Geral
|
||||
O sistema de processamento de áudio é uma Edge Function que analisa gravações de leitura, fornecendo métricas detalhadas sobre fluência, pronúncia e compreensão.
|
||||
|
||||
## Estrutura de Dados
|
||||
|
||||
### AudioRecord
|
||||
```typescript
|
||||
interface AudioRecord {
|
||||
id: string
|
||||
story_id: string
|
||||
student_id: string
|
||||
audio_url: string
|
||||
status: 'pending_analysis' | 'processing' | 'completed' | 'error'
|
||||
analysis: any
|
||||
created_at: string
|
||||
transcription: string | null
|
||||
processed_at: string | null
|
||||
error_message: string | null
|
||||
fluency_score: number | null
|
||||
pronunciation_score: number | null
|
||||
accuracy_score: number | null
|
||||
comprehension_score: number | null
|
||||
words_per_minute: number | null
|
||||
pause_count: number | null
|
||||
error_count: number | null
|
||||
self_corrections: number | null
|
||||
strengths: string[]
|
||||
improvements: string[]
|
||||
suggestions: string | null
|
||||
}
|
||||
```
|
||||
|
||||
## Fluxo de Processamento
|
||||
|
||||
### 1. Recebimento da Requisição
|
||||
- Validação inicial dos dados recebidos
|
||||
- Configuração de CORS e headers
|
||||
- Inicialização do logger
|
||||
|
||||
### 2. Processamento Principal
|
||||
O processamento ocorre em etapas sequenciais:
|
||||
|
||||
1. **Verificação e Atualização de Status**
|
||||
- Verifica existência do registro
|
||||
- Cria registro se necessário
|
||||
- Atualiza status para 'processing'
|
||||
|
||||
2. **Processamento do Áudio**
|
||||
- Transcrição via Whisper API
|
||||
- Análise do texto transcrito
|
||||
|
||||
3. **Análise da Leitura**
|
||||
- Cálculo de métricas de fluência
|
||||
- Avaliação de pronúncia
|
||||
- Identificação de pontos fortes e melhorias
|
||||
|
||||
4. **Atualização do Banco**
|
||||
- Preparação dos dados de análise
|
||||
- Verificação pré-update
|
||||
- Atualização do registro
|
||||
- Verificação pós-update
|
||||
|
||||
## Tratamento de Erros
|
||||
|
||||
### 1. Validação de Dados
|
||||
```typescript
|
||||
if (!data?.record?.id || !data?.record?.audio_url) {
|
||||
throw new Error('Dados inválidos: ID ou URL do áudio ausentes')
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Atualização de Status de Erro
|
||||
- Em caso de falha, atualiza o registro com status 'error'
|
||||
- Armazena mensagem de erro para diagnóstico
|
||||
- Retorna resposta com detalhes do erro
|
||||
|
||||
## Métricas Analisadas
|
||||
|
||||
### Pontuações
|
||||
- Fluência (0-100)
|
||||
- Pronúncia (0-100)
|
||||
- Precisão (0-100)
|
||||
- Compreensão (0-100)
|
||||
|
||||
### Métricas Quantitativas
|
||||
- Palavras por minuto
|
||||
- Contagem de pausas
|
||||
- Contagem de erros
|
||||
- Autocorreções
|
||||
|
||||
### Feedback Qualitativo
|
||||
- Pontos fortes identificados
|
||||
- Áreas para melhoria
|
||||
- Sugestões personalizadas
|
||||
|
||||
## Logs e Monitoramento
|
||||
|
||||
### Eventos Registrados
|
||||
- Recebimento de requisição
|
||||
- Atualizações de status
|
||||
- Resultados de processamento
|
||||
- Erros e exceções
|
||||
|
||||
### Formato dos Logs
|
||||
```typescript
|
||||
logger.info('event_name', 'Descrição do evento', {
|
||||
contextData: 'dados adicionais'
|
||||
})
|
||||
```
|
||||
|
||||
## Considerações de Segurança
|
||||
|
||||
### 1. Autenticação
|
||||
- Validação de tokens
|
||||
- Verificação de permissões
|
||||
- Controle de acesso por usuário
|
||||
|
||||
### 2. Dados Sensíveis
|
||||
- Sanitização de inputs
|
||||
- Validação de URLs
|
||||
- Proteção contra injeção
|
||||
|
||||
### 3. Rate Limiting
|
||||
- Controle de requisições
|
||||
- Proteção contra sobrecarga
|
||||
- Cache de resultados
|
||||
|
||||
## Próximas Melhorias
|
||||
|
||||
1. **Análise Avançada**
|
||||
- Detecção de padrões de erro
|
||||
- Análise de entonação
|
||||
- Reconhecimento de emoção
|
||||
|
||||
2. **Performance**
|
||||
- Otimização de processamento
|
||||
- Cache distribuído
|
||||
- Processamento em lote
|
||||
|
||||
3. **Feedback**
|
||||
- Relatórios detalhados
|
||||
- Visualizações gráficas
|
||||
- Recomendações personalizadas
|
||||
|
||||
4. **Integração**
|
||||
- Webhooks para notificações
|
||||
- API para consultas em tempo real
|
||||
- Exportação de dados
|
||||
25
docs/voice-features.md
Normal file
25
docs/voice-features.md
Normal file
@ -0,0 +1,25 @@
|
||||
## Geração por Voz
|
||||
|
||||
### Como usar:
|
||||
1. Clique no ícone de microfone
|
||||
2. Fale sua descrição por 15-120 segundos
|
||||
3. Confira a transcrição
|
||||
4. Ajuste se necessário
|
||||
5. Envie para gerar a história
|
||||
|
||||
### Requisitos:
|
||||
- Navegador moderno (Chrome, Edge, Safari 14+)
|
||||
- Microfone habilitado
|
||||
- Conexão estável
|
||||
|
||||
## Segurança
|
||||
|
||||
- Gravações temporárias são excluídas após 1h
|
||||
- Transcrições são validadas contra conteúdo sensível
|
||||
- Dados de áudio não são armazenados permanentemente
|
||||
|
||||
## Limitações Conhecidas
|
||||
|
||||
- Acento pode afetar precisão da transcrição
|
||||
- Ruído ambiente pode interferir na qualidade
|
||||
- Suporte limitado a sotaques regionais
|
||||
@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/book.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Histórias Mágicas - Plataforma educacional de histórias interativas para escolas" />
|
||||
<title>Histórias Mágicas | Educação através de histórias interativas</title>
|
||||
<meta name="description" content="Leiturama - Plataforma educacional de histórias interativas para escolas" />
|
||||
<title>Leiturama | Educação através de histórias interativas</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
60
netlify.toml
Normal file
60
netlify.toml
Normal file
@ -0,0 +1,60 @@
|
||||
[build]
|
||||
command = "npm run build"
|
||||
publish = "dist"
|
||||
|
||||
[build.environment]
|
||||
NODE_VERSION = "18"
|
||||
VITE_SUPABASE_URL = "https://bsjlbnyslxzsdwxvkaap.supabase.co"
|
||||
VITE_RESEND_API_KEY = "GEoM_cVt4qyBFVkngJWi8wBrMWOiPMUAuxuFGykcP0A"
|
||||
VITE_APP_URL = "https://leiturama.ai/"
|
||||
SECRETS_SCAN_OMIT_KEYS = "SUPABASE_ANON_KEY"
|
||||
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
|
||||
[[headers]]
|
||||
for = "/*"
|
||||
[headers.values]
|
||||
X-Frame-Options = "DENY"
|
||||
X-XSS-Protection = "1; mode=block"
|
||||
X-Content-Type-Options = "nosniff"
|
||||
Referrer-Policy = "strict-origin-when-cross-origin"
|
||||
Cross-Origin-Embedder-Policy = "credentialless"
|
||||
Cross-Origin-Opener-Policy = "same-origin"
|
||||
Cross-Origin-Resource-Policy = "cross-origin"
|
||||
Content-Security-Policy = """
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.rudderlabs.com https://*.cloudfront.net https://www.googletagmanager.com https://*.sentry.io;
|
||||
connect-src 'self' https://*.rudderlabs.com https://*.ingest.sentry.io https://*.supabase.co https://www.google-analytics.com https://*.dataplane.rudderstack.com https://*.bugsnag.com/ https://*.ingest.us.sentry.io/ https://*.sentry.io/;
|
||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
|
||||
img-src 'self' data: https: blob: https://*.supabase.co;
|
||||
font-src 'self' data: https://fonts.gstatic.com;
|
||||
frame-src 'self' https://www.googletagmanager.com;
|
||||
worker-src 'self' blob:;
|
||||
"""
|
||||
Access-Control-Allow-Origin = "*"
|
||||
Access-Control-Allow-Methods = "GET, POST, PUT, DELETE, OPTIONS"
|
||||
Access-Control-Allow-Headers = """
|
||||
Authorization,
|
||||
Content-Type,
|
||||
Accept,
|
||||
Origin,
|
||||
User-Agent,
|
||||
DNT,
|
||||
Cache-Control,
|
||||
X-Mx-ReqToken,
|
||||
Keep-Alive,
|
||||
X-Requested-With,
|
||||
If-Modified-Since
|
||||
"""
|
||||
Access-Control-Max-Age = "3600"
|
||||
|
||||
[dev]
|
||||
command = "npm run dev"
|
||||
port = 5173
|
||||
publish = "dist"
|
||||
|
||||
[functions]
|
||||
node_bundler = "esbuild"
|
||||
21
next.config.js
Normal file
21
next.config.js
Normal file
@ -0,0 +1,21 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
images: {
|
||||
domains: [
|
||||
'oaidalleapiprodscus.blob.core.windows.net',
|
||||
'leiturama.ai',
|
||||
'localhost',
|
||||
'bsjlbnyslxzsdwxvkaap.supabase.co',
|
||||
'leiturama.netlify.app'
|
||||
],
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
minimumCacheTTL: 60,
|
||||
},
|
||||
experimental: {
|
||||
optimizeCss: true,
|
||||
optimizeImages: true,
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
7132
package-lock.json
generated
7132
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
55
package.json
55
package.json
@ -9,20 +9,70 @@
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx}\""
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
||||
"docker:build": "docker build -t leiturama .",
|
||||
"docker:run": "docker run -p 3000:3000 leiturama",
|
||||
"deploy:prod": "docker-compose up -d --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ffmpeg/ffmpeg": "^0.12.7",
|
||||
"@ffmpeg/util": "^0.12.1",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.57.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.57.1",
|
||||
"@opentelemetry/sdk-metrics": "^1.30.1",
|
||||
"@opentelemetry/sdk-trace-web": "^1.30.1",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-progress": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@sentry/react": "^8.48.0",
|
||||
"@supabase/supabase-js": "^2.39.7",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/react-query": "^5.62.8",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@tiptap/extension-character-count": "^2.11.5",
|
||||
"@tiptap/extension-color": "^2.11.5",
|
||||
"@tiptap/extension-highlight": "^2.11.5",
|
||||
"@tiptap/extension-placeholder": "^2.11.5",
|
||||
"@tiptap/extension-text-align": "^2.11.5",
|
||||
"@tiptap/extension-text-style": "^2.11.5",
|
||||
"@tiptap/extension-underline": "^2.11.5",
|
||||
"@tiptap/pm": "^2.11.5",
|
||||
"@tiptap/react": "^2.11.5",
|
||||
"@tiptap/starter-kit": "^2.11.5",
|
||||
"@tremor/react": "^3.18.7",
|
||||
"@types/ioredis": "^4.28.10",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/next": "^8.0.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"ioredis": "^5.4.2",
|
||||
"lucide-react": "^0.344.0",
|
||||
"next": "^15.1.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"resend": "^3.2.0"
|
||||
"recharts": "^2.15.1",
|
||||
"resend": "^3.2.0",
|
||||
"shadcn-ui": "^0.9.4",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^11.0.3",
|
||||
"vitest": "^2.1.8",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.1",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@types/react": "^18.3.17",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.9.1",
|
||||
@ -30,6 +80,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.11",
|
||||
"globals": "^15.9.0",
|
||||
"postcss": "^8.4.49",
|
||||
"supabase": "^2.1.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.3.0",
|
||||
|
||||
34
portainer-stack.yml
Normal file
34
portainer-stack.yml
Normal file
@ -0,0 +1,34 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
leiturama:
|
||||
image: ${REGISTRY}/leiturama:${TAG}
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- REDIS_URL=redis://redis:6379
|
||||
networks:
|
||||
- traefik-public
|
||||
- redis-network
|
||||
deploy:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.leiturama.rule=Host(`${DOMAIN}`)"
|
||||
- "traefik.http.routers.leiturama.entrypoints=websecure"
|
||||
- "traefik.http.routers.leiturama.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.leiturama.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
replicas: 2
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
redis-network:
|
||||
external: true
|
||||
4
public/book.svg
Normal file
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 |
1
public/images/director-1.webp
Normal file
1
public/images/director-1.webp
Normal file
@ -0,0 +1 @@
|
||||
data:image/webp;base64,UklGRlIAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAABBxAREYiI/gcAAABWUDggGAAAADABAJ0BKgEAAQABABwlpAADcAD+/gbQAA==
|
||||
1
public/images/evidence-based.webp
Normal file
1
public/images/evidence-based.webp
Normal file
@ -0,0 +1 @@
|
||||
data:image/webp;base64,UklGRlIAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAABBxAREYiI/gcAAABWUDggGAAAADABAJ0BKgEAAQABABwlpAADcAD+/gbQAA==
|
||||
1
public/images/parent-1.webp
Normal file
1
public/images/parent-1.webp
Normal file
@ -0,0 +1 @@
|
||||
data:image/webp;base64,UklGRlIAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAABBxAREYiI/gcAAABWUDggGAAAADABAJ0BKgEAAQABABwlpAADcAD+/gbQAA==
|
||||
1
public/images/teacher-1.webp
Normal file
1
public/images/teacher-1.webp
Normal file
@ -0,0 +1 @@
|
||||
data:image/webp;base64,UklGRlIAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAABBxAREYiI/gcAAABWUDggGAAAADABAJ0BKgEAAQABABwlpAADcAD+/gbQAA==
|
||||
8
public/patterns/dots.svg
Normal file
8
public/patterns/dots.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="2" cy="2" r="2" fill="currentColor"/>
|
||||
<circle cx="18" cy="2" r="2" fill="currentColor"/>
|
||||
<circle cx="10" cy="10" r="2" fill="currentColor"/>
|
||||
<circle cx="2" cy="18" r="2" fill="currentColor"/>
|
||||
<circle cx="18" cy="18" r="2" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
90
src/App.tsx
90
src/App.tsx
@ -10,6 +10,10 @@ import { AuthUser, SavedStory } from './types/auth';
|
||||
import { User, Theme } from './types';
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Toaster } from './components/ui/toaster';
|
||||
import { router } from './routes';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
|
||||
type AppStep =
|
||||
| 'welcome'
|
||||
@ -20,6 +24,16 @@ type AppStep =
|
||||
| 'story'
|
||||
| 'library';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutos
|
||||
gcTime: 1000 * 60 * 30, // 30 minutos (antes era cacheTime)
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export function App() {
|
||||
const navigate = useNavigate();
|
||||
const [step, setStep] = useState<AppStep>('welcome');
|
||||
@ -74,43 +88,47 @@ export function App() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<div className="min-h-screen bg-gradient-to-b from-purple-100 to-blue-100">
|
||||
{step === 'welcome' && (
|
||||
<WelcomePage
|
||||
onLoginClick={() => setStep('login')}
|
||||
onRegisterClick={() => setStep('register')}
|
||||
/>
|
||||
)}
|
||||
{step === 'login' && (
|
||||
<div className="min-h-screen flex items-center justify-center p-6">
|
||||
<LoginForm
|
||||
userType="school"
|
||||
onLogin={handleLogin}
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
<div className="min-h-screen bg-gradient-to-b from-purple-100 to-blue-100">
|
||||
{step === 'welcome' && (
|
||||
<WelcomePage
|
||||
onLoginClick={() => setStep('login')}
|
||||
onRegisterClick={() => setStep('register')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step === 'register' && (
|
||||
<RegistrationForm
|
||||
userType="school"
|
||||
onComplete={handleRegistrationComplete}
|
||||
/>
|
||||
)}
|
||||
{step === 'avatar' && user && (
|
||||
<AvatarSelector user={user} onComplete={handleAvatarComplete} />
|
||||
)}
|
||||
{step === 'theme' && <ThemeSelector onSelect={handleThemeSelect} />}
|
||||
{step === 'story' && user && selectedTheme && (
|
||||
<StoryViewer theme={selectedTheme} user={user} />
|
||||
)}
|
||||
{step === 'library' && authUser && (
|
||||
<StoryLibrary
|
||||
stories={savedStories}
|
||||
onStorySelect={handleStorySelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AuthProvider>
|
||||
)}
|
||||
{step === 'login' && (
|
||||
<div className="min-h-screen flex items-center justify-center p-6">
|
||||
<LoginForm
|
||||
userType="school"
|
||||
onLogin={handleLogin}
|
||||
onRegisterClick={() => setStep('register')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step === 'register' && (
|
||||
<RegistrationForm
|
||||
userType="school"
|
||||
onComplete={handleRegistrationComplete}
|
||||
/>
|
||||
)}
|
||||
{step === 'avatar' && user && (
|
||||
<AvatarSelector user={user} onComplete={handleAvatarComplete} />
|
||||
)}
|
||||
{step === 'theme' && <ThemeSelector onSelect={handleThemeSelect} />}
|
||||
{step === 'story' && user && selectedTheme && (
|
||||
<StoryViewer theme={selectedTheme} user={user} />
|
||||
)}
|
||||
{step === 'library' && authUser && (
|
||||
<StoryLibrary
|
||||
stories={savedStories}
|
||||
onStorySelect={handleStorySelect}
|
||||
/>
|
||||
)}
|
||||
<Toaster />
|
||||
</div>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@ -18,7 +18,7 @@ export function StoryViewer({ theme, user, demo = false }: Props) {
|
||||
title: "Uma Aventura Educacional",
|
||||
pages: [
|
||||
{
|
||||
text: "Bem-vindo à demonstração do Histórias Mágicas! Aqui você pode ver como funciona nossa plataforma...",
|
||||
text: "Bem-vindo à demonstração do Leiturama! Aqui você pode ver como funciona nossa plataforma...",
|
||||
image: "https://images.unsplash.com/photo-1472162072942-cd5147eb3902?auto=format&fit=crop&q=80&w=800&h=600",
|
||||
},
|
||||
{
|
||||
|
||||
@ -12,7 +12,7 @@ export function WelcomePage({ onLoginClick, onRegisterClick }: Props) {
|
||||
<div className="max-w-6xl mx-auto px-6 py-16">
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-5xl font-bold text-purple-600 mb-4">
|
||||
Histórias Mágicas
|
||||
Leiturama
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600">
|
||||
Embarque em uma jornada de aprendizado e diversão!
|
||||
|
||||
39
src/components/analytics/GoogleTagManager.tsx
Normal file
39
src/components/analytics/GoogleTagManager.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
|
||||
interface GoogleTagManagerProps {
|
||||
gtmId: string;
|
||||
}
|
||||
|
||||
export function GoogleTagManager({ gtmId }: GoogleTagManagerProps) {
|
||||
React.useEffect(() => {
|
||||
// Carrega o script do GTM
|
||||
const script = document.createElement('script');
|
||||
script.innerHTML = `
|
||||
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','${gtmId}');
|
||||
`;
|
||||
document.head.appendChild(script);
|
||||
|
||||
// Adiciona o noscript iframe
|
||||
const noscript = document.createElement('noscript');
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = `https://www.googletagmanager.com/ns.html?id=${gtmId}`;
|
||||
iframe.height = '0';
|
||||
iframe.width = '0';
|
||||
iframe.style.display = 'none';
|
||||
iframe.style.visibility = 'hidden';
|
||||
noscript.appendChild(iframe);
|
||||
document.body.insertBefore(noscript, document.body.firstChild);
|
||||
|
||||
return () => {
|
||||
// Cleanup
|
||||
document.head.removeChild(script);
|
||||
document.body.removeChild(noscript);
|
||||
};
|
||||
}, [gtmId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
127
src/components/analytics/PageTracker.tsx
Normal file
127
src/components/analytics/PageTracker.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRudderstack } from '../../hooks/useRudderstack';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
|
||||
export function PageTracker() {
|
||||
const location = useLocation();
|
||||
const { page } = useRudderstack();
|
||||
const { user } = useAuth();
|
||||
const lastPageTracked = useRef<string | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
useEffect(() => {
|
||||
// Se já rastreamos esta página, não rastrear novamente
|
||||
if (lastPageTracked.current === location.pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Limpa o timeout anterior se existir
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce de 300ms para evitar múltiplos eventos
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
// Coleta informações do dispositivo/navegador
|
||||
const deviceInfo = {
|
||||
screenWidth: window.screen.width,
|
||||
screenHeight: window.screen.height,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
deviceType: getDeviceType(),
|
||||
deviceOrientation: window.screen.orientation.type,
|
||||
userAgent: navigator.userAgent,
|
||||
language: navigator.language,
|
||||
};
|
||||
|
||||
// Coleta informações de performance
|
||||
const performanceInfo = {
|
||||
loadTime: window.performance.timing.loadEventEnd - window.performance.timing.navigationStart,
|
||||
domInteractive: window.performance.timing.domInteractive - window.performance.timing.navigationStart,
|
||||
firstContentfulPaint: getFirstContentfulPaint(),
|
||||
};
|
||||
|
||||
// Informações da sessão
|
||||
const sessionInfo = {
|
||||
sessionStartTime: sessionStorage.getItem('sessionStartTime') || new Date().toISOString(),
|
||||
isFirstVisit: !localStorage.getItem('returningVisitor'),
|
||||
lastVisitedPage: sessionStorage.getItem('lastVisitedPage'),
|
||||
};
|
||||
|
||||
// Traits do usuário (se autenticado)
|
||||
const userTraits = user ? {
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
school_id: user.user_metadata?.school_id,
|
||||
class_id: user.user_metadata?.class_id,
|
||||
name: user.user_metadata?.name,
|
||||
role: user.user_metadata?.role,
|
||||
last_updated: user.updated_at,
|
||||
created_at: user.created_at
|
||||
} : {};
|
||||
|
||||
// Envia dados adicionais usando o page() do Rudderstack
|
||||
page(undefined, {
|
||||
// Informações da página
|
||||
path: location.pathname,
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
title: document.title,
|
||||
referrer: document.referrer,
|
||||
url: window.location.href,
|
||||
|
||||
// Informações do dispositivo e navegador
|
||||
...deviceInfo,
|
||||
|
||||
// Informações de performance
|
||||
...performanceInfo,
|
||||
|
||||
// Informações da sessão
|
||||
...sessionInfo,
|
||||
|
||||
// Traits do usuário
|
||||
...userTraits,
|
||||
|
||||
// Metadados adicionais
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
});
|
||||
|
||||
// Atualiza a última página rastreada
|
||||
lastPageTracked.current = location.pathname;
|
||||
|
||||
// Atualiza informações da sessão
|
||||
sessionStorage.setItem('lastVisitedPage', location.pathname);
|
||||
if (!localStorage.getItem('returningVisitor')) {
|
||||
localStorage.setItem('returningVisitor', 'true');
|
||||
}
|
||||
if (!sessionStorage.getItem('sessionStartTime')) {
|
||||
sessionStorage.setItem('sessionStartTime', new Date().toISOString());
|
||||
}
|
||||
}, 300);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [location.pathname, user]); // Reduzido dependências para apenas pathname e user
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Função auxiliar para determinar o tipo de dispositivo
|
||||
function getDeviceType() {
|
||||
const width = window.innerWidth;
|
||||
if (width < 768) return 'mobile';
|
||||
if (width < 1024) return 'tablet';
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
// Função auxiliar para obter o First Contentful Paint
|
||||
function getFirstContentfulPaint() {
|
||||
const perfEntries = performance.getEntriesByType('paint');
|
||||
const fcpEntry = perfEntries.find(entry => entry.name === 'first-contentful-paint');
|
||||
return fcpEntry ? fcpEntry.startTime : null;
|
||||
}
|
||||
330
src/components/analytics/README.md
Normal file
330
src/components/analytics/README.md
Normal file
@ -0,0 +1,330 @@
|
||||
# 📊 Sistema de Analytics
|
||||
|
||||
Este diretório contém a implementação do sistema de analytics do Leiturama, utilizando Rudderstack como principal ferramenta de tracking.
|
||||
|
||||
## 🚀 Inicialização
|
||||
|
||||
Para inicializar o sistema de analytics corretamente, certifique-se de:
|
||||
|
||||
1. Configurar as variáveis de ambiente:
|
||||
```env
|
||||
VITE_RUDDERSTACK_WRITE_KEY=seu_write_key
|
||||
VITE_RUDDERSTACK_DATA_PLANE_URL=sua_url
|
||||
```
|
||||
|
||||
2. Inicializar o analytics antes de usar:
|
||||
```typescript
|
||||
// main.tsx
|
||||
import { analytics } from './lib/analytics';
|
||||
|
||||
// Inicializa o analytics antes de renderizar o app
|
||||
await analytics.init();
|
||||
|
||||
// Renderiza o app
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Erros Comuns
|
||||
|
||||
1. **Falha na Inicialização**
|
||||
```typescript
|
||||
Failed to initialize analytics: Event {...}
|
||||
```
|
||||
Possíveis causas:
|
||||
- Variáveis de ambiente não configuradas
|
||||
- Script do Rudderstack bloqueado
|
||||
- Erro na carga do script
|
||||
|
||||
Soluções:
|
||||
- Verifique as variáveis de ambiente
|
||||
- Verifique se o domínio do Rudderstack está liberado
|
||||
- Adicione tratamento de erro na inicialização:
|
||||
```typescript
|
||||
try {
|
||||
await analytics.init();
|
||||
} catch (error) {
|
||||
console.error('Falha ao inicializar analytics:', error);
|
||||
// Continue renderizando o app mesmo com falha no analytics
|
||||
}
|
||||
```
|
||||
|
||||
2. **Eventos Não Rastreados**
|
||||
Se os eventos não estão sendo rastreados, verifique:
|
||||
- Se o analytics foi inicializado corretamente
|
||||
- Se há erros no console
|
||||
- Se o writeKey e dataPlaneUrl estão corretos
|
||||
- Se há bloqueadores de rastreamento no navegador
|
||||
|
||||
3. **Erros de Tipo**
|
||||
Se encontrar erros de tipo ao usar os hooks:
|
||||
- Verifique se está usando as interfaces corretas
|
||||
- Importe os tipos necessários
|
||||
- Use as constantes de EVENT_CATEGORIES
|
||||
|
||||
## 📦 Componentes
|
||||
|
||||
### PageTracker
|
||||
Componente responsável pelo tracking automático de visualizações de página.
|
||||
|
||||
```tsx
|
||||
// App.tsx
|
||||
<PageTracker />
|
||||
```
|
||||
|
||||
### GoogleTagManager
|
||||
Componente para integração com Google Tag Manager.
|
||||
|
||||
```tsx
|
||||
// App.tsx
|
||||
<GoogleTagManager gtmId="GTM-XXXXXX" />
|
||||
```
|
||||
|
||||
## 🎯 Hooks Disponíveis
|
||||
|
||||
### useButtonTracking
|
||||
|
||||
Hook para rastreamento de interações com botões e elementos clicáveis.
|
||||
|
||||
```typescript
|
||||
const { trackButtonClick } = useButtonTracking({
|
||||
category?: string; // Categoria do evento (default: 'interaction')
|
||||
location?: string; // Localização do botão (default: pathname atual)
|
||||
});
|
||||
|
||||
// Uso:
|
||||
trackButtonClick('button-id', {
|
||||
label: 'Botão de Login',
|
||||
variant: 'primary',
|
||||
position: 'header',
|
||||
section: 'auth'
|
||||
});
|
||||
```
|
||||
|
||||
### useFormTracking
|
||||
|
||||
Hook para rastreamento de interações com formulários.
|
||||
|
||||
```typescript
|
||||
const {
|
||||
trackFormStarted,
|
||||
trackFormStepCompleted,
|
||||
trackFormSubmitted,
|
||||
trackFormError,
|
||||
trackFormAbandoned,
|
||||
trackFieldInteraction
|
||||
} = useFormTracking({
|
||||
formId: string; // ID único do formulário
|
||||
formName: string; // Nome descritivo do formulário
|
||||
category?: string; // Categoria (default: 'form')
|
||||
});
|
||||
|
||||
// Exemplos de Uso:
|
||||
// Início do formulário
|
||||
trackFormStarted();
|
||||
|
||||
// Completou um passo
|
||||
trackFormStepCompleted('dados-pessoais', true);
|
||||
|
||||
// Submeteu o formulário
|
||||
trackFormSubmitted(true, {
|
||||
user_type: 'student'
|
||||
});
|
||||
|
||||
// Erro no formulário
|
||||
trackFormError('validation', 'Email inválido', 'email');
|
||||
|
||||
// Abandonou o formulário
|
||||
trackFormAbandoned('payment');
|
||||
|
||||
// Interação com campo
|
||||
trackFieldInteraction('email', 'focus');
|
||||
```
|
||||
|
||||
### useStudentTracking
|
||||
|
||||
Hook especializado para rastreamento de atividades do estudante.
|
||||
|
||||
```typescript
|
||||
const {
|
||||
trackStoryGenerated,
|
||||
trackAudioRecorded,
|
||||
trackExerciseCompleted,
|
||||
trackInterestAdded,
|
||||
trackInterestRemoved
|
||||
} = useStudentTracking();
|
||||
|
||||
// Exemplo: Rastrear geração de história
|
||||
trackStoryGenerated({
|
||||
story_id: 'story-123',
|
||||
theme: 'aventura',
|
||||
prompt: 'Uma história sobre...',
|
||||
generation_time: 2.5,
|
||||
word_count: 300,
|
||||
student_id: 'student-123'
|
||||
});
|
||||
|
||||
// Exemplo: Rastrear exercício completado
|
||||
trackExerciseCompleted({
|
||||
exercise_id: 'ex-123',
|
||||
story_id: 'story-123',
|
||||
student_id: 'student-123',
|
||||
exercise_type: 'pronunciation',
|
||||
score: 85,
|
||||
time_spent: 120
|
||||
});
|
||||
```
|
||||
|
||||
### useErrorTracking
|
||||
|
||||
Hook para rastreamento de erros e exceções.
|
||||
|
||||
```typescript
|
||||
const {
|
||||
trackError,
|
||||
trackErrorBoundary,
|
||||
trackApiError
|
||||
} = useErrorTracking({
|
||||
category?: string; // Categoria (default: 'error')
|
||||
userId?: string; // ID do usuário
|
||||
userEmail?: string; // Email do usuário
|
||||
});
|
||||
|
||||
// Exemplo: Rastrear erro genérico
|
||||
trackError(error, {
|
||||
componentName: 'LoginForm',
|
||||
action: 'submit',
|
||||
metadata: { attempt: 2 }
|
||||
});
|
||||
|
||||
// Exemplo: Rastrear erro de API
|
||||
trackApiError(error, '/api/login', 'POST', {
|
||||
email: 'user@example.com'
|
||||
});
|
||||
```
|
||||
|
||||
## 📝 Padrões de Nomenclatura
|
||||
|
||||
### Eventos
|
||||
|
||||
- Use snake_case para nomes de eventos
|
||||
- Formato: `{objeto}_{ação}`
|
||||
- Exemplos:
|
||||
- `button_clicked`
|
||||
- `form_submitted`
|
||||
- `story_generated`
|
||||
- `exercise_completed`
|
||||
|
||||
### Propriedades
|
||||
|
||||
- Use snake_case para nomes de propriedades
|
||||
- Categorize propriedades por namespace:
|
||||
```typescript
|
||||
{
|
||||
// Propriedades de página
|
||||
page_url: string;
|
||||
page_title: string;
|
||||
|
||||
// Propriedades de usuário
|
||||
user_id: string;
|
||||
user_type: string;
|
||||
|
||||
// Propriedades de elemento
|
||||
element_type: string;
|
||||
element_id: string;
|
||||
|
||||
// Propriedades de formulário
|
||||
form_id: string;
|
||||
form_name: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Categorias
|
||||
|
||||
Categorias predefinidas disponíveis em `EVENT_CATEGORIES`:
|
||||
- `page`: Eventos de visualização de página
|
||||
- `user`: Eventos relacionados ao usuário
|
||||
- `story`: Eventos de histórias
|
||||
- `exercise`: Eventos de exercícios
|
||||
- `interaction`: Eventos de interação do usuário
|
||||
- `error`: Eventos de erro
|
||||
- `subscription`: Eventos de assinatura
|
||||
- `auth`: Eventos de autenticação
|
||||
- `navigation`: Eventos de navegação
|
||||
- `form`: Eventos de formulário
|
||||
|
||||
## 🔨 Exemplos de Implementação
|
||||
|
||||
### Botão com Tracking
|
||||
|
||||
```typescript
|
||||
<Button
|
||||
trackingId="signup-button"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
trackingProperties={{
|
||||
category: EVENT_CATEGORIES.AUTH,
|
||||
action: 'signup_click',
|
||||
label: 'homepage_hero',
|
||||
position: 'hero_section'
|
||||
}}
|
||||
onClick={handleSignup}
|
||||
>
|
||||
Cadastre-se Agora
|
||||
</Button>
|
||||
```
|
||||
|
||||
### Formulário com Tracking
|
||||
|
||||
```typescript
|
||||
<Form
|
||||
formId="signup-form"
|
||||
formName="student-signup"
|
||||
trackingProperties={{
|
||||
category: EVENT_CATEGORIES.AUTH,
|
||||
user_type: 'student',
|
||||
source: 'homepage'
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{/* campos do formulário */}
|
||||
</Form>
|
||||
```
|
||||
|
||||
### Link com Tracking
|
||||
|
||||
```typescript
|
||||
<Link
|
||||
to="/dashboard"
|
||||
trackingId="dashboard-link"
|
||||
trackingProperties={{
|
||||
category: EVENT_CATEGORIES.NAVIGATION,
|
||||
section: 'sidebar',
|
||||
position: 'top'
|
||||
}}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
```
|
||||
|
||||
### Tracking de Erro em Try/Catch
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await submitForm(data);
|
||||
} catch (error) {
|
||||
errorTracking.trackError(error, {
|
||||
componentName: 'SignupForm',
|
||||
action: 'submit',
|
||||
metadata: {
|
||||
formData: data,
|
||||
attempt: retryCount
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
78
src/components/audio/AudioUploader.tsx
Normal file
78
src/components/audio/AudioUploader.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { processAudio } from '../../services/audioService';
|
||||
import { Button } from '../ui/button';
|
||||
import { EVENT_CATEGORIES } from '../../constants/analytics';
|
||||
|
||||
interface AudioUploaderProps {
|
||||
storyId: string;
|
||||
onUploadComplete?: (transcription: string) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export function AudioUploader({
|
||||
storyId,
|
||||
onUploadComplete,
|
||||
onError
|
||||
}: AudioUploaderProps): JSX.Element {
|
||||
const [isProcessing, setIsProcessing] = React.useState(false);
|
||||
const [error, setError] = React.useState<string>();
|
||||
|
||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
setError(undefined);
|
||||
|
||||
const response = await processAudio(file, storyId);
|
||||
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
onError?.(response.error);
|
||||
} else if (response.transcription) {
|
||||
onUploadComplete?.(response.transcription);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Erro ao processar áudio';
|
||||
setError(errorMessage);
|
||||
onError?.(errorMessage);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={handleFileUpload}
|
||||
disabled={isProcessing}
|
||||
className="hidden"
|
||||
id="audio-upload"
|
||||
/>
|
||||
<label htmlFor="audio-upload">
|
||||
<Button
|
||||
as="span"
|
||||
disabled={isProcessing}
|
||||
className="cursor-pointer"
|
||||
trackingId="audio-upload-button"
|
||||
trackingProperties={{
|
||||
category: EVENT_CATEGORIES.AUDIO,
|
||||
action: 'upload_click',
|
||||
label: 'audio_uploader'
|
||||
}}
|
||||
>
|
||||
{isProcessing ? 'Processando...' : 'Enviar Áudio'}
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,13 @@
|
||||
import React, { useState } from 'react';
|
||||
import { LogIn } from 'lucide-react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { LogIn, Eye, EyeOff, School, GraduationCap, User } from 'lucide-react';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useDataLayer } from '../../hooks/useDataLayer';
|
||||
import { useFormTracking } from '../../hooks/useFormTracking';
|
||||
import { Button } from '../ui/button';
|
||||
import { useErrorTracking } from '../../hooks/useErrorTracking';
|
||||
import { EVENT_CATEGORIES } from '../../constants/analytics';
|
||||
|
||||
interface LoginFormProps {
|
||||
userType: 'school' | 'teacher' | 'student';
|
||||
@ -9,99 +15,257 @@ interface LoginFormProps {
|
||||
onRegisterClick?: () => void;
|
||||
}
|
||||
|
||||
const userTypeIcons = {
|
||||
school: <School className="h-8 w-8 text-purple-600" />,
|
||||
teacher: <GraduationCap className="h-8 w-8 text-purple-600" />,
|
||||
student: <User className="h-8 w-8 text-purple-600" />
|
||||
};
|
||||
|
||||
const userTypeLabels = {
|
||||
school: 'Escola',
|
||||
teacher: 'Professor',
|
||||
student: 'Aluno'
|
||||
};
|
||||
|
||||
export function LoginForm({ userType, onLogin, onRegisterClick }: LoginFormProps) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { signIn } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { trackEvent } = useDataLayer();
|
||||
const formTracking = useFormTracking({
|
||||
formId: 'login-form',
|
||||
formName: `${userType}-login`,
|
||||
category: 'auth'
|
||||
});
|
||||
const errorTracking = useErrorTracking({
|
||||
category: 'auth',
|
||||
userEmail: email
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
formTracking.trackFormStarted();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { user } = await signIn(email, password);
|
||||
if (user) {
|
||||
if (userType === 'school') {
|
||||
navigate('/dashboard');
|
||||
} else if (onLogin) {
|
||||
await onLogin({ email, password });
|
||||
}
|
||||
console.log('Tentando login com:', { email, userType });
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email: email,
|
||||
password: password
|
||||
});
|
||||
|
||||
console.log('Resposta do Supabase:', { data, error });
|
||||
|
||||
if (error) {
|
||||
errorTracking.trackApiError(error, '/auth/sign-in', 'POST', { email, userType });
|
||||
formTracking.trackFormError('auth_error', error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data.user) {
|
||||
const err = new Error('Usuário não encontrado');
|
||||
errorTracking.trackError(err, {
|
||||
componentName: 'LoginForm',
|
||||
action: 'login_attempt',
|
||||
metadata: { userType }
|
||||
});
|
||||
formTracking.trackFormError('user_not_found', 'Usuário não encontrado');
|
||||
throw err;
|
||||
}
|
||||
|
||||
const userRole = data.user.user_metadata.role;
|
||||
console.log('Metadados do usuário:', data.user.user_metadata);
|
||||
console.log('Role esperado:', userType);
|
||||
console.log('Role atual:', userRole);
|
||||
|
||||
if (userRole !== userType) {
|
||||
const err = new Error(`Este não é um login de ${userTypeLabels[userType]}`);
|
||||
errorTracking.trackError(err, {
|
||||
componentName: 'LoginForm',
|
||||
action: 'role_validation',
|
||||
metadata: {
|
||||
expectedRole: userType,
|
||||
actualRole: userRole
|
||||
}
|
||||
});
|
||||
formTracking.trackFormError('invalid_role', err.message);
|
||||
throw err;
|
||||
}
|
||||
|
||||
formTracking.trackFormSubmitted(true, {
|
||||
user_type: userType,
|
||||
user_id: data.user.id
|
||||
});
|
||||
|
||||
switch (userType) {
|
||||
case 'school':
|
||||
navigate('/dashboard');
|
||||
break;
|
||||
case 'teacher':
|
||||
navigate('/professor');
|
||||
break;
|
||||
case 'student':
|
||||
navigate('/aluno');
|
||||
break;
|
||||
default:
|
||||
throw new Error('Tipo de usuário inválido');
|
||||
}
|
||||
|
||||
trackEvent('auth', 'login_success', 'form');
|
||||
} catch (err) {
|
||||
setError('Erro ao fazer login. Verifique suas credenciais.');
|
||||
console.error('Erro no login:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Email ou senha incorretos';
|
||||
setError(errorMessage);
|
||||
|
||||
formTracking.trackFormSubmitted(false, {
|
||||
error_type: err instanceof Error ? 'validation_error' : 'unknown_error',
|
||||
error_message: errorMessage
|
||||
});
|
||||
trackEvent('auth', 'login_error', errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFieldChange = (field: string, value: string) => {
|
||||
formTracking.trackFieldInteraction(field, 'change');
|
||||
if (field === 'email') setEmail(value);
|
||||
if (field === 'password') setPassword(value);
|
||||
};
|
||||
|
||||
const handleFieldFocus = (field: string) => {
|
||||
formTracking.trackFieldInteraction(field, 'focus');
|
||||
};
|
||||
|
||||
const handleFieldBlur = (field: string) => {
|
||||
formTracking.trackFieldInteraction(field, 'blur');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-center text-purple-600">
|
||||
Bem-vindo de volta!
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-gray-600">
|
||||
Continue sua jornada de histórias mágicas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Senha
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
/>
|
||||
<div className="min-h-screen bg-gradient-to-b from-purple-50 to-white py-12">
|
||||
<div className="max-w-md mx-auto px-4">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-purple-100 mb-4">
|
||||
{userTypeIcons[userType]}
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Bem-vindo de volta!
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Faça login como {userTypeLabels[userType]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent rounded-lg shadow-sm text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
|
||||
>
|
||||
<LogIn className="w-5 h-5" />
|
||||
Entrar
|
||||
</button>
|
||||
|
||||
{onRegisterClick && (
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegisterClick}
|
||||
className="text-purple-600 hover:text-purple-500"
|
||||
>
|
||||
Criar uma nova conta
|
||||
</button>
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => handleFieldChange('email', e.target.value)}
|
||||
onFocus={() => handleFieldFocus('email')}
|
||||
onBlur={() => handleFieldBlur('email')}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Senha
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => handleFieldChange('password', e.target.value)}
|
||||
onFocus={() => handleFieldFocus('password')}
|
||||
onBlur={() => handleFieldBlur('password')}
|
||||
className="block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
trackingId="login-submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
trackingProperties={{
|
||||
category: EVENT_CATEGORIES.AUTH,
|
||||
action: 'login_attempt',
|
||||
label: `${userType}_login`,
|
||||
value: 1,
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? (
|
||||
'Entrando...'
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="h-5 w-5 mr-2" />
|
||||
Entrar
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{onRegisterClick && (
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Ainda não tem uma conta?{' '}
|
||||
<Button
|
||||
trackingId="register-link"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={onRegisterClick}
|
||||
trackingProperties={{
|
||||
category: EVENT_CATEGORIES.AUTH,
|
||||
action: 'register_click',
|
||||
label: userType,
|
||||
}}
|
||||
className="text-purple-600 hover:text-purple-500 font-medium p-0"
|
||||
>
|
||||
Cadastre-se
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
src/components/auth/ProtectedRoute.tsx
Normal file
52
src/components/auth/ProtectedRoute.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import type { AuthContextType } from '../../hooks/useAuth';
|
||||
import type { UserRole } from '../../types/supabase';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
allowedRoles?: UserRole[];
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children, allowedRoles = [] }: ProtectedRouteProps) {
|
||||
const { user, loading, userRole } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
|
||||
if (loading) {
|
||||
return <div>Carregando...</div>;
|
||||
}
|
||||
|
||||
// Se não houver usuário, redireciona para login
|
||||
if (!user) {
|
||||
return <Navigate to="/login/school" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
// Pegar o role diretamente dos metadados do usuário
|
||||
const currentRole = user.user_metadata?.role;
|
||||
|
||||
// Se não houver roles requeridas, permite acesso
|
||||
if (allowedRoles.length === 0) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Se o usuário não tiver o role necessário
|
||||
if (!allowedRoles.includes(currentRole)) {
|
||||
console.log('ProtectedRoute - Acesso negado');
|
||||
|
||||
// Redireciona para a página apropriada
|
||||
switch (currentRole) {
|
||||
case 'school':
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
case 'teacher':
|
||||
return <Navigate to="/professor" replace />;
|
||||
case 'student':
|
||||
return <Navigate to="/aluno" replace />;
|
||||
default:
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
160
src/components/dashboard/DashboardMetrics.tsx
Normal file
160
src/components/dashboard/DashboardMetrics.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import React from 'react';
|
||||
import { BookOpen, Clock, TrendingUp, Award, Mic, Target, Brain, Gauge, Pause, XCircle, HelpCircle } from 'lucide-react';
|
||||
import { MetricCard } from './MetricCard';
|
||||
|
||||
interface DashboardMetricsData {
|
||||
totalStories: number;
|
||||
averageReadingFluency: number;
|
||||
totalReadingTime: number;
|
||||
currentLevel: number;
|
||||
averagePronunciation: number;
|
||||
averageAccuracy: number;
|
||||
averageComprehension: number;
|
||||
averageWordsPerMinute: number;
|
||||
averagePauses: number;
|
||||
averageErrors: number;
|
||||
}
|
||||
|
||||
interface DashboardMetricsProps {
|
||||
data: DashboardMetricsData;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MAIN_METRICS = [
|
||||
{
|
||||
key: 'totalStories',
|
||||
title: 'Total de Histórias',
|
||||
getValue: (data: DashboardMetricsData) => data.totalStories,
|
||||
icon: BookOpen,
|
||||
iconColor: 'text-purple-600',
|
||||
iconBgColor: 'bg-purple-100'
|
||||
},
|
||||
{
|
||||
key: 'averageReadingFluency',
|
||||
title: 'Fluência Média',
|
||||
getValue: (data: DashboardMetricsData) => `${data.averageReadingFluency}%`,
|
||||
icon: TrendingUp,
|
||||
iconColor: 'text-green-600',
|
||||
iconBgColor: 'bg-green-100'
|
||||
},
|
||||
{
|
||||
key: 'totalReadingTime',
|
||||
title: 'Tempo de Leitura',
|
||||
getValue: (data: DashboardMetricsData) => `${data.totalReadingTime}min`,
|
||||
icon: Clock,
|
||||
iconColor: 'text-blue-600',
|
||||
iconBgColor: 'bg-blue-100'
|
||||
},
|
||||
{
|
||||
key: 'currentLevel',
|
||||
title: 'Nível Atual',
|
||||
getValue: (data: DashboardMetricsData) => data.currentLevel,
|
||||
icon: Award,
|
||||
iconColor: 'text-yellow-600',
|
||||
iconBgColor: 'bg-yellow-100'
|
||||
}
|
||||
];
|
||||
|
||||
const DETAILED_METRICS = [
|
||||
{
|
||||
key: 'averagePronunciation',
|
||||
title: 'Pronúncia Média',
|
||||
getValue: (data: DashboardMetricsData) => `${data.averagePronunciation}%`,
|
||||
icon: Mic,
|
||||
iconColor: 'text-indigo-600',
|
||||
iconBgColor: 'bg-indigo-100',
|
||||
tooltip: 'Avalia a qualidade da sua pronúncia durante a leitura, considerando a clareza e correção dos sons das palavras'
|
||||
},
|
||||
{
|
||||
key: 'averageAccuracy',
|
||||
title: 'Precisão na Leitura',
|
||||
getValue: (data: DashboardMetricsData) => `${data.averageAccuracy}%`,
|
||||
icon: Target,
|
||||
iconColor: 'text-pink-600',
|
||||
iconBgColor: 'bg-pink-100',
|
||||
tooltip: 'Indica o quão preciso você é ao ler as palavras, sem trocas ou omissões de letras e sílabas'
|
||||
},
|
||||
{
|
||||
key: 'averageComprehension',
|
||||
title: 'Compreensão do Texto',
|
||||
getValue: (data: DashboardMetricsData) => `${data.averageComprehension}%`,
|
||||
icon: Brain,
|
||||
iconColor: 'text-orange-600',
|
||||
iconBgColor: 'bg-orange-100',
|
||||
tooltip: 'Avalia seu nível de entendimento do texto durante a leitura, baseado no ritmo e entonação adequados'
|
||||
},
|
||||
{
|
||||
key: 'averageWordsPerMinute',
|
||||
title: 'Velocidade de Leitura',
|
||||
getValue: (data: DashboardMetricsData) => `${data.averageWordsPerMinute} WPM`,
|
||||
icon: Gauge,
|
||||
iconColor: 'text-cyan-600',
|
||||
iconBgColor: 'bg-cyan-100',
|
||||
tooltip: 'Média de palavras lidas por minuto (WPM), indicando a velocidade e fluidez da sua leitura'
|
||||
},
|
||||
{
|
||||
key: 'averagePauses',
|
||||
title: 'Pausas na Leitura',
|
||||
getValue: (data: DashboardMetricsData) => data.averagePauses,
|
||||
icon: Pause,
|
||||
iconColor: 'text-amber-600',
|
||||
iconBgColor: 'bg-amber-100',
|
||||
tooltip: 'Média de pausas não planejadas durante a leitura, indicando momentos de hesitação'
|
||||
},
|
||||
{
|
||||
key: 'averageErrors',
|
||||
title: 'Erros de Leitura',
|
||||
getValue: (data: DashboardMetricsData) => data.averageErrors,
|
||||
icon: XCircle,
|
||||
iconColor: 'text-red-600',
|
||||
iconBgColor: 'bg-red-100',
|
||||
tooltip: 'Média de erros cometidos durante a leitura, como trocas, omissões ou adições de palavras'
|
||||
}
|
||||
];
|
||||
|
||||
export function DashboardMetrics({ data, className = '' }: DashboardMetricsProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Métricas Principais */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
{MAIN_METRICS.map(metric => (
|
||||
<MetricCard
|
||||
key={metric.key}
|
||||
title={metric.title}
|
||||
value={metric.getValue(data)}
|
||||
icon={metric.icon}
|
||||
iconColor={metric.iconColor}
|
||||
iconBgColor={metric.iconBgColor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Métricas Detalhadas */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Métricas Detalhadas de Leitura</h2>
|
||||
<div
|
||||
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
|
||||
title="Estas métricas são calculadas com base em todas as suas gravações de leitura, fornecendo uma visão detalhada do seu progresso"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{DETAILED_METRICS.map(metric => (
|
||||
<MetricCard
|
||||
key={metric.key}
|
||||
title={metric.title}
|
||||
value={metric.getValue(data)}
|
||||
icon={metric.icon}
|
||||
iconColor={metric.iconColor}
|
||||
iconBgColor={metric.iconBgColor}
|
||||
tooltip={metric.tooltip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/components/dashboard/MetricCard.tsx
Normal file
45
src/components/dashboard/MetricCard.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: LucideIcon;
|
||||
iconColor: string;
|
||||
iconBgColor: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export function MetricCard({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
iconColor,
|
||||
iconBgColor,
|
||||
tooltip
|
||||
}: MetricCardProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-3 ${iconBgColor} rounded-lg`}>
|
||||
<Icon className={`h-6 w-6 ${iconColor}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-sm text-gray-500">{title}</p>
|
||||
{tooltip && (
|
||||
<div
|
||||
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
|
||||
title={tooltip}
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
239
src/components/dashboard/MetricsChart.tsx
Normal file
239
src/components/dashboard/MetricsChart.tsx
Normal file
@ -0,0 +1,239 @@
|
||||
import React from 'react';
|
||||
import { Calendar, HelpCircle } from 'lucide-react';
|
||||
import { Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Bar, ComposedChart } from 'recharts';
|
||||
import type { WeeklyReadingMetrics } from '@/types/metrics';
|
||||
|
||||
interface MetricConfig {
|
||||
key: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
type TimeFilter = '3m' | '6m' | '12m' | 'all';
|
||||
|
||||
interface TimeFilterOption {
|
||||
value: TimeFilter;
|
||||
label: string;
|
||||
months: number | null;
|
||||
}
|
||||
|
||||
const METRICS_CONFIG: MetricConfig[] = [
|
||||
{ key: 'fluency', name: 'Fluência', color: '#6366f1' },
|
||||
{ key: 'pronunciation', name: 'Pronúncia', color: '#f43f5e' },
|
||||
{ key: 'accuracy', name: 'Precisão', color: '#0ea5e9' },
|
||||
{ key: 'comprehension', name: 'Compreensão', color: '#10b981' },
|
||||
{ key: 'wordsPerMinute', name: 'Palavras/Min', color: '#8b5cf6' }
|
||||
];
|
||||
|
||||
const TIME_FILTERS: TimeFilterOption[] = [
|
||||
{ value: '3m', label: '3 meses', months: 3 },
|
||||
{ value: '6m', label: '6 meses', months: 6 },
|
||||
{ value: '12m', label: '12 meses', months: 12 },
|
||||
{ value: 'all', label: 'Todo período', months: null },
|
||||
];
|
||||
|
||||
interface MetricsChartProps {
|
||||
data: WeeklyReadingMetrics[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MetricsChart({ data = [], className = '' }: MetricsChartProps) {
|
||||
const [visibleMetrics, setVisibleMetrics] = React.useState<Set<string>>(
|
||||
new Set(METRICS_CONFIG.map(metric => metric.key))
|
||||
);
|
||||
const [timeFilter, setTimeFilter] = React.useState<TimeFilter>('12m');
|
||||
|
||||
const toggleMetric = (metricKey: string) => {
|
||||
setVisibleMetrics(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(metricKey)) {
|
||||
newSet.delete(metricKey);
|
||||
} else {
|
||||
newSet.add(metricKey);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const filterDataByTime = (data: WeeklyReadingMetrics[]): WeeklyReadingMetrics[] => {
|
||||
if (!data || !Array.isArray(data)) return [];
|
||||
|
||||
if (timeFilter === 'all') return data;
|
||||
|
||||
const months = TIME_FILTERS.find(f => f.value === timeFilter)?.months || 12;
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setMonth(cutoffDate.getMonth() - months);
|
||||
|
||||
return data.filter(item => {
|
||||
if (!item?.week) return false;
|
||||
const [year, week] = item.week.split('-W').map(Number);
|
||||
if (!year || !week) return false;
|
||||
const itemDate = new Date(year, 0, 1 + (week - 1) * 7);
|
||||
return itemDate >= cutoffDate;
|
||||
});
|
||||
};
|
||||
|
||||
const filteredData = React.useMemo(() => filterDataByTime(data), [data, timeFilter]);
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-sm border border-gray-200 p-8 ${className}`}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Evolução da Leitura por Semana</h2>
|
||||
<p className="text-sm text-gray-500">Acompanhe seu progresso na leitura ao longo do tempo</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Filtro de Período */}
|
||||
<div className="flex items-center gap-2 bg-gray-50 p-1 rounded-lg">
|
||||
<Calendar className="h-4 w-4 text-gray-500" />
|
||||
{TIME_FILTERS.map(filter => (
|
||||
<button
|
||||
key={filter.value}
|
||||
onClick={() => setTimeFilter(filter.value)}
|
||||
className={`
|
||||
px-3 py-1 rounded-md text-sm font-medium transition-all duration-200
|
||||
${timeFilter === filter.value
|
||||
? 'bg-white text-purple-600 shadow-sm'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
|
||||
title="Gráfico mostrando a evolução das suas métricas de leitura ao longo das semanas"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pill Buttons */}
|
||||
<div className="flex flex-wrap gap-2 p-1">
|
||||
{METRICS_CONFIG.map(metric => (
|
||||
<button
|
||||
key={metric.key}
|
||||
onClick={() => toggleMetric(metric.key)}
|
||||
className={`
|
||||
px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 ease-in-out
|
||||
${visibleMetrics.has(metric.key)
|
||||
? 'shadow-md transform -translate-y-px'
|
||||
: 'bg-gray-50 text-gray-500 hover:bg-gray-100'
|
||||
}
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-opacity-50
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: visibleMetrics.has(metric.key) ? metric.color : undefined,
|
||||
color: visibleMetrics.has(metric.key) ? 'white' : undefined,
|
||||
boxShadow: visibleMetrics.has(metric.key) ? '0 2px 4px rgba(0,0,0,0.1)' : undefined
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${visibleMetrics.has(metric.key) ? 'bg-white' : 'bg-gray-400'}`}></span>
|
||||
{metric.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-[400px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={filteredData} margin={{ top: 20, right: 30, left: 20, bottom: 20 }}>
|
||||
<defs>
|
||||
<linearGradient id="barGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#6366f1" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="#6366f1" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
stroke="#f0f0f0"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="week"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 12, fill: '#6b7280' }}
|
||||
dy={10}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 12, fill: '#6b7280' }}
|
||||
dx={-10}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 12, fill: '#6b7280' }}
|
||||
dx={10}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => {
|
||||
const metricNames: { [key: string]: string } = {
|
||||
fluency: 'Fluência',
|
||||
pronunciation: 'Pronúncia',
|
||||
accuracy: 'Precisão',
|
||||
comprehension: 'Compreensão',
|
||||
wordsPerMinute: 'Palavras/Min',
|
||||
minutesRead: 'Minutos Lendo'
|
||||
};
|
||||
return [value, metricNames[name] || name];
|
||||
}}
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.98)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
padding: '12px'
|
||||
}}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
align="right"
|
||||
iconType="circle"
|
||||
wrapperStyle={{
|
||||
paddingBottom: '20px'
|
||||
}}
|
||||
/>
|
||||
{METRICS_CONFIG.map(metric => (
|
||||
visibleMetrics.has(metric.key) && (
|
||||
<Line
|
||||
key={metric.key}
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey={metric.key}
|
||||
stroke={metric.color}
|
||||
name={metric.name}
|
||||
strokeWidth={2.5}
|
||||
dot={{ strokeWidth: 2, r: 4, fill: 'white' }}
|
||||
activeDot={{ r: 6, strokeWidth: 2 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
<Bar
|
||||
yAxisId="right"
|
||||
dataKey="minutesRead"
|
||||
name="Minutos Lendo"
|
||||
fill="url(#barGradient)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
maxBarSize={50}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
281
src/components/dashboard/WritingMetricsChart.tsx
Normal file
281
src/components/dashboard/WritingMetricsChart.tsx
Normal file
@ -0,0 +1,281 @@
|
||||
import React from 'react';
|
||||
import { Calendar, HelpCircle } from 'lucide-react';
|
||||
import { Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Bar, ComposedChart } from 'recharts';
|
||||
import type { WeeklyWritingMetrics } from '@/types/metrics';
|
||||
|
||||
interface MetricConfig {
|
||||
key: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
type TimeFilter = '3m' | '6m' | '12m' | 'all';
|
||||
|
||||
interface TimeFilterOption {
|
||||
value: TimeFilter;
|
||||
label: string;
|
||||
months: number | null;
|
||||
}
|
||||
|
||||
interface MetricNames {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
const WRITING_METRICS: MetricConfig[] = [
|
||||
{ key: 'score', name: 'Nota Geral', color: '#6366f1' },
|
||||
{ key: 'adequacy', name: 'Adequação', color: '#f43f5e' },
|
||||
{ key: 'coherence', name: 'Coerência', color: '#0ea5e9' },
|
||||
{ key: 'cohesion', name: 'Coesão', color: '#10b981' },
|
||||
{ key: 'vocabulary', name: 'Vocabulário', color: '#8b5cf6' },
|
||||
{ key: 'grammar', name: 'Gramática', color: '#f59e0b' }
|
||||
];
|
||||
|
||||
const ENEM_METRICS: MetricConfig[] = [
|
||||
{ key: 'language_domain', name: 'Domínio da Língua', color: '#f43f5e' },
|
||||
{ key: 'proposal_comprehension', name: 'Compreensão da Proposta', color: '#0ea5e9' },
|
||||
{ key: 'argument_selection', name: 'Seleção de Argumentos', color: '#10b981' },
|
||||
{ key: 'linguistic_mechanisms', name: 'Mecanismos Linguísticos', color: '#8b5cf6' },
|
||||
{ key: 'intervention_proposal', name: 'Proposta de Intervenção', color: '#f59e0b' }
|
||||
];
|
||||
|
||||
const TIME_FILTERS: TimeFilterOption[] = [
|
||||
{ value: '3m', label: '3 meses', months: 3 },
|
||||
{ value: '6m', label: '6 meses', months: 6 },
|
||||
{ value: '12m', label: '12 meses', months: 12 },
|
||||
{ value: 'all', label: 'Todo período', months: null },
|
||||
];
|
||||
|
||||
interface WritingMetricsChartProps {
|
||||
data: WeeklyWritingMetrics[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WritingMetricsChart({ data = [], className = '' }: WritingMetricsChartProps) {
|
||||
const [visibleWritingMetrics, setVisibleWritingMetrics] = React.useState<Set<string>>(
|
||||
new Set(WRITING_METRICS.map(metric => metric.key))
|
||||
);
|
||||
const [visibleEnemMetrics, setVisibleEnemMetrics] = React.useState<Set<string>>(
|
||||
new Set(ENEM_METRICS.map(metric => metric.key))
|
||||
);
|
||||
const [timeFilter, setTimeFilter] = React.useState<TimeFilter>('12m');
|
||||
|
||||
const toggleWritingMetric = (metricKey: string) => {
|
||||
setVisibleWritingMetrics(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(metricKey)) {
|
||||
newSet.delete(metricKey);
|
||||
} else {
|
||||
newSet.add(metricKey);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleEnemMetric = (metricKey: string) => {
|
||||
setVisibleEnemMetrics(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(metricKey)) {
|
||||
newSet.delete(metricKey);
|
||||
} else {
|
||||
newSet.add(metricKey);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const filterDataByTime = (data: WeeklyWritingMetrics[]): WeeklyWritingMetrics[] => {
|
||||
if (!data || !Array.isArray(data)) return [];
|
||||
|
||||
if (timeFilter === 'all') return data;
|
||||
|
||||
const months = TIME_FILTERS.find(f => f.value === timeFilter)?.months || 12;
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setMonth(cutoffDate.getMonth() - months);
|
||||
|
||||
return data.filter(item => {
|
||||
if (!item?.week) return false;
|
||||
const [year, week] = item.week.split('-W').map(Number);
|
||||
if (!year || !week) return false;
|
||||
const itemDate = new Date(year, 0, 1 + (week - 1) * 7);
|
||||
return itemDate >= cutoffDate;
|
||||
});
|
||||
};
|
||||
|
||||
const filteredData = React.useMemo(() => filterDataByTime(data), [data, timeFilter]);
|
||||
|
||||
const renderChart = (title: string, description: string, metrics: MetricConfig[], visibleMetrics: Set<string>, toggleMetric: (key: string) => void) => (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 mb-8">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{title}</h2>
|
||||
<p className="text-sm text-gray-500">{description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Filtro de Período */}
|
||||
<div className="flex items-center gap-2 bg-gray-50 p-1 rounded-lg">
|
||||
<Calendar className="h-4 w-4 text-gray-500" />
|
||||
{TIME_FILTERS.map(filter => (
|
||||
<button
|
||||
key={filter.value}
|
||||
onClick={() => setTimeFilter(filter.value)}
|
||||
className={`
|
||||
px-3 py-1 rounded-md text-sm font-medium transition-all duration-200
|
||||
${timeFilter === filter.value
|
||||
? 'bg-white text-purple-600 shadow-sm'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
|
||||
title={`Gráfico mostrando a evolução das suas ${title.toLowerCase()} ao longo das semanas`}
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pill Buttons */}
|
||||
<div className="flex flex-wrap gap-2 p-1">
|
||||
{metrics.map(metric => (
|
||||
<button
|
||||
key={metric.key}
|
||||
onClick={() => toggleMetric(metric.key)}
|
||||
className={`
|
||||
px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 ease-in-out
|
||||
${visibleMetrics.has(metric.key)
|
||||
? 'shadow-md transform -translate-y-px'
|
||||
: 'bg-gray-50 text-gray-500 hover:bg-gray-100'
|
||||
}
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-opacity-50
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: visibleMetrics.has(metric.key) ? metric.color : undefined,
|
||||
color: visibleMetrics.has(metric.key) ? 'white' : undefined,
|
||||
boxShadow: visibleMetrics.has(metric.key) ? '0 2px 4px rgba(0,0,0,0.1)' : undefined
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${visibleMetrics.has(metric.key) ? 'bg-white' : 'bg-gray-400'}`}></span>
|
||||
{metric.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-[400px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={filteredData} margin={{ top: 20, right: 30, left: 20, bottom: 20 }}>
|
||||
<defs>
|
||||
<linearGradient id="barGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#6366f1" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="#6366f1" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
stroke="#f0f0f0"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="week"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 12, fill: '#6b7280' }}
|
||||
dy={10}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 12, fill: '#6b7280' }}
|
||||
dx={-10}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 12, fill: '#6b7280' }}
|
||||
dx={10}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => {
|
||||
const metricNames: MetricNames = metrics.reduce((acc, m) => ({ ...acc, [m.key]: m.name }), {
|
||||
minutesWriting: 'Minutos Escrevendo'
|
||||
});
|
||||
return [value, metricNames[name] || name];
|
||||
}}
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.98)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
padding: '12px'
|
||||
}}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
align="right"
|
||||
iconType="circle"
|
||||
wrapperStyle={{
|
||||
paddingBottom: '20px'
|
||||
}}
|
||||
/>
|
||||
{metrics.map(metric => (
|
||||
visibleMetrics.has(metric.key) && (
|
||||
<Line
|
||||
key={metric.key}
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey={metric.key}
|
||||
stroke={metric.color}
|
||||
name={metric.name}
|
||||
strokeWidth={2.5}
|
||||
dot={{ strokeWidth: 2, r: 4, fill: 'white' }}
|
||||
activeDot={{ r: 6, strokeWidth: 2 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
<Bar
|
||||
yAxisId="right"
|
||||
dataKey="minutesWriting"
|
||||
name="Minutos Escrevendo"
|
||||
fill="url(#barGradient)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
maxBarSize={50}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{renderChart(
|
||||
"Evolução da Escrita por Semana",
|
||||
"Acompanhe seu progresso na escrita ao longo do tempo",
|
||||
WRITING_METRICS,
|
||||
visibleWritingMetrics,
|
||||
toggleWritingMetric
|
||||
)}
|
||||
{renderChart(
|
||||
"Evolução das Competências do ENEM",
|
||||
"Acompanhe seu progresso nas competências do ENEM ao longo do tempo",
|
||||
ENEM_METRICS,
|
||||
visibleEnemMetrics,
|
||||
toggleEnemMetric
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
src/components/dashboard/WritingMetricsSection.tsx
Normal file
143
src/components/dashboard/WritingMetricsSection.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
import { BookOpen, Clock, TrendingUp, Award, Target, Brain, Gauge, Sparkles, Puzzle, Pencil, HelpCircle } from 'lucide-react';
|
||||
import { MetricCard } from './MetricCard';
|
||||
import type { WritingMetrics } from '@/types/metrics';
|
||||
|
||||
interface WritingMetricsSectionProps {
|
||||
data: WritingMetrics;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MAIN_METRICS = [
|
||||
{
|
||||
key: 'totalEssays',
|
||||
title: 'Total de Redações',
|
||||
getValue: (data: WritingMetrics) => data.totalEssays,
|
||||
icon: BookOpen,
|
||||
iconColor: 'text-purple-600',
|
||||
iconBgColor: 'bg-purple-100'
|
||||
},
|
||||
{
|
||||
key: 'averageScore',
|
||||
title: 'Nota Média',
|
||||
getValue: (data: WritingMetrics) => `${data.averageScore}%`,
|
||||
icon: TrendingUp,
|
||||
iconColor: 'text-green-600',
|
||||
iconBgColor: 'bg-green-100'
|
||||
},
|
||||
{
|
||||
key: 'totalEssaysTime',
|
||||
title: 'Tempo de Escrita',
|
||||
getValue: (data: WritingMetrics) => `${data.totalEssaysTime}min`,
|
||||
icon: Clock,
|
||||
iconColor: 'text-blue-600',
|
||||
iconBgColor: 'bg-blue-100'
|
||||
},
|
||||
{
|
||||
key: 'currentWritingLevel',
|
||||
title: 'Nível de Escrita',
|
||||
getValue: (data: WritingMetrics) => data.currentWritingLevel,
|
||||
icon: Award,
|
||||
iconColor: 'text-yellow-600',
|
||||
iconBgColor: 'bg-yellow-100'
|
||||
}
|
||||
];
|
||||
|
||||
const DETAILED_METRICS = [
|
||||
{
|
||||
key: 'averageAdequacy',
|
||||
title: 'Adequação ao Tema',
|
||||
getValue: (data: WritingMetrics) => `${data.averageAdequacy}%`,
|
||||
icon: Target,
|
||||
iconColor: 'text-indigo-600',
|
||||
iconBgColor: 'bg-indigo-100',
|
||||
tooltip: 'Avalia o quanto sua redação está alinhada com o tema e gênero propostos'
|
||||
},
|
||||
{
|
||||
key: 'averageCoherence',
|
||||
title: 'Coerência',
|
||||
getValue: (data: WritingMetrics) => `${data.averageCoherence}%`,
|
||||
icon: Brain,
|
||||
iconColor: 'text-pink-600',
|
||||
iconBgColor: 'bg-pink-100',
|
||||
tooltip: 'Indica a clareza e lógica no desenvolvimento das ideias do texto'
|
||||
},
|
||||
{
|
||||
key: 'averageCohesion',
|
||||
title: 'Coesão',
|
||||
getValue: (data: WritingMetrics) => `${data.averageCohesion}%`,
|
||||
icon: Puzzle,
|
||||
iconColor: 'text-orange-600',
|
||||
iconBgColor: 'bg-orange-100',
|
||||
tooltip: 'Avalia o uso adequado de conectivos e elementos de ligação entre as partes do texto'
|
||||
},
|
||||
{
|
||||
key: 'averageVocabulary',
|
||||
title: 'Vocabulário',
|
||||
getValue: (data: WritingMetrics) => `${data.averageVocabulary}%`,
|
||||
icon: Sparkles,
|
||||
iconColor: 'text-cyan-600',
|
||||
iconBgColor: 'bg-cyan-100',
|
||||
tooltip: 'Analisa a riqueza e adequação do vocabulário utilizado'
|
||||
},
|
||||
{
|
||||
key: 'averageGrammar',
|
||||
title: 'Gramática',
|
||||
getValue: (data: WritingMetrics) => `${data.averageGrammar}%`,
|
||||
icon: Pencil,
|
||||
iconColor: 'text-amber-600',
|
||||
iconBgColor: 'bg-amber-100',
|
||||
tooltip: 'Avalia o uso correto das regras gramaticais e ortográficas'
|
||||
}
|
||||
];
|
||||
|
||||
export function WritingMetricsSection({ data, className = '' }: WritingMetricsSectionProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Métricas de Escrita</h2>
|
||||
|
||||
{/* Métricas Principais */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
{MAIN_METRICS.map(metric => (
|
||||
<MetricCard
|
||||
key={metric.key}
|
||||
title={metric.title}
|
||||
value={metric.getValue(data)}
|
||||
icon={metric.icon}
|
||||
iconColor={metric.iconColor}
|
||||
iconBgColor={metric.iconBgColor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Métricas Detalhadas */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Métricas Detalhadas de Escrita</h3>
|
||||
<div
|
||||
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
|
||||
title="Estas métricas são calculadas com base em todas as suas redações, fornecendo uma visão detalhada do seu progresso na escrita"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{DETAILED_METRICS.map(metric => (
|
||||
<MetricCard
|
||||
key={metric.key}
|
||||
title={metric.title}
|
||||
value={metric.getValue(data)}
|
||||
icon={metric.icon}
|
||||
iconColor={metric.iconColor}
|
||||
iconBgColor={metric.iconBgColor}
|
||||
tooltip={metric.tooltip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
219
src/components/exercises/PronunciationPractice.tsx
Normal file
219
src/components/exercises/PronunciationPractice.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { Mic, Square, Play, Loader2, ArrowRight } from 'lucide-react';
|
||||
import { useStudentTracking } from '../../hooks/useStudentTracking';
|
||||
|
||||
interface PronunciationPracticeProps {
|
||||
words: string[];
|
||||
storyId: string;
|
||||
studentId: string;
|
||||
onComplete?: (score: number) => void;
|
||||
}
|
||||
|
||||
export function PronunciationPractice({ words, storyId, studentId, onComplete }: PronunciationPracticeProps) {
|
||||
const [currentWordIndex, setCurrentWordIndex] = useState(0);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const [score, setScore] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const { trackExerciseCompleted } = useStudentTracking();
|
||||
const startTime = useRef(Date.now());
|
||||
const wordsAttempted = useRef(0);
|
||||
const wordsCorrect = useRef(0);
|
||||
|
||||
// Verificar se há palavras para praticar
|
||||
if (!words.length) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<p className="text-gray-600">
|
||||
Não há palavras para praticar neste momento.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mediaRecorderRef.current = new MediaRecorder(stream);
|
||||
chunksRef.current = [];
|
||||
|
||||
mediaRecorderRef.current.ondataavailable = (e) => {
|
||||
chunksRef.current.push(e.data);
|
||||
};
|
||||
|
||||
mediaRecorderRef.current.onstop = () => {
|
||||
const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||
setAudioBlob(audioBlob);
|
||||
};
|
||||
|
||||
mediaRecorderRef.current.start();
|
||||
setIsRecording(true);
|
||||
} catch (err) {
|
||||
console.error('Erro ao iniciar gravação:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
|
||||
const playAudio = () => {
|
||||
if (audioBlob) {
|
||||
const url = URL.createObjectURL(audioBlob);
|
||||
const audio = new Audio(url);
|
||||
audio.play();
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextWord = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Simular análise de pronúncia (você pode substituir por uma análise real)
|
||||
const wordScore = Math.floor(Math.random() * 30) + 70; // Score entre 70-100
|
||||
const newScore = score + wordScore;
|
||||
setScore(newScore);
|
||||
|
||||
wordsAttempted.current += 1;
|
||||
if (wordScore >= 80) {
|
||||
wordsCorrect.current += 1;
|
||||
}
|
||||
|
||||
if (currentWordIndex < words.length - 1) {
|
||||
setCurrentWordIndex(prev => prev + 1);
|
||||
setAudioBlob(null);
|
||||
} else {
|
||||
setCompleted(true);
|
||||
const timeSpent = Date.now() - startTime.current;
|
||||
|
||||
// Track exercise completion
|
||||
trackExerciseCompleted({
|
||||
exercise_id: `${storyId}_pronunciation`,
|
||||
story_id: storyId,
|
||||
student_id: studentId,
|
||||
exercise_type: 'pronunciation',
|
||||
score: Math.floor(newScore / words.length),
|
||||
time_spent: timeSpent,
|
||||
words_attempted: wordsAttempted.current,
|
||||
words_correct: wordsCorrect.current,
|
||||
pronunciation_score: Math.floor(newScore / words.length),
|
||||
fluency_score: 85, // Este valor poderia vir de uma análise real de fluência
|
||||
difficulty_level: words.length <= 5 ? 'easy' : words.length <= 10 ? 'medium' : 'hard'
|
||||
});
|
||||
|
||||
if (onComplete) {
|
||||
onComplete(Math.floor(newScore / words.length));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao processar áudio:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
{/* Cabeçalho */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
Treino de Pronúncia
|
||||
</h2>
|
||||
<div className="mt-2 flex justify-between items-center text-sm text-gray-500">
|
||||
<span>Palavra {currentWordIndex + 1} de {words.length}</span>
|
||||
<span>{score} pontos</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-600 transition-all"
|
||||
style={{ width: `${((currentWordIndex + 1) / words.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{completed ? (
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
Parabéns! Você completou o exercício!
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Pontuação final: {score} pontos
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Palavra atual */}
|
||||
<div className="mb-8 text-center">
|
||||
<h3 className="text-4xl font-bold text-gray-900">
|
||||
{words[currentWordIndex]}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Controles de gravação */}
|
||||
<div className="flex justify-center gap-4 mb-8">
|
||||
{!isRecording && !audioBlob && (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-red-600 text-white rounded-lg
|
||||
hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<Mic className="w-5 h-5" />
|
||||
Gravar
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<button
|
||||
onClick={stopRecording}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gray-600 text-white rounded-lg
|
||||
hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<Square className="w-5 h-5" />
|
||||
Parar
|
||||
</button>
|
||||
)}
|
||||
|
||||
{audioBlob && (
|
||||
<button
|
||||
onClick={playAudio}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-lg
|
||||
hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<Play className="w-5 h-5" />
|
||||
Ouvir
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Botão de próxima palavra */}
|
||||
{audioBlob && (
|
||||
<button
|
||||
onClick={handleNextWord}
|
||||
disabled={isSubmitting}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 bg-purple-600 text-white
|
||||
rounded-lg font-medium hover:bg-purple-700 disabled:bg-gray-300
|
||||
disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{currentWordIndex < words.length - 1 ? 'Próxima Palavra' : 'Finalizar'}
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
src/components/exercises/SentenceCompletion.tsx
Normal file
203
src/components/exercises/SentenceCompletion.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useStudentTracking } from '../../hooks/useStudentTracking';
|
||||
|
||||
interface SentenceCompletionProps {
|
||||
story: {
|
||||
id: string;
|
||||
content: {
|
||||
pages: Array<{
|
||||
text: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
studentId: string;
|
||||
onComplete?: (score: number) => void;
|
||||
}
|
||||
|
||||
export function SentenceCompletion({ story, studentId, onComplete }: SentenceCompletionProps) {
|
||||
const [currentSentence, setCurrentSentence] = useState(0);
|
||||
const [userAnswer, setUserAnswer] = useState('');
|
||||
const [score, setScore] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [exerciseSentences, setExerciseSentences] = useState<Array<{
|
||||
sentence: string;
|
||||
answer: string;
|
||||
}>>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { trackExerciseCompleted } = useStudentTracking();
|
||||
const startTime = useRef(Date.now());
|
||||
const correctAnswers = useRef(0);
|
||||
|
||||
// Carregar palavras e preparar sentenças
|
||||
React.useEffect(() => {
|
||||
const loadExerciseWords = async () => {
|
||||
try {
|
||||
const { data: words, error } = await supabase
|
||||
.from('story_exercise_words')
|
||||
.select('*')
|
||||
.eq('story_id', story.id)
|
||||
.eq('exercise_type', 'completion');
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Extrair todas as sentenças do texto
|
||||
const allSentences = story.content.pages
|
||||
.map(page => page.text.split(/[.!?]+/))
|
||||
.flat()
|
||||
.filter(Boolean)
|
||||
.map(sentence => sentence.trim());
|
||||
|
||||
// Preparar exercícios com as palavras do banco
|
||||
const exercises = allSentences
|
||||
.filter(sentence =>
|
||||
words?.some(word =>
|
||||
sentence.toLowerCase().includes(word.word.toLowerCase())
|
||||
)
|
||||
)
|
||||
.map(sentence => {
|
||||
const word = words?.find(w =>
|
||||
sentence.toLowerCase().includes(w.word.toLowerCase())
|
||||
);
|
||||
return {
|
||||
sentence: sentence.replace(
|
||||
new RegExp(word?.word || '', 'i'),
|
||||
'_____'
|
||||
),
|
||||
answer: word?.word || ''
|
||||
};
|
||||
})
|
||||
.filter(exercise => exercise.answer); // Remover exercícios sem resposta
|
||||
|
||||
setExerciseSentences(exercises);
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar palavras:', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadExerciseWords();
|
||||
}, [story.id, story.content.pages]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3 mb-6" />
|
||||
<div className="h-64 bg-gray-200 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!exerciseSentences.length) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<p className="text-gray-600">
|
||||
Não foi possível gerar exercícios para este texto.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentExercise = exerciseSentences[currentSentence];
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const isCorrect = userAnswer.toLowerCase() === currentExercise.answer.toLowerCase();
|
||||
|
||||
if (isCorrect) {
|
||||
setScore(prev => prev + 10);
|
||||
correctAnswers.current += 1;
|
||||
}
|
||||
|
||||
// Avançar para próxima sentença ou finalizar
|
||||
if (currentSentence < exerciseSentences.length - 1) {
|
||||
setCurrentSentence(prev => prev + 1);
|
||||
setUserAnswer('');
|
||||
} else {
|
||||
// Track exercise completion
|
||||
const timeSpent = Date.now() - startTime.current;
|
||||
trackExerciseCompleted({
|
||||
exercise_id: `${story.id}_completion`,
|
||||
story_id: story.id,
|
||||
student_id: studentId,
|
||||
exercise_type: 'completion',
|
||||
score: Math.floor((correctAnswers.current / exerciseSentences.length) * 100),
|
||||
time_spent: timeSpent,
|
||||
answers_correct: correctAnswers.current,
|
||||
answers_total: exerciseSentences.length,
|
||||
difficulty_level: exerciseSentences.length <= 5 ? 'easy' : exerciseSentences.length <= 10 ? 'medium' : 'hard'
|
||||
});
|
||||
|
||||
if (onComplete) {
|
||||
onComplete(Math.floor((correctAnswers.current / exerciseSentences.length) * 100));
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao verificar resposta:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
Complete a Frase
|
||||
</h2>
|
||||
|
||||
{/* Progresso */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm text-gray-500">
|
||||
<span>Questão {currentSentence + 1} de {exerciseSentences.length}</span>
|
||||
<span>{score} pontos</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-600 transition-all"
|
||||
style={{ width: `${((currentSentence + 1) / exerciseSentences.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sentença atual */}
|
||||
<div className="mb-8">
|
||||
<p className="text-xl leading-relaxed text-gray-700">
|
||||
{currentExercise.sentence}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Campo de resposta */}
|
||||
<div className="mb-8">
|
||||
<input
|
||||
type="text"
|
||||
value={userAnswer}
|
||||
onChange={(e) => setUserAnswer(e.target.value)}
|
||||
placeholder="Digite a palavra que completa a frase..."
|
||||
className="w-full px-4 py-3 text-lg border border-gray-300 rounded-lg
|
||||
focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Botão de verificação */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!userAnswer || isSubmitting}
|
||||
className="w-full py-3 bg-purple-600 text-white rounded-lg font-medium
|
||||
hover:bg-purple-700 disabled:bg-gray-300 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto" />
|
||||
) : (
|
||||
'Verificar'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
294
src/components/exercises/WordFormation.tsx
Normal file
294
src/components/exercises/WordFormation.tsx
Normal file
@ -0,0 +1,294 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useStudentTracking } from '../../hooks/useStudentTracking';
|
||||
|
||||
interface WordFormationProps {
|
||||
words: string[];
|
||||
storyId: string;
|
||||
studentId: string;
|
||||
onComplete?: (score: number) => void;
|
||||
}
|
||||
|
||||
interface SyllableWord {
|
||||
word: string;
|
||||
syllables: string[];
|
||||
}
|
||||
|
||||
export function WordFormation({ words, storyId, studentId, onComplete }: WordFormationProps) {
|
||||
const [availableSyllables, setAvailableSyllables] = useState<string[]>([]);
|
||||
const [userWord, setUserWord] = useState<string>('');
|
||||
const [score, setScore] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [targetWords, setTargetWords] = useState<SyllableWord[]>([]);
|
||||
const [completedWords, setCompletedWords] = useState<string[]>([]);
|
||||
const [showFeedback, setShowFeedback] = useState<{
|
||||
type: 'success' | 'error';
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const { trackExerciseCompleted } = useStudentTracking();
|
||||
const startTime = useRef(Date.now());
|
||||
const correctAnswers = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const loadWords = async () => {
|
||||
try {
|
||||
const { data: exerciseWords, error } = await supabase
|
||||
.from('story_exercise_words')
|
||||
.select('*')
|
||||
.eq('story_id', storyId)
|
||||
.eq('exercise_type', 'formation');
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Dividir palavras em sílabas (simplificado)
|
||||
const wordList = exerciseWords?.map(w => ({
|
||||
word: w.word,
|
||||
syllables: dividePalavraEmSilabas(w.word)
|
||||
})) || [];
|
||||
|
||||
// Coletar todas as sílabas únicas
|
||||
const allSyllables = wordList.flatMap(w => w.syllables);
|
||||
const uniqueSyllables = [...new Set(allSyllables)];
|
||||
|
||||
setTargetWords(wordList);
|
||||
setAvailableSyllables(uniqueSyllables);
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar palavras:', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadWords();
|
||||
}, [storyId]);
|
||||
|
||||
const dividePalavraEmSilabas = (palavra: string): string[] => {
|
||||
// Implementação simplificada - você pode usar uma biblioteca mais robusta
|
||||
// ou implementar regras mais complexas de divisão silábica
|
||||
const silabas: string[] = [];
|
||||
let silaba = '';
|
||||
|
||||
for (let i = 0; i < palavra.length; i++) {
|
||||
const letra = palavra[i];
|
||||
silaba += letra;
|
||||
|
||||
// Regras básicas de divisão silábica
|
||||
if (i < palavra.length - 1) {
|
||||
const proximaLetra = palavra[i + 1];
|
||||
|
||||
// Se a próxima letra for uma vogal e a atual não
|
||||
if (isVogal(proximaLetra) && !isVogal(letra)) {
|
||||
silabas.push(silaba);
|
||||
silaba = '';
|
||||
}
|
||||
// Se a atual for uma vogal e a próxima uma consoante
|
||||
else if (isVogal(letra) && !isVogal(proximaLetra)) {
|
||||
silabas.push(silaba);
|
||||
silaba = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (silaba) {
|
||||
silabas.push(silaba);
|
||||
}
|
||||
|
||||
return silabas;
|
||||
};
|
||||
|
||||
const isVogal = (letra: string): boolean => {
|
||||
return /[aeiouáéíóúâêîôûãõàèìòùäëïöü]/i.test(letra);
|
||||
};
|
||||
|
||||
const handleSyllableClick = (syllable: string) => {
|
||||
setUserWord(prev => prev + syllable);
|
||||
};
|
||||
|
||||
const handleVerify = () => {
|
||||
const matchedWord = targetWords.find(
|
||||
w => w.word.toLowerCase() === userWord.toLowerCase()
|
||||
);
|
||||
|
||||
if (matchedWord && !completedWords.includes(matchedWord.word)) {
|
||||
setScore(prev => prev + 10);
|
||||
correctAnswers.current += 1;
|
||||
setCompletedWords(prev => [...prev, matchedWord.word]);
|
||||
setShowFeedback({
|
||||
type: 'success',
|
||||
message: 'Parabéns! Palavra correta!'
|
||||
});
|
||||
|
||||
// Se completou todas as palavras
|
||||
if (completedWords.length + 1 === targetWords.length) {
|
||||
const timeSpent = Date.now() - startTime.current;
|
||||
trackExerciseCompleted({
|
||||
exercise_id: `${storyId}_word_formation`,
|
||||
story_id: storyId,
|
||||
student_id: studentId,
|
||||
exercise_type: 'word_formation',
|
||||
score: score + 10,
|
||||
time_spent: timeSpent,
|
||||
words_formed: correctAnswers.current,
|
||||
words_correct: correctAnswers.current,
|
||||
difficulty_level: targetWords.length <= 5 ? 'easy' : targetWords.length <= 10 ? 'medium' : 'hard'
|
||||
});
|
||||
|
||||
if (onComplete) {
|
||||
onComplete(score + 10);
|
||||
}
|
||||
}
|
||||
} else if (completedWords.includes(matchedWord?.word || '')) {
|
||||
setShowFeedback({
|
||||
type: 'error',
|
||||
message: 'Você já encontrou esta palavra!'
|
||||
});
|
||||
} else {
|
||||
setShowFeedback({
|
||||
type: 'error',
|
||||
message: 'Tente novamente!'
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setShowFeedback(null);
|
||||
}, 2000);
|
||||
|
||||
setUserWord('');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3 mb-6" />
|
||||
<div className="h-64 bg-gray-200 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!availableSyllables.length) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<p className="text-gray-600">
|
||||
Não há palavras para formar neste momento.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
Formação de Palavras
|
||||
</h2>
|
||||
|
||||
{/* Barra de Progresso */}
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between text-sm text-gray-500 mb-2">
|
||||
<span>
|
||||
Palavras encontradas: {completedWords.length} de {targetWords.length}
|
||||
</span>
|
||||
<span>{score} pontos</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-600 transition-all duration-500"
|
||||
style={{
|
||||
width: `${(completedWords.length / targetWords.length) * 100}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback Visual */}
|
||||
{showFeedback && (
|
||||
<div className={`mb-4 p-4 rounded-lg text-center font-medium
|
||||
${showFeedback.type === 'success'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{showFeedback.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sílabas Disponíveis */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
Sílabas Disponíveis:
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableSyllables.map((syllable, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleSyllableClick(syllable)}
|
||||
className="px-4 py-2 bg-purple-100 rounded-lg
|
||||
hover:bg-purple-200 active:bg-purple-300
|
||||
transition-all transform hover:scale-105
|
||||
text-purple-900 font-medium shadow-sm"
|
||||
>
|
||||
{syllable}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Palavra do Usuário */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
Sua Palavra:
|
||||
</h3>
|
||||
<div className="p-4 bg-gray-50 rounded-lg min-h-[60px] text-xl
|
||||
font-medium text-gray-900 flex items-center justify-center
|
||||
border-2 border-dashed border-gray-300">
|
||||
{userWord || (
|
||||
<span className="text-gray-400">
|
||||
Clique nas sílabas para formar uma palavra
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Palavras Encontradas */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
Palavras Encontradas:
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{completedWords.map((word, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-3 py-1 bg-green-100 text-green-800
|
||||
rounded-full font-medium"
|
||||
>
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botões de Ação */}
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setUserWord('')}
|
||||
className="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg
|
||||
hover:bg-gray-300 active:bg-gray-400 transition-colors flex-1
|
||||
font-medium shadow-sm"
|
||||
>
|
||||
Limpar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleVerify}
|
||||
disabled={!userWord}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg
|
||||
hover:bg-purple-700 active:bg-purple-800 transition-colors flex-1
|
||||
disabled:bg-gray-300 disabled:cursor-not-allowed
|
||||
font-medium shadow-sm"
|
||||
>
|
||||
Verificar Palavra
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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">Leiturama</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{user ? (
|
||||
<ProfileMenu />
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
to="/login/school"
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Entrar
|
||||
</Link>
|
||||
<Link
|
||||
to="/register/school"
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||||
>
|
||||
Cadastrar Escola
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
476
src/components/home/HomePage.tsx
Normal file
476
src/components/home/HomePage.tsx
Normal file
@ -0,0 +1,476 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
BookOpen, ArrowRight, School, Users, Shield,
|
||||
Sparkles, BookCheck, Play, CheckCircle, Star,
|
||||
GraduationCap, BarChart, Brain, X, Check,
|
||||
Pencil, Wand, Mic, Share2
|
||||
} from 'lucide-react';
|
||||
import { Footer } from '@/components/ui/footer';
|
||||
import { PlanForSchools } from '@/components/ui/plan-for-schools';
|
||||
import { FAQ } from '@/components/ui/faq';
|
||||
import { StatCard } from '@/components/ui/stat-card';
|
||||
import { TestimonialCard } from '@/components/ui/testimonial-card';
|
||||
import { FeatureCard } from '@/components/ui/feature-card';
|
||||
import { ProcessStep } from '@/components/ui/process-step';
|
||||
import { InfoCard } from '@/components/ui/info-card';
|
||||
import { ComparisonSection } from '@/components/ui/comparison-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EVENT_CATEGORIES } from '../../constants/analytics';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Início', href: '/' },
|
||||
{ name: 'Para Pais', href: '/para-pais' },
|
||||
{ name: 'Evidências', href: '/evidencias' },
|
||||
{ name: 'Para Educadores', href: '/para-educadores' },
|
||||
];
|
||||
|
||||
export function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const [showUserOptions, setShowUserOptions] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('schools');
|
||||
const [showFaq, setShowFaq] = useState<number | null>(null);
|
||||
|
||||
// Navigation handlers
|
||||
const handleLoginClick = () => setShowUserOptions(!showUserOptions);
|
||||
const handleSchoolLogin = () => navigate('/login/school');
|
||||
const handleTeacherLogin = () => navigate('/login/teacher');
|
||||
const handleStudentLogin = () => navigate('/login/student');
|
||||
const handleSchoolRegister = () => navigate('/register');
|
||||
const handleDemo = () => navigate('/demo');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-purple-50 to-white">
|
||||
{/* Header/Nav */}
|
||||
<nav className="bg-white/80 backdrop-blur-md fixed w-full z-50 shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center">
|
||||
<BookOpen className="h-8 w-8 text-purple-600" />
|
||||
<span className="ml-2 text-xl font-bold text-gray-900">Leiturama</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<Button
|
||||
onClick={handleLoginClick}
|
||||
variant="ghost"
|
||||
trackingId="nav_login_button"
|
||||
trackingProperties={{
|
||||
category: EVENT_CATEGORIES.NAVIGATION,
|
||||
action: 'click',
|
||||
label: 'login_dropdown'
|
||||
}}
|
||||
>
|
||||
Entrar
|
||||
</Button>
|
||||
{showUserOptions && (
|
||||
<div className="absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5">
|
||||
<div className="py-1" role="menu">
|
||||
<Button
|
||||
onClick={handleSchoolLogin}
|
||||
variant="ghost"
|
||||
className="w-full text-left"
|
||||
trackingId="nav_school_login"
|
||||
trackingProperties={{
|
||||
category: EVENT_CATEGORIES.NAVIGATION,
|
||||
action: 'click',
|
||||
label: 'school_login'
|
||||
}}
|
||||
>
|
||||
Entrar como Escola
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleTeacherLogin}
|
||||
variant="ghost"
|
||||
className="w-full text-left"
|
||||
trackingId="nav_teacher_login"
|
||||
trackingProperties={{
|
||||
category: EVENT_CATEGORIES.NAVIGATION,
|
||||
action: 'click',
|
||||
label: 'teacher_login'
|
||||
}}
|
||||
>
|
||||
Entrar como Professor
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleStudentLogin}
|
||||
variant="ghost"
|
||||
className="w-full text-left"
|
||||
trackingId="nav_student_login"
|
||||
trackingProperties={{
|
||||
category: EVENT_CATEGORIES.NAVIGATION,
|
||||
action: 'click',
|
||||
label: 'student_login'
|
||||
}}
|
||||
>
|
||||
Entrar como Aluno
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/*
|
||||
<Button
|
||||
onClick={handleSchoolRegister}
|
||||
variant="primary"
|
||||
trackingId="nav_register_button"
|
||||
trackingProperties={{
|
||||
category: EVENT_CATEGORIES.NAVIGATION,
|
||||
action: 'click',
|
||||
label: 'register_school'
|
||||
}}
|
||||
>
|
||||
Cadastrar Escola
|
||||
</Button>
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<div className="pt-32 pb-24 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<div className="inline-block px-4 py-1 rounded-full bg-purple-100 text-purple-600 font-medium text-sm mb-6">
|
||||
Potencializado por IA 🚀
|
||||
</div>
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-gray-900 mb-6">
|
||||
Transforme a educação com
|
||||
<span className="text-purple-600"> histórias inteligentes</span>
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 mb-8">
|
||||
Uma plataforma educacional que usa IA para criar experiências de
|
||||
aprendizado personalizadas através de histórias interativas.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Button
|
||||
onClick={() => window.location.href = 'https://typebot-public.inventivos.co/leiturama-leads'}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="gap-2"
|
||||
trackingId="hero_register_button"
|
||||
trackingProperties={{
|
||||
category: EVENT_CATEGORIES.HERO,
|
||||
action: 'click',
|
||||
label: 'contact_us',
|
||||
position: 'hero_section'
|
||||
}}
|
||||
>
|
||||
Entre em contato
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDemo}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="gap-2"
|
||||
trackingId="hero_demo_button"
|
||||
trackingProperties={{
|
||||
category: EVENT_CATEGORIES.HERO,
|
||||
action: 'click',
|
||||
label: 'watch_demo',
|
||||
position: 'hero_section'
|
||||
}}
|
||||
>
|
||||
<Play className="w-5 h-5" />
|
||||
Ver Demo
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-8 flex items-center gap-4">
|
||||
<div className="flex -space-x-2">
|
||||
{[1,2,3,4].map((i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={`/avatars/${i}.jpg`}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full border-2 border-white"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="text-purple-600 font-semibold">+1000 escolas</span> 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}
|
||||
variant="ghost"
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/30 hover:bg-black/40 transition group"
|
||||
trackingId="hero_video_play"
|
||||
trackingProperties={{
|
||||
category: EVENT_CATEGORIES.HERO,
|
||||
action: 'click',
|
||||
label: 'play_demo_video',
|
||||
position: 'hero_video'
|
||||
}}
|
||||
>
|
||||
<div className="w-16 h-16 rounded-full bg-white/90 flex items-center justify-center">
|
||||
<Play className="w-8 h-8 text-purple-600 group-hover:scale-110 transition" />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Student Journey Section */}
|
||||
<div className="py-24 bg-gradient-to-b from-purple-50 to-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Jornada do Aluno
|
||||
</h2>
|
||||
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||
Um processo inteligente e envolvente de aprendizado
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-12">
|
||||
<ProcessStep
|
||||
number={1}
|
||||
title="Escolha o tema da aventura"
|
||||
description="Selecione entre diversos temas educativos alinhados com a BNCC e adequados à idade."
|
||||
/>
|
||||
|
||||
<ProcessStep
|
||||
number={2}
|
||||
title="Personalize os personagens"
|
||||
description="Crie personagens que seu filho vai adorar, com características únicas e cativantes."
|
||||
/>
|
||||
|
||||
<ProcessStep
|
||||
number={3}
|
||||
title="A IA cria a história mágica"
|
||||
description="Nossa IA educacional gera uma história personalizada em segundos."
|
||||
/>
|
||||
|
||||
<ProcessStep
|
||||
number={4}
|
||||
title="A aventura educativa começa"
|
||||
description="Seu filho mergulha em uma jornada mágica de aprendizado e diversão."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Summary */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="mt-24 bg-white rounded-xl shadow-lg p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Resultados Comprovados
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Nossa abordagem inovadora tem transformado a experiência de leitura
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-4 gap-8">
|
||||
<FeatureCard
|
||||
icon={Star}
|
||||
title="Fluência de Leitura"
|
||||
description="Nossa tecnologia avalia a leitura dos alunos e gera relatórios para melhorar a comunicação com confiança."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={Sparkles}
|
||||
title="Mais tempo"
|
||||
description="Queremos economizar o tempo dos professores, permitindo mais foco nos alunos e melhorando os resultados."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={CheckCircle}
|
||||
title="Alunos engajados"
|
||||
description="Histórias personalizadas com IA e gamificação tornam a aprendizagem mais divertida."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={BookOpen}
|
||||
title="Bilíngue"
|
||||
description="Melhore leitura e compreensão em português, inglês e espanhol em uma única plataforma."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Grid */}
|
||||
<div className="mt-24">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Tecnologia e Educação em Harmonia
|
||||
</h2>
|
||||
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||
Uma plataforma completa que une o poder da IA com as melhores práticas pedagógicas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<FeatureCard
|
||||
icon={Brain}
|
||||
title="IA Adaptativa"
|
||||
description="Conteúdo que se adapta ao ritmo e estilo de aprendizagem de cada aluno"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={BookOpen}
|
||||
title="Histórias Interativas"
|
||||
description="Narrativas envolventes que tornam o aprendizado mais divertido e eficaz"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={BarChart}
|
||||
title="Analytics Avançado"
|
||||
description="Insights detalhados sobre o progresso e engajamento dos alunos"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={Users}
|
||||
title="Colaboração"
|
||||
description="Ferramentas para professores trabalharem juntos e compartilharem recursos"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={Shield}
|
||||
title="Ambiente Seguro"
|
||||
description="Proteção de dados e conteúdo adequado para todas as idades"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={GraduationCap}
|
||||
title="Suporte Pedagógico"
|
||||
description="Recursos e orientações para maximizar o potencial de aprendizagem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Before & After Section */}
|
||||
<div className="mt-24">
|
||||
<ComparisonSection
|
||||
title="Compare a Transformação"
|
||||
items={[
|
||||
{
|
||||
title: "Personalização",
|
||||
without: [
|
||||
"Conteúdo padronizado que não atende necessidades individuais",
|
||||
"Material didático tradicional e pouco envolvente",
|
||||
"Mesma abordagem para todos os alunos"
|
||||
],
|
||||
with: [
|
||||
"Histórias adaptativas que evoluem com cada aluno",
|
||||
"Conteúdo personalizado e envolvente",
|
||||
"Experiência única para cada estudante"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Engajamento",
|
||||
without: [
|
||||
"Alunos desmotivados com atividades repetitivas",
|
||||
"Baixo interesse nas atividades de leitura",
|
||||
"Dificuldade em manter a atenção dos alunos"
|
||||
],
|
||||
with: [
|
||||
"Estudantes engajados e participativos",
|
||||
"Aumento de 300% no engajamento com leitura",
|
||||
"Alunos ansiosos pela próxima atividade"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Acompanhamento",
|
||||
without: [
|
||||
"Professores sobrecarregados com correções manuais",
|
||||
"Dificuldade em acompanhar o progresso individual",
|
||||
"Falta de dados para decisões pedagógicas"
|
||||
],
|
||||
with: [
|
||||
"Correção automática com feedback instantâneo",
|
||||
"Dashboard em tempo real do progresso individual",
|
||||
"Insights precisos para intervenções pedagógicas"
|
||||
]
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Testimonials */}
|
||||
<div className="mt-24 grid md:grid-cols-2 gap-8">
|
||||
<TestimonialCard
|
||||
quote="A transformação que vimos em nossa escola foi incrível. Alunos que mal conseguiam juntar letras agora estão lendo com fluência e, mais importante, com prazer."
|
||||
author="Maria Silva"
|
||||
role="Diretora Pedagógica"
|
||||
/>
|
||||
<TestimonialCard
|
||||
quote="Como professora há 15 anos, nunca vi um método tão eficaz e envolvente. A plataforma me ajuda a personalizar o ensino para cada aluno."
|
||||
author="Ana Paula Santos"
|
||||
role="Professora"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pricing Section
|
||||
<div className="mt-24">
|
||||
<PlanForSchools />
|
||||
</div>
|
||||
*/}
|
||||
|
||||
|
||||
{/* FAQ Section */}
|
||||
<div className="mt-24">
|
||||
<FAQ
|
||||
title="Dúvidas Frequentes"
|
||||
description="Tire suas dúvidas sobre a implementação do Leiturama em sua escola"
|
||||
items={[
|
||||
{
|
||||
question: "Como o Leiturama se integra ao currículo escolar?",
|
||||
answer: "Nossa plataforma foi desenvolvida para complementar e enriquecer o currículo existente. Oferecemos conteúdo alinhado à BNCC e ferramentas de personalização que permitem adaptar as atividades aos objetivos pedagógicos específicos de cada escola."
|
||||
},
|
||||
{
|
||||
question: "Quanto tempo leva para implementar a plataforma?",
|
||||
answer: "O processo de implementação é personalizado e gradual, levando em média 2-3 semanas. Iniciamos com uma fase piloto, oferecemos treinamento completo para a equipe e fornecemos suporte contínuo durante todo o processo."
|
||||
},
|
||||
{
|
||||
question: "Como posso acompanhar o progresso dos alunos?",
|
||||
answer: "Disponibilizamos um dashboard intuitivo com métricas em tempo real, relatórios detalhados e insights sobre o desempenho individual e coletivo. Professores e coordenadores podem monitorar o progresso, identificar áreas de melhoria e personalizar intervenções."
|
||||
},
|
||||
{
|
||||
question: "Quais são os requisitos técnicos?",
|
||||
answer: "A plataforma é acessível via navegador web em qualquer dispositivo (computadores, tablets, smartphones). Recomendamos uma conexão estável à internet e o uso de fones de ouvido para melhor experiência nas atividades de áudio."
|
||||
},
|
||||
{
|
||||
question: "Como vocês protegem os dados dos alunos?",
|
||||
answer: "Seguimos rigorosos protocolos de segurança em conformidade com a LGPD. Todos os dados são criptografados, o acesso é controlado e realizamos auditorias regulares de segurança."
|
||||
},
|
||||
{
|
||||
question: "Que tipo de suporte vocês oferecem?",
|
||||
answer: "Oferecemos suporte técnico e pedagógico através de múltiplos canais (chat, email, telefone), além de atualizações regulares da plataforma e workshops para capacitação contínua da equipe escolar."
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Final CTA */}
|
||||
<div className="mt-24 pb-24">
|
||||
<div className="bg-purple-600 rounded-3xl px-8 py-16 text-center">
|
||||
<h2 className="text-4xl font-bold text-white mb-4">
|
||||
Pronto para Transformar sua Escola?
|
||||
</h2>
|
||||
<p className="text-white/90 mb-8 max-w-2xl mx-auto text-lg">
|
||||
Junte-se a mais de 1000 escolas que já estão revolucionando a educação
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.href = 'https://typebot-public.inventivos.co/leiturama-leads'}
|
||||
className="bg-white text-purple-600 px-8 py-3 rounded-lg font-semibold hover:bg-purple-50 transition"
|
||||
>
|
||||
Entre em contato
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HomePage;
|
||||
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>
|
||||
);
|
||||
}
|
||||
11
src/components/layouts/BaseLayout.tsx
Normal file
11
src/components/layouts/BaseLayout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { PageTracker } from '../analytics/PageTracker';
|
||||
|
||||
export function BaseLayout() {
|
||||
return (
|
||||
<>
|
||||
<PageTracker />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
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">
|
||||
Leiturama
|
||||
</span>
|
||||
</div>
|
||||
<DashboardSidebar />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="p-4 sm:ml-64">
|
||||
<div className="p-4 border-2 border-gray-200 border-dashed rounded-lg dark:border-gray-700">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
128
src/components/learning/ExerciseSuggestions.tsx
Normal file
128
src/components/learning/ExerciseSuggestions.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { BookOpen, Puzzle, Mic } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
interface ExerciseSuggestionsProps {
|
||||
storyId: string;
|
||||
storyText: string;
|
||||
readingMetrics: {
|
||||
difficultWords: string[];
|
||||
errorCount: number;
|
||||
pauseCount: number;
|
||||
fluencyScore: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ExerciseWord {
|
||||
word: string;
|
||||
exercise_type: string;
|
||||
phonemes: string[] | null;
|
||||
syllable_pattern: string | null;
|
||||
}
|
||||
|
||||
export function ExerciseSuggestions({ storyId, storyText, readingMetrics }: ExerciseSuggestionsProps) {
|
||||
const navigate = useNavigate();
|
||||
const [exerciseWords, setExerciseWords] = useState<ExerciseWord[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadExerciseWords = async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('story_exercise_words')
|
||||
.select('*')
|
||||
.eq('story_id', storyId)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (!error && data) {
|
||||
setExerciseWords(data);
|
||||
}
|
||||
};
|
||||
|
||||
loadExerciseWords();
|
||||
}, [storyId]);
|
||||
|
||||
const handleExerciseSelect = (exerciseType: string) => {
|
||||
if (!storyId) {
|
||||
console.error('ID da história não fornecido');
|
||||
return;
|
||||
}
|
||||
navigate(`/aluno/historias/${storyId}/exercicios/${exerciseType}`);
|
||||
};
|
||||
|
||||
const generateExercises = () => {
|
||||
const exercises = [
|
||||
{
|
||||
type: 'word-formation',
|
||||
title: 'Formação de Palavras',
|
||||
description: 'Monte novas palavras usando sílabas da história',
|
||||
icon: <Puzzle className="w-6 h-6" />,
|
||||
words: exerciseWords
|
||||
.filter(w => w.exercise_type === 'formation')
|
||||
.map(w => w.word),
|
||||
},
|
||||
{
|
||||
type: 'sentence-completion',
|
||||
title: 'Complete a História',
|
||||
description: 'Complete as frases com as palavras corretas',
|
||||
icon: <BookOpen className="w-6 h-6" />,
|
||||
words: exerciseWords
|
||||
.filter(w => w.exercise_type === 'completion')
|
||||
.map(w => w.word),
|
||||
},
|
||||
{
|
||||
type: 'pronunciation-practice',
|
||||
title: 'Treino de Pronúncia',
|
||||
description: 'Pratique a pronúncia das palavras difíceis',
|
||||
icon: <Mic className="w-6 h-6" />,
|
||||
words: exerciseWords
|
||||
.filter(w => w.exercise_type === 'pronunciation')
|
||||
.map(w => w.word),
|
||||
}
|
||||
];
|
||||
|
||||
return exercises;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4 ">
|
||||
Exercícios Sugeridos
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{generateExercises().map((exercise) => (
|
||||
<button
|
||||
key={exercise.type}
|
||||
onClick={() => handleExerciseSelect(exercise.type)}
|
||||
className="p-4 bg-white rounded-lg border border-gray-200 hover:border-purple-500 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="text-purple-600">
|
||||
{exercise.icon}
|
||||
</div>
|
||||
<h4 className="font-semibold text-gray-900">
|
||||
{exercise.title}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
{exercise.description}
|
||||
</p>
|
||||
<div className="text-xs text-gray-500">
|
||||
{exercise.type === 'word-formation' && (
|
||||
<div>
|
||||
Palavras para praticar:
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{exercise.words.map(word => (
|
||||
<span key={word} className="bg-purple-100 px-2 py-1 rounded">
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
src/components/learning/WordHighlighter.test.tsx
Normal file
83
src/components/learning/WordHighlighter.test.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { WordHighlighter } from './WordHighlighter'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
describe('WordHighlighter', () => {
|
||||
const mockText = "O gato pulou o muro."
|
||||
const mockHighlightedWords = ['gato', 'pulou']
|
||||
const mockDifficultWords = ['muro']
|
||||
const mockOnWordClick = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnWordClick.mockClear()
|
||||
})
|
||||
|
||||
it('deve renderizar todas as palavras do texto', () => {
|
||||
render(
|
||||
<WordHighlighter
|
||||
text={mockText}
|
||||
highlightedWords={mockHighlightedWords}
|
||||
difficultWords={mockDifficultWords}
|
||||
onWordClick={mockOnWordClick}
|
||||
/>
|
||||
)
|
||||
|
||||
// Verifica se cada palavra está presente
|
||||
const words = mockText.split(/(\s+)/).filter(word => word.trim().length > 0)
|
||||
words.forEach(word => {
|
||||
expect(screen.getByText(word)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('deve destacar as palavras corretas', () => {
|
||||
render(
|
||||
<WordHighlighter
|
||||
text={mockText}
|
||||
highlightedWords={mockHighlightedWords}
|
||||
difficultWords={mockDifficultWords}
|
||||
onWordClick={mockOnWordClick}
|
||||
/>
|
||||
)
|
||||
|
||||
// Verifica palavras destacadas
|
||||
const highlightedElements = screen.getAllByText(/gato|pulou/)
|
||||
highlightedElements.forEach(element => {
|
||||
expect(element).toHaveClass('bg-yellow-200')
|
||||
})
|
||||
|
||||
// Verifica palavras difíceis
|
||||
const difficultElements = screen.getAllByText('muro')
|
||||
difficultElements.forEach(element => {
|
||||
expect(element).toHaveClass('bg-red-100')
|
||||
})
|
||||
})
|
||||
|
||||
it('deve chamar onWordClick com a palavra correta', () => {
|
||||
render(
|
||||
<WordHighlighter
|
||||
text={mockText}
|
||||
highlightedWords={mockHighlightedWords}
|
||||
difficultWords={mockDifficultWords}
|
||||
onWordClick={mockOnWordClick}
|
||||
/>
|
||||
)
|
||||
|
||||
// Clica em uma palavra
|
||||
fireEvent.click(screen.getByText('gato'))
|
||||
expect(mockOnWordClick).toHaveBeenCalledWith('gato')
|
||||
})
|
||||
|
||||
it('deve lidar com pontuação corretamente', () => {
|
||||
render(
|
||||
<WordHighlighter
|
||||
text="Olá, mundo!"
|
||||
highlightedWords={['mundo']}
|
||||
difficultWords={[]}
|
||||
onWordClick={mockOnWordClick}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('mundo!')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
129
src/components/learning/WordHighlighter.tsx
Normal file
129
src/components/learning/WordHighlighter.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ChevronUp, ChevronDown, Play, Pause } from 'lucide-react';
|
||||
|
||||
interface WordHighlighterProps {
|
||||
text: string; // Texto completo
|
||||
highlightedWords: string[]; // Palavras para destacar (ex: palavras difíceis)
|
||||
difficultWords: string[]; // Palavras que o aluno teve dificuldade
|
||||
onWordClick: (word: string) => void; // Função para quando clicar na palavra
|
||||
highlightSpeed?: number; // palavras por minuto
|
||||
initialFontSize?: number;
|
||||
}
|
||||
|
||||
export function WordHighlighter({
|
||||
text,
|
||||
highlightedWords,
|
||||
difficultWords,
|
||||
onWordClick,
|
||||
highlightSpeed = 60,
|
||||
initialFontSize = 18
|
||||
}: WordHighlighterProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentWordIndex, setCurrentWordIndex] = useState(0);
|
||||
const [fontSize, setFontSize] = useState(initialFontSize);
|
||||
|
||||
// Divide o texto em palavras mantendo a pontuação
|
||||
const words = text.split(/(\s+)/).filter(word => word.trim().length > 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
const intervalTime = (60 / highlightSpeed) * 1000;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentWordIndex((prevIndex) => {
|
||||
if (prevIndex >= words.length - 1) {
|
||||
setIsPlaying(false);
|
||||
return prevIndex;
|
||||
}
|
||||
return prevIndex + 1;
|
||||
});
|
||||
}, intervalTime);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isPlaying, highlightSpeed, words.length]);
|
||||
|
||||
const handleFontSizeChange = (delta: number) => {
|
||||
setFontSize(prev => Math.min(Math.max(12, prev + delta), 32));
|
||||
};
|
||||
|
||||
const togglePlayPause = () => {
|
||||
if (!isPlaying) {
|
||||
setCurrentWordIndex(0);
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Controles */}
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleFontSizeChange(-2)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100"
|
||||
aria-label="Diminuir fonte"
|
||||
>
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
</button>
|
||||
<span className="text-sm font-medium">{fontSize}px</span>
|
||||
<button
|
||||
onClick={() => handleFontSizeChange(2)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100"
|
||||
aria-label="Aumentar fonte"
|
||||
>
|
||||
<ChevronUp className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={togglePlayPause}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-100 text-purple-700 hover:bg-purple-200"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<>
|
||||
<Pause className="h-4 w-4" />
|
||||
Pausar Leitura
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
Iniciar Leitura
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Texto */}
|
||||
<div
|
||||
className="leading-relaxed space-y-4"
|
||||
style={{ fontSize: `${fontSize}px` }}
|
||||
>
|
||||
{words.map((word, i) => {
|
||||
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/, '');
|
||||
const isHighlighted = highlightedWords.includes(cleanWord);
|
||||
const isDifficult = difficultWords.includes(cleanWord);
|
||||
const isCurrentWord = i === currentWordIndex && isPlaying;
|
||||
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
onClick={() => onWordClick(word)}
|
||||
className={`
|
||||
inline-block mx-1 px-1 rounded cursor-pointer transition-all
|
||||
hover:scale-110
|
||||
${isHighlighted ? 'bg-yellow-200 hover:bg-yellow-300' : ''}
|
||||
${isDifficult ? 'bg-red-100 hover:bg-red-200' : ''}
|
||||
${isCurrentWord ? 'bg-purple-200 scale-110' : ''}
|
||||
hover:bg-gray-100
|
||||
`}
|
||||
title="Clique para ver mais informações"
|
||||
>
|
||||
{word}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
src/components/phonics/AudioPlayer.tsx
Normal file
70
src/components/phonics/AudioPlayer.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Volume2, Loader2 } from "lucide-react";
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
interface AudioPlayerProps {
|
||||
word: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function AudioPlayer({ word, disabled }: AudioPlayerProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const playAudio = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Buscar ou gerar o áudio da palavra
|
||||
const { data, error } = await supabase.functions.invoke('generate-word-audio', {
|
||||
body: { word }
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
if (data?.audioUrl) {
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio(data.audioUrl);
|
||||
} else {
|
||||
audioRef.current.src = data.audioUrl;
|
||||
}
|
||||
|
||||
await audioRef.current.play();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erro ao reproduzir áudio:', err);
|
||||
setError('Erro ao reproduzir áudio');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
className="gap-2"
|
||||
onClick={playAudio}
|
||||
disabled={disabled || isLoading}
|
||||
trackingId="audio-player-toggle"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Volume2 className="w-5 h-5" />
|
||||
)}
|
||||
Ouvir Palavra
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 mt-1">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/components/phonics/CategoryTabs.tsx
Normal file
43
src/components/phonics/CategoryTabs.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { usePhonicsCategories } from "@/hooks/phonics/usePhonicsExercises";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface CategoryTabsProps {
|
||||
selectedCategory?: string;
|
||||
onSelectCategory: (categoryId: string) => void;
|
||||
}
|
||||
|
||||
export function CategoryTabs({ selectedCategory, onSelectCategory }: CategoryTabsProps) {
|
||||
const { data: categories, isLoading } = usePhonicsCategories();
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-10 w-full max-w-[600px]" />;
|
||||
}
|
||||
|
||||
if (!categories?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={selectedCategory || "all"}
|
||||
onValueChange={(value) => onSelectCategory(value === "all" ? "" : value)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="w-full max-w-[600px] h-auto flex-wrap">
|
||||
<TabsTrigger value="all" className="flex-1">
|
||||
Todos
|
||||
</TabsTrigger>
|
||||
{categories.map((category) => (
|
||||
<TabsTrigger
|
||||
key={category.id}
|
||||
value={category.id}
|
||||
className="flex-1"
|
||||
>
|
||||
{category.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
72
src/components/phonics/ExerciseCard.tsx
Normal file
72
src/components/phonics/ExerciseCard.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Clock, Star, Timer } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PhonicsExercise, StudentPhonicsProgress } from "@/types/phonics";
|
||||
|
||||
interface ExerciseCardProps {
|
||||
exercise: PhonicsExercise;
|
||||
progress?: StudentPhonicsProgress;
|
||||
onStart: (exerciseId: string) => void;
|
||||
}
|
||||
|
||||
export function ExerciseCard({ exercise, progress, onStart }: ExerciseCardProps) {
|
||||
const isCompleted = progress?.completed;
|
||||
const stars = progress?.stars || 0;
|
||||
const progressValue = progress ? (progress.best_score * 100) : 0;
|
||||
|
||||
return (
|
||||
<Card className="w-full hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-lg font-bold">{exercise.title}</CardTitle>
|
||||
<CardDescription>{exercise.description}</CardDescription>
|
||||
</div>
|
||||
<Badge variant={isCompleted ? "success" : "secondary"}>
|
||||
{isCompleted ? "Completo" : "Pendente"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{Math.ceil((exercise.estimated_time_seconds ?? 0) / 60)} min</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-4 h-4 ${i < stars ? "fill-yellow-400 text-yellow-400" : "text-gray-300"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{progress && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Progresso</span>
|
||||
<span>{Math.round(progressValue)}%</span>
|
||||
</div>
|
||||
<Progress value={progressValue} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => onStart(exercise.id)}
|
||||
variant={isCompleted ? "secondary" : "default"}
|
||||
trackingId="exercise-card-start"
|
||||
>
|
||||
{isCompleted ? "Praticar Novamente" : "Começar"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
48
src/components/phonics/ExerciseGrid.tsx
Normal file
48
src/components/phonics/ExerciseGrid.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { usePhonicsExercises } from "@/hooks/phonics/usePhonicsExercises";
|
||||
import { usePhonicsProgress } from "@/hooks/phonics/usePhonicsProgress";
|
||||
import { ExerciseCard } from "./ExerciseCard";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface ExerciseGridProps {
|
||||
categoryId?: string;
|
||||
studentId: string;
|
||||
onSelectExercise: (exerciseId: string) => void;
|
||||
}
|
||||
|
||||
export function ExerciseGrid({ categoryId, studentId, onSelectExercise }: ExerciseGridProps) {
|
||||
const { data: exercises, isLoading: isLoadingExercises } = usePhonicsExercises(categoryId);
|
||||
const { data: progress, isLoading: isLoadingProgress } = usePhonicsProgress(studentId);
|
||||
|
||||
const isLoading = isLoadingExercises || isLoadingProgress;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[250px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!exercises?.length) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Nenhum exercício encontrado nesta categoria.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{exercises.map((exercise) => (
|
||||
<ExerciseCard
|
||||
key={exercise.id}
|
||||
exercise={exercise}
|
||||
progress={progress?.find((p) => p.exercise_id === exercise.id)}
|
||||
onStart={onSelectExercise}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
src/components/phonics/ExercisePlayer.tsx
Normal file
182
src/components/phonics/ExercisePlayer.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useUpdatePhonicsProgress } from "@/hooks/phonics/usePhonicsProgress";
|
||||
import { ExerciseFactory } from "./exercises/ExerciseFactory";
|
||||
import { Timer } from "lucide-react";
|
||||
import type { PhonicsExercise, UpdateProgressParams } from "@/types/phonics";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AdaptiveText } from '../ui/adaptive-text';
|
||||
import { useUppercasePreference } from '../../hooks/useUppercasePreference';
|
||||
|
||||
interface ExercisePlayerProps {
|
||||
exercise: PhonicsExercise;
|
||||
student_id: string;
|
||||
onComplete: (result: {
|
||||
score: number;
|
||||
stars: number;
|
||||
xp_earned: number;
|
||||
completed: boolean;
|
||||
}) => void;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
export function ExercisePlayer({
|
||||
exercise,
|
||||
student_id,
|
||||
onComplete,
|
||||
onExit
|
||||
}: ExercisePlayerProps) {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [score, setScore] = useState(0);
|
||||
const [timeSpent, setTimeSpent] = useState(0);
|
||||
const [showFeedback, setShowFeedback] = useState(false);
|
||||
const [lastAnswerCorrect, setLastAnswerCorrect] = useState<boolean | null>(null);
|
||||
|
||||
const updateProgress = useUpdatePhonicsProgress();
|
||||
const { isUpperCase } = useUppercasePreference();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setTimeSpent((prev) => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const handleAnswer = async (word: string, isCorrect: boolean) => {
|
||||
setLastAnswerCorrect(isCorrect);
|
||||
setShowFeedback(true);
|
||||
|
||||
if (isCorrect) {
|
||||
setScore((prev) => prev + 1);
|
||||
}
|
||||
|
||||
// Aguardar feedback antes de prosseguir
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
setShowFeedback(false);
|
||||
|
||||
// Filtra apenas as palavras corretas
|
||||
const correctWords = exercise.words?.filter(w => w.is_correct_answer) || [];
|
||||
|
||||
if (currentStep < correctWords.length - 1) {
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
} else {
|
||||
handleComplete();
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
// Filtra apenas as palavras corretas
|
||||
const correctWords = exercise.words?.filter(w => w.is_correct_answer) || [];
|
||||
const finalScore = score / correctWords.length;
|
||||
const stars = Math.ceil(finalScore * 3);
|
||||
const xp_earned = Math.round(finalScore * exercise.points);
|
||||
const completed = finalScore >= exercise.required_score;
|
||||
|
||||
const updateParams: UpdateProgressParams = {
|
||||
student_id,
|
||||
exercise_id: exercise.id,
|
||||
best_score: finalScore,
|
||||
last_score: finalScore,
|
||||
completed,
|
||||
stars,
|
||||
xp_earned,
|
||||
time_spent_seconds: timeSpent,
|
||||
correct_answers_count: score,
|
||||
total_answers_count: correctWords.length
|
||||
};
|
||||
|
||||
await updateProgress.mutateAsync(updateParams);
|
||||
onComplete({
|
||||
score: finalScore,
|
||||
stars,
|
||||
xp_earned,
|
||||
completed
|
||||
});
|
||||
};
|
||||
|
||||
if (!exercise.words?.length) {
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<CardContent className="py-8">
|
||||
<div className="text-center text-muted-foreground">
|
||||
Carregando exercício...
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Filtra apenas as palavras corretas e ordena por order_index
|
||||
const correctWords = exercise.words
|
||||
.filter(w => w.is_correct_answer)
|
||||
.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
|
||||
|
||||
// Pega a palavra atual
|
||||
const currentWord = correctWords[currentStep];
|
||||
|
||||
// Pega as opções (incluindo a palavra correta)
|
||||
const options = exercise.words
|
||||
.filter(w => w.order_index === currentWord.order_index)
|
||||
.map(w => w.word)
|
||||
.sort(() => Math.random() - 0.5);
|
||||
|
||||
const progress = ((currentStep + 1) / correctWords.length) * 100;
|
||||
|
||||
return (
|
||||
<Card className={cn(
|
||||
"w-full max-w-2xl mx-auto transition-colors duration-500",
|
||||
showFeedback && lastAnswerCorrect && "bg-green-50",
|
||||
showFeedback && !lastAnswerCorrect && "bg-red-50"
|
||||
)}>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle>{exercise.title}</CardTitle>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Timer className="w-4 h-4" />
|
||||
<span>{Math.floor(timeSpent / 60)}:{(timeSpent % 60).toString().padStart(2, '0')}</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={onExit} trackingId="exercise-player-exit">
|
||||
Sair do Exercício
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<div className="text-center text-muted-foreground mb-8">
|
||||
Exercício {currentStep + 1} de {correctWords.length}
|
||||
</div>
|
||||
|
||||
<AdaptiveText
|
||||
text={exercise.instructions}
|
||||
isUpperCase={isUpperCase}
|
||||
className="text-lg"
|
||||
/>
|
||||
|
||||
<ExerciseFactory
|
||||
type_id={exercise.type_id}
|
||||
currentWord={currentWord.word}
|
||||
options={options}
|
||||
onAnswer={handleAnswer}
|
||||
disabled={showFeedback}
|
||||
/>
|
||||
|
||||
{showFeedback && (
|
||||
<div className={cn(
|
||||
"text-center text-lg font-medium py-4 rounded-lg",
|
||||
lastAnswerCorrect ? "text-green-600" : "text-red-600"
|
||||
)}>
|
||||
{lastAnswerCorrect ? "Muito bem!" : "Tente novamente na próxima!"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
41
src/components/phonics/exercises/AlliterationExercise.tsx
Normal file
41
src/components/phonics/exercises/AlliterationExercise.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PhonicsWord } from "@/types/phonics";
|
||||
|
||||
interface AlliterationExerciseProps {
|
||||
currentWord: PhonicsWord;
|
||||
options: PhonicsWord[];
|
||||
onAnswer: (word: string, isCorrect: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function AlliterationExercise({ currentWord, options, onAnswer, disabled }: AlliterationExerciseProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium mb-2">Qual palavra começa com o mesmo som?</h3>
|
||||
<div className="text-4xl font-bold">{currentWord.word}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.id}
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-auto py-6 text-xl font-medium",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
onClick={() => onAnswer(option.word, option.id === currentWord.id)}
|
||||
disabled={disabled}
|
||||
trackingId={`alliteration-option-${option.word}`}
|
||||
>
|
||||
{option.word}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
src/components/phonics/exercises/BaseExercise.tsx
Normal file
31
src/components/phonics/exercises/BaseExercise.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import type { PhonicsWord } from "@/types/phonics";
|
||||
import { AudioPlayer } from "../AudioPlayer";
|
||||
|
||||
export interface BaseExerciseProps {
|
||||
currentWord: PhonicsWord;
|
||||
onAnswer: (word: string, isCorrect: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ExerciseOption {
|
||||
id: string;
|
||||
text: string;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
export function BaseExercise({ currentWord, disabled }: BaseExerciseProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<AudioPlayer
|
||||
word={currentWord.word}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<div className="text-2xl font-bold">
|
||||
{currentWord.word}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
src/components/phonics/exercises/ExerciseFactory.tsx
Normal file
66
src/components/phonics/exercises/ExerciseFactory.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { RhymeExercise } from "./RhymeExercise";
|
||||
import { AlliterationExercise } from "./AlliterationExercise";
|
||||
import { SyllablesExercise } from "./SyllablesExercise";
|
||||
import { InitialSoundExercise } from "./InitialSoundExercise";
|
||||
import { FinalSoundExercise } from "./FinalSoundExercise";
|
||||
import type { PhonicsWord } from "@/types/phonics";
|
||||
|
||||
interface ExerciseFactoryProps {
|
||||
type_id: string;
|
||||
currentWord: PhonicsWord;
|
||||
options: PhonicsWord[];
|
||||
onAnswer: (word: string, isCorrect: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ExerciseFactory({ type_id, currentWord, options, onAnswer, disabled }: ExerciseFactoryProps) {
|
||||
switch (type_id) {
|
||||
case '1': // Rima
|
||||
return (
|
||||
<RhymeExercise
|
||||
currentWord={currentWord}
|
||||
options={options}
|
||||
onAnswer={onAnswer}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
case '2': // Aliteração
|
||||
return (
|
||||
<AlliterationExercise
|
||||
currentWord={currentWord}
|
||||
options={options}
|
||||
onAnswer={onAnswer}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
case '3': // Sílabas
|
||||
return (
|
||||
<SyllablesExercise
|
||||
currentWord={currentWord}
|
||||
options={options}
|
||||
onAnswer={onAnswer}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
case '4': // Som Inicial
|
||||
return (
|
||||
<InitialSoundExercise
|
||||
currentWord={currentWord}
|
||||
options={options}
|
||||
onAnswer={onAnswer}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
case '5': // Som Final
|
||||
return (
|
||||
<FinalSoundExercise
|
||||
currentWord={currentWord}
|
||||
options={options}
|
||||
onAnswer={onAnswer}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
41
src/components/phonics/exercises/FinalSoundExercise.tsx
Normal file
41
src/components/phonics/exercises/FinalSoundExercise.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PhonicsWord } from "@/types/phonics";
|
||||
|
||||
interface FinalSoundExerciseProps {
|
||||
currentWord: PhonicsWord;
|
||||
options: PhonicsWord[];
|
||||
onAnswer: (word: string, isCorrect: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function FinalSoundExercise({ currentWord, options, onAnswer, disabled }: FinalSoundExerciseProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium mb-2">Qual palavra termina com o mesmo som?</h3>
|
||||
<div className="text-4xl font-bold">{currentWord.word}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.id}
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-auto py-6 text-xl font-medium",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
onClick={() => onAnswer(option.word, option.id === currentWord.id)}
|
||||
disabled={disabled}
|
||||
trackingId={`final-sound-option-${option.word}`}
|
||||
>
|
||||
{option.word}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/components/phonics/exercises/InitialSoundExercise.tsx
Normal file
41
src/components/phonics/exercises/InitialSoundExercise.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PhonicsWord } from "@/types/phonics";
|
||||
|
||||
interface InitialSoundExerciseProps {
|
||||
currentWord: PhonicsWord;
|
||||
options: PhonicsWord[];
|
||||
onAnswer: (word: string, isCorrect: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function InitialSoundExercise({ currentWord, options, onAnswer, disabled }: InitialSoundExerciseProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium mb-2">Qual palavra começa com o mesmo som?</h3>
|
||||
<div className="text-4xl font-bold">{currentWord.word}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.id}
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-auto py-6 text-xl font-medium",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
onClick={() => onAnswer(option.word, option.id === currentWord.id)}
|
||||
disabled={disabled}
|
||||
trackingId={`initial-sound-option-${option.word}`}
|
||||
>
|
||||
{option.word}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/components/phonics/exercises/RhymeExercise.tsx
Normal file
41
src/components/phonics/exercises/RhymeExercise.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PhonicsWord } from "@/types/phonics";
|
||||
|
||||
interface RhymeExerciseProps {
|
||||
currentWord: PhonicsWord;
|
||||
options: PhonicsWord[];
|
||||
onAnswer: (word: string, isCorrect: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function RhymeExercise({ currentWord, options, onAnswer, disabled }: RhymeExerciseProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium mb-2">Qual palavra rima com:</h3>
|
||||
<div className="text-4xl font-bold">{currentWord.word}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.id}
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-auto py-6 text-xl font-medium",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
onClick={() => onAnswer(option.word, option.id === currentWord.id)}
|
||||
disabled={disabled}
|
||||
trackingId={`rhyme-option-${option.word}`}
|
||||
>
|
||||
{option.word}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/components/phonics/exercises/SoundMatchExercise.tsx
Normal file
54
src/components/phonics/exercises/SoundMatchExercise.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BaseExercise, type BaseExerciseProps } from "./BaseExercise";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SoundMatchExerciseProps extends BaseExerciseProps {
|
||||
type: 'initial' | 'final';
|
||||
options: Array<{
|
||||
word: string;
|
||||
hasMatchingSound: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function SoundMatchExercise({
|
||||
currentWord,
|
||||
onAnswer,
|
||||
type,
|
||||
options,
|
||||
disabled
|
||||
}: SoundMatchExerciseProps) {
|
||||
const instruction = type === 'initial'
|
||||
? "Qual palavra começa com o mesmo som?"
|
||||
: "Qual palavra termina com o mesmo som?";
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<BaseExercise currentWord={currentWord} onAnswer={onAnswer} disabled={disabled} />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-center text-muted-foreground">
|
||||
{instruction}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.word}
|
||||
onClick={() => onAnswer(option.word, option.hasMatchingSound)}
|
||||
disabled={disabled}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-16 text-lg",
|
||||
disabled && option.hasMatchingSound && "border-green-500 bg-green-50",
|
||||
disabled && !option.hasMatchingSound && "border-red-500 bg-red-50"
|
||||
)}
|
||||
trackingId={`sound-match-option-${option.word}`}
|
||||
>
|
||||
{option.word}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/components/phonics/exercises/SyllablesExercise.tsx
Normal file
41
src/components/phonics/exercises/SyllablesExercise.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PhonicsWord } from "@/types/phonics";
|
||||
|
||||
interface SyllablesExerciseProps {
|
||||
currentWord: PhonicsWord;
|
||||
options: PhonicsWord[];
|
||||
onAnswer: (word: string, isCorrect: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SyllablesExercise({ currentWord, options, onAnswer, disabled }: SyllablesExerciseProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium mb-2">Quantas sílabas tem a palavra?</h3>
|
||||
<div className="text-4xl font-bold">{currentWord.word}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.id}
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-auto py-6 text-xl font-medium",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
onClick={() => onAnswer(option.word, option.syllables_count === currentWord.syllables_count)}
|
||||
disabled={disabled}
|
||||
trackingId={`syllables-option-${option.syllables_count}`}
|
||||
>
|
||||
{option.syllables_count} sílabas
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
245
src/components/story/AudioRecorder.tsx
Normal file
245
src/components/story/AudioRecorder.tsx
Normal file
@ -0,0 +1,245 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Mic, Square, Loader, Play, Upload } from 'lucide-react';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface AudioRecorderProps {
|
||||
storyId: string;
|
||||
studentId: string;
|
||||
onAudioUploaded: (audioUrl: string) => void;
|
||||
onRecordingStart?: () => void;
|
||||
onRecordingStop?: () => void;
|
||||
focusModeActive?: boolean;
|
||||
onFocusModeToggle?: () => void;
|
||||
}
|
||||
|
||||
export function AudioRecorder({
|
||||
storyId,
|
||||
studentId,
|
||||
onAudioUploaded,
|
||||
onRecordingStart,
|
||||
onRecordingStop,
|
||||
focusModeActive = false,
|
||||
onFocusModeToggle
|
||||
}: AudioRecorderProps) {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const startTime = React.useRef<number | null>(null);
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
if (!focusModeActive && onFocusModeToggle) {
|
||||
onFocusModeToggle();
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mediaRecorderRef.current = new MediaRecorder(stream);
|
||||
chunksRef.current = [];
|
||||
|
||||
mediaRecorderRef.current.ondataavailable = (e) => {
|
||||
chunksRef.current.push(e.data);
|
||||
};
|
||||
|
||||
mediaRecorderRef.current.onstop = () => {
|
||||
const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||
setAudioBlob(audioBlob);
|
||||
onRecordingStop?.();
|
||||
};
|
||||
|
||||
mediaRecorderRef.current.start();
|
||||
startTime.current = Date.now();
|
||||
setIsRecording(true);
|
||||
setError(null);
|
||||
onRecordingStart?.();
|
||||
} catch (err) {
|
||||
setError('Erro ao acessar microfone. Verifique as permissões.');
|
||||
console.error('Erro ao iniciar gravação:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
|
||||
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
|
||||
|
||||
// Desativar modo foco ao parar a gravação
|
||||
if (focusModeActive && onFocusModeToggle) {
|
||||
onFocusModeToggle();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const triggerAudioProcessing = async (recordingData: {
|
||||
id: string;
|
||||
story_id: string;
|
||||
student_id: string;
|
||||
audio_url: string;
|
||||
status: string;
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
const { error } = await supabase.functions.invoke('process-audio', {
|
||||
body: {
|
||||
record: recordingData
|
||||
}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao iniciar processamento:', error);
|
||||
// Não vamos tratar o erro aqui pois o processamento é assíncrono
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erro ao chamar função de processamento:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadAudio = async () => {
|
||||
if (!audioBlob) return;
|
||||
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.user) {
|
||||
setError('Usuário não autenticado');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
|
||||
const fileId = uuidv4();
|
||||
const filePath = `${studentId}/${storyId}/${fileId}.webm`;
|
||||
|
||||
try {
|
||||
// 1. Primeiro fazer o upload do arquivo
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('recordings')
|
||||
.upload(filePath, audioBlob, {
|
||||
contentType: 'audio/webm',
|
||||
cacheControl: '3600',
|
||||
upsert: false
|
||||
});
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
// 2. Obter URL pública
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('recordings')
|
||||
.getPublicUrl(filePath);
|
||||
|
||||
// 3. Criar o registro com a URL do áudio
|
||||
const { error: recordError } = await supabase
|
||||
.from('story_recordings')
|
||||
.insert({
|
||||
id: fileId,
|
||||
story_id: storyId,
|
||||
student_id: studentId,
|
||||
audio_url: publicUrl, // Salvar o caminho relativo
|
||||
status: 'pending_analysis',
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (recordError) throw recordError;
|
||||
|
||||
// 4. Disparar processamento
|
||||
await triggerAudioProcessing({
|
||||
id: fileId,
|
||||
story_id: storyId,
|
||||
student_id: studentId,
|
||||
audio_url: publicUrl,
|
||||
status: 'pending_analysis'
|
||||
}).catch(console.error);
|
||||
|
||||
onAudioUploaded(publicUrl);
|
||||
setAudioBlob(null);
|
||||
|
||||
} catch (err) {
|
||||
// Em caso de erro, limpar arquivo se foi feito upload
|
||||
if (filePath) {
|
||||
await supabase.storage.from('recordings').remove([filePath]);
|
||||
}
|
||||
setError('Erro ao enviar áudio. Tente novamente.');
|
||||
console.error('Erro no upload:', err);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"",
|
||||
focusModeActive && "bg-purple-50"
|
||||
)}>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
{!isRecording && !audioBlob && (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-lg transition",
|
||||
focusModeActive
|
||||
? "bg-purple-600 text-white hover:bg-purple-700"
|
||||
: "bg-red-600 text-white hover:bg-red-700"
|
||||
)}
|
||||
>
|
||||
<Mic className="w-5 h-5" />
|
||||
{focusModeActive ? "Iniciar Leitura" : "Iniciar Gravação"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<button
|
||||
onClick={stopRecording}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
|
||||
>
|
||||
<Square className="w-5 h-5" />
|
||||
Parar Gravação
|
||||
</button>
|
||||
)}
|
||||
|
||||
{audioBlob && !isUploading && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = URL.createObjectURL(audioBlob);
|
||||
const audio = new Audio(url);
|
||||
audio.play();
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||||
>
|
||||
<Play className="w-5 h-5" />
|
||||
Ouvir
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={uploadAudio}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
|
||||
>
|
||||
<Upload className="w-5 h-5" />
|
||||
Enviar Áudio
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<Loader className="w-5 h-5 animate-spin" />
|
||||
Enviando áudio...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
615
src/components/story/StoryGenerator.tsx
Normal file
615
src/components/story/StoryGenerator.tsx
Normal file
@ -0,0 +1,615 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useSession } from '../../hooks/useSession';
|
||||
import { useStoryCategories } from '../../hooks/useStoryCategories';
|
||||
import { Wand2, ArrowLeft, Globe } from 'lucide-react';
|
||||
import { useStudentTracking } from '../../hooks/useStudentTracking';
|
||||
import { useLanguages } from '../../hooks/useLanguages';
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface StoryStep {
|
||||
title: string;
|
||||
key?: keyof StoryChoices;
|
||||
items?: Category[];
|
||||
isContextStep?: boolean;
|
||||
isLanguageStep?: boolean;
|
||||
}
|
||||
|
||||
export interface StoryChoices {
|
||||
theme_id: string | null;
|
||||
subject_id: string | null;
|
||||
character_id: string | null;
|
||||
setting_id: string | null;
|
||||
context?: string;
|
||||
language_type: string;
|
||||
}
|
||||
|
||||
interface StoryGeneratorProps {
|
||||
initialContext?: string;
|
||||
onContextChange: (context: string) => void;
|
||||
inputMode: 'voice' | 'form';
|
||||
voiceTranscript: string;
|
||||
isGenerating: boolean;
|
||||
setIsGenerating: (value: boolean) => void;
|
||||
step: number;
|
||||
setStep: (value: number | ((prev: number) => number)) => void;
|
||||
choices: StoryChoices;
|
||||
setChoices: React.Dispatch<React.SetStateAction<StoryChoices>>;
|
||||
}
|
||||
|
||||
export function StoryGenerator({
|
||||
initialContext = '',
|
||||
onContextChange,
|
||||
inputMode,
|
||||
voiceTranscript,
|
||||
isGenerating,
|
||||
setIsGenerating,
|
||||
step,
|
||||
setStep,
|
||||
choices,
|
||||
setChoices
|
||||
}: StoryGeneratorProps) {
|
||||
const { themes, subjects, characters, settings, isLoading: isCategoriesLoading } = useStoryCategories();
|
||||
const { languages, supportedLanguages, isLoading: isLanguagesLoading } = useLanguages();
|
||||
|
||||
// Definir steps com os dados obtidos
|
||||
const steps: StoryStep[] = [
|
||||
{
|
||||
title: 'Escolha o Tema',
|
||||
items: themes || [],
|
||||
key: 'theme_id'
|
||||
},
|
||||
{
|
||||
title: 'Escolha a Disciplina',
|
||||
items: subjects || [],
|
||||
key: 'subject_id'
|
||||
},
|
||||
{
|
||||
title: 'Escolha o Personagem',
|
||||
items: characters || [],
|
||||
key: 'character_id'
|
||||
},
|
||||
{
|
||||
title: 'Escolha o Cenário',
|
||||
items: settings || [],
|
||||
key: 'setting_id'
|
||||
},
|
||||
{
|
||||
title: 'Escolha o Idioma da História',
|
||||
isLanguageStep: true
|
||||
},
|
||||
{
|
||||
title: 'Contexto da História (Opcional)',
|
||||
isContextStep: true
|
||||
}
|
||||
];
|
||||
|
||||
// useEffect que depende dos dados
|
||||
React.useEffect(() => {
|
||||
// Só aplicar escolhas aleatórias se estiver no modo voz
|
||||
if (inputMode === 'voice' && voiceTranscript && themes && !choices.theme_id) {
|
||||
setStep(steps.length); // Vai para o último passo (contexto)
|
||||
// Selecionar IDs aleatórios válidos para cada categoria
|
||||
const randomTheme = themes[Math.floor(Math.random() * themes.length)];
|
||||
const randomSubject = subjects?.[Math.floor(Math.random() * (subjects?.length || 1))] || null;
|
||||
const randomCharacter = characters?.[Math.floor(Math.random() * (characters?.length || 1))] || null;
|
||||
const randomSetting = settings?.[Math.floor(Math.random() * (settings?.length || 1))] || null;
|
||||
|
||||
setChoices(prev => ({
|
||||
...prev,
|
||||
theme_id: randomTheme?.id || null,
|
||||
subject_id: randomSubject?.id || null,
|
||||
character_id: randomCharacter?.id || null,
|
||||
setting_id: randomSetting?.id || null,
|
||||
language_type: prev.language_type // Mantém o idioma selecionado
|
||||
}));
|
||||
}
|
||||
}, [inputMode, voiceTranscript, themes, subjects, characters, settings, setStep, setChoices, choices.theme_id]);
|
||||
|
||||
// Atualizar apenas o contexto quando mudar o modo ou a transcrição
|
||||
React.useEffect(() => {
|
||||
if (inputMode === 'voice' && voiceTranscript) {
|
||||
setChoices(prev => ({
|
||||
...prev,
|
||||
context: voiceTranscript
|
||||
}));
|
||||
} else if (inputMode === 'form') {
|
||||
setChoices(prev => ({
|
||||
...prev,
|
||||
context: initialContext
|
||||
}));
|
||||
}
|
||||
}, [voiceTranscript, initialContext, inputMode]);
|
||||
|
||||
const handleContextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onContextChange(e.target.value);
|
||||
};
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { session } = useSession();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [generationStatus, setGenerationStatus] = React.useState<
|
||||
'idle' | 'creating' | 'generating-images' | 'saving'
|
||||
>('idle');
|
||||
const { trackStoryGenerated } = useStudentTracking();
|
||||
const startTime = React.useRef(Date.now());
|
||||
|
||||
const currentStep = steps[step - 1];
|
||||
|
||||
const handleSelect = (key: keyof StoryChoices, value: string) => {
|
||||
console.log(`Selecionando ${key}:`, value); // Log para debug
|
||||
|
||||
if (!value) {
|
||||
setError(`Valor inválido para ${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setChoices(prev => ({ ...prev, [key]: value }));
|
||||
|
||||
// Avançar apenas se houver um próximo passo
|
||||
if (step < steps.length) {
|
||||
setStep((prev: number) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep.isContextStep) {
|
||||
setStep((prev: number) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLanguageSelect = (language: string) => {
|
||||
console.log('Selecionando idioma:', language);
|
||||
|
||||
const selectedLanguage = languages.find(lang => lang.code === language);
|
||||
if (!selectedLanguage) {
|
||||
setError('Idioma inválido selecionado');
|
||||
return;
|
||||
}
|
||||
|
||||
setChoices(prev => ({
|
||||
...prev,
|
||||
language_type: language
|
||||
}));
|
||||
|
||||
// Avançar para o próximo passo
|
||||
if (step < steps.length) {
|
||||
setStep((prev: number) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
// Validação apenas para modo voz
|
||||
if (inputMode === 'voice' && !voiceTranscript) {
|
||||
setError('Grave uma descrição por voz antes de enviar');
|
||||
return;
|
||||
}
|
||||
|
||||
// Contexto é opcional no formulário
|
||||
const finalContext = inputMode === 'voice' ? voiceTranscript : initialContext;
|
||||
|
||||
if (!session?.user?.id) {
|
||||
setError('Usuário não autenticado');
|
||||
return;
|
||||
}
|
||||
|
||||
// Log inicial para debug
|
||||
console.log('=== Iniciando geração de história ===');
|
||||
console.log('Modo:', inputMode);
|
||||
console.log('Choices:', choices);
|
||||
|
||||
// Validações iniciais
|
||||
if (!themes?.length || !subjects?.length || !characters?.length || !settings?.length) {
|
||||
console.error('Dados das categorias não carregados:', { themes, subjects, characters, settings });
|
||||
setError('Erro ao carregar dados necessários. Tente novamente.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar se todos os IDs são UUIDs válidos
|
||||
const isValidUUID = (id: string | null) => {
|
||||
if (!id) return false;
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(id);
|
||||
};
|
||||
|
||||
// Validar cada ID individualmente
|
||||
const validations = [
|
||||
{ field: 'theme_id', value: choices.theme_id, exists: themes.some(t => t.id === choices.theme_id) },
|
||||
{ field: 'subject_id', value: choices.subject_id, exists: subjects.some(s => s.id === choices.subject_id) },
|
||||
{ field: 'character_id', value: choices.character_id, exists: characters.some(c => c.id === choices.character_id) },
|
||||
{ field: 'setting_id', value: choices.setting_id, exists: settings.some(s => s.id === choices.setting_id) }
|
||||
];
|
||||
|
||||
// Verificar cada validação
|
||||
for (const validation of validations) {
|
||||
console.log(`Validando ${validation.field}:`, validation);
|
||||
|
||||
if (!validation.value) {
|
||||
setError(`${validation.field} não selecionado`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidUUID(validation.value)) {
|
||||
setError(`${validation.field} não é um UUID válido`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validation.exists) {
|
||||
setError(`${validation.field} não encontrado na lista de opções`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validar idioma
|
||||
if (!choices.language_type || !languages.some(lang => lang.code === choices.language_type)) {
|
||||
setError('Idioma não selecionado ou inválido');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
setGenerationStatus('creating');
|
||||
|
||||
// Log detalhado antes de fazer a inserção
|
||||
console.log('=== Dados validados para inserção ===', {
|
||||
student_id: session.user.id,
|
||||
theme_id: choices.theme_id,
|
||||
subject_id: choices.subject_id,
|
||||
character_id: choices.character_id,
|
||||
setting_id: choices.setting_id,
|
||||
context: finalContext,
|
||||
language_type: choices.language_type
|
||||
});
|
||||
|
||||
// Criar objeto da história antes da inserção para validação
|
||||
const storyData = {
|
||||
student_id: session.user.id,
|
||||
title: 'Gerando...',
|
||||
theme_id: choices.theme_id,
|
||||
subject_id: choices.subject_id,
|
||||
character_id: choices.character_id,
|
||||
setting_id: choices.setting_id,
|
||||
context: finalContext,
|
||||
language_type: choices.language_type,
|
||||
status: 'draft',
|
||||
content: {
|
||||
prompt: choices,
|
||||
pages: []
|
||||
}
|
||||
} as const;
|
||||
|
||||
// Validar se todos os campos necessários estão presentes
|
||||
const requiredFields = ['student_id', 'theme_id', 'subject_id', 'character_id', 'setting_id', 'language_type'] as const;
|
||||
const missingFields = requiredFields.filter(field => !storyData[field]);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
throw new Error(`Campos obrigatórios faltando: ${missingFields.join(', ')}`);
|
||||
}
|
||||
|
||||
const { data: story, error: storyError } = await supabase
|
||||
.from('stories')
|
||||
.insert(storyData)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (storyError) {
|
||||
console.error('Erro ao inserir história:', storyError);
|
||||
throw storyError;
|
||||
}
|
||||
|
||||
// Tracking da criação da história
|
||||
const selectedTheme = themes?.find(t => t.id === choices.theme_id)?.title || '';
|
||||
const selectedSubject = subjects?.find(s => s.id === choices.subject_id)?.title || '';
|
||||
const selectedCharacter = characters?.find(c => c.id === choices.character_id)?.title || '';
|
||||
const selectedSetting = settings?.find(s => s.id === choices.setting_id)?.title || '';
|
||||
|
||||
trackStoryGenerated({
|
||||
story_id: story.id,
|
||||
theme: selectedTheme,
|
||||
subject: selectedSubject,
|
||||
character: selectedCharacter,
|
||||
setting: selectedSetting,
|
||||
context: finalContext,
|
||||
generation_time: Date.now() - startTime.current,
|
||||
word_count: 0,
|
||||
student_id: session.user.id,
|
||||
school_id: session.user.user_metadata?.school_id,
|
||||
class_id: session.user.user_metadata?.class_id
|
||||
});
|
||||
|
||||
setGenerationStatus('generating-images');
|
||||
console.log('=== Chamando Edge Function ===');
|
||||
console.log('Story ID:', story.id);
|
||||
console.log('Story Data:', story);
|
||||
|
||||
try {
|
||||
if (!story?.id) {
|
||||
throw new Error('ID da história não encontrado');
|
||||
}
|
||||
|
||||
const storyPayload = {
|
||||
voice_context: finalContext || '',
|
||||
student_id: session.user.id,
|
||||
theme_id: choices.theme_id,
|
||||
subject_id: choices.subject_id,
|
||||
character_id: choices.character_id,
|
||||
setting_id: choices.setting_id,
|
||||
language_type: choices.language_type,
|
||||
theme: selectedTheme,
|
||||
subject: selectedSubject,
|
||||
character: selectedCharacter,
|
||||
setting: selectedSetting,
|
||||
story_id: story.id // Garantindo que o ID existe
|
||||
};
|
||||
|
||||
console.log('=== Dados da História ===');
|
||||
console.log('ID:', story.id);
|
||||
console.log('Payload completo:', storyPayload);
|
||||
|
||||
const response = await supabase.functions
|
||||
.invoke('generate-story', {
|
||||
body: storyPayload
|
||||
});
|
||||
|
||||
console.log('=== Resposta da Edge Function ===');
|
||||
console.log('Resposta completa:', response);
|
||||
|
||||
// Se a resposta não for 200, lançar erro
|
||||
if (response.error) {
|
||||
console.error('Erro na Edge Function:', response.error);
|
||||
throw new Error(`Erro na Edge Function: ${response.error.message}`);
|
||||
}
|
||||
|
||||
// Se não houver dados na resposta
|
||||
if (!response.data) {
|
||||
console.error('Edge Function não retornou dados');
|
||||
throw new Error('Edge Function não retornou dados');
|
||||
}
|
||||
|
||||
// Atualizar o status da história para success
|
||||
const { error: updateError } = await supabase
|
||||
.from('stories')
|
||||
.update({
|
||||
status: 'published',
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', story.id)
|
||||
.single();
|
||||
|
||||
if (updateError) {
|
||||
console.error('Erro ao atualizar status da história:', updateError);
|
||||
throw updateError;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('=== Erro na Edge Function ===');
|
||||
console.error('Erro completo:', error);
|
||||
console.error('Story ID:', story?.id);
|
||||
console.error('Estado atual:', { choices, inputMode, step });
|
||||
|
||||
if (!story?.id) {
|
||||
throw new Error('ID da história não encontrado para atualizar status de erro');
|
||||
}
|
||||
|
||||
// Atualizar status da história para erro
|
||||
const { error: updateError } = await supabase
|
||||
.from('stories')
|
||||
.update({
|
||||
status: 'failed',
|
||||
title: 'Erro na Geração',
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', story.id)
|
||||
.single();
|
||||
|
||||
if (updateError) {
|
||||
console.error('Erro ao atualizar status de erro:', updateError);
|
||||
}
|
||||
|
||||
throw new Error(`Erro na geração da história. Por favor, tente novamente.`);
|
||||
}
|
||||
|
||||
setGenerationStatus('saving');
|
||||
const { data: updatedStory, error: updateError } = await supabase
|
||||
.from('stories')
|
||||
.select('*')
|
||||
.eq('id', story.id)
|
||||
.single();
|
||||
|
||||
if (updateError) {
|
||||
console.error('Erro ao buscar história atualizada:', updateError);
|
||||
throw updateError;
|
||||
}
|
||||
|
||||
// Atualizar a contagem de palavras após a geração
|
||||
const wordCount = updatedStory.content.pages.reduce((acc: number, page: { text: string }) =>
|
||||
acc + page.text.split(/\s+/).length, 0);
|
||||
|
||||
await supabase.from('story_metrics').insert({
|
||||
story_id: story.id,
|
||||
word_count: wordCount,
|
||||
generation_time: Date.now() - startTime.current
|
||||
});
|
||||
|
||||
navigate(`/aluno/historias/${story.id}`);
|
||||
} catch (err) {
|
||||
console.error('=== Erro detalhado ===');
|
||||
console.error('Erro:', err);
|
||||
console.error('Estado atual:', { choices, inputMode, step });
|
||||
|
||||
if (err instanceof Error) {
|
||||
setError(`Erro ao gerar história: ${err.message}`);
|
||||
} else {
|
||||
setError('Erro desconhecido ao gerar história. Tente novamente.');
|
||||
}
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
setGenerationStatus('idle');
|
||||
}
|
||||
};
|
||||
|
||||
const getGenerationStatusText = () => {
|
||||
switch (generationStatus) {
|
||||
case 'creating':
|
||||
return 'Iniciando criação...';
|
||||
case 'generating-images':
|
||||
return 'Gerando história e imagens...';
|
||||
case 'saving':
|
||||
return 'Finalizando...';
|
||||
default:
|
||||
return 'Criar História Mágica';
|
||||
}
|
||||
};
|
||||
|
||||
if (isCategoriesLoading || isLanguagesLoading) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-8">
|
||||
<div className="h-2 bg-gray-200 rounded-full" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-32 bg-gray-200 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Progress Bar */}
|
||||
<div className="flex gap-2 mb-8">
|
||||
{steps.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-2 rounded-full flex-1 ${
|
||||
i + 1 <= step ? 'bg-purple-600' : 'bg-gray-200'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-medium text-gray-900 mb-6">
|
||||
{currentStep.title}
|
||||
</h2>
|
||||
|
||||
{currentStep.isLanguageStep ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{supportedLanguages.map((option) => {
|
||||
const languageDetails = languages.find(lang => lang.code === option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => handleLanguageSelect(option.value)}
|
||||
className={`p-6 rounded-xl border-2 transition-all text-left ${
|
||||
choices.language_type === option.value
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-purple-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{languageDetails?.flag_icon ? (
|
||||
<img
|
||||
src={languageDetails.flag_icon}
|
||||
alt={`Bandeira ${option.label}`}
|
||||
className="h-6 w-6 object-cover rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<Globe className="h-6 w-6 text-purple-600" />
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{option.label}</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{`Escreva sua história em ${option.label}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : currentStep.isContextStep ? (
|
||||
<div className="space-y-4">
|
||||
<textarea
|
||||
value={initialContext}
|
||||
onChange={handleContextChange}
|
||||
className="w-full p-3 border rounded-lg"
|
||||
placeholder="Descreva sua história... (opcional)"
|
||||
/>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
<Wand2 className="h-5 w-5" />
|
||||
{isGenerating ? getGenerationStatusText() : 'Criar História Mágica'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{currentStep.items?.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelect(currentStep.key!, item.id)}
|
||||
className={`p-6 rounded-xl border-2 transition-all text-left ${
|
||||
choices[currentStep.key!] === item.id
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-purple-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{item.icon}</span>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{item.title}</h3>
|
||||
<p className="text-sm text-gray-600">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 text-red-600 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{choices.theme_id === 'auto' && (
|
||||
<div className="mb-4 p-3 bg-blue-50 text-blue-600 rounded-lg">
|
||||
Configurações automáticas selecionadas com base na descrição por voz
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex justify-between pt-6">
|
||||
<button
|
||||
onClick={() => setStep((prev: number) => prev - 1)}
|
||||
disabled={step === 1}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 disabled:opacity-50"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
Voltar
|
||||
</button>
|
||||
|
||||
{!currentStep.isLanguageStep && !currentStep.isContextStep && (
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={currentStep.key && !choices[currentStep.key] || isGenerating}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
Próximo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
105
src/components/story/StoryReader.tsx
Normal file
105
src/components/story/StoryReader.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { WordHighlighter } from "../learning/WordHighlighter";
|
||||
import { useState } from "react";
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
|
||||
interface StoryReaderProps {
|
||||
storyText: string;
|
||||
studentProgress: {
|
||||
difficultWords: string[];
|
||||
masteredWords: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export function StoryReader({ storyText, studentProgress }: StoryReaderProps) {
|
||||
const [selectedWord, setSelectedWord] = useState<string | null>(null);
|
||||
const [showWordDetails, setShowWordDetails] = useState(false);
|
||||
|
||||
// Palavras importantes para destacar
|
||||
const highlightedWords = [
|
||||
'casa', 'bola', 'menino', 'cachorro',
|
||||
// Palavras frequentes ou importantes para a história
|
||||
];
|
||||
|
||||
const handleWordClick = (word: string) => {
|
||||
// Abre um modal ou popover com:
|
||||
// 1. Definição da palavra
|
||||
// 2. Exemplo de uso
|
||||
// 3. Imagem relacionada
|
||||
// 4. Exercícios de pronúncia
|
||||
setSelectedWord(word);
|
||||
setShowWordDetails(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<WordHighlighter
|
||||
text={storyText}
|
||||
highlightedWords={highlightedWords}
|
||||
difficultWords={studentProgress.difficultWords}
|
||||
onWordClick={handleWordClick}
|
||||
/>
|
||||
|
||||
{/* Modal de detalhes da palavra */}
|
||||
<WordDetailsModal
|
||||
word={selectedWord}
|
||||
isOpen={showWordDetails}
|
||||
onClose={() => setShowWordDetails(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Adicionar interface para as props do modal
|
||||
interface WordDetailsModalProps {
|
||||
word: string | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Componente para mostrar detalhes da palavra
|
||||
function WordDetailsModal({ word, isOpen, onClose }: WordDetailsModalProps) {
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={onClose}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
|
||||
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="p-6 bg-white rounded-xl">
|
||||
<h3 className="text-2xl font-bold mb-4">{word}</h3>
|
||||
|
||||
{/* Significado */}
|
||||
<div className="mb-4">
|
||||
<h4 className="font-semibold">Significado:</h4>
|
||||
<p>{/* Buscar significado da palavra */}</p>
|
||||
</div>
|
||||
|
||||
{/* Sílabas */}
|
||||
<div className="mb-4">
|
||||
<h4 className="font-semibold">Sílabas:</h4>
|
||||
<div className="flex gap-2">
|
||||
{word?.split(/(?=[BCDFGHJKLMNPQRSTVWXZ][aeiou])/i).map((syllable, i) => (
|
||||
<span key={i} className="bg-purple-100 px-2 py-1 rounded">
|
||||
{syllable}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exemplo */}
|
||||
<div className="mb-4">
|
||||
<h4 className="font-semibold">Exemplo:</h4>
|
||||
<p className="italic">{/* Exemplo contextualizado */}</p>
|
||||
</div>
|
||||
|
||||
{/* Botão de áudio para ouvir a pronúncia */}
|
||||
<button
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded-lg"
|
||||
onClick={() => {/* Reproduzir áudio */}}
|
||||
>
|
||||
Ouvir Pronúncia
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
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 };
|
||||
94
src/components/ui/adaptive-text.tsx
Normal file
94
src/components/ui/adaptive-text.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { SyllableHighlighter } from '../../features/syllables/components/SyllableHighlighter';
|
||||
import { formatTextWithSyllables } from '../../features/syllables/utils/syllableSplitter';
|
||||
|
||||
interface AdaptiveTextProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
text: string;
|
||||
isUpperCase: boolean;
|
||||
as?: keyof JSX.IntrinsicElements;
|
||||
preserveWhitespace?: boolean;
|
||||
highlightSyllables?: boolean;
|
||||
formattedText?: string;
|
||||
}
|
||||
|
||||
export const AdaptiveText = React.memo(({
|
||||
text,
|
||||
isUpperCase,
|
||||
as: Component = 'span',
|
||||
preserveWhitespace = false,
|
||||
highlightSyllables = false,
|
||||
formattedText,
|
||||
className,
|
||||
...props
|
||||
}: AdaptiveTextProps) => {
|
||||
// Se tiver texto formatado (com sílabas), usa ele
|
||||
// Senão, formata o texto normal
|
||||
const finalText = formattedText || formatTextWithSyllables(text, highlightSyllables);
|
||||
const displayText = isUpperCase ? finalText.toUpperCase() : finalText;
|
||||
|
||||
return React.createElement(
|
||||
Component,
|
||||
{
|
||||
className: cn(
|
||||
'transition-colors duration-200',
|
||||
className
|
||||
),
|
||||
...props
|
||||
},
|
||||
displayText
|
||||
);
|
||||
});
|
||||
|
||||
AdaptiveText.displayName = 'AdaptiveText';
|
||||
|
||||
// Variantes específicas para diferentes contextos
|
||||
export const AdaptiveTitle = ({
|
||||
className,
|
||||
...props
|
||||
}: AdaptiveTextProps) => (
|
||||
<AdaptiveText
|
||||
as="h1"
|
||||
className={cn(
|
||||
'text-2xl font-bold text-gray-900',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const AdaptiveParagraph = ({
|
||||
className,
|
||||
...props
|
||||
}: AdaptiveTextProps) => (
|
||||
<AdaptiveText
|
||||
as="p"
|
||||
className={cn(
|
||||
'text-base text-gray-700 leading-relaxed',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const AdaptiveLabel = ({
|
||||
className,
|
||||
...props
|
||||
}: AdaptiveTextProps) => (
|
||||
<AdaptiveText
|
||||
as="span"
|
||||
className={cn(
|
||||
'text-sm font-medium text-gray-600',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
// Hook para memoização de textos longos
|
||||
export function useAdaptiveText(text: string, isUpperCase: boolean) {
|
||||
return React.useMemo(
|
||||
() => isUpperCase ? text.toUpperCase() : text,
|
||||
[text, isUpperCase]
|
||||
);
|
||||
}
|
||||
146
src/components/ui/alert-dialog.tsx
Normal file
146
src/components/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50",
|
||||
"transition-all duration-200",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4",
|
||||
"border bg-white p-6 shadow-lg rounded-lg",
|
||||
"transition-all duration-300 ease-in-out",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
85
src/components/ui/button.tsx
Normal file
85
src/components/ui/button.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { useButtonTracking } from '../../hooks/useButtonTracking';
|
||||
import { ButtonTrackingOptions } from '../../types/analytics';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { EVENT_CATEGORIES } from '../../constants/analytics';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
as?: 'button' | 'span';
|
||||
trackingId: string;
|
||||
variant?: 'default' | 'primary' | 'secondary' | 'outline' | 'ghost' | 'link' | 'destructive';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
trackingProperties?: ButtonTrackingOptions;
|
||||
}
|
||||
|
||||
export function buttonVariants({
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
className = '',
|
||||
}: {
|
||||
variant?: ButtonProps['variant'];
|
||||
size?: ButtonProps['size'];
|
||||
className?: string;
|
||||
} = {}) {
|
||||
return cn(
|
||||
'inline-flex items-center justify-center px-4 py-2',
|
||||
'text-sm font-medium',
|
||||
'rounded-md shadow-sm',
|
||||
'transition-colors duration-200',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
{
|
||||
'text-white bg-purple-600 hover:bg-purple-700': variant === 'primary' || variant === 'default',
|
||||
'text-gray-700 bg-white border border-gray-300 hover:bg-gray-50': variant === 'secondary',
|
||||
'text-purple-600 bg-transparent hover:bg-purple-50': variant === 'ghost',
|
||||
'text-purple-600 bg-transparent hover:underline': variant === 'link',
|
||||
'text-purple-600 border border-purple-600 hover:bg-purple-50': variant === 'outline',
|
||||
'text-white bg-red-600 hover:bg-red-700': variant === 'destructive',
|
||||
'px-3 py-1.5 text-sm': size === 'sm',
|
||||
'px-4 py-2 text-base': size === 'md',
|
||||
'px-6 py-3 text-lg': size === 'lg',
|
||||
},
|
||||
className
|
||||
);
|
||||
}
|
||||
|
||||
export function Button({
|
||||
as: Component = 'button',
|
||||
children,
|
||||
className = '',
|
||||
trackingId,
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
trackingProperties,
|
||||
onClick,
|
||||
disabled,
|
||||
type = 'button',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const { trackButtonClick } = useButtonTracking({
|
||||
category: EVENT_CATEGORIES.INTERACTION,
|
||||
element_type: 'button',
|
||||
...trackingProperties
|
||||
});
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
trackButtonClick(trackingId, {
|
||||
variant,
|
||||
size,
|
||||
...trackingProperties,
|
||||
});
|
||||
|
||||
onClick?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<Component
|
||||
type={Component === 'button' ? type : undefined}
|
||||
className={buttonVariants({ variant, size, className })}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
79
src/components/ui/card.tsx
Normal file
79
src/components/ui/card.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
81
src/components/ui/comparison-section.tsx
Normal file
81
src/components/ui/comparison-section.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { X, CheckCircle } from 'lucide-react';
|
||||
|
||||
interface ComparisonItem {
|
||||
title: string;
|
||||
without: string[];
|
||||
with: string[];
|
||||
}
|
||||
|
||||
interface ComparisonSectionProps {
|
||||
title: string;
|
||||
items: ComparisonItem[];
|
||||
}
|
||||
|
||||
export function ComparisonSection({ title, items }: ComparisonSectionProps) {
|
||||
return (
|
||||
<section className="px-4 py-24 bg-white">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<h2 className="text-4xl font-bold text-center text-gray-900 mb-16">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* Sem Leiturama */}
|
||||
<div className="p-8 bg-gray-50 rounded-xl border border-gray-200">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<X className="h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900">
|
||||
Sem Leiturama
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{items.map((category, index) => (
|
||||
<div key={index} className="mb-8 last:mb-0">
|
||||
<h4 className="font-bold text-gray-900 mb-4">{category.title}</h4>
|
||||
<ul className="space-y-3">
|
||||
{category.without.map((item, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<X className="h-5 w-5 text-red-500 flex-shrink-0 mt-1" />
|
||||
<span className="text-gray-600">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Com Leiturama */}
|
||||
<div className="p-8 bg-gradient-to-br from-purple-50 to-blue-50 rounded-xl
|
||||
border-2 border-purple-200 transform hover:scale-[1.02] transition-transform">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-purple-600 to-blue-500
|
||||
rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900">
|
||||
Com Leiturama
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{items.map((category, index) => (
|
||||
<div key={index} className="mb-8 last:mb-0">
|
||||
<h4 className="font-bold text-gray-900 mb-4">{category.title}</h4>
|
||||
<ul className="space-y-3">
|
||||
{category.with.map((item, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-1" />
|
||||
<span className="text-gray-600">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user