first commit
This commit is contained in:
commit
f283127dce
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 951 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(" ")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(" ")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
@ -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)));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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)));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 & session questions
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<HyperNavigationFAB eventId={eventId} />
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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'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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 ("EventBuzz," "we," "us," or "our") provides an
|
||||
event engagement platform for organizers and attendees, including live Q&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 "Services").
|
||||
</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'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
|
||||
"Last updated" 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 ("Terms") govern your access to and use of EventBuzz websites,
|
||||
applications, and related services (collectively, the "Services") operated by EventBuzz
|
||||
("EventBuzz," "we," "us," or "our").
|
||||
</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&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 "as is" 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'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 "AS IS" AND "AS AVAILABLE" 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
|
||||
"Last updated" 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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()}`;
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue