🎨 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.md para 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

AspectoLaravel (antes)Supabase (ahora)
Admin panelFilament en /adminNext.js en /tutor/*
AuthSanctum + tokensSupabase Auth
Data fetchingAPI callsDirect queries
FormsLaravel validationReact Hook Form + Zod
Real-timePusher (manual)Supabase Realtime
File uploadsS3 configSupabase Storage

🎯 Prioridades Hackathon

✅ MUST HAVE (Horas 1-6)

  1. Landing page
  2. Búsqueda con filtros
  3. Perfil de tutor
  4. Mis tutorías (estudiante)
  5. Dashboard tutor básico

⚡ NICE TO HAVE (Horas 7-8)

  1. Gemini matching
  2. Gestión de ofertas
  3. Sistema de reviews

❌ POST-HACKATHON

  1. Chat en tiempo real
  2. Notificaciones push
  3. Sistema de pagos

Consulta Design System & Style Guide.md para componentes, colores y estilos específicos. 🎨