mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-18 06:17:56 +00:00
Compare commits
69 Commits
618ecaf040
...
de28dea3b5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de28dea3b5 | ||
|
|
4765be66da | ||
|
|
c562ae570a | ||
|
|
f4965db3e6 | ||
|
|
933358483e | ||
|
|
66d401f98f | ||
|
|
a3b522d283 | ||
|
|
5812d46049 | ||
|
|
007441c285 | ||
|
|
c776efaec9 | ||
|
|
6cf273126e | ||
|
|
ec97f640f9 | ||
|
|
a8c332d442 | ||
|
|
4d09386d96 | ||
|
|
cc23c83c05 | ||
|
|
521a99a5c2 | ||
|
|
563a62a517 | ||
|
|
3ef8c99062 | ||
|
|
d5c75ab6c2 | ||
|
|
28fa4d70e6 | ||
|
|
02119a62d1 | ||
|
|
7087a87ece | ||
|
|
fbeeace8bb | ||
|
|
961fce03f6 | ||
|
|
8af9950ed7 | ||
|
|
7e3b4551ec | ||
|
|
03732de610 | ||
|
|
3701e692f1 | ||
|
|
4f3b80246f | ||
|
|
0b8c050bd7 | ||
|
|
1a3a603ff6 | ||
|
|
0661f2c225 | ||
|
|
6531a9282c | ||
|
|
1132f7438d | ||
|
|
797967ca5b | ||
|
|
6f8e890e86 | ||
|
|
f70585e9c1 | ||
|
|
5573274ad4 | ||
|
|
9ecf46a9ac | ||
|
|
6e7c85e853 | ||
|
|
1e181785b4 | ||
|
|
eb77476d51 | ||
|
|
8e8936e9f4 | ||
|
|
dea81a5711 | ||
|
|
89c325cc7c | ||
|
|
7430ae15a8 | ||
|
|
c8420421eb | ||
|
|
441b55535e | ||
|
|
4b431358e0 | ||
|
|
c0aa725fa6 | ||
|
|
fca293c4fc | ||
|
|
beef3da647 | ||
|
|
5952d83ec8 | ||
|
|
f1a7cd8730 | ||
|
|
e9e72677a4 | ||
|
|
fd734a5c26 | ||
|
|
70953ab57a | ||
|
|
fd50d59d3c | ||
|
|
d8c665d48e | ||
|
|
39bbc2c827 | ||
|
|
3176e95a75 | ||
|
|
6f03e72a22 | ||
|
|
abf0033590 | ||
|
|
4cc6ab641e | ||
|
|
5193ba95f4 | ||
|
|
b7d30fdc06 | ||
|
|
6afb728dce | ||
|
|
543ed7532b | ||
|
|
677ee422c4 |
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
|
||||
23
.eslintrc.json
Normal file
23
.eslintrc.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"root": true,
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2020": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended"
|
||||
],
|
||||
"ignorePatterns": ["dist", ".eslintrc.json"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["react-refresh"],
|
||||
"rules": {
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ "allowConstantExport": true }
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": "warn",
|
||||
"no-unused-vars": "warn"
|
||||
}
|
||||
}
|
||||
8
.gitea/config/registry.yml
Normal file
8
.gitea/config/registry.yml
Normal file
@ -0,0 +1,8 @@
|
||||
version: "1.0"
|
||||
registries:
|
||||
- name: seu-registry
|
||||
host: seu-registry.com
|
||||
type: container
|
||||
credentials:
|
||||
username: ${REGISTRY_USERNAME}
|
||||
password: ${REGISTRY_PASSWORD}
|
||||
58
.gitea/workflows/docker-build.yml
Normal file
58
.gitea/workflows/docker-build.yml
Normal file
@ -0,0 +1,58 @@
|
||||
name: Docker Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
tags: [ 'v*' ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: seu-registry.com/historias-magicas
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: seu-registry.com
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=seu-registry.com/historias-magicas:buildcache
|
||||
cache-to: type=registry,ref=seu-registry.com/historias-magicas:buildcache,mode=max
|
||||
|
||||
- name: Update Portainer stack
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_KEY }}
|
||||
script: |
|
||||
cd /opt/portainer
|
||||
docker stack deploy -c portainer-stack.yml historias-magicas
|
||||
18
.github/workflows/deploy.yml
vendored
Normal file
18
.github/workflows/deploy.yml
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Deploy to production
|
||||
env:
|
||||
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
|
||||
run: |
|
||||
echo "$DEPLOY_KEY" > deploy_key
|
||||
chmod 600 deploy_key
|
||||
ssh -i deploy_key user@seu-servidor.com 'cd /app && ./scripts/deploy.sh'
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -9,6 +9,8 @@ lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist/
|
||||
build/
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
@ -26,3 +28,11 @@ dist-ssr
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env*
|
||||
.env.*
|
||||
.env.production
|
||||
.env.development
|
||||
|
||||
# Backup files
|
||||
*copy*
|
||||
*.bak
|
||||
|
||||
5
.vscode/extensions.json
vendored
Normal file
5
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"denoland.vscode-deno"
|
||||
]
|
||||
}
|
||||
13
CHANGELOG.md
Normal file
13
CHANGELOG.md
Normal file
@ -0,0 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## [1.1.0] - 2024-03-21
|
||||
|
||||
### Modificado
|
||||
- Melhorado o processo de upload de áudio para evitar colisões de arquivos e garantir integridade dos dados
|
||||
- Implementado processamento assíncrono de áudio via Edge Function
|
||||
|
||||
### Técnico
|
||||
- Adicionado UUID para identificação única de arquivos de áudio
|
||||
- Implementada transação atômica para upload de áudio
|
||||
- Integrada chamada assíncrona para processamento de áudio
|
||||
- Melhorado tratamento de erros no processo de upload
|
||||
40
Dockerfile
Normal file
40
Dockerfile
Normal file
@ -0,0 +1,40 @@
|
||||
# Build stage
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Adicionar dependência do Redis
|
||||
RUN apk add --no-cache redis
|
||||
|
||||
# Copiar arquivos de dependências
|
||||
COPY package*.json ./
|
||||
COPY yarn.lock ./
|
||||
|
||||
# Instalar dependências
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
# Copiar código fonte
|
||||
COPY . .
|
||||
|
||||
# Build da aplicação
|
||||
RUN yarn build
|
||||
|
||||
# Production stage
|
||||
FROM node:18-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Adicionar dependência do Redis
|
||||
RUN apk add --no-cache redis
|
||||
|
||||
# Copiar arquivos necessários do builder
|
||||
COPY --from=builder /app/next.config.js ./
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
# Expor porta
|
||||
EXPOSE 3000
|
||||
|
||||
# Comando para rodar a aplicação
|
||||
CMD ["node", "server.js"]
|
||||
15
Dockerfile.dev
Normal file
15
Dockerfile.dev
Normal file
@ -0,0 +1,15 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Adicionar dependência do Redis
|
||||
RUN apk add --no-cache redis
|
||||
|
||||
COPY package*.json ./
|
||||
COPY yarn.lock ./
|
||||
|
||||
RUN yarn install
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["yarn", "dev"]
|
||||
@ -27,6 +27,12 @@ Histórias Mágicas é uma aplicação web desenvolvida em React que permite que
|
||||
- Tailwind CSS
|
||||
- Lucide React (ícones)
|
||||
- Vite
|
||||
- Supabase
|
||||
- Supabase Functions
|
||||
- OpenAI
|
||||
- DALL-E
|
||||
|
||||
|
||||
|
||||
## 🚀 Como Executar
|
||||
|
||||
|
||||
31
docker-compose.dev.yml
Normal file
31
docker-compose.dev.yml
Normal file
@ -0,0 +1,31 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- REDIS_URL=redis://redis:6379
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@ -0,0 +1,31 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
historias-magicas:
|
||||
image: ${REGISTRY}/historias-magicas:${TAG}
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- REDIS_URL=redis://redis:6379
|
||||
networks:
|
||||
- network_public
|
||||
deploy:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.historias-magicas.rule=Host(`${DOMAIN}`)"
|
||||
- "traefik.http.routers.historias-magicas.entrypoints=websecure"
|
||||
- "traefik.http.routers.historias-magicas.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.historias-magicas.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
replicas: 1
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
networks:
|
||||
network_public:
|
||||
external: true
|
||||
22
netlify.toml
Normal file
22
netlify.toml
Normal file
@ -0,0 +1,22 @@
|
||||
[build]
|
||||
command = "npm run build"
|
||||
publish = "dist"
|
||||
|
||||
[build.environment]
|
||||
NODE_VERSION = "18"
|
||||
VITE_SUPABASE_URL = "https://bsjlbnyslxzsdwxvkaap.supabase.co"
|
||||
VITE_RESEND_API_KEY = "GEoM_cVt4qyBFVkngJWi8wBrMWOiPMUAuxuFGykcP0A"
|
||||
VITE_APP_URL = "https://historiasmagicas.netlify.app/"
|
||||
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
|
||||
[dev]
|
||||
command = "npm run dev"
|
||||
port = 5173
|
||||
publish = "dist"
|
||||
|
||||
[functions]
|
||||
node_bundler = "esbuild"
|
||||
12
next.config.js
Normal file
12
next.config.js
Normal file
@ -0,0 +1,12 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
images: {
|
||||
domains: [
|
||||
'oaidalleapiprodscus.blob.core.windows.net',
|
||||
// outros domínios necessários
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
2167
package-lock.json
generated
2167
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@ -9,20 +9,36 @@
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx}\""
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
||||
"docker:build": "docker build -t historias-magicas .",
|
||||
"docker:run": "docker run -p 3000:3000 historias-magicas",
|
||||
"deploy:prod": "docker-compose up -d --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@supabase/supabase-js": "^2.39.7",
|
||||
"@tanstack/react-query": "^5.62.8",
|
||||
"@types/ioredis": "^4.28.10",
|
||||
"@types/next": "^8.0.7",
|
||||
"clsx": "^2.1.1",
|
||||
"ioredis": "^5.4.2",
|
||||
"lucide-react": "^0.344.0",
|
||||
"next": "^15.1.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"resend": "^3.2.0"
|
||||
"recharts": "^2.15.0",
|
||||
"resend": "^3.2.0",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"uuid": "^11.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.1",
|
||||
"@types/react": "^18.3.17",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.9.1",
|
||||
@ -30,6 +46,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.11",
|
||||
"globals": "^15.9.0",
|
||||
"postcss": "^8.4.49",
|
||||
"supabase": "^2.1.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.3.0",
|
||||
|
||||
34
portainer-stack.yml
Normal file
34
portainer-stack.yml
Normal file
@ -0,0 +1,34 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
historias-magicas:
|
||||
image: ${REGISTRY}/historias-magicas:${TAG}
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- REDIS_URL=redis://redis:6379
|
||||
networks:
|
||||
- traefik-public
|
||||
- redis-network
|
||||
deploy:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.historias-magicas.rule=Host(`${DOMAIN}`)"
|
||||
- "traefik.http.routers.historias-magicas.entrypoints=websecure"
|
||||
- "traefik.http.routers.historias-magicas.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.historias-magicas.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
replicas: 2
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
redis-network:
|
||||
external: true
|
||||
4
public/book.svg
Normal file
4
public/book.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path>
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 313 B |
85
src/App.tsx
85
src/App.tsx
@ -10,6 +10,7 @@ import { AuthUser, SavedStory } from './types/auth';
|
||||
import { User, Theme } from './types';
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
type AppStep =
|
||||
| 'welcome'
|
||||
@ -20,6 +21,16 @@ type AppStep =
|
||||
| 'story'
|
||||
| 'library';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutos
|
||||
gcTime: 1000 * 60 * 30, // 30 minutos (antes era cacheTime)
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export function App() {
|
||||
const navigate = useNavigate();
|
||||
const [step, setStep] = useState<AppStep>('welcome');
|
||||
@ -74,43 +85,45 @@ export function App() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<div className="min-h-screen bg-gradient-to-b from-purple-100 to-blue-100">
|
||||
{step === 'welcome' && (
|
||||
<WelcomePage
|
||||
onLoginClick={() => setStep('login')}
|
||||
onRegisterClick={() => setStep('register')}
|
||||
/>
|
||||
)}
|
||||
{step === 'login' && (
|
||||
<div className="min-h-screen flex items-center justify-center p-6">
|
||||
<LoginForm
|
||||
userType="school"
|
||||
onLogin={handleLogin}
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<div className="min-h-screen bg-gradient-to-b from-purple-100 to-blue-100">
|
||||
{step === 'welcome' && (
|
||||
<WelcomePage
|
||||
onLoginClick={() => setStep('login')}
|
||||
onRegisterClick={() => setStep('register')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step === 'register' && (
|
||||
<RegistrationForm
|
||||
userType="school"
|
||||
onComplete={handleRegistrationComplete}
|
||||
/>
|
||||
)}
|
||||
{step === 'avatar' && user && (
|
||||
<AvatarSelector user={user} onComplete={handleAvatarComplete} />
|
||||
)}
|
||||
{step === 'theme' && <ThemeSelector onSelect={handleThemeSelect} />}
|
||||
{step === 'story' && user && selectedTheme && (
|
||||
<StoryViewer theme={selectedTheme} user={user} />
|
||||
)}
|
||||
{step === 'library' && authUser && (
|
||||
<StoryLibrary
|
||||
stories={savedStories}
|
||||
onStorySelect={handleStorySelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AuthProvider>
|
||||
)}
|
||||
{step === 'login' && (
|
||||
<div className="min-h-screen flex items-center justify-center p-6">
|
||||
<LoginForm
|
||||
userType="school"
|
||||
onLogin={handleLogin}
|
||||
onRegisterClick={() => setStep('register')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step === 'register' && (
|
||||
<RegistrationForm
|
||||
userType="school"
|
||||
onComplete={handleRegistrationComplete}
|
||||
/>
|
||||
)}
|
||||
{step === 'avatar' && user && (
|
||||
<AvatarSelector user={user} onComplete={handleAvatarComplete} />
|
||||
)}
|
||||
{step === 'theme' && <ThemeSelector onSelect={handleThemeSelect} />}
|
||||
{step === 'story' && user && selectedTheme && (
|
||||
<StoryViewer theme={selectedTheme} user={user} />
|
||||
)}
|
||||
{step === 'library' && authUser && (
|
||||
<StoryLibrary
|
||||
stories={savedStories}
|
||||
onStorySelect={handleStorySelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
71
src/components/audio/AudioUploader.tsx
Normal file
71
src/components/audio/AudioUploader.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { processAudio } from '../../services/audioService';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
interface AudioUploaderProps {
|
||||
storyId: string;
|
||||
onUploadComplete?: (transcription: string) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export function AudioUploader({
|
||||
storyId,
|
||||
onUploadComplete,
|
||||
onError
|
||||
}: AudioUploaderProps): JSX.Element {
|
||||
const [isProcessing, setIsProcessing] = React.useState(false);
|
||||
const [error, setError] = React.useState<string>();
|
||||
|
||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
setError(undefined);
|
||||
|
||||
const response = await processAudio(file, storyId);
|
||||
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
onError?.(response.error);
|
||||
} else if (response.transcription) {
|
||||
onUploadComplete?.(response.transcription);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Erro ao processar áudio';
|
||||
setError(errorMessage);
|
||||
onError?.(errorMessage);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={handleFileUpload}
|
||||
disabled={isProcessing}
|
||||
className="hidden"
|
||||
id="audio-upload"
|
||||
/>
|
||||
<label htmlFor="audio-upload">
|
||||
<Button
|
||||
as="span"
|
||||
disabled={isProcessing}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{isProcessing ? 'Processando...' : 'Enviar Áudio'}
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { LogIn } from 'lucide-react';
|
||||
import { LogIn, Eye, EyeOff, School, GraduationCap, User } from 'lucide-react';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
interface LoginFormProps {
|
||||
userType: 'school' | 'teacher' | 'student';
|
||||
@ -9,99 +10,178 @@ interface LoginFormProps {
|
||||
onRegisterClick?: () => void;
|
||||
}
|
||||
|
||||
const userTypeIcons = {
|
||||
school: <School className="h-8 w-8 text-purple-600" />,
|
||||
teacher: <GraduationCap className="h-8 w-8 text-purple-600" />,
|
||||
student: <User className="h-8 w-8 text-purple-600" />
|
||||
};
|
||||
|
||||
const userTypeLabels = {
|
||||
school: 'Escola',
|
||||
teacher: 'Professor',
|
||||
student: 'Aluno'
|
||||
};
|
||||
|
||||
export function LoginForm({ userType, onLogin, onRegisterClick }: LoginFormProps) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { signIn } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { user } = await signIn(email, password);
|
||||
if (user) {
|
||||
if (userType === 'school') {
|
||||
navigate('/dashboard');
|
||||
} else if (onLogin) {
|
||||
await onLogin({ email, password });
|
||||
}
|
||||
console.log('Tentando login com:', { email, userType });
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email: email,
|
||||
password: password
|
||||
});
|
||||
|
||||
console.log('Resposta do Supabase:', { data, error });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
if (!data.user) {
|
||||
throw new Error('Usuário não encontrado');
|
||||
}
|
||||
|
||||
const userRole = data.user.user_metadata.role;
|
||||
console.log('Metadados do usuário:', data.user.user_metadata);
|
||||
console.log('Role esperado:', userType);
|
||||
console.log('Role atual:', userRole);
|
||||
|
||||
if (userRole !== userType) {
|
||||
throw new Error(`Este não é um login de ${userTypeLabels[userType]}`);
|
||||
}
|
||||
|
||||
switch (userType) {
|
||||
case 'school':
|
||||
navigate('/dashboard');
|
||||
break;
|
||||
case 'teacher':
|
||||
navigate('/professor');
|
||||
break;
|
||||
case 'student':
|
||||
navigate('/aluno');
|
||||
break;
|
||||
default:
|
||||
throw new Error('Tipo de usuário inválido');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
setError('Erro ao fazer login. Verifique suas credenciais.');
|
||||
console.error('Erro no login:', err);
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('Email ou senha incorretos');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-center text-purple-600">
|
||||
Bem-vindo de volta!
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-gray-600">
|
||||
Continue sua jornada de histórias mágicas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Senha
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
/>
|
||||
<div className="min-h-screen bg-gradient-to-b from-purple-50 to-white py-12">
|
||||
<div className="max-w-md mx-auto px-4">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-purple-100 mb-4">
|
||||
{userTypeIcons[userType]}
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Bem-vindo de volta!
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Faça login como {userTypeLabels[userType]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent rounded-lg shadow-sm text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
|
||||
>
|
||||
<LogIn className="w-5 h-5" />
|
||||
Entrar
|
||||
</button>
|
||||
|
||||
{onRegisterClick && (
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegisterClick}
|
||||
className="text-purple-600 hover:text-purple-500"
|
||||
>
|
||||
Criar uma nova conta
|
||||
</button>
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Senha
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent rounded-lg shadow-sm text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
'Entrando...'
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="h-5 w-5" />
|
||||
Entrar
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{onRegisterClick && (
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Ainda não tem uma conta?{' '}
|
||||
<button
|
||||
onClick={onRegisterClick}
|
||||
className="text-purple-600 hover:text-purple-500 font-medium"
|
||||
>
|
||||
Cadastre-se
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/components/auth/ProtectedRoute.tsx
Normal file
56
src/components/auth/ProtectedRoute.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import type { AuthContextType } from '../../hooks/useAuth';
|
||||
import type { UserRole } from '../../types/supabase';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
allowedRoles?: UserRole[];
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children, allowedRoles = [] }: ProtectedRouteProps) {
|
||||
const { user, loading, userRole } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
console.log('ProtectedRoute - User:', user?.user_metadata);
|
||||
console.log('ProtectedRoute - UserRole do contexto:', userRole);
|
||||
console.log('ProtectedRoute - Roles permitidas:', allowedRoles);
|
||||
|
||||
if (loading) {
|
||||
return <div>Carregando...</div>;
|
||||
}
|
||||
|
||||
// Se não houver usuário, redireciona para login
|
||||
if (!user) {
|
||||
return <Navigate to="/login/school" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
// Pegar o role diretamente dos metadados do usuário
|
||||
const currentRole = user.user_metadata?.role;
|
||||
console.log('ProtectedRoute - Role dos metadados:', currentRole);
|
||||
|
||||
// Se não houver roles requeridas, permite acesso
|
||||
if (allowedRoles.length === 0) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Se o usuário não tiver o role necessário
|
||||
if (!allowedRoles.includes(currentRole)) {
|
||||
console.log('ProtectedRoute - Acesso negado');
|
||||
|
||||
// Redireciona para a página apropriada
|
||||
switch (currentRole) {
|
||||
case 'school':
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
case 'teacher':
|
||||
return <Navigate to="/professor" replace />;
|
||||
case 'student':
|
||||
return <Navigate to="/aluno" replace />;
|
||||
default:
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
263
src/components/auth/SchoolRegistrationForm.tsx
Normal file
263
src/components/auth/SchoolRegistrationForm.tsx
Normal file
@ -0,0 +1,263 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { School, Eye, EyeOff } from 'lucide-react';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
export function SchoolRegistrationForm() {
|
||||
const navigate = useNavigate();
|
||||
const { error: authError } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
schoolName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
directorName: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('As senhas não coincidem');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Criar usuário na autenticação
|
||||
const { data: authData, error: signUpError } = await supabase.auth.signUp({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
options: {
|
||||
data: {
|
||||
role: 'school_admin',
|
||||
school_name: formData.schoolName,
|
||||
director_name: formData.directorName
|
||||
},
|
||||
emailRedirectTo: `${window.location.origin}/auth/callback`
|
||||
}
|
||||
});
|
||||
|
||||
if (signUpError) {
|
||||
console.error('Erro no signup:', signUpError);
|
||||
throw new Error(signUpError.message);
|
||||
}
|
||||
|
||||
if (!authData.user?.id) {
|
||||
throw new Error('Usuário não foi criado corretamente');
|
||||
}
|
||||
|
||||
// 2. Criar registro da escola usando a função do servidor
|
||||
const { error: schoolError } = await supabase.rpc('create_school', {
|
||||
school_id: authData.user.id,
|
||||
school_name: formData.schoolName,
|
||||
school_email: formData.email,
|
||||
director_name: formData.directorName
|
||||
});
|
||||
|
||||
if (schoolError) {
|
||||
console.error('Erro ao criar escola:', schoolError);
|
||||
throw new Error(schoolError.message);
|
||||
}
|
||||
|
||||
// 3. Redirecionar para login com mensagem de sucesso
|
||||
navigate('/login/school', {
|
||||
state: {
|
||||
message: 'Cadastro realizado com sucesso! Por favor, verifique seu email para confirmar o cadastro.'
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Erro detalhado:', err);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Erro ao cadastrar escola. Por favor, tente novamente.'
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePasswordVisibility = (field: 'password' | 'confirmPassword') => {
|
||||
if (field === 'password') {
|
||||
setShowPassword(!showPassword);
|
||||
} else {
|
||||
setShowConfirmPassword(!showConfirmPassword);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-purple-50 to-white py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-2xl mx-auto bg-white rounded-xl shadow-md p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center">
|
||||
<School className="h-12 w-12 text-purple-600" />
|
||||
</div>
|
||||
<h2 className="mt-4 text-3xl font-bold text-gray-900">
|
||||
Cadastro de Escola
|
||||
</h2>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Comece a transformar a educação com histórias interativas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(error || authError) && (
|
||||
<div className="mb-4 p-4 bg-red-50 text-red-600 rounded-lg text-sm">
|
||||
{error || authError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="schoolName" className="block text-sm font-medium text-gray-700">
|
||||
Nome da Escola
|
||||
</label>
|
||||
<input
|
||||
id="schoolName"
|
||||
name="schoolName"
|
||||
type="text"
|
||||
required
|
||||
value={formData.schoolName}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email Institucional
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="relative">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Senha
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePasswordVisibility('password')}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
|
||||
Confirmar Senha
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
required
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePasswordVisibility('confirmPassword')}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="directorName" className="block text-sm font-medium text-gray-700">
|
||||
Nome do Diretor(a)
|
||||
</label>
|
||||
<input
|
||||
id="directorName"
|
||||
name="directorName"
|
||||
type="text"
|
||||
required
|
||||
value={formData.directorName}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="terms"
|
||||
name="terms"
|
||||
type="checkbox"
|
||||
required
|
||||
className="h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="terms" className="ml-2 block text-sm text-gray-900">
|
||||
Li e aceito os{' '}
|
||||
<a href="#" className="text-purple-600 hover:text-purple-500">
|
||||
termos de uso
|
||||
</a>{' '}
|
||||
e a{' '}
|
||||
<a href="#" className="text-purple-600 hover:text-purple-500">
|
||||
política de privacidade
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-lg font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Cadastrando...' : 'Cadastrar Escola'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Já tem uma conta?{' '}
|
||||
<button
|
||||
onClick={() => navigate('/login/school')}
|
||||
className="text-purple-600 hover:text-purple-500 font-medium"
|
||||
>
|
||||
Faça login
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/components/dashboard/StatsCard.tsx
Normal file
24
src/components/dashboard/StatsCard.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface StatsCardProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
value: number;
|
||||
iconBgColor: string;
|
||||
iconColor: string;
|
||||
}
|
||||
|
||||
export function StatsCard({ icon: Icon, title, value, iconBgColor, iconColor }: StatsCardProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl p-6 flex items-center gap-4 border border-gray-200 shadow-sm">
|
||||
<div className={`w-12 h-12 rounded-xl ${iconBgColor} flex items-center justify-center`}>
|
||||
<Icon className={`h-6 w-6 ${iconColor}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600">{title}</p>
|
||||
<p className="text-3xl font-bold">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
src/components/demo/AudioRecorderDemo.tsx
Normal file
154
src/components/demo/AudioRecorderDemo.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Mic, Square, Loader, Play, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface AudioRecorderDemoProps {
|
||||
onAnalysisComplete: (result: {
|
||||
fluency: number;
|
||||
accuracy: number;
|
||||
confidence: number;
|
||||
feedback: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function AudioRecorderDemo({ onAnalysisComplete }: AudioRecorderDemoProps) {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mediaRecorderRef.current = new MediaRecorder(stream);
|
||||
chunksRef.current = [];
|
||||
|
||||
mediaRecorderRef.current.ondataavailable = (e) => {
|
||||
chunksRef.current.push(e.data);
|
||||
};
|
||||
|
||||
mediaRecorderRef.current.onstop = () => {
|
||||
const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||
setAudioBlob(audioBlob);
|
||||
};
|
||||
|
||||
mediaRecorderRef.current.start();
|
||||
setIsRecording(true);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Erro ao acessar microfone. Verifique as permissões.');
|
||||
console.error('Erro ao iniciar gravação:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
|
||||
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
|
||||
const analyzeAudio = async () => {
|
||||
if (!audioBlob) return;
|
||||
|
||||
setIsAnalyzing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Simulação de análise para demo
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Resultados simulados para demonstração
|
||||
onAnalysisComplete({
|
||||
fluency: Math.floor(Math.random() * 20) + 80, // 80-100
|
||||
accuracy: Math.floor(Math.random() * 15) + 85, // 85-100
|
||||
confidence: Math.floor(Math.random() * 25) + 75, // 75-100
|
||||
feedback: "Excelente leitura! Sua fluência está muito boa e você demonstra confiança na pronúncia. Continue praticando para melhorar ainda mais."
|
||||
});
|
||||
} catch (err) {
|
||||
setError('Erro ao analisar áudio. Tente novamente.');
|
||||
console.error('Erro na análise:', err);
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetRecording = () => {
|
||||
setAudioBlob(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 rounded-xl">
|
||||
<div className="flex flex-wrap items-center gap-4 justify-center">
|
||||
{!isRecording && !audioBlob && (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-red-600 text-white rounded-xl hover:bg-red-700 transition"
|
||||
>
|
||||
<Mic className="w-5 h-5" />
|
||||
Iniciar Gravação
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<button
|
||||
onClick={stopRecording}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gray-600 text-white rounded-xl hover:bg-gray-700 transition"
|
||||
>
|
||||
<Square className="w-5 h-5" />
|
||||
Parar Gravação
|
||||
</button>
|
||||
)}
|
||||
|
||||
{audioBlob && !isAnalyzing && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = URL.createObjectURL(audioBlob);
|
||||
const audio = new Audio(url);
|
||||
audio.play();
|
||||
}}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition"
|
||||
>
|
||||
<Play className="w-5 h-5" />
|
||||
Ouvir
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={analyzeAudio}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-green-600 text-white rounded-xl hover:bg-green-700 transition"
|
||||
>
|
||||
Analisar Leitura
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={resetRecording}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gray-200 text-gray-600 rounded-xl hover:bg-gray-300 transition"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
Recomeçar
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAnalyzing && (
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<Loader className="w-5 h-5 animate-spin" />
|
||||
Analisando sua leitura...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 text-red-600 text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
src/components/header/Header.tsx
Normal file
42
src/components/header/Header.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { ProfileMenu } from './ProfileMenu';
|
||||
|
||||
export function Header() {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<img src="/logo.svg" alt="Logo" className="h-8 w-8" />
|
||||
<span className="font-semibold text-gray-900">Histórias Mágicas</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{user ? (
|
||||
<ProfileMenu />
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
to="/login/school"
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Entrar
|
||||
</Link>
|
||||
<Link
|
||||
to="/register/school"
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||||
>
|
||||
Cadastrar Escola
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
100
src/components/header/ProfileMenu.tsx
Normal file
100
src/components/header/ProfileMenu.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { LogOut, Settings, LayoutDashboard } from 'lucide-react';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import type { AuthContextType } from '../../hooks/useAuth';
|
||||
import type { UserRole } from '../../types/supabase';
|
||||
|
||||
export function ProfileMenu() {
|
||||
const { user, userRole, signOut } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fecha o menu quando clicar fora dele
|
||||
React.useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const getDashboardPath = () => {
|
||||
switch (userRole) {
|
||||
case 'school':
|
||||
return '/dashboard';
|
||||
case 'teacher':
|
||||
return '/professor';
|
||||
case 'student':
|
||||
return '/aluno';
|
||||
default:
|
||||
return '/';
|
||||
}
|
||||
};
|
||||
|
||||
const getProfilePath = () => {
|
||||
switch (userRole) {
|
||||
case 'school':
|
||||
return '/dashboard/configuracoes';
|
||||
case 'teacher':
|
||||
return '/professor/perfil';
|
||||
case 'student':
|
||||
return '/aluno/perfil';
|
||||
default:
|
||||
return '/';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center hover:bg-purple-200 transition"
|
||||
>
|
||||
<span className="text-lg font-medium text-purple-600">
|
||||
{user?.user_metadata?.name?.[0]?.toUpperCase() || user?.email?.[0]?.toUpperCase()}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg py-1 border border-gray-200">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate(getDashboardPath());
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2"
|
||||
>
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
Acessar Dashboard
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate(getProfilePath());
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Meu Perfil
|
||||
</button>
|
||||
|
||||
<hr className="my-1" />
|
||||
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-gray-100 flex items-center gap-2"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sair
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
594
src/components/home/HomePage.tsx
Normal file
594
src/components/home/HomePage.tsx
Normal file
@ -0,0 +1,594 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
BookOpen, ArrowRight, School, Users, Shield,
|
||||
Sparkles, BookCheck, Play, CheckCircle, Star,
|
||||
GraduationCap, BarChart, Brain, X, Check,
|
||||
Pencil,
|
||||
Wand,
|
||||
Mic,
|
||||
Share2
|
||||
} from 'lucide-react';
|
||||
|
||||
// Components
|
||||
const FeatureCard = ({ icon, title, description }: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
}) => (
|
||||
<div className="p-6 rounded-xl border border-gray-200 hover:shadow-lg transition bg-white">
|
||||
<div className="w-12 h-12 rounded-lg bg-purple-100 flex items-center justify-center mb-4">
|
||||
<div className="text-purple-600">{icon}</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
<p className="text-gray-600">{description}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const StatCard = ({ number, label }: { number: string; label: string }) => (
|
||||
<div className="p-6">
|
||||
<div className="text-4xl font-bold mb-2">{number}</div>
|
||||
<div className="text-purple-200">{label}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const TestimonialCard = ({ quote, author, role, image }: {
|
||||
quote: string;
|
||||
author: string;
|
||||
role: string;
|
||||
image: string;
|
||||
}) => (
|
||||
<div className="p-6 rounded-xl bg-white shadow-md">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<img src={image} alt={author} className="w-12 h-12 rounded-full" />
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900">{author}</div>
|
||||
<div className="text-sm text-gray-600">{role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 italic">"{quote}"</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PriceCard = ({
|
||||
plan,
|
||||
price,
|
||||
description,
|
||||
features,
|
||||
highlighted = false
|
||||
}: {
|
||||
plan: string;
|
||||
price: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
highlighted?: boolean;
|
||||
}) => (
|
||||
<div className={`p-6 rounded-xl border ${
|
||||
highlighted ? 'border-purple-600 shadow-lg' : 'border-gray-200'
|
||||
}`}>
|
||||
<div className="text-xl font-semibold text-gray-900 mb-2">{plan}</div>
|
||||
<div className="text-3xl font-bold text-gray-900 mb-2">
|
||||
R$ {price}<span className="text-sm font-normal text-gray-600">/mês</span>
|
||||
</div>
|
||||
<p className="text-gray-600 mb-6">{description}</p>
|
||||
<ul className="space-y-3 mb-6">
|
||||
{features.map((feature, index) => (
|
||||
<li key={index} className="flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-purple-600" />
|
||||
<span className="text-gray-600">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button className={`w-full py-2 px-4 rounded-lg transition ${
|
||||
highlighted
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'border border-purple-600 text-purple-600 hover:bg-purple-50'
|
||||
}`}>
|
||||
Começar agora
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const [showUserOptions, setShowUserOptions] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('schools');
|
||||
const [showFaq, setShowFaq] = useState<number | null>(null);
|
||||
|
||||
// Navigation handlers
|
||||
const handleLoginClick = () => setShowUserOptions(!showUserOptions);
|
||||
const handleSchoolLogin = () => navigate('/login/school');
|
||||
const handleTeacherLogin = () => navigate('/login/teacher');
|
||||
const handleStudentLogin = () => navigate('/login/student');
|
||||
const handleSchoolRegister = () => navigate('/register/school');
|
||||
const handleDemo = () => navigate('/demo');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-purple-50 to-white">
|
||||
{/* Header/Nav */}
|
||||
<nav className="bg-white/80 backdrop-blur-md fixed w-full z-50 shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center">
|
||||
<BookOpen className="h-8 w-8 text-purple-600" />
|
||||
<span className="ml-2 text-xl font-bold text-gray-900">Histórias Mágicas</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleLoginClick}
|
||||
className="text-gray-600 hover:text-gray-900 px-3 py-2"
|
||||
>
|
||||
Entrar
|
||||
</button>
|
||||
{showUserOptions && (
|
||||
<div className="absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5">
|
||||
<div className="py-1" role="menu">
|
||||
<button
|
||||
onClick={handleSchoolLogin}
|
||||
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-purple-50"
|
||||
>
|
||||
Entrar como Escola
|
||||
</button>
|
||||
<button
|
||||
onClick={handleTeacherLogin}
|
||||
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-purple-50"
|
||||
>
|
||||
Entrar como Professor
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStudentLogin}
|
||||
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-purple-50"
|
||||
>
|
||||
Entrar como Aluno
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSchoolRegister}
|
||||
className="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition"
|
||||
>
|
||||
Cadastrar Escola
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<div className="pt-32 pb-20 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<div className="inline-block px-4 py-1 rounded-full bg-purple-100 text-purple-600 font-medium text-sm mb-6">
|
||||
Potencializado por IA 🚀
|
||||
</div>
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-gray-900 mb-6">
|
||||
Transforme a educação com
|
||||
<span className="text-purple-600"> histórias inteligentes</span>
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 mb-8">
|
||||
Uma plataforma educacional que usa IA para criar experiências de
|
||||
aprendizado personalizadas através de histórias interativas.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<button
|
||||
onClick={handleSchoolRegister}
|
||||
className="bg-purple-600 text-white px-8 py-4 rounded-xl hover:bg-purple-700 transition flex items-center justify-center gap-2 text-lg font-semibold"
|
||||
>
|
||||
Começar Gratuitamente
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDemo}
|
||||
className="border-2 border-purple-600 text-purple-600 px-8 py-4 rounded-xl hover:bg-purple-50 transition text-lg font-semibold flex items-center justify-center gap-2"
|
||||
>
|
||||
<Play className="w-5 h-5" />
|
||||
Ver Demo
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-8 flex items-center gap-4">
|
||||
<div className="flex -space-x-2">
|
||||
{[1,2,3,4].map((i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={`/avatars/${i}.jpg`}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full border-2 border-white"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="text-purple-600 font-semibold">+1000 escolas</span> já
|
||||
transformaram sua educação
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="aspect-video rounded-xl overflow-hidden shadow-2xl">
|
||||
<img
|
||||
src="/demo-platform.png"
|
||||
alt="Platform demo"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={handleDemo}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/30 hover:bg-black/40 transition group"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-full bg-white/90 flex items-center justify-center">
|
||||
<Play className="w-8 h-8 text-purple-600 group-hover:scale-110 transition" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Student Journey Section */}
|
||||
<div className="py-20 bg-gradient-to-b from-purple-50 to-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Jornada do Aluno
|
||||
</h2>
|
||||
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||
Um processo inteligente e envolvente de aprendizado
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/* Timeline Line */}
|
||||
<div className="hidden md:block absolute left-1/2 transform -translate-x-1/2 h-full w-0.5 bg-purple-200" />
|
||||
|
||||
{/* Timeline Items */}
|
||||
{[
|
||||
{
|
||||
icon: <Pencil className="w-6 h-6" />,
|
||||
title: "Criação Personalizada",
|
||||
description: "O aluno cria uma história baseada em seus interesses e características pessoais",
|
||||
image: "/journey/create-story.png"
|
||||
},
|
||||
{
|
||||
icon: <Wand className="w-6 h-6" />,
|
||||
title: "Geração por IA",
|
||||
description: "Nossa IA avançada gera uma história única e personalizada",
|
||||
image: "/journey/ai-generation.png"
|
||||
},
|
||||
{
|
||||
icon: <Mic className="w-6 h-6" />,
|
||||
title: "Gravação de Áudio",
|
||||
description: "O aluno grava sua voz lendo a história criada",
|
||||
image: "/journey/audio-recording.png"
|
||||
},
|
||||
{
|
||||
icon: <BarChart className="w-6 h-6" />,
|
||||
title: "Análise de Leitura",
|
||||
description: "A IA analisa a leitura e fornece feedback detalhado sobre o desempenho",
|
||||
image: "/journey/reading-analysis.png"
|
||||
},
|
||||
{
|
||||
icon: <Share2 className="w-6 h-6" />,
|
||||
title: "Compartilhamento de Resultados",
|
||||
description: "Dados e insights são compartilhados com pais, professores e escola",
|
||||
image: "/journey/share-results.png"
|
||||
}
|
||||
].map((item, index) => (
|
||||
<div key={index} className={`mb-12 md:mb-24 relative ${
|
||||
index % 2 === 0 ? 'md:text-right' : ''
|
||||
}`}>
|
||||
<div className={`flex items-center gap-8 ${
|
||||
index % 2 === 0 ? 'md:flex-row-reverse' : ''
|
||||
}`}>
|
||||
{/* Content Side */}
|
||||
<div className="flex-1">
|
||||
<div className={`bg-white rounded-xl shadow-lg p-6 transform transition-all duration-300 hover:scale-105 ${
|
||||
index % 2 === 0 ? 'md:ml-auto' : ''
|
||||
}`}>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 rounded-full bg-purple-100 flex items-center justify-center text-purple-600">
|
||||
{item.icon}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900">
|
||||
{item.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline Marker */}
|
||||
<div className="hidden md:flex items-center justify-center">
|
||||
<div className="w-12 h-12 rounded-full bg-purple-600 text-white flex items-center justify-center font-bold">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Side */}
|
||||
<div className="flex-1 hidden md:block">
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.title}
|
||||
className="rounded-xl shadow-lg w-full max-w-md mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Results Summary */}
|
||||
<div className="mt-16 bg-white rounded-xl shadow-lg p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Resultados Comprovados
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Nossa abordagem inovadora tem transformado a experiência de leitura
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-4 gap-8">
|
||||
{[
|
||||
{
|
||||
number: "95%",
|
||||
label: "Melhoria na fluência de leitura"
|
||||
},
|
||||
{
|
||||
number: "87%",
|
||||
label: "Aumento no engajamento"
|
||||
},
|
||||
{
|
||||
number: "92%",
|
||||
label: "Satisfação dos pais"
|
||||
},
|
||||
{
|
||||
number: "3x",
|
||||
label: "Mais histórias lidas por aluno"
|
||||
}
|
||||
].map((stat, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<div className="text-3xl font-bold text-purple-600 mb-2">
|
||||
{stat.number}
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm">
|
||||
{stat.label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Grid */}
|
||||
<div className="py-20 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Tecnologia e Educação em Harmonia
|
||||
</h2>
|
||||
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||
Uma plataforma completa que une o poder da IA com as melhores práticas pedagógicas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<FeatureCard
|
||||
icon={<Brain />}
|
||||
title="IA Adaptativa"
|
||||
description="Conteúdo que se adapta ao ritmo e estilo de aprendizagem de cada aluno"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<BookOpen />}
|
||||
title="Histórias Interativas"
|
||||
description="Narrativas envolventes que tornam o aprendizado mais divertido e eficaz"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<BarChart />}
|
||||
title="Analytics Avançado"
|
||||
description="Insights detalhados sobre o progresso e engajamento dos alunos"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Users />}
|
||||
title="Colaboração"
|
||||
description="Ferramentas para professores trabalharem juntos e compartilharem recursos"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Shield />}
|
||||
title="Ambiente Seguro"
|
||||
description="Proteção de dados e conteúdo adequado para todas as idades"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<GraduationCap />}
|
||||
title="Suporte Pedagógico"
|
||||
description="Recursos e orientações para maximizar o potencial de aprendizagem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Before & After Section */}
|
||||
<div className="py-20 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Transforme a Experiência de Aprendizagem
|
||||
</h2>
|
||||
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||
Veja como o Histórias Mágicas revoluciona o ensino
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-16">
|
||||
{/* Before */}
|
||||
<div className="rounded-2xl bg-red-50 p-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<X className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-gray-900">Antes</h3>
|
||||
</div>
|
||||
<ul className="space-y-4">
|
||||
{[
|
||||
'Conteúdo padronizado que não atende necessidades individuais',
|
||||
'Alunos desmotivados com material didático tradicional',
|
||||
'Professores sobrecarregados com correções manuais',
|
||||
'Dificuldade em acompanhar o progresso individual',
|
||||
'Baixo engajamento nas atividades de leitura e escrita',
|
||||
'Falta de dados para tomada de decisão pedagógica'
|
||||
].map((item, index) => (
|
||||
<li key={index} className="flex items-start gap-3">
|
||||
<div className="mt-1">
|
||||
<X className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
<span className="text-gray-600">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* After */}
|
||||
<div className="rounded-2xl bg-green-50 p-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-12 h-12 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<Check className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-gray-900">Depois</h3>
|
||||
</div>
|
||||
<ul className="space-y-4">
|
||||
{[
|
||||
'Histórias adaptativas que evoluem com cada aluno',
|
||||
'Estudantes engajados com conteúdo personalizado',
|
||||
'Correção automática com feedback instantâneo',
|
||||
'Dashboard em tempo real do progresso individual',
|
||||
'Aumento de 300% no engajamento com leitura',
|
||||
'Insights precisos para intervenções pedagógicas'
|
||||
].map((item, index) => (
|
||||
<li key={index} className="flex items-start gap-3">
|
||||
<div className="mt-1">
|
||||
<Check className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<span className="text-gray-600">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Results Preview */}
|
||||
<div className="md:col-span-2 mt-8">
|
||||
<div className="bg-white rounded-xl shadow-lg p-8">
|
||||
<div className="grid md:grid-cols-3 gap-8 text-center">
|
||||
<div>
|
||||
<div className="text-4xl font-bold text-purple-600 mb-2">300%</div>
|
||||
<p className="text-gray-600">Aumento no engajamento</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-4xl font-bold text-purple-600 mb-2">85%</div>
|
||||
<p className="text-gray-600">Melhoria no desempenho</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-4xl font-bold text-purple-600 mb-2">50%</div>
|
||||
<p className="text-gray-600">Redução da carga dos professores</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="py-20 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Planos para Cada Necessidade
|
||||
</h2>
|
||||
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||
Escolha o plano ideal para sua escola
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
<PriceCard
|
||||
plan="Básico"
|
||||
price="497"
|
||||
description="Ideal para escolas pequenas"
|
||||
features={[
|
||||
"Até 200 alunos",
|
||||
"Histórias básicas",
|
||||
"Suporte por email"
|
||||
]}
|
||||
/>
|
||||
<PriceCard
|
||||
plan="Profissional"
|
||||
price="997"
|
||||
description="Para escolas em crescimento"
|
||||
features={[
|
||||
"Até 1000 alunos",
|
||||
"Histórias personalizadas",
|
||||
"Suporte prioritário",
|
||||
"Analytics avançado"
|
||||
]}
|
||||
highlighted
|
||||
/>
|
||||
<PriceCard
|
||||
plan="Enterprise"
|
||||
price="Consulte"
|
||||
description="Para redes de ensino"
|
||||
features={[
|
||||
"Alunos ilimitados",
|
||||
"Customização completa",
|
||||
"Suporte 24/7",
|
||||
"API dedicada"
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Final CTA */}
|
||||
<div className="py-20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 rounded-2xl p-8 md:p-16 text-center text-white">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">
|
||||
Pronto para Transformar sua Escola?
|
||||
</h2>
|
||||
<p className="text-lg mb-8 max-w-2xl mx-auto">
|
||||
Junte-se a mais de 1000 escolas que já estão revolucionando a educação
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSchoolRegister}
|
||||
className="bg-white text-purple-600 px-8 py-4 rounded-xl hover:bg-purple-50 transition text-lg font-semibold"
|
||||
>
|
||||
Começar Gratuitamente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid md:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<BookOpen className="h-8 w-8" />
|
||||
<span className="text-xl font-bold">Histórias Mágicas</span>
|
||||
</div>
|
||||
<p className="text-gray-400">
|
||||
Transformando a educação através de histórias interativas
|
||||
</p>
|
||||
</div>
|
||||
{/* Adicione mais seções do footer conforme necessário */}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/components/layout/RootLayout.tsx
Normal file
10
src/components/layout/RootLayout.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
export function RootLayout(): JSX.Element {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/components/layouts/DashboardLayout.tsx
Normal file
47
src/components/layouts/DashboardLayout.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { DashboardSidebar } from './DashboardSidebar';
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DashboardLayout({ children }: DashboardLayoutProps): JSX.Element {
|
||||
const [sidebarOpen, setSidebarOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
type="button"
|
||||
className="inline-flex items-center p-2 mt-2 ms-3 text-sm text-gray-500 rounded-lg sm:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
|
||||
>
|
||||
<span className="sr-only">Abrir menu</span>
|
||||
<svg className="w-6 h-6" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path clipRule="evenodd" fillRule="evenodd" d="M2 4.75A.75.75 0 012.75 4h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 4.75zm0 10.5a.75.75 0 01.75-.75h7.5a.75.75 0 010 1.5h-7.5a.75.75 0 01-.75-.75zM2 10a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 10z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<aside
|
||||
className={`fixed top-0 left-0 z-40 w-64 h-screen transition-transform ${
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
} sm:translate-x-0`}
|
||||
>
|
||||
<div className="h-full px-3 py-4 overflow-y-auto bg-white border-r border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||
<div className="flex items-center pb-4 mb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<img src="/logo.svg" className="h-8 me-3" alt="Logo" />
|
||||
<span className="self-center text-xl font-semibold whitespace-nowrap dark:text-white">
|
||||
Histórias Mágicas
|
||||
</span>
|
||||
</div>
|
||||
<DashboardSidebar />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="p-4 sm:ml-64">
|
||||
<div className="p-4 border-2 border-gray-200 border-dashed rounded-lg dark:border-gray-700">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
39
src/components/layouts/DashboardPageLayout.tsx
Normal file
39
src/components/layouts/DashboardPageLayout.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { DashboardSidebar } from './DashboardSidebar';
|
||||
|
||||
interface DashboardPageLayoutProps {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function DashboardPageLayout({
|
||||
children,
|
||||
title,
|
||||
description
|
||||
}: DashboardPageLayoutProps): JSX.Element {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<DashboardSidebar />
|
||||
|
||||
<main className="flex-1 min-h-screen transition-all duration-300">
|
||||
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-primary mb-2">
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="text-sm sm:text-base text-gray-600">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/components/layouts/DashboardSidebar.tsx
Normal file
37
src/components/layouts/DashboardSidebar.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { LayoutDashboard, Users, GraduationCap, UserCircle, BookOpen, Settings, LogOut } from 'lucide-react';
|
||||
|
||||
const links = [
|
||||
{ icon: <LayoutDashboard className="w-5 h-5" />, label: 'Visão Geral', href: '/dashboard' },
|
||||
{ icon: <Users className="w-5 h-5" />, label: 'Turmas', href: '/dashboard/turmas' },
|
||||
{ icon: <GraduationCap className="w-5 h-5" />, label: 'Professores', href: '/dashboard/professores' },
|
||||
{ icon: <UserCircle className="w-5 h-5" />, label: 'Alunos', href: '/dashboard/alunos' },
|
||||
{ icon: <BookOpen className="w-5 h-5" />, label: 'Histórias', href: '/dashboard/historias' },
|
||||
{ icon: <Settings className="w-5 h-5" />, label: 'Configurações', href: '/dashboard/configuracoes' },
|
||||
{ icon: <LogOut className="w-5 h-5" />, label: 'Sair', href: '/logout' },
|
||||
];
|
||||
|
||||
export function DashboardSidebar(): JSX.Element {
|
||||
return (
|
||||
<ul className="space-y-2 font-medium">
|
||||
{links.map((link) => (
|
||||
<li key={link.href}>
|
||||
<NavLink
|
||||
to={link.href}
|
||||
className={({ isActive }) => `
|
||||
flex items-center p-2 text-gray-900 rounded-lg dark:text-white
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700 group
|
||||
${isActive ? 'bg-gray-100 dark:bg-gray-700' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex-shrink-0 w-5 h-5 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white">
|
||||
{link.icon}
|
||||
</div>
|
||||
<span className="flex-1 ms-3 whitespace-nowrap">{link.label}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
233
src/components/story/AudioRecorder.tsx
Normal file
233
src/components/story/AudioRecorder.tsx
Normal file
@ -0,0 +1,233 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Mic, Square, Loader, Play, Upload } from 'lucide-react';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
interface AudioRecorderProps {
|
||||
storyId: string;
|
||||
studentId: string;
|
||||
onAudioUploaded: (audioUrl: string) => void;
|
||||
}
|
||||
|
||||
export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioRecorderProps) {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mediaRecorderRef.current = new MediaRecorder(stream);
|
||||
chunksRef.current = [];
|
||||
|
||||
mediaRecorderRef.current.ondataavailable = (e) => {
|
||||
chunksRef.current.push(e.data);
|
||||
};
|
||||
|
||||
mediaRecorderRef.current.onstop = () => {
|
||||
const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||
setAudioBlob(audioBlob);
|
||||
};
|
||||
|
||||
mediaRecorderRef.current.start();
|
||||
setIsRecording(true);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Erro ao acessar microfone. Verifique as permissões.');
|
||||
console.error('Erro ao iniciar gravação:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
|
||||
// Parar todas as tracks do stream
|
||||
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
|
||||
const triggerAudioProcessing = async (recordingData: {
|
||||
id: string;
|
||||
story_id: string;
|
||||
student_id: string;
|
||||
audio_url: string;
|
||||
status: string;
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
const { error } = await supabase.functions.invoke('process-audio', {
|
||||
body: {
|
||||
record: recordingData
|
||||
}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao iniciar processamento:', error);
|
||||
// Não vamos tratar o erro aqui pois o processamento é assíncrono
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erro ao chamar função de processamento:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadAudio = async () => {
|
||||
if (!audioBlob) return;
|
||||
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.user) {
|
||||
setError('Usuário não autenticado');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
|
||||
// Gerar um UUID único para o arquivo
|
||||
const fileId = uuidv4();
|
||||
const filePath = `${studentId}/${storyId}/${fileId}.webm`;
|
||||
|
||||
try {
|
||||
// Iniciar uma transação
|
||||
const { data: recordData, error: recordError } = await supabase
|
||||
.from('story_recordings')
|
||||
.insert({
|
||||
id: fileId, // Usar o mesmo UUID como ID do registro
|
||||
story_id: storyId,
|
||||
student_id: studentId,
|
||||
status: 'uploading', // Status inicial
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (recordError) throw recordError;
|
||||
|
||||
// Upload do arquivo
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('recordings')
|
||||
.upload(filePath, audioBlob, {
|
||||
contentType: 'audio/webm',
|
||||
cacheControl: '3600',
|
||||
upsert: false
|
||||
});
|
||||
|
||||
if (uploadError) {
|
||||
// Se o upload falhar, remover o registro do banco
|
||||
await supabase
|
||||
.from('story_recordings')
|
||||
.delete()
|
||||
.eq('id', fileId);
|
||||
throw uploadError;
|
||||
}
|
||||
|
||||
// Obter URL pública
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('recordings')
|
||||
.getPublicUrl(filePath);
|
||||
|
||||
// Atualizar o registro com a URL e status
|
||||
const { error: updateError } = await supabase
|
||||
.from('story_recordings')
|
||||
.update({
|
||||
audio_url: publicUrl,
|
||||
status: 'pending_analysis'
|
||||
})
|
||||
.eq('id', fileId);
|
||||
|
||||
if (updateError) {
|
||||
// Se a atualização falhar, limpar tudo
|
||||
await Promise.all([
|
||||
supabase.storage.from('recordings').remove([filePath]),
|
||||
supabase.from('story_recordings').delete().eq('id', fileId)
|
||||
]);
|
||||
throw updateError;
|
||||
}
|
||||
|
||||
// Disparar o processamento de forma assíncrona
|
||||
triggerAudioProcessing({
|
||||
id: fileId,
|
||||
story_id: storyId,
|
||||
student_id: studentId,
|
||||
audio_url: publicUrl,
|
||||
status: 'pending_analysis'
|
||||
}).catch(console.error); // Capturar erros mas não esperar pela conclusão
|
||||
|
||||
onAudioUploaded(publicUrl);
|
||||
setAudioBlob(null);
|
||||
|
||||
} catch (err) {
|
||||
setError('Erro ao enviar áudio. Tente novamente.');
|
||||
console.error('Erro no upload:', err);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-white rounded-lg shadow">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
{!isRecording && !audioBlob && (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
|
||||
>
|
||||
<Mic className="w-5 h-5" />
|
||||
Iniciar Gravação
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<button
|
||||
onClick={stopRecording}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
|
||||
>
|
||||
<Square className="w-5 h-5" />
|
||||
Parar Gravação
|
||||
</button>
|
||||
)}
|
||||
|
||||
{audioBlob && !isUploading && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = URL.createObjectURL(audioBlob);
|
||||
const audio = new Audio(url);
|
||||
audio.play();
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||||
>
|
||||
<Play className="w-5 h-5" />
|
||||
Ouvir
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={uploadAudio}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
|
||||
>
|
||||
<Upload className="w-5 h-5" />
|
||||
Enviar Áudio
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<Loader className="w-5 h-5 animate-spin" />
|
||||
Enviando áudio...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/components/story/RecordingHistoryCard.tsx
Normal file
124
src/components/story/RecordingHistoryCard.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion';
|
||||
import type { StoryRecording } from '../../types/database';
|
||||
|
||||
export function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
|
||||
const metrics = [
|
||||
{ label: 'Fluência', value: recording.fluency_score, color: 'text-blue-600' },
|
||||
{ label: 'Pronúncia', value: recording.pronunciation_score, color: 'text-green-600' },
|
||||
{ label: 'Precisão', value: recording.accuracy_score, color: 'text-purple-600' },
|
||||
{ label: 'Compreensão', value: recording.comprehension_score, color: 'text-orange-600' }
|
||||
];
|
||||
|
||||
const details = [
|
||||
{ label: 'Palavras por minuto', value: recording.words_per_minute },
|
||||
{ label: 'Pausas', value: recording.pause_count },
|
||||
{ label: 'Erros', value: recording.error_count },
|
||||
{ label: 'Autocorreções', value: recording.self_corrections }
|
||||
];
|
||||
|
||||
return (
|
||||
<Accordion type="single" collapsible className="bg-white rounded-lg border border-gray-200">
|
||||
<AccordionItem value={recording.id}>
|
||||
<AccordionTrigger className="px-4 hover:no-underline hover:bg-gray-50">
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-gray-500">
|
||||
{new Date(recording.created_at).toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{metrics.map((metric) => (
|
||||
<div key={metric.label} className="flex flex-col">
|
||||
<span className={`text-sm font-medium ${metric.color}`}>
|
||||
{metric.label}
|
||||
</span>
|
||||
<span className="text-lg font-semibold">
|
||||
{metric.value}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="px-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Coluna 1: Detalhes Técnicos */}
|
||||
<div className="space-y-4">
|
||||
<h5 className="text-sm font-medium text-gray-900">Detalhes Técnicos</h5>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{details.map((detail) => (
|
||||
<div key={detail.label} className="bg-gray-50 p-3 rounded-lg">
|
||||
<span className="text-xs text-gray-500 block">
|
||||
{detail.label}
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{detail.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coluna 2: Pontos Fortes e Melhorias */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-green-600 mb-2 flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-green-600 rounded-full" />
|
||||
Pontos Fortes
|
||||
</h5>
|
||||
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
|
||||
{recording.strengths.map((strength: string, i: number) => (
|
||||
<li key={i} className="leading-relaxed">{strength}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-orange-600 mb-2 flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-orange-600 rounded-full" />
|
||||
Pontos para Melhorar
|
||||
</h5>
|
||||
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
|
||||
{recording.improvements.map((improvement: string, i: number) => (
|
||||
<li key={i} className="leading-relaxed">{improvement}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coluna 3: Sugestões e Próximos Passos */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-blue-600 mb-2 flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-blue-600 rounded-full" />
|
||||
Sugestões
|
||||
</h5>
|
||||
<p className="text-sm text-gray-600 leading-relaxed bg-blue-50 p-3 rounded-lg">
|
||||
{recording.suggestions}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-purple-600 mb-2 flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-purple-600 rounded-full" />
|
||||
Próxima Meta
|
||||
</h5>
|
||||
<p className="text-sm text-gray-600 leading-relaxed bg-purple-50 p-3 rounded-lg">
|
||||
Tente alcançar {Math.min(100, recording.fluency_score + 5)}% de fluência na próxima leitura.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
270
src/components/story/StoryGenerator.tsx
Normal file
270
src/components/story/StoryGenerator.tsx
Normal file
@ -0,0 +1,270 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useSession } from '../../hooks/useSession';
|
||||
import { useStoryCategories } from '../../hooks/useStoryCategories';
|
||||
import { Wand2, ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface StoryStep {
|
||||
title: string;
|
||||
key?: keyof StoryChoices;
|
||||
items?: Category[];
|
||||
isContextStep?: boolean;
|
||||
}
|
||||
|
||||
interface StoryChoices {
|
||||
theme_id: string | null;
|
||||
subject_id: string | null;
|
||||
character_id: string | null;
|
||||
setting_id: string | null;
|
||||
context?: string;
|
||||
}
|
||||
|
||||
export function StoryGenerator() {
|
||||
const navigate = useNavigate();
|
||||
const { session } = useSession();
|
||||
const { themes, subjects, characters, settings, isLoading } = useStoryCategories();
|
||||
const [step, setStep] = React.useState(1);
|
||||
const [choices, setChoices] = React.useState<StoryChoices>({
|
||||
theme_id: null,
|
||||
subject_id: null,
|
||||
character_id: null,
|
||||
setting_id: null,
|
||||
context: ''
|
||||
});
|
||||
const [isGenerating, setIsGenerating] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [generationStatus, setGenerationStatus] = React.useState<
|
||||
'idle' | 'creating' | 'generating-images' | 'saving'
|
||||
>('idle');
|
||||
|
||||
const steps: StoryStep[] = [
|
||||
{
|
||||
title: 'Escolha o Tema',
|
||||
items: themes || [],
|
||||
key: 'theme_id'
|
||||
},
|
||||
{
|
||||
title: 'Escolha a Disciplina',
|
||||
items: subjects || [],
|
||||
key: 'subject_id'
|
||||
},
|
||||
{
|
||||
title: 'Escolha o Personagem',
|
||||
items: characters || [],
|
||||
key: 'character_id'
|
||||
},
|
||||
{
|
||||
title: 'Escolha o Cenário',
|
||||
items: settings || [],
|
||||
key: 'setting_id'
|
||||
},
|
||||
{
|
||||
title: 'Adicione um Contexto (Opcional)',
|
||||
isContextStep: true
|
||||
}
|
||||
];
|
||||
|
||||
const currentStep = steps[step - 1];
|
||||
const isLastStep = step === steps.length;
|
||||
|
||||
const handleSelect = (key: keyof StoryChoices, value: string) => {
|
||||
setChoices(prev => ({ ...prev, [key]: value }));
|
||||
if (step < steps.length) {
|
||||
setStep(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setChoices(prev => ({ ...prev, context: event.target.value }));
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (step > 1) {
|
||||
setStep(prev => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
if (!choices.theme_id || !choices.subject_id || !choices.character_id || !choices.setting_id) {
|
||||
setError('Por favor, preencha todas as escolhas antes de continuar.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
setGenerationStatus('creating');
|
||||
|
||||
const { data: story, error: storyError } = await supabase
|
||||
.from('stories')
|
||||
.insert({
|
||||
student_id: session.user.id,
|
||||
title: 'Gerando...',
|
||||
theme_id: choices.theme_id,
|
||||
subject_id: choices.subject_id,
|
||||
character_id: choices.character_id,
|
||||
setting_id: choices.setting_id,
|
||||
context: choices.context || null,
|
||||
status: 'draft',
|
||||
content: {
|
||||
prompt: choices,
|
||||
pages: []
|
||||
}
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (storyError) throw storyError;
|
||||
|
||||
setGenerationStatus('generating-images');
|
||||
console.log('Chamando Edge Function com:', story);
|
||||
|
||||
const { data: functionData, error: functionError } = await supabase.functions
|
||||
.invoke('generate-story', {
|
||||
body: { record: story }
|
||||
});
|
||||
|
||||
console.log('Resposta da Edge Function:', functionData);
|
||||
|
||||
if (functionError) {
|
||||
throw new Error(`Erro na Edge Function: ${functionError.message}`);
|
||||
}
|
||||
|
||||
setGenerationStatus('saving');
|
||||
const { data: updatedStory, error: updateError } = await supabase
|
||||
.from('stories')
|
||||
.select('*')
|
||||
.eq('id', story.id)
|
||||
.single();
|
||||
|
||||
if (updateError) throw updateError;
|
||||
|
||||
navigate(`/aluno/historias/${story.id}`);
|
||||
} catch (err) {
|
||||
console.error('Erro ao gerar história:', err);
|
||||
setError('Não foi possível criar sua história. Tente novamente.');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
setGenerationStatus('idle');
|
||||
}
|
||||
};
|
||||
|
||||
const getGenerationStatusText = () => {
|
||||
switch (generationStatus) {
|
||||
case 'creating':
|
||||
return 'Iniciando criação...';
|
||||
case 'generating-images':
|
||||
return 'Gerando história e imagens...';
|
||||
case 'saving':
|
||||
return 'Finalizando...';
|
||||
default:
|
||||
return 'Criar História Mágica';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-8">
|
||||
<div className="h-2 bg-gray-200 rounded-full" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-32 bg-gray-200 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Progress Bar */}
|
||||
<div className="flex gap-2 mb-8">
|
||||
{steps.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-2 rounded-full flex-1 ${
|
||||
i + 1 <= step ? 'bg-purple-600' : 'bg-gray-200'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-medium text-gray-900 mb-6">
|
||||
{currentStep.title}
|
||||
</h2>
|
||||
|
||||
{currentStep.isContextStep ? (
|
||||
<div className="space-y-4">
|
||||
<textarea
|
||||
value={choices.context}
|
||||
onChange={handleContextChange}
|
||||
placeholder="Adicione detalhes ou ideias específicas para sua história..."
|
||||
className="w-full h-32 p-4 border border-gray-300 rounded-lg focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{currentStep.items?.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelect(currentStep.key!, item.id)}
|
||||
className={`p-6 rounded-xl border-2 transition-all text-left ${
|
||||
choices[currentStep.key!] === item.id
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-purple-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{item.icon}</span>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{item.title}</h3>
|
||||
<p className="text-sm text-gray-600">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 text-red-600 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex justify-between pt-6">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
disabled={step === 1}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 disabled:opacity-50"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
Voltar
|
||||
</button>
|
||||
|
||||
{isLastStep && (
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!choices.theme_id || !choices.subject_id || !choices.character_id || !choices.setting_id || isGenerating}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
<Wand2 className="h-5 w-5" />
|
||||
{isGenerating ? getGenerationStatusText() : 'Criar História Mágica'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
src/components/story/StoryMetrics.tsx
Normal file
142
src/components/story/StoryMetrics.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import React from 'react';
|
||||
import { Activity, Book, Mic, Brain } from 'lucide-react';
|
||||
|
||||
export interface MetricsData {
|
||||
metrics: {
|
||||
fluency: number;
|
||||
pronunciation: number;
|
||||
accuracy: number;
|
||||
comprehension: number;
|
||||
};
|
||||
feedback: {
|
||||
strengths: string[];
|
||||
improvements: string[];
|
||||
suggestions: string;
|
||||
};
|
||||
details: {
|
||||
wordsPerMinute: number;
|
||||
pauseCount: number;
|
||||
errorCount: number;
|
||||
selfCorrections: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface StoryMetricsProps {
|
||||
data?: MetricsData;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function StoryMetrics({ data, isLoading }: StoryMetricsProps): JSX.Element {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-24 bg-gray-100 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-lg p-6 text-center mb-6">
|
||||
<p className="text-gray-600">
|
||||
Aguardando gravação para gerar métricas de leitura...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
label: 'Fluência',
|
||||
value: data.metrics.fluency,
|
||||
icon: Activity,
|
||||
color: 'text-blue-600',
|
||||
detail: `${data.details.wordsPerMinute} palavras/min`
|
||||
},
|
||||
{
|
||||
label: 'Pronúncia',
|
||||
value: data.metrics.pronunciation,
|
||||
icon: Mic,
|
||||
color: 'text-green-600',
|
||||
detail: `${data.details.errorCount} erros`
|
||||
},
|
||||
{
|
||||
label: 'Precisão',
|
||||
value: data.metrics.accuracy,
|
||||
icon: Book,
|
||||
color: 'text-purple-600',
|
||||
detail: `${data.details.selfCorrections} autocorreções`
|
||||
},
|
||||
{
|
||||
label: 'Compreensão',
|
||||
value: data.metrics.comprehension,
|
||||
icon: Brain,
|
||||
color: 'text-orange-600',
|
||||
detail: `${data.details.pauseCount} pausas`
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{metrics.map((metric) => (
|
||||
<div
|
||||
key={metric.label}
|
||||
className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<metric.icon className={`w-5 h-5 ${metric.color}`} />
|
||||
<span className="text-2xl font-bold">{metric.value}%</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-600">{metric.label}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">{metric.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
|
||||
<h3 className="font-medium mb-4">Feedback da Leitura</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-green-600 mb-2 flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-green-600 rounded-full" />
|
||||
Pontos Fortes
|
||||
</h4>
|
||||
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
|
||||
{data.feedback.strengths.map((strength, i) => (
|
||||
<li key={i} className="leading-relaxed">{strength}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-orange-600 mb-2 flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-orange-600 rounded-full" />
|
||||
Pontos para Melhorar
|
||||
</h4>
|
||||
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
|
||||
{data.feedback.improvements.map((improvement, i) => (
|
||||
<li key={i} className="leading-relaxed">{improvement}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-blue-600 mb-2 flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-blue-600 rounded-full" />
|
||||
Sugestões
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
{data.feedback.suggestions}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/components/student/StudentDashboardNavbar.tsx
Normal file
41
src/components/student/StudentDashboardNavbar.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
export function StudentDashboardNavbar() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<nav className="bg-white border-b border-gray-200">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center gap-8">
|
||||
<Link
|
||||
to="/aluno"
|
||||
className={`text-gray-900 hover:text-purple-600 ${
|
||||
location.pathname === '/aluno' ? 'text-purple-600' : ''
|
||||
}`}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
to="/aluno/historias"
|
||||
className={`text-gray-900 hover:text-purple-600 ${
|
||||
location.pathname.includes('/aluno/historias') ? 'text-purple-600' : ''
|
||||
}`}
|
||||
>
|
||||
Histórias
|
||||
</Link>
|
||||
<Link
|
||||
to="/aluno/configuracoes"
|
||||
className={`text-gray-900 hover:text-purple-600 ${
|
||||
location.pathname === '/aluno/configuracoes' ? 'text-purple-600' : ''
|
||||
}`}
|
||||
>
|
||||
Configurações
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
54
src/components/ui/accordion.tsx
Normal file
54
src/components/ui/accordion.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import * as React from 'react';
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn('border-b', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AccordionItem.displayName = 'AccordionItem';
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-gray-500 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
));
|
||||
AccordionTrigger.displayName = 'AccordionTrigger';
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('pb-4 pt-0', className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
AccordionContent.displayName = 'AccordionContent';
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
40
src/components/ui/avatar-upload.tsx
Normal file
40
src/components/ui/avatar-upload.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Camera } from 'lucide-react';
|
||||
|
||||
export function AvatarUpload(): JSX.Element {
|
||||
const [preview, setPreview] = useState<string>();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-24 h-24">
|
||||
<div
|
||||
className="w-full h-full rounded-full bg-gray-100 flex items-center justify-center overflow-hidden border-2 border-gray-200"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{preview ? (
|
||||
<img src={preview} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Camera className="h-8 w-8 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/components/ui/badge.tsx
Normal file
34
src/components/ui/badge.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
|
||||
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'default' | 'success' | 'warning' | 'error' | 'secondary';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const variantStyles = {
|
||||
default: 'bg-primary text-primary-foreground',
|
||||
success: 'bg-green-100 text-green-800',
|
||||
warning: 'bg-yellow-100 text-yellow-800',
|
||||
error: 'bg-red-100 text-red-800',
|
||||
secondary: 'bg-gray-100 text-gray-800'
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
variant = 'default',
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
}: BadgeProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
${variantStyles[variant]}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/components/ui/button.tsx
Normal file
30
src/components/ui/button.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
as?: 'button' | 'span';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
as: Component = 'button',
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps): JSX.Element {
|
||||
return (
|
||||
<Component
|
||||
className={`
|
||||
inline-flex items-center justify-center px-4 py-2
|
||||
text-sm font-medium text-white
|
||||
bg-purple-600 hover:bg-purple-700
|
||||
rounded-md shadow-sm
|
||||
transition-colors duration-200
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
16
src/components/ui/card.tsx
Normal file
16
src/components/ui/card.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Card({ className = '', children, ...props }: CardProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-sm border border-gray-200 ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/components/ui/date-picker.tsx
Normal file
27
src/components/ui/date-picker.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
interface DatePickerProps {
|
||||
label?: string;
|
||||
name: string;
|
||||
value?: string;
|
||||
onChange?: (date: string) => void;
|
||||
}
|
||||
|
||||
export function DatePicker({ label, name, value, onChange }: DatePickerProps): JSX.Element {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
type="date"
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function Input({ label, className, ...props }: InputProps): JSX.Element {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
className={`w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/components/ui/select.tsx
Normal file
39
src/components/ui/select.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
label?: string;
|
||||
name: string;
|
||||
options: SelectOption[];
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function Select({ label, name, options, value, onChange }: SelectProps): JSX.Element {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
src/components/ui/tabs.tsx
Normal file
52
src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-lg bg-gray-100 p-1 text-gray-500",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-950 data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@ -1,13 +1,23 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { supabase } from '../lib/supabase'
|
||||
import { User, Session } from '@supabase/supabase-js'
|
||||
import type { WeakPassword } from '../types/supabase'
|
||||
import { UserRole } from '../types/supabase'
|
||||
|
||||
interface AuthContextType {
|
||||
export interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
signIn: (email: string, password: string) => Promise<{ user: User; session: Session }>;
|
||||
signUp: (email: string, password: string) => Promise<{ user: User; session: Session }>;
|
||||
userRole: UserRole | null;
|
||||
signIn: (email: string, password: string) => Promise<{
|
||||
user: User;
|
||||
session: Session;
|
||||
weakPassword?: WeakPassword;
|
||||
}>;
|
||||
signUp: (email: string, password: string) => Promise<{
|
||||
user: User;
|
||||
session: Session;
|
||||
}>;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
@ -15,6 +25,7 @@ export function useAuth() {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [userRole, setUserRole] = useState<AuthContextType['userRole']>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Verificar sessão atual
|
||||
@ -31,6 +42,12 @@ export function useAuth() {
|
||||
return () => subscription.unsubscribe()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.user_metadata?.role) {
|
||||
setUserRole(user.user_metadata.role);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const signIn = async (email: string, password: string) => {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
@ -78,6 +95,7 @@ export function useAuth() {
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
userRole,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut
|
||||
|
||||
102
src/hooks/useAuth.tsx
Normal file
102
src/hooks/useAuth.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { User } from '@supabase/supabase-js';
|
||||
import { UserMetadata, UserRole } from '../types/supabase';
|
||||
|
||||
export interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
signIn: (email: string, password: string) => Promise<any>;
|
||||
signUp: (email: string, password: string) => Promise<any>;
|
||||
signOut: () => Promise<void>;
|
||||
userRole: UserRole | null;
|
||||
}
|
||||
|
||||
const AuthContext = React.createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const [user, setUser] = React.useState<User | null>(null);
|
||||
const [userRole, setUserRole] = React.useState<UserRole | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchSession = async () => {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
console.log('Sessão atual:', session);
|
||||
|
||||
if (session?.user) {
|
||||
setUser(session.user);
|
||||
const role = session.user.user_metadata.role as UserMetadata['role'];
|
||||
console.log('Role na sessão:', role);
|
||||
setUserRole(role);
|
||||
|
||||
if (role === 'school') {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
fetchSession();
|
||||
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
console.log('Evento de auth:', event);
|
||||
console.log('Sessão no evento:', session);
|
||||
|
||||
if (session?.user) {
|
||||
setUser(session.user);
|
||||
const role = session.user.user_metadata.role as UserMetadata['role'];
|
||||
console.log('Role no evento:', role);
|
||||
setUserRole(role);
|
||||
|
||||
if (event === 'SIGNED_IN') {
|
||||
if (role === 'school') {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setUser(null);
|
||||
setUserRole(null);
|
||||
navigate('/');
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
loading,
|
||||
error: null,
|
||||
signIn: async () => ({}),
|
||||
signUp: async () => ({}),
|
||||
signOut: async () => {
|
||||
await supabase.auth.signOut();
|
||||
setUser(null);
|
||||
setUserRole(null);
|
||||
navigate('/');
|
||||
},
|
||||
userRole
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextType {
|
||||
const context = React.useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
28
src/hooks/useSession.ts
Normal file
28
src/hooks/useSession.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Session } from '@supabase/supabase-js';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
export function useSession() {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Pega a sessão atual
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
setSession(session);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
// Escuta mudanças na autenticação
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setSession(session);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
return { session, loading };
|
||||
}
|
||||
72
src/hooks/useStoryCategories.ts
Normal file
72
src/hooks/useStoryCategories.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export function useStoryCategories() {
|
||||
const { data: themes, isLoading: loadingThemes } = useQuery({
|
||||
queryKey: ['story-themes'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('story_themes')
|
||||
.select('*')
|
||||
.order('title');
|
||||
|
||||
if (error) throw error;
|
||||
return data as Category[];
|
||||
}
|
||||
});
|
||||
|
||||
const { data: subjects, isLoading: loadingSubjects } = useQuery({
|
||||
queryKey: ['story-subjects'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('story_subjects')
|
||||
.select('*')
|
||||
.order('title');
|
||||
|
||||
if (error) throw error;
|
||||
return data as Category[];
|
||||
}
|
||||
});
|
||||
|
||||
const { data: characters, isLoading: loadingCharacters } = useQuery({
|
||||
queryKey: ['story-characters'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('story_characters')
|
||||
.select('*')
|
||||
.order('title');
|
||||
|
||||
if (error) throw error;
|
||||
return data as Category[];
|
||||
}
|
||||
});
|
||||
|
||||
const { data: settings, isLoading: loadingSettings } = useQuery({
|
||||
queryKey: ['story-settings'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('story_settings')
|
||||
.select('*')
|
||||
.order('title');
|
||||
|
||||
if (error) throw error;
|
||||
return data as Category[];
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
themes,
|
||||
subjects,
|
||||
characters,
|
||||
settings,
|
||||
isLoading: loadingThemes || loadingSubjects || loadingCharacters || loadingSettings
|
||||
};
|
||||
}
|
||||
14
src/layouts/student/StudentDashboardLayout.tsx
Normal file
14
src/layouts/student/StudentDashboardLayout.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { StudentDashboardNavbar } from '@/components/student/StudentDashboardNavbar';
|
||||
import React from 'react';
|
||||
|
||||
export function StudentDashboardLayout() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<StudentDashboardNavbar />
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
src/lib/imageCache.ts
Normal file
17
src/lib/imageCache.ts
Normal file
@ -0,0 +1,17 @@
|
||||
const imageCache = new Map<string, string>();
|
||||
|
||||
export function cacheImage(url: string): Promise<string> {
|
||||
if (imageCache.has(url)) {
|
||||
return Promise.resolve(imageCache.get(url)!);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
img.onload = () => {
|
||||
imageCache.set(url, url);
|
||||
resolve(url);
|
||||
};
|
||||
img.onerror = reject;
|
||||
});
|
||||
}
|
||||
35
src/lib/imageUtils.ts
Normal file
35
src/lib/imageUtils.ts
Normal file
@ -0,0 +1,35 @@
|
||||
interface ImageOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
export function getOptimizedImageUrl(url: string | undefined, options: ImageOptions = {}): string {
|
||||
// Retorna uma imagem padrão ou vazia se a URL for undefined
|
||||
if (!url) {
|
||||
return '/placeholder-image.jpg'; // ou retorne uma imagem padrão apropriada
|
||||
}
|
||||
|
||||
const {
|
||||
width = 800,
|
||||
height = undefined,
|
||||
quality = 80
|
||||
} = options;
|
||||
|
||||
// Se for URL do Supabase Storage
|
||||
if (url.includes('storage.googleapis.com')) {
|
||||
const params = new URLSearchParams({
|
||||
width: width.toString(),
|
||||
quality: quality.toString(),
|
||||
format: 'webp'
|
||||
});
|
||||
|
||||
if (height) {
|
||||
params.append('height', height.toString());
|
||||
}
|
||||
|
||||
return `${url}?${params.toString()}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
62
src/lib/redis.ts
Normal file
62
src/lib/redis.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import Redis from 'ioredis';
|
||||
|
||||
if (!process.env.REDIS_URL) {
|
||||
throw new Error('REDIS_URL não configurada');
|
||||
}
|
||||
|
||||
const redis = new Redis(process.env.REDIS_URL, {
|
||||
maxRetriesPerRequest: 3,
|
||||
retryStrategy(times) {
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
return delay;
|
||||
},
|
||||
reconnectOnError(err) {
|
||||
const targetError = 'READONLY';
|
||||
if (err.message.includes(targetError)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
redis.on('error', (error) => {
|
||||
console.error('[Redis] Erro de conexão:', error);
|
||||
});
|
||||
|
||||
redis.on('connect', () => {
|
||||
console.log('[Redis] Conectado com sucesso');
|
||||
});
|
||||
|
||||
export async function getFromCache<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const cached = await redis.get(key);
|
||||
if (cached) {
|
||||
return JSON.parse(cached) as T;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[Redis] Erro ao buscar do cache:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setInCache(key: string, value: any, expireInSeconds = 3600): Promise<void> {
|
||||
try {
|
||||
await redis.set(key, JSON.stringify(value), 'EX', expireInSeconds);
|
||||
} catch (error) {
|
||||
console.error('[Redis] Erro ao salvar no cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function invalidateCache(pattern: string): Promise<void> {
|
||||
try {
|
||||
const keys = await redis.keys(pattern);
|
||||
if (keys.length > 0) {
|
||||
await redis.del(...keys);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Redis] Erro ao invalidar cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export default redis;
|
||||
@ -1,3 +1,4 @@
|
||||
import { StoryPrompt } from '@/types/story-generator'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||
@ -13,4 +14,26 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
persistSession: true,
|
||||
detectSessionInUrl: true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export const generateStoryFunction = async (prompt: StoryPrompt) => {
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
|
||||
const response = await fetch(
|
||||
'https://seu-project-ref.supabase.co/functions/v1/generate-story',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${session?.access_token}`,
|
||||
},
|
||||
body: JSON.stringify(prompt),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Falha ao gerar história')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
15
src/main.tsx
15
src/main.tsx
@ -1,11 +1,24 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { router } from './routes';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5,
|
||||
gcTime: 1000 * 60 * 30,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
28
src/pages/AuthCallback.tsx
Normal file
28
src/pages/AuthCallback.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
export function AuthCallback() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.onAuthStateChange((event, session) => {
|
||||
if (event === 'SIGNED_IN') {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">
|
||||
Verificando autenticação...
|
||||
</h2>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Por favor, aguarde enquanto confirmamos seu acesso.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
src/pages/admin/UserManagementPage.tsx
Normal file
120
src/pages/admin/UserManagementPage.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { User } from '@supabase/supabase-js';
|
||||
import { UserMetadata } from '../../types/supabase';
|
||||
|
||||
interface AdminUser extends User {
|
||||
user_metadata: UserMetadata;
|
||||
}
|
||||
|
||||
export function UserManagementPage() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [updating, setUpdating] = React.useState<string | null>(null);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const { data: users, error } = await supabase.auth.admin.listUsers();
|
||||
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const validUsers = users.filter((user: User) =>
|
||||
user.user_metadata && user.user_metadata.role
|
||||
) as AdminUser[];
|
||||
|
||||
setUsers(validUsers);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erro ao carregar usuários');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await fetchUsers();
|
||||
};
|
||||
|
||||
const handleUpdateRole = async (userId: string, role: string) => {
|
||||
setUpdating(userId);
|
||||
try {
|
||||
const { error } = await supabase.auth.admin.updateUserById(
|
||||
userId,
|
||||
{ user_metadata: { role } }
|
||||
);
|
||||
if (error) throw error;
|
||||
await fetchUsers();
|
||||
} catch (err) {
|
||||
console.error('Erro ao atualizar papel:', err);
|
||||
setError('Não foi possível atualizar o papel do usuário');
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div>Carregando...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Gerenciamento de Usuários</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 text-red-600 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Papel Atual
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Ações
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{user.email}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
{user.user_metadata?.role || 'Não definido'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<select
|
||||
value={user.user_metadata?.role || ''}
|
||||
onChange={(e) => handleUpdateRole(user.id, e.target.value)}
|
||||
disabled={updating === user.id}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Selecione um papel</option>
|
||||
<option value="school">Escola</option>
|
||||
<option value="teacher">Professor</option>
|
||||
<option value="student">Aluno</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
src/pages/api/health.ts
Normal file
22
src/pages/api/health.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import redis from '@/lib/redis';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
export default async function handler(_: any, res: { status: (arg0: number) => { (): any; new(): any; json: { (arg0: { status: string; error?: any; }): void; new(): any; }; }; }) {
|
||||
try {
|
||||
// Verifica conexão com Redis
|
||||
await redis.ping();
|
||||
|
||||
// Verifica conexão com Supabase
|
||||
const { data, error } = await supabase.from('stories').select('id').limit(1);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
res.status(200).json({ status: 'healthy' });
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
res.status(500).json({ status: 'unhealthy', error: error.message });
|
||||
} else {
|
||||
res.status(500).json({ status: 'unhealthy', error: 'Erro desconhecido' });
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/pages/api/stories/[id].ts
Normal file
28
src/pages/api/stories/[id].ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import redis from '@/lib/redis';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
const { id } = req.query;
|
||||
|
||||
// Tenta pegar do cache primeiro
|
||||
const cachedStory = await redis.get(`story:${id}`);
|
||||
if (cachedStory) {
|
||||
return res.json(JSON.parse(cachedStory));
|
||||
}
|
||||
|
||||
// Se não estiver no cache, busca do Supabase
|
||||
const { data: story } = await supabase
|
||||
.from('stories')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
// Salva no cache por 1 hora
|
||||
await redis.setex(`story:${id}`, 3600, JSON.stringify(story));
|
||||
|
||||
return res.json(story);
|
||||
}
|
||||
16
src/pages/api/updateRole.ts
Normal file
16
src/pages/api/updateRole.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
export async function updateRole(email: string, role: string) {
|
||||
const { data: { user }, error } = await supabase.auth.admin.updateUserById(
|
||||
email,
|
||||
{
|
||||
user_metadata: { role }
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
14
src/pages/api/updateUserRole.ts
Normal file
14
src/pages/api/updateUserRole.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
export async function updateUserRole(userId: string, role: string) {
|
||||
const { data, error } = await supabase.auth.admin.updateUserById(
|
||||
userId,
|
||||
{ user_metadata: { role: role } }
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
172
src/pages/dashboard/DashboardHome.tsx
Normal file
172
src/pages/dashboard/DashboardHome.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Users, GraduationCap, BookOpen } from 'lucide-react';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { StatsCard } from '../../components/dashboard/StatsCard';
|
||||
|
||||
interface DashboardStats {
|
||||
totalClasses: number;
|
||||
totalTeachers: number;
|
||||
totalStudents: number;
|
||||
recentClasses: any[];
|
||||
recentStories: any[];
|
||||
}
|
||||
|
||||
export function DashboardHome() {
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
totalClasses: 0,
|
||||
totalTeachers: 0,
|
||||
totalStudents: 0,
|
||||
recentClasses: [],
|
||||
recentStories: []
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDashboardStats = async () => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
const schoolId = session.user.id;
|
||||
|
||||
// Buscar total de turmas
|
||||
const { count: classesCount } = await supabase
|
||||
.from('classes')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('school_id', schoolId);
|
||||
|
||||
// Buscar total de professores
|
||||
const { count: teachersCount } = await supabase
|
||||
.from('teacher_classes')
|
||||
.select('teacher_id', { count: 'exact', head: true })
|
||||
.eq('school_id', schoolId);
|
||||
|
||||
// Buscar total de alunos
|
||||
const { count: studentsCount } = await supabase
|
||||
.from('students')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('school_id', schoolId);
|
||||
|
||||
// Buscar turmas recentes
|
||||
const { data: recentClasses } = await supabase
|
||||
.from('classes')
|
||||
.select(`
|
||||
*,
|
||||
students:students(count)
|
||||
`)
|
||||
.eq('school_id', schoolId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(5);
|
||||
|
||||
// Buscar histórias recentes
|
||||
const { data: recentStories } = await supabase
|
||||
.from('stories')
|
||||
.select(`
|
||||
*,
|
||||
students:students(name)
|
||||
`)
|
||||
.eq('school_id', schoolId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(5);
|
||||
|
||||
setStats({
|
||||
totalClasses: classesCount || 0,
|
||||
totalTeachers: teachersCount || 0,
|
||||
totalStudents: studentsCount || 0,
|
||||
recentClasses: recentClasses || [],
|
||||
recentStories: recentStories || []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar estatísticas:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDashboardStats();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-8">Dashboard</h1>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 mb-8">
|
||||
<StatsCard
|
||||
icon={Users}
|
||||
title="Total de Turmas"
|
||||
value={stats.totalClasses}
|
||||
iconBgColor="bg-purple-100"
|
||||
iconColor="text-purple-600"
|
||||
/>
|
||||
<StatsCard
|
||||
icon={GraduationCap}
|
||||
title="Total de Professores"
|
||||
value={stats.totalTeachers}
|
||||
iconBgColor="bg-blue-100"
|
||||
iconColor="text-blue-600"
|
||||
/>
|
||||
<StatsCard
|
||||
icon={BookOpen}
|
||||
title="Total de Alunos"
|
||||
value={stats.totalStudents}
|
||||
iconBgColor="bg-green-100"
|
||||
iconColor="text-green-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="bg-white rounded-2xl p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Últimas Turmas</h2>
|
||||
{loading ? (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-12 bg-gray-100 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : stats.recentClasses.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-4">Nenhuma turma cadastrada</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{stats.recentClasses.map((classItem) => (
|
||||
<div key={classItem.id} className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="font-medium">{classItem.name}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{classItem.grade} • {classItem.students.count} alunos
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Histórias Recentes</h2>
|
||||
{loading ? (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-12 bg-gray-100 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : stats.recentStories.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-4">Nenhuma história registrada</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{stats.recentStories.map((story) => (
|
||||
<div key={story.id} className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="font-medium">{story.title}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
por {story.students.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
src/pages/dashboard/DashboardLayout.tsx
Normal file
135
src/pages/dashboard/DashboardLayout.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
GraduationCap,
|
||||
BookOpen,
|
||||
Settings,
|
||||
LogOut,
|
||||
School,
|
||||
UserRound
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
|
||||
export function DashboardLayout() {
|
||||
const navigate = useNavigate();
|
||||
const { signOut } = useAuth();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Sidebar */}
|
||||
<aside className="fixed left-0 top-0 h-full w-64 bg-white border-r border-gray-200">
|
||||
<div className="flex items-center gap-2 p-6 border-b border-gray-200">
|
||||
<School className="h-8 w-8 text-purple-600" />
|
||||
<span className="font-semibold text-gray-900">Histórias Mágicas</span>
|
||||
</div>
|
||||
|
||||
<nav className="p-4 space-y-1">
|
||||
<NavLink
|
||||
to="/dashboard"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
|
||||
isActive
|
||||
? 'bg-purple-50 text-purple-700'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<LayoutDashboard className="h-5 w-5" />
|
||||
Visão Geral
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/dashboard/turmas"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
|
||||
isActive
|
||||
? 'bg-purple-50 text-purple-700'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Users className="h-5 w-5" />
|
||||
Turmas
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/dashboard/professores"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
|
||||
isActive
|
||||
? 'bg-purple-50 text-purple-700'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<GraduationCap className="h-5 w-5" />
|
||||
Professores
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/dashboard/alunos"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
|
||||
isActive
|
||||
? 'bg-purple-50 text-purple-700'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<UserRound className="h-5 w-5" />
|
||||
Alunos
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/dashboard/historias"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
|
||||
isActive
|
||||
? 'bg-purple-50 text-purple-700'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<BookOpen className="h-5 w-5" />
|
||||
Histórias
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/dashboard/configuracoes"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
|
||||
isActive
|
||||
? 'bg-purple-50 text-purple-700'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
Configurações
|
||||
</NavLink>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-50 w-full"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
Sair
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="ml-64 p-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/pages/dashboard/DashboardPage.tsx
Normal file
10
src/pages/dashboard/DashboardPage.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { DashboardLayout } from "../../components/layouts/DashboardLayout";
|
||||
import React from "react";
|
||||
|
||||
export function DashboardPage(): JSX.Element {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div>Conteúdo do Dashboard</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
121
src/pages/dashboard/classes/ClassesPage.tsx
Normal file
121
src/pages/dashboard/classes/ClassesPage.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import { Plus, Search, MoreVertical, GraduationCap } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDatabase } from '../../../hooks/useDatabase';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
import type { Class } from '../../../types/database';
|
||||
|
||||
export function ClassesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [classes, setClasses] = React.useState<Class[]>([]);
|
||||
const [searchTerm, setSearchTerm] = React.useState('');
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchClasses = async () => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('classes')
|
||||
.select(`
|
||||
*,
|
||||
students:students(count),
|
||||
teachers:teacher_classes(count)
|
||||
`)
|
||||
.eq('school_id', session.user.id);
|
||||
|
||||
if (error) throw error;
|
||||
setClasses(data || []);
|
||||
} catch (err) {
|
||||
console.error('Erro ao buscar turmas:', err);
|
||||
setError('Erro ao buscar turmas');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchClasses();
|
||||
}, []);
|
||||
|
||||
const filteredClasses = classes.filter(classItem =>
|
||||
classItem.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
classItem.grade.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Turmas</h1>
|
||||
<button
|
||||
onClick={() => navigate('nova')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
Adicionar Turma
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 text-red-600 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar turmas..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">Carregando...</div>
|
||||
) : filteredClasses.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
{searchTerm ? 'Nenhuma turma encontrada' : 'Nenhuma turma cadastrada'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{filteredClasses.map((classItem) => (
|
||||
<div
|
||||
key={classItem.id}
|
||||
className="p-4 hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => navigate(`/dashboard/turmas/${classItem.id}`)}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{classItem.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<GraduationCap className="h-4 w-4" />
|
||||
{classItem.grade} • {(classItem as any).students?.count || 0} alunos
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Ativo
|
||||
</span>
|
||||
<button className="p-2 hover:bg-gray-100 rounded-full">
|
||||
<MoreVertical className="h-5 w-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
src/pages/dashboard/classes/CreateClassPage.tsx
Normal file
151
src/pages/dashboard/classes/CreateClassPage.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
import type { Class } from '../../../types';
|
||||
|
||||
export function CreateClassPage() {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<Omit<Class, 'id' | 'school_id' | 'created_at' | 'updated_at'>>({
|
||||
name: '',
|
||||
grade: '',
|
||||
year: new Date().getFullYear(),
|
||||
period: 'morning'
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setFormError(null);
|
||||
|
||||
try {
|
||||
const { data: { session }, error: authError } = await supabase.auth.getSession();
|
||||
|
||||
if (authError) throw authError;
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Usuário não autenticado');
|
||||
}
|
||||
|
||||
const { data: newClass, error: classError } = await supabase
|
||||
.from('classes')
|
||||
.insert({
|
||||
school_id: session.user.id,
|
||||
name: formData.name,
|
||||
grade: formData.grade,
|
||||
year: formData.year,
|
||||
period: formData.period
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (classError) throw classError;
|
||||
|
||||
navigate('/dashboard/turmas', {
|
||||
state: { message: 'Turma criada com sucesso!' }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Erro ao criar turma:', err);
|
||||
setFormError(err instanceof Error ? err.message : 'Erro ao criar turma');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/turmas')}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
Voltar para turmas
|
||||
</button>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Nova Turma</h1>
|
||||
|
||||
{formError && (
|
||||
<div className="mb-4 p-4 bg-red-50 text-red-600 rounded-lg">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
Nome da Turma
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
required
|
||||
placeholder="Ex: 5º Ano A"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="grade" className="block text-sm font-medium text-gray-700">
|
||||
Série/Ano
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="grade"
|
||||
value={formData.grade}
|
||||
onChange={(e) => setFormData({ ...formData, grade: e.target.value })}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
required
|
||||
placeholder="Ex: 5º Ano"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="year" className="block text-sm font-medium text-gray-700">
|
||||
Ano Letivo
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="year"
|
||||
value={formData.year}
|
||||
onChange={(e) => setFormData({ ...formData, year: parseInt(e.target.value) })}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
required
|
||||
min={2024}
|
||||
max={2100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="period" className="block text-sm font-medium text-gray-700">
|
||||
Período
|
||||
</label>
|
||||
<select
|
||||
id="period"
|
||||
value={formData.period}
|
||||
onChange={(e) => setFormData({ ...formData, period: e.target.value as Class['period'] })}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
>
|
||||
<option value="morning">Manhã</option>
|
||||
<option value="afternoon">Tarde</option>
|
||||
<option value="evening">Noite</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Criando...' : 'Criar Turma'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
286
src/pages/dashboard/settings/SettingsPage.tsx
Normal file
286
src/pages/dashboard/settings/SettingsPage.tsx
Normal file
@ -0,0 +1,286 @@
|
||||
import React from 'react';
|
||||
import { Building2, Mail, Phone, MapPin, Save, User } from 'lucide-react';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
import type { School } from '../../../types/database';
|
||||
|
||||
interface SchoolSettings {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zipCode: string;
|
||||
directorName: string;
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const [settings, setSettings] = React.useState<SchoolSettings>({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipCode: '',
|
||||
directorName: ''
|
||||
});
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchSchoolSettings = async () => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('schools')
|
||||
.select('*')
|
||||
.eq('id', session.user.id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
if (data) {
|
||||
setSettings({
|
||||
name: data.name || '',
|
||||
email: data.email || '',
|
||||
phone: data.phone || '',
|
||||
address: data.address || '',
|
||||
city: data.city || '',
|
||||
state: data.state || '',
|
||||
zipCode: data.zip_code || '',
|
||||
directorName: data.director_name || ''
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erro ao carregar configurações:', err);
|
||||
setError('Não foi possível carregar as configurações da escola');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSchoolSettings();
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setSettings(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.user?.id) throw new Error('Usuário não autenticado');
|
||||
|
||||
const { error } = await supabase
|
||||
.from('schools')
|
||||
.update({
|
||||
name: settings.name,
|
||||
email: settings.email,
|
||||
phone: settings.phone,
|
||||
address: settings.address,
|
||||
city: settings.city,
|
||||
state: settings.state,
|
||||
zip_code: settings.zipCode,
|
||||
director_name: settings.directorName,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', session.user.id);
|
||||
|
||||
if (error) throw error;
|
||||
setSuccessMessage('Configurações atualizadas com sucesso!');
|
||||
} catch (err) {
|
||||
console.error('Erro ao salvar configurações:', err);
|
||||
setError('Não foi possível salvar as configurações');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-center text-gray-500">Carregando...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Configurações da Escola</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 text-red-600 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<div className="p-4 bg-green-50 text-green-600 rounded-lg">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nome da Escola
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={settings.name}
|
||||
onChange={handleChange}
|
||||
className="pl-10 w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={settings.email}
|
||||
onChange={handleChange}
|
||||
className="pl-10 w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Telefone
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={settings.phone}
|
||||
onChange={handleChange}
|
||||
className="pl-10 w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="directorName" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nome do Diretor(a)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
id="directorName"
|
||||
name="directorName"
|
||||
value={settings.directorName}
|
||||
onChange={handleChange}
|
||||
className="pl-10 w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Endereço</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="address" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Endereço
|
||||
</label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
id="address"
|
||||
name="address"
|
||||
value={settings.address}
|
||||
onChange={handleChange}
|
||||
className="pl-10 w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="city" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Cidade
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="city"
|
||||
name="city"
|
||||
value={settings.city}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="state" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Estado
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="state"
|
||||
name="state"
|
||||
value={settings.state}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="zipCode" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
CEP
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="zipCode"
|
||||
name="zipCode"
|
||||
value={settings.zipCode}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-5 w-5" />
|
||||
{saving ? 'Salvando...' : 'Salvar Alterações'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
330
src/pages/dashboard/students/AddStudentPage.tsx
Normal file
330
src/pages/dashboard/students/AddStudentPage.tsx
Normal file
@ -0,0 +1,330 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Copy, RefreshCw } from 'lucide-react';
|
||||
import { useDatabase } from '../../../hooks/useDatabase';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
import { sendStudentCredentialsEmail } from '../../../services/email';
|
||||
import { generateMnemonicPassword } from '../../../utils/passwordGenerator';
|
||||
|
||||
interface Class {
|
||||
id: string;
|
||||
name: string;
|
||||
grade: string;
|
||||
}
|
||||
|
||||
export function AddStudentPage() {
|
||||
const navigate = useNavigate();
|
||||
const { loading, error } = useDatabase();
|
||||
const [classes, setClasses] = React.useState<Class[]>([]);
|
||||
const [formData, setFormData] = React.useState({
|
||||
name: '',
|
||||
email: '',
|
||||
class_id: '',
|
||||
guardian_name: '',
|
||||
guardian_email: '',
|
||||
guardian_phone: '',
|
||||
password: generateMnemonicPassword()
|
||||
});
|
||||
const [formError, setFormError] = React.useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchClasses = async () => {
|
||||
try {
|
||||
const { data: authData } = await supabase.auth.getSession();
|
||||
if (!authData.session?.user) return;
|
||||
|
||||
const { data: schoolData, error: schoolError } = await supabase
|
||||
.from('schools')
|
||||
.select('id')
|
||||
.eq('id', authData.session.user.id)
|
||||
.single();
|
||||
|
||||
if (schoolError) throw schoolError;
|
||||
|
||||
const { data: classesData, error: classesError } = await supabase
|
||||
.from('classes')
|
||||
.select('id, name, grade')
|
||||
.eq('school_id', schoolData.id)
|
||||
.order('name');
|
||||
|
||||
if (classesError) throw classesError;
|
||||
setClasses(classesData || []);
|
||||
} catch (err) {
|
||||
console.error('Erro ao buscar turmas:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchClasses();
|
||||
}, []);
|
||||
|
||||
const handleRegeneratePassword = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
password: generateMnemonicPassword()
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCopyPassword = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(formData.password);
|
||||
setSuccessMessage('Senha copiada!');
|
||||
setTimeout(() => setSuccessMessage(null), 2000);
|
||||
} catch (err) {
|
||||
console.error('Erro ao copiar senha:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setFormError(null);
|
||||
|
||||
try {
|
||||
const { data: authData } = await supabase.auth.getSession();
|
||||
if (!authData.session?.user) {
|
||||
throw new Error('Usuário não autenticado');
|
||||
}
|
||||
|
||||
const { data: schoolData, error: schoolError } = await supabase
|
||||
.from('schools')
|
||||
.select('id')
|
||||
.eq('id', authData.session.user.id)
|
||||
.single();
|
||||
|
||||
if (schoolError) throw schoolError;
|
||||
|
||||
// Criar usuário para o aluno com a senha mnemônica
|
||||
const { data: userData, error: userError } = await supabase.auth.signUp({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
options: {
|
||||
data: {
|
||||
role: 'student',
|
||||
name: formData.name,
|
||||
school_id: schoolData.id
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (userError) throw userError;
|
||||
if (!userData.user) throw new Error('Erro ao criar usuário');
|
||||
|
||||
// Criar registro do aluno
|
||||
const { data: newStudent, error: studentError } = await supabase
|
||||
.from('students')
|
||||
.insert({
|
||||
id: userData.user.id,
|
||||
school_id: schoolData.id,
|
||||
class_id: formData.class_id,
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
guardian_name: formData.guardian_name,
|
||||
guardian_email: formData.guardian_email,
|
||||
guardian_phone: formData.guardian_phone
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (studentError) throw studentError;
|
||||
|
||||
// Enviar emails com as credenciais
|
||||
const emailSent = await sendStudentCredentialsEmail({
|
||||
studentName: formData.name,
|
||||
studentEmail: formData.email,
|
||||
password: formData.password,
|
||||
guardianName: formData.guardian_name,
|
||||
guardianEmail: formData.guardian_email
|
||||
});
|
||||
|
||||
navigate('/dashboard/alunos', {
|
||||
state: {
|
||||
message: `Aluno adicionado com sucesso! ${
|
||||
emailSent
|
||||
? 'As credenciais foram enviadas por email.'
|
||||
: 'Não foi possível enviar os emails com as credenciais.'
|
||||
}`
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Erro ao adicionar aluno:', err);
|
||||
setFormError(err instanceof Error ? err.message : 'Erro ao adicionar aluno');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/alunos')}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
Voltar para alunos
|
||||
</button>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Adicionar Aluno</h1>
|
||||
|
||||
{formError && (
|
||||
<div className="mb-4 p-4 bg-red-50 text-red-600 rounded-lg">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
Nome do Aluno
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email do Aluno
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Senha de Acesso
|
||||
</label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
id="password"
|
||||
value={formData.password}
|
||||
readOnly
|
||||
className="block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyPassword}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<Copy className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRegeneratePassword}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg border border-gray-300"
|
||||
title="Gerar nova senha"
|
||||
>
|
||||
<RefreshCw className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Senha gerada automaticamente para fácil memorização
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{successMessage && (
|
||||
<div className="p-4 bg-green-50 text-green-600 rounded-lg">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="class_id" className="block text-sm font-medium text-gray-700">
|
||||
Turma
|
||||
</label>
|
||||
<select
|
||||
id="class_id"
|
||||
value={formData.class_id}
|
||||
onChange={(e) => setFormData({ ...formData, class_id: e.target.value })}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione uma turma</option>
|
||||
{classes.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} - {c.grade}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Informações do Responsável
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="guardian_name" className="block text-sm font-medium text-gray-700">
|
||||
Nome do Responsável
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="guardian_name"
|
||||
value={formData.guardian_name}
|
||||
onChange={(e) => setFormData({ ...formData, guardian_name: e.target.value })}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="guardian_email" className="block text-sm font-medium text-gray-700">
|
||||
Email do Responsável
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="guardian_email"
|
||||
value={formData.guardian_email}
|
||||
onChange={(e) => setFormData({ ...formData, guardian_email: e.target.value })}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="guardian_phone" className="block text-sm font-medium text-gray-700">
|
||||
Telefone do Responsável
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="guardian_phone"
|
||||
value={formData.guardian_phone}
|
||||
onChange={(e) => setFormData({ ...formData, guardian_phone: e.target.value })}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Adicionando...' : 'Adicionar Aluno'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
src/pages/dashboard/students/StudentsPage.tsx
Normal file
175
src/pages/dashboard/students/StudentsPage.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
import React from 'react';
|
||||
import { Plus, Search, MoreVertical, GraduationCap } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDatabase } from '../../../hooks/useDatabase';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
|
||||
interface StudentData {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
class_id: string;
|
||||
school_id: string;
|
||||
status: 'active' | 'inactive';
|
||||
classes: {
|
||||
name: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface StudentWithDetails {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
class_name: string;
|
||||
stories_count: number;
|
||||
status: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
export function StudentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { loading, error } = useDatabase();
|
||||
const [students, setStudents] = React.useState<StudentWithDetails[]>([]);
|
||||
const [searchTerm, setSearchTerm] = React.useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchStudents = async () => {
|
||||
try {
|
||||
const { data: authData } = await supabase.auth.getSession();
|
||||
if (!authData.session?.user) return;
|
||||
|
||||
const { data: studentsData, error: studentsError } = await supabase
|
||||
.from('students')
|
||||
.select(`
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
class_id,
|
||||
status,
|
||||
classes (
|
||||
name
|
||||
)
|
||||
`);
|
||||
|
||||
if (studentsError) throw studentsError;
|
||||
|
||||
const studentsWithCounts = studentsData.map((student) => ({
|
||||
id: student.id,
|
||||
name: student.name,
|
||||
email: student.email,
|
||||
class_name: student.classes && student.classes[0] ? student.classes[0].name : 'Sem turma',
|
||||
stories_count: 0,
|
||||
status: student.status || 'active'
|
||||
}));
|
||||
|
||||
setStudents(studentsWithCounts);
|
||||
} catch (err) {
|
||||
console.error('Erro ao buscar alunos:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStudents();
|
||||
}, []);
|
||||
|
||||
const handleAddStudent = () => {
|
||||
navigate('/dashboard/alunos/novo');
|
||||
};
|
||||
|
||||
const handleStudentClick = (studentId: string) => {
|
||||
navigate(`/dashboard/alunos/${studentId}`);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: StudentData['status']) => {
|
||||
return status === 'active'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
const getStatusText = (status: StudentData['status']) => {
|
||||
return status === 'active' ? 'Ativo' : 'Inativo';
|
||||
};
|
||||
|
||||
const filteredStudents = students.filter(s =>
|
||||
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
s.class_name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Alunos</h1>
|
||||
<button
|
||||
onClick={handleAddStudent}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
Adicionar Aluno
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 text-red-600 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar alunos..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">Carregando...</div>
|
||||
) : filteredStudents.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
Nenhum aluno encontrado
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{filteredStudents.map((student) => (
|
||||
<div
|
||||
key={student.id}
|
||||
className="p-4 hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => handleStudentClick(student.id)}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{student.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<GraduationCap className="h-4 w-4" />
|
||||
{student.class_name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-sm text-gray-500">
|
||||
<span className="font-medium text-gray-900">
|
||||
{student.stories_count}
|
||||
</span>{' '}
|
||||
histórias
|
||||
</div>
|
||||
<div className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(student.status)}`}>
|
||||
{getStatusText(student.status)}
|
||||
</div>
|
||||
<button className="p-2 hover:bg-gray-100 rounded-full">
|
||||
<MoreVertical className="h-5 w-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
src/pages/dashboard/teachers/InviteTeacherPage.tsx
Normal file
148
src/pages/dashboard/teachers/InviteTeacherPage.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Send } from 'lucide-react';
|
||||
import { useDatabase } from '../../../hooks/useDatabase';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
|
||||
export function InviteTeacherPage() {
|
||||
const navigate = useNavigate();
|
||||
const { inviteTeacher } = useDatabase();
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [formData, setFormData] = React.useState({
|
||||
name: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
message: ''
|
||||
});
|
||||
const [formError, setFormError] = React.useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setFormError(null);
|
||||
|
||||
try {
|
||||
const { data: authData } = await supabase.auth.getSession();
|
||||
if (!authData.session?.user) {
|
||||
throw new Error('Usuário não autenticado');
|
||||
}
|
||||
|
||||
const { data: schoolData, error: schoolError } = await supabase
|
||||
.from('schools')
|
||||
.select('id')
|
||||
.eq('id', authData.session.user.id)
|
||||
.single();
|
||||
|
||||
if (schoolError) throw schoolError;
|
||||
|
||||
const result = await inviteTeacher(schoolData.id, {
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
subject: formData.subject,
|
||||
message: formData.message
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Erro ao enviar convite');
|
||||
}
|
||||
|
||||
navigate('/dashboard/professores', {
|
||||
state: { message: 'Convite enviado com sucesso!' }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Erro ao enviar convite:', err);
|
||||
setFormError(err instanceof Error ? err.message : 'Erro ao enviar convite');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/professores')}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
Voltar para professores
|
||||
</button>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Convidar Professor</h1>
|
||||
|
||||
{formError && (
|
||||
<div className="mb-4 p-4 bg-red-50 text-red-600 rounded-lg">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
Nome do Professor
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="subject" className="block text-sm font-medium text-gray-700">
|
||||
Disciplina
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="subject"
|
||||
value={formData.subject}
|
||||
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-gray-700">
|
||||
Mensagem Personalizada (opcional)
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
rows={4}
|
||||
value={formData.message}
|
||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex justify-center items-center gap-2 py-2 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{isLoading ? 'Enviando...' : 'Enviar Convite'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
src/pages/dashboard/teachers/TeachersPage.tsx
Normal file
115
src/pages/dashboard/teachers/TeachersPage.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { Plus, Search, MoreVertical, GraduationCap, Mail } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
import type { Teacher } from '../../../types/database';
|
||||
|
||||
export function TeachersPage() {
|
||||
const navigate = useNavigate();
|
||||
const [teachers, setTeachers] = React.useState<Teacher[]>([]);
|
||||
const [searchTerm, setSearchTerm] = React.useState('');
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchTeachers = async () => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('teachers')
|
||||
.select('*')
|
||||
.eq('school_id', session.user.id);
|
||||
|
||||
if (error) throw error;
|
||||
setTeachers(data || []);
|
||||
} catch (err) {
|
||||
console.error('Erro ao buscar professores:', err);
|
||||
setError('Erro ao buscar professores');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTeachers();
|
||||
}, []);
|
||||
|
||||
const filteredTeachers = teachers.filter(teacher =>
|
||||
teacher.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Professores</h1>
|
||||
<button
|
||||
onClick={() => navigate('convidar')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
Convidar Professor
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 text-red-600 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar professores..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">Carregando...</div>
|
||||
) : filteredTeachers.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
{searchTerm ? 'Nenhum professor encontrado' : 'Nenhum professor cadastrado'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{filteredTeachers.map((teacher) => (
|
||||
<div
|
||||
key={teacher.id}
|
||||
className="p-4 hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => navigate(`/dashboard/professores/${teacher.id}`)}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{teacher.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Mail className="h-4 w-4" />
|
||||
{teacher.email}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Ativo
|
||||
</span>
|
||||
<button className="p-2 hover:bg-gray-100 rounded-full">
|
||||
<MoreVertical className="h-5 w-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
src/pages/demo/DemoPage.tsx
Normal file
96
src/pages/demo/DemoPage.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AudioRecorderDemo } from '../../components/demo/AudioRecorderDemo';
|
||||
import { ArrowRight, Sparkles } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export function DemoPage() {
|
||||
const navigate = useNavigate();
|
||||
const [demoResult, setDemoResult] = useState<{
|
||||
fluency?: number;
|
||||
accuracy?: number;
|
||||
confidence?: number;
|
||||
feedback?: string;
|
||||
} | null>(null);
|
||||
|
||||
const handleDemoComplete = (result: typeof demoResult) => {
|
||||
setDemoResult(result);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-purple-50 to-white py-12">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Experimente Agora!
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600">
|
||||
Grave um trecho de leitura e veja como nossa IA avalia seu desempenho
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 mb-8">
|
||||
<div className="prose max-w-none mb-8">
|
||||
<h2>Texto Sugerido para Leitura:</h2>
|
||||
<blockquote className="text-lg text-gray-700 border-l-4 border-purple-300 pl-4">
|
||||
"O pequeno príncipe sentou-se numa pedra e levantou os olhos para o céu:
|
||||
— Pergunto-me se as estrelas são iluminadas para que cada um possa um dia encontrar a sua."
|
||||
</blockquote>
|
||||
</div>
|
||||
|
||||
<AudioRecorderDemo onAnalysisComplete={handleDemoComplete} />
|
||||
</div>
|
||||
|
||||
{demoResult && (
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center gap-2">
|
||||
<Sparkles className="text-purple-600" />
|
||||
Resultado da Análise
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6 mb-8">
|
||||
<div className="bg-purple-50 rounded-xl p-6">
|
||||
<div className="text-3xl font-bold text-purple-600 mb-2">
|
||||
{demoResult.fluency}%
|
||||
</div>
|
||||
<div className="text-gray-600">Fluência</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-purple-50 rounded-xl p-6">
|
||||
<div className="text-3xl font-bold text-purple-600 mb-2">
|
||||
{demoResult.accuracy}%
|
||||
</div>
|
||||
<div className="text-gray-600">Precisão</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-purple-50 rounded-xl p-6">
|
||||
<div className="text-3xl font-bold text-purple-600 mb-2">
|
||||
{demoResult.confidence}%
|
||||
</div>
|
||||
<div className="text-gray-600">Confiança</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 rounded-xl p-6 mb-8">
|
||||
<h3 className="text-lg font-semibold text-green-800 mb-2">
|
||||
Feedback da IA
|
||||
</h3>
|
||||
<p className="text-green-700">
|
||||
{demoResult.feedback}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={() => navigate('/register/school')}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition"
|
||||
>
|
||||
Começar a Usar na Minha Escola
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
277
src/pages/demo/StoryPageDemo.tsx
Normal file
277
src/pages/demo/StoryPageDemo.tsx
Normal file
@ -0,0 +1,277 @@
|
||||
import React from 'react';
|
||||
import { ArrowLeft, ArrowRight, Mic, Volume2, Share2, ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
|
||||
import { AudioRecorder } from '../../components/story/AudioRecorder';
|
||||
import { StoryMetrics } from '../../components/story/StoryMetrics';
|
||||
import type { StoryRecording } from '../../types/database';
|
||||
|
||||
// Separar dados mock em arquivo próprio
|
||||
const DEMO_DATA = {
|
||||
recording: {
|
||||
id: 'demo-recording',
|
||||
fluency_score: 85,
|
||||
pronunciation_score: 90,
|
||||
accuracy_score: 88,
|
||||
comprehension_score: 92,
|
||||
words_per_minute: 120,
|
||||
pause_count: 3,
|
||||
error_count: 2,
|
||||
self_corrections: 1,
|
||||
strengths: [
|
||||
'Ótima pronúncia das palavras',
|
||||
'Boa velocidade de leitura',
|
||||
'Excelente compreensão do texto'
|
||||
],
|
||||
improvements: [
|
||||
'Reduzir pequenas pausas entre frases',
|
||||
'Praticar palavras mais complexas'
|
||||
],
|
||||
suggestions: 'Continue praticando a leitura em voz alta regularmente',
|
||||
created_at: new Date().toISOString(),
|
||||
processed_at: new Date().toISOString()
|
||||
},
|
||||
story: {
|
||||
id: 'demo',
|
||||
student_id: 'demo',
|
||||
title: 'Uma Aventura Educacional',
|
||||
content: {
|
||||
pages: [
|
||||
{
|
||||
text: 'Bem-vindo à demonstração do Histórias Mágicas! Aqui você pode ver como funciona nossa plataforma de leitura interativa...',
|
||||
image: 'https://images.unsplash.com/photo-1472162072942-cd5147eb3902?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
},
|
||||
{
|
||||
text: 'Com histórias interativas e educativas, seus alunos aprenderão de forma divertida e envolvente. Cada história é uma nova aventura!',
|
||||
image: 'https://images.unsplash.com/photo-1519681393784-d120267933ba?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Componente para imagem com loading
|
||||
function ImageWithLoading({ src, alt }: { src: string; alt: string }) {
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
|
||||
return (
|
||||
<div className="relative aspect-video bg-gray-50">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 text-purple-600 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`w-full h-full object-cover transition-opacity duration-300 ${
|
||||
isLoading ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Componente para navegação entre páginas
|
||||
function PageNavigation({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPrevious,
|
||||
onNext
|
||||
}: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-between items-center mt-8 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
onClick={onPrevious}
|
||||
disabled={currentPage === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50 transition"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
Anterior
|
||||
</button>
|
||||
|
||||
<span className="text-sm font-medium text-gray-500">
|
||||
Página {currentPage + 1} de {totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={currentPage === totalPages - 1}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50 transition"
|
||||
>
|
||||
Próxima
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StoryPageDemo(): JSX.Element {
|
||||
const [currentPage, setCurrentPage] = React.useState(0);
|
||||
const [isPlaying, setIsPlaying] = React.useState(false);
|
||||
const [showMetrics, setShowMetrics] = React.useState(false);
|
||||
const [isRecording, setIsRecording] = React.useState(false);
|
||||
|
||||
const handleShare = () => {
|
||||
alert('Funcionalidade de compartilhamento disponível apenas na versão completa!');
|
||||
};
|
||||
|
||||
// Simula o processo de gravação e análise
|
||||
const handleRecordingComplete = () => {
|
||||
setIsRecording(true);
|
||||
setTimeout(() => {
|
||||
setIsRecording(false);
|
||||
setShowMetrics(true);
|
||||
}, 3000); // Simula 3 segundos de "processamento"
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-purple-50 to-white py-12">
|
||||
<div className="max-w-4xl mx-auto px-4 space-y-8">
|
||||
{/* Header */}
|
||||
<header className="flex justify-between items-center">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 transition"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
Voltar
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 transition"
|
||||
>
|
||||
<Share2 className="h-5 w-5" />
|
||||
Compartilhar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsPlaying(!isPlaying)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 transition"
|
||||
>
|
||||
<Volume2 className="h-5 w-5" />
|
||||
{isPlaying ? 'Pausar' : 'Ouvir'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* História com Imagem */}
|
||||
<main className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<ImageWithLoading
|
||||
src={DEMO_DATA.story.content.pages[currentPage].image}
|
||||
alt={`Página ${currentPage + 1}`}
|
||||
/>
|
||||
|
||||
<div className="p-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-6">
|
||||
{DEMO_DATA.story.title}
|
||||
</h1>
|
||||
|
||||
<p className="text-xl leading-relaxed text-gray-700 mb-8">
|
||||
{DEMO_DATA.story.content.pages[currentPage].text}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleRecordingComplete}
|
||||
disabled={isRecording}
|
||||
className={`flex items-center gap-2 px-6 py-3 rounded-lg text-white transition
|
||||
${isRecording
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-purple-600 hover:bg-purple-700'}`}
|
||||
>
|
||||
<Mic className="h-5 w-5" />
|
||||
{isRecording ? 'Processando...' : 'Gravar Leitura'}
|
||||
</button>
|
||||
{isRecording && (
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Analisando sua leitura...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PageNavigation
|
||||
currentPage={currentPage}
|
||||
totalPages={DEMO_DATA.story.content.pages.length}
|
||||
onPrevious={() => setCurrentPage(p => Math.max(0, p - 1))}
|
||||
onNext={() => setCurrentPage(p => Math.min(DEMO_DATA.story.content.pages.length - 1, p + 1))}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Dashboard de Métricas Condicional */}
|
||||
{showMetrics && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
Dashboard de Leitura
|
||||
</h2>
|
||||
|
||||
<StoryMetrics
|
||||
data={{
|
||||
metrics: {
|
||||
fluency: DEMO_DATA.recording.fluency_score,
|
||||
pronunciation: DEMO_DATA.recording.pronunciation_score,
|
||||
accuracy: DEMO_DATA.recording.accuracy_score,
|
||||
comprehension: DEMO_DATA.recording.comprehension_score
|
||||
},
|
||||
feedback: {
|
||||
strengths: DEMO_DATA.recording.strengths,
|
||||
improvements: DEMO_DATA.recording.improvements,
|
||||
suggestions: DEMO_DATA.recording.suggestions
|
||||
},
|
||||
details: {
|
||||
wordsPerMinute: DEMO_DATA.recording.words_per_minute,
|
||||
pauseCount: DEMO_DATA.recording.pause_count,
|
||||
errorCount: DEMO_DATA.recording.error_count,
|
||||
selfCorrections: DEMO_DATA.recording.self_corrections
|
||||
}
|
||||
}}
|
||||
isLoading={false}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* CTAs */}
|
||||
<section className="border-t border-gray-200 pt-8 mt-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 text-center">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4">
|
||||
Para Escolas
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Transforme a experiência de leitura na sua escola com nossa plataforma educacional.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.href = '/register/school'}
|
||||
className="w-full px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||||
>
|
||||
Começar a Usar na Minha Escola
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 text-center">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4">
|
||||
Para Pais
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Acompanhe e incentive o desenvolvimento da leitura do seu filho de forma interativa.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.href = '/register/parent'}
|
||||
className="w-full px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
|
||||
>
|
||||
Quero Usar com Meu Filho
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
807
src/pages/landing/EducationalForParents.tsx
Normal file
807
src/pages/landing/EducationalForParents.tsx
Normal file
@ -0,0 +1,807 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowRight, Wand2, Shield, Star, BookOpen,
|
||||
Brain, Target, Users, Award, CheckCircle,
|
||||
Clock, Heart, Sparkles, ScrollText, Lock, X,
|
||||
Facebook, Instagram, Twitter, Youtube
|
||||
} from 'lucide-react';
|
||||
|
||||
export function EducationalForParents(): JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* 1. Hero Section */}
|
||||
<section className="relative overflow-hidden bg-gradient-to-b from-purple-50 via-white to-purple-50">
|
||||
<div className="absolute inset-0 bg-[url('/patterns/magic.svg')] opacity-5" />
|
||||
<div className="px-4 py-24 mx-auto max-w-7xl relative">
|
||||
{/* Reading Time */}
|
||||
<div className="absolute top-8 right-8 flex items-center gap-2 text-sm text-gray-500">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Tempo de leitura: 5 minutos</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center gap-16">
|
||||
<div className="flex-1 space-y-8">
|
||||
<h1 className="text-6xl font-bold text-gray-900 leading-tight">
|
||||
Transforme o Aprendizado em Uma
|
||||
<span className="block bg-gradient-to-r from-purple-600 to-blue-500 bg-clip-text text-transparent">
|
||||
Aventura Mágica
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-gray-600 leading-relaxed">
|
||||
Histórias educativas personalizadas que encantam e ensinam, criadas especialmente
|
||||
para o desenvolvimento único do seu filho.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/register/parent')}
|
||||
className="group px-8 py-4 bg-gradient-to-r from-purple-600 to-blue-500
|
||||
text-white rounded-xl hover:from-purple-700 hover:to-blue-600
|
||||
transform hover:scale-105 transition-all shadow-lg"
|
||||
>
|
||||
Comece Sua Aventura Mágica Grátis
|
||||
<ArrowRight className="inline-block ml-2 h-5 w-5
|
||||
group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Social Proof */}
|
||||
<div className="flex gap-8 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5 text-purple-600" />
|
||||
<span>Mais de 10.000 histórias mágicas criadas</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-blue-500" />
|
||||
<span>5.000 pequenos leitores encantados</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-4 bg-gradient-to-r from-purple-600 to-blue-500
|
||||
rounded-2xl blur-lg opacity-20" />
|
||||
<img
|
||||
src="/images/magic-book.webp"
|
||||
alt="Crianças mergulhadas em um livro mágico"
|
||||
className="relative rounded-2xl shadow-2xl transform hover:scale-[1.02]
|
||||
transition-transform"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 2. Problema & Solução */}
|
||||
<section className="px-4 py-24 bg-white">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<h2 className="text-4xl font-bold text-center text-gray-900 mb-16">
|
||||
Desafios que Todo Pai Enfrenta
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8 mb-16">
|
||||
{challenges.map((challenge, index) => (
|
||||
<div key={index} className="p-6 bg-purple-50 rounded-xl">
|
||||
<challenge.icon className="h-12 w-12 text-purple-600 mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">
|
||||
{challenge.title}
|
||||
</h3>
|
||||
<p className="text-gray-600">{challenge.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-4 gap-8">
|
||||
{benefits.map((benefit, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<div className="mx-auto w-16 h-16 flex items-center justify-center
|
||||
bg-gradient-to-r from-purple-600 to-blue-500 rounded-full mb-4">
|
||||
<benefit.icon className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h4 className="font-bold text-gray-900 mb-2">{benefit.title}</h4>
|
||||
<p className="text-sm text-gray-600">{benefit.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 3. Como a Magia Acontece */}
|
||||
<section className="px-4 py-24 bg-gradient-to-br from-purple-50 to-blue-50">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<h2 className="text-4xl font-bold text-center text-gray-900 mb-16">
|
||||
Como a Magia Acontece
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div className="space-y-12">
|
||||
{magicSteps.map((step, index) => (
|
||||
<div key={index} className="flex gap-6">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-gradient-to-r from-purple-600
|
||||
to-blue-500 text-white rounded-full flex items-center justify-center
|
||||
text-xl font-bold">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="text-gray-600">{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="aspect-video rounded-xl overflow-hidden shadow-xl">
|
||||
<video
|
||||
className="w-full h-full object-cover"
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
poster="/images/demo-poster.webp"
|
||||
>
|
||||
<source src="/videos/demo.mp4" type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
<div className="absolute -bottom-6 -right-6 bg-white p-4 rounded-lg shadow-lg">
|
||||
<div className="flex items-center gap-2 text-sm text-purple-600 font-medium">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>Veja a mágica acontecer!</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 4. Comparação */}
|
||||
<section className="px-4 py-24 bg-white">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<h2 className="text-4xl font-bold text-center text-gray-900 mb-16">
|
||||
A Magia da Transformação
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* Sem Histórias Mágicas */}
|
||||
<div className="p-8 bg-gray-50 rounded-xl border border-gray-200">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<X className="h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900">
|
||||
Sem Histórias Mágicas
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{comparisonData.map((category, index) => (
|
||||
<div key={index} className="mb-8 last:mb-0">
|
||||
<h4 className="font-bold text-gray-900 mb-4">{category.title}</h4>
|
||||
<ul className="space-y-3">
|
||||
{category.without.map((item, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<X className="h-5 w-5 text-red-500 flex-shrink-0 mt-1" />
|
||||
<span className="text-gray-600">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Com Histórias Mágicas */}
|
||||
<div className="p-8 bg-gradient-to-br from-purple-50 to-blue-50 rounded-xl
|
||||
border-2 border-purple-200 transform hover:scale-[1.02] transition-transform">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-purple-600 to-blue-500
|
||||
rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900">
|
||||
Com Histórias Mágicas
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{comparisonData.map((category, index) => (
|
||||
<div key={index} className="mb-8 last:mb-0">
|
||||
<h4 className="font-bold text-gray-900 mb-4">{category.title}</h4>
|
||||
<ul className="space-y-3">
|
||||
{category.with.map((item, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-1" />
|
||||
<span className="text-gray-600">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 5. Benefícios Mágicos Detalhados */}
|
||||
<section className="px-4 py-24 bg-gradient-to-br from-purple-50 to-blue-50">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<h2 className="text-4xl font-bold text-center text-gray-900 mb-16">
|
||||
Benefícios Mágicos Detalhados
|
||||
</h2>
|
||||
|
||||
<div className="grid lg:grid-cols-5 gap-8 mb-16">
|
||||
{detailedBenefits.map((benefit, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white p-6 rounded-xl shadow-sm hover:shadow-md
|
||||
transform hover:scale-105 transition-all"
|
||||
>
|
||||
<div className="flex flex-col items-center text-center gap-4">
|
||||
<div className="w-16 h-16 flex items-center justify-center
|
||||
bg-gradient-to-r from-purple-600 to-blue-500 rounded-full">
|
||||
<benefit.icon className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900">{benefit.title}</h3>
|
||||
<p className="text-gray-600">{benefit.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Preview do Portal */}
|
||||
<div className="relative mt-20">
|
||||
<div className="absolute -inset-4 bg-gradient-to-r from-purple-600 to-blue-500
|
||||
rounded-2xl blur-lg opacity-20" />
|
||||
<div className="relative bg-white p-8 rounded-xl shadow-xl">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
Portal dos Pais: Acompanhamento em Tempo Real
|
||||
</h3>
|
||||
<ul className="space-y-4">
|
||||
<li className="flex items-start gap-3">
|
||||
<CheckCircle className="h-6 w-6 text-green-500 flex-shrink-0 mt-1" />
|
||||
<span className="text-gray-600">
|
||||
Métricas detalhadas de progresso e desenvolvimento
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<CheckCircle className="h-6 w-6 text-green-500 flex-shrink-0 mt-1" />
|
||||
<span className="text-gray-600">
|
||||
Relatórios semanais personalizados
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<CheckCircle className="h-6 w-6 text-green-500 flex-shrink-0 mt-1" />
|
||||
<span className="text-gray-600">
|
||||
Recomendações pedagógicas baseadas em dados
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<CheckCircle className="h-6 w-6 text-green-500 flex-shrink-0 mt-1" />
|
||||
<span className="text-gray-600">
|
||||
Histórico completo de leituras e conquistas
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<img
|
||||
src="/images/dashboard-preview.webp"
|
||||
alt="Portal dos Pais"
|
||||
className="rounded-xl shadow-2xl"
|
||||
/>
|
||||
<div className="absolute -bottom-6 -right-6 bg-white p-4 rounded-lg shadow-lg">
|
||||
<div className="flex items-center gap-2 text-sm text-purple-600 font-medium">
|
||||
<Lock className="h-4 w-4" />
|
||||
<span>Ambiente 100% seguro e monitorado</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 6. Testimoniais */}
|
||||
<section className="px-4 py-24 bg-white">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<h2 className="text-4xl font-bold text-center text-gray-900 mb-16">
|
||||
Histórias de Transformação
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{testimonials.map((testimonial, index) => (
|
||||
<div key={index} className="bg-gradient-to-br from-purple-50 to-blue-50
|
||||
p-6 rounded-xl shadow-sm">
|
||||
<div className="relative mb-8">
|
||||
<img
|
||||
src={testimonial.image}
|
||||
alt={`Família de ${testimonial.name}`}
|
||||
className="w-full h-48 object-cover rounded-lg"
|
||||
/>
|
||||
<div className="absolute -bottom-4 -right-4 bg-white p-2 rounded-full shadow-lg">
|
||||
<Heart className="h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 mb-4 italic">"{testimonial.text}"</p>
|
||||
<div>
|
||||
<p className="font-bold text-gray-900">{testimonial.name}</p>
|
||||
<p className="text-sm text-gray-500">{testimonial.role}</p>
|
||||
</div>
|
||||
<div className="mt-4 p-3 bg-white rounded-lg">
|
||||
<p className="text-sm text-purple-600 font-medium">
|
||||
✨ Momento mágico: {testimonial.magicMoment}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 7. Planos */}
|
||||
<section className="px-4 py-24 bg-gradient-to-b from-purple-50 via-white to-purple-50">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<h2 className="text-4xl font-bold text-center text-gray-900 mb-4">
|
||||
Planos Mágicos
|
||||
</h2>
|
||||
<p className="text-center text-gray-600 mb-16 max-w-2xl mx-auto">
|
||||
Escolha o plano perfeito para a jornada mágica do seu filho
|
||||
</p>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{plans.map((plan, index) => (
|
||||
<div key={index} className={`
|
||||
p-8 rounded-xl shadow-lg border-2
|
||||
${index === 1 ? 'bg-gradient-to-br from-purple-50 to-blue-50 border-purple-200 transform scale-105'
|
||||
: 'bg-white border-gray-100'}
|
||||
`}>
|
||||
<div className="text-center mb-8">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">{plan.title}</h3>
|
||||
<p className="text-gray-600">{plan.description}</p>
|
||||
<div className="mt-4">
|
||||
<span className="text-4xl font-bold text-gray-900">R${plan.price}</span>
|
||||
<span className="text-gray-500">/{plan.period}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-4 mb-8">
|
||||
{plan.features.map((feature, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-1" />
|
||||
<span className="text-gray-600">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/register/parent')}
|
||||
className={`
|
||||
w-full py-4 rounded-xl font-medium transition-all
|
||||
${index === 1
|
||||
? 'bg-gradient-to-r from-purple-600 to-blue-500 text-white hover:from-purple-700 hover:to-blue-600'
|
||||
: 'border-2 border-purple-600 text-purple-600 hover:bg-purple-50'}
|
||||
`}
|
||||
>
|
||||
Começar Agora
|
||||
</button>
|
||||
|
||||
{index === 1 && (
|
||||
<div className="mt-4 text-center">
|
||||
<span className="inline-block px-4 py-1 bg-purple-100 text-purple-600
|
||||
rounded-full text-sm font-medium">
|
||||
Mais Popular
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Garantia mágica de 30 dias ou seu dinheiro de volta
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
{paymentMethods.map((method, index) => (
|
||||
<img
|
||||
key={index}
|
||||
src={method.icon}
|
||||
alt={method.name}
|
||||
className="h-8"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 8. FAQ */}
|
||||
<section className="px-4 py-24 bg-white">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h2 className="text-4xl font-bold text-center text-gray-900 mb-16">
|
||||
Perguntas Mágicas
|
||||
</h2>
|
||||
|
||||
<div className="space-y-8">
|
||||
{faqItems.map((item, index) => (
|
||||
<div key={index} className="bg-gray-50 rounded-xl p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">{item.question}</h3>
|
||||
<p className="text-gray-600">{item.answer}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 9. CTA Final */}
|
||||
<section className="px-4 py-24 bg-gradient-to-br from-purple-600 to-blue-500 text-white">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<h2 className="text-4xl font-bold mb-8">
|
||||
Comece a Jornada Mágica Hoje
|
||||
</h2>
|
||||
|
||||
<p className="text-xl opacity-90 mb-12">
|
||||
Transforme a educação do seu filho em uma aventura inesquecível
|
||||
</p>
|
||||
|
||||
<div className="bg-white/10 p-6 rounded-xl mb-8">
|
||||
<p className="font-medium mb-2">
|
||||
Oferta por tempo limitado!
|
||||
</p>
|
||||
<p className="text-sm opacity-90">
|
||||
7 dias grátis + Bônus especial de boas-vindas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/register/parent')}
|
||||
className="px-12 py-6 bg-white text-purple-600 rounded-xl text-xl font-bold
|
||||
hover:bg-gray-100 transform hover:scale-105 transition-all shadow-lg"
|
||||
>
|
||||
Criar Conta Gratuita
|
||||
<ArrowRight className="inline-block ml-2 h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<p className="mt-6 text-sm opacity-75">
|
||||
Garantia de 30 dias ou seu dinheiro de volta
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 10. Rodapé */}
|
||||
<footer className="bg-gray-900 text-gray-400 py-16">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<div className="grid md:grid-cols-4 gap-12">
|
||||
<div>
|
||||
<h4 className="text-white font-bold mb-4">Histórias Mágicas</h4>
|
||||
<p className="text-sm">
|
||||
Transformando a educação através da magia da leitura personalizada
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{footerLinks.map((column, index) => (
|
||||
<div key={index}>
|
||||
<h4 className="text-white font-bold mb-4">{column.title}</h4>
|
||||
<ul className="space-y-2">
|
||||
{column.links.map((link, idx) => (
|
||||
<li key={idx}>
|
||||
<a href={link.href} className="text-sm hover:text-white transition-colors">
|
||||
{link.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 pt-8 border-t border-gray-800 text-sm">
|
||||
<div className="flex justify-between items-center">
|
||||
<p>© 2024 Histórias Mágicas. Todos os direitos reservados.</p>
|
||||
<div className="flex gap-4">
|
||||
{socialLinks.map((social, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={social.href}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<social.icon className="h-5 w-5" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const challenges = [
|
||||
{
|
||||
icon: Brain,
|
||||
title: "Manter as crianças interessadas em aprender",
|
||||
description: "É difícil competir com jogos e vídeos para capturar a atenção das crianças."
|
||||
},
|
||||
{
|
||||
icon: BookOpen,
|
||||
title: "Encontrar conteúdo educativo de qualidade",
|
||||
description: "Muito conteúdo disponível, mas pouco realmente educativo e envolvente."
|
||||
},
|
||||
{
|
||||
icon: Target,
|
||||
title: "Acompanhar o desenvolvimento da criança",
|
||||
description: "Falta de ferramentas para monitorar o progresso de forma clara e objetiva."
|
||||
}
|
||||
];
|
||||
|
||||
const benefits = [
|
||||
{
|
||||
icon: Wand2,
|
||||
title: "Personalização por IA",
|
||||
description: "Histórias únicas criadas especialmente para cada criança"
|
||||
},
|
||||
{
|
||||
icon: Star,
|
||||
title: "Monitoramento Educacional",
|
||||
description: "Acompanhe o progresso com métricas claras e objetivas"
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: "Segurança de Conteúdo",
|
||||
description: "Ambiente seguro e controlado para o aprendizado"
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: "Engajamento Garantido",
|
||||
description: "Histórias que prendem a atenção e estimulam a imaginação"
|
||||
}
|
||||
];
|
||||
|
||||
const magicSteps = [
|
||||
{
|
||||
title: "Escolha o tema da aventura",
|
||||
description: "Selecione entre diversos temas educativos alinhados com a BNCC e adequados à idade."
|
||||
},
|
||||
{
|
||||
title: "Personalize os personagens",
|
||||
description: "Crie personagens que seu filho vai adorar, com características únicas e cativantes."
|
||||
},
|
||||
{
|
||||
title: "A IA cria a história mágica",
|
||||
description: "Nossa IA educacional gera uma história personalizada em segundos."
|
||||
},
|
||||
{
|
||||
title: "A aventura educativa começa",
|
||||
description: "Seu filho mergulha em uma jornada mágica de aprendizado e diversão."
|
||||
}
|
||||
];
|
||||
|
||||
const comparisonData = [
|
||||
{
|
||||
title: "Tempo & Diversão",
|
||||
without: [
|
||||
"Horas procurando conteúdo educativo adequado",
|
||||
"Crianças entediadas com leituras tradicionais",
|
||||
"Histórias que não capturam a imaginação",
|
||||
"Dificuldade em acompanhar o progresso"
|
||||
],
|
||||
with: [
|
||||
"Histórias mágicas personalizadas em minutos",
|
||||
"Crianças fascinadas por aventuras únicas",
|
||||
"Mundos mágicos que educam e encantam",
|
||||
"Portal mágico de acompanhamento do progresso"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Qualidade do Aprendizado",
|
||||
without: [
|
||||
"Conteúdo genérico e previsível",
|
||||
"Falta de conexão emocional com a leitura",
|
||||
"Dificuldade em manter o interesse",
|
||||
"Aprendizado fragmentado"
|
||||
],
|
||||
with: [
|
||||
"Histórias que evoluem com cada criança",
|
||||
"Conexão emocional com personagens únicos",
|
||||
"Aventuras que mesclam diversão e educação",
|
||||
"Jornada de aprendizado mágica e integrada"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Resultados",
|
||||
without: [
|
||||
"Progresso lento e desmotivador",
|
||||
"Resistência à leitura e aprendizado",
|
||||
"Rotina de estudos cansativa",
|
||||
"Pais preocupados com desenvolvimento"
|
||||
],
|
||||
with: [
|
||||
"Evolução visível e empolgante",
|
||||
"Amor natural pela leitura e conhecimento",
|
||||
"Aventuras diárias de aprendizado",
|
||||
"Pais confiantes no desenvolvimento mágico"
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const detailedBenefits = [
|
||||
{
|
||||
icon: Wand2,
|
||||
title: "Aprendizado Através de Aventuras",
|
||||
description: "Histórias que se adaptam ao nível e interesses do seu filho, tornando o aprendizado natural e divertido."
|
||||
},
|
||||
{
|
||||
icon: ScrollText,
|
||||
title: "Portal dos Pais",
|
||||
description: "Acompanhe em tempo real o progresso de leitura, compreensão e desenvolvimento do seu filho."
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: "Proteção Mágica",
|
||||
description: "Conteúdo 100% seguro e adequado, com moderação constante e controles parentais."
|
||||
},
|
||||
{
|
||||
icon: BookOpen,
|
||||
title: "Alinhamento com BNCC",
|
||||
description: "Histórias criadas seguindo as diretrizes da Base Nacional Comum Curricular."
|
||||
},
|
||||
{
|
||||
icon: Brain,
|
||||
title: "IA Educacional",
|
||||
description: "Nossa inteligência artificial analisa o perfil do seu filho para criar histórias personalizadas e adaptativas."
|
||||
}
|
||||
];
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
image: "/images/testimonial-1.webp",
|
||||
text: "Minha filha passou de resistente à leitura para não querer parar de ler! As histórias personalizadas fizeram toda a diferença.",
|
||||
name: "Ana Silva",
|
||||
role: "Mãe da Maria, 8 anos",
|
||||
magicMoment: "Primeira história completa lida sozinha"
|
||||
},
|
||||
{
|
||||
image: "/images/testimonial-2.webp",
|
||||
text: "Como pai, é incrível ver o progresso do Pedro. O portal dos pais me ajuda a entender exatamente onde ele precisa de apoio.",
|
||||
name: "Carlos Santos",
|
||||
role: "Pai do Pedro, 10 anos",
|
||||
magicMoment: "Superou a dificuldade com palavras complexas"
|
||||
},
|
||||
{
|
||||
image: "/images/testimonial-3.webp",
|
||||
text: "As histórias são tão envolventes que meu filho pede para ler mais uma toda noite. O aprendizado acontece naturalmente!",
|
||||
name: "Juliana Costa",
|
||||
role: "Mãe do Lucas, 7 anos",
|
||||
magicMoment: "Começou a criar suas próprias histórias"
|
||||
}
|
||||
];
|
||||
|
||||
const plans = [
|
||||
{
|
||||
title: "Aprendiz de Mago",
|
||||
description: "Perfeito para começar",
|
||||
price: "49,90",
|
||||
period: "mês",
|
||||
features: [
|
||||
"5 histórias personalizadas por mês",
|
||||
"Análise básica de progresso",
|
||||
"Suporte por email",
|
||||
"Acesso ao portal dos pais",
|
||||
"Relatórios mensais"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Mago Experiente",
|
||||
description: "Mais popular",
|
||||
price: "39,90",
|
||||
period: "mês",
|
||||
features: [
|
||||
"15 histórias personalizadas por mês",
|
||||
"Análise avançada de progresso",
|
||||
"Suporte prioritário",
|
||||
"Portal dos pais premium",
|
||||
"Relatórios semanais",
|
||||
"Histórias temáticas especiais",
|
||||
"Bônus: Kit de Atividades Mágicas"
|
||||
],
|
||||
highlight: true,
|
||||
commitment: "Semestral"
|
||||
},
|
||||
{
|
||||
title: "Grão-Mestre",
|
||||
description: "Melhor custo-benefício",
|
||||
price: "29,90",
|
||||
period: "mês",
|
||||
features: [
|
||||
"Histórias ilimitadas",
|
||||
"Análise completa de progresso",
|
||||
"Suporte VIP 24/7",
|
||||
"Portal dos pais premium",
|
||||
"Relatórios diários",
|
||||
"Histórias temáticas especiais",
|
||||
"Bônus: Kit de Atividades Mágicas",
|
||||
"Bônus: Sessões com pedagogo"
|
||||
],
|
||||
commitment: "Anual"
|
||||
}
|
||||
];
|
||||
|
||||
const faqItems = [
|
||||
{
|
||||
question: "Como a magia da IA funciona?",
|
||||
answer: "Nossa IA educacional analisa o perfil do seu filho, incluindo idade, interesses e nível de leitura, para criar histórias únicas que combinam diversão com aprendizado personalizado."
|
||||
},
|
||||
{
|
||||
question: "Como garantimos histórias seguras?",
|
||||
answer: "Todas as histórias passam por múltiplas camadas de verificação, incluindo filtros de IA e revisão humana, garantindo conteúdo 100% adequado e seguro."
|
||||
},
|
||||
{
|
||||
question: "Como acompanhar a evolução mágica?",
|
||||
answer: "Através do Portal dos Pais, você tem acesso a relatórios detalhados sobre fluência, compreensão, vocabulário e muito mais, com visualizações claras do progresso."
|
||||
},
|
||||
{
|
||||
question: "Qual é a política de cancelamento?",
|
||||
answer: "Você pode cancelar sua assinatura a qualquer momento, sem multas. Oferecemos garantia de 30 dias - se não estiver satisfeito, devolvemos seu dinheiro."
|
||||
},
|
||||
{
|
||||
question: "Quantas histórias mágicas por mês?",
|
||||
answer: "O número de histórias varia conforme o plano escolhido, desde 5 histórias mensais no plano básico até histórias ilimitadas no plano Grão-Mestre."
|
||||
},
|
||||
{
|
||||
question: "Como funciona o suporte aos pais?",
|
||||
answer: "Oferecemos suporte via chat, email e telefone, com especialistas em educação prontos para ajudar. Planos premium incluem acesso a pedagogos."
|
||||
}
|
||||
];
|
||||
|
||||
const footerLinks = [
|
||||
{
|
||||
title: "Produto",
|
||||
links: [
|
||||
{ text: "Recursos", href: "#recursos" },
|
||||
{ text: "Preços", href: "#precos" },
|
||||
{ text: "Como Funciona", href: "#como-funciona" },
|
||||
{ text: "Histórias de Sucesso", href: "#testimoniais" }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Suporte",
|
||||
links: [
|
||||
{ text: "Central de Ajuda", href: "#ajuda" },
|
||||
{ text: "Contato", href: "#contato" },
|
||||
{ text: "FAQ", href: "#faq" },
|
||||
{ text: "Tutoriais", href: "#tutoriais" }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Legal",
|
||||
links: [
|
||||
{ text: "Termos de Uso", href: "#termos" },
|
||||
{ text: "Privacidade", href: "#privacidade" },
|
||||
{ text: "Segurança", href: "#seguranca" },
|
||||
{ text: "Cookies", href: "#cookies" }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const socialLinks = [
|
||||
{ icon: Facebook, href: "https://facebook.com/historias-magicas" },
|
||||
{ icon: Instagram, href: "https://instagram.com/historias-magicas" },
|
||||
{ icon: Twitter, href: "https://twitter.com/historias-magicas" },
|
||||
{ icon: Youtube, href: "https://youtube.com/historias-magicas" }
|
||||
];
|
||||
|
||||
const paymentMethods = [
|
||||
{ name: "Cartão de Crédito", icon: "/icons/credit-card.svg" },
|
||||
{ name: "Boleto", icon: "/icons/boleto.svg" },
|
||||
{ name: "PIX", icon: "/icons/pix.svg" },
|
||||
{ name: "PayPal", icon: "/icons/paypal.svg" }
|
||||
];
|
||||
458
src/pages/landing/ParentsLandingPage.tsx
Normal file
458
src/pages/landing/ParentsLandingPage.tsx
Normal file
@ -0,0 +1,458 @@
|
||||
import React from 'react';
|
||||
import { ArrowRight, BookOpen, Brain, Target, Clock, Shield, Check, X } from 'lucide-react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export function ParentsLandingPage(): JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-purple-50 via-white to-purple-50">
|
||||
{/* 1. Hero Section */}
|
||||
<section className="relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[url('/patterns/grid.svg')] opacity-5" />
|
||||
<div className="px-4 py-24 mx-auto max-w-7xl relative">
|
||||
<div className="flex flex-col md:flex-row items-center gap-16">
|
||||
<div className="flex-1 space-y-8">
|
||||
<h1 className="text-6xl font-bold text-gray-900 leading-tight">
|
||||
Transforme a Leitura do Seu Filho em uma
|
||||
<span className="bg-gradient-to-r from-purple-600 to-indigo-600 bg-clip-text text-transparent">
|
||||
Jornada Mágica
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-gray-600 leading-relaxed">
|
||||
Uma plataforma educacional que combina tecnologia e pedagogia para
|
||||
desenvolver habilidades essenciais de leitura de forma divertida e envolvente.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/register/parent')}
|
||||
className="px-8 py-4 bg-gradient-to-r from-purple-600 to-indigo-600 text-white rounded-xl
|
||||
hover:from-purple-700 hover:to-indigo-700 transform hover:scale-105 transition-all shadow-lg"
|
||||
>
|
||||
Começar Gratuitamente
|
||||
<ArrowRight className="inline-block ml-2 h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/demo')}
|
||||
className="px-8 py-4 border-2 border-purple-600 text-purple-600 rounded-xl
|
||||
hover:bg-purple-50 transform hover:scale-105 transition-all"
|
||||
>
|
||||
Ver Demonstração
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-4 bg-gradient-to-r from-purple-600 to-indigo-600 rounded-2xl blur-lg opacity-20" />
|
||||
<img
|
||||
src="/images/reading-kid.webp"
|
||||
alt="Criança lendo com entusiasmo"
|
||||
className="relative rounded-2xl shadow-2xl transform hover:scale-[1.02] transition-transform"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 2. Por que escolher */}
|
||||
<section className="px-4 py-24 bg-white relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[url('/patterns/dots.svg')] opacity-5" />
|
||||
<div className="mx-auto max-w-7xl relative">
|
||||
<h2 className="text-3xl font-bold text-center text-gray-900 mb-16">
|
||||
Por que escolher o Histórias Mágicas?
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
<div className="p-6 bg-purple-50 rounded-xl">
|
||||
<BookOpen className="h-12 w-12 text-purple-600 mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">
|
||||
Leitura Personalizada
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Histórias adaptadas ao nível e interesses do seu filho,
|
||||
garantindo engajamento e progresso contínuo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-purple-50 rounded-xl">
|
||||
<Brain className="h-12 w-12 text-purple-600 mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">
|
||||
Análise em Tempo Real
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Feedback instantâneo sobre fluência, compreensão e
|
||||
pronúncia para identificar áreas de melhoria.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-purple-50 rounded-xl">
|
||||
<Target className="h-12 w-12 text-purple-600 mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">
|
||||
Progresso Mensurável
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Acompanhe o desenvolvimento do seu filho com métricas
|
||||
claras e relatórios detalhados.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 3. Como Funciona */}
|
||||
<section className="px-4 py-24">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<h2 className="text-3xl font-bold text-center text-gray-900 mb-16">
|
||||
Como Funciona
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div className="space-y-8">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-purple-600 text-white rounded-full flex items-center justify-center">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">
|
||||
Escolha uma História
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Biblioteca diversificada com conteúdo educativo e adequado
|
||||
para cada idade e nível de leitura.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-purple-600 text-white rounded-full flex items-center justify-center">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">
|
||||
Pratique a Leitura
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Interface interativa que incentiva a leitura em voz alta
|
||||
e fornece suporte quando necessário.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-purple-600 text-white rounded-full flex items-center justify-center">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">
|
||||
Receba Feedback
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Análise detalhada do desempenho com sugestões
|
||||
personalizadas para melhorar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-xl shadow-xl">
|
||||
<img
|
||||
src="/images/app-demo.webp"
|
||||
alt="Demonstração da plataforma"
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 4. Análise Detalhada */}
|
||||
<section className="px-4 py-24 bg-gray-50">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<h2 className="text-3xl font-bold text-center text-gray-900 mb-16">
|
||||
Análise Detalhada do Progresso
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-12">
|
||||
{/* Métricas */}
|
||||
<div className="bg-white p-8 rounded-xl shadow-sm">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-6">
|
||||
Exemplo de Análise de Leitura
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-500">Fluência</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-4 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-green-500 rounded-full" style={{ width: '85%' }} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">85%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-500">Pronúncia</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-4 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-500 rounded-full" style={{ width: '92%' }} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">92%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-500">Compreensão</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-4 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-purple-500 rounded-full" style={{ width: '88%' }} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">88%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-500">Ritmo</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-4 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-yellow-500 rounded-full" style={{ width: '78%' }} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">78%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 p-4 bg-purple-50 rounded-lg">
|
||||
<h4 className="font-medium text-purple-900 mb-2">Sugestões de Melhoria</h4>
|
||||
<ul className="text-sm text-purple-700 space-y-2">
|
||||
<li>• Praticar palavras mais complexas</li>
|
||||
<li>• Manter ritmo constante durante a leitura</li>
|
||||
<li>• Fazer pausas adequadas na pontuação</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gráfico */}
|
||||
<div className="bg-white p-8 rounded-xl shadow-sm">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-6">
|
||||
Evolução da Leitura
|
||||
</h3>
|
||||
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={progressData}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="fluency"
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={2}
|
||||
name="Fluência"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="comprehension"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
name="Compreensão"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 5. A Diferença que Faz */}
|
||||
<section className="px-4 py-24">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<h2 className="text-3xl font-bold text-center text-gray-900 mb-16">
|
||||
A Diferença que Faz
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* Sem o Histórias Mágicas */}
|
||||
<div className="p-8 bg-gray-50 rounded-xl">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-2">
|
||||
<X className="text-red-500" />
|
||||
Sem o Histórias Mágicas
|
||||
</h3>
|
||||
|
||||
<ul className="space-y-4">
|
||||
<li className="flex items-start gap-2">
|
||||
<X className="h-5 w-5 text-red-500 flex-shrink-0 mt-1" />
|
||||
<span className="text-gray-600">
|
||||
Leitura monótona e desmotivadora
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<X className="h-5 w-5 text-red-500 flex-shrink-0 mt-1" />
|
||||
<span className="text-gray-600">
|
||||
Sem feedback sobre o progresso
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<X className="h-5 w-5 text-red-500 flex-shrink-0 mt-1" />
|
||||
<span className="text-gray-600">
|
||||
Dificuldade em manter consistência
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<X className="h-5 w-5 text-red-500 flex-shrink-0 mt-1" />
|
||||
<span className="text-gray-600">
|
||||
Erros passam despercebidos
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<X className="h-5 w-5 text-red-500 flex-shrink-0 mt-1" />
|
||||
<span className="text-gray-600">
|
||||
Falta de direcionamento pedagógico
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Com o Histórias Mágicas */}
|
||||
<div className="p-8 bg-purple-50 rounded-xl">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-2">
|
||||
<Check className="text-green-500" />
|
||||
Com o Histórias Mágicas
|
||||
</h3>
|
||||
|
||||
<ul className="space-y-4">
|
||||
<li className="flex items-start gap-2">
|
||||
<Check className="h-5 w-5 text-green-500 flex-shrink-0 mt-1" />
|
||||
<span className="text-gray-600">
|
||||
Histórias interativas e envolventes
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Check className="h-5 w-5 text-green-500 flex-shrink-0 mt-1" />
|
||||
<span className="text-gray-600">
|
||||
Análise detalhada do desempenho
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Check className="h-5 w-5 text-green-500 flex-shrink-0 mt-1" />
|
||||
<span className="text-gray-600">
|
||||
Gamificação que incentiva a prática diária
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Check className="h-5 w-5 text-green-500 flex-shrink-0 mt-1" />
|
||||
<span className="text-gray-600">
|
||||
Correção em tempo real e sugestões
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Check className="h-5 w-5 text-green-500 flex-shrink-0 mt-1" />
|
||||
<span className="text-gray-600">
|
||||
Acompanhamento pedagógico personalizado
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 6. O que os Pais Dizem */}
|
||||
<section className="px-4 py-24 bg-purple-50">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<h2 className="text-3xl font-bold text-center text-gray-900 mb-16">
|
||||
O Que os Pais Dizem
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{testimonials.map((testimonial, index) => (
|
||||
<div key={index} className="bg-white p-6 rounded-xl shadow-sm">
|
||||
<p className="text-gray-600 mb-4">"{testimonial.text}"</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={testimonial.avatar}
|
||||
alt={testimonial.name}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{testimonial.name}</p>
|
||||
<p className="text-sm text-gray-500">{testimonial.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 7. CTA Final */}
|
||||
<section className="px-4 py-24 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-purple-50 to-purple-100 opacity-50" />
|
||||
<div className="mx-auto max-w-3xl text-center relative">
|
||||
<h2 className="text-5xl font-bold text-gray-900 mb-8 leading-tight">
|
||||
Comece a Jornada de Leitura do Seu Filho
|
||||
<span className="bg-gradient-to-r from-purple-600 to-indigo-600 bg-clip-text text-transparent">
|
||||
Hoje
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<p className="text-xl text-gray-600 mb-12 leading-relaxed">
|
||||
Junte-se a milhares de pais que já transformaram a experiência
|
||||
de leitura de seus filhos com o Histórias Mágicas.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/register/parent')}
|
||||
className="px-10 py-5 bg-gradient-to-r from-purple-600 to-indigo-600 text-white text-lg
|
||||
rounded-xl hover:from-purple-700 hover:to-indigo-700 transform hover:scale-105
|
||||
transition-all shadow-lg"
|
||||
>
|
||||
Criar Conta Gratuita
|
||||
<ArrowRight className="inline-block ml-2 h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
text: "Minha filha melhorou muito sua leitura em apenas 3 meses. Ela adora as histórias e sempre pede para ler mais!",
|
||||
name: "Ana Silva",
|
||||
role: "Mãe da Maria, 8 anos",
|
||||
avatar: "/avatars/parent-1.webp"
|
||||
},
|
||||
{
|
||||
text: "O feedback em tempo real ajuda muito a identificar onde meu filho precisa melhorar. É como ter um professor particular.",
|
||||
name: "Carlos Santos",
|
||||
role: "Pai do Pedro, 10 anos",
|
||||
avatar: "/avatars/parent-2.webp"
|
||||
},
|
||||
{
|
||||
text: "As histórias são envolventes e educativas. É ótimo ver meu filho animado para ler todos os dias.",
|
||||
name: "Juliana Costa",
|
||||
role: "Mãe do Lucas, 7 anos",
|
||||
avatar: "/avatars/parent-3.webp"
|
||||
}
|
||||
];
|
||||
|
||||
const progressData = [
|
||||
{ month: 'Jan', fluency: 45, comprehension: 40 },
|
||||
{ month: 'Fev', fluency: 52, comprehension: 48 },
|
||||
{ month: 'Mar', fluency: 58, comprehension: 55 },
|
||||
{ month: 'Abr', fluency: 65, comprehension: 62 },
|
||||
{ month: 'Mai', fluency: 75, comprehension: 70 },
|
||||
{ month: 'Jun', fluency: 85, comprehension: 82 }
|
||||
];
|
||||
86
src/pages/student-dashboard/AchievementsPage.tsx
Normal file
86
src/pages/student-dashboard/AchievementsPage.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
|
||||
interface Achievement {
|
||||
id: string;
|
||||
titulo: string;
|
||||
descricao: string;
|
||||
icone: string;
|
||||
conquistado: boolean;
|
||||
dataConquista?: string;
|
||||
progresso?: number;
|
||||
}
|
||||
|
||||
const conquistas: Achievement[] = [
|
||||
{
|
||||
id: '1',
|
||||
titulo: 'Primeira História',
|
||||
descricao: 'Completou sua primeira história',
|
||||
icone: '📚',
|
||||
conquistado: true,
|
||||
dataConquista: '2024-03-15',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
titulo: 'Leitor Dedicado',
|
||||
descricao: 'Leu histórias por 5 dias seguidos',
|
||||
icone: '🌟',
|
||||
conquistado: false,
|
||||
progresso: 3,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
titulo: 'Explorador de Mundos',
|
||||
descricao: 'Leu histórias de 5 categorias diferentes',
|
||||
icone: '🌍',
|
||||
conquistado: false,
|
||||
progresso: 2,
|
||||
},
|
||||
];
|
||||
|
||||
export function AchievementsPage(): JSX.Element {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-primary mb-2">Minhas Conquistas</h1>
|
||||
<p className="text-gray-600">
|
||||
Continue lendo e completando atividades para desbloquear mais conquistas!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{conquistas.map((conquista) => (
|
||||
<Card key={conquista.id} className={`p-6 ${!conquista.conquistado ? 'opacity-75' : ''}`}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<span className="text-4xl">{conquista.icone}</span>
|
||||
{conquista.conquistado ? (
|
||||
<Badge variant="success">Conquistado!</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Em progresso</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-2">{conquista.titulo}</h3>
|
||||
<p className="text-gray-600 mb-4">{conquista.descricao}</p>
|
||||
|
||||
{conquista.progresso !== undefined && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div
|
||||
className="bg-primary h-2.5 rounded-full transition-all"
|
||||
style={{ width: `${(conquista.progresso / 5) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conquista.dataConquista && (
|
||||
<p className="text-sm text-gray-500 mt-4">
|
||||
Conquistado em: {new Date(conquista.dataConquista).toLocaleDateString('pt-BR')}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
src/pages/student-dashboard/CreateStoryPage.tsx
Normal file
71
src/pages/student-dashboard/CreateStoryPage.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { ArrowLeft, Sparkles } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { StoryGenerator } from '../../components/story/StoryGenerator';
|
||||
import { useSession } from '../../hooks/useSession';
|
||||
|
||||
export function CreateStoryPage() {
|
||||
const navigate = useNavigate();
|
||||
const { session } = useSession();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600">Você precisa estar logado para criar histórias.</p>
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="mt-4 text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
Fazer login
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => navigate('/aluno/historias')}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
Voltar para histórias
|
||||
</button>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="p-2 bg-purple-100 rounded-lg">
|
||||
<Sparkles className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Criar Nova História</h1>
|
||||
<p className="text-gray-600">
|
||||
Vamos criar uma história personalizada baseada nos seus interesses
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 text-red-600 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StoryGenerator />
|
||||
|
||||
<div className="mt-8 p-4 bg-purple-50 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-purple-900 mb-2">
|
||||
Como funciona?
|
||||
</h3>
|
||||
<ol className="text-sm text-purple-700 space-y-2">
|
||||
<li>1. Conte-nos sobre seus interesses e preferências</li>
|
||||
<li>2. Escolha personagens e cenários para sua história</li>
|
||||
<li>3. Nossa IA criará uma história única e personalizada</li>
|
||||
<li>4. Você poderá ler e praticar com sua nova história</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
459
src/pages/student-dashboard/StoryPage.tsx
Normal file
459
src/pages/student-dashboard/StoryPage.tsx
Normal file
@ -0,0 +1,459 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ArrowLeft, ArrowRight, Mic, Volume2, Share2, Save, ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { AudioRecorder } from '../../components/story/AudioRecorder';
|
||||
import type { Story } from '../../types/database';
|
||||
import { StoryMetrics } from '../../components/story/StoryMetrics';
|
||||
import type { MetricsData } from '../../components/story/StoryMetrics';
|
||||
import { getOptimizedImageUrl } from '../../lib/imageUtils';
|
||||
|
||||
interface StoryRecording {
|
||||
id: string;
|
||||
fluency_score: number;
|
||||
pronunciation_score: number;
|
||||
accuracy_score: number;
|
||||
comprehension_score: number;
|
||||
words_per_minute: number;
|
||||
pause_count: number;
|
||||
error_count: number;
|
||||
self_corrections: number;
|
||||
strengths: string[];
|
||||
improvements: string[];
|
||||
suggestions: string;
|
||||
created_at: string;
|
||||
processed_at: string | null;
|
||||
}
|
||||
|
||||
function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
|
||||
const metrics = [
|
||||
{ label: 'Fluência', value: recording.fluency_score, color: 'text-blue-600' },
|
||||
{ label: 'Pronúncia', value: recording.pronunciation_score, color: 'text-green-600' },
|
||||
{ label: 'Precisão', value: recording.accuracy_score, color: 'text-purple-600' },
|
||||
{ label: 'Compreensão', value: recording.comprehension_score, color: 'text-orange-600' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
{/* Cabeçalho sempre visível */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full p-4 text-left hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-gray-500">
|
||||
{new Date(recording.created_at).toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grid de métricas */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{metrics.map((metric) => (
|
||||
<div key={metric.label} className="flex flex-col">
|
||||
<span className={`text-sm font-medium ${metric.color}`}>
|
||||
{metric.label}
|
||||
</span>
|
||||
<span className="text-lg font-semibold">
|
||||
{metric.value}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Conteúdo expandido */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 border-t border-gray-100">
|
||||
<div className="grid grid-cols-2 gap-4 mt-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Palavras por minuto:</span>
|
||||
<span className="ml-2 font-medium">{recording.words_per_minute}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Pausas:</span>
|
||||
<span className="ml-2 font-medium">{recording.pause_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Erros:</span>
|
||||
<span className="ml-2 font-medium">{recording.error_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Autocorreções:</span>
|
||||
<span className="ml-2 font-medium">{recording.self_corrections}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-green-600 mb-1">Pontos Fortes</h5>
|
||||
<ul className="list-disc list-inside text-sm text-gray-600">
|
||||
{recording.strengths.map((strength, i) => (
|
||||
<li key={i}>{strength}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-orange-600 mb-1">Pontos para Melhorar</h5>
|
||||
<ul className="list-disc list-inside text-sm text-gray-600">
|
||||
{recording.improvements.map((improvement, i) => (
|
||||
<li key={i}>{improvement}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-blue-600 mb-1">Sugestões</h5>
|
||||
<p className="text-sm text-gray-600">{recording.suggestions}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageWithLoading({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative aspect-video bg-gray-100">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 text-purple-600 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
className={`w-full h-full object-cover transition-opacity duration-300 ${
|
||||
isLoading ? 'opacity-0' : 'opacity-100'
|
||||
} ${className}`}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
onError={() => {
|
||||
setError(true);
|
||||
setIsLoading(false);
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
|
||||
<p className="text-gray-500">Erro ao carregar imagem</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StoryPage() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const [story, setStory] = React.useState<Story | null>(null);
|
||||
const [currentPage, setCurrentPage] = React.useState(0);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isPlaying, setIsPlaying] = React.useState(false);
|
||||
const [recordings, setRecordings] = React.useState<StoryRecording[]>([]);
|
||||
const [loadingRecordings, setLoadingRecordings] = React.useState(true);
|
||||
const [metrics, setMetrics] = React.useState<MetricsData | null>(null);
|
||||
const [loadingMetrics, setLoadingMetrics] = React.useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchStory = async () => {
|
||||
try {
|
||||
if (!id) throw new Error('ID da história não fornecido');
|
||||
|
||||
// Buscar história e suas páginas
|
||||
const { data: storyData, error: storyError } = await supabase
|
||||
.from('stories')
|
||||
.select(`
|
||||
*,
|
||||
story_pages (
|
||||
id,
|
||||
page_number,
|
||||
text,
|
||||
image_url
|
||||
)
|
||||
`)
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (storyError) throw storyError;
|
||||
|
||||
// Ordenar páginas por número
|
||||
const orderedPages = storyData.story_pages.sort((a: { page_number: number }, b: { page_number: number }) => a.page_number - b.page_number);
|
||||
setStory({
|
||||
...storyData,
|
||||
content: {
|
||||
pages: orderedPages.map((page: { text: string; image_url: string }) => ({
|
||||
text: page.text,
|
||||
image: page.image_url
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Erro ao carregar história:', err);
|
||||
setError('Não foi possível carregar a história');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStory();
|
||||
}, [id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchMetrics = async () => {
|
||||
if (!story?.id) return;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('reading_metrics')
|
||||
.select('*')
|
||||
.eq('story_id', story.id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
setMetrics(data);
|
||||
} catch (err) {
|
||||
console.error('Erro ao carregar métricas:', err);
|
||||
} finally {
|
||||
setLoadingMetrics(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMetrics();
|
||||
}, [story?.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchRecordings = async () => {
|
||||
if (!story?.id) return;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('story_recordings')
|
||||
.select('*')
|
||||
.eq('story_id', story.id)
|
||||
.eq('status', 'completed')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setRecordings(data || []);
|
||||
} catch (err) {
|
||||
console.error('Erro ao carregar gravações:', err);
|
||||
} finally {
|
||||
setLoadingRecordings(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRecordings();
|
||||
}, [story?.id]);
|
||||
|
||||
const handleShare = async () => {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: story?.title,
|
||||
text: 'Confira minha história!',
|
||||
url: window.location.href
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Erro ao compartilhar:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getLatestRecording = () => recordings[0];
|
||||
|
||||
const formatMetricsData = (recording: StoryRecording) => ({
|
||||
metrics: {
|
||||
fluency: recording.fluency_score,
|
||||
pronunciation: recording.pronunciation_score,
|
||||
accuracy: recording.accuracy_score,
|
||||
comprehension: recording.comprehension_score
|
||||
},
|
||||
feedback: {
|
||||
strengths: recording.strengths,
|
||||
improvements: recording.improvements,
|
||||
suggestions: recording.suggestions
|
||||
},
|
||||
details: {
|
||||
wordsPerMinute: recording.words_per_minute,
|
||||
pauseCount: recording.pause_count,
|
||||
errorCount: recording.error_count,
|
||||
selfCorrections: recording.self_corrections
|
||||
}
|
||||
});
|
||||
|
||||
// Pré-carregar próxima imagem
|
||||
useEffect(() => {
|
||||
const nextImageUrl = story?.content?.pages?.[currentPage + 1]?.image;
|
||||
if (nextImageUrl) {
|
||||
const nextImage = new Image();
|
||||
nextImage.src = getOptimizedImageUrl(nextImageUrl, {
|
||||
width: 1200,
|
||||
quality: 85
|
||||
});
|
||||
}
|
||||
}, [currentPage, story]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-96 bg-gray-200 rounded-xl mb-8" />
|
||||
<div className="h-20 bg-gray-200 rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !story) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-500 mb-4">{error}</div>
|
||||
<button
|
||||
onClick={() => navigate('/aluno/historias')}
|
||||
className="text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
Voltar para histórias
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<button
|
||||
onClick={() => navigate('/aluno/historias')}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
Voltar para histórias
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<Share2 className="h-5 w-5" />
|
||||
Compartilhar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsPlaying(!isPlaying)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<Volume2 className="h-5 w-5" />
|
||||
{isPlaying ? 'Pausar' : 'Ouvir'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard de métricas */}
|
||||
{loadingRecordings ? (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-48 bg-gray-100 rounded-lg mb-6" />
|
||||
</div>
|
||||
) : recordings.length > 0 ? (
|
||||
<StoryMetrics
|
||||
data={formatMetricsData(getLatestRecording())}
|
||||
isLoading={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-gray-50 rounded-lg p-6 text-center mb-6">
|
||||
<p className="text-gray-600">
|
||||
Você ainda não tem gravações para esta história.
|
||||
Faça sua primeira gravação para ver suas métricas!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Histórico de gravações */}
|
||||
{recordings.length > 1 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-medium mb-4">Histórico de Gravações</h3>
|
||||
<div className="space-y-4">
|
||||
{recordings.slice(1).map((recording) => (
|
||||
<RecordingHistoryCard
|
||||
key={recording.id}
|
||||
recording={recording}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
{/* Imagem da página atual */}
|
||||
{story?.content?.pages?.[currentPage]?.image && (
|
||||
<ImageWithLoading
|
||||
src={getOptimizedImageUrl(story.content.pages[currentPage].image, {
|
||||
width: 1200,
|
||||
quality: 85
|
||||
})}
|
||||
alt={`Página ${currentPage + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">{story?.title}</h1>
|
||||
|
||||
{/* Texto da página atual */}
|
||||
<p className="text-lg text-gray-700 mb-8">
|
||||
{story?.content?.pages?.[currentPage]?.text || 'Carregando...'}
|
||||
</p>
|
||||
|
||||
{/* Gravador de áudio */}
|
||||
<AudioRecorder
|
||||
storyId={story.id}
|
||||
studentId={story.student_id}
|
||||
onAudioUploaded={(audioUrl) => {
|
||||
console.log('Áudio gravado:', audioUrl);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Navegação entre páginas */}
|
||||
<div className="flex justify-between items-center mt-8 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.max(0, prev - 1))}
|
||||
disabled={currentPage === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
Anterior
|
||||
</button>
|
||||
|
||||
<span className="text-sm text-gray-500">
|
||||
Página {currentPage + 1} de {story.content.pages.length}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.min(story.content.pages.length - 1, prev + 1))}
|
||||
disabled={currentPage === story.content.pages.length - 1}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50"
|
||||
>
|
||||
Próxima
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
src/pages/student-dashboard/StudentClassPage.tsx
Normal file
15
src/pages/student-dashboard/StudentClassPage.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
export function StudentClassPage(): JSX.Element {
|
||||
const { classId } = useParams();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Turma {classId}
|
||||
</h1>
|
||||
{/* Conteúdo da página da turma será implementado aqui */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/pages/student-dashboard/StudentDashboard.tsx
Normal file
12
src/pages/student-dashboard/StudentDashboard.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
export function StudentDashboard(): JSX.Element {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Bem-vindo ao Dashboard
|
||||
</h1>
|
||||
{/* Conteúdo do dashboard será implementado aqui */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
src/pages/student-dashboard/StudentDashboardLayout.tsx
Normal file
142
src/pages/student-dashboard/StudentDashboardLayout.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import React from 'react';
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
BookOpen,
|
||||
Settings,
|
||||
LogOut,
|
||||
School,
|
||||
Trophy,
|
||||
History
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
|
||||
export function StudentDashboardLayout() {
|
||||
const navigate = useNavigate();
|
||||
const { signOut } = useAuth();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Sidebar */}
|
||||
<aside className="fixed left-0 top-0 h-full w-64 bg-white border-r border-gray-200">
|
||||
<div className="flex items-center gap-2 p-6 border-b border-gray-200">
|
||||
<School className="h-8 w-8 text-purple-600" />
|
||||
<span className="font-semibold text-gray-900">Histórias Mágicas</span>
|
||||
</div>
|
||||
|
||||
<nav className="p-4 space-y-1">
|
||||
<NavLink
|
||||
to="/aluno"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
|
||||
isActive
|
||||
? 'bg-purple-50 text-purple-700'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<LayoutDashboard className="h-5 w-5" />
|
||||
Início
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/aluno/historias"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
|
||||
isActive
|
||||
? 'bg-purple-50 text-purple-700'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<BookOpen className="h-5 w-5" />
|
||||
Minhas Histórias
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/aluno/conquistas"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
|
||||
isActive
|
||||
? 'bg-purple-50 text-purple-700'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Trophy className="h-5 w-5" />
|
||||
Conquistas
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/aluno/historico"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
|
||||
isActive
|
||||
? 'bg-purple-50 text-purple-700'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<History className="h-5 w-5" />
|
||||
Histórico
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/aluno/configuracoes"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
|
||||
isActive
|
||||
? 'bg-purple-50 text-purple-600'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
Configurações
|
||||
</NavLink>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-50 w-full mt-4"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
Sair
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Footer com informações do aluno */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-purple-600">
|
||||
{/* Primeira letra do nome do aluno */}
|
||||
A
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{/* Nome do aluno */}
|
||||
Aluno da Silva
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{/* Turma do aluno */}
|
||||
5º Ano A
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="ml-64 p-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
286
src/pages/student-dashboard/StudentDashboardPage.tsx
Normal file
286
src/pages/student-dashboard/StudentDashboardPage.tsx
Normal file
@ -0,0 +1,286 @@
|
||||
import React from 'react';
|
||||
import { Plus, BookOpen, Clock, TrendingUp, Award } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import type { Story, Student } from '../../types/database';
|
||||
import { getOptimizedImageUrl } from '../../lib/imageUtils';
|
||||
|
||||
interface DashboardMetrics {
|
||||
totalStories: number;
|
||||
averageReadingFluency: number;
|
||||
totalReadingTime: number;
|
||||
currentLevel: number;
|
||||
}
|
||||
|
||||
export function StudentDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const [student, setStudent] = React.useState<Student | null>(null);
|
||||
const [stories, setStories] = React.useState<Story[]>([]);
|
||||
const [metrics, setMetrics] = React.useState<DashboardMetrics>({
|
||||
totalStories: 0,
|
||||
averageReadingFluency: 0,
|
||||
totalReadingTime: 0,
|
||||
currentLevel: 1
|
||||
});
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [recentStories, setRecentStories] = React.useState<Story[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
// Buscar dados do aluno
|
||||
const { data: studentData, error: studentError } = await supabase
|
||||
.from('students')
|
||||
.select(`
|
||||
*,
|
||||
class:classes(name, grade),
|
||||
school:schools(name)
|
||||
`)
|
||||
.eq('id', session.user.id)
|
||||
.single();
|
||||
|
||||
if (studentError) throw studentError;
|
||||
setStudent(studentData);
|
||||
|
||||
// Buscar histórias do aluno
|
||||
const { data: storiesData, error: storiesError } = await supabase
|
||||
.from('stories')
|
||||
.select('*')
|
||||
.eq('student_id', session.user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(6);
|
||||
|
||||
if (storiesError) throw storiesError;
|
||||
setStories(storiesData || []);
|
||||
|
||||
// Calcular métricas
|
||||
// Em produção: Implementar cálculos reais baseados nos dados
|
||||
setMetrics({
|
||||
totalStories: storiesData?.length || 0,
|
||||
averageReadingFluency: 85, // Exemplo
|
||||
totalReadingTime: 120, // Exemplo: 120 minutos
|
||||
currentLevel: 3 // Exemplo
|
||||
});
|
||||
|
||||
// Buscar histórias recentes com a primeira página como capa
|
||||
const { data, error } = await supabase
|
||||
.from('stories')
|
||||
.select(`
|
||||
*,
|
||||
cover:story_pages!inner(
|
||||
image_url
|
||||
)
|
||||
`)
|
||||
.eq('student_id', session.user.id)
|
||||
.eq('story_pages.page_number', 1) // Garante que pegamos a primeira página
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(3);
|
||||
|
||||
if (error) throw error;
|
||||
setRecentStories(data || []);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Erro ao carregar dashboard:', err);
|
||||
setError('Não foi possível carregar seus dados');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-32 bg-gray-200 rounded-xl mb-8" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-24 bg-gray-200 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="h-64 bg-gray-200 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-500 mb-4">{error}</div>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
Tentar novamente
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Cabeçalho */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
{student?.avatar_url ? (
|
||||
<img
|
||||
src={student.avatar_url}
|
||||
alt={student.name}
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-2xl font-bold text-purple-600">
|
||||
{student?.name?.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{student?.name}</h1>
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{student?.class?.name} - {student?.class?.grade} • {student?.school?.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/aluno/historias/nova')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
Nova História
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Métricas */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-purple-100 rounded-lg">
|
||||
<BookOpen className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Total de Histórias</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{metrics.totalStories}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-green-100 rounded-lg">
|
||||
<TrendingUp className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Fluência Média</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{metrics.averageReadingFluency}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-blue-100 rounded-lg">
|
||||
<Clock className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Tempo de Leitura</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{metrics.totalReadingTime}min</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-yellow-100 rounded-lg">
|
||||
<Award className="h-6 w-6 text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Nível Atual</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{metrics.currentLevel}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Histórias Recentes */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Histórias Recentes</h2>
|
||||
<button
|
||||
onClick={() => navigate('/aluno/historias')}
|
||||
className="flex items-center gap-2 text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
Ver todas
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{recentStories.length === 0 ? (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center">
|
||||
<BookOpen className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Nenhuma história ainda
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
Comece sua jornada criando sua primeira história!
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/aluno/historias/nova')}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
Criar História
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{recentStories.map((story) => (
|
||||
<div
|
||||
key={story.id}
|
||||
className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden cursor-pointer hover:shadow-md transition"
|
||||
onClick={() => navigate(`/aluno/historias/${story.id}`)}
|
||||
>
|
||||
{story.cover && (
|
||||
<div className="relative aspect-video">
|
||||
<img
|
||||
src={getOptimizedImageUrl(story.cover.image_url, {
|
||||
width: 400,
|
||||
height: 300
|
||||
})}
|
||||
alt={story.title}
|
||||
className="w-full h-48 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-2">{story.title}</h3>
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>{new Date(story.created_at).toLocaleDateString()}</span>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
story.status === 'published'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{story.status === 'published' ? 'Publicada' : 'Rascunho'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
src/pages/student-dashboard/StudentSettingsPage.tsx
Normal file
66
src/pages/student-dashboard/StudentSettingsPage.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../components/ui/tabs';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { DatePicker } from '../../components/ui/date-picker';
|
||||
import { Select } from '../../components/ui/select';
|
||||
import { AvatarUpload } from '../../components/ui/avatar-upload';
|
||||
|
||||
export function StudentSettingsPage() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
Configurações do Perfil
|
||||
</h1>
|
||||
|
||||
<Tabs defaultValue="personal">
|
||||
<TabsList>
|
||||
<TabsTrigger value="personal">Informações Pessoais</TabsTrigger>
|
||||
<TabsTrigger value="preferences">Preferências</TabsTrigger>
|
||||
<TabsTrigger value="accessibility">Acessibilidade</TabsTrigger>
|
||||
<TabsTrigger value="notifications">Notificações</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="personal">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<AvatarUpload />
|
||||
<div>
|
||||
<h3 className="font-medium">Foto do Perfil</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
JPG ou PNG, máximo 2MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Nome Completo"
|
||||
name="fullName"
|
||||
placeholder="Seu nome completo"
|
||||
/>
|
||||
<Input
|
||||
label="Nome Social/Apelido"
|
||||
name="nickname"
|
||||
placeholder="Como prefere ser chamado"
|
||||
/>
|
||||
<DatePicker
|
||||
label="Data de Nascimento"
|
||||
name="birthDate"
|
||||
/>
|
||||
<Select
|
||||
label="Gênero"
|
||||
name="gender"
|
||||
options={[
|
||||
{ value: 'male', label: 'Masculino' },
|
||||
{ value: 'female', label: 'Feminino' },
|
||||
{ value: 'non_binary', label: 'Não-binário' },
|
||||
{ value: 'other', label: 'Outro' },
|
||||
{ value: 'prefer_not_to_say', label: 'Prefiro não dizer' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
src/pages/student-dashboard/StudentSettingsPage_old.tsx
Normal file
71
src/pages/student-dashboard/StudentSettingsPage_old.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { AvatarUpload } from "@/components/ui/avatar-upload";
|
||||
import { DatePicker } from "@/components/ui/date-picker";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs";
|
||||
|
||||
export function StudentSettingsPage() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
Configurações do Perfil
|
||||
</h1>
|
||||
|
||||
{/* Seções em Tabs */}
|
||||
<Tabs defaultValue="personal">
|
||||
<TabsList>
|
||||
<TabsTrigger value="personal">Informações Pessoais</TabsTrigger>
|
||||
<TabsTrigger value="preferences">Preferências</TabsTrigger>
|
||||
<TabsTrigger value="accessibility">Acessibilidade</TabsTrigger>
|
||||
<TabsTrigger value="notifications">Notificações</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="personal">
|
||||
<div className="space-y-6">
|
||||
{/* Avatar Upload */}
|
||||
<div className="flex items-center gap-4">
|
||||
<AvatarUpload />
|
||||
<div>
|
||||
<h3 className="font-medium">Foto do Perfil</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
JPG ou PNG, máximo 2MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Informações Básicas */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Nome Completo"
|
||||
name="fullName"
|
||||
placeholder="Seu nome completo"
|
||||
/>
|
||||
<Input
|
||||
label="Nome Social/Apelido"
|
||||
name="nickname"
|
||||
placeholder="Como prefere ser chamado"
|
||||
/>
|
||||
<DatePicker
|
||||
label="Data de Nascimento"
|
||||
name="birthDate"
|
||||
/>
|
||||
<Select
|
||||
label="Gênero"
|
||||
name="gender"
|
||||
options={[
|
||||
{ value: 'male', label: 'Masculino' },
|
||||
{ value: 'female', label: 'Feminino' },
|
||||
{ value: 'non_binary', label: 'Não-binário' },
|
||||
{ value: 'other', label: 'Outro' },
|
||||
{ value: 'prefer_not_to_say', label: 'Prefiro não dizer' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Outras tabs... */}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
235
src/pages/student-dashboard/StudentStoriesPage.tsx
Normal file
235
src/pages/student-dashboard/StudentStoriesPage.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
import React from 'react';
|
||||
import { Plus, Search, Filter, BookOpen, ArrowUpDown } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import type { Story } from '../../types/database';
|
||||
import { getOptimizedImageUrl } from '../../lib/imageUtils';
|
||||
|
||||
type StoryStatus = 'all' | 'draft' | 'published';
|
||||
type SortOption = 'recent' | 'oldest' | 'title' | 'performance';
|
||||
|
||||
export function StudentStoriesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [stories, setStories] = React.useState<Story[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = React.useState('');
|
||||
const [statusFilter, setStatusFilter] = React.useState<StoryStatus>('all');
|
||||
const [sortBy, setSortBy] = React.useState<SortOption>('recent');
|
||||
const [showFilters, setShowFilters] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchStories = async () => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
const query = supabase
|
||||
.from('stories')
|
||||
.select('*')
|
||||
.eq('student_id', session.user.id);
|
||||
|
||||
if (statusFilter !== 'all') {
|
||||
query.eq('status', statusFilter);
|
||||
}
|
||||
|
||||
let { data, error } = await query;
|
||||
if (error) throw error;
|
||||
|
||||
// Aplicar ordenação
|
||||
const sortedData = (data || []).sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'oldest':
|
||||
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
case 'title':
|
||||
return a.title.localeCompare(b.title);
|
||||
case 'performance':
|
||||
return (b.performance_score || 0) - (a.performance_score || 0);
|
||||
default: // recent
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||
}
|
||||
});
|
||||
|
||||
setStories(sortedData);
|
||||
} catch (err) {
|
||||
console.error('Erro ao buscar histórias:', err);
|
||||
setError('Não foi possível carregar suas histórias');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStories();
|
||||
}, [statusFilter, sortBy]);
|
||||
|
||||
const filteredStories = stories.filter(story =>
|
||||
story.title.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-20 bg-gray-200 rounded-xl mb-6" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="h-64 bg-gray-200 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Minhas Histórias</h1>
|
||||
<button
|
||||
onClick={() => navigate('/aluno/historias/nova')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
Nova História
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* Busca */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar histórias..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filtros e Ordenação */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<Filter className="h-5 w-5" />
|
||||
Filtros
|
||||
</button>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="recent">Mais recentes</option>
|
||||
<option value="oldest">Mais antigas</option>
|
||||
<option value="title">Por título</option>
|
||||
<option value="performance">Melhor desempenho</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Painel de Filtros */}
|
||||
{showFilters && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
statusFilter === 'all'
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Todas
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('published')}
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
statusFilter === 'published'
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Publicadas
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('draft')}
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
statusFilter === 'draft'
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Rascunhos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lista de Histórias */}
|
||||
{filteredStories.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<BookOpen className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Nenhuma história encontrada
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
{searchTerm
|
||||
? 'Tente usar outros termos na busca'
|
||||
: 'Comece criando sua primeira história!'}
|
||||
</p>
|
||||
{!searchTerm && (
|
||||
<button
|
||||
onClick={() => navigate('/aluno/historias/nova')}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
Criar História
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 p-6">
|
||||
{filteredStories.map((story) => (
|
||||
<div
|
||||
key={story.id}
|
||||
className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden cursor-pointer hover:shadow-md transition"
|
||||
onClick={() => navigate(`/aluno/historias/${story.id}`)}
|
||||
>
|
||||
{story.cover && (
|
||||
<div className="relative aspect-video">
|
||||
<img
|
||||
src={getOptimizedImageUrl(story.cover.image_url, {
|
||||
width: 400,
|
||||
height: 300,
|
||||
quality: 80
|
||||
})}
|
||||
alt={story.title}
|
||||
className="w-full h-48 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-2">{story.title}</h3>
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>{new Date(story.created_at).toLocaleDateString()}</span>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
story.status === 'published'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{story.status === 'published' ? 'Publicada' : 'Rascunho'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
src/pages/student-dashboard/index.ts
Normal file
9
src/pages/student-dashboard/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export { StudentDashboardLayout } from './StudentDashboardLayout';
|
||||
export { StudentDashboard } from './StudentDashboard';
|
||||
export { StudentClassPage } from './StudentClassPage';
|
||||
export { StudentSettingsPage } from './StudentSettingsPage';
|
||||
export { CreateStoryPage } from './CreateStoryPage';
|
||||
export { StoryPage } from './StoryPage';
|
||||
export { StudentDashboardPage } from './StudentDashboardPage';
|
||||
export { StudentStoriesPage } from './StudentStoriesPage';
|
||||
|
||||
196
src/routes.tsx
Normal file
196
src/routes.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
import { createBrowserRouter } from 'react-router-dom';
|
||||
import { HomePage } from './components/home/HomePage';
|
||||
import { LoginForm } from './components/auth/LoginForm';
|
||||
import { SchoolRegistrationForm } from './components/auth/SchoolRegistrationForm';
|
||||
import { RegistrationForm } from './components/RegistrationForm';
|
||||
import { StoryViewer } from './components/StoryViewer';
|
||||
import { AuthCallback } from './pages/AuthCallback';
|
||||
import { DashboardLayout } from './pages/dashboard/DashboardLayout';
|
||||
import { DashboardHome } from './pages/dashboard/DashboardHome';
|
||||
import { ClassesPage } from './pages/dashboard/classes/ClassesPage';
|
||||
import { CreateClassPage } from './pages/dashboard/classes/CreateClassPage';
|
||||
import { TeachersPage } from './pages/dashboard/teachers/TeachersPage';
|
||||
import { InviteTeacherPage } from './pages/dashboard/teachers/InviteTeacherPage';
|
||||
import { StudentsPage } from './pages/dashboard/students/StudentsPage';
|
||||
import { AddStudentPage } from './pages/dashboard/students/AddStudentPage';
|
||||
import { SettingsPage } from './pages/dashboard/settings/SettingsPage';
|
||||
import { StudentDashboardPage } from './pages/student-dashboard/StudentDashboardPage';
|
||||
import { StudentDashboardLayout } from './pages/student-dashboard/StudentDashboardLayout';
|
||||
import { StudentStoriesPage } from './pages/student-dashboard/StudentStoriesPage';
|
||||
import { StudentSettingsPage } from './pages/student-dashboard/StudentSettingsPage';
|
||||
import { CreateStoryPage } from './pages/student-dashboard/CreateStoryPage';
|
||||
import { StoryPageDemo } from './pages/demo/StoryPageDemo';
|
||||
import { StoryPage } from './pages/student-dashboard/StoryPage';
|
||||
import { ProtectedRoute } from './components/auth/ProtectedRoute';
|
||||
import { UserManagementPage } from './pages/admin/UserManagementPage';
|
||||
import { AchievementsPage } from './pages/student-dashboard/AchievementsPage';
|
||||
import { StudentClassPage } from './pages/student-dashboard/StudentClassPage';
|
||||
import { DemoPage } from './pages/demo/DemoPage';
|
||||
import { ParentsLandingPage } from './pages/landing/ParentsLandingPage';
|
||||
import { EducationalForParents } from './pages/landing/EducationalForParents';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <HomePage />,
|
||||
},
|
||||
{
|
||||
path: '/para-pais',
|
||||
element: <ParentsLandingPage />,
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
children: [
|
||||
{
|
||||
path: 'school',
|
||||
element: <LoginForm userType="school" />,
|
||||
},
|
||||
{
|
||||
path: 'teacher',
|
||||
element: <LoginForm userType="teacher" />,
|
||||
},
|
||||
{
|
||||
path: 'student',
|
||||
element: <LoginForm userType="student" />,
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
children: [
|
||||
{
|
||||
path: 'school',
|
||||
element: <SchoolRegistrationForm />,
|
||||
},
|
||||
{
|
||||
path: 'teacher',
|
||||
element: <RegistrationForm
|
||||
userType="teacher"
|
||||
onComplete={(userData) => {
|
||||
console.log('Registro completo:', userData);
|
||||
}}
|
||||
/>,
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
element: (
|
||||
<ProtectedRoute allowedRoles={['school']}>
|
||||
<DashboardLayout />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <DashboardHome />,
|
||||
},
|
||||
{
|
||||
path: 'turmas',
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <ClassesPage />,
|
||||
},
|
||||
{
|
||||
path: 'nova',
|
||||
element: <CreateClassPage />,
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'professores',
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <TeachersPage />,
|
||||
},
|
||||
{
|
||||
path: 'convidar',
|
||||
element: <InviteTeacherPage />,
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'alunos',
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <StudentsPage />,
|
||||
},
|
||||
{
|
||||
path: 'novo',
|
||||
element: <AddStudentPage />,
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'configuracoes',
|
||||
element: <SettingsPage />
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/demo',
|
||||
element: <StoryPageDemo />
|
||||
},
|
||||
{
|
||||
path: '/auth/callback',
|
||||
element: <AuthCallback />
|
||||
},
|
||||
{
|
||||
path: '/aluno',
|
||||
element: (
|
||||
<ProtectedRoute allowedRoles={['student']}>
|
||||
<StudentDashboardLayout />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <StudentDashboardPage />,
|
||||
},
|
||||
{
|
||||
path: 'historias',
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <StudentStoriesPage />,
|
||||
},
|
||||
{
|
||||
path: 'nova',
|
||||
element: <CreateStoryPage />,
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
element: <StoryPage />,
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'configuracoes',
|
||||
element: <StudentSettingsPage />,
|
||||
},
|
||||
{
|
||||
path: 'conquistas',
|
||||
element: <AchievementsPage />,
|
||||
},
|
||||
{
|
||||
path: 'turmas/:classId',
|
||||
element: <StudentClassPage />,
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/admin/users',
|
||||
element: (
|
||||
<ProtectedRoute allowedRoles={['admin']}>
|
||||
<UserManagementPage />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/para-educadores',
|
||||
element: <EducationalForParents />,
|
||||
}
|
||||
]);
|
||||
33
src/scripts/updateUserRole.ts
Normal file
33
src/scripts/updateUserRole.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabase = createClient(
|
||||
'SUA_URL_DO_SUPABASE',
|
||||
'SUA_ANON_KEY'
|
||||
);
|
||||
|
||||
async function updateUserRole(email: string, role: 'school' | 'teacher' | 'student') {
|
||||
try {
|
||||
// Primeiro fazer login como o usuário
|
||||
const { data: authData, error: authError } = await supabase.auth.signInWithPassword({
|
||||
email: email,
|
||||
password: 'SENHA_DO_USUARIO' // Substitua pela senha real
|
||||
});
|
||||
|
||||
if (authError) throw authError;
|
||||
|
||||
// Depois atualizar os metadados
|
||||
const { data, error } = await supabase.auth.updateUser({
|
||||
data: { role: role }
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
console.log('Papel do usuário atualizado com sucesso:', data);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Erro ao atualizar papel do usuário:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Exemplo de uso:
|
||||
updateUserRole('email@escola.com', 'school');
|
||||
86
src/services/audioService.ts
Normal file
86
src/services/audioService.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
interface ProcessAudioResponse {
|
||||
transcription?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function processAudio(audioFile: File, storyId: string): Promise<ProcessAudioResponse> {
|
||||
try {
|
||||
// 1. Gerar nome único para o arquivo
|
||||
const fileName = `${crypto.randomUUID()}-${audioFile.name}`;
|
||||
|
||||
// 2. Primeiro criar o registro no banco
|
||||
const { data: recordData, error: recordError } = await supabase
|
||||
.from('story_recordings')
|
||||
.insert({
|
||||
story_id: storyId,
|
||||
status: 'pending_analysis',
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (recordError) throw recordError;
|
||||
|
||||
// 3. Upload do arquivo para o bucket do Supabase
|
||||
const { data: uploadData, error: uploadError } = await supabase.storage
|
||||
.from('audio-uploads')
|
||||
.upload(`recordings/${recordData.id}/${fileName}`, audioFile, {
|
||||
cacheControl: '3600',
|
||||
contentType: audioFile.type,
|
||||
upsert: false
|
||||
});
|
||||
|
||||
if (uploadError) {
|
||||
// Se falhar o upload, deletar o registro
|
||||
await supabase
|
||||
.from('story_recordings')
|
||||
.delete()
|
||||
.eq('id', recordData.id);
|
||||
|
||||
throw uploadError;
|
||||
}
|
||||
|
||||
// 4. Pegar URL pública do arquivo
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('audio-uploads')
|
||||
.getPublicUrl(`recordings/${recordData.id}/${fileName}`);
|
||||
|
||||
// 5. Atualizar registro com URL do áudio
|
||||
const { error: updateError } = await supabase
|
||||
.from('story_recordings')
|
||||
.update({
|
||||
audio_url: publicUrl
|
||||
})
|
||||
.eq('id', recordData.id);
|
||||
|
||||
if (updateError) throw updateError;
|
||||
|
||||
// 6. Chamar a Edge Function para processar o áudio
|
||||
const { data, error } = await supabase.functions.invoke<ProcessAudioResponse>(
|
||||
'process-audio',
|
||||
{
|
||||
body: {
|
||||
record: {
|
||||
id: recordData.id,
|
||||
story_id: storyId,
|
||||
audio_url: publicUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return {
|
||||
transcription: data?.transcription
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao processar áudio:', error);
|
||||
return {
|
||||
error: 'Falha ao processar o áudio. Tente novamente.'
|
||||
};
|
||||
}
|
||||
}
|
||||
84
src/services/email.ts
Normal file
84
src/services/email.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { Resend } from 'resend';
|
||||
|
||||
const resend = new Resend(import.meta.env.VITE_RESEND_API_KEY);
|
||||
|
||||
interface SendStudentCredentialsEmailProps {
|
||||
studentName: string;
|
||||
studentEmail: string;
|
||||
password: string;
|
||||
guardianName: string;
|
||||
guardianEmail: string;
|
||||
}
|
||||
|
||||
export async function sendStudentCredentialsEmail({
|
||||
studentName,
|
||||
studentEmail,
|
||||
password,
|
||||
guardianName,
|
||||
guardianEmail
|
||||
}: SendStudentCredentialsEmailProps) {
|
||||
try {
|
||||
// Email para o aluno
|
||||
await resend.emails.send({
|
||||
from: 'Histórias Mágicas <noreply@historias-magicas.com.br>',
|
||||
to: studentEmail,
|
||||
subject: 'Bem-vindo ao Histórias Mágicas!',
|
||||
html: `
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="color: #7C3AED;">Olá ${studentName}!</h1>
|
||||
<p>Bem-vindo ao Histórias Mágicas! Sua conta foi criada com sucesso.</p>
|
||||
<p>Use as credenciais abaixo para acessar sua conta:</p>
|
||||
<div style="background-color: #F3F4F6; padding: 16px; border-radius: 8px; margin: 16px 0;">
|
||||
<p style="margin: 0;"><strong>Email:</strong> ${studentEmail}</p>
|
||||
<p style="margin: 8px 0 0;"><strong>Senha:</strong> ${password}</p>
|
||||
</div>
|
||||
<p style="color: #EF4444; font-size: 14px;">
|
||||
Por favor, altere sua senha no primeiro acesso.
|
||||
</p>
|
||||
<a
|
||||
href="${import.meta.env.VITE_APP_URL}/login/student"
|
||||
style="display: inline-block; background-color: #7C3AED; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; margin-top: 16px;"
|
||||
>
|
||||
Acessar Plataforma
|
||||
</a>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
|
||||
// Email para o responsável
|
||||
await resend.emails.send({
|
||||
from: 'Histórias Mágicas <noreply@historias-magicas.com.br>',
|
||||
to: guardianEmail,
|
||||
subject: `Conta do ${studentName} criada no Histórias Mágicas`,
|
||||
html: `
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="color: #7C3AED;">Olá ${guardianName}!</h1>
|
||||
<p>
|
||||
Uma conta foi criada para ${studentName} na plataforma Histórias Mágicas.
|
||||
Como responsável, você receberá atualizações sobre o progresso e atividades.
|
||||
</p>
|
||||
<p>As credenciais de acesso foram enviadas para o email do aluno:</p>
|
||||
<div style="background-color: #F3F4F6; padding: 16px; border-radius: 8px; margin: 16px 0;">
|
||||
<p style="margin: 0;"><strong>Email do aluno:</strong> ${studentEmail}</p>
|
||||
</div>
|
||||
<p>
|
||||
Por favor, ajude o aluno a fazer o primeiro acesso e alterar a senha.
|
||||
Em caso de dúvidas, entre em contato com a escola.
|
||||
</p>
|
||||
<div style="font-size: 14px; color: #6B7280; margin-top: 24px;">
|
||||
<p>
|
||||
O Histórias Mágicas é uma plataforma educacional que permite aos alunos
|
||||
criarem e compartilharem histórias interativas, incentivando a criatividade
|
||||
e o aprendizado.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Erro ao enviar emails:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -33,6 +33,7 @@ export interface Class {
|
||||
export interface Student {
|
||||
id: string;
|
||||
class_id: string;
|
||||
school_id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
birth_date?: string;
|
||||
@ -41,6 +42,18 @@ export interface Student {
|
||||
guardian_email?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
status?: string;
|
||||
avatar_url?: string;
|
||||
// Relacionamentos
|
||||
class?: {
|
||||
id: string;
|
||||
name: string;
|
||||
grade: string;
|
||||
};
|
||||
school?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TeacherClass {
|
||||
@ -92,8 +105,11 @@ export interface StoryPage {
|
||||
}
|
||||
|
||||
export interface Story {
|
||||
cover: any;
|
||||
id: string;
|
||||
student_id: string;
|
||||
class_id: string;
|
||||
school_id: string;
|
||||
title: string;
|
||||
theme: string;
|
||||
content: {
|
||||
@ -114,4 +130,21 @@ export interface StudentWithStories extends Student {
|
||||
// Atualizando ClassWithStudents para incluir histórias dos alunos
|
||||
export interface ClassWithStudentsAndStories extends Class {
|
||||
students: StudentWithStories[];
|
||||
}
|
||||
|
||||
export interface StoryRecording {
|
||||
id: string;
|
||||
fluency_score: number;
|
||||
pronunciation_score: number;
|
||||
accuracy_score: number;
|
||||
comprehension_score: number;
|
||||
words_per_minute: number;
|
||||
pause_count: number;
|
||||
error_count: number;
|
||||
self_corrections: number;
|
||||
strengths: string[];
|
||||
improvements: string[];
|
||||
suggestions: string;
|
||||
created_at: string;
|
||||
processed_at: string | null;
|
||||
}
|
||||
29
src/types/story-generator.ts
Normal file
29
src/types/story-generator.ts
Normal file
@ -0,0 +1,29 @@
|
||||
export interface StoryPrompt {
|
||||
studentInterests: string[];
|
||||
characters: {
|
||||
main: string;
|
||||
supporting?: string[];
|
||||
};
|
||||
setting: {
|
||||
place: string;
|
||||
time?: string;
|
||||
};
|
||||
practiceWords?: string[];
|
||||
studentCharacteristics?: {
|
||||
age?: number;
|
||||
gender?: string;
|
||||
personalityTraits?: string[];
|
||||
};
|
||||
theme?: string;
|
||||
difficulty?: 'easy' | 'medium' | 'hard';
|
||||
}
|
||||
|
||||
export interface GeneratedStory {
|
||||
title: string;
|
||||
content: {
|
||||
pages: {
|
||||
text: string;
|
||||
image?: string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
21
src/types/supabase.ts
Normal file
21
src/types/supabase.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export type UserRole = 'admin' | 'school' | 'teacher' | 'student';
|
||||
|
||||
export interface UserMetadata {
|
||||
role: UserRole;
|
||||
name: string;
|
||||
school_id?: string;
|
||||
class_id?: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
user_metadata: UserMetadata;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WeakPassword {
|
||||
message: string;
|
||||
suggestions: string[];
|
||||
}
|
||||
12
src/utils/passwordGenerator.ts
Normal file
12
src/utils/passwordGenerator.ts
Normal file
@ -0,0 +1,12 @@
|
||||
const ANIMALS = ['leao', 'tigre', 'gato', 'cao', 'panda', 'urso', 'lobo', 'rato', 'sapo', 'peixe'];
|
||||
const COLORS = ['azul', 'verde', 'rosa', 'roxo', 'ouro', 'prata', 'coral', 'jade', 'ruby', 'safira'];
|
||||
const NUMBERS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
|
||||
|
||||
export function generateMnemonicPassword(): string {
|
||||
const randomAnimal = ANIMALS[Math.floor(Math.random() * ANIMALS.length)];
|
||||
const randomColor = COLORS[Math.floor(Math.random() * COLORS.length)];
|
||||
const randomNumber = NUMBERS[Math.floor(Math.random() * NUMBERS.length)] +
|
||||
NUMBERS[Math.floor(Math.random() * NUMBERS.length)];
|
||||
|
||||
return `${randomColor}${randomAnimal}${randomNumber}`;
|
||||
}
|
||||
20
src/utils/updateUserRole.ts
Normal file
20
src/utils/updateUserRole.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
export async function updateUserRole(userId: string, role: 'school' | 'teacher' | 'student') {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.updateUser({
|
||||
data: { role }
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
console.log('Role atualizado com sucesso:', data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('Erro ao atualizar role:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Uso:
|
||||
// await updateUserRole('id-do-usuario', 'school');
|
||||
4
supabase/.gitignore
vendored
Normal file
4
supabase/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
# Supabase
|
||||
.branches
|
||||
.temp
|
||||
.env
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user