1july
This commit is contained in:
parent
f283127dce
commit
0419adeba6
|
|
@ -16,6 +16,7 @@ import { RegisterPage } from "./pages/RegisterPage";
|
|||
import { RewardsPage } from "./pages/RewardsPage";
|
||||
import { SingleEventPage } from "./pages/SingleEventPage";
|
||||
import { QuizPollTakePage } from "./pages/QuizPollTakePage";
|
||||
import { QuizPollPresentPage } from "./pages/QuizPollPresentPage";
|
||||
import { CodeEntryPage } from "./pages/CodeEntryPage";
|
||||
import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage";
|
||||
import { TermsOfServicePage } from "./pages/TermsOfServicePage";
|
||||
|
|
@ -37,6 +38,7 @@ export default function App() {
|
|||
<Route path="/track-v2" element={<EventTrackV2Page />} />
|
||||
<Route path="/session" element={<SingleEventPage />} />
|
||||
<Route path="/quiz-poll" element={<QuizPollTakePage />} />
|
||||
<Route path="/quiz-present/:token" element={<QuizPollPresentPage />} />
|
||||
<Route path="/questions" element={<Questions />} />
|
||||
<Route path="/ask" element={<Navigate to="/questions" replace />} />
|
||||
<Route path="/feedback" element={<FeedbackPage />} />
|
||||
|
|
|
|||
|
|
@ -1,8 +1,24 @@
|
|||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { Star } from "lucide-react";
|
||||
import type { VisitorPublicFeedbackForm, VisitorPublicFeedbackFormField } from "../lib/api";
|
||||
import { postPublicFormSubmit } from "../lib/api";
|
||||
import { getAttendeeParticipant } from "../lib/attendeeAuth";
|
||||
|
||||
function isStarRatingField(f: VisitorPublicFeedbackFormField): boolean {
|
||||
const t = (f.type || "").toLowerCase().trim();
|
||||
if (t === "stars" || t === "star rating" || t === "star ratings") return true;
|
||||
if (t === "select") {
|
||||
const opts = (f.options || []).map((o) => String(o).trim());
|
||||
return opts.length === 5 && opts.every((v, i) => v === String(i + 1));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function starValueValid(raw: unknown): boolean {
|
||||
const n = typeof raw === "number" ? raw : parseInt(String(raw ?? ""), 10);
|
||||
return Number.isFinite(n) && n >= 1 && n <= 5;
|
||||
}
|
||||
|
||||
export function DynamicFeedbackForm({
|
||||
eventId,
|
||||
schema,
|
||||
|
|
@ -30,6 +46,8 @@ export function DynamicFeedbackForm({
|
|||
const raw = values[key];
|
||||
if ((f.type || "").toLowerCase() === "checkboxes") {
|
||||
if (!Array.isArray(raw) || raw.length === 0) return false;
|
||||
} else if (isStarRatingField(f)) {
|
||||
if (!starValueValid(raw)) return false;
|
||||
} else if (raw === undefined || raw === null || String(raw).trim() === "") {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -138,6 +156,38 @@ function FieldInput({
|
|||
);
|
||||
}
|
||||
|
||||
if (isStarRatingField(f)) {
|
||||
const n = typeof value === "number" ? value : parseInt(String(value ?? ""), 10);
|
||||
const selected = Number.isFinite(n) ? n : 0;
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-sm font-semibold text-[#24282f]">
|
||||
{label}
|
||||
{f.required ? <span className="text-red-600"> *</span> : null}
|
||||
</legend>
|
||||
<div className="flex gap-2">
|
||||
{Array.from({ length: 5 }, (_, i) => {
|
||||
const star = i + 1;
|
||||
return (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => onChange(label, star)}
|
||||
className="text-[#ef8d1d]"
|
||||
aria-label={`${star} star${star === 1 ? "" : "s"}`}
|
||||
>
|
||||
<Star
|
||||
className={`size-7 ${star <= selected ? "fill-[#ef8d1d]" : "fill-transparent"}`}
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
if (t === "select") {
|
||||
return (
|
||||
<label className="flex flex-col gap-2">
|
||||
|
|
@ -238,6 +288,24 @@ function FieldInput({
|
|||
);
|
||||
}
|
||||
|
||||
if (t === "textarea") {
|
||||
return (
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-sm font-semibold text-[#24282f]">
|
||||
{label}
|
||||
{f.required ? <span className="text-red-600"> *</span> : null}
|
||||
</span>
|
||||
<textarea
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(label, e.target.value)}
|
||||
rows={4}
|
||||
placeholder={ph}
|
||||
className={`${baseInput} resize-none`}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const longText =
|
||||
label.toLowerCase().includes("comment") ||
|
||||
label.toLowerCase().includes("describe") ||
|
||||
|
|
|
|||
|
|
@ -51,10 +51,12 @@ export function HyperNavigationFAB({
|
|||
eventId ? `/questions?${allQuestionsQs.toString()}` : "/questions";
|
||||
const feedbackQs = new URLSearchParams();
|
||||
if (eventId) feedbackQs.set("event_id", eventId);
|
||||
if (sessionId) feedbackQs.set("session_id", sessionId);
|
||||
if (feedbackFormId?.trim()) feedbackQs.set("form_id", feedbackFormId.trim());
|
||||
const feedbackTo = eventId ? `/feedback?${feedbackQs.toString()}` : "/feedback";
|
||||
const ratingsQs = new URLSearchParams();
|
||||
if (eventId) ratingsQs.set("event_id", eventId);
|
||||
if (sessionId) ratingsQs.set("session_id", sessionId);
|
||||
if (ratingsFormId?.trim()) ratingsQs.set("form_id", ratingsFormId.trim());
|
||||
const ratingsTo = eventId ? `/ratings?${ratingsQs.toString()}` : "/ratings";
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
export function PresentFooterNav({
|
||||
title,
|
||||
onPrev,
|
||||
onNext,
|
||||
prevDisabled,
|
||||
nextDisabled,
|
||||
}: {
|
||||
title: string;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
prevDisabled: boolean;
|
||||
nextDisabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<footer className="fixed inset-x-0 bottom-0 z-40 border-t border-[#e5e5ea] bg-white/95 shadow-[0_-4px_12px_rgba(0,0,0,0.06)] backdrop-blur-md">
|
||||
<div className="mx-auto flex h-16 max-w-5xl items-center justify-between gap-4 px-4 sm:px-6">
|
||||
<button
|
||||
type="button"
|
||||
disabled={prevDisabled}
|
||||
onClick={onPrev}
|
||||
className="min-w-[88px] rounded-lg border border-[#e5e5ea] bg-white px-4 py-2 text-sm font-semibold text-[#24282f] transition hover:bg-[#f2f2f7] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
◀ Prev
|
||||
</button>
|
||||
<h2 className="min-w-0 flex-1 truncate text-center text-base font-semibold text-[#24282f] sm:text-lg">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
disabled={nextDisabled}
|
||||
onClick={onNext}
|
||||
className="min-w-[88px] rounded-lg border border-[#e5e5ea] bg-white px-4 py-2 text-sm font-semibold text-[#24282f] transition hover:bg-[#f2f2f7] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Next ▶
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import type { PublicQuizPollResponder } from "../../lib/api";
|
||||
|
||||
function formatTimeTaken(sec: number): string {
|
||||
if (sec <= 0) return "—";
|
||||
if (sec < 60) return `${sec} sec`;
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return s > 0 ? `${m} min ${s} sec` : `${m} min`;
|
||||
}
|
||||
|
||||
export function PresentRespondersList({
|
||||
responders,
|
||||
isQuiz,
|
||||
showAnswerText,
|
||||
showCorrectness,
|
||||
}: {
|
||||
responders: PublicQuizPollResponder[];
|
||||
isQuiz: boolean;
|
||||
showAnswerText?: boolean;
|
||||
showCorrectness?: boolean;
|
||||
}) {
|
||||
if (responders.length === 0) {
|
||||
return (
|
||||
<p className="py-8 text-center text-sm text-[rgba(36,40,47,0.55)]">No responses yet for this question.</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{responders.map((r, i) => (
|
||||
<li
|
||||
key={`${r.participantId}-${r.answeredAt}-${i}`}
|
||||
className="flex items-center gap-3 rounded-lg border border-[#e5e5ea] bg-white px-4 py-3 shadow-sm"
|
||||
>
|
||||
<span className="w-8 shrink-0 text-center text-sm font-bold tabular-nums text-[rgba(36,40,47,0.4)]">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-base font-semibold text-[#24282f]">{r.displayName}</p>
|
||||
{showAnswerText && r.answerText ? (
|
||||
<p className="mt-0.5 truncate text-sm text-[rgba(36,40,47,0.65)]">{r.answerText}</p>
|
||||
) : null}
|
||||
<p className="mt-1 text-sm text-[rgba(36,40,47,0.65)]">
|
||||
<span className="font-medium text-[#24282f]">Time taken:</span>{" "}
|
||||
<span className="font-semibold tabular-nums text-[#1e72ef]">{formatTimeTaken(r.responseTimeSec)}</span>
|
||||
</p>
|
||||
</div>
|
||||
{isQuiz && showCorrectness ? (
|
||||
<div className="shrink-0 text-right">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2.5 py-1 text-xs font-bold ${
|
||||
r.isCorrect === true
|
||||
? "bg-emerald-100 text-emerald-800"
|
||||
: r.isCorrect === false
|
||||
? "bg-red-100 text-red-700"
|
||||
: "bg-slate-100 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{r.isCorrect === true ? "Correct" : r.isCorrect === false ? "Incorrect" : "—"}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
type BarOption = {
|
||||
optionId: string;
|
||||
text: string;
|
||||
count: number;
|
||||
percent: number;
|
||||
isCorrect?: boolean;
|
||||
};
|
||||
|
||||
export function PresentStatsBars({
|
||||
kind,
|
||||
options,
|
||||
answeredCount,
|
||||
percentAnswered,
|
||||
totalResponses,
|
||||
revealCorrect,
|
||||
}: {
|
||||
kind: string;
|
||||
options: BarOption[];
|
||||
answeredCount?: number;
|
||||
percentAnswered?: number;
|
||||
totalResponses: number;
|
||||
revealCorrect?: boolean;
|
||||
}) {
|
||||
if (kind === "text") {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#e5e5ea] bg-[#f9f9fb] p-6 text-center">
|
||||
<p className="text-4xl font-bold tabular-nums text-[#24282f]">{answeredCount ?? 0}</p>
|
||||
<p className="mt-2 text-sm text-[rgba(36,40,47,0.65)]">
|
||||
of {totalResponses} responded ({percentAnswered ?? 0}%)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (options.length === 0) {
|
||||
return <p className="text-center text-sm text-[rgba(36,40,47,0.55)]">No options for this question.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{options.map((opt) => {
|
||||
const highlighted = revealCorrect && opt.isCorrect === true;
|
||||
const barColor = highlighted ? "bg-emerald-500" : "bg-[#1e72ef]";
|
||||
const borderColor = highlighted ? "border-emerald-400 ring-2 ring-emerald-200" : "border-[#e5e5ea]";
|
||||
|
||||
return (
|
||||
<div key={opt.optionId} className={`rounded-lg border bg-white p-4 shadow-sm ${borderColor}`}>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<span className="min-w-0 flex-1 text-base font-medium leading-snug text-[#24282f]">
|
||||
{opt.text || "—"}
|
||||
{highlighted ? (
|
||||
<span className="ml-2 inline-flex rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-800">
|
||||
Correct answer
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2 rounded-full bg-[#f2f2f7] px-3 py-1.5">
|
||||
<span className="text-xl font-bold tabular-nums text-[#24282f]">{opt.count}</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-[rgba(36,40,47,0.55)]">
|
||||
{opt.count === 1 ? "response" : "responses"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-1 flex justify-end">
|
||||
<span className="text-sm font-semibold tabular-nums text-[#1e72ef]">{opt.percent}%</span>
|
||||
</div>
|
||||
<div className="h-3 overflow-hidden rounded-full bg-[#e5e5ea]">
|
||||
<div
|
||||
className={`h-full rounded-full transition-[width] duration-500 ease-out ${barColor}`}
|
||||
style={{ width: `${Math.min(100, Math.max(opt.count > 0 ? 2 : 0, opt.percent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import type { PublicQuizPollPresentationQuestion, PublicQuizPollQuestionStats } from "../../lib/api";
|
||||
|
||||
export type DisplayBarOption = {
|
||||
optionId: string;
|
||||
text: string;
|
||||
count: number;
|
||||
percent: number;
|
||||
isCorrect?: boolean;
|
||||
};
|
||||
|
||||
export function mergeDisplayOptions(
|
||||
question: PublicQuizPollPresentationQuestion,
|
||||
stats: PublicQuizPollQuestionStats | null,
|
||||
): DisplayBarOption[] {
|
||||
const statOpts = stats?.options ?? [];
|
||||
const kind = (stats?.kind ?? question.type ?? "single").toLowerCase();
|
||||
|
||||
if (kind === "text") return [];
|
||||
|
||||
if (kind === "rating") {
|
||||
if (statOpts.length > 0) return statOpts;
|
||||
return [1, 2, 3, 4, 5].map((n) => ({
|
||||
optionId: String(n),
|
||||
text: String(n),
|
||||
count: 0,
|
||||
percent: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
const qOpts = question.options ?? [];
|
||||
if (qOpts.length === 0 && statOpts.length > 0) return statOpts;
|
||||
|
||||
const byId = new Map<string, DisplayBarOption>();
|
||||
for (const o of statOpts) {
|
||||
byId.set(o.optionId, o);
|
||||
byId.set(o.optionId.toLowerCase(), o);
|
||||
}
|
||||
|
||||
return qOpts.map((o, i) => {
|
||||
const id = o._id?.trim() ? o._id.trim() : `opt-${i}`;
|
||||
const s = byId.get(id) ?? byId.get(id.toLowerCase()) ?? statOpts[i];
|
||||
return {
|
||||
optionId: id,
|
||||
text: o.text?.trim() ? o.text : (s?.text ?? ""),
|
||||
count: s?.count ?? 0,
|
||||
percent: s?.percent ?? 0,
|
||||
isCorrect: s?.isCorrect,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function canRevealCorrectAnswer(
|
||||
isQuiz: boolean,
|
||||
kind: string,
|
||||
options: DisplayBarOption[],
|
||||
): boolean {
|
||||
if (!isQuiz) return false;
|
||||
const k = kind.toLowerCase();
|
||||
if (k !== "single" && k !== "multiple") return false;
|
||||
return options.some((o) => o.isCorrect === true);
|
||||
}
|
||||
|
|
@ -1,5 +1,16 @@
|
|||
import type { LoginPreviewResponse } from "../types/visitorContext";
|
||||
|
||||
export type RegistrationBaseFields =
|
||||
| "name_email"
|
||||
| "email_only"
|
||||
| "name_email_phone"
|
||||
| "anonymous_name"
|
||||
| "anonymous";
|
||||
|
||||
export function registrationRequiresEmail(base: RegistrationBaseFields | string): boolean {
|
||||
return base === "name_email" || base === "email_only" || base === "name_email_phone";
|
||||
}
|
||||
|
||||
export function useEventRegistrationSettings(event: LoginPreviewResponse | null | undefined) {
|
||||
const s = event?.settings;
|
||||
const registrationOpen = s?.registrationOpen !== false;
|
||||
|
|
@ -10,12 +21,7 @@ export function useEventRegistrationSettings(event: LoginPreviewResponse | null
|
|||
| "otp_email"
|
||||
| "link_email"
|
||||
| "none";
|
||||
const registrationBaseFields = (s?.registrationBaseFields || "name_email") as
|
||||
| "name_email"
|
||||
| "email_only"
|
||||
| "name_email_phone"
|
||||
| "anonymous_name"
|
||||
| "anonymous";
|
||||
const registrationBaseFields = (s?.registrationBaseFields || "name_email") as RegistrationBaseFields;
|
||||
const registrationMode = s?.registrationMode || "Open";
|
||||
const enableFeedback = s?.enableFeedback !== false;
|
||||
const enableRatings = s?.enableRatings === true;
|
||||
|
|
@ -34,6 +40,8 @@ export function useEventRegistrationSettings(event: LoginPreviewResponse | null
|
|||
newRegistrationBlocked,
|
||||
authenticationType,
|
||||
registrationBaseFields,
|
||||
requiresEmailOnLogin:
|
||||
authenticationType !== "none" || registrationRequiresEmail(registrationBaseFields),
|
||||
registrationMode,
|
||||
enableFeedback,
|
||||
enableRatings,
|
||||
|
|
|
|||
132
src/lib/api.ts
132
src/lib/api.ts
|
|
@ -239,6 +239,14 @@ export type VisitorSessionPreview = {
|
|||
audienceFeedbackEnabled?: boolean;
|
||||
/** false = hide ratings link on session page (event must still enable ratings). */
|
||||
audienceRatingsEnabled?: boolean;
|
||||
/** false = hide Key Persons tab on session page. */
|
||||
audienceKeyPersonsEnabled?: boolean;
|
||||
/** false = hide My Sessions tab on session page. */
|
||||
audienceMySessionsEnabled?: boolean;
|
||||
/** false = hide Agenda tab on session page. */
|
||||
audienceAgendaEnabled?: boolean;
|
||||
/** false = hide Q&A on visitor session page. */
|
||||
enableQnA?: boolean;
|
||||
audienceFeedbackForm?: string | null;
|
||||
audienceRatingsForm?: string | null;
|
||||
};
|
||||
|
|
@ -274,6 +282,24 @@ export async function getVisitorFeedbackFormSchema(
|
|||
return (body as { data: VisitorPublicFeedbackForm }).data;
|
||||
}
|
||||
|
||||
/** Resolve session-linked or default active feedback/survey form for an event. */
|
||||
export async function resolveVisitorFeedbackFormSchema(
|
||||
eventId: string,
|
||||
opts?: { sessionId?: string; purpose?: "feedback" | "ratings" },
|
||||
): Promise<VisitorPublicFeedbackForm> {
|
||||
const base = getBaseUrl();
|
||||
const qs = new URLSearchParams();
|
||||
if (opts?.sessionId?.trim()) qs.set("session_id", opts.sessionId.trim());
|
||||
if (opts?.purpose) qs.set("purpose", opts.purpose);
|
||||
const q = qs.toString();
|
||||
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/feedback-forms/resolve${q ? `?${q}` : ""}`;
|
||||
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: VisitorPublicFeedbackForm }).data;
|
||||
}
|
||||
|
||||
export async function postPublicFormSubmit(
|
||||
formId: string,
|
||||
data: Record<string, unknown>,
|
||||
|
|
@ -281,9 +307,13 @@ export async function postPublicFormSubmit(
|
|||
): Promise<unknown> {
|
||||
const base = getBaseUrl();
|
||||
const url = `${base}/api/forms/${encodeURIComponent(formId)}/submit`;
|
||||
const token = getAttendeeToken();
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data,
|
||||
...(participantId ? { participantId } : {}),
|
||||
|
|
@ -336,6 +366,8 @@ export type VisitorQuizPollResponseItem = {
|
|||
answer: unknown;
|
||||
isCorrect?: boolean;
|
||||
pointsEarned?: number;
|
||||
/** Per-question seconds (quiz master). */
|
||||
timeSpent?: number;
|
||||
};
|
||||
|
||||
/** Attendee JWT required. Submits answers for a session quiz/poll (Mongo id from link / RTDB). */
|
||||
|
|
@ -370,6 +402,32 @@ export async function postVisitorQuizPollResponse(
|
|||
return (body as { data: VisitorQuizPollSubmitResult }).data;
|
||||
}
|
||||
|
||||
export type VisitorQuizPollMyResponse = {
|
||||
hasResponse: boolean;
|
||||
answeredQuestionIndices: number[];
|
||||
totalScore?: number;
|
||||
};
|
||||
|
||||
/** Attendee JWT — existing answers for this quiz/poll (quiz master per-question state). */
|
||||
export async function getVisitorQuizPollMyResponse(
|
||||
eventId: string,
|
||||
quizPollId: string,
|
||||
): Promise<VisitorQuizPollMyResponse> {
|
||||
const base = getBaseUrl();
|
||||
const token = getAttendeeToken();
|
||||
if (!token) {
|
||||
return { hasResponse: false, answeredQuestionIndices: [] };
|
||||
}
|
||||
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/quiz-polls/${encodeURIComponent(quizPollId)}/responses/me`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
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: VisitorQuizPollMyResponse }).data;
|
||||
}
|
||||
|
||||
export type VisitorPollResultsOptionStat = {
|
||||
optionId: string;
|
||||
text: string;
|
||||
|
|
@ -664,7 +722,7 @@ export async function postVisitorExchangeLink(token: string): Promise<VisitorAut
|
|||
export async function postVisitorGuest(
|
||||
eventId: string,
|
||||
ctx: { type: VisitorJoinType; sessionId?: string; trackId?: string },
|
||||
opts: { inviteToken?: string; guestToken?: string; displayName?: string },
|
||||
opts: { inviteToken?: string; guestToken?: string; displayName?: string; email?: string },
|
||||
): Promise<VisitorAuthPayload> {
|
||||
const base = getBaseUrl();
|
||||
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/auth/guest`;
|
||||
|
|
@ -675,6 +733,7 @@ export async function postVisitorGuest(
|
|||
...joinBody(ctx.type, ctx.sessionId, ctx.trackId, opts.inviteToken),
|
||||
guestToken: opts.guestToken,
|
||||
displayName: opts.displayName,
|
||||
email: opts.email,
|
||||
}),
|
||||
});
|
||||
const body = await parseJson(res);
|
||||
|
|
@ -708,3 +767,72 @@ export async function patchVisitorProfile(
|
|||
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
|
||||
return (body as { data: VisitorAuthPayload }).data;
|
||||
}
|
||||
|
||||
export type PublicQuizPollPresentationQuestion = {
|
||||
_id: string;
|
||||
question: string;
|
||||
type: string;
|
||||
options?: { _id: string; text: string }[];
|
||||
points?: number;
|
||||
timeLimit?: number;
|
||||
};
|
||||
|
||||
export type PublicQuizPollPresentation = {
|
||||
_id: string;
|
||||
eventId: string;
|
||||
sessionId: string;
|
||||
title: string;
|
||||
type: string;
|
||||
settings: {
|
||||
displayMode?: string;
|
||||
liveQuestionId?: string | null;
|
||||
showResults?: boolean;
|
||||
};
|
||||
questions: PublicQuizPollPresentationQuestion[];
|
||||
};
|
||||
|
||||
export type PublicQuizPollResponder = {
|
||||
participantId: string;
|
||||
displayName: string;
|
||||
isCorrect: boolean | null;
|
||||
pointsEarned: number;
|
||||
answeredAt: string;
|
||||
responseTimeSec: number;
|
||||
answerText?: string;
|
||||
};
|
||||
|
||||
export type PublicQuizPollQuestionStats = {
|
||||
questionIndex: number;
|
||||
questionId: string;
|
||||
question: string;
|
||||
type: string;
|
||||
kind: string;
|
||||
totalResponses: number;
|
||||
options?: { optionId: string; text: string; count: number; percent: number; isCorrect?: boolean }[];
|
||||
answeredCount?: number;
|
||||
percentAnswered?: number;
|
||||
responders: PublicQuizPollResponder[];
|
||||
};
|
||||
|
||||
export async function getPublicQuizPollPresentation(token: string): Promise<PublicQuizPollPresentation> {
|
||||
const base = getBaseUrl();
|
||||
const url = `${base}/api/public/quiz-polls/present/${encodeURIComponent(token)}`;
|
||||
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: PublicQuizPollPresentation }).data;
|
||||
}
|
||||
|
||||
export async function getPublicQuizPollQuestionStats(
|
||||
token: string,
|
||||
questionIndex: number,
|
||||
): Promise<PublicQuizPollQuestionStats> {
|
||||
const base = getBaseUrl();
|
||||
const url = `${base}/api/public/quiz-polls/present/${encodeURIComponent(token)}/questions/${questionIndex}/stats`;
|
||||
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: PublicQuizPollQuestionStats }).data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ export type RtdbQuizPollSettings = {
|
|||
/** Seconds for timed mode */
|
||||
timeLimit?: number;
|
||||
showResults?: boolean;
|
||||
allowMultipleAttempts?: boolean;
|
||||
/** quiz_master: currently live question _id */
|
||||
liveQuestionId?: string | null;
|
||||
/** quiz_master: projector shows correct answer highlight */
|
||||
showCorrectAnswer?: boolean;
|
||||
};
|
||||
|
||||
/** Normalize status from RTDB; legacy nodes without status are treated as active. */
|
||||
|
|
@ -49,7 +54,7 @@ export function firstQuizMasterOrManualVisibleId(
|
|||
*
|
||||
* - always_sched: status active, now in [startTime, endTime] (inclusive)
|
||||
* - always_live: status active
|
||||
* - quiz_master: status active and isLive
|
||||
* - quiz_master: status active, isLive, and liveQuestionId set
|
||||
* - manual: status active and isLive
|
||||
*/
|
||||
export function isQuizPollVisibleForAudience(settings: RtdbQuizPollSettings, now: Date = new Date()): boolean {
|
||||
|
|
@ -68,7 +73,12 @@ export function isQuizPollVisibleForAudience(settings: RtdbQuizPollSettings, now
|
|||
return t >= start && t <= end;
|
||||
}
|
||||
|
||||
if (mode === "quiz_master" || mode === "manual") {
|
||||
if (mode === "quiz_master") {
|
||||
const liveQ = settings.liveQuestionId != null ? String(settings.liveQuestionId).trim() : "";
|
||||
return isLive && liveQ !== "";
|
||||
}
|
||||
|
||||
if (mode === "manual") {
|
||||
return isLive;
|
||||
}
|
||||
|
||||
|
|
@ -79,6 +89,7 @@ export type RtdbQuizPollOption = {
|
|||
_id: string;
|
||||
text: string;
|
||||
isCorrect: boolean;
|
||||
imageUrl?: string;
|
||||
};
|
||||
|
||||
export type RtdbQuizPollQuestion = {
|
||||
|
|
@ -87,6 +98,9 @@ export type RtdbQuizPollQuestion = {
|
|||
type: string;
|
||||
options: RtdbQuizPollOption[];
|
||||
points?: number;
|
||||
/** Per-question seconds (quiz master + time restricted) */
|
||||
timeLimit?: number;
|
||||
imageUrl?: string;
|
||||
};
|
||||
|
||||
export type RtdbQuizPollListItem = {
|
||||
|
|
@ -115,12 +129,18 @@ function readSettings(raw: Record<string, unknown>): RtdbQuizPollSettings {
|
|||
const endTime = o.endTime != null ? String(o.endTime) : undefined;
|
||||
const displayMode = o.displayMode != null ? String(o.displayMode).trim() : undefined;
|
||||
const status = o.status != null ? String(o.status).trim() : undefined;
|
||||
const timeRestricted = o.timeRestricted === true;
|
||||
const timeRestricted = o.timeRestricted === true || o.timeRestricted === "true";
|
||||
const timeLimitRaw = o.timeLimit;
|
||||
const timeLimit =
|
||||
typeof timeLimitRaw === "number" && Number.isFinite(timeLimitRaw) ? Math.max(0, Math.floor(timeLimitRaw)) : 0;
|
||||
const showResults = o.showResults !== false;
|
||||
return { isLive, startTime, endTime, displayMode, status, timeRestricted, timeLimit, showResults };
|
||||
const allowMultipleAttempts = o.allowMultipleAttempts === true;
|
||||
const liveQuestionId =
|
||||
o.liveQuestionId != null && String(o.liveQuestionId).trim() !== ""
|
||||
? String(o.liveQuestionId).trim()
|
||||
: null;
|
||||
const showCorrectAnswer = o.showCorrectAnswer === true || o.showCorrectAnswer === "true";
|
||||
return { isLive, startTime, endTime, displayMode, status, timeRestricted, timeLimit, showResults, allowMultipleAttempts, liveQuestionId, showCorrectAnswer };
|
||||
}
|
||||
|
||||
function mapOptions(raw: unknown): RtdbQuizPollOption[] {
|
||||
|
|
@ -135,6 +155,9 @@ function mapOptions(raw: unknown): RtdbQuizPollOption[] {
|
|||
_id: id,
|
||||
text: o.text != null ? String(o.text) : "",
|
||||
isCorrect: Boolean(o.isCorrect),
|
||||
...(o.imageUrl != null && String(o.imageUrl).trim() !== ""
|
||||
? { imageUrl: String(o.imageUrl).trim() }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -154,6 +177,13 @@ function mapQuestions(raw: unknown): RtdbQuizPollQuestion[] {
|
|||
type: qt,
|
||||
options: mapOptions(o.options),
|
||||
points: typeof o.points === "number" ? o.points : undefined,
|
||||
timeLimit:
|
||||
typeof o.timeLimit === "number" && Number.isFinite(o.timeLimit)
|
||||
? Math.max(0, Math.floor(o.timeLimit))
|
||||
: undefined,
|
||||
...(o.imageUrl != null && String(o.imageUrl).trim() !== ""
|
||||
? { imageUrl: String(o.imageUrl).trim() }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,15 +4,22 @@ import { HeaderMobile } from "../components/HeaderMobile";
|
|||
import { MobileShell } from "../components/MobileShell";
|
||||
import { DynamicFeedbackForm } from "../components/DynamicFeedbackForm";
|
||||
import { useEventRegistrationSettings } from "../hooks/useEventRegistrationSettings";
|
||||
import { getVisitorFeedbackFormSchema, getVisitorLoginPreview, type VisitorPublicFeedbackForm } from "../lib/api";
|
||||
import {
|
||||
getVisitorFeedbackFormSchema,
|
||||
getVisitorLoginPreview,
|
||||
resolveVisitorFeedbackFormSchema,
|
||||
type VisitorPublicFeedbackForm,
|
||||
} from "../lib/api";
|
||||
import { readStoredVisitorContext } from "../types/visitorContext";
|
||||
|
||||
export function FeedbackPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const formId = searchParams.get("form_id")?.trim() || "";
|
||||
const urlEventId = searchParams.get("event_id")?.trim() || "";
|
||||
const urlSessionId = searchParams.get("session_id")?.trim() || "";
|
||||
const ctx = readStoredVisitorContext();
|
||||
const eventId = urlEventId || ctx?.eventId || "";
|
||||
const sessionId = urlSessionId || ctx?.sessionId || "";
|
||||
|
||||
const [eventSnapshot, setEventSnapshot] = useState(
|
||||
ctx?.eventId === eventId && ctx.event ? ctx.event : null,
|
||||
|
|
@ -44,16 +51,22 @@ export function FeedbackPage() {
|
|||
}, [eventId, ctx?.eventId, ctx?.event]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!formId || !eventId) {
|
||||
if (!eventId) {
|
||||
setSchema(null);
|
||||
setSchemaErr(null);
|
||||
setSchemaLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setSchemaLoading(true);
|
||||
setSchemaErr(null);
|
||||
getVisitorFeedbackFormSchema(eventId, formId)
|
||||
|
||||
const load = formId
|
||||
? getVisitorFeedbackFormSchema(eventId, formId)
|
||||
: resolveVisitorFeedbackFormSchema(eventId, { sessionId, purpose: "feedback" });
|
||||
|
||||
load
|
||||
.then((s) => {
|
||||
if (!cancelled) setSchema(s);
|
||||
})
|
||||
|
|
@ -66,13 +79,13 @@ export function FeedbackPage() {
|
|||
.finally(() => {
|
||||
if (!cancelled) setSchemaLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [formId, eventId]);
|
||||
}, [formId, eventId, sessionId]);
|
||||
|
||||
const reg = useEventRegistrationSettings(eventSnapshot);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
if (!eventId) {
|
||||
return (
|
||||
|
|
@ -108,63 +121,29 @@ export function FeedbackPage() {
|
|||
);
|
||||
}
|
||||
|
||||
if (formId) {
|
||||
return (
|
||||
<MobileShell className="relative">
|
||||
<HeaderMobile logoUrl={eventSnapshot?.logoUrl} eventId={eventId || null} />
|
||||
<main className="flex flex-1 flex-col bg-[#f2f2f7] px-4 pb-10 pt-6">
|
||||
<div className="mx-auto w-full max-w-[370px]">
|
||||
<h1 className="font-inter text-xl font-semibold text-[#24282f]">
|
||||
{schema?.name || "Feedback"}
|
||||
</h1>
|
||||
{schemaLoading ? (
|
||||
<p className="mt-4 text-sm text-[rgba(36,40,47,0.67)]">Loading form…</p>
|
||||
) : schemaErr ? (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-red-600">{schemaErr}</p>
|
||||
<Link to="/event" className="mt-4 inline-block text-sm font-semibold text-[#1e72ef] underline">
|
||||
Back to event
|
||||
</Link>
|
||||
</div>
|
||||
) : schema ? (
|
||||
<div className="mt-6">
|
||||
<DynamicFeedbackForm eventId={eventId} schema={schema} variant="light" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileShell className="relative">
|
||||
<HeaderMobile logoUrl={eventSnapshot?.logoUrl} eventId={eventId || null} />
|
||||
<main className="flex flex-1 flex-col bg-[#f2f2f7] px-4 pb-10 pt-6">
|
||||
<div className="mx-auto w-full max-w-[370px]">
|
||||
<h1 className="font-inter text-xl font-semibold text-[#24282f]">Event Feedback</h1>
|
||||
<p className="mt-2 text-sm leading-snug text-[rgba(36,40,47,0.67)]">
|
||||
We'd love to hear your thoughts about this event. Your feedback helps us improve future
|
||||
experiences.
|
||||
</p>
|
||||
<div className="mt-6 rounded-md bg-white p-4 shadow-[0_1px_4px_rgba(12,12,13,0.05)]">
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-sm font-semibold text-[#24282f]">Your feedback</span>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={6}
|
||||
placeholder="Share your experience, suggestions, or issues…"
|
||||
className="w-full resize-none rounded-lg border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] px-3 py-2 font-inter text-sm text-[#24282f] placeholder:text-[rgba(36,40,47,0.45)]"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 w-full rounded-full bg-[#ef8d1d] py-3 text-xs font-semibold uppercase tracking-wide text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)]"
|
||||
>
|
||||
Submit feedback
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="font-inter text-xl font-semibold text-[#24282f]">{schema?.name || "Feedback"}</h1>
|
||||
{schemaLoading ? (
|
||||
<p className="mt-4 text-sm text-[rgba(36,40,47,0.67)]">Loading form…</p>
|
||||
) : schemaErr ? (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-red-600">{schemaErr}</p>
|
||||
<p className="mt-2 text-sm text-[rgba(36,40,47,0.67)]">
|
||||
Ask the organizer to publish an active feedback form for this event.
|
||||
</p>
|
||||
<Link to="/event" className="mt-4 inline-block text-sm font-semibold text-[#1e72ef] underline">
|
||||
Back to event
|
||||
</Link>
|
||||
</div>
|
||||
) : schema ? (
|
||||
<div className="mt-6">
|
||||
<DynamicFeedbackForm eventId={eventId} schema={schema} variant="light" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
</MobileShell>
|
||||
|
|
|
|||
|
|
@ -161,10 +161,17 @@ export function LoginPage() {
|
|||
setLoading(true);
|
||||
try {
|
||||
if (reg.authenticationType === "none") {
|
||||
const guestTok = getGuestTokenForEvent(ctx.eventId);
|
||||
const em = reg.requiresEmailOnLogin ? email.trim().toLowerCase() : "";
|
||||
if (reg.requiresEmailOnLogin && !em) {
|
||||
setSubmitError("Email is required.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const guestTok = reg.requiresEmailOnLogin ? undefined : getGuestTokenForEvent(ctx.eventId);
|
||||
const data = await postVisitorGuest(ctx.eventId, joinCtx, {
|
||||
inviteToken,
|
||||
guestToken: guestTok || undefined,
|
||||
email: em || undefined,
|
||||
});
|
||||
setAttendeeSession(data.token, data.participant);
|
||||
if (data.guestToken) setGuestTokenForEvent(ctx.eventId, data.guestToken);
|
||||
|
|
@ -226,7 +233,7 @@ export function LoginPage() {
|
|||
? "New registration may be closed. If you already registered, you can still sign in with the same email."
|
||||
: null;
|
||||
|
||||
const showEmail = reg.authenticationType !== "none";
|
||||
const showEmail = reg.requiresEmailOnLogin;
|
||||
const showInviteField = reg.registrationMode === "Invite Only";
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,256 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
getPublicQuizPollPresentation,
|
||||
getPublicQuizPollQuestionStats,
|
||||
type PublicQuizPollPresentation,
|
||||
type PublicQuizPollQuestionStats,
|
||||
} from "../lib/api";
|
||||
import { isFirebaseClientConfigured } from "../lib/firebaseClient";
|
||||
import { subscribeSessionQuizPollDetail } from "../lib/sessionQuizPollsRtdb";
|
||||
import { PresentFooterNav } from "../components/present/PresentFooterNav";
|
||||
import { PresentRespondersList } from "../components/present/PresentRespondersList";
|
||||
import { PresentStatsBars } from "../components/present/PresentStatsBars";
|
||||
import {
|
||||
mergeDisplayOptions,
|
||||
} from "../components/present/presentStatsMerge";
|
||||
|
||||
type TabId = "responses" | "participants";
|
||||
|
||||
const STATS_POLL_MS = 2500;
|
||||
|
||||
export function QuizPollPresentPage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const [presentation, setPresentation] = useState<PublicQuizPollPresentation | null>(null);
|
||||
const [stats, setStats] = useState<PublicQuizPollQuestionStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<TabId>("responses");
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
const [browsing, setBrowsing] = useState(false);
|
||||
const [liveQuestionId, setLiveQuestionId] = useState<string | null>(null);
|
||||
const [showCorrectAnswer, setShowCorrectAnswer] = useState(false);
|
||||
|
||||
const prevLiveIdRef = useRef<string | null>(null);
|
||||
|
||||
const isQuizMaster = presentation?.settings.displayMode === "quiz_master";
|
||||
const isQuiz = presentation?.type.toLowerCase() === "quiz";
|
||||
const questionCount = presentation?.questions.length ?? 0;
|
||||
const questionSafe = Math.min(questionIndex, Math.max(0, questionCount - 1));
|
||||
const currentQuestion = presentation?.questions[questionSafe];
|
||||
|
||||
const liveQuestionIndex = useMemo(() => {
|
||||
if (!presentation || !liveQuestionId) return -1;
|
||||
return presentation.questions.findIndex((q) => q._id === liveQuestionId);
|
||||
}, [presentation, liveQuestionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = token?.trim();
|
||||
if (!t) {
|
||||
setErr("Invalid presentation link.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
getPublicQuizPollPresentation(t)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setPresentation(data);
|
||||
setQuestionIndex(0);
|
||||
setBrowsing(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setErr(e instanceof Error ? e.message : "Could not load presentation.");
|
||||
setPresentation(null);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!presentation?.sessionId || !presentation._id || !isFirebaseClientConfigured()) return;
|
||||
const unsub = subscribeSessionQuizPollDetail(
|
||||
presentation.sessionId,
|
||||
presentation._id,
|
||||
(detail) => {
|
||||
const live =
|
||||
detail?.settings.liveQuestionId != null && String(detail.settings.liveQuestionId).trim() !== ""
|
||||
? String(detail.settings.liveQuestionId).trim()
|
||||
: null;
|
||||
setLiveQuestionId(live);
|
||||
setShowCorrectAnswer(detail?.settings.showCorrectAnswer === true);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
return () => unsub();
|
||||
}, [presentation?.sessionId, presentation?._id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isQuizMaster || liveQuestionIndex < 0) return;
|
||||
const live = liveQuestionId ?? null;
|
||||
if (live !== prevLiveIdRef.current) {
|
||||
prevLiveIdRef.current = live;
|
||||
setBrowsing(false);
|
||||
setQuestionIndex(liveQuestionIndex);
|
||||
} else if (!browsing) {
|
||||
setQuestionIndex(liveQuestionIndex);
|
||||
}
|
||||
}, [isQuizMaster, liveQuestionId, liveQuestionIndex, browsing]);
|
||||
|
||||
const displayOptions = useMemo(() => {
|
||||
if (!currentQuestion) return [];
|
||||
return mergeDisplayOptions(currentQuestion, stats);
|
||||
}, [currentQuestion, stats]);
|
||||
|
||||
const statsKind = stats?.kind ?? currentQuestion?.type ?? "single";
|
||||
const revealCorrect =
|
||||
isQuizMaster && liveQuestionIndex >= 0 && questionSafe === liveQuestionIndex && showCorrectAnswer;
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
const t = token?.trim();
|
||||
if (!t || !presentation) return;
|
||||
setStatsLoading(true);
|
||||
try {
|
||||
const data = await getPublicQuizPollQuestionStats(t, questionSafe);
|
||||
setStats(data);
|
||||
setErr(null);
|
||||
} catch (e) {
|
||||
setStats(null);
|
||||
setErr(e instanceof Error ? e.message : "Could not load stats.");
|
||||
} finally {
|
||||
setStatsLoading(false);
|
||||
}
|
||||
}, [token, presentation, questionSafe]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!presentation) return;
|
||||
void loadStats();
|
||||
const id = window.setInterval(() => void loadStats(), STATS_POLL_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [presentation, loadStats]);
|
||||
|
||||
const goPrev = () => {
|
||||
setBrowsing(true);
|
||||
setQuestionIndex((i) => Math.max(0, i - 1));
|
||||
};
|
||||
|
||||
const goNext = () => {
|
||||
setBrowsing(true);
|
||||
setQuestionIndex((i) => Math.min(questionCount - 1, i + 1));
|
||||
};
|
||||
|
||||
const viewingOffLive =
|
||||
isQuizMaster && liveQuestionIndex >= 0 && questionSafe !== liveQuestionIndex;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-white text-[rgba(36,40,47,0.65)]">
|
||||
Loading presentation…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (err && !presentation) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
if (!presentation || !currentQuestion) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-white text-[rgba(36,40,47,0.65)]">
|
||||
Presentation not found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="mb-6 rounded-xl border border-[#e5e5ea] bg-white p-5 shadow-sm">
|
||||
{viewingOffLive ? (
|
||||
<p className="mb-3 text-center text-xs font-medium uppercase tracking-wide text-amber-700">
|
||||
Viewing Q{questionSafe + 1} — live: Q{liveQuestionIndex + 1}
|
||||
</p>
|
||||
) : isQuizMaster && liveQuestionIndex >= 0 ? (
|
||||
<p className="mb-3 text-center text-xs font-medium uppercase tracking-wide text-emerald-700">
|
||||
Live question
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-sm font-medium uppercase tracking-wide text-[#1e72ef]">
|
||||
Question {questionSafe + 1} of {questionCount}
|
||||
</p>
|
||||
<h1 className="mt-2 text-2xl font-bold leading-snug text-[#24282f] sm:text-3xl">{currentQuestion.question}</h1>
|
||||
{statsLoading && !stats ? (
|
||||
<p className="mt-2 text-xs text-[rgba(36,40,47,0.45)]">Updating…</p>
|
||||
) : stats ? (
|
||||
<p className="mt-2 text-sm text-[rgba(36,40,47,0.65)]">{stats.totalResponses} responses</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex gap-2 rounded-lg border border-[#e5e5ea] bg-white p-1 shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("responses")}
|
||||
className={`flex-1 rounded-md px-3 py-2 text-sm font-semibold transition ${
|
||||
tab === "responses" ? "bg-[#1e72ef] text-white" : "text-[rgba(36,40,47,0.65)] hover:text-[#24282f]"
|
||||
}`}
|
||||
>
|
||||
Responses
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("participants")}
|
||||
className={`flex-1 rounded-md px-3 py-2 text-sm font-semibold transition ${
|
||||
tab === "participants" ? "bg-[#1e72ef] text-white" : "text-[rgba(36,40,47,0.65)] hover:text-[#24282f]"
|
||||
}`}
|
||||
>
|
||||
Participants
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
{tab === "responses" ? (
|
||||
<>
|
||||
<PresentStatsBars
|
||||
kind={statsKind}
|
||||
options={displayOptions}
|
||||
answeredCount={stats?.answeredCount}
|
||||
percentAnswered={stats?.percentAnswered}
|
||||
totalResponses={stats?.totalResponses ?? 0}
|
||||
revealCorrect={revealCorrect}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<PresentRespondersList
|
||||
responders={stats?.responders ?? []}
|
||||
isQuiz={isQuiz}
|
||||
showAnswerText={currentQuestion.type === "text"}
|
||||
showCorrectness={revealCorrect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<PresentFooterNav
|
||||
title={presentation.title}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
prevDisabled={questionSafe <= 0}
|
||||
nextDisabled={questionSafe >= questionCount - 1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { HeaderMobile } from "../components/HeaderMobile";
|
|||
import { MobileShell } from "../components/MobileShell";
|
||||
import {
|
||||
getVisitorLoginPreview,
|
||||
getVisitorQuizPollMyResponse,
|
||||
getVisitorQuizPollResultsSummary,
|
||||
postVisitorQuizPollResponse,
|
||||
type VisitorPollResultsSummary,
|
||||
|
|
@ -14,6 +15,7 @@ import type { RtdbQuizPollDetail, RtdbQuizPollQuestion } from "../lib/sessionQui
|
|||
import {
|
||||
firstQuizMasterOrManualVisibleId,
|
||||
isQuizPollVisibleForAudience,
|
||||
settingsDisplayMode,
|
||||
subscribeSessionQuizPollDetail,
|
||||
} from "../lib/sessionQuizPollsRtdb";
|
||||
import { useSessionQuizPollsRtdb } from "../hooks/useSessionQuizPollsRtdb";
|
||||
|
|
@ -73,6 +75,19 @@ function correctAnswerLine(q: RtdbQuizPollQuestion): string | null {
|
|||
return correct.join(", ");
|
||||
}
|
||||
|
||||
function QuestionMedia({ url, alt }: { url?: string; alt: string }) {
|
||||
const src = url?.trim();
|
||||
if (!src) return null;
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="mt-3 max-h-52 w-full rounded-md border border-[#e5e5ea] object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function buildQuizResponses(
|
||||
quiz: RtdbQuizPollDetail,
|
||||
answers: Record<string, string | string[]>,
|
||||
|
|
@ -114,6 +129,56 @@ function buildQuizResponses(
|
|||
return { items, totalScore };
|
||||
}
|
||||
|
||||
function buildSingleQuestionResponse(
|
||||
quiz: RtdbQuizPollDetail,
|
||||
q: RtdbQuizPollQuestion,
|
||||
questionIndex: number,
|
||||
answers: Record<string, string | string[]>,
|
||||
timeSpentSec: number,
|
||||
): { items: VisitorQuizPollResponseItem[]; totalScore: number } {
|
||||
const isQuizType = quiz.type.toLowerCase() === "quiz";
|
||||
const raw = answers[q._id];
|
||||
let answer: unknown;
|
||||
if (q.type === "multiple") {
|
||||
answer = Array.isArray(raw) ? raw : raw !== undefined ? [raw as string] : [];
|
||||
} else if (q.type === "text") {
|
||||
answer = typeof raw === "string" ? raw : "";
|
||||
} else {
|
||||
answer = raw ?? null;
|
||||
}
|
||||
|
||||
const timeSpent = Math.max(0, Math.floor(timeSpentSec));
|
||||
|
||||
if (!isQuizType) {
|
||||
return { items: [{ questionIndex, answer, timeSpent }], totalScore: 0 };
|
||||
}
|
||||
|
||||
let isCorrect = false;
|
||||
let pointsEarned = 0;
|
||||
const pts = q.points ?? 1;
|
||||
if (q.type === "single" || q.type === "multiple") {
|
||||
const correctIds = new Set(q.options.filter((o) => o.isCorrect).map((o) => o._id));
|
||||
if (q.type === "single") {
|
||||
isCorrect = typeof raw === "string" && correctIds.has(raw);
|
||||
} else {
|
||||
const sel = new Set(Array.isArray(raw) ? raw : []);
|
||||
isCorrect =
|
||||
correctIds.size > 0 && correctIds.size === sel.size && [...correctIds].every((id) => sel.has(id));
|
||||
}
|
||||
pointsEarned = isCorrect ? pts : 0;
|
||||
}
|
||||
return {
|
||||
items: [{ questionIndex, answer, isCorrect, pointsEarned, timeSpent }],
|
||||
totalScore: pointsEarned,
|
||||
};
|
||||
}
|
||||
|
||||
function elapsedQuestionSec(startedAt: number, limitSec: number): number {
|
||||
if (!startedAt || startedAt <= 0) return 0;
|
||||
const raw = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
|
||||
return limitSec > 0 ? Math.min(raw, limitSec) : raw;
|
||||
}
|
||||
|
||||
export function QuizPollTakePage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -131,6 +196,7 @@ export function QuizPollTakePage() {
|
|||
const [eventInfo, setEventInfo] = useState<LoginPreviewResponse | null>(null);
|
||||
|
||||
const sessionStartedAtRef = useRef<number>(0);
|
||||
const questionStartedAtRef = useRef<number>(0);
|
||||
const sessionKeyRef = useRef<string | null>(null);
|
||||
const autoSubmitDoneRef = useRef(false);
|
||||
const submitOnceRef = useRef(false);
|
||||
|
|
@ -229,7 +295,26 @@ export function QuizPollTakePage() {
|
|||
const [pollResultsLoading, setPollResultsLoading] = useState(false);
|
||||
const [pollResultsErr, setPollResultsErr] = useState<string | null>(null);
|
||||
const [questionStep, setQuestionStep] = useState(0);
|
||||
const [answeredQuestionIndices, setAnsweredQuestionIndices] = useState<number[]>([]);
|
||||
const [myResponseChecked, setMyResponseChecked] = useState(false);
|
||||
const [questionTimerEpoch, setQuestionTimerEpoch] = useState(0);
|
||||
const mainScrollRef = useRef<HTMLElement | null>(null);
|
||||
const liveQuestionIdRef = useRef<string | null>(null);
|
||||
|
||||
const isQuizMasterMode = Boolean(quiz && settingsDisplayMode(quiz.settings) === "quiz_master");
|
||||
const liveQuestionId =
|
||||
quiz?.settings.liveQuestionId != null && String(quiz.settings.liveQuestionId).trim() !== ""
|
||||
? String(quiz.settings.liveQuestionId).trim()
|
||||
: null;
|
||||
const liveQuestionIndex =
|
||||
quiz && liveQuestionId
|
||||
? quiz.questions.findIndex((q) => String(q._id) === liveQuestionId)
|
||||
: -1;
|
||||
const liveQuestion = liveQuestionIndex >= 0 && quiz ? quiz.questions[liveQuestionIndex]! : null;
|
||||
const liveQuestionAlreadyAnswered =
|
||||
isQuizMasterMode &&
|
||||
liveQuestionIndex >= 0 &&
|
||||
answeredQuestionIndices.includes(liveQuestionIndex);
|
||||
|
||||
useEffect(() => {
|
||||
setAnswers({});
|
||||
|
|
@ -241,7 +326,11 @@ export function QuizPollTakePage() {
|
|||
setPollResultsErr(null);
|
||||
setPollResultsLoading(false);
|
||||
setQuestionStep(0);
|
||||
setAnsweredQuestionIndices([]);
|
||||
setMyResponseChecked(false);
|
||||
sessionKeyRef.current = null;
|
||||
liveQuestionIdRef.current = null;
|
||||
questionStartedAtRef.current = 0;
|
||||
autoSubmitDoneRef.current = false;
|
||||
submitOnceRef.current = false;
|
||||
}, [quizPollId, quiz?._id]);
|
||||
|
|
@ -252,8 +341,11 @@ export function QuizPollTakePage() {
|
|||
}, [quiz?.questions?.length, quiz?._id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isQuizMasterMode) return;
|
||||
mainScrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, [questionStep]);
|
||||
questionStartedAtRef.current = Date.now();
|
||||
autoSubmitDoneRef.current = false;
|
||||
}, [questionStep, isQuizMasterMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!submitted || !quiz || quiz.settings.showResults === false) return;
|
||||
|
|
@ -284,26 +376,115 @@ export function QuizPollTakePage() {
|
|||
};
|
||||
}, [submitted, quiz, eventId, quizPollId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!quiz || !eventId || !quizPollId || loading) return;
|
||||
if (!getAttendeeToken()) {
|
||||
setMyResponseChecked(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setMyResponseChecked(false);
|
||||
getVisitorQuizPollMyResponse(eventId, quizPollId)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
const indices = data.answeredQuestionIndices ?? [];
|
||||
setAnsweredQuestionIndices(indices);
|
||||
const mode = settingsDisplayMode(quiz.settings);
|
||||
if (mode !== "quiz_master") {
|
||||
const allowRetry = quiz.settings.allowMultipleAttempts === true;
|
||||
const qCount = quiz.questions.length;
|
||||
const answeredAll =
|
||||
qCount > 0 &&
|
||||
indices.length >= qCount &&
|
||||
[...new Set(indices)].length >= qCount;
|
||||
if (!allowRetry && (answeredAll || data.hasResponse)) {
|
||||
setSubmitted(true);
|
||||
if (quiz.type.toLowerCase() === "quiz" && typeof data.totalScore === "number") {
|
||||
setScoreSubmitted(data.totalScore);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* ignore — user can still attempt submit */
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setMyResponseChecked(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [quiz, eventId, quizPollId, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || err || !quiz) return;
|
||||
if (isQuizPollVisibleForAudience(quiz.settings, new Date())) return;
|
||||
const t = window.setTimeout(() => {
|
||||
navigate(sessionBackHref, { replace: true });
|
||||
}, 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [loading, err, quiz, navigate, sessionBackHref]);
|
||||
|
||||
useEffect(() => {
|
||||
if (liveQuestionIdRef.current === liveQuestionId) return;
|
||||
liveQuestionIdRef.current = liveQuestionId;
|
||||
setAnswers({});
|
||||
setSubmitError(null);
|
||||
submitOnceRef.current = false;
|
||||
autoSubmitDoneRef.current = false;
|
||||
questionStartedAtRef.current = Date.now();
|
||||
setQuestionTimerEpoch((n) => n + 1);
|
||||
mainScrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, [isQuizMasterMode, liveQuestionId]);
|
||||
|
||||
const totalQuestions = quiz?.questions.length ?? 0;
|
||||
const stepSafe = Math.min(questionStep, Math.max(0, totalQuestions - 1));
|
||||
const multiQuestion = !isQuizMasterMode && totalQuestions > 1;
|
||||
const currentQuestion = isQuizMasterMode ? liveQuestion : quiz?.questions[stepSafe];
|
||||
|
||||
const timeRestrictedOn = Boolean(
|
||||
quiz?.settings.timeRestricted === true ||
|
||||
String(quiz?.settings.timeRestricted ?? "").toLowerCase() === "true",
|
||||
);
|
||||
|
||||
const perQuestionTimer = Boolean(
|
||||
quiz && settingsDisplayMode(quiz.settings) === "quiz_master" && timeRestrictedOn,
|
||||
);
|
||||
|
||||
const timerActive = Boolean(
|
||||
quiz &&
|
||||
quiz.settings.timeRestricted === true &&
|
||||
typeof quiz.settings.timeLimit === "number" &&
|
||||
quiz.settings.timeLimit > 0,
|
||||
timeRestrictedOn &&
|
||||
(perQuestionTimer
|
||||
? typeof currentQuestion?.timeLimit === "number" && currentQuestion.timeLimit > 0
|
||||
: typeof quiz.settings.timeLimit === "number" && quiz.settings.timeLimit > 0),
|
||||
);
|
||||
const timeLimitSec = timerActive ? Math.max(1, quiz!.settings.timeLimit ?? 0) : 0;
|
||||
const timeLimitSec = timerActive
|
||||
? perQuestionTimer
|
||||
? Math.max(1, currentQuestion?.timeLimit ?? 0)
|
||||
: Math.max(1, quiz!.settings.timeLimit ?? 0)
|
||||
: 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!quiz || submitted) return;
|
||||
const key = `${quiz._id}-${quizPollId}`;
|
||||
if (sessionKeyRef.current !== key) {
|
||||
sessionKeyRef.current = key;
|
||||
sessionStartedAtRef.current = Date.now();
|
||||
const now = Date.now();
|
||||
sessionStartedAtRef.current = now;
|
||||
if (settingsDisplayMode(quiz.settings) !== "quiz_master") {
|
||||
questionStartedAtRef.current = now;
|
||||
}
|
||||
autoSubmitDoneRef.current = false;
|
||||
}
|
||||
}, [quiz, quizPollId, submitted]);
|
||||
|
||||
const runSubmit = useCallback(async () => {
|
||||
if (!quiz || !eventId || !quizPollId || submitted || submitOnceRef.current) return;
|
||||
if (isQuizMasterMode && (!liveQuestion || liveQuestionIndex < 0)) return;
|
||||
if (isQuizMasterMode && liveQuestionAlreadyAnswered) return;
|
||||
|
||||
submitOnceRef.current = true;
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
|
|
@ -315,13 +496,33 @@ export function QuizPollTakePage() {
|
|||
return;
|
||||
}
|
||||
|
||||
const { items, totalScore } = buildQuizResponses(quiz, answers);
|
||||
const elapsedSec = Math.max(
|
||||
0,
|
||||
Math.round((Date.now() - (sessionStartedAtRef.current || Date.now())) / 1000),
|
||||
const elapsedSec = elapsedQuestionSec(
|
||||
questionStartedAtRef.current || Date.now(),
|
||||
perQuestionTimer && isQuizMasterMode ? timeLimitSec : 0,
|
||||
);
|
||||
|
||||
try {
|
||||
if (isQuizMasterMode && liveQuestion) {
|
||||
const { items, totalScore } = buildSingleQuestionResponse(
|
||||
quiz,
|
||||
liveQuestion,
|
||||
liveQuestionIndex,
|
||||
answers,
|
||||
elapsedSec,
|
||||
);
|
||||
const submitResult = await postVisitorQuizPollResponse(eventId, quizPollId, {
|
||||
responses: items,
|
||||
totalScore: quiz.type.toLowerCase() === "quiz" ? totalScore : undefined,
|
||||
timeSpent: elapsedSec,
|
||||
});
|
||||
notifyRewardFromApiData(submitResult);
|
||||
setAnsweredQuestionIndices((prev) =>
|
||||
prev.includes(liveQuestionIndex) ? prev : [...prev, liveQuestionIndex],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { items, totalScore } = buildQuizResponses(quiz, answers);
|
||||
const submitResult = await postVisitorQuizPollResponse(eventId, quizPollId, {
|
||||
responses: items,
|
||||
totalScore: quiz.type.toLowerCase() === "quiz" ? totalScore : undefined,
|
||||
|
|
@ -335,23 +536,70 @@ export function QuizPollTakePage() {
|
|||
setScoreSubmitted(null);
|
||||
}
|
||||
} catch (e) {
|
||||
setSubmitError(e instanceof Error ? e.message : "Could not submit.");
|
||||
submitOnceRef.current = false;
|
||||
const msg = e instanceof Error ? e.message : "Could not submit.";
|
||||
if (
|
||||
isQuizMasterMode &&
|
||||
(msg.includes("already submitted an answer for this question") ||
|
||||
msg.includes("Response already recorded"))
|
||||
) {
|
||||
setAnsweredQuestionIndices((prev) =>
|
||||
liveQuestionIndex >= 0 && !prev.includes(liveQuestionIndex)
|
||||
? [...prev, liveQuestionIndex]
|
||||
: prev,
|
||||
);
|
||||
setSubmitError(null);
|
||||
} else if (
|
||||
msg.includes("already submitted a response") ||
|
||||
msg.includes("You have already submitted")
|
||||
) {
|
||||
setSubmitted(true);
|
||||
setSubmitError(null);
|
||||
} else {
|
||||
setSubmitError(msg);
|
||||
submitOnceRef.current = false;
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [quiz, eventId, quizPollId, answers, submitted]);
|
||||
}, [
|
||||
quiz,
|
||||
eventId,
|
||||
quizPollId,
|
||||
answers,
|
||||
submitted,
|
||||
isQuizMasterMode,
|
||||
liveQuestion,
|
||||
liveQuestionIndex,
|
||||
liveQuestionAlreadyAnswered,
|
||||
perQuestionTimer,
|
||||
timeLimitSec,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!quiz || submitted || !timerActive) {
|
||||
setSecondsLeft(null);
|
||||
return;
|
||||
}
|
||||
const deadline = sessionStartedAtRef.current + timeLimitSec * 1000;
|
||||
if (isQuizMasterMode && (liveQuestionAlreadyAnswered || !liveQuestion)) {
|
||||
setSecondsLeft(null);
|
||||
return;
|
||||
}
|
||||
const startedAt = perQuestionTimer ? questionStartedAtRef.current : sessionStartedAtRef.current;
|
||||
if (perQuestionTimer && startedAt <= 0) {
|
||||
setSecondsLeft(null);
|
||||
return;
|
||||
}
|
||||
const tick = () => {
|
||||
const left = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
|
||||
if (perQuestionTimer && questionStartedAtRef.current <= 0) return;
|
||||
const tickStartedAt = perQuestionTimer ? questionStartedAtRef.current : sessionStartedAtRef.current;
|
||||
const tickDeadline = tickStartedAt + timeLimitSec * 1000;
|
||||
const left = Math.max(0, Math.ceil((tickDeadline - Date.now()) / 1000));
|
||||
setSecondsLeft(left);
|
||||
if (left <= 0 && !autoSubmitDoneRef.current) {
|
||||
if (perQuestionTimer && !isQuizMasterMode && stepSafe < totalQuestions - 1) {
|
||||
setQuestionStep((s) => Math.min(totalQuestions - 1, s + 1));
|
||||
return;
|
||||
}
|
||||
autoSubmitDoneRef.current = true;
|
||||
void runSubmit();
|
||||
}
|
||||
|
|
@ -359,7 +607,23 @@ export function QuizPollTakePage() {
|
|||
tick();
|
||||
const id = window.setInterval(tick, 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [quiz?._id, quizPollId, submitted, timerActive, timeLimitSec, runSubmit, quiz]);
|
||||
}, [
|
||||
quiz?._id,
|
||||
quizPollId,
|
||||
submitted,
|
||||
timerActive,
|
||||
timeLimitSec,
|
||||
runSubmit,
|
||||
quiz,
|
||||
perQuestionTimer,
|
||||
stepSafe,
|
||||
totalQuestions,
|
||||
isQuizMasterMode,
|
||||
liveQuestionId,
|
||||
liveQuestion,
|
||||
liveQuestionAlreadyAnswered,
|
||||
questionTimerEpoch,
|
||||
]);
|
||||
|
||||
const headerLogo = useMemo(() => {
|
||||
if (ctx?.eventId === eventId && ctx.event?.logoUrl?.trim()) return ctx.event.logoUrl.trim();
|
||||
|
|
@ -432,12 +696,7 @@ export function QuizPollTakePage() {
|
|||
<MobileShell className="relative">
|
||||
<HeaderMobile logoUrl={headerLogo} eventId={eventId} />
|
||||
<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]">
|
||||
This {typeHeading(quiz.type).toLowerCase()} is not available right now.
|
||||
</p>
|
||||
<Link to={sessionBackHref} className="mt-4 text-sm font-semibold text-[#1e72ef] underline">
|
||||
Back to session
|
||||
</Link>
|
||||
<p className="text-center text-sm text-[#24282f]">Returning to session…</p>
|
||||
</main>
|
||||
</MobileShell>
|
||||
);
|
||||
|
|
@ -607,10 +866,62 @@ export function QuizPollTakePage() {
|
|||
);
|
||||
}
|
||||
|
||||
if (isQuizMasterMode && signedIn && !myResponseChecked) {
|
||||
return (
|
||||
<MobileShell className="relative h-dvh min-h-0 flex-col">
|
||||
<div className="flex shrink-0 flex-col">
|
||||
<HeaderMobile logoUrl={headerLogo} eventId={eventId} />
|
||||
</div>
|
||||
<main className="flex flex-1 items-center justify-center bg-[#f2f2f7] px-4 py-10">
|
||||
<p className="text-sm text-[#24282f]">Loading…</p>
|
||||
</main>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
const submitLabel = submitting ? "Submitting…" : `Submit ${typeHeading(quiz.type)}`;
|
||||
const totalQuestions = quiz.questions.length;
|
||||
const multiQuestion = totalQuestions > 1;
|
||||
const stepSafe = Math.min(questionStep, Math.max(0, totalQuestions - 1));
|
||||
|
||||
if (isQuizMasterMode && liveQuestionAlreadyAnswered) {
|
||||
return (
|
||||
<MobileShell className="relative h-dvh min-h-0 flex-col">
|
||||
<div className="flex shrink-0 flex-col">
|
||||
<HeaderMobile logoUrl={headerLogo} eventId={eventId} />
|
||||
</div>
|
||||
<main className="flex flex-1 flex-col items-center justify-center bg-[#f2f2f7] px-4 py-10">
|
||||
<div className="mx-auto w-full max-w-[370px] rounded-lg border border-[rgba(116,112,112,0.2)] bg-white p-5 text-center shadow-sm">
|
||||
<p className="font-inter text-lg font-semibold text-[#24282f]">Answer submitted</p>
|
||||
<p className="mt-2 text-sm text-[rgba(36,40,47,0.75)]">
|
||||
You have already submitted this question. Please wait for the host to show the next one.
|
||||
</p>
|
||||
</div>
|
||||
<Link to={sessionBackHref} className="mt-6 text-sm font-semibold text-[#1e72ef] underline">
|
||||
Back to session
|
||||
</Link>
|
||||
</main>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (isQuizMasterMode && !liveQuestion) {
|
||||
return (
|
||||
<MobileShell className="relative h-dvh min-h-0 flex-col">
|
||||
<div className="flex shrink-0 flex-col">
|
||||
<HeaderMobile logoUrl={headerLogo} eventId={eventId} />
|
||||
</div>
|
||||
<main className="flex flex-1 flex-col items-center justify-center bg-[#f2f2f7] px-4 py-10">
|
||||
<div className="mx-auto w-full max-w-[370px] rounded-lg border border-[rgba(116,112,112,0.2)] bg-white p-5 text-center shadow-sm">
|
||||
<p className="font-inter text-lg font-semibold text-[#24282f]">{quiz.title}</p>
|
||||
<p className="mt-2 text-sm text-[rgba(36,40,47,0.75)]">
|
||||
The host will start the next question soon.
|
||||
</p>
|
||||
</div>
|
||||
<Link to={sessionBackHref} className="mt-6 text-sm font-semibold text-[#1e72ef] underline">
|
||||
Back to session
|
||||
</Link>
|
||||
</main>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
const footerBtnClass =
|
||||
"rounded-lg px-3 py-1.5 text-center text-xs font-semibold shadow-sm disabled:opacity-50 disabled:hover:bg-inherit";
|
||||
|
|
@ -628,7 +939,11 @@ export function QuizPollTakePage() {
|
|||
<div className="mx-auto w-full max-w-[370px] pb-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-[#1e72ef]">{typeHeading(quiz.type)}</p>
|
||||
<h1 className="mt-1 font-inter text-xl font-semibold text-[#24282f]">{quiz.title}</h1>
|
||||
{multiQuestion ? (
|
||||
{isQuizMasterMode && liveQuestion ? (
|
||||
<p className="mt-2 text-xs font-medium tabular-nums text-[rgba(36,40,47,0.55)]">
|
||||
Question {liveQuestionIndex + 1} of {totalQuestions}
|
||||
</p>
|
||||
) : multiQuestion ? (
|
||||
<p className="mt-2 text-xs font-medium tabular-nums text-[rgba(36,40,47,0.55)]">
|
||||
Question {stepSafe + 1} of {totalQuestions}
|
||||
</p>
|
||||
|
|
@ -640,7 +955,9 @@ export function QuizPollTakePage() {
|
|||
secondsLeft <= 30 ? "border-amber-400 bg-amber-50" : "border-[#e5e5ea] bg-white"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-medium text-[#24282f]">Time left</span>
|
||||
<span className="text-xs font-medium text-[#24282f]">
|
||||
{perQuestionTimer && isQuizMasterMode ? "Time left for this question" : "Time left"}
|
||||
</span>
|
||||
<span
|
||||
className={`font-mono text-lg font-bold tabular-nums ${
|
||||
secondsLeft <= 30 ? "text-amber-800" : "text-[#24282f]"
|
||||
|
|
@ -652,14 +969,20 @@ export function QuizPollTakePage() {
|
|||
) : null}
|
||||
|
||||
<ul className="mt-6 flex flex-col gap-6">
|
||||
{(multiQuestion ? [quiz.questions[stepSafe]!] : quiz.questions).map((q, offset) => {
|
||||
const qi = multiQuestion ? stepSafe : offset;
|
||||
{(isQuizMasterMode && liveQuestion
|
||||
? [liveQuestion]
|
||||
: multiQuestion
|
||||
? [quiz.questions[stepSafe]!]
|
||||
: quiz.questions
|
||||
).map((q, offset) => {
|
||||
const qi = isQuizMasterMode ? liveQuestionIndex : multiQuestion ? stepSafe : offset;
|
||||
return (
|
||||
<li key={q._id} className="rounded-lg border border-[rgba(116,112,112,0.2)] bg-white p-4 shadow-sm">
|
||||
<p className="text-sm font-semibold text-[#24282f]">
|
||||
<span className="text-[#8e8e93]">{qi + 1}. </span>
|
||||
{q.question}
|
||||
</p>
|
||||
<QuestionMedia url={q.imageUrl} alt="" />
|
||||
{q.type === "multiple" ? (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{q.options.map((opt) => {
|
||||
|
|
@ -677,7 +1000,10 @@ export function QuizPollTakePage() {
|
|||
onChange={() => toggleMulti(q, opt._id)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>{opt.text}</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<span>{opt.text}</span>
|
||||
<QuestionMedia url={opt.imageUrl} alt="" />
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
|
@ -723,7 +1049,10 @@ export function QuizPollTakePage() {
|
|||
onChange={() => setSingle(q, opt._id)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>{opt.text}</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<span>{opt.text}</span>
|
||||
<QuestionMedia url={opt.imageUrl} alt="" />
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -771,7 +1100,7 @@ export function QuizPollTakePage() {
|
|||
style={{ paddingBottom: "max(12px, env(safe-area-inset-bottom))", paddingTop: 12, paddingLeft: 16, paddingRight: 16 }}
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-[370px] gap-2">
|
||||
{multiQuestion ? (
|
||||
{multiQuestion && !isQuizMasterMode ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,52 +1,25 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Star } from "lucide-react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { HeaderMobile } from "../components/HeaderMobile";
|
||||
import { LegalFooterLinks } from "../components/marketing/LegalFooterLinks";
|
||||
import { MobileShell } from "../components/MobileShell";
|
||||
import { DynamicFeedbackForm } from "../components/DynamicFeedbackForm";
|
||||
import { useEventRegistrationSettings } from "../hooks/useEventRegistrationSettings";
|
||||
import { getVisitorFeedbackFormSchema, getVisitorLoginPreview, type VisitorPublicFeedbackForm } from "../lib/api";
|
||||
import {
|
||||
getVisitorFeedbackFormSchema,
|
||||
getVisitorLoginPreview,
|
||||
resolveVisitorFeedbackFormSchema,
|
||||
type VisitorPublicFeedbackForm,
|
||||
} from "../lib/api";
|
||||
import { readStoredVisitorContext } from "../types/visitorContext";
|
||||
|
||||
function StarRow({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (n: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<p className="font-inter text-base font-semibold text-[#24282f]">{label}</p>
|
||||
<div className="flex gap-4">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => onChange(i + 1)}
|
||||
className="text-[#ef8d1d]"
|
||||
aria-label={`${i + 1} stars`}
|
||||
>
|
||||
<Star
|
||||
className={`size-6 ${i < value ? "fill-[#ef8d1d]" : "fill-transparent"}`}
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RatingsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const formId = searchParams.get("form_id")?.trim() || "";
|
||||
const urlEventId = searchParams.get("event_id")?.trim() || "";
|
||||
const urlSessionId = searchParams.get("session_id")?.trim() || "";
|
||||
const ctx = readStoredVisitorContext();
|
||||
const eventId = urlEventId || ctx?.eventId || "";
|
||||
const sessionId = urlSessionId || ctx?.sessionId || "";
|
||||
|
||||
const [eventSnapshot, setEventSnapshot] = useState(
|
||||
ctx?.eventId === eventId && ctx.event ? ctx.event : null,
|
||||
|
|
@ -55,10 +28,6 @@ export function RatingsPage() {
|
|||
const [schemaLoading, setSchemaLoading] = useState(false);
|
||||
const [schemaErr, setSchemaErr] = useState<string | null>(null);
|
||||
|
||||
const [a, setA] = useState(0);
|
||||
const [b, setB] = useState(0);
|
||||
const [c, setC] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventId) {
|
||||
setEventSnapshot(null);
|
||||
|
|
@ -82,16 +51,22 @@ export function RatingsPage() {
|
|||
}, [eventId, ctx?.eventId, ctx?.event]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!formId || !eventId) {
|
||||
if (!eventId) {
|
||||
setSchema(null);
|
||||
setSchemaErr(null);
|
||||
setSchemaLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setSchemaLoading(true);
|
||||
setSchemaErr(null);
|
||||
getVisitorFeedbackFormSchema(eventId, formId)
|
||||
|
||||
const load = formId
|
||||
? getVisitorFeedbackFormSchema(eventId, formId)
|
||||
: resolveVisitorFeedbackFormSchema(eventId, { sessionId, purpose: "ratings" });
|
||||
|
||||
load
|
||||
.then((s) => {
|
||||
if (!cancelled) setSchema(s);
|
||||
})
|
||||
|
|
@ -104,10 +79,11 @@ export function RatingsPage() {
|
|||
.finally(() => {
|
||||
if (!cancelled) setSchemaLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [formId, eventId]);
|
||||
}, [formId, eventId, sessionId]);
|
||||
|
||||
const reg = useEventRegistrationSettings(eventSnapshot);
|
||||
|
||||
|
|
@ -145,71 +121,31 @@ export function RatingsPage() {
|
|||
);
|
||||
}
|
||||
|
||||
if (formId) {
|
||||
return (
|
||||
<MobileShell className="relative">
|
||||
<HeaderMobile logoUrl={eventSnapshot?.logoUrl} eventId={eventId || null} />
|
||||
<main className="flex flex-1 flex-col bg-[#f2f2f7] px-4 pb-10 pt-6">
|
||||
<div className="mx-auto w-full max-w-[370px]">
|
||||
<h1 className="font-inter text-xl font-semibold text-[#24282f]">{schema?.name || "Ratings"}</h1>
|
||||
{schemaLoading ? (
|
||||
<p className="mt-4 text-sm text-[rgba(36,40,47,0.67)]">Loading form…</p>
|
||||
) : schemaErr ? (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-red-600">{schemaErr}</p>
|
||||
<Link to="/event" className="mt-4 inline-block text-sm font-semibold text-[#1e72ef] underline">
|
||||
Back to event
|
||||
</Link>
|
||||
</div>
|
||||
) : schema ? (
|
||||
<div className="mt-6">
|
||||
<DynamicFeedbackForm eventId={eventId} schema={schema} variant="light" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileShell className="relative">
|
||||
<HeaderMobile logoUrl={eventSnapshot?.logoUrl} eventId={eventId || null} />
|
||||
<div className="eb-gradient-auth flex flex-1 flex-col pb-10 pt-4">
|
||||
<h1 className="text-center font-inter text-xl font-semibold text-white">Event Ratings</h1>
|
||||
<div className="mx-auto mt-6 w-full max-w-[354px] flex-1 px-4">
|
||||
<div className="rounded-xl bg-white p-6 shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
|
||||
<h2 className="text-center font-inter text-base font-semibold text-[#24282f]">Your Rating</h2>
|
||||
<p className="mt-3 text-center text-base leading-[22px] text-[rgba(36,40,47,0.67)]">
|
||||
We'd love to hear your thoughts about this event. Your rating helps us improve future
|
||||
events.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col gap-6">
|
||||
<StarRow label="How will you rate the speakers?" value={a} onChange={setA} />
|
||||
<StarRow label="How will you rate the quality of content?" value={b} onChange={setB} />
|
||||
<StarRow
|
||||
label="Probability to recommend to your friends and colleagues"
|
||||
value={c}
|
||||
onChange={setC}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-6 w-full rounded-full border border-[rgba(214,200,200,0.6)] bg-[#ef8d1d] py-3 text-xs font-semibold uppercase tracking-wide text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)]"
|
||||
>
|
||||
Submit Ratings
|
||||
</button>
|
||||
<div className="mt-4 text-center">
|
||||
<Link to="/event" className="font-inter font-semibold text-[#1e72ef] underline">
|
||||
Cancel
|
||||
<main className="flex flex-1 flex-col bg-[#f2f2f7] px-4 pb-10 pt-6">
|
||||
<div className="mx-auto w-full max-w-[370px]">
|
||||
<h1 className="font-inter text-xl font-semibold text-[#24282f]">{schema?.name || "Ratings"}</h1>
|
||||
{schemaLoading ? (
|
||||
<p className="mt-4 text-sm text-[rgba(36,40,47,0.67)]">Loading form…</p>
|
||||
) : schemaErr ? (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-red-600">{schemaErr}</p>
|
||||
<p className="mt-2 text-sm text-[rgba(36,40,47,0.67)]">
|
||||
Ask the organizer to publish an active ratings form for this event.
|
||||
</p>
|
||||
<Link to="/event" className="mt-4 inline-block text-sm font-semibold text-[#1e72ef] underline">
|
||||
Back to event
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : schema ? (
|
||||
<div className="mt-6">
|
||||
<DynamicFeedbackForm eventId={eventId} schema={schema} variant="light" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<footer className="mx-auto mt-6 text-sm font-light">
|
||||
<LegalFooterLinks variant="light" />
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,20 @@ import { useEventRegistrationSettings } from "../hooks/useEventRegistrationSetti
|
|||
import { useSessionQuizPollsRtdb } from "../hooks/useSessionQuizPollsRtdb";
|
||||
import { firstQuizMasterOrManualVisibleId } from "../lib/sessionQuizPollsRtdb";
|
||||
|
||||
const tabs = ["Key Persons", "My Sessions", "Agenda"] as const;
|
||||
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";
|
||||
|
|
@ -301,8 +314,23 @@ export function SingleEventPage() {
|
|||
};
|
||||
}, [eventId, sessionId, ctx?.eventId]);
|
||||
|
||||
const visibleTabs = useMemo(() => {
|
||||
if (!session) return [];
|
||||
return SESSION_TABS.filter((t) => sessionTabEnabled(session, t.id));
|
||||
}, [session]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventId) {
|
||||
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);
|
||||
|
|
@ -328,7 +356,7 @@ export function SingleEventPage() {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [eventId]);
|
||||
}, [eventId, needsAgendaData]);
|
||||
|
||||
useEffect(() => {
|
||||
setDayOpen({});
|
||||
|
|
@ -422,13 +450,13 @@ export function SingleEventPage() {
|
|||
const showSessionRatings = reg.enableRatings && sessionAllowsRatings;
|
||||
|
||||
const sessionFeedbackHref = (() => {
|
||||
const q = new URLSearchParams({ event_id: eventId });
|
||||
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 });
|
||||
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()}`;
|
||||
|
|
@ -557,12 +585,14 @@ export function SingleEventPage() {
|
|||
|
||||
<main className="relative z-0 w-full max-w-none flex-1 bg-[#f2f2f7] pb-28 pt-4">
|
||||
<div className="flex w-full max-w-none flex-col gap-4">
|
||||
<SessionQuestionsCard
|
||||
title="Audience Questions"
|
||||
showQuestionNav
|
||||
eventId={eventId}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
{session && session.enableQnA !== false ? (
|
||||
<SessionQuestionsCard
|
||||
title="Audience Questions"
|
||||
showQuestionNav
|
||||
eventId={eventId}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SessionQuizPollsSection eventId={eventId} sessionId={sessionId} />
|
||||
|
||||
|
|
@ -600,24 +630,26 @@ export function SingleEventPage() {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex w-full border-b border-[#c7c7cc] bg-transparent">
|
||||
{tabs.map((t, i) => (
|
||||
<button
|
||||
key={t}
|
||||
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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{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}
|
||||
|
||||
{tab === 0 ? (
|
||||
{activeTabId === "key-persons" ? (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{(session.speakers || []).map((s) => (
|
||||
<KeyPersonRow key={s._id} person={s} tag={s.speakerRole?.trim() || "Speaker"} />
|
||||
|
|
@ -635,7 +667,7 @@ export function SingleEventPage() {
|
|||
</ul>
|
||||
) : null}
|
||||
|
||||
{tab === 1 ? (
|
||||
{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>
|
||||
|
|
@ -654,7 +686,7 @@ export function SingleEventPage() {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === 2 ? (
|
||||
{activeTabId === "agenda" ? (
|
||||
<div className="w-full max-w-none">
|
||||
{agendaErr ? <p className="text-sm text-red-600">{agendaErr}</p> : null}
|
||||
{agendaLoading && !agendaSessions.length ? (
|
||||
|
|
|
|||
Loading…
Reference in New Issue