463 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|