first commit

This commit is contained in:
Ravi Gulhane 2026-06-02 11:04:03 +05:30
commit f283127dce
75 changed files with 13373 additions and 0 deletions

18
index.html Normal file
View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>EventBuzz — Visitor</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3641
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "eventbuzz-visitorweb",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"canvas-confetti": "^1.9.4",
"firebase": "^11.0.2",
"lucide-react": "0.487.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.1.12",
"@types/canvas-confetti": "^1.9.0",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "4.7.0",
"tailwindcss": "4.1.12",
"typescript": "5.6.3",
"vite": "6.3.5"
}
}

52
src/App.tsx Normal file
View File

@ -0,0 +1,52 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { Questions } from "./pages/Questions";
import { EventLandingPage } from "./pages/EventLandingPage";
import { EventTrackPage } from "./pages/EventTrackPage";
import { EventTrackV2Page } from "./pages/EventTrackV2Page";
import { FeedbackPage } from "./pages/FeedbackPage";
import { LeaderboardPage } from "./pages/LeaderboardPage";
import { RatingsPage } from "./pages/RatingsPage";
import { Home } from "./pages/Home";
import { AuthCallbackPage } from "./pages/AuthCallbackPage";
import { CompleteProfilePage } from "./pages/CompleteProfilePage";
import { LoginPage } from "./pages/LoginPage";
import { PendingApprovalPage } from "./pages/PendingApprovalPage";
import { RegisterOtpPage } from "./pages/RegisterOtpPage";
import { RegisterPage } from "./pages/RegisterPage";
import { RewardsPage } from "./pages/RewardsPage";
import { SingleEventPage } from "./pages/SingleEventPage";
import { QuizPollTakePage } from "./pages/QuizPollTakePage";
import { CodeEntryPage } from "./pages/CodeEntryPage";
import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage";
import { TermsOfServicePage } from "./pages/TermsOfServicePage";
export default function App() {
return (
<div className="flex min-h-dvh flex-1 flex-col">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/code/:code" element={<CodeEntryPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/auth/callback" element={<AuthCallbackPage />} />
<Route path="/complete-profile" element={<CompleteProfilePage />} />
<Route path="/pending-approval" element={<PendingApprovalPage />} />
<Route path="/event" element={<EventLandingPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/register/otp" element={<RegisterOtpPage />} />
<Route path="/track" element={<EventTrackPage />} />
<Route path="/track-v2" element={<EventTrackV2Page />} />
<Route path="/session" element={<SingleEventPage />} />
<Route path="/quiz-poll" element={<QuizPollTakePage />} />
<Route path="/questions" element={<Questions />} />
<Route path="/ask" element={<Navigate to="/questions" replace />} />
<Route path="/feedback" element={<FeedbackPage />} />
<Route path="/ratings" element={<RatingsPage />} />
<Route path="/rewards" element={<RewardsPage />} />
<Route path="/leaderboard" element={<LeaderboardPage />} />
<Route path="/privacy-policy" element={<PrivacyPolicyPage />} />
<Route path="/terms-of-service" element={<TermsOfServicePage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
);
}

50
src/RootErrorBoundary.tsx Normal file
View File

@ -0,0 +1,50 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
type Props = { children: ReactNode };
type State = { error: Error | null };
export class RootErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("[visitorweb]", error, info.componentStack);
}
render() {
if (this.state.error) {
return (
<div
style={{
fontFamily: "system-ui, sans-serif",
padding: 24,
maxWidth: 480,
margin: "40px auto",
color: "#24282f",
}}
>
<h1 style={{ fontSize: 18, marginBottom: 12 }}>Something went wrong</h1>
<pre
style={{
fontSize: 13,
overflow: "auto",
background: "#f2f2f7",
padding: 12,
borderRadius: 8,
}}
>
{this.state.error.message}
</pre>
<p style={{ fontSize: 14, marginTop: 16, color: "#555" }}>
Open the browser console (DevTools) for the full stack trace.
</p>
</div>
);
}
return this.props.children;
}
}

BIN
src/assets/about-banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 951 KiB

BIN
src/assets/logo-white.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
src/assets/main_banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@ -0,0 +1,83 @@
import { Bookmark, User } from "lucide-react";
export type SpeakerLine = { name: string; role: "Speaker" | "Moderator" };
export type AgendaRowData = {
time: string;
duration: string;
trackTitle: string;
sessionType: string;
description: string;
speakers: SpeakerLine[];
};
function SpeakerLineView({ name, role }: SpeakerLine) {
const pill =
role === "Speaker" ? "bg-[#f0fbd7]" : "bg-[#fbefd7]";
return (
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
<div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-[#ededed]">
<User className="size-3 text-[#24282f]/70" strokeWidth={2} />
</div>
<span className="text-xs font-medium tracking-wide text-[#24282f]">{name}</span>
</div>
<span
className={`rounded px-2 py-0.5 text-[10px] font-normal tracking-wide text-[#24282f] ${pill}`}
>
{role}
</span>
</div>
);
}
type AgendaRowProps = {
row: AgendaRowData;
};
export function AgendaRow({ row }: AgendaRowProps) {
return (
<div className="relative flex gap-2 pb-3">
<div className="relative z-[1] flex shrink-0 flex-col items-center">
<div className="flex size-10 items-center justify-center rounded-full bg-[#c2deff]">
<User className="size-4 text-[#24282f]/80" strokeWidth={2} />
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-1 text-[10px] tracking-wide text-[#24282f]">
<span>{row.time}</span>
<span className="h-3 w-px bg-[rgba(36,40,47,0.5)]" />
<span>{row.duration}</span>
</div>
<h3 className="text-sm font-semibold leading-[22px] tracking-wide text-[#24282f]">
{row.trackTitle}
</h3>
<div
className="inline-flex w-fit items-center rounded px-2 py-0.5 text-[10px] tracking-wide text-[#24282f]"
style={{ backgroundColor: "rgba(94, 147, 239, 0.14)" }}
>
{row.sessionType}
</div>
</div>
<button
type="button"
className="shrink-0 text-[#1d66ef]"
aria-label="Bookmark session"
>
<Bookmark className="size-5" strokeWidth={1.75} />
</button>
</div>
<p className="text-xs leading-normal tracking-wide text-[rgba(36,40,47,0.87)]">
{row.description}
</p>
<div className="flex flex-col gap-2">
{row.speakers.map((s) => (
<SpeakerLineView key={`${s.name}-${s.role}-${s.name}`} {...s} />
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,156 @@
import { useState } from "react";
import { Bookmark, ChevronDown, ChevronRight, MapPin } from "lucide-react";
import { Link } from "react-router-dom";
export type TrackV2SessionStatus =
| { kind: "live" }
| { kind: "starts"; label: string };
export type AgendaRowTrackV2Bookmark = {
isBookmarked: boolean;
onToggle: () => void;
};
export type AgendaRowTrackV2Data = {
time: string;
duration: string;
trackTitle: string;
sessionType: string;
description: string;
speakerNames: string[];
status: TrackV2SessionStatus | null;
showJoin?: boolean;
/** When set with `showJoin`, Join navigates here instead of a no-op button. */
joinHref?: string;
/** When set, bookmark control is shown and toggles this session on the device. */
bookmark?: AgendaRowTrackV2Bookmark;
/** Shown before clock time (e.g. full date on “My Sessions”). */
sessionDateLabel?: string;
/** Track hall / room below the session title. */
trackLocation?: string;
};
export function AgendaRowTrackV2({ row }: { row: AgendaRowTrackV2Data }) {
const [detailsOpen, setDetailsOpen] = useState(false);
return (
<div className="relative w-full pb-4">
<div className="flex min-w-0 w-full flex-col gap-2">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span
className="inline-flex shrink-0 rounded px-2 py-0.5 text-[10px] text-[#24282f]"
style={{ backgroundColor: "rgba(94, 147, 239, 0.14)" }}
>
{row.sessionType}
</span>
<div className="flex min-w-0 flex-wrap items-center gap-1 text-[10px] tracking-wide text-[#24282f]">
{row.sessionDateLabel ? (
<>
<span className="shrink-0">{row.sessionDateLabel}</span>
<span className="h-3 w-px shrink-0 bg-[rgba(36,40,47,0.5)]" />
</>
) : null}
<span className="shrink-0">{row.time}</span>
<span className="h-3 w-px shrink-0 bg-[rgba(36,40,47,0.5)]" />
<span className="shrink-0">{row.duration}</span>
</div>
</div>
<h3 className="text-sm font-semibold leading-[22px] text-[#24282f]">
{row.trackTitle}
</h3>
{row.trackLocation?.trim() ? (
<div className="flex items-start gap-1.5 text-xs text-[rgba(36,40,47,0.75)]">
<MapPin className="mt-0.5 size-3.5 shrink-0 text-[#1e72ef]" strokeWidth={1.75} />
<span className="leading-snug">{row.trackLocation.trim()}</span>
</div>
) : null}
</div>
<div className="flex shrink-0 flex-col items-center gap-0">
<div className="flex items-center gap-2">
{row.status?.kind === "live" && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-[#14ae5c] px-2 py-0.5 text-[10px] font-semibold text-white">
<span className="size-1 rounded-full bg-white" />
LIVE
</span>
)}
{row.status?.kind === "starts" && (
<span className="rounded-full bg-[#ef8d1d]/15 px-2 py-0.5 text-[10px] font-medium text-[#c45a0a]">
{row.status.label}
</span>
)}
{row.bookmark ? (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
row.bookmark!.onToggle();
}}
className="rounded-md p-0.5 text-[#1d66ef] hover:bg-[#1d66ef]/10"
aria-label={row.bookmark.isBookmarked ? "Remove bookmark" : "Bookmark session"}
aria-pressed={row.bookmark.isBookmarked}
>
<Bookmark
className={`size-5 ${row.bookmark.isBookmarked ? "fill-current" : ""}`}
strokeWidth={1.75}
/>
</button>
) : null}
</div>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDetailsOpen((o) => !o);
}}
className="mt-[18px] rounded px-1 py-1 text-[#8e8e93] hover:bg-black/5 hover:text-[#24282f]"
aria-expanded={detailsOpen}
aria-label={detailsOpen ? "Hide session details" : "Show session details"}
>
<ChevronDown
className={`size-4 transition-transform ${detailsOpen ? "rotate-180" : ""}`}
strokeWidth={2}
/>
</button>
</div>
</div>
{detailsOpen ? (
<div className="flex min-w-0 flex-col gap-2">
{row.description?.trim() ? (
<p className="text-xs leading-normal text-[rgba(36,40,47,0.87)]">{row.description}</p>
) : null}
<div className="flex min-w-0 items-center gap-1.5">
<span className="shrink-0 rounded bg-[#f0fbd7] px-2 py-0.5 text-[10px] text-[#24282f]">
Speaker
</span>
<p className="min-w-0 flex-1 text-xs font-medium leading-snug text-[#24282f] line-clamp-1">
{row.speakerNames.join(", ")}
</p>
</div>
</div>
) : null}
{row.showJoin &&
(row.joinHref ? (
<Link
to={row.joinHref}
className="mt-1 inline-flex h-8 w-[84px] items-center justify-between rounded-full bg-[#ef8d1d] pl-[18px] pr-2 text-xs font-semibold uppercase text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)]"
>
Join
<ChevronRight className="size-3" strokeWidth={2.5} />
</Link>
) : (
<button
type="button"
className="mt-1 inline-flex h-8 w-[84px] items-center justify-between rounded-full bg-[#ef8d1d] pl-[18px] pr-2 text-xs font-semibold uppercase text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)]"
>
Join
<ChevronRight className="size-3" strokeWidth={2.5} />
</button>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,128 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { postVisitorSessionQuestion } from "../lib/api";
import { getAttendeeToken } from "../lib/attendeeAuth";
import { notifyRewardFromApiData } from "../lib/rewardCelebration";
type AskQuestionModalProps = {
open: boolean;
onClose: () => void;
eventId: string;
sessionId: string;
idSuffix?: string;
};
export function AskQuestionModal({
open,
onClose,
eventId,
sessionId,
idSuffix = "",
}: AskQuestionModalProps) {
const [draft, setDraft] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const inputId = `session-question-input${idSuffix}`;
const close = useCallback(() => {
onClose();
setDraft("");
setSubmitError(null);
}, [onClose]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, close]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const text = draft.trim();
if (!text) return;
const eid = eventId.trim();
const sid = sessionId.trim();
if (!eid || !sid) return;
setSubmitting(true);
setSubmitError(null);
try {
const token = getAttendeeToken();
const out = await postVisitorSessionQuestion(sid, eid, {
question: text,
isAnonymous: !token,
});
notifyRewardFromApiData(out);
close();
} catch (err) {
setSubmitError(err instanceof Error ? err.message : "Could not submit question.");
} finally {
setSubmitting(false);
}
};
if (!open) return null;
return createPortal(
<div className="fixed inset-0 z-[200] flex items-end justify-center sm:items-center" role="presentation">
<button
type="button"
className="absolute inset-0 bg-black/45"
aria-label="Close dialog"
onClick={close}
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby={`ask-question-title${idSuffix}`}
className="relative z-10 w-full max-w-[min(100%,402px)] rounded-t-2xl bg-white px-5 pb-8 pt-5 shadow-[0_-8px_32px_rgba(0,0,0,0.12)] sm:mx-4 sm:rounded-2xl sm:shadow-xl"
>
<div className="mb-4 flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 id={`ask-question-title${idSuffix}`} className="text-lg font-semibold text-[#24282f]">
Ask a Question
</h3>
<p className="mt-1 text-sm leading-snug text-[rgba(36,40,47,0.65)]">
Submit your question to the session moderator.
</p>
</div>
<button
type="button"
className="shrink-0 rounded-full p-1.5 text-[#24282f] hover:bg-[#f2f2f7]"
aria-label="Close"
onClick={close}
>
<X className="size-5" strokeWidth={2} />
</button>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<label className="sr-only" htmlFor={inputId}>
Your question
</label>
<textarea
id={inputId}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Type your question here..."
rows={5}
disabled={submitting}
className="min-h-[120px] w-full resize-y rounded-xl border border-[#c7c7cc] bg-[#f2f2f7]/40 px-3 py-3 text-sm text-[#24282f] placeholder:text-[rgba(36,40,47,0.45)] focus:border-[#ef8d1d] focus:outline-none focus:ring-2 focus:ring-[#ef8d1d]/30 disabled:opacity-60"
/>
{submitError ? <p className="text-sm text-red-600">{submitError}</p> : null}
<button
type="submit"
disabled={submitting}
className="w-full rounded-xl bg-[#ef8d1d] py-3 text-sm font-semibold text-white shadow-[0_4px_4px_rgba(0,0,0,0.12)] transition hover:bg-[#e0800c] active:scale-[0.99] disabled:opacity-60"
>
{submitting ? "Submitting…" : "Submit Question"}
</button>
</form>
</div>
</div>,
document.body,
);
}

View File

@ -0,0 +1,150 @@
import { useCallback, useEffect, useState } from "react";
import { ThumbsDown, ThumbsUp, User } from "lucide-react";
import { postVisitorSessionQuestionVote } from "../lib/api";
import { notifyRewardFromApiData } from "../lib/rewardCelebration";
import {
getOrCreateAudienceVoteKey,
readStoredQuestionVote,
writeStoredQuestionVote,
} from "../lib/audienceVoteKey";
export type AudienceQuestionItem = {
_id?: string;
author: string;
text: string;
up: number;
down: number;
};
export type AudienceQuestionVoteContext = {
eventId: string;
sessionId: string;
};
export function AudienceQuestionCard({
item,
variant = "default",
voteContext,
}: {
item: AudienceQuestionItem;
variant?: "default" | "embedded";
/** When set and `item._id` is present, up/down votes call the visitor API and sync to RTDB */
voteContext?: AudienceQuestionVoteContext;
}) {
const rawId = item._id;
const qid =
rawId == null || rawId === ""
? ""
: typeof rawId === "string"
? rawId.trim()
: String(rawId).trim();
const canVote = Boolean(voteContext && qid);
const [userVote, setUserVote] = useState<"up" | "down" | null>(() =>
qid ? readStoredQuestionVote(qid) : null,
);
const [voting, setVoting] = useState(false);
const [voteError, setVoteError] = useState<string | null>(null);
useEffect(() => {
setUserVote(qid ? readStoredQuestionVote(qid) : null);
}, [qid]);
const shell =
variant === "embedded"
? "flex flex-col gap-4 p-4"
: "flex flex-col gap-4 rounded-md border border-[#f2f2f7] bg-white p-4 shadow-[0_1px_4px_rgba(12,12,13,0.05)]";
const doVote = useCallback(
async (direction: "up" | "down") => {
if (!canVote || !voteContext || voting) return;
if (userVote === direction) return;
setVoting(true);
setVoteError(null);
try {
const voterKey = getOrCreateAudienceVoteKey();
const res = await postVisitorSessionQuestionVote(
voteContext.sessionId,
voteContext.eventId,
qid,
{ voterKey, direction },
);
setUserVote(res.yourVote);
writeStoredQuestionVote(qid, res.yourVote);
notifyRewardFromApiData(res);
} catch (e) {
setVoteError(e instanceof Error ? e.message : "Vote failed");
} finally {
setVoting(false);
}
},
[canVote, voteContext, qid, userVote, voting],
);
const upBtn = (
<button
type="button"
disabled={!canVote || voting}
onClick={() => void doVote("up")}
className={`flex items-center gap-2 rounded-full border-2 px-2 py-2 transition ${
userVote === "up"
? "border-[#34c759] bg-[#34c759]/10 text-[#34c759]"
: "border-[#34c759] bg-white text-[#34c759] hover:bg-[#34c759]/5"
} disabled:cursor-not-allowed disabled:opacity-45`}
aria-label={canVote ? "Upvote" : undefined}
>
<ThumbsUp className="size-4 shrink-0" strokeWidth={2} />
<span className="text-[10px] font-medium tracking-wide">{item.up}</span>
</button>
);
const downBtn = (
<button
type="button"
disabled={!canVote || voting}
onClick={() => void doVote("down")}
className={`flex items-center gap-2 rounded-full border-2 px-2 py-2 transition ${
userVote === "down"
? "border-[#ff3b30] bg-[#ff3b30]/10 text-[#ff3b30]"
: "border-[#ff3b30] bg-white text-[#ff3b30] hover:bg-[#ff3b30]/5"
} disabled:cursor-not-allowed disabled:opacity-45`}
aria-label={canVote ? "Downvote" : undefined}
>
<span className="text-[10px] font-medium tracking-wide">{item.down}</span>
<ThumbsDown className="size-4 shrink-0" strokeWidth={2} />
</button>
);
return (
<div className={shell}>
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-full bg-[#f2f2f7]">
<User className="size-4 text-[#24282f]/70" strokeWidth={2} />
</div>
<span className="text-base font-semibold tracking-wide text-[#24282f]">{item.author}</span>
</div>
<p className="text-base leading-[22px] tracking-wide text-[#24282f]">{item.text}</p>
<div className="flex flex-col items-end gap-1">
<div className="flex justify-end gap-2">
{canVote ? (
upBtn
) : (
<div className="flex items-center gap-2 rounded-full border-2 border-[#34c759] bg-white px-2 py-2 text-[#34c759]">
<ThumbsUp className="size-4" strokeWidth={2} />
<span className="text-[10px] font-medium tracking-wide">{item.up}</span>
</div>
)}
{canVote ? (
downBtn
) : (
<div className="flex items-center gap-2 rounded-full border-2 border-[#ff3b30] bg-white px-2 py-2 text-[#ff3b30]">
<span className="text-[10px] font-medium tracking-wide">{item.down}</span>
<ThumbsDown className="size-4" strokeWidth={2} />
</div>
)}
</div>
{voteError ? <p className="max-w-full text-right text-xs text-red-600">{voteError}</p> : null}
</div>
</div>
);
}

View File

@ -0,0 +1,280 @@
import { useMemo, useState, type FormEvent } from "react";
import type { VisitorPublicFeedbackForm, VisitorPublicFeedbackFormField } from "../lib/api";
import { postPublicFormSubmit } from "../lib/api";
import { getAttendeeParticipant } from "../lib/attendeeAuth";
export function DynamicFeedbackForm({
eventId,
schema,
variant = "light",
}: {
eventId: string;
schema: VisitorPublicFeedbackForm;
variant?: "light" | "gradient";
}) {
const [values, setValues] = useState<Record<string, unknown>>({});
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [done, setDone] = useState(false);
const fields = useMemo(() => (schema.fields || []).filter((f) => f.label?.trim()), [schema.fields]);
const setVal = (label: string, v: unknown) => {
setValues((prev) => ({ ...prev, [label]: v }));
};
const validate = (): boolean => {
for (const f of fields) {
const key = String(f.label).trim();
if (!f.required) continue;
const raw = values[key];
if ((f.type || "").toLowerCase() === "checkboxes") {
if (!Array.isArray(raw) || raw.length === 0) return false;
} else if (raw === undefined || raw === null || String(raw).trim() === "") {
return false;
}
}
return true;
};
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setErr(null);
if (!validate()) {
setErr("Please fill all required fields.");
return;
}
const data: Record<string, unknown> = {};
for (const f of fields) {
const key = String(f.label).trim();
data[key] = values[key] ?? "";
}
setSubmitting(true);
try {
const participant = getAttendeeParticipant();
const pid =
participant && String(participant.event) === String(eventId) ? participant._id : undefined;
await postPublicFormSubmit(schema._id, data, pid);
setDone(true);
} catch (er) {
setErr(er instanceof Error ? er.message : "Could not submit.");
} finally {
setSubmitting(false);
}
};
if (done && schema.showThankYou !== false) {
return (
<div
className={
variant === "gradient"
? "rounded-xl bg-white p-6 text-center shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]"
: "rounded-md bg-white p-4 text-center shadow-[0_1px_4px_rgba(12,12,13,0.05)]"
}
>
<p className="font-inter text-sm font-semibold text-[#24282f]">Thank you</p>
<p className="mt-2 text-sm text-[rgba(36,40,47,0.67)]">
{schema.thankYouMessage?.trim() || "Your response has been recorded."}
</p>
</div>
);
}
const fieldShell =
variant === "gradient"
? "rounded-xl bg-white p-6 shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]"
: "rounded-md bg-white p-4 shadow-[0_1px_4px_rgba(12,12,13,0.05)]";
return (
<form onSubmit={onSubmit} className={fieldShell}>
{schema.description?.trim() ? (
<p className="mb-4 text-sm leading-snug text-[rgba(36,40,47,0.67)]">{stripHtml(schema.description.trim())}</p>
) : null}
<div className="flex flex-col gap-4">
{fields.map((f) => (
<FieldInput key={String(f.label)} f={f} value={values[String(f.label).trim()]} onChange={setVal} />
))}
</div>
{err ? <p className="mt-3 text-sm text-red-600">{err}</p> : null}
<button
type="submit"
disabled={submitting}
className="mt-4 w-full rounded-full bg-[#ef8d1d] py-3 text-xs font-semibold uppercase tracking-wide text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)] disabled:opacity-60"
>
{submitting ? "Submitting…" : "Submit"}
</button>
</form>
);
}
/** Minimal strip — form descriptions are plain text from dashboard */
function stripHtml(s: string): string {
return s.replace(/<[^>]*>/g, "").trim() || s;
}
function FieldInput({
f,
value,
onChange,
}: {
f: VisitorPublicFeedbackFormField;
value: unknown;
onChange: (label: string, v: unknown) => void;
}) {
const label = String(f.label || "").trim();
const ph = f.placeholder?.trim() || "";
const opts = (f.options || []).map((o) => String(o).trim()).filter(Boolean);
const t = (f.type || "text").toLowerCase();
const baseInput =
"w-full rounded-lg border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] px-3 py-2 font-inter text-sm text-[#24282f] placeholder:text-[rgba(36,40,47,0.45)]";
if (t === "file") {
return (
<p className="text-sm text-[rgba(36,40,47,0.67)]">
<span className="font-semibold text-[#24282f]">{label}</span>
<span className="mt-1 block">File upload isn&apos;t supported in the mobile app yet.</span>
</p>
);
}
if (t === "select") {
return (
<label className="flex flex-col gap-2">
<span className="text-sm font-semibold text-[#24282f]">
{label}
{f.required ? <span className="text-red-600"> *</span> : null}
</span>
<select
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(label, e.target.value)}
className={baseInput}
>
<option value="">Select…</option>
{opts.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</label>
);
}
if (t === "radio") {
return (
<fieldset className="flex flex-col gap-2">
<legend className="text-sm font-semibold text-[#24282f]">
{label}
{f.required ? <span className="text-red-600"> *</span> : null}
</legend>
<div className="flex flex-col gap-2">
{(opts.length ? opts : ["Yes", "No"]).map((o) => (
<label key={o} className="flex items-center gap-2 text-sm text-[#24282f]">
<input type="radio" name={label} checked={value === o} onChange={() => onChange(label, o)} />
{o}
</label>
))}
</div>
</fieldset>
);
}
if (t === "checkboxes") {
const arr = Array.isArray(value) ? (value as string[]) : [];
const toggle = (o: string) => {
if (arr.includes(o)) onChange(label, arr.filter((x) => x !== o));
else onChange(label, [...arr, o]);
};
return (
<fieldset className="flex flex-col gap-2">
<legend className="text-sm font-semibold text-[#24282f]">
{label}
{f.required ? <span className="text-red-600"> *</span> : null}
</legend>
<div className="flex flex-col gap-2">
{opts.map((o) => (
<label key={o} className="flex items-center gap-2 text-sm text-[#24282f]">
<input type="checkbox" checked={arr.includes(o)} onChange={() => toggle(o)} />
{o}
</label>
))}
</div>
</fieldset>
);
}
if (t === "checkbox") {
return (
<label className="flex items-center gap-2 text-sm text-[#24282f]">
<input
type="checkbox"
checked={value === true || value === "yes" || value === "Yes"}
onChange={(e) => onChange(label, e.target.checked ? "Yes" : "No")}
/>
<span>
{label}
{f.required ? <span className="text-red-600"> *</span> : null}
</span>
</label>
);
}
if (t === "email" || t === "phone") {
return (
<label className="flex flex-col gap-2">
<span className="text-sm font-semibold text-[#24282f]">
{label}
{f.required ? <span className="text-red-600"> *</span> : null}
</span>
<input
type={t === "email" ? "email" : "tel"}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(label, e.target.value)}
placeholder={ph}
className={baseInput}
/>
</label>
);
}
const longText =
label.toLowerCase().includes("comment") ||
label.toLowerCase().includes("describe") ||
label.toLowerCase().includes("feedback") ||
ph.length > 50;
if (t === "text" && longText) {
return (
<label className="flex flex-col gap-2">
<span className="text-sm font-semibold text-[#24282f]">
{label}
{f.required ? <span className="text-red-600"> *</span> : null}
</span>
<textarea
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(label, e.target.value)}
rows={4}
placeholder={ph}
className={`${baseInput} resize-none`}
/>
</label>
);
}
return (
<label className="flex flex-col gap-2">
<span className="text-sm font-semibold text-[#24282f]">
{label}
{f.required ? <span className="text-red-600"> *</span> : null}
</span>
<input
type="text"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(label, e.target.value)}
placeholder={ph}
className={baseInput}
/>
</label>
);
}

View File

@ -0,0 +1,34 @@
import { EventBuzzLogo } from "./EventBuzzLogo";
export type EventBannerProps = {
/** Event banner URL from login-preview (`bannerImage` or `image`) */
imageUrl?: string | null;
alt?: string;
};
export function EventBanner({ imageUrl, alt = "" }: EventBannerProps) {
const src = imageUrl?.trim();
if (src) {
return (
<div className="w-full shrink-0 overflow-hidden bg-[#e5e5ea]">
<img
src={src}
alt={alt}
className="max-h-[200px] w-full object-cover object-center"
/>
</div>
);
}
return (
<div
className="eb-gradient-banner flex h-[130px] shrink-0 items-center justify-center border-y border-[#f2f2f7] px-8 py-10 sm:px-16"
data-name="Event Logo"
>
<EventBuzzLogo
variant="white"
className="h-10 w-auto max-w-[min(280px,85vw)] object-center sm:h-12"
/>
</div>
);
}

View File

@ -0,0 +1,28 @@
import logoColor from "../assets/logo.png";
import logoWhite from "../assets/logo-white.png";
type EventBuzzLogoProps = {
variant: "color" | "white";
className?: string;
alt?: string;
};
/** Brand marks from `src/assets` — `white` on dark/gradient, `color` on light backgrounds */
export function EventBuzzLogo({
variant,
className = "",
alt = "EventBuzz",
}: EventBuzzLogoProps) {
const src = variant === "white" ? logoWhite : logoColor;
return (
<img
src={src}
alt={alt}
width={256}
height={48}
className={["block h-auto min-h-[36px] w-auto max-w-full shrink-0 object-contain", className]
.filter(Boolean)
.join(" ")}
/>
);
}

View File

@ -0,0 +1,17 @@
import { EventBuzzLogo } from "./EventBuzzLogo";
type EventBuzzMarkProps = {
className?: string;
};
/** White logo for blue/dark headers, auth shell, marketing footer */
export function EventBuzzMark({ className = "" }: EventBuzzMarkProps) {
return (
<EventBuzzLogo
variant="white"
className={["h-10 w-auto min-w-[140px] max-w-[220px] object-contain sm:h-11", className]
.filter(Boolean)
.join(" ")}
/>
);
}

View File

@ -0,0 +1,266 @@
import type { Dispatch, SetStateAction } from "react";
import { Calendar, ChevronDown, MapPin } from "lucide-react";
import type { VisitorSessionPreview } from "../lib/api";
import {
formatDateLong,
formatDurationMinutes,
formatTimeShort,
sessionTypeDisplay,
} from "../lib/sessionDisplay";
import type { AgendaRowTrackV2Bookmark, AgendaRowTrackV2Data } from "./AgendaRowTrackV2";
import { AgendaRowTrackV2 } from "./AgendaRowTrackV2";
export function landingSpeakerName(s: { firstName?: string; lastName?: string }): string {
return [s.firstName, s.lastName].filter(Boolean).join(" ").trim() || "Speaker";
}
export type LandingAgendaDay = {
dateKey: string;
dateLabel: string;
trackGroups: Array<{
trackId: string;
trackName: string;
trackHall?: string;
sessions: VisitorSessionPreview[];
}>;
};
export function groupSessionsIntoAgendaDays(sessions: VisitorSessionPreview[]): LandingAgendaDay[] {
if (!sessions.length) return [];
const localDateKey = (iso: string) => {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
};
const dayLabel = (iso: string) => {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
};
const byDay = new Map<string, VisitorSessionPreview[]>();
for (const s of sessions) {
const key = localDateKey(s.startTime);
if (!key) continue;
const arr = byDay.get(key) ?? [];
arr.push(s);
byDay.set(key, arr);
}
const keys = [...byDay.keys()].sort();
return keys.map((dateKey) => {
const daySessions = byDay.get(dateKey)!;
const labelIso = daySessions[0]?.startTime ?? "";
const byTrack = new Map<
string,
{
trackId: string;
trackName: string;
trackHall?: string;
sessions: VisitorSessionPreview[];
}
>();
for (const s of daySessions) {
const tid = s.track?._id ?? "__none__";
const tname = s.track?.name?.trim() || "General";
let g = byTrack.get(tid);
if (!g) {
g = {
trackId: tid,
trackName: tname,
trackHall: s.track?.hall?.trim() || undefined,
sessions: [],
};
byTrack.set(tid, g);
} else {
if (!g.trackHall && s.track?.hall?.trim()) g.trackHall = s.track.hall.trim();
}
g.sessions.push(s);
}
const trackGroups = [...byTrack.values()].sort((a, b) => {
const ta = new Date(a.sessions[0].startTime).getTime();
const tb = new Date(b.sessions[0].startTime).getTime();
return ta - tb;
});
for (const tg of trackGroups) {
tg.sessions.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
}
return { dateKey, dateLabel: dayLabel(labelIso), trackGroups };
});
}
function sessionToAgendaRowV2(
s: VisitorSessionPreview,
eventId: string,
bookmark: AgendaRowTrackV2Bookmark,
): AgendaRowTrackV2Data {
const names = (s.speakers || []).map(landingSpeakerName);
if (s.moderator) names.push(`${landingSpeakerName(s.moderator)} (Moderator)`);
const joinHref = `/session?${new URLSearchParams({ event_id: eventId, session_id: s._id }).toString()}`;
return {
time: formatTimeShort(s.startTime),
duration: formatDurationMinutes(s.startTime, s.endTime) || "—",
trackTitle: s.title,
sessionType: sessionTypeDisplay(s.type),
description:
s.description?.trim() || "Agenda details will appear here when available.",
speakerNames: names.length ? names : ["—"],
status: s.status === "ongoing" ? { kind: "live" } : null,
showJoin: true,
joinHref,
bookmark,
};
}
function sessionToMySessionsRowV2(
s: VisitorSessionPreview,
eventId: string,
bookmark: AgendaRowTrackV2Bookmark,
): AgendaRowTrackV2Data {
const base = sessionToAgendaRowV2(s, eventId, bookmark);
const hall = s.track?.hall?.trim();
const dateLbl = formatDateLong(s.startTime);
return {
...base,
...(dateLbl ? { sessionDateLabel: dateLbl } : {}),
...(hall ? { trackLocation: hall } : {}),
};
}
type LandingAgendaCardsProps = {
days: LandingAgendaDay[];
dayOpen: Record<string, boolean>;
setDayOpen: Dispatch<SetStateAction<Record<string, boolean>>>;
eventId: string;
bookmarkedIds: Set<string>;
toggleBookmark: (sessionId: string) => void | Promise<void>;
};
export function LandingAgendaCards({
days,
dayOpen,
setDayOpen,
eventId,
bookmarkedIds,
toggleBookmark,
}: LandingAgendaCardsProps) {
return (
<div className="mt-3 flex w-full max-w-none flex-col gap-2">
{days.map((day) => {
const isOpen = dayOpen[day.dateKey] ?? false;
const toggleDay = () => {
setDayOpen((p) => ({ ...p, [day.dateKey]: !(p[day.dateKey] ?? false) }));
};
return (
<div
key={day.dateKey}
className="w-full rounded-lg border-x-0 border-[rgba(116,112,112,0.2)] bg-white shadow-sm sm:border-x"
>
<button
type="button"
className="flex w-full cursor-pointer items-center justify-between gap-2 px-4 py-3 text-left"
onClick={toggleDay}
aria-expanded={isOpen}
>
<div className="flex min-w-0 items-center gap-2">
<Calendar className="size-4 shrink-0 text-[#1e72ef]" strokeWidth={1.75} />
<span className="font-inter text-sm font-semibold text-[#24282f]">{day.dateLabel}</span>
</div>
<ChevronDown
className={`size-4 shrink-0 text-[#1e72ef] transition-transform ${isOpen ? "rotate-180" : ""}`}
strokeWidth={2}
aria-hidden
/>
</button>
{isOpen ? (
<div className="w-full border-t border-[#f2f2f7] pb-4 pt-1">
{day.trackGroups.map((tg, tgIdx) => (
<div key={tg.trackId} className="w-full">
{tgIdx > 0 ? (
<div
className="my-5 w-full border-t-[3px] border-[#1e72ef] shadow-[0_1px_0_rgba(12,12,13,0.06)]"
role="separator"
aria-hidden
/>
) : null}
<div className="px-4 pt-2">
<p className="font-inter text-xs font-semibold uppercase tracking-wide text-[#8e8e93]">
{tg.trackName}
</p>
{tg.trackHall ? (
<div className="mt-2 flex items-start gap-2 text-sm text-[#24282f]">
<MapPin
className="mt-0.5 size-4 shrink-0 text-[#1e72ef]"
strokeWidth={1.75}
/>
<span className="font-inter leading-snug">{tg.trackHall}</span>
</div>
) : null}
</div>
<div className="relative mt-3 w-full max-w-none px-0">
<div className="relative w-full max-w-none rounded-none bg-[#fafafa] p-4 shadow-[0_1px_4px_rgba(12,12,13,0.05)] sm:rounded-md">
<div className="flex w-full flex-col gap-4">
{tg.sessions.map((s) => (
<AgendaRowTrackV2
key={s._id}
row={sessionToAgendaRowV2(s, eventId, {
isBookmarked: bookmarkedIds.has(s._id),
onToggle: () => void toggleBookmark(s._id),
})}
/>
))}
</div>
</div>
</div>
</div>
))}
</div>
) : null}
</div>
);
})}
</div>
);
}
export function LandingMySessionsList({
sessions,
eventId,
bookmarkedIds,
toggleBookmark,
}: {
sessions: VisitorSessionPreview[];
eventId: string;
bookmarkedIds: Set<string>;
toggleBookmark: (sessionId: string) => void | Promise<void>;
}) {
return (
<div className="mt-3 w-full max-w-none">
<div className="relative w-full">
<div className="relative w-full max-w-none rounded-none bg-[#fafafa] p-4 shadow-[0_1px_4px_rgba(12,12,13,0.05)] sm:rounded-md">
<div className="flex w-full flex-col gap-4">
{sessions.map((s) => (
<AgendaRowTrackV2
key={s._id}
row={sessionToMySessionsRowV2(s, eventId, {
isBookmarked: bookmarkedIds.has(s._id),
onToggle: () => void toggleBookmark(s._id),
})}
/>
))}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,35 @@
import { Star } from "lucide-react";
export type EventRatingsSummary = {
average: number;
count: number;
};
export function EventRatingBadge({ ratings }: { ratings: EventRatingsSummary | null | undefined }) {
const count = ratings?.count ?? 0;
const average = ratings?.average ?? 0;
if (!count || count <= 0 || !Number.isFinite(average) || average <= 0) return null;
const full = Math.floor(average);
const hasHalf = average % 1 >= 0.4;
return (
<>
<div className="flex gap-0.5" aria-hidden>
{[1, 2, 3, 4, 5].map((i) => {
const filled = i <= full || (i === full + 1 && hasHalf);
return (
<Star
key={i}
className={`size-3 ${filled ? "fill-amber-400 text-amber-400" : "text-[#c7c7cc]"}`}
strokeWidth={filled ? 0 : 1.75}
/>
);
})}
</div>
<span className="font-inter text-[#8e8e93]">
{average.toFixed(1)} ({count})
</span>
</>
);
}

View File

@ -0,0 +1,192 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronLeft, LogOut, Trophy } from "lucide-react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { EventBuzzLogo } from "./EventBuzzLogo";
import { getVisitorEventRewardBalance } from "../lib/api";
import { EB_REFRESH_REWARD_BALANCE } from "../lib/rewardCelebration";
import { clearAttendeeSession, clearGuestTokenForEvent, getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
import { clearStoredVisitorContext, readStoredVisitorContext } from "../types/visitorContext";
export type HeaderMobileProps = {
onBack?: () => void;
/** When false, back control is hidden (e.g. event hub and session detail). Default true. */
showBack?: boolean;
/** Event / org logo URL; when missing, shows `eventName` (trimmed) or EventBuzz mark */
logoUrl?: string | null;
/** Shown in the header when there is no logo (truncated to available width). */
eventName?: string | null;
/**
* Explicit event scope (balance + rewards link). When omitted, resolves from
* `event_id` query, stored visitor context, or attendee participant.
*/
eventId?: string | null;
/** When false, only logo (and back if showBack). Default: true */
showRewardsAndLogout?: boolean;
};
function resolveHeaderEventId(
propId: string | null | undefined,
urlEventId: string,
ctxEventId: string | undefined,
participantEventId: string | undefined,
): string {
const fromProp = propId != null && String(propId).trim() !== "" ? String(propId).trim() : "";
if (fromProp) return fromProp;
if (urlEventId) return urlEventId;
if (ctxEventId) return ctxEventId;
if (participantEventId) return participantEventId;
return "";
}
function resolveHeaderEventName(
propName: string | null | undefined,
resolvedEventId: string,
ctx: ReturnType<typeof readStoredVisitorContext>,
): string {
const fromProp = propName != null && String(propName).trim() !== "" ? String(propName).trim() : "";
if (fromProp) return fromProp;
if (resolvedEventId && ctx?.eventId === resolvedEventId && ctx.event?.name?.trim()) {
return ctx.event.name.trim();
}
return "";
}
export function HeaderMobile({
onBack,
showBack = true,
logoUrl,
eventName: eventNameProp,
eventId: eventIdProp,
showRewardsAndLogout = true,
}: HeaderMobileProps) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const urlEventId = searchParams.get("event_id")?.trim() || "";
const ctx = readStoredVisitorContext();
const participant = getAttendeeParticipant();
const hasToken = Boolean(getAttendeeToken());
const resolvedEventId = useMemo(
() => resolveHeaderEventId(eventIdProp ?? null, urlEventId, ctx?.eventId, participant?.event),
[eventIdProp, urlEventId, ctx?.eventId, participant?.event],
);
const resolvedEventName = useMemo(
() => resolveHeaderEventName(eventNameProp ?? null, resolvedEventId, ctx),
[eventNameProp, resolvedEventId, ctx],
);
const logoSrc = logoUrl?.trim() || "";
const [rewardPoints, setRewardPoints] = useState<number | null>(null);
useEffect(() => {
if (!showRewardsAndLogout || !hasToken || !resolvedEventId) {
setRewardPoints(null);
return;
}
let cancelled = false;
setRewardPoints(null);
getVisitorEventRewardBalance(resolvedEventId)
.then((n) => {
if (!cancelled) setRewardPoints(Number.isFinite(n) ? Math.max(0, n) : 0);
})
.catch(() => {
if (!cancelled) setRewardPoints(0);
});
return () => {
cancelled = true;
};
}, [showRewardsAndLogout, hasToken, resolvedEventId]);
useEffect(() => {
const onRefresh = () => {
if (!showRewardsAndLogout || !getAttendeeToken() || !resolvedEventId) return;
getVisitorEventRewardBalance(resolvedEventId)
.then((n) => setRewardPoints(Number.isFinite(n) ? Math.max(0, n) : 0))
.catch(() => setRewardPoints(0));
};
window.addEventListener(EB_REFRESH_REWARD_BALANCE, onRefresh);
return () => window.removeEventListener(EB_REFRESH_REWARD_BALANCE, onRefresh);
}, [showRewardsAndLogout, resolvedEventId]);
const handleLogout = useCallback(() => {
clearAttendeeSession();
clearStoredVisitorContext();
if (resolvedEventId) clearGuestTokenForEvent(resolvedEventId);
navigate("/", { replace: true });
}, [navigate, resolvedEventId]);
const rewardsHref = resolvedEventId
? `/rewards?${new URLSearchParams({ event_id: resolvedEventId }).toString()}`
: "/rewards";
const pointsLabel =
!showRewardsAndLogout || !hasToken
? null
: rewardPoints === null
? "…"
: String(Math.max(0, rewardPoints));
return (
<header
className="sticky top-0 z-20 flex h-[60px] shrink-0 items-center border-b border-white/40 bg-white px-4 shadow-[0_1px_4px_rgba(12,12,13,0.05),0_1px_4px_rgba(12,12,13,0.1)]"
data-name="Header_Mobile"
>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
{showBack ? (
<button
type="button"
onClick={() => (onBack ? onBack() : navigate(-1))}
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-[#f2f2f7] text-[#1d66ef]"
aria-label="Back"
>
<ChevronLeft className="size-6" strokeWidth={2} />
</button>
) : null}
<div className="flex h-10 min-w-0 max-w-[min(200px,42vw)] items-center justify-center rounded border border-[#f2f2f7] bg-white px-2 py-1.5">
{logoSrc ? (
<img
src={logoSrc}
alt={resolvedEventName || "Event"}
className="max-h-8 max-w-full object-contain object-center"
/>
) : resolvedEventName ? (
<span
className="block w-full truncate text-left text-sm font-semibold leading-tight text-[#0f3072]"
title={resolvedEventName}
>
{resolvedEventName}
</span>
) : (
<EventBuzzLogo variant="color" className="h-8 max-h-8 w-auto object-center" />
)}
</div>
</div>
{showRewardsAndLogout && hasToken ? (
<div className="flex shrink-0 items-center justify-end gap-2 sm:gap-4">
<Link
to={rewardsHref}
className="flex items-center gap-1.5 rounded-full border border-[#ef8d1d] px-3 py-2 sm:gap-2 sm:px-4"
>
<Trophy className="size-5 shrink-0 text-[#ef8d1d] sm:size-6" strokeWidth={1.75} />
<span className="min-w-[1.25rem] text-sm font-medium tabular-nums tracking-wide text-[#24282f]">
{pointsLabel}
</span>
</Link>
<button
type="button"
onClick={handleLogout}
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-[#ef8d1d] text-white"
aria-label="Sign out"
>
<LogOut className="size-5 sm:size-6" strokeWidth={2} />
</button>
</div>
) : null}
</div>
</header>
);
}

View File

@ -0,0 +1,139 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Gift, Home, MessageCircle, MessageCircleQuestion, Star, Trophy, Users } from "lucide-react";
import { Link } from "react-router-dom";
import { readStoredVisitorContext, visitorEventUrl } from "../types/visitorContext";
type HyperNavigationFABProps = {
/** Preferred; falls back to stored visitor context `eventId`. */
eventId?: string;
/** When set (e.g. on session page), matches Audience Questions “View All” (`/questions?event_id&session_id`). */
sessionId?: string;
/** When true, add Feedback to the menu (session page + event allows feedback + session flag). */
showFeedbackNav?: boolean;
/** When true, add Ratings to the menu (session page + event ratings on + session flag). */
showRatingsNav?: boolean;
/** When set with showFeedbackNav, append form_id to the feedback link. */
feedbackFormId?: string;
/** When set with showRatingsNav, append form_id to the ratings link. */
ratingsFormId?: string;
};
type MenuItem = {
label: string;
to: string;
icon: ReactNode;
};
/** Fixed to viewport bottom-right, aligned with `MobileShell` max width (402px). Opens actions above the FAB. */
export function HyperNavigationFAB({
eventId: eventIdProp,
sessionId: sessionIdProp,
showFeedbackNav = false,
showRatingsNav = false,
feedbackFormId,
ratingsFormId,
}: HyperNavigationFABProps) {
const ctx = readStoredVisitorContext();
const eventId = (eventIdProp ?? ctx?.eventId ?? "").trim();
const sessionId = (sessionIdProp ?? ctx?.sessionId ?? "").trim();
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const eventHome = eventId ? visitorEventUrl(eventId) : "/";
const rewards =
eventId ? `/rewards?${new URLSearchParams({ event_id: eventId }).toString()}` : "/rewards";
const leaderboard =
eventId ? `/leaderboard?${new URLSearchParams({ event_id: eventId }).toString()}` : "/leaderboard";
const allQuestionsQs = new URLSearchParams();
if (eventId) allQuestionsQs.set("event_id", eventId);
if (sessionId) allQuestionsQs.set("session_id", sessionId);
const allQuestions =
eventId ? `/questions?${allQuestionsQs.toString()}` : "/questions";
const feedbackQs = new URLSearchParams();
if (eventId) feedbackQs.set("event_id", eventId);
if (feedbackFormId?.trim()) feedbackQs.set("form_id", feedbackFormId.trim());
const feedbackTo = eventId ? `/feedback?${feedbackQs.toString()}` : "/feedback";
const ratingsQs = new URLSearchParams();
if (eventId) ratingsQs.set("event_id", eventId);
if (ratingsFormId?.trim()) ratingsQs.set("form_id", ratingsFormId.trim());
const ratingsTo = eventId ? `/ratings?${ratingsQs.toString()}` : "/ratings";
const items: MenuItem[] = [
{ label: "Event Home", to: eventHome, icon: <Home className="size-4 shrink-0 text-[#1e72ef]" strokeWidth={1.75} /> },
{ label: "Rewards", to: rewards, icon: <Gift className="size-4 shrink-0 text-[#1e72ef]" strokeWidth={1.75} /> },
{ label: "Leaderboard", to: leaderboard, icon: <Trophy className="size-4 shrink-0 text-[#1e72ef]" strokeWidth={1.75} /> },
...(showFeedbackNav
? [{ label: "Feedback", to: feedbackTo, icon: <MessageCircle className="size-4 shrink-0 text-[#1e72ef]" strokeWidth={1.75} /> }]
: []),
...(showRatingsNav
? [{ label: "Ratings", to: ratingsTo, icon: <Star className="size-4 shrink-0 text-[#1e72ef]" strokeWidth={1.75} /> }]
: []),
{
label: "All Questions",
to: allQuestions,
icon: <MessageCircleQuestion className="size-4 shrink-0 text-[#1e72ef]" strokeWidth={1.75} />,
},
];
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const close = useCallback(() => setOpen(false), []);
return (
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-50 flex justify-center pb-6">
<div className="pointer-events-none flex w-full max-w-[402px] justify-end pr-4">
<div ref={wrapRef} className="relative flex flex-col items-end pointer-events-auto">
{open ? (
<div
className="mb-2 w-[min(280px,calc(100vw-2rem))] divide-y divide-[#f2f2f7] overflow-hidden rounded-xl border border-[rgba(116,112,112,0.2)] bg-white shadow-[0_8px_24px_rgba(12,12,13,0.12)]"
role="menu"
aria-label="Quick navigation"
>
{items.map((item) => (
<Link
key={item.label}
to={item.to}
role="menuitem"
onClick={close}
className="flex items-center gap-3 px-4 py-3 text-left text-sm font-medium text-[#24282f] transition-colors hover:bg-[#f2f2f7] active:bg-[#e5e5ea]"
>
{item.icon}
{item.label}
</Link>
))}
</div>
) : null}
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-haspopup="menu"
aria-label={open ? "Close quick navigation" : "Open quick navigation"}
className="relative flex size-12 shrink-0 items-center justify-center rounded-full border-2 border-white text-white shadow-[0_14px_18px_rgba(0,0,0,0.2),0_2px_3px_rgba(0,0,0,0.17)] eb-gradient-fab"
>
<span
className="pointer-events-none absolute inset-[-2px] rounded-full shadow-[inset_0_6px_4px_rgba(0,0,0,0.25)]"
aria-hidden
/>
<Users className={`relative z-[1] size-7 transition-transform ${open ? "scale-90" : ""}`} strokeWidth={1.75} />
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,25 @@
import type { ReactNode } from "react";
import { PoweredByFooter } from "./PoweredByFooter";
type MobileShellProps = {
children: ReactNode;
className?: string;
/** “Powered by EventBuzz” strip at the bottom. Default on for event screens. */
showPoweredByFooter?: boolean;
};
/** 402px artboard width from Figma mobile frames */
export function MobileShell({
children,
className = "",
showPoweredByFooter = true,
}: MobileShellProps) {
return (
<div
className={`mx-auto flex min-h-dvh w-full max-w-[402px] flex-1 flex-col bg-[#f2f2f7] ${className}`}
>
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
{showPoweredByFooter ? <PoweredByFooter /> : null}
</div>
);
}

View File

@ -0,0 +1,22 @@
import { EventBuzzLogo } from "./EventBuzzLogo";
/** Dark strip footer: “Powered by” + EventBuzz mark on event app screens. */
export function PoweredByFooter() {
return (
<footer
className="flex shrink-0 items-center justify-center gap-2 border-t border-white/10 bg-[#1a1d24] px-4 py-3"
data-name="PoweredBy_Footer"
>
<span className="text-xs font-medium tracking-wide text-white/75">Powered by</span>
<a
href="https://eventbuzz.live"
target="_blank"
rel="noopener noreferrer"
className="rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-white/40"
aria-label="EventBuzz — opens in a new tab"
>
<EventBuzzLogo variant="white" className="h-5 max-h-5 w-auto" />
</a>
</footer>
);
}

View File

@ -0,0 +1,183 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useSessionQuestionsRtdb } from "../hooks/useSessionQuestionsRtdb";
import { AudienceQuestionCard, type AudienceQuestionItem } from "./AudienceQuestionCard";
import { AskQuestionModal } from "./AskQuestionModal";
type QuestionItem = AudienceQuestionItem & { _id?: string };
const sample: QuestionItem[] = [
{
author: "Ashok Kuwal",
text: "Role of GINA Guidelines for Iniang and Monitoring Asthma Treatment?",
up: 2,
down: 3,
},
{
author: "Ashok Kuwal",
text: "Role of GINA Guidelines for Iniang and Monitoring Asthma Treatment?",
up: 2,
down: 3,
},
{
author: "Ashok Kuwal",
text: "Role of GINA Guidelines for Iniang and Monitoring Asthma Treatment?",
up: 2,
down: 3,
},
];
type SessionQuestionsCardProps = {
title?: string;
showQuestionNav?: boolean;
eventId?: string;
sessionId?: string;
};
export function SessionQuestionsCard({
title = "Session Questions",
showQuestionNav = false,
eventId,
sessionId,
}: SessionQuestionsCardProps) {
const live = Boolean(eventId?.trim() && sessionId?.trim());
const eid = eventId?.trim() ?? "";
const sid = sessionId?.trim() ?? "";
const { items: rtdbItems, loading, error } = useSessionQuestionsRtdb(
live ? sid : null,
live ? eid : null,
);
const items: QuestionItem[] = live
? rtdbItems.map((r) => ({
_id: r._id,
author: r.author,
text: r.text,
up: r.up,
down: r.down,
}))
: sample;
const total = items.length;
const [index, setIndex] = useState(0);
const [askModalOpen, setAskModalOpen] = useState(false);
useEffect(() => {
if (index >= total && total > 0) setIndex(0);
else if (total === 0) setIndex(0);
}, [total, index]);
const goNext = useCallback(() => {
if (total <= 0) return;
setIndex((i) => (i + 1) % total);
}, [total]);
const goPrev = useCallback(() => {
if (total <= 0) return;
setIndex((i) => (i - 1 + total) % total);
}, [total]);
useEffect(() => {
if (total <= 1) return;
const id = window.setInterval(goNext, 2000);
return () => window.clearInterval(id);
}, [total, goNext]);
const safeIndex = total > 0 ? Math.min(index, total - 1) : 0;
const current = total > 0 ? items[safeIndex] : null;
const viewAllTo =
live ? `/questions?event_id=${encodeURIComponent(eid)}&session_id=${encodeURIComponent(sid)}` : null;
return (
<section
className="relative z-10 flex w-full max-w-none flex-col gap-4 rounded-lg border border-[rgba(116,112,112,0.2)] bg-white py-4 shadow-sm"
data-name="Session Questions"
>
<div className="flex h-8 items-center justify-between gap-2 px-4">
<div className="flex min-w-0 flex-1 items-center gap-2">
<h2 className="truncate text-lg font-semibold tracking-wide text-[#24282f]">
{title}
</h2>
{total > 0 ? (
<span className="shrink-0 rounded bg-[#e5e5ea] px-2 py-0.5 text-[10px] tracking-wide text-[#24282f]">
{safeIndex + 1}/{total}
</span>
) : null}
</div>
{showQuestionNav && total > 1 ? (
<div className="flex shrink-0 gap-1">
<button
type="button"
className="rounded-full bg-[#f2f2f7] p-1"
aria-label="Previous question"
onClick={goPrev}
>
<ChevronLeft className="size-4 text-[#24282f]" />
</button>
<button
type="button"
className="rounded-full bg-[#f2f2f7] p-1"
aria-label="Next question"
onClick={goNext}
>
<ChevronRight className="size-4 text-[#24282f]" />
</button>
</div>
) : null}
</div>
<div className="border-t border-[#f2f2f7]">
{live && loading ? (
<p className="px-4 py-6 text-center text-sm text-[rgba(36,40,47,0.55)]">Loading questions…</p>
) : live && error ? (
<p className="px-4 py-6 text-center text-sm text-red-600">{error}</p>
) : current ? (
<div className="p-0">
<AudienceQuestionCard
key={current._id ?? `${safeIndex}-${current.text.slice(0, 24)}`}
item={current}
variant="embedded"
voteContext={live ? { eventId: eid, sessionId: sid } : undefined}
/>
</div>
) : (
<p className="px-4 py-6 text-center text-sm text-[rgba(36,40,47,0.55)]">No questions yet.</p>
)}
</div>
<div className="flex items-center justify-between gap-3 px-4">
{viewAllTo ? (
<Link
to={viewAllTo}
className="font-inter text-xs font-semibold uppercase tracking-wider text-[#1e72ef]"
>
View All
</Link>
) : (
<span className="font-inter text-xs font-semibold uppercase tracking-wider text-[rgba(36,40,47,0.35)]">
View All
</span>
)}
<button
type="button"
onClick={() => live && setAskModalOpen(true)}
disabled={!live}
className="shrink-0 rounded-lg bg-[#ef8d1d] px-3 py-2 text-xs font-semibold text-white shadow-[0_2px_4px_rgba(0,0,0,0.1)] transition hover:bg-[#e0800c] disabled:opacity-50"
>
Ask your Question
</button>
</div>
{live ? (
<AskQuestionModal
open={askModalOpen}
onClose={() => setAskModalOpen(false)}
eventId={eid}
sessionId={sid}
idSuffix="-card"
/>
) : null}
</section>
);
}

View File

@ -0,0 +1,106 @@
import { useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { Link } from "react-router-dom";
import { useSessionQuizPollsRtdb } from "../hooks/useSessionQuizPollsRtdb";
function typeBadgeClass(type: string): string {
const t = type.toLowerCase();
if (t === "poll") return "bg-[#eaddff]";
if (t === "survey") return "bg-[#e5e5ea]";
return "bg-[#ebffee]";
}
function typeLabel(type: string): string {
const t = type.toLowerCase();
if (t === "poll") return "Poll";
if (t === "survey") return "Survey";
return "Quiz";
}
function actionLabel(type: string): string {
const t = type.toLowerCase();
if (t === "poll") return "Take Poll";
if (t === "survey") return "Take Survey";
return "Take Quiz";
}
type SessionQuizPollsSectionProps = {
eventId: string;
sessionId: string;
};
export function SessionQuizPollsSection({ eventId, sessionId }: SessionQuizPollsSectionProps) {
const [open, setOpen] = useState(true);
const { items, loading, error } = useSessionQuizPollsRtdb(sessionId);
const eid = eventId.trim();
const sid = sessionId.trim();
const live = Boolean(eid && sid);
const takeHref = (quizPollId: string) =>
`/quiz-poll?${new URLSearchParams({
event_id: eid,
session_id: sid,
quiz_poll_id: quizPollId,
}).toString()}`;
return (
<div className="w-full max-w-none rounded-lg border border-[rgba(116,112,112,0.2)] bg-white shadow-sm">
<button
type="button"
className="flex w-full items-center justify-between gap-2 px-4 py-3 text-left"
onClick={() => setOpen(!open)}
aria-expanded={open}
>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[#24282f]">Quiz &amp; Polls</span>
<span className="rounded bg-[#ebffee] px-2 py-0.5 text-[10px] uppercase text-[#24282f]">
{loading ? "…" : `${items.length} Active`}
</span>
</div>
{open ? (
<ChevronUp className="size-4 shrink-0 text-[#1d66ef]" strokeWidth={2} />
) : (
<ChevronDown className="size-4 shrink-0 text-[#1d66ef]" strokeWidth={2} />
)}
</button>
{open ? (
<div className="border-t border-[#f2f2f7] px-4 pb-4 pt-1">
{!live ? (
<p className="py-3 text-center text-sm text-[rgba(36,40,47,0.55)]">Join a session to see quizzes.</p>
) : error ? (
<p className="py-3 text-center text-sm text-red-600">{error}</p>
) : loading ? (
<p className="py-3 text-center text-sm text-[rgba(36,40,47,0.55)]">Loading quizzes…</p>
) : items.length === 0 ? (
<p className="py-3 text-center text-sm text-[rgba(36,40,47,0.55)]">No live quizzes or polls right now.</p>
) : (
<ul className="flex flex-col gap-0">
{items.map((item) => (
<li
key={item._id}
className="flex items-center justify-between gap-2 border-b border-[#f2f2f7] py-3 last:border-0"
>
<div className="min-w-0 flex-1">
<span
className={`inline-block rounded px-2 py-0.5 text-[10px] font-medium text-[#24282f] ${typeBadgeClass(item.type)}`}
>
{typeLabel(item.type).toUpperCase()}
</span>
<p className="mt-1 truncate text-sm font-medium text-[#24282f]">{item.title || "Untitled"}</p>
</div>
<Link
to={takeHref(item._id)}
className="shrink-0 rounded bg-[#0b4dd1] px-3 py-1.5 text-center text-xs font-semibold text-white transition hover:bg-[#0a43b8]"
>
{actionLabel(item.type)}
</Link>
</li>
))}
</ul>
)}
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,31 @@
import type { ReactNode } from "react";
import { EventBuzzMark } from "../EventBuzzMark";
import { LegalFooterLinks } from "../marketing/LegalFooterLinks";
type AuthShellProps = {
children: ReactNode;
/** Vertical offset for main card cluster (Figma uses ~50% translate) */
mainClassName?: string;
/** Show EventBuzz white logo pinned to the top of the auth shell */
showLogo?: boolean;
};
export function AuthShell({ children, mainClassName = "", showLogo = true }: AuthShellProps) {
return (
<div className="eb-gradient-auth relative flex min-h-dvh w-full flex-1 flex-col">
{showLogo ? (
<header className="mx-auto flex w-full max-w-[402px] shrink-0 justify-center px-6 pt-8 pb-4">
<EventBuzzMark className="h-10 w-auto sm:h-11" />
</header>
) : null}
<div
className={`mx-auto flex w-full max-w-[402px] flex-1 flex-col items-center px-6 pb-6 ${showLogo ? "justify-start pt-2" : "justify-center py-8"} ${mainClassName}`}
>
{children}
</div>
<footer className="mx-auto mt-auto w-full max-w-[402px] shrink-0 px-6 py-3 text-sm font-light">
<LegalFooterLinks variant="light" />
</footer>
</div>
);
}

View File

@ -0,0 +1,81 @@
import { ArrowRight, Calendar } from "lucide-react";
import { useJoinEventCode } from "../../hooks/useJoinEventCode";
const shellX = "px-4 sm:px-6 lg:px-12 xl:px-[min(147px,8vw)]";
export function JoinCodeTopStrip({ id = "join-event" }: { id?: string }) {
const { code, setCode, submitting, joinError, handleJoin } = useJoinEventCode();
return (
<section
id={id}
className="border-b border-[rgba(116,112,112,0.2)] py-4"
style={{
background: "linear-gradient(135deg, #eef5ff 0%, #f2f2f7 55%, #f5f0ff 100%)",
}}
data-name="Join_Event_Strip"
>
<div className={`mx-auto max-w-[1728px] ${shellX}`}>
<form
onSubmit={handleJoin}
className="flex flex-col items-center justify-between gap-4 sm:flex-row"
>
<div className="flex items-center gap-3">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-full"
style={{
background: "linear-gradient(135deg, #34A7F2 0%, #1D66EF 100%)",
boxShadow: "0 2px 8px rgba(52,167,242,0.25)",
}}
aria-hidden
>
<Calendar className="size-5 text-white" strokeWidth={2} />
</div>
<div>
<h3 className="mb-0.5 font-inter text-sm font-medium text-[#24282f]">
Attending an Event?
</h3>
<p className="font-inter text-xs text-[rgba(36,40,47,0.72)]">
Enter your event code to join instantly
</p>
</div>
</div>
<div className="flex flex-wrap items-center justify-center gap-2 sm:justify-end">
<label className="sr-only" htmlFor="join-event-code">
Event code
</label>
<div className="flex items-center gap-2 rounded-lg border border-[rgba(116,112,112,0.25)] bg-white px-3 py-2">
<input
id="join-event-code"
type="text"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="ABC123"
maxLength={12}
className="min-w-[160px] w-[160px] bg-transparent font-inter text-sm font-medium tracking-[0.08em] text-[#24282f] uppercase outline-none placeholder:font-normal placeholder:tracking-normal placeholder:text-[#8e8e93] sm:min-w-[180px] sm:w-[180px]"
/>
</div>
<button
type="submit"
disabled={submitting || !code.trim()}
className="flex items-center gap-1.5 rounded-lg px-5 py-2 font-inter text-sm font-medium text-white transition-all hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
style={{
background: "linear-gradient(135deg, #34A7F2 0%, #1D66EF 100%)",
boxShadow: "0 2px 8px rgba(52,167,242,0.25)",
}}
>
{submitting ? "Joining…" : "Join Now"}
<ArrowRight className="size-4" strokeWidth={2} />
</button>
</div>
</form>
{joinError ? (
<p className="mt-3 text-center text-sm font-medium text-red-600 sm:text-right" role="alert">
{joinError}
</p>
) : null}
</div>
</section>
);
}

View File

@ -0,0 +1,28 @@
import { Link } from "react-router-dom";
type LegalFooterLinksProps = {
variant?: "dark" | "light";
className?: string;
};
export function LegalFooterLinks({ variant = "dark", className = "" }: LegalFooterLinksProps) {
const linkClass =
variant === "light"
? "hover:underline text-white/95"
: "hover:underline text-[#24282f]";
const sepClass = variant === "light" ? "text-[#d9d9d9]" : "text-[#8e8e93]";
return (
<div className={`flex flex-wrap items-center justify-center gap-4 ${className}`}>
<Link to="/privacy-policy" className={linkClass}>
Privacy Policy
</Link>
<span className={sepClass} aria-hidden>
|
</span>
<Link to="/terms-of-service" className={linkClass}>
Terms of Service
</Link>
</div>
);
}

View File

@ -0,0 +1,61 @@
import type { ReactNode } from "react";
import { MarketingFooter } from "./MarketingFooter";
import { MarketingHeader } from "./MarketingHeader";
import { MARKETING_HEADER_SCROLL_MARGIN, shellX } from "./marketingLayout";
type LegalPageLayoutProps = {
title: string;
lastUpdated: string;
children: ReactNode;
};
export function LegalPageLayout({ title, lastUpdated, children }: LegalPageLayoutProps) {
return (
<div className="flex min-h-dvh w-full flex-col bg-white text-[#24282f]">
<MarketingHeader />
<main
className={`${MARKETING_HEADER_SCROLL_MARGIN} mx-auto w-full max-w-[1728px] flex-1 py-8 sm:py-10 lg:py-14 ${shellX}`}
>
<div className="mx-auto w-full max-w-4xl">
<h1 className="font-inter text-[clamp(1.5rem,4vw,2.25rem)] font-semibold tracking-tight text-[#24282f]">
{title}
</h1>
<p className="mt-2 font-inter text-sm text-[#8e8e93] sm:text-base">
Last updated: {lastUpdated}
</p>
<article className="mt-6 space-y-8 font-inter text-[15px] leading-relaxed text-[rgba(36,40,47,0.87)] sm:mt-8 sm:text-base sm:leading-7 lg:space-y-10">
{children}
</article>
</div>
</main>
<MarketingFooter />
</div>
);
}
export function LegalSection({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<section>
<h2 className="mb-3 font-inter text-base font-semibold text-[#24282f] sm:text-lg">{title}</h2>
<div className="space-y-3">{children}</div>
</section>
);
}
export function LegalList({ items }: { items: string[] }) {
return (
<ul className="list-disc space-y-2 pl-5 sm:pl-6">
{items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
}

View File

@ -0,0 +1,19 @@
import { EventBuzzMark } from "../EventBuzzMark";
import { LegalFooterLinks } from "./LegalFooterLinks";
import { shellX } from "./marketingLayout";
export function MarketingFooter() {
return (
<footer className="w-full bg-[#24282f] px-4 py-6 lg:py-6" data-name="Footer_Mobile">
<div
className={`mx-auto flex w-full max-w-[1728px] flex-col items-center gap-6 lg:flex-row lg:items-center lg:justify-between lg:gap-8 ${shellX}`}
>
<EventBuzzMark className="scale-110 lg:scale-100" />
<LegalFooterLinks
variant="light"
className="gap-10 text-base font-light tracking-wide lg:justify-end lg:text-[22px]"
/>
</div>
</footer>
);
}

View File

@ -0,0 +1,208 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { Menu, X } from "lucide-react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { EventBuzzMark } from "../EventBuzzMark";
import { PORTAL_LOGIN_URL, PORTAL_REGISTER_URL } from "../../constants/portalUrls";
import {
scrollToId,
scrollToJoinEvent,
scrollTop,
shellX,
} from "./marketingLayout";
type NavAction = () => void;
function NavButton({ children, onClick }: { children: ReactNode; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="rounded-xl px-2 py-2 text-center text-sm font-medium text-white hover:bg-white/10 xl:px-3 xl:text-base"
>
{children}
</button>
);
}
function useMarketingNav() {
const location = useLocation();
const navigate = useNavigate();
const isHome = location.pathname === "/";
const goHome = useCallback(() => {
if (isHome) scrollTop();
else navigate("/");
}, [isHome, navigate]);
const goSection = useCallback(
(id: string) => {
if (isHome) scrollToId(id);
else navigate(`/#${id}`);
},
[isHome, navigate],
);
const goJoinEvent = useCallback(() => {
if (isHome) scrollToJoinEvent();
else navigate("/#join-event");
}, [isHome, navigate]);
return { goHome, goSection, goJoinEvent };
}
export function MarketingHeaderMobile() {
const [menuOpen, setMenuOpen] = useState(false);
const { goHome, goSection, goJoinEvent } = useMarketingNav();
useEffect(() => {
if (!menuOpen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setMenuOpen(false);
}
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
};
}, [menuOpen]);
function closeMenu() {
setMenuOpen(false);
}
function runNav(fn: NavAction) {
fn();
closeMenu();
}
const navItems: { label: string; action: NavAction }[] = [
{ label: "Home", action: goHome },
{ label: "Join Event", action: goJoinEvent },
{ label: "Features", action: () => goSection("features") },
{ label: "About", action: () => goSection("about") },
{ label: "Contact", action: () => goSection("contact") },
{ label: "Support", action: () => goSection("contact") },
];
return (
<header
className="sticky top-0 z-30 flex h-[60px] w-full shrink-0 items-center border-b border-white/40 bg-[#1d66ef] px-4 shadow-[0_1px_4px_rgba(12,12,13,0.08)] lg:hidden"
data-name="Header_Mobile"
>
<div className="mx-auto flex w-full max-w-[402px] items-center justify-between gap-3 sm:max-w-none sm:px-2">
<Link to="/" className="min-w-0 shrink" aria-label="EventBuzz home" onClick={closeMenu}>
<EventBuzzMark />
</Link>
<button
type="button"
onClick={() => setMenuOpen((o) => !o)}
aria-expanded={menuOpen}
aria-controls="mobile-marketing-nav"
aria-label={menuOpen ? "Close menu" : "Open menu"}
className="rounded border border-white/30 p-2 text-white hover:bg-white/10"
>
{menuOpen ? <X className="size-6" strokeWidth={2} /> : <Menu className="size-6" strokeWidth={2} />}
</button>
</div>
{menuOpen && (
<>
<button
type="button"
className="fixed bottom-0 left-0 right-0 top-[60px] z-40 bg-black/45 lg:hidden"
aria-label="Close menu"
onClick={closeMenu}
/>
<div
id="mobile-marketing-nav"
className="fixed inset-x-0 top-[60px] z-50 max-h-[min(calc(100dvh-60px),640px)] overflow-y-auto border-t border-white/20 bg-[#1d66ef] px-4 py-5 shadow-[0_12px_24px_rgba(0,0,0,0.2)] lg:hidden"
role="dialog"
aria-modal="true"
aria-label="Site menu"
>
<nav className="flex flex-col gap-0.5">
{navItems.map(({ label, action }) => (
<button
key={label}
type="button"
onClick={() => runNav(action)}
className="rounded-xl px-3 py-3 text-left text-base font-medium text-white hover:bg-white/10"
>
{label}
</button>
))}
</nav>
<div className="mt-4 flex flex-col gap-3 border-t border-white/20 pt-5">
<a
href={PORTAL_REGISTER_URL}
onClick={closeMenu}
className="rounded-full border border-white bg-white py-3 text-center text-base font-semibold text-[#1d66ef]"
>
Get Started
</a>
<a
href={PORTAL_LOGIN_URL}
onClick={closeMenu}
className="rounded-full border border-white py-3 text-center text-base font-semibold text-white hover:bg-white/10"
>
Login
</a>
</div>
</div>
</>
)}
</header>
);
}
export function MarketingHeaderDesktop() {
const { goHome, goSection, goJoinEvent } = useMarketingNav();
return (
<header
className="sticky top-0 z-40 hidden h-[84px] w-full shrink-0 items-center border-b border-white/40 bg-[#1d66ef] shadow-[0_1px_4px_rgba(12,12,13,0.08)] lg:flex"
data-name="Header"
>
<div className={`mx-auto flex w-full max-w-[1728px] items-center justify-between gap-6 ${shellX}`}>
<Link to="/" className="shrink-0" aria-label="EventBuzz home">
<EventBuzzMark className="lg:scale-110 xl:scale-125" />
</Link>
<div className="flex min-w-0 flex-1 items-center justify-end gap-6 xl:gap-10">
<nav className="hidden min-w-0 flex-wrap items-center justify-end gap-0.5 lg:flex">
<NavButton onClick={goHome}>Home</NavButton>
<NavButton onClick={goJoinEvent}>Join Event</NavButton>
<NavButton onClick={() => goSection("features")}>Features</NavButton>
<NavButton onClick={() => goSection("about")}>About</NavButton>
<NavButton onClick={() => goSection("contact")}>Contact</NavButton>
<NavButton onClick={() => goSection("contact")}>Support</NavButton>
</nav>
<div className="flex shrink-0 items-center gap-3">
<a
href={PORTAL_REGISTER_URL}
className="rounded-full border border-white bg-white px-5 py-2.5 text-base font-semibold text-[#1d66ef] shadow-sm hover:bg-white/95"
>
Get Started
</a>
<a
href={PORTAL_LOGIN_URL}
className="rounded-full border border-white px-5 py-2.5 text-base font-semibold text-white hover:bg-white/10"
>
Login
</a>
</div>
</div>
</div>
</header>
);
}
export function MarketingHeader() {
return (
<>
<MarketingHeaderMobile />
<MarketingHeaderDesktop />
</>
);
}

View File

@ -0,0 +1,176 @@
import {
ArrowRight,
BarChart3,
CalendarClock,
CheckCircle,
MessageCircleQuestion,
Star,
Users,
Zap,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import mainBanner from "../../assets/main_banner.png";
import { PORTAL_REGISTER_URL } from "../../constants/portalUrls";
const shellX = "px-4 sm:px-6 lg:px-12 xl:px-[min(147px,8vw)]";
const heroHighlights = [
"Live Q&A & Polling",
"Multi-track Sessions",
"Real-time Analytics",
"Custom Branding",
] as const;
const heroFeatureCards: {
id: string;
title: string;
description: string;
Icon: LucideIcon;
}[] = [
{
id: "qa",
title: "Live Q&A",
description: "Real-time audience questions during every session.",
Icon: MessageCircleQuestion,
},
{
id: "polls",
title: "Interactive Polls",
description: "Instant feedback with live results on screen.",
Icon: BarChart3,
},
{
id: "agenda",
title: "Event Agenda",
description: "Schedules, tracks, and speaker details in one place.",
Icon: CalendarClock,
},
{
id: "analytics",
title: "Analytics",
description: "Engagement insights to improve your next event.",
Icon: BarChart3,
},
];
const heroStats: {
value: string;
label: string;
Icon: LucideIcon;
bg: string;
color: string;
}[] = [
{ value: "1000+", label: "Events Hosted", Icon: CalendarClock, bg: "rgba(52,167,242,0.15)", color: "#34A7F2" },
{ value: "50K+", label: "Attendees", Icon: Users, bg: "rgba(52,167,242,0.12)", color: "#34A7F2" },
{ value: "96%", label: "Satisfaction", Icon: Star, bg: "rgba(239,141,29,0.15)", color: "#ef8d1d" },
{ value: "1M+", label: "Live Interactions", Icon: MessageCircleQuestion, bg: "rgba(29,102,239,0.15)", color: "#1d66ef" },
];
export function MarketingHeroSection() {
return (
<section className="relative overflow-hidden bg-[#24282f]" data-name="Marketing_Hero">
<img
src={mainBanner}
alt=""
aria-hidden
className="pointer-events-none absolute inset-0 size-full object-cover opacity-50"
/>
<div
className="pointer-events-none absolute inset-0"
aria-hidden
style={{
background:
"linear-gradient(to right, rgba(15,23,40,0.72) 0%, rgba(15,23,40,0.35) 60%, transparent 100%)",
}}
/>
<div className={`relative mx-auto max-w-[1728px] py-16 md:py-24 ${shellX}`}>
<div className="grid items-center gap-12 lg:grid-cols-2">
<div>
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-[rgba(11,77,209,0.4)] bg-[rgba(11,77,209,0.25)] px-3 py-1.5">
<Zap className="size-3.5 text-[#34A7F2]" strokeWidth={2} />
<span className="font-inter text-xs text-[#34A7F2]">Event management made easy</span>
</div>
<h1 className="mb-6 font-inter text-[clamp(2rem,4vw,3rem)] font-medium leading-[1.15] text-white">
Create Your First Event in <span className="text-[#34A7F2]">Minutes</span>
</h1>
<p className="mb-10 max-w-xl font-inter text-lg leading-relaxed text-[rgba(249,250,251,0.75)]">
Build engaging conferences, workshops, and webinars with interactive Q&A, live polls,
quizzes, and real-time analytics. No technical expertise required.
</p>
<div className="mb-8 grid gap-3 sm:grid-cols-2">
{heroHighlights.map((text) => (
<div key={text} className="flex items-center gap-2">
<CheckCircle className="size-5 shrink-0 text-[#34A7F2]" strokeWidth={2} />
<span className="font-inter text-sm text-[rgba(249,250,251,0.85)]">{text}</span>
</div>
))}
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<a
href={PORTAL_REGISTER_URL}
className="flex items-center justify-center gap-2 rounded-full bg-[#34A7F2] px-8 py-4 font-inter text-base font-medium text-white shadow-[0_4px_14px_rgba(52,167,242,0.4)] transition-all hover:scale-[1.02] hover:opacity-90"
>
Create your first event
<ArrowRight className="size-5" strokeWidth={2} />
</a>
</div>
<p className="mt-4 font-inter text-xs text-[rgba(249,250,251,0.5)]">
Free to start • No credit card required • 1,200+ events created
</p>
</div>
<div className="hidden lg:block">
<div className="grid grid-cols-2 gap-4">
{heroFeatureCards.map(({ id, title, description, Icon }) => (
<div
key={id}
className="rounded-2xl border border-white/15 bg-white/[0.08] p-5 backdrop-blur-sm"
>
<div className="mb-3 flex size-12 items-center justify-center rounded-lg bg-[rgba(52,167,242,0.15)]">
<Icon className="size-6 text-[#34A7F2]" strokeWidth={1.75} />
</div>
<h3 className="mb-1 font-inter text-sm font-medium text-white">{title}</h3>
<p className="font-inter text-xs leading-relaxed text-[rgba(249,250,251,0.6)]">
{description}
</p>
</div>
))}
</div>
</div>
</div>
</div>
<div className="relative border-t border-white/[0.08] bg-[rgba(5,10,25,0.45)]">
<div className={`mx-auto flex max-w-[1728px] items-center ${shellX}`} style={{ minHeight: 140 }}>
<div className="grid w-full grid-cols-2 gap-px overflow-hidden rounded-2xl bg-white/[0.08] md:grid-cols-4">
{heroStats.map(({ value, label, Icon, bg, color }) => (
<div
key={label}
className="flex items-center gap-3 bg-[rgba(5,10,25,0.45)] px-4 py-4 sm:px-6"
>
<div
className="flex size-9 shrink-0 items-center justify-center rounded-full"
style={{ background: bg }}
>
<Icon className="size-4" style={{ color }} strokeWidth={2} />
</div>
<div>
<div className="font-inter text-xl font-medium leading-tight text-white">{value}</div>
<div className="font-inter text-xs tracking-wide text-[rgba(249,250,251,0.55)]">
{label}
</div>
</div>
</div>
))}
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,28 @@
/** Horizontal padding aligned with marketing homepage sections. */
export const shellX = "px-4 sm:px-6 lg:px-12 xl:px-[min(147px,8vw)]";
/** Sticky marketing headers: mobile 60px, desktop 84px (lg breakpoint). */
export const MARKETING_HEADER_SCROLL_MARGIN = "scroll-mt-[60px] lg:scroll-mt-[84px]";
export function getMarketingHeaderOffset(): number {
return window.matchMedia("(min-width: 1024px)").matches ? 84 : 60;
}
export function scrollToElement(el: HTMLElement | null) {
if (!el) return;
const offset = getMarketingHeaderOffset();
const top = el.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
}
export function scrollToId(id: string) {
scrollToElement(document.getElementById(id));
}
export function scrollTop() {
window.scrollTo({ top: 0, behavior: "smooth" });
}
export function scrollToJoinEvent() {
scrollToElement(document.getElementById("join-event"));
}

View File

@ -0,0 +1,3 @@
/** EventBuzz organizer console (dashboard) */
export const PORTAL_REGISTER_URL = "https://console.eventbuzz.live/register";
export const PORTAL_LOGIN_URL = "https://console.eventbuzz.live/login";

View File

@ -0,0 +1,42 @@
import type { LoginPreviewResponse } from "../types/visitorContext";
export function useEventRegistrationSettings(event: LoginPreviewResponse | null | undefined) {
const s = event?.settings;
const registrationOpen = s?.registrationOpen !== false;
const deadline = s?.registrationDeadline ? new Date(s.registrationDeadline) : null;
const deadlinePassed = deadline ? deadline.getTime() <= Date.now() : false;
const newRegistrationBlocked = !registrationOpen || deadlinePassed;
const authenticationType = (s?.authenticationType || "otp_email") as
| "otp_email"
| "link_email"
| "none";
const registrationBaseFields = (s?.registrationBaseFields || "name_email") as
| "name_email"
| "email_only"
| "name_email_phone"
| "anonymous_name"
| "anonymous";
const registrationMode = s?.registrationMode || "Open";
const enableFeedback = s?.enableFeedback !== false;
const enableRatings = s?.enableRatings === true;
const primaryCtaLabel =
authenticationType === "otp_email"
? "Send OTP"
: authenticationType === "link_email"
? "Email me a link"
: "Continue";
return {
settings: s,
registrationOpen,
deadlinePassed,
newRegistrationBlocked,
authenticationType,
registrationBaseFields,
registrationMode,
enableFeedback,
enableRatings,
primaryCtaLabel,
};
}

View File

@ -0,0 +1,36 @@
import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { normalizeJoinCodeInput, resolveJoinCodeAndNavigate } from "../lib/joinCodeNavigation";
export function useJoinEventCode() {
const navigate = useNavigate();
const [code, setCode] = useState("");
const [submitting, setSubmitting] = useState(false);
const [joinError, setJoinError] = useState<string | null>(null);
async function handleJoin(e?: FormEvent) {
e?.preventDefault();
setJoinError(null);
const trimmed = normalizeJoinCodeInput(code);
if (!trimmed) {
setJoinError("Please enter a code.");
return;
}
setSubmitting(true);
try {
await resolveJoinCodeAndNavigate(trimmed, navigate);
} catch (err) {
setJoinError(err instanceof Error ? err.message : "Invalid code.");
} finally {
setSubmitting(false);
}
}
return {
code,
setCode,
submitting,
joinError,
handleJoin,
};
}

View File

@ -0,0 +1,131 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { EB_ATTENDEE_SESSION_CHANGED, getAttendeeToken } from "../lib/attendeeAuth";
import { getVisitorSessionBookmarks, putVisitorSessionBookmark } from "../lib/api";
import {
getBookmarkedSessionIds,
replaceBookmarksForEvent,
SESSION_BOOKMARKS_STORAGE_KEY,
setSessionBookmarked,
} from "../lib/sessionBookmarks";
/**
* Session bookmarks: localStorage when signed out; MongoDB (via visitor API) when signed in.
* After login, local-only bookmarks are merged to the server once.
*/
export function useSessionBookmarks(eventId: string) {
const [bookmarkedIds, setBookmarkedIds] = useState<Set<string>>(() =>
eventId ? getBookmarkedSessionIds(eventId) : new Set(),
);
const [authEpoch, setAuthEpoch] = useState(0);
const bookmarkedIdsRef = useRef(bookmarkedIds);
bookmarkedIdsRef.current = bookmarkedIds;
useEffect(() => {
const bump = () => setAuthEpoch((n) => n + 1);
window.addEventListener(EB_ATTENDEE_SESSION_CHANGED, bump);
return () => window.removeEventListener(EB_ATTENDEE_SESSION_CHANGED, bump);
}, []);
useEffect(() => {
if (!eventId) {
setBookmarkedIds(new Set());
return;
}
setBookmarkedIds(getBookmarkedSessionIds(eventId));
}, [eventId]);
useEffect(() => {
if (!eventId || !getAttendeeToken()) return;
let cancelled = false;
(async () => {
try {
let serverIds = await getVisitorSessionBookmarks(eventId);
if (cancelled) return;
const localSet = getBookmarkedSessionIds(eventId);
for (const sid of localSet) {
if (!serverIds.includes(sid)) {
try {
serverIds = await putVisitorSessionBookmark(eventId, sid, true);
} catch {
/* ignore merge failure for one id */
}
if (cancelled) return;
}
}
if (cancelled) return;
setBookmarkedIds(new Set(serverIds));
replaceBookmarksForEvent(eventId, serverIds);
} catch {
/* keep local cache */
}
})();
return () => {
cancelled = true;
};
}, [eventId, authEpoch]);
useEffect(() => {
if (!eventId) return;
const onStorage = (e: StorageEvent) => {
if (e.key === SESSION_BOOKMARKS_STORAGE_KEY || e.key === null) {
if (!getAttendeeToken()) {
setBookmarkedIds(getBookmarkedSessionIds(eventId));
}
}
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [eventId]);
const toggle = useCallback(
async (sessionId: string) => {
if (!eventId || !sessionId) return;
// Read intent once. React Strict Mode may run setState updaters twice; deriving "wasOn"
// inside the updater makes the second run flip the toggle back (add after remove).
const wasOn = bookmarkedIdsRef.current.has(sessionId);
const nextOn = !wasOn;
setBookmarkedIds((prev) => {
const next = new Set(prev);
if (nextOn) next.add(sessionId);
else next.delete(sessionId);
return next;
});
bookmarkedIdsRef.current = (() => {
const next = new Set(bookmarkedIdsRef.current);
if (nextOn) next.add(sessionId);
else next.delete(sessionId);
return next;
})();
setSessionBookmarked(eventId, sessionId, nextOn);
if (!getAttendeeToken()) return;
try {
const ids = await putVisitorSessionBookmark(eventId, sessionId, nextOn);
setBookmarkedIds(new Set(ids));
replaceBookmarksForEvent(eventId, ids);
bookmarkedIdsRef.current = new Set(ids);
} catch {
setBookmarkedIds((prev) => {
const revert = new Set(prev);
if (wasOn) revert.add(sessionId);
else revert.delete(sessionId);
return revert;
});
bookmarkedIdsRef.current = (() => {
const next = new Set(bookmarkedIdsRef.current);
if (wasOn) next.add(sessionId);
else next.delete(sessionId);
return next;
})();
setSessionBookmarked(eventId, sessionId, wasOn);
}
},
[eventId],
);
return { bookmarkedIds, toggle };
}

View File

@ -0,0 +1,133 @@
import { useEffect, useState } from "react";
import {
subscribeSessionQuestions,
type RtdbQuestionItem,
} from "../lib/sessionQuestionsRtdb";
import { getVisitorSessionQuestions, type VisitorSessionQuestion } from "../lib/api";
import { isFirebaseClientConfigured } from "../lib/firebaseClient";
const API_POLL_MS = 4000;
function isFirebasePermissionError(message: string): boolean {
return /permission_denied|PERMISSION_DENIED|permission denied/i.test(message);
}
function apiQuestionsToRtdbItems(rows: VisitorSessionQuestion[]): RtdbQuestionItem[] {
return rows
.map((q) => ({
_id: String(q._id),
author: String(q.author ?? "Anonymous"),
text: String(q.text ?? ""),
up: typeof q.up === "number" ? q.up : Number(q.up) || 0,
down: typeof q.down === "number" ? q.down : Number(q.down) || 0,
createdAt: q.createdAt != null ? String(q.createdAt) : undefined,
status: "pending",
audienceListed: true,
}))
.sort((a, b) => {
if (b.up !== a.up) return b.up - a.up;
const ta = a.createdAt ? Date.parse(a.createdAt) : 0;
const tb = b.createdAt ? Date.parse(b.createdAt) : 0;
return tb - ta;
});
}
/**
* Live session questions for attendees.
* - Tries Firebase RTDB when configured.
* - On `permission_denied` (rules require auth), falls back to GET /api/visitor/sessions/:id/questions with polling.
*/
export function useSessionQuestionsRtdb(
sessionId: string | null | undefined,
eventId?: string | null,
) {
const [items, setItems] = useState<RtdbQuestionItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const sid = sessionId?.trim() ?? "";
const eid = eventId?.trim() ?? "";
useEffect(() => {
if (!sid || !eid) {
setItems([]);
setLoading(false);
setError(
sid && !eid
? "event_id is required to load questions."
: null,
);
return;
}
let cancelled = false;
let pollTimer: ReturnType<typeof setInterval> | null = null;
let unsubRtdb: (() => void) | null = null;
let usingApi = false;
const loadViaApi = async () => {
try {
const rows = await getVisitorSessionQuestions(sid, eid);
if (cancelled) return;
setItems(apiQuestionsToRtdbItems(rows));
setLoading(false);
setError(null);
} catch (e) {
if (cancelled) return;
setError(e instanceof Error ? e.message : "Could not load questions.");
setItems([]);
setLoading(false);
}
};
const startApiPolling = () => {
if (usingApi) return;
usingApi = true;
if (unsubRtdb) {
unsubRtdb();
unsubRtdb = null;
}
void loadViaApi();
pollTimer = setInterval(() => {
if (cancelled || document.visibilityState !== "visible") return;
void loadViaApi();
}, API_POLL_MS);
};
setLoading(true);
setError(null);
if (!isFirebaseClientConfigured()) {
startApiPolling();
} else {
unsubRtdb = subscribeSessionQuestions(
sid,
(list) => {
if (cancelled || usingApi) return;
setItems(list);
setLoading(false);
setError(null);
},
(err) => {
if (cancelled) return;
const msg = err.message || String(err);
if (isFirebasePermissionError(msg)) {
startApiPolling();
return;
}
setError(msg);
setItems([]);
setLoading(false);
},
);
}
return () => {
cancelled = true;
if (unsubRtdb) unsubRtdb();
if (pollTimer) clearInterval(pollTimer);
};
}, [sid, eid]);
return { items, loading, error };
}

View File

@ -0,0 +1,63 @@
import { useEffect, useMemo, useState } from "react";
import {
filterVisibleQuizPolls,
subscribeSessionQuizPolls,
type RtdbQuizPollListItem,
} from "../lib/sessionQuizPollsRtdb";
import { isFirebaseClientConfigured } from "../lib/firebaseClient";
/**
* Quiz/polls for a session from Firebase RTDB, filtered by display rules
* (`displayMode`, `status`, `isLive`, schedule). Re-evaluates on an interval so
* `always_sched` windows update without a full reload.
*/
export function useSessionQuizPollsRtdb(sessionId: string | null | undefined) {
const [rawItems, setRawItems] = useState<RtdbQuizPollListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [scheduleTick, setScheduleTick] = useState(0);
const sid = sessionId?.trim() ?? "";
useEffect(() => {
const id = window.setInterval(() => setScheduleTick((n) => n + 1), 15_000);
return () => window.clearInterval(id);
}, []);
const items = useMemo(
() => filterVisibleQuizPolls(rawItems, new Date()),
[rawItems, scheduleTick],
);
useEffect(() => {
if (!sid || !isFirebaseClientConfigured()) {
setRawItems([]);
setLoading(false);
setError(
sid && !isFirebaseClientConfigured()
? "Quizzes load from Firebase. Set VITE_FIREBASE_API_KEY and VITE_FIREBASE_DATABASE_URL."
: null,
);
return;
}
setLoading(true);
setError(null);
const unsub = subscribeSessionQuizPolls(
sid,
(list) => {
setRawItems(list);
setLoading(false);
setError(null);
},
(err) => {
setError(err.message);
setRawItems([]);
setLoading(false);
},
);
return () => unsub();
}, [sid]);
return { items, loading, error };
}

106
src/index.css Normal file
View File

@ -0,0 +1,106 @@
@import "tailwindcss";
@theme {
--font-sans: "Noto Sans", "Inter", system-ui, sans-serif;
--font-inter: "Inter", system-ui, sans-serif;
--color-eb-text: #24282f;
--color-eb-orange: #ef8d1d;
--color-eb-gray-5: #e5e5ea;
--color-eb-gray-6: #f2f2f7;
--color-eb-timeline: #c2deff;
--color-eb-speaker-pill: #f0fbd7;
--color-eb-moderator-pill: #fbefd7;
--color-eb-session-tag: rgba(94, 147, 239, 0.14);
}
html {
min-height: 100dvh;
background-color: #f2f2f7;
}
body {
margin: 0;
min-height: 100dvh;
font-family: var(--font-sans);
color: var(--color-eb-text);
background-color: #f2f2f7;
-webkit-font-smoothing: antialiased;
}
#root {
display: flex;
flex-direction: column;
min-height: 100dvh;
background-color: #f2f2f7;
}
.eb-gradient-banner {
background: linear-gradient(
34.8deg,
rgb(29, 102, 239) 0%,
rgb(30, 121, 240) 45%,
rgb(189, 53, 230) 100%
);
}
.eb-gradient-fab {
background: linear-gradient(
65deg,
rgb(29, 102, 239) 0%,
rgb(30, 121, 240) 40%,
rgb(189, 53, 230) 100%
);
}
/* Auth / modal shells (Figma EventBuzz_Event_Mobile_V1_Register) */
.eb-gradient-auth {
background: linear-gradient(
77.92deg,
rgb(29, 102, 239) 0%,
rgb(30, 121, 240) 45%,
rgb(189, 53, 230) 100%
);
}
/* Rewards / Leaderboard “Total Points” card */
.eb-rank-highlight {
background: radial-gradient(
120% 180% at 50% 0%,
rgb(135, 209, 239) 0%,
rgb(29, 130, 239) 45%,
rgb(63, 165, 255) 100%
);
}
/* Avoid Tailwind arbitrary `font-[family-name:…]` — can break parsing in v4 */
.font-inter {
font-family: Inter, system-ui, sans-serif;
}
/* Reward points celebration (notifyRewardFromApiData / celebratePointsEarned) */
.eb-points-toast {
position: fixed;
left: 50%;
top: 38%;
transform: translate(-50%, -20px) scale(0.75);
z-index: 10001;
pointer-events: none;
padding: 0.65rem 1.35rem;
border-radius: 999px;
font-family: Inter, system-ui, sans-serif;
font-size: 1.35rem;
font-weight: 800;
letter-spacing: -0.02em;
color: #fff;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
background: linear-gradient(120deg, #16a34a, #22c55e, #fbbf24);
box-shadow: 0 12px 40px rgba(22, 163, 74, 0.45), 0 4px 0 rgba(0, 0, 0, 0.08);
opacity: 0;
transition:
opacity 0.35s ease,
transform 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.eb-points-toast--show {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}

710
src/lib/api.ts Normal file
View File

@ -0,0 +1,710 @@
import type { AttendeeParticipant } from "./attendeeAuth";
import { getAttendeeToken } from "./attendeeAuth";
import type { LoginPreviewResponse, ResolveCodeResponse, VisitorJoinType } from "../types/visitorContext";
function getBaseUrl(): string {
const raw = import.meta.env.VITE_API_BASE_URL;
if (typeof raw !== "string" || !raw.trim()) return "";
return raw.replace(/\/$/, "");
}
async function parseJson(res: Response): Promise<unknown> {
const text = await res.text();
if (!text) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return null;
}
}
function messageFromBody(body: unknown): string {
if (body && typeof body === "object" && "error" in body) {
const err = (body as { error?: { message?: string } }).error;
if (err?.message) return err.message;
}
return "Something went wrong";
}
export async function postVisitorResolveCode(code: string): Promise<ResolveCodeResponse> {
const base = getBaseUrl();
const url = `${base}/api/visitor/resolve-code`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
});
const body = await parseJson(res);
if (!res.ok) {
throw new Error(messageFromBody(body));
}
if (!body || typeof body !== "object" || !("data" in body)) {
throw new Error("Invalid response");
}
const data = (body as { data: ResolveCodeResponse }).data;
if (!data?.type || !data.eventId) {
throw new Error("Invalid response");
}
if (data.type === "session" && !data.sessionId) {
throw new Error("Invalid response");
}
if (data.type === "track" && !data.trackId) {
throw new Error("Invalid response");
}
return data;
}
export async function getVisitorLoginPreview(eventId: string): Promise<LoginPreviewResponse> {
const base = getBaseUrl();
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/login-preview`;
const res = await fetch(url);
const body = await parseJson(res);
if (!res.ok) {
throw new Error(messageFromBody(body));
}
if (!body || typeof body !== "object" || !("data" in body)) {
throw new Error("Invalid response");
}
return (body as { data: LoginPreviewResponse }).data;
}
export type VisitorEventTrackListItem = {
_id: string;
event: string;
name: string;
hall?: string;
trackType?: string;
status?: string;
joinCode?: string;
};
/** Public list of tracks for building event agenda links. */
export async function getVisitorEventTracks(eventId: string): Promise<VisitorEventTrackListItem[]> {
const base = getBaseUrl();
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/tracks`;
const res = await fetch(url);
const body = await parseJson(res);
if (!res.ok) throw new Error(messageFromBody(body));
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
const data = (body as { data: { tracks?: VisitorEventTrackListItem[] } }).data;
return Array.isArray(data?.tracks) ? data.tracks : [];
}
export type VisitorEventQuizPollListItem = {
_id: string;
title: string;
type: string;
session: string;
};
/** Public list of active session-scoped quizzes/polls (for take links). */
export async function getVisitorEventQuizPolls(eventId: string): Promise<VisitorEventQuizPollListItem[]> {
const base = getBaseUrl();
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/quiz-polls`;
const res = await fetch(url);
const body = await parseJson(res);
if (!res.ok) throw new Error(messageFromBody(body));
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
const data = (body as { data: { quizPolls?: VisitorEventQuizPollListItem[] } }).data;
return Array.isArray(data?.quizPolls) ? data.quizPolls : [];
}
/** Requires attendee JWT; participant must belong to `eventId`. */
export async function getVisitorEventRewardBalance(eventId: string): Promise<number> {
const base = getBaseUrl();
const token = getAttendeeToken();
if (!token) throw new Error("Not signed in");
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/reward-balance`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
const body = await parseJson(res);
if (!res.ok) throw new Error(messageFromBody(body));
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
const data = (body as { data: { total?: unknown } }).data;
const n = typeof data?.total === "number" ? data.total : Number(data?.total);
return Number.isFinite(n) ? n : 0;
}
export type VisitorRewardHistoryItem = {
_id: string;
points: number;
type: string;
source?: string;
description?: string;
createdAt?: string;
};
/** Paginated ledger for the signed-in attendee and event. */
export async function getVisitorRewardHistory(
eventId: string,
opts?: { page?: number; limit?: number }
): Promise<{ items: VisitorRewardHistoryItem[]; total: number; page: number; limit: number }> {
const base = getBaseUrl();
const token = getAttendeeToken();
if (!token) throw new Error("Not signed in");
const sp = new URLSearchParams();
if (opts?.page != null) sp.set("page", String(opts.page));
if (opts?.limit != null) sp.set("limit", String(opts.limit));
const q = sp.toString();
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/reward-history${q ? `?${q}` : ""}`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
const body = await parseJson(res);
if (!res.ok) throw new Error(messageFromBody(body));
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
const data = (body as { data: { items?: VisitorRewardHistoryItem[]; total?: number; page?: number; limit?: number } }).data;
return {
items: Array.isArray(data?.items) ? data.items : [],
total: typeof data?.total === "number" ? data.total : 0,
page: typeof data?.page === "number" ? data.page : 1,
limit: typeof data?.limit === "number" ? data.limit : 20,
};
}
/** Attendee JWT required. Session ids bookmarked for this event. */
export async function getVisitorSessionBookmarks(eventId: string): Promise<string[]> {
const base = getBaseUrl();
const token = getAttendeeToken();
if (!token) throw new Error("Not signed in");
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/session-bookmarks`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
const body = await parseJson(res);
if (!res.ok) throw new Error(messageFromBody(body));
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
const data = (body as { data: { sessionIds?: string[] } }).data;
return Array.isArray(data?.sessionIds) ? data.sessionIds : [];
}
/** Attendee JWT required. Returns updated bookmark list for the event. */
export async function putVisitorSessionBookmark(
eventId: string,
sessionId: string,
bookmarked: boolean,
): Promise<string[]> {
const base = getBaseUrl();
const token = getAttendeeToken();
if (!token) throw new Error("Not signed in");
const url = `${base}/api/visitor/events/${encodeURIComponent(eventId)}/session-bookmarks`;
const res = await fetch(url, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ sessionId, bookmarked }),
});
const body = await parseJson(res);
if (!res.ok) throw new Error(messageFromBody(body));
if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
const data = (body as { data: { sessionIds?: string[] } }).data;
return Array.isArray(data?.sessionIds) ? data.sessionIds : [];
}
export type VisitorSessionPreviewSpeakerSocial = {
linkedin?: string;
twitter?: string;
website?: string;
};
export type VisitorSessionPreviewSpeaker = {
_id: string;
firstName?: string;
lastName?: string;
title?: string;
photo?: string;
speakerRole?: 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;
audienceFeedbackForm?: string | null;
audienceRatingsForm?: string | null;
};
export type VisitorPublicFeedbackFormField = {
label?: string;
type?: string;
required?: boolean;
options?: string[];
placeholder?: 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;
}
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 res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
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;
};
/** 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 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 },
): 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,
}),
});
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;
}

92
src/lib/attendeeAuth.ts Normal file
View File

@ -0,0 +1,92 @@
export const EB_ATTENDEE_JWT_KEY = "eb_attendee_jwt";
export const EB_ATTENDEE_PARTICIPANT_KEY = "eb_attendee_participant";
export const EB_GUEST_TOKEN_KEY = "eb_guest_token";
/** Fired on same-tab login/logout so hooks can sync server state. */
export const EB_ATTENDEE_SESSION_CHANGED = "eb-attendee-session-changed";
function notifyAttendeeSessionChanged() {
if (typeof window === "undefined") return;
try {
window.dispatchEvent(new Event(EB_ATTENDEE_SESSION_CHANGED));
} catch {
/* ignore */
}
}
export type AttendeeParticipant = {
_id: string;
event: string;
email?: string;
firstName?: string;
lastName?: string;
displayName?: string;
phone?: string;
guestToken?: string;
status: string;
emailVerified?: boolean;
/** Saved answers for event metadata.registrationCustomFields (codes as keys) */
customFields?: Record<string, unknown>;
};
export function setAttendeeSession(token: string, participant: AttendeeParticipant) {
try {
localStorage.setItem(EB_ATTENDEE_JWT_KEY, token);
localStorage.setItem(EB_ATTENDEE_PARTICIPANT_KEY, JSON.stringify(participant));
} catch {
/* ignore */
}
notifyAttendeeSessionChanged();
}
export function setGuestTokenForEvent(eventId: string, guestToken: string) {
try {
const key = `${EB_GUEST_TOKEN_KEY}_${eventId}`;
localStorage.setItem(key, guestToken);
} catch {
/* ignore */
}
}
export function getGuestTokenForEvent(eventId: string): string | null {
try {
return localStorage.getItem(`${EB_GUEST_TOKEN_KEY}_${eventId}`);
} catch {
return null;
}
}
export function clearGuestTokenForEvent(eventId: string): void {
try {
localStorage.removeItem(`${EB_GUEST_TOKEN_KEY}_${eventId}`);
} catch {
/* ignore */
}
}
export function getAttendeeToken(): string | null {
try {
return localStorage.getItem(EB_ATTENDEE_JWT_KEY);
} catch {
return null;
}
}
export function getAttendeeParticipant(): AttendeeParticipant | null {
try {
const raw = localStorage.getItem(EB_ATTENDEE_PARTICIPANT_KEY);
if (!raw) return null;
return JSON.parse(raw) as AttendeeParticipant;
} catch {
return null;
}
}
export function clearAttendeeSession() {
try {
localStorage.removeItem(EB_ATTENDEE_JWT_KEY);
localStorage.removeItem(EB_ATTENDEE_PARTICIPANT_KEY);
} catch {
/* ignore */
}
notifyAttendeeSessionChanged();
}

View File

@ -0,0 +1,43 @@
const STORAGE_KEY = "eb_audience_device_vote_key";
export function getOrCreateAudienceVoteKey(): string {
try {
let k = localStorage.getItem(STORAGE_KEY);
if (!k || k.length < 8) {
k =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `vk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
localStorage.setItem(STORAGE_KEY, k);
}
return k;
} catch {
return `vk_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
}
}
function votePrefKey(questionId: string): string {
return `eb_audience_qvote_${questionId}`;
}
export type StoredAudienceVote = "up" | "down" | null;
export function readStoredQuestionVote(questionId: string): StoredAudienceVote {
try {
const v = localStorage.getItem(votePrefKey(questionId));
if (v === "up" || v === "down") return v;
return null;
} catch {
return null;
}
}
export function writeStoredQuestionVote(questionId: string, vote: StoredAudienceVote): void {
try {
const key = votePrefKey(questionId);
if (vote == null) localStorage.removeItem(key);
else localStorage.setItem(key, vote);
} catch {
/* ignore */
}
}

30
src/lib/firebaseClient.ts Normal file
View File

@ -0,0 +1,30 @@
import { initializeApp, getApps, type FirebaseApp } from "firebase/app";
import { getDatabase, type Database } from "firebase/database";
const apiKey = typeof import.meta.env.VITE_FIREBASE_API_KEY === "string" ? import.meta.env.VITE_FIREBASE_API_KEY : "";
const databaseURL =
typeof import.meta.env.VITE_FIREBASE_DATABASE_URL === "string" ? import.meta.env.VITE_FIREBASE_DATABASE_URL : "";
let app: FirebaseApp | null = null;
export function isFirebaseClientConfigured(): boolean {
return Boolean(apiKey.trim() && databaseURL.trim());
}
export function getFirebaseApp(): FirebaseApp {
if (!isFirebaseClientConfigured()) {
throw new Error("VITE_FIREBASE_API_KEY and VITE_FIREBASE_DATABASE_URL must be set.");
}
if (!app) {
if (!getApps().length) {
app = initializeApp({ apiKey: apiKey.trim(), databaseURL: databaseURL.trim() });
} else {
app = getApps()[0]!;
}
}
return app;
}
export function getRtdb(): Database {
return getDatabase(getFirebaseApp());
}

View File

@ -0,0 +1,47 @@
import type { NavigateFunction } from "react-router-dom";
import { postVisitorResolveCode } from "./api";
import type { ResolveCodeResponse } from "../types/visitorContext";
export function normalizeJoinCodeInput(code: string): string {
return code.trim().toUpperCase();
}
export function buildLoginUrlFromResolvedCode(data: ResolveCodeResponse): string {
if (data.type === "event") {
return `/login?type=event&id=${encodeURIComponent(data.eventId)}`;
}
if (data.type === "track") {
const trackId = data.trackId;
if (!trackId) throw new Error("Invalid response");
return `/login?type=track&id=${encodeURIComponent(trackId)}&eventId=${encodeURIComponent(data.eventId)}`;
}
const sessionId = data.sessionId;
if (!sessionId) throw new Error("Invalid response");
return `/login?type=session&id=${encodeURIComponent(sessionId)}&eventId=${encodeURIComponent(data.eventId)}`;
}
export function navigateFromResolvedCode(
data: ResolveCodeResponse,
navigate: NavigateFunction,
options?: { replace?: boolean },
) {
const url = buildLoginUrlFromResolvedCode(data);
navigate(url, options?.replace ? { replace: true } : undefined);
}
export async function resolveJoinCodeAndNavigate(
code: string,
navigate: NavigateFunction,
options?: { replace?: boolean },
) {
const trimmed = normalizeJoinCodeInput(code);
if (!trimmed) {
throw new Error("Please enter a code.");
}
const apiBase = import.meta.env.VITE_API_BASE_URL;
if (typeof apiBase !== "string" || !apiBase.trim()) {
throw new Error("Set VITE_API_BASE_URL in .env (e.g. http://localhost:3001).");
}
const data = await postVisitorResolveCode(trimmed);
navigateFromResolvedCode(data, navigate, options);
}

View File

@ -0,0 +1,92 @@
/** Dispatched after points animation so the header can refetch balance. */
export const EB_REFRESH_REWARD_BALANCE = "eb-refresh-reward-balance";
function runConfettiBurst() {
if (typeof document === "undefined") return;
const canvas = document.createElement("canvas");
canvas.setAttribute("aria-hidden", "true");
canvas.style.cssText =
"position:fixed;inset:0;pointer-events:none;z-index:10000;width:100%;height:100%";
document.body.appendChild(canvas);
const ctx = canvas.getContext("2d");
if (!ctx) {
canvas.remove();
return;
}
const w = window.innerWidth;
const h = window.innerHeight;
canvas.width = w;
canvas.height = h;
const n = 72;
const originX = w / 2;
const originY = h * 0.58;
type P = { x: number; y: number; vx: number; vy: number; rot: number; vr: number; life: number; c: string; s: number };
const parts: P[] = [];
for (let i = 0; i < n; i++) {
const ang = (Math.PI * 2 * i) / n + Math.random() * 0.5;
const sp = 6 + Math.random() * 10;
parts.push({
x: originX,
y: originY,
vx: Math.cos(ang) * sp + (Math.random() - 0.5) * 4,
vy: Math.sin(ang) * sp - 8 - Math.random() * 6,
rot: Math.random() * Math.PI,
vr: (Math.random() - 0.5) * 0.35,
life: 55 + Math.floor(Math.random() * 35),
c: `hsl(${Math.floor(Math.random() * 360)}, 82%, 56%)`,
s: 4 + Math.random() * 5,
});
}
const step = () => {
ctx.clearRect(0, 0, w, h);
let any = false;
for (const p of parts) {
if (p.life <= 0) continue;
any = true;
p.vy += 0.32;
p.x += p.vx;
p.y += p.vy;
p.rot += p.vr;
p.life -= 1;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = p.c;
ctx.fillRect(-p.s / 2, -p.s / 2, p.s, p.s * 0.65);
ctx.restore();
}
if (any) requestAnimationFrame(step);
else canvas.remove();
};
step();
}
function showPointsToast(points: number) {
const el = document.createElement("div");
el.className = "eb-points-toast";
el.setAttribute("role", "status");
el.textContent = `+${points} points!`;
document.body.appendChild(el);
requestAnimationFrame(() => el.classList.add("eb-points-toast--show"));
window.setTimeout(() => {
el.classList.remove("eb-points-toast--show");
window.setTimeout(() => el.remove(), 420);
}, 2400);
}
/** Full-screen confetti + floating “+N points” label; notifies header to refresh balance. */
export function celebratePointsEarned(points: number) {
if (typeof window === "undefined" || points <= 0) return;
runConfettiBurst();
showPointsToast(points);
window.dispatchEvent(new CustomEvent(EB_REFRESH_REWARD_BALANCE, { detail: { points } }));
}
/** If API payload includes `reward.pointsEarned`, celebrate (quiz response is spread onto root). */
export function notifyRewardFromApiData(data: unknown) {
if (!data || typeof data !== "object") return;
const o = data as Record<string, unknown>;
const r = o.reward as { pointsEarned?: unknown } | undefined;
const n = Number(r?.pointsEarned);
if (Number.isFinite(n) && n > 0) celebratePointsEarned(n);
}

View File

@ -0,0 +1,49 @@
const STORAGE_KEY = "eventbuzz.sessionBookmarks.v1";
type BookmarkStore = Record<string, string[]>;
function readStore(): BookmarkStore {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const p = JSON.parse(raw) as unknown;
if (!p || typeof p !== "object" || Array.isArray(p)) return {};
return p as BookmarkStore;
} catch {
return {};
}
}
function writeStore(store: BookmarkStore) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
}
/** Session ids bookmarked for this event (visitor device). */
export function getBookmarkedSessionIds(eventId: string): Set<string> {
if (!eventId) return new Set();
const row = readStore()[eventId];
return new Set(Array.isArray(row) ? row.filter((id) => typeof id === "string" && id.trim()) : []);
}
export function setSessionBookmarked(eventId: string, sessionId: string, bookmarked: boolean) {
if (!eventId || !sessionId) return;
const store = readStore();
const cur = new Set(getBookmarkedSessionIds(eventId));
if (bookmarked) cur.add(sessionId);
else cur.delete(sessionId);
store[eventId] = [...cur];
writeStore(store);
}
/** Replace local cache for an event (e.g. after server sync). */
export function replaceBookmarksForEvent(eventId: string, sessionIds: string[]) {
if (!eventId) return;
const store = readStore();
const unique = [
...new Set(sessionIds.filter((id) => typeof id === "string" && id.trim())),
];
store[eventId] = unique;
writeStore(store);
}
export { STORAGE_KEY as SESSION_BOOKMARKS_STORAGE_KEY };

47
src/lib/sessionDisplay.ts Normal file
View File

@ -0,0 +1,47 @@
const SESSION_TYPE_LABELS: Record<string, string> = {
keynote: "Keynote",
workshop: "Workshop",
panel: "Panel",
breakout: "Breakout",
networking: "Networking",
};
export function sessionTypeDisplay(type: string | undefined): string {
if (!type) return "Session";
return SESSION_TYPE_LABELS[type] ?? type.charAt(0).toUpperCase() + type.slice(1);
}
export function formatTimeShort(iso: string | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
}
export function formatDateLong(iso: string | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" });
}
export function formatDurationMinutes(startIso: string | undefined, endIso: string | undefined): string {
if (!startIso || !endIso) return "";
const a = new Date(startIso).getTime();
const b = new Date(endIso).getTime();
if (Number.isNaN(a) || Number.isNaN(b) || b <= a) return "";
const m = Math.round((b - a) / 60000);
return `${m} MINS`;
}
export function formatEventScheduleRange(startIso: string | undefined, endIso: string | undefined): string {
if (!startIso || !endIso) return "";
const s = new Date(startIso);
const e = new Date(endIso);
if (Number.isNaN(s.getTime()) || Number.isNaN(e.getTime())) return "";
const opts: Intl.DateTimeFormatOptions = { month: "long", day: "numeric", year: "numeric" };
if (s.toDateString() === e.toDateString()) {
return s.toLocaleDateString(undefined, opts);
}
return `${s.toLocaleDateString(undefined, opts)} – ${e.toLocaleDateString(undefined, opts)}`;
}

View File

@ -0,0 +1,78 @@
import { ref, onValue, type Unsubscribe } from "firebase/database";
import { getRtdb } from "./firebaseClient";
export type RtdbQuestionItem = {
_id: string;
author: string;
text: string;
up: number;
down: number;
createdAt?: string;
status?: string;
shortlisted?: boolean;
audienceListed?: boolean;
};
function num(v: unknown): number {
if (typeof v === "number" && !Number.isNaN(v)) return v;
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
/** Normalize RTDB `sessions/{sessionId}/questions` snapshot (object keyed by id). */
export function rtdbSnapshotToQuestions(val: unknown): RtdbQuestionItem[] {
if (!val || typeof val !== "object" || Array.isArray(val)) return [];
const data = val as Record<string, Record<string, unknown>>;
return Object.entries(data)
.map(([key, raw]) => {
if (!raw || typeof raw !== "object") return null;
const v = raw as Record<string, unknown>;
const fromField =
v._id != null && String(v._id).trim() !== "" ? String(v._id).trim() : "";
const id = fromField || key;
const status = v.status != null ? String(v.status) : "pending";
const audienceListed = v.audienceListed !== false;
if (status === "archived" || !audienceListed) return null;
return {
_id: id,
author: String(v.authorName ?? "Anonymous"),
text: String(v.question ?? ""),
up: num(v.upvotes),
down: num(v.downvotes),
createdAt: v.createdAt != null ? String(v.createdAt) : undefined,
status,
shortlisted: Boolean(v.shortlisted),
audienceListed,
} satisfies RtdbQuestionItem;
})
.filter((x): x is RtdbQuestionItem => x != null)
.sort((a, b) => {
if (b.up !== a.up) return b.up - a.up;
const ta = a.createdAt ? Date.parse(a.createdAt) : 0;
const tb = b.createdAt ? Date.parse(b.createdAt) : 0;
return tb - ta;
});
}
/** Subscribe to `sessions/{sessionId}/questions`. */
export function subscribeSessionQuestions(
sessionId: string,
onData: (items: RtdbQuestionItem[]) => void,
onError?: (e: Error) => void,
): Unsubscribe {
const db = getRtdb();
const r = ref(db, `sessions/${sessionId}/questions`);
return onValue(
r,
(snap) => {
try {
onData(rtdbSnapshotToQuestions(snap.val()));
} catch (e) {
onError?.(e instanceof Error ? e : new Error(String(e)));
}
},
(err) => {
onError?.(err instanceof Error ? err : new Error(String(err)));
},
);
}

View File

@ -0,0 +1,252 @@
import { ref, onValue, type Unsubscribe } from "firebase/database";
import { getRtdb } from "./firebaseClient";
export type RtdbQuizPollSettings = {
isLive?: boolean;
startTime?: string;
endTime?: string;
/** always_live | always_sched | manual | quiz_master */
displayMode?: string;
/** draft | active */
status?: string;
timeRestricted?: boolean;
/** Seconds for timed mode */
timeLimit?: number;
showResults?: boolean;
};
/** Normalize status from RTDB; legacy nodes without status are treated as active. */
function settingsStatusActive(settings: RtdbQuizPollSettings): boolean {
const raw = settings.status?.trim().toLowerCase();
if (raw == null || raw === "") return true;
return raw === "active";
}
export function settingsDisplayMode(settings: RtdbQuizPollSettings): string {
const m = settings.displayMode?.trim().toLowerCase();
if (!m) return "manual";
return m;
}
/**
* First visible quiz/poll that is audience-controlled via RTDB (`quiz_master` / `manual` while live).
* `visibleQuizPolls` should be the output of {@link filterVisibleQuizPolls}.
*/
export function firstQuizMasterOrManualVisibleId(
visibleQuizPolls: RtdbQuizPollListItem[],
): string | null {
for (const item of visibleQuizPolls) {
const mode = settingsDisplayMode(item.settings);
if (mode === "quiz_master" || mode === "manual") {
return item._id;
}
}
return null;
}
/**
* Session list & take-page visibility (aligned with dashboard display modes).
*
* - always_sched: status active, now in [startTime, endTime] (inclusive)
* - always_live: status active
* - quiz_master: status active and isLive
* - manual: status active and isLive
*/
export function isQuizPollVisibleForAudience(settings: RtdbQuizPollSettings, now: Date = new Date()): boolean {
if (!settingsStatusActive(settings)) return false;
const mode = settingsDisplayMode(settings);
const isLive = Boolean(settings.isLive);
if (mode === "always_live") return true;
if (mode === "always_sched") {
const start = settings.startTime != null ? Date.parse(settings.startTime) : NaN;
const end = settings.endTime != null ? Date.parse(settings.endTime) : NaN;
const t = now.getTime();
if (!Number.isFinite(start) || !Number.isFinite(end)) return false;
return t >= start && t <= end;
}
if (mode === "quiz_master" || mode === "manual") {
return isLive;
}
return false;
}
export type RtdbQuizPollOption = {
_id: string;
text: string;
isCorrect: boolean;
};
export type RtdbQuizPollQuestion = {
_id: string;
question: string;
type: string;
options: RtdbQuizPollOption[];
points?: number;
};
export type RtdbQuizPollListItem = {
_id: string;
title: string;
type: string;
isLive: boolean;
settings: RtdbQuizPollSettings;
};
export type RtdbQuizPollDetail = RtdbQuizPollListItem & {
questions: RtdbQuizPollQuestion[];
};
function readSettings(raw: Record<string, unknown>): RtdbQuizPollSettings {
const s = raw.settings;
if (!s || typeof s !== "object" || Array.isArray(s)) return {};
const o = s as Record<string, unknown>;
const isLive =
typeof o.isLive === "boolean"
? o.isLive
: typeof o.isActive === "boolean"
? o.isActive
: false;
const startTime = o.startTime != null ? String(o.startTime) : undefined;
const endTime = o.endTime != null ? String(o.endTime) : undefined;
const displayMode = o.displayMode != null ? String(o.displayMode).trim() : undefined;
const status = o.status != null ? String(o.status).trim() : undefined;
const timeRestricted = o.timeRestricted === true;
const timeLimitRaw = o.timeLimit;
const timeLimit =
typeof timeLimitRaw === "number" && Number.isFinite(timeLimitRaw) ? Math.max(0, Math.floor(timeLimitRaw)) : 0;
const showResults = o.showResults !== false;
return { isLive, startTime, endTime, displayMode, status, timeRestricted, timeLimit, showResults };
}
function mapOptions(raw: unknown): RtdbQuizPollOption[] {
if (!Array.isArray(raw)) return [];
return raw.map((opt, j) => {
if (!opt || typeof opt !== "object") {
return { _id: `opt-${j}`, text: "", isCorrect: false };
}
const o = opt as Record<string, unknown>;
const id = o._id != null && String(o._id).trim() !== "" ? String(o._id).trim() : `opt-${j}`;
return {
_id: id,
text: o.text != null ? String(o.text) : "",
isCorrect: Boolean(o.isCorrect),
};
});
}
function mapQuestions(raw: unknown): RtdbQuizPollQuestion[] {
if (!Array.isArray(raw)) return [];
return raw.map((q, i) => {
if (!q || typeof q !== "object") {
return { _id: `q-${i}`, question: "", type: "single", options: [] };
}
const o = q as Record<string, unknown>;
const id = o._id != null && String(o._id).trim() !== "" ? String(o._id).trim() : `q-${i}`;
const qt = o.type != null ? String(o.type) : "single";
return {
_id: id,
question: o.question != null ? String(o.question) : "",
type: qt,
options: mapOptions(o.options),
points: typeof o.points === "number" ? o.points : undefined,
};
});
}
export function rtdbSnapshotToQuizPolls(val: unknown): RtdbQuizPollListItem[] {
if (!val || typeof val !== "object" || Array.isArray(val)) return [];
const data = val as Record<string, Record<string, unknown>>;
return Object.entries(data)
.map(([key, raw]) => {
if (!raw || typeof raw !== "object") return null;
const v = raw as Record<string, unknown>;
const settings = readSettings(v);
const fromField =
v._id != null && String(v._id).trim() !== "" ? String(v._id).trim() : "";
const id = fromField || key;
return {
_id: id,
title: v.title != null ? String(v.title) : "",
type: v.type != null ? String(v.type) : "poll",
isLive: Boolean(settings.isLive),
settings,
} satisfies RtdbQuizPollListItem;
})
.filter((x): x is RtdbQuizPollListItem => x != null)
.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: "base" }));
}
export function filterVisibleQuizPolls(
items: RtdbQuizPollListItem[],
now: Date = new Date(),
): RtdbQuizPollListItem[] {
return items.filter((x) => isQuizPollVisibleForAudience(x.settings, now));
}
export function rtdbValToQuizPollDetail(val: unknown, fallbackId: string): RtdbQuizPollDetail | null {
if (!val || typeof val !== "object" || Array.isArray(val)) return null;
const v = val as Record<string, unknown>;
const settings = readSettings(v);
const fromField =
v._id != null && String(v._id).trim() !== "" ? String(v._id).trim() : "";
const id = fromField || fallbackId;
return {
_id: id,
title: v.title != null ? String(v.title) : "",
type: v.type != null ? String(v.type) : "poll",
isLive: Boolean(settings.isLive),
settings,
questions: mapQuestions(v.questions),
};
}
export function subscribeSessionQuizPolls(
sessionId: string,
onData: (items: RtdbQuizPollListItem[]) => void,
onError?: (e: Error) => void,
): Unsubscribe {
const db = getRtdb();
const r = ref(db, `sessions/${sessionId}/quizPolls`);
return onValue(
r,
(snap) => {
try {
onData(rtdbSnapshotToQuizPolls(snap.val()));
} catch (e) {
onError?.(e instanceof Error ? e : new Error(String(e)));
}
},
(err) => {
onError?.(err instanceof Error ? err : new Error(String(err)));
},
);
}
export function subscribeSessionQuizPollDetail(
sessionId: string,
quizPollId: string,
onData: (detail: RtdbQuizPollDetail | null) => void,
onError?: (e: Error) => void,
): Unsubscribe {
const db = getRtdb();
const r = ref(db, `sessions/${sessionId}/quizPolls/${quizPollId}`);
return onValue(
r,
(snap) => {
try {
const d = rtdbValToQuizPollDetail(snap.val(), quizPollId);
onData(d);
} catch (e) {
onError?.(e instanceof Error ? e : new Error(String(e)));
}
},
(err) => {
onError?.(err instanceof Error ? err : new Error(String(err)));
},
);
}

21
src/main.tsx Normal file
View File

@ -0,0 +1,21 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { RootErrorBoundary } from "./RootErrorBoundary";
import "./index.css";
const el = document.getElementById("root");
if (!el) {
throw new Error('visitorweb: missing #root in index.html');
}
createRoot(el).render(
<StrictMode>
<RootErrorBoundary>
<BrowserRouter>
<App />
</BrowserRouter>
</RootErrorBoundary>
</StrictMode>
);

View File

@ -0,0 +1,67 @@
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { AuthShell } from "../components/auth/AuthShell";
import { setAttendeeSession, setGuestTokenForEvent } from "../lib/attendeeAuth";
import { postVisitorExchangeLink } from "../lib/api";
import { notifyRewardFromApiData } from "../lib/rewardCelebration";
import { buildPostAuthUrl, readStoredVisitorContext } from "../types/visitorContext";
export function AuthCallbackPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
const token = searchParams.get("token");
if (!token) {
setErr("Missing sign-in link. Open the link from your email again.");
return;
}
let cancelled = false;
(async () => {
try {
const data = await postVisitorExchangeLink(token);
if (cancelled) return;
setAttendeeSession(data.token, data.participant);
if (data.guestToken && data.participant.event) {
setGuestTokenForEvent(data.participant.event, data.guestToken);
}
notifyRewardFromApiData(data);
const ctx = readStoredVisitorContext();
if (data.pendingApproval) {
navigate("/pending-approval", { replace: true });
return;
}
if (data.needsProfile) {
navigate("/complete-profile", { replace: true });
return;
}
navigate(buildPostAuthUrl(ctx), { replace: true });
} catch (e) {
if (!cancelled) setErr(e instanceof Error ? e.message : "Sign-in failed.");
}
})();
return () => {
cancelled = true;
};
}, [searchParams, navigate]);
return (
<AuthShell>
<div className="flex w-full max-w-[354px] flex-col items-center gap-6 px-4 text-center">
<p className="font-inter text-base text-white/95">{err ? err : "Signing you in…"}</p>
{err ? (
<button
type="button"
onClick={() => navigate("/login", { replace: true })}
className="rounded-full bg-[#ef8d1d] px-6 py-2 text-sm font-semibold text-white"
>
Back to login
</button>
) : null}
</div>
</AuthShell>
);
}

View File

@ -0,0 +1,62 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { AuthShell } from "../components/auth/AuthShell";
import { normalizeJoinCodeInput, resolveJoinCodeAndNavigate } from "../lib/joinCodeNavigation";
/**
* Handles https://eventbuzz.live/code/{code} — same resolve flow as the homepage join strip.
*/
export function CodeEntryPage() {
const { code: codeParam } = useParams<{ code: string }>();
const navigate = useNavigate();
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
const raw = normalizeJoinCodeInput(codeParam ?? "");
if (!raw) {
setErr("Missing code in URL.");
return;
}
let cancelled = false;
void (async () => {
try {
await resolveJoinCodeAndNavigate(raw, navigate, { replace: true });
} catch (e) {
if (!cancelled) {
setErr(e instanceof Error ? e.message : "Invalid code.");
}
}
})();
return () => {
cancelled = true;
};
}, [codeParam, navigate]);
if (err) {
return (
<AuthShell>
<div className="flex w-full max-w-[354px] flex-col items-center gap-6 text-center">
<p className="font-inter text-base text-white/95" role="alert">
{err}
</p>
<Link
to="/"
className="rounded-full border border-white/60 px-6 py-2.5 font-inter text-sm font-semibold text-white hover:bg-white/10"
>
Back to home
</Link>
</div>
</AuthShell>
);
}
return (
<AuthShell>
<div className="flex w-full max-w-[354px] flex-col items-center gap-4 text-center">
<p className="font-inter text-base text-white/95">Opening your event…</p>
</div>
</AuthShell>
);
}

View File

@ -0,0 +1,471 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { AuthShell } from "../components/auth/AuthShell";
import { getAttendeeParticipant, getAttendeeToken, setAttendeeSession } from "../lib/attendeeAuth";
import { patchVisitorProfile } from "../lib/api";
import { useEventRegistrationSettings } from "../hooks/useEventRegistrationSettings";
import {
buildPostAuthUrl,
readStoredVisitorContext,
type RegistrationCustomFieldDef,
} from "../types/visitorContext";
function parseCustomDefs(meta: unknown): RegistrationCustomFieldDef[] {
if (!meta || typeof meta !== "object") return [];
const raw = (meta as { registrationCustomFields?: unknown }).registrationCustomFields;
return Array.isArray(raw) ? (raw as RegistrationCustomFieldDef[]) : [];
}
function initCustomValues(
defs: RegistrationCustomFieldDef[],
existing?: Record<string, unknown>,
): Record<string, string | string[]> {
const out: Record<string, string | string[]> = {};
for (const d of defs) {
const code = String(d.code || "").trim();
if (!code) continue;
const ft = String(d.fieldType || "text");
const ex = existing?.[code];
if (ft === "checkboxes") {
if (Array.isArray(ex)) out[code] = ex.map(String);
else if (typeof ex === "string" && ex)
out[code] = ex
.split(",")
.map((s) => s.trim())
.filter(Boolean);
else out[code] = [];
} else if (ft === "yes_no") {
if (ex === true || ex === "yes" || ex === "Yes") out[code] = "yes";
else if (ex === false || ex === "no" || ex === "No") out[code] = "no";
else out[code] = "";
} else {
if (ex === undefined || ex === null) out[code] = "";
else if (typeof ex === "number" || typeof ex === "boolean") out[code] = String(ex);
else out[code] = String(ex);
}
}
return out;
}
function validateCustomFields(
defs: RegistrationCustomFieldDef[],
values: Record<string, string | string[]>,
): string | null {
for (const d of defs) {
const code = String(d.code || "").trim();
if (!code) continue;
const label = String(d.label || code).trim();
const ft = String(d.fieldType || "text");
const v = values[code];
if (ft === "checkboxes") {
if (!Array.isArray(v) || v.length === 0) return `Select at least one option for "${label}".`;
continue;
}
if (ft === "stars") {
const n = typeof v === "string" ? parseInt(v, 10) : NaN;
if (Number.isNaN(n) || n < 1 || n > 5) return `Please select a rating for "${label}".`;
continue;
}
if (ft === "radio" || ft === "dropdown" || ft === "yes_no") {
if (typeof v !== "string" || !v.trim()) return `Please answer "${label}".`;
continue;
}
if (v === undefined || v === null) return `Please answer "${label}".`;
if (typeof v === "string" && !v.trim()) return `Please answer "${label}".`;
}
return null;
}
function buildCustomFieldsPayload(
defs: RegistrationCustomFieldDef[],
values: Record<string, string | string[]>,
): Record<string, string | number | boolean | string[]> {
const out: Record<string, string | number | boolean | string[]> = {};
for (const d of defs) {
const code = String(d.code || "").trim();
if (!code) continue;
const v = values[code];
const ft = String(d.fieldType || "text");
if (ft === "checkboxes" && Array.isArray(v)) {
out[code] = v;
} else if (ft === "stars") {
const n = typeof v === "string" ? parseInt(v, 10) : NaN;
if (!Number.isNaN(n)) out[code] = n;
} else if (ft === "yes_no" && typeof v === "string" && v) {
out[code] = v === "yes";
} else if (typeof v === "string" && v.trim()) {
out[code] = v.trim();
}
}
return out;
}
function CustomFieldInput({
def,
value,
onChange,
}: {
def: RegistrationCustomFieldDef;
value: string | string[];
onChange: (next: string | string[]) => void;
}) {
const code = String(def.code || "").trim();
const label = String(def.label || code || "Field");
const ft = String(def.fieldType || "text");
const opts = (def.options || []).filter((o) => String(o.value ?? "").trim() || String(o.label ?? "").trim());
const baseClass =
"w-full rounded-lg border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] px-3 py-2 text-sm text-[#24282f]";
if (ft === "textarea") {
return (
<label className="flex flex-col gap-1">
<span className="text-sm font-semibold text-[#24282f]">{label}</span>
<textarea
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
rows={4}
className={baseClass}
required
/>
</label>
);
}
if (ft === "dropdown") {
const s = typeof value === "string" ? value : "";
return (
<label className="flex flex-col gap-1">
<span className="text-sm font-semibold text-[#24282f]">{label}</span>
<select
value={s}
onChange={(e) => onChange(e.target.value)}
className={baseClass}
required
>
<option value="">Select…</option>
{opts.map((o, i) => (
<option key={i} value={String(o.value ?? "")}>
{String(o.label ?? o.value ?? "")}
</option>
))}
</select>
</label>
);
}
if (ft === "radio") {
const s = typeof value === "string" ? value : "";
return (
<fieldset className="flex flex-col gap-2">
<legend className="text-sm font-semibold text-[#24282f]">{label}</legend>
<div className="flex flex-col gap-2">
{opts.map((o, i) => {
const v = String(o.value ?? "");
const id = `${code}-${i}`;
return (
<label key={id} htmlFor={id} className="flex cursor-pointer items-center gap-2 text-sm">
<input
id={id}
type="radio"
name={code}
value={v}
checked={s === v}
onChange={() => onChange(v)}
/>
<span>{String(o.label ?? v)}</span>
</label>
);
})}
</div>
</fieldset>
);
}
if (ft === "checkboxes") {
const arr = Array.isArray(value) ? value : [];
const toggle = (optVal: string, checked: boolean) => {
const next = checked ? [...arr, optVal] : arr.filter((x) => x !== optVal);
onChange(next);
};
return (
<fieldset className="flex flex-col gap-2">
<legend className="text-sm font-semibold text-[#24282f]">{label}</legend>
<div className="flex flex-col gap-2">
{opts.map((o, i) => {
const v = String(o.value ?? "");
const id = `${code}-cb-${i}`;
return (
<label key={id} htmlFor={id} className="flex cursor-pointer items-center gap-2 text-sm">
<input
id={id}
type="checkbox"
checked={arr.includes(v)}
onChange={(e) => toggle(v, e.target.checked)}
/>
<span>{String(o.label ?? v)}</span>
</label>
);
})}
</div>
</fieldset>
);
}
if (ft === "yes_no") {
const s = typeof value === "string" ? value : "";
return (
<label className="flex flex-col gap-1">
<span className="text-sm font-semibold text-[#24282f]">{label}</span>
<select
value={s}
onChange={(e) => onChange(e.target.value)}
className={baseClass}
required
>
<option value="">Select…</option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
);
}
if (ft === "stars") {
const s = typeof value === "string" ? value : "";
return (
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-[#24282f]">{label}</span>
<div className="flex flex-wrap gap-2">
{["1", "2", "3", "4", "5"].map((n) => (
<button
key={n}
type="button"
onClick={() => onChange(n)}
className={`rounded-lg border px-3 py-2 text-sm font-semibold ${
s === n
? "border-[#ef8d1d] bg-[#ef8d1d] text-white"
: "border-[rgba(116,112,112,0.3)] bg-white text-[#24282f]"
}`}
>
{n}
</button>
))}
</div>
</div>
);
}
if (ft === "image_upload" || ft === "file_upload") {
const s = typeof value === "string" ? value : "";
return (
<label className="flex flex-col gap-1">
<span className="text-sm font-semibold text-[#24282f]">{label}</span>
<p className="text-xs text-[rgba(36,40,47,0.65)]">
Paste a link to your file or image (upload is not available in the app).
</p>
<input
type="url"
value={s}
onChange={(e) => onChange(e.target.value)}
placeholder="https://…"
className={baseClass}
required
/>
</label>
);
}
const s = typeof value === "string" ? value : "";
return (
<label className="flex flex-col gap-1">
<span className="text-sm font-semibold text-[#24282f]">{label}</span>
<input
type="text"
value={s}
onChange={(e) => onChange(e.target.value)}
className={baseClass}
required
/>
</label>
);
}
export function CompleteProfilePage() {
const navigate = useNavigate();
const ctx = readStoredVisitorContext();
const token = getAttendeeToken();
const participant = getAttendeeParticipant();
const reg = useEventRegistrationSettings(ctx?.event ?? null);
const [firstName, setFirstName] = useState(participant?.firstName ?? "");
const [lastName, setLastName] = useState(participant?.lastName ?? "");
const [phone, setPhone] = useState(participant?.phone ?? "");
const [displayName, setDisplayName] = useState(participant?.displayName ?? "");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const customDefs = useMemo(
() => parseCustomDefs(ctx?.event?.metadata).filter((d) => String(d.code || "").trim()),
[ctx?.event?.metadata],
);
const [customValues, setCustomValues] = useState<Record<string, string | string[]>>(() =>
initCustomValues(customDefs, participant?.customFields),
);
if (!token || !participant || !ctx) {
return (
<AuthShell>
<div className="px-4 text-center">
<p className="text-sm text-red-600">Session expired. Please sign in again.</p>
<button
type="button"
onClick={() => navigate("/login")}
className="mt-4 text-[#1e72ef] underline"
>
Go to login
</button>
</div>
</AuthShell>
);
}
function setCustomField(code: string, next: string | string[]) {
setCustomValues((prev) => ({ ...prev, [code]: next }));
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
const customErr = customDefs.length ? validateCustomFields(customDefs, customValues) : null;
if (customErr) {
setError(customErr);
return;
}
setLoading(true);
try {
const customPayload =
customDefs.length > 0 ? buildCustomFieldsPayload(customDefs, customValues) : undefined;
const data = await patchVisitorProfile(token, {
firstName: firstName.trim() || undefined,
lastName: lastName.trim() || undefined,
phone: phone.trim() || undefined,
displayName: displayName.trim() || undefined,
...(customPayload && Object.keys(customPayload).length > 0 ? { customFields: customPayload } : {}),
});
setAttendeeSession(data.token, data.participant);
if (data.pendingApproval) {
navigate("/pending-approval", { replace: true });
return;
}
if (data.needsProfile) {
setError("Please fill in all required fields.");
setLoading(false);
return;
}
navigate(buildPostAuthUrl(ctx), { replace: true });
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save profile.");
} finally {
setLoading(false);
}
}
const base = reg.registrationBaseFields;
return (
<AuthShell>
<div className="flex w-full max-w-[354px] flex-col items-center">
<div className="w-full rounded-xl bg-white px-6 py-8 shadow-md">
<h1 className="font-inter text-lg font-semibold text-[#24282f]">Complete your profile</h1>
<p className="mt-2 text-sm text-[rgba(36,40,47,0.75)]">
A few more details are required before you continue.
</p>
<form onSubmit={onSubmit} className="mt-6 flex flex-col gap-4">
{(base === "name_email" || base === "name_email_phone") && (
<>
<label className="flex flex-col gap-1">
<span className="text-sm font-semibold text-[#24282f]">First name</span>
<input
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="rounded-lg border border-[rgba(116,112,112,0.3)] px-3 py-2 text-sm"
required
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-sm font-semibold text-[#24282f]">Last name</span>
<input
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="rounded-lg border border-[rgba(116,112,112,0.3)] px-3 py-2 text-sm"
required
/>
</label>
</>
)}
{base === "name_email_phone" && (
<label className="flex flex-col gap-1">
<span className="text-sm font-semibold text-[#24282f]">Phone</span>
<input
value={phone}
onChange={(e) => setPhone(e.target.value)}
type="tel"
className="rounded-lg border border-[rgba(116,112,112,0.3)] px-3 py-2 text-sm"
required
/>
</label>
)}
{base === "anonymous_name" && (
<label className="flex flex-col gap-1">
<span className="text-sm font-semibold text-[#24282f]">Display name</span>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="rounded-lg border border-[rgba(116,112,112,0.3)] px-3 py-2 text-sm"
required
/>
</label>
)}
{customDefs.length > 0 ? (
<div className="mt-2 border-t border-[#f2f2f7] pt-4">
<h2 className="text-sm font-semibold text-[#24282f]">Additional information</h2>
<p className="mt-1 text-xs text-[rgba(36,40,47,0.65)]">
Your organizer requested the following details.
</p>
<div className="mt-4 flex flex-col gap-4">
{customDefs.map((def) => {
const code = String(def.code || "").trim();
if (!code) return null;
const v = customValues[code] ?? (String(def.fieldType || "") === "checkboxes" ? [] : "");
return (
<CustomFieldInput
key={code}
def={def}
value={v}
onChange={(next) => setCustomField(code, next)}
/>
);
})}
</div>
</div>
) : null}
{error ? <p className="text-sm text-red-600">{error}</p> : null}
<button
type="submit"
disabled={loading}
className="rounded-full bg-[#ef8d1d] py-3 text-xs font-semibold uppercase tracking-wide text-white disabled:opacity-60"
>
{loading ? "Saving…" : "Continue"}
</button>
</form>
</div>
</div>
</AuthShell>
);
}

View File

@ -0,0 +1,232 @@
import { useEffect, useMemo, useState } from "react";
import { Calendar, MapPin } from "lucide-react";
import { Link, useSearchParams } from "react-router-dom";
import {
getVisitorEventAgenda,
getVisitorLoginPreview,
type VisitorSessionPreview,
} from "../lib/api";
import { formatEventScheduleRange } from "../lib/sessionDisplay";
import {
groupSessionsIntoAgendaDays,
LandingAgendaCards,
LandingMySessionsList,
} from "../components/EventLandingAgenda";
import { EventBanner } from "../components/EventBanner";
import { useEventRegistrationSettings } from "../hooks/useEventRegistrationSettings";
import { useSessionBookmarks } from "../hooks/useSessionBookmarks";
import { readStoredVisitorContext } from "../types/visitorContext";
import type { LoginPreviewResponse } from "../types/visitorContext";
import { EventRatingBadge } from "../components/EventRatingBadge";
import { HeaderMobile } from "../components/HeaderMobile";
import { MobileShell } from "../components/MobileShell";
export function EventLandingPage() {
const [searchParams] = useSearchParams();
const urlEventId = searchParams.get("event_id")?.trim() || "";
const ctx = readStoredVisitorContext();
const eventId = urlEventId || ctx?.eventId || "";
const [fetchedEvent, setFetchedEvent] = useState<LoginPreviewResponse | null>(null);
useEffect(() => {
if (!eventId) {
setFetchedEvent(null);
return;
}
let cancelled = false;
getVisitorLoginPreview(eventId)
.then((ev) => {
if (!cancelled) setFetchedEvent(ev);
})
.catch(() => {
if (!cancelled) setFetchedEvent(null);
});
return () => {
cancelled = true;
};
}, [eventId]);
const event = fetchedEvent ?? (ctx?.eventId === eventId ? ctx.event : null);
const reg = useEventRegistrationSettings(event ?? null);
const { bookmarkedIds, toggle: toggleBookmark } = useSessionBookmarks(eventId);
const [agendaSessions, setAgendaSessions] = useState<VisitorSessionPreview[]>([]);
const [agendaLoading, setAgendaLoading] = useState(false);
const [agendaErr, setAgendaErr] = useState<string | null>(null);
useEffect(() => {
if (!eventId) {
setAgendaSessions([]);
setAgendaErr(null);
setAgendaLoading(false);
return;
}
let cancelled = false;
setAgendaLoading(true);
setAgendaErr(null);
setAgendaSessions([]);
getVisitorEventAgenda(eventId)
.then((sessions) => {
if (!cancelled) setAgendaSessions(sessions);
})
.catch((e) => {
if (!cancelled) {
setAgendaErr(e instanceof Error ? e.message : "Could not load agenda.");
setAgendaSessions([]);
}
})
.finally(() => {
if (!cancelled) setAgendaLoading(false);
});
return () => {
cancelled = true;
};
}, [eventId]);
const [dayOpen, setDayOpen] = useState<Record<string, boolean>>({});
useEffect(() => {
setDayOpen({});
}, [eventId]);
const agendaDays = useMemo(() => groupSessionsIntoAgendaDays(agendaSessions), [agendaSessions]);
const bookmarkedSessionsSorted = useMemo(() => {
if (!bookmarkedIds.size) return [];
return agendaSessions
.filter((s) => bookmarkedIds.has(s._id))
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
}, [agendaSessions, bookmarkedIds]);
useEffect(() => {
const first = agendaDays[0]?.dateKey;
if (!first) return;
setDayOpen((prev) => {
if (Object.keys(prev).length > 0) return prev;
return { [first]: true };
});
}, [agendaDays]);
const schedule =
event?.startDate && event?.endDate
? formatEventScheduleRange(
typeof event.startDate === "string" ? event.startDate : String(event.startDate),
typeof event.endDate === "string" ? event.endDate : String(event.endDate),
)
: "";
const scopedQs = eventId ? new URLSearchParams({ event_id: eventId }).toString() : "";
const ratingsHref = scopedQs ? `/ratings?${scopedQs}` : "/ratings";
const bannerSrc = event?.bannerImage?.trim() || event?.image?.trim() || "";
if (!eventId) {
return (
<MobileShell className="relative">
<HeaderMobile showRewardsAndLogout={false} showBack={false} />
<main className="flex flex-1 flex-col items-center justify-center bg-[#f2f2f7] px-4 py-10">
<p className="text-center text-sm text-[#24282f]">Open this page from your event link or sign in again.</p>
<Link to="/" className="mt-4 text-sm font-semibold text-[#1e72ef] underline">
Home
</Link>
</main>
</MobileShell>
);
}
if (!event) {
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={ctx?.event?.logoUrl} eventId={eventId || null} showBack={false} />
<main className="flex flex-1 items-center justify-center bg-[#f2f2f7] px-4 py-10">
<p className="text-sm text-[#24282f]">Loading event…</p>
</main>
</MobileShell>
);
}
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={event.logoUrl} eventId={eventId || null} showBack={false} />
<EventBanner imageUrl={bannerSrc} alt={event.name} />
<main className="relative z-0 flex w-full max-w-none flex-1 flex-col bg-[#f2f2f7] pb-8 pt-6">
<div className="flex w-full max-w-none flex-col gap-6">
<section className="w-full">
<div className="w-full rounded-lg border border-[rgba(116,112,112,0.2)] bg-white p-4 shadow-sm">
<h1 className="text-lg font-semibold text-[#24282f]">{event.name}</h1>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<EventRatingBadge ratings={event.ratings} />
{reg.enableRatings ? (
<Link to={ratingsHref} className="font-inter font-semibold uppercase tracking-wide text-[#1e72ef]">
rate now
</Link>
) : (
<span className="font-inter text-[#8e8e93]">Ratings off</span>
)}
</div>
<p className="mt-3 text-base leading-5 text-[rgba(36,40,47,0.87)]">
{event.description?.trim() || event.subtitle?.trim() || "Welcome to this event."}
</p>
<button type="button" className="mt-2 font-inter text-xs font-semibold uppercase tracking-wide text-[#1e72ef]">
Read More
</button>
<div className="mt-4 flex flex-col gap-2 border-t border-[#f2f2f7] pt-4 text-xs text-[#24282f]">
<div className="flex items-center gap-2">
<Calendar className="size-4 shrink-0 text-[#8e8e93]" strokeWidth={1.75} />
<span className="font-inter">{schedule || "—"}</span>
</div>
{event.location?.trim() ? (
<div className="flex items-center gap-2">
<MapPin className="size-4 shrink-0 text-[#8e8e93]" strokeWidth={1.75} />
<span className="font-inter">{event.location.trim()}</span>
</div>
) : null}
</div>
</div>
</section>
{bookmarkedIds.size > 0 ? (
<section className="w-full">
<div className="w-full px-4">
<h2 className="font-inter text-base font-semibold text-[#24282f]">My Sessions</h2>
</div>
{agendaLoading && !agendaSessions.length ? (
<p className="mt-3 px-4 text-sm text-[rgba(36,40,47,0.55)]">Loading…</p>
) : (
<LandingMySessionsList
sessions={bookmarkedSessionsSorted}
eventId={eventId}
bookmarkedIds={bookmarkedIds}
toggleBookmark={toggleBookmark}
/>
)}
</section>
) : null}
<section className="w-full">
<div className="w-full px-4">
<h2 className="font-inter text-base font-semibold text-[#24282f]">Agenda</h2>
</div>
{agendaErr ? (
<p className="mt-3 px-4 text-sm text-red-600">{agendaErr}</p>
) : null}
{agendaLoading && !agendaSessions.length ? (
<p className="mt-3 px-4 text-sm text-[rgba(36,40,47,0.55)]">Loading agenda…</p>
) : agendaDays.length === 0 ? (
<p className="mt-3 px-4 text-sm text-[rgba(36,40,47,0.55)]">No sessions scheduled for this event yet.</p>
) : (
<LandingAgendaCards
days={agendaDays}
dayOpen={dayOpen}
setDayOpen={setDayOpen}
eventId={eventId}
bookmarkedIds={bookmarkedIds}
toggleBookmark={toggleBookmark}
/>
)}
</section>
</div>
</main>
</MobileShell>
);
}

View File

@ -0,0 +1,230 @@
import { useEffect, useState } from "react";
import { Calendar, ChevronDown, Clock, User } from "lucide-react";
import { Link, useSearchParams } from "react-router-dom";
import type { AgendaRowData } from "../components/AgendaRow";
import { AgendaRow } from "../components/AgendaRow";
import { EventBanner } from "../components/EventBanner";
import { HeaderMobile } from "../components/HeaderMobile";
import { HyperNavigationFAB } from "../components/HyperNavigationFAB";
import { MobileShell } from "../components/MobileShell";
import { getVisitorLoginPreview, getVisitorTrackPreview, type VisitorTrackPreview } from "../lib/api";
import { formatEventScheduleRange } from "../lib/sessionDisplay";
import { readStoredVisitorContext } from "../types/visitorContext";
import type { LoginPreviewResponse } from "../types/visitorContext";
const agendaRows: AgendaRowData[] = [
{
time: "06:00 PM",
duration: "45 MINS",
trackTitle: "Asthma",
sessionType: "Symposium",
description:
"Role of GINA Guidelines for Inning and Monitoring Asthma Treatment",
speakers: [
{ name: "Dr. Mansi Gupta", role: "Speaker" },
{ name: "Dr. Soumya Mitra", role: "Speaker" },
{ name: "Dr. Kumar Utsav Samaria", role: "Speaker" },
],
},
{
time: "07:00 PM",
duration: "45 MINS",
trackTitle: "Asthma",
sessionType: "Panel Discussion",
description:
"Role of GINA Guidelines for Iniang and Monitoring Asthma Treatment",
speakers: [
{ name: "Dr. Mansi Gupta", role: "Speaker" },
{ name: "Dr. Soumya Mitra", role: "Speaker" },
{ name: "Dr. Kumar Utsav Samaria", role: "Speaker" },
{ name: "Dr. Agam Vora", role: "Moderator" },
],
},
{
time: "08:00 PM",
duration: "45 MINS",
trackTitle: "Interventional Pulmonology",
sessionType: "Keynote Address",
description:
"Role of GINA Guidelines for Iniang and Monitoring Asthma Treatment",
speakers: [
{ name: "Dr. Mansi Gupta", role: "Speaker" },
{ name: "Dr. Soumya Mitra", role: "Speaker" },
{ name: "Dr. Kumar Utsav Samaria", role: "Speaker" },
],
},
];
export function EventTrackPage() {
const [searchParams] = useSearchParams();
const eventId = searchParams.get("event_id")?.trim() || "";
const trackId = searchParams.get("track_id")?.trim() || "";
const ctx = readStoredVisitorContext();
const [track, setTrack] = useState<VisitorTrackPreview | null>(null);
const [eventInfo, setEventInfo] = useState<LoginPreviewResponse | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
if (!eventId || !trackId) {
setErr("Missing track link. Open the track from your event QR code or invite.");
setLoading(false);
setTrack(null);
setEventInfo(null);
return;
}
let cancelled = false;
setLoading(true);
setErr(null);
(async () => {
try {
const tr = await getVisitorTrackPreview(trackId, eventId);
if (cancelled) return;
setTrack(tr);
if (ctx?.eventId === eventId && ctx.event) {
setEventInfo(ctx.event);
} else {
const ev = await getVisitorLoginPreview(eventId);
if (cancelled) return;
setEventInfo(ev);
}
} catch (e) {
if (!cancelled) {
setErr(e instanceof Error ? e.message : "Could not load track.");
setTrack(null);
setEventInfo(null);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [eventId, trackId, ctx?.eventId]);
if (!eventId || !trackId) {
return (
<MobileShell className="relative">
<HeaderMobile eventId={eventId || null} />
<main className="flex flex-1 flex-col items-center justify-center bg-[#f2f2f7] px-4 py-10">
<p className="text-center text-sm text-[#24282f]">{err}</p>
<Link to="/" className="mt-4 text-sm font-semibold text-[#1e72ef] underline">
Home
</Link>
</main>
</MobileShell>
);
}
if (loading) {
return (
<MobileShell className="relative">
<HeaderMobile eventId={eventId || null} />
<main className="flex flex-1 items-center justify-center bg-[#f2f2f7] px-4 py-10">
<p className="text-sm text-[#24282f]">Loading track…</p>
</main>
</MobileShell>
);
}
if (err || !track) {
return (
<MobileShell className="relative">
<HeaderMobile eventId={eventId || null} />
<main className="flex flex-1 flex-col items-center justify-center bg-[#f2f2f7] px-4 py-10">
<p className="text-center text-sm text-red-600">{err ?? "Track not found."}</p>
</main>
</MobileShell>
);
}
const ev = eventInfo;
const eventName = ev?.name ?? "Event";
const schedule =
ev?.startDate && ev?.endDate
? formatEventScheduleRange(
typeof ev.startDate === "string" ? ev.startDate : String(ev.startDate),
typeof ev.endDate === "string" ? ev.endDate : String(ev.endDate),
)
: "";
const trackTypeLabel = track.trackType ? String(track.trackType).replace(/_/g, " ") : "Track";
const headerLogo =
ev?.logoUrl?.trim() ||
(ctx?.eventId === eventId && ctx.event?.logoUrl?.trim() ? ctx.event.logoUrl.trim() : undefined);
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={headerLogo} eventId={eventId || null} />
<EventBanner />
<main className="relative z-0 flex flex-1 flex-col bg-[#f2f2f7] px-4 pb-28 pt-6">
<div className="mx-auto w-full max-w-[370px]">
<div className="mb-6 flex flex-col gap-4">
<div className="inline-flex w-fit rounded bg-[#e5e5ea] px-2 py-0.5">
<span className="text-[10px] tracking-wide text-[#24282f]">{eventName.toUpperCase()}</span>
</div>
<h1 className="text-lg font-semibold tracking-wide text-[#24282f]">{track.name}</h1>
{track.hall?.trim() ? (
<p className="text-xs text-[rgba(36,40,47,0.7)]">{track.hall.trim()}</p>
) : null}
<p className="text-[10px] uppercase tracking-wide text-[#8e8e93]">{trackTypeLabel}</p>
<div className="flex flex-col gap-1 text-xs text-[#24282f]">
<div className="flex items-center gap-2">
<Calendar className="size-4 shrink-0" strokeWidth={1.75} />
<span className="font-inter tracking-wide">{schedule || "—"}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="size-4 shrink-0" strokeWidth={1.75} />
<span className="font-inter tracking-wide">See sessions below</span>
</div>
<div className="flex items-center gap-2">
<User className="size-4 shrink-0" strokeWidth={1.75} />
<span className="font-inter tracking-wide">Track agenda (sample)</span>
</div>
</div>
</div>
<div className="mb-4 flex w-full max-w-[370px] flex-col gap-2">
<h2 className="font-inter text-base font-semibold text-[#24282f]">Sessions</h2>
<button
type="button"
className="flex h-[43px] w-full items-center justify-between rounded-lg border border-[rgba(116,112,112,0.3)] bg-white px-4 py-3"
>
<div className="flex items-center gap-2">
<Calendar className="size-4 shrink-0 text-[#24282f]" strokeWidth={1.75} />
<span className="font-inter text-sm text-[#24282f]">{schedule || "Schedule"}</span>
</div>
<ChevronDown className="size-4 shrink-0 text-[#1d66ef]" strokeWidth={2} />
</button>
</div>
<div className="relative rounded-md bg-white p-4 shadow-[0_1px_4px_rgba(12,12,13,0.05)]">
<div className="flex flex-col gap-6">
{agendaRows.map((row) => (
<AgendaRow key={row.time + row.sessionType} row={row} />
))}
</div>
</div>
<p className="mt-6 text-center text-xs text-[#24282f]/60">
<Link
className="text-[#1e72ef] underline"
to={`/questions?event_id=${encodeURIComponent(eventId)}`}
>
Ask &amp; session questions
</Link>
</p>
</div>
</main>
<HyperNavigationFAB eventId={eventId} />
</MobileShell>
);
}

View File

@ -0,0 +1,117 @@
import { useState } from "react";
import { Calendar, Clock, MapPin, User } from "lucide-react";
import { Link, useSearchParams } from "react-router-dom";
import type { AgendaRowTrackV2Data } from "../components/AgendaRowTrackV2";
import { AgendaRowTrackV2 } from "../components/AgendaRowTrackV2";
import { EventBanner } from "../components/EventBanner";
import { HeaderMobile } from "../components/HeaderMobile";
import { readStoredVisitorContext } from "../types/visitorContext";
import { HyperNavigationFAB } from "../components/HyperNavigationFAB";
import { MobileShell } from "../components/MobileShell";
const dates = ["November 22, 2025", "November 23, 2025"];
const rowsV2: AgendaRowTrackV2Data[] = [
{
time: "06:00 PM",
duration: "45 MINS",
trackTitle: "Asthma",
sessionType: "Symposium",
description:
"Role of GINA Guidelines for Inning and Monitoring Asthma Treatment",
speakerNames: ["Dr. Kumar Utsav Samaria", "Dr. Soumya Mitra", "Dr. Mansi Gupta"],
status: { kind: "live" },
showJoin: true,
},
{
time: "07:00 PM",
duration: "45 MINS",
trackTitle: "Asthma",
sessionType: "Panel Discussion",
description:
"Role of GINA Guidelines for Iniang and Monitoring Asthma Treatment",
speakerNames: ["Dr. Kumar Utsav Samaria", "Dr. Soumya Mitra", "Dr. Mansi Gupta", "Dr. Agam Vora"],
status: { kind: "starts", label: "Starts in 45 Mins" },
showJoin: true,
},
];
export function EventTrackV2Page() {
const [searchParams] = useSearchParams();
const ctx = readStoredVisitorContext();
const eventId = searchParams.get("event_id")?.trim() || "";
const [tab, setTab] = useState(0);
return (
<MobileShell className="relative">
<HeaderMobile
logoUrl={ctx?.eventId === eventId ? ctx.event?.logoUrl : undefined}
eventId={eventId || null}
/>
<EventBanner />
<main className="relative z-0 flex flex-1 flex-col bg-[#f2f2f7] px-4 pb-28 pt-6">
<div className="mx-auto w-full max-w-[370px]">
<div className="mb-4 flex flex-col gap-4">
<span className="inline-flex w-fit rounded bg-[#e5e5ea] px-2 py-0.5 text-[10px] text-[#24282f]">
NAPCON JAIPUR 2026
</span>
<h1 className="text-lg font-semibold text-[#24282f]">This will be Track Name</h1>
<div className="flex items-center gap-2 text-xs text-[#24282f]">
<MapPin className="size-4 shrink-0" strokeWidth={1.75} />
<span className="font-inter">Health and Wellness Expo</span>
</div>
<div className="flex flex-col gap-1 text-xs text-[#24282f]">
<div className="flex items-center gap-2">
<Calendar className="size-4" strokeWidth={1.75} />
<span className="font-inter">November 22, 2025 - November 23, 2025</span>
</div>
<div className="flex items-center gap-2">
<Clock className="size-4" strokeWidth={1.75} />
<span className="font-inter">6:00 PM - 10:00 PM</span>
</div>
<div className="flex items-center gap-2">
<User className="size-4" strokeWidth={1.75} />
<span className="font-inter">400 Participants (Capacity)</span>
</div>
</div>
</div>
<div className="mb-4 flex w-full border-b-2 border-[#c7c7cc]">
{dates.map((d, i) => (
<button
key={d}
type="button"
onClick={() => setTab(i)}
className={`flex-1 pb-2 pt-1 text-center text-base ${
tab === i
? "border-b-2 border-[#1e72ef] font-semibold text-[#24282f]"
: "font-normal text-[#24282f]"
}`}
style={{ marginBottom: -2 }}
>
{d}
</button>
))}
</div>
<div className="relative rounded-md bg-white p-4 shadow-[0_1px_4px_rgba(12,12,13,0.05)]">
<div className="flex flex-col gap-4">
{rowsV2.map((row) => (
<AgendaRowTrackV2 key={row.time + row.sessionType} row={row} />
))}
</div>
</div>
<p className="mt-4 text-center text-xs">
<Link className="text-[#1e72ef] underline" to="/event">
Back to event
</Link>
</p>
</div>
</main>
<HyperNavigationFAB eventId={eventId} />
</MobileShell>
);
}

172
src/pages/FeedbackPage.tsx Normal file
View File

@ -0,0 +1,172 @@
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { HeaderMobile } from "../components/HeaderMobile";
import { MobileShell } from "../components/MobileShell";
import { DynamicFeedbackForm } from "../components/DynamicFeedbackForm";
import { useEventRegistrationSettings } from "../hooks/useEventRegistrationSettings";
import { getVisitorFeedbackFormSchema, getVisitorLoginPreview, type VisitorPublicFeedbackForm } from "../lib/api";
import { readStoredVisitorContext } from "../types/visitorContext";
export function FeedbackPage() {
const [searchParams] = useSearchParams();
const formId = searchParams.get("form_id")?.trim() || "";
const urlEventId = searchParams.get("event_id")?.trim() || "";
const ctx = readStoredVisitorContext();
const eventId = urlEventId || ctx?.eventId || "";
const [eventSnapshot, setEventSnapshot] = useState(
ctx?.eventId === eventId && ctx.event ? ctx.event : null,
);
const [schema, setSchema] = useState<VisitorPublicFeedbackForm | null>(null);
const [schemaLoading, setSchemaLoading] = useState(false);
const [schemaErr, setSchemaErr] = useState<string | null>(null);
useEffect(() => {
if (!eventId) {
setEventSnapshot(null);
return;
}
if (ctx?.eventId === eventId && ctx.event) {
setEventSnapshot(ctx.event);
return;
}
let cancelled = false;
getVisitorLoginPreview(eventId)
.then((ev) => {
if (!cancelled) setEventSnapshot(ev);
})
.catch(() => {
if (!cancelled) setEventSnapshot(null);
});
return () => {
cancelled = true;
};
}, [eventId, ctx?.eventId, ctx?.event]);
useEffect(() => {
if (!formId || !eventId) {
setSchema(null);
setSchemaErr(null);
setSchemaLoading(false);
return;
}
let cancelled = false;
setSchemaLoading(true);
setSchemaErr(null);
getVisitorFeedbackFormSchema(eventId, formId)
.then((s) => {
if (!cancelled) setSchema(s);
})
.catch((e) => {
if (!cancelled) {
setSchema(null);
setSchemaErr(e instanceof Error ? e.message : "Could not load form.");
}
})
.finally(() => {
if (!cancelled) setSchemaLoading(false);
});
return () => {
cancelled = true;
};
}, [formId, eventId]);
const reg = useEventRegistrationSettings(eventSnapshot);
const [message, setMessage] = useState("");
if (!eventId) {
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={ctx?.event?.logoUrl} eventId={null} />
<main className="flex flex-1 flex-col bg-[#f2f2f7] px-4 pb-10 pt-6">
<div className="mx-auto w-full max-w-[370px]">
<h1 className="font-inter text-xl font-semibold text-[#24282f]">Feedback</h1>
<p className="mt-3 text-sm text-[rgba(36,40,47,0.67)]">Open feedback from your event or session link.</p>
<Link to="/" className="mt-4 inline-block text-sm font-semibold text-[#1e72ef] underline">
Home
</Link>
</div>
</main>
</MobileShell>
);
}
if (!reg.enableFeedback) {
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={eventSnapshot?.logoUrl} eventId={eventId || null} />
<main className="flex flex-1 flex-col bg-[#f2f2f7] px-4 pb-10 pt-6">
<div className="mx-auto w-full max-w-[370px]">
<h1 className="font-inter text-xl font-semibold text-[#24282f]">Feedback</h1>
<p className="mt-3 text-sm text-[rgba(36,40,47,0.67)]">Feedback is disabled for this event.</p>
<Link to="/event" className="mt-4 inline-block text-sm font-semibold text-[#1e72ef] underline">
Back to event
</Link>
</div>
</main>
</MobileShell>
);
}
if (formId) {
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={eventSnapshot?.logoUrl} eventId={eventId || null} />
<main className="flex flex-1 flex-col bg-[#f2f2f7] px-4 pb-10 pt-6">
<div className="mx-auto w-full max-w-[370px]">
<h1 className="font-inter text-xl font-semibold text-[#24282f]">
{schema?.name || "Feedback"}
</h1>
{schemaLoading ? (
<p className="mt-4 text-sm text-[rgba(36,40,47,0.67)]">Loading form…</p>
) : schemaErr ? (
<div className="mt-4">
<p className="text-sm text-red-600">{schemaErr}</p>
<Link to="/event" className="mt-4 inline-block text-sm font-semibold text-[#1e72ef] underline">
Back to event
</Link>
</div>
) : schema ? (
<div className="mt-6">
<DynamicFeedbackForm eventId={eventId} schema={schema} variant="light" />
</div>
) : null}
</div>
</main>
</MobileShell>
);
}
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={eventSnapshot?.logoUrl} eventId={eventId || null} />
<main className="flex flex-1 flex-col bg-[#f2f2f7] px-4 pb-10 pt-6">
<div className="mx-auto w-full max-w-[370px]">
<h1 className="font-inter text-xl font-semibold text-[#24282f]">Event Feedback</h1>
<p className="mt-2 text-sm leading-snug text-[rgba(36,40,47,0.67)]">
We&apos;d love to hear your thoughts about this event. Your feedback helps us improve future
experiences.
</p>
<div className="mt-6 rounded-md bg-white p-4 shadow-[0_1px_4px_rgba(12,12,13,0.05)]">
<label className="flex flex-col gap-2">
<span className="text-sm font-semibold text-[#24282f]">Your feedback</span>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={6}
placeholder="Share your experience, suggestions, or issues…"
className="w-full resize-none rounded-lg border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] px-3 py-2 font-inter text-sm text-[#24282f] placeholder:text-[rgba(36,40,47,0.45)]"
/>
</label>
<button
type="button"
className="mt-4 w-full rounded-full bg-[#ef8d1d] py-3 text-xs font-semibold uppercase tracking-wide text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)]"
>
Submit feedback
</button>
</div>
</div>
</main>
</MobileShell>
);
}

418
src/pages/Home.tsx Normal file
View File

@ -0,0 +1,418 @@
import { useCallback, useState, type ReactNode } from "react";
import {
BarChart3,
CalendarClock,
ClipboardList,
Facebook,
Gamepad2,
Mail,
MessageCircle,
MessageCircleQuestion,
Network,
Phone,
Youtube,
} from "lucide-react";
import { Link } from "react-router-dom";
import aboutBanner from "../assets/about-banner.png";
import { JoinCodeTopStrip } from "../components/marketing/JoinCodeTopStrip";
import { MarketingFooter } from "../components/marketing/MarketingFooter";
import { MarketingHeader } from "../components/marketing/MarketingHeader";
import { MarketingHeroSection } from "../components/marketing/MarketingHeroSection";
import {
MARKETING_HEADER_SCROLL_MARGIN,
scrollToId,
shellX,
} from "../components/marketing/marketingLayout";
import { PORTAL_LOGIN_URL, PORTAL_REGISTER_URL } from "../constants/portalUrls";
const featureItems = [
{
title: "Live Q&A",
body: "Engage your audience with real-time questions and answers during sessions.",
Icon: MessageCircleQuestion,
},
{
title: "Quizzes",
body: "Make learning fun with interactive quizzes.",
Icon: ClipboardList,
},
{
title: "Gamification",
body: "Boosts event engagement by turning participation into a rewarding experience.",
Icon: Gamepad2,
},
{
title: "Interactive Polls",
body: "Capture instant feedback with live polls and see results in real-time.",
Icon: BarChart3,
},
{
title: "Event Agenda",
body: "Keep attendees informed with detailed session schedules and speaker info.",
Icon: CalendarClock,
},
{
title: "Multi-Track Events",
body: "Manage complex events with multiple tracks and concurrent sessions.",
Icon: Network,
},
] as const;
/** Marketing homepage (default route `/`) */
export function Home() {
const [captcha, setCaptcha] = useState(() => randomCaptcha());
const [captchaInput, setCaptchaInput] = useState("");
const reloadCaptcha = useCallback(() => {
setCaptcha(randomCaptcha());
setCaptchaInput("");
}, []);
function handleContactSubmit(e: React.FormEvent) {
e.preventDefault();
reloadCaptcha();
}
return (
<div className="min-h-dvh w-full bg-white text-[#24282f]" data-name="Home">
<MarketingHeader />
<JoinCodeTopStrip />
<MarketingHeroSection />
{/* Features */}
<section
id="features"
className={`${MARKETING_HEADER_SCROLL_MARGIN} mx-auto w-full max-w-[1728px] py-10 lg:py-20 ${shellX}`}
data-name="Banner_02_Mobile"
>
<div className="space-y-9 lg:space-y-12">
<div className="mx-auto max-w-4xl space-y-4 text-center lg:mx-0 lg:max-w-none lg:text-left">
<h2 className="text-[clamp(1.5rem,4vw,3.5rem)] font-light italic leading-tight tracking-wide lg:leading-[68px]">
Everything you need to create{" "}
<span className="font-bold not-italic">engaging</span>
{" and "}
<span className="font-bold not-italic">interactive</span> events
</h2>
<p className="text-lg font-light leading-snug tracking-wide text-[#24282f] lg:max-w-[804px] lg:text-[22px] lg:leading-[27px]">
Our platform enables real-time interaction through live Q&A sessions, instant polls,
engaging quizzes, and much more.
</p>
</div>
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:gap-8">
{featureItems.map(({ title, body, Icon }) => (
<li
key={title}
className="flex gap-4 rounded-xl border border-[#1d66ef] bg-white p-4 shadow-sm lg:gap-6 lg:rounded-3xl lg:px-8 lg:py-6"
>
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg bg-[#f2f2f7] lg:size-[88px] lg:rounded-2xl lg:p-6">
<Icon className="size-6 text-[#1d66ef] lg:size-14" strokeWidth={1.5} />
</div>
<div className="min-w-0 space-y-1 lg:space-y-6">
<h3 className="font-inter text-base font-semibold tracking-wide lg:text-2xl">
{title}
</h3>
<p className="text-sm font-light leading-snug tracking-wide lg:text-[22px] lg:leading-[27px]">
{body}
</p>
</div>
</li>
))}
</ul>
</div>
</section>
{/* About + stats */}
<section
id="about"
className={`${MARKETING_HEADER_SCROLL_MARGIN} bg-[#f2f2f7] py-10 lg:py-20`}
data-name="Banner_03_Mobile"
>
<div className={`mx-auto w-full max-w-[1728px] space-y-9 lg:space-y-12 ${shellX}`}>
<div className="mx-auto max-w-4xl space-y-4 lg:mx-0 lg:max-w-4xl">
<h2 className="text-[clamp(1.5rem,4vw,3rem)] font-light italic leading-tight tracking-wide">
About event<span className="font-bold not-italic">buzz</span>
</h2>
<p className="text-lg font-light leading-snug tracking-wide lg:text-[22px] lg:leading-[27px]">
EventBuzz is a comprehensive event engagement platform designed to transform how
organizers connect with their audience. Whether you&apos;re hosting a conference,
workshop, training session, or corporate event, EventBuzz provides the tools you need
to create memorable experiences.
</p>
<p className="text-base font-medium leading-snug tracking-wide lg:text-lg">
Our platform enables real-time interaction through live Q&A sessions, instant polls,
engaging quizzes, and much more.
</p>
</div>
<div
className="relative mx-auto max-w-[1439px] overflow-hidden rounded-[40px] sm:rounded-[60px]"
data-name="About_Banner"
>
<div className="relative aspect-[16/10] min-h-[280px] w-full lg:aspect-[1439/494] lg:min-h-[400px]">
<img
src={aboutBanner}
alt=""
className="absolute inset-0 size-full object-cover object-center"
/>
<div className="absolute inset-0 bg-black/55" />
</div>
<div className="absolute inset-0 flex flex-col justify-end gap-4 p-6 pb-8 lg:flex-row lg:items-end lg:justify-center lg:gap-6 lg:px-12 lg:pb-16">
{[
{ n: "1000+", l: "Events Hosted" },
{ n: "50K+", l: "Attendees" },
{ n: "96%", l: "Satisfaction" },
].map((row) => (
<div
key={row.l}
className="flex items-center gap-4 rounded-2xl bg-white/20 px-6 py-3 text-[#f1f1f1] shadow-[0_6px_10px_rgba(0,0,0,0.15),0_2px_3px_rgba(0,0,0,0.3)] lg:flex-col lg:items-center lg:gap-2 lg:px-10 lg:py-4 lg:text-center"
>
<span className="text-3xl font-light italic tracking-wide sm:text-[40px] lg:text-[60px] lg:leading-[84px]">
{row.n}
</span>
<span className="text-base font-semibold italic tracking-wide lg:text-[22px] lg:leading-[27px]">
{row.l}
</span>
</div>
))}
</div>
</div>
</div>
</section>
{/* Contact intro (dark) */}
<section className="bg-[#24282f] px-4 py-8 text-white sm:px-6 lg:py-20" data-name="Contact Us">
<div className={`mx-auto w-full max-w-[1728px] space-y-6 lg:space-y-9 ${shellX}`}>
<h2 className="text-[clamp(1.75rem,4vw,4.75rem)] font-light italic leading-tight tracking-wide lg:leading-[84px]">
Contact Us
</h2>
<p className="max-w-4xl text-lg font-light leading-snug tracking-wide text-white/95 lg:max-w-[1067px] lg:text-[22px] lg:leading-[27px]">
Have questions? We&apos;d love to hear from you. Send us a message!
</p>
</div>
</section>
{/* Form + Get in touch */}
<section
id="contact"
className={`${MARKETING_HEADER_SCROLL_MARGIN} mx-auto w-full max-w-[1728px] py-10 lg:py-12 ${shellX}`}
>
<div className="flex flex-col gap-10 lg:flex-row lg:items-start lg:gap-6 xl:gap-8">
<form
onSubmit={handleContactSubmit}
className="min-w-0 flex-1 space-y-6 lg:max-w-[704px] lg:space-y-6"
>
<Field label="Your Name" htmlFor="m-name" desktopLarge>
<input
id="m-name"
name="name"
defaultValue="John Smith"
className="h-[51px] w-full rounded-lg border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] px-4 font-inter text-sm text-[#24282f] lg:h-[59px] lg:rounded-xl lg:p-4"
/>
</Field>
<Field label="Your Email" htmlFor="m-email" desktopLarge>
<input
id="m-email"
name="email"
type="email"
defaultValue="john@email.com"
className="h-[51px] w-full rounded-lg border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] px-4 font-inter text-sm text-[#24282f] lg:h-[59px] lg:rounded-xl lg:p-4"
/>
</Field>
<Field label="Message" htmlFor="m-msg" desktopLarge>
<textarea
id="m-msg"
name="message"
rows={4}
placeholder="Tell us about your event needs..."
className="w-full rounded-lg border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] px-4 py-3 font-inter text-sm text-[#24282f] placeholder:text-[#24282f]/50 lg:rounded-xl lg:p-4"
/>
</Field>
<div className="space-y-2">
<p className="font-inter text-base font-semibold lg:text-2xl">Captcha Code</p>
<CaptchaRow
captcha={captcha}
value={captchaInput}
onChange={setCaptchaInput}
onReload={reloadCaptcha}
/>
</div>
<button
type="submit"
className="w-full rounded-full border border-[rgba(214,200,200,0.6)] bg-[#ef8d1d] py-3 text-xs font-semibold uppercase tracking-wide text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)] lg:w-auto lg:px-8 lg:py-4 lg:text-lg"
>
Send Message
</button>
</form>
<div className="w-full shrink-0 space-y-8 rounded-3xl bg-[#f2f2f7] p-4 lg:max-w-[464px] lg:self-stretch lg:p-10">
<div>
<h3 className="font-inter text-base font-semibold lg:text-[22px]">Get in Touch</h3>
<p className="mt-2 text-sm leading-normal lg:text-lg">
Have questions? We&apos;d love to hear from you. Send us a message!
</p>
</div>
<div className="flex gap-4">
<div className="flex size-14 shrink-0 items-center justify-center rounded-xl bg-[#ef8d1d] text-white">
<Phone className="size-6" strokeWidth={1.75} />
</div>
<div>
<p className="font-inter text-base font-semibold lg:text-2xl">Phone</p>
<a href="tel:+919923121245" className="text-sm hover:underline lg:text-lg">
+91 99231 21245
</a>
</div>
</div>
<div className="flex gap-4">
<div className="flex size-14 shrink-0 items-center justify-center rounded-xl bg-[#ef8d1d] text-white">
<Mail className="size-6" strokeWidth={1.75} />
</div>
<div>
<p className="font-inter text-base font-semibold lg:text-2xl">Email</p>
<a href="mailto:hello@eventbuzz.live" className="text-sm hover:underline lg:text-lg">
hello@eventbuzz.live
</a>
</div>
</div>
<div>
<p className="font-inter text-base font-semibold lg:text-[22px]">Follow Us</p>
<div className="mt-4 flex gap-4">
<a
href="https://facebook.com"
target="_blank"
rel="noreferrer"
className="flex size-10 items-center justify-center rounded-full bg-white text-[#24282f] shadow-sm hover:opacity-90 lg:size-14"
aria-label="Facebook"
>
<Facebook className="size-5 lg:size-7" strokeWidth={1.5} />
</a>
<a
href="https://youtube.com"
target="_blank"
rel="noreferrer"
className="flex size-10 items-center justify-center rounded-full bg-white text-[#24282f] shadow-sm hover:opacity-90 lg:size-14"
aria-label="YouTube"
>
<Youtube className="size-5 lg:size-7" strokeWidth={1.5} />
</a>
<a
href="https://wa.me/"
target="_blank"
rel="noreferrer"
className="flex size-10 items-center justify-center rounded-full bg-[#25D366] text-white shadow-sm hover:opacity-90 lg:size-14"
aria-label="WhatsApp"
>
<MessageCircle className="size-5 lg:size-7" strokeWidth={1.75} />
</a>
</div>
</div>
</div>
</div>
</section>
{/* Bottom CTA */}
<section
className="eb-gradient-auth px-4 py-8 text-white sm:px-6 lg:py-20"
data-name="Trial_Mobile"
>
<div
className={`mx-auto flex w-full max-w-[1728px] flex-col gap-8 lg:items-center lg:text-center ${shellX}`}
>
<div className="space-y-4 lg:max-w-4xl lg:space-y-4">
<h2 className="text-[clamp(1.5rem,4vw,2.75rem)] font-light italic leading-tight tracking-wide lg:text-[45px] lg:leading-[84px]">
Ready to Engage Your Audience?
</h2>
<p className="text-lg font-light leading-snug tracking-wide lg:text-[22px] lg:leading-[27px]">
Get started with EventBuzz today and transform your events into interactive
experiences.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap lg:justify-center">
<a
href={PORTAL_REGISTER_URL}
className="rounded-full border border-[rgba(214,200,200,0.6)] bg-white px-[18px] py-3 text-center text-xs font-semibold uppercase tracking-wide text-[#24282f] shadow-[0_4px_4px_rgba(0,0,0,0.15)] lg:px-8 lg:py-4 lg:text-lg"
>
Start free trial
</a>
<button
type="button"
onClick={() => scrollToId("contact")}
className="rounded-full border border-white/60 px-[18px] py-3 text-xs font-semibold uppercase tracking-wide text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)] lg:px-8 lg:py-4 lg:text-lg"
>
Contact us
</button>
</div>
</div>
</section>
<MarketingFooter />
</div>
);
}
function Field({
label,
htmlFor,
children,
desktopLarge,
}: {
label: string;
htmlFor: string;
children: ReactNode;
desktopLarge?: boolean;
}) {
return (
<div className="space-y-2">
<label
htmlFor={htmlFor}
className={`font-inter font-semibold ${desktopLarge ? "text-base lg:text-2xl" : "text-base"}`}
>
{label}
</label>
{children}
</div>
);
}
function randomCaptcha() {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
return Array.from({ length: 4 }, () => chars[Math.floor(Math.random() * chars.length)]).join(
" ",
);
}
function CaptchaRow({
captcha,
value,
onChange,
onReload,
}: {
captcha: string;
value: string;
onChange: (v: string) => void;
onReload: () => void;
}) {
return (
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2 lg:gap-6">
<div className="flex min-h-[52px] items-center justify-between gap-3 rounded-lg border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] px-4 lg:min-h-[60px] lg:rounded-xl lg:px-4">
<span className="font-mono text-lg font-semibold tracking-[0.35em] text-[#24282f]">
{captcha}
</span>
<button
type="button"
onClick={onReload}
className="shrink-0 rounded-full bg-[#24282f] px-6 py-2 text-xs font-semibold uppercase tracking-wide text-white lg:text-base"
>
Reload
</button>
</div>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Enter Captcha"
className="h-[51px] w-full rounded-lg border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] px-4 font-inter text-sm text-[#24282f] lg:h-[59px] lg:self-end lg:rounded-xl lg:p-4"
aria-label="Enter captcha"
/>
</div>
);
}

View File

@ -0,0 +1,143 @@
import { useEffect, useState } from "react";
import { Award } from "lucide-react";
import { Link, useSearchParams } from "react-router-dom";
import { getVisitorEventLeaderboard } from "../lib/api";
import { getAttendeeToken } from "../lib/attendeeAuth";
import { HeaderMobile } from "../components/HeaderMobile";
import { MobileShell } from "../components/MobileShell";
import { readStoredVisitorContext } from "../types/visitorContext";
import type { VisitorLeaderboardRow } from "../lib/api";
function Medal({ tier }: { tier: "gold" | "silver" | "bronze" }) {
const cls =
tier === "gold"
? "text-amber-500"
: tier === "silver"
? "text-slate-400"
: "text-amber-800";
return <Award className={`size-6 ${cls}`} strokeWidth={1.5} />;
}
function medalForRank(rank: number): "gold" | "silver" | "bronze" | null {
if (rank === 1) return "gold";
if (rank === 2) return "silver";
if (rank === 3) return "bronze";
return null;
}
export function LeaderboardPage() {
const [searchParams] = useSearchParams();
const urlEventId = searchParams.get("event_id")?.trim() || "";
const ctx = readStoredVisitorContext();
const eventId = urlEventId || ctx?.eventId || "";
const [rows, setRows] = useState<VisitorLeaderboardRow[]>([]);
const [yourRank, setYourRank] = useState<number | null>(null);
const [yourTotal, setYourTotal] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
if (!eventId) {
setRows([]);
setYourRank(null);
setYourTotal(null);
return;
}
let cancelled = false;
setLoading(true);
setErr(null);
getVisitorEventLeaderboard(eventId, { limit: 50 })
.then((r) => {
if (cancelled) return;
setRows(r.leaderboard);
if (getAttendeeToken()) {
const y = r.your;
setYourRank(y?.rank ?? null);
setYourTotal(typeof y?.total === "number" ? y.total : null);
} else {
setYourRank(null);
setYourTotal(null);
}
})
.catch((e) => {
if (!cancelled) setErr(e instanceof Error ? e.message : "Could not load leaderboard.");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [eventId]);
const top5 = rows.slice(0, 5);
const signedIn = Boolean(getAttendeeToken());
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={ctx?.event?.logoUrl} eventId={eventId || null} />
<div className="eb-gradient-auth flex flex-1 flex-col items-center px-6 pb-10 pt-6">
<Award className="size-[68px] text-amber-300" strokeWidth={1.25} />
<h1 className="mt-1 font-inter text-xl font-semibold text-white">Leaderboard</h1>
<div className="mt-6 w-full max-w-[354px] rounded-xl bg-white shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
<div className="p-4">
<div className="eb-rank-highlight flex flex-col gap-2 rounded-3xl border-4 border-white px-4 py-4 text-white shadow-[0_9px_16px_rgba(0,0,0,0.14)]">
<p className="text-center font-inter text-base font-semibold uppercase tracking-wide">Your rank</p>
<div className="flex items-center justify-center gap-4">
<span className="font-inter text-5xl font-bold drop-shadow-[0_4px_4px_rgba(0,0,0,0.25)]">
{!signedIn ? "—" : loading ? "…" : yourRank != null ? yourRank : "—"}
</span>
<span className="rounded-xl bg-[#cff7d3] px-4 py-3 text-xs font-medium text-[#009951] shadow-sm">
{!signedIn
? "Sign in"
: loading
? "…"
: yourTotal != null
? `${yourTotal} points`
: "0 points"}
</span>
</div>
</div>
<h2 className="mt-6 font-inter text-base font-semibold text-[#24282f]">Top participants</h2>
{err ? (
<p className="mt-3 text-xs text-red-600">{err}</p>
) : loading && rows.length === 0 ? (
<p className="mt-3 text-xs text-[rgba(36,40,47,0.67)]">Loading…</p>
) : top5.length === 0 ? (
<p className="mt-3 text-xs text-[rgba(36,40,47,0.67)]">
No scores yet. Complete activities and earn points when organizers enable rewards.
</p>
) : (
<ul className="mt-3 flex flex-col gap-3">
{top5.map((row) => {
const medal = medalForRank(row.rank);
return (
<li
key={row.participantId}
className="flex items-center gap-3 border-b border-[#ededed] pb-3 last:border-0"
>
<span className="shrink-0 rounded bg-[#ebffee] px-3 py-2 text-xs font-medium text-[#009951] shadow-sm">
#{row.rank} · {row.total} pts
</span>
<span className="min-w-0 flex-1 text-sm font-semibold text-[#24282f]">{row.displayName}</span>
{medal ? <Medal tier={medal} /> : null}
</li>
);
})}
</ul>
)}
</div>
</div>
<Link
to={eventId ? `/rewards?event_id=${encodeURIComponent(eventId)}` : "/rewards"}
className="mt-6 font-inter font-semibold text-white underline"
>
Rewards
</Link>
</div>
</MobileShell>
);
}

341
src/pages/LoginPage.tsx Normal file
View File

@ -0,0 +1,341 @@
import { useEffect, useState } from "react";
import { Calendar, MapPin } from "lucide-react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { AuthShell } from "../components/auth/AuthShell";
import { EventBuzzLogo } from "../components/EventBuzzLogo";
import { useEventRegistrationSettings } from "../hooks/useEventRegistrationSettings";
import {
getVisitorLoginPreview,
postVisitorGuest,
postVisitorStartLink,
postVisitorStartOtp,
} from "../lib/api";
import {
getGuestTokenForEvent,
setAttendeeSession,
setGuestTokenForEvent,
} from "../lib/attendeeAuth";
import { notifyRewardFromApiData } from "../lib/rewardCelebration";
import {
EB_VISITOR_CONTEXT_KEY,
type EbVisitorContext,
type LoginPreviewResponse,
type VisitorJoinType,
buildPostAuthUrl,
} from "../types/visitorContext";
function formatEventSchedule(startIso: string, endIso: string): string {
const s = new Date(startIso);
const e = new Date(endIso);
if (Number.isNaN(s.getTime()) || Number.isNaN(e.getTime())) return "";
const opts: Intl.DateTimeFormatOptions = { month: "long", day: "numeric", year: "numeric" };
if (s.toDateString() === e.toDateString()) {
return s.toLocaleDateString(undefined, opts);
}
return `${s.toLocaleDateString(undefined, opts)} – ${e.toLocaleDateString(undefined, opts)}`;
}
function parseLoginContext(
type: string | null,
id: string | null,
eventIdParam: string | null,
): { type: VisitorJoinType; eventId: string; sessionId?: string; trackId?: string } | null {
if (type !== "event" && type !== "session" && type !== "track") return null;
if (!id) return null;
if ((type === "session" || type === "track") && !eventIdParam) return null;
return {
type,
eventId: type === "event" ? id : eventIdParam!,
sessionId: type === "session" ? id : undefined,
trackId: type === "track" ? id : undefined,
};
}
export function LoginPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [email, setEmail] = useState("");
const [inviteInput, setInviteInput] = useState("");
const [preview, setPreview] = useState<LoginPreviewResponse | null>(null);
const [loadingPreview, setLoadingPreview] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [linkSent, setLinkSent] = useState(false);
const type = searchParams.get("type");
const id = searchParams.get("id");
const eventIdParam = searchParams.get("eventId");
const inviteFromUrl = searchParams.get("invite") || searchParams.get("inviteToken") || "";
const ctx = parseLoginContext(type, id, eventIdParam);
const reg = useEventRegistrationSettings(preview);
useEffect(() => {
if (inviteFromUrl) setInviteInput(inviteFromUrl);
}, [inviteFromUrl]);
useEffect(() => {
const parsed = parseLoginContext(type, id, eventIdParam);
if (!parsed) {
setPreview(null);
setPreviewError(null);
setLoadingPreview(false);
return;
}
const apiBase = import.meta.env.VITE_API_BASE_URL;
if (typeof apiBase !== "string" || !apiBase.trim()) {
setPreview(null);
setPreviewError("Missing VITE_API_BASE_URL. Add it to .env.");
setLoadingPreview(false);
return;
}
let cancelled = false;
setLoadingPreview(true);
setPreviewError(null);
(async () => {
try {
const data = await getVisitorLoginPreview(parsed.eventId);
if (cancelled) return;
setPreview(data);
const stored: EbVisitorContext = {
type: parsed.type,
eventId: parsed.eventId,
sessionId: parsed.sessionId,
trackId: parsed.trackId,
event: data,
fetchedAt: new Date().toISOString(),
};
try {
localStorage.setItem(EB_VISITOR_CONTEXT_KEY, JSON.stringify(stored));
} catch {
/* ignore */
}
} catch (e) {
if (!cancelled) {
setPreview(null);
setPreviewError(e instanceof Error ? e.message : "Could not load event.");
}
} finally {
if (!cancelled) setLoadingPreview(false);
}
})();
return () => {
cancelled = true;
};
}, [type, id, eventIdParam]);
const schedule =
preview && preview.startDate && preview.endDate
? formatEventSchedule(
typeof preview.startDate === "string"
? preview.startDate
: new Date(preview.startDate as unknown as Date).toISOString(),
typeof preview.endDate === "string"
? preview.endDate
: new Date(preview.endDate as unknown as Date).toISOString(),
)
: "";
const headline =
preview?.name ??
(ctx && loadingPreview ? "Loading…" : ctx && previewError ? "Event" : "Your event");
const showMeta = Boolean(preview && (schedule || (preview.location && preview.location.trim())));
const inviteToken =
reg.registrationMode === "Invite Only" ? inviteInput.trim() || undefined : undefined;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitError(null);
setLinkSent(false);
if (!ctx || !preview) return;
const joinCtx = { type: ctx.type, sessionId: ctx.sessionId, trackId: ctx.trackId };
setLoading(true);
try {
if (reg.authenticationType === "none") {
const guestTok = getGuestTokenForEvent(ctx.eventId);
const data = await postVisitorGuest(ctx.eventId, joinCtx, {
inviteToken,
guestToken: guestTok || undefined,
});
setAttendeeSession(data.token, data.participant);
if (data.guestToken) setGuestTokenForEvent(ctx.eventId, data.guestToken);
notifyRewardFromApiData(data);
if (data.pendingApproval) {
navigate("/pending-approval");
return;
}
if (data.needsProfile) {
navigate("/complete-profile");
return;
}
navigate(buildPostAuthUrl(ctx));
return;
}
const em = email.trim().toLowerCase();
if (!em) {
setSubmitError("Email is required.");
setLoading(false);
return;
}
if (reg.authenticationType === "otp_email") {
await postVisitorStartOtp(ctx.eventId, em, joinCtx, inviteToken);
navigate("/register/otp", {
state: {
eventId: ctx.eventId,
email: em,
inviteToken,
},
});
return;
}
if (reg.authenticationType === "link_email") {
await postVisitorStartLink(ctx.eventId, em, joinCtx, inviteToken);
setLinkSent(true);
return;
}
} catch (err) {
setSubmitError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setLoading(false);
}
}
if (!ctx) {
return (
<AuthShell>
<div className="max-w-md px-4 text-center text-sm text-white/95">
<p>Open this app from your event link or QR code to sign in.</p>
</div>
</AuthShell>
);
}
const closedHint = reg.newRegistrationBlocked
? "New registration may be closed. If you already registered, you can still sign in with the same email."
: null;
const showEmail = reg.authenticationType !== "none";
const showInviteField = reg.registrationMode === "Invite Only";
return (
<AuthShell>
<div className="flex w-full max-w-[354px] flex-col items-center">
<div className="w-full rounded-xl shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
<div className="rounded-xl bg-white">
<div className="px-6 pb-2 pt-6">
<div className="flex h-[103px] items-center justify-center rounded-xl border border-[#f2f2f7] px-4">
{preview?.logoUrl ? (
<img
src={preview.logoUrl}
alt=""
className="max-h-[72px] w-full max-w-[240px] object-contain object-center"
/>
) : (
<EventBuzzLogo variant="color" className="max-h-[72px] w-auto object-center" />
)}
</div>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-8 px-6 pb-8 pt-2">
<div className="flex flex-col items-center gap-4 text-center">
<p className="font-inter text-base font-semibold text-[#24282f]">Log in to</p>
<div className="min-w-0 space-y-1">
<p className="font-inter text-[28px] font-bold leading-tight text-[#24282f] sm:text-[32px]">
{headline}
</p>
{preview?.subtitle ? (
<p className="font-inter text-sm font-medium text-[#24282f]/80">{preview.subtitle}</p>
) : null}
</div>
{previewError ? (
<p className="text-sm text-red-600" role="alert">
{previewError}
</p>
) : null}
{closedHint ? (
<p className="text-xs text-amber-800" role="status">
{closedHint}
</p>
) : null}
{showMeta ? (
<div className="flex flex-wrap items-center justify-center gap-2 text-xs text-[#24282f]">
{schedule ? (
<>
<Calendar className="size-4 shrink-0" strokeWidth={1.75} />
<span className="font-inter">{schedule}</span>
</>
) : null}
{schedule && preview?.location?.trim() ? (
<span className="hidden h-3 w-px bg-[rgba(36,40,47,0.5)] sm:inline" aria-hidden />
) : null}
{preview?.location?.trim() ? (
<>
<MapPin className="size-4 shrink-0" strokeWidth={1.75} />
<span className="font-inter">{preview.location.trim()}</span>
</>
) : null}
</div>
) : null}
</div>
{showInviteField ? (
<label className="flex flex-col gap-2">
<span className="font-inter text-sm font-semibold text-[#24282f]">Invite code</span>
<input
value={inviteInput}
onChange={(e) => setInviteInput(e.target.value)}
placeholder="Enter your invite code"
className="h-[44px] w-full rounded-lg border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] px-4 font-inter text-sm text-[#24282f]"
/>
</label>
) : null}
{showEmail ? (
<label className="flex flex-col gap-2">
<span className="font-inter text-base font-semibold text-[#24282f]">Email address</span>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email address"
className="h-[51px] w-full rounded-lg border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] px-4 font-inter text-sm tracking-wide text-[#24282f] placeholder:text-[rgba(36,40,47,0.6)]"
/>
</label>
) : null}
{linkSent ? (
<p className="text-center text-sm text-[#009951]">
Check your email for a sign-in link. It expires in 15 minutes.
</p>
) : null}
{submitError ? (
<p className="text-center text-sm text-red-600" role="alert">
{submitError}
</p>
) : null}
<button
type="submit"
disabled={loading || loadingPreview}
className="w-full rounded-full border border-[rgba(214,200,200,0.6)] bg-[#ef8d1d] py-3 text-xs font-semibold uppercase tracking-wide text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)] disabled:opacity-60"
>
{loading ? "Please wait…" : reg.primaryCtaLabel}
</button>
</form>
</div>
</div>
</div>
</AuthShell>
);
}

View File

@ -0,0 +1,29 @@
import { AuthShell } from "../components/auth/AuthShell";
import { getAttendeeParticipant } from "../lib/attendeeAuth";
import { readStoredVisitorContext } from "../types/visitorContext";
export function PendingApprovalPage() {
const ctx = readStoredVisitorContext();
const p = getAttendeeParticipant();
return (
<AuthShell>
<div className="flex w-full max-w-[354px] flex-col items-center gap-6 rounded-xl bg-white px-6 py-8 text-center shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
<h1 className="font-inter text-xl font-semibold text-[#24282f]">
Registration pending approval
</h1>
<p className="text-sm leading-relaxed text-[rgba(36,40,47,0.87)]">
Your registration for{" "}
<span className="font-semibold text-[#24282f]">{ctx?.event?.name ?? "this event"}</span> is
waiting for organizer approval. You will be able to use the app once your status is
confirmed.
</p>
{p?.email ? (
<p className="text-xs text-[rgba(36,40,47,0.6)]">
Signed in as <span className="font-medium">{p.email}</span>
</p>
) : null}
</div>
</AuthShell>
);
}

View File

@ -0,0 +1,166 @@
import { LegalList, LegalPageLayout, LegalSection } from "../components/marketing/LegalPageLayout";
const LAST_UPDATED = "May 15, 2026";
export function PrivacyPolicyPage() {
return (
<LegalPageLayout title="Privacy Policy" lastUpdated={LAST_UPDATED}>
<LegalSection title="1. Introduction">
<p>
EventBuzz (&quot;EventBuzz,&quot; &quot;we,&quot; &quot;us,&quot; or &quot;our&quot;) provides an
event engagement platform for organizers and attendees, including live Q&amp;A, polls, quizzes,
agendas, feedback, and related services available at{" "}
<a href="https://eventbuzz.live" className="font-semibold text-[#1d66ef] hover:underline">
eventbuzz.live
</a>{" "}
and associated applications (collectively, the &quot;Services&quot;).
</p>
<p>
This Privacy Policy explains how we collect, use, disclose, and protect personal information when
you use our marketing site, attendee experiences, and organizer console at{" "}
<a
href="https://console.eventbuzz.live"
className="font-semibold text-[#1d66ef] hover:underline"
>
console.eventbuzz.live
</a>
. By using the Services, you agree to the practices described here.
</p>
</LegalSection>
<LegalSection title="2. Information we collect">
<p>Depending on how you use EventBuzz, we may collect:</p>
<LegalList
items={[
"Account and profile information (such as name, email address, phone number, organization, and role).",
"Event and registration data you provide as an organizer or attendee, including custom registration fields, session selections, and bookmarks.",
"Content you submit through the Services, including questions, poll responses, quiz answers, ratings, feedback, and messages.",
"Technical and usage data (such as IP address, device type, browser, pages viewed, and approximate location derived from IP).",
"Communications with us (support requests, contact forms, and email correspondence).",
"Payment-related information processed by our payment partners; we do not store full card numbers on our servers.",
]}
/>
</LegalSection>
<LegalSection title="3. How we use information">
<p>We use personal information to:</p>
<LegalList
items={[
"Provide, operate, and improve the Services.",
"Authenticate users and manage event access (including codes, links, and OTP verification where enabled).",
"Enable organizers to run events, view engagement analytics, and communicate with attendees.",
"Process registrations, feedback, and participation features you choose to use.",
"Send service-related messages (such as login links, confirmations, and security notices).",
"Detect, prevent, and address fraud, abuse, or technical issues.",
"Comply with legal obligations and enforce our Terms of Service.",
]}
/>
</LegalSection>
<LegalSection title="4. How organizers and attendees interact with data">
<p>
Event organizers control much of the data collected for their events. When you participate in an
event, the organizer (not EventBuzz alone) determines what information is collected, how it is used
for that event, and how long it is retained. Organizers should provide their own privacy notices
to attendees where required by law.
</p>
<p>
If you have questions about a specific event&apos;s use of your data, please contact the event
organizer first. We can assist where appropriate at{" "}
<a href="mailto:hello@eventbuzz.live" className="font-semibold text-[#1d66ef] hover:underline">
hello@eventbuzz.live
</a>
.
</p>
</LegalSection>
<LegalSection title="5. Sharing of information">
<p>We may share information with:</p>
<LegalList
items={[
"Service providers who help us host infrastructure, send email, process payments, analyze performance, or provide customer support, under contractual confidentiality obligations.",
"Event organizers, for data submitted in connection with their events.",
"Other participants, when you choose to post public content (such as audience questions visible to a session).",
"Authorities or third parties when required by law, court order, or to protect rights, safety, and security.",
"Successors in connection with a merger, acquisition, or sale of assets, subject to this Policy.",
]}
/>
<p>We do not sell your personal information for money.</p>
</LegalSection>
<LegalSection title="6. Cookies and similar technologies">
<p>
We use cookies and similar technologies to keep you signed in, remember preferences, measure
traffic, and improve the Services. You can control cookies through your browser settings; disabling
some cookies may limit certain features.
</p>
</LegalSection>
<LegalSection title="7. Data retention">
<p>
We retain information for as long as needed to provide the Services, fulfill the purposes described
in this Policy, comply with legal obligations, resolve disputes, and enforce agreements. Organizers
may export or delete event data according to their account settings and plan features.
</p>
</LegalSection>
<LegalSection title="8. Security">
<p>
We implement administrative, technical, and organizational measures designed to protect personal
information. No method of transmission or storage is completely secure; we cannot guarantee absolute
security.
</p>
</LegalSection>
<LegalSection title="9. Your rights and choices">
<p>
Depending on your location, you may have rights to access, correct, delete, restrict, or object to
certain processing of your personal information, and to data portability or withdrawal of consent
where applicable.
</p>
<p>
To exercise these rights, contact us at{" "}
<a href="mailto:hello@eventbuzz.live" className="font-semibold text-[#1d66ef] hover:underline">
hello@eventbuzz.live
</a>
. We may need to verify your identity before responding. You may also unsubscribe from marketing
emails using the link in those messages.
</p>
</LegalSection>
<LegalSection title="10. Children's privacy">
<p>
The Services are not directed to children under 16. We do not knowingly collect personal
information from children under 16. If you believe we have done so, contact us and we will take
appropriate steps to delete the information.
</p>
</LegalSection>
<LegalSection title="11. International transfers">
<p>
We may process information in countries other than where you live. Where required, we use appropriate
safeguards for cross-border transfers consistent with applicable law.
</p>
</LegalSection>
<LegalSection title="12. Changes to this Policy">
<p>
We may update this Privacy Policy from time to time. We will post the revised version with an updated
&quot;Last updated&quot; date. Material changes may be communicated through the Services or by email
where appropriate. Continued use after changes take effect constitutes acceptance of the updated
Policy.
</p>
</LegalSection>
<LegalSection title="13. Contact us">
<p>
For privacy questions or requests, contact EventBuzz at{" "}
<a href="mailto:hello@eventbuzz.live" className="font-semibold text-[#1d66ef] hover:underline">
hello@eventbuzz.live
</a>
.
</p>
</LegalSection>
</LegalPageLayout>
);
}

202
src/pages/Questions.tsx Normal file
View File

@ -0,0 +1,202 @@
import { useEffect, useState } from "react";
import { ChevronRight } from "lucide-react";
import { Link, useSearchParams } from "react-router-dom";
import { getVisitorLoginPreview, getVisitorSessionPreview } from "../lib/api";
import { useSessionQuestionsRtdb } from "../hooks/useSessionQuestionsRtdb";
import { AudienceQuestionCard } from "../components/AudienceQuestionCard";
import { AskQuestionModal } from "../components/AskQuestionModal";
import { HeaderMobile } from "../components/HeaderMobile";
import { HyperNavigationFAB } from "../components/HyperNavigationFAB";
import { MobileShell } from "../components/MobileShell";
import type { LoginPreviewResponse } from "../types/visitorContext";
import { readStoredVisitorContext } from "../types/visitorContext";
function headerLogoForEvent(
eventId: string,
ev: LoginPreviewResponse | null | undefined,
ctx: ReturnType<typeof readStoredVisitorContext>,
): string | undefined {
const fromPreview = ev?.logoUrl?.trim();
if (fromPreview) return fromPreview;
if (ctx?.eventId === eventId && ctx.event?.logoUrl?.trim()) return ctx.event.logoUrl.trim();
return undefined;
}
export function Questions() {
const [searchParams] = useSearchParams();
const eventId = searchParams.get("event_id")?.trim() || "";
const sessionId = searchParams.get("session_id")?.trim() || "";
const ctx = readStoredVisitorContext();
const [eventInfo, setEventInfo] = useState<LoginPreviewResponse | null>(null);
const [sessionTitle, setSessionTitle] = useState<string | null>(null);
const [askOpen, setAskOpen] = useState(false);
const { items: rtdbItems, loading: listLoading, error: listError } = useSessionQuestionsRtdb(
sessionId || null,
eventId || null,
);
const list = rtdbItems.map((r) => ({
_id: r._id,
author: r.author,
text: r.text,
up: r.up,
down: r.down,
}));
useEffect(() => {
if (!eventId) {
setEventInfo(null);
return;
}
if (ctx?.eventId === eventId && ctx.event) {
setEventInfo(ctx.event);
return;
}
let c = false;
getVisitorLoginPreview(eventId)
.then((ev) => {
if (!c) setEventInfo(ev);
})
.catch(() => {
if (!c) setEventInfo(null);
});
return () => {
c = true;
};
}, [eventId, ctx?.eventId]);
useEffect(() => {
if (!eventId || !sessionId) {
setSessionTitle(null);
return;
}
let c = false;
getVisitorSessionPreview(sessionId, eventId)
.then((s) => {
if (!c) setSessionTitle(s.title);
})
.catch(() => {
if (!c) setSessionTitle(null);
});
return () => {
c = true;
};
}, [eventId, sessionId]);
const ev = ctx?.eventId === eventId ? ctx.event : eventInfo;
const headerLogoUrl = headerLogoForEvent(eventId, ev, ctx);
const sessionHref =
eventId && sessionId
? `/session?event_id=${encodeURIComponent(eventId)}&session_id=${encodeURIComponent(sessionId)}`
: null;
const trackHref = eventId ? `/track-v2?event_id=${encodeURIComponent(eventId)}` : "/";
if (!eventId) {
return (
<MobileShell className="relative">
<HeaderMobile eventId={eventId || null} />
<main className="flex flex-1 flex-col items-center justify-center bg-[#f2f2f7] px-4 py-10">
<p className="text-center text-sm text-[#24282f]">
Add <span className="font-mono">event_id</span> to the URL to open questions.
</p>
<Link to="/" className="mt-4 text-sm font-semibold text-[#1e72ef] underline">
Home
</Link>
</main>
</MobileShell>
);
}
if (!sessionId) {
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={headerLogoUrl} eventId={eventId || null} />
<main className="relative z-0 flex flex-1 flex-col bg-[#f2f2f7] px-4 pb-28 pt-6">
<div className="mx-auto w-full max-w-[370px]">
<h1 className="text-lg font-semibold text-[#24282f]">{ev?.name ?? "Event"}</h1>
<p className="mt-3 text-sm leading-relaxed text-[rgba(36,40,47,0.75)]">
Open a session from the agenda to view all audience questions and ask your own.
</p>
<Link
to={trackHref}
className="mt-6 inline-flex items-center gap-1 text-sm font-semibold text-[#1e72ef] underline"
>
Browse sessions
</Link>
</div>
</main>
<HyperNavigationFAB eventId={eventId} sessionId={sessionId || undefined} />
</MobileShell>
);
}
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={headerLogoUrl} eventId={eventId || null} />
<main className="relative z-0 flex flex-1 flex-col bg-[#f2f2f7] pb-28 pt-6">
<div className="mx-auto w-full max-w-[370px] px-4">
<div className="mb-1 inline-flex w-fit rounded bg-[#e5e5ea] px-2 py-0.5">
<span className="text-[10px] tracking-wide text-[#24282f]">Audience questions</span>
</div>
<h1 className="text-lg font-semibold text-[#24282f]">
{sessionTitle ?? "Session questions"}
</h1>
{ev?.name ? (
<p className="mt-1 text-xs text-[rgba(36,40,47,0.65)]">{ev.name}</p>
) : null}
<button
type="button"
onClick={() => setAskOpen(true)}
className="mt-4 flex h-11 w-full items-center justify-between rounded-full bg-[#ef8d1d] px-[18px] text-left shadow-sm transition hover:bg-[#e0800c]"
>
<span className="text-xs font-semibold uppercase tracking-wide text-white">
Ask a question
</span>
<ChevronRight className="size-4 text-white" strokeWidth={2.5} />
</button>
<div className="mt-6 flex flex-col gap-3">
{listLoading ? (
<p className="text-center text-sm text-[rgba(36,40,47,0.55)]">Loading questions…</p>
) : listError ? (
<p className="text-center text-sm text-red-600">{listError}</p>
) : list.length === 0 ? (
<p className="text-center text-sm text-[rgba(36,40,47,0.55)]">No questions yet.</p>
) : (
list.map((item, i) => (
<AudienceQuestionCard
key={item._id ?? `${i}-${item.text.slice(0, 40)}`}
item={item}
voteContext={{ eventId, sessionId }}
/>
))
)}
</div>
{sessionHref ? (
<p className="mt-6 text-center text-xs">
<Link className="text-[#1e72ef] underline" to={sessionHref}>
Back to session
</Link>
</p>
) : null}
</div>
</main>
<AskQuestionModal
open={askOpen}
onClose={() => setAskOpen(false)}
eventId={eventId}
sessionId={sessionId}
idSuffix="-questions-page"
/>
<HyperNavigationFAB eventId={eventId} sessionId={sessionId || undefined} />
</MobileShell>
);
}

View File

@ -0,0 +1,817 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { HeaderMobile } from "../components/HeaderMobile";
import { MobileShell } from "../components/MobileShell";
import {
getVisitorLoginPreview,
getVisitorQuizPollResultsSummary,
postVisitorQuizPollResponse,
type VisitorPollResultsSummary,
type VisitorQuizPollResponseItem,
} from "../lib/api";
import { isFirebaseClientConfigured } from "../lib/firebaseClient";
import type { RtdbQuizPollDetail, RtdbQuizPollQuestion } from "../lib/sessionQuizPollsRtdb";
import {
firstQuizMasterOrManualVisibleId,
isQuizPollVisibleForAudience,
subscribeSessionQuizPollDetail,
} from "../lib/sessionQuizPollsRtdb";
import { useSessionQuizPollsRtdb } from "../hooks/useSessionQuizPollsRtdb";
import { getAttendeeToken } from "../lib/attendeeAuth";
import { notifyRewardFromApiData } from "../lib/rewardCelebration";
import type { LoginPreviewResponse } from "../types/visitorContext";
import { readStoredVisitorContext, visitorEventUrl } from "../types/visitorContext";
function typeHeading(type: string): string {
const t = type.toLowerCase();
if (t === "poll") return "Poll";
if (t === "survey") return "Survey";
return "Quiz";
}
function formatCountdown(totalSeconds: number): string {
const s = Math.max(0, totalSeconds);
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${r.toString().padStart(2, "0")}`;
}
function formatUserAnswerLine(q: RtdbQuizPollQuestion, raw: unknown): string {
if (q.type === "text") return typeof raw === "string" && raw.trim() ? raw.trim() : "—";
if (q.type === "rating") return raw != null && String(raw).trim() !== "" ? String(raw) : "—";
if (q.type === "multiple") {
const ids = Array.isArray(raw) ? raw.map(String) : [];
const labels = q.options.filter((o) => ids.includes(o._id)).map((o) => o.text);
return labels.length ? labels.join(", ") : "—";
}
const id = typeof raw === "string" ? raw : "";
return q.options.find((o) => o._id === id)?.text || "—";
}
/** null = not auto-graded (text/rating) */
function userAnswerCorrectQuiz(q: RtdbQuizPollQuestion, raw: unknown): boolean | null {
if (q.type === "text" || q.type === "rating") return null;
if (q.type === "single") {
const id = typeof raw === "string" ? raw : "";
const opt = q.options.find((o) => o._id === id);
if (!opt) return false;
return opt.isCorrect;
}
if (q.type === "multiple") {
const correctIds = new Set(q.options.filter((o) => o.isCorrect).map((o) => o._id));
const sel = new Set(Array.isArray(raw) ? raw.map(String) : []);
if (correctIds.size === 0) return false;
return correctIds.size === sel.size && [...correctIds].every((id) => sel.has(id));
}
return null;
}
function correctAnswerLine(q: RtdbQuizPollQuestion): string | null {
if (q.type === "text" || q.type === "rating") return null;
const correct = q.options.filter((o) => o.isCorrect).map((o) => o.text);
if (!correct.length) return null;
return correct.join(", ");
}
function buildQuizResponses(
quiz: RtdbQuizPollDetail,
answers: Record<string, string | string[]>,
): { items: VisitorQuizPollResponseItem[]; totalScore: number } {
const isQuizType = quiz.type.toLowerCase() === "quiz";
let totalScore = 0;
const items = quiz.questions.map((q, questionIndex) => {
const raw = answers[q._id];
let answer: unknown;
if (q.type === "multiple") {
answer = Array.isArray(raw) ? raw : raw !== undefined ? [raw as string] : [];
} else if (q.type === "text") {
answer = typeof raw === "string" ? raw : "";
} else {
answer = raw ?? null;
}
if (!isQuizType) {
return { questionIndex, answer };
}
let isCorrect = false;
let pointsEarned = 0;
const pts = q.points ?? 1;
if (q.type === "single" || q.type === "multiple") {
const correctIds = new Set(q.options.filter((o) => o.isCorrect).map((o) => o._id));
if (q.type === "single") {
isCorrect = typeof raw === "string" && correctIds.has(raw);
} else {
const sel = new Set(Array.isArray(raw) ? raw : []);
isCorrect =
correctIds.size > 0 && correctIds.size === sel.size && [...correctIds].every((id) => sel.has(id));
}
pointsEarned = isCorrect ? pts : 0;
}
totalScore += pointsEarned;
return { questionIndex, answer, isCorrect, pointsEarned };
});
return { items, totalScore };
}
export function QuizPollTakePage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const eventId = searchParams.get("event_id")?.trim() || "";
const sessionId = searchParams.get("session_id")?.trim() || "";
const quizPollId = searchParams.get("quiz_poll_id")?.trim() || "";
const { items: sessionLiveQuizPolls, loading: sessionQuizPollListLoading } =
useSessionQuizPollsRtdb(sessionId);
const ctx = readStoredVisitorContext();
const [quiz, setQuiz] = useState<RtdbQuizPollDetail | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [eventInfo, setEventInfo] = useState<LoginPreviewResponse | null>(null);
const sessionStartedAtRef = useRef<number>(0);
const sessionKeyRef = useRef<string | null>(null);
const autoSubmitDoneRef = useRef(false);
const submitOnceRef = useRef(false);
const sessionBackHref =
eventId && sessionId
? `/session?${new URLSearchParams({ event_id: eventId, session_id: sessionId }).toString()}`
: eventId
? visitorEventUrl(eventId)
: "/";
useEffect(() => {
if (!eventId) {
setEventInfo(null);
return;
}
if (ctx?.eventId === eventId && ctx.event) {
setEventInfo(ctx.event);
return;
}
let cancelled = false;
getVisitorLoginPreview(eventId)
.then((ev) => {
if (!cancelled) setEventInfo(ev);
})
.catch(() => {
if (!cancelled) setEventInfo(null);
});
return () => {
cancelled = true;
};
}, [eventId, ctx?.eventId]);
useEffect(() => {
if (!eventId || !sessionId || !quizPollId) {
setErr("Invalid quiz link.");
setQuiz(null);
setLoading(false);
return;
}
if (!isFirebaseClientConfigured()) {
setErr("Firebase is not configured. Set VITE_FIREBASE_API_KEY and VITE_FIREBASE_DATABASE_URL.");
setQuiz(null);
setLoading(false);
return;
}
setLoading(true);
setErr(null);
const unsub = subscribeSessionQuizPollDetail(
sessionId,
quizPollId,
(detail) => {
setQuiz(detail);
setLoading(false);
if (!detail) setErr("This quiz was removed or is unavailable.");
},
(e) => {
setErr(e.message);
setQuiz(null);
setLoading(false);
},
);
return () => unsub();
}, [eventId, sessionId, quizPollId]);
useEffect(() => {
if (!eventId || !sessionId || !quizPollId) return;
if (sessionQuizPollListLoading) return;
const target = firstQuizMasterOrManualVisibleId(sessionLiveQuizPolls);
if (!target || target === quizPollId) return;
navigate(
`/quiz-poll?${new URLSearchParams({
event_id: eventId,
session_id: sessionId,
quiz_poll_id: target,
}).toString()}`,
{ replace: true },
);
}, [
eventId,
sessionId,
quizPollId,
sessionQuizPollListLoading,
sessionLiveQuizPolls,
navigate,
]);
const [answers, setAnswers] = useState<Record<string, string | string[]>>({});
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [scoreSubmitted, setScoreSubmitted] = useState<number | null>(null);
const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
const [pollResults, setPollResults] = useState<VisitorPollResultsSummary | null>(null);
const [pollResultsLoading, setPollResultsLoading] = useState(false);
const [pollResultsErr, setPollResultsErr] = useState<string | null>(null);
const [questionStep, setQuestionStep] = useState(0);
const mainScrollRef = useRef<HTMLElement | null>(null);
useEffect(() => {
setAnswers({});
setSubmitted(false);
setSubmitError(null);
setScoreSubmitted(null);
setSecondsLeft(null);
setPollResults(null);
setPollResultsErr(null);
setPollResultsLoading(false);
setQuestionStep(0);
sessionKeyRef.current = null;
autoSubmitDoneRef.current = false;
submitOnceRef.current = false;
}, [quizPollId, quiz?._id]);
useEffect(() => {
if (!quiz?.questions?.length) return;
setQuestionStep((s) => Math.min(s, Math.max(0, quiz.questions.length - 1)));
}, [quiz?.questions?.length, quiz?._id]);
useEffect(() => {
mainScrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, [questionStep]);
useEffect(() => {
if (!submitted || !quiz || quiz.settings.showResults === false) return;
const t = quiz.type.toLowerCase();
if (t !== "poll" && t !== "survey") return;
if (!eventId || !quizPollId) return;
let cancelled = false;
setPollResultsLoading(true);
setPollResultsErr(null);
getVisitorQuizPollResultsSummary(eventId, quizPollId)
.then((data) => {
if (!cancelled) {
setPollResults(data);
setPollResultsErr(null);
}
})
.catch((e) => {
if (!cancelled) {
setPollResultsErr(e instanceof Error ? e.message : "Could not load results.");
setPollResults(null);
}
})
.finally(() => {
if (!cancelled) setPollResultsLoading(false);
});
return () => {
cancelled = true;
};
}, [submitted, quiz, eventId, quizPollId]);
const timerActive = Boolean(
quiz &&
quiz.settings.timeRestricted === true &&
typeof quiz.settings.timeLimit === "number" &&
quiz.settings.timeLimit > 0,
);
const timeLimitSec = timerActive ? Math.max(1, quiz!.settings.timeLimit ?? 0) : 0;
useEffect(() => {
if (!quiz || submitted) return;
const key = `${quiz._id}-${quizPollId}`;
if (sessionKeyRef.current !== key) {
sessionKeyRef.current = key;
sessionStartedAtRef.current = Date.now();
autoSubmitDoneRef.current = false;
}
}, [quiz, quizPollId, submitted]);
const runSubmit = useCallback(async () => {
if (!quiz || !eventId || !quizPollId || submitted || submitOnceRef.current) return;
submitOnceRef.current = true;
setSubmitting(true);
setSubmitError(null);
if (!getAttendeeToken()) {
setSubmitError("Sign in to submit your response.");
submitOnceRef.current = false;
setSubmitting(false);
return;
}
const { items, totalScore } = buildQuizResponses(quiz, answers);
const elapsedSec = Math.max(
0,
Math.round((Date.now() - (sessionStartedAtRef.current || Date.now())) / 1000),
);
try {
const submitResult = await postVisitorQuizPollResponse(eventId, quizPollId, {
responses: items,
totalScore: quiz.type.toLowerCase() === "quiz" ? totalScore : undefined,
timeSpent: elapsedSec,
});
notifyRewardFromApiData(submitResult);
setSubmitted(true);
if (quiz.type.toLowerCase() === "quiz" && quiz.settings.showResults !== false) {
setScoreSubmitted(totalScore);
} else {
setScoreSubmitted(null);
}
} catch (e) {
setSubmitError(e instanceof Error ? e.message : "Could not submit.");
submitOnceRef.current = false;
} finally {
setSubmitting(false);
}
}, [quiz, eventId, quizPollId, answers, submitted]);
useEffect(() => {
if (!quiz || submitted || !timerActive) {
setSecondsLeft(null);
return;
}
const deadline = sessionStartedAtRef.current + timeLimitSec * 1000;
const tick = () => {
const left = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
setSecondsLeft(left);
if (left <= 0 && !autoSubmitDoneRef.current) {
autoSubmitDoneRef.current = true;
void runSubmit();
}
};
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, [quiz?._id, quizPollId, submitted, timerActive, timeLimitSec, runSubmit, quiz]);
const headerLogo = useMemo(() => {
if (ctx?.eventId === eventId && ctx.event?.logoUrl?.trim()) return ctx.event.logoUrl.trim();
return eventInfo?.logoUrl?.trim();
}, [ctx, eventId, eventInfo?.logoUrl]);
const setSingle = (q: RtdbQuizPollQuestion, optionId: string) => {
setAnswers((prev) => ({ ...prev, [q._id]: optionId }));
};
const toggleMulti = (q: RtdbQuizPollQuestion, optionId: string) => {
setAnswers((prev) => {
const cur = prev[q._id];
const list = Array.isArray(cur) ? [...cur] : [];
const i = list.indexOf(optionId);
if (i >= 0) list.splice(i, 1);
else list.push(optionId);
return { ...prev, [q._id]: list };
});
};
const setText = (q: RtdbQuizPollQuestion, text: string) => {
setAnswers((prev) => ({ ...prev, [q._id]: text }));
};
const signedIn = Boolean(getAttendeeToken());
if (!eventId || !sessionId || !quizPollId) {
return (
<MobileShell className="relative">
<HeaderMobile eventId={null} />
<main className="flex flex-1 flex-col items-center justify-center bg-[#f2f2f7] px-4 py-10">
<p className="text-center text-sm text-[#24282f]">Invalid quiz link.</p>
<Link to="/" className="mt-4 text-sm font-semibold text-[#1e72ef] underline">
Home
</Link>
</main>
</MobileShell>
);
}
if (loading) {
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={headerLogo} eventId={eventId} />
<main className="flex flex-1 items-center justify-center bg-[#f2f2f7] px-4 py-10">
<p className="text-sm text-[#24282f]">Loading…</p>
</main>
</MobileShell>
);
}
if (err || !quiz) {
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={headerLogo} eventId={eventId} />
<main className="flex flex-1 flex-col items-center justify-center bg-[#f2f2f7] px-4 py-10">
<p className="text-center text-sm text-red-600">{err ?? "Quiz not found."}</p>
<Link to={sessionBackHref} className="mt-4 text-sm font-semibold text-[#1e72ef] underline">
Back to session
</Link>
</main>
</MobileShell>
);
}
const quizVisible = isQuizPollVisibleForAudience(quiz.settings, new Date());
if (!quizVisible) {
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={headerLogo} eventId={eventId} />
<main className="flex flex-1 flex-col items-center justify-center bg-[#f2f2f7] px-4 py-10">
<p className="text-center text-sm text-[#24282f]">
This {typeHeading(quiz.type).toLowerCase()} is not available right now.
</p>
<Link to={sessionBackHref} className="mt-4 text-sm font-semibold text-[#1e72ef] underline">
Back to session
</Link>
</main>
</MobileShell>
);
}
if (submitted && !quiz) {
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={headerLogo} eventId={eventId} />
<main className="flex flex-1 flex-col items-center justify-center bg-[#f2f2f7] px-4 py-10">
<p className="font-inter text-lg font-semibold text-[#24282f]">Thank you</p>
<p className="mt-2 text-center text-sm text-[#24282f]">Your response was submitted.</p>
<Link
to={sessionBackHref}
className="mt-6 text-sm font-semibold text-[#1e72ef] underline"
>
Back to session
</Link>
</main>
</MobileShell>
);
}
if (submitted && quiz) {
const showRes = quiz.settings.showResults !== false;
const isQuiz = quiz.type.toLowerCase() === "quiz";
const isPollOrSurvey =
quiz.type.toLowerCase() === "poll" || quiz.type.toLowerCase() === "survey";
return (
<MobileShell className="relative h-dvh min-h-0 flex-col">
<div className="flex shrink-0 flex-col">
<HeaderMobile logoUrl={headerLogo} eventId={eventId} />
</div>
<main className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-[#f2f2f7] px-4 pb-8 pt-6">
<div className="mx-auto w-full max-w-[370px]">
<div className="rounded-lg border border-[rgba(116,112,112,0.2)] bg-white p-5 text-center shadow-sm">
<p className="font-inter text-lg font-semibold text-[#24282f]">Thank you</p>
<p className="mt-1 text-sm text-[#24282f]">Your response was submitted.</p>
{showRes && isQuiz && typeof scoreSubmitted === "number" ? (
<p className="mt-3 text-sm font-semibold text-[#1e72ef]">Your score: {scoreSubmitted}</p>
) : null}
</div>
{showRes && isQuiz ? (
<div className="mt-6">
<p className="text-[10px] font-semibold uppercase tracking-wide text-[#1e72ef]">Results</p>
<p className="mt-0.5 text-xs text-[rgba(36,40,47,0.55)]">Questions with correct answers</p>
<ul className="mt-4 flex flex-col gap-4">
{quiz.questions.map((q, qi) => {
const raw = answers[q._id];
const graded = userAnswerCorrectQuiz(q, raw);
const corr = correctAnswerLine(q);
return (
<li
key={q._id}
className="rounded-lg border border-[rgba(116,112,112,0.2)] bg-white p-4 shadow-sm"
>
<p className="text-sm font-semibold text-[#24282f]">
<span className="text-[#8e8e93]">{qi + 1}. </span>
{q.question}
</p>
<p className="mt-2 text-[10px] font-medium uppercase tracking-wide text-[#8e8e93]">
Your answer
</p>
<p
className={`text-sm font-medium ${
graded === true
? "text-green-700"
: graded === false
? "text-red-600"
: "text-[#24282f]"
}`}
>
{formatUserAnswerLine(q, raw)}
{graded === true ? " ✓" : graded === false ? " ✗" : ""}
</p>
{corr ? (
<>
<p className="mt-2 text-[10px] font-medium uppercase tracking-wide text-[#8e8e93]">
Correct answer
</p>
<p className="text-sm font-semibold text-green-800">{corr}</p>
</>
) : q.type === "text" ? (
<p className="mt-2 text-xs text-[rgba(36,40,47,0.55)]">
Open-ended — no single correct answer.
</p>
) : q.type === "rating" ? (
<p className="mt-2 text-xs text-[rgba(36,40,47,0.55)]">
Rating scale — no correct answer key.
</p>
) : null}
</li>
);
})}
</ul>
</div>
) : null}
{showRes && isPollOrSurvey ? (
<div className="mt-6">
<p className="text-[10px] font-semibold uppercase tracking-wide text-[#1e72ef]">Results</p>
<p className="mt-0.5 text-xs text-[rgba(36,40,47,0.55)]">Share of responses per option</p>
{pollResultsLoading ? (
<p className="mt-6 text-center text-sm text-[#8e8e93]">Loading results…</p>
) : null}
{pollResultsErr ? (
<p className="mt-4 text-center text-sm text-red-600">{pollResultsErr}</p>
) : null}
{pollResults && !pollResultsLoading ? (
<p className="mt-3 text-xs text-[rgba(36,40,47,0.65)]">
Based on {pollResults.totalSubmissions} submission
{pollResults.totalSubmissions === 1 ? "" : "s"}
</p>
) : null}
{pollResults && !pollResultsLoading ? (
<ul className="mt-4 flex flex-col gap-5">
{pollResults.questions.map((pq) => (
<li
key={`${pq.questionIndex}-${pq.questionId}`}
className="rounded-lg border border-[rgba(116,112,112,0.2)] bg-white p-4 shadow-sm"
>
<p className="text-sm font-semibold text-[#24282f]">{pq.question}</p>
{pq.kind === "text" ? (
<p className="mt-2 text-sm text-[rgba(36,40,47,0.75)]">
{(pq.answeredCount ?? 0) === 0
? "No text answers yet."
: `${pq.answeredCount} of ${pq.totalResponses} answered (${pq.percentAnswered ?? 0}%)`}
</p>
) : (
<div className="mt-3 flex flex-col gap-3">
{(pq.options || []).map((opt) => (
<div key={opt.optionId}>
<div className="mb-1 flex justify-between gap-2 text-xs text-[#24282f]">
<span className="min-w-0 flex-1 leading-snug">{opt.text || "—"}</span>
<span className="shrink-0 tabular-nums font-semibold text-[#1e72ef]">
{opt.percent}%
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[#e5e5ea]">
<div
className="h-full rounded-full bg-[#1e72ef]"
style={{ width: `${Math.min(100, Math.max(0, opt.percent))}%` }}
/>
</div>
</div>
))}
</div>
)}
</li>
))}
</ul>
) : null}
</div>
) : null}
<Link
to={sessionBackHref}
className="mt-8 block text-center text-sm font-semibold text-[#1e72ef] underline"
>
Back to session
</Link>
</div>
</main>
</MobileShell>
);
}
const submitLabel = submitting ? "Submitting…" : `Submit ${typeHeading(quiz.type)}`;
const totalQuestions = quiz.questions.length;
const multiQuestion = totalQuestions > 1;
const stepSafe = Math.min(questionStep, Math.max(0, totalQuestions - 1));
const footerBtnClass =
"rounded-lg px-3 py-1.5 text-center text-xs font-semibold shadow-sm disabled:opacity-50 disabled:hover:bg-inherit";
const footerSecondaryClass = `${footerBtnClass} flex-1 border border-[#e5e5ea] bg-white text-[#24282f] hover:bg-[#f2f2f7]`;
return (
<MobileShell className="relative h-dvh min-h-0 flex-col">
<div className="flex shrink-0 flex-col">
<HeaderMobile logoUrl={headerLogo} eventId={eventId} />
</div>
<main
ref={mainScrollRef}
className="relative z-0 min-h-0 flex-1 overflow-y-auto overscroll-contain bg-[#f2f2f7] px-4 pt-6"
>
<div className="mx-auto w-full max-w-[370px] pb-4">
<p className="text-[10px] font-semibold uppercase tracking-wide text-[#1e72ef]">{typeHeading(quiz.type)}</p>
<h1 className="mt-1 font-inter text-xl font-semibold text-[#24282f]">{quiz.title}</h1>
{multiQuestion ? (
<p className="mt-2 text-xs font-medium tabular-nums text-[rgba(36,40,47,0.55)]">
Question {stepSafe + 1} of {totalQuestions}
</p>
) : null}
{timerActive && secondsLeft != null ? (
<div
className={`mt-4 flex items-center justify-between rounded-lg border px-3 py-2.5 ${
secondsLeft <= 30 ? "border-amber-400 bg-amber-50" : "border-[#e5e5ea] bg-white"
}`}
>
<span className="text-xs font-medium text-[#24282f]">Time left</span>
<span
className={`font-mono text-lg font-bold tabular-nums ${
secondsLeft <= 30 ? "text-amber-800" : "text-[#24282f]"
}`}
>
{formatCountdown(secondsLeft)}
</span>
</div>
) : null}
<ul className="mt-6 flex flex-col gap-6">
{(multiQuestion ? [quiz.questions[stepSafe]!] : quiz.questions).map((q, offset) => {
const qi = multiQuestion ? stepSafe : offset;
return (
<li key={q._id} className="rounded-lg border border-[rgba(116,112,112,0.2)] bg-white p-4 shadow-sm">
<p className="text-sm font-semibold text-[#24282f]">
<span className="text-[#8e8e93]">{qi + 1}. </span>
{q.question}
</p>
{q.type === "multiple" ? (
<div className="mt-3 flex flex-col gap-2">
{q.options.map((opt) => {
const selected = Array.isArray(answers[q._id]) && answers[q._id].includes(opt._id);
return (
<label
key={opt._id}
className={`flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2 text-sm ${
selected ? "border-[#1e72ef] bg-[#1e72ef]/5" : "border-[#e5e5ea]"
}`}
>
<input
type="checkbox"
checked={selected}
onChange={() => toggleMulti(q, opt._id)}
className="mt-0.5"
/>
<span>{opt.text}</span>
</label>
);
})}
</div>
) : q.type === "text" ? (
<textarea
value={typeof answers[q._id] === "string" ? answers[q._id] : ""}
onChange={(e) => setText(q, e.target.value)}
rows={3}
className="mt-3 w-full rounded-md border border-[#e5e5ea] px-3 py-2 text-sm"
placeholder="Your answer"
/>
) : q.type === "rating" ? (
<div className="mt-3 flex flex-wrap gap-2">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
onClick={() => setSingle(q, String(n))}
className={`size-10 rounded-full border text-sm font-semibold ${
answers[q._id] === String(n)
? "border-[#1e72ef] bg-[#1e72ef] text-white"
: "border-[#e5e5ea] bg-white text-[#24282f]"
}`}
>
{n}
</button>
))}
</div>
) : (
<div className="mt-3 flex flex-col gap-2">
{q.options.map((opt) => (
<label
key={opt._id}
className={`flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2 text-sm ${
answers[q._id] === opt._id ? "border-[#1e72ef] bg-[#1e72ef]/5" : "border-[#e5e5ea]"
}`}
>
<input
type="radio"
name={`q-${q._id}`}
checked={answers[q._id] === opt._id}
onChange={() => setSingle(q, opt._id)}
className="mt-0.5"
/>
<span>{opt.text}</span>
</label>
))}
</div>
)}
</li>
);
})}
</ul>
{!signedIn ? (
<p className="mt-8 text-center text-xs text-[rgba(36,40,47,0.65)]">
{multiQuestion ? (
<>
Use <span className="font-semibold text-[#24282f]">Next</span> or{" "}
<span className="font-semibold text-[#24282f]">Submit</span> below after{" "}
<Link className="font-semibold text-[#1e72ef] underline" to={visitorEventUrl(eventId)}>
signing in
</Link>
.
</>
) : (
<>
Tap <span className="font-semibold text-[#24282f]">Submit</span> below after{" "}
<Link className="font-semibold text-[#1e72ef] underline" to={visitorEventUrl(eventId)}>
signing in
</Link>
.
</>
)}
</p>
) : null}
{submitError ? <p className="mt-4 text-center text-sm text-red-600">{submitError}</p> : null}
<p className="mt-6 text-center text-xs">
<Link className="text-[#1e72ef] underline" to={sessionBackHref}>
Back to session
</Link>
</p>
</div>
</main>
<footer
className="z-40 shrink-0 border-t border-[#e5e5ea] bg-white shadow-[0_-4px_12px_rgba(0,0,0,0.06)]"
style={{ paddingBottom: "max(12px, env(safe-area-inset-bottom))", paddingTop: 12, paddingLeft: 16, paddingRight: 16 }}
>
<div className="mx-auto flex w-full max-w-[370px] gap-2">
{multiQuestion ? (
<>
<button
type="button"
disabled={stepSafe <= 0}
onClick={() => setQuestionStep((s) => Math.max(0, s - 1))}
className={footerSecondaryClass}
>
Previous
</button>
{stepSafe >= totalQuestions - 1 ? (
<button
type="button"
disabled={submitting}
onClick={() => void runSubmit()}
className={`${footerBtnClass} flex-1 bg-orange-500 text-white hover:bg-orange-600 disabled:hover:bg-orange-500`}
>
{submitLabel}
</button>
) : (
<button
type="button"
onClick={() => setQuestionStep((s) => Math.min(totalQuestions - 1, s + 1))}
className={`${footerBtnClass} flex-1 bg-orange-500 text-white hover:bg-orange-600`}
>
Next
</button>
)}
</>
) : (
<button
type="button"
disabled={submitting}
onClick={() => void runSubmit()}
className="w-full rounded-lg bg-orange-500 px-3 py-1.5 text-center text-xs font-semibold text-white shadow-sm hover:bg-orange-600 disabled:opacity-60 disabled:hover:bg-orange-500"
>
{submitLabel}
</button>
)}
</div>
</footer>
</MobileShell>
);
}

215
src/pages/RatingsPage.tsx Normal file
View File

@ -0,0 +1,215 @@
import { useEffect, useState } from "react";
import { Star } from "lucide-react";
import { Link, useSearchParams } from "react-router-dom";
import { HeaderMobile } from "../components/HeaderMobile";
import { LegalFooterLinks } from "../components/marketing/LegalFooterLinks";
import { MobileShell } from "../components/MobileShell";
import { DynamicFeedbackForm } from "../components/DynamicFeedbackForm";
import { useEventRegistrationSettings } from "../hooks/useEventRegistrationSettings";
import { getVisitorFeedbackFormSchema, getVisitorLoginPreview, type VisitorPublicFeedbackForm } from "../lib/api";
import { readStoredVisitorContext } from "../types/visitorContext";
function StarRow({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (n: number) => void;
}) {
return (
<div className="flex w-full flex-col gap-3">
<p className="font-inter text-base font-semibold text-[#24282f]">{label}</p>
<div className="flex gap-4">
{Array.from({ length: 5 }, (_, i) => (
<button
key={i}
type="button"
onClick={() => onChange(i + 1)}
className="text-[#ef8d1d]"
aria-label={`${i + 1} stars`}
>
<Star
className={`size-6 ${i < value ? "fill-[#ef8d1d]" : "fill-transparent"}`}
strokeWidth={1.75}
/>
</button>
))}
</div>
</div>
);
}
export function RatingsPage() {
const [searchParams] = useSearchParams();
const formId = searchParams.get("form_id")?.trim() || "";
const urlEventId = searchParams.get("event_id")?.trim() || "";
const ctx = readStoredVisitorContext();
const eventId = urlEventId || ctx?.eventId || "";
const [eventSnapshot, setEventSnapshot] = useState(
ctx?.eventId === eventId && ctx.event ? ctx.event : null,
);
const [schema, setSchema] = useState<VisitorPublicFeedbackForm | null>(null);
const [schemaLoading, setSchemaLoading] = useState(false);
const [schemaErr, setSchemaErr] = useState<string | null>(null);
const [a, setA] = useState(0);
const [b, setB] = useState(0);
const [c, setC] = useState(0);
useEffect(() => {
if (!eventId) {
setEventSnapshot(null);
return;
}
if (ctx?.eventId === eventId && ctx.event) {
setEventSnapshot(ctx.event);
return;
}
let cancelled = false;
getVisitorLoginPreview(eventId)
.then((ev) => {
if (!cancelled) setEventSnapshot(ev);
})
.catch(() => {
if (!cancelled) setEventSnapshot(null);
});
return () => {
cancelled = true;
};
}, [eventId, ctx?.eventId, ctx?.event]);
useEffect(() => {
if (!formId || !eventId) {
setSchema(null);
setSchemaErr(null);
setSchemaLoading(false);
return;
}
let cancelled = false;
setSchemaLoading(true);
setSchemaErr(null);
getVisitorFeedbackFormSchema(eventId, formId)
.then((s) => {
if (!cancelled) setSchema(s);
})
.catch((e) => {
if (!cancelled) {
setSchema(null);
setSchemaErr(e instanceof Error ? e.message : "Could not load form.");
}
})
.finally(() => {
if (!cancelled) setSchemaLoading(false);
});
return () => {
cancelled = true;
};
}, [formId, eventId]);
const reg = useEventRegistrationSettings(eventSnapshot);
if (!eventId) {
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={ctx?.event?.logoUrl} eventId={null} />
<main className="flex flex-1 flex-col bg-[#f2f2f7] px-4 pb-10 pt-6">
<div className="mx-auto w-full max-w-[370px]">
<h1 className="font-inter text-xl font-semibold text-[#24282f]">Ratings</h1>
<p className="mt-3 text-sm text-[rgba(36,40,47,0.67)]">Open ratings from your event or session link.</p>
<Link to="/" className="mt-4 inline-block text-sm font-semibold text-[#1e72ef] underline">
Home
</Link>
</div>
</main>
</MobileShell>
);
}
if (!reg.enableRatings) {
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={eventSnapshot?.logoUrl} eventId={eventId || null} />
<main className="flex flex-1 flex-col bg-[#f2f2f7] px-4 pb-10 pt-6">
<div className="mx-auto w-full max-w-[370px]">
<h1 className="font-inter text-xl font-semibold text-[#24282f]">Ratings</h1>
<p className="mt-3 text-sm text-[rgba(36,40,47,0.67)]">Ratings are disabled for this event.</p>
<Link to="/event" className="mt-4 inline-block text-sm font-semibold text-[#1e72ef] underline">
Back to event
</Link>
</div>
</main>
</MobileShell>
);
}
if (formId) {
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={eventSnapshot?.logoUrl} eventId={eventId || null} />
<main className="flex flex-1 flex-col bg-[#f2f2f7] px-4 pb-10 pt-6">
<div className="mx-auto w-full max-w-[370px]">
<h1 className="font-inter text-xl font-semibold text-[#24282f]">{schema?.name || "Ratings"}</h1>
{schemaLoading ? (
<p className="mt-4 text-sm text-[rgba(36,40,47,0.67)]">Loading form…</p>
) : schemaErr ? (
<div className="mt-4">
<p className="text-sm text-red-600">{schemaErr}</p>
<Link to="/event" className="mt-4 inline-block text-sm font-semibold text-[#1e72ef] underline">
Back to event
</Link>
</div>
) : schema ? (
<div className="mt-6">
<DynamicFeedbackForm eventId={eventId} schema={schema} variant="light" />
</div>
) : null}
</div>
</main>
</MobileShell>
);
}
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={eventSnapshot?.logoUrl} eventId={eventId || null} />
<div className="eb-gradient-auth flex flex-1 flex-col pb-10 pt-4">
<h1 className="text-center font-inter text-xl font-semibold text-white">Event Ratings</h1>
<div className="mx-auto mt-6 w-full max-w-[354px] flex-1 px-4">
<div className="rounded-xl bg-white p-6 shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
<h2 className="text-center font-inter text-base font-semibold text-[#24282f]">Your Rating</h2>
<p className="mt-3 text-center text-base leading-[22px] text-[rgba(36,40,47,0.67)]">
We&apos;d love to hear your thoughts about this event. Your rating helps us improve future
events.
</p>
<div className="mt-6 flex flex-col gap-6">
<StarRow label="How will you rate the speakers?" value={a} onChange={setA} />
<StarRow label="How will you rate the quality of content?" value={b} onChange={setB} />
<StarRow
label="Probability to recommend to your friends and colleagues"
value={c}
onChange={setC}
/>
</div>
<button
type="button"
className="mt-6 w-full rounded-full border border-[rgba(214,200,200,0.6)] bg-[#ef8d1d] py-3 text-xs font-semibold uppercase tracking-wide text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)]"
>
Submit Ratings
</button>
<div className="mt-4 text-center">
<Link to="/event" className="font-inter font-semibold text-[#1e72ef] underline">
Cancel
</Link>
</div>
</div>
</div>
<footer className="mx-auto mt-6 text-sm font-light">
<LegalFooterLinks variant="light" />
</footer>
</div>
</MobileShell>
);
}

View File

@ -0,0 +1,237 @@
import { useEffect, useMemo, useState } from "react";
import { Calendar, MapPin } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import { AuthShell } from "../components/auth/AuthShell";
import { EventBuzzLogo } from "../components/EventBuzzLogo";
import { postVisitorStartOtp, postVisitorVerifyOtp } from "../lib/api";
import { setAttendeeSession } from "../lib/attendeeAuth";
import { notifyRewardFromApiData } from "../lib/rewardCelebration";
import { buildPostAuthUrl, readStoredVisitorContext } from "../types/visitorContext";
type OtpLocationState = {
eventId?: string;
email?: string;
inviteToken?: string;
firstName?: string;
lastName?: string;
phone?: string;
displayName?: string;
};
function formatEventSchedule(startIso: string, endIso: string): string {
const s = new Date(startIso);
const e = new Date(endIso);
if (Number.isNaN(s.getTime()) || Number.isNaN(e.getTime())) return "";
const opts: Intl.DateTimeFormatOptions = { month: "long", day: "numeric", year: "numeric" };
if (s.toDateString() === e.toDateString()) {
return s.toLocaleDateString(undefined, opts);
}
return `${s.toLocaleDateString(undefined, opts)} – ${e.toLocaleDateString(undefined, opts)}`;
}
export function RegisterOtpPage() {
const navigate = useNavigate();
const location = useLocation();
const st = (location.state as OtpLocationState | null) ?? {};
const eventId = st.eventId ?? "";
const email = st.email ?? "";
const ctx = readStoredVisitorContext();
const event = ctx?.event;
const [digits, setDigits] = useState<string[]>(() => Array(6).fill(""));
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [resendCooldown, setResendCooldown] = useState(0);
useEffect(() => {
if (resendCooldown <= 0) return;
const t = window.setTimeout(() => setResendCooldown((c) => c - 1), 1000);
return () => window.clearTimeout(t);
}, [resendCooldown]);
const schedule = useMemo(() => {
if (!event?.startDate || !event?.endDate) return "";
const a =
typeof event.startDate === "string"
? event.startDate
: new Date(event.startDate as unknown as Date).toISOString();
const b =
typeof event.endDate === "string"
? event.endDate
: new Date(event.endDate as unknown as Date).toISOString();
return formatEventSchedule(a, b);
}, [event]);
if (!eventId || !email) {
return (
<AuthShell>
<div className="px-4 text-center text-sm">
<p className="text-[#24282f]">Missing session. Start from the login page.</p>
<button type="button" className="mt-4 text-[#1e72ef] underline" onClick={() => navigate("/login")}>
Back to login
</button>
</div>
</AuthShell>
);
}
function setDigit(i: number, v: string) {
const d = v.replace(/\D/g, "").slice(-1);
const next = [...digits];
next[i] = d;
setDigits(next);
if (d && i < 5) {
document.getElementById(`otp-${i + 1}`)?.focus();
}
}
async function handleVerify(e: React.FormEvent) {
e.preventDefault();
const otp = digits.join("");
if (otp.length !== 6) {
setError("Enter the 6-digit code.");
return;
}
setError(null);
setLoading(true);
try {
const data = await postVisitorVerifyOtp(eventId, {
email,
otp,
inviteToken: st.inviteToken,
firstName: st.firstName,
lastName: st.lastName,
phone: st.phone,
displayName: st.displayName,
});
setAttendeeSession(data.token, data.participant);
notifyRewardFromApiData(data);
if (data.pendingApproval) {
navigate("/pending-approval");
return;
}
if (data.needsProfile) {
navigate("/complete-profile");
return;
}
navigate(buildPostAuthUrl(ctx));
} catch (err) {
setError(err instanceof Error ? err.message : "Verification failed.");
} finally {
setLoading(false);
}
}
async function handleResend() {
if (resendCooldown > 0 || !ctx) return;
setError(null);
try {
await postVisitorStartOtp(
eventId,
email,
{ type: ctx.type, sessionId: ctx.sessionId, trackId: ctx.trackId },
st.inviteToken,
);
setResendCooldown(45);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not resend.");
}
}
return (
<AuthShell>
<div className="flex w-full max-w-[354px] flex-col items-center">
<div className="w-full rounded-xl shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
<div className="rounded-xl bg-white">
<div className="px-6 pb-2 pt-6">
<div className="flex h-[103px] items-center justify-center rounded-xl border border-[#f2f2f7] px-4">
{event?.logoUrl ? (
<img
src={event.logoUrl}
alt=""
className="max-h-[72px] w-full max-w-[240px] object-contain"
/>
) : (
<EventBuzzLogo variant="color" className="max-h-[72px] w-auto object-center" />
)}
</div>
</div>
<form onSubmit={handleVerify} className="flex flex-col gap-8 px-6 pb-8 pt-2">
<div className="flex flex-col items-center gap-4 text-center">
<p className="font-inter text-base font-semibold text-[#24282f]">Enter code</p>
<div className="min-w-0 space-y-1">
<p className="font-inter text-xl font-bold text-[#24282f]">{event?.name ?? "Event"}</p>
</div>
{schedule ? (
<div className="flex flex-wrap items-center justify-center gap-2 text-xs text-[#24282f]">
<Calendar className="size-4 shrink-0" strokeWidth={1.75} />
<span className="font-inter">{schedule}</span>
{event?.location?.trim() ? (
<>
<span className="hidden h-3 w-px bg-[rgba(36,40,47,0.5)] sm:inline" aria-hidden />
<MapPin className="size-4 shrink-0" strokeWidth={1.75} />
<span className="font-inter">{event.location.trim()}</span>
</>
) : null}
</div>
) : null}
</div>
<div className="flex flex-col gap-4 text-center">
<div className="rounded-lg border border-dashed border-[#34c759] bg-[#ebffee] px-2 py-2 text-base leading-5 text-[#009951]">
Code sent to your email
</div>
<div className="text-xs text-[rgba(36,40,47,0.87)]">
<p className="mb-1">Sent to:</p>
<p className="font-semibold text-[#24282f]">{email}</p>
</div>
</div>
<div className="flex flex-col gap-4">
<p className="font-inter text-base font-semibold text-[#24282f]">Enter OTP</p>
<div className="flex justify-center gap-2">
{digits.map((d, i) => (
<input
key={i}
id={`otp-${i}`}
inputMode="numeric"
maxLength={1}
value={d}
onChange={(e) => setDigit(i, e.target.value)}
className="size-11 rounded-lg border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] text-center font-inter text-lg font-semibold text-[#24282f]"
/>
))}
</div>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
<button
type="submit"
disabled={loading}
className="w-full rounded-full border border-[rgba(214,200,200,0.6)] bg-[#ef8d1d] py-3 text-xs font-semibold uppercase tracking-wide text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)] disabled:opacity-60"
>
{loading ? "Verifying…" : "Verify OTP"}
</button>
<div className="flex items-center justify-center gap-4 text-sm">
<button
type="button"
onClick={() => navigate("/login")}
className="font-inter text-[#1e72ef] underline"
>
Change email
</button>
<button
type="button"
disabled={resendCooldown > 0}
onClick={() => void handleResend()}
className="font-inter font-semibold text-[#1e72ef] underline disabled:opacity-40"
>
{resendCooldown > 0 ? `Resend (${resendCooldown}s)` : "Resend code"}
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</AuthShell>
);
}

View File

@ -0,0 +1,45 @@
import { useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";
import { AuthShell } from "../components/auth/AuthShell";
import { readStoredVisitorContext } from "../types/visitorContext";
/** Registration uses the same flow as login; redirect with join context from stored visitor context. */
export function RegisterPage() {
const navigate = useNavigate();
const ctx = readStoredVisitorContext();
useEffect(() => {
if (!ctx) return;
const q = new URLSearchParams();
q.set("type", ctx.type);
if (ctx.type === "event") {
q.set("id", ctx.eventId);
} else if (ctx.type === "session" && ctx.sessionId) {
q.set("id", ctx.sessionId);
q.set("eventId", ctx.eventId);
} else if (ctx.type === "track" && ctx.trackId) {
q.set("id", ctx.trackId);
q.set("eventId", ctx.eventId);
}
navigate(`/login?${q.toString()}`, { replace: true });
}, [ctx, navigate]);
if (!ctx) {
return (
<AuthShell>
<div className="max-w-md px-4 text-center text-sm text-white/95">
<p className="mb-4">Scan the event QR code or open your invite link first, then return here to register.</p>
<Link to="/" className="font-semibold text-white underline">
Home
</Link>
</div>
</AuthShell>
);
}
return (
<AuthShell>
<p className="text-sm text-white/95">Redirecting…</p>
</AuthShell>
);
}

129
src/pages/RewardsPage.tsx Normal file
View File

@ -0,0 +1,129 @@
import { useEffect, useState } from "react";
import { Trophy } from "lucide-react";
import { Link, useSearchParams } from "react-router-dom";
import { getVisitorEventRewardBalance, getVisitorRewardHistory } from "../lib/api";
import { getAttendeeToken } from "../lib/attendeeAuth";
import { HeaderMobile } from "../components/HeaderMobile";
import { MobileShell } from "../components/MobileShell";
import { readStoredVisitorContext } from "../types/visitorContext";
function historyLabel(source?: string, description?: string): string {
if (description && String(description).trim()) return String(description).trim();
const m: Record<string, string> = {
registration: "Registration",
question_ask: "Asked a question",
question_vote: "Question vote",
quiz_poll: "Quiz",
poll: "Poll or survey",
manual: "Adjustment",
feedback: "Feedback",
rating: "Rating",
quiz: "Quiz",
};
return m[source || ""] || source || "Points";
}
export function RewardsPage() {
const [searchParams] = useSearchParams();
const urlEventId = searchParams.get("event_id")?.trim() || "";
const ctx = readStoredVisitorContext();
const eventId = urlEventId || ctx?.eventId || "";
const [totalPoints, setTotalPoints] = useState<number | null>(null);
const [historyItems, setHistoryItems] = useState<
Awaited<ReturnType<typeof getVisitorRewardHistory>>["items"]
>([]);
const [historyLoading, setHistoryLoading] = useState(false);
useEffect(() => {
if (!eventId || !getAttendeeToken()) {
setTotalPoints(null);
setHistoryItems([]);
return;
}
let cancelled = false;
getVisitorEventRewardBalance(eventId)
.then((n) => {
if (!cancelled) setTotalPoints(Math.max(0, n));
})
.catch(() => {
if (!cancelled) setTotalPoints(0);
});
setHistoryLoading(true);
getVisitorRewardHistory(eventId, { page: 1, limit: 30 })
.then((r) => {
if (!cancelled) setHistoryItems(r.items);
})
.catch(() => {
if (!cancelled) setHistoryItems([]);
})
.finally(() => {
if (!cancelled) setHistoryLoading(false);
});
return () => {
cancelled = true;
};
}, [eventId]);
const displayTotal =
!getAttendeeToken() || !eventId ? "—" : totalPoints === null ? "…" : String(totalPoints);
return (
<MobileShell className="relative">
<HeaderMobile logoUrl={ctx?.event?.logoUrl} eventId={eventId || null} />
<div className="eb-gradient-auth flex flex-1 flex-col items-center px-6 pb-10 pt-6">
<Trophy className="size-[68px] text-amber-300" strokeWidth={1.25} />
<h1 className="mt-1 font-inter text-xl font-semibold text-white">Your Rewards</h1>
<Link
to={eventId ? `/leaderboard?event_id=${encodeURIComponent(eventId)}` : "/leaderboard"}
className="mt-6 w-full max-w-[354px] rounded-full border border-[rgba(214,200,200,0.6)] bg-[#ef8d1d] py-3 text-center text-xs font-semibold uppercase tracking-wide text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)]"
>
Leaderboard
</Link>
<div className="mt-6 w-full max-w-[354px] rounded-xl bg-white shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
<div className="p-4">
<div className="eb-rank-highlight flex flex-col items-center justify-center gap-2 rounded-3xl border-4 border-white py-4 text-white shadow-[0_9px_16px_rgba(0,0,0,0.14)]">
<p className="font-inter text-base font-semibold uppercase tracking-wide">Total Points</p>
<p className="font-inter text-5xl font-bold tabular-nums drop-shadow-[0_4px_4px_rgba(0,0,0,0.25)]">
{displayTotal}
</p>
</div>
<h2 className="mt-6 font-inter text-base font-semibold text-[#24282f]">Points history</h2>
<ul className="mt-3 flex flex-col gap-3">
{historyLoading && (
<li className="text-xs text-[rgba(36,40,47,0.67)]">Loading…</li>
)}
{!historyLoading && historyItems.length === 0 && (
<li className="text-xs text-[rgba(36,40,47,0.67)]">No transactions yet.</li>
)}
{!historyLoading &&
historyItems.map((row) => {
const signed = row.type === "redeemed" ? "-" : "+";
const cls =
row.type === "redeemed"
? "bg-[#fff0f0] text-[#c02424]"
: row.type === "earned"
? "bg-[#ebffee] text-[#009951]"
: "bg-[#f3f4f6] text-[#24282f]";
return (
<li
key={row._id}
className="flex gap-4 border-b border-[#ededed] pb-3 last:border-0"
>
<span className={`shrink-0 rounded px-3 py-2 text-xs font-medium shadow-sm ${cls}`}>
{signed}
{row.points} pts
</span>
<span className="text-xs leading-snug text-[rgba(36,40,47,0.67)]">
{historyLabel(row.source, row.description)}
</span>
</li>
);
})}
</ul>
</div>
</div>
</div>
</MobileShell>
);
}

View File

@ -0,0 +1,689 @@
import { useEffect, useMemo, useState } from "react";
import { Bookmark, Calendar, ChevronDown, ChevronRight, Clock, ExternalLink, MapPin, MessageCircle, Star } from "lucide-react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { HeaderMobile } from "../components/HeaderMobile";
import {
groupSessionsIntoAgendaDays,
LandingAgendaCards,
LandingMySessionsList,
} from "../components/EventLandingAgenda";
import { HyperNavigationFAB } from "../components/HyperNavigationFAB";
import { MobileShell } from "../components/MobileShell";
import { SessionQuestionsCard } from "../components/SessionQuestionsCard";
import { SessionQuizPollsSection } from "../components/SessionQuizPollsSection";
import {
getVisitorEventAgenda,
getVisitorLoginPreview,
getVisitorSessionPreview,
type VisitorSessionPreview,
type VisitorSessionPreviewSpeaker,
} from "../lib/api";
import {
formatDateLong,
formatDurationMinutes,
formatTimeShort,
sessionTypeDisplay,
} from "@/lib/sessionDisplay";
import type { LoginPreviewResponse } from "../types/visitorContext";
import { readStoredVisitorContext } from "../types/visitorContext";
import { useSessionBookmarks } from "../hooks/useSessionBookmarks";
import { useEventRegistrationSettings } from "../hooks/useEventRegistrationSettings";
import { useSessionQuizPollsRtdb } from "../hooks/useSessionQuizPollsRtdb";
import { firstQuizMasterOrManualVisibleId } from "../lib/sessionQuizPollsRtdb";
const tabs = ["Key Persons", "My Sessions", "Agenda"] as const;
function speakerName(s: { firstName?: string; lastName?: string }): string {
return [s.firstName, s.lastName].filter(Boolean).join(" ").trim() || "Speaker";
}
function absoluteUrl(href: string): string {
const t = href.trim();
if (!t) return "";
if (/^https?:\/\//i.test(t)) return t;
return `https://${t}`;
}
function KeyPersonRow({
person,
tag,
}: {
person: VisitorSessionPreviewSpeaker;
tag: string;
}) {
const [open, setOpen] = useState(false);
const name = speakerName(person);
const expertise = (person.expertise || []).filter((x) => String(x).trim());
const social = person.socialLinks;
const hasLinkedIn = Boolean(social?.linkedin?.trim());
const hasTwitter = Boolean(social?.twitter?.trim());
const hasWebsite = Boolean(social?.website?.trim());
const hasPublicExtras =
Boolean(person.organizationName?.trim()) || hasLinkedIn || hasTwitter || hasWebsite;
const hasExpandableContent = Boolean(
person.photo?.trim() ||
person.title?.trim() ||
person.bio?.trim() ||
expertise.length > 0 ||
hasPublicExtras,
);
return (
<li className="overflow-hidden rounded-lg border border-[rgba(116,112,112,0.2)] bg-white text-sm">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
disabled={!hasExpandableContent}
className={`flex w-full items-center justify-between gap-3 px-3 py-3 text-left ${
!hasExpandableContent ? "cursor-default opacity-90" : ""
}`}
>
<div className="min-w-0 flex-1">
<p className="font-semibold text-[#24282f]">{name}</p>
<span className="mt-1 inline-block rounded bg-[#e5e5ea] px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[#24282f]">
{tag}
</span>
</div>
{hasExpandableContent ? (
<ChevronDown
className={`size-5 shrink-0 text-[#1e72ef] transition-transform ${open ? "rotate-180" : ""}`}
strokeWidth={2}
aria-hidden
/>
) : null}
</button>
{open && hasExpandableContent ? (
<div className="border-t border-[#f2f2f7] bg-[#fafafa] px-3 py-3 text-[#24282f]">
<div className="flex flex-col gap-3">
{person.photo?.trim() ? (
<img
src={person.photo.trim()}
alt=""
className="h-24 w-24 rounded-full object-cover"
/>
) : null}
{person.title?.trim() ? (
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-[#8e8e93]">Title</p>
<p className="mt-1 text-sm leading-snug">{person.title.trim()}</p>
</div>
) : null}
{expertise.length > 0 ? (
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-[#8e8e93]">
Specialization
</p>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{expertise.map((x) => (
<span
key={x}
className="rounded-md bg-[#e8f0fe] px-2 py-0.5 text-xs font-medium text-[#1e72ef]"
>
{x}
</span>
))}
</div>
</div>
) : null}
{person.bio?.trim() ? (
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-[#8e8e93]">Bio</p>
<p className="mt-1 whitespace-pre-wrap text-sm leading-snug">{person.bio.trim()}</p>
</div>
) : null}
{hasPublicExtras ? (
<div className="border-t border-[#e5e5ea] pt-3">
{person.organizationName?.trim() ? (
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-[#8e8e93]">
Organisation
</p>
<p className="mt-1 text-sm leading-snug">{person.organizationName.trim()}</p>
</div>
) : null}
{hasLinkedIn || hasTwitter || hasWebsite ? (
<div className={person.organizationName?.trim() ? "mt-3" : ""}>
<p className="text-[10px] font-semibold uppercase tracking-wide text-[#8e8e93]">
Links
</p>
<div className="mt-1.5 flex flex-col gap-1.5">
{hasLinkedIn ? (
<a
href={absoluteUrl(social!.linkedin!)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm font-medium text-[#1e72ef] underline"
>
<ExternalLink className="size-3.5 shrink-0" strokeWidth={2} />
LinkedIn
</a>
) : null}
{hasTwitter ? (
<a
href={absoluteUrl(social!.twitter!)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm font-medium text-[#1e72ef] underline"
>
<ExternalLink className="size-3.5 shrink-0" strokeWidth={2} />
X (Twitter)
</a>
) : null}
{hasWebsite ? (
<a
href={absoluteUrl(social!.website!)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm font-medium text-[#1e72ef] underline"
>
<ExternalLink className="size-3.5 shrink-0" strokeWidth={2} />
Website
</a>
) : null}
</div>
</div>
) : null}
</div>
) : null}
</div>
</div>
) : null}
</li>
);
}
function headerLogoForEvent(
eventId: string,
ev: LoginPreviewResponse | null | undefined,
ctx: ReturnType<typeof readStoredVisitorContext>,
): string | undefined {
const fromPreview = ev?.logoUrl?.trim();
if (fromPreview) return fromPreview;
if (ctx?.eventId === eventId && ctx.event?.logoUrl?.trim()) return ctx.event.logoUrl.trim();
return undefined;
}
function headerEventNameForEvent(
eventId: string,
ev: LoginPreviewResponse | null | undefined,
ctx: ReturnType<typeof readStoredVisitorContext>,
): string | undefined {
const fromPreview = ev?.name?.trim();
if (fromPreview) return fromPreview;
if (ctx?.eventId === eventId && ctx.event?.name?.trim()) return ctx.event.name.trim();
return undefined;
}
export function SingleEventPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const eventId = searchParams.get("event_id")?.trim() || "";
const sessionId = searchParams.get("session_id")?.trim() || "";
const ctx = readStoredVisitorContext();
const [session, setSession] = useState<VisitorSessionPreview | null>(null);
const [eventInfo, setEventInfo] = useState<LoginPreviewResponse | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [tab, setTab] = useState(0);
const [sessionMetaOpen, setSessionMetaOpen] = useState(false);
const { bookmarkedIds, toggle: toggleBookmark } = useSessionBookmarks(eventId);
const [agendaSessions, setAgendaSessions] = useState<VisitorSessionPreview[]>([]);
const [agendaLoading, setAgendaLoading] = useState(false);
const [agendaErr, setAgendaErr] = useState<string | null>(null);
const [dayOpen, setDayOpen] = useState<Record<string, boolean>>({});
const reg = useEventRegistrationSettings(eventInfo);
const { items: sessionLiveQuizPolls, loading: rtdbQuizPollsLoading } =
useSessionQuizPollsRtdb(sessionId);
useEffect(() => {
if (!eventId || !sessionId || loading || err || !session) return;
if (rtdbQuizPollsLoading) return;
const target = firstQuizMasterOrManualVisibleId(sessionLiveQuizPolls);
if (!target) return;
navigate(
`/quiz-poll?${new URLSearchParams({ event_id: eventId, session_id: sessionId, quiz_poll_id: target }).toString()}`,
{ replace: true },
);
}, [
eventId,
sessionId,
loading,
err,
session,
rtdbQuizPollsLoading,
sessionLiveQuizPolls,
navigate,
]);
useEffect(() => {
if (!eventId || !sessionId) {
setErr("Missing session link. Open the session from your event QR code or invite.");
setLoading(false);
setSession(null);
setEventInfo(null);
return;
}
let cancelled = false;
setLoading(true);
setErr(null);
(async () => {
try {
const sess = await getVisitorSessionPreview(sessionId, eventId);
if (cancelled) return;
setSession(sess);
if (ctx?.eventId === eventId && ctx.event) {
setEventInfo(ctx.event);
} else {
const ev = await getVisitorLoginPreview(eventId);
if (cancelled) return;
setEventInfo(ev);
}
} catch (e) {
if (!cancelled) {
setErr(e instanceof Error ? e.message : "Could not load session.");
setSession(null);
setEventInfo(null);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [eventId, sessionId, ctx?.eventId]);
useEffect(() => {
if (!eventId) {
setAgendaSessions([]);
setAgendaErr(null);
setAgendaLoading(false);
return;
}
let cancelled = false;
setAgendaLoading(true);
setAgendaErr(null);
setAgendaSessions([]);
getVisitorEventAgenda(eventId)
.then((sessions) => {
if (!cancelled) setAgendaSessions(sessions);
})
.catch((e) => {
if (!cancelled) {
setAgendaErr(e instanceof Error ? e.message : "Could not load agenda.");
setAgendaSessions([]);
}
})
.finally(() => {
if (!cancelled) setAgendaLoading(false);
});
return () => {
cancelled = true;
};
}, [eventId]);
useEffect(() => {
setDayOpen({});
}, [eventId]);
const agendaDays = useMemo(() => groupSessionsIntoAgendaDays(agendaSessions), [agendaSessions]);
const bookmarkedSessionsSorted = useMemo(() => {
if (!bookmarkedIds.size) return [];
return agendaSessions
.filter((s) => bookmarkedIds.has(s._id))
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
}, [agendaSessions, bookmarkedIds]);
useEffect(() => {
const first = agendaDays[0]?.dateKey;
if (!first) return;
setDayOpen((prev) => {
if (Object.keys(prev).length > 0) return prev;
return { [first]: true };
});
}, [agendaDays]);
if (!eventId || !sessionId) {
return (
<MobileShell className="relative">
<HeaderMobile
eventName={headerEventNameForEvent(eventId, null, ctx)}
eventId={eventId || null}
showBack={false}
/>
<main className="flex flex-1 flex-col items-center justify-center bg-[#f2f2f7] px-4 py-10">
<p className="text-center text-sm text-[#24282f]">{err}</p>
<Link to="/" className="mt-4 text-sm font-semibold text-[#1e72ef] underline">
Home
</Link>
</main>
</MobileShell>
);
}
if (loading) {
return (
<MobileShell className="relative">
<HeaderMobile
logoUrl={headerLogoForEvent(eventId, null, ctx)}
eventName={headerEventNameForEvent(eventId, null, ctx)}
eventId={eventId || null}
showBack={false}
/>
<main className="flex flex-1 items-center justify-center bg-[#f2f2f7] px-4 py-10">
<p className="text-sm text-[#24282f]">Loading session…</p>
</main>
</MobileShell>
);
}
if (err || !session) {
return (
<MobileShell className="relative">
<HeaderMobile
logoUrl={headerLogoForEvent(eventId, eventInfo, ctx)}
eventName={headerEventNameForEvent(eventId, eventInfo, ctx)}
eventId={eventId || null}
showBack={false}
/>
<main className="flex flex-1 flex-col items-center justify-center bg-[#f2f2f7] px-4 py-10">
<p className="text-center text-sm text-red-600">{err ?? "Session not found."}</p>
</main>
</MobileShell>
);
}
const ev = eventInfo;
const typeLabel = sessionTypeDisplay(session.type);
const trackTitle = session.track?.name?.trim() || typeLabel;
const startT = formatTimeShort(session.startTime);
const endT = formatTimeShort(session.endTime);
const timeLine = startT && endT ? `${startT} – ${endT}` : startT;
const duration = formatDurationMinutes(session.startTime, session.endTime);
const sessionDate = formatDateLong(session.startTime);
const live = session.status === "ongoing";
const sessionBookmarked = bookmarkedIds.has(sessionId);
const headerLogoUrl = headerLogoForEvent(eventId, ev, ctx);
const headerEventName = headerEventNameForEvent(eventId, ev, ctx);
const sessionAllowsFeedback = session.audienceFeedbackEnabled !== false;
const sessionAllowsRatings = session.audienceRatingsEnabled !== false;
const showSessionFeedback = reg.enableFeedback && sessionAllowsFeedback;
const showSessionRatings = reg.enableRatings && sessionAllowsRatings;
const sessionFeedbackHref = (() => {
const q = new URLSearchParams({ event_id: eventId });
const fid = session.audienceFeedbackForm?.trim();
if (fid) q.set("form_id", fid);
return `/feedback?${q.toString()}`;
})();
const sessionRatingsHref = (() => {
const q = new URLSearchParams({ event_id: eventId });
const fid = session.audienceRatingsForm?.trim();
if (fid) q.set("form_id", fid);
return `/ratings?${q.toString()}`;
})();
const sessionNotes = session.description?.trim() ?? "";
const sessionLocation = session.location?.trim() ?? "";
const trackHall = session.track?.hall?.trim() ?? "";
const hasCapacity = session.capacity != null && session.capacity > 0;
const hasExpandableSessionMeta =
Boolean(sessionNotes || sessionLocation || trackHall || hasCapacity);
return (
<MobileShell className="relative">
<HeaderMobile
logoUrl={headerLogoUrl}
eventName={headerEventName}
eventId={eventId || null}
showBack={false}
/>
{ev?.bannerImage?.trim() ? (
<div className="w-full overflow-hidden bg-[#e5e5ea]">
<img
src={ev.bannerImage.trim()}
alt=""
className="max-h-[200px] w-full object-cover object-center"
/>
</div>
) : null}
<div className="eb-gradient-banner relative flex flex-col items-center justify-center gap-1.5 px-6 pb-4 pt-3 text-center text-white">
<div className="absolute right-3 top-2.5 z-10 flex max-w-[calc(100%-4.5rem)] flex-col items-end gap-1.5">
{live ? (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-[#14ae5c] px-2 py-0.5 text-[10px] font-semibold text-white">
<span className="size-1 rounded-full bg-white" />
LIVE
</span>
) : null}
<button
type="button"
onClick={() => toggleBookmark(sessionId)}
className="shrink-0 rounded-md p-1 text-white hover:bg-white/15"
aria-label={sessionBookmarked ? "Remove bookmark" : "Bookmark session"}
aria-pressed={sessionBookmarked}
>
<Bookmark className={`size-6 ${sessionBookmarked ? "fill-current" : ""}`} strokeWidth={1.75} />
</button>
</div>
<div className="flex max-w-full flex-wrap items-center justify-center gap-x-2 gap-y-1 px-2 text-[10px] text-white/90">
<span className="rounded bg-white/20 px-2 py-0.5">{typeLabel}</span>
{timeLine ? <span>{timeLine}</span> : null}
{trackTitle !== typeLabel ? <span className="opacity-90">{trackTitle}</span> : null}
</div>
<h1 className="font-inter text-2xl font-bold tracking-tight">{session.title}</h1>
<div className="flex w-full max-w-full flex-wrap items-center justify-end gap-x-3 gap-y-1 pr-1 text-sm text-white/95">
{duration ? (
<span className="flex items-center gap-1.5">
<Clock className="size-4 shrink-0" strokeWidth={1.75} />
{startT} ({duration})
</span>
) : startT ? (
<span className="flex items-center gap-1.5">
<Clock className="size-4 shrink-0" strokeWidth={1.75} />
{startT}
</span>
) : null}
{sessionDate ? (
<span className="flex items-center gap-1.5">
<Calendar className="size-4 shrink-0" strokeWidth={1.75} />
{sessionDate}
</span>
) : null}
{hasExpandableSessionMeta ? (
<button
type="button"
onClick={() => setSessionMetaOpen((o) => !o)}
aria-expanded={sessionMetaOpen}
aria-label={
sessionMetaOpen ? "Hide session notes and location" : "Show session notes and location"
}
className="inline-flex shrink-0 items-center rounded-md p-0.5 text-white hover:bg-white/15"
>
<ChevronDown
className={`size-5 transition-transform ${sessionMetaOpen ? "rotate-180" : ""}`}
strokeWidth={2}
aria-hidden
/>
</button>
) : null}
</div>
{sessionMetaOpen && hasExpandableSessionMeta ? (
<div className="mt-2 w-full max-w-full border-t border-white/25 px-1 pt-3 text-left text-sm text-white/95">
{sessionNotes ? (
<div className="mb-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-white/70">Notes</p>
<p className="mt-1.5 whitespace-pre-wrap leading-snug">{sessionNotes}</p>
</div>
) : null}
{sessionLocation || trackHall ? (
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-white/70">Location</p>
<ul className="mt-1.5 flex flex-col gap-1.5">
{sessionLocation ? (
<li className="flex items-start gap-2 leading-snug">
<MapPin className="mt-0.5 size-4 shrink-0 text-white/90" strokeWidth={1.75} />
<span>{sessionLocation}</span>
</li>
) : null}
{trackHall ? (
<li className="flex items-start gap-2 leading-snug">
<MapPin className="mt-0.5 size-4 shrink-0 text-white/90" strokeWidth={1.75} />
<span>{trackHall}</span>
</li>
) : null}
</ul>
</div>
) : null}
{hasCapacity ? (
<p className="mt-3 text-xs text-white/85">Capacity: {session.capacity}</p>
) : null}
</div>
) : null}
</div>
<main className="relative z-0 w-full max-w-none flex-1 bg-[#f2f2f7] pb-28 pt-4">
<div className="flex w-full max-w-none flex-col gap-4">
<SessionQuestionsCard
title="Audience Questions"
showQuestionNav
eventId={eventId}
sessionId={sessionId}
/>
<SessionQuizPollsSection eventId={eventId} sessionId={sessionId} />
{showSessionFeedback || showSessionRatings ? (
<div className="flex w-full max-w-none flex-col gap-2">
<p className="px-1 text-[10px] font-semibold uppercase tracking-wide text-[#8e8e93]">
Feedback & ratings
</p>
<div className="flex flex-col gap-2">
{showSessionFeedback ? (
<Link
to={sessionFeedbackHref}
className="flex items-center justify-between gap-3 rounded-lg border border-[rgba(116,112,112,0.2)] bg-white px-3 py-3 text-sm font-medium text-[#24282f] shadow-[0_1px_4px_rgba(12,12,13,0.05)] active:bg-[#f2f2f7]"
>
<span className="flex min-w-0 items-center gap-2.5">
<MessageCircle className="size-[18px] shrink-0 text-[#1e72ef]" strokeWidth={1.75} />
<span className="truncate">Session feedback</span>
</span>
<ChevronRight className="size-4 shrink-0 text-[#c7c7cc]" strokeWidth={2} aria-hidden />
</Link>
) : null}
{showSessionRatings ? (
<Link
to={sessionRatingsHref}
className="flex items-center justify-between gap-3 rounded-lg border border-[rgba(116,112,112,0.2)] bg-white px-3 py-3 text-sm font-medium text-[#24282f] shadow-[0_1px_4px_rgba(12,12,13,0.05)] active:bg-[#f2f2f7]"
>
<span className="flex min-w-0 items-center gap-2.5">
<Star className="size-[18px] shrink-0 text-[#1e72ef]" strokeWidth={1.75} />
<span className="truncate">Rate this event</span>
</span>
<ChevronRight className="size-4 shrink-0 text-[#c7c7cc]" strokeWidth={2} aria-hidden />
</Link>
) : null}
</div>
</div>
) : null}
<div className="flex w-full border-b border-[#c7c7cc] bg-transparent">
{tabs.map((t, i) => (
<button
key={t}
type="button"
onClick={() => setTab(i)}
className={`flex-1 px-1 pb-2 pt-3 text-center text-sm sm:px-2 ${
tab === i
? "border-b-2 border-[#1e72ef] font-semibold text-[#24282f]"
: "-mb-px border-b-2 border-transparent font-normal text-[#24282f]/80"
}`}
>
{t}
</button>
))}
</div>
{tab === 0 ? (
<ul className="flex flex-col gap-3">
{(session.speakers || []).map((s) => (
<KeyPersonRow key={s._id} person={s} tag={s.speakerRole?.trim() || "Speaker"} />
))}
{session.moderator ? (
<KeyPersonRow
key={`mod-${session.moderator._id}`}
person={session.moderator}
tag={session.moderator.speakerRole?.trim() || "Moderator"}
/>
) : null}
{(!session.speakers || session.speakers.length === 0) && !session.moderator ? (
<p className="text-sm text-[rgba(36,40,47,0.6)]">No speakers listed yet.</p>
) : null}
</ul>
) : null}
{tab === 1 ? (
<div className="w-full max-w-none">
{agendaLoading && !agendaSessions.length ? (
<p className="text-sm text-[rgba(36,40,47,0.55)]">Loading…</p>
) : bookmarkedSessionsSorted.length === 0 ? (
<p className="text-sm text-[rgba(36,40,47,0.55)]">
No bookmarked sessions yet. Tap the bookmark on any agenda session to save it here.
</p>
) : (
<LandingMySessionsList
sessions={bookmarkedSessionsSorted}
eventId={eventId}
bookmarkedIds={bookmarkedIds}
toggleBookmark={toggleBookmark}
/>
)}
</div>
) : null}
{tab === 2 ? (
<div className="w-full max-w-none">
{agendaErr ? <p className="text-sm text-red-600">{agendaErr}</p> : null}
{agendaLoading && !agendaSessions.length ? (
<p className="text-sm text-[rgba(36,40,47,0.55)]">Loading agenda…</p>
) : agendaDays.length === 0 ? (
<p className="text-sm text-[rgba(36,40,47,0.55)]">No sessions scheduled for this event yet.</p>
) : (
<LandingAgendaCards
days={agendaDays}
dayOpen={dayOpen}
setDayOpen={setDayOpen}
eventId={eventId}
bookmarkedIds={bookmarkedIds}
toggleBookmark={toggleBookmark}
/>
)}
</div>
) : null}
</div>
</main>
<HyperNavigationFAB
eventId={eventId}
sessionId={sessionId}
showFeedbackNav={showSessionFeedback}
showRatingsNav={showSessionRatings}
feedbackFormId={showSessionFeedback ? session.audienceFeedbackForm?.trim() || undefined : undefined}
ratingsFormId={showSessionRatings ? session.audienceRatingsForm?.trim() || undefined : undefined}
/>
</MobileShell>
);
}

View File

@ -0,0 +1,188 @@
import { LegalList, LegalPageLayout, LegalSection } from "../components/marketing/LegalPageLayout";
const LAST_UPDATED = "May 15, 2026";
export function TermsOfServicePage() {
return (
<LegalPageLayout title="Terms of Service" lastUpdated={LAST_UPDATED}>
<LegalSection title="1. Agreement to terms">
<p>
These Terms of Service (&quot;Terms&quot;) govern your access to and use of EventBuzz websites,
applications, and related services (collectively, the &quot;Services&quot;) operated by EventBuzz
(&quot;EventBuzz,&quot; &quot;we,&quot; &quot;us,&quot; or &quot;our&quot;).
</p>
<p>
By accessing or using the Services, you agree to these Terms and our{" "}
<a href="/privacy-policy" className="font-semibold text-[#1d66ef] hover:underline">
Privacy Policy
</a>
. If you do not agree, do not use the Services.
</p>
</LegalSection>
<LegalSection title="2. The Services">
<p>
EventBuzz helps event organizers create and manage conferences, workshops, webinars, and similar
programs. Features may include agendas, attendee registration, live audience engagement (Q&amp;A,
polls, quizzes), feedback and ratings, rewards or gamification, and analytics. Attendees may access
events through codes, links, or invitations provided by organizers.
</p>
<p>
We may modify, suspend, or discontinue any part of the Services at any time. Beta or preview features
may be offered &quot;as is&quot; with limited support.
</p>
</LegalSection>
<LegalSection title="3. Accounts and eligibility">
<p>
You must provide accurate information when creating an account or registering for an event. You are
responsible for safeguarding your credentials and for activity under your account. You must be at
least 16 years old (or the age required in your jurisdiction) to use the Services.
</p>
<p>
Organizer accounts are created through our console at{" "}
<a
href="https://console.eventbuzz.live"
className="font-semibold text-[#1d66ef] hover:underline"
>
console.eventbuzz.live
</a>
. Attendee access may be guest-based or authenticated depending on event settings.
</p>
</LegalSection>
<LegalSection title="4. Organizer responsibilities">
<p>If you use EventBuzz as an organizer, you agree that you:</p>
<LegalList
items={[
"Have authority to run your event and collect attendee information you request.",
"Will provide attendees with any legally required notices (including privacy and marketing consent).",
"Will not use the Services for unlawful, harmful, or deceptive purposes.",
"Are solely responsible for event content, speaker materials, and communications with attendees.",
"Will comply with applicable laws (including data protection, accessibility, and industry-specific rules).",
]}
/>
</LegalSection>
<LegalSection title="5. Attendee use">
<p>
When you join an event as an attendee, you agree to follow the organizer&apos;s rules and any
applicable code of conduct. You grant the organizer and EventBuzz the rights necessary to display
and process content you submit (such as questions and poll responses) in connection with the event.
</p>
</LegalSection>
<LegalSection title="6. Acceptable use">
<p>You may not:</p>
<LegalList
items={[
"Violate laws or third-party rights.",
"Upload malware, spam, or content that is harassing, defamatory, obscene, or infringing.",
"Attempt to gain unauthorized access to systems, accounts, or data.",
"Reverse engineer or scrape the Services except as permitted by law.",
"Interfere with the integrity or performance of the Services.",
"Use the Services to send unsolicited commercial messages without consent.",
]}
/>
<p>
We may remove content or suspend access if we reasonably believe you have violated these Terms or
pose a risk to the Services or others.
</p>
</LegalSection>
<LegalSection title="7. Intellectual property">
<p>
EventBuzz and its licensors own the Services, software, branding, and underlying technology. We grant
you a limited, non-exclusive, non-transferable license to use the Services for their intended purpose
during your subscription or event access period.
</p>
<p>
You retain ownership of content you submit. You grant EventBuzz a worldwide, royalty-free license to
host, use, reproduce, and display that content solely to operate and improve the Services and support
your events.
</p>
</LegalSection>
<LegalSection title="8. Fees and payment">
<p>
Some features require paid plans or per-event fees. Prices and billing terms are presented at
purchase. Fees are generally non-refundable except where required by law or stated in a separate
agreement. You are responsible for applicable taxes.
</p>
</LegalSection>
<LegalSection title="9. Disclaimers">
<p>
THE SERVICES ARE PROVIDED &quot;AS IS&quot; AND &quot;AS AVAILABLE&quot; WITHOUT WARRANTIES OF ANY
KIND, WHETHER EXPRESS OR IMPLIED, INCLUDING IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE, AND NON-INFRINGEMENT. WE DO NOT WARRANT THAT THE SERVICES WILL BE UNINTERRUPTED,
ERROR-FREE, OR SECURE.
</p>
</LegalSection>
<LegalSection title="10. Limitation of liability">
<p>
TO THE MAXIMUM EXTENT PERMITTED BY LAW, EVENTBUZZ AND ITS AFFILIATES, OFFICERS, EMPLOYEES, AND
SUPPLIERS WILL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE
DAMAGES, OR ANY LOSS OF PROFITS, DATA, OR GOODWILL, ARISING FROM YOUR USE OF THE SERVICES.
</p>
<p>
OUR TOTAL LIABILITY FOR ANY CLAIM ARISING OUT OF THESE TERMS OR THE SERVICES WILL NOT EXCEED THE
GREATER OF (A) THE AMOUNT YOU PAID US IN THE TWELVE (12) MONTHS BEFORE THE CLAIM OR (B) ONE HUNDRED
U.S. DOLLARS (USD $100), EXCEPT WHERE SUCH LIMITATIONS ARE PROHIBITED BY LAW.
</p>
</LegalSection>
<LegalSection title="11. Indemnification">
<p>
You will defend, indemnify, and hold harmless EventBuzz from claims, damages, and expenses (including
reasonable legal fees) arising from your content, your events, your violation of these Terms, or your
violation of any law or third-party rights.
</p>
</LegalSection>
<LegalSection title="12. Termination">
<p>
You may stop using the Services at any time. We may suspend or terminate your access if you breach
these Terms or if we discontinue the Services. Provisions that by their nature should survive
termination (including intellectual property, disclaimers, limitations of liability, and
indemnification) will survive.
</p>
</LegalSection>
<LegalSection title="13. Governing law and disputes">
<p>
These Terms are governed by the laws of India, without regard to conflict-of-law principles, except
where mandatory local law requires otherwise. Courts located in India will have exclusive
jurisdiction over disputes arising from these Terms or the Services, unless applicable law requires
a different forum.
</p>
<p>
Before filing a claim, you agree to contact us at{" "}
<a href="mailto:hello@eventbuzz.live" className="font-semibold text-[#1d66ef] hover:underline">
hello@eventbuzz.live
</a>{" "}
to try to resolve the dispute informally.
</p>
</LegalSection>
<LegalSection title="14. Changes to these Terms">
<p>
We may update these Terms from time to time. We will post the revised Terms with an updated
&quot;Last updated&quot; date. Continued use after changes become effective constitutes acceptance.
If you do not agree to the updated Terms, you must stop using the Services.
</p>
</LegalSection>
<LegalSection title="15. Contact">
<p>
Questions about these Terms may be sent to{" "}
<a href="mailto:hello@eventbuzz.live" className="font-semibold text-[#1d66ef] hover:underline">
hello@eventbuzz.live
</a>
.
</p>
</LegalSection>
</LegalPageLayout>
);
}

116
src/types/visitorContext.ts Normal file
View File

@ -0,0 +1,116 @@
export type VisitorJoinType = "session" | "event" | "track";
/** Matches dashboard CreateEventPanel custom registration field shape */
export interface RegistrationCustomFieldDef {
label?: string;
code?: string;
fieldType?: string;
options?: Array<{ value?: string; label?: string }>;
}
export interface ResolveCodeResponse {
type: VisitorJoinType;
eventId: string;
sessionId?: string;
trackId?: string;
}
/** Visitor login-preview: full event (no website / createdAt / updatedAt) + computed logoUrl. */
export interface LoginPreviewResponse {
_id: string;
organization: string;
name: string;
subtitle?: string;
description?: string;
type: string;
status: string;
category?: string;
startDate: string;
endDate: string;
location?: string;
locationType?: string;
virtualLink?: string;
tags?: string[];
image?: string;
settings?: {
registrationOpen?: boolean;
maxParticipants?: number;
requiresApproval?: boolean;
registrationMode?: string;
verifyEmail?: boolean;
registrationDeadline?: string;
enableFeedback?: boolean;
enableRatings?: boolean;
authenticationType?: string;
registrationBaseFields?: string;
};
bannerImage?: string;
metadata?: {
registrationCustomFields?: RegistrationCustomFieldDef[];
[key: string]: unknown;
};
logoUrl: string;
/** Star ratings for this event only (from feedback/survey submissions). */
ratings?: { average: number; count: number };
}
export interface EbVisitorContext {
type: VisitorJoinType;
eventId: string;
sessionId?: string;
trackId?: string;
/** Full payload from GET login-preview */
event: LoginPreviewResponse;
fetchedAt: string;
}
export const EB_VISITOR_CONTEXT_KEY = "eb_visitor_context";
export function readStoredVisitorContext(): EbVisitorContext | null {
try {
const raw = localStorage.getItem(EB_VISITOR_CONTEXT_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as EbVisitorContext;
if (!parsed?.eventId || !parsed?.event) return null;
return parsed;
} catch {
return null;
}
}
export function clearStoredVisitorContext(): void {
try {
localStorage.removeItem(EB_VISITOR_CONTEXT_KEY);
} catch {
/* ignore */
}
}
/** Join context for building scoped URLs (snake_case query params). */
export type VisitorJoinContext = {
type: VisitorJoinType;
eventId: string;
sessionId?: string;
trackId?: string;
};
/**
* After login / profile completion: `/event|session|track?event_id=&session_id=&track_id=`
*/
export function buildPostAuthUrl(ctx: VisitorJoinContext | EbVisitorContext | null): string {
if (!ctx) return "/event";
const params = new URLSearchParams();
params.set("event_id", ctx.eventId);
if (ctx.sessionId) params.set("session_id", ctx.sessionId);
if (ctx.trackId) params.set("track_id", ctx.trackId);
const qs = params.toString();
if (ctx.type === "session") return `/session?${qs}`;
if (ctx.type === "track") return `/track?${qs}`;
return `/event?${qs}`;
}
/** Event home with required event_id (for links from session/track pages). */
export function visitorEventUrl(eventId: string): string {
return `/event?${new URLSearchParams({ event_id: eventId }).toString()}`;
}

11
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
readonly VITE_FIREBASE_API_KEY?: string;
readonly VITE_FIREBASE_DATABASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

19
tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

16
vite.config.ts Normal file
View File

@ -0,0 +1,16 @@
import path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5174,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});