diff --git a/src/app/components/dashboard/EventHomeModules.tsx b/src/app/components/dashboard/EventHomeModules.tsx index e345e667..9d1f80b9 100644 --- a/src/app/components/dashboard/EventHomeModules.tsx +++ b/src/app/components/dashboard/EventHomeModules.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState } from "react"; -import { BarChart2, CalendarDays, Check, FileText, Globe, Image, Images, Link2, Mail, MessageSquare, Palette, Plus, Share2, ShieldCheck, Sparkles, Store, Tag, Ticket, Trophy, Upload, UserCircle } from "lucide-react"; +import { BarChart2, CalendarDays, Check, FileText, Gamepad2, Globe, Image, Images, Link2, Mail, MessageSquare, Palette, Plus, Share2, ShieldCheck, Sparkles, Store, Tag, Ticket, Trophy, Upload, UserCircle } from "lucide-react"; import { useBreakpoint } from "../../hooks/useBreakpoint"; import { useAppNav } from "../../contexts/AppNavContext"; import { useAuth } from "../../contexts/AuthContext"; @@ -43,6 +43,7 @@ import { WhatsAppBlastHistoryPanel } from "./WhatsAppBlastHistoryPanel"; import { SendWhatsAppNotificationPanel } from "./SendWhatsAppNotificationPanel"; import { whatsappBlastsApi } from "../../../services/api/whatsappBlasts"; import { EventExhibitorsPanel } from "./EventExhibitorsPanel"; +import { GameManagePanel } from "./GameManagePanel"; import { AddExhibitorPanel } from "../panels/AddExhibitorPanel"; import { AddQuizPollModal, buildQuizPollSettingsPayload } from "../quiz/AddQuizPollModal"; import type { QuizPollModalForm } from "../quiz/AddQuizPollModal"; @@ -58,7 +59,9 @@ import { exhibitorsApi, type ApiExhibitor, } from "../../../services/api"; -import type { FormWithSubmissionCount } from "../../../services/api/types"; +import type { ActivityModule, FormWithSubmissionCount } from "../../../services/api/types"; +import { gamesApi } from "../../../services/api/games"; +import { gameQuizUrl, matchUpUrl } from "../../utils/gameUrls"; import { publicFormPageUrl } from "../../utils/publicFormUrl"; function ModuleCard({ @@ -194,6 +197,16 @@ const NEW_FORM_OPTIONS: { value: "registration" | "feedback" | "web-check-in" | { value: "other", label: "Other" }, ]; +const ACTIVITY_MODULE_OPTIONS: { value: ActivityModule; label: string }[] = [ + { value: "photoSearch", label: "Event Photo Search" }, + { value: "socialShare", label: "Social Share" }, + { value: "aiAvatar", label: "AI Avatar" }, + { value: "photoWall", label: "Photo Wall" }, + { value: "spotlight", label: "Spotlight" }, + { value: "matchUp", label: "Match Up" }, + { value: "quizGame", label: "Quiz" }, +]; + function formPanelMode(apiType: string): "feedback" | "registration" | "other" | "web-check-in" { if (apiType === "feedback") return "feedback"; if (apiType === "registration") return "registration"; @@ -253,6 +266,7 @@ export function EventHomeModules() { const canReadWebCheckIn = canModule("web_check_in", "read"); const canWriteWebCheckIn = canModule("web_check_in", "write"); const canReadDocuments = canModule("content_management", "read"); + const canWriteDocuments = canModule("content_management", "write"); const canReadPhotoWall = canModule("photo_wall", "read"); const canWritePhotoWall = canModule("photo_wall", "write"); const canReadSocialShare = canModule("social_share", "read"); @@ -308,6 +322,9 @@ export function EventHomeModules() { const [agendaCounts, setAgendaCounts] = useState({ tracks: 0, sessions: 0 }); const [agendaLoading, setAgendaLoading] = useState(false); const [eventSettings, setEventSettings] = useState>({}); + const [activityModules, setActivityModules] = useState([]); + const [activityModuleType, setActivityModuleType] = useState("photoSearch"); + const [activityModulesLoading, setActivityModulesLoading] = useState(false); const [webCheckInEnabled, setWebCheckInEnabled] = useState(false); const [webCheckInLoading, setWebCheckInLoading] = useState(false); const [showDocumentsPanel, setShowDocumentsPanel] = useState(false); @@ -343,6 +360,10 @@ export function EventHomeModules() { const [aiAvatarToken, setAiAvatarToken] = useState(null); const [copiedAiAvatarLink, setCopiedAiAvatarLink] = useState(false); const [showEngagementPanel, setShowEngagementPanel] = useState(false); + const [showMatchUpPanel, setShowMatchUpPanel] = useState(false); + const [showQuizGamePanel, setShowQuizGamePanel] = useState(false); + const [matchUpMeta, setMatchUpMeta] = useState(null); + const [quizGameMeta, setQuizGameMeta] = useState(null); const [engagementEnabled, setEngagementEnabled] = useState(false); const [engagementLoading, setEngagementLoading] = useState(false); const [copiedEngagementLink, setCopiedEngagementLink] = useState(false); @@ -379,6 +400,8 @@ export function EventHomeModules() { useEffect(() => { if (!selectedEventId) { setEventName(null); + setEventSettings({}); + setActivityModules([]); return; } let cancelled = false; @@ -386,6 +409,10 @@ export function EventHomeModules() { if (cancelled) return; setEventName(res.data?.name ?? null); setEventSettings((res.data?.settings as Record | undefined) ?? {}); + const configuredModules = res.data?.settings?.activityModules; + setActivityModules( + ACTIVITY_MODULE_OPTIONS.map((option) => option.value).filter((module) => configuredModules?.includes(module)), + ); setWebCheckInEnabled(Boolean((res.data?.settings as Record | undefined)?.webCheckInEnabled)); }); return () => { @@ -670,7 +697,7 @@ export function EventHomeModules() { }, [loadExhibitorMeta]); const loadPhotoWallMeta = useCallback(async () => { - if (!selectedEventId || !canReadPhotoWall) { + if (!selectedEventId || !activityModules.includes("photoWall") || !canReadPhotoWall) { setPhotoWallEnabled(false); setPhotoWallCount(0); setPhotoWallUploadToken(null); @@ -694,14 +721,14 @@ export function EventHomeModules() { } finally { setPhotoWallLoading(false); } - }, [selectedEventId, canReadPhotoWall]); + }, [selectedEventId, activityModules, canReadPhotoWall]); useEffect(() => { void loadPhotoWallMeta(); }, [loadPhotoWallMeta]); const loadSpotlightMeta = useCallback(async () => { - if (!selectedEventId || !canReadPhotoWall) { + if (!selectedEventId || !activityModules.includes("spotlight") || !canReadPhotoWall) { setSpotlightEnabled(false); setSpotlightCount(0); setSpotlightPresentToken(null); @@ -721,14 +748,14 @@ export function EventHomeModules() { } finally { setSpotlightLoading(false); } - }, [selectedEventId, canReadPhotoWall]); + }, [selectedEventId, activityModules, canReadPhotoWall]); useEffect(() => { void loadSpotlightMeta(); }, [loadSpotlightMeta]); const loadSocialShareMeta = useCallback(async () => { - if (!selectedEventId || !canReadSocialShare) { + if (!selectedEventId || !activityModules.includes("socialShare") || !canReadSocialShare) { setSocialShareEnabled(false); setSocialShareFrameCount(0); setSocialShareToken(null); @@ -749,14 +776,14 @@ export function EventHomeModules() { } finally { setSocialShareLoading(false); } - }, [selectedEventId, canReadSocialShare]); + }, [selectedEventId, activityModules, canReadSocialShare]); useEffect(() => { void loadSocialShareMeta(); }, [loadSocialShareMeta]); const loadAiAvatarMeta = useCallback(async () => { - if (!selectedEventId || !canReadAiAvatar) { + if (!selectedEventId || !activityModules.includes("aiAvatar") || !canReadAiAvatar) { setAiAvatarEnabled(false); setAiAvatarCount(0); setAiAvatarToken(null); @@ -777,7 +804,7 @@ export function EventHomeModules() { } finally { setAiAvatarLoading(false); } - }, [selectedEventId, canReadAiAvatar]); + }, [selectedEventId, activityModules, canReadAiAvatar]); useEffect(() => { void loadAiAvatarMeta(); @@ -805,7 +832,7 @@ export function EventHomeModules() { }, [loadEngagementMeta]); const loadPhotoSearchMeta = useCallback(async () => { - if (!selectedEventId) { + if (!selectedEventId || !activityModules.includes("photoSearch")) { setPhotoSearchEnabled(false); setPhotoSearchCount(0); setPhotoSearchToken(null); @@ -826,12 +853,22 @@ export function EventHomeModules() { } finally { setPhotoSearchLoading(false); } - }, [selectedEventId]); + }, [selectedEventId, activityModules]); useEffect(() => { void loadPhotoSearchMeta(); }, [loadPhotoSearchMeta]); + useEffect(() => { + if (!selectedEventId || !activityModules.includes("matchUp")) { setMatchUpMeta(null); return; } + void gamesApi.getMatchUp(selectedEventId).then((res) => setMatchUpMeta(res.data ? `${res.data.gridSize}x${res.data.gridSize} · ${res.data.enabled ? "Enabled" : "Disabled"}` : null)); + }, [selectedEventId, activityModules]); + + useEffect(() => { + if (!selectedEventId || !activityModules.includes("quizGame")) { setQuizGameMeta(null); return; } + void gamesApi.getQuiz(selectedEventId).then((res) => setQuizGameMeta(res.data ? (res.data.enabled ? `${res.data.questions.length} question${res.data.questions.length === 1 ? "" : "s"}` : "Disabled") : null)); + }, [selectedEventId, activityModules]); + const handleBadgeSuccess = () => { setShowAddBadge(false); setDuplicateBadgeBatchId(null); @@ -972,6 +1009,43 @@ export function EventHomeModules() { ? `Enabled · ${photoSearchCount} photo${photoSearchCount === 1 ? "" : "s"}` : "Disabled"; + const canWriteActivityModule = (module: ActivityModule) => { + if (module === "photoSearch") return Boolean(selectedEventId); + if (module === "socialShare") return canWriteSocialShare; + if (module === "aiAvatar") return canWriteAiAvatar; + if (module === "matchUp" || module === "quizGame") return canWriteDocuments; + return canWritePhotoWall; + }; + const availableActivityModules = ACTIVITY_MODULE_OPTIONS.filter( + (option) => !activityModules.includes(option.value) && canWriteActivityModule(option.value), + ); + const activityModulesMeta = activityModules.length + ? activityModules.map((module) => ACTIVITY_MODULE_OPTIONS.find((option) => option.value === module)?.label).filter(Boolean).join(", ") + : "No feature cards selected"; + + const updateActivityModules = async (nextModules: ActivityModule[]) => { + if (!selectedEventId) return; + setActivityModulesLoading(true); + try { + const res = await eventsApi.updateActivityModules(selectedEventId, nextModules); + if (!res.error && res.data) { + setActivityModules(res.data.activityModules); + } + } finally { + setActivityModulesLoading(false); + } + }; + + const addActivityModule = () => { + if (!canWriteActivityModule(activityModuleType) || activityModules.includes(activityModuleType)) return; + void updateActivityModules([...activityModules, activityModuleType]); + }; + + // const removeActivityModule = (module: ActivityModule) => { + // if (!activityModules.includes(module) || !canWriteActivityModule(module)) return; + // void updateActivityModules(activityModules.filter((current) => current !== module)); + // }; + const handleWebCheckInToggle = async (enabled: boolean) => { if (!selectedEventId) return; setWebCheckInLoading(true); @@ -1682,6 +1756,7 @@ const copyPhotoWallLink = async (kind: "upload" | "present") => { } /> ) : null} + {activityModules.includes("photoSearch") ? ( } title="Event Photo Search" @@ -1745,7 +1820,8 @@ const copyPhotoWallLink = async (kind: "upload" | "present") => { } /> - {canReadSocialShare ? ( + ) : null} + {activityModules.includes("socialShare") && canReadSocialShare ? ( } title="Social Share" @@ -1883,7 +1959,140 @@ const copyPhotoWallLink = async (kind: "upload" | "present") => { } /> ) : null} - {canReadAiAvatar ? ( + } + title="Activities" + description="Choose the feature cards available on this event home" + meta={activityModulesMeta} + actions={ + <> + + + + + } + + // actions={ + // <> + // + // + // {activityModules.map((module) => ( + // + // ))} + // + // } + /> + {activityModules.includes("matchUp") && canReadDocuments ? } title="Match Up" meta={matchUpMeta || "Not configured"} description="Find hidden groups of related tiles" actions={<>} /> : null} + {activityModules.includes("quizGame") && canReadDocuments ? } title="Quiz" meta={quizGameMeta || "Not configured"} description="Run a scored quiz for visitors" actions={<>} /> : null} + {activityModules.includes("aiAvatar") && canReadAiAvatar ? ( } title="AI Avatar" @@ -1952,7 +2161,7 @@ const copyPhotoWallLink = async (kind: "upload" | "present") => { } /> ) : null} - {canReadPhotoWall ? ( + {activityModules.includes("photoWall") && canReadPhotoWall ? ( } title="Photo Wall" @@ -2031,7 +2240,7 @@ const copyPhotoWallLink = async (kind: "upload" | "present") => { } /> ) : null} - {canReadPhotoWall ? ( + {activityModules.includes("spotlight") && canReadPhotoWall ? ( } title="Spotlight" @@ -2363,6 +2572,14 @@ const copyPhotoWallLink = async (kind: "upload" | "present") => { /> ) : null} + {showMatchUpPanel && selectedEventId ? ( + setShowMatchUpPanel(false)} onChanged={() => void gamesApi.getMatchUp(selectedEventId).then((res) => setMatchUpMeta(res.data ? `${res.data.gridSize}x${res.data.gridSize} · ${res.data.enabled ? "Enabled" : "Disabled"}` : null))} /> + ) : null} + + {showQuizGamePanel && selectedEventId ? ( + setShowQuizGamePanel(false)} onChanged={() => void gamesApi.getQuiz(selectedEventId).then((res) => setQuizGameMeta(res.data ? `${res.data.questions.length} question${res.data.questions.length === 1 ? "" : "s"}` : null))} /> + ) : null} + {showWaBlastHistory && selectedEventId ? ( ({ title: `Group ${i + 1}`, description: '', items: Array.from({ length: size }, () => ({ text: '', imageUrl: '' })) })); +} + +function emptyQuestions(count: number) { + return Array.from({ length: count }, () => ({ question: '', options: ['', '', '', ''], correctIndex: 0, points: 5 })); +} + +export function GameManagePanel({ kind, eventId, eventName, canWrite, onClose, onChanged }: { kind: Kind; eventId: string; eventName?: string; canWrite: boolean; onClose: () => void; onChanged?: () => void }) { + const [matchUp, setMatchUp] = useState(null); + const [quiz, setQuiz] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [uploadingBackground, setUploadingBackground] = useState(false); + const [error, setError] = useState(''); + const [copied, setCopied] = useState(false); + const backgroundInputRef = useRef(null); + const isMatchUp = kind === 'matchUp'; + const config = isMatchUp ? matchUp : quiz; + const link = config?.publicToken ? (isMatchUp ? matchUpUrl(config.publicToken) : gameQuizUrl(config.publicToken)) : ''; + const title = isMatchUp ? 'Match Up' : 'Quiz'; + + useEffect(() => { + void (async () => { + setLoading(true); + const res = isMatchUp ? await gamesApi.getMatchUp(eventId) : await gamesApi.getQuiz(eventId); + if (res.error) setError(res.error); + else if (res.data) isMatchUp ? setMatchUp(res.data) : setQuiz(res.data); + setLoading(false); + })(); + }, [eventId, isMatchUp]); + + const save = async () => { + if (!config || !canWrite) return; + + setSaving(true); + setError(''); + + const res = isMatchUp + ? await gamesApi.updateMatchUp(eventId, { + name: matchUp!.name, + backgroundImage: matchUp!.backgroundImage, + enabled: matchUp!.enabled, + gridSize: matchUp!.gridSize, + + groups: matchUp!.groups.map((group) => ({ + title: group.title, + description: group.description || '', + items: group.items.map((item) => ({ + text: item.text, + imageUrl: '', +})), + })), + }) + : await gamesApi.updateQuiz(eventId, { + name: quiz!.name, + backgroundImage: quiz!.backgroundImage, + enabled: quiz!.enabled, + questions: quiz!.questions, + }); + + if (res.error) { + setError(res.error); + } else { + isMatchUp + ? setMatchUp(res.data) + : setQuiz(res.data); + + onChanged?.(); + } + + setSaving(false); + }; + + const uploadBackground = async (file?: File) => { + if (!file || !canWrite) return; + setUploadingBackground(true); setError(''); + try { + const result = await uploadMediaFile(file, { eventId, module: `games/${kind}/background`, visibility: 'public' }); + if (result.url) { + if (isMatchUp) setMatchUp((current) => current ? { ...current, backgroundImage: result.url } : current); + else setQuiz((current) => current ? { ...current, backgroundImage: result.url } : current); + } + } catch (err) { setError(err instanceof Error ? err.message : 'Background upload failed'); } + finally { setUploadingBackground(false); } + }; + const removeBackground = () => { + if (!canWrite) return; + + if (isMatchUp) { + setMatchUp((current) => + current + ? { + ...current, + backgroundImage: '', + } + : current + ); + } else { + setQuiz((current) => + current + ? { + ...current, + backgroundImage: '', + } + : current + ); + } + + setError(''); +}; + + // const uploadTileImage = async ( + // file: File | undefined, + // groupIndex: number, + // tileIndex: number + // ) => { + // if (!file || !canWrite || !matchUp) return; + + // setError(''); + + // try { + // const result = await uploadMediaFile(file, { + // eventId, + // module: 'games/matchUp/tile', + // visibility: 'public', + // }); + + // if (result.url) { + // setMatchUp((current) => { + // if (!current) return current; + + // return { + // ...current, + // groups: current.groups.map((group, gi) => + // gi === groupIndex + // ? { + // ...group, + // items: group.items.map((item, ii) => + // ii === tileIndex + // ? { + // ...item, + // imageUrl: result.url, + // } + // : item + // ), + // } + // : group + // ), + // }; + // }); + // } + // } catch (err) { + // setError( + // err instanceof Error + // ? err.message + // : 'Tile image upload failed' + // ); + // } + // }; + + const copyLink = async () => { if (!link) return; await navigator.clipboard.writeText(link); setCopied(true); window.setTimeout(() => setCopied(false), 1500); }; + const setSize = (size: 3 | 4 | 5) => setMatchUp((current) => current ? { ...current, gridSize: size, groups: emptyGroups(size) } : current); + const addQuestion = () => setQuiz((current) => current ? { ...current, questions: [...current.questions, ...emptyQuestions(1)] } : current); + const questions = quiz?.questions || []; + const groups = matchUp?.groups || []; + const canSave = Boolean(config) && !loading && !saving && canWrite; + + return void save()} panelWidth="min(920px, 100vw)"> + {loading ? ( +

Loading...

+ ) : config ? ( +
+
+

Game details

+ + + + + + + + + + {config.backgroundImage ? ( + Background preview + ) : null} +
+ + {isMatchUp && matchUp ? ( +
+ +

+ Puzzle structure +

+ + + + + {groups.map((group, gi) => ( +
+ + {/* GROUP HEADER */} +
+ +
+ + {/* CONNECTION TITLE */} + + + setMatchUp({ + ...matchUp, + groups: groups.map((g, i) => + i === gi + ? { + ...g, + title: e.target.value, + } + : g + ), + }) + } + style={IS} + placeholder="Connection title" + /> + + + {/* CONNECTION DESCRIPTION */} + +