1376 lines
47 KiB
TypeScript
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,
|
|
};
|
|
}
|