mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-16 21:37:51 +00:00
feat: implementa sistema de exercícios f��nicos
Some checks are pending
Docker Build and Push / build (push) Waiting to run
Some checks are pending
Docker Build and Push / build (push) Waiting to run
- Cria estrutura completa de banco de dados para exerc��cios f��nicos - Implementa tabelas para categorias, tipos, exerc��cios e palavras - Adiciona sistema de progresso e conquistas do estudante - Configura pol��ticas de seguran��a RLS para prote����o dos dados - Otimiza performance com ��ndices e relacionamentos apropriados BREAKING CHANGE: Nova estrutura de banco de dados para exerc��cios f��nicos
This commit is contained in:
parent
e1a99f32f5
commit
350a66bb9e
57
CHANGELOG.md
57
CHANGELOG.md
@ -5,21 +5,52 @@ 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/).
|
||||
|
||||
## [1.0.0] - 2024-03-19
|
||||
## [1.0.0] - 2024-03-20
|
||||
|
||||
### Adicionado
|
||||
- Implementação do tracking de eventos nos botões dos planos para escolas e pais
|
||||
- Tracking detalhado de visualização de página com informações enriquecidas
|
||||
- Integração com Rudderstack para análise de dados
|
||||
- Novos componentes de UI: `PlanForSchools` e `PlanForParents`
|
||||
|
||||
### Modificado
|
||||
- Atualização do componente `PageTracker` para incluir dados do usuário via `user_metadata`
|
||||
- Refatoração dos botões para usar o componente `Button` com propriedades de tracking
|
||||
- Remoção do CTA "Ver Demonstração" da seção superior dos planos
|
||||
#### Sistema de Exercícios Fônicos
|
||||
- Criação do sistema de exercícios fônicos com categorias e tipos
|
||||
- Implementação de exercícios de rima, aliteração, sílabas e sons
|
||||
- Sistema de progresso do estudante com pontuação e estrelas
|
||||
- Sistema de conquistas e recompensas
|
||||
|
||||
#### Banco de Dados
|
||||
- Tabelas para categorias de exercícios (`phonics_exercise_categories`)
|
||||
- Tabelas para tipos de exercícios (`phonics_exercise_types`)
|
||||
- Tabela principal de exercícios (`phonics_exercises`)
|
||||
- Tabela de palavras e suas características fonéticas (`phonics_words`)
|
||||
- Tabela de relação exercício-palavras (`phonics_exercise_words`)
|
||||
- Sistema de mídia para exercícios (`media_types`, `phonics_exercise_media`)
|
||||
- Sistema de progresso do estudante (`student_phonics_progress`)
|
||||
- Sistema de tentativas e respostas (`student_phonics_attempts`, `student_phonics_attempt_answers`)
|
||||
- Sistema de conquistas (`achievement_types`, `phonics_achievements`, `student_phonics_achievements`)
|
||||
|
||||
#### Funcionalidades
|
||||
- Categorização de exercícios por nível e tipo
|
||||
- Sistema de pontuação e progresso
|
||||
- Registro detalhado de tentativas e respostas
|
||||
- Sistema de conquistas com diferentes tipos (sequência, conclusão, maestria)
|
||||
- Suporte a diferentes tipos de mídia (imagens, sons, animações)
|
||||
|
||||
#### Segurança
|
||||
- Políticas de acesso baseadas em Row Level Security (RLS)
|
||||
- Proteção de dados específicos do estudante
|
||||
- Controle de acesso para diferentes tipos de usuários
|
||||
|
||||
#### Performance
|
||||
- Índices otimizados para consultas frequentes
|
||||
- Estrutura de dados normalizada
|
||||
- Relacionamentos e chaves estrangeiras para integridade dos dados
|
||||
|
||||
### Técnico
|
||||
- Correção de erros de tipagem no `PageTracker` relacionados ao objeto `User`
|
||||
- Implementação de tracking consistente em todos os botões de planos
|
||||
- Adição de propriedades de tracking detalhadas para análise de conversão
|
||||
- Melhoria na coleta de dados de dispositivo, performance e sessão
|
||||
- Implementação de migrações do banco de dados
|
||||
- Criação de índices para otimização de consultas
|
||||
- Implementação de políticas de segurança RLS
|
||||
- Estrutura de dados normalizada com relacionamentos apropriados
|
||||
|
||||
### Modificado
|
||||
- N/A (primeira versão)
|
||||
|
||||
### Removido
|
||||
- N/A (primeira versão)
|
||||
|
||||
25
package-lock.json
generated
25
package-lock.json
generated
@ -12,6 +12,7 @@
|
||||
"@ffmpeg/util": "^0.12.1",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-progress": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@sentry/react": "^8.48.0",
|
||||
@ -2123,6 +2124,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-progress": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.1.tgz",
|
||||
"integrity": "sha512-6diOawA84f/eMxFHcWut0aE1C2kyE9dOyCTQOMRR2C/qPiXz/X0SaiA/RLbapQaXUCmy0/hLMf9meSccD1N0pA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz",
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
"@ffmpeg/util": "^0.12.1",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-progress": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@sentry/react": "^8.48.0",
|
||||
|
||||
69
src/components/phonics/AudioPlayer.tsx
Normal file
69
src/components/phonics/AudioPlayer.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Volume2, Loader2 } from "lucide-react";
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
interface AudioPlayerProps {
|
||||
word: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function AudioPlayer({ word, disabled }: AudioPlayerProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const playAudio = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Buscar ou gerar o áudio da palavra
|
||||
const { data, error } = await supabase.functions.invoke('generate-word-audio', {
|
||||
body: { word }
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
if (data?.audioUrl) {
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio(data.audioUrl);
|
||||
} else {
|
||||
audioRef.current.src = data.audioUrl;
|
||||
}
|
||||
|
||||
await audioRef.current.play();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erro ao reproduzir áudio:', err);
|
||||
setError('Erro ao reproduzir áudio');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
className="gap-2"
|
||||
onClick={playAudio}
|
||||
disabled={disabled || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Volume2 className="w-5 h-5" />
|
||||
)}
|
||||
Ouvir Palavra
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 mt-1">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/components/phonics/CategoryTabs.tsx
Normal file
43
src/components/phonics/CategoryTabs.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { usePhonicsCategories } from "@/hooks/phonics/usePhonicsExercises";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface CategoryTabsProps {
|
||||
selectedCategory?: string;
|
||||
onSelectCategory: (categoryId: string) => void;
|
||||
}
|
||||
|
||||
export function CategoryTabs({ selectedCategory, onSelectCategory }: CategoryTabsProps) {
|
||||
const { data: categories, isLoading } = usePhonicsCategories();
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-10 w-full max-w-[600px]" />;
|
||||
}
|
||||
|
||||
if (!categories?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={selectedCategory || "all"}
|
||||
onValueChange={(value) => onSelectCategory(value === "all" ? "" : value)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="w-full max-w-[600px] h-auto flex-wrap">
|
||||
<TabsTrigger value="all" className="flex-1">
|
||||
Todos
|
||||
</TabsTrigger>
|
||||
{categories.map((category) => (
|
||||
<TabsTrigger
|
||||
key={category.id}
|
||||
value={category.id}
|
||||
className="flex-1"
|
||||
>
|
||||
{category.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
70
src/components/phonics/ExerciseCard.tsx
Normal file
70
src/components/phonics/ExerciseCard.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Clock, Star } from "lucide-react";
|
||||
import type { PhonicsExercise, PhonicsProgress } from "@/types/phonics";
|
||||
|
||||
interface ExerciseCardProps {
|
||||
exercise: PhonicsExercise;
|
||||
progress?: PhonicsProgress;
|
||||
onStart: (exerciseId: string) => void;
|
||||
}
|
||||
|
||||
export function ExerciseCard({ exercise, progress, onStart }: ExerciseCardProps) {
|
||||
const isCompleted = progress?.completed;
|
||||
const stars = progress?.stars || 0;
|
||||
const progressValue = progress ? (progress.bestScore * 100) : 0;
|
||||
|
||||
return (
|
||||
<Card className="w-full hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-lg font-bold">{exercise.title}</CardTitle>
|
||||
<CardDescription>{exercise.description}</CardDescription>
|
||||
</div>
|
||||
<Badge variant={isCompleted ? "success" : "secondary"}>
|
||||
{isCompleted ? "Completo" : "Pendente"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{Math.ceil(exercise.estimatedTimeSeconds / 60)} min</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-4 h-4 ${i < stars ? "fill-yellow-400 text-yellow-400" : "text-gray-300"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{progress && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Progresso</span>
|
||||
<span>{Math.round(progressValue)}%</span>
|
||||
</div>
|
||||
<Progress value={progressValue} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => onStart(exercise.id)}
|
||||
variant={isCompleted ? "secondary" : "default"}
|
||||
>
|
||||
{isCompleted ? "Praticar Novamente" : "Começar"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
48
src/components/phonics/ExerciseGrid.tsx
Normal file
48
src/components/phonics/ExerciseGrid.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { usePhonicsExercises } from "@/hooks/phonics/usePhonicsExercises";
|
||||
import { usePhonicsProgress } from "@/hooks/phonics/usePhonicsProgress";
|
||||
import { ExerciseCard } from "./ExerciseCard";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface ExerciseGridProps {
|
||||
categoryId?: string;
|
||||
studentId: string;
|
||||
onSelectExercise: (exerciseId: string) => void;
|
||||
}
|
||||
|
||||
export function ExerciseGrid({ categoryId, studentId, onSelectExercise }: ExerciseGridProps) {
|
||||
const { data: exercises, isLoading: isLoadingExercises } = usePhonicsExercises(categoryId);
|
||||
const { data: progress, isLoading: isLoadingProgress } = usePhonicsProgress(studentId);
|
||||
|
||||
const isLoading = isLoadingExercises || isLoadingProgress;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[250px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!exercises?.length) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Nenhum exercício encontrado nesta categoria.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{exercises.map((exercise) => (
|
||||
<ExerciseCard
|
||||
key={exercise.id}
|
||||
exercise={exercise}
|
||||
progress={progress?.find((p) => p.exerciseId === exercise.id)}
|
||||
onStart={onSelectExercise}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
src/components/phonics/ExercisePlayer.tsx
Normal file
147
src/components/phonics/ExercisePlayer.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useExerciseWords } from "@/hooks/phonics/useExerciseAttempt";
|
||||
import { useUpdatePhonicsProgress } from "@/hooks/phonics/usePhonicsProgress";
|
||||
import { ExerciseFactory } from "./exercises/ExerciseFactory";
|
||||
import { Timer } from "lucide-react";
|
||||
import type { PhonicsExercise, PhonicsWord } from "@/types/phonics";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ExercisePlayerProps {
|
||||
exercise: PhonicsExercise;
|
||||
studentId: string;
|
||||
onComplete: () => void;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
export function ExercisePlayer({
|
||||
exercise,
|
||||
studentId,
|
||||
onComplete,
|
||||
onExit
|
||||
}: ExercisePlayerProps) {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [score, setScore] = useState(0);
|
||||
const [timeSpent, setTimeSpent] = useState(0);
|
||||
const [answers, setAnswers] = useState<string[]>([]);
|
||||
const [mistakes, setMistakes] = useState<string[]>([]);
|
||||
const [showFeedback, setShowFeedback] = useState(false);
|
||||
const [lastAnswerCorrect, setLastAnswerCorrect] = useState<boolean | null>(null);
|
||||
|
||||
const { data: exerciseWords, isLoading } = useExerciseWords(exercise.id);
|
||||
const updateProgress = useUpdatePhonicsProgress();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setTimeSpent((prev) => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const handleAnswer = async (word: string, isCorrect: boolean) => {
|
||||
setLastAnswerCorrect(isCorrect);
|
||||
setShowFeedback(true);
|
||||
|
||||
if (isCorrect) {
|
||||
setScore((prev) => prev + 1);
|
||||
setAnswers((prev) => [...prev, word]);
|
||||
} else {
|
||||
setMistakes((prev) => [...prev, word]);
|
||||
}
|
||||
|
||||
// Aguardar feedback antes de prosseguir
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
setShowFeedback(false);
|
||||
|
||||
if (currentStep < (exerciseWords?.length || 0) - 1) {
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
} else {
|
||||
handleComplete();
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
const finalScore = score / (exerciseWords?.length || 1);
|
||||
const stars = Math.ceil(finalScore * 3);
|
||||
|
||||
await updateProgress.mutateAsync({
|
||||
studentId,
|
||||
exerciseId: exercise.id,
|
||||
attempts: 1,
|
||||
bestScore: finalScore,
|
||||
lastScore: finalScore,
|
||||
completed: finalScore >= exercise.requiredScore,
|
||||
stars,
|
||||
xpEarned: Math.round(finalScore * exercise.points)
|
||||
});
|
||||
|
||||
onComplete();
|
||||
};
|
||||
|
||||
if (isLoading || !exerciseWords?.length) {
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<CardContent className="py-8">
|
||||
<div className="text-center text-muted-foreground">
|
||||
Carregando exercício...
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const progress = ((currentStep + 1) / exerciseWords.length) * 100;
|
||||
const currentWord = exerciseWords[currentStep].word as unknown as PhonicsWord;
|
||||
|
||||
return (
|
||||
<Card className={cn(
|
||||
"w-full max-w-2xl mx-auto transition-colors duration-500",
|
||||
showFeedback && lastAnswerCorrect && "bg-green-50",
|
||||
showFeedback && !lastAnswerCorrect && "bg-red-50"
|
||||
)}>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle>{exercise.title}</CardTitle>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Timer className="w-4 h-4" />
|
||||
<span>{Math.floor(timeSpent / 60)}:{(timeSpent % 60).toString().padStart(2, '0')}</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={onExit}>
|
||||
Sair
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<div className="text-center text-muted-foreground mb-8">
|
||||
Exercício {currentStep + 1} de {exerciseWords.length}
|
||||
</div>
|
||||
|
||||
<ExerciseFactory
|
||||
type={exercise.exerciseType}
|
||||
currentWord={currentWord}
|
||||
options={exerciseWords[currentStep].options}
|
||||
onAnswer={handleAnswer}
|
||||
disabled={showFeedback}
|
||||
/>
|
||||
|
||||
{showFeedback && (
|
||||
<div className={cn(
|
||||
"text-center text-lg font-medium py-4 rounded-lg",
|
||||
lastAnswerCorrect ? "text-green-600" : "text-red-600"
|
||||
)}>
|
||||
{lastAnswerCorrect ? "Muito bem!" : "Tente novamente na próxima!"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
47
src/components/phonics/exercises/AlliterationExercise.tsx
Normal file
47
src/components/phonics/exercises/AlliterationExercise.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BaseExercise, type BaseExerciseProps } from "./BaseExercise";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AlliterationExerciseProps extends BaseExerciseProps {
|
||||
options: Array<{
|
||||
word: string;
|
||||
hasSameInitialSound: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function AlliterationExercise({
|
||||
currentWord,
|
||||
onAnswer,
|
||||
options,
|
||||
disabled
|
||||
}: AlliterationExerciseProps) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<BaseExercise currentWord={currentWord} onAnswer={onAnswer} disabled={disabled} />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-center text-muted-foreground">
|
||||
Qual palavra começa com o mesmo som que <span className="font-medium text-foreground">{currentWord.word}</span>?
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.word}
|
||||
onClick={() => onAnswer(option.word, option.hasSameInitialSound)}
|
||||
disabled={disabled}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-16 text-lg",
|
||||
disabled && option.hasSameInitialSound && "border-green-500 bg-green-50",
|
||||
disabled && !option.hasSameInitialSound && "border-red-500 bg-red-50"
|
||||
)}
|
||||
>
|
||||
{option.word}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
src/components/phonics/exercises/BaseExercise.tsx
Normal file
31
src/components/phonics/exercises/BaseExercise.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import type { PhonicsWord } from "@/types/phonics";
|
||||
import { AudioPlayer } from "../AudioPlayer";
|
||||
|
||||
export interface BaseExerciseProps {
|
||||
currentWord: PhonicsWord;
|
||||
onAnswer: (word: string, isCorrect: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ExerciseOption {
|
||||
id: string;
|
||||
text: string;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
export function BaseExercise({ currentWord, disabled }: BaseExerciseProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<AudioPlayer
|
||||
word={currentWord.word}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<div className="text-2xl font-bold">
|
||||
{currentWord.word}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
src/components/phonics/exercises/ExerciseFactory.tsx
Normal file
77
src/components/phonics/exercises/ExerciseFactory.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import type { PhonicsExerciseType, PhonicsWord } from "@/types/phonics";
|
||||
import { RhymeExercise } from "./RhymeExercise";
|
||||
import { AlliterationExercise } from "./AlliterationExercise";
|
||||
import { SyllablesExercise } from "./SyllablesExercise";
|
||||
import { SoundMatchExercise } from "./SoundMatchExercise";
|
||||
|
||||
interface ExerciseFactoryProps {
|
||||
type: PhonicsExerciseType;
|
||||
currentWord: PhonicsWord;
|
||||
options: any; // Tipo específico para cada exercício
|
||||
onAnswer: (word: string, isCorrect: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ExerciseFactory({ type, currentWord, options, onAnswer, disabled }: ExerciseFactoryProps) {
|
||||
switch (type) {
|
||||
case 'rhyme':
|
||||
return (
|
||||
<RhymeExercise
|
||||
currentWord={currentWord}
|
||||
options={options}
|
||||
onAnswer={onAnswer}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'alliteration':
|
||||
return (
|
||||
<AlliterationExercise
|
||||
currentWord={currentWord}
|
||||
options={options}
|
||||
onAnswer={onAnswer}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'syllables':
|
||||
return (
|
||||
<SyllablesExercise
|
||||
currentWord={currentWord}
|
||||
syllables={options.syllables}
|
||||
correctOrder={options.correctOrder}
|
||||
onAnswer={onAnswer}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'initial_sound':
|
||||
return (
|
||||
<SoundMatchExercise
|
||||
currentWord={currentWord}
|
||||
type="initial"
|
||||
options={options}
|
||||
onAnswer={onAnswer}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'final_sound':
|
||||
return (
|
||||
<SoundMatchExercise
|
||||
currentWord={currentWord}
|
||||
type="final"
|
||||
options={options}
|
||||
onAnswer={onAnswer}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="text-center text-red-500">
|
||||
Tipo de exercício não implementado: {type}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
42
src/components/phonics/exercises/RhymeExercise.tsx
Normal file
42
src/components/phonics/exercises/RhymeExercise.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BaseExercise, type BaseExerciseProps } from "./BaseExercise";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface RhymeExerciseProps extends BaseExerciseProps {
|
||||
options: Array<{
|
||||
word: string;
|
||||
isRhyme: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function RhymeExercise({ currentWord, onAnswer, options, disabled }: RhymeExerciseProps) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<BaseExercise currentWord={currentWord} onAnswer={onAnswer} disabled={disabled} />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-center text-muted-foreground">
|
||||
Qual palavra rima com <span className="font-medium text-foreground">{currentWord.word}</span>?
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.word}
|
||||
onClick={() => onAnswer(option.word, option.isRhyme)}
|
||||
disabled={disabled}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-16 text-lg",
|
||||
disabled && option.isRhyme && "border-green-500 bg-green-50",
|
||||
disabled && !option.isRhyme && "border-red-500 bg-red-50"
|
||||
)}
|
||||
>
|
||||
{option.word}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/components/phonics/exercises/SoundMatchExercise.tsx
Normal file
53
src/components/phonics/exercises/SoundMatchExercise.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BaseExercise, type BaseExerciseProps } from "./BaseExercise";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SoundMatchExerciseProps extends BaseExerciseProps {
|
||||
type: 'initial' | 'final';
|
||||
options: Array<{
|
||||
word: string;
|
||||
hasMatchingSound: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function SoundMatchExercise({
|
||||
currentWord,
|
||||
onAnswer,
|
||||
type,
|
||||
options,
|
||||
disabled
|
||||
}: SoundMatchExerciseProps) {
|
||||
const instruction = type === 'initial'
|
||||
? "Qual palavra começa com o mesmo som?"
|
||||
: "Qual palavra termina com o mesmo som?";
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<BaseExercise currentWord={currentWord} onAnswer={onAnswer} disabled={disabled} />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-center text-muted-foreground">
|
||||
{instruction}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.word}
|
||||
onClick={() => onAnswer(option.word, option.hasMatchingSound)}
|
||||
disabled={disabled}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-16 text-lg",
|
||||
disabled && option.hasMatchingSound && "border-green-500 bg-green-50",
|
||||
disabled && !option.hasMatchingSound && "border-red-500 bg-red-50"
|
||||
)}
|
||||
>
|
||||
{option.word}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
src/components/phonics/exercises/SyllablesExercise.tsx
Normal file
81
src/components/phonics/exercises/SyllablesExercise.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BaseExercise, type BaseExerciseProps } from "./BaseExercise";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SyllablesExerciseProps extends BaseExerciseProps {
|
||||
syllables: string[];
|
||||
correctOrder: number[];
|
||||
}
|
||||
|
||||
export function SyllablesExercise({
|
||||
currentWord,
|
||||
onAnswer,
|
||||
syllables,
|
||||
correctOrder,
|
||||
disabled
|
||||
}: SyllablesExerciseProps) {
|
||||
const [selectedSyllables, setSelectedSyllables] = useState<number[]>([]);
|
||||
|
||||
const handleSyllableClick = (index: number) => {
|
||||
if (selectedSyllables.includes(index)) {
|
||||
setSelectedSyllables(prev => prev.filter(i => i !== index));
|
||||
} else {
|
||||
setSelectedSyllables(prev => [...prev, index]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheck = () => {
|
||||
const isCorrect = selectedSyllables.every(
|
||||
(syllableIndex, position) => syllableIndex === correctOrder[position]
|
||||
);
|
||||
onAnswer(currentWord.word, isCorrect);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<BaseExercise currentWord={currentWord} onAnswer={onAnswer} disabled={disabled} />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-center text-muted-foreground">
|
||||
Selecione as sílabas na ordem correta
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{syllables.map((syllable, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={() => handleSyllableClick(index)}
|
||||
disabled={disabled}
|
||||
variant={selectedSyllables.includes(index) ? "default" : "outline"}
|
||||
className={cn(
|
||||
"text-lg px-4",
|
||||
disabled && correctOrder.indexOf(index) === selectedSyllables.indexOf(index) && "border-green-500 bg-green-50",
|
||||
disabled && correctOrder.indexOf(index) !== selectedSyllables.indexOf(index) && "border-red-500 bg-red-50"
|
||||
)}
|
||||
>
|
||||
{syllable}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedSyllables.length > 0 && !disabled && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
onClick={handleCheck}
|
||||
className="mt-4"
|
||||
>
|
||||
Verificar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSyllables.length > 0 && (
|
||||
<div className="text-center text-xl font-medium">
|
||||
{syllables.filter((_, i) => selectedSyllables.includes(i)).join("-")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,16 +1,79 @@
|
||||
import React from 'react';
|
||||
import * as React from "react"
|
||||
|
||||
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function Card({ className = '', children, ...props }: CardProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-sm border border-gray-200 ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
28
src/components/ui/progress.tsx
Normal file
28
src/components/ui/progress.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
44
src/hooks/phonics/useExerciseAttempt.ts
Normal file
44
src/hooks/phonics/useExerciseAttempt.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import type { PhonicsAttempt } from '@/types/phonics';
|
||||
|
||||
export function useExerciseAttempt() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (attempt: Omit<PhonicsAttempt, 'id'>) => {
|
||||
const { data, error } = await supabase
|
||||
.from('phonics_student_attempts')
|
||||
.insert(attempt)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['phonics-progress', variables.studentId]
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useExerciseWords(exerciseId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['exercise-words', exerciseId],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('phonics_exercise_words')
|
||||
.select(`
|
||||
*,
|
||||
word:phonics_words(*)
|
||||
`)
|
||||
.eq('exercise_id', exerciseId)
|
||||
.order('order_index', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
}
|
||||
47
src/hooks/phonics/usePhonicsExercises.ts
Normal file
47
src/hooks/phonics/usePhonicsExercises.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import type { PhonicsExercise, PhonicsExerciseCategory } from '@/types/phonics';
|
||||
|
||||
export function usePhonicsExercises(categoryId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['phonics-exercises', categoryId],
|
||||
queryFn: async () => {
|
||||
const query = supabase
|
||||
.from('phonics_exercises')
|
||||
.select(`
|
||||
*,
|
||||
category:phonics_exercise_categories(name),
|
||||
type:phonics_exercise_types(name),
|
||||
words:phonics_exercise_words(
|
||||
word:phonics_words(*)
|
||||
)
|
||||
`)
|
||||
.eq('is_active', true)
|
||||
.order('difficulty_level', { ascending: true });
|
||||
|
||||
if (categoryId) {
|
||||
query.eq('category_id', categoryId);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
|
||||
return data as PhonicsExercise[];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function usePhonicsCategories() {
|
||||
return useQuery({
|
||||
queryKey: ['phonics-categories'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('phonics_exercise_categories')
|
||||
.select('*')
|
||||
.order('order_index', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
return data as PhonicsExerciseCategory[];
|
||||
}
|
||||
});
|
||||
}
|
||||
78
src/hooks/phonics/usePhonicsProgress.ts
Normal file
78
src/hooks/phonics/usePhonicsProgress.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import type { StudentPhonicsProgress } from '@/types/phonics';
|
||||
|
||||
export function usePhonicsProgress(studentId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['phonics-progress', studentId],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('student_phonics_progress')
|
||||
.select('*')
|
||||
.eq('student_id', studentId);
|
||||
|
||||
if (error) throw error;
|
||||
return data as StudentPhonicsProgress[];
|
||||
},
|
||||
enabled: !!studentId
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePhonicsProgress() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
studentId,
|
||||
exerciseId,
|
||||
score,
|
||||
timeSpent
|
||||
}: {
|
||||
studentId: string;
|
||||
exerciseId: string;
|
||||
score: number;
|
||||
timeSpent: number;
|
||||
}) => {
|
||||
// Primeiro, registra a tentativa
|
||||
const { data: attemptData, error: attemptError } = await supabase
|
||||
.from('student_phonics_attempts')
|
||||
.insert({
|
||||
student_id: studentId,
|
||||
exercise_id: exerciseId,
|
||||
score,
|
||||
time_spent_seconds: timeSpent
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (attemptError) throw attemptError;
|
||||
|
||||
// Depois, atualiza ou cria o progresso
|
||||
const { data: progressData, error: progressError } = await supabase
|
||||
.from('student_phonics_progress')
|
||||
.upsert({
|
||||
student_id: studentId,
|
||||
exercise_id: exerciseId,
|
||||
attempts: 1,
|
||||
best_score: score,
|
||||
last_score: score,
|
||||
completed: score >= 0.7,
|
||||
completed_at: score >= 0.7 ? new Date().toISOString() : null,
|
||||
stars: Math.ceil(score * 3),
|
||||
xp_earned: Math.ceil(score * 100)
|
||||
}, {
|
||||
onConflict: 'student_id,exercise_id',
|
||||
ignoreDuplicates: false
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (progressError) throw progressError;
|
||||
|
||||
return progressData;
|
||||
},
|
||||
onSuccess: (_, { studentId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['phonics-progress', studentId] });
|
||||
}
|
||||
});
|
||||
}
|
||||
55
src/pages/student-dashboard/PhonicsPage.tsx
Normal file
55
src/pages/student-dashboard/PhonicsPage.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { useState } from "react";
|
||||
import { CategoryTabs } from "@/components/phonics/CategoryTabs";
|
||||
import { ExerciseGrid } from "@/components/phonics/ExerciseGrid";
|
||||
import { ExercisePlayer } from "@/components/phonics/ExercisePlayer";
|
||||
import { usePhonicsExercises } from "@/hooks/phonics/usePhonicsExercises";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
export function PhonicsPage() {
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>();
|
||||
const [selectedExercise, setSelectedExercise] = useState<string>();
|
||||
const { user } = useAuth();
|
||||
const { data: exercises } = usePhonicsExercises(selectedCategory);
|
||||
|
||||
const handleExerciseComplete = () => {
|
||||
setSelectedExercise(undefined);
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
if (selectedExercise) {
|
||||
const exercise = exercises?.find(ex => ex.id === selectedExercise);
|
||||
if (!exercise) return null;
|
||||
|
||||
return (
|
||||
<ExercisePlayer
|
||||
exercise={exercise}
|
||||
studentId={user.id}
|
||||
onComplete={handleExerciseComplete}
|
||||
onExit={() => setSelectedExercise(undefined)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container py-6 space-y-8">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold">Exercícios Fônicos</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Pratique seus conhecimentos com exercícios interativos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CategoryTabs
|
||||
selectedCategory={selectedCategory}
|
||||
onSelectCategory={setSelectedCategory}
|
||||
/>
|
||||
|
||||
<ExerciseGrid
|
||||
categoryId={selectedCategory}
|
||||
studentId={user.id}
|
||||
onSelectExercise={setSelectedExercise}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
src/pages/student-dashboard/PhonicsProgressPage.tsx
Normal file
113
src/pages/student-dashboard/PhonicsProgressPage.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import { usePhonicsProgress } from "@/hooks/phonics/usePhonicsProgress";
|
||||
import { usePhonicsExercises } from "@/hooks/phonics/usePhonicsExercises";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Star, Trophy } from "lucide-react";
|
||||
|
||||
export function PhonicsProgressPage() {
|
||||
const { user } = useAuth();
|
||||
const { data: progress } = usePhonicsProgress(user?.id || "");
|
||||
const { data: exercises } = usePhonicsExercises();
|
||||
|
||||
if (!user || !progress || !exercises) return null;
|
||||
|
||||
const totalExercises = exercises.length;
|
||||
const completedExercises = progress.filter(p => p.completed).length;
|
||||
const totalStars = progress.reduce((acc, p) => acc + p.stars, 0);
|
||||
const totalXP = progress.reduce((acc, p) => acc + p.xpEarned, 0);
|
||||
const completionRate = (completedExercises / totalExercises) * 100;
|
||||
|
||||
return (
|
||||
<div className="container py-6 space-y-8">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold">Seu Progresso</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Acompanhe seu desenvolvimento nos exercícios fônicos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Exercícios Completados</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="text-3xl font-bold">
|
||||
{completedExercises} / {totalExercises}
|
||||
</div>
|
||||
<Progress value={completionRate} className="h-2" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Estrelas Conquistadas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="w-8 h-8 text-yellow-400 fill-yellow-400" />
|
||||
<span className="text-3xl font-bold">{totalStars}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">XP Total</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="w-8 h-8 text-purple-500" />
|
||||
<span className="text-3xl font-bold">{totalXP}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Histórico de Exercícios</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{exercises.map((exercise) => {
|
||||
const exerciseProgress = progress.find(p => p.exerciseId === exercise.id);
|
||||
const progressValue = exerciseProgress ? exerciseProgress.bestScore * 100 : 0;
|
||||
|
||||
return (
|
||||
<div key={exercise.id} className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{exercise.title}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{exerciseProgress?.completed ? "Completo" : "Pendente"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-4 h-4 ${
|
||||
i < (exerciseProgress?.stars || 0)
|
||||
? "text-yellow-400 fill-yellow-400"
|
||||
: "text-gray-300"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-32">
|
||||
<Progress value={progressValue} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -94,6 +94,36 @@ export function StudentDashboardLayout() {
|
||||
{!isCollapsed && <span>Histórico</span>}
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/aluno/fonicos"
|
||||
onClick={handleNavigation}
|
||||
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" />
|
||||
{!isCollapsed && <span>Exercícios Fônicos</span>}
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/aluno/fonicos/progresso"
|
||||
onClick={handleNavigation}
|
||||
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" />
|
||||
{!isCollapsed && <span>Progresso Fônico</span>}
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/aluno/configuracoes"
|
||||
onClick={handleNavigation}
|
||||
|
||||
@ -25,13 +25,14 @@ 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';
|
||||
import { TestWordHighlighter } from './pages/TestWordHighlighter';
|
||||
import { ExercisePage } from './pages/student-dashboard/ExercisePage';
|
||||
import { EvidenceBased } from './pages/landing/EvidenceBased';
|
||||
import { TextSalesLetter } from './pages/landing/TextSalesLetter';
|
||||
import { PhonicsPage } from "./pages/student-dashboard/PhonicsPage";
|
||||
import { PhonicsProgressPage } from "./pages/student-dashboard/PhonicsProgressPage";
|
||||
|
||||
function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@ -206,6 +207,14 @@ export const router = createBrowserRouter([
|
||||
{
|
||||
path: 'turmas/:classId',
|
||||
element: <StudentClassPage />,
|
||||
},
|
||||
{
|
||||
path: 'fonicos',
|
||||
element: <PhonicsPage />,
|
||||
},
|
||||
{
|
||||
path: 'fonicos/progresso',
|
||||
element: <PhonicsProgressPage />,
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
123
src/types/phonics.ts
Normal file
123
src/types/phonics.ts
Normal file
@ -0,0 +1,123 @@
|
||||
export interface PhonicsExerciseType {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PhonicsExerciseCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
level: number;
|
||||
order_index: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PhonicsExercise {
|
||||
id: string;
|
||||
category_id: string;
|
||||
type_id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
difficulty_level: number;
|
||||
estimated_time_seconds: number | null;
|
||||
instructions: string;
|
||||
points: number;
|
||||
is_active: boolean;
|
||||
required_score: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PhonicsWord {
|
||||
id: string;
|
||||
word: string;
|
||||
phonetic_transcription: string | null;
|
||||
syllables_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PhonicsExerciseWord {
|
||||
id: string;
|
||||
exercise_id: string;
|
||||
word_id: string;
|
||||
is_correct_answer: boolean;
|
||||
order_index: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MediaType {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PhonicsExerciseMedia {
|
||||
id: string;
|
||||
exercise_id: string;
|
||||
media_type_id: string;
|
||||
url: string;
|
||||
alt_text: string | null;
|
||||
order_index: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface StudentPhonicsProgress {
|
||||
id: string;
|
||||
student_id: string;
|
||||
exercise_id: string;
|
||||
attempts: number;
|
||||
best_score: number;
|
||||
last_score: number;
|
||||
completed: boolean;
|
||||
completed_at: string | null;
|
||||
stars: number;
|
||||
xp_earned: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface StudentPhonicsAttempt {
|
||||
id: string;
|
||||
student_id: string;
|
||||
exercise_id: string;
|
||||
score: number;
|
||||
time_spent_seconds: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface StudentPhonicsAttemptAnswer {
|
||||
id: string;
|
||||
attempt_id: string;
|
||||
word_id: string;
|
||||
is_correct: boolean;
|
||||
answer_text: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AchievementType {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PhonicsAchievement {
|
||||
id: string;
|
||||
type_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
points: number;
|
||||
icon_url: string | null;
|
||||
required_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface StudentPhonicsAchievement {
|
||||
id: string;
|
||||
student_id: string;
|
||||
achievement_id: string;
|
||||
earned_at: string;
|
||||
}
|
||||
109
supabase/functions/generate-word-audio/index.ts
Normal file
109
supabase/functions/generate-word-audio/index.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const { word } = await req.json();
|
||||
|
||||
if (!word || typeof word !== 'string') {
|
||||
throw new Error('Palavra inválida');
|
||||
}
|
||||
|
||||
// Criar cliente Supabase
|
||||
const supabaseClient = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_ANON_KEY') ?? ''
|
||||
);
|
||||
|
||||
// Verificar se já existe áudio para esta palavra
|
||||
const { data: existingAudio } = await supabaseClient
|
||||
.from('phonics_word_audio')
|
||||
.select('audio_url')
|
||||
.eq('word', word.toLowerCase())
|
||||
.single();
|
||||
|
||||
if (existingAudio?.audio_url) {
|
||||
return new Response(
|
||||
JSON.stringify({ audioUrl: existingAudio.audio_url }),
|
||||
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Gerar novo áudio usando Text-to-Speech
|
||||
const response = await fetch('https://api.elevenlabs.io/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'xi-api-key': Deno.env.get('ELEVEN_LABS_API_KEY') ?? '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: word,
|
||||
model_id: "eleven_multilingual_v2",
|
||||
voice_settings: {
|
||||
stability: 0.75,
|
||||
similarity_boost: 0.75,
|
||||
style: 0.5,
|
||||
use_speaker_boost: true
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erro ao gerar áudio');
|
||||
}
|
||||
|
||||
const audioBuffer = await response.arrayBuffer();
|
||||
const audioBase64 = btoa(String.fromCharCode(...new Uint8Array(audioBuffer)));
|
||||
|
||||
// Salvar áudio no storage
|
||||
const fileName = `${word.toLowerCase()}_${Date.now()}.mp3`;
|
||||
const { error: uploadError } = await supabaseClient.storage
|
||||
.from('phonics-audio')
|
||||
.upload(fileName, audioBuffer, {
|
||||
contentType: 'audio/mpeg',
|
||||
cacheControl: '31536000', // 1 ano
|
||||
});
|
||||
|
||||
if (uploadError) {
|
||||
throw uploadError;
|
||||
}
|
||||
|
||||
// Obter URL pública
|
||||
const { data: { publicUrl } } = supabaseClient.storage
|
||||
.from('phonics-audio')
|
||||
.getPublicUrl(fileName);
|
||||
|
||||
// Salvar referência no banco
|
||||
await supabaseClient
|
||||
.from('phonics_word_audio')
|
||||
.insert({
|
||||
word: word.toLowerCase(),
|
||||
audio_url: publicUrl,
|
||||
audio_path: fileName,
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ audioUrl: publicUrl }),
|
||||
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: error.message }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
105
supabase/migrations/20240320000002_populate_phonics_tables.sql
Normal file
105
supabase/migrations/20240320000002_populate_phonics_tables.sql
Normal file
@ -0,0 +1,105 @@
|
||||
-- Inserir tipos de exercícios
|
||||
INSERT INTO phonics_exercise_types (id, name, description) VALUES
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d479', 'rhyme', 'Exercícios de rima'),
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d480', 'alliteration', 'Exercícios de aliteração'),
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d481', 'syllables', 'Exercícios de sílabas'),
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d482', 'sound_match', 'Exercícios de correspondência de sons');
|
||||
|
||||
-- Inserir categorias
|
||||
INSERT INTO phonics_exercise_categories (id, name, description, level, order_index) VALUES
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d483', 'Rimas Básicas', 'Exercícios básicos de rima', 1, 1),
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d484', 'Aliteração Inicial', 'Exercícios de sons iniciais', 1, 2),
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d485', 'Sílabas Simples', 'Exercícios de separação silábica', 1, 3),
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d486', 'Sons das Letras', 'Exercícios de sons das letras', 1, 4);
|
||||
|
||||
-- Inserir palavras
|
||||
INSERT INTO phonics_words (id, word, phonetic_transcription, syllables_count) VALUES
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d487', 'bola', 'bɔ.la', 2),
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d488', 'cola', 'kɔ.la', 2),
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d489', 'mola', 'mɔ.la', 2),
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d490', 'gato', 'ga.tu', 2),
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d491', 'pato', 'pa.tu', 2),
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d492', 'rato', 'ha.tu', 2),
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d493', 'casa', 'ka.za', 2),
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d494', 'mesa', 'me.za', 2),
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d495', 'pera', 'pɛ.ɾa', 2);
|
||||
|
||||
-- Inserir tipos de mídia
|
||||
INSERT INTO media_types (id, name, description) VALUES
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d496', 'image', 'Imagens para exercícios'),
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d497', 'sound', 'Sons para exercícios'),
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d498', 'animation', 'Animações para exercícios');
|
||||
|
||||
-- Inserir exercícios
|
||||
INSERT INTO phonics_exercises (id, category_id, type_id, title, description, difficulty_level, estimated_time_seconds, instructions, points, required_score) VALUES
|
||||
(
|
||||
'f47ac10b-58cc-4372-a567-0e02b2c3d499',
|
||||
'f47ac10b-58cc-4372-a567-0e02b2c3d483',
|
||||
'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
'Encontre a Rima: Bola',
|
||||
'Encontre a palavra que rima com BOLA',
|
||||
1,
|
||||
60,
|
||||
'Clique na palavra que rima com BOLA',
|
||||
10,
|
||||
0.7
|
||||
),
|
||||
(
|
||||
'f47ac10b-58cc-4372-a567-0e02b2c3d500',
|
||||
'f47ac10b-58cc-4372-a567-0e02b2c3d484',
|
||||
'f47ac10b-58cc-4372-a567-0e02b2c3d480',
|
||||
'Sons Iniciais: Gato',
|
||||
'Encontre a palavra que começa com o mesmo som de GATO',
|
||||
1,
|
||||
60,
|
||||
'Clique na palavra que começa com o mesmo som de GATO',
|
||||
10,
|
||||
0.7
|
||||
);
|
||||
|
||||
-- Relacionar exercícios com palavras
|
||||
INSERT INTO phonics_exercise_words (exercise_id, word_id, is_correct_answer, order_index) VALUES
|
||||
-- Exercício de rima com "bola"
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d499', 'f47ac10b-58cc-4372-a567-0e02b2c3d487', false, 1), -- bola (palavra base)
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d499', 'f47ac10b-58cc-4372-a567-0e02b2c3d488', true, 2), -- cola (resposta correta)
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d499', 'f47ac10b-58cc-4372-a567-0e02b2c3d493', false, 3), -- casa (distrator)
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d499', 'f47ac10b-58cc-4372-a567-0e02b2c3d495', false, 4), -- pera (distrator)
|
||||
|
||||
-- Exercício de aliteração com "gato"
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d500', 'f47ac10b-58cc-4372-a567-0e02b2c3d490', false, 1), -- gato (palavra base)
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d500', 'f47ac10b-58cc-4372-a567-0e02b2c3d491', false, 2), -- pato (distrator)
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d500', 'f47ac10b-58cc-4372-a567-0e02b2c3d493', true, 3), -- casa (resposta correta)
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d500', 'f47ac10b-58cc-4372-a567-0e02b2c3d494', false, 4); -- mesa (distrator)
|
||||
|
||||
-- Inserir tipos de conquistas
|
||||
INSERT INTO achievement_types (id, name, description) VALUES
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d501', 'streak', 'Conquistas de sequência'),
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d502', 'completion', 'Conquistas de conclusão'),
|
||||
('f47ac10b-58cc-4372-a567-0e02b2c3d503', 'mastery', 'Conquistas de maestria');
|
||||
|
||||
-- Inserir conquistas
|
||||
INSERT INTO phonics_achievements (id, type_id, name, description, points, required_count) VALUES
|
||||
(
|
||||
'f47ac10b-58cc-4372-a567-0e02b2c3d504',
|
||||
'f47ac10b-58cc-4372-a567-0e02b2c3d501',
|
||||
'Primeira Sequência',
|
||||
'Complete 3 exercícios seguidos corretamente',
|
||||
50,
|
||||
3
|
||||
),
|
||||
(
|
||||
'f47ac10b-58cc-4372-a567-0e02b2c3d505',
|
||||
'f47ac10b-58cc-4372-a567-0e02b2c3d502',
|
||||
'Iniciante em Rimas',
|
||||
'Complete 5 exercícios de rima',
|
||||
100,
|
||||
5
|
||||
),
|
||||
(
|
||||
'f47ac10b-58cc-4372-a567-0e02b2c3d506',
|
||||
'f47ac10b-58cc-4372-a567-0e02b2c3d503',
|
||||
'Mestre das Rimas',
|
||||
'Obtenha 3 estrelas em 10 exercícios de rima',
|
||||
200,
|
||||
10
|
||||
);
|
||||
124
supabase/migrations/20240320000003_create_phonics_policies.sql
Normal file
124
supabase/migrations/20240320000003_create_phonics_policies.sql
Normal file
@ -0,0 +1,124 @@
|
||||
-- Enable RLS
|
||||
ALTER TABLE phonics_exercise_categories ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE phonics_exercise_types ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE phonics_exercises ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE phonics_words ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE phonics_exercise_words ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE media_types ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE phonics_exercise_media ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE student_phonics_progress ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE student_phonics_attempts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE student_phonics_attempt_answers ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE achievement_types ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE phonics_achievements ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE student_phonics_achievements ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Políticas para categorias
|
||||
CREATE POLICY "Categorias visíveis para usuários autenticados"
|
||||
ON phonics_exercise_categories FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- Políticas para tipos de exercícios
|
||||
CREATE POLICY "Tipos de exercícios visíveis para usuários autenticados"
|
||||
ON phonics_exercise_types FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- Políticas para exercícios
|
||||
CREATE POLICY "Exercícios visíveis para usuários autenticados"
|
||||
ON phonics_exercises FOR SELECT
|
||||
TO authenticated
|
||||
USING (is_active = true);
|
||||
|
||||
-- Políticas para palavras
|
||||
CREATE POLICY "Palavras visíveis para usuários autenticados"
|
||||
ON phonics_words FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- Políticas para relação exercício-palavras
|
||||
CREATE POLICY "Relações exercício-palavras visíveis para usuários autenticados"
|
||||
ON phonics_exercise_words FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- Políticas para tipos de mídia
|
||||
CREATE POLICY "Tipos de mídia visíveis para usuários autenticados"
|
||||
ON media_types FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- Políticas para mídia dos exercícios
|
||||
CREATE POLICY "Mídia dos exercícios visível para usuários autenticados"
|
||||
ON phonics_exercise_media FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- Políticas para progresso do estudante
|
||||
CREATE POLICY "Progresso visível apenas para o próprio estudante"
|
||||
ON student_phonics_progress FOR SELECT
|
||||
TO authenticated
|
||||
USING (auth.uid() = student_id);
|
||||
|
||||
CREATE POLICY "Progresso pode ser inserido pelo próprio estudante"
|
||||
ON student_phonics_progress FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (auth.uid() = student_id);
|
||||
|
||||
CREATE POLICY "Progresso pode ser atualizado pelo próprio estudante"
|
||||
ON student_phonics_progress FOR UPDATE
|
||||
TO authenticated
|
||||
USING (auth.uid() = student_id)
|
||||
WITH CHECK (auth.uid() = student_id);
|
||||
|
||||
-- Políticas para tentativas
|
||||
CREATE POLICY "Tentativas visíveis apenas para o próprio estudante"
|
||||
ON student_phonics_attempts FOR SELECT
|
||||
TO authenticated
|
||||
USING (auth.uid() = student_id);
|
||||
|
||||
CREATE POLICY "Tentativas podem ser inseridas pelo próprio estudante"
|
||||
ON student_phonics_attempts FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (auth.uid() = student_id);
|
||||
|
||||
-- Políticas para respostas das tentativas
|
||||
CREATE POLICY "Respostas visíveis apenas para o próprio estudante"
|
||||
ON student_phonics_attempt_answers FOR SELECT
|
||||
TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM student_phonics_attempts
|
||||
WHERE id = attempt_id AND student_id = auth.uid()
|
||||
));
|
||||
|
||||
CREATE POLICY "Respostas podem ser inseridas pelo próprio estudante"
|
||||
ON student_phonics_attempt_answers FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM student_phonics_attempts
|
||||
WHERE id = attempt_id AND student_id = auth.uid()
|
||||
));
|
||||
|
||||
-- Políticas para tipos de conquistas
|
||||
CREATE POLICY "Tipos de conquistas visíveis para usuários autenticados"
|
||||
ON achievement_types FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- Políticas para conquistas
|
||||
CREATE POLICY "Conquistas visíveis para usuários autenticados"
|
||||
ON phonics_achievements FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- Políticas para conquistas do estudante
|
||||
CREATE POLICY "Conquistas do estudante visíveis apenas para o próprio estudante"
|
||||
ON student_phonics_achievements FOR SELECT
|
||||
TO authenticated
|
||||
USING (auth.uid() = student_id);
|
||||
|
||||
CREATE POLICY "Conquistas podem ser inseridas pelo próprio estudante"
|
||||
ON student_phonics_achievements FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (auth.uid() = student_id);
|
||||
175
supabase/migrations/20240320000004_recreate_phonics_tables.sql
Normal file
175
supabase/migrations/20240320000004_recreate_phonics_tables.sql
Normal file
@ -0,0 +1,175 @@
|
||||
-- Primeiro, dropar todas as tabelas na ordem correta (inversa das dependências)
|
||||
DROP TABLE IF EXISTS student_phonics_achievements;
|
||||
DROP TABLE IF EXISTS phonics_achievements;
|
||||
DROP TABLE IF EXISTS achievement_types;
|
||||
DROP TABLE IF EXISTS student_phonics_attempt_answers;
|
||||
DROP TABLE IF EXISTS student_phonics_attempts;
|
||||
DROP TABLE IF EXISTS student_phonics_progress;
|
||||
DROP TABLE IF EXISTS phonics_exercise_media;
|
||||
DROP TABLE IF EXISTS media_types;
|
||||
DROP TABLE IF EXISTS phonics_exercise_words;
|
||||
DROP TABLE IF EXISTS phonics_words;
|
||||
DROP TABLE IF EXISTS phonics_exercises;
|
||||
DROP TABLE IF EXISTS phonics_exercise_types;
|
||||
DROP TABLE IF EXISTS phonics_exercise_categories;
|
||||
|
||||
-- Dropar os índices (não é necessário pois eles são dropados junto com as tabelas)
|
||||
-- DROP INDEX IF EXISTS idx_exercises_category;
|
||||
-- DROP INDEX IF EXISTS idx_exercises_type;
|
||||
-- DROP INDEX IF EXISTS idx_exercise_words;
|
||||
-- DROP INDEX IF EXISTS idx_student_progress;
|
||||
-- DROP INDEX IF EXISTS idx_student_attempts;
|
||||
-- DROP INDEX IF EXISTS idx_attempt_answers;
|
||||
-- DROP INDEX IF EXISTS idx_student_achievements;
|
||||
|
||||
-- Recriar as tabelas na ordem correta
|
||||
|
||||
-- Tabela de categorias de exercícios
|
||||
CREATE TABLE phonics_exercise_categories (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
level INTEGER NOT NULL,
|
||||
order_index INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tabela de tipos de exercícios
|
||||
CREATE TABLE phonics_exercise_types (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(50) NOT NULL, -- (rima, aliteração, segmentação, etc)
|
||||
description TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tabela de exercícios
|
||||
CREATE TABLE phonics_exercises (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
category_id UUID REFERENCES phonics_exercise_categories(id),
|
||||
type_id UUID REFERENCES phonics_exercise_types(id),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
difficulty_level INTEGER NOT NULL, -- (1-5)
|
||||
estimated_time_seconds INTEGER,
|
||||
instructions TEXT NOT NULL,
|
||||
points INTEGER DEFAULT 10,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
required_score FLOAT DEFAULT 0.7, -- Pontuação mínima para passar (70%)
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tabela de palavras
|
||||
CREATE TABLE phonics_words (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
word VARCHAR(255) NOT NULL,
|
||||
phonetic_transcription VARCHAR(255),
|
||||
syllables_count INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tabela de relação exercício-palavras
|
||||
CREATE TABLE phonics_exercise_words (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
exercise_id UUID REFERENCES phonics_exercises(id),
|
||||
word_id UUID REFERENCES phonics_words(id),
|
||||
is_correct_answer BOOLEAN DEFAULT false,
|
||||
order_index INTEGER,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(exercise_id, word_id)
|
||||
);
|
||||
|
||||
-- Tabela de tipos de mídia
|
||||
CREATE TABLE media_types (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(50) NOT NULL, -- (image, sound, animation)
|
||||
description TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tabela de recursos de mídia
|
||||
CREATE TABLE phonics_exercise_media (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
exercise_id UUID REFERENCES phonics_exercises(id),
|
||||
media_type_id UUID REFERENCES media_types(id),
|
||||
url TEXT NOT NULL,
|
||||
alt_text TEXT,
|
||||
order_index INTEGER,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tabela de progresso do estudante
|
||||
CREATE TABLE student_phonics_progress (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
student_id UUID REFERENCES auth.users(id),
|
||||
exercise_id UUID REFERENCES phonics_exercises(id),
|
||||
attempts INTEGER DEFAULT 0,
|
||||
best_score FLOAT DEFAULT 0,
|
||||
last_score FLOAT DEFAULT 0,
|
||||
completed BOOLEAN DEFAULT false,
|
||||
completed_at TIMESTAMP WITH TIME ZONE,
|
||||
stars INTEGER DEFAULT 0, -- (1-3 estrelas)
|
||||
xp_earned INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(student_id, exercise_id)
|
||||
);
|
||||
|
||||
-- Tabela de tentativas
|
||||
CREATE TABLE student_phonics_attempts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
student_id UUID REFERENCES auth.users(id),
|
||||
exercise_id UUID REFERENCES phonics_exercises(id),
|
||||
score FLOAT NOT NULL,
|
||||
time_spent_seconds INTEGER,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tabela de respostas das tentativas
|
||||
CREATE TABLE student_phonics_attempt_answers (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
attempt_id UUID REFERENCES student_phonics_attempts(id),
|
||||
word_id UUID REFERENCES phonics_words(id),
|
||||
is_correct BOOLEAN NOT NULL,
|
||||
answer_text TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tabela de tipos de conquistas
|
||||
CREATE TABLE achievement_types (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(50) NOT NULL, -- (streak, completion, mastery)
|
||||
description TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tabela de conquistas
|
||||
CREATE TABLE phonics_achievements (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
type_id UUID REFERENCES achievement_types(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
points INTEGER DEFAULT 0,
|
||||
icon_url TEXT,
|
||||
required_count INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tabela de conquistas do estudante
|
||||
CREATE TABLE student_phonics_achievements (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
student_id UUID REFERENCES auth.users(id),
|
||||
achievement_id UUID REFERENCES phonics_achievements(id),
|
||||
earned_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(student_id, achievement_id)
|
||||
);
|
||||
|
||||
-- Recriar os índices
|
||||
CREATE INDEX idx_exercises_category ON phonics_exercises(category_id);
|
||||
CREATE INDEX idx_exercises_type ON phonics_exercises(type_id);
|
||||
CREATE INDEX idx_exercise_words ON phonics_exercise_words(exercise_id, word_id);
|
||||
CREATE INDEX idx_student_progress ON student_phonics_progress(student_id, exercise_id);
|
||||
CREATE INDEX idx_student_attempts ON student_phonics_attempts(student_id, exercise_id);
|
||||
CREATE INDEX idx_attempt_answers ON student_phonics_attempt_answers(attempt_id);
|
||||
CREATE INDEX idx_student_achievements ON student_phonics_achievements(student_id);
|
||||
Loading…
Reference in New Issue
Block a user