EventBuzz_Website/src/lib/api.ts

1376 lines
47 KiB
TypeScript

import type { AttendeeParticipant } from "./attendeeAuth";
import { getAttendeeToken } from "./attendeeAuth";
import type {
EventThemeConfig,
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;
}
/** Public visitor theme for an event — GET /api/public/visitor-theme/:eventId */
export type PublicVisitorThemeResponse = {
eventId: string;
theme: EventThemeConfig;
};
export async function getPublicVisitorTheme(eventId: string): Promise<PublicVisitorThemeResponse> {
const base = getBaseUrl();
const url = `${base}/api/public/visitor-theme/${encodeURIComponent(eventId)}`;
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: PublicVisitorThemeResponse }).data;
if (!data?.eventId || !data.theme) {
throw new Error("Invalid response");
}
return 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;
showLeaderboard?: 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;
}
export type PublicQuizPollLeaderboardEntry = {
rank: number;
participantId: string;
displayName: string;
correctCount: number;
totalQuestions: number;
timeTakenSec: number;
totalScore: number;
};
export type PublicQuizPollLeaderboard = {
totalQuestions: number;
entries: PublicQuizPollLeaderboardEntry[];
};
export async function getPublicQuizPollLeaderboard(token: string): Promise<PublicQuizPollLeaderboard> {
const base = getBaseUrl();
const url = `${base}/api/public/quiz-polls/present/${encodeURIComponent(token)}/leaderboard`;
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: PublicQuizPollLeaderboard }).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 });
}
export type PhotoWallPublicFormField = {
label?: string;
name?: string;
type?: string;
required?: boolean;
options?: string[];
placeholder?: string;
mapsTo?: string;
};
export type PhotoWallEventHeaderDisplay = {
showLogo?: boolean;
showBanner?: boolean;
showTitle?: boolean;
showDateRange?: boolean;
showEventType?: boolean;
showLocationType?: boolean;
showVenue?: boolean;
};
export type PhotoWallPublicEvent = {
_id?: string;
name: string;
subtitle?: string;
eventType?: string;
startDate?: string | null;
endDate?: string | null;
location?: string;
locationType?: string;
logoUrl?: string | null;
imageUrl?: string | null;
};
export type PhotoWallSharePlatforms = {
linkedin?: boolean;
facebook?: boolean;
instagram?: boolean;
snapchat?: boolean;
x?: boolean;
whatsapp?: boolean;
};
export type PhotoWallUploadMeta = {
eventId: string;
eventName: string;
event?: PhotoWallPublicEvent | null;
eventHeader?: PhotoWallEventHeaderDisplay | null;
accessMode: "open" | "login";
collectDetails: boolean;
createParticipant: boolean;
moderationMode: "auto" | "approval";
showUploaderNameOnWall?: boolean;
requireUploadCrop?: boolean;
askUploaderName?: boolean;
maxPhotoWidth?: number;
maxPhotoHeight?: number;
frameEnabled?: boolean;
allowFramedDownload?: boolean;
sharePlatforms?: PhotoWallSharePlatforms;
form: {
_id: string;
title?: string;
description?: string;
type?: string;
fields: PhotoWallPublicFormField[];
settings?: { thankYouMessage?: string; isActive?: boolean };
} | null;
};
export type PhotoWallPresentData = {
eventId: string;
eventName: string;
event?: PhotoWallPublicEvent | null;
eventHeader?: PhotoWallEventHeaderDisplay | null;
photosPerRow: number;
maxPhotoWidth: number;
maxPhotoHeight: number;
gapPx: number;
presentMarginTopPx?: number;
presentMarginLeftPx?: number;
presentMarginRightPx?: number;
showUploaderNameOnWall?: boolean;
frameEnabled?: boolean;
frameCanvasWidth?: number;
frameCanvasHeight?: number;
photos: Array<{ _id: string; url: string; createdAt?: string; displayName?: string | null }>;
};
export async function getPhotoWallUploadMeta(uploadToken: string): Promise<PhotoWallUploadMeta> {
const base = getBaseUrl();
const url = `${base}/api/public/photo-wall/upload/${encodeURIComponent(uploadToken)}`;
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: PhotoWallUploadMeta }).data;
}
export async function uploadPhotoWallSelfie(
uploadToken: string,
file: File,
formData?: Record<string, unknown>,
options?: { displayName?: string },
): Promise<{
awaitingApproval: boolean;
previewUrl: string | null;
/** API proxy URL — use for download/share fetch (CORS-safe). */
fileUrl: string | null;
framed: boolean;
photoId: string | null;
photoUrl: string | null;
framedUrl: string | null;
}> {
const base = getBaseUrl();
const url = `${base}/api/public/photo-wall/upload/${encodeURIComponent(uploadToken)}`;
const fd = new FormData();
fd.append("file", file, file.name);
if (formData && Object.keys(formData).length) {
fd.append("formData", JSON.stringify(formData));
}
if (options?.displayName?.trim()) {
fd.append("displayName", options.displayName.trim());
}
const headers: Record<string, string> = {};
const token = getAttendeeToken();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(url, { method: "POST", headers, body: fd });
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: {
awaitingApproval?: boolean;
previewUrl?: string;
fileUrl?: string;
framed?: boolean;
photo?: { _id?: string; url?: string; framedUrl?: string | null };
};
}
).data;
const photoUrl = data?.photo?.url || null;
const framedUrl = data?.photo?.framedUrl || null;
const photoId = data?.photo?._id ? String(data.photo._id) : null;
const fileUrlRelative = data?.fileUrl || null;
const fileUrl = fileUrlRelative
? fileUrlRelative.startsWith("http")
? fileUrlRelative
: `${base}${fileUrlRelative.startsWith("/") ? "" : "/"}${fileUrlRelative}`
: data?.previewUrl || framedUrl || photoUrl || null;
return {
awaitingApproval: Boolean(data?.awaitingApproval),
previewUrl: data?.previewUrl || framedUrl || photoUrl,
fileUrl,
framed: Boolean(data?.framed || framedUrl),
photoId,
photoUrl,
framedUrl,
};
}
export async function getPhotoWallPresent(presentToken: string): Promise<PhotoWallPresentData> {
const base = getBaseUrl();
const url = `${base}/api/public/photo-wall/present/${encodeURIComponent(presentToken)}`;
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: PhotoWallPresentData }).data;
}
export type PhotoSearchMeta = {
eventId: string;
eventName: string;
event?: PhotoWallPublicEvent | null;
eventHeader?: PhotoWallEventHeaderDisplay | null;
accessMode: "open" | "login";
allowDownload: boolean;
sharePlatforms?: PhotoWallSharePlatforms;
};
export type PhotoSearchMatch = {
_id: string;
event: string;
organization: string;
fileName?: string;
originalFileName?: string;
url: string;
fileUrl: string;
mimeType?: string;
size?: number;
uploadedAt?: string;
similarity?: number;
};
export async function getPhotoSearchMeta(searchToken: string): Promise<PhotoSearchMeta> {
const base = getBaseUrl();
const url = `${base}/api/public/photo-search/${encodeURIComponent(searchToken)}`;
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: PhotoSearchMeta }).data;
}
export type SocialSharePublicFrame = {
_id: string;
name: string;
frameImageUrl: string | null;
frameCanvasWidth?: number;
frameCanvasHeight?: number;
/** Photo slot size on the frame canvas — used for upload crop aspect. */
photoSlot?: { w: number; h: number } | null;
};
export type SocialShareMeta = {
eventId: string;
eventName: string;
event?: PhotoWallPublicEvent | null;
eventHeader?: PhotoWallEventHeaderDisplay | null;
accessMode: "open" | "login";
requireUploadCrop?: boolean;
maxPhotoWidth?: number;
maxPhotoHeight?: number;
allowDownload: boolean;
sharePlatforms?: PhotoWallSharePlatforms;
frames: SocialSharePublicFrame[];
};
export async function getSocialShareMeta(uploadToken: string): Promise<SocialShareMeta> {
const base = getBaseUrl();
const url = `${base}/api/public/social-share/${encodeURIComponent(uploadToken)}`;
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: SocialShareMeta }).data;
}
export async function uploadSocialSharePhoto(
uploadToken: string,
file: File,
frameId: string,
): Promise<{
previewUrl: string | null;
fileUrl: string | null;
framed: boolean;
outputId: string | null;
}> {
const base = getBaseUrl();
const url = `${base}/api/public/social-share/${encodeURIComponent(uploadToken)}`;
const fd = new FormData();
fd.append("file", file, file.name);
fd.append("frameId", frameId);
const headers: Record<string, string> = {};
const token = getAttendeeToken();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(url, { method: "POST", headers, body: fd });
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: {
previewUrl?: string;
fileUrl?: string;
framed?: boolean;
outputId?: string;
};
}
).data;
const fileUrlRelative = data?.fileUrl || null;
const fileUrl = fileUrlRelative
? fileUrlRelative.startsWith("http")
? fileUrlRelative
: `${base}${fileUrlRelative.startsWith("/") ? "" : "/"}${fileUrlRelative}`
: data?.previewUrl || null;
return {
previewUrl: data?.previewUrl || null,
fileUrl,
framed: Boolean(data?.framed),
outputId: data?.outputId ? String(data.outputId) : null,
};
}
export type AiAvatarPublicTemplate = {
_id: string;
name: string;
imageUrl: string | null;
/** Crop aspect for selfie (default square). */
photoSlot?: { w: number; h: number } | null;
};
export type AiAvatarMeta = {
eventId: string;
eventName: string;
event?: PhotoWallPublicEvent | null;
eventHeader?: PhotoWallEventHeaderDisplay | null;
accessMode: "open" | "login";
allowDownload: boolean;
sharePlatforms?: PhotoWallSharePlatforms;
avatars: AiAvatarPublicTemplate[];
};
export async function getAiAvatarMeta(uploadToken: string): Promise<AiAvatarMeta> {
const base = getBaseUrl();
const url = `${base}/api/public/ai-avatar/${encodeURIComponent(uploadToken)}`;
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: AiAvatarMeta }).data;
}
export async function uploadAiAvatarPhoto(
uploadToken: string,
file: File,
avatarId: string,
): Promise<{
previewUrl: string | null;
fileUrl: string | null;
outputId: string | null;
}> {
const base = getBaseUrl();
const url = `${base}/api/public/ai-avatar/${encodeURIComponent(uploadToken)}`;
const fd = new FormData();
fd.append("file", file, file.name);
fd.append("avatarId", avatarId);
const headers: Record<string, string> = {};
const token = getAttendeeToken();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(url, { method: "POST", headers, body: fd });
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: {
previewUrl?: string;
fileUrl?: string;
outputId?: string;
};
}
).data;
const fileUrlRelative = data?.fileUrl || null;
const fileUrl = fileUrlRelative
? fileUrlRelative.startsWith("http")
? fileUrlRelative
: `${base}${fileUrlRelative.startsWith("/") ? "" : "/"}${fileUrlRelative}`
: data?.previewUrl || null;
return {
previewUrl: data?.previewUrl || null,
fileUrl,
outputId: data?.outputId ? String(data.outputId) : null,
};
}
export async function searchPhotoSearchMatches(
searchToken: string,
file: File,
): Promise<{ collectionExists: boolean; matchCount: number; matches: PhotoSearchMatch[] }> {
const base = getBaseUrl();
const url = `${base}/api/public/photo-search/${encodeURIComponent(searchToken)}`;
const fd = new FormData();
fd.append("files", file, file.name);
const headers: Record<string, string> = {};
const token = getAttendeeToken();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(url, { method: "POST", headers, body: fd });
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: {
collectionExists?: boolean;
matchCount?: number;
matches?: Array<Partial<PhotoSearchMatch> & { _id?: string; fileUrl?: string; url?: string }>;
};
}
).data;
const matches = (data?.matches || []).map((m) => {
const id = m._id ? String(m._id) : "";
const fileUrlRelative = m.fileUrl || null;
const fileUrl = fileUrlRelative
? fileUrlRelative.startsWith("http")
? fileUrlRelative
: `${base}${fileUrlRelative.startsWith("/") ? "" : "/"}${fileUrlRelative}`
: typeof m.url === "string" && m.url.trim()
? m.url.trim()
: "";
return {
_id: id,
event: String(m.event || ""),
organization: String(m.organization || ""),
fileName: m.fileName,
originalFileName: m.originalFileName,
// CDN thumb for display — do not fall back to API fileUrl
url: typeof m.url === "string" && m.url.trim() ? m.url.trim() : "",
fileUrl,
mimeType: m.mimeType,
size: m.size,
uploadedAt: m.uploadedAt,
similarity: typeof m.similarity === "number" ? m.similarity : undefined,
} satisfies PhotoSearchMatch;
});
return {
collectionExists: Boolean(data?.collectionExists),
matchCount: typeof data?.matchCount === "number" ? data.matchCount : matches.length,
matches,
};
}