From ff8dfb0636f5e07c77f2b637071e08d291712d1a Mon Sep 17 00:00:00 2001 From: Tanuja Pansare Date: Wed, 30 Sep 2026 18:01:53 +0530 Subject: [PATCH] Add spotlight feature --- src/app/App.tsx | 64 +++-- .../components/dashboard/EventHomeModules.tsx | 142 +++++++++-- .../dashboard/SpotlightManagePanel.tsx | 221 ++++++++++++++++++ src/app/utils/spotlightUrls.ts | 20 ++ src/services/api/index.ts | 1 + src/services/api/spotlight.ts | 55 +++++ 6 files changed, 465 insertions(+), 38 deletions(-) create mode 100644 src/app/components/dashboard/SpotlightManagePanel.tsx create mode 100644 src/app/utils/spotlightUrls.ts create mode 100644 src/services/api/spotlight.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index 8a279341..7ae854a6 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -14,6 +14,7 @@ import { SuperAdminMicrositeV2TemplatesPage } from "./views/SuperAdminMicrositeV import { EventTicketCheckoutView } from "../views/EventTicketCheckoutView"; import { PublicFormView } from "../views/PublicFormView"; import { PublicAttendanceCheckInView } from "../views/PublicAttendanceCheckInView"; +import { AppNavProvider } from "./contexts/AppNavContext"; export default function App() { return ( @@ -21,29 +22,46 @@ export default function App() { - - - - } /> - } /> - } /> - } /> - } /> - } /> - }> - } /> - }> - } /> - } /> - } /> - } /> - - } /> - } /> - - } /> - - + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + + }> + } /> + + }> + } /> + } /> + } /> + } + /> + + + } + /> + + } + /> + + + } /> + + + diff --git a/src/app/components/dashboard/EventHomeModules.tsx b/src/app/components/dashboard/EventHomeModules.tsx index 28be1f58..e345e667 100644 --- a/src/app/components/dashboard/EventHomeModules.tsx +++ b/src/app/components/dashboard/EventHomeModules.tsx @@ -21,16 +21,19 @@ import { CreateBadgePanel } from "../panels/CreateBadgePanel"; import { EventBadgesPanel } from "./EventBadgesPanel"; import { EventDocumentsPanel } from "./EventDocumentsPanel"; import { PhotoWallManagePanel } from "./PhotoWallManagePanel"; +import { SpotlightManagePanel } from "./SpotlightManagePanel"; import { SocialShareManagePanel } from "./SocialShareManagePanel"; import { AiAvatarManagePanel } from "./AiAvatarManagePanel"; import { EngagementManagePanel } from "./EngagementManagePanel"; import { EventPhotoSearchPanel } from "../panels/EventPhotoSearchPanel"; import { photoWallApi } from "../../../services/api/photoWall"; +import { spotlightApi } from "../../../services/api/spotlight"; import { eventPhotoSearchApi } from "../../../services/api/eventPhotoSearch"; import { socialShareApi } from "../../../services/api/socialShare"; import { aiAvatarApi } from "../../../services/api/aiAvatar"; import { engagementApi } from "../../../services/api/engagement"; import { photoWallUploadUrl, photoWallPresentUrl } from "../../utils/photoWallUrls"; +import { spotlightPresentUrl } from "../../utils/spotlightUrls"; import { photoSearchVisitorUrl } from "../../utils/photoSearchUrls"; import { socialShareVisitorUrl } from "../../utils/socialShareUrls"; import { aiAvatarVisitorUrl } from "../../utils/aiAvatarUrls"; @@ -321,6 +324,12 @@ export function EventHomeModules() { const [photoWallUploadToken, setPhotoWallUploadToken] = useState(null); const [photoWallPresentToken, setPhotoWallPresentToken] = useState(null); const [copiedPhotoWallLink, setCopiedPhotoWallLink] = useState<"upload" | "present" | null>(null); + const [showSpotlightPanel, setShowSpotlightPanel] = useState(false); + const [spotlightEnabled, setSpotlightEnabled] = useState(false); + const [spotlightLoading, setSpotlightLoading] = useState(false); + const [spotlightCount, setSpotlightCount] = useState(0); + const [spotlightPresentToken, setSpotlightPresentToken] = useState(null); + const [copiedSpotlightLink, setCopiedSpotlightLink] = useState(false); const [showSocialSharePanel, setShowSocialSharePanel] = useState(false); const [socialShareEnabled, setSocialShareEnabled] = useState(false); const [socialShareLoading, setSocialShareLoading] = useState(false); @@ -691,6 +700,33 @@ export function EventHomeModules() { void loadPhotoWallMeta(); }, [loadPhotoWallMeta]); + const loadSpotlightMeta = useCallback(async () => { + if (!selectedEventId || !canReadPhotoWall) { + setSpotlightEnabled(false); + setSpotlightCount(0); + setSpotlightPresentToken(null); + setSpotlightLoading(false); + return; + } + setSpotlightLoading(true); + try { + const res = await spotlightApi.get(selectedEventId); + setSpotlightEnabled(Boolean(res.data?.enabled)); + setSpotlightCount(res.data?.photoCount ?? 0); + setSpotlightPresentToken(res.data?.presentToken ?? null); + } catch { + setSpotlightEnabled(false); + setSpotlightCount(0); + setSpotlightPresentToken(null); + } finally { + setSpotlightLoading(false); + } + }, [selectedEventId, canReadPhotoWall]); + + useEffect(() => { + void loadSpotlightMeta(); + }, [loadSpotlightMeta]); + const loadSocialShareMeta = useCallback(async () => { if (!selectedEventId || !canReadSocialShare) { setSocialShareEnabled(false); @@ -1070,22 +1106,65 @@ export function EventHomeModules() { } }; - const copyPhotoWallLink = async (kind: "upload" | "present") => { - const url = - kind === "upload" && photoWallUploadToken - ? photoWallUploadUrl(photoWallUploadToken) - : kind === "present" && photoWallPresentToken - ? photoWallPresentUrl(photoWallPresentToken) - : ""; - if (!url) return; - try { - await navigator.clipboard.writeText(url); - setCopiedPhotoWallLink(kind); - window.setTimeout(() => setCopiedPhotoWallLink(null), 1500); - } catch { - /* ignore */ + const handleSpotlightToggle = async (enabled: boolean) => { + if (!selectedEventId || !canWritePhotoWall) return; + + setSpotlightLoading(true); + + try { + const res = await spotlightApi.update(selectedEventId, { enabled }); + + if (!res.error && res.data) { + setSpotlightEnabled(Boolean(res.data.enabled)); + setSpotlightCount(res.data.photoCount ?? 0); + setSpotlightPresentToken(res.data.presentToken ?? null); } - }; + } finally { + setSpotlightLoading(false); + } +}; + +const copySpotlightLink = async () => { + if (!spotlightPresentToken) return; + + try { + await navigator.clipboard.writeText( + spotlightPresentUrl(spotlightPresentToken) + ); + + setCopiedSpotlightLink(true); + window.setTimeout(() => setCopiedSpotlightLink(false), 1500); + } catch { + /* ignore */ + } +}; + +const spotlightMeta = !selectedEventId + ? "Select an event to configure Spotlight" + : spotlightLoading + ? "Loading…" + : spotlightEnabled + ? `Enabled · ${spotlightCount} photo${spotlightCount === 1 ? "" : "s"}` + : "Disabled"; + +const copyPhotoWallLink = async (kind: "upload" | "present") => { + const url = + kind === "upload" && photoWallUploadToken + ? photoWallUploadUrl(photoWallUploadToken) + : kind === "present" && photoWallPresentToken + ? photoWallPresentUrl(photoWallPresentToken) + : ""; + + if (!url) return; + + try { + await navigator.clipboard.writeText(url); + setCopiedPhotoWallLink(kind); + window.setTimeout(() => setCopiedPhotoWallLink(null), 1500); + } catch { + /* ignore */ + } +}; const handleSavePollQuiz = async ( kind: "poll" | "quiz", @@ -1952,6 +2031,29 @@ export function EventHomeModules() { } /> ) : null} + {canReadPhotoWall ? ( + } + title="Spotlight" + meta={spotlightMeta} + description="One presenter wall for published event photos" + headerAction={spotlightEnabled && spotlightPresentToken ? ( + + ) : null} + actions={ + <> + {spotlightEnabled ? ( + + ) : ( + + )} + + + } + /> + ) : null} {canReadWaNotifications ? ( } @@ -2221,6 +2323,16 @@ export function EventHomeModules() { /> ) : null} + {showSpotlightPanel && selectedEventId ? ( + setShowSpotlightPanel(false)} + onChanged={() => void loadSpotlightMeta()} + /> + ) : null} + {showSocialSharePanel && selectedEventId ? ( = { + socialShare: 'Social Share', aiAvatar: 'AI Avatar', quiz: 'Quiz', photoWall: 'Photo Wall', +}; +const DEFAULT_SOURCES: SpotlightSources = { socialShare: true, aiAvatar: true, quiz: true, photoWall: true }; + +export function SpotlightManagePanel({ + eventId, eventName, canWrite, onClose, onChanged, +}: { + eventId: string; eventName?: string; canWrite: boolean; onClose: () => void; onChanged?: () => void; +}) { + const [tab, setTab] = useState('photos'); + const [config, setConfig] = useState(null); + const [photos, setPhotos] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [copied, setCopied] = useState(false); + const [uploading, setUploading] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [draft, setDraft] = useState({ + sources: DEFAULT_SOURCES, + photosPerRow: 4, + totalRows: 3, + marqueeDirection: 'alternate' as SpotlightConfig['marqueeDirection'], + maxPhotoWidth: 320, + maxPhotoHeight: 320, + gapPx: 8, + presentMarginTopPx: 0, + presentMarginLeftPx: 0, + presentMarginRightPx: 0, + presentPhotoShape: 'rounded' as SpotlightConfig['presentPhotoShape'], + showUploaderNameOnWall: false, + frameEnabled: false, + frameImageUrl: '', + frameImageLayout: defaultFrameImageLayout() as PhotoWallFrameImageLayout, + frameCanvasWidth: 1080, + frameCanvasHeight: 1080, + frameLayout: [] as PhotoWallFrameElement[], + }); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + try { + const res = await spotlightApi.get(eventId); + if (res.error || !res.data) throw new Error(res.error || 'Could not load Spotlight.'); + const next = res.data; + setConfig(next); + setPhotos(next.photos || []); + setDraft({ + sources: { ...DEFAULT_SOURCES, ...next.sources }, + photosPerRow: next.photosPerRow ?? 4, + totalRows: next.totalRows ?? 3, + marqueeDirection: next.marqueeDirection || 'alternate', + maxPhotoWidth: next.maxPhotoWidth ?? 320, + maxPhotoHeight: next.maxPhotoHeight ?? 320, + gapPx: next.gapPx ?? 8, + presentMarginTopPx: next.presentMarginTopPx ?? 0, + presentMarginLeftPx: next.presentMarginLeftPx ?? 0, + presentMarginRightPx: next.presentMarginRightPx ?? 0, + presentPhotoShape: next.presentPhotoShape || 'rounded', + showUploaderNameOnWall: Boolean(next.showUploaderNameOnWall), + frameEnabled: Boolean(next.frameEnabled), + frameImageUrl: next.frameImageUrl || '', + frameImageLayout: normalizeFrameImageLayout(next.frameImageLayout), + frameCanvasWidth: next.frameCanvasWidth ?? 1080, + frameCanvasHeight: next.frameCanvasHeight ?? 1080, + frameLayout: normalizeFrameLayout(next.frameLayout, next.frameCanvasWidth ?? 1080, next.frameCanvasHeight ?? 1080), + }); + } catch (e) { + setError(e instanceof Error ? e.message : 'Could not load Spotlight.'); + } finally { + setLoading(false); + } + }, [eventId]); + + useEffect(() => { void load(); }, [load]); + + const save = async () => { + if (!canWrite) return; + setSaving(true); + setError(''); + try { + const payload = tab === 'frame' + ? { + frameEnabled: draft.frameEnabled, frameImageUrl: draft.frameImageUrl.trim() || null, + frameImageLayout: draft.frameImageLayout, frameCanvasWidth: draft.frameCanvasWidth, + frameCanvasHeight: draft.frameCanvasHeight, frameLayout: draft.frameLayout, + } + : { + sources: draft.sources, photosPerRow: draft.photosPerRow, totalRows: draft.totalRows, + marqueeDirection: draft.marqueeDirection, maxPhotoWidth: draft.maxPhotoWidth, + maxPhotoHeight: draft.maxPhotoHeight, gapPx: draft.gapPx, + presentMarginTopPx: draft.presentMarginTopPx, presentMarginLeftPx: draft.presentMarginLeftPx, + presentMarginRightPx: draft.presentMarginRightPx, presentPhotoShape: draft.presentPhotoShape, + showUploaderNameOnWall: draft.showUploaderNameOnWall, + }; + const res = await spotlightApi.update(eventId, payload); + if (res.error || !res.data) throw new Error(res.error || 'Could not save Spotlight.'); + setConfig(res.data); + setPhotos(res.data.photos || []); + onChanged?.(); + } catch (e) { + setError(e instanceof Error ? e.message : 'Could not save Spotlight.'); + } finally { + setSaving(false); + } + }; + + const copyLink = async () => { + if (!config?.presentToken) return; + try { + await navigator.clipboard.writeText(spotlightPresentUrl(config.presentToken)); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { setError('Could not copy presenter link.'); } + }; + + const uploadFrame = async (file: File | null) => { + if (!file || !canWrite) return; + setUploading(true); + try { + const result = await uploadMediaFile(file, { eventId, module: 'spotlight/frames' }); + if (!result.url) throw new Error('Upload succeeded but no URL was returned.'); + setDraft((current) => ({ ...current, frameImageUrl: result.url, frameImageLayout: defaultFrameImageLayout() })); + } catch (e) { setError(e instanceof Error ? e.message : 'Frame upload failed.'); } + finally { setUploading(false); } + }; + + const addLayer = (element: PhotoWallFrameElement) => { + setDraft((current) => ({ ...current, frameLayout: [...current.frameLayout, element] })); + setSelectedId(element.id); + }; + + const patchSelected = (patch: Partial) => { + setDraft((current) => ({ + ...current, + frameLayout: current.frameLayout.map((element) => element.id === selectedId ? { ...element, ...patch } : element), + })); + }; + + const tabButton = (id: Tab, label: string) => ( + + ); + + return ( + void save()} panelWidth={tab === 'frame' ? 'min(960px,100vw)' : 'min(720px,100vw)'}> +
+ {tabButton('photos', 'Photos')}{tabButton('settings', 'Settings')}{tabButton('frame', 'Frame')}{tabButton('links', 'Links')} +
+
+ {error ?

{error}

: null} + {loading ?

Loading…

: null} + {!loading && tab === 'photos' ? ( + photos.length ?
+ {photos.map((photo) =>
{SOURCE_LABELS[photo.source]}
)} +
:

No published photos from the selected sources yet.

+ ) : null} + {!loading && tab === 'settings' ? <> + +
+ + + + + + + + +
+ + : null} + {!loading && tab === 'frame' ? <> + +
+ + + + + +
+
+ setDraft((d) => ({ ...d, frameLayout }))} onFrameImageLayoutChange={(frameImageLayout) => setDraft((d) => ({ ...d, frameImageLayout }))} /> + element.id === selectedId) || null} onChange={patchSelected} disabled={!canWrite} canvasWidth={draft.frameCanvasWidth} canvasHeight={draft.frameCanvasHeight} /> +
+ : null} + {!loading && tab === 'links' ? <> + + {canWrite ? : null} + : null} +
+
+ ); +} + +const buttonStyle: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 10px', border: '1px solid var(--border)', borderRadius: 'var(--radius-button)', background: 'var(--card)', color: 'var(--foreground)', fontSize: 'var(--text-sm)', cursor: 'pointer' }; \ No newline at end of file diff --git a/src/app/utils/spotlightUrls.ts b/src/app/utils/spotlightUrls.ts new file mode 100644 index 00000000..e992d58e --- /dev/null +++ b/src/app/utils/spotlightUrls.ts @@ -0,0 +1,20 @@ +function eventbuzzPublicBase(): string { + const fromEnv = + (typeof import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL === 'string' && import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL.trim()) || + ''; + const raw = (fromEnv || (import.meta.env.DEV ? 'http://localhost:5174' : 'https://join.eventbuzz.live')).replace(/\/+$/, ''); + try { + const url = new URL(raw); + if (url.hostname === 'eventbuzz.live' || url.hostname === 'www.eventbuzz.live') { + url.hostname = 'join.eventbuzz.live'; + return url.origin; + } + } catch { + /* Keep configured base URL as-is. */ + } + return raw; +} + +export function spotlightPresentUrl(presentToken: string): string { + return `${eventbuzzPublicBase()}/spotlight-present/${encodeURIComponent(presentToken)}`; +} \ No newline at end of file diff --git a/src/services/api/index.ts b/src/services/api/index.ts index 6b692bc9..7880ae3f 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -61,6 +61,7 @@ export { type PhotoWallMarqueeDirection, type PhotoWallEventHeader, } from './photoWall'; +export { spotlightApi, type SpotlightConfig, type SpotlightPhoto, type SpotlightSource, type SpotlightSources, type SpotlightUpdatePayload } from './spotlight'; export { attendancesApi, diff --git a/src/services/api/spotlight.ts b/src/services/api/spotlight.ts new file mode 100644 index 00000000..cc03cbb3 --- /dev/null +++ b/src/services/api/spotlight.ts @@ -0,0 +1,55 @@ +import { api } from './client'; +import type { PhotoWallEventHeader, PhotoWallFrameElement, PhotoWallFrameImageLayout, PhotoWallMarqueeDirection, PhotoWallPresentPhotoShape } from './photoWall'; + +export type SpotlightSource = 'socialShare' | 'aiAvatar' | 'quiz' | 'photoWall'; +export type SpotlightSources = Record; + +export interface SpotlightPhoto { + _id: string; + source: SpotlightSource; + url: string; + createdAt?: string; + displayName?: string | null; +} + +export interface SpotlightConfig { + _id: string; + event: string; + organization: string; + enabled: boolean; + sources: SpotlightSources; + presentToken: string | null; + photoCount: number; + photos: SpotlightPhoto[]; + photosPerRow: number; + totalRows: number; + marqueeDirection: PhotoWallMarqueeDirection; + maxPhotoWidth: number; + maxPhotoHeight: number; + gapPx: number; + presentMarginTopPx: number; + presentMarginLeftPx: number; + presentMarginRightPx: number; + presentPhotoShape: PhotoWallPresentPhotoShape; + showUploaderNameOnWall: boolean; + frameEnabled: boolean; + frameImageUrl: string | null; + frameImageLayout: PhotoWallFrameImageLayout; + frameCanvasWidth: number; + frameCanvasHeight: number; + frameLayout: PhotoWallFrameElement[]; + presenterEventHeader: PhotoWallEventHeader; +} + +export type SpotlightUpdatePayload = Partial> & { + regenerateTokens?: boolean; +}; + +export const spotlightApi = { + get(eventId: string) { + return api.get(`/events/${eventId}/spotlight`); + }, + update(eventId: string, payload: SpotlightUpdatePayload) { + return api.patch(`/events/${eventId}/spotlight`, payload); + }, +}; \ No newline at end of file