Add event games management
This commit is contained in:
parent
ff8dfb0636
commit
beebf1dfb7
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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)}`; }
|
||||
|
|
@ -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}`);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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); },
|
||||
};
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue