EventBuzz_Website/src/components/EventLandingAgenda.tsx

267 lines
9.0 KiB
TypeScript

import type { Dispatch, SetStateAction } from "react";
import { Calendar, ChevronDown, MapPin } from "lucide-react";
import type { VisitorSessionPreview } from "../lib/api";
import {
formatDateLong,
formatDurationMinutes,
formatTimeShort,
sessionTypeDisplay,
} from "../lib/sessionDisplay";
import type { AgendaRowTrackV2Bookmark, AgendaRowTrackV2Data } from "./AgendaRowTrackV2";
import { AgendaRowTrackV2 } from "./AgendaRowTrackV2";
export function landingSpeakerName(s: { firstName?: string; lastName?: string }): string {
return [s.firstName, s.lastName].filter(Boolean).join(" ").trim() || "Speaker";
}
export type LandingAgendaDay = {
dateKey: string;
dateLabel: string;
trackGroups: Array<{
trackId: string;
trackName: string;
trackHall?: string;
sessions: VisitorSessionPreview[];
}>;
};
export function groupSessionsIntoAgendaDays(sessions: VisitorSessionPreview[]): LandingAgendaDay[] {
if (!sessions.length) return [];
const localDateKey = (iso: string) => {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
};
const dayLabel = (iso: string) => {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
};
const byDay = new Map<string, VisitorSessionPreview[]>();
for (const s of sessions) {
const key = localDateKey(s.startTime);
if (!key) continue;
const arr = byDay.get(key) ?? [];
arr.push(s);
byDay.set(key, arr);
}
const keys = [...byDay.keys()].sort();
return keys.map((dateKey) => {
const daySessions = byDay.get(dateKey)!;
const labelIso = daySessions[0]?.startTime ?? "";
const byTrack = new Map<
string,
{
trackId: string;
trackName: string;
trackHall?: string;
sessions: VisitorSessionPreview[];
}
>();
for (const s of daySessions) {
const tid = s.track?._id ?? "__none__";
const tname = s.track?.name?.trim() || "General";
let g = byTrack.get(tid);
if (!g) {
g = {
trackId: tid,
trackName: tname,
trackHall: s.track?.hall?.trim() || undefined,
sessions: [],
};
byTrack.set(tid, g);
} else {
if (!g.trackHall && s.track?.hall?.trim()) g.trackHall = s.track.hall.trim();
}
g.sessions.push(s);
}
const trackGroups = [...byTrack.values()].sort((a, b) => {
const ta = new Date(a.sessions[0].startTime).getTime();
const tb = new Date(b.sessions[0].startTime).getTime();
return ta - tb;
});
for (const tg of trackGroups) {
tg.sessions.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
}
return { dateKey, dateLabel: dayLabel(labelIso), trackGroups };
});
}
function sessionToAgendaRowV2(
s: VisitorSessionPreview,
eventId: string,
bookmark: AgendaRowTrackV2Bookmark,
): AgendaRowTrackV2Data {
const names = (s.speakers || []).map(landingSpeakerName);
if (s.moderator) names.push(`${landingSpeakerName(s.moderator)} (Moderator)`);
const joinHref = `/session?${new URLSearchParams({ event_id: eventId, session_id: s._id }).toString()}`;
return {
time: formatTimeShort(s.startTime),
duration: formatDurationMinutes(s.startTime, s.endTime) || "—",
trackTitle: s.title,
sessionType: sessionTypeDisplay(s.type),
description:
s.description?.trim() || "Agenda details will appear here when available.",
speakerNames: names.length ? names : ["—"],
status: s.status === "ongoing" ? { kind: "live" } : null,
showJoin: true,
joinHref,
bookmark,
};
}
function sessionToMySessionsRowV2(
s: VisitorSessionPreview,
eventId: string,
bookmark: AgendaRowTrackV2Bookmark,
): AgendaRowTrackV2Data {
const base = sessionToAgendaRowV2(s, eventId, bookmark);
const hall = s.track?.hall?.trim();
const dateLbl = formatDateLong(s.startTime);
return {
...base,
...(dateLbl ? { sessionDateLabel: dateLbl } : {}),
...(hall ? { trackLocation: hall } : {}),
};
}
type LandingAgendaCardsProps = {
days: LandingAgendaDay[];
dayOpen: Record<string, boolean>;
setDayOpen: Dispatch<SetStateAction<Record<string, boolean>>>;
eventId: string;
bookmarkedIds: Set<string>;
toggleBookmark: (sessionId: string) => void | Promise<void>;
};
export function LandingAgendaCards({
days,
dayOpen,
setDayOpen,
eventId,
bookmarkedIds,
toggleBookmark,
}: LandingAgendaCardsProps) {
return (
<div className="mt-3 flex w-full max-w-none flex-col gap-2">
{days.map((day) => {
const isOpen = dayOpen[day.dateKey] ?? false;
const toggleDay = () => {
setDayOpen((p) => ({ ...p, [day.dateKey]: !(p[day.dateKey] ?? false) }));
};
return (
<div
key={day.dateKey}
className="w-full rounded-lg border-x-0 border-[rgba(116,112,112,0.2)] bg-white shadow-sm sm:border-x"
>
<button
type="button"
className="flex w-full cursor-pointer items-center justify-between gap-2 px-4 py-3 text-left"
onClick={toggleDay}
aria-expanded={isOpen}
>
<div className="flex min-w-0 items-center gap-2">
<Calendar className="size-4 shrink-0 text-[#1e72ef]" strokeWidth={1.75} />
<span className="font-inter text-sm font-semibold text-[#24282f]">{day.dateLabel}</span>
</div>
<ChevronDown
className={`size-4 shrink-0 text-[#1e72ef] transition-transform ${isOpen ? "rotate-180" : ""}`}
strokeWidth={2}
aria-hidden
/>
</button>
{isOpen ? (
<div className="w-full border-t border-[#f2f2f7] pb-4 pt-1">
{day.trackGroups.map((tg, tgIdx) => (
<div key={tg.trackId} className="w-full">
{tgIdx > 0 ? (
<div
className="my-5 w-full border-t-[3px] border-[#1e72ef] shadow-[0_1px_0_rgba(12,12,13,0.06)]"
role="separator"
aria-hidden
/>
) : null}
<div className="px-4 pt-2">
<p className="font-inter text-xs font-semibold uppercase tracking-wide text-[#8e8e93]">
{tg.trackName}
</p>
{tg.trackHall ? (
<div className="mt-2 flex items-start gap-2 text-sm text-[#24282f]">
<MapPin
className="mt-0.5 size-4 shrink-0 text-[#1e72ef]"
strokeWidth={1.75}
/>
<span className="font-inter leading-snug">{tg.trackHall}</span>
</div>
) : null}
</div>
<div className="relative mt-3 w-full max-w-none px-0">
<div className="relative w-full max-w-none rounded-none bg-[#fafafa] p-4 shadow-[0_1px_4px_rgba(12,12,13,0.05)] sm:rounded-md">
<div className="flex w-full flex-col gap-4">
{tg.sessions.map((s) => (
<AgendaRowTrackV2
key={s._id}
row={sessionToAgendaRowV2(s, eventId, {
isBookmarked: bookmarkedIds.has(s._id),
onToggle: () => void toggleBookmark(s._id),
})}
/>
))}
</div>
</div>
</div>
</div>
))}
</div>
) : null}
</div>
);
})}
</div>
);
}
export function LandingMySessionsList({
sessions,
eventId,
bookmarkedIds,
toggleBookmark,
}: {
sessions: VisitorSessionPreview[];
eventId: string;
bookmarkedIds: Set<string>;
toggleBookmark: (sessionId: string) => void | Promise<void>;
}) {
return (
<div className="mt-3 w-full max-w-none">
<div className="relative w-full">
<div className="relative w-full max-w-none rounded-none bg-[#fafafa] p-4 shadow-[0_1px_4px_rgba(12,12,13,0.05)] sm:rounded-md">
<div className="flex w-full flex-col gap-4">
{sessions.map((s) => (
<AgendaRowTrackV2
key={s._id}
row={sessionToMySessionsRowV2(s, eventId, {
isBookmarked: bookmarkedIds.has(s._id),
onToggle: () => void toggleBookmark(s._id),
})}
/>
))}
</div>
</div>
</div>
</div>
);
}