Files
ACVE/app/(public)/eventos/page.tsx
2026-03-15 13:52:11 +00:00

463 lines
17 KiB
TypeScript

"use client";
import Link from "next/link";
import { type FormEvent, useEffect, useMemo, useState } from "react";
import { readDemoClientAuth } from "@/lib/auth/clientAuth";
import { supabaseBrowser } from "@/lib/supabase/browser";
type EventItem = {
id: string;
title: string;
date: string;
startTime: string;
endTime: string;
mode: string;
location: string;
summary: string;
details: string;
thumbnail: string;
};
type EventDraft = {
title: string;
date: string;
startTime: string;
endTime: string;
mode: string;
location: string;
summary: string;
details: string;
thumbnail: string;
};
const EVENTS_STORAGE_KEY = "acve.custom-events.v1";
const defaultThumbnail =
"https://images.unsplash.com/photo-1511578314322-379afb476865?auto=format&fit=crop&w=1200&q=80";
const defaultEvents: EventItem[] = [
{
id: "launch-day-2026-03-20",
title: "Launch Day ACVE",
date: "2026-03-20",
startTime: "18:00",
endTime: "20:30",
mode: "Híbrido",
location: "Monterrey",
summary: "Presentación oficial de ACVE, agenda académica y networking inicial.",
details:
"Sesión inaugural con bienvenida institucional, visión del programa 2026 y espacio de networking para alumnos, docentes e invitados del sector legal.",
thumbnail:
"https://images.unsplash.com/photo-1528605248644-14dd04022da1?auto=format&fit=crop&w=1200&q=80",
},
{
id: "webinar-drafting-2026-03-27",
title: "Webinar: Legal Drafting in English",
date: "2026-03-27",
startTime: "19:00",
endTime: "20:15",
mode: "Online",
location: "Zoom ACVE",
summary: "Buenas prácticas para redactar cláusulas con claridad y precisión.",
details:
"Revisión de estructura, vocabulario funcional y errores frecuentes en contratos internacionales. Incluye sesión breve de preguntas al final.",
thumbnail:
"https://images.unsplash.com/photo-1543269865-cbf427effbad?auto=format&fit=crop&w=1200&q=80",
},
{
id: "qa-session-2026-04-05",
title: "Q&A de Cohorte",
date: "2026-04-05",
startTime: "17:30",
endTime: "18:30",
mode: "Streaming",
location: "Campus Virtual ACVE",
summary: "Resolución de dudas académicas y guía de estudio para la siguiente unidad.",
details:
"Encuentro en vivo para alinear progreso de la cohorte, resolver dudas de contenido y compartir recomendaciones de práctica.",
thumbnail:
"https://images.unsplash.com/photo-1515169067868-5387ec356754?auto=format&fit=crop&w=1200&q=80",
},
{
id: "workshop-monterrey-2026-04-18",
title: "Workshop Presencial",
date: "2026-04-18",
startTime: "10:00",
endTime: "13:00",
mode: "Presencial",
location: "Monterrey",
summary: "Simulación de negociación y revisión colaborativa de cláusulas.",
details:
"Taller práctico con actividades por equipos para aplicar vocabulario jurídico en contexto de negociación y redacción de términos clave.",
thumbnail:
"https://images.unsplash.com/photo-1475721027785-f74eccf877e2?auto=format&fit=crop&w=1200&q=80",
},
{
id: "networking-night-2026-05-02",
title: "Networking Night",
date: "2026-05-02",
startTime: "19:30",
endTime: "21:00",
mode: "Híbrido",
location: "Monterrey + Online",
summary: "Conexión entre estudiantes, alumni y profesores ACVE.",
details:
"Espacio informal para compartir experiencias, oportunidades de colaboración y avances en el uso profesional del inglés legal.",
thumbnail:
"https://images.unsplash.com/photo-1528909514045-2fa4ac7a08ba?auto=format&fit=crop&w=1200&q=80",
},
];
const blankDraft: EventDraft = {
title: "",
date: "",
startTime: "18:00",
endTime: "19:00",
mode: "Online",
location: "Monterrey",
summary: "",
details: "",
thumbnail: "",
};
function parseDateInUtc(value: string): Date {
const [year, month, day] = value.split("-").map(Number);
return new Date(Date.UTC(year, month - 1, day));
}
function sortEvents(items: EventItem[]): EventItem[] {
return [...items].sort((a, b) => {
if (a.date !== b.date) return a.date.localeCompare(b.date);
return a.startTime.localeCompare(b.startTime);
});
}
function formatCardDate(value: string) {
const date = parseDateInUtc(value);
return {
day: new Intl.DateTimeFormat("es-MX", { day: "2-digit", timeZone: "UTC" }).format(date),
month: new Intl.DateTimeFormat("es-MX", { month: "short", timeZone: "UTC" }).format(date).toUpperCase(),
};
}
function formatLongDate(value: string): string {
return new Intl.DateTimeFormat("es-MX", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
timeZone: "UTC",
}).format(parseDateInUtc(value));
}
function isEventItem(value: unknown): value is EventItem {
if (!value || typeof value !== "object") return false;
const record = value as Record<string, unknown>;
return (
typeof record.id === "string" &&
typeof record.title === "string" &&
typeof record.date === "string" &&
typeof record.startTime === "string" &&
typeof record.endTime === "string" &&
typeof record.mode === "string" &&
typeof record.location === "string" &&
typeof record.summary === "string" &&
typeof record.details === "string" &&
typeof record.thumbnail === "string"
);
}
export default function EventosPage() {
const [selectedEvent, setSelectedEvent] = useState<EventItem | null>(null);
const [addedEvents, setAddedEvents] = useState<EventItem[]>([]);
const [draft, setDraft] = useState<EventDraft>(blankDraft);
const [formError, setFormError] = useState<string | null>(null);
const [isTeacher, setIsTeacher] = useState(false);
const [isCheckingRole, setIsCheckingRole] = useState(true);
const orderedEvents = useMemo(() => sortEvents([...defaultEvents, ...addedEvents]), [addedEvents]);
useEffect(() => {
if (typeof window === "undefined") return;
try {
const stored = window.localStorage.getItem(EVENTS_STORAGE_KEY);
if (!stored) return;
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
setAddedEvents(parsed.filter(isEventItem));
}
} catch {
setAddedEvents([]);
}
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
window.localStorage.setItem(EVENTS_STORAGE_KEY, JSON.stringify(sortEvents(addedEvents)));
}, [addedEvents]);
useEffect(() => {
let mounted = true;
const demoSnapshot = readDemoClientAuth();
const client = supabaseBrowser();
if (!client) {
setIsTeacher(demoSnapshot.isTeacher);
setIsCheckingRole(false);
return;
}
const resolveTeacherRole = async () => {
try {
const {
data: { user },
} = await client.auth.getUser();
if (!mounted) return;
if (!user) {
setIsTeacher(demoSnapshot.isTeacher);
return;
}
const response = await fetch("/api/auth/session", { cache: "no-store" });
if (!mounted) return;
const payload = (await response.json()) as { isTeacher?: boolean };
setIsTeacher(payload.isTeacher === true || demoSnapshot.isTeacher);
} catch {
if (mounted) setIsTeacher(demoSnapshot.isTeacher);
} finally {
if (mounted) setIsCheckingRole(false);
}
};
void resolveTeacherRole();
const {
data: { subscription },
} = client.auth.onAuthStateChange(() => {
void resolveTeacherRole();
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, []);
const handleAddEvent = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isTeacher) {
setFormError("Solo docentes pueden agregar eventos.");
return;
}
const required = [draft.title, draft.date, draft.startTime, draft.endTime, draft.mode, draft.location, draft.summary, draft.details];
if (required.some((field) => field.trim().length === 0)) {
setFormError("Completa todos los campos obligatorios para publicar el evento.");
return;
}
setAddedEvents((previous) => [
...previous,
{
id: typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}`,
title: draft.title.trim(),
date: draft.date,
startTime: draft.startTime,
endTime: draft.endTime,
mode: draft.mode.trim(),
location: draft.location.trim(),
summary: draft.summary.trim(),
details: draft.details.trim(),
thumbnail: draft.thumbnail.trim() || defaultThumbnail,
},
]);
setDraft(blankDraft);
setFormError(null);
};
return (
<div className="acve-page">
<section className="acve-panel acve-section-base">
<p className="acve-pill mb-4 w-fit">Eventos ACVE</p>
<h1 className="acve-heading text-4xl md:text-5xl">Calendario académico y networking</h1>
<p className="mt-3 max-w-3xl text-base text-muted-foreground md:text-lg">
Eventos ordenados cronológicamente. Haz clic en cualquier tarjeta para ver el detalle completo del evento.
</p>
<div className="acve-scrollbar-none mt-8 flex gap-4 overflow-x-auto pb-2">
{orderedEvents.map((eventCard) => {
const cardDate = formatCardDate(eventCard.date);
return (
<button
key={eventCard.id}
className="group relative flex min-h-[272px] min-w-[292px] max-w-[320px] flex-col overflow-hidden rounded-2xl border border-border/80 bg-card text-left shadow-sm transition hover:-translate-y-0.5 hover:border-primary/45 hover:shadow-md"
type="button"
onClick={() => setSelectedEvent(eventCard)}
>
<div className="h-32 w-full overflow-hidden border-b border-border/70">
<img
alt={`Miniatura de ${eventCard.title}`}
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
loading="lazy"
src={eventCard.thumbnail}
/>
</div>
<div className="flex flex-1 flex-col p-4">
<div className="flex items-center gap-3">
<div className="rounded-xl border border-primary/25 bg-primary/10 px-2 py-1 text-center text-primary">
<p className="text-lg font-bold leading-none">{cardDate.day}</p>
<p className="text-[11px] font-semibold tracking-wide">{cardDate.month}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">{eventCard.mode}</p>
<h2 className="text-base font-semibold text-foreground">{eventCard.title}</h2>
</div>
</div>
<p className="mt-4 text-sm text-muted-foreground">{eventCard.summary}</p>
<p className="mt-auto pt-4 text-xs font-semibold uppercase tracking-wide text-primary">{eventCard.location}</p>
</div>
</button>
);
})}
</div>
<div className="mt-6">
<Link className="acve-button-secondary inline-flex px-5 py-2.5 text-sm font-semibold hover:bg-accent" href="/#eventos">
Ver sección de eventos en Inicio
</Link>
</div>
</section>
<section className="acve-panel acve-section-base border-dashed">
<h2 className="acve-heading text-2xl md:text-3xl">Gestión de eventos</h2>
<p className="mt-2 text-sm text-muted-foreground md:text-base">Solo el equipo docente puede publicar nuevos eventos en este tablero.</p>
{isCheckingRole ? (
<p className="mt-4 text-sm text-muted-foreground">Verificando permisos...</p>
) : null}
{!isCheckingRole && !isTeacher ? (
<p className="mt-4 rounded-xl border border-border/80 bg-card/70 px-4 py-3 text-sm text-muted-foreground">
Tu perfil actual no tiene permisos de publicación. Si eres docente, inicia sesión con tu cuenta de profesor.
</p>
) : null}
{!isCheckingRole && isTeacher ? (
<form className="mt-6 grid gap-3 md:grid-cols-2" onSubmit={handleAddEvent}>
<input
required
className="rounded-xl border border-border bg-background px-3 py-2 text-sm"
placeholder="Título del evento"
value={draft.title}
onChange={(event) => setDraft((previous) => ({ ...previous, title: event.target.value }))}
/>
<input
required
className="rounded-xl border border-border bg-background px-3 py-2 text-sm"
type="date"
value={draft.date}
onChange={(event) => setDraft((previous) => ({ ...previous, date: event.target.value }))}
/>
<input
required
className="rounded-xl border border-border bg-background px-3 py-2 text-sm"
type="time"
value={draft.startTime}
onChange={(event) => setDraft((previous) => ({ ...previous, startTime: event.target.value }))}
/>
<input
required
className="rounded-xl border border-border bg-background px-3 py-2 text-sm"
type="time"
value={draft.endTime}
onChange={(event) => setDraft((previous) => ({ ...previous, endTime: event.target.value }))}
/>
<input
required
className="rounded-xl border border-border bg-background px-3 py-2 text-sm"
placeholder="Modalidad (Online, Presencial, Híbrido)"
value={draft.mode}
onChange={(event) => setDraft((previous) => ({ ...previous, mode: event.target.value }))}
/>
<input
required
className="rounded-xl border border-border bg-background px-3 py-2 text-sm"
placeholder="Ubicación"
value={draft.location}
onChange={(event) => setDraft((previous) => ({ ...previous, location: event.target.value }))}
/>
<textarea
required
className="min-h-[92px] rounded-xl border border-border bg-background px-3 py-2 text-sm md:col-span-2"
placeholder="Resumen breve para la tarjeta"
value={draft.summary}
onChange={(event) => setDraft((previous) => ({ ...previous, summary: event.target.value }))}
/>
<textarea
required
className="min-h-[120px] rounded-xl border border-border bg-background px-3 py-2 text-sm md:col-span-2"
placeholder="Detalle completo para el popup"
value={draft.details}
onChange={(event) => setDraft((previous) => ({ ...previous, details: event.target.value }))}
/>
<input
className="rounded-xl border border-border bg-background px-3 py-2 text-sm md:col-span-2"
placeholder="URL de miniatura (opcional)"
value={draft.thumbnail}
onChange={(event) => setDraft((previous) => ({ ...previous, thumbnail: event.target.value }))}
/>
{formError ? <p className="text-sm font-medium text-primary md:col-span-2">{formError}</p> : null}
<button className="acve-button-primary inline-flex w-fit items-center justify-center px-5 py-2.5 text-sm font-semibold md:col-span-2" type="submit">
Publicar evento
</button>
</form>
) : null}
</section>
{selectedEvent ? (
<div
aria-modal="true"
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/55 p-4"
role="dialog"
onClick={() => setSelectedEvent(null)}
>
<article
className="acve-panel relative w-full max-w-3xl overflow-hidden shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<button
aria-label="Cerrar detalle del evento"
className="absolute right-3 top-3 z-10 rounded-full border border-white/70 bg-white/90 px-3 py-1 text-xs font-semibold text-foreground hover:bg-white"
type="button"
onClick={() => setSelectedEvent(null)}
>
Cerrar
</button>
<div className="h-52 w-full overflow-hidden border-b border-border/70 md:h-72">
<img alt={`Imagen del evento ${selectedEvent.title}`} className="h-full w-full object-cover" src={selectedEvent.thumbnail} />
</div>
<div className="p-5 md:p-7">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">
{formatLongDate(selectedEvent.date)} · {selectedEvent.startTime} - {selectedEvent.endTime}
</p>
<h3 className="mt-2 text-2xl font-semibold text-foreground md:text-3xl">{selectedEvent.title}</h3>
<p className="mt-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{selectedEvent.mode} · {selectedEvent.location}
</p>
<p className="mt-4 text-sm leading-relaxed text-muted-foreground md:text-base">{selectedEvent.details}</p>
</div>
</article>
</div>
) : null}
</div>
);
}