feat: implementa sistema de exercícios f��nicos
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:
Lucas Santana 2025-01-17 20:59:50 -03:00
parent e1a99f32f5
commit 350a66bb9e
29 changed files with 1911 additions and 28 deletions

View File

@ -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
View File

@ -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",

View File

@ -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",

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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 }

View 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 }

View 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 }

View 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;
}
});
}

View 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[];
}
});
}

View 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] });
}
});
}

View 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>
);
}

View 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>
);
}

View File

@ -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}

View File

@ -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
View 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;
}

View 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' }
}
);
}
});

View 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
);

View 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);

View 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);