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 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 { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||||
import { useAppNav } from "../../contexts/AppNavContext";
|
import { useAppNav } from "../../contexts/AppNavContext";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
|
@ -43,6 +43,7 @@ import { WhatsAppBlastHistoryPanel } from "./WhatsAppBlastHistoryPanel";
|
||||||
import { SendWhatsAppNotificationPanel } from "./SendWhatsAppNotificationPanel";
|
import { SendWhatsAppNotificationPanel } from "./SendWhatsAppNotificationPanel";
|
||||||
import { whatsappBlastsApi } from "../../../services/api/whatsappBlasts";
|
import { whatsappBlastsApi } from "../../../services/api/whatsappBlasts";
|
||||||
import { EventExhibitorsPanel } from "./EventExhibitorsPanel";
|
import { EventExhibitorsPanel } from "./EventExhibitorsPanel";
|
||||||
|
import { GameManagePanel } from "./GameManagePanel";
|
||||||
import { AddExhibitorPanel } from "../panels/AddExhibitorPanel";
|
import { AddExhibitorPanel } from "../panels/AddExhibitorPanel";
|
||||||
import { AddQuizPollModal, buildQuizPollSettingsPayload } from "../quiz/AddQuizPollModal";
|
import { AddQuizPollModal, buildQuizPollSettingsPayload } from "../quiz/AddQuizPollModal";
|
||||||
import type { QuizPollModalForm } from "../quiz/AddQuizPollModal";
|
import type { QuizPollModalForm } from "../quiz/AddQuizPollModal";
|
||||||
|
|
@ -58,7 +59,9 @@ import {
|
||||||
exhibitorsApi,
|
exhibitorsApi,
|
||||||
type ApiExhibitor,
|
type ApiExhibitor,
|
||||||
} from "../../../services/api";
|
} 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";
|
import { publicFormPageUrl } from "../../utils/publicFormUrl";
|
||||||
|
|
||||||
function ModuleCard({
|
function ModuleCard({
|
||||||
|
|
@ -194,6 +197,16 @@ const NEW_FORM_OPTIONS: { value: "registration" | "feedback" | "web-check-in" |
|
||||||
{ value: "other", label: "Other" },
|
{ 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" {
|
function formPanelMode(apiType: string): "feedback" | "registration" | "other" | "web-check-in" {
|
||||||
if (apiType === "feedback") return "feedback";
|
if (apiType === "feedback") return "feedback";
|
||||||
if (apiType === "registration") return "registration";
|
if (apiType === "registration") return "registration";
|
||||||
|
|
@ -253,6 +266,7 @@ export function EventHomeModules() {
|
||||||
const canReadWebCheckIn = canModule("web_check_in", "read");
|
const canReadWebCheckIn = canModule("web_check_in", "read");
|
||||||
const canWriteWebCheckIn = canModule("web_check_in", "write");
|
const canWriteWebCheckIn = canModule("web_check_in", "write");
|
||||||
const canReadDocuments = canModule("content_management", "read");
|
const canReadDocuments = canModule("content_management", "read");
|
||||||
|
const canWriteDocuments = canModule("content_management", "write");
|
||||||
const canReadPhotoWall = canModule("photo_wall", "read");
|
const canReadPhotoWall = canModule("photo_wall", "read");
|
||||||
const canWritePhotoWall = canModule("photo_wall", "write");
|
const canWritePhotoWall = canModule("photo_wall", "write");
|
||||||
const canReadSocialShare = canModule("social_share", "read");
|
const canReadSocialShare = canModule("social_share", "read");
|
||||||
|
|
@ -308,6 +322,9 @@ export function EventHomeModules() {
|
||||||
const [agendaCounts, setAgendaCounts] = useState({ tracks: 0, sessions: 0 });
|
const [agendaCounts, setAgendaCounts] = useState({ tracks: 0, sessions: 0 });
|
||||||
const [agendaLoading, setAgendaLoading] = useState(false);
|
const [agendaLoading, setAgendaLoading] = useState(false);
|
||||||
const [eventSettings, setEventSettings] = useState<Record<string, unknown>>({});
|
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 [webCheckInEnabled, setWebCheckInEnabled] = useState(false);
|
||||||
const [webCheckInLoading, setWebCheckInLoading] = useState(false);
|
const [webCheckInLoading, setWebCheckInLoading] = useState(false);
|
||||||
const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
||||||
|
|
@ -343,6 +360,10 @@ export function EventHomeModules() {
|
||||||
const [aiAvatarToken, setAiAvatarToken] = useState<string | null>(null);
|
const [aiAvatarToken, setAiAvatarToken] = useState<string | null>(null);
|
||||||
const [copiedAiAvatarLink, setCopiedAiAvatarLink] = useState(false);
|
const [copiedAiAvatarLink, setCopiedAiAvatarLink] = useState(false);
|
||||||
const [showEngagementPanel, setShowEngagementPanel] = 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 [engagementEnabled, setEngagementEnabled] = useState(false);
|
||||||
const [engagementLoading, setEngagementLoading] = useState(false);
|
const [engagementLoading, setEngagementLoading] = useState(false);
|
||||||
const [copiedEngagementLink, setCopiedEngagementLink] = useState(false);
|
const [copiedEngagementLink, setCopiedEngagementLink] = useState(false);
|
||||||
|
|
@ -379,6 +400,8 @@ export function EventHomeModules() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedEventId) {
|
if (!selectedEventId) {
|
||||||
setEventName(null);
|
setEventName(null);
|
||||||
|
setEventSettings({});
|
||||||
|
setActivityModules([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -386,6 +409,10 @@ export function EventHomeModules() {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setEventName(res.data?.name ?? null);
|
setEventName(res.data?.name ?? null);
|
||||||
setEventSettings((res.data?.settings as Record<string, unknown> | undefined) ?? {});
|
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));
|
setWebCheckInEnabled(Boolean((res.data?.settings as Record<string, unknown> | undefined)?.webCheckInEnabled));
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -670,7 +697,7 @@ export function EventHomeModules() {
|
||||||
}, [loadExhibitorMeta]);
|
}, [loadExhibitorMeta]);
|
||||||
|
|
||||||
const loadPhotoWallMeta = useCallback(async () => {
|
const loadPhotoWallMeta = useCallback(async () => {
|
||||||
if (!selectedEventId || !canReadPhotoWall) {
|
if (!selectedEventId || !activityModules.includes("photoWall") || !canReadPhotoWall) {
|
||||||
setPhotoWallEnabled(false);
|
setPhotoWallEnabled(false);
|
||||||
setPhotoWallCount(0);
|
setPhotoWallCount(0);
|
||||||
setPhotoWallUploadToken(null);
|
setPhotoWallUploadToken(null);
|
||||||
|
|
@ -694,14 +721,14 @@ export function EventHomeModules() {
|
||||||
} finally {
|
} finally {
|
||||||
setPhotoWallLoading(false);
|
setPhotoWallLoading(false);
|
||||||
}
|
}
|
||||||
}, [selectedEventId, canReadPhotoWall]);
|
}, [selectedEventId, activityModules, canReadPhotoWall]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadPhotoWallMeta();
|
void loadPhotoWallMeta();
|
||||||
}, [loadPhotoWallMeta]);
|
}, [loadPhotoWallMeta]);
|
||||||
|
|
||||||
const loadSpotlightMeta = useCallback(async () => {
|
const loadSpotlightMeta = useCallback(async () => {
|
||||||
if (!selectedEventId || !canReadPhotoWall) {
|
if (!selectedEventId || !activityModules.includes("spotlight") || !canReadPhotoWall) {
|
||||||
setSpotlightEnabled(false);
|
setSpotlightEnabled(false);
|
||||||
setSpotlightCount(0);
|
setSpotlightCount(0);
|
||||||
setSpotlightPresentToken(null);
|
setSpotlightPresentToken(null);
|
||||||
|
|
@ -721,14 +748,14 @@ export function EventHomeModules() {
|
||||||
} finally {
|
} finally {
|
||||||
setSpotlightLoading(false);
|
setSpotlightLoading(false);
|
||||||
}
|
}
|
||||||
}, [selectedEventId, canReadPhotoWall]);
|
}, [selectedEventId, activityModules, canReadPhotoWall]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadSpotlightMeta();
|
void loadSpotlightMeta();
|
||||||
}, [loadSpotlightMeta]);
|
}, [loadSpotlightMeta]);
|
||||||
|
|
||||||
const loadSocialShareMeta = useCallback(async () => {
|
const loadSocialShareMeta = useCallback(async () => {
|
||||||
if (!selectedEventId || !canReadSocialShare) {
|
if (!selectedEventId || !activityModules.includes("socialShare") || !canReadSocialShare) {
|
||||||
setSocialShareEnabled(false);
|
setSocialShareEnabled(false);
|
||||||
setSocialShareFrameCount(0);
|
setSocialShareFrameCount(0);
|
||||||
setSocialShareToken(null);
|
setSocialShareToken(null);
|
||||||
|
|
@ -749,14 +776,14 @@ export function EventHomeModules() {
|
||||||
} finally {
|
} finally {
|
||||||
setSocialShareLoading(false);
|
setSocialShareLoading(false);
|
||||||
}
|
}
|
||||||
}, [selectedEventId, canReadSocialShare]);
|
}, [selectedEventId, activityModules, canReadSocialShare]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadSocialShareMeta();
|
void loadSocialShareMeta();
|
||||||
}, [loadSocialShareMeta]);
|
}, [loadSocialShareMeta]);
|
||||||
|
|
||||||
const loadAiAvatarMeta = useCallback(async () => {
|
const loadAiAvatarMeta = useCallback(async () => {
|
||||||
if (!selectedEventId || !canReadAiAvatar) {
|
if (!selectedEventId || !activityModules.includes("aiAvatar") || !canReadAiAvatar) {
|
||||||
setAiAvatarEnabled(false);
|
setAiAvatarEnabled(false);
|
||||||
setAiAvatarCount(0);
|
setAiAvatarCount(0);
|
||||||
setAiAvatarToken(null);
|
setAiAvatarToken(null);
|
||||||
|
|
@ -777,7 +804,7 @@ export function EventHomeModules() {
|
||||||
} finally {
|
} finally {
|
||||||
setAiAvatarLoading(false);
|
setAiAvatarLoading(false);
|
||||||
}
|
}
|
||||||
}, [selectedEventId, canReadAiAvatar]);
|
}, [selectedEventId, activityModules, canReadAiAvatar]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadAiAvatarMeta();
|
void loadAiAvatarMeta();
|
||||||
|
|
@ -805,7 +832,7 @@ export function EventHomeModules() {
|
||||||
}, [loadEngagementMeta]);
|
}, [loadEngagementMeta]);
|
||||||
|
|
||||||
const loadPhotoSearchMeta = useCallback(async () => {
|
const loadPhotoSearchMeta = useCallback(async () => {
|
||||||
if (!selectedEventId) {
|
if (!selectedEventId || !activityModules.includes("photoSearch")) {
|
||||||
setPhotoSearchEnabled(false);
|
setPhotoSearchEnabled(false);
|
||||||
setPhotoSearchCount(0);
|
setPhotoSearchCount(0);
|
||||||
setPhotoSearchToken(null);
|
setPhotoSearchToken(null);
|
||||||
|
|
@ -826,12 +853,22 @@ export function EventHomeModules() {
|
||||||
} finally {
|
} finally {
|
||||||
setPhotoSearchLoading(false);
|
setPhotoSearchLoading(false);
|
||||||
}
|
}
|
||||||
}, [selectedEventId]);
|
}, [selectedEventId, activityModules]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadPhotoSearchMeta();
|
void loadPhotoSearchMeta();
|
||||||
}, [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 = () => {
|
const handleBadgeSuccess = () => {
|
||||||
setShowAddBadge(false);
|
setShowAddBadge(false);
|
||||||
setDuplicateBadgeBatchId(null);
|
setDuplicateBadgeBatchId(null);
|
||||||
|
|
@ -972,6 +1009,43 @@ export function EventHomeModules() {
|
||||||
? `Enabled · ${photoSearchCount} photo${photoSearchCount === 1 ? "" : "s"}`
|
? `Enabled · ${photoSearchCount} photo${photoSearchCount === 1 ? "" : "s"}`
|
||||||
: "Disabled";
|
: "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) => {
|
const handleWebCheckInToggle = async (enabled: boolean) => {
|
||||||
if (!selectedEventId) return;
|
if (!selectedEventId) return;
|
||||||
setWebCheckInLoading(true);
|
setWebCheckInLoading(true);
|
||||||
|
|
@ -1682,6 +1756,7 @@ const copyPhotoWallLink = async (kind: "upload" | "present") => {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{activityModules.includes("photoSearch") ? (
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
icon={<Image size={18} />}
|
icon={<Image size={18} />}
|
||||||
title="Event Photo Search"
|
title="Event Photo Search"
|
||||||
|
|
@ -1745,7 +1820,8 @@ const copyPhotoWallLink = async (kind: "upload" | "present") => {
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{canReadSocialShare ? (
|
) : null}
|
||||||
|
{activityModules.includes("socialShare") && canReadSocialShare ? (
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
icon={<Share2 size={18} />}
|
icon={<Share2 size={18} />}
|
||||||
title="Social Share"
|
title="Social Share"
|
||||||
|
|
@ -1883,7 +1959,140 @@ const copyPhotoWallLink = async (kind: "upload" | "present") => {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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
|
<ModuleCard
|
||||||
icon={<Sparkles size={18} />}
|
icon={<Sparkles size={18} />}
|
||||||
title="AI Avatar"
|
title="AI Avatar"
|
||||||
|
|
@ -1952,7 +2161,7 @@ const copyPhotoWallLink = async (kind: "upload" | "present") => {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{canReadPhotoWall ? (
|
{activityModules.includes("photoWall") && canReadPhotoWall ? (
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
icon={<Images size={18} />}
|
icon={<Images size={18} />}
|
||||||
title="Photo Wall"
|
title="Photo Wall"
|
||||||
|
|
@ -2031,7 +2240,7 @@ const copyPhotoWallLink = async (kind: "upload" | "present") => {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{canReadPhotoWall ? (
|
{activityModules.includes("spotlight") && canReadPhotoWall ? (
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
icon={<Image size={18} />}
|
icon={<Image size={18} />}
|
||||||
title="Spotlight"
|
title="Spotlight"
|
||||||
|
|
@ -2363,6 +2572,14 @@ const copyPhotoWallLink = async (kind: "upload" | "present") => {
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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 ? (
|
{showWaBlastHistory && selectedEventId ? (
|
||||||
<WhatsAppBlastHistoryPanel
|
<WhatsAppBlastHistoryPanel
|
||||||
eventId={selectedEventId}
|
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 { api } from './client';
|
||||||
import type { Event, EventDashboardAnalytics } from './types';
|
import type { ActivityModule, Event, EventDashboardAnalytics } from './types';
|
||||||
|
|
||||||
interface ListParams {
|
interface ListParams {
|
||||||
organization?: string;
|
organization?: string;
|
||||||
|
|
@ -42,6 +42,10 @@ export const eventsApi = {
|
||||||
return api.put<Event>(`/events/${id}`, payload);
|
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) {
|
remove(id: string) {
|
||||||
return api.delete<unknown>(`/events/${id}`);
|
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;
|
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). */
|
/** Event/session/quiz loyalty overrides (omit or null = inherit / unused at layer). */
|
||||||
export interface RewardRules {
|
export interface RewardRules {
|
||||||
registrationComplete?: number | null;
|
registrationComplete?: number | null;
|
||||||
|
|
@ -410,7 +416,7 @@ export interface Event {
|
||||||
joinCode?: string;
|
joinCode?: string;
|
||||||
|
|
||||||
website?: EventWebsiteSettings;
|
website?: EventWebsiteSettings;
|
||||||
settings?: Record<string, unknown>;
|
settings?: EventSettings;
|
||||||
metadata?: { totalRevenue?: number; rating?: number; ratingCount?: number };
|
metadata?: { totalRevenue?: number; rating?: number; ratingCount?: number };
|
||||||
rewardRules?: RewardRules | null;
|
rewardRules?: RewardRules | null;
|
||||||
/** Request-only: POST /events — when true, server adds default track + opening session. */
|
/** Request-only: POST /events — when true, server adds default track + opening session. */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue