Add event games management

This commit is contained in:
Tanuja Pansare 2026-10-01 17:56:58 +05:30
parent ff8dfb0636
commit beebf1dfb7
6 changed files with 906 additions and 18 deletions

View File

@ -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<Record<string, unknown>>({});
const [activityModules, setActivityModules] = useState<ActivityModule[]>([]);
const [activityModuleType, setActivityModuleType] = useState<ActivityModule>("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<string | null>(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<string | null>(null);
const [quizGameMeta, setQuizGameMeta] = useState<string | null>(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<string, unknown> | 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<string, unknown> | 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") ? (
<ModuleCard
icon={<Image size={18} />}
title="Event Photo Search"
@ -1745,7 +1820,8 @@ const copyPhotoWallLink = async (kind: "upload" | "present") => {
</>
}
/>
{canReadSocialShare ? (
) : null}
{activityModules.includes("socialShare") && canReadSocialShare ? (
<ModuleCard
icon={<Share2 size={18} />}
title="Social Share"
@ -1883,7 +1959,140 @@ const copyPhotoWallLink = async (kind: "upload" | "present") => {
}
/>
) : null}
{canReadAiAvatar ? (
<ModuleCard
icon={<Images size={18} />}
title="Activities"
description="Choose the feature cards available on this event home"
meta={activityModulesMeta}
actions={
<>
<select
disabled={
actionsDisabled ||
activityModulesLoading ||
availableActivityModules.length === 0
}
value={
availableActivityModules.some(
(option) => option.value === activityModuleType
)
? activityModuleType
: (availableActivityModules[0]?.value ?? activityModuleType)
}
onChange={(e) =>
setActivityModuleType(e.target.value as ActivityModule)
}
style={{
...moduleButtonStyle(),
appearance: "auto",
opacity:
actionsDisabled ||
activityModulesLoading ||
availableActivityModules.length === 0
? 0.55
: 1,
cursor:
actionsDisabled ||
activityModulesLoading ||
availableActivityModules.length === 0
? "not-allowed"
: "pointer",
}}
>
{availableActivityModules.length ? (
availableActivityModules.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))
) : (
<option value={activityModuleType}>
All features added
</option>
)}
</select>
<button
type="button"
disabled={
actionsDisabled ||
activityModulesLoading ||
availableActivityModules.length === 0
}
style={{
...moduleButtonStyle(true),
flex: "0 0 auto",
opacity:
actionsDisabled ||
activityModulesLoading ||
availableActivityModules.length === 0
? 0.55
: 1,
cursor:
actionsDisabled ||
activityModulesLoading ||
availableActivityModules.length === 0
? "not-allowed"
: "pointer",
}}
onClick={addActivityModule}
>
Add
</button>
</>
}
// actions={
// <>
// <select
// disabled={actionsDisabled || activityModulesLoading || availableActivityModules.length === 0}
// value={availableActivityModules.some((option) => option.value === activityModuleType) ? activityModuleType : (availableActivityModules[0]?.value ?? activityModuleType)}
// onChange={(e) => setActivityModuleType(e.target.value as ActivityModule)}
// style={{
// ...moduleButtonStyle(),
// appearance: "auto",
// opacity: actionsDisabled || activityModulesLoading || availableActivityModules.length === 0 ? 0.55 : 1,
// cursor: actionsDisabled || activityModulesLoading || availableActivityModules.length === 0 ? "not-allowed" : "pointer",
// }}
// >
// {availableActivityModules.length ? availableActivityModules.map((option) => (
// <option key={option.value} value={option.value}>{option.label}</option>
// )) : <option value={activityModuleType}>All features added</option>}
// </select>
// <button
// type="button"
// disabled={actionsDisabled || activityModulesLoading || availableActivityModules.length === 0}
// style={{
// ...moduleButtonStyle(true),
// flex: "0 0 auto",
// opacity: actionsDisabled || activityModulesLoading || availableActivityModules.length === 0 ? 0.55 : 1,
// cursor: actionsDisabled || activityModulesLoading || availableActivityModules.length === 0 ? "not-allowed" : "pointer",
// }}
// onClick={addActivityModule}
// >
// Add
// </button>
// {activityModules.map((module) => (
// <button
// key={module}
// type="button"
// disabled={actionsDisabled || activityModulesLoading || !canWriteActivityModule(module)}
// title={`Remove ${ACTIVITY_MODULE_OPTIONS.find((option) => option.value === module)?.label || module}`}
// style={{
// ...moduleButtonStyle(),
// opacity: actionsDisabled || activityModulesLoading || !canWriteActivityModule(module) ? 0.55 : 1,
// }}
// onClick={() => removeActivityModule(module)}
// >
// Remove {ACTIVITY_MODULE_OPTIONS.find((option) => option.value === module)?.label || module}
// </button>
// ))}
// </>
// }
/>
{activityModules.includes("matchUp") && canReadDocuments ? <ModuleCard icon={<Gamepad2 size={18} />} title="Match Up" meta={matchUpMeta || "Not configured"} description="Find hidden groups of related tiles" actions={<><button type="button" disabled={actionsDisabled} style={moduleButtonStyle()} onClick={() => setShowMatchUpPanel(true)}>Manage</button></>} /> : null}
{activityModules.includes("quizGame") && canReadDocuments ? <ModuleCard icon={<Gamepad2 size={18} />} title="Quiz" meta={quizGameMeta || "Not configured"} description="Run a scored quiz for visitors" actions={<><button type="button" disabled={actionsDisabled} style={moduleButtonStyle()} onClick={() => setShowQuizGamePanel(true)}>Manage</button></>} /> : null}
{activityModules.includes("aiAvatar") && canReadAiAvatar ? (
<ModuleCard
icon={<Sparkles size={18} />}
title="AI Avatar"
@ -1952,7 +2161,7 @@ const copyPhotoWallLink = async (kind: "upload" | "present") => {
}
/>
) : null}
{canReadPhotoWall ? (
{activityModules.includes("photoWall") && canReadPhotoWall ? (
<ModuleCard
icon={<Images size={18} />}
title="Photo Wall"
@ -2031,7 +2240,7 @@ const copyPhotoWallLink = async (kind: "upload" | "present") => {
}
/>
) : null}
{canReadPhotoWall ? (
{activityModules.includes("spotlight") && canReadPhotoWall ? (
<ModuleCard
icon={<Image size={18} />}
title="Spotlight"
@ -2363,6 +2572,14 @@ const copyPhotoWallLink = async (kind: "upload" | "present") => {
/>
) : null}
{showMatchUpPanel && selectedEventId ? (
<GameManagePanel kind="matchUp" eventId={selectedEventId} eventName={eventName ?? undefined} canWrite={canWriteDocuments} onClose={() => 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 ? (
<GameManagePanel kind="quiz" eventId={selectedEventId} eventName={eventName ?? undefined} canWrite={canWriteDocuments} onClose={() => 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 ? (
<WhatsAppBlastHistoryPanel
eventId={selectedEventId}

View File

@ -0,0 +1,641 @@
import React, { useEffect, useRef, useState } from 'react';
import { Copy, Image, Plus, Trash2, Upload } from 'lucide-react';
import { gamesApi, type GameQuizConfig, type MatchUpConfig, type MatchUpGroup } from '../../../services/api/games';
import { uploadMediaFile } from '../../../services/api/media';
import { gameQuizUrl, matchUpUrl } from '../../utils/gameUrls';
import { Field, Grid, HelperText, IS, Label, PanelShell, SS } from '../panels/PanelShell';
type Kind = 'matchUp' | 'quiz';
const buttonStyle: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, padding: '8px 10px', border: '1px solid var(--border)', borderRadius: 'var(--radius-button)', background: 'var(--card)', color: 'var(--foreground)', cursor: 'pointer', fontSize: 'var(--text-sm)' };
const sectionTitleStyle: React.CSSProperties = {
color: '#163f7a',
fontSize: '20px',
fontWeight: 700,
lineHeight: 1.3,
margin: 0,
};
const sectionStyle: React.CSSProperties = {
display: 'grid',
gap: 16,
};
const groupStyle: React.CSSProperties = {
borderTop: '1px solid var(--border)',
paddingTop: 18,
display: 'grid',
gap: 14,
};
const groupHeaderStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
minHeight: 24,
};
const groupTileCountStyle: React.CSSProperties = {
color: 'var(--muted)',
fontSize: 'var(--text-sm)',
lineHeight: 1.4,
whiteSpace: 'nowrap',
};
const fieldLabelStyle: React.CSSProperties = {
color: '#27364a',
fontSize: '15px',
fontWeight: 600,
};
const helperStyle: React.CSSProperties = {
marginTop: -4,
};
const descriptionStyle: React.CSSProperties = {
...IS,
minHeight: 72,
resize: 'vertical',
lineHeight: 1.45,
};
function emptyGroups(size: number): MatchUpGroup[] {
return Array.from({ length: size }, (_, i) => ({ 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<MatchUpConfig | null>(null);
const [quiz, setQuiz] = useState<GameQuizConfig | null>(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<HTMLInputElement>(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 <PanelShell onClose={onClose} title={title} breadcrumb={eventName ? `Event Home - ${eventName}` : 'Event Home'} description={isMatchUp ? 'Set up a tile matching activity for visitors' : 'Set up a scored quiz for visitors'} completedCount={0} totalSections={0} ctaLabel={saving ? 'Saving...' : 'Save settings'} showProgress={false} hidePrimaryCta={!canWrite} ctaDisabled={!canSave} onCtaClick={() => void save()} panelWidth="min(920px, 100vw)">
{loading ? (
<p style={{ color: 'var(--muted)' }}>Loading...</p>
) : config ? (
<div
style={{
display: 'grid',
gap: 28,
padding: '0 24px 24px',
}}
>
<section style={sectionStyle}>
<h2 style={sectionTitleStyle}>Game details</h2>
<Grid>
<Field>
<Label text="Display name" required />
<input
disabled={!canWrite}
value={config.name}
onChange={(e) =>
isMatchUp
? setMatchUp({ ...matchUp!, name: e.target.value })
: setQuiz({ ...quiz!, name: e.target.value })
}
style={IS}
placeholder={isMatchUp ? 'Match Up' : 'Quiz'}
/>
<div style={helperStyle}>
<HelperText text="Shown on the visitor start screen and public game." />
</div>
</Field>
<Field>
<Label text="Background image" />
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
}}
>
<button
type="button"
disabled={!canWrite || uploadingBackground}
onClick={() => backgroundInputRef.current?.click()}
style={buttonStyle}
>
<Upload size={15} />
{uploadingBackground
? 'Uploading...'
: config.backgroundImage
? 'Change image'
: 'Upload image'}
</button>
{config.backgroundImage ? (
<button
type="button"
disabled={!canWrite || uploadingBackground}
onClick={removeBackground}
style={{
...buttonStyle,
color: 'var(--destructive)',
}}
>
<Trash2 size={15} />
Remove image
</button>
) : null}
</div>
<input
ref={backgroundInputRef}
hidden
type="file"
accept="image/*"
onChange={(e) => {
void uploadBackground(e.target.files?.[0]);
e.currentTarget.value = '';
}}
/>
{config.backgroundImage ? (
<Image size={17} color="var(--success)" />
) : null}
</div>
<div style={helperStyle}>
<HelperText text="Upload the image shown behind the public game." />
</div>
</Field>
</Grid>
{config.backgroundImage ? (
<img
src={config.backgroundImage}
alt="Background preview"
style={{
width: '100%',
maxHeight: 180,
objectFit: 'cover',
borderRadius: 8,
border: '1px solid var(--border)',
}}
/>
) : null}
</section>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
color: '#163f7a',
fontSize: '17px',
fontWeight: 600,
cursor: canWrite ? 'pointer' : 'default',
}}
>
<input
type="checkbox"
disabled={!canWrite}
checked={config.enabled}
onChange={(e) =>
isMatchUp
? setMatchUp({ ...matchUp!, enabled: e.target.checked })
: setQuiz({ ...quiz!, enabled: e.target.checked })
}
style={{
width: 18,
height: 18,
accentColor: '#155eef',
}}
/>
Enable public game
</label>
{isMatchUp && matchUp ? (
<section style={sectionStyle}>
<h2 style={sectionTitleStyle}>
Puzzle structure
</h2>
<Field>
<Label text="Metric" required />
<select
disabled={!canWrite}
value={matchUp.gridSize}
onChange={(e) =>
setSize(Number(e.target.value) as 3 | 4 | 5)
}
style={SS}
>
<option value={3}>3 x 3</option>
<option value={4}>4 x 4</option>
<option value={5}>5 x 5</option>
</select>
<div style={helperStyle}>
<HelperText
text={`${matchUp.gridSize} groups, ${matchUp.gridSize} related tiles in each group.`}
/>
</div>
</Field>
{groups.map((group, gi) => (
<div
key={gi}
style={{
...groupStyle,
marginTop: gi === 0 ? 4 : 2,
}}
>
{/* GROUP HEADER */}
<div style={groupHeaderStyle}>
<Label
text={`Group ${gi + 1}`}
required
/>
<span style={groupTileCountStyle}>
{matchUp.gridSize} tiles
</span>
</div>
{/* CONNECTION TITLE */}
<Field>
<input
disabled={!canWrite}
value={group.title}
onChange={(e) =>
setMatchUp({
...matchUp,
groups: groups.map((g, i) =>
i === gi
? {
...g,
title: e.target.value,
}
: g
),
})
}
style={IS}
placeholder="Connection title"
/>
</Field>
{/* CONNECTION DESCRIPTION */}
<Field>
<textarea
disabled={!canWrite}
value={group.description || ''}
onChange={(e) =>
setMatchUp({
...matchUp,
groups: groups.map((g, i) =>
i === gi
? {
...g,
description: e.target.value,
}
: g
),
})
}
style={descriptionStyle}
placeholder="Connection description"
/>
</Field>
{/* TILES */}
<Grid>
{group.items.map((item, ii) => (
<Field key={ii}>
<Label
text={`Tile ${ii + 1}`}
required
/>
<input
disabled={!canWrite}
value={item.text}
onChange={(e) =>
setMatchUp({
...matchUp,
groups: groups.map((g, i) =>
i === gi
? {
...g,
items: g.items.map((it, j) =>
j === ii
? {
...it,
text: e.target.value,
}
: it
),
}
: g
),
})
}
style={IS}
placeholder="Tile text"
/>
</Field>
))}
</Grid>
{/* TILES
<Grid>
{group.items.map((item, ii) => (
<Field key={ii}>
<Label
text={`Tile ${ii + 1}`}
required
/>
<input
disabled={!canWrite}
value={item.text}
onChange={(e) =>
setMatchUp({
...matchUp,
groups: groups.map((g, i) =>
i === gi
? {
...g,
items: g.items.map((it, j) =>
j === ii
? {
...it,
text: e.target.value,
}
: it
),
}
: g
),
})
}
style={IS}
placeholder="Tile text"
/>
<div
style={{
display: 'grid',
gap: 8,
}}
>
<input
id={`tile-image-${gi}-${ii}`}
type="file"
accept="image/*"
disabled={!canWrite}
hidden
onChange={(e) => {
void uploadTileImage(
e.target.files?.[0],
gi,
ii
);
e.currentTarget.value = '';
}}
/>
<label
htmlFor={`tile-image-${gi}-${ii}`}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
width: 'fit-content',
padding: '8px 12px',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-button)',
background: 'var(--card)',
color: 'var(--foreground)',
cursor: canWrite ? 'pointer' : 'default',
fontSize: 'var(--text-sm)',
}}
>
<Upload size={15} />
{item.imageUrl ? 'Change image' : 'Upload image'}
</label>
{item.imageUrl ? (
<img
src={item.imageUrl}
alt={`Tile ${ii + 1}`}
style={{
width: '100%',
height: 100,
objectFit: 'cover',
borderRadius: 6,
border: '1px solid var(--border)',
}}
/>
) : null}
</div>
</Field>
))}
</Grid> */}
</div>
))}
</section>
) : null}
{!isMatchUp && quiz ? <section style={{ display: 'grid', gap: 14 }}><strong style={{ color: 'var(--foreground)' }}>Question setup</strong><Field><Label text="Number of questions" required /><input type="number" min={1} max={50} disabled={!canWrite} value={questions.length} onChange={(e) => { const n = Math.max(1, Math.min(50, Number(e.target.value) || 1)); setQuiz({ ...quiz, questions: n > questions.length ? [...questions, ...emptyQuestions(n - questions.length)] : questions.slice(0, n) }); }} style={IS} /><HelperText text="Each correct answer is worth 5 points." /></Field>{questions.map((question, qi) => <div key={qi} style={{ borderTop: '1px solid var(--border)', paddingTop: 14, display: 'grid', gap: 10 }}><div style={{ display: 'flex', justifyContent: 'space-between' }}><Label text={`Question ${qi + 1}`} required />{questions.length > 1 ? <button type="button" disabled={!canWrite} onClick={() => setQuiz({ ...quiz, questions: questions.filter((_, i) => i !== qi) })} style={{ ...buttonStyle, color: 'var(--destructive)' }}><Trash2 size={14} />Remove</button> : null}</div><textarea disabled={!canWrite} value={question.question} onChange={(e) => setQuiz({ ...quiz, questions: questions.map((q, i) => i === qi ? { ...q, question: e.target.value } : q) })} style={{ ...IS, minHeight: 70, resize: 'vertical' }} placeholder="Write the question" /><Grid>{question.options.map((option, oi) => <Field key={oi}><Label text={`Option ${oi + 1}`} required /><input disabled={!canWrite} value={option} onChange={(e) => setQuiz({ ...quiz, questions: questions.map((q, i) => i === qi ? { ...q, options: q.options.map((o, j) => j === oi ? e.target.value : o) } : q) })} style={IS} placeholder="Answer option" /></Field>)}</Grid><Field><Label text="Correct option" required /><select disabled={!canWrite} value={question.correctIndex} onChange={(e) => setQuiz({ ...quiz, questions: questions.map((q, i) => i === qi ? { ...q, correctIndex: Number(e.target.value) } : q) })} style={SS}>{question.options.map((_, oi) => <option key={oi} value={oi}>Option {oi + 1}</option>)}</select></Field></div>)}<button type="button" disabled={!canWrite} onClick={addQuestion} style={{ ...buttonStyle, justifySelf: 'start' }}><Plus size={15} />Add question</button></section> : null}
<Field><Label text="Public game link" /><div style={{ display: 'flex', gap: 8 }}><input readOnly value={link || 'Save to generate a link'} style={{ ...IS, flex: 1 }} /><button type="button" disabled={!link} onClick={() => void copyLink()} style={buttonStyle}><Copy size={15} />{copied ? 'Copied' : 'Copy'}</button></div></Field>
{error ? (
<p style={{ color: 'var(--destructive)', margin: 0 }}>
{error}
</p>
) : null}
</div>
) : (
<p style={{ color: 'var(--destructive)' }}>
{error || 'Could not load game.'}
</p>
)}
</PanelShell>;
}

View File

@ -0,0 +1,6 @@
function publicBase(): string {
const configured = typeof import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL === 'string' ? import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL.trim() : '';
return (configured || (import.meta.env.DEV ? 'http://localhost:5174' : 'https://join.eventbuzz.live')).replace(/\/+$/, '');
}
export function matchUpUrl(token: string) { return `${publicBase()}/match-up/${encodeURIComponent(token)}`; }
export function gameQuizUrl(token: string) { return `${publicBase()}/game-quiz/${encodeURIComponent(token)}`; }

View File

@ -1,5 +1,5 @@
import { api } from './client';
import type { Event, EventDashboardAnalytics } from './types';
import type { ActivityModule, Event, EventDashboardAnalytics } from './types';
interface ListParams {
organization?: string;
@ -42,6 +42,10 @@ export const eventsApi = {
return api.put<Event>(`/events/${id}`, payload);
},
updateActivityModules(id: string, activityModules: ActivityModule[]) {
return api.patch<{ activityModules: ActivityModule[] }>(`/events/${id}/activity-modules`, { activityModules });
},
remove(id: string) {
return api.delete<unknown>(`/events/${id}`);
},

14
src/services/api/games.ts Normal file
View File

@ -0,0 +1,14 @@
import { api } from './client';
export interface MatchUpItem { _id?: string; text: string; imageUrl?: string; }
export interface MatchUpGroup { _id?: string; title: string; description?: string; items: MatchUpItem[]; }
export interface MatchUpConfig { _id: string; event: string; name: string; backgroundImage: string; gridSize: 3 | 4 | 5; groups: MatchUpGroup[]; enabled: boolean; publicToken: string; }
export interface GameQuizQuestion { _id?: string; question: string; options: string[]; correctIndex?: number; points?: number; }
export interface GameQuizConfig { _id: string; event: string; name: string; backgroundImage: string; questions: GameQuizQuestion[]; enabled: boolean; publicToken: string; }
export const gamesApi = {
getMatchUp(eventId: string) { return api.get<MatchUpConfig>(`/events/${eventId}/match-up`); },
updateMatchUp(eventId: string, payload: Omit<MatchUpConfig, '_id' | 'event' | 'publicToken'>) { return api.patch<MatchUpConfig>(`/events/${eventId}/match-up`, payload); },
getQuiz(eventId: string) { return api.get<GameQuizConfig>(`/events/${eventId}/game-quiz`); },
updateQuiz(eventId: string, payload: Omit<GameQuizConfig, '_id' | 'event' | 'publicToken'>) { return api.patch<GameQuizConfig>(`/events/${eventId}/game-quiz`, payload); },
};

View File

@ -360,6 +360,12 @@ export interface EventWebsiteSettings {
theme?: WebsiteThemeConfig | null;
}
export type ActivityModule = 'photoSearch' | 'socialShare' | 'aiAvatar' | 'photoWall' | 'spotlight' | 'matchUp' | 'quizGame';
export interface EventSettings extends Record<string, unknown> {
activityModules?: ActivityModule[];
}
/** Event/session/quiz loyalty overrides (omit or null = inherit / unused at layer). */
export interface RewardRules {
registrationComplete?: number | null;
@ -410,7 +416,7 @@ export interface Event {
joinCode?: string;
website?: EventWebsiteSettings;
settings?: Record<string, unknown>;
settings?: EventSettings;
metadata?: { totalRevenue?: number; rating?: number; ratingCount?: number };
rewardRules?: RewardRules | null;
/** Request-only: POST /events — when true, server adds default track + opening session. */