906 lines
32 KiB
TypeScript
906 lines
32 KiB
TypeScript
import type { AttendeeParticipant } from "./attendeeAuth";
|
|
import { getAttendeeToken } from "./attendeeAuth";
|
|
import type { LoginPreviewResponse, ResolveCodeResponse, VisitorJoinType } from "../types/visitorContext";
|
|
|
|
function getBaseUrl(): string {
|
|
const raw = import.meta.env.VITE_API_BASE_URL;
|
|
if (typeof raw !== "string" || !raw.trim()) return "";
|
|
return raw.replace(/\/$/, "");
|
|
}
|
|
|
|
async function parseJson(res: Response): Promise<unknown> {
|
|
const text = await res.text();
|
|
if (!text) return null;
|
|
try {
|
|
return JSON.parse(text) as unknown;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function messageFromBody(body: unknown): string {
|
|
if (body && typeof body === "object" && "error" in body) {
|
|
const err = (body as { error?: { message?: string } }).error;
|
|
if (err?.message) return err.message;
|
|
}
|
|
return "Something went wrong";
|
|
}
|
|
|
|
export async function postVisitorResolveCode(code: string): Promise<ResolveCodeResponse> {
|
|
const base = getBaseUrl();
|
|
const url = `${base}/api/visitor/resolve-code`;
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ code }),
|
|
});
|
|
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");
|
|
}
|
|
const data = (body as { data: ResolveCodeResponse }).data;
|
|
if (!data?.type || !data.eventId) {
|
|
throw new Error("Invalid response");
|
|
}
|
|
if (data.type === "session" && !data.sessionId) {
|
|
throw new Error("Invalid response");
|
|
}
|
|
if (data.type === "track" && !data.trackId) {
|
|
throw new Error("Invalid response");
|
|
}
|
|
return data;
|
|
}
|
|
|
|
export async function getVisitorLoginPreview(eventId: string): Promise<LoginPreviewResponse> {
|
|
const base = getBaseUrl();
|
|
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/login-preview`;
|
|
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: LoginPreviewResponse }).data;
|
|
}
|
|
|
|
export type VisitorEventTrackListItem = {
|
|
_id: string;
|
|
event: string;
|
|
name: string;
|
|
hall?: string;
|
|
trackType?: string;
|
|
status?: string;
|
|
joinCode?: string;
|
|
};
|
|
|
|
/** Public list of tracks for building event agenda links. */
|
|
export async function getVisitorEventTracks(eventId: string): Promise<VisitorEventTrackListItem[]> {
|
|
const base = getBaseUrl();
|
|
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/tracks`;
|
|
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");
|
|
const data = (body as { data: { tracks?: VisitorEventTrackListItem[] } }).data;
|
|
return Array.isArray(data?.tracks) ? data.tracks : [];
|
|
}
|
|
|
|
export type VisitorEventQuizPollListItem = {
|
|
_id: string;
|
|
title: string;
|
|
type: string;
|
|
session: string;
|
|
};
|
|
|
|
/** Public list of active session-scoped quizzes/polls (for take links). */
|
|
export async function getVisitorEventQuizPolls(eventId: string): Promise<VisitorEventQuizPollListItem[]> {
|
|
const base = getBaseUrl();
|
|
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/quiz-polls`;
|
|
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");
|
|
const data = (body as { data: { quizPolls?: VisitorEventQuizPollListItem[] } }).data;
|
|
return Array.isArray(data?.quizPolls) ? data.quizPolls : [];
|
|
}
|
|
|
|
/** Requires attendee JWT; participant must belong to `eventId`. */
|
|
export async function getVisitorEventRewardBalance(eventId: string): Promise<number> {
|
|
const base = getBaseUrl();
|
|
const token = getAttendeeToken();
|
|
if (!token) throw new Error("Not signed in");
|
|
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/reward-balance`;
|
|
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");
|
|
const data = (body as { data: { total?: unknown } }).data;
|
|
const n = typeof data?.total === "number" ? data.total : Number(data?.total);
|
|
return Number.isFinite(n) ? n : 0;
|
|
}
|
|
|
|
export type VisitorRewardHistoryItem = {
|
|
_id: string;
|
|
points: number;
|
|
type: string;
|
|
source?: string;
|
|
description?: string;
|
|
createdAt?: string;
|
|
};
|
|
|
|
/** Paginated ledger for the signed-in attendee and event. */
|
|
export async function getVisitorRewardHistory(
|
|
eventId: string,
|
|
opts?: { page?: number; limit?: number }
|
|
): Promise<{ items: VisitorRewardHistoryItem[]; total: number; page: number; limit: number }> {
|
|
const base = getBaseUrl();
|
|
const token = getAttendeeToken();
|
|
if (!token) throw new Error("Not signed in");
|
|
const sp = new URLSearchParams();
|
|
if (opts?.page != null) sp.set("page", String(opts.page));
|
|
if (opts?.limit != null) sp.set("limit", String(opts.limit));
|
|
const q = sp.toString();
|
|
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/reward-history${q ? `?${q}` : ""}`;
|
|
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");
|
|
const data = (body as { data: { items?: VisitorRewardHistoryItem[]; total?: number; page?: number; limit?: number } }).data;
|
|
return {
|
|
items: Array.isArray(data?.items) ? data.items : [],
|
|
total: typeof data?.total === "number" ? data.total : 0,
|
|
page: typeof data?.page === "number" ? data.page : 1,
|
|
limit: typeof data?.limit === "number" ? data.limit : 20,
|
|
};
|
|
}
|
|
|
|
/** Attendee JWT required. Session ids bookmarked for this event. */
|
|
export async function getVisitorSessionBookmarks(eventId: string): Promise<string[]> {
|
|
const base = getBaseUrl();
|
|
const token = getAttendeeToken();
|
|
if (!token) throw new Error("Not signed in");
|
|
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/session-bookmarks`;
|
|
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");
|
|
const data = (body as { data: { sessionIds?: string[] } }).data;
|
|
return Array.isArray(data?.sessionIds) ? data.sessionIds : [];
|
|
}
|
|
|
|
/** Attendee JWT required. Returns updated bookmark list for the event. */
|
|
export async function putVisitorSessionBookmark(
|
|
eventId: string,
|
|
sessionId: string,
|
|
bookmarked: boolean,
|
|
): Promise<string[]> {
|
|
const base = getBaseUrl();
|
|
const token = getAttendeeToken();
|
|
if (!token) throw new Error("Not signed in");
|
|
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/session-bookmarks`;
|
|
const res = await fetch(url, {
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ sessionId, bookmarked }),
|
|
});
|
|
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");
|
|
const data = (body as { data: { sessionIds?: string[] } }).data;
|
|
return Array.isArray(data?.sessionIds) ? data.sessionIds : [];
|
|
}
|
|
|
|
export type VisitorSessionPreviewSpeakerSocial = {
|
|
linkedin?: string;
|
|
twitter?: string;
|
|
website?: string;
|
|
};
|
|
|
|
export type VisitorSessionPreviewSpeaker = {
|
|
_id: string;
|
|
firstName?: string;
|
|
lastName?: string;
|
|
title?: string;
|
|
photo?: string;
|
|
/** Faculty profile role (directory). Prefer sessionRole for this session. */
|
|
speakerRole?: string;
|
|
/** Per-session key-person role assigned on the session. */
|
|
sessionRole?: string;
|
|
/** Shown when present (private profiles still get bio / expertise / photo / title). */
|
|
bio?: string;
|
|
expertise?: string[];
|
|
/** Only included when speaker profile visibility is public. */
|
|
organizationName?: string;
|
|
/** Only included when speaker profile visibility is public. */
|
|
socialLinks?: VisitorSessionPreviewSpeakerSocial;
|
|
};
|
|
|
|
export type VisitorSessionPreview = {
|
|
_id: string;
|
|
event: string;
|
|
track: { _id: string; name?: string; hall?: string; trackType?: string } | null;
|
|
title: string;
|
|
description?: string;
|
|
type: string;
|
|
startTime: string;
|
|
endTime: string;
|
|
location?: string;
|
|
capacity?: number;
|
|
status: string;
|
|
joinCode?: string;
|
|
speakers: VisitorSessionPreviewSpeaker[];
|
|
moderator: VisitorSessionPreviewSpeaker | null;
|
|
/** false = hide feedback link on session page (event must still allow feedback). */
|
|
audienceFeedbackEnabled?: boolean;
|
|
/** false = hide ratings link on session page (event must still enable ratings). */
|
|
audienceRatingsEnabled?: boolean;
|
|
/** false = hide Key Persons tab on session page. */
|
|
audienceKeyPersonsEnabled?: boolean;
|
|
/** false = hide My Sessions tab on session page. */
|
|
audienceMySessionsEnabled?: boolean;
|
|
/** false = hide Agenda tab on session page. */
|
|
audienceAgendaEnabled?: boolean;
|
|
/** false = hide Q&A on visitor session page. */
|
|
enableQnA?: boolean;
|
|
audienceFeedbackForm?: string | null;
|
|
audienceRatingsForm?: string | null;
|
|
};
|
|
|
|
export type VisitorPublicFeedbackFormField = {
|
|
label?: string;
|
|
type?: string;
|
|
required?: boolean;
|
|
options?: string[];
|
|
placeholder?: string;
|
|
remarks?: string;
|
|
};
|
|
|
|
export type VisitorPublicFeedbackForm = {
|
|
_id: string;
|
|
name: string;
|
|
description?: string;
|
|
fields: VisitorPublicFeedbackFormField[];
|
|
thankYouMessage?: string;
|
|
showThankYou?: boolean;
|
|
};
|
|
|
|
/** Public form schema for rendering; form must be type feedback and belong to event org. */
|
|
export async function getVisitorFeedbackFormSchema(
|
|
eventId: string,
|
|
formId: string,
|
|
): Promise<VisitorPublicFeedbackForm> {
|
|
const base = getBaseUrl();
|
|
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/feedback-forms/${encodeURIComponent(formId)}`;
|
|
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;
|
|
}
|
|
|
|
/** Resolve session-linked or default active feedback/survey form for an event. */
|
|
export async function resolveVisitorFeedbackFormSchema(
|
|
eventId: string,
|
|
opts?: { sessionId?: string; purpose?: "feedback" | "ratings" },
|
|
): Promise<VisitorPublicFeedbackForm> {
|
|
const base = getBaseUrl();
|
|
const qs = new URLSearchParams();
|
|
if (opts?.sessionId?.trim()) qs.set("session_id", opts.sessionId.trim());
|
|
if (opts?.purpose) qs.set("purpose", opts.purpose);
|
|
const q = qs.toString();
|
|
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/feedback-forms/resolve${q ? `?${q}` : ""}`;
|
|
const res = await fetch(url);
|
|
const body = await parseJson(res);
|
|
if (!res.ok) throw new Error(messageFromBody(body));
|
|
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
|
|
return (body as { data: VisitorPublicFeedbackForm }).data;
|
|
}
|
|
|
|
export async function postPublicFormSubmit(
|
|
formId: string,
|
|
data: Record<string, unknown>,
|
|
participantId?: string | null,
|
|
): Promise<unknown> {
|
|
const base = getBaseUrl();
|
|
const url = `${base}/api/forms/${encodeURIComponent(formId)}/submit`;
|
|
const token = getAttendeeToken();
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
body: JSON.stringify({
|
|
data,
|
|
...(participantId ? { participantId } : {}),
|
|
}),
|
|
});
|
|
const body = await parseJson(res);
|
|
if (!res.ok) throw new Error(messageFromBody(body));
|
|
if (!body || typeof body !== "object") throw new Error("Invalid response");
|
|
return body;
|
|
}
|
|
|
|
/** Public full event agenda (sessions with track/time) for attendee schedule. */
|
|
export async function getVisitorEventAgenda(eventId: string): Promise<VisitorSessionPreview[]> {
|
|
const base = getBaseUrl();
|
|
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/agenda`;
|
|
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");
|
|
const data = (body as { data: { sessions?: VisitorSessionPreview[] } }).data;
|
|
return Array.isArray(data?.sessions) ? data.sessions : [];
|
|
}
|
|
|
|
export type VisitorTrackPreview = {
|
|
_id: string;
|
|
event: string;
|
|
name: string;
|
|
hall?: string;
|
|
trackType?: string;
|
|
status?: string;
|
|
joinCode?: string;
|
|
};
|
|
|
|
export async function getVisitorSessionPreview(
|
|
sessionId: string,
|
|
eventId: string,
|
|
): Promise<VisitorSessionPreview> {
|
|
const base = getBaseUrl();
|
|
const qs = new URLSearchParams({ event_id: eventId });
|
|
const url = `${base}/api/visitor/sessions/${encodeURIComponent(sessionId)}/preview?${qs}`;
|
|
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: VisitorSessionPreview }).data;
|
|
}
|
|
|
|
export type VisitorQuizPollResponseItem = {
|
|
questionIndex: number;
|
|
answer: unknown;
|
|
isCorrect?: boolean;
|
|
pointsEarned?: number;
|
|
/** Per-question seconds (quiz master). */
|
|
timeSpent?: number;
|
|
};
|
|
|
|
/** Attendee JWT required. Submits answers for a session quiz/poll (Mongo id from link / RTDB). */
|
|
export type VisitorQuizPollSubmitResult = Record<string, unknown> & {
|
|
reward?: { pointsEarned?: number };
|
|
};
|
|
|
|
export async function postVisitorQuizPollResponse(
|
|
eventId: string,
|
|
quizPollId: string,
|
|
payload: {
|
|
responses: VisitorQuizPollResponseItem[];
|
|
totalScore?: number;
|
|
timeSpent?: number;
|
|
},
|
|
): Promise<VisitorQuizPollSubmitResult> {
|
|
const base = getBaseUrl();
|
|
const token = getAttendeeToken();
|
|
if (!token) throw new Error("Sign in to submit");
|
|
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/quiz-polls/${encodeURIComponent(quizPollId)}/responses`;
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
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: VisitorQuizPollSubmitResult }).data;
|
|
}
|
|
|
|
export type VisitorQuizPollMyResponse = {
|
|
hasResponse: boolean;
|
|
answeredQuestionIndices: number[];
|
|
totalScore?: number;
|
|
};
|
|
|
|
/** Attendee JWT — existing answers for this quiz/poll (quiz master per-question state). */
|
|
export async function getVisitorQuizPollMyResponse(
|
|
eventId: string,
|
|
quizPollId: string,
|
|
): Promise<VisitorQuizPollMyResponse> {
|
|
const base = getBaseUrl();
|
|
const token = getAttendeeToken();
|
|
if (!token) {
|
|
return { hasResponse: false, answeredQuestionIndices: [] };
|
|
}
|
|
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/quiz-polls/${encodeURIComponent(quizPollId)}/responses/me`;
|
|
const res = await fetch(url, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
const body = await parseJson(res);
|
|
if (!res.ok) throw new Error(messageFromBody(body));
|
|
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
|
|
return (body as { data: VisitorQuizPollMyResponse }).data;
|
|
}
|
|
|
|
export type VisitorPollResultsOptionStat = {
|
|
optionId: string;
|
|
text: string;
|
|
count: number;
|
|
percent: number;
|
|
};
|
|
|
|
export type VisitorPollResultsQuestion = {
|
|
questionIndex: number;
|
|
questionId: string;
|
|
type: string;
|
|
question: string;
|
|
totalResponses: number;
|
|
kind?: string;
|
|
options?: VisitorPollResultsOptionStat[];
|
|
answeredCount?: number;
|
|
percentAnswered?: number;
|
|
};
|
|
|
|
export type VisitorPollResultsSummary = {
|
|
totalSubmissions: number;
|
|
questions: VisitorPollResultsQuestion[];
|
|
};
|
|
|
|
/** Attendee JWT. Aggregated percentages for poll/survey when showResults is enabled on the server. */
|
|
export async function getVisitorQuizPollResultsSummary(
|
|
eventId: string,
|
|
quizPollId: string,
|
|
): Promise<VisitorPollResultsSummary> {
|
|
const base = getBaseUrl();
|
|
const token = getAttendeeToken();
|
|
if (!token) throw new Error("Sign in to view results");
|
|
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/quiz-polls/${encodeURIComponent(quizPollId)}/results-summary`;
|
|
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");
|
|
const data = (body as { data: VisitorPollResultsSummary }).data;
|
|
if (!data?.questions || !Array.isArray(data.questions)) throw new Error("Invalid response");
|
|
return {
|
|
totalSubmissions: typeof data.totalSubmissions === "number" ? data.totalSubmissions : 0,
|
|
questions: data.questions,
|
|
};
|
|
}
|
|
|
|
export async function getVisitorTrackPreview(trackId: string, eventId: string): Promise<VisitorTrackPreview> {
|
|
const base = getBaseUrl();
|
|
const qs = new URLSearchParams({ event_id: eventId });
|
|
const url = `${base}/api/visitor/tracks/${encodeURIComponent(trackId)}/preview?${qs}`;
|
|
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: VisitorTrackPreview }).data;
|
|
}
|
|
|
|
export type VisitorSessionQuestion = {
|
|
_id: string;
|
|
author: string;
|
|
text: string;
|
|
up: number;
|
|
down: number;
|
|
createdAt: string;
|
|
};
|
|
|
|
export async function getVisitorSessionQuestions(
|
|
sessionId: string,
|
|
eventId: string,
|
|
): Promise<VisitorSessionQuestion[]> {
|
|
const base = getBaseUrl();
|
|
const qs = new URLSearchParams({ event_id: eventId });
|
|
const url = `${base}/api/visitor/sessions/${encodeURIComponent(sessionId)}/questions?${qs}`;
|
|
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");
|
|
const data = (body as { data: { questions?: VisitorSessionQuestion[] } }).data;
|
|
return Array.isArray(data?.questions) ? data.questions : [];
|
|
}
|
|
|
|
export async function postVisitorSessionQuestionVote(
|
|
sessionId: string,
|
|
eventId: string,
|
|
questionId: string,
|
|
body: { voterKey: string; direction: "up" | "down" },
|
|
): Promise<{
|
|
questionId: string;
|
|
up: number;
|
|
down: number;
|
|
yourVote: "up" | "down" | null;
|
|
reward?: { pointsEarned: number };
|
|
}> {
|
|
const base = getBaseUrl();
|
|
const qs = new URLSearchParams({ event_id: eventId });
|
|
const url = `${base}/api/visitor/sessions/${encodeURIComponent(sessionId)}/questions/${encodeURIComponent(questionId)}/vote?${qs}`;
|
|
const token = getAttendeeToken();
|
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
});
|
|
const parsed = await parseJson(res);
|
|
if (!res.ok) throw new Error(messageFromBody(parsed));
|
|
if (!parsed || typeof parsed !== "object" || !("data" in parsed)) throw new Error("Invalid response");
|
|
const data = (parsed as { data: Record<string, unknown> }).data;
|
|
const yourVoteRaw = data?.yourVote;
|
|
const yourVote =
|
|
yourVoteRaw === "up" || yourVoteRaw === "down" ? yourVoteRaw : null;
|
|
const rewardRaw = data?.reward as { pointsEarned?: unknown } | undefined;
|
|
const pe = Number(rewardRaw?.pointsEarned);
|
|
return {
|
|
questionId: String(data?.questionId ?? questionId),
|
|
up: typeof data?.up === "number" ? data.up : Number(data?.up) || 0,
|
|
down: typeof data?.down === "number" ? data.down : Number(data?.down) || 0,
|
|
yourVote,
|
|
...(Number.isFinite(pe) && pe > 0 ? { reward: { pointsEarned: pe } } : {}),
|
|
};
|
|
}
|
|
|
|
export async function postVisitorSessionQuestion(
|
|
sessionId: string,
|
|
eventId: string,
|
|
payload: { question: string; isAnonymous?: boolean },
|
|
): Promise<{ question: VisitorSessionQuestion; reward?: { pointsEarned: number } }> {
|
|
const base = getBaseUrl();
|
|
const qs = new URLSearchParams({ event_id: eventId });
|
|
const url = `${base}/api/visitor/sessions/${encodeURIComponent(sessionId)}/questions?${qs}`;
|
|
const token = getAttendeeToken();
|
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({
|
|
question: payload.question,
|
|
isAnonymous: payload.isAnonymous ?? false,
|
|
}),
|
|
});
|
|
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");
|
|
const data = (body as { data: { question?: VisitorSessionQuestion; reward?: { pointsEarned?: number } } }).data;
|
|
if (!data?.question) throw new Error("Invalid response");
|
|
const pe = Number(data.reward?.pointsEarned);
|
|
return {
|
|
question: data.question,
|
|
...(Number.isFinite(pe) && pe > 0 ? { reward: { pointsEarned: pe } } : {}),
|
|
};
|
|
}
|
|
|
|
export type VisitorLeaderboardRow = {
|
|
rank: number;
|
|
participantId: string;
|
|
total: number;
|
|
displayName: string;
|
|
};
|
|
|
|
export type VisitorLeaderboardResponse = {
|
|
leaderboard: VisitorLeaderboardRow[];
|
|
your: { total: number; rank: number | null } | null;
|
|
};
|
|
|
|
/** Public; send attendee JWT optional to receive `your` rank and net total. */
|
|
export async function getVisitorEventLeaderboard(
|
|
eventId: string,
|
|
opts?: { limit?: number },
|
|
): Promise<VisitorLeaderboardResponse> {
|
|
const base = getBaseUrl();
|
|
const token = getAttendeeToken();
|
|
const sp = new URLSearchParams();
|
|
if (opts?.limit != null) sp.set("limit", String(opts.limit));
|
|
const q = sp.toString();
|
|
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/leaderboard${q ? `?${q}` : ""}`;
|
|
const headers: Record<string, string> = {};
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
const res = await fetch(url, { headers });
|
|
const parsed = await parseJson(res);
|
|
if (!res.ok) throw new Error(messageFromBody(parsed));
|
|
if (!parsed || typeof parsed !== "object" || !("data" in parsed)) throw new Error("Invalid response");
|
|
const data = (parsed as { data: VisitorLeaderboardResponse }).data;
|
|
return {
|
|
leaderboard: Array.isArray(data?.leaderboard) ? data.leaderboard : [],
|
|
your: data?.your && typeof data.your === "object" ? data.your : null,
|
|
};
|
|
}
|
|
|
|
export type VisitorAuthPayload = {
|
|
token: string;
|
|
participant: AttendeeParticipant;
|
|
needsProfile: boolean;
|
|
pendingApproval: boolean;
|
|
guestToken?: string;
|
|
/** Present when new registration loyalty points were awarded */
|
|
reward?: { pointsEarned: number };
|
|
};
|
|
|
|
function joinBody(
|
|
type: VisitorJoinType,
|
|
sessionId?: string,
|
|
trackId?: string,
|
|
inviteToken?: string,
|
|
) {
|
|
return {
|
|
joinType: type,
|
|
sessionId: type === "session" ? sessionId : undefined,
|
|
trackId: type === "track" ? trackId : undefined,
|
|
inviteToken: inviteToken || undefined,
|
|
};
|
|
}
|
|
|
|
export async function postVisitorStartOtp(
|
|
eventId: string,
|
|
email: string,
|
|
ctx: { type: VisitorJoinType; sessionId?: string; trackId?: string },
|
|
inviteToken?: string,
|
|
): Promise<void> {
|
|
const base = getBaseUrl();
|
|
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/auth/start-otp`;
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
email,
|
|
...joinBody(ctx.type, ctx.sessionId, ctx.trackId, inviteToken),
|
|
}),
|
|
});
|
|
const body = await parseJson(res);
|
|
if (!res.ok) throw new Error(messageFromBody(body));
|
|
}
|
|
|
|
export async function postVisitorVerifyOtp(
|
|
eventId: string,
|
|
payload: {
|
|
email: string;
|
|
otp: string;
|
|
inviteToken?: string;
|
|
firstName?: string;
|
|
lastName?: string;
|
|
phone?: string;
|
|
displayName?: string;
|
|
},
|
|
): Promise<VisitorAuthPayload> {
|
|
const base = getBaseUrl();
|
|
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/auth/verify-otp`;
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
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: VisitorAuthPayload }).data;
|
|
}
|
|
|
|
export async function postVisitorStartLink(
|
|
eventId: string,
|
|
email: string,
|
|
ctx: { type: VisitorJoinType; sessionId?: string; trackId?: string },
|
|
inviteToken?: string,
|
|
): Promise<void> {
|
|
const base = getBaseUrl();
|
|
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/auth/start-link`;
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
email,
|
|
...joinBody(ctx.type, ctx.sessionId, ctx.trackId, inviteToken),
|
|
}),
|
|
});
|
|
const body = await parseJson(res);
|
|
if (!res.ok) throw new Error(messageFromBody(body));
|
|
}
|
|
|
|
export async function postVisitorExchangeLink(token: string): Promise<VisitorAuthPayload> {
|
|
const base = getBaseUrl();
|
|
const url = `${base}/api/visitor/auth/exchange-link`;
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ 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: VisitorAuthPayload }).data;
|
|
}
|
|
|
|
export async function postVisitorGuest(
|
|
eventId: string,
|
|
ctx: { type: VisitorJoinType; sessionId?: string; trackId?: string },
|
|
opts: { inviteToken?: string; guestToken?: string; displayName?: string; email?: string },
|
|
): Promise<VisitorAuthPayload> {
|
|
const base = getBaseUrl();
|
|
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/auth/guest`;
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
...joinBody(ctx.type, ctx.sessionId, ctx.trackId, opts.inviteToken),
|
|
guestToken: opts.guestToken,
|
|
displayName: opts.displayName,
|
|
email: opts.email,
|
|
}),
|
|
});
|
|
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: VisitorAuthPayload }).data;
|
|
}
|
|
|
|
export async function patchVisitorProfile(
|
|
token: string,
|
|
payload: {
|
|
firstName?: string;
|
|
lastName?: string;
|
|
phone?: string;
|
|
displayName?: string;
|
|
customFields?: Record<string, string | number | boolean | string[]>;
|
|
},
|
|
): Promise<VisitorAuthPayload> {
|
|
const base = getBaseUrl();
|
|
const url = `${base}/api/visitor/me/profile`;
|
|
const res = await fetch(url, {
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
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: 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;
|
|
isLive?: boolean;
|
|
liveQuestionId?: string | null;
|
|
showCorrectAnswer?: boolean;
|
|
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 type PublicPresentationSessionActivity = {
|
|
_id: string;
|
|
title: string;
|
|
type: string;
|
|
presentationToken: string;
|
|
presentationUrl: string;
|
|
isLive: boolean;
|
|
displayMode?: string;
|
|
liveQuestionId?: string | null;
|
|
};
|
|
|
|
export type PublicPresentationSessionActivities = {
|
|
sessionId: string | null;
|
|
activities: PublicPresentationSessionActivity[];
|
|
};
|
|
|
|
export async function getPublicPresentationSessionActivities(
|
|
token: string,
|
|
): Promise<PublicPresentationSessionActivities> {
|
|
const base = getBaseUrl();
|
|
const url = `${base}/api/public/quiz-polls/present/${encodeURIComponent(token)}/session-activities`;
|
|
const res = await fetch(url);
|
|
const body = await parseJson(res);
|
|
if (!res.ok) throw new Error(messageFromBody(body));
|
|
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
|
|
return (body as { data: PublicPresentationSessionActivities }).data;
|
|
}
|
|
|
|
export async function getPublicQuizPollQuestionStats(
|
|
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;
|
|
}
|
|
|
|
async function postPublicPresentationControl(
|
|
token: string,
|
|
pathSuffix: string,
|
|
payload?: Record<string, unknown>,
|
|
): Promise<PublicQuizPollPresentation> {
|
|
const base = getBaseUrl();
|
|
const url = `${base}/api/public/quiz-polls/present/${encodeURIComponent(token)}${pathSuffix}`;
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: payload != null ? JSON.stringify(payload) : undefined,
|
|
});
|
|
const body = await parseJson(res);
|
|
if (!res.ok) throw new Error(messageFromBody(body));
|
|
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
|
|
return (body as { data: PublicQuizPollPresentation }).data;
|
|
}
|
|
|
|
export async function publishPublicPresentation(token: string): Promise<PublicQuizPollPresentation> {
|
|
return postPublicPresentationControl(token, "/publish");
|
|
}
|
|
|
|
export async function closePublicPresentation(token: string): Promise<PublicQuizPollPresentation> {
|
|
return postPublicPresentationControl(token, "/close");
|
|
}
|
|
|
|
export async function setPublicLiveQuestion(
|
|
token: string,
|
|
questionId: string | null,
|
|
): Promise<PublicQuizPollPresentation> {
|
|
return postPublicPresentationControl(token, "/live-question", { questionId });
|
|
}
|