869 lines
40 KiB
TypeScript
869 lines
40 KiB
TypeScript
/**
|
|
* Activities — standalone Q&A / quiz / poll shells (Event recordKind=activity).
|
|
*/
|
|
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
|
import {
|
|
Search, X, LayoutGrid, List, Calendar as CalIcon,
|
|
MessageCircle, BarChart3, HelpCircle, Users, CheckCircle2, Star,
|
|
} from "lucide-react";
|
|
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
|
import { useAuth } from "../../contexts/AuthContext";
|
|
import { portfolioListOrganizationId, eventsApi, sessionsApi, quizPollsApi } from "../../../services/api";
|
|
import type { Event } from "../../../services/api/types";
|
|
import { LoadingState } from "../shared";
|
|
import { FlatpickrDatetimeInput } from "../ui/FlatpickrDatetimeInput";
|
|
import {
|
|
AddQuizPollModal,
|
|
buildQuizPollSettingsPayload,
|
|
datetimeLocalToIso,
|
|
defaultActivityShellDateTimes,
|
|
defaultQuizPollModalForm,
|
|
type QuizPollModalForm,
|
|
} from "../quiz/AddQuizPollModal";
|
|
import type { Question } from "../quiz/AddQuestionModal";
|
|
import { modalQuestionToApi } from "../quiz/quizQuestionMappers";
|
|
import { formatActivityRating, weightedActivityRating } from "./activityRatingDisplay";
|
|
|
|
function mapQuestionForQuizPollApi(q: Question, kind: "quiz" | "poll") {
|
|
return modalQuestionToApi(q, kind);
|
|
}
|
|
|
|
type ActivityUiType = "Q&A Session" | "Quiz" | "Poll";
|
|
type ActivityStatusUi = "Active" | "Scheduled" | "Completed" | "Draft";
|
|
type ViewMode = "card" | "list" | "calendar";
|
|
|
|
interface ActivityRow {
|
|
id: string;
|
|
name: string;
|
|
type: ActivityUiType;
|
|
status: ActivityStatusUi;
|
|
createdDate: string;
|
|
subtitle?: string;
|
|
participants: number;
|
|
questions: number;
|
|
responses: number;
|
|
eventRatingAverage: number;
|
|
eventRatingCount: number;
|
|
sessionRatingAverage: number;
|
|
sessionRatingCount: number;
|
|
subtype: "qna" | "quiz" | "poll";
|
|
raw: Event;
|
|
}
|
|
|
|
function mapSubtype(s: string | null | undefined): ActivityUiType {
|
|
if (s === "quiz") return "Quiz";
|
|
if (s === "poll") return "Poll";
|
|
return "Q&A Session";
|
|
}
|
|
|
|
function mapStatus(st: string | undefined): ActivityStatusUi {
|
|
const x = (st || "draft").toLowerCase();
|
|
if (x === "live") return "Active";
|
|
if (x === "upcoming") return "Scheduled";
|
|
if (x === "completed") return "Completed";
|
|
return "Draft";
|
|
}
|
|
|
|
function formatListDate(iso: string | undefined): string {
|
|
if (!iso) return "—";
|
|
try {
|
|
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
|
} catch {
|
|
return "—";
|
|
}
|
|
}
|
|
|
|
const shellInputStyle: React.CSSProperties = {
|
|
width: "100%",
|
|
boxSizing: "border-box",
|
|
padding: "10px 12px",
|
|
borderRadius: "var(--radius-button)",
|
|
border: "1px solid var(--border)",
|
|
background: "var(--background)",
|
|
color: "var(--foreground)",
|
|
fontSize: "var(--text-sm)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
};
|
|
|
|
async function createActivityShell(
|
|
organizationId: string,
|
|
name: string,
|
|
subtype: "qna" | "quiz" | "poll",
|
|
startLocal: string,
|
|
endLocal: string,
|
|
): Promise<{ ok: true; eventId: string; sessionId: string } | { ok: false; error: string }> {
|
|
const startIso = datetimeLocalToIso(startLocal);
|
|
const endIso = datetimeLocalToIso(endLocal);
|
|
if (!startIso || !endIso) {
|
|
return { ok: false, error: "Invalid start or end date/time." };
|
|
}
|
|
const start = new Date(startIso);
|
|
let end = new Date(endIso);
|
|
if (end <= start) end = new Date(start.getTime() + 60 * 60 * 1000);
|
|
const res = await eventsApi.create({
|
|
name: name.trim(),
|
|
type: "webinar",
|
|
recordKind: "activity",
|
|
activitySubtype: subtype,
|
|
status: "draft",
|
|
startDate: start.toISOString(),
|
|
endDate: end.toISOString(),
|
|
locationType: "virtual",
|
|
organization: organizationId,
|
|
});
|
|
if (res.error || !res.data?._id) {
|
|
return { ok: false, error: res.error || "Could not create activity." };
|
|
}
|
|
const eventId = res.data._id;
|
|
const sesRes = await sessionsApi.list(eventId);
|
|
const sessions = sesRes.data?.sessions ?? [];
|
|
const sessionId = sessions[0]?._id;
|
|
if (!sessionId) {
|
|
return { ok: false, error: "Activity created but no session was found." };
|
|
}
|
|
return { ok: true, eventId, sessionId };
|
|
}
|
|
|
|
function eventToRow(ev: Event): ActivityRow {
|
|
const st = ev.activityStats;
|
|
return {
|
|
id: ev._id,
|
|
name: ev.name,
|
|
type: mapSubtype(ev.activitySubtype),
|
|
status: mapStatus(ev.status),
|
|
createdDate: formatListDate(ev.createdAt),
|
|
subtitle: ev.description?.trim() ? ev.description.trim().slice(0, 120) : ev.subtitle?.trim() || undefined,
|
|
participants: st?.participantCount ?? 0,
|
|
questions: st?.questionCount ?? 0,
|
|
responses: st?.responseCount ?? 0,
|
|
eventRatingAverage: st?.eventRatingAverage ?? 0,
|
|
eventRatingCount: st?.eventRatingCount ?? 0,
|
|
sessionRatingAverage: st?.sessionRatingAverage ?? 0,
|
|
sessionRatingCount: st?.sessionRatingCount ?? 0,
|
|
subtype: (ev.activitySubtype as "qna" | "quiz" | "poll") || "qna",
|
|
raw: ev,
|
|
};
|
|
}
|
|
|
|
const TYPE_COLORS: Record<ActivityUiType, { bg: string; fg: string; icon: React.ReactNode }> = {
|
|
"Q&A Session": { bg: "#E8EFFC", fg: "#0B4DD1", icon: <MessageCircle size={14} /> },
|
|
"Quiz": { bg: "#FEF3C7", fg: "#D97706", icon: <HelpCircle size={14} /> },
|
|
"Poll": { bg: "#ECFDF5", fg: "#059669", icon: <BarChart3 size={14} /> },
|
|
};
|
|
|
|
const STATUS_BADGE: Record<ActivityStatusUi, { bg: string; color: string }> = {
|
|
Active: { bg: "#dcfce7", color: "#15803d" },
|
|
Scheduled: { bg: "#dbeafe", color: "#1d4ed8" },
|
|
Completed: { bg: "#f1f5f9", color: "#475569" },
|
|
Draft: { bg: "#ffedd5", color: "#c2410c" },
|
|
};
|
|
|
|
function ActivitySidePanel({
|
|
title,
|
|
subtitle,
|
|
onClose,
|
|
children,
|
|
footer,
|
|
}: {
|
|
title: string;
|
|
subtitle?: string;
|
|
onClose: () => void;
|
|
children: React.ReactNode;
|
|
footer?: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<>
|
|
<div onClick={onClose} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 200 }} role="presentation" />
|
|
<div
|
|
style={{
|
|
position: "fixed",
|
|
top: 0,
|
|
right: 0,
|
|
height: "100%",
|
|
width: "min(720px, 100vw)",
|
|
background: "var(--card)",
|
|
zIndex: 201,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
boxShadow: "-4px 0 32px rgba(0,0,0,0.18)",
|
|
animation: "activityPanelIn 0.25s ease",
|
|
}}
|
|
>
|
|
<style>{`@keyframes activityPanelIn { from { transform: translateX(100%); } to { transform: translateX(0); } }`}</style>
|
|
<div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border)", flexShrink: 0, display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: "12px" }}>
|
|
<div>
|
|
<h2 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-lg)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", margin: 0 }}>{title}</h2>
|
|
{subtitle ? (
|
|
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: "6px 0 0", lineHeight: 1.45 }}>{subtitle}</p>
|
|
) : null}
|
|
</div>
|
|
<button type="button" onClick={onClose} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", padding: 4 }}>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
|
{children}
|
|
</div>
|
|
{footer ? (
|
|
<div style={{ padding: "14px 20px", borderTop: "1px solid var(--border)", flexShrink: 0 }}>{footer}</div>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function ActivitiesPage({
|
|
onNavigateToActivity,
|
|
}: {
|
|
onNavigateToActivity?: (eventId: string) => void;
|
|
}) {
|
|
const { user } = useAuth();
|
|
const orgId = portfolioListOrganizationId(user);
|
|
const [viewMode, setViewMode] = useState<ViewMode>("card");
|
|
const [search, setSearch] = useState("");
|
|
const [filterType, setFilterType] = useState<string>("all");
|
|
const [filterStatus, setFilterStatus] = useState<string>("all");
|
|
const [searchFocused, setSearchFocused] = useState(false);
|
|
const [rows, setRows] = useState<ActivityRow[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadErr, setLoadErr] = useState<string | null>(null);
|
|
const [qnaPanelOpen, setQnaPanelOpen] = useState(false);
|
|
const [quizPollPanelOpen, setQuizPollPanelOpen] = useState(false);
|
|
const [quizPollStep, setQuizPollStep] = useState<"shell" | "form">("shell");
|
|
const [quizPollKind, setQuizPollKind] = useState<"quiz" | "poll">("poll");
|
|
const [shellName, setShellName] = useState("");
|
|
const [shellStart, setShellStart] = useState("");
|
|
const [shellEnd, setShellEnd] = useState("");
|
|
const [shellBusy, setShellBusy] = useState(false);
|
|
const [shellError, setShellError] = useState<string | null>(null);
|
|
const [createdEventId, setCreatedEventId] = useState<string | null>(null);
|
|
const [createdSessionId, setCreatedSessionId] = useState<string | null>(null);
|
|
const [qnaShellBusy, setQnaShellBusy] = useState(false);
|
|
const [qnaShellError, setQnaShellError] = useState<string | null>(null);
|
|
const [quizPollFormError, setQuizPollFormError] = useState<string | null>(null);
|
|
|
|
const bp = useBreakpoint();
|
|
const isMobile = bp === "mobile";
|
|
/** Card grid: 1 col on small screens, 4 equal columns in a row otherwise. */
|
|
const activityCardGridColumns =
|
|
bp === "mobile" ? "1fr" : "repeat(4, minmax(0, 1fr))";
|
|
|
|
const fetchActivities = useCallback(async () => {
|
|
if (!orgId) {
|
|
setRows([]);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
setLoadErr(null);
|
|
const res = await eventsApi.list({
|
|
organization: orgId,
|
|
recordKind: "activity",
|
|
enrich: true,
|
|
limit: 200,
|
|
});
|
|
setLoading(false);
|
|
if (res.error || !res.data?.events) {
|
|
setLoadErr(res.error || "Could not load activities.");
|
|
setRows([]);
|
|
return;
|
|
}
|
|
setRows(res.data.events.map(eventToRow));
|
|
}, [orgId]);
|
|
|
|
useEffect(() => {
|
|
void fetchActivities();
|
|
}, [fetchActivities]);
|
|
|
|
const openQnaPanel = () => {
|
|
const { start, end } = defaultActivityShellDateTimes();
|
|
setShellName("");
|
|
setShellStart(start);
|
|
setShellEnd(end);
|
|
setQnaShellError(null);
|
|
setQnaPanelOpen(true);
|
|
};
|
|
|
|
const openQuizPollPanel = () => {
|
|
const { start, end } = defaultActivityShellDateTimes();
|
|
setShellName("");
|
|
setShellStart(start);
|
|
setShellEnd(end);
|
|
setQuizPollKind("poll");
|
|
setShellError(null);
|
|
setQuizPollStep("shell");
|
|
setCreatedEventId(null);
|
|
setCreatedSessionId(null);
|
|
setQuizPollPanelOpen(true);
|
|
};
|
|
|
|
const closeQuizPollPanel = () => {
|
|
setQuizPollPanelOpen(false);
|
|
setQuizPollStep("shell");
|
|
setCreatedEventId(null);
|
|
setCreatedSessionId(null);
|
|
setShellError(null);
|
|
setQuizPollFormError(null);
|
|
};
|
|
|
|
const quizPollFormInitial = useMemo(() => {
|
|
if (quizPollStep !== "form") return undefined;
|
|
const base = defaultQuizPollModalForm();
|
|
base.title = shellName.trim();
|
|
base.status = "Draft";
|
|
base.displayMode = "always_sched";
|
|
base.scheduledStart = shellStart;
|
|
base.scheduledEnd = shellEnd;
|
|
return { form: base, questions: [] as Question[] };
|
|
}, [quizPollStep, shellName, shellStart, shellEnd]);
|
|
|
|
const submitQnaShell = () => {
|
|
void (async () => {
|
|
if (!orgId) return;
|
|
setQnaShellError(null);
|
|
const name = shellName.trim();
|
|
if (name.length < 3) {
|
|
setQnaShellError("Name must be at least 3 characters.");
|
|
return;
|
|
}
|
|
setQnaShellBusy(true);
|
|
const r = await createActivityShell(orgId, name, "qna", shellStart, shellEnd);
|
|
setQnaShellBusy(false);
|
|
if (!r.ok) {
|
|
setQnaShellError(r.error);
|
|
return;
|
|
}
|
|
setQnaPanelOpen(false);
|
|
void fetchActivities();
|
|
onNavigateToActivity?.(r.eventId);
|
|
})();
|
|
};
|
|
|
|
const continueQuizPollShell = () => {
|
|
void (async () => {
|
|
if (!orgId) return;
|
|
setShellError(null);
|
|
const name = shellName.trim();
|
|
if (name.length < 3) {
|
|
setShellError("Name must be at least 3 characters.");
|
|
return;
|
|
}
|
|
setShellBusy(true);
|
|
const r = await createActivityShell(orgId, name, quizPollKind, shellStart, shellEnd);
|
|
setShellBusy(false);
|
|
if (!r.ok) {
|
|
setShellError(r.error);
|
|
return;
|
|
}
|
|
setCreatedEventId(r.eventId);
|
|
setCreatedSessionId(r.sessionId);
|
|
setQuizPollFormError(null);
|
|
setQuizPollStep("form");
|
|
})();
|
|
};
|
|
|
|
const saveQuizPollFromActivities = (data: { form: QuizPollModalForm; questions: Question[] }) => {
|
|
void (async () => {
|
|
if (!createdEventId || !createdSessionId) return;
|
|
const kind = quizPollKind;
|
|
setQuizPollFormError(null);
|
|
const payload = {
|
|
session: createdSessionId,
|
|
title: data.form.title,
|
|
type: kind,
|
|
description: data.form.description || undefined,
|
|
questions: data.questions.map(q => mapQuestionForQuizPollApi(q, kind)),
|
|
settings: buildQuizPollSettingsPayload(data.form, kind),
|
|
};
|
|
const res = await quizPollsApi.create(createdEventId, payload);
|
|
if (res.error) {
|
|
setQuizPollFormError(res.error);
|
|
return;
|
|
}
|
|
closeQuizPollPanel();
|
|
void fetchActivities();
|
|
onNavigateToActivity?.(createdEventId);
|
|
})();
|
|
};
|
|
|
|
const filteredActivities = useMemo(() => {
|
|
return rows.filter(a => {
|
|
const matchesSearch =
|
|
search === "" ||
|
|
a.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
(a.subtitle?.toLowerCase().includes(search.toLowerCase()) ?? false);
|
|
const matchesType = filterType === "all" || a.type === filterType;
|
|
const matchesStatus = filterStatus === "all" || a.status === filterStatus;
|
|
return matchesSearch && matchesType && matchesStatus;
|
|
});
|
|
}, [rows, search, filterType, filterStatus]);
|
|
|
|
const totals = useMemo(() => {
|
|
const base = rows;
|
|
const eventRating = weightedActivityRating(
|
|
base.map((a) => ({ average: a.eventRatingAverage, count: a.eventRatingCount })),
|
|
);
|
|
const sessionRating = weightedActivityRating(
|
|
base.map((a) => ({ average: a.sessionRatingAverage, count: a.sessionRatingCount })),
|
|
);
|
|
return {
|
|
count: base.length,
|
|
active: base.filter(a => a.status === "Active").length,
|
|
participants: base.reduce((s, a) => s + a.participants, 0),
|
|
responses: base.reduce((s, a) => s + a.responses, 0),
|
|
eventRating,
|
|
sessionRating,
|
|
};
|
|
}, [rows]);
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "24px" }}>
|
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: "12px", flexWrap: "wrap" }}>
|
|
<div>
|
|
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: isMobile ? "var(--text-xl)" : "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", margin: "0 0 6px" }}>
|
|
Activities
|
|
</h1>
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0 }}>
|
|
Manage Q&A sessions, quizzes, and polls (standalone activity events)
|
|
</p>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px", alignItems: "center" }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => orgId && openQnaPanel()}
|
|
disabled={!orgId}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "6px",
|
|
padding: "7px 16px",
|
|
borderRadius: "var(--radius-button)",
|
|
background: orgId ? "var(--primary)" : "var(--muted)",
|
|
color: "var(--primary-foreground)",
|
|
border: "none",
|
|
cursor: orgId ? "pointer" : "not-allowed",
|
|
fontSize: "var(--text-sm)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
fontWeight: "var(--font-weight-medium)",
|
|
opacity: orgId ? 1 : 0.7,
|
|
}}
|
|
>
|
|
<MessageCircle size={14} /> Create Q&A Session
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => orgId && openQuizPollPanel()}
|
|
disabled={!orgId}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "6px",
|
|
padding: "7px 16px",
|
|
borderRadius: "var(--radius-button)",
|
|
background: orgId ? "var(--card)" : "var(--muted)",
|
|
color: orgId ? "var(--foreground)" : "var(--muted-foreground)",
|
|
border: "1px solid var(--border)",
|
|
cursor: orgId ? "pointer" : "not-allowed",
|
|
fontSize: "var(--text-sm)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
fontWeight: "var(--font-weight-medium)",
|
|
}}
|
|
>
|
|
<BarChart3 size={14} /> Create Quiz/Poll
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{!orgId && (
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0 }}>
|
|
Set up an organization in your portfolio to view and create activities.
|
|
</p>
|
|
)}
|
|
|
|
{loadErr && (
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--destructive)", margin: 0 }}>{loadErr}</p>
|
|
)}
|
|
|
|
{loading && orgId ? (
|
|
<LoadingState message="Loading activities…" />
|
|
) : null}
|
|
|
|
{!loading && orgId ? (
|
|
<>
|
|
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr 1fr" : "repeat(3, 1fr)", gap: "12px" }}>
|
|
<StatCard label="Total Activities" value={String(totals.count)} />
|
|
<StatCard label="Active" value={String(totals.active)} color="#15803d" />
|
|
<StatCard label="Total Participants" value={String(totals.participants)} />
|
|
<StatCard label="Total Responses" value={String(totals.responses)} />
|
|
<StatCard
|
|
label="Event Rating"
|
|
value={formatActivityRating(totals.eventRating.average, totals.eventRating.count)}
|
|
sublabel={totals.eventRating.count ? `${totals.eventRating.count} ratings` : "No ratings yet"}
|
|
color="#d97706"
|
|
/>
|
|
<StatCard
|
|
label="Session Rating"
|
|
value={formatActivityRating(totals.sessionRating.average, totals.sessionRating.count)}
|
|
sublabel={totals.sessionRating.count ? `${totals.sessionRating.count} ratings` : "No ratings yet"}
|
|
color="#d97706"
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "7px", padding: "0 10px", height: "34px", flex: isMobile ? "1 1 100%" : "1 1 180px", background: "var(--card)", borderTopWidth: "1px", borderRightWidth: "1px", borderBottomWidth: "1px", borderLeftWidth: "1px", borderTopStyle: "solid", borderRightStyle: "solid", borderBottomStyle: "solid", borderLeftStyle: "solid", borderTopColor: searchFocused ? "var(--ring)" : "var(--border)", borderRightColor: searchFocused ? "var(--ring)" : "var(--border)", borderBottomColor: searchFocused ? "var(--ring)" : "var(--border)", borderLeftColor: searchFocused ? "var(--ring)" : "var(--border)", borderRadius: "var(--radius-button)", transition: "border-top-color 0.15s, border-right-color 0.15s, border-bottom-color 0.15s, border-left-color 0.15s" }}>
|
|
<Search size={13} style={{ color: "var(--muted)", flexShrink: 0 }} />
|
|
<input
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
placeholder="Search activities…"
|
|
onFocus={() => setSearchFocused(true)}
|
|
onBlur={() => setSearchFocused(false)}
|
|
style={{ background: "transparent", border: "none", outline: "none", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", width: "100%" }}
|
|
/>
|
|
{search ? (
|
|
<button type="button" onClick={() => setSearch("")} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", padding: 0, display: "flex" }}>
|
|
<X size={12} />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
|
|
<select
|
|
value={filterType}
|
|
onChange={e => setFilterType(e.target.value)}
|
|
style={{ padding: "0 32px 0 10px", height: "34px", borderRadius: "var(--radius-button)", borderWidth: "1px", borderStyle: "solid", borderColor: "var(--border)", background: "var(--card)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", cursor: "pointer", outline: "none" }}
|
|
>
|
|
<option value="all">All Types</option>
|
|
<option value="Q&A Session">Q&A Session</option>
|
|
<option value="Quiz">Quiz</option>
|
|
<option value="Poll">Poll</option>
|
|
</select>
|
|
|
|
<select
|
|
value={filterStatus}
|
|
onChange={e => setFilterStatus(e.target.value)}
|
|
style={{ padding: "0 32px 0 10px", height: "34px", borderRadius: "var(--radius-button)", borderWidth: "1px", borderStyle: "solid", borderColor: "var(--border)", background: "var(--card)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", cursor: "pointer", outline: "none" }}
|
|
>
|
|
<option value="all">All Status</option>
|
|
<option value="Active">Active</option>
|
|
<option value="Scheduled">Scheduled</option>
|
|
<option value="Completed">Completed</option>
|
|
<option value="Draft">Draft</option>
|
|
</select>
|
|
|
|
<div style={{ flex: 1 }} />
|
|
|
|
<div style={{ display: "flex", gap: "4px", borderRadius: "var(--radius-button)", background: "var(--secondary)", padding: "3px" }}>
|
|
{([
|
|
{ mode: "card" as ViewMode, icon: <LayoutGrid size={14} />, label: "Cards" },
|
|
{ mode: "list" as ViewMode, icon: <List size={14} />, label: "List" },
|
|
{ mode: "calendar" as ViewMode, icon: <CalIcon size={14} />, label: "Calendar" },
|
|
] as const).map(v => (
|
|
<button
|
|
key={v.mode}
|
|
type="button"
|
|
onClick={() => setViewMode(v.mode)}
|
|
title={v.label}
|
|
style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "32px", height: "28px", borderRadius: "var(--radius-button)", background: viewMode === v.mode ? "var(--card)" : "transparent", color: viewMode === v.mode ? "var(--primary)" : "var(--muted)", border: "none", cursor: "pointer", transition: "all 0.15s" }}
|
|
>
|
|
{v.icon}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
|
|
Showing {filteredActivities.length} {filteredActivities.length === 1 ? "activity" : "activities"}
|
|
</div>
|
|
|
|
{viewMode === "card" && (
|
|
<div style={{ display: "grid", gridTemplateColumns: activityCardGridColumns, gap: "16px" }}>
|
|
{filteredActivities.map(activity => (
|
|
<ActivityCard
|
|
key={activity.id}
|
|
activity={activity}
|
|
onClick={() => onNavigateToActivity?.(activity.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{viewMode === "list" && (
|
|
<div style={{ background: "var(--card)", borderRadius: "var(--radius-card)", borderWidth: "1px", borderStyle: "solid", borderColor: "var(--border)", overflow: "hidden" }}>
|
|
{filteredActivities.map((activity, i) => (
|
|
<ActivityListRow
|
|
key={activity.id}
|
|
activity={activity}
|
|
isLast={i === filteredActivities.length - 1}
|
|
onClick={() => onNavigateToActivity?.(activity.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{viewMode === "calendar" && (
|
|
<div style={{ padding: "60px 20px", textAlign: "center", background: "var(--card)", borderRadius: "var(--radius-card)", borderWidth: "1px", borderStyle: "solid", borderColor: "var(--border)" }}>
|
|
<CalIcon size={32} style={{ color: "var(--muted)", margin: "0 auto 16px", display: "block" }} />
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
|
|
Calendar view coming soon
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{filteredActivities.length === 0 && !loading ? (
|
|
<div style={{ padding: "60px 20px", textAlign: "center", background: "var(--card)", borderRadius: "var(--radius-card)", borderWidth: "1px", borderStyle: "solid", borderColor: "var(--border)" }}>
|
|
<Search size={32} style={{ color: "var(--muted)", margin: "0 auto 16px", display: "block" }} />
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
|
|
{rows.length === 0 ? "No activities yet. Create one to get started." : "No activities match your filters."}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
|
|
{qnaPanelOpen && orgId ? (
|
|
<ActivitySidePanel
|
|
title="Create Q&A Session"
|
|
subtitle="Name is used for the event and the session. Track: Default."
|
|
onClose={() => { setQnaPanelOpen(false); setQnaShellError(null); }}
|
|
footer={(
|
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: "10px" }}>
|
|
<button type="button" onClick={() => { setQnaPanelOpen(false); setQnaShellError(null); }} disabled={qnaShellBusy} style={{ padding: "8px 16px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: qnaShellBusy ? "not-allowed" : "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
|
|
Cancel
|
|
</button>
|
|
<button type="button" onClick={submitQnaShell} disabled={qnaShellBusy} style={{ padding: "8px 16px", borderRadius: "var(--radius-button)", border: "none", background: qnaShellBusy ? "var(--muted)" : "var(--primary)", color: "var(--primary-foreground)", cursor: qnaShellBusy ? "not-allowed" : "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
|
|
{qnaShellBusy ? "Creating…" : "Create"}
|
|
</button>
|
|
</div>
|
|
)}
|
|
>
|
|
<div style={{ padding: "20px", overflowY: "auto" }}>
|
|
<label style={{ display: "block", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "6px" }}>Name of activity</label>
|
|
<input
|
|
value={shellName}
|
|
onChange={e => setShellName(e.target.value)}
|
|
disabled={qnaShellBusy}
|
|
placeholder="e.g. Keynote Q&A"
|
|
style={{ ...shellInputStyle, marginBottom: "16px" }}
|
|
/>
|
|
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr" : "1fr 1fr", gap: "16px" }}>
|
|
<div>
|
|
<label style={{ display: "block", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "6px" }}>Start</label>
|
|
<FlatpickrDatetimeInput value={shellStart} onChange={setShellStart} style={shellInputStyle} disabled={qnaShellBusy} />
|
|
</div>
|
|
<div>
|
|
<label style={{ display: "block", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "6px" }}>End</label>
|
|
<FlatpickrDatetimeInput value={shellEnd} onChange={setShellEnd} style={shellInputStyle} disabled={qnaShellBusy} />
|
|
</div>
|
|
</div>
|
|
{qnaShellError ? <p style={{ fontSize: "var(--text-sm)", color: "var(--destructive)", marginTop: "14px", marginBottom: 0 }}>{qnaShellError}</p> : null}
|
|
</div>
|
|
</ActivitySidePanel>
|
|
) : null}
|
|
|
|
{quizPollPanelOpen && orgId ? (
|
|
<ActivitySidePanel
|
|
title={quizPollStep === "shell" ? "Create Quiz / Poll" : (quizPollKind === "quiz" ? "New quiz" : "New poll")}
|
|
subtitle={quizPollStep === "shell" ? "Step 1: name and schedule (event & session; track: Default). Step 2: same form as Quiz & Polls in the event dashboard." : undefined}
|
|
onClose={closeQuizPollPanel}
|
|
footer={quizPollStep === "shell" ? (
|
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: "10px" }}>
|
|
<button type="button" onClick={closeQuizPollPanel} disabled={shellBusy} style={{ padding: "8px 16px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: shellBusy ? "not-allowed" : "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
|
|
Cancel
|
|
</button>
|
|
<button type="button" onClick={continueQuizPollShell} disabled={shellBusy} style={{ padding: "8px 16px", borderRadius: "var(--radius-button)", border: "none", background: shellBusy ? "var(--muted)" : "var(--primary)", color: "var(--primary-foreground)", cursor: shellBusy ? "not-allowed" : "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
|
|
{shellBusy ? "Creating…" : "Continue to form"}
|
|
</button>
|
|
</div>
|
|
) : undefined}
|
|
>
|
|
{quizPollStep === "shell" ? (
|
|
<div style={{ padding: "20px", overflowY: "auto" }}>
|
|
<label style={{ display: "block", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "6px" }}>Name of activity</label>
|
|
<input
|
|
value={shellName}
|
|
onChange={e => setShellName(e.target.value)}
|
|
disabled={shellBusy}
|
|
placeholder="e.g. Audience pulse check"
|
|
style={{ ...shellInputStyle, marginBottom: "16px" }}
|
|
/>
|
|
<label style={{ display: "block", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "6px" }}>Type</label>
|
|
<select
|
|
value={quizPollKind}
|
|
onChange={e => setQuizPollKind(e.target.value as "quiz" | "poll")}
|
|
disabled={shellBusy}
|
|
style={{ ...shellInputStyle, marginBottom: "16px", cursor: shellBusy ? "not-allowed" : "pointer" }}
|
|
>
|
|
<option value="poll">Poll</option>
|
|
<option value="quiz">Quiz</option>
|
|
</select>
|
|
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr" : "1fr 1fr", gap: "16px" }}>
|
|
<div>
|
|
<label style={{ display: "block", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "6px" }}>Start</label>
|
|
<FlatpickrDatetimeInput value={shellStart} onChange={setShellStart} style={shellInputStyle} disabled={shellBusy} />
|
|
</div>
|
|
<div>
|
|
<label style={{ display: "block", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "6px" }}>End</label>
|
|
<FlatpickrDatetimeInput value={shellEnd} onChange={setShellEnd} style={shellInputStyle} disabled={shellBusy} />
|
|
</div>
|
|
</div>
|
|
{shellError ? <p style={{ fontSize: "var(--text-sm)", color: "var(--destructive)", marginTop: "14px", marginBottom: 0 }}>{shellError}</p> : null}
|
|
</div>
|
|
) : createdEventId && quizPollFormInitial ? (
|
|
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
|
{quizPollFormError ? (
|
|
<div style={{ padding: "10px 20px", borderBottom: "1px solid var(--border)", background: "var(--secondary)", color: "var(--destructive)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
|
|
{quizPollFormError}
|
|
</div>
|
|
) : null}
|
|
<AddQuizPollModal
|
|
key={`${createdEventId}-${quizPollKind}`}
|
|
variant="panel"
|
|
kind={quizPollKind}
|
|
eventId={createdEventId}
|
|
initial={quizPollFormInitial}
|
|
onClose={closeQuizPollPanel}
|
|
onSave={saveQuizPollFromActivities}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</ActivitySidePanel>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatCard({ label, value, color, sublabel }: { label: string; value: string; color?: string; sublabel?: string }) {
|
|
return (
|
|
<div style={{ background: "var(--card)", borderRadius: "var(--radius-card)", borderWidth: "1px", borderStyle: "solid", borderColor: "var(--border)", padding: "16px 20px" }}>
|
|
<div style={{ fontSize: "var(--text-2xl)", fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)", color: color || "var(--foreground)", marginBottom: "4px" }}>
|
|
{value}
|
|
</div>
|
|
<div style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.5px" }}>
|
|
{label}
|
|
</div>
|
|
{sublabel ? (
|
|
<div style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "6px" }}>
|
|
{sublabel}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActivityCard({ activity, onClick }: { activity: ActivityRow; onClick: () => void }) {
|
|
const typeConfig = TYPE_COLORS[activity.type];
|
|
const statusConfig = STATUS_BADGE[activity.status];
|
|
|
|
return (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={onClick}
|
|
onKeyDown={e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(); } }}
|
|
style={{ background: "var(--card)", borderRadius: "var(--radius-card)", borderWidth: "1px", borderStyle: "solid", borderColor: "var(--border)", padding: "20px", cursor: "pointer", transition: "border-color 0.15s, box-shadow 0.15s" }}
|
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.boxShadow = "0 4px 12px rgba(11,77,209,0.1)"; }}
|
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.boxShadow = "none"; }}
|
|
>
|
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: "12px", marginBottom: "12px" }}>
|
|
<h3 style={{ fontSize: "var(--text-base)", fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", margin: 0, flex: 1 }}>
|
|
{activity.name}
|
|
</h3>
|
|
<span style={{ padding: "3px 10px", borderRadius: "999px", background: statusConfig.bg, color: statusConfig.color, fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", flexShrink: 0, textTransform: "uppercase", letterSpacing: "0.3px" }}>
|
|
{activity.status}
|
|
</span>
|
|
</div>
|
|
|
|
<div style={{ display: "inline-flex", alignItems: "center", gap: "6px", padding: "4px 10px", borderRadius: "var(--radius-button)", background: typeConfig.bg, marginBottom: "16px" }}>
|
|
{typeConfig.icon}
|
|
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: typeConfig.fg }}>
|
|
{activity.type}
|
|
</span>
|
|
</div>
|
|
|
|
{activity.subtitle ? (
|
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: "0 0 16px", lineHeight: 1.4 }}>
|
|
{activity.subtitle}
|
|
</p>
|
|
) : null}
|
|
|
|
<div style={{ display: "flex", gap: "16px", paddingTop: "12px", borderTop: "1px solid var(--border)", flexWrap: "wrap" }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
|
<Users size={14} style={{ color: "var(--muted)" }} />
|
|
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{activity.participants}</span>
|
|
</div>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
|
<HelpCircle size={14} style={{ color: "var(--muted)" }} />
|
|
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{activity.questions}</span>
|
|
</div>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
|
<CheckCircle2 size={14} style={{ color: "var(--muted)" }} />
|
|
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{activity.responses}</span>
|
|
</div>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "6px" }} title="Event rating">
|
|
<Star size={14} style={{ color: "#d97706" }} />
|
|
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
|
|
{formatActivityRating(activity.eventRatingAverage, activity.eventRatingCount)}
|
|
</span>
|
|
</div>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "6px" }} title="Session rating">
|
|
<Star size={14} style={{ color: "#d97706", opacity: 0.75 }} />
|
|
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
|
|
{formatActivityRating(activity.sessionRatingAverage, activity.sessionRatingCount)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActivityListRow({ activity, isLast, onClick }: { activity: ActivityRow; isLast: boolean; onClick: () => void }) {
|
|
const typeConfig = TYPE_COLORS[activity.type];
|
|
const statusConfig = STATUS_BADGE[activity.status];
|
|
|
|
return (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={onClick}
|
|
onKeyDown={e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(); } }}
|
|
style={{ display: "flex", alignItems: "center", gap: "16px", padding: "16px 20px", borderBottom: isLast ? "none" : "1px solid var(--border)", cursor: "pointer", transition: "background 0.15s" }}
|
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "var(--secondary)"; }}
|
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
|
>
|
|
<div style={{ width: "36px", height: "36px", borderRadius: "var(--radius-button)", background: typeConfig.bg, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, color: typeConfig.fg }}>
|
|
{typeConfig.icon}
|
|
</div>
|
|
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<h4 style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", margin: "0 0 3px" }}>
|
|
{activity.name}
|
|
</h4>
|
|
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0 }}>
|
|
{activity.createdDate}
|
|
</p>
|
|
</div>
|
|
|
|
<div style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", minWidth: "100px" }}>
|
|
{activity.type}
|
|
</div>
|
|
|
|
<div style={{ display: "flex", gap: "12px", minWidth: "120px", flexWrap: "wrap" }}>
|
|
<div style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
|
|
{activity.participants} participants
|
|
</div>
|
|
<div style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
|
|
Event {formatActivityRating(activity.eventRatingAverage, activity.eventRatingCount)}
|
|
</div>
|
|
<div style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
|
|
Session {formatActivityRating(activity.sessionRatingAverage, activity.sessionRatingCount)}
|
|
</div>
|
|
</div>
|
|
|
|
<span style={{ padding: "3px 10px", borderRadius: "999px", background: statusConfig.bg, color: statusConfig.color, fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", flexShrink: 0, textTransform: "uppercase" }}>
|
|
{activity.status}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|