267 lines
9.0 KiB
TypeScript
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>
|
|
);
|
|
}
|