+
+ {isQuiz ? (
+
+ ) : null}
@@ -497,13 +585,15 @@ export function QuizPollPresentPage() {
totalResponses={stats?.totalResponses ?? 0}
revealCorrect={revealCorrect}
/>
- ) : (
+ ) : tab === "participants" ? (
+ ) : (
+
)}
diff --git a/src/pages/QuizPollTakePage.tsx b/src/pages/QuizPollTakePage.tsx
index a945a76..5158125 100644
--- a/src/pages/QuizPollTakePage.tsx
+++ b/src/pages/QuizPollTakePage.tsx
@@ -13,12 +13,11 @@ import {
import { isFirebaseClientConfigured } from "../lib/firebaseClient";
import type { RtdbQuizPollDetail, RtdbQuizPollQuestion } from "../lib/sessionQuizPollsRtdb";
import {
- firstQuizMasterOrManualVisibleId,
isQuizPollVisibleForAudience,
settingsDisplayMode,
subscribeSessionQuizPollDetail,
} from "../lib/sessionQuizPollsRtdb";
-import { useSessionQuizPollsRtdb } from "../hooks/useSessionQuizPollsRtdb";
+import { useAutoNavigateToLiveQuizPoll } from "../hooks/useAutoNavigateToLiveQuizPoll";
import { getAttendeeToken } from "../lib/attendeeAuth";
import { notifyRewardFromApiData } from "../lib/rewardCelebration";
import type { LoginPreviewResponse } from "../types/visitorContext";
@@ -186,10 +185,14 @@ export function QuizPollTakePage() {
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();
+
+ useAutoNavigateToLiveQuizPoll({
+ eventId,
+ sessionId,
+ currentQuizPollId: quizPollId || null,
+ enabled: Boolean(eventId && sessionId && quizPollId),
+ });
const [quiz, setQuiz] = useState
(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
@@ -263,28 +266,6 @@ export function QuizPollTakePage() {
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>({});
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
diff --git a/src/pages/RatingsPage.tsx b/src/pages/RatingsPage.tsx
index e3f5f10..97a76b3 100644
--- a/src/pages/RatingsPage.tsx
+++ b/src/pages/RatingsPage.tsx
@@ -3,6 +3,7 @@ import { Link, useSearchParams } from "react-router-dom";
import { HeaderMobile } from "../components/HeaderMobile";
import { MobileShell } from "../components/MobileShell";
import { DynamicFeedbackForm } from "../components/DynamicFeedbackForm";
+import { useAutoNavigateToLiveQuizPoll } from "../hooks/useAutoNavigateToLiveQuizPoll";
import { useEventRegistrationSettings } from "../hooks/useEventRegistrationSettings";
import {
getVisitorFeedbackFormSchema,
@@ -21,6 +22,12 @@ export function RatingsPage() {
const eventId = urlEventId || ctx?.eventId || "";
const sessionId = urlSessionId || ctx?.sessionId || "";
+ useAutoNavigateToLiveQuizPoll({
+ eventId,
+ sessionId,
+ enabled: Boolean(eventId && sessionId),
+ });
+
const [eventSnapshot, setEventSnapshot] = useState(
ctx?.eventId === eventId && ctx.event ? ctx.event : null,
);
diff --git a/src/pages/SingleEventPage.tsx b/src/pages/SingleEventPage.tsx
index 1c96537..cde5835 100644
--- a/src/pages/SingleEventPage.tsx
+++ b/src/pages/SingleEventPage.tsx
@@ -1,6 +1,6 @@
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 { Link, useSearchParams } from "react-router-dom";
import { HeaderMobile } from "../components/HeaderMobile";
import {
groupSessionsIntoAgendaDays,
@@ -28,8 +28,7 @@ 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";
+import { useAutoNavigateToLiveQuizPoll } from "../hooks/useAutoNavigateToLiveQuizPoll";
type SessionTabId = "key-persons" | "my-sessions" | "agenda";
@@ -230,7 +229,6 @@ function headerEventNameForEvent(
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();
@@ -250,28 +248,11 @@ export function SingleEventPage() {
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 },
- );
- }, [
+ useAutoNavigateToLiveQuizPoll({
eventId,
sessionId,
- loading,
- err,
- session,
- rtdbQuizPollsLoading,
- sessionLiveQuizPolls,
- navigate,
- ]);
+ enabled: Boolean(eventId && sessionId && !loading && !err && session),
+ });
useEffect(() => {
if (!eventId || !sessionId) {
diff --git a/src/types/visitorContext.ts b/src/types/visitorContext.ts
index 46bc23f..1f9126b 100644
--- a/src/types/visitorContext.ts
+++ b/src/types/visitorContext.ts
@@ -94,10 +94,34 @@ export type VisitorJoinContext = {
trackId?: string;
};
+/** Optional return path after visitor auth (e.g. photo-wall upload). */
+export const EB_POST_AUTH_RETURN_KEY = "eb_post_auth_return";
+
+export function setPostAuthReturnPath(path: string) {
+ try {
+ if (path && path.startsWith("/")) sessionStorage.setItem(EB_POST_AUTH_RETURN_KEY, path);
+ } catch {
+ /* ignore */
+ }
+}
+
+export function consumePostAuthReturnPath(): string | null {
+ try {
+ const path = sessionStorage.getItem(EB_POST_AUTH_RETURN_KEY);
+ if (path) sessionStorage.removeItem(EB_POST_AUTH_RETURN_KEY);
+ return path && path.startsWith("/") ? path : null;
+ } catch {
+ return null;
+ }
+}
+
/**
* After login / profile completion: `/event|session|track?event_id=&session_id=&track_id=`
+ * Honors an optional return path set via `setPostAuthReturnPath`.
*/
export function buildPostAuthUrl(ctx: VisitorJoinContext | EbVisitorContext | null): string {
+ const ret = consumePostAuthReturnPath();
+ if (ret) return ret;
if (!ctx) return "/event";
const params = new URLSearchParams();
params.set("event_id", ctx.eventId);
From aea5bea7cbbb3fc11ba10292490a9bef75518e9d Mon Sep 17 00:00:00 2001
From: Ravi Gulhane <>
Date: Fri, 14 Aug 2026 12:51:59 +0530
Subject: [PATCH 2/2] Add attendee photo search, social share, and AI avatar
flows.
Also document the visitor app in a non-technical brief and make photo-wall sharing more reliable on mobile.
Co-authored-by: Cursor
---
FUNCTIONALITY_BRIEF.md | 189 +++++++
src/App.tsx | 6 +
src/components/PhotoWallEventHeader.tsx | 23 +-
src/lib/api.ts | 248 ++++++++-
src/lib/photoWallShare.ts | 205 +++++++-
src/pages/AiAvatarPage.tsx | 664 ++++++++++++++++++++++++
src/pages/PhotoSearchPage.tsx | 472 +++++++++++++++++
src/pages/PhotoWallUploadPage.tsx | 140 ++++-
src/pages/SocialSharePage.tsx | 604 +++++++++++++++++++++
9 files changed, 2509 insertions(+), 42 deletions(-)
create mode 100644 FUNCTIONALITY_BRIEF.md
create mode 100644 src/pages/AiAvatarPage.tsx
create mode 100644 src/pages/PhotoSearchPage.tsx
create mode 100644 src/pages/SocialSharePage.tsx
diff --git a/FUNCTIONALITY_BRIEF.md b/FUNCTIONALITY_BRIEF.md
new file mode 100644
index 0000000..fbfb827
--- /dev/null
+++ b/FUNCTIONALITY_BRIEF.md
@@ -0,0 +1,189 @@
+# EventBuzz Attendee App — Functionality Brief
+
+This is the **attendee-facing EventBuzz experience**: the phone-friendly website people open at an event (typically from a QR code, invite, or join link). It is not the organiser dashboard. Organisers set up the event elsewhere; attendees use this app to join, follow the programme, take part, and share moments.
+
+---
+
+## In one sentence
+
+Guests join an event with a short code, then use their phone to follow the agenda, ask questions, take quizzes and polls, earn points, give feedback, and create branded photos to share.
+
+---
+
+## Who it is for
+
+**Attendees** use it throughout the event: finding sessions, interacting during talks, and collecting photos afterwards.
+
+**Speakers and hosts** can also use dedicated **on-stage screens** (opened from a special link) so the room can see live questions, poll results, and a photo wall on the projector.
+
+**Organisers** do not run the event from this app, but they control what attendees see: branding, who can register, which sessions are live, and which extras (feedback, ratings, photos, rewards) are switched on.
+
+---
+
+## Getting in
+
+Attendees do not need to hunt for an app store listing. They open a link or scan a QR, then:
+
+1. **Enter a join code** (from an invite, slide, or poster), or land on a link that already includes the code.
+2. See the **event name, logo, dates, and venue**.
+3. Sign in the way the organiser has chosen:
+ - **Open / guest access** — continue with little or no friction (email may still be asked).
+ - **Email one-time code (OTP)** — a code is sent to their inbox.
+ - **Magic link** — a sign-in link is emailed (it expires after a short time).
+ - **Invite only** — they must also enter an invite code.
+
+If registration needs extra details (name, phone, custom questions), they complete a **profile** before entering. Some events require **organiser approval** first; those guests see a waiting screen until they are accepted.
+
+Returning guests who already registered can sign in again even if new registrations have closed.
+
+---
+
+## Event home
+
+Once inside, attendees land on the **event hub**:
+
+- Event name, description, banner image, and organiser branding
+- Dates and location
+- **Full agenda**, grouped by day, with session times and details
+- **My Sessions** — a personal list of talks they have bookmarked
+- Optional **event ratings** (if the organiser has turned that on)
+
+This is the home base they can return to from anywhere in the app.
+
+---
+
+## Sessions and the agenda
+
+Each session has its own page with:
+
+- Title, type (for example symposium or panel), time, duration, and room
+- Option to **bookmark** it for later
+- **Key people** — speakers, moderators, and similar, with photo, title, bio, organisation, and public links (LinkedIn, X, website) when provided
+- **My Sessions** and the **full agenda** again, so people can jump to the next talk without going home
+- Live **Q&A** and **quizzes / polls** for that room (when the organiser has them running)
+
+Organisers can hide individual tabs (key people, my sessions, or agenda) per session if they want a simpler room experience.
+
+**Tracks** (parallel programmes, halls, or streams) have their own landing page when someone joins with a track code.
+
+---
+
+## Live Q&A
+
+During a session, attendees can:
+
+- **Ask a question** from their phone (including anonymously if they are not fully signed in)
+- See questions from the rest of the room as they come in
+- **Upvote or downvote** questions so popular ones rise
+
+On the **presenter screen**, shortlisted questions can be shown to the room so the speaker can answer what the audience actually cares about.
+
+---
+
+## Quizzes, polls, and surveys
+
+Organisers can push live activities into a session. Attendees are taken into them (including automatically when something goes live) and can:
+
+- Answer **quizzes** (scored, with right/wrong answers)
+- Vote in **polls**
+- Complete **surveys** (including text answers and ratings)
+
+Question types include single choice, multiple choice, free text, and star ratings. Quizzes can run on a **timer**. After submitting, people may see their own answers, the correct answers (when the host allows it), and live result summaries.
+
+**On-stage presentation screens** let the host:
+
+- Put a question on air
+- Show live response charts
+- See who has answered
+- Show a **quiz leaderboard**
+- Reveal the correct answer when they choose
+- Switch between Q&A and quiz/poll views for the same session
+
+---
+
+## Rewards and leaderboard
+
+Participation can earn **points**. Typical ways to earn them include registering, asking or voting on questions, taking quizzes or polls, and submitting feedback or ratings. Organisers can also adjust points manually.
+
+Attendees can:
+
+- See their **total points** and a history of how they earned them
+- Open a **leaderboard** of top participants
+- See **their own rank** among the group
+
+A celebration can appear when points are awarded, so the reward feels immediate.
+
+---
+
+## Feedback and ratings
+
+When the organiser enables them, attendees can fill in **custom forms**:
+
+- **Feedback** — comments and structured questions about the event or a session
+- **Ratings** — typically star ratings and similar scores, including an overall event rating on the event home
+
+Forms are built by the organiser (required fields, multiple choice, checkboxes, stars, and so on). Submission is a simple one-screen flow on the phone.
+
+---
+
+## Photo experiences
+
+These are usually opened from a dedicated QR or link at a booth or after a session. They are designed for sharing on social media.
+
+### Photo wall
+
+Attendees take or upload a selfie (cropping to the right shape if needed). The photo can be placed in an event **frame**, and the organiser can ask for a name and extra fields. Photos may wait for **approval** before they appear.
+
+A **live photo wall** on a large screen shows approved photos as they come in.
+
+People can **download** their framed photo and **share** it to LinkedIn, Facebook, Instagram, Snapchat, X, WhatsApp, or the phone’s native share sheet — whichever the organiser has allowed.
+
+### Find my photos
+
+Attendees upload a selfie and the app **finds photos of them** from the event. Matches can be downloaded and shared the same way.
+
+### Social share (branded frames)
+
+Guests pick an official **event frame**, add their photo, crop it, and walk away with a ready-to-post image.
+
+### AI avatar
+
+Guests pick a themed avatar template, upload a selfie, and the app generates a **stylised portrait** of them in that look. They can download and share the result.
+
+---
+
+## Moving around the app
+
+A floating **quick menu** (on session and related screens) jumps to:
+
+- Event home
+- Rewards
+- Leaderboard
+- Feedback and ratings (when those are on for the session)
+- All questions
+
+The header shows the **event logo**, a **points total**, and sign-out. The layout is built for a phone in the hand at a venue.
+
+---
+
+## Branding and trust
+
+Each event can show its own **logo, banner, name, and colours**. Legal pages for **privacy** and **terms of service** are available from the marketing/legal area of the site.
+
+---
+
+## Typical attendee journey
+
+1. Scan a poster QR or type a code from a slide.
+2. Sign in (or continue as a guest) and complete a profile if asked.
+3. Browse the agenda, bookmark sessions, and open the current talk.
+4. Ask questions, vote, and take a live poll or quiz.
+5. Earn points and check the leaderboard.
+6. Rate the session or send feedback.
+7. At a photo booth or from a follow-up link, create a framed photo, find pictures of themselves, or generate an AI avatar — then share it.
+
+---
+
+## What this app is not
+
+It is **not** the organiser control panel (creating events, building forms, managing attendees). It is **not** a check-in / badge-scanning tool. Those live in other EventBuzz products. This app is what **guests and the room** use during and around the event.
diff --git a/src/App.tsx b/src/App.tsx
index b13a98c..1c5dd63 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -20,6 +20,9 @@ import { QuizPollPresentPage } from "./pages/QuizPollPresentPage";
import { QuestionsPresentPage } from "./pages/QuestionsPresentPage";
import { PhotoWallUploadPage } from "./pages/PhotoWallUploadPage";
import { PhotoWallPresentPage } from "./pages/PhotoWallPresentPage";
+import { PhotoSearchPage } from "./pages/PhotoSearchPage";
+import { SocialSharePage } from "./pages/SocialSharePage";
+import { AiAvatarPage } from "./pages/AiAvatarPage";
import { CodeEntryPage } from "./pages/CodeEntryPage";
import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage";
import { TermsOfServicePage } from "./pages/TermsOfServicePage";
@@ -45,6 +48,9 @@ export default function App() {
} />
} />
} />
+ } />
+ } />
+ } />
} />
} />
} />
diff --git a/src/components/PhotoWallEventHeader.tsx b/src/components/PhotoWallEventHeader.tsx
index 93022c8..cec5c9f 100644
--- a/src/components/PhotoWallEventHeader.tsx
+++ b/src/components/PhotoWallEventHeader.tsx
@@ -48,11 +48,15 @@ function formatEventDateRange(ev: PhotoWallPublicEvent): string {
export function PhotoWallEventHeader({
event,
display,
+ /** Small label above the event title. Default "Photo Wall"; pass null/"" to hide. */
+ eyebrow = "Photo Wall",
}: {
event: PhotoWallPublicEvent;
display?: PhotoWallEventHeaderDisplay | null;
+ eyebrow?: string | null;
}) {
const d = resolveDisplay(display);
+ const eyebrowText = eyebrow == null ? "" : String(eyebrow).trim();
const typeLabel = formatEventType(event.eventType);
const dateLine = formatEventDateRange(event);
const kind = formatLocationKind(event.locationType);
@@ -87,7 +91,14 @@ export function PhotoWallEventHeader({
);
}
- if (!showBanner && !showLogo && !showTitle && !showSubtitle && metaItems.length === 0) {
+ if (
+ !showBanner &&
+ !showLogo &&
+ !showTitle &&
+ !showSubtitle &&
+ metaItems.length === 0 &&
+ !eyebrowText
+ ) {
return null;
}
@@ -109,7 +120,9 @@ export function PhotoWallEventHeader({
) : null}
{showTitle || showSubtitle || metaItems.length > 0 ? (
-
Photo Wall
+ {eyebrowText ? (
+
{eyebrowText}
+ ) : null}
{showTitle ? (
{event.name}
@@ -133,9 +146,9 @@ export function PhotoWallEventHeader({
) : null}
+ ) : null}
);
}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index fefe81a..26e409f 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -1074,9 +1074,7 @@ export async function uploadPhotoWallSelfie(
? fileUrlRelative.startsWith("http")
? fileUrlRelative
: `${base}${fileUrlRelative.startsWith("/") ? "" : "/"}${fileUrlRelative}`
- : photoId
- ? `${base}/api/public/photo-wall/upload/${encodeURIComponent(uploadToken)}/photos/${encodeURIComponent(photoId)}/file`
- : null;
+ : data?.previewUrl || framedUrl || photoUrl || null;
return {
awaitingApproval: Boolean(data?.awaitingApproval),
previewUrl: data?.previewUrl || framedUrl || photoUrl,
@@ -1097,3 +1095,247 @@ export async function getPhotoWallPresent(presentToken: string): Promise