Improve event landing agenda and presentation flows.
Update session display, present menus, and related attendee app pages. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
228b81630e
commit
ecae5a7eb2
|
|
@ -0,0 +1,55 @@
|
||||||
|
# nginx example for join.eventbuzz.live (participant SPA from `eventbuzz/`)
|
||||||
|
#
|
||||||
|
# Deploy: cd eventbuzz && npm run build → serve `dist` here.
|
||||||
|
# API CORS must allow https://join.eventbuzz.live; backend EVENTBUZZ_PUBLIC_URL
|
||||||
|
# must be https://join.eventbuzz.live (join codes, magic links, quiz-present).
|
||||||
|
#
|
||||||
|
# Dashboard stays on console.eventbuzz.live; microsites on web.eventbuzz.live;
|
||||||
|
# marketing apex eventbuzz.live can redirect legacy participant paths (below).
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name join.eventbuzz.live;
|
||||||
|
|
||||||
|
# ssl_certificate ...
|
||||||
|
# ssl_certificate_key ...
|
||||||
|
|
||||||
|
root /var/www/eventbuzz-join/dist;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
try_files $uri =404;
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback — /code/:code, /auth/callback, /quiz-present/..., event/session routes
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional: keep old printed QRs / emails working after host move.
|
||||||
|
# Place on the marketing apex (eventbuzz.live) vhost — not on join.
|
||||||
|
#
|
||||||
|
# server {
|
||||||
|
# listen 443 ssl http2;
|
||||||
|
# server_name eventbuzz.live;
|
||||||
|
# # ... marketing site ...
|
||||||
|
#
|
||||||
|
# location ^~ /code/ {
|
||||||
|
# return 301 https://join.eventbuzz.live$request_uri;
|
||||||
|
# }
|
||||||
|
# location ^~ /auth/callback {
|
||||||
|
# return 301 https://join.eventbuzz.live$request_uri;
|
||||||
|
# }
|
||||||
|
# location ^~ /quiz-present/ {
|
||||||
|
# return 301 https://join.eventbuzz.live$request_uri;
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
|
||||||
|
# Local development
|
||||||
|
# - API: http://localhost:3001/api
|
||||||
|
# - Participant SPA: cd eventbuzz && npm run dev (port 5174)
|
||||||
|
# - Dashboard: cd frontend && npm run dev
|
||||||
|
# - Backend .env: EVENTBUZZ_PUBLIC_URL=http://localhost:5174 (or https://join.eventbuzz.live in prod)
|
||||||
|
|
@ -17,6 +17,7 @@ import { RewardsPage } from "./pages/RewardsPage";
|
||||||
import { SingleEventPage } from "./pages/SingleEventPage";
|
import { SingleEventPage } from "./pages/SingleEventPage";
|
||||||
import { QuizPollTakePage } from "./pages/QuizPollTakePage";
|
import { QuizPollTakePage } from "./pages/QuizPollTakePage";
|
||||||
import { QuizPollPresentPage } from "./pages/QuizPollPresentPage";
|
import { QuizPollPresentPage } from "./pages/QuizPollPresentPage";
|
||||||
|
import { QuestionsPresentPage } from "./pages/QuestionsPresentPage";
|
||||||
import { CodeEntryPage } from "./pages/CodeEntryPage";
|
import { CodeEntryPage } from "./pages/CodeEntryPage";
|
||||||
import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage";
|
import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage";
|
||||||
import { TermsOfServicePage } from "./pages/TermsOfServicePage";
|
import { TermsOfServicePage } from "./pages/TermsOfServicePage";
|
||||||
|
|
@ -39,6 +40,7 @@ export default function App() {
|
||||||
<Route path="/session" element={<SingleEventPage />} />
|
<Route path="/session" element={<SingleEventPage />} />
|
||||||
<Route path="/quiz-poll" element={<QuizPollTakePage />} />
|
<Route path="/quiz-poll" element={<QuizPollTakePage />} />
|
||||||
<Route path="/quiz-present/:token" element={<QuizPollPresentPage />} />
|
<Route path="/quiz-present/:token" element={<QuizPollPresentPage />} />
|
||||||
|
<Route path="/questions-present/:sessionId" element={<QuestionsPresentPage />} />
|
||||||
<Route path="/questions" element={<Questions />} />
|
<Route path="/questions" element={<Questions />} />
|
||||||
<Route path="/ask" element={<Navigate to="/questions" replace />} />
|
<Route path="/ask" element={<Navigate to="/questions" replace />} />
|
||||||
<Route path="/feedback" element={<FeedbackPage />} />
|
<Route path="/feedback" element={<FeedbackPage />} />
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ export type AgendaRowTrackV2Data = {
|
||||||
showJoin?: boolean;
|
showJoin?: boolean;
|
||||||
/** When set with `showJoin`, Join navigates here instead of a no-op button. */
|
/** When set with `showJoin`, Join navigates here instead of a no-op button. */
|
||||||
joinHref?: string;
|
joinHref?: string;
|
||||||
|
/** Session is in progress — highlight Join and show a blinking live dot. */
|
||||||
|
joinLive?: boolean;
|
||||||
/** When set, bookmark control is shown and toggles this session on the device. */
|
/** When set, bookmark control is shown and toggles this session on the device. */
|
||||||
bookmark?: AgendaRowTrackV2Bookmark;
|
bookmark?: AgendaRowTrackV2Bookmark;
|
||||||
/** Shown before clock time (e.g. full date on “My Sessions”). */
|
/** Shown before clock time (e.g. full date on “My Sessions”). */
|
||||||
|
|
@ -32,6 +34,9 @@ export type AgendaRowTrackV2Data = {
|
||||||
|
|
||||||
export function AgendaRowTrackV2({ row }: { row: AgendaRowTrackV2Data }) {
|
export function AgendaRowTrackV2({ row }: { row: AgendaRowTrackV2Data }) {
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
|
const joinBtnClass = row.joinLive
|
||||||
|
? "inline-flex h-8 w-[84px] items-center justify-between rounded-full bg-[#e67e00] pl-[18px] pr-2 text-xs font-semibold uppercase text-white shadow-[0_0_0_2px_rgba(239,141,29,0.5),0_4px_14px_rgba(239,141,29,0.45)] ring-2 ring-[#ffb347]/40"
|
||||||
|
: "inline-flex h-8 w-[84px] items-center justify-between rounded-full bg-[#ef8d1d] pl-[18px] pr-2 text-xs font-semibold uppercase text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full pb-4">
|
<div className="relative w-full pb-4">
|
||||||
|
|
@ -132,24 +137,24 @@ export function AgendaRowTrackV2({ row }: { row: AgendaRowTrackV2Data }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{row.showJoin &&
|
{row.showJoin ? (
|
||||||
(row.joinHref ? (
|
<div className="mt-1 flex items-center gap-2">
|
||||||
<Link
|
{row.joinHref ? (
|
||||||
to={row.joinHref}
|
<Link to={row.joinHref} className={joinBtnClass}>
|
||||||
className="mt-1 inline-flex h-8 w-[84px] items-center justify-between rounded-full bg-[#ef8d1d] pl-[18px] pr-2 text-xs font-semibold uppercase text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)]"
|
|
||||||
>
|
|
||||||
Join
|
Join
|
||||||
<ChevronRight className="size-3" strokeWidth={2.5} />
|
<ChevronRight className="size-3" strokeWidth={2.5} />
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button type="button" className={joinBtnClass}>
|
||||||
type="button"
|
|
||||||
className="mt-1 inline-flex h-8 w-[84px] items-center justify-between rounded-full bg-[#ef8d1d] pl-[18px] pr-2 text-xs font-semibold uppercase text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)]"
|
|
||||||
>
|
|
||||||
Join
|
Join
|
||||||
<ChevronRight className="size-3" strokeWidth={2.5} />
|
<ChevronRight className="size-3" strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
)}
|
||||||
|
{row.joinLive ? (
|
||||||
|
<span className="eb-live-dot" aria-hidden />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ import {
|
||||||
formatDateLong,
|
formatDateLong,
|
||||||
formatDurationMinutes,
|
formatDurationMinutes,
|
||||||
formatTimeShort,
|
formatTimeShort,
|
||||||
|
isSessionLiveNow,
|
||||||
sessionTypeDisplay,
|
sessionTypeDisplay,
|
||||||
} from "../lib/sessionDisplay";
|
} from "../lib/sessionDisplay";
|
||||||
|
import { useNow } from "../hooks/useNow";
|
||||||
import type { AgendaRowTrackV2Bookmark, AgendaRowTrackV2Data } from "./AgendaRowTrackV2";
|
import type { AgendaRowTrackV2Bookmark, AgendaRowTrackV2Data } from "./AgendaRowTrackV2";
|
||||||
import { AgendaRowTrackV2 } from "./AgendaRowTrackV2";
|
import { AgendaRowTrackV2 } from "./AgendaRowTrackV2";
|
||||||
|
|
||||||
|
|
@ -103,10 +105,12 @@ function sessionToAgendaRowV2(
|
||||||
s: VisitorSessionPreview,
|
s: VisitorSessionPreview,
|
||||||
eventId: string,
|
eventId: string,
|
||||||
bookmark: AgendaRowTrackV2Bookmark,
|
bookmark: AgendaRowTrackV2Bookmark,
|
||||||
|
nowMs: number,
|
||||||
): AgendaRowTrackV2Data {
|
): AgendaRowTrackV2Data {
|
||||||
const names = (s.speakers || []).map(landingSpeakerName);
|
const names = (s.speakers || []).map(landingSpeakerName);
|
||||||
if (s.moderator) names.push(`${landingSpeakerName(s.moderator)} (Moderator)`);
|
if (s.moderator) names.push(`${landingSpeakerName(s.moderator)} (Moderator)`);
|
||||||
const joinHref = `/session?${new URLSearchParams({ event_id: eventId, session_id: s._id }).toString()}`;
|
const joinHref = `/session?${new URLSearchParams({ event_id: eventId, session_id: s._id }).toString()}`;
|
||||||
|
const liveNow = isSessionLiveNow(s.startTime, s.endTime, nowMs) || s.status === "ongoing";
|
||||||
return {
|
return {
|
||||||
time: formatTimeShort(s.startTime),
|
time: formatTimeShort(s.startTime),
|
||||||
duration: formatDurationMinutes(s.startTime, s.endTime) || "—",
|
duration: formatDurationMinutes(s.startTime, s.endTime) || "—",
|
||||||
|
|
@ -115,9 +119,10 @@ function sessionToAgendaRowV2(
|
||||||
description:
|
description:
|
||||||
s.description?.trim() || "Agenda details will appear here when available.",
|
s.description?.trim() || "Agenda details will appear here when available.",
|
||||||
speakerNames: names.length ? names : ["—"],
|
speakerNames: names.length ? names : ["—"],
|
||||||
status: s.status === "ongoing" ? { kind: "live" } : null,
|
status: liveNow ? { kind: "live" } : null,
|
||||||
showJoin: true,
|
showJoin: true,
|
||||||
joinHref,
|
joinHref,
|
||||||
|
joinLive: liveNow,
|
||||||
bookmark,
|
bookmark,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -126,8 +131,9 @@ function sessionToMySessionsRowV2(
|
||||||
s: VisitorSessionPreview,
|
s: VisitorSessionPreview,
|
||||||
eventId: string,
|
eventId: string,
|
||||||
bookmark: AgendaRowTrackV2Bookmark,
|
bookmark: AgendaRowTrackV2Bookmark,
|
||||||
|
nowMs: number,
|
||||||
): AgendaRowTrackV2Data {
|
): AgendaRowTrackV2Data {
|
||||||
const base = sessionToAgendaRowV2(s, eventId, bookmark);
|
const base = sessionToAgendaRowV2(s, eventId, bookmark, nowMs);
|
||||||
const hall = s.track?.hall?.trim();
|
const hall = s.track?.hall?.trim();
|
||||||
const dateLbl = formatDateLong(s.startTime);
|
const dateLbl = formatDateLong(s.startTime);
|
||||||
return {
|
return {
|
||||||
|
|
@ -154,6 +160,8 @@ export function LandingAgendaCards({
|
||||||
bookmarkedIds,
|
bookmarkedIds,
|
||||||
toggleBookmark,
|
toggleBookmark,
|
||||||
}: LandingAgendaCardsProps) {
|
}: LandingAgendaCardsProps) {
|
||||||
|
const nowMs = useNow();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 flex w-full max-w-none flex-col gap-2">
|
<div className="mt-3 flex w-full max-w-none flex-col gap-2">
|
||||||
{days.map((day) => {
|
{days.map((day) => {
|
||||||
|
|
@ -216,7 +224,7 @@ export function LandingAgendaCards({
|
||||||
row={sessionToAgendaRowV2(s, eventId, {
|
row={sessionToAgendaRowV2(s, eventId, {
|
||||||
isBookmarked: bookmarkedIds.has(s._id),
|
isBookmarked: bookmarkedIds.has(s._id),
|
||||||
onToggle: () => void toggleBookmark(s._id),
|
onToggle: () => void toggleBookmark(s._id),
|
||||||
})}
|
}, nowMs)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -244,6 +252,8 @@ export function LandingMySessionsList({
|
||||||
bookmarkedIds: Set<string>;
|
bookmarkedIds: Set<string>;
|
||||||
toggleBookmark: (sessionId: string) => void | Promise<void>;
|
toggleBookmark: (sessionId: string) => void | Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
|
const nowMs = useNow();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 w-full max-w-none">
|
<div className="mt-3 w-full max-w-none">
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
|
|
@ -255,7 +265,7 @@ export function LandingMySessionsList({
|
||||||
row={sessionToMySessionsRowV2(s, eventId, {
|
row={sessionToMySessionsRowV2(s, eventId, {
|
||||||
isBookmarked: bookmarkedIds.has(s._id),
|
isBookmarked: bookmarkedIds.has(s._id),
|
||||||
onToggle: () => void toggleBookmark(s._id),
|
onToggle: () => void toggleBookmark(s._id),
|
||||||
})}
|
}, nowMs)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export type PresentSessionMenuItem = {
|
||||||
|
_id: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
presentationToken: string;
|
||||||
|
isOnAir: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function groupLabel(type: string): string {
|
||||||
|
const t = type.trim().toLowerCase();
|
||||||
|
if (t === "quiz") return "Quizzes";
|
||||||
|
if (t === "survey") return "Surveys";
|
||||||
|
if (t === "questions") return "Questions";
|
||||||
|
return "Polls";
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupOrder(type: string): number {
|
||||||
|
const t = type.trim().toLowerCase();
|
||||||
|
if (t === "quiz") return 0;
|
||||||
|
if (t === "poll") return 1;
|
||||||
|
if (t === "survey") return 2;
|
||||||
|
if (t === "questions") return 3;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PresentSessionMenu({
|
||||||
|
items,
|
||||||
|
currentId,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
items: PresentSessionMenuItem[];
|
||||||
|
currentId: string;
|
||||||
|
onSelect: (item: PresentSessionMenuItem) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const menuId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDoc);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDoc);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const groups = items
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => {
|
||||||
|
const g = groupOrder(a.type) - groupOrder(b.type);
|
||||||
|
if (g !== 0) return g;
|
||||||
|
return a.title.localeCompare(b.title, undefined, { sensitivity: "base" });
|
||||||
|
})
|
||||||
|
.reduce<{ label: string; items: PresentSessionMenuItem[] }[]>((acc, item) => {
|
||||||
|
const label = groupLabel(item.type);
|
||||||
|
const last = acc[acc.length - 1];
|
||||||
|
if (last && last.label === label) last.items.push(item);
|
||||||
|
else acc.push({ label, items: [item] });
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Session activities"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={menuId}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex h-11 w-11 items-center justify-center rounded-lg border border-[#e5e5ea] bg-white text-[#24282f] shadow-sm transition hover:bg-[#f2f2f7]"
|
||||||
|
>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M4 7h16M4 12h16M4 17h16"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<div
|
||||||
|
id={menuId}
|
||||||
|
role="menu"
|
||||||
|
className="absolute left-0 top-full z-50 mt-2 max-h-[70vh] w-[min(22rem,calc(100vw-2rem))] overflow-y-auto rounded-xl border border-[#e5e5ea] bg-white py-2 shadow-lg"
|
||||||
|
>
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<p className="px-4 py-3 text-sm text-[rgba(36,40,47,0.55)]">No activities</p>
|
||||||
|
) : (
|
||||||
|
groups.map((group) => (
|
||||||
|
<div key={group.label} className="py-1">
|
||||||
|
<p className="px-4 py-1.5 text-xs font-bold uppercase tracking-wide text-[rgba(36,40,47,0.45)]">
|
||||||
|
{group.label}
|
||||||
|
</p>
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const isCurrent = item._id === currentId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item._id}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
if (!isCurrent) onSelect(item);
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-3 px-4 py-3 text-left text-base transition hover:bg-[#f2f2f7] ${
|
||||||
|
isCurrent ? "bg-[#eef5ff]" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-semibold text-[#24282f]">
|
||||||
|
{item.title || "Untitled"}
|
||||||
|
</span>
|
||||||
|
{item.isOnAir ? (
|
||||||
|
<span className="flex-shrink-0 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-800">
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{isCurrent ? (
|
||||||
|
<span className="flex-shrink-0 text-xs font-semibold text-[#1e72ef]">Current</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { PresentSessionMenuItem } from "./PresentSessionMenu";
|
||||||
|
|
||||||
|
export const PRESENT_QUESTIONS_MENU_ID = "__session_questions__";
|
||||||
|
|
||||||
|
export function withQuestionsMenuItem(
|
||||||
|
items: PresentSessionMenuItem[],
|
||||||
|
sessionId: string | null | undefined,
|
||||||
|
): PresentSessionMenuItem[] {
|
||||||
|
if (!sessionId?.trim()) return items;
|
||||||
|
return [
|
||||||
|
...items,
|
||||||
|
{
|
||||||
|
_id: PRESENT_QUESTIONS_MENU_ID,
|
||||||
|
title: "Questions",
|
||||||
|
type: "questions",
|
||||||
|
presentationToken: "",
|
||||||
|
isOnAir: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function questionsPresentPath(sessionId: string, fromToken?: string | null): string {
|
||||||
|
const base = `/questions-present/${encodeURIComponent(sessionId)}`;
|
||||||
|
const t = fromToken?.trim();
|
||||||
|
if (!t) return base;
|
||||||
|
return `${base}?token=${encodeURIComponent(t)}`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/** Re-render on an interval so time-sensitive UI (e.g. live sessions) stays current. */
|
||||||
|
export function useNow(intervalMs = 30000): number {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(() => setNow(Date.now()), intervalMs);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [intervalMs]);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
@ -104,3 +104,26 @@ body {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(-50%, 0) scale(1);
|
transform: translate(-50%, 0) scale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Live session indicator beside Join on event agenda */
|
||||||
|
.eb-live-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background-color: #14ae5c;
|
||||||
|
box-shadow: 0 0 0 2px rgba(20, 174, 92, 0.25);
|
||||||
|
animation: eb-live-blink 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes eb-live-blink {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.35;
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -210,7 +210,10 @@ export type VisitorSessionPreviewSpeaker = {
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
photo?: string;
|
photo?: string;
|
||||||
|
/** Faculty profile role (directory). Prefer sessionRole for this session. */
|
||||||
speakerRole?: string;
|
speakerRole?: string;
|
||||||
|
/** Per-session key-person role assigned on the session. */
|
||||||
|
sessionRole?: string;
|
||||||
/** Shown when present (private profiles still get bio / expertise / photo / title). */
|
/** Shown when present (private profiles still get bio / expertise / photo / title). */
|
||||||
bio?: string;
|
bio?: string;
|
||||||
expertise?: string[];
|
expertise?: string[];
|
||||||
|
|
@ -786,7 +789,9 @@ export type PublicQuizPollPresentation = {
|
||||||
type: string;
|
type: string;
|
||||||
settings: {
|
settings: {
|
||||||
displayMode?: string;
|
displayMode?: string;
|
||||||
|
isLive?: boolean;
|
||||||
liveQuestionId?: string | null;
|
liveQuestionId?: string | null;
|
||||||
|
showCorrectAnswer?: boolean;
|
||||||
showResults?: boolean;
|
showResults?: boolean;
|
||||||
};
|
};
|
||||||
questions: PublicQuizPollPresentationQuestion[];
|
questions: PublicQuizPollPresentationQuestion[];
|
||||||
|
|
@ -825,6 +830,34 @@ export async function getPublicQuizPollPresentation(token: string): Promise<Publ
|
||||||
return (body as { data: PublicQuizPollPresentation }).data;
|
return (body as { data: PublicQuizPollPresentation }).data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PublicPresentationSessionActivity = {
|
||||||
|
_id: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
presentationToken: string;
|
||||||
|
presentationUrl: string;
|
||||||
|
isLive: boolean;
|
||||||
|
displayMode?: string;
|
||||||
|
liveQuestionId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PublicPresentationSessionActivities = {
|
||||||
|
sessionId: string | null;
|
||||||
|
activities: PublicPresentationSessionActivity[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getPublicPresentationSessionActivities(
|
||||||
|
token: string,
|
||||||
|
): Promise<PublicPresentationSessionActivities> {
|
||||||
|
const base = getBaseUrl();
|
||||||
|
const url = `${base}/api/public/quiz-polls/present/${encodeURIComponent(token)}/session-activities`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
const body = await parseJson(res);
|
||||||
|
if (!res.ok) throw new Error(messageFromBody(body));
|
||||||
|
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
|
||||||
|
return (body as { data: PublicPresentationSessionActivities }).data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getPublicQuizPollQuestionStats(
|
export async function getPublicQuizPollQuestionStats(
|
||||||
token: string,
|
token: string,
|
||||||
questionIndex: number,
|
questionIndex: number,
|
||||||
|
|
@ -837,3 +870,36 @@ export async function getPublicQuizPollQuestionStats(
|
||||||
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
|
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
|
||||||
return (body as { data: PublicQuizPollQuestionStats }).data;
|
return (body as { data: PublicQuizPollQuestionStats }).data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function postPublicPresentationControl(
|
||||||
|
token: string,
|
||||||
|
pathSuffix: string,
|
||||||
|
payload?: Record<string, unknown>,
|
||||||
|
): Promise<PublicQuizPollPresentation> {
|
||||||
|
const base = getBaseUrl();
|
||||||
|
const url = `${base}/api/public/quiz-polls/present/${encodeURIComponent(token)}${pathSuffix}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: payload != null ? JSON.stringify(payload) : undefined,
|
||||||
|
});
|
||||||
|
const body = await parseJson(res);
|
||||||
|
if (!res.ok) throw new Error(messageFromBody(body));
|
||||||
|
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
|
||||||
|
return (body as { data: PublicQuizPollPresentation }).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishPublicPresentation(token: string): Promise<PublicQuizPollPresentation> {
|
||||||
|
return postPublicPresentationControl(token, "/publish");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closePublicPresentation(token: string): Promise<PublicQuizPollPresentation> {
|
||||||
|
return postPublicPresentationControl(token, "/close");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setPublicLiveQuestion(
|
||||||
|
token: string,
|
||||||
|
questionId: string | null,
|
||||||
|
): Promise<PublicQuizPollPresentation> {
|
||||||
|
return postPublicPresentationControl(token, "/live-question", { questionId });
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,3 +45,16 @@ export function formatEventScheduleRange(startIso: string | undefined, endIso: s
|
||||||
}
|
}
|
||||||
return `${s.toLocaleDateString(undefined, opts)} – ${e.toLocaleDateString(undefined, opts)}`;
|
return `${s.toLocaleDateString(undefined, opts)} – ${e.toLocaleDateString(undefined, opts)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when `now` falls within [start, end] (inclusive). */
|
||||||
|
export function isSessionLiveNow(
|
||||||
|
startIso: string | undefined,
|
||||||
|
endIso: string | undefined,
|
||||||
|
nowMs: number = Date.now(),
|
||||||
|
): boolean {
|
||||||
|
if (!startIso || !endIso) return false;
|
||||||
|
const start = new Date(startIso).getTime();
|
||||||
|
const end = new Date(endIso).getTime();
|
||||||
|
if (Number.isNaN(start) || Number.isNaN(end) || end <= start) return false;
|
||||||
|
return nowMs >= start && nowMs <= end;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,17 +23,17 @@ function num(v: unknown): number {
|
||||||
export function rtdbSnapshotToQuestions(val: unknown): RtdbQuestionItem[] {
|
export function rtdbSnapshotToQuestions(val: unknown): RtdbQuestionItem[] {
|
||||||
if (!val || typeof val !== "object" || Array.isArray(val)) return [];
|
if (!val || typeof val !== "object" || Array.isArray(val)) return [];
|
||||||
const data = val as Record<string, Record<string, unknown>>;
|
const data = val as Record<string, Record<string, unknown>>;
|
||||||
return Object.entries(data)
|
const items: RtdbQuestionItem[] = [];
|
||||||
.map(([key, raw]) => {
|
for (const [key, raw] of Object.entries(data)) {
|
||||||
if (!raw || typeof raw !== "object") return null;
|
if (!raw || typeof raw !== "object") continue;
|
||||||
const v = raw as Record<string, unknown>;
|
const v = raw as Record<string, unknown>;
|
||||||
const fromField =
|
const fromField =
|
||||||
v._id != null && String(v._id).trim() !== "" ? String(v._id).trim() : "";
|
v._id != null && String(v._id).trim() !== "" ? String(v._id).trim() : "";
|
||||||
const id = fromField || key;
|
const id = fromField || key;
|
||||||
const status = v.status != null ? String(v.status) : "pending";
|
const status = v.status != null ? String(v.status) : "pending";
|
||||||
const audienceListed = v.audienceListed !== false;
|
const audienceListed = v.audienceListed !== false;
|
||||||
if (status === "archived" || !audienceListed) return null;
|
if (status === "archived" || !audienceListed) continue;
|
||||||
return {
|
items.push({
|
||||||
_id: id,
|
_id: id,
|
||||||
author: String(v.authorName ?? "Anonymous"),
|
author: String(v.authorName ?? "Anonymous"),
|
||||||
text: String(v.question ?? ""),
|
text: String(v.question ?? ""),
|
||||||
|
|
@ -43,10 +43,9 @@ export function rtdbSnapshotToQuestions(val: unknown): RtdbQuestionItem[] {
|
||||||
status,
|
status,
|
||||||
shortlisted: Boolean(v.shortlisted),
|
shortlisted: Boolean(v.shortlisted),
|
||||||
audienceListed,
|
audienceListed,
|
||||||
} satisfies RtdbQuestionItem;
|
});
|
||||||
})
|
}
|
||||||
.filter((x): x is RtdbQuestionItem => x != null)
|
return items.sort((a, b) => {
|
||||||
.sort((a, b) => {
|
|
||||||
if (b.up !== a.up) return b.up - a.up;
|
if (b.up !== a.up) return b.up - a.up;
|
||||||
const ta = a.createdAt ? Date.parse(a.createdAt) : 0;
|
const ta = a.createdAt ? Date.parse(a.createdAt) : 0;
|
||||||
const tb = b.createdAt ? Date.parse(b.createdAt) : 0;
|
const tb = b.createdAt ? Date.parse(b.createdAt) : 0;
|
||||||
|
|
@ -76,3 +75,61 @@ export function subscribeSessionQuestions(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Presenter view: shortlisted questions only (includes audience-hidden; excludes archived).
|
||||||
|
*/
|
||||||
|
export function rtdbSnapshotToShortlistedQuestions(val: unknown): RtdbQuestionItem[] {
|
||||||
|
if (!val || typeof val !== "object" || Array.isArray(val)) return [];
|
||||||
|
const data = val as Record<string, Record<string, unknown>>;
|
||||||
|
const items: RtdbQuestionItem[] = [];
|
||||||
|
for (const [key, raw] of Object.entries(data)) {
|
||||||
|
if (!raw || typeof raw !== "object") continue;
|
||||||
|
const v = raw as Record<string, unknown>;
|
||||||
|
const fromField =
|
||||||
|
v._id != null && String(v._id).trim() !== "" ? String(v._id).trim() : "";
|
||||||
|
const id = fromField || key;
|
||||||
|
const status = v.status != null ? String(v.status) : "pending";
|
||||||
|
if (status === "archived" || !Boolean(v.shortlisted)) continue;
|
||||||
|
items.push({
|
||||||
|
_id: id,
|
||||||
|
author: String(v.authorName ?? "Anonymous"),
|
||||||
|
text: String(v.question ?? ""),
|
||||||
|
up: num(v.upvotes),
|
||||||
|
down: num(v.downvotes),
|
||||||
|
createdAt: v.createdAt != null ? String(v.createdAt) : undefined,
|
||||||
|
status,
|
||||||
|
shortlisted: true,
|
||||||
|
audienceListed: v.audienceListed !== false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items.sort((a, b) => {
|
||||||
|
if (b.up !== a.up) return b.up - a.up;
|
||||||
|
const ta = a.createdAt ? Date.parse(a.createdAt) : 0;
|
||||||
|
const tb = b.createdAt ? Date.parse(b.createdAt) : 0;
|
||||||
|
return tb - ta;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to shortlisted session questions for presenter mode. */
|
||||||
|
export function subscribeSessionShortlistedQuestions(
|
||||||
|
sessionId: string,
|
||||||
|
onData: (items: RtdbQuestionItem[]) => void,
|
||||||
|
onError?: (e: Error) => void,
|
||||||
|
): Unsubscribe {
|
||||||
|
const db = getRtdb();
|
||||||
|
const r = ref(db, `sessions/${sessionId}/questions`);
|
||||||
|
return onValue(
|
||||||
|
r,
|
||||||
|
(snap) => {
|
||||||
|
try {
|
||||||
|
onData(rtdbSnapshotToShortlistedQuestions(snap.val()));
|
||||||
|
} catch (e) {
|
||||||
|
onError?.(e instanceof Error ? e : new Error(String(e)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
onError?.(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { AuthShell } from "../components/auth/AuthShell";
|
||||||
import { normalizeJoinCodeInput, resolveJoinCodeAndNavigate } from "../lib/joinCodeNavigation";
|
import { normalizeJoinCodeInput, resolveJoinCodeAndNavigate } from "../lib/joinCodeNavigation";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles https://eventbuzz.live/code/{code} — same resolve flow as the homepage join strip.
|
* Handles https://join.eventbuzz.live/code/{code} — same resolve flow as the homepage join strip.
|
||||||
*/
|
*/
|
||||||
export function CodeEntryPage() {
|
export function CodeEntryPage() {
|
||||||
const { code: codeParam } = useParams<{ code: string }>();
|
const { code: codeParam } = useParams<{ code: string }>();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
getPublicPresentationSessionActivities,
|
||||||
|
type PublicPresentationSessionActivity,
|
||||||
|
} from "../lib/api";
|
||||||
|
import { isFirebaseClientConfigured } from "../lib/firebaseClient";
|
||||||
|
import {
|
||||||
|
filterVisibleQuizPolls,
|
||||||
|
settingsDisplayMode,
|
||||||
|
subscribeSessionQuizPolls,
|
||||||
|
type RtdbQuizPollListItem,
|
||||||
|
} from "../lib/sessionQuizPollsRtdb";
|
||||||
|
import {
|
||||||
|
subscribeSessionShortlistedQuestions,
|
||||||
|
type RtdbQuestionItem,
|
||||||
|
} from "../lib/sessionQuestionsRtdb";
|
||||||
|
import { PresentSessionMenu } from "../components/present/PresentSessionMenu";
|
||||||
|
import {
|
||||||
|
PRESENT_QUESTIONS_MENU_ID,
|
||||||
|
withQuestionsMenuItem,
|
||||||
|
} from "../components/present/presentSessionMenuHelpers";
|
||||||
|
|
||||||
|
function isManualOrQuizMasterOnAir(item: RtdbQuizPollListItem): boolean {
|
||||||
|
const mode = settingsDisplayMode(item.settings);
|
||||||
|
return mode === "manual" || mode === "quiz_master";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAirIdsFromSessionList(items: RtdbQuizPollListItem[]): string[] {
|
||||||
|
return filterVisibleQuizPolls(items)
|
||||||
|
.filter(isManualOrQuizMasterOnAir)
|
||||||
|
.map((x) => x._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuestionsPresentPage() {
|
||||||
|
const { sessionId: sessionIdParam } = useParams<{ sessionId: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const fromToken = searchParams.get("token")?.trim() || "";
|
||||||
|
|
||||||
|
const sessionId = sessionIdParam?.trim() || "";
|
||||||
|
const [questions, setQuestions] = useState<RtdbQuestionItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [sessionActivities, setSessionActivities] = useState<PublicPresentationSessionActivity[]>([]);
|
||||||
|
const [sessionRtdbItems, setSessionRtdbItems] = useState<RtdbQuizPollListItem[]>([]);
|
||||||
|
|
||||||
|
const prevOnAirIdsRef = useRef<Set<string> | null>(null);
|
||||||
|
const tokenByIdRef = useRef<Map<string, string>>(new Map());
|
||||||
|
const pendingSwitchIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) {
|
||||||
|
setErr("Invalid session.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isFirebaseClientConfigured()) {
|
||||||
|
setErr("Firebase is not configured.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setErr(null);
|
||||||
|
const unsub = subscribeSessionShortlistedQuestions(
|
||||||
|
sessionId,
|
||||||
|
(items) => {
|
||||||
|
setQuestions(items);
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
setErr(e.message);
|
||||||
|
setQuestions([]);
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => unsub();
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fromToken) {
|
||||||
|
setSessionActivities([]);
|
||||||
|
tokenByIdRef.current = new Map();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
getPublicPresentationSessionActivities(fromToken)
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const activities = data.activities ?? [];
|
||||||
|
setSessionActivities(activities);
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const a of activities) {
|
||||||
|
if (a._id && a.presentationToken) map.set(a._id, a.presentationToken);
|
||||||
|
}
|
||||||
|
tokenByIdRef.current = map;
|
||||||
|
const pending = pendingSwitchIdRef.current;
|
||||||
|
if (pending) {
|
||||||
|
const nextToken = map.get(pending);
|
||||||
|
if (nextToken) {
|
||||||
|
pendingSwitchIdRef.current = null;
|
||||||
|
navigate(`/quiz-present/${encodeURIComponent(nextToken)}`, { replace: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSessionActivities([]);
|
||||||
|
tokenByIdRef.current = new Map();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [fromToken, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId || !isFirebaseClientConfigured()) {
|
||||||
|
setSessionRtdbItems([]);
|
||||||
|
prevOnAirIdsRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prevOnAirIdsRef.current = null;
|
||||||
|
const unsub = subscribeSessionQuizPolls(
|
||||||
|
sessionId,
|
||||||
|
(items) => {
|
||||||
|
setSessionRtdbItems(items);
|
||||||
|
const onAir = onAirIdsFromSessionList(items);
|
||||||
|
const prev = prevOnAirIdsRef.current;
|
||||||
|
if (prev == null) {
|
||||||
|
prevOnAirIdsRef.current = new Set(onAir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newlyEntered = onAir.filter((id) => !prev.has(id));
|
||||||
|
prevOnAirIdsRef.current = new Set(onAir);
|
||||||
|
const switchTo = newlyEntered[0];
|
||||||
|
if (!switchTo) return;
|
||||||
|
const nextToken = tokenByIdRef.current.get(switchTo);
|
||||||
|
if (!nextToken) {
|
||||||
|
pendingSwitchIdRef.current = switchTo;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingSwitchIdRef.current = null;
|
||||||
|
navigate(`/quiz-present/${encodeURIComponent(nextToken)}`, { replace: true });
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
return () => unsub();
|
||||||
|
}, [sessionId, navigate]);
|
||||||
|
|
||||||
|
const onAirIdSet = useMemo(() => new Set(onAirIdsFromSessionList(sessionRtdbItems)), [sessionRtdbItems]);
|
||||||
|
|
||||||
|
const menuItems = useMemo(() => {
|
||||||
|
const base = sessionActivities.map((a) => ({
|
||||||
|
_id: a._id,
|
||||||
|
title: a.title,
|
||||||
|
type: a.type,
|
||||||
|
presentationToken: a.presentationToken,
|
||||||
|
isOnAir: onAirIdSet.has(a._id),
|
||||||
|
}));
|
||||||
|
return withQuestionsMenuItem(base, sessionId);
|
||||||
|
}, [sessionActivities, sessionId, onAirIdSet]);
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh items-center justify-center bg-white px-4 text-center">
|
||||||
|
<p className="text-red-600">Invalid session.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh items-center justify-center bg-white text-[rgba(36,40,47,0.65)]">
|
||||||
|
Loading questions…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh items-center justify-center bg-white px-4 text-center">
|
||||||
|
<p className="text-red-600">{err}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh flex-col bg-[#f2f2f7] text-[#24282f]">
|
||||||
|
<main className="mx-auto flex w-full max-w-4xl flex-1 flex-col px-4 py-8 sm:px-6">
|
||||||
|
<div className="mb-6 flex flex-wrap items-start gap-3">
|
||||||
|
<PresentSessionMenu
|
||||||
|
items={menuItems}
|
||||||
|
currentId={PRESENT_QUESTIONS_MENU_ID}
|
||||||
|
onSelect={(item) => {
|
||||||
|
if (item.type === "questions") return;
|
||||||
|
const nextToken = item.presentationToken?.trim();
|
||||||
|
if (nextToken) {
|
||||||
|
navigate(`/quiz-present/${encodeURIComponent(nextToken)}`, { replace: true });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium uppercase tracking-wide text-[#1e72ef]">Questions</p>
|
||||||
|
<h1 className="mt-1 text-2xl font-bold text-[#24282f] sm:text-3xl">Shortlisted</h1>
|
||||||
|
<p className="mt-1 text-sm text-[rgba(36,40,47,0.55)]">
|
||||||
|
{questions.length} question{questions.length === 1 ? "" : "s"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{questions.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-[#e5e5ea] bg-white px-6 py-16 text-center shadow-sm">
|
||||||
|
<p className="text-lg font-semibold text-[#24282f]">No shortlisted questions</p>
|
||||||
|
<p className="mt-2 text-sm text-[rgba(36,40,47,0.55)]">
|
||||||
|
Shortlist questions from Activities to show them here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-4">
|
||||||
|
{questions.map((q, index) => (
|
||||||
|
<li
|
||||||
|
key={q._id}
|
||||||
|
className="rounded-xl border border-[#e5e5ea] bg-white p-5 shadow-sm sm:p-7"
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="rounded-full bg-[#eef5ff] px-3 py-1 text-xs font-bold uppercase tracking-wide text-[#1e72ef]">
|
||||||
|
Q{index + 1}
|
||||||
|
</span>
|
||||||
|
{q.status === "answered" ? (
|
||||||
|
<span className="rounded-full bg-emerald-100 px-3 py-1 text-xs font-bold uppercase tracking-wide text-emerald-800">
|
||||||
|
Answered
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="text-sm text-[rgba(36,40,47,0.45)]">{q.up}↑</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-semibold leading-snug text-[#24282f] sm:text-2xl">{q.text}</p>
|
||||||
|
<p className="mt-3 text-sm font-medium text-[rgba(36,40,47,0.55)]">{q.author}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,38 +1,89 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
|
closePublicPresentation,
|
||||||
|
getPublicPresentationSessionActivities,
|
||||||
getPublicQuizPollPresentation,
|
getPublicQuizPollPresentation,
|
||||||
getPublicQuizPollQuestionStats,
|
getPublicQuizPollQuestionStats,
|
||||||
|
publishPublicPresentation,
|
||||||
|
setPublicLiveQuestion,
|
||||||
|
type PublicPresentationSessionActivity,
|
||||||
type PublicQuizPollPresentation,
|
type PublicQuizPollPresentation,
|
||||||
type PublicQuizPollQuestionStats,
|
type PublicQuizPollQuestionStats,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import { isFirebaseClientConfigured } from "../lib/firebaseClient";
|
import { isFirebaseClientConfigured } from "../lib/firebaseClient";
|
||||||
import { subscribeSessionQuizPollDetail } from "../lib/sessionQuizPollsRtdb";
|
import {
|
||||||
|
filterVisibleQuizPolls,
|
||||||
|
settingsDisplayMode,
|
||||||
|
subscribeSessionQuizPollDetail,
|
||||||
|
subscribeSessionQuizPolls,
|
||||||
|
type RtdbQuizPollListItem,
|
||||||
|
} from "../lib/sessionQuizPollsRtdb";
|
||||||
import { PresentFooterNav } from "../components/present/PresentFooterNav";
|
import { PresentFooterNav } from "../components/present/PresentFooterNav";
|
||||||
import { PresentRespondersList } from "../components/present/PresentRespondersList";
|
import { PresentRespondersList } from "../components/present/PresentRespondersList";
|
||||||
import { PresentStatsBars } from "../components/present/PresentStatsBars";
|
import { PresentSessionMenu } from "../components/present/PresentSessionMenu";
|
||||||
import {
|
import {
|
||||||
mergeDisplayOptions,
|
questionsPresentPath,
|
||||||
} from "../components/present/presentStatsMerge";
|
withQuestionsMenuItem,
|
||||||
|
} from "../components/present/presentSessionMenuHelpers";
|
||||||
|
import { PresentStatsBars } from "../components/present/PresentStatsBars";
|
||||||
|
import { mergeDisplayOptions } from "../components/present/presentStatsMerge";
|
||||||
|
|
||||||
|
function isManualOrQuizMasterOnAir(item: RtdbQuizPollListItem): boolean {
|
||||||
|
const mode = settingsDisplayMode(item.settings);
|
||||||
|
return mode === "manual" || mode === "quiz_master";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAirIdsFromSessionList(items: RtdbQuizPollListItem[]): string[] {
|
||||||
|
return filterVisibleQuizPolls(items)
|
||||||
|
.filter(isManualOrQuizMasterOnAir)
|
||||||
|
.map((x) => x._id);
|
||||||
|
}
|
||||||
|
|
||||||
type TabId = "responses" | "participants";
|
type TabId = "responses" | "participants";
|
||||||
|
|
||||||
const STATS_POLL_MS = 2500;
|
const STATS_POLL_MS = 2500;
|
||||||
|
|
||||||
|
function applyPresentationSettings(
|
||||||
|
data: PublicQuizPollPresentation,
|
||||||
|
setters: {
|
||||||
|
setLiveQuestionId: (v: string | null) => void;
|
||||||
|
setShowCorrectAnswer: (v: boolean) => void;
|
||||||
|
setIsLive: (v: boolean) => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const live =
|
||||||
|
data.settings.liveQuestionId != null && String(data.settings.liveQuestionId).trim() !== ""
|
||||||
|
? String(data.settings.liveQuestionId).trim()
|
||||||
|
: null;
|
||||||
|
setters.setLiveQuestionId(live);
|
||||||
|
setters.setShowCorrectAnswer(data.settings.showCorrectAnswer === true);
|
||||||
|
setters.setIsLive(data.settings.isLive === true);
|
||||||
|
}
|
||||||
|
|
||||||
export function QuizPollPresentPage() {
|
export function QuizPollPresentPage() {
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [presentation, setPresentation] = useState<PublicQuizPollPresentation | null>(null);
|
const [presentation, setPresentation] = useState<PublicQuizPollPresentation | null>(null);
|
||||||
const [stats, setStats] = useState<PublicQuizPollQuestionStats | null>(null);
|
const [stats, setStats] = useState<PublicQuizPollQuestionStats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [statsLoading, setStatsLoading] = useState(false);
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [controlErr, setControlErr] = useState<string | null>(null);
|
||||||
|
const [controlBusy, setControlBusy] = useState(false);
|
||||||
const [tab, setTab] = useState<TabId>("responses");
|
const [tab, setTab] = useState<TabId>("responses");
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
const [questionIndex, setQuestionIndex] = useState(0);
|
||||||
const [browsing, setBrowsing] = useState(false);
|
const [browsing, setBrowsing] = useState(false);
|
||||||
const [liveQuestionId, setLiveQuestionId] = useState<string | null>(null);
|
const [liveQuestionId, setLiveQuestionId] = useState<string | null>(null);
|
||||||
const [showCorrectAnswer, setShowCorrectAnswer] = useState(false);
|
const [showCorrectAnswer, setShowCorrectAnswer] = useState(false);
|
||||||
|
const [isLive, setIsLive] = useState(false);
|
||||||
|
const [sessionActivities, setSessionActivities] = useState<PublicPresentationSessionActivity[]>([]);
|
||||||
|
const [sessionRtdbItems, setSessionRtdbItems] = useState<RtdbQuizPollListItem[]>([]);
|
||||||
|
|
||||||
const prevLiveIdRef = useRef<string | null>(null);
|
const prevLiveIdRef = useRef<string | null>(null);
|
||||||
|
const prevOnAirIdsRef = useRef<Set<string> | null>(null);
|
||||||
|
const tokenByIdRef = useRef<Map<string, string>>(new Map());
|
||||||
|
const pendingSwitchIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const isQuizMaster = presentation?.settings.displayMode === "quiz_master";
|
const isQuizMaster = presentation?.settings.displayMode === "quiz_master";
|
||||||
const isQuiz = presentation?.type.toLowerCase() === "quiz";
|
const isQuiz = presentation?.type.toLowerCase() === "quiz";
|
||||||
|
|
@ -45,6 +96,9 @@ export function QuizPollPresentPage() {
|
||||||
return presentation.questions.findIndex((q) => q._id === liveQuestionId);
|
return presentation.questions.findIndex((q) => q._id === liveQuestionId);
|
||||||
}, [presentation, liveQuestionId]);
|
}, [presentation, liveQuestionId]);
|
||||||
|
|
||||||
|
const currentQuestionIsLive =
|
||||||
|
isQuizMaster && Boolean(currentQuestion?._id) && currentQuestion?._id === liveQuestionId;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = token?.trim();
|
const t = token?.trim();
|
||||||
if (!t) {
|
if (!t) {
|
||||||
|
|
@ -57,11 +111,11 @@ export function QuizPollPresentPage() {
|
||||||
setErr(null);
|
setErr(null);
|
||||||
getPublicQuizPollPresentation(t)
|
getPublicQuizPollPresentation(t)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!cancelled) {
|
if (cancelled) return;
|
||||||
setPresentation(data);
|
setPresentation(data);
|
||||||
setQuestionIndex(0);
|
setQuestionIndex(0);
|
||||||
setBrowsing(false);
|
setBrowsing(false);
|
||||||
}
|
applyPresentationSettings(data, { setLiveQuestionId, setShowCorrectAnswer, setIsLive });
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
|
@ -77,6 +131,40 @@ export function QuizPollPresentPage() {
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = token?.trim();
|
||||||
|
if (!t || !presentation) return;
|
||||||
|
let cancelled = false;
|
||||||
|
getPublicPresentationSessionActivities(t)
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const activities = data.activities ?? [];
|
||||||
|
setSessionActivities(activities);
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const a of activities) {
|
||||||
|
if (a._id && a.presentationToken) map.set(a._id, a.presentationToken);
|
||||||
|
}
|
||||||
|
tokenByIdRef.current = map;
|
||||||
|
const pending = pendingSwitchIdRef.current;
|
||||||
|
if (pending && pending !== presentation._id) {
|
||||||
|
const nextToken = map.get(pending);
|
||||||
|
if (nextToken && nextToken !== t) {
|
||||||
|
pendingSwitchIdRef.current = null;
|
||||||
|
navigate(`/quiz-present/${encodeURIComponent(nextToken)}`, { replace: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSessionActivities([]);
|
||||||
|
tokenByIdRef.current = new Map();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [token, presentation?._id, presentation?.sessionId, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!presentation?.sessionId || !presentation._id || !isFirebaseClientConfigured()) return;
|
if (!presentation?.sessionId || !presentation._id || !isFirebaseClientConfigured()) return;
|
||||||
const unsub = subscribeSessionQuizPollDetail(
|
const unsub = subscribeSessionQuizPollDetail(
|
||||||
|
|
@ -89,12 +177,51 @@ export function QuizPollPresentPage() {
|
||||||
: null;
|
: null;
|
||||||
setLiveQuestionId(live);
|
setLiveQuestionId(live);
|
||||||
setShowCorrectAnswer(detail?.settings.showCorrectAnswer === true);
|
setShowCorrectAnswer(detail?.settings.showCorrectAnswer === true);
|
||||||
|
if (detail) {
|
||||||
|
setIsLive(detail.isLive === true || detail.settings.isLive === true);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
() => {},
|
() => {},
|
||||||
);
|
);
|
||||||
return () => unsub();
|
return () => unsub();
|
||||||
}, [presentation?.sessionId, presentation?._id]);
|
}, [presentation?.sessionId, presentation?._id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sessionId = presentation?.sessionId?.trim();
|
||||||
|
if (!sessionId || !presentation?._id || !isFirebaseClientConfigured()) {
|
||||||
|
setSessionRtdbItems([]);
|
||||||
|
prevOnAirIdsRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prevOnAirIdsRef.current = null;
|
||||||
|
const currentId = presentation._id;
|
||||||
|
const unsub = subscribeSessionQuizPolls(
|
||||||
|
sessionId,
|
||||||
|
(items) => {
|
||||||
|
setSessionRtdbItems(items);
|
||||||
|
const onAir = onAirIdsFromSessionList(items);
|
||||||
|
const prev = prevOnAirIdsRef.current;
|
||||||
|
if (prev == null) {
|
||||||
|
prevOnAirIdsRef.current = new Set(onAir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newlyEntered = onAir.filter((id) => !prev.has(id));
|
||||||
|
prevOnAirIdsRef.current = new Set(onAir);
|
||||||
|
const switchTo = newlyEntered.find((id) => id !== currentId);
|
||||||
|
if (!switchTo) return;
|
||||||
|
const nextToken = tokenByIdRef.current.get(switchTo);
|
||||||
|
if (!nextToken || nextToken === token?.trim()) {
|
||||||
|
pendingSwitchIdRef.current = switchTo;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingSwitchIdRef.current = null;
|
||||||
|
navigate(`/quiz-present/${encodeURIComponent(nextToken)}`, { replace: true });
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
return () => unsub();
|
||||||
|
}, [presentation?.sessionId, presentation?._id, token, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isQuizMaster || liveQuestionIndex < 0) return;
|
if (!isQuizMaster || liveQuestionIndex < 0) return;
|
||||||
const live = liveQuestionId ?? null;
|
const live = liveQuestionId ?? null;
|
||||||
|
|
@ -139,6 +266,24 @@ export function QuizPollPresentPage() {
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [presentation, loadStats]);
|
}, [presentation, loadStats]);
|
||||||
|
|
||||||
|
const applyControlResult = (data: PublicQuizPollPresentation) => {
|
||||||
|
setPresentation(data);
|
||||||
|
applyPresentationSettings(data, { setLiveQuestionId, setShowCorrectAnswer, setIsLive });
|
||||||
|
};
|
||||||
|
|
||||||
|
const runControl = async (action: () => Promise<PublicQuizPollPresentation>) => {
|
||||||
|
setControlBusy(true);
|
||||||
|
setControlErr(null);
|
||||||
|
try {
|
||||||
|
const data = await action();
|
||||||
|
applyControlResult(data);
|
||||||
|
} catch (e) {
|
||||||
|
setControlErr(e instanceof Error ? e.message : "Could not update live status.");
|
||||||
|
} finally {
|
||||||
|
setControlBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const goPrev = () => {
|
const goPrev = () => {
|
||||||
setBrowsing(true);
|
setBrowsing(true);
|
||||||
setQuestionIndex((i) => Math.max(0, i - 1));
|
setQuestionIndex((i) => Math.max(0, i - 1));
|
||||||
|
|
@ -152,6 +297,47 @@ export function QuizPollPresentPage() {
|
||||||
const viewingOffLive =
|
const viewingOffLive =
|
||||||
isQuizMaster && liveQuestionIndex >= 0 && questionSafe !== liveQuestionIndex;
|
isQuizMaster && liveQuestionIndex >= 0 && questionSafe !== liveQuestionIndex;
|
||||||
|
|
||||||
|
const onAirIdSet = useMemo(() => new Set(onAirIdsFromSessionList(sessionRtdbItems)), [sessionRtdbItems]);
|
||||||
|
|
||||||
|
const menuItems = useMemo(() => {
|
||||||
|
if (!presentation) return [];
|
||||||
|
const base =
|
||||||
|
sessionActivities.length > 0
|
||||||
|
? sessionActivities
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
_id: presentation._id,
|
||||||
|
title: presentation.title,
|
||||||
|
type: presentation.type,
|
||||||
|
presentationToken: token?.trim() || "",
|
||||||
|
presentationUrl: "",
|
||||||
|
isLive,
|
||||||
|
displayMode: presentation.settings.displayMode,
|
||||||
|
liveQuestionId,
|
||||||
|
} satisfies PublicPresentationSessionActivity,
|
||||||
|
];
|
||||||
|
const items = base
|
||||||
|
.filter((a) => a.presentationToken)
|
||||||
|
.map((a) => ({
|
||||||
|
_id: a._id,
|
||||||
|
title: a.title,
|
||||||
|
type: a.type,
|
||||||
|
presentationToken: a.presentationToken,
|
||||||
|
isOnAir:
|
||||||
|
onAirIdSet.has(a._id) ||
|
||||||
|
(a._id === presentation._id && isLive && (isQuizMaster ? Boolean(liveQuestionId) : true)),
|
||||||
|
}));
|
||||||
|
return withQuestionsMenuItem(items, presentation.sessionId);
|
||||||
|
}, [
|
||||||
|
presentation,
|
||||||
|
sessionActivities,
|
||||||
|
token,
|
||||||
|
isLive,
|
||||||
|
liveQuestionId,
|
||||||
|
isQuizMaster,
|
||||||
|
onAirIdSet,
|
||||||
|
]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh items-center justify-center bg-white text-[rgba(36,40,47,0.65)]">
|
<div className="flex min-h-dvh items-center justify-center bg-white text-[rgba(36,40,47,0.65)]">
|
||||||
|
|
@ -176,19 +362,99 @@ export function QuizPollPresentPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const t = token?.trim() || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh flex-col bg-[#f2f2f7] pb-20 text-[#24282f]">
|
<div className="flex min-h-dvh flex-col bg-[#f2f2f7] pb-20 text-[#24282f]">
|
||||||
<main className="mx-auto flex w-full max-w-4xl flex-1 flex-col px-4 py-8 sm:px-6">
|
<main className="mx-auto flex w-full max-w-4xl flex-1 flex-col px-4 py-8 sm:px-6">
|
||||||
<div className="mb-6 rounded-xl border border-[#e5e5ea] bg-white p-5 shadow-sm">
|
<div className="mb-6 rounded-xl border border-[#e5e5ea] bg-white p-5 shadow-sm">
|
||||||
|
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<PresentSessionMenu
|
||||||
|
items={menuItems}
|
||||||
|
currentId={presentation._id}
|
||||||
|
onSelect={(item) => {
|
||||||
|
if (item.type === "questions") {
|
||||||
|
const sid = presentation.sessionId?.trim();
|
||||||
|
if (sid) navigate(questionsPresentPath(sid, t), { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextToken = item.presentationToken?.trim();
|
||||||
|
if (nextToken && nextToken !== t) {
|
||||||
|
navigate(`/quiz-present/${encodeURIComponent(nextToken)}`, { replace: true });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isQuizMaster ? (
|
||||||
|
liveQuestionIndex >= 0 ? (
|
||||||
|
<span className="rounded-full bg-emerald-100 px-3 py-1 text-xs font-bold uppercase tracking-wide text-emerald-800">
|
||||||
|
Live · Question {liveQuestionIndex + 1}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-[#f2f2f7] px-3 py-1 text-xs font-bold uppercase tracking-wide text-[rgba(36,40,47,0.55)]">
|
||||||
|
Offline · No question live
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : isLive ? (
|
||||||
|
<span className="rounded-full bg-emerald-100 px-3 py-1 text-xs font-bold uppercase tracking-wide text-emerald-800">
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-[#f2f2f7] px-3 py-1 text-xs font-bold uppercase tracking-wide text-[rgba(36,40,47,0.55)]">
|
||||||
|
Offline
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{viewingOffLive ? (
|
{viewingOffLive ? (
|
||||||
<p className="mb-3 text-center text-xs font-medium uppercase tracking-wide text-amber-700">
|
<span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-medium uppercase tracking-wide text-amber-800">
|
||||||
Viewing Q{questionSafe + 1} — live: Q{liveQuestionIndex + 1}
|
Viewing Q{questionSafe + 1} — live: Q{liveQuestionIndex + 1}
|
||||||
</p>
|
</span>
|
||||||
) : isQuizMaster && liveQuestionIndex >= 0 ? (
|
|
||||||
<p className="mb-3 text-center text-xs font-medium uppercase tracking-wide text-emerald-700">
|
|
||||||
Live question
|
|
||||||
</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-shrink-0 flex-col items-end gap-1">
|
||||||
|
{isQuizMaster ? (
|
||||||
|
currentQuestionIsLive ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={controlBusy || !t}
|
||||||
|
onClick={() => void runControl(() => setPublicLiveQuestion(t, null))}
|
||||||
|
className="rounded-lg border border-[#e5e5ea] bg-white px-4 py-2 text-sm font-semibold text-[#24282f] shadow-sm transition hover:bg-[#f2f2f7] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{controlBusy ? "Updating…" : "Make offline"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={controlBusy || !t || !currentQuestion._id}
|
||||||
|
onClick={() => void runControl(() => setPublicLiveQuestion(t, currentQuestion._id))}
|
||||||
|
className="rounded-lg bg-[#16a34a] px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-[#15803d] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{controlBusy ? "Updating…" : "Make this question live"}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
) : isLive ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={controlBusy || !t}
|
||||||
|
onClick={() => void runControl(() => closePublicPresentation(t))}
|
||||||
|
className="rounded-lg border border-[#e5e5ea] bg-white px-4 py-2 text-sm font-semibold text-[#24282f] shadow-sm transition hover:bg-[#f2f2f7] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{controlBusy ? "Updating…" : "Make offline"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={controlBusy || !t}
|
||||||
|
onClick={() => void runControl(() => publishPublicPresentation(t))}
|
||||||
|
className="rounded-lg bg-[#16a34a] px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-[#15803d] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{controlBusy ? "Updating…" : "Go live"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{controlErr ? <p className="max-w-[220px] text-right text-sm text-red-600">{controlErr}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-sm font-medium uppercase tracking-wide text-[#1e72ef]">
|
<p className="text-sm font-medium uppercase tracking-wide text-[#1e72ef]">
|
||||||
Question {questionSafe + 1} of {questionCount}
|
Question {questionSafe + 1} of {questionCount}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -223,7 +489,6 @@ export function QuizPollPresentPage() {
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
{tab === "responses" ? (
|
{tab === "responses" ? (
|
||||||
<>
|
|
||||||
<PresentStatsBars
|
<PresentStatsBars
|
||||||
kind={statsKind}
|
kind={statsKind}
|
||||||
options={displayOptions}
|
options={displayOptions}
|
||||||
|
|
@ -232,7 +497,6 @@ export function QuizPollPresentPage() {
|
||||||
totalResponses={stats?.totalResponses ?? 0}
|
totalResponses={stats?.totalResponses ?? 0}
|
||||||
revealCorrect={revealCorrect}
|
revealCorrect={revealCorrect}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<PresentRespondersList
|
<PresentRespondersList
|
||||||
responders={stats?.responders ?? []}
|
responders={stats?.responders ?? []}
|
||||||
|
|
|
||||||
|
|
@ -621,7 +621,7 @@ export function SingleEventPage() {
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-2.5">
|
<span className="flex min-w-0 items-center gap-2.5">
|
||||||
<Star className="size-[18px] shrink-0 text-[#1e72ef]" strokeWidth={1.75} />
|
<Star className="size-[18px] shrink-0 text-[#1e72ef]" strokeWidth={1.75} />
|
||||||
<span className="truncate">Rate this event</span>
|
<span className="truncate">Rate this session</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronRight className="size-4 shrink-0 text-[#c7c7cc]" strokeWidth={2} aria-hidden />
|
<ChevronRight className="size-4 shrink-0 text-[#c7c7cc]" strokeWidth={2} aria-hidden />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -652,7 +652,11 @@ export function SingleEventPage() {
|
||||||
{activeTabId === "key-persons" ? (
|
{activeTabId === "key-persons" ? (
|
||||||
<ul className="flex flex-col gap-3">
|
<ul className="flex flex-col gap-3">
|
||||||
{(session.speakers || []).map((s) => (
|
{(session.speakers || []).map((s) => (
|
||||||
<KeyPersonRow key={s._id} person={s} tag={s.speakerRole?.trim() || "Speaker"} />
|
<KeyPersonRow
|
||||||
|
key={s._id}
|
||||||
|
person={s}
|
||||||
|
tag={s.sessionRole?.trim() || s.speakerRole?.trim() || "Speaker"}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{session.moderator ? (
|
{session.moderator ? (
|
||||||
<KeyPersonRow
|
<KeyPersonRow
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue