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/package-lock.json b/package-lock.json index 95a9ade..6b89dda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "lucide-react": "0.487.0", "react": "18.3.1", "react-dom": "18.3.1", + "react-easy-crop": "^6.2.3", "react-router-dom": "^6.28.0" }, "devDependencies": { @@ -3082,6 +3083,12 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==", + "license": "BSD-3-Clause" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3180,6 +3187,19 @@ "react": "^18.3.1" } }, + "node_modules/react-easy-crop": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.2.3.tgz", + "integrity": "sha512-ebimG3OGlzizjxEZ77Cj9CVLcg5vDZ6QVz8ud1rx4WmsCDWHIROEhgMi0GpJ/jKAuwHrH0M+QZUK4hyvxbYhOA==", + "license": "MIT", + "dependencies": { + "normalize-wheel": "^1.0.1" + }, + "peerDependencies": { + "react": ">=16.4.0", + "react-dom": ">=16.4.0" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", diff --git a/package.json b/package.json index 41d810d..f56c082 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "lucide-react": "0.487.0", "react": "18.3.1", "react-dom": "18.3.1", + "react-easy-crop": "^6.2.3", "react-router-dom": "^6.28.0" }, "devDependencies": { diff --git a/src/App.tsx b/src/App.tsx index 6ef182e..6fbb63b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,11 @@ import { SingleEventPage } from "./pages/SingleEventPage"; import { QuizPollTakePage } from "./pages/QuizPollTakePage"; 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 +50,11 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/EventBuzzLogo.tsx b/src/components/EventBuzzLogo.tsx index b578495..2278470 100644 --- a/src/components/EventBuzzLogo.tsx +++ b/src/components/EventBuzzLogo.tsx @@ -20,7 +20,7 @@ export function EventBuzzLogo({ alt={alt} width={256} height={48} - className={["block h-auto min-h-[36px] w-auto max-w-full shrink-0 object-contain", className] + className={["block w-auto max-w-full shrink-0 object-contain", className] .filter(Boolean) .join(" ")} /> diff --git a/src/components/PhotoWallCropModal.tsx b/src/components/PhotoWallCropModal.tsx new file mode 100644 index 0000000..5ba0c09 --- /dev/null +++ b/src/components/PhotoWallCropModal.tsx @@ -0,0 +1,113 @@ +import { useCallback, useState } from "react"; +import Cropper, { type Area } from "react-easy-crop"; +import { getCroppedImageFile } from "../lib/cropImage"; + +type PhotoWallCropModalProps = { + imageSrc: string; + aspect: number; + sourceFileName?: string; + onCancel: () => void; + onConfirm: (file: File) => void; +}; + +export function PhotoWallCropModal({ + imageSrc, + aspect, + sourceFileName, + onCancel, + onConfirm, +}: PhotoWallCropModalProps) { + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); + const [working, setWorking] = useState(false); + const [err, setErr] = useState(null); + + const onCropComplete = useCallback((_area: Area, pixels: Area) => { + setCroppedAreaPixels(pixels); + }, []); + + const confirm = async () => { + if (!croppedAreaPixels) return; + setWorking(true); + setErr(null); + try { + const file = await getCroppedImageFile( + imageSrc, + croppedAreaPixels, + sourceFileName || "photo.jpg", + "image/jpeg", + ); + onConfirm(file); + } catch (e) { + setErr(e instanceof Error ? e.message : "Could not crop photo"); + } finally { + setWorking(false); + } + }; + + return ( +
+
+
+

Crop your photo

+

+ Adjust to match the wall photo ratio before uploading. +

+
+ +
+ +
+ +
+ + + {err ? ( +

+ {err} +

+ ) : null} + +
+ + +
+
+
+
+ ); +} diff --git a/src/components/PhotoWallEventHeader.tsx b/src/components/PhotoWallEventHeader.tsx new file mode 100644 index 0000000..cec5c9f --- /dev/null +++ b/src/components/PhotoWallEventHeader.tsx @@ -0,0 +1,154 @@ +import type { ReactNode } from "react"; +import { Calendar, MapPin } from "lucide-react"; +import type { PhotoWallEventHeaderDisplay, PhotoWallPublicEvent } from "../lib/api"; + +function resolveDisplay(display?: PhotoWallEventHeaderDisplay | null) { + return { + showLogo: display?.showLogo !== false, + showBanner: display?.showBanner === true, + showTitle: display?.showTitle !== false, + showDateRange: display?.showDateRange !== false, + showEventType: display?.showEventType !== false, + showLocationType: display?.showLocationType !== false, + showVenue: display?.showVenue !== false, + }; +} + +const EVENT_TYPE_LABEL: Record = { + conference: "Conference", + workshop: "Workshop", + summit: "Summit", + cme: "CME", + webinar: "Webinar", +}; + +function formatEventType(t?: string): string { + if (!t) return ""; + return EVENT_TYPE_LABEL[t] || t.replace(/^\w/, (c) => c.toUpperCase()); +} + +function formatLocationKind(locationType?: string): string { + if (locationType === "virtual") return "Virtual"; + if (locationType === "hybrid") return "Hybrid"; + return "In person"; +} + +function formatEventDateRange(ev: PhotoWallPublicEvent): string { + const { startDate, endDate } = ev; + if (!startDate) return ""; + const s = new Date(startDate); + const e = endDate ? new Date(endDate) : s; + if (Number.isNaN(s.getTime())) return ""; + const sameDay = !Number.isNaN(e.getTime()) && s.toDateString() === e.toDateString(); + const dOpt: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" }; + if (sameDay) return s.toLocaleDateString(undefined, { weekday: "short", ...dOpt }); + return `${s.toLocaleDateString(undefined, dOpt)} – ${e.toLocaleDateString(undefined, dOpt)}`; +} + +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); + const venue = event.location?.trim() || ""; + const showLoc = + d.showVenue && + !!venue && + (event.locationType === "physical" || event.locationType === "hybrid" || !event.locationType); + + const showBanner = d.showBanner && !!event.imageUrl; + const showLogo = d.showLogo && !!event.logoUrl; + const showTitle = d.showTitle && !!event.name; + const showSubtitle = d.showTitle && !!event.subtitle?.trim(); + + const metaItems: ReactNode[] = []; + if (d.showDateRange && dateLine) { + metaItems.push( + + + {dateLine} + , + ); + } + if (d.showEventType && typeLabel) metaItems.push({typeLabel}); + if (d.showLocationType && kind) metaItems.push({kind}); + if (showLoc) { + metaItems.push( + + + {venue} + , + ); + } + + if ( + !showBanner && + !showLogo && + !showTitle && + !showSubtitle && + metaItems.length === 0 && + !eyebrowText + ) { + return null; + } + + return ( +
+ {showLogo ? ( +
+ {event.name} +
+ ) : null} + {showBanner ? ( +
+ +
+ ) : null} + {showTitle || showSubtitle || metaItems.length > 0 ? ( +
+ {eyebrowText ? ( +

{eyebrowText}

+ ) : null} + {showTitle ? ( +

+ {event.name} +

+ ) : null} + {showSubtitle ? ( +

{event.subtitle}

+ ) : null} + {metaItems.length > 0 ? ( +
+ {metaItems.map((item, index) => ( + + {index > 0 ? ( + + ) : null} + {item} + + ))} +
+ ) : null} +
+ ) : eyebrowText ? ( +

{eyebrowText}

+ ) : null} +
+ ); +} diff --git a/src/components/auth/AuthShell.tsx b/src/components/auth/AuthShell.tsx index 0b16e59..7de9c67 100644 --- a/src/components/auth/AuthShell.tsx +++ b/src/components/auth/AuthShell.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; +import { EventBuzzLogo } from "../EventBuzzLogo"; import { EventBuzzMark } from "../EventBuzzMark"; -import { LegalFooterLinks } from "../marketing/LegalFooterLinks"; type AuthShellProps = { children: ReactNode; @@ -8,9 +8,16 @@ type AuthShellProps = { mainClassName?: string; /** Show EventBuzz white logo pinned to the top of the auth shell */ showLogo?: boolean; + /** Classes for the footer EventBuzz mark (default h-5) */ + footerLogoClassName?: string; }; -export function AuthShell({ children, mainClassName = "", showLogo = true }: AuthShellProps) { +export function AuthShell({ + children, + mainClassName = "", + showLogo = true, + footerLogoClassName = "h-5 max-h-5 min-h-0 w-auto", +}: AuthShellProps) { return (
{showLogo ? ( @@ -23,8 +30,17 @@ export function AuthShell({ children, mainClassName = "", showLogo = true }: Aut > {children}
-