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 { 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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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") ||
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
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,
|
||||||
|
|
|
||||||
132
src/lib/api.ts
132
src/lib/api.ts
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,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 (
|
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]">Event Feedback</h1>
|
<h1 className="font-inter text-xl font-semibold text-[#24282f]">{schema?.name || "Feedback"}</h1>
|
||||||
<p className="mt-2 text-sm leading-snug text-[rgba(36,40,47,0.67)]">
|
{schemaLoading ? (
|
||||||
We'd love to hear your thoughts about this event. Your feedback helps us improve future
|
<p className="mt-4 text-sm text-[rgba(36,40,47,0.67)]">Loading form…</p>
|
||||||
experiences.
|
) : schemaErr ? (
|
||||||
</p>
|
<div className="mt-4">
|
||||||
<div className="mt-6 rounded-md bg-white p-4 shadow-[0_1px_4px_rgba(12,12,13,0.05)]">
|
<p className="text-sm text-red-600">{schemaErr}</p>
|
||||||
<label className="flex flex-col gap-2">
|
<p className="mt-2 text-sm text-[rgba(36,40,47,0.67)]">
|
||||||
<span className="text-sm font-semibold text-[#24282f]">Your feedback</span>
|
Ask the organizer to publish an active feedback form for this event.
|
||||||
<textarea
|
</p>
|
||||||
value={message}
|
<Link to="/event" className="mt-4 inline-block text-sm font-semibold text-[#1e72ef] underline">
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
Back to event
|
||||||
rows={6}
|
</Link>
|
||||||
placeholder="Share your experience, suggestions, or issues…"
|
</div>
|
||||||
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)]"
|
) : schema ? (
|
||||||
/>
|
<div className="mt-6">
|
||||||
</label>
|
<DynamicFeedbackForm eventId={eventId} schema={schema} variant="light" />
|
||||||
<button
|
</div>
|
||||||
type="button"
|
) : null}
|
||||||
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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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 { 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.";
|
||||||
submitOnceRef.current = false;
|
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 {
|
} 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>{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>
|
</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>{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>
|
</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"
|
||||||
|
|
|
||||||
|
|
@ -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,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 (
|
return (
|
||||||
<MobileShell className="relative">
|
<MobileShell className="relative">
|
||||||
<HeaderMobile logoUrl={eventSnapshot?.logoUrl} eventId={eventId || null} />
|
<HeaderMobile logoUrl={eventSnapshot?.logoUrl} eventId={eventId || null} />
|
||||||
<div className="eb-gradient-auth flex flex-1 flex-col pb-10 pt-4">
|
<main className="flex flex-1 flex-col bg-[#f2f2f7] px-4 pb-10 pt-6">
|
||||||
<h1 className="text-center font-inter text-xl font-semibold text-white">Event Ratings</h1>
|
<div className="mx-auto w-full max-w-[370px]">
|
||||||
<div className="mx-auto mt-6 w-full max-w-[354px] flex-1 px-4">
|
<h1 className="font-inter text-xl font-semibold text-[#24282f]">{schema?.name || "Ratings"}</h1>
|
||||||
<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)]">
|
{schemaLoading ? (
|
||||||
<h2 className="text-center font-inter text-base font-semibold text-[#24282f]">Your Rating</h2>
|
<p className="mt-4 text-sm text-[rgba(36,40,47,0.67)]">Loading form…</p>
|
||||||
<p className="mt-3 text-center text-base leading-[22px] text-[rgba(36,40,47,0.67)]">
|
) : schemaErr ? (
|
||||||
We'd love to hear your thoughts about this event. Your rating helps us improve future
|
<div className="mt-4">
|
||||||
events.
|
<p className="text-sm text-red-600">{schemaErr}</p>
|
||||||
</p>
|
<p className="mt-2 text-sm text-[rgba(36,40,47,0.67)]">
|
||||||
<div className="mt-6 flex flex-col gap-6">
|
Ask the organizer to publish an active ratings form for this event.
|
||||||
<StarRow label="How will you rate the speakers?" value={a} onChange={setA} />
|
</p>
|
||||||
<StarRow label="How will you rate the quality of content?" value={b} onChange={setB} />
|
<Link to="/event" className="mt-4 inline-block text-sm font-semibold text-[#1e72ef] underline">
|
||||||
<StarRow
|
Back to event
|
||||||
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : schema ? (
|
||||||
|
<div className="mt-6">
|
||||||
|
<DynamicFeedbackForm eventId={eventId} schema={schema} variant="light" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<footer className="mx-auto mt-6 text-sm font-light">
|
</main>
|
||||||
<LegalFooterLinks variant="light" />
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
<SessionQuestionsCard
|
{session && session.enableQnA !== false ? (
|
||||||
title="Audience Questions"
|
<SessionQuestionsCard
|
||||||
showQuestionNav
|
title="Audience Questions"
|
||||||
eventId={eventId}
|
showQuestionNav
|
||||||
sessionId={sessionId}
|
eventId={eventId}
|
||||||
/>
|
sessionId={sessionId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<SessionQuizPollsSection eventId={eventId} sessionId={sessionId} />
|
<SessionQuizPollsSection eventId={eventId} sessionId={sessionId} />
|
||||||
|
|
||||||
|
|
@ -600,24 +630,26 @@ export function SingleEventPage() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex w-full border-b border-[#c7c7cc] bg-transparent">
|
{visibleTabs.length > 0 ? (
|
||||||
{tabs.map((t, i) => (
|
<div className="flex w-full border-b border-[#c7c7cc] bg-transparent">
|
||||||
<button
|
{visibleTabs.map((t, i) => (
|
||||||
key={t}
|
<button
|
||||||
type="button"
|
key={t.id}
|
||||||
onClick={() => setTab(i)}
|
type="button"
|
||||||
className={`flex-1 px-1 pb-2 pt-3 text-center text-sm sm:px-2 ${
|
onClick={() => setTab(i)}
|
||||||
tab === i
|
className={`flex-1 px-1 pb-2 pt-3 text-center text-sm sm:px-2 ${
|
||||||
? "border-b-2 border-[#1e72ef] font-semibold text-[#24282f]"
|
tab === i
|
||||||
: "-mb-px border-b-2 border-transparent font-normal text-[#24282f]/80"
|
? "border-b-2 border-[#1e72ef] font-semibold text-[#24282f]"
|
||||||
}`}
|
: "-mb-px border-b-2 border-transparent font-normal text-[#24282f]/80"
|
||||||
>
|
}`}
|
||||||
{t}
|
>
|
||||||
</button>
|
{t.label}
|
||||||
))}
|
</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 ? (
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue