707 lines
28 KiB
TypeScript
707 lines
28 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { Bookmark, Calendar, ChevronDown, ChevronRight, Clock, ExternalLink, MapPin, MessageCircle, Star } from "lucide-react";
|
||
import { Link, useSearchParams } from "react-router-dom";
|
||
import { HeaderMobile } from "../components/HeaderMobile";
|
||
import {
|
||
groupSessionsIntoAgendaDays,
|
||
LandingAgendaCards,
|
||
LandingMySessionsList,
|
||
} from "../components/EventLandingAgenda";
|
||
import { HyperNavigationFAB } from "../components/HyperNavigationFAB";
|
||
import { MobileShell } from "../components/MobileShell";
|
||
import { SessionQuestionsCard } from "../components/SessionQuestionsCard";
|
||
import { SessionQuizPollsSection } from "../components/SessionQuizPollsSection";
|
||
import {
|
||
getVisitorEventAgenda,
|
||
getVisitorLoginPreview,
|
||
getVisitorSessionPreview,
|
||
type VisitorSessionPreview,
|
||
type VisitorSessionPreviewSpeaker,
|
||
} from "../lib/api";
|
||
import {
|
||
formatDateLong,
|
||
formatDurationMinutes,
|
||
formatTimeShort,
|
||
sessionTypeDisplay,
|
||
} from "@/lib/sessionDisplay";
|
||
import type { LoginPreviewResponse } from "../types/visitorContext";
|
||
import { readStoredVisitorContext } from "../types/visitorContext";
|
||
import { useSessionBookmarks } from "../hooks/useSessionBookmarks";
|
||
import { useEventRegistrationSettings } from "../hooks/useEventRegistrationSettings";
|
||
import { useAutoNavigateToLiveQuizPoll } from "../hooks/useAutoNavigateToLiveQuizPoll";
|
||
|
||
type SessionTabId = "key-persons" | "my-sessions" | "agenda";
|
||
|
||
const SESSION_TABS: { id: SessionTabId; label: string }[] = [
|
||
{ id: "key-persons", label: "Key Persons" },
|
||
{ id: "my-sessions", label: "My Sessions" },
|
||
{ id: "agenda", label: "Agenda" },
|
||
];
|
||
|
||
function sessionTabEnabled(session: VisitorSessionPreview, tabId: SessionTabId): boolean {
|
||
if (tabId === "key-persons") return session.audienceKeyPersonsEnabled !== false;
|
||
if (tabId === "my-sessions") return session.audienceMySessionsEnabled !== false;
|
||
if (tabId === "agenda") return session.audienceAgendaEnabled !== false;
|
||
return true;
|
||
}
|
||
|
||
function speakerName(s: { firstName?: string; lastName?: string }): string {
|
||
return [s.firstName, s.lastName].filter(Boolean).join(" ").trim() || "Speaker";
|
||
}
|
||
|
||
function absoluteUrl(href: string): string {
|
||
const t = href.trim();
|
||
if (!t) return "";
|
||
if (/^https?:\/\//i.test(t)) return t;
|
||
return `https://${t}`;
|
||
}
|
||
|
||
function KeyPersonRow({
|
||
person,
|
||
tag,
|
||
}: {
|
||
person: VisitorSessionPreviewSpeaker;
|
||
tag: string;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const name = speakerName(person);
|
||
const expertise = (person.expertise || []).filter((x) => String(x).trim());
|
||
const social = person.socialLinks;
|
||
const hasLinkedIn = Boolean(social?.linkedin?.trim());
|
||
const hasTwitter = Boolean(social?.twitter?.trim());
|
||
const hasWebsite = Boolean(social?.website?.trim());
|
||
const hasPublicExtras =
|
||
Boolean(person.organizationName?.trim()) || hasLinkedIn || hasTwitter || hasWebsite;
|
||
const hasExpandableContent = Boolean(
|
||
person.photo?.trim() ||
|
||
person.title?.trim() ||
|
||
person.bio?.trim() ||
|
||
expertise.length > 0 ||
|
||
hasPublicExtras,
|
||
);
|
||
|
||
return (
|
||
<li className="overflow-hidden rounded-lg border border-[rgba(116,112,112,0.2)] bg-white text-sm">
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((v) => !v)}
|
||
aria-expanded={open}
|
||
disabled={!hasExpandableContent}
|
||
className={`flex w-full items-center justify-between gap-3 px-3 py-3 text-left ${
|
||
!hasExpandableContent ? "cursor-default opacity-90" : ""
|
||
}`}
|
||
>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="font-semibold text-[#24282f]">{name}</p>
|
||
<span className="mt-1 inline-block rounded bg-[#e5e5ea] px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[#24282f]">
|
||
{tag}
|
||
</span>
|
||
</div>
|
||
{hasExpandableContent ? (
|
||
<ChevronDown
|
||
className={`size-5 shrink-0 text-[#1e72ef] transition-transform ${open ? "rotate-180" : ""}`}
|
||
strokeWidth={2}
|
||
aria-hidden
|
||
/>
|
||
) : null}
|
||
</button>
|
||
{open && hasExpandableContent ? (
|
||
<div className="border-t border-[#f2f2f7] bg-[#fafafa] px-3 py-3 text-[#24282f]">
|
||
<div className="flex flex-col gap-3">
|
||
{person.photo?.trim() ? (
|
||
<img
|
||
src={person.photo.trim()}
|
||
alt=""
|
||
className="h-24 w-24 rounded-full object-cover"
|
||
/>
|
||
) : null}
|
||
{person.title?.trim() ? (
|
||
<div>
|
||
<p className="text-[10px] font-semibold uppercase tracking-wide text-[#8e8e93]">Title</p>
|
||
<p className="mt-1 text-sm leading-snug">{person.title.trim()}</p>
|
||
</div>
|
||
) : null}
|
||
{expertise.length > 0 ? (
|
||
<div>
|
||
<p className="text-[10px] font-semibold uppercase tracking-wide text-[#8e8e93]">
|
||
Specialization
|
||
</p>
|
||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||
{expertise.map((x) => (
|
||
<span
|
||
key={x}
|
||
className="rounded-md bg-[#e8f0fe] px-2 py-0.5 text-xs font-medium text-[#1e72ef]"
|
||
>
|
||
{x}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{person.bio?.trim() ? (
|
||
<div>
|
||
<p className="text-[10px] font-semibold uppercase tracking-wide text-[#8e8e93]">Bio</p>
|
||
<p className="mt-1 whitespace-pre-wrap text-sm leading-snug">{person.bio.trim()}</p>
|
||
</div>
|
||
) : null}
|
||
{hasPublicExtras ? (
|
||
<div className="border-t border-[#e5e5ea] pt-3">
|
||
{person.organizationName?.trim() ? (
|
||
<div>
|
||
<p className="text-[10px] font-semibold uppercase tracking-wide text-[#8e8e93]">
|
||
Organisation
|
||
</p>
|
||
<p className="mt-1 text-sm leading-snug">{person.organizationName.trim()}</p>
|
||
</div>
|
||
) : null}
|
||
{hasLinkedIn || hasTwitter || hasWebsite ? (
|
||
<div className={person.organizationName?.trim() ? "mt-3" : ""}>
|
||
<p className="text-[10px] font-semibold uppercase tracking-wide text-[#8e8e93]">
|
||
Links
|
||
</p>
|
||
<div className="mt-1.5 flex flex-col gap-1.5">
|
||
{hasLinkedIn ? (
|
||
<a
|
||
href={absoluteUrl(social!.linkedin!)}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="inline-flex items-center gap-1.5 text-sm font-medium text-[#1e72ef] underline"
|
||
>
|
||
<ExternalLink className="size-3.5 shrink-0" strokeWidth={2} />
|
||
LinkedIn
|
||
</a>
|
||
) : null}
|
||
{hasTwitter ? (
|
||
<a
|
||
href={absoluteUrl(social!.twitter!)}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="inline-flex items-center gap-1.5 text-sm font-medium text-[#1e72ef] underline"
|
||
>
|
||
<ExternalLink className="size-3.5 shrink-0" strokeWidth={2} />
|
||
X (Twitter)
|
||
</a>
|
||
) : null}
|
||
{hasWebsite ? (
|
||
<a
|
||
href={absoluteUrl(social!.website!)}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="inline-flex items-center gap-1.5 text-sm font-medium text-[#1e72ef] underline"
|
||
>
|
||
<ExternalLink className="size-3.5 shrink-0" strokeWidth={2} />
|
||
Website
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</li>
|
||
);
|
||
}
|
||
|
||
function headerLogoForEvent(
|
||
eventId: string,
|
||
ev: LoginPreviewResponse | null | undefined,
|
||
ctx: ReturnType<typeof readStoredVisitorContext>,
|
||
): string | undefined {
|
||
const fromPreview = ev?.logoUrl?.trim();
|
||
if (fromPreview) return fromPreview;
|
||
if (ctx?.eventId === eventId && ctx.event?.logoUrl?.trim()) return ctx.event.logoUrl.trim();
|
||
return undefined;
|
||
}
|
||
|
||
function headerEventNameForEvent(
|
||
eventId: string,
|
||
ev: LoginPreviewResponse | null | undefined,
|
||
ctx: ReturnType<typeof readStoredVisitorContext>,
|
||
): string | undefined {
|
||
const fromPreview = ev?.name?.trim();
|
||
if (fromPreview) return fromPreview;
|
||
if (ctx?.eventId === eventId && ctx.event?.name?.trim()) return ctx.event.name.trim();
|
||
return undefined;
|
||
}
|
||
|
||
export function SingleEventPage() {
|
||
const [searchParams] = useSearchParams();
|
||
const eventId = searchParams.get("event_id")?.trim() || "";
|
||
const sessionId = searchParams.get("session_id")?.trim() || "";
|
||
const ctx = readStoredVisitorContext();
|
||
|
||
const [session, setSession] = useState<VisitorSessionPreview | null>(null);
|
||
const [eventInfo, setEventInfo] = useState<LoginPreviewResponse | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
const [tab, setTab] = useState(0);
|
||
const [sessionMetaOpen, setSessionMetaOpen] = useState(false);
|
||
const { bookmarkedIds, toggle: toggleBookmark } = useSessionBookmarks(eventId);
|
||
|
||
const [agendaSessions, setAgendaSessions] = useState<VisitorSessionPreview[]>([]);
|
||
const [agendaLoading, setAgendaLoading] = useState(false);
|
||
const [agendaErr, setAgendaErr] = useState<string | null>(null);
|
||
const [dayOpen, setDayOpen] = useState<Record<string, boolean>>({});
|
||
|
||
const reg = useEventRegistrationSettings(eventInfo);
|
||
|
||
useAutoNavigateToLiveQuizPoll({
|
||
eventId,
|
||
sessionId,
|
||
enabled: Boolean(eventId && sessionId && !loading && !err && session),
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (!eventId || !sessionId) {
|
||
setErr("Missing session link. Open the session from your event QR code or invite.");
|
||
setLoading(false);
|
||
setSession(null);
|
||
setEventInfo(null);
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
setLoading(true);
|
||
setErr(null);
|
||
|
||
(async () => {
|
||
try {
|
||
const sess = await getVisitorSessionPreview(sessionId, eventId);
|
||
if (cancelled) return;
|
||
setSession(sess);
|
||
if (ctx?.eventId === eventId && ctx.event) {
|
||
setEventInfo(ctx.event);
|
||
} else {
|
||
const ev = await getVisitorLoginPreview(eventId);
|
||
if (cancelled) return;
|
||
setEventInfo(ev);
|
||
}
|
||
} catch (e) {
|
||
if (!cancelled) {
|
||
setErr(e instanceof Error ? e.message : "Could not load session.");
|
||
setSession(null);
|
||
setEventInfo(null);
|
||
}
|
||
} finally {
|
||
if (!cancelled) setLoading(false);
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [eventId, sessionId, ctx?.eventId]);
|
||
|
||
const visibleTabs = useMemo(() => {
|
||
if (!session) return [];
|
||
return SESSION_TABS.filter((t) => sessionTabEnabled(session, t.id));
|
||
}, [session]);
|
||
|
||
useEffect(() => {
|
||
setTab((t) => (t >= visibleTabs.length ? 0 : t));
|
||
}, [visibleTabs.length]);
|
||
|
||
const activeTabId = visibleTabs[tab]?.id ?? null;
|
||
|
||
const needsAgendaData = session
|
||
? session.audienceMySessionsEnabled !== false || session.audienceAgendaEnabled !== false
|
||
: true;
|
||
|
||
useEffect(() => {
|
||
if (!eventId || !needsAgendaData) {
|
||
setAgendaSessions([]);
|
||
setAgendaErr(null);
|
||
setAgendaLoading(false);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
setAgendaLoading(true);
|
||
setAgendaErr(null);
|
||
setAgendaSessions([]);
|
||
getVisitorEventAgenda(eventId)
|
||
.then((sessions) => {
|
||
if (!cancelled) setAgendaSessions(sessions);
|
||
})
|
||
.catch((e) => {
|
||
if (!cancelled) {
|
||
setAgendaErr(e instanceof Error ? e.message : "Could not load agenda.");
|
||
setAgendaSessions([]);
|
||
}
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) setAgendaLoading(false);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [eventId, needsAgendaData]);
|
||
|
||
useEffect(() => {
|
||
setDayOpen({});
|
||
}, [eventId]);
|
||
|
||
const agendaDays = useMemo(() => groupSessionsIntoAgendaDays(agendaSessions), [agendaSessions]);
|
||
|
||
const bookmarkedSessionsSorted = useMemo(() => {
|
||
if (!bookmarkedIds.size) return [];
|
||
return agendaSessions
|
||
.filter((s) => bookmarkedIds.has(s._id))
|
||
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
|
||
}, [agendaSessions, bookmarkedIds]);
|
||
|
||
useEffect(() => {
|
||
const first = agendaDays[0]?.dateKey;
|
||
if (!first) return;
|
||
setDayOpen((prev) => {
|
||
if (Object.keys(prev).length > 0) return prev;
|
||
return { [first]: true };
|
||
});
|
||
}, [agendaDays]);
|
||
|
||
if (!eventId || !sessionId) {
|
||
return (
|
||
<MobileShell className="relative">
|
||
<HeaderMobile
|
||
eventName={headerEventNameForEvent(eventId, null, ctx)}
|
||
eventId={eventId || null}
|
||
showBack={false}
|
||
/>
|
||
<main className="flex flex-1 flex-col items-center justify-center bg-[#f2f2f7] px-4 py-10">
|
||
<p className="text-center text-sm text-[#24282f]">{err}</p>
|
||
<Link to="/" className="mt-4 text-sm font-semibold text-[#1e72ef] underline">
|
||
Home
|
||
</Link>
|
||
</main>
|
||
</MobileShell>
|
||
);
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<MobileShell className="relative">
|
||
<HeaderMobile
|
||
logoUrl={headerLogoForEvent(eventId, null, ctx)}
|
||
eventName={headerEventNameForEvent(eventId, null, ctx)}
|
||
eventId={eventId || null}
|
||
showBack={false}
|
||
/>
|
||
<main className="flex flex-1 items-center justify-center bg-[#f2f2f7] px-4 py-10">
|
||
<p className="text-sm text-[#24282f]">Loading session…</p>
|
||
</main>
|
||
</MobileShell>
|
||
);
|
||
}
|
||
|
||
if (err || !session) {
|
||
return (
|
||
<MobileShell className="relative">
|
||
<HeaderMobile
|
||
logoUrl={headerLogoForEvent(eventId, eventInfo, ctx)}
|
||
eventName={headerEventNameForEvent(eventId, eventInfo, ctx)}
|
||
eventId={eventId || null}
|
||
showBack={false}
|
||
/>
|
||
<main className="flex flex-1 flex-col items-center justify-center bg-[#f2f2f7] px-4 py-10">
|
||
<p className="text-center text-sm text-red-600">{err ?? "Session not found."}</p>
|
||
</main>
|
||
</MobileShell>
|
||
);
|
||
}
|
||
|
||
const ev = eventInfo;
|
||
const typeLabel = sessionTypeDisplay(session.type);
|
||
const trackTitle = session.track?.name?.trim() || typeLabel;
|
||
const startT = formatTimeShort(session.startTime);
|
||
const endT = formatTimeShort(session.endTime);
|
||
const timeLine = startT && endT ? `${startT} – ${endT}` : startT;
|
||
const duration = formatDurationMinutes(session.startTime, session.endTime);
|
||
const sessionDate = formatDateLong(session.startTime);
|
||
const live = session.status === "ongoing";
|
||
const sessionBookmarked = bookmarkedIds.has(sessionId);
|
||
|
||
const headerLogoUrl = headerLogoForEvent(eventId, ev, ctx);
|
||
const headerEventName = headerEventNameForEvent(eventId, ev, ctx);
|
||
|
||
const sessionAllowsFeedback = session.audienceFeedbackEnabled !== false;
|
||
const sessionAllowsRatings = session.audienceRatingsEnabled !== false;
|
||
const showSessionFeedback = reg.enableFeedback && sessionAllowsFeedback;
|
||
const showSessionRatings = reg.enableRatings && sessionAllowsRatings;
|
||
|
||
const sessionFeedbackHref = (() => {
|
||
const q = new URLSearchParams({ event_id: eventId, session_id: sessionId });
|
||
const fid = session.audienceFeedbackForm?.trim();
|
||
if (fid) q.set("form_id", fid);
|
||
return `/feedback?${q.toString()}`;
|
||
})();
|
||
const sessionRatingsHref = (() => {
|
||
const q = new URLSearchParams({ event_id: eventId, session_id: sessionId });
|
||
const fid = session.audienceRatingsForm?.trim();
|
||
if (fid) q.set("form_id", fid);
|
||
return `/ratings?${q.toString()}`;
|
||
})();
|
||
|
||
const sessionNotes = session.description?.trim() ?? "";
|
||
const sessionLocation = session.location?.trim() ?? "";
|
||
const trackHall = session.track?.hall?.trim() ?? "";
|
||
const hasCapacity = session.capacity != null && session.capacity > 0;
|
||
const hasExpandableSessionMeta =
|
||
Boolean(sessionNotes || sessionLocation || trackHall || hasCapacity);
|
||
|
||
return (
|
||
<MobileShell className="relative bg-transparent">
|
||
<HeaderMobile
|
||
logoUrl={headerLogoUrl}
|
||
eventName={headerEventName}
|
||
eventId={eventId || null}
|
||
showBack={false}
|
||
/>
|
||
|
||
{ev?.bannerImage?.trim() ? (
|
||
<div className="w-full overflow-hidden bg-[#e5e5ea]">
|
||
<img
|
||
src={ev.bannerImage.trim()}
|
||
alt=""
|
||
className="max-h-[200px] w-full object-cover object-center"
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="eb-gradient-banne card-bg relative flex flex-col items-cente justify-center rounded-b-lg gap-1.5 px-6 pb-4 pt-3 text-cente text-whit text-[var(--theme-card-text-color)]">
|
||
<div className="absolute right-3 top-2.5 z-10 flex max-w-[calc(100%-4.5rem)] flex-col items-end gap-1.5">
|
||
{live ? (
|
||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-[#14ae5c] px-2 py-0.5 text-[10px] font-semibold text-white">
|
||
<span className="size-1 rounded-full bg-white" />
|
||
LIVE
|
||
</span>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleBookmark(sessionId)}
|
||
className="shrink-0 rounded-md p-1 text-[var(--theme-button-color)] hover:bg-white/15"
|
||
aria-label={sessionBookmarked ? "Remove bookmark" : "Bookmark session"}
|
||
aria-pressed={sessionBookmarked}
|
||
>
|
||
<Bookmark className={`size-6 ${sessionBookmarked ? "fill-current" : ""}`} strokeWidth={1.75} />
|
||
</button>
|
||
</div>
|
||
<div className="flex max-w-full flex-wrap items-center justify-cente gap-x-2 gap-y-1 pe-2 text-[10px]">
|
||
<span className="rounded bg-[var(--theme-button-color)]/20 px-2 py-0.5">{typeLabel}</span>
|
||
{timeLine ? <span>{timeLine}</span> : null}
|
||
{trackTitle !== typeLabel ? <span className="opacity-90">{trackTitle}</span> : null}
|
||
</div>
|
||
<h1 className="font-inter text-2xl font-bold tracking-tight">{session.title}</h1>
|
||
<div className="flex w-full max-w-full flex-wrap items-center justify-end gap-x-3 gap-y-1 pr-1 text-sm text-[var(--theme-card-text-color)]/70">
|
||
{duration ? (
|
||
<span className="flex items-center gap-1.5">
|
||
<Clock className="size-4 shrink-0" strokeWidth={1.75} />
|
||
{startT} ({duration})
|
||
</span>
|
||
) : startT ? (
|
||
<span className="flex items-center gap-1.5">
|
||
<Clock className="size-4 shrink-0" strokeWidth={1.75} />
|
||
{startT}
|
||
</span>
|
||
) : null}
|
||
{sessionDate ? (
|
||
<span className="flex items-center gap-1.5">
|
||
<Calendar className="size-4 shrink-0" strokeWidth={1.75} />
|
||
{sessionDate}
|
||
</span>
|
||
) : null}
|
||
{hasExpandableSessionMeta ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setSessionMetaOpen((o) => !o)}
|
||
aria-expanded={sessionMetaOpen}
|
||
aria-label={
|
||
sessionMetaOpen ? "Hide session notes and location" : "Show session notes and location"
|
||
}
|
||
className="inline-flex shrink-0 items-center rounded-md p-0.5 text-[var(--theme-card-text-color)] hover:bg-[var(--theme-button-color)]/15"
|
||
>
|
||
<ChevronDown
|
||
className={`size-5 transition-transform ${sessionMetaOpen ? "rotate-180" : ""}`}
|
||
strokeWidth={2}
|
||
aria-hidden
|
||
/>
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
|
||
{sessionMetaOpen && hasExpandableSessionMeta ? (
|
||
<div className="mt-2 w-full max-w-full border-t border-[var(--theme-card-color)]/35 px-1 pt-3 text-left text-sm text-[var(--theme-card-text-color)]/95">
|
||
{sessionNotes ? (
|
||
<div className="mb-3">
|
||
<p className="text-[10px] font-semibold uppercase tracking-wide text-[var(--theme-card-text-color)]/80">Notes</p>
|
||
<p className="mt-1.5 whitespace-pre-wrap leading-snug">{sessionNotes}</p>
|
||
</div>
|
||
) : null}
|
||
{sessionLocation || trackHall ? (
|
||
<div>
|
||
<p className="text-[10px] font-semibold uppercase tracking-wide text-[var(--theme-card-text-color)]/80">Location</p>
|
||
<ul className="mt-1.5 flex flex-col gap-1.5">
|
||
{sessionLocation ? (
|
||
<li className="flex items-start gap-2 leading-snug">
|
||
<MapPin className="mt-0.5 size-4 shrink-0 text-[var(--theme-button-color)]" strokeWidth={1.75} />
|
||
<span>{sessionLocation}</span>
|
||
</li>
|
||
) : null}
|
||
{trackHall ? (
|
||
<li className="flex items-start gap-2 leading-snug">
|
||
<MapPin className="mt-0.5 size-4 shrink-0 text-white/90" strokeWidth={1.75} />
|
||
<span>{trackHall}</span>
|
||
</li>
|
||
) : null}
|
||
</ul>
|
||
</div>
|
||
) : null}
|
||
{hasCapacity ? (
|
||
<p className="mt-3 text-xs text-white/85">Capacity: {session.capacity}</p>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<main className="test-class relative z-0 w-full max-w-none flex-1 bg-transparent px-4 pb-28 pt-4">
|
||
<div className="flex w-full max-w-none flex-col gap-4">
|
||
{session && session.enableQnA !== false ? (
|
||
<SessionQuestionsCard
|
||
title="Audience Questions"
|
||
showQuestionNav
|
||
eventId={eventId}
|
||
sessionId={sessionId}
|
||
/>
|
||
) : null}
|
||
|
||
<SessionQuizPollsSection eventId={eventId} sessionId={sessionId} />
|
||
|
||
{showSessionFeedback || showSessionRatings ? (
|
||
<div className="flex w-full max-w-none flex-col gap-2">
|
||
<p className="px-1 text-[10px] font-semibold uppercase tracking-wide text-[#8e8e93]">
|
||
Feedback & ratings
|
||
</p>
|
||
<div className="flex flex-col gap-2">
|
||
{showSessionFeedback ? (
|
||
<Link
|
||
to={sessionFeedbackHref}
|
||
className="flex items-center justify-between gap-3 rounded-lg border border-[rgba(116,112,112,0.2)] bg-whit card-bg px-3 py-3 text-sm font-medium text-[#24282f] shadow-[0_1px_4px_rgba(12,12,13,0.05)] active:bg-[#f2f2f7]"
|
||
>
|
||
<span className="flex min-w-0 items-center gap-2.5">
|
||
<MessageCircle className="size-[18px] shrink-0 text-[#1e72ef]" strokeWidth={1.75} />
|
||
<span className="truncate">Session feedback</span>
|
||
</span>
|
||
<ChevronRight className="size-4 shrink-0 text-[#c7c7cc]" strokeWidth={2} aria-hidden />
|
||
</Link>
|
||
) : null}
|
||
{showSessionRatings ? (
|
||
<Link
|
||
to={sessionRatingsHref}
|
||
className="flex items-center justify-between gap-3 rounded-lg border border-[rgba(116,112,112,0.2)] bg-whit card-bg px-3 py-3 text-sm font-medium text-[#24282f] shadow-[0_1px_4px_rgba(12,12,13,0.05)] active:bg-[#f2f2f7]"
|
||
>
|
||
<span className="flex min-w-0 items-center gap-2.5">
|
||
<Star className="size-[18px] shrink-0 text-[#1e72ef]" strokeWidth={1.75} />
|
||
<span className="truncate">Rate this session</span>
|
||
</span>
|
||
<ChevronRight className="size-4 shrink-0 text-[#c7c7cc]" strokeWidth={2} aria-hidden />
|
||
</Link>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{visibleTabs.length > 0 ? (
|
||
<div className="flex w-full border-b border-[#c7c7cc] bg-transparent">
|
||
{visibleTabs.map((t, i) => (
|
||
<button
|
||
key={t.id}
|
||
type="button"
|
||
onClick={() => setTab(i)}
|
||
className={`flex-1 px-1 pb-2 pt-3 text-center text-sm sm:px-2 ${
|
||
tab === i
|
||
? "border-b-2 border-[#1e72ef] font-semibold text-[#24282f]"
|
||
: "-mb-px border-b-2 border-transparent font-normal text-[#24282f]/80"
|
||
}`}
|
||
>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
{activeTabId === "key-persons" ? (
|
||
<ul className="flex flex-col gap-3">
|
||
{(session.speakers || []).map((s) => (
|
||
<KeyPersonRow
|
||
key={s._id}
|
||
person={s}
|
||
tag={s.sessionRole?.trim() || s.speakerRole?.trim() || "Speaker"}
|
||
/>
|
||
))}
|
||
{session.moderator ? (
|
||
<KeyPersonRow
|
||
key={`mod-${session.moderator._id}`}
|
||
person={session.moderator}
|
||
tag={session.moderator.speakerRole?.trim() || "Moderator"}
|
||
/>
|
||
) : null}
|
||
{(!session.speakers || session.speakers.length === 0) && !session.moderator ? (
|
||
<p className="text-sm text-[rgba(36,40,47,0.6)]">No speakers listed yet.</p>
|
||
) : null}
|
||
</ul>
|
||
) : null}
|
||
|
||
{activeTabId === "my-sessions" ? (
|
||
<div className="w-full max-w-none">
|
||
{agendaLoading && !agendaSessions.length ? (
|
||
<p className="text-sm text-[rgba(36,40,47,0.55)]">Loading…</p>
|
||
) : bookmarkedSessionsSorted.length === 0 ? (
|
||
<p className="text-sm text-[rgba(36,40,47,0.55)]">
|
||
No bookmarked sessions yet. Tap the bookmark on any agenda session to save it here.
|
||
</p>
|
||
) : (
|
||
<LandingMySessionsList
|
||
sessions={bookmarkedSessionsSorted}
|
||
eventId={eventId}
|
||
bookmarkedIds={bookmarkedIds}
|
||
toggleBookmark={toggleBookmark}
|
||
/>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{activeTabId === "agenda" ? (
|
||
<div className="w-full max-w-none">
|
||
{agendaErr ? <p className="text-sm text-red-600">{agendaErr}</p> : null}
|
||
{agendaLoading && !agendaSessions.length ? (
|
||
<p className="text-sm text-[rgba(36,40,47,0.55)]">Loading agenda…</p>
|
||
) : agendaDays.length === 0 ? (
|
||
<p className="text-sm text-[rgba(36,40,47,0.55)]">No sessions scheduled for this event yet.</p>
|
||
) : (
|
||
<LandingAgendaCards
|
||
days={agendaDays}
|
||
dayOpen={dayOpen}
|
||
setDayOpen={setDayOpen}
|
||
eventId={eventId}
|
||
bookmarkedIds={bookmarkedIds}
|
||
toggleBookmark={toggleBookmark}
|
||
/>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</main>
|
||
|
||
<HyperNavigationFAB
|
||
eventId={eventId}
|
||
sessionId={sessionId}
|
||
showFeedbackNav={showSessionFeedback}
|
||
showRatingsNav={showSessionRatings}
|
||
feedbackFormId={showSessionFeedback ? session.audienceFeedbackForm?.trim() || undefined : undefined}
|
||
ratingsFormId={showSessionRatings ? session.audienceRatingsForm?.trim() || undefined : undefined}
|
||
/>
|
||
</MobileShell>
|
||
);
|
||
}
|