diff --git a/CHANGELOG.md b/CHANGELOG.md index d664e6b..41a7927 100644 --- a/CHANGELOG.md +++ b/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) diff --git a/package-lock.json b/package-lock.json index 9f178b4..d749641 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7a09e34..94ed3f6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/phonics/AudioPlayer.tsx b/src/components/phonics/AudioPlayer.tsx new file mode 100644 index 0000000..09efbf7 --- /dev/null +++ b/src/components/phonics/AudioPlayer.tsx @@ -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(null); + const audioRef = useRef(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 ( +
+ + + {error && ( +

+ {error} +

+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/phonics/CategoryTabs.tsx b/src/components/phonics/CategoryTabs.tsx new file mode 100644 index 0000000..096e35f --- /dev/null +++ b/src/components/phonics/CategoryTabs.tsx @@ -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 ; + } + + if (!categories?.length) { + return null; + } + + return ( + onSelectCategory(value === "all" ? "" : value)} + className="w-full" + > + + + Todos + + {categories.map((category) => ( + + {category.name} + + ))} + + + ); +} \ No newline at end of file diff --git a/src/components/phonics/ExerciseCard.tsx b/src/components/phonics/ExerciseCard.tsx new file mode 100644 index 0000000..143ce9a --- /dev/null +++ b/src/components/phonics/ExerciseCard.tsx @@ -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 ( + + +
+
+ {exercise.title} + {exercise.description} +
+ + {isCompleted ? "Completo" : "Pendente"} + +
+
+ +
+
+
+ + {Math.ceil(exercise.estimatedTimeSeconds / 60)} min +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ + {progress && ( +
+
+ Progresso + {Math.round(progressValue)}% +
+ +
+ )} + + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/phonics/ExerciseGrid.tsx b/src/components/phonics/ExerciseGrid.tsx new file mode 100644 index 0000000..6c16d70 --- /dev/null +++ b/src/components/phonics/ExerciseGrid.tsx @@ -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 ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ); + } + + if (!exercises?.length) { + return ( +
+ Nenhum exercício encontrado nesta categoria. +
+ ); + } + + return ( +
+ {exercises.map((exercise) => ( + p.exerciseId === exercise.id)} + onStart={onSelectExercise} + /> + ))} +
+ ); +} \ No newline at end of file diff --git a/src/components/phonics/ExercisePlayer.tsx b/src/components/phonics/ExercisePlayer.tsx new file mode 100644 index 0000000..a215564 --- /dev/null +++ b/src/components/phonics/ExercisePlayer.tsx @@ -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([]); + const [mistakes, setMistakes] = useState([]); + const [showFeedback, setShowFeedback] = useState(false); + const [lastAnswerCorrect, setLastAnswerCorrect] = useState(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 ( + + +
+ Carregando exercício... +
+
+
+ ); + } + + const progress = ((currentStep + 1) / exerciseWords.length) * 100; + const currentWord = exerciseWords[currentStep].word as unknown as PhonicsWord; + + return ( + + +
+ {exercise.title} +
+
+ + {Math.floor(timeSpent / 60)}:{(timeSpent % 60).toString().padStart(2, '0')} +
+ +
+
+ +
+ + +
+
+ Exercício {currentStep + 1} de {exerciseWords.length} +
+ + + + {showFeedback && ( +
+ {lastAnswerCorrect ? "Muito bem!" : "Tente novamente na próxima!"} +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/phonics/exercises/AlliterationExercise.tsx b/src/components/phonics/exercises/AlliterationExercise.tsx new file mode 100644 index 0000000..06a4175 --- /dev/null +++ b/src/components/phonics/exercises/AlliterationExercise.tsx @@ -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 ( +
+ + +
+
+ Qual palavra começa com o mesmo som que {currentWord.word}? +
+ +
+ {options.map((option) => ( + + ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/phonics/exercises/BaseExercise.tsx b/src/components/phonics/exercises/BaseExercise.tsx new file mode 100644 index 0000000..3ec2e41 --- /dev/null +++ b/src/components/phonics/exercises/BaseExercise.tsx @@ -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 ( +
+
+ + +
+ {currentWord.word} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/phonics/exercises/ExerciseFactory.tsx b/src/components/phonics/exercises/ExerciseFactory.tsx new file mode 100644 index 0000000..420dfaf --- /dev/null +++ b/src/components/phonics/exercises/ExerciseFactory.tsx @@ -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 ( + + ); + + case 'alliteration': + return ( + + ); + + case 'syllables': + return ( + + ); + + case 'initial_sound': + return ( + + ); + + case 'final_sound': + return ( + + ); + + default: + return ( +
+ Tipo de exercício não implementado: {type} +
+ ); + } +} \ No newline at end of file diff --git a/src/components/phonics/exercises/RhymeExercise.tsx b/src/components/phonics/exercises/RhymeExercise.tsx new file mode 100644 index 0000000..d0ed69d --- /dev/null +++ b/src/components/phonics/exercises/RhymeExercise.tsx @@ -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 ( +
+ + +
+
+ Qual palavra rima com {currentWord.word}? +
+ +
+ {options.map((option) => ( + + ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/phonics/exercises/SoundMatchExercise.tsx b/src/components/phonics/exercises/SoundMatchExercise.tsx new file mode 100644 index 0000000..8e6728b --- /dev/null +++ b/src/components/phonics/exercises/SoundMatchExercise.tsx @@ -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 ( +
+ + +
+
+ {instruction} +
+ +
+ {options.map((option) => ( + + ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/phonics/exercises/SyllablesExercise.tsx b/src/components/phonics/exercises/SyllablesExercise.tsx new file mode 100644 index 0000000..b05d85c --- /dev/null +++ b/src/components/phonics/exercises/SyllablesExercise.tsx @@ -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([]); + + 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 ( +
+ + +
+
+ Selecione as sílabas na ordem correta +
+ +
+ {syllables.map((syllable, index) => ( + + ))} +
+ + {selectedSyllables.length > 0 && !disabled && ( +
+ +
+ )} + + {selectedSyllables.length > 0 && ( +
+ {syllables.filter((_, i) => selectedSyllables.includes(i)).join("-")} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index e857589..e855d73 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -1,16 +1,79 @@ -import React from 'react'; +import * as React from "react" -interface CardProps extends React.HTMLAttributes { - children: React.ReactNode; -} +import { cn } from "@/lib/utils" -export function Card({ className = '', children, ...props }: CardProps): JSX.Element { - return ( -
- {children} -
- ); -} \ No newline at end of file +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } \ No newline at end of file diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..7f96e7b --- /dev/null +++ b/src/components/ui/progress.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } \ No newline at end of file diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..f7de1db --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } \ No newline at end of file diff --git a/src/hooks/phonics/useExerciseAttempt.ts b/src/hooks/phonics/useExerciseAttempt.ts new file mode 100644 index 0000000..c875f95 --- /dev/null +++ b/src/hooks/phonics/useExerciseAttempt.ts @@ -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) => { + 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; + } + }); +} \ No newline at end of file diff --git a/src/hooks/phonics/usePhonicsExercises.ts b/src/hooks/phonics/usePhonicsExercises.ts new file mode 100644 index 0000000..f03c72d --- /dev/null +++ b/src/hooks/phonics/usePhonicsExercises.ts @@ -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[]; + } + }); +} \ No newline at end of file diff --git a/src/hooks/phonics/usePhonicsProgress.ts b/src/hooks/phonics/usePhonicsProgress.ts new file mode 100644 index 0000000..2101dc7 --- /dev/null +++ b/src/hooks/phonics/usePhonicsProgress.ts @@ -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] }); + } + }); +} \ No newline at end of file diff --git a/src/pages/student-dashboard/PhonicsPage.tsx b/src/pages/student-dashboard/PhonicsPage.tsx new file mode 100644 index 0000000..a00f205 --- /dev/null +++ b/src/pages/student-dashboard/PhonicsPage.tsx @@ -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(); + const [selectedExercise, setSelectedExercise] = useState(); + 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 ( + setSelectedExercise(undefined)} + /> + ); + } + + return ( +
+
+

Exercícios Fônicos

+

+ Pratique seus conhecimentos com exercícios interativos +

+
+ + + + +
+ ); +} \ No newline at end of file diff --git a/src/pages/student-dashboard/PhonicsProgressPage.tsx b/src/pages/student-dashboard/PhonicsProgressPage.tsx new file mode 100644 index 0000000..8df42a6 --- /dev/null +++ b/src/pages/student-dashboard/PhonicsProgressPage.tsx @@ -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 ( +
+
+

Seu Progresso

+

+ Acompanhe seu desenvolvimento nos exercícios fônicos +

+
+ +
+ + + Exercícios Completados + + +
+
+ {completedExercises} / {totalExercises} +
+ +
+
+
+ + + + Estrelas Conquistadas + + +
+ + {totalStars} +
+
+
+ + + + XP Total + + +
+ + {totalXP} +
+
+
+
+ + + + Histórico de Exercícios + + +
+ {exercises.map((exercise) => { + const exerciseProgress = progress.find(p => p.exerciseId === exercise.id); + const progressValue = exerciseProgress ? exerciseProgress.bestScore * 100 : 0; + + return ( +
+
+
{exercise.title}
+
+ {exerciseProgress?.completed ? "Completo" : "Pendente"} +
+
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ +
+ +
+
+ ); + })} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/pages/student-dashboard/StudentDashboardLayout.tsx b/src/pages/student-dashboard/StudentDashboardLayout.tsx index a24c482..9b51484 100644 --- a/src/pages/student-dashboard/StudentDashboardLayout.tsx +++ b/src/pages/student-dashboard/StudentDashboardLayout.tsx @@ -94,6 +94,36 @@ export function StudentDashboardLayout() { {!isCollapsed && Histórico} + + `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' + }` + } + > + + {!isCollapsed && Exercícios Fônicos} + + + + `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' + }` + } + > + + {!isCollapsed && Progresso Fônico} + + , + }, + { + path: 'fonicos', + element: , + }, + { + path: 'fonicos/progresso', + element: , } ] }, diff --git a/src/types/phonics.ts b/src/types/phonics.ts new file mode 100644 index 0000000..b8001b1 --- /dev/null +++ b/src/types/phonics.ts @@ -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; +} \ No newline at end of file diff --git a/supabase/functions/generate-word-audio/index.ts b/supabase/functions/generate-word-audio/index.ts new file mode 100644 index 0000000..6418fc2 --- /dev/null +++ b/supabase/functions/generate-word-audio/index.ts @@ -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' } + } + ); + } +}); \ No newline at end of file diff --git a/supabase/migrations/20240320000002_populate_phonics_tables.sql b/supabase/migrations/20240320000002_populate_phonics_tables.sql new file mode 100644 index 0000000..ff3e442 --- /dev/null +++ b/supabase/migrations/20240320000002_populate_phonics_tables.sql @@ -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 + ); \ No newline at end of file diff --git a/supabase/migrations/20240320000003_create_phonics_policies.sql b/supabase/migrations/20240320000003_create_phonics_policies.sql new file mode 100644 index 0000000..08fcb0a --- /dev/null +++ b/supabase/migrations/20240320000003_create_phonics_policies.sql @@ -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); \ No newline at end of file diff --git a/supabase/migrations/20240320000004_recreate_phonics_tables.sql b/supabase/migrations/20240320000004_recreate_phonics_tables.sql new file mode 100644 index 0000000..274ae26 --- /dev/null +++ b/supabase/migrations/20240320000004_recreate_phonics_tables.sql @@ -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); \ No newline at end of file