Product Requirements Document (PRD)
TutorConnect UC - Plataforma P2P de Tutorías
Versión: 1.0 Fecha: Diciembre 2024 Autor: TutorConnect Team Stack: Next.js 15 + Supabase + TypeScript + Gemini AI
📋 Tabla de Contenidos
- Resumen Ejecutivo
- Problema y Oportunidad
- Objetivos del Producto
- Usuarios y Stakeholders
- Arquitectura Técnica
- Especificaciones Funcionales
- Modelo de Datos
- Seguridad y Autenticación
- Integración con Gemini AI
- Plan de Implementación
- Métricas de Éxito
1. Resumen Ejecutivo
1.1 Descripción del Producto
TutorConnect UC es una plataforma web peer-to-peer que conecta estudiantes de la Pontificia Universidad Católica de Chile que necesitan apoyo académico con tutores verificados de la misma institución. La plataforma garantiza verificación institucional mediante correos @uc.cl, utiliza códigos de ramos reales del catálogo UC, e implementa recomendaciones inteligentes potenciadas por Gemini AI de Google.
1.2 Propuesta de Valor
Para Estudiantes:
- Acceso a tutores verificados UC con notas de aprobación transparentes
- Búsqueda por código de ramo exacto (IIC2233, MAT1610, etc.)
- Recomendaciones inteligentes basadas en IA
- Sistema de reviews y reputación confiable
- Coordinación simplificada de sesiones presenciales u online
Para Tutores:
- Monetización de conocimiento académico
- Plataforma profesional para construir reputación
- Gestión eficiente de disponibilidad y sesiones
- Alcance a estudiantes que necesitan ayuda específica
1.3 Diferenciadores Clave
- Verificación institucional: Solo correos @uc.cl
- Catálogo real: Base de datos completa de ramos UC
- Transparencia: Notas de aprobación visibles
- IA integrada: Matching inteligente con Gemini
- Marketplace dual: Tutores ofrecen + Estudiantes buscan
2. Problema y Oportunidad
2.1 Problemas Identificados
| Problema | Impacto | Frecuencia |
|---|---|---|
| Ramos con alta tasa de reprobación | Retraso académico, deserción | Muy Alta |
| Falta de tutores verificados | Desconfianza, malas experiencias | Alta |
| Descoordinación logística | Pérdida de tiempo, frustración | Alta |
| Información opaca sobre tutores | Decisiones mal informadas | Media |
| Talento estudiantil sin aprovechar | Pérdida de oportunidades económicas | Media |
2.2 Oportunidad de Mercado
- Mercado objetivo: ~20,000 estudiantes UC activos
- Ramos con mayor demanda: Programación, Matemáticas, Física, Química
- Disposición a pagar: 25,000 CLP por sesión (módulo UC)
- Frecuencia estimada: 2-4 sesiones por mes durante semestre
2.3 Competencia
Alternativas actuales:
- WhatsApp/Instagram (informal, no verificado)
- Grupos de Facebook (desorganizado)
- Plataformas genéricas (no específicas UC)
Ventaja competitiva de TutorConnect UC:
- Única plataforma diseñada específicamente para UC
- Verificación institucional automática
- Integración con catálogo oficial de ramos
- Sistema de reputación transparente
3. Objetivos del Producto
3.1 Objetivos de Negocio
-
Validación MVP (Fase Hackathon)
- Demostrar viabilidad técnica
- Obtener primeras 20+ sesiones agendadas
- Lograr 50+ usuarios registrados
-
Crecimiento (Post-Hackathon)
- Alcanzar 500+ usuarios en primer semestre
- Facilitar 200+ tutorías mensuales
- Lograr 80%+ tasa de satisfacción
-
Sostenibilidad (6-12 meses)
- Implementar modelo de comisiones (10-12%)
- Alcanzar punto de equilibrio operacional
- Expandir a otras universidades chilenas
3.2 Objetivos de Usuario
Estudiantes:
- Encontrar tutor calificado en < 5 minutos
- Agendar sesión en < 3 clics
- Tener claridad de costo antes de contratar
- Acceder a reviews verificadas
Tutores:
- Crear perfil completo en < 10 minutos
- Recibir solicitudes de tutorías cualificadas
- Gestionar calendario fácilmente
- Construir reputación medible
4. Usuarios y Stakeholders
4.1 Perfiles de Usuario
4.1.1 Estudiante (Buscador de Tutorías)
Demografía:
- Edad: 18-25 años
- Nivel: Pregrado UC (todos los años)
- Familiaridad tecnológica: Alta
Necesidades:
- Apoyo urgente antes de pruebas/exámenes
- Explicaciones complementarias a clases
- Resolución de dudas específicas
- Preparación de tareas/proyectos
Comportamientos:
- Busca rápidamente cuando enfrenta dificultad
- Valora recomendaciones de pares
- Prefiere tutores con buenas notas
- Disponibilidad flexible (campus o online)
Puntos de dolor:
- No sabe dónde encontrar tutores confiables
- Miedo a perder tiempo/dinero
- Dificultad para coordinar horarios
- Información insuficiente sobre tutores
4.1.2 Tutor (Ofertante de Tutorías)
Demografía:
- Edad: 20-26 años
- Nivel: Pregrado avanzado, egresados, postgrado
- Familiaridad tecnológica: Alta
Motivaciones:
- Generar ingresos complementarios
- Reforzar conocimientos enseñando
- Construir experiencia docente
- Ayudar a comunidad estudiantil
Necesidades:
- Plataforma profesional para ofrecer servicios
- Gestión eficiente de calendario
- Visibilidad ante estudiantes
- Sistema de pago confiable (futuro)
Puntos de dolor:
- Falta de demanda organizada
- Dificultad para establecer reputación
- Coordinación manual tediosa
- No hay forma de destacar credenciales
4.2 Stakeholders Secundarios
- Universidad Católica: Interés en éxito académico estudiantil
- Facultades: Reducción de tasas de reprobación
- Centros de estudiantes: Apoyo a comunidad
- Administración UC: Potencial integración institucional
5. Arquitectura Técnica
5.1 Stack Tecnológico
5.1.1 Frontend
Framework: Next.js 15 (App Router)
Lenguaje: TypeScript 5.3+
UI Library: React 18.2+
Estilos: Tailwind CSS 3.4+
Componentes: shadcn/ui
Validación: Zod + React Hook Form
Fechas: date-fns
Deploy: Vercel5.1.2 Backend (BaaS)
Plataforma: Supabase
Base de Datos: PostgreSQL 15+
Autenticación: Supabase Auth
Storage: Supabase Storage
Realtime: Supabase Realtime (WebSockets)
Edge Functions: Deno runtime5.1.3 IA y APIs Externas
IA: Google Gemini API (gemini-pro)
Uso: Matching inteligente tutor-estudiante
Deploy: Supabase Edge Functions5.2 Arquitectura del Sistema
┌─────────────────────────────────────────────────────────────┐
│ USUARIO (Navegador) │
└───────────────────────────┬─────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ NEXT.JS FRONTEND (Vercel) │
│ • Server Components (RSC) │
│ • Client Components (Interactividad) │
│ • API Routes (Middleware) │
└───────────────────────────┬─────────────────────────────────┘
│
│ Supabase Client
│ (@supabase/supabase-js)
│
▼
┌─────────────────────────────────────────────────────────────┐
│ SUPABASE (Backend) │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ PostgreSQL │ │ Auth │ │ Storage │ │
│ │ + RLS │ │ (GoTrue) │ │ (Fotos) │ │
│ └─────────────┘ └──────────────┘ └──────────────┘ │
│ ┌─────────────┐ ┌──────────────┐ │
│ │ Realtime │ │ Edge Funcs │ │
│ │ (WebSocket) │ │ (Deno) │ ──► Gemini API │
│ └─────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘5.3 Configuración de Supabase Client
5.3.1 Cliente para Server Components
// lib/supabase/server.ts
import { createServerClient as createClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createServerClient() {
const cookieStore = await cookies()
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Server Component - cookies() can't be modified
}
},
},
}
)
}5.3.2 Cliente para Client Components
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}5.3.3 Middleware para Autenticación
// middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
const {
data: { session },
} = await supabase.auth.getSession()
// Proteger rutas de tutor y estudiante
if (!session && request.nextUrl.pathname.startsWith('/tutor')) {
return NextResponse.redirect(new URL('/auth/login', request.url))
}
if (!session && request.nextUrl.pathname.startsWith('/mis-tutorias')) {
return NextResponse.redirect(new URL('/auth/login', request.url))
}
return supabaseResponse
}
export const config = {
matcher: [
'/tutor/:path*',
'/mis-tutorias/:path*',
'/agendar/:path*',
],
}5.4 Variables de Entorno
# .env.local
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://[project-ref].supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... # Solo servidor
# Gemini AI
GEMINI_API_KEY=AIzaSy... # Solo Edge Functions
# App
NEXT_PUBLIC_APP_URL=http://localhost:30006. Especificaciones Funcionales
6.1 Autenticación y Onboarding
6.1.1 Registro de Usuario
Flujo:
- Usuario accede a
/auth/registro - Ingresa email (debe ser @uc.cl), contraseña y nombre completo
- Sistema valida formato de email
- Supabase Auth crea usuario y envía email de verificación
- Usuario confirma email
- Trigger automático crea registro en tabla
profiles - Usuario es redirigido a
/onboardingpara seleccionar rol
Validaciones:
- Email debe terminar en
@uc.cl - Contraseña mínimo 8 caracteres
- Nombre completo requerido
Código de Implementación:
// app/auth/registro/page.tsx
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
export default function RegistroPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [fullName, setFullName] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
const supabase = createClient()
async function handleRegistro(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
// Validar email UC
if (!email.endsWith('@uc.cl')) {
setError('Debes usar tu email institucional @uc.cl')
setLoading(false)
return
}
// Registrar usuario
const { data, error: signUpError } = await supabase.auth.signUp({
email,
password,
options: {
data: {
full_name: fullName,
},
emailRedirectTo: `${window.location.origin}/auth/callback`,
},
})
if (signUpError) {
setError(signUpError.message)
setLoading(false)
return
}
// Mostrar mensaje de verificación
alert('¡Registro exitoso! Revisa tu email @uc.cl para verificar tu cuenta.')
router.push('/auth/verificar-email')
}
return (
<div className="container max-w-md mx-auto py-20">
<h1 className="text-3xl font-bold mb-8">Crear Cuenta</h1>
<form onSubmit={handleRegistro} className="space-y-4">
<div>
<Label htmlFor="fullName">Nombre Completo</Label>
<Input
id="fullName"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="email">Email Institucional</Label>
<Input
id="email"
type="email"
placeholder="tu.email@uc.cl"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="password">Contraseña</Label>
<Input
id="password"
type="password"
placeholder="Mínimo 8 caracteres"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={8}
required
/>
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Creando cuenta...' : 'Crear Cuenta'}
</Button>
</form>
</div>
)
}6.1.2 Inicio de Sesión
// app/auth/login/page.tsx
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const router = useRouter()
const supabase = createClient()
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setError('')
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
setError(error.message)
} else {
router.push('/')
router.refresh()
}
}
return (
<div className="container max-w-md mx-auto py-20">
<h1 className="text-3xl font-bold mb-8">Iniciar Sesión</h1>
<form onSubmit={handleLogin} className="space-y-4">
<Input
type="email"
placeholder="tu.email@uc.cl"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Input
type="password"
placeholder="Contraseña"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <p className="text-red-500 text-sm">{error}</p>}
<Button type="submit" className="w-full">
Iniciar Sesión
</Button>
</form>
</div>
)
}6.2 Búsqueda y Descubrimiento de Tutores
6.2.1 Página de Búsqueda
Funcionalidades:
- Búsqueda por código de ramo (IIC2233, MAT1610)
- Filtros: campus, rango de precio, rating
- Ordenamiento: mejor valorados, precio, disponibilidad
- Paginación de resultados
Implementación con Server Components:
// app/buscar/page.tsx
import { createServerClient } from '@/lib/supabase/server'
import SearchFilters from '@/components/search-filters'
import TutorCard from '@/components/tutor-card'
import { Suspense } from 'react'
type SearchParams = {
q?: string
campus?: string
min_price?: string
max_price?: string
page?: string
}
export default async function BuscarPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const params = await searchParams
const supabase = await createServerClient()
// Construir query base
let query = supabase
.from('tutores')
.select(`
*,
profiles!inner(full_name, avatar_url),
tutor_ramo!inner(
ramos(codigo, nombre),
nota
),
reviews(rating)
`)
// Aplicar filtros
if (params.q) {
// Buscar por código de ramo
query = query.contains('tutor_ramo.ramos.codigo', [params.q.toUpperCase()])
}
if (params.campus) {
query = query.eq('campus', params.campus)
}
if (params.min_price) {
query = query.gte('tarifa', parseInt(params.min_price))
}
if (params.max_price) {
query = query.lte('tarifa', parseInt(params.max_price))
}
const { data: tutores, error } = await query
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-8">Buscar Tutores</h1>
<div className="grid md:grid-cols-4 gap-6">
<aside className="md:col-span-1">
<SearchFilters />
</aside>
<main className="md:col-span-3">
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{tutores?.map((tutor) => (
<TutorCard key={tutor.id} tutor={tutor} />
))}
</div>
{tutores?.length === 0 && (
<p className="text-center text-gray-500 py-12">
No se encontraron tutores con esos filtros
</p>
)}
</main>
</div>
</div>
)
}6.3 Perfil de Tutor
6.3.1 Vista Pública del Perfil
// app/tutor/[id]/page.tsx
import { createServerClient } from '@/lib/supabase/server'
import { notFound } from 'next/navigation'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
export default async function TutorProfilePage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const supabase = await createServerClient()
// Obtener datos del tutor
const { data: tutor, error } = await supabase
.from('tutores')
.select(`
*,
profiles!inner(full_name, avatar_url, email),
tutor_ramo(
*,
ramos(codigo, nombre)
),
reviews(
*,
profiles!reviews_student_id_fkey(full_name)
)
`)
.eq('id', id)
.single()
if (error || !tutor) {
notFound()
}
// Calcular rating promedio
const avgRating = tutor.reviews.length > 0
? (tutor.reviews.reduce((sum, r) => sum + r.rating, 0) / tutor.reviews.length).toFixed(1)
: 'N/A'
return (
<div className="container max-w-4xl mx-auto py-8">
{/* Header */}
<div className="flex gap-6 mb-8 items-start">
<Avatar className="h-24 w-24">
<AvatarImage src={tutor.profiles.avatar_url || undefined} />
<AvatarFallback>{tutor.profiles.full_name[0]}</AvatarFallback>
</Avatar>
<div className="flex-1">
<h1 className="text-3xl font-bold">{tutor.profiles.full_name}</h1>
<p className="text-gray-600 mt-1">{tutor.carrera}</p>
<div className="flex items-center gap-4 mt-2 text-sm text-gray-600">
<span>⭐ {avgRating}</span>
<span>•</span>
<span>{tutor.reviews.length} reviews</span>
<span>•</span>
<Badge variant="secondary">{tutor.campus}</Badge>
</div>
</div>
<div className="text-right">
<p className="text-3xl font-bold text-primary">
${tutor.tarifa.toLocaleString()}
</p>
<p className="text-sm text-gray-600">por módulo (80 min)</p>
<Button asChild className="mt-4">
<Link href={`/agendar/${tutor.id}`}>Agendar Tutoría</Link>
</Button>
</div>
</div>
{/* Tabs */}
<Tabs defaultValue="sobre-mi">
<TabsList>
<TabsTrigger value="sobre-mi">Sobre mí</TabsTrigger>
<TabsTrigger value="ramos">
Ramos ({tutor.tutor_ramo.length})
</TabsTrigger>
<TabsTrigger value="reviews">
Reviews ({tutor.reviews.length})
</TabsTrigger>
</TabsList>
<TabsContent value="sobre-mi" className="mt-6">
<div className="prose max-w-none">
<p className="whitespace-pre-line">{tutor.bio}</p>
</div>
<div className="mt-6 grid grid-cols-2 gap-4">
<div className="border p-4 rounded-lg">
<h3 className="font-semibold mb-2">Año de Ingreso</h3>
<p className="text-2xl">{tutor.año_ingreso}</p>
</div>
<div className="border p-4 rounded-lg">
<h3 className="font-semibold mb-2">Campus Principal</h3>
<p className="text-2xl">{tutor.campus}</p>
</div>
</div>
</TabsContent>
<TabsContent value="ramos" className="mt-6">
<div className="grid gap-4">
{tutor.tutor_ramo.map((tr) => (
<div key={tr.id} className="border p-4 rounded-lg">
<div className="flex justify-between items-start">
<div>
<h3 className="font-bold text-lg">{tr.ramos.codigo}</h3>
<p className="text-gray-600">{tr.ramos.nombre}</p>
<p className="text-sm text-gray-500 mt-1">
Aprobado en {tr.semestre}
</p>
</div>
<Badge variant="default" className="text-lg">
Nota: {tr.nota}
</Badge>
</div>
</div>
))}
</div>
</TabsContent>
<TabsContent value="reviews" className="mt-6">
<div className="space-y-4">
{tutor.reviews.length === 0 ? (
<p className="text-center text-gray-500 py-8">
Aún no hay reviews para este tutor
</p>
) : (
tutor.reviews.map((review) => (
<div key={review.id} className="border p-4 rounded-lg">
<div className="flex justify-between mb-2">
<span className="font-semibold">
{review.profiles.full_name}
</span>
<span className="text-yellow-500">
{'⭐'.repeat(review.rating)}
</span>
</div>
{review.comment && (
<p className="text-gray-700">{review.comment}</p>
)}
<p className="text-xs text-gray-500 mt-2">
{new Date(review.created_at).toLocaleDateString('es-CL')}
</p>
</div>
))
)}
</div>
</TabsContent>
</Tabs>
</div>
)
}6.4 Agendamiento de Tutorías
6.4.1 Flujo de Agendamiento
// app/agendar/[tutorId]/page.tsx
import { createServerClient } from '@/lib/supabase/server'
import AgendarForm from '@/components/agendar-form'
import { redirect } from 'next/navigation'
export default async function AgendarPage({
params,
}: {
params: Promise<{ tutorId: string }>
}) {
const { tutorId } = await params
const supabase = await createServerClient()
// Verificar sesión
const {
data: { session },
} = await supabase.auth.getSession()
if (!session) {
redirect('/auth/login?redirect=/agendar/' + tutorId)
}
// Obtener info del tutor
const { data: tutor } = await supabase
.from('tutores')
.select(`
*,
profiles(full_name),
tutor_ramo(ramos(id, codigo, nombre))
`)
.eq('id', tutorId)
.single()
if (!tutor) {
redirect('/buscar')
}
return (
<div className="container max-w-2xl mx-auto py-8">
<h1 className="text-3xl font-bold mb-2">Agendar Tutoría</h1>
<p className="text-gray-600 mb-8">
Con {tutor.profiles.full_name}
</p>
<AgendarForm tutor={tutor} studentId={session.user.id} />
</div>
)
}// components/agendar-form.tsx
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
export default function AgendarForm({ tutor, studentId }: any) {
const [loading, setLoading] = useState(false)
const [ramoId, setRamoId] = useState('')
const [modalidad, setModalidad] = useState<'presencial' | 'online'>('presencial')
const [ubicacion, setUbicacion] = useState('')
const [fecha, setFecha] = useState('')
const [hora, setHora] = useState('')
const [duracion, setDuracion] = useState('1')
const [notas, setNotas] = useState('')
const router = useRouter()
const supabase = createClient()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
// Construir fecha/hora
const fechaHora = new Date(`${fecha}T${hora}:00`)
// Calcular precio total
const precioTotal = tutor.tarifa * parseFloat(duracion)
// Crear sesión
const { error } = await supabase
.from('tutoring_sessions')
.insert({
tutor_id: tutor.id,
student_id: studentId,
ramo_id: ramoId,
modalidad,
ubicacion,
fecha_hora: fechaHora.toISOString(),
duracion_modulos: parseFloat(duracion),
precio_total: precioTotal,
notas,
estado: tutor.auto_accept ? 'confirmada' : 'pendiente',
})
if (error) {
alert('Error al agendar: ' + error.message)
setLoading(false)
return
}
alert('¡Tutoría agendada exitosamente!')
router.push('/mis-tutorias')
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<Label>Ramo</Label>
<Select value={ramoId} onValueChange={setRamoId} required>
<SelectTrigger>
<SelectValue placeholder="Selecciona un ramo" />
</SelectTrigger>
<SelectContent>
{tutor.tutor_ramo.map((tr: any) => (
<SelectItem key={tr.ramos.id} value={tr.ramos.id}>
{tr.ramos.codigo} - {tr.ramos.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Modalidad</Label>
<Select
value={modalidad}
onValueChange={(v) => setModalidad(v as any)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="presencial">Presencial</SelectItem>
<SelectItem value="online">Online</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>
{modalidad === 'presencial' ? 'Ubicación' : 'Link de videollamada'}
</Label>
<Input
value={ubicacion}
onChange={(e) => setUbicacion(e.target.value)}
placeholder={
modalidad === 'presencial'
? 'Ej: Biblioteca, Sala 201'
: 'Ej: Google Meet, Zoom'
}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Fecha</Label>
<Input
type="date"
value={fecha}
onChange={(e) => setFecha(e.target.value)}
min={new Date().toISOString().split('T')[0]}
required
/>
</div>
<div>
<Label>Hora</Label>
<Input
type="time"
value={hora}
onChange={(e) => setHora(e.target.value)}
required
/>
</div>
</div>
<div>
<Label>Duración (módulos UC)</Label>
<Select value={duracion} onValueChange={setDuracion}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.5">0.5 módulos (40 min)</SelectItem>
<SelectItem value="1">1 módulo (80 min)</SelectItem>
<SelectItem value="1.5">1.5 módulos (120 min)</SelectItem>
<SelectItem value="2">2 módulos (160 min)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Notas adicionales (opcional)</Label>
<Textarea
value={notas}
onChange={(e) => setNotas(e.target.value)}
placeholder="Ej: Necesito ayuda con ejercicios de derivadas"
rows={3}
/>
</div>
<div className="border-t pt-4">
<div className="flex justify-between items-center mb-4">
<span className="text-lg font-semibold">Precio Total:</span>
<span className="text-2xl font-bold text-primary">
${(tutor.tarifa * parseFloat(duracion)).toLocaleString()} CLP
</span>
</div>
<Button type="submit" className="w-full" size="lg" disabled={loading}>
{loading ? 'Agendando...' : 'Confirmar Agendamiento'}
</Button>
<p className="text-xs text-gray-500 text-center mt-2">
{tutor.auto_accept
? 'Esta tutoría será confirmada automáticamente'
: 'El tutor debe confirmar tu solicitud'}
</p>
</div>
</form>
)
}7. Modelo de Datos
7.1 Esquema de Base de Datos
7.1.1 Tipos ENUM
-- Roles de usuario
CREATE TYPE user_role AS ENUM ('student', 'tutor', 'both');
-- Modalidad de tutoría
CREATE TYPE tutoria_modalidad AS ENUM ('presencial', 'online');
-- Estado de tutoría
CREATE TYPE tutoria_estado AS ENUM ('pendiente', 'confirmada', 'completada', 'cancelada');7.1.2 Tabla: profiles
CREATE TABLE profiles (
id UUID REFERENCES auth.users(id) ON DELETE CASCADE PRIMARY KEY,
full_name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL CHECK (email LIKE '%@uc.cl'),
role user_role NOT NULL DEFAULT 'student',
avatar_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_profiles_email ON profiles(email);
CREATE INDEX idx_profiles_role ON profiles(role);
-- RLS Policies
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Profiles son públicos para lectura"
ON profiles FOR SELECT
USING (true);
CREATE POLICY "Users pueden actualizar su propio perfil"
ON profiles FOR UPDATE
USING (auth.uid() = id);
CREATE POLICY "Users pueden crear su propio perfil"
ON profiles FOR INSERT
WITH CHECK (auth.uid() = id);
-- Trigger para crear profile automáticamente
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, full_name, email, role)
VALUES (
NEW.id,
COALESCE(NEW.raw_user_meta_data->>'full_name', 'Usuario'),
NEW.email,
'student'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();7.1.3 Tabla: tutores
CREATE TABLE tutores (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE UNIQUE NOT NULL,
bio TEXT,
campus TEXT NOT NULL CHECK (campus IN ('San Joaquín', 'Casa Central', 'Lo Contador', 'Villarrica')),
carrera TEXT NOT NULL,
año_ingreso INTEGER NOT NULL CHECK (año_ingreso >= 2000 AND año_ingreso <= EXTRACT(YEAR FROM NOW())),
tarifa INTEGER NOT NULL CHECK (tarifa >= 0),
disponibilidad JSONB DEFAULT '{}',
auto_accept BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_tutores_campus ON tutores(campus);
CREATE INDEX idx_tutores_tarifa ON tutores(tarifa);
CREATE INDEX idx_tutores_user_id ON tutores(user_id);
-- RLS Policies
ALTER TABLE tutores ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Tutores son públicos"
ON tutores FOR SELECT
USING (true);
CREATE POLICY "Users pueden gestionar su perfil de tutor"
ON tutores FOR ALL
USING (auth.uid() = user_id);7.1.4 Tabla: ramos
CREATE TABLE ramos (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
codigo TEXT UNIQUE NOT NULL,
nombre TEXT NOT NULL,
descripcion TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Índices
CREATE UNIQUE INDEX idx_ramo_codigo ON ramos(codigo);
CREATE INDEX idx_ramo_nombre ON ramos USING GIN(to_tsvector('spanish', nombre));
-- RLS Policies
ALTER TABLE ramos ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Ramos son públicos"
ON ramos FOR SELECT
USING (true);7.1.5 Tabla: tutor_ramo
CREATE TABLE tutor_ramo (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
tutor_id UUID REFERENCES tutores(id) ON DELETE CASCADE NOT NULL,
ramo_id UUID REFERENCES ramos(id) ON DELETE CASCADE NOT NULL,
nota DECIMAL(2,1) NOT NULL CHECK (nota >= 1.0 AND nota <= 7.0),
semestre TEXT NOT NULL CHECK (semestre ~ '^\d{4}-[12]$'),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tutor_id, ramo_id)
);
-- Índices
CREATE UNIQUE INDEX idx_tutor_ramo_unique ON tutor_ramo(tutor_id, ramo_id);
CREATE INDEX idx_tutor_ramo_tutor ON tutor_ramo(tutor_id);
CREATE INDEX idx_tutor_ramo_ramo ON tutor_ramo(ramo_id);
CREATE INDEX idx_tutor_ramo_nota ON tutor_ramo(nota DESC);
-- RLS Policies
ALTER TABLE tutor_ramo ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Tutor-Ramo es público"
ON tutor_ramo FOR SELECT
USING (true);
CREATE POLICY "Tutores gestionan sus propios ramos"
ON tutor_ramo FOR ALL
USING (
EXISTS (
SELECT 1 FROM tutores
WHERE tutores.id = tutor_ramo.tutor_id
AND tutores.user_id = auth.uid()
)
);7.1.6 Tabla: tutoring_sessions
CREATE TABLE tutoring_sessions (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
tutor_id UUID REFERENCES tutores(id) ON DELETE CASCADE NOT NULL,
student_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
ramo_id UUID REFERENCES ramos(id) ON DELETE CASCADE NOT NULL,
modalidad tutoria_modalidad NOT NULL DEFAULT 'presencial',
ubicacion TEXT,
fecha_hora TIMESTAMPTZ NOT NULL,
duracion_modulos DECIMAL NOT NULL DEFAULT 1 CHECK (duracion_modulos > 0),
estado tutoria_estado NOT NULL DEFAULT 'pendiente',
precio_total INTEGER NOT NULL CHECK (precio_total >= 0),
notas TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_sessions_tutor ON tutoring_sessions(tutor_id);
CREATE INDEX idx_sessions_student ON tutoring_sessions(student_id);
CREATE INDEX idx_sessions_estado ON tutoring_sessions(estado);
CREATE INDEX idx_sessions_fecha ON tutoring_sessions(fecha_hora);
-- RLS Policies
ALTER TABLE tutoring_sessions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Solo participantes ven sesiones"
ON tutoring_sessions FOR SELECT
USING (
auth.uid() = student_id
OR EXISTS (
SELECT 1 FROM tutores
WHERE tutores.id = tutoring_sessions.tutor_id
AND tutores.user_id = auth.uid()
)
);
CREATE POLICY "Estudiantes crean y actualizan sesiones"
ON tutoring_sessions FOR INSERT
WITH CHECK (auth.uid() = student_id);
CREATE POLICY "Estudiantes actualizan sus sesiones"
ON tutoring_sessions FOR UPDATE
USING (auth.uid() = student_id);
CREATE POLICY "Tutores actualizan sesiones"
ON tutoring_sessions FOR UPDATE
USING (
EXISTS (
SELECT 1 FROM tutores
WHERE tutores.id = tutoring_sessions.tutor_id
AND tutores.user_id = auth.uid()
)
);7.1.7 Tabla: reviews
CREATE TABLE reviews (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
session_id UUID REFERENCES tutoring_sessions(id) ON DELETE CASCADE,
tutor_id UUID REFERENCES tutores(id) ON DELETE CASCADE NOT NULL,
student_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
ramo_id UUID REFERENCES ramos(id) ON DELETE CASCADE NOT NULL,
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
comment TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(session_id)
);
-- Índices
CREATE INDEX idx_reviews_tutor ON reviews(tutor_id);
CREATE INDEX idx_reviews_rating ON reviews(rating DESC);
CREATE INDEX idx_reviews_session ON reviews(session_id);
-- RLS Policies
ALTER TABLE reviews ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Reviews son públicas"
ON reviews FOR SELECT
USING (true);
CREATE POLICY "Estudiantes crean reviews de sus sesiones"
ON reviews FOR INSERT
WITH CHECK (
auth.uid() = student_id
AND EXISTS (
SELECT 1 FROM tutoring_sessions
WHERE tutoring_sessions.id = reviews.session_id
AND tutoring_sessions.estado = 'completada'
AND tutoring_sessions.student_id = auth.uid()
)
);7.2 Funciones y Triggers
7.2.1 Auto-update timestamps
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Aplicar a todas las tablas relevantes
CREATE TRIGGER update_profiles_updated_at
BEFORE UPDATE ON profiles
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_tutores_updated_at
BEFORE UPDATE ON tutores
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_ramos_updated_at
BEFORE UPDATE ON ramos
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_tutor_ramo_updated_at
BEFORE UPDATE ON tutor_ramo
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_sessions_updated_at
BEFORE UPDATE ON tutoring_sessions
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();8. Seguridad y Autenticación
8.1 Row Level Security (RLS)
Todas las tablas implementan RLS para garantizar que:
- Datos públicos: Perfiles de tutores, ramos, reviews son visibles para todos
- Datos privados: Solo los participantes ven detalles de sesiones
- Modificaciones: Solo el dueño puede editar sus datos
8.2 Validación de Email @uc.cl
Se implementa en múltiples niveles:
- Base de datos: CHECK constraint en tabla
profiles - Frontend: Validación antes de enviar formulario
- Supabase Auth: Email verification obligatoria
8.3 Protección de Rutas
El middleware de Next.js protege rutas sensibles:
// middleware.ts - Ver sección 5.3.39. Integración con Gemini AI
9.1 Edge Function para Matching
// supabase/functions/gemini-match/index.ts
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) => {
// Handle CORS
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
try {
const { courseCode, preferences } = await req.json()
// 1. Conectar a Supabase
const supabase = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
)
// 2. Obtener tutores que enseñan este ramo
const { data: tutors, error } = await supabase
.from('tutores')
.select(`
id,
bio,
tarifa,
campus,
profiles(full_name),
tutor_ramo!inner(
nota,
ramos!inner(codigo, nombre)
),
reviews(rating)
`)
.eq('tutor_ramo.ramos.codigo', courseCode)
if (error || !tutors || tutors.length === 0) {
return new Response(
JSON.stringify({ error: 'No tutors found' }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 404 }
)
}
// 3. Llamar a Gemini AI
const geminiResponse = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=${Deno.env.get('GEMINI_API_KEY')}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{
parts: [{
text: `Eres un experto en matching de tutores para estudiantes universitarios UC.
Analiza estos tutores y recomienda los mejores 3 para un estudiante que busca ayuda en ${courseCode}.
Tutores disponibles:
${JSON.stringify(tutors, null, 2)}
Preferencias del estudiante:
${JSON.stringify(preferences, null, 2)}
Responde SOLO con un JSON válido (sin markdown) en este formato:
[
{
"tutorId": "uuid",
"score": 95,
"reasoning": "Explicación breve de por qué este tutor es ideal (max 100 caracteres)"
}
]`
}]
}]
})
}
)
const geminiData = await geminiResponse.json()
const textResponse = geminiData.candidates[0].content.parts[0].text
// Limpiar respuesta (remover markdown si existe)
const cleanedText = textResponse
.replace(/```json\n?/g, '')
.replace(/```\n?/g, '')
.trim()
const recommendations = JSON.parse(cleanedText)
return new Response(
JSON.stringify(recommendations),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
} catch (error) {
return new Response(
JSON.stringify({ error: error.message }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 500 }
)
}
})9.2 Despliegue de Edge Function
# Desde el directorio raíz del proyecto
supabase functions deploy gemini-match
# Configurar secrets
supabase secrets set GEMINI_API_KEY=AIzaSy...9.3 Uso desde Frontend
// components/gemini-recommendations.tsx
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
export default function GeminiRecommendations({ courseCode }: { courseCode: string }) {
const [loading, setLoading] = useState(false)
const [recommendations, setRecommendations] = useState<any[]>([])
const supabase = createClient()
async function getRecommendations() {
setLoading(true)
const { data, error } = await supabase.functions.invoke('gemini-match', {
body: {
courseCode,
preferences: {
budget: 20000,
campus: 'San Joaquín',
}
}
})
if (error) {
console.error('Error:', error)
} else {
setRecommendations(data)
}
setLoading(false)
}
return (
<div className="border p-6 rounded-lg">
<h3 className="text-xl font-bold mb-4">🤖 Recomendaciones IA</h3>
<p className="text-gray-600 mb-4">
Deja que Gemini AI encuentre los mejores tutores para ti
</p>
<Button onClick={getRecommendations} disabled={loading}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Analizando...
</>
) : (
'Recomiéndame Tutores'
)}
</Button>
{recommendations.length > 0 && (
<div className="mt-6 space-y-4">
<h4 className="font-semibold">Top 3 Recomendados:</h4>
{recommendations.map((rec, idx) => (
<div key={rec.tutorId} className="border-l-4 border-primary pl-4">
<div className="flex items-center gap-2 mb-1">
<span className="font-bold">#{idx + 1}</span>
<span className="text-sm text-gray-500">
Match Score: {rec.score}/100
</span>
</div>
<p className="text-sm">{rec.reasoning}</p>
</div>
))}
</div>
)}
</div>
)
}10. Plan de Implementación
10.1 Fase 1: MVP (Hackathon - 9 horas)
Hora 1 (9:00-10:00): Setup
- Crear proyecto Supabase
- Crear proyecto Next.js 15
- Instalar dependencias
- Ejecutar schema SQL
- Deploy inicial a Vercel
Hora 2 (10:00-11:00): Data + Auth
- Seedear tabla
ramosdesde courses.ndjson - Crear tutores fake (20)
- Implementar registro/login
- Configurar middleware
Horas 3-4 (11:00-13:00): Core Features
- Landing page
- Página de búsqueda con filtros
- Perfil de tutor completo
- Dashboard básico de tutor
Horas 5-6 (13:00-15:00): Tutorías
- Formulario de agendamiento
- Lista “Mis tutorías” (estudiante)
- Gestión de sesiones (tutor)
- Sistema de estados (pendiente/confirmada/etc.)
Hora 7 (15:00-16:00): Gemini AI
- Edge Function para matching
- Componente de recomendaciones
- Integración en búsqueda
Hora 8 (16:00-17:00): Polish
- Sistema de reviews
- Mejoras de UI/UX
- Responsive mobile
- Loading states
Hora 9 (17:00-18:00): Deploy Final
- Testing manual completo
- Fix de bugs críticos
- Deploy final
- Video demo (2-3 min)
10.2 Fase 2: Post-Hackathon (Semanas 1-4)
Semana 1-2:
- Implementar sistema de pagos (Flow/Mercadopago)
- Chat en tiempo real entre tutor-estudiante
- Notificaciones push (Email + Web Push)
- Panel de analytics para tutores
Semana 3-4:
- Integración con Google Calendar
- Sistema de reportes/disputes
- Programa de referidos
- Blog de contenido SEO
10.3 Fase 3: Crecimiento (Meses 2-6)
- App móvil (React Native)
- Marketplace de materiales de estudio
- Sistema de suscripciones premium
- Expansión a otras universidades chilenas
11. Métricas de Éxito
11.1 KPIs de Producto
| Métrica | Objetivo MVP | Objetivo 3 meses |
|---|---|---|
| Usuarios registrados | 50+ | 500+ |
| Tutores activos | 20+ | 100+ |
| Sesiones agendadas | 20+ | 200+/mes |
| Tasa de conversión (búsqueda → agendamiento) | 10%+ | 15%+ |
| Rating promedio tutorías | 4.0+ | 4.5+ |
| Tiempo promedio de búsqueda | <5 min | <3 min |
11.2 Métricas Técnicas
| Métrica | Target |
|---|---|
| Time to First Byte (TTFB) | <500ms |
| Largest Contentful Paint (LCP) | <2.5s |
| First Input Delay (FID) | <100ms |
| Cumulative Layout Shift (CLS) | <0.1 |
| Uptime | 99.9% |
| Edge Function cold start | <200ms |
11.3 Métricas de Negocio (Post-Hackathon)
| Métrica | Objetivo |
|---|---|
| GMV (Gross Merchandise Value) | $5M CLP/mes |
| Comisión promedio | 10-12% |
| Ingreso mensual | $500K CLP |
| CAC (Customer Acquisition Cost) | <$2,000 CLP |
| LTV (Lifetime Value) | >$20,000 CLP |
| LTV/CAC Ratio | >10x |
12. Apéndices
12.1 Dependencias del Proyecto
{
"name": "tutorconnect-uc",
"version": "1.0.0",
"dependencies": {
"next": "^15.1.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"typescript": "^5.3.0",
"@supabase/supabase-js": "^2.39.0",
"@supabase/ssr": "^0.1.0",
"tailwindcss": "^3.4.0",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-tabs": "^1.0.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.0",
"date-fns": "^3.0.0",
"zod": "^3.22.0",
"react-hook-form": "^7.49.0",
"@hookform/resolvers": "^3.3.0",
"lucide-react": "^0.303.0"
},
"devDependencies": {
"@types/node": "^20.10.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"eslint": "^8.56.0",
"eslint-config-next": "^15.1.0"
}
}12.2 Estructura de Carpetas
tutorconnect-uc/
├── app/
│ ├── (auth)/
│ │ ├── login/
│ │ └── registro/
│ ├── buscar/
│ ├── tutor/
│ │ ├── [id]/
│ │ └── dashboard/
│ ├── agendar/
│ │ └── [tutorId]/
│ ├── mis-tutorias/
│ ├── layout.tsx
│ └── page.tsx
├── components/
│ ├── ui/ (shadcn components)
│ ├── tutor-card.tsx
│ ├── search-filters.tsx
│ ├── agendar-form.tsx
│ └── gemini-recommendations.tsx
├── lib/
│ ├── supabase/
│ │ ├── client.ts
│ │ └── server.ts
│ └── utils.ts
├── supabase/
│ ├── functions/
│ │ └── gemini-match/
│ ├── migrations/
│ └── seed.sql
├── public/
├── middleware.ts
├── tailwind.config.ts
├── tsconfig.json
├── package.json
└── README.md12.3 Comandos Útiles
# Desarrollo local
npm run dev
# Build de producción
npm run build
# Supabase local
supabase start
supabase db reset
supabase functions serve
# Deploy
vercel deploy
supabase functions deploy
# Generar tipos de Supabase
supabase gen types typescript --local > lib/database.types.ts13. Conclusión
TutorConnect UC representa una solución integral al problema de coordinación de tutorías en la UC, combinando:
✅ Verificación institucional para confianza ✅ Catálogo real de ramos para precisión ✅ IA integrada para mejores matches ✅ Stack moderno para escalabilidad ✅ UX intuitiva para adopción rápida
Este PRD proporciona una hoja de ruta clara para construir un MVP funcional en 9 horas durante el hackathon, con un camino definido para crecimiento post-hackathon.
Preparados para construir. 🚀
Documento preparado por: TutorConnect Team Última actualización: Diciembre 2024 Versión: 1.0.0