🎨 TutorConnect UC - Arquitectura de Páginas (Supabase Stack)
Importante
Este documento define la arquitectura de páginas usando Next.js + Supabase. Ya NO usamos Filament - todo el panel de tutores está en Next.js. Consulta
Design System & Style Guide.mdpara componentes y estilos.
📱 Estructura de Navegación
Para TODOS los usuarios (Next.js App)
┌─────────────────────────────────────────────────────────────────┐
│ NAVBAR (Sticky Top) │
│ [Logo] TutorConnect UC | Buscar Ofertas Perfil [Avatar] │
└─────────────────────────────────────────────────────────────────┘
Si NO está logueado:
│ [Logo] TutorConnect UC | Buscar Ofertas Ayuda [Login] │
Si es TUTOR logueado:
│ [Logo] TutorConnect UC | Buscar Ofertas Dashboard [Avatar]│
Diferencia clave con Laravel:
- ❌ Ya NO hay panel Filament separado en
/admin - ✅ TODO está en Next.js bajo
/tutor/*para tutores - ✅ Misma app, diferentes rutas según rol
🌐 Páginas Públicas (No requieren login)
1. 🏠 Landing Page /
Objetivo: Convertir visitantes en usuarios registrados
// app/page.tsx
export default async function HomePage() {
const { data: tutoresDestacados } = await supabase
.from('tutores_con_stats')
.select('*')
.order('avg_rating', { ascending: false })
.limit(6)
return (
<>
<HeroSection />
<ComoFuncionaSection />
<TutoresDestacadosGrid tutores={tutoresDestacados} />
<RamosMasBuscadosSection />
<TestimoniosSection />
<CTASection />
</>
)
}Secciones:
┌─────────────────────────────────────────┐
│ HERO (bg-gradient UC blue → light) │
│ "Encuentra el mejor tutor UC │
│ para aprobar con 7.0" │
│ │
│ [Input: Buscar ramo] [Botón: Buscar→] │
│ "Verificados • @uc.cl • Calificados" │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ CÓMO FUNCIONA (3 cards con iconos) │
│ [1. 🔍] [2. ✅] [3. 📅] │
│ Busca → Elige → Agenda │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ TUTORES DESTACADOS │
│ Grid 3 columnas (desktop) / 1 (mobile) │
│ [TutorCard] [TutorCard] [TutorCard] │
│ [Ver todos →] │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ RAMOS MÁS BUSCADOS (Badges) │
│ [IIC2233] [MAT1610] [FIS1503] ... │
└─────────────────────────────────────────┘
Componentes:
<HeroSection />- Con search bar prominente<TutorCard />- Reutilizable (ver Design System)<RamoBadge />- Link a búsqueda de ese ramo
2. 🔍 Búsqueda /buscar
Query Params: ?q=IIC2233&campus=San+Joaquín&min=10000&max=20000
// app/buscar/page.tsx
export default async function BuscarPage({
searchParams
}: {
searchParams: { q?: string; campus?: string; min?: string; max?: string }
}) {
// Query con Supabase
let query = supabase
.from('tutores_con_stats')
.select('*')
if (searchParams.q) {
// Buscar en ramos que enseña
query = query.contains('ramo_codigos', [searchParams.q])
}
if (searchParams.campus) {
query = query.eq('campus', searchParams.campus)
}
const { data: tutores } = await query
return (
<div className="container py-8">
<div className="grid lg:grid-cols-4 gap-8">
<aside className="lg:col-span-1">
<SearchFilters />
</aside>
<main className="lg:col-span-3">
<SearchHeader resultCount={tutores?.length || 0} />
{/* Gemini AI Button */}
<GeminiMatchButton courseCode={searchParams.q} />
<TutorGrid tutores={tutores || []} />
</main>
</div>
</div>
)
}Filtros (Sidebar en desktop, Sheet en mobile):
// components/search-filters.tsx
<div className="space-y-6">
{/* Campus */}
<div>
<Label>Campus</Label>
<div className="space-y-2">
<Checkbox id="sj" /> <Label htmlFor="sj">San Joaquín</Label>
<Checkbox id="cc" /> <Label htmlFor="cc">Casa Central</Label>
<Checkbox id="lc" /> <Label htmlFor="lc">Lo Contador</Label>
</div>
</div>
{/* Modalidad */}
<div>
<Label>Modalidad</Label>
<RadioGroup>
<Radio value="all">Todas</Radio>
<Radio value="presencial">Presencial</Radio>
<Radio value="online">Online</Radio>
</RadioGroup>
</div>
{/* Precio (Range Slider) */}
<div>
<Label>Precio (CLP)</Label>
<Slider min={0} max={50000} step={1000} />
<div className="flex justify-between text-sm">
<span>$0</span>
<span>$50.000</span>
</div>
</div>
{/* Rating */}
<div>
<Label>Calificación mínima</Label>
<Select>
<option value="0">Todas</option>
<option value="4">⭐ 4.0+</option>
<option value="4.5">⭐ 4.5+</option>
</Select>
</div>
<Button variant="outline" className="w-full">
Limpiar filtros
</Button>
</div>Gemini AI Matching:
// components/gemini-match-button.tsx
'use client'
export function GeminiMatchButton({ courseCode }: { courseCode?: string }) {
const [loading, setLoading] = useState(false)
const [recommendations, setRecommendations] = useState(null)
async function handleMatch() {
setLoading(true)
const { data } = await supabase.functions.invoke('gemini-match', {
body: { courseCode, preferences: {} }
})
setRecommendations(data)
setLoading(false)
}
return (
<div className="mb-6">
<Button
onClick={handleMatch}
disabled={!courseCode || loading}
className="bg-gradient-to-r from-purple-600 to-pink-600"
>
{loading ? '🤖 Analizando...' : '🤖 Recomiéndame los mejores 3'}
</Button>
{recommendations && (
<div className="mt-4 grid gap-4">
{recommendations.map((rec: any) => (
<RecommendationCard key={rec.tutorId} rec={rec} />
))}
</div>
)}
</div>
)
}3. 👤 Perfil de Tutor /tutor/[id]
// app/tutor/[id]/page.tsx
export default async function TutorProfilePage({
params
}: {
params: { id: string }
}) {
const { data: tutor } = await supabase
.from('tutores_con_stats')
.select('*')
.eq('id', params.id)
.single()
const { data: ramos } = await supabase
.from('tutor_ramo')
.select('*, ramos(*)')
.eq('tutor_id', params.id)
const { data: reviews } = await supabase
.from('reviews')
.select('*, profiles!student_id(*)')
.eq('tutor_id', params.id)
.order('created_at', { ascending: false })
.limit(10)
return (
<div className="container max-w-5xl py-8">
<ProfileHeader tutor={tutor} />
<Tabs defaultValue="sobre-mi">
<TabsList>
<TabsTrigger value="sobre-mi">Sobre mí</TabsTrigger>
<TabsTrigger value="ramos">Ramos ({ramos.length})</TabsTrigger>
<TabsTrigger value="reviews">Reviews ({reviews.length})</TabsTrigger>
</TabsList>
<TabsContent value="sobre-mi">
<BioSection bio={tutor.bio} />
</TabsContent>
<TabsContent value="ramos">
<RamosGrid ramos={ramos} tutorId={tutor.id} />
</TabsContent>
<TabsContent value="reviews">
<ReviewsList reviews={reviews} />
</TabsContent>
</Tabs>
{/* Sticky Sidebar */}
<aside className="lg:fixed lg:right-8 lg:top-24 lg:w-80">
<PricingCard tarifa={tutor.tarifa} />
<Button className="w-full" size="lg">
Agendar Tutoría →
</Button>
</aside>
</div>
)
}🔐 Páginas Protegidas (Requieren login)
Auth Flow (Supabase)
// middleware.ts
export async function middleware(req: NextRequest) {
const res = NextResponse.next()
const supabase = createMiddlewareClient({ req, res })
const { data: { session } } = await supabase.auth.getSession()
// Proteger rutas /tutor/* y /mis-tutorias
if (!session && (
req.nextUrl.pathname.startsWith('/tutor') ||
req.nextUrl.pathname.startsWith('/mis-tutorias')
)) {
return NextResponse.redirect(new URL('/login', req.url))
}
return res
}4. 📅 Mis Tutorías /mis-tutorias
Para Estudiantes:
// app/mis-tutorias/page.tsx
export default async function MisTutoriasPage() {
const { data: { user } } = await supabase.auth.getUser()
const { data: sessions } = await supabase
.from('tutoring_sessions')
.select(`
*,
tutores!inner(*, profiles(*)),
ramos(*)
`)
.eq('student_id', user.id)
.order('fecha_hora', { ascending: true })
const proximas = sessions?.filter(s =>
new Date(s.fecha_hora) > new Date() &&
s.estado !== 'cancelada'
)
const pasadas = sessions?.filter(s =>
new Date(s.fecha_hora) < new Date() ||
s.estado === 'completada'
)
return (
<div className="container max-w-4xl py-8">
<h1 className="text-3xl font-bold mb-8">Mis Tutorías</h1>
<Tabs defaultValue="proximas">
<TabsList>
<TabsTrigger value="proximas">
Próximas ({proximas?.length || 0})
</TabsTrigger>
<TabsTrigger value="pasadas">
Pasadas ({pasadas?.length || 0})
</TabsTrigger>
</TabsList>
<TabsContent value="proximas">
<div className="space-y-4">
{proximas?.map(session => (
<SessionCard key={session.id} session={session} />
))}
</div>
</TabsContent>
<TabsContent value="pasadas">
<div className="space-y-4">
{pasadas?.map(session => (
<SessionCard key={session.id} session={session} canReview />
))}
</div>
</TabsContent>
</Tabs>
</div>
)
}👨🏫 Dashboard de Tutores (Reemplazo de Filament)
Diferencia clave: Ya NO usamos Filament. Todo en Next.js bajo /tutor/*
5. 📊 Dashboard Tutor /tutor/dashboard
// app/tutor/dashboard/page.tsx
export default async function TutorDashboardPage() {
const { data: { user } } = await supabase.auth.getUser()
// Get tutor profile
const { data: tutor } = await supabase
.from('tutores')
.select('*')
.eq('user_id', user.id)
.single()
// Stats
const stats = await getTutorStats(tutor.id)
// Pending sessions
const { data: pendingSessions } = await supabase
.from('tutoring_sessions')
.select('*, profiles!student_id(*), ramos(*)')
.eq('tutor_id', tutor.id)
.eq('estado', 'pendiente')
return (
<div className="container py-8">
<h1 className="text-3xl font-bold mb-8">
Dashboard
</h1>
{/* Stats Cards */}
<div className="grid md:grid-cols-3 gap-6 mb-8">
<StatsCard
title="Ingresos este mes"
value={`$${stats.ingresos.toLocaleString()}`}
icon={<DollarSign />}
/>
<StatsCard
title="Tutorías completadas"
value={stats.completadas}
subtitle={`${stats.pendientes} pendientes`}
icon={<Calendar />}
/>
<StatsCard
title="Rating promedio"
value={`⭐ ${stats.avgRating}`}
subtitle={`${stats.totalReviews} reviews`}
icon={<Star />}
/>
</div>
{/* Pending Confirmations */}
<Card>
<CardHeader>
<CardTitle>Solicitudes Pendientes ({pendingSessions?.length})</CardTitle>
</CardHeader>
<CardContent>
{pendingSessions?.map(session => (
<PendingSessionItem
key={session.id}
session={session}
onAccept={handleAccept}
onReject={handleReject}
/>
))}
</CardContent>
</Card>
{/* Upcoming Sessions Calendar */}
<Card className="mt-6">
<CardHeader>
<CardTitle>Próximas Tutorías</CardTitle>
</CardHeader>
<CardContent>
<UpcomingSessionsCalendar tutorId={tutor.id} />
</CardContent>
</Card>
</div>
)
}6. 💼 Gestión de Ofertas /tutor/ofertas
// app/tutor/ofertas/page.tsx
'use client'
export default function TutorOfertasPage() {
const [ofertas, setOfertas] = useState([])
const [showCreateModal, setShowCreateModal] = useState(false)
useEffect(() => {
loadOfertas()
}, [])
async function loadOfertas() {
const { data } = await supabase
.from('tutoring_offers')
.select('*, tutores(*), ramos(*)')
.eq('tutor_id', tutorId)
setOfertas(data)
}
return (
<div className="container py-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Mis Ofertas</h1>
<Button onClick={() => setShowCreateModal(true)}>
+ Nueva Oferta
</Button>
</div>
<div className="grid gap-4">
{ofertas.map(oferta => (
<OfertaManageCard
key={oferta.id}
oferta={oferta}
onEdit={handleEdit}
onDelete={handleDelete}
onToggleActive={handleToggleActive}
/>
))}
</div>
{/* Create/Edit Modal */}
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>Nueva Oferta de Tutoría</DialogTitle>
</DialogHeader>
<OfertaForm onSubmit={handleCreate} />
</DialogContent>
</Dialog>
</div>
)
}OfertaForm Component:
<form onSubmit={handleSubmit} className="space-y-4">
{/* Ramo */}
<div>
<Label>Ramo</Label>
<Select name="ramo_id">
{misRamos.map(r => (
<option key={r.id} value={r.ramo_id}>
{r.ramos.codigo} - {r.ramos.nombre}
</option>
))}
</Select>
</div>
{/* Título */}
<div>
<Label>Título</Label>
<Input placeholder="Tutorías de Programación Avanzada" />
</div>
{/* Descripción */}
<div>
<Label>Descripción</Label>
<Textarea rows={4} />
</div>
{/* Modalidad */}
<div>
<Label>Modalidad</Label>
<div className="flex gap-4">
<Checkbox id="presencial" /> Presencial
<Checkbox id="online" /> Online
</div>
</div>
{/* Precio */}
<div>
<Label>Precio por módulo</Label>
<Input type="number" prefix="$" />
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline">Cancelar</Button>
<Button type="submit">Crear Oferta</Button>
</div>
</form>7. 📩 Búsquedas de Estudiantes /tutor/busquedas
// app/tutor/busquedas/page.tsx
export default async function TutorBusquedasPage() {
// Get open tutoring requests
const { data: requests } = await supabase
.from('tutoring_requests')
.select('*, profiles!student_id(*), ramos(*)')
.eq('status', 'open')
.order('created_at', { ascending: false })
return (
<div className="container py-8">
<h1 className="text-3xl font-bold mb-8">
Estudiantes Buscando Tutores
</h1>
<div className="space-y-4">
{requests?.map(request => (
<RequestCard
key={request.id}
request={request}
onSendProposal={() => handleSendProposal(request.id)}
/>
))}
</div>
</div>
)
}RequestCard con Gemini AI:
<Card className={urgencyBorder}>
<CardHeader>
<div className="flex justify-between items-start">
<div>
<Badge>{urgencyBadge}</Badge>
<h3 className="font-bold mt-2">{request.ramos.codigo}</h3>
<p className="text-sm text-gray-600">{request.title}</p>
</div>
<span className="text-lg font-bold">
Hasta ${request.budget?.toLocaleString()}
</span>
</div>
</CardHeader>
<CardContent>
<p className="text-sm mb-4">{request.description}</p>
<div className="flex gap-2 text-sm text-gray-600">
<span>📍 {request.preferred_campus || 'Cualquiera'}</span>
<span>🌐 {request.accepts_online ? 'Acepta online' : 'Solo presencial'}</span>
</div>
</CardContent>
<CardFooter className="flex gap-2">
<Dialog>
<DialogTrigger asChild>
<Button>Enviar Propuesta</Button>
</DialogTrigger>
<DialogContent>
<ProposalForm
requestId={request.id}
suggestedBudget={request.budget}
/>
</DialogContent>
</Dialog>
<Button variant="outline" onClick={handleGenerateWithAI}>
🤖 Generar con Gemini
</Button>
</CardFooter>
</Card>8. 🎓 Mis Ramos /tutor/mis-ramos
// app/tutor/mis-ramos/page.tsx
export default async function TutorRamosPage() {
const { data: tutorRamos } = await supabase
.from('tutor_ramo')
.select('*, ramos(*)')
.eq('tutor_id', tutorId)
return (
<div className="container py-8">
<div className="flex justify-between mb-8">
<h1 className="text-3xl font-bold">Ramos que Puedo Enseñar</h1>
<Button onClick={() => setShowAddModal(true)}>
+ Agregar Ramo
</Button>
</div>
<div className="grid gap-4">
{tutorRamos?.map(tr => (
<Card key={tr.id}>
<CardContent className="flex justify-between items-center p-6">
<div>
<code className="text-lg font-mono text-uc-blue">
{tr.ramos.codigo}
</code>
<p className="text-gray-600">{tr.ramos.nombre}</p>
<div className="flex gap-4 text-sm text-gray-500 mt-2">
<span>Nota: {tr.nota}</span>
<span>Semestre: {tr.semestre}</span>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">Editar</Button>
<Button variant="destructive" size="sm">Eliminar</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}🔔 Features Especiales
Gemini AI - 3 Lugares
1. Matching de Tutores (/buscar)
// Botón que llama a Edge Function
const { data } = await supabase.functions.invoke('gemini-match', {
body: {
courseCode: 'IIC2233',
preferences: {
campus: 'San Joaquín',
maxPrice: 20000
}
}
})
// Muestra Top 3 con explicación
<RecommendationCard
tutor={tutor}
score={95}
reasoning="Este tutor tiene 6.8 en el ramo y 4.9 rating..."
/>2. Generador de Propuestas (/tutor/busquedas)
// Edge Function que genera mensaje personalizado
const { data } = await supabase.functions.invoke('generate-proposal', {
body: {
requestDetails: request,
tutorProfile: myProfile
}
})
// Pre-llena el textarea con propuesta AI
<Textarea defaultValue={data.proposalText} />3. Auto-matching (Background)
// Trigger cuando se crea tutoring_request
// Notifica a los mejores 3 tutores automáticamente📱 Mobile Navigation
// components/mobile-nav.tsx (solo mobile)
<nav className="lg:hidden fixed bottom-0 inset-x-0 bg-white border-t z-50">
<div className="flex justify-around py-2">
<NavItem href="/" icon={Home} label="Inicio" />
<NavItem href="/buscar" icon={Search} label="Buscar" />
<NavItem href="/ofertas" icon={Briefcase} label="Ofertas" />
<NavItem href="/mis-tutorias" icon={Calendar} label="Mis" />
<NavItem href="/perfil" icon={User} label="Perfil" />
</div>
</nav>✅ Resumen de Cambios vs Laravel
| Aspecto | Laravel (antes) | Supabase (ahora) |
|---|---|---|
| Admin panel | Filament en /admin | Next.js en /tutor/* |
| Auth | Sanctum + tokens | Supabase Auth |
| Data fetching | API calls | Direct queries |
| Forms | Laravel validation | React Hook Form + Zod |
| Real-time | Pusher (manual) | Supabase Realtime |
| File uploads | S3 config | Supabase Storage |
🎯 Prioridades Hackathon
✅ MUST HAVE (Horas 1-6)
- Landing page
- Búsqueda con filtros
- Perfil de tutor
- Mis tutorías (estudiante)
- Dashboard tutor básico
⚡ NICE TO HAVE (Horas 7-8)
- Gemini matching
- Gestión de ofertas
- Sistema de reviews
❌ POST-HACKATHON
- Chat en tiempo real
- Notificaciones push
- Sistema de pagos
Consulta Design System & Style Guide.md para componentes, colores y estilos específicos. 🎨