This commit is contained in:
Ravi Gulhane 2026-07-01 16:45:08 +05:30
parent f283127dce
commit 0419adeba6
16 changed files with 1256 additions and 234 deletions

View File

@ -16,6 +16,7 @@ import { RegisterPage } from "./pages/RegisterPage";
import { RewardsPage } from "./pages/RewardsPage"; 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 { 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";
@ -37,6 +38,7 @@ export default function App() {
<Route path="/track-v2" element={<EventTrackV2Page />} /> <Route path="/track-v2" element={<EventTrackV2Page />} />
<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="/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 />} />

View File

@ -1,8 +1,24 @@
import { useMemo, useState, type FormEvent } from "react"; import { useMemo, useState, type FormEvent } from "react";
import { Star } from "lucide-react";
import type { VisitorPublicFeedbackForm, VisitorPublicFeedbackFormField } from "../lib/api"; import type { VisitorPublicFeedbackForm, VisitorPublicFeedbackFormField } from "../lib/api";
import { postPublicFormSubmit } from "../lib/api"; import { postPublicFormSubmit } from "../lib/api";
import { getAttendeeParticipant } from "../lib/attendeeAuth"; 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({ export function DynamicFeedbackForm({
eventId, eventId,
schema, schema,
@ -30,6 +46,8 @@ export function DynamicFeedbackForm({
const raw = values[key]; const raw = values[key];
if ((f.type || "").toLowerCase() === "checkboxes") { if ((f.type || "").toLowerCase() === "checkboxes") {
if (!Array.isArray(raw) || raw.length === 0) return false; 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() === "") { } else if (raw === undefined || raw === null || String(raw).trim() === "") {
return false; 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") { if (t === "select") {
return ( return (
<label className="flex flex-col gap-2"> <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 = const longText =
label.toLowerCase().includes("comment") || label.toLowerCase().includes("comment") ||
label.toLowerCase().includes("describe") || label.toLowerCase().includes("describe") ||

View File

@ -51,10 +51,12 @@ export function HyperNavigationFAB({
eventId ? `/questions?${allQuestionsQs.toString()}` : "/questions"; eventId ? `/questions?${allQuestionsQs.toString()}` : "/questions";
const feedbackQs = new URLSearchParams(); const feedbackQs = new URLSearchParams();
if (eventId) feedbackQs.set("event_id", eventId); if (eventId) feedbackQs.set("event_id", eventId);
if (sessionId) feedbackQs.set("session_id", sessionId);
if (feedbackFormId?.trim()) feedbackQs.set("form_id", feedbackFormId.trim()); if (feedbackFormId?.trim()) feedbackQs.set("form_id", feedbackFormId.trim());
const feedbackTo = eventId ? `/feedback?${feedbackQs.toString()}` : "/feedback"; const feedbackTo = eventId ? `/feedback?${feedbackQs.toString()}` : "/feedback";
const ratingsQs = new URLSearchParams(); const ratingsQs = new URLSearchParams();
if (eventId) ratingsQs.set("event_id", eventId); if (eventId) ratingsQs.set("event_id", eventId);
if (sessionId) ratingsQs.set("session_id", sessionId);
if (ratingsFormId?.trim()) ratingsQs.set("form_id", ratingsFormId.trim()); if (ratingsFormId?.trim()) ratingsQs.set("form_id", ratingsFormId.trim());
const ratingsTo = eventId ? `/ratings?${ratingsQs.toString()}` : "/ratings"; const ratingsTo = eventId ? `/ratings?${ratingsQs.toString()}` : "/ratings";

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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);
}

View File

@ -1,5 +1,16 @@
import type { LoginPreviewResponse } from "../types/visitorContext"; 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) { export function useEventRegistrationSettings(event: LoginPreviewResponse | null | undefined) {
const s = event?.settings; const s = event?.settings;
const registrationOpen = s?.registrationOpen !== false; const registrationOpen = s?.registrationOpen !== false;
@ -10,12 +21,7 @@ export function useEventRegistrationSettings(event: LoginPreviewResponse | null
| "otp_email" | "otp_email"
| "link_email" | "link_email"
| "none"; | "none";
const registrationBaseFields = (s?.registrationBaseFields || "name_email") as const registrationBaseFields = (s?.registrationBaseFields || "name_email") as RegistrationBaseFields;
| "name_email"
| "email_only"
| "name_email_phone"
| "anonymous_name"
| "anonymous";
const registrationMode = s?.registrationMode || "Open"; const registrationMode = s?.registrationMode || "Open";
const enableFeedback = s?.enableFeedback !== false; const enableFeedback = s?.enableFeedback !== false;
const enableRatings = s?.enableRatings === true; const enableRatings = s?.enableRatings === true;
@ -34,6 +40,8 @@ export function useEventRegistrationSettings(event: LoginPreviewResponse | null
newRegistrationBlocked, newRegistrationBlocked,
authenticationType, authenticationType,
registrationBaseFields, registrationBaseFields,
requiresEmailOnLogin:
authenticationType !== "none" || registrationRequiresEmail(registrationBaseFields),
registrationMode, registrationMode,
enableFeedback, enableFeedback,
enableRatings, enableRatings,

View File

@ -239,6 +239,14 @@ export type VisitorSessionPreview = {
audienceFeedbackEnabled?: boolean; audienceFeedbackEnabled?: boolean;
/** false = hide ratings link on session page (event must still enable ratings). */ /** false = hide ratings link on session page (event must still enable ratings). */
audienceRatingsEnabled?: boolean; 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; audienceFeedbackForm?: string | null;
audienceRatingsForm?: string | null; audienceRatingsForm?: string | null;
}; };
@ -274,6 +282,24 @@ export async function getVisitorFeedbackFormSchema(
return (body as { data: VisitorPublicFeedbackForm }).data; 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( export async function postPublicFormSubmit(
formId: string, formId: string,
data: Record<string, unknown>, data: Record<string, unknown>,
@ -281,9 +307,13 @@ export async function postPublicFormSubmit(
): Promise<unknown> { ): Promise<unknown> {
const base = getBaseUrl(); const base = getBaseUrl();
const url = `${base}/api/forms/${encodeURIComponent(formId)}/submit`; const url = `${base}/api/forms/${encodeURIComponent(formId)}/submit`;
const token = getAttendeeToken();
const res = await fetch(url, { const res = await fetch(url, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ body: JSON.stringify({
data, data,
...(participantId ? { participantId } : {}), ...(participantId ? { participantId } : {}),
@ -336,6 +366,8 @@ export type VisitorQuizPollResponseItem = {
answer: unknown; answer: unknown;
isCorrect?: boolean; isCorrect?: boolean;
pointsEarned?: number; pointsEarned?: number;
/** Per-question seconds (quiz master). */
timeSpent?: number;
}; };
/** Attendee JWT required. Submits answers for a session quiz/poll (Mongo id from link / RTDB). */ /** 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; 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 = { export type VisitorPollResultsOptionStat = {
optionId: string; optionId: string;
text: string; text: string;
@ -664,7 +722,7 @@ export async function postVisitorExchangeLink(token: string): Promise<VisitorAut
export async function postVisitorGuest( export async function postVisitorGuest(
eventId: string, eventId: string,
ctx: { type: VisitorJoinType; sessionId?: string; trackId?: 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> { ): Promise<VisitorAuthPayload> {
const base = getBaseUrl(); const base = getBaseUrl();
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/auth/guest`; 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), ...joinBody(ctx.type, ctx.sessionId, ctx.trackId, opts.inviteToken),
guestToken: opts.guestToken, guestToken: opts.guestToken,
displayName: opts.displayName, displayName: opts.displayName,
email: opts.email,
}), }),
}); });
const body = await parseJson(res); 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"); if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
return (body as { data: VisitorAuthPayload }).data; 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;
}

View File

@ -13,6 +13,11 @@ export type RtdbQuizPollSettings = {
/** Seconds for timed mode */ /** Seconds for timed mode */
timeLimit?: number; timeLimit?: number;
showResults?: boolean; 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. */ /** 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_sched: status active, now in [startTime, endTime] (inclusive)
* - always_live: status active * - always_live: status active
* - quiz_master: status active and isLive * - quiz_master: status active, isLive, and liveQuestionId set
* - manual: status active and isLive * - manual: status active and isLive
*/ */
export function isQuizPollVisibleForAudience(settings: RtdbQuizPollSettings, now: Date = new Date()): boolean { 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; 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; return isLive;
} }
@ -79,6 +89,7 @@ export type RtdbQuizPollOption = {
_id: string; _id: string;
text: string; text: string;
isCorrect: boolean; isCorrect: boolean;
imageUrl?: string;
}; };
export type RtdbQuizPollQuestion = { export type RtdbQuizPollQuestion = {
@ -87,6 +98,9 @@ export type RtdbQuizPollQuestion = {
type: string; type: string;
options: RtdbQuizPollOption[]; options: RtdbQuizPollOption[];
points?: number; points?: number;
/** Per-question seconds (quiz master + time restricted) */
timeLimit?: number;
imageUrl?: string;
}; };
export type RtdbQuizPollListItem = { export type RtdbQuizPollListItem = {
@ -115,12 +129,18 @@ function readSettings(raw: Record<string, unknown>): RtdbQuizPollSettings {
const endTime = o.endTime != null ? String(o.endTime) : undefined; const endTime = o.endTime != null ? String(o.endTime) : undefined;
const displayMode = o.displayMode != null ? String(o.displayMode).trim() : undefined; const displayMode = o.displayMode != null ? String(o.displayMode).trim() : undefined;
const status = o.status != null ? String(o.status).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 timeLimitRaw = o.timeLimit;
const timeLimit = const timeLimit =
typeof timeLimitRaw === "number" && Number.isFinite(timeLimitRaw) ? Math.max(0, Math.floor(timeLimitRaw)) : 0; typeof timeLimitRaw === "number" && Number.isFinite(timeLimitRaw) ? Math.max(0, Math.floor(timeLimitRaw)) : 0;
const showResults = o.showResults !== false; 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[] { function mapOptions(raw: unknown): RtdbQuizPollOption[] {
@ -135,6 +155,9 @@ function mapOptions(raw: unknown): RtdbQuizPollOption[] {
_id: id, _id: id,
text: o.text != null ? String(o.text) : "", text: o.text != null ? String(o.text) : "",
isCorrect: Boolean(o.isCorrect), 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, type: qt,
options: mapOptions(o.options), options: mapOptions(o.options),
points: typeof o.points === "number" ? o.points : undefined, 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() }
: {}),
}; };
}); });
} }

View File

@ -4,15 +4,22 @@ import { HeaderMobile } from "../components/HeaderMobile";
import { MobileShell } from "../components/MobileShell"; import { MobileShell } from "../components/MobileShell";
import { DynamicFeedbackForm } from "../components/DynamicFeedbackForm"; import { DynamicFeedbackForm } from "../components/DynamicFeedbackForm";
import { useEventRegistrationSettings } from "../hooks/useEventRegistrationSettings"; 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"; import { readStoredVisitorContext } from "../types/visitorContext";
export function FeedbackPage() { export function FeedbackPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const formId = searchParams.get("form_id")?.trim() || ""; const formId = searchParams.get("form_id")?.trim() || "";
const urlEventId = searchParams.get("event_id")?.trim() || ""; const urlEventId = searchParams.get("event_id")?.trim() || "";
const urlSessionId = searchParams.get("session_id")?.trim() || "";
const ctx = readStoredVisitorContext(); const ctx = readStoredVisitorContext();
const eventId = urlEventId || ctx?.eventId || ""; const eventId = urlEventId || ctx?.eventId || "";
const sessionId = urlSessionId || ctx?.sessionId || "";
const [eventSnapshot, setEventSnapshot] = useState( const [eventSnapshot, setEventSnapshot] = useState(
ctx?.eventId === eventId && ctx.event ? ctx.event : null, ctx?.eventId === eventId && ctx.event ? ctx.event : null,
@ -44,16 +51,22 @@ export function FeedbackPage() {
}, [eventId, ctx?.eventId, ctx?.event]); }, [eventId, ctx?.eventId, ctx?.event]);
useEffect(() => { useEffect(() => {
if (!formId || !eventId) { if (!eventId) {
setSchema(null); setSchema(null);
setSchemaErr(null); setSchemaErr(null);
setSchemaLoading(false); setSchemaLoading(false);
return; return;
} }
let cancelled = false; let cancelled = false;
setSchemaLoading(true); setSchemaLoading(true);
setSchemaErr(null); setSchemaErr(null);
getVisitorFeedbackFormSchema(eventId, formId)
const load = formId
? getVisitorFeedbackFormSchema(eventId, formId)
: resolveVisitorFeedbackFormSchema(eventId, { sessionId, purpose: "feedback" });
load
.then((s) => { .then((s) => {
if (!cancelled) setSchema(s); if (!cancelled) setSchema(s);
}) })
@ -66,13 +79,13 @@ export function FeedbackPage() {
.finally(() => { .finally(() => {
if (!cancelled) setSchemaLoading(false); if (!cancelled) setSchemaLoading(false);
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [formId, eventId]); }, [formId, eventId, sessionId]);
const reg = useEventRegistrationSettings(eventSnapshot); const reg = useEventRegistrationSettings(eventSnapshot);
const [message, setMessage] = useState("");
if (!eventId) { if (!eventId) {
return ( return (
@ -108,20 +121,20 @@ export function FeedbackPage() {
); );
} }
if (formId) {
return ( return (
<MobileShell className="relative"> <MobileShell className="relative">
<HeaderMobile logoUrl={eventSnapshot?.logoUrl} eventId={eventId || null} /> <HeaderMobile logoUrl={eventSnapshot?.logoUrl} eventId={eventId || null} />
<main className="flex flex-1 flex-col bg-[#f2f2f7] px-4 pb-10 pt-6"> <main className="flex flex-1 flex-col bg-[#f2f2f7] px-4 pb-10 pt-6">
<div className="mx-auto w-full max-w-[370px]"> <div className="mx-auto w-full max-w-[370px]">
<h1 className="font-inter text-xl font-semibold text-[#24282f]"> <h1 className="font-inter text-xl font-semibold text-[#24282f]">{schema?.name || "Feedback"}</h1>
{schema?.name || "Feedback"}
</h1>
{schemaLoading ? ( {schemaLoading ? (
<p className="mt-4 text-sm text-[rgba(36,40,47,0.67)]">Loading form…</p> <p className="mt-4 text-sm text-[rgba(36,40,47,0.67)]">Loading form…</p>
) : schemaErr ? ( ) : schemaErr ? (
<div className="mt-4"> <div className="mt-4">
<p className="text-sm text-red-600">{schemaErr}</p> <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"> <Link to="/event" className="mt-4 inline-block text-sm font-semibold text-[#1e72ef] underline">
Back to event Back to event
</Link> </Link>
@ -135,38 +148,4 @@ export function FeedbackPage() {
</main> </main>
</MobileShell> </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&apos;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>
</div>
</main>
</MobileShell>
);
} }

View File

@ -161,10 +161,17 @@ export function LoginPage() {
setLoading(true); setLoading(true);
try { try {
if (reg.authenticationType === "none") { 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, { const data = await postVisitorGuest(ctx.eventId, joinCtx, {
inviteToken, inviteToken,
guestToken: guestTok || undefined, guestToken: guestTok || undefined,
email: em || undefined,
}); });
setAttendeeSession(data.token, data.participant); setAttendeeSession(data.token, data.participant);
if (data.guestToken) setGuestTokenForEvent(ctx.eventId, data.guestToken); 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." ? "New registration may be closed. If you already registered, you can still sign in with the same email."
: null; : null;
const showEmail = reg.authenticationType !== "none"; const showEmail = reg.requiresEmailOnLogin;
const showInviteField = reg.registrationMode === "Invite Only"; const showInviteField = reg.registrationMode === "Invite Only";
return ( return (

View File

@ -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>
);
}

View File

@ -4,6 +4,7 @@ import { HeaderMobile } from "../components/HeaderMobile";
import { MobileShell } from "../components/MobileShell"; import { MobileShell } from "../components/MobileShell";
import { import {
getVisitorLoginPreview, getVisitorLoginPreview,
getVisitorQuizPollMyResponse,
getVisitorQuizPollResultsSummary, getVisitorQuizPollResultsSummary,
postVisitorQuizPollResponse, postVisitorQuizPollResponse,
type VisitorPollResultsSummary, type VisitorPollResultsSummary,
@ -14,6 +15,7 @@ import type { RtdbQuizPollDetail, RtdbQuizPollQuestion } from "../lib/sessionQui
import { import {
firstQuizMasterOrManualVisibleId, firstQuizMasterOrManualVisibleId,
isQuizPollVisibleForAudience, isQuizPollVisibleForAudience,
settingsDisplayMode,
subscribeSessionQuizPollDetail, subscribeSessionQuizPollDetail,
} from "../lib/sessionQuizPollsRtdb"; } from "../lib/sessionQuizPollsRtdb";
import { useSessionQuizPollsRtdb } from "../hooks/useSessionQuizPollsRtdb"; import { useSessionQuizPollsRtdb } from "../hooks/useSessionQuizPollsRtdb";
@ -73,6 +75,19 @@ function correctAnswerLine(q: RtdbQuizPollQuestion): string | null {
return correct.join(", "); 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( function buildQuizResponses(
quiz: RtdbQuizPollDetail, quiz: RtdbQuizPollDetail,
answers: Record<string, string | string[]>, answers: Record<string, string | string[]>,
@ -114,6 +129,56 @@ function buildQuizResponses(
return { items, totalScore }; 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() { export function QuizPollTakePage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
@ -131,6 +196,7 @@ export function QuizPollTakePage() {
const [eventInfo, setEventInfo] = useState<LoginPreviewResponse | null>(null); const [eventInfo, setEventInfo] = useState<LoginPreviewResponse | null>(null);
const sessionStartedAtRef = useRef<number>(0); const sessionStartedAtRef = useRef<number>(0);
const questionStartedAtRef = useRef<number>(0);
const sessionKeyRef = useRef<string | null>(null); const sessionKeyRef = useRef<string | null>(null);
const autoSubmitDoneRef = useRef(false); const autoSubmitDoneRef = useRef(false);
const submitOnceRef = useRef(false); const submitOnceRef = useRef(false);
@ -229,7 +295,26 @@ export function QuizPollTakePage() {
const [pollResultsLoading, setPollResultsLoading] = useState(false); const [pollResultsLoading, setPollResultsLoading] = useState(false);
const [pollResultsErr, setPollResultsErr] = useState<string | null>(null); const [pollResultsErr, setPollResultsErr] = useState<string | null>(null);
const [questionStep, setQuestionStep] = useState(0); 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 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(() => { useEffect(() => {
setAnswers({}); setAnswers({});
@ -241,7 +326,11 @@ export function QuizPollTakePage() {
setPollResultsErr(null); setPollResultsErr(null);
setPollResultsLoading(false); setPollResultsLoading(false);
setQuestionStep(0); setQuestionStep(0);
setAnsweredQuestionIndices([]);
setMyResponseChecked(false);
sessionKeyRef.current = null; sessionKeyRef.current = null;
liveQuestionIdRef.current = null;
questionStartedAtRef.current = 0;
autoSubmitDoneRef.current = false; autoSubmitDoneRef.current = false;
submitOnceRef.current = false; submitOnceRef.current = false;
}, [quizPollId, quiz?._id]); }, [quizPollId, quiz?._id]);
@ -252,8 +341,11 @@ export function QuizPollTakePage() {
}, [quiz?.questions?.length, quiz?._id]); }, [quiz?.questions?.length, quiz?._id]);
useEffect(() => { useEffect(() => {
if (isQuizMasterMode) return;
mainScrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); mainScrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, [questionStep]); questionStartedAtRef.current = Date.now();
autoSubmitDoneRef.current = false;
}, [questionStep, isQuizMasterMode]);
useEffect(() => { useEffect(() => {
if (!submitted || !quiz || quiz.settings.showResults === false) return; if (!submitted || !quiz || quiz.settings.showResults === false) return;
@ -284,26 +376,115 @@ export function QuizPollTakePage() {
}; };
}, [submitted, quiz, eventId, quizPollId]); }, [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( const timerActive = Boolean(
quiz && quiz &&
quiz.settings.timeRestricted === true && timeRestrictedOn &&
typeof quiz.settings.timeLimit === "number" && (perQuestionTimer
quiz.settings.timeLimit > 0, ? 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(() => { useEffect(() => {
if (!quiz || submitted) return; if (!quiz || submitted) return;
const key = `${quiz._id}-${quizPollId}`; const key = `${quiz._id}-${quizPollId}`;
if (sessionKeyRef.current !== key) { if (sessionKeyRef.current !== key) {
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; autoSubmitDoneRef.current = false;
} }
}, [quiz, quizPollId, submitted]); }, [quiz, quizPollId, submitted]);
const runSubmit = useCallback(async () => { const runSubmit = useCallback(async () => {
if (!quiz || !eventId || !quizPollId || submitted || submitOnceRef.current) return; if (!quiz || !eventId || !quizPollId || submitted || submitOnceRef.current) return;
if (isQuizMasterMode && (!liveQuestion || liveQuestionIndex < 0)) return;
if (isQuizMasterMode && liveQuestionAlreadyAnswered) return;
submitOnceRef.current = true; submitOnceRef.current = true;
setSubmitting(true); setSubmitting(true);
setSubmitError(null); setSubmitError(null);
@ -315,13 +496,33 @@ export function QuizPollTakePage() {
return; return;
} }
const { items, totalScore } = buildQuizResponses(quiz, answers); const elapsedSec = elapsedQuestionSec(
const elapsedSec = Math.max( questionStartedAtRef.current || Date.now(),
0, perQuestionTimer && isQuizMasterMode ? timeLimitSec : 0,
Math.round((Date.now() - (sessionStartedAtRef.current || Date.now())) / 1000),
); );
try { 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, { const submitResult = await postVisitorQuizPollResponse(eventId, quizPollId, {
responses: items, responses: items,
totalScore: quiz.type.toLowerCase() === "quiz" ? totalScore : undefined, totalScore: quiz.type.toLowerCase() === "quiz" ? totalScore : undefined,
@ -335,23 +536,70 @@ export function QuizPollTakePage() {
setScoreSubmitted(null); setScoreSubmitted(null);
} }
} catch (e) { } catch (e) {
setSubmitError(e instanceof Error ? e.message : "Could not submit."); 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; submitOnceRef.current = false;
}
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}, [quiz, eventId, quizPollId, answers, submitted]); }, [
quiz,
eventId,
quizPollId,
answers,
submitted,
isQuizMasterMode,
liveQuestion,
liveQuestionIndex,
liveQuestionAlreadyAnswered,
perQuestionTimer,
timeLimitSec,
]);
useEffect(() => { useEffect(() => {
if (!quiz || submitted || !timerActive) { if (!quiz || submitted || !timerActive) {
setSecondsLeft(null); setSecondsLeft(null);
return; 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 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); setSecondsLeft(left);
if (left <= 0 && !autoSubmitDoneRef.current) { if (left <= 0 && !autoSubmitDoneRef.current) {
if (perQuestionTimer && !isQuizMasterMode && stepSafe < totalQuestions - 1) {
setQuestionStep((s) => Math.min(totalQuestions - 1, s + 1));
return;
}
autoSubmitDoneRef.current = true; autoSubmitDoneRef.current = true;
void runSubmit(); void runSubmit();
} }
@ -359,7 +607,23 @@ export function QuizPollTakePage() {
tick(); tick();
const id = window.setInterval(tick, 1000); const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id); 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(() => { const headerLogo = useMemo(() => {
if (ctx?.eventId === eventId && ctx.event?.logoUrl?.trim()) return ctx.event.logoUrl.trim(); if (ctx?.eventId === eventId && ctx.event?.logoUrl?.trim()) return ctx.event.logoUrl.trim();
@ -432,12 +696,7 @@ export function QuizPollTakePage() {
<MobileShell className="relative"> <MobileShell className="relative">
<HeaderMobile logoUrl={headerLogo} eventId={eventId} /> <HeaderMobile logoUrl={headerLogo} eventId={eventId} />
<main className="flex flex-1 flex-col items-center justify-center bg-[#f2f2f7] px-4 py-10"> <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]"> <p className="text-center text-sm text-[#24282f]">Returning to session…</p>
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>
</main> </main>
</MobileShell> </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 submitLabel = submitting ? "Submitting…" : `Submit ${typeHeading(quiz.type)}`;
const totalQuestions = quiz.questions.length;
const multiQuestion = totalQuestions > 1; if (isQuizMasterMode && liveQuestionAlreadyAnswered) {
const stepSafe = Math.min(questionStep, Math.max(0, totalQuestions - 1)); 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 = const footerBtnClass =
"rounded-lg px-3 py-1.5 text-center text-xs font-semibold shadow-sm disabled:opacity-50 disabled:hover:bg-inherit"; "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"> <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> <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> <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)]"> <p className="mt-2 text-xs font-medium tabular-nums text-[rgba(36,40,47,0.55)]">
Question {stepSafe + 1} of {totalQuestions} Question {stepSafe + 1} of {totalQuestions}
</p> </p>
@ -640,7 +955,9 @@ export function QuizPollTakePage() {
secondsLeft <= 30 ? "border-amber-400 bg-amber-50" : "border-[#e5e5ea] bg-white" 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 <span
className={`font-mono text-lg font-bold tabular-nums ${ className={`font-mono text-lg font-bold tabular-nums ${
secondsLeft <= 30 ? "text-amber-800" : "text-[#24282f]" secondsLeft <= 30 ? "text-amber-800" : "text-[#24282f]"
@ -652,14 +969,20 @@ export function QuizPollTakePage() {
) : null} ) : null}
<ul className="mt-6 flex flex-col gap-6"> <ul className="mt-6 flex flex-col gap-6">
{(multiQuestion ? [quiz.questions[stepSafe]!] : quiz.questions).map((q, offset) => { {(isQuizMasterMode && liveQuestion
const qi = multiQuestion ? stepSafe : offset; ? [liveQuestion]
: multiQuestion
? [quiz.questions[stepSafe]!]
: quiz.questions
).map((q, offset) => {
const qi = isQuizMasterMode ? liveQuestionIndex : multiQuestion ? stepSafe : offset;
return ( return (
<li key={q._id} className="rounded-lg border border-[rgba(116,112,112,0.2)] bg-white p-4 shadow-sm"> <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]"> <p className="text-sm font-semibold text-[#24282f]">
<span className="text-[#8e8e93]">{qi + 1}. </span> <span className="text-[#8e8e93]">{qi + 1}. </span>
{q.question} {q.question}
</p> </p>
<QuestionMedia url={q.imageUrl} alt="" />
{q.type === "multiple" ? ( {q.type === "multiple" ? (
<div className="mt-3 flex flex-col gap-2"> <div className="mt-3 flex flex-col gap-2">
{q.options.map((opt) => { {q.options.map((opt) => {
@ -677,7 +1000,10 @@ export function QuizPollTakePage() {
onChange={() => toggleMulti(q, opt._id)} onChange={() => toggleMulti(q, opt._id)}
className="mt-0.5" className="mt-0.5"
/> />
<span className="flex min-w-0 flex-1 flex-col gap-2">
<span>{opt.text}</span> <span>{opt.text}</span>
<QuestionMedia url={opt.imageUrl} alt="" />
</span>
</label> </label>
); );
})} })}
@ -723,7 +1049,10 @@ export function QuizPollTakePage() {
onChange={() => setSingle(q, opt._id)} onChange={() => setSingle(q, opt._id)}
className="mt-0.5" className="mt-0.5"
/> />
<span className="flex min-w-0 flex-1 flex-col gap-2">
<span>{opt.text}</span> <span>{opt.text}</span>
<QuestionMedia url={opt.imageUrl} alt="" />
</span>
</label> </label>
))} ))}
</div> </div>
@ -771,7 +1100,7 @@ export function QuizPollTakePage() {
style={{ paddingBottom: "max(12px, env(safe-area-inset-bottom))", paddingTop: 12, paddingLeft: 16, paddingRight: 16 }} 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"> <div className="mx-auto flex w-full max-w-[370px] gap-2">
{multiQuestion ? ( {multiQuestion && !isQuizMasterMode ? (
<> <>
<button <button
type="button" type="button"

View File

@ -1,52 +1,25 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Star } from "lucide-react";
import { Link, useSearchParams } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import { HeaderMobile } from "../components/HeaderMobile"; import { HeaderMobile } from "../components/HeaderMobile";
import { LegalFooterLinks } from "../components/marketing/LegalFooterLinks";
import { MobileShell } from "../components/MobileShell"; import { MobileShell } from "../components/MobileShell";
import { DynamicFeedbackForm } from "../components/DynamicFeedbackForm"; import { DynamicFeedbackForm } from "../components/DynamicFeedbackForm";
import { useEventRegistrationSettings } from "../hooks/useEventRegistrationSettings"; 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"; 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() { export function RatingsPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const formId = searchParams.get("form_id")?.trim() || ""; const formId = searchParams.get("form_id")?.trim() || "";
const urlEventId = searchParams.get("event_id")?.trim() || ""; const urlEventId = searchParams.get("event_id")?.trim() || "";
const urlSessionId = searchParams.get("session_id")?.trim() || "";
const ctx = readStoredVisitorContext(); const ctx = readStoredVisitorContext();
const eventId = urlEventId || ctx?.eventId || ""; const eventId = urlEventId || ctx?.eventId || "";
const sessionId = urlSessionId || ctx?.sessionId || "";
const [eventSnapshot, setEventSnapshot] = useState( const [eventSnapshot, setEventSnapshot] = useState(
ctx?.eventId === eventId && ctx.event ? ctx.event : null, ctx?.eventId === eventId && ctx.event ? ctx.event : null,
@ -55,10 +28,6 @@ export function RatingsPage() {
const [schemaLoading, setSchemaLoading] = useState(false); const [schemaLoading, setSchemaLoading] = useState(false);
const [schemaErr, setSchemaErr] = useState<string | null>(null); const [schemaErr, setSchemaErr] = useState<string | null>(null);
const [a, setA] = useState(0);
const [b, setB] = useState(0);
const [c, setC] = useState(0);
useEffect(() => { useEffect(() => {
if (!eventId) { if (!eventId) {
setEventSnapshot(null); setEventSnapshot(null);
@ -82,16 +51,22 @@ export function RatingsPage() {
}, [eventId, ctx?.eventId, ctx?.event]); }, [eventId, ctx?.eventId, ctx?.event]);
useEffect(() => { useEffect(() => {
if (!formId || !eventId) { if (!eventId) {
setSchema(null); setSchema(null);
setSchemaErr(null); setSchemaErr(null);
setSchemaLoading(false); setSchemaLoading(false);
return; return;
} }
let cancelled = false; let cancelled = false;
setSchemaLoading(true); setSchemaLoading(true);
setSchemaErr(null); setSchemaErr(null);
getVisitorFeedbackFormSchema(eventId, formId)
const load = formId
? getVisitorFeedbackFormSchema(eventId, formId)
: resolveVisitorFeedbackFormSchema(eventId, { sessionId, purpose: "ratings" });
load
.then((s) => { .then((s) => {
if (!cancelled) setSchema(s); if (!cancelled) setSchema(s);
}) })
@ -104,10 +79,11 @@ export function RatingsPage() {
.finally(() => { .finally(() => {
if (!cancelled) setSchemaLoading(false); if (!cancelled) setSchemaLoading(false);
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [formId, eventId]); }, [formId, eventId, sessionId]);
const reg = useEventRegistrationSettings(eventSnapshot); const reg = useEventRegistrationSettings(eventSnapshot);
@ -145,7 +121,6 @@ export function RatingsPage() {
); );
} }
if (formId) {
return ( return (
<MobileShell className="relative"> <MobileShell className="relative">
<HeaderMobile logoUrl={eventSnapshot?.logoUrl} eventId={eventId || null} /> <HeaderMobile logoUrl={eventSnapshot?.logoUrl} eventId={eventId || null} />
@ -157,6 +132,9 @@ export function RatingsPage() {
) : schemaErr ? ( ) : schemaErr ? (
<div className="mt-4"> <div className="mt-4">
<p className="text-sm text-red-600">{schemaErr}</p> <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"> <Link to="/event" className="mt-4 inline-block text-sm font-semibold text-[#1e72ef] underline">
Back to event Back to event
</Link> </Link>
@ -170,46 +148,4 @@ export function RatingsPage() {
</main> </main>
</MobileShell> </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&apos;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
</Link>
</div>
</div>
</div>
<footer className="mx-auto mt-6 text-sm font-light">
<LegalFooterLinks variant="light" />
</footer>
</div>
</MobileShell>
);
} }

View File

@ -31,7 +31,20 @@ import { useEventRegistrationSettings } from "../hooks/useEventRegistrationSetti
import { useSessionQuizPollsRtdb } from "../hooks/useSessionQuizPollsRtdb"; import { useSessionQuizPollsRtdb } from "../hooks/useSessionQuizPollsRtdb";
import { firstQuizMasterOrManualVisibleId } from "../lib/sessionQuizPollsRtdb"; 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 { function speakerName(s: { firstName?: string; lastName?: string }): string {
return [s.firstName, s.lastName].filter(Boolean).join(" ").trim() || "Speaker"; return [s.firstName, s.lastName].filter(Boolean).join(" ").trim() || "Speaker";
@ -301,8 +314,23 @@ export function SingleEventPage() {
}; };
}, [eventId, sessionId, ctx?.eventId]); }, [eventId, sessionId, ctx?.eventId]);
const visibleTabs = useMemo(() => {
if (!session) return [];
return SESSION_TABS.filter((t) => sessionTabEnabled(session, t.id));
}, [session]);
useEffect(() => { 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([]); setAgendaSessions([]);
setAgendaErr(null); setAgendaErr(null);
setAgendaLoading(false); setAgendaLoading(false);
@ -328,7 +356,7 @@ export function SingleEventPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [eventId]); }, [eventId, needsAgendaData]);
useEffect(() => { useEffect(() => {
setDayOpen({}); setDayOpen({});
@ -422,13 +450,13 @@ export function SingleEventPage() {
const showSessionRatings = reg.enableRatings && sessionAllowsRatings; const showSessionRatings = reg.enableRatings && sessionAllowsRatings;
const sessionFeedbackHref = (() => { const sessionFeedbackHref = (() => {
const q = new URLSearchParams({ event_id: eventId }); const q = new URLSearchParams({ event_id: eventId, session_id: sessionId });
const fid = session.audienceFeedbackForm?.trim(); const fid = session.audienceFeedbackForm?.trim();
if (fid) q.set("form_id", fid); if (fid) q.set("form_id", fid);
return `/feedback?${q.toString()}`; return `/feedback?${q.toString()}`;
})(); })();
const sessionRatingsHref = (() => { const sessionRatingsHref = (() => {
const q = new URLSearchParams({ event_id: eventId }); const q = new URLSearchParams({ event_id: eventId, session_id: sessionId });
const fid = session.audienceRatingsForm?.trim(); const fid = session.audienceRatingsForm?.trim();
if (fid) q.set("form_id", fid); if (fid) q.set("form_id", fid);
return `/ratings?${q.toString()}`; 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"> <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"> <div className="flex w-full max-w-none flex-col gap-4">
{session && session.enableQnA !== false ? (
<SessionQuestionsCard <SessionQuestionsCard
title="Audience Questions" title="Audience Questions"
showQuestionNav showQuestionNav
eventId={eventId} eventId={eventId}
sessionId={sessionId} sessionId={sessionId}
/> />
) : null}
<SessionQuizPollsSection eventId={eventId} sessionId={sessionId} /> <SessionQuizPollsSection eventId={eventId} sessionId={sessionId} />
@ -600,10 +630,11 @@ export function SingleEventPage() {
</div> </div>
) : null} ) : null}
{visibleTabs.length > 0 ? (
<div className="flex w-full border-b border-[#c7c7cc] bg-transparent"> <div className="flex w-full border-b border-[#c7c7cc] bg-transparent">
{tabs.map((t, i) => ( {visibleTabs.map((t, i) => (
<button <button
key={t} key={t.id}
type="button" type="button"
onClick={() => setTab(i)} onClick={() => setTab(i)}
className={`flex-1 px-1 pb-2 pt-3 text-center text-sm sm:px-2 ${ className={`flex-1 px-1 pb-2 pt-3 text-center text-sm sm:px-2 ${
@ -612,12 +643,13 @@ export function SingleEventPage() {
: "-mb-px border-b-2 border-transparent font-normal text-[#24282f]/80" : "-mb-px border-b-2 border-transparent font-normal text-[#24282f]/80"
}`} }`}
> >
{t} {t.label}
</button> </button>
))} ))}
</div> </div>
) : null}
{tab === 0 ? ( {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.speakerRole?.trim() || "Speaker"} />
@ -635,7 +667,7 @@ export function SingleEventPage() {
</ul> </ul>
) : null} ) : null}
{tab === 1 ? ( {activeTabId === "my-sessions" ? (
<div className="w-full max-w-none"> <div className="w-full max-w-none">
{agendaLoading && !agendaSessions.length ? ( {agendaLoading && !agendaSessions.length ? (
<p className="text-sm text-[rgba(36,40,47,0.55)]">Loading…</p> <p className="text-sm text-[rgba(36,40,47,0.55)]">Loading…</p>
@ -654,7 +686,7 @@ export function SingleEventPage() {
</div> </div>
) : null} ) : null}
{tab === 2 ? ( {activeTabId === "agenda" ? (
<div className="w-full max-w-none"> <div className="w-full max-w-none">
{agendaErr ? <p className="text-sm text-red-600">{agendaErr}</p> : null} {agendaErr ? <p className="text-sm text-red-600">{agendaErr}</p> : null}
{agendaLoading && !agendaSessions.length ? ( {agendaLoading && !agendaSessions.length ? (